mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-27 02:42:25 +08:00
feat(editor): support embed iframe block in edgeless (#10830)
To close: [BS-2665](https://linear.app/affine-design/issue/BS-2665/iframe-embed-block-edgeless-mode-支持) [BS-2666](https://linear.app/affine-design/issue/BS-2666/iframe-embed-block-edgeless-toolbar) [BS-2667](https://linear.app/affine-design/issue/BS-2667/iframe-embed-block-edgeless-mode-拖拽调整支持) [BS-2789](https://linear.app/affine-design/issue/BS-2789/iframe-embed-block-edgeless-block-component)
This commit is contained in:
@@ -5,7 +5,10 @@ import type { EmbedCardStyle } from '@blocksuite/affine-model';
|
|||||||
import { EmbedOptionProvider } from '@blocksuite/affine-shared/services';
|
import { EmbedOptionProvider } from '@blocksuite/affine-shared/services';
|
||||||
import type { Command } from '@blocksuite/block-std';
|
import type { Command } from '@blocksuite/block-std';
|
||||||
|
|
||||||
export const insertBookmarkCommand: Command<{ url: string }> = (ctx, next) => {
|
export const insertBookmarkCommand: Command<
|
||||||
|
{ url: string },
|
||||||
|
{ blockId: string; flavour: string }
|
||||||
|
> = (ctx, next) => {
|
||||||
const { url, std } = ctx;
|
const { url, std } = ctx;
|
||||||
const embedOptions = std.get(EmbedOptionProvider).getEmbedBlockOptions(url);
|
const embedOptions = std.get(EmbedOptionProvider).getEmbedBlockOptions(url);
|
||||||
|
|
||||||
@@ -16,6 +19,7 @@ export const insertBookmarkCommand: Command<{ url: string }> = (ctx, next) => {
|
|||||||
flavour = embedOptions.flavour;
|
flavour = embedOptions.flavour;
|
||||||
targetStyle = embedOptions.styles[0];
|
targetStyle = embedOptions.styles[0];
|
||||||
}
|
}
|
||||||
insertEmbedCard(std, { flavour, targetStyle, props });
|
const blockId = insertEmbedCard(std, { flavour, targetStyle, props });
|
||||||
next();
|
if (!blockId) return;
|
||||||
|
next({ blockId, flavour });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
type InsertedLinkType,
|
type InsertedLinkType,
|
||||||
|
insertEmbedIframeCommand,
|
||||||
insertEmbedLinkedDocCommand,
|
insertEmbedLinkedDocCommand,
|
||||||
|
type LinkableFlavour,
|
||||||
} from '@blocksuite/affine-block-embed';
|
} from '@blocksuite/affine-block-embed';
|
||||||
import { QuickSearchProvider } from '@blocksuite/affine-shared/services';
|
import {
|
||||||
|
FeatureFlagService,
|
||||||
|
QuickSearchProvider,
|
||||||
|
} from '@blocksuite/affine-shared/services';
|
||||||
import type { Command } from '@blocksuite/block-std';
|
import type { Command } from '@blocksuite/block-std';
|
||||||
|
|
||||||
import { insertBookmarkCommand } from './insert-bookmark';
|
import { insertBookmarkCommand } from './insert-bookmark';
|
||||||
@@ -36,10 +41,33 @@ export const insertLinkByQuickSearchCommand: Command<
|
|||||||
|
|
||||||
// add normal link;
|
// add normal link;
|
||||||
if ('externalUrl' in result) {
|
if ('externalUrl' in result) {
|
||||||
std.command.exec(insertBookmarkCommand, { url: result.externalUrl });
|
const featureFlagService = std.get(FeatureFlagService);
|
||||||
return {
|
const enableEmbedIframeBlock = featureFlagService.getFlag(
|
||||||
flavour: 'affine:bookmark',
|
'enable_embed_iframe_block'
|
||||||
};
|
);
|
||||||
|
if (enableEmbedIframeBlock) {
|
||||||
|
// try to insert embed iframe block first
|
||||||
|
const [success, { flavour }] = std.command
|
||||||
|
.chain()
|
||||||
|
.try(chain => [
|
||||||
|
chain.pipe(insertEmbedIframeCommand, { url: result.externalUrl }),
|
||||||
|
chain.pipe(insertBookmarkCommand, { url: result.externalUrl }),
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
if (!success || !flavour) return null;
|
||||||
|
return {
|
||||||
|
flavour: flavour as LinkableFlavour,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const [success, { flavour }] = std.command.exec(
|
||||||
|
insertBookmarkCommand,
|
||||||
|
{ url: result.externalUrl }
|
||||||
|
);
|
||||||
|
if (!success || !flavour) return null;
|
||||||
|
return {
|
||||||
|
flavour: flavour as LinkableFlavour,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -48,7 +48,13 @@ export function insertEmbedCard(
|
|||||||
const parent = host.doc.getParent(block.model);
|
const parent = host.doc.getParent(block.model);
|
||||||
if (!parent) return;
|
if (!parent) return;
|
||||||
const index = parent.children.indexOf(block.model);
|
const index = parent.children.indexOf(block.model);
|
||||||
host.doc.addBlock(flavour as never, props, parent, index + 1);
|
const cardId = host.doc.addBlock(
|
||||||
|
flavour as never,
|
||||||
|
props,
|
||||||
|
parent,
|
||||||
|
index + 1
|
||||||
|
);
|
||||||
|
return cardId;
|
||||||
} else {
|
} else {
|
||||||
const rootId = std.store.root?.id;
|
const rootId = std.store.root?.id;
|
||||||
if (!rootId) return;
|
if (!rootId) return;
|
||||||
@@ -85,5 +91,7 @@ export function insertEmbedCard(
|
|||||||
// @ts-expect-error FIXME: resolve after gfx tool refactor
|
// @ts-expect-error FIXME: resolve after gfx tool refactor
|
||||||
'default'
|
'default'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return cardId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { EmbedIframeCreateModal } from './embed-iframe-block/components/embed-if
|
|||||||
import { EmbedIframeErrorCard } from './embed-iframe-block/components/embed-iframe-error-card';
|
import { EmbedIframeErrorCard } from './embed-iframe-block/components/embed-iframe-error-card';
|
||||||
import { EmbedIframeLinkEditPopup } from './embed-iframe-block/components/embed-iframe-link-edit-popup';
|
import { EmbedIframeLinkEditPopup } from './embed-iframe-block/components/embed-iframe-link-edit-popup';
|
||||||
import { EmbedIframeLoadingCard } from './embed-iframe-block/components/embed-iframe-loading-card';
|
import { EmbedIframeLoadingCard } from './embed-iframe-block/components/embed-iframe-loading-card';
|
||||||
|
import { EmbedEdgelessIframeBlockComponent } from './embed-iframe-block/embed-edgeless-iframe-block';
|
||||||
import { EmbedIframeBlockComponent } from './embed-iframe-block/embed-iframe-block';
|
import { EmbedIframeBlockComponent } from './embed-iframe-block/embed-iframe-block';
|
||||||
import { EmbedLinkedDocBlockComponent } from './embed-linked-doc-block';
|
import { EmbedLinkedDocBlockComponent } from './embed-linked-doc-block';
|
||||||
import { EmbedEdgelessLinkedDocBlockComponent } from './embed-linked-doc-block/embed-edgeless-linked-doc-block';
|
import { EmbedEdgelessLinkedDocBlockComponent } from './embed-linked-doc-block/embed-edgeless-linked-doc-block';
|
||||||
@@ -78,6 +79,10 @@ export function effects() {
|
|||||||
EmbedSyncedDocBlockComponent
|
EmbedSyncedDocBlockComponent
|
||||||
);
|
);
|
||||||
|
|
||||||
|
customElements.define(
|
||||||
|
'affine-embed-edgeless-iframe-block',
|
||||||
|
EmbedEdgelessIframeBlockComponent
|
||||||
|
);
|
||||||
customElements.define('affine-embed-iframe-block', EmbedIframeBlockComponent);
|
customElements.define('affine-embed-iframe-block', EmbedIframeBlockComponent);
|
||||||
customElements.define(
|
customElements.define(
|
||||||
'affine-embed-iframe-create-modal',
|
'affine-embed-iframe-create-modal',
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './insert-embed-iframe';
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
EdgelessCRUDIdentifier,
|
||||||
|
SurfaceBlockComponent,
|
||||||
|
} from '@blocksuite/affine-block-surface';
|
||||||
|
import {
|
||||||
|
BlockSelection,
|
||||||
|
type Command,
|
||||||
|
SurfaceSelection,
|
||||||
|
TextSelection,
|
||||||
|
} from '@blocksuite/block-std';
|
||||||
|
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||||
|
import { Bound, Vec } from '@blocksuite/global/gfx';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE,
|
||||||
|
EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE,
|
||||||
|
} from '../consts';
|
||||||
|
import { EmbedIframeService } from '../extension/embed-iframe-service';
|
||||||
|
|
||||||
|
export const insertEmbedIframeCommand: Command<
|
||||||
|
{ url: string },
|
||||||
|
{ blockId: string; flavour: string }
|
||||||
|
> = (ctx, next) => {
|
||||||
|
const { url, std } = ctx;
|
||||||
|
const embedIframeService = std.get(EmbedIframeService);
|
||||||
|
if (!embedIframeService || !embedIframeService.canEmbed(url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = embedIframeService.getConfig(url);
|
||||||
|
if (!config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { host } = std;
|
||||||
|
const selectionManager = host.selection;
|
||||||
|
|
||||||
|
let selectedBlockId: string | undefined;
|
||||||
|
const textSelection = selectionManager.find(TextSelection);
|
||||||
|
const blockSelection = selectionManager.find(BlockSelection);
|
||||||
|
const surfaceSelection = selectionManager.find(SurfaceSelection);
|
||||||
|
if (textSelection) {
|
||||||
|
selectedBlockId = textSelection.blockId;
|
||||||
|
} else if (blockSelection) {
|
||||||
|
selectedBlockId = blockSelection.blockId;
|
||||||
|
} else if (surfaceSelection && surfaceSelection.editing) {
|
||||||
|
selectedBlockId = surfaceSelection.blockId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flavour = 'affine:embed-iframe';
|
||||||
|
const props: Record<string, unknown> = { url };
|
||||||
|
// When there is a selected block, it means that the selection is in note or edgeless text
|
||||||
|
// we should insert the embed iframe block after the selected block and only need the url prop
|
||||||
|
let newBlockId: string | undefined;
|
||||||
|
if (selectedBlockId) {
|
||||||
|
const block = host.view.getBlock(selectedBlockId);
|
||||||
|
if (!block) return;
|
||||||
|
const parent = host.doc.getParent(block.model);
|
||||||
|
if (!parent) return;
|
||||||
|
const index = parent.children.indexOf(block.model);
|
||||||
|
newBlockId = host.doc.addBlock(flavour, props, parent, index + 1);
|
||||||
|
} else {
|
||||||
|
// When there is no selected block and in edgeless mode
|
||||||
|
// We should insert the embed iframe block to surface
|
||||||
|
// It means that not only the url prop but also the xywh prop is needed
|
||||||
|
const rootId = std.store.root?.id;
|
||||||
|
if (!rootId) return;
|
||||||
|
const edgelessRoot = std.view.getBlock(rootId);
|
||||||
|
if (!edgelessRoot) return;
|
||||||
|
|
||||||
|
const gfx = std.get(GfxControllerIdentifier);
|
||||||
|
const crud = std.get(EdgelessCRUDIdentifier);
|
||||||
|
|
||||||
|
gfx.viewport.smoothZoom(1);
|
||||||
|
const surfaceBlock = gfx.surfaceComponent;
|
||||||
|
if (!(surfaceBlock instanceof SurfaceBlockComponent)) return;
|
||||||
|
|
||||||
|
const options = config.options;
|
||||||
|
const { widthInSurface, heightInSurface } = options ?? {};
|
||||||
|
const width = widthInSurface ?? EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE;
|
||||||
|
const height = heightInSurface ?? EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE;
|
||||||
|
const center = Vec.toVec(surfaceBlock.renderer.viewport.center);
|
||||||
|
const xywh = Bound.fromCenter(center, width, height).serialize();
|
||||||
|
newBlockId = crud.addBlock(
|
||||||
|
flavour,
|
||||||
|
{
|
||||||
|
...props,
|
||||||
|
xywh,
|
||||||
|
},
|
||||||
|
surfaceBlock.model
|
||||||
|
);
|
||||||
|
|
||||||
|
gfx.selection.set({
|
||||||
|
elements: [newBlockId],
|
||||||
|
editing: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
gfx.tool.setTool(
|
||||||
|
// @ts-expect-error FIXME: resolve after gfx tool refactor
|
||||||
|
'default'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newBlockId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next({ blockId: newBlockId, flavour });
|
||||||
|
};
|
||||||
@@ -19,6 +19,7 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.affine-embed-iframe-error-card {
|
.affine-embed-iframe-error-card {
|
||||||
|
container: affine-embed-iframe-error-card / inline-size;
|
||||||
display: flex;
|
display: flex;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -124,6 +125,12 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
|
|||||||
width: 204px;
|
width: 204px;
|
||||||
height: 102px;
|
height: 102px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@container affine-embed-iframe-error-card (width < 480px) {
|
||||||
|
.error-banner {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export class EmbedIframeLoadingCard extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.affine-embed-iframe-loading-card {
|
.affine-embed-iframe-loading-card {
|
||||||
|
container: affine-embed-iframe-loading-card / inline-size;
|
||||||
display: flex;
|
display: flex;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -86,6 +87,12 @@ export class EmbedIframeLoadingCard extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@container affine-embed-iframe-loading-card (width < 480px) {
|
||||||
|
.loading-banner {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
validateEmbedIframeUrl,
|
validateEmbedIframeUrl,
|
||||||
} from '../../utils';
|
} from '../../utils';
|
||||||
|
|
||||||
const GOOGLE_DRIVE_DEFAULT_WIDTH = '100%';
|
const GOOGLE_DRIVE_DEFAULT_WIDTH_IN_SURFACE = 640;
|
||||||
const GOOGLE_DRIVE_DEFAULT_HEIGHT = '480px';
|
const GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_SURFACE = 480;
|
||||||
|
const GOOGLE_DRIVE_DEFAULT_WIDTH_PERCENT = 100;
|
||||||
|
const GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_NOTE = 480;
|
||||||
const GOOGLE_DRIVE_EMBED_FOLDER_URL =
|
const GOOGLE_DRIVE_EMBED_FOLDER_URL =
|
||||||
'https://drive.google.com/embeddedfolderview';
|
'https://drive.google.com/embeddedfolderview';
|
||||||
const GOOGLE_DRIVE_EMBED_FILE_URL = 'https://drive.google.com/file/d/';
|
const GOOGLE_DRIVE_EMBED_FILE_URL = 'https://drive.google.com/file/d/';
|
||||||
@@ -181,8 +183,10 @@ const googleDriveConfig = {
|
|||||||
},
|
},
|
||||||
useOEmbedUrlDirectly: true,
|
useOEmbedUrlDirectly: true,
|
||||||
options: {
|
options: {
|
||||||
defaultWidth: GOOGLE_DRIVE_DEFAULT_WIDTH,
|
widthInSurface: GOOGLE_DRIVE_DEFAULT_WIDTH_IN_SURFACE,
|
||||||
defaultHeight: GOOGLE_DRIVE_DEFAULT_HEIGHT,
|
heightInSurface: GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_SURFACE,
|
||||||
|
widthPercent: GOOGLE_DRIVE_DEFAULT_WIDTH_PERCENT,
|
||||||
|
heightInNote: GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_NOTE,
|
||||||
allowFullscreen: true,
|
allowFullscreen: true,
|
||||||
style: 'border: none; border-radius: 8px;',
|
style: 'border: none; border-radius: 8px;',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
validateEmbedIframeUrl,
|
validateEmbedIframeUrl,
|
||||||
} from '../../utils';
|
} from '../../utils';
|
||||||
|
|
||||||
const SPOTIFY_DEFAULT_WIDTH = '100%';
|
const SPOTIFY_DEFAULT_WIDTH_IN_SURFACE = 640;
|
||||||
const SPOTIFY_DEFAULT_HEIGHT = '152px';
|
const SPOTIFY_DEFAULT_HEIGHT_IN_SURFACE = 152;
|
||||||
|
const SPOTIFY_DEFAULT_HEIGHT_IN_NOTE = 152;
|
||||||
|
const SPOTIFY_DEFAULT_WIDTH_PERCENT = 100;
|
||||||
|
|
||||||
// https://developer.spotify.com/documentation/embeds/reference/oembed
|
// https://developer.spotify.com/documentation/embeds/reference/oembed
|
||||||
const spotifyEndpoint = 'https://open.spotify.com/oembed';
|
const spotifyEndpoint = 'https://open.spotify.com/oembed';
|
||||||
@@ -30,8 +32,10 @@ const spotifyConfig = {
|
|||||||
},
|
},
|
||||||
useOEmbedUrlDirectly: false,
|
useOEmbedUrlDirectly: false,
|
||||||
options: {
|
options: {
|
||||||
defaultWidth: SPOTIFY_DEFAULT_WIDTH,
|
widthInSurface: SPOTIFY_DEFAULT_WIDTH_IN_SURFACE,
|
||||||
defaultHeight: SPOTIFY_DEFAULT_HEIGHT,
|
heightInSurface: SPOTIFY_DEFAULT_HEIGHT_IN_SURFACE,
|
||||||
|
heightInNote: SPOTIFY_DEFAULT_HEIGHT_IN_NOTE,
|
||||||
|
widthPercent: SPOTIFY_DEFAULT_WIDTH_PERCENT,
|
||||||
allow:
|
allow:
|
||||||
'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture',
|
'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture',
|
||||||
style: 'border-radius: 8px;',
|
style: 'border-radius: 8px;',
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE = 752;
|
||||||
|
export const EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE = 116;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||||
|
import { toGfxBlockComponent } from '@blocksuite/block-std';
|
||||||
|
import { Bound } from '@blocksuite/global/gfx';
|
||||||
|
import { nothing } from 'lit';
|
||||||
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
|
import { html } from 'lit/static-html.js';
|
||||||
|
|
||||||
|
import { EmbedIframeBlockComponent } from './embed-iframe-block';
|
||||||
|
|
||||||
|
export class EmbedEdgelessIframeBlockComponent extends toGfxBlockComponent(
|
||||||
|
EmbedIframeBlockComponent
|
||||||
|
) {
|
||||||
|
override selectedStyle$ = null;
|
||||||
|
|
||||||
|
override blockDraggable = false;
|
||||||
|
|
||||||
|
override accessor blockContainerStyles = { margin: '0' };
|
||||||
|
|
||||||
|
get edgelessSlots() {
|
||||||
|
return this.std.get(EdgelessLegacySlotIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
this.edgelessSlots.elementResizeStart.subscribe(() => {
|
||||||
|
this.isResizing$.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.edgelessSlots.elementResizeEnd.subscribe(() => {
|
||||||
|
this.isResizing$.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override renderGfxBlock() {
|
||||||
|
if (!this.isEmbedIframeBlockEnabled) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bound = Bound.deserialize(this.model.xywh$.value);
|
||||||
|
const scale = this.model.scale$.value;
|
||||||
|
const width = bound.w / scale;
|
||||||
|
const height = bound.h / scale;
|
||||||
|
const style = {
|
||||||
|
width: `${width}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="edgeless-embed-iframe-block" style=${styleMap(style)}>
|
||||||
|
${this.renderPageContent()}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
|
||||||
import {
|
import {
|
||||||
CaptionedBlockComponent,
|
CaptionedBlockComponent,
|
||||||
SelectedStyle,
|
SelectedStyle,
|
||||||
@@ -7,13 +8,13 @@ import {
|
|||||||
FeatureFlagService,
|
FeatureFlagService,
|
||||||
LinkPreviewerService,
|
LinkPreviewerService,
|
||||||
} from '@blocksuite/affine-shared/services';
|
} from '@blocksuite/affine-shared/services';
|
||||||
|
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||||
import { BlockSelection } from '@blocksuite/block-std';
|
import { BlockSelection } from '@blocksuite/block-std';
|
||||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||||
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
|
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
|
||||||
import { html, nothing } from 'lit';
|
import { html, nothing } from 'lit';
|
||||||
import { type ClassInfo, classMap } from 'lit/directives/class-map.js';
|
import { type ClassInfo, classMap } from 'lit/directives/class-map.js';
|
||||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
import { styleMap } from 'lit/directives/style-map.js';
|
|
||||||
|
|
||||||
import type { IframeOptions } from './extension/embed-iframe-config.js';
|
import type { IframeOptions } from './extension/embed-iframe-config.js';
|
||||||
import { EmbedIframeService } from './extension/embed-iframe-service.js';
|
import { EmbedIframeService } from './extension/embed-iframe-service.js';
|
||||||
@@ -21,6 +22,7 @@ import { embedIframeBlockStyles } from './style.js';
|
|||||||
|
|
||||||
export type EmbedIframeStatus = 'idle' | 'loading' | 'success' | 'error';
|
export type EmbedIframeStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
const DEFAULT_IFRAME_HEIGHT = 152;
|
const DEFAULT_IFRAME_HEIGHT = 152;
|
||||||
|
const DEFAULT_IFRAME_WIDTH = '100%';
|
||||||
|
|
||||||
export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIframeBlockModel> {
|
export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIframeBlockModel> {
|
||||||
selectedStyle$: ReadonlySignal<ClassInfo> | null = computed<ClassInfo>(
|
selectedStyle$: ReadonlySignal<ClassInfo> | null = computed<ClassInfo>(
|
||||||
@@ -40,6 +42,18 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
|||||||
readonly hasError$ = computed(() => this.status$.value === 'error');
|
readonly hasError$ = computed(() => this.status$.value === 'error');
|
||||||
readonly isSuccess$ = computed(() => this.status$.value === 'success');
|
readonly isSuccess$ = computed(() => this.status$.value === 'success');
|
||||||
|
|
||||||
|
readonly isDraggingOnHost$ = signal(false);
|
||||||
|
readonly isResizing$ = signal(false);
|
||||||
|
// show overlay to prevent the iframe from capturing pointer events
|
||||||
|
// when the block is dragging, resizing, or not selected
|
||||||
|
readonly showOverlay$ = computed(
|
||||||
|
() =>
|
||||||
|
this.isSuccess$.value &&
|
||||||
|
(this.isDraggingOnHost$.value ||
|
||||||
|
this.isResizing$.value ||
|
||||||
|
!this.selected$.value)
|
||||||
|
);
|
||||||
|
|
||||||
private _iframeOptions: IframeOptions | undefined = undefined;
|
private _iframeOptions: IframeOptions | undefined = undefined;
|
||||||
|
|
||||||
get embedIframeService() {
|
get embedIframeService() {
|
||||||
@@ -50,6 +64,16 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
|||||||
return this.std.get(LinkPreviewerService);
|
return this.std.get(LinkPreviewerService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get inSurface() {
|
||||||
|
return matchModels(this.model.parent, [SurfaceBlockModel]);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isEmbedIframeBlockEnabled() {
|
||||||
|
const featureFlagService = this.doc.get(FeatureFlagService);
|
||||||
|
const flag = featureFlagService.getFlag('enable_embed_iframe_block');
|
||||||
|
return flag ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
open = () => {
|
open = () => {
|
||||||
const link = this.model.url;
|
const link = this.model.url;
|
||||||
window.open(link, '_blank');
|
window.open(link, '_blank');
|
||||||
@@ -134,8 +158,12 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
|||||||
selectionManager.setGroup('note', [blockSelection]);
|
selectionManager.setGroup('note', [blockSelection]);
|
||||||
};
|
};
|
||||||
|
|
||||||
protected readonly _handleClick = (event: MouseEvent) => {
|
protected _handleClick = (event: MouseEvent) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
// We don't need to select the block when the block is in the surface
|
||||||
|
if (this.inSurface) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._selectBlock();
|
this._selectBlock();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -143,27 +171,25 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
|||||||
await this.refreshData();
|
await this.refreshData();
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly _embedIframeBlockEnabled$: ReadonlySignal = computed(() => {
|
|
||||||
const featureFlagService = this.doc.get(FeatureFlagService);
|
|
||||||
const flag = featureFlagService.getFlag('enable_embed_iframe_block');
|
|
||||||
return flag ?? false;
|
|
||||||
});
|
|
||||||
|
|
||||||
private readonly _renderIframe = () => {
|
private readonly _renderIframe = () => {
|
||||||
const { iframeUrl } = this.model;
|
const { iframeUrl } = this.model;
|
||||||
const {
|
const {
|
||||||
defaultWidth,
|
widthPercent,
|
||||||
defaultHeight,
|
heightInNote,
|
||||||
style,
|
style,
|
||||||
allow,
|
allow,
|
||||||
referrerpolicy,
|
referrerpolicy,
|
||||||
scrolling,
|
scrolling,
|
||||||
allowFullscreen,
|
allowFullscreen,
|
||||||
} = this._iframeOptions ?? {};
|
} = this._iframeOptions ?? {};
|
||||||
|
const width = `${widthPercent}%`;
|
||||||
|
// if the block is in the surface, use 100% as the height
|
||||||
|
// otherwise, use the heightInNote
|
||||||
|
const height = this.inSurface ? '100%' : heightInNote;
|
||||||
return html`
|
return html`
|
||||||
<iframe
|
<iframe
|
||||||
width=${defaultWidth ?? '100%'}
|
width=${width ?? DEFAULT_IFRAME_WIDTH}
|
||||||
height=${defaultHeight ?? DEFAULT_IFRAME_HEIGHT}
|
height=${height ?? DEFAULT_IFRAME_HEIGHT}
|
||||||
?allowfullscreen=${allowFullscreen}
|
?allowfullscreen=${allowFullscreen}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
@@ -218,31 +244,51 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// subscribe the editor host global dragging event
|
||||||
|
// to show the overlay for the dragging area or other pointer events
|
||||||
|
this.handleEvent(
|
||||||
|
'dragStart',
|
||||||
|
() => {
|
||||||
|
this.isDraggingOnHost$.value = true;
|
||||||
|
},
|
||||||
|
{ global: true }
|
||||||
|
);
|
||||||
|
this.handleEvent(
|
||||||
|
'dragEnd',
|
||||||
|
() => {
|
||||||
|
this.isDraggingOnHost$.value = false;
|
||||||
|
},
|
||||||
|
{ global: true }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
override renderBlock() {
|
override renderBlock() {
|
||||||
if (!this._embedIframeBlockEnabled$.value) {
|
if (!this.isEmbedIframeBlockEnabled) {
|
||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const classes = classMap({
|
const containerClasses = classMap({
|
||||||
'affine-embed-iframe-block': true,
|
'affine-embed-iframe-block-container': true,
|
||||||
...this.selectedStyle$?.value,
|
...this.selectedStyle$?.value,
|
||||||
|
'in-surface': this.inSurface,
|
||||||
});
|
});
|
||||||
|
const overlayClasses = classMap({
|
||||||
const style = styleMap({
|
'affine-embed-iframe-block-overlay': true,
|
||||||
width: '100%',
|
show: this.showOverlay$.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
draggable=${this.blockDraggable ? 'true' : 'false'}
|
draggable=${this.blockDraggable ? 'true' : 'false'}
|
||||||
class=${classes}
|
class=${containerClasses}
|
||||||
style=${style}
|
|
||||||
@click=${this._handleClick}
|
@click=${this._handleClick}
|
||||||
@dblclick=${this._handleDoubleClick}
|
@dblclick=${this._handleDoubleClick}
|
||||||
>
|
>
|
||||||
${this._renderContent()}
|
${this._renderContent()}
|
||||||
|
|
||||||
|
<!-- overlay to prevent the iframe from capturing pointer events -->
|
||||||
|
<div class=${overlayClasses}></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,21 +8,19 @@ import type { ExtensionType } from '@blocksuite/store';
|
|||||||
* The options for the iframe
|
* The options for the iframe
|
||||||
* @example
|
* @example
|
||||||
* {
|
* {
|
||||||
* defaultWidth: '100%',
|
* widthInSurface: 640,
|
||||||
* defaultHeight: '152px',
|
* heightInSurface: 152,
|
||||||
|
* heightInNote: 152,
|
||||||
|
* widthPercent: 100,
|
||||||
* style: 'border-radius: 12px;',
|
* style: 'border-radius: 12px;',
|
||||||
* allow: 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture',
|
* allow: 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture',
|
||||||
* }
|
* }
|
||||||
* <iframe
|
|
||||||
* width="100%"
|
|
||||||
* height="152px"
|
|
||||||
* style="border-radius: 12px;"
|
|
||||||
* allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
|
||||||
* ></iframe>
|
|
||||||
*/
|
*/
|
||||||
export type IframeOptions = {
|
export type IframeOptions = {
|
||||||
defaultWidth?: string;
|
widthInSurface: number; // the default width of embed iframe in surface, in pixels
|
||||||
defaultHeight?: string;
|
heightInSurface: number; // the default height of embed iframe in surface, in pixels
|
||||||
|
heightInNote: number; // the default height of embed iframe in note, in pixels
|
||||||
|
widthPercent: number; // the width percentage of embed iframe relative to parent container width, normalized to 0-100
|
||||||
style?: string;
|
style?: string;
|
||||||
referrerpolicy?: string;
|
referrerpolicy?: string;
|
||||||
scrolling?: boolean;
|
scrolling?: boolean;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './adapters';
|
export * from './adapters';
|
||||||
|
export * from './commands';
|
||||||
export * from './components/embed-iframe-create-modal';
|
export * from './components/embed-iframe-create-modal';
|
||||||
export * from './configs';
|
export * from './configs';
|
||||||
export * from './embed-iframe-block';
|
export * from './embed-iframe-block';
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
import { css } from 'lit';
|
import { css } from 'lit';
|
||||||
|
|
||||||
export const embedIframeBlockStyles = css`
|
export const embedIframeBlockStyles = css`
|
||||||
.affine-embed-iframe-block {
|
.affine-embed-iframe-block-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affine-embed-iframe-block-container.in-surface {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affine-embed-iframe-block-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.affine-embed-iframe-block-overlay.show {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -3,19 +3,32 @@ import type { Command } from '@blocksuite/block-std';
|
|||||||
|
|
||||||
import { insertEmbedCard } from '../../common/insert-embed-card.js';
|
import { insertEmbedCard } from '../../common/insert-embed-card.js';
|
||||||
|
|
||||||
|
export type LinkableFlavour =
|
||||||
|
| 'affine:bookmark'
|
||||||
|
| 'affine:embed-linked-doc'
|
||||||
|
| 'affine:embed-iframe'
|
||||||
|
| 'affine:embed-figma'
|
||||||
|
| 'affine:embed-github'
|
||||||
|
| 'affine:embed-loom'
|
||||||
|
| 'affine:embed-youtube';
|
||||||
|
|
||||||
export type InsertedLinkType = {
|
export type InsertedLinkType = {
|
||||||
flavour?: 'affine:bookmark' | 'affine:embed-linked-doc';
|
flavour: LinkableFlavour;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
export const insertEmbedLinkedDocCommand: Command<{
|
export const insertEmbedLinkedDocCommand: Command<
|
||||||
docId: string;
|
{
|
||||||
params?: ReferenceParams;
|
docId: string;
|
||||||
}> = (ctx, next) => {
|
params?: ReferenceParams;
|
||||||
|
},
|
||||||
|
{ blockId: string }
|
||||||
|
> = (ctx, next) => {
|
||||||
const { docId, params, std } = ctx;
|
const { docId, params, std } = ctx;
|
||||||
const flavour = 'affine:embed-linked-doc';
|
const flavour = 'affine:embed-linked-doc';
|
||||||
const targetStyle: EmbedCardStyle = 'vertical';
|
const targetStyle: EmbedCardStyle = 'vertical';
|
||||||
const props: Record<string, unknown> = { pageId: docId };
|
const props: Record<string, unknown> = { pageId: docId };
|
||||||
if (params) props.params = params;
|
if (params) props.params = params;
|
||||||
insertEmbedCard(std, { flavour, targetStyle, props });
|
const blockId = insertEmbedCard(std, { flavour, targetStyle, props });
|
||||||
next();
|
if (!blockId) return;
|
||||||
|
next({ blockId });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -441,6 +441,7 @@ export class EdgelessClipboardController extends PageClipboard {
|
|||||||
this.registerBlock('affine:embed-html', this._createHtmlEmbedBlock);
|
this.registerBlock('affine:embed-html', this._createHtmlEmbedBlock);
|
||||||
this.registerBlock('affine:embed-loom', this._createLoomEmbedBlock);
|
this.registerBlock('affine:embed-loom', this._createLoomEmbedBlock);
|
||||||
this.registerBlock('affine:embed-youtube', this._createYoutubeEmbedBlock);
|
this.registerBlock('affine:embed-youtube', this._createYoutubeEmbedBlock);
|
||||||
|
this.registerBlock('affine:embed-iframe', this._createIframeEmbedBlock);
|
||||||
|
|
||||||
// internal links
|
// internal links
|
||||||
this.registerBlock(
|
this.registerBlock(
|
||||||
@@ -881,6 +882,36 @@ export class EdgelessClipboardController extends PageClipboard {
|
|||||||
return embedYoutubeId;
|
return embedYoutubeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _createIframeEmbedBlock(embedIframe: BlockSnapshot) {
|
||||||
|
const {
|
||||||
|
xywh,
|
||||||
|
caption,
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
iframeUrl,
|
||||||
|
scale,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
} = embedIframe.props;
|
||||||
|
|
||||||
|
return this.crud.addBlock(
|
||||||
|
'affine:embed-iframe',
|
||||||
|
{
|
||||||
|
url,
|
||||||
|
iframeUrl,
|
||||||
|
xywh,
|
||||||
|
caption,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
scale,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
},
|
||||||
|
this.surface.model.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async _edgelessToCanvas(
|
private async _edgelessToCanvas(
|
||||||
edgeless: EdgelessRootBlockComponent,
|
edgeless: EdgelessRootBlockComponent,
|
||||||
bound: IBound,
|
bound: IBound,
|
||||||
|
|||||||
Reference in New Issue
Block a user