donteatfriedrice
2025-03-15 09:23:02 +00:00
parent b4f49a234f
commit 1d4ee1e383
18 changed files with 397 additions and 55 deletions
@@ -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 {
container: affine-embed-iframe-error-card / inline-size;
display: flex;
box-sizing: border-box;
width: 100%;
@@ -124,6 +125,12 @@ export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
width: 204px;
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 {
container: affine-embed-iframe-loading-card / inline-size;
display: flex;
box-sizing: border-box;
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,
} from '../../utils';
const GOOGLE_DRIVE_DEFAULT_WIDTH = '100%';
const GOOGLE_DRIVE_DEFAULT_HEIGHT = '480px';
const GOOGLE_DRIVE_DEFAULT_WIDTH_IN_SURFACE = 640;
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 =
'https://drive.google.com/embeddedfolderview';
const GOOGLE_DRIVE_EMBED_FILE_URL = 'https://drive.google.com/file/d/';
@@ -181,8 +183,10 @@ const googleDriveConfig = {
},
useOEmbedUrlDirectly: true,
options: {
defaultWidth: GOOGLE_DRIVE_DEFAULT_WIDTH,
defaultHeight: GOOGLE_DRIVE_DEFAULT_HEIGHT,
widthInSurface: GOOGLE_DRIVE_DEFAULT_WIDTH_IN_SURFACE,
heightInSurface: GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_SURFACE,
widthPercent: GOOGLE_DRIVE_DEFAULT_WIDTH_PERCENT,
heightInNote: GOOGLE_DRIVE_DEFAULT_HEIGHT_IN_NOTE,
allowFullscreen: true,
style: 'border: none; border-radius: 8px;',
},
@@ -4,8 +4,10 @@ import {
validateEmbedIframeUrl,
} from '../../utils';
const SPOTIFY_DEFAULT_WIDTH = '100%';
const SPOTIFY_DEFAULT_HEIGHT = '152px';
const SPOTIFY_DEFAULT_WIDTH_IN_SURFACE = 640;
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
const spotifyEndpoint = 'https://open.spotify.com/oembed';
@@ -30,8 +32,10 @@ const spotifyConfig = {
},
useOEmbedUrlDirectly: false,
options: {
defaultWidth: SPOTIFY_DEFAULT_WIDTH,
defaultHeight: SPOTIFY_DEFAULT_HEIGHT,
widthInSurface: SPOTIFY_DEFAULT_WIDTH_IN_SURFACE,
heightInSurface: SPOTIFY_DEFAULT_HEIGHT_IN_SURFACE,
heightInNote: SPOTIFY_DEFAULT_HEIGHT_IN_NOTE,
widthPercent: SPOTIFY_DEFAULT_WIDTH_PERCENT,
allow:
'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture',
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 {
CaptionedBlockComponent,
SelectedStyle,
@@ -7,13 +8,13 @@ import {
FeatureFlagService,
LinkPreviewerService,
} from '@blocksuite/affine-shared/services';
import { matchModels } from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/block-std';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import { html, nothing } from 'lit';
import { type ClassInfo, classMap } from 'lit/directives/class-map.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 { EmbedIframeService } from './extension/embed-iframe-service.js';
@@ -21,6 +22,7 @@ import { embedIframeBlockStyles } from './style.js';
export type EmbedIframeStatus = 'idle' | 'loading' | 'success' | 'error';
const DEFAULT_IFRAME_HEIGHT = 152;
const DEFAULT_IFRAME_WIDTH = '100%';
export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIframeBlockModel> {
selectedStyle$: ReadonlySignal<ClassInfo> | null = computed<ClassInfo>(
@@ -40,6 +42,18 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
readonly hasError$ = computed(() => this.status$.value === 'error');
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;
get embedIframeService() {
@@ -50,6 +64,16 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
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 = () => {
const link = this.model.url;
window.open(link, '_blank');
@@ -134,8 +158,12 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
selectionManager.setGroup('note', [blockSelection]);
};
protected readonly _handleClick = (event: MouseEvent) => {
protected _handleClick = (event: MouseEvent) => {
event.stopPropagation();
// We don't need to select the block when the block is in the surface
if (this.inSurface) {
return;
}
this._selectBlock();
};
@@ -143,27 +171,25 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent<EmbedIfra
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 = () => {
const { iframeUrl } = this.model;
const {
defaultWidth,
defaultHeight,
widthPercent,
heightInNote,
style,
allow,
referrerpolicy,
scrolling,
allowFullscreen,
} = 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`
<iframe
width=${defaultWidth ?? '100%'}
height=${defaultHeight ?? DEFAULT_IFRAME_HEIGHT}
width=${width ?? DEFAULT_IFRAME_WIDTH}
height=${height ?? DEFAULT_IFRAME_HEIGHT}
?allowfullscreen=${allowFullscreen}
loading="lazy"
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() {
if (!this._embedIframeBlockEnabled$.value) {
if (!this.isEmbedIframeBlockEnabled) {
return nothing;
}
const classes = classMap({
'affine-embed-iframe-block': true,
const containerClasses = classMap({
'affine-embed-iframe-block-container': true,
...this.selectedStyle$?.value,
'in-surface': this.inSurface,
});
const style = styleMap({
width: '100%',
const overlayClasses = classMap({
'affine-embed-iframe-block-overlay': true,
show: this.showOverlay$.value,
});
return html`
<div
draggable=${this.blockDraggable ? 'true' : 'false'}
class=${classes}
style=${style}
class=${containerClasses}
@click=${this._handleClick}
@dblclick=${this._handleDoubleClick}
>
${this._renderContent()}
<!-- overlay to prevent the iframe from capturing pointer events -->
<div class=${overlayClasses}></div>
</div>
`;
}
@@ -8,21 +8,19 @@ import type { ExtensionType } from '@blocksuite/store';
* The options for the iframe
* @example
* {
* defaultWidth: '100%',
* defaultHeight: '152px',
* widthInSurface: 640,
* heightInSurface: 152,
* heightInNote: 152,
* widthPercent: 100,
* style: 'border-radius: 12px;',
* 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 = {
defaultWidth?: string;
defaultHeight?: string;
widthInSurface: number; // the default width of embed iframe in surface, in pixels
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;
referrerpolicy?: string;
scrolling?: boolean;
@@ -1,4 +1,5 @@
export * from './adapters';
export * from './commands';
export * from './components/embed-iframe-create-modal';
export * from './configs';
export * from './embed-iframe-block';
@@ -1,12 +1,29 @@
import { css } from 'lit';
export const embedIframeBlockStyles = css`
.affine-embed-iframe-block {
.affine-embed-iframe-block-container {
display: flex;
width: 100%;
border-radius: 8px;
user-select: none;
align-items: 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;
}
`;