diff --git a/blocksuite/affine/all/src/adapters/html/block-matcher.ts b/blocksuite/affine/all/src/adapters/html/block-matcher.ts
index 1a190068de..b98b28d800 100644
--- a/blocksuite/affine/all/src/adapters/html/block-matcher.ts
+++ b/blocksuite/affine/all/src/adapters/html/block-matcher.ts
@@ -5,6 +5,7 @@ import { DividerBlockHtmlAdapterExtension } from '@blocksuite/affine-block-divid
import {
EmbedFigmaBlockHtmlAdapterExtension,
EmbedGithubBlockHtmlAdapterExtension,
+ EmbedIframeBlockHtmlAdapterExtension,
EmbedLinkedDocHtmlAdapterExtension,
EmbedLoomBlockHtmlAdapterExtension,
EmbedSyncedDocBlockHtmlAdapterExtension,
@@ -27,6 +28,7 @@ export const defaultBlockHtmlAdapterMatchers = [
EmbedFigmaBlockHtmlAdapterExtension,
EmbedLoomBlockHtmlAdapterExtension,
EmbedGithubBlockHtmlAdapterExtension,
+ EmbedIframeBlockHtmlAdapterExtension,
BookmarkBlockHtmlAdapterExtension,
DatabaseBlockHtmlAdapterExtension,
TableBlockHtmlAdapterExtension,
diff --git a/blocksuite/affine/all/src/adapters/markdown/block-matcher.ts b/blocksuite/affine/all/src/adapters/markdown/block-matcher.ts
index f753874da1..58ac5806bb 100644
--- a/blocksuite/affine/all/src/adapters/markdown/block-matcher.ts
+++ b/blocksuite/affine/all/src/adapters/markdown/block-matcher.ts
@@ -5,6 +5,7 @@ import { DividerBlockMarkdownAdapterExtension } from '@blocksuite/affine-block-d
import {
EmbedFigmaMarkdownAdapterExtension,
EmbedGithubMarkdownAdapterExtension,
+ EmbedIframeBlockMarkdownAdapterExtension,
EmbedLinkedDocMarkdownAdapterExtension,
EmbedLoomMarkdownAdapterExtension,
EmbedSyncedDocMarkdownAdapterExtension,
@@ -27,6 +28,7 @@ export const defaultBlockMarkdownAdapterMatchers = [
EmbedLoomMarkdownAdapterExtension,
EmbedSyncedDocMarkdownAdapterExtension,
EmbedYoutubeMarkdownAdapterExtension,
+ EmbedIframeBlockMarkdownAdapterExtension,
ListBlockMarkdownAdapterExtension,
ParagraphBlockMarkdownAdapterExtension,
BookmarkBlockMarkdownAdapterExtension,
diff --git a/blocksuite/affine/all/src/adapters/plain-text/block-matcher.ts b/blocksuite/affine/all/src/adapters/plain-text/block-matcher.ts
index f6faea8dd9..4b4f44bb67 100644
--- a/blocksuite/affine/all/src/adapters/plain-text/block-matcher.ts
+++ b/blocksuite/affine/all/src/adapters/plain-text/block-matcher.ts
@@ -5,6 +5,7 @@ import { DividerBlockPlainTextAdapterExtension } from '@blocksuite/affine-block-
import {
EmbedFigmaBlockPlainTextAdapterExtension,
EmbedGithubBlockPlainTextAdapterExtension,
+ EmbedIframeBlockPlainTextAdapterExtension,
EmbedLinkedDocBlockPlainTextAdapterExtension,
EmbedLoomBlockPlainTextAdapterExtension,
EmbedSyncedDocBlockPlainTextAdapterExtension,
@@ -27,6 +28,7 @@ export const defaultBlockPlainTextAdapterMatchers: ExtensionType[] = [
EmbedYoutubeBlockPlainTextAdapterExtension,
EmbedLinkedDocBlockPlainTextAdapterExtension,
EmbedSyncedDocBlockPlainTextAdapterExtension,
+ EmbedIframeBlockPlainTextAdapterExtension,
LatexBlockPlainTextAdapterExtension,
DatabaseBlockPlainTextAdapterExtension,
];
diff --git a/blocksuite/affine/all/src/extensions/store.ts b/blocksuite/affine/all/src/extensions/store.ts
index 04fd4931f4..916e574fac 100644
--- a/blocksuite/affine/all/src/extensions/store.ts
+++ b/blocksuite/affine/all/src/extensions/store.ts
@@ -1,5 +1,9 @@
import { DataViewBlockSchemaExtension } from '@blocksuite/affine-block-data-view';
import { DatabaseSelectionExtension } from '@blocksuite/affine-block-database';
+import {
+ EmbedIframeConfigExtensions,
+ EmbedIframeService,
+} from '@blocksuite/affine-block-embed';
import { ImageStoreSpec } from '@blocksuite/affine-block-image';
import { SurfaceBlockSchemaExtension } from '@blocksuite/affine-block-surface';
import { TableSelectionExtension } from '@blocksuite/affine-block-table';
@@ -14,6 +18,7 @@ import {
EmbedFigmaBlockSchemaExtension,
EmbedGithubBlockSchemaExtension,
EmbedHtmlBlockSchemaExtension,
+ EmbedIframeBlockSchemaExtension,
EmbedLinkedDocBlockSchemaExtension,
EmbedLoomBlockSchemaExtension,
EmbedSyncedDocBlockSchemaExtension,
@@ -72,6 +77,7 @@ export const StoreExtensions: ExtensionType[] = [
EmbedSyncedDocBlockSchemaExtension,
EmbedLinkedDocBlockSchemaExtension,
EmbedHtmlBlockSchemaExtension,
+ EmbedIframeBlockSchemaExtension,
EmbedGithubBlockSchemaExtension,
EmbedFigmaBlockSchemaExtension,
EmbedLoomBlockSchemaExtension,
@@ -101,4 +107,6 @@ export const StoreExtensions: ExtensionType[] = [
FileSizeLimitService,
ImageStoreSpec,
BlockMetaService,
+ EmbedIframeConfigExtensions,
+ EmbedIframeService,
].flat();
diff --git a/blocksuite/affine/blocks/block-embed/src/effects.ts b/blocksuite/affine/blocks/block-embed/src/effects.ts
index cb78258fe2..143ae00c44 100644
--- a/blocksuite/affine/blocks/block-embed/src/effects.ts
+++ b/blocksuite/affine/blocks/block-embed/src/effects.ts
@@ -5,6 +5,11 @@ import { EmbedEdgelessGithubBlockComponent } from './embed-github-block/embed-ed
import { EmbedHtmlBlockComponent } from './embed-html-block';
import { EmbedHtmlFullscreenToolbar } from './embed-html-block/components/fullscreen-toolbar';
import { EmbedEdgelessHtmlBlockComponent } from './embed-html-block/embed-edgeless-html-block';
+import { EmbedIframeCreateModal } from './embed-iframe-block/components/embed-iframe-create-modal';
+import { EmbedIframeErrorCard } from './embed-iframe-block/components/embed-iframe-error-card';
+import { EmbedIframeLinkEditPopup } from './embed-iframe-block/components/embed-iframe-link-edit-popup';
+import { EmbedIframeLoadingCard } from './embed-iframe-block/components/embed-iframe-loading-card';
+import { EmbedIframeBlockComponent } from './embed-iframe-block/embed-iframe-block';
import { EmbedLinkedDocBlockComponent } from './embed-linked-doc-block';
import { EmbedEdgelessLinkedDocBlockComponent } from './embed-linked-doc-block/embed-edgeless-linked-doc-block';
import { EmbedLoomBlockComponent } from './embed-loom-block';
@@ -72,6 +77,18 @@ export function effects() {
'affine-embed-synced-doc-block',
EmbedSyncedDocBlockComponent
);
+
+ customElements.define('affine-embed-iframe-block', EmbedIframeBlockComponent);
+ customElements.define(
+ 'affine-embed-iframe-create-modal',
+ EmbedIframeCreateModal
+ );
+ customElements.define('embed-iframe-loading-card', EmbedIframeLoadingCard);
+ customElements.define('embed-iframe-error-card', EmbedIframeErrorCard);
+ customElements.define(
+ 'embed-iframe-link-edit-popup',
+ EmbedIframeLinkEditPopup
+ );
}
declare global {
@@ -92,5 +109,10 @@ declare global {
'affine-embed-edgeless-synced-doc-block': EmbedEdgelessSyncedDocBlockComponent;
'affine-embed-linked-doc-block': EmbedLinkedDocBlockComponent;
'affine-embed-edgeless-linked-doc-block': EmbedEdgelessLinkedDocBlockComponent;
+ 'affine-embed-iframe-block': EmbedIframeBlockComponent;
+ 'affine-embed-iframe-create-modal': EmbedIframeCreateModal;
+ 'embed-iframe-loading-card': EmbedIframeLoadingCard;
+ 'embed-iframe-error-card': EmbedIframeErrorCard;
+ 'embed-iframe-link-edit-popup': EmbedIframeLinkEditPopup;
}
}
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/adapters/html.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/adapters/html.ts
new file mode 100644
index 0000000000..96e793bcd3
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/adapters/html.ts
@@ -0,0 +1,55 @@
+import { EmbedIframeBlockSchema } from '@blocksuite/affine-model';
+import { BlockHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters';
+
+import { createEmbedBlockHtmlAdapterMatcher } from '../../common/adapters/html';
+
+export const embedIframeBlockHtmlAdapterMatcher =
+ createEmbedBlockHtmlAdapterMatcher(EmbedIframeBlockSchema.model.flavour, {
+ fromBlockSnapshot: {
+ enter: (o, context) => {
+ const { walkerContext } = context;
+ // Parse as link
+ if (
+ typeof o.node.props.title !== 'string' ||
+ typeof o.node.props.url !== 'string'
+ ) {
+ return;
+ }
+
+ walkerContext
+ .openNode(
+ {
+ type: 'element',
+ tagName: 'div',
+ properties: {
+ className: ['affine-paragraph-block-container'],
+ },
+ children: [],
+ },
+ 'children'
+ )
+ .openNode(
+ {
+ type: 'element',
+ tagName: 'a',
+ properties: {
+ href: o.node.props.url,
+ },
+ children: [
+ {
+ type: 'text',
+ value: o.node.props.title,
+ },
+ ],
+ },
+ 'children'
+ )
+ .closeNode()
+ .closeNode();
+ },
+ },
+ });
+
+export const EmbedIframeBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
+ embedIframeBlockHtmlAdapterMatcher
+);
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/adapters/index.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/adapters/index.ts
new file mode 100644
index 0000000000..5b85aac735
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/adapters/index.ts
@@ -0,0 +1,15 @@
+import type { ExtensionType } from '@blocksuite/store';
+
+import { EmbedIframeBlockHtmlAdapterExtension } from './html';
+import { EmbedIframeBlockMarkdownAdapterExtension } from './markdown';
+import { EmbedIframeBlockPlainTextAdapterExtension } from './plain-text';
+
+export * from './html';
+export * from './markdown';
+export * from './plain-text';
+
+export const EmbedIframeBlockAdapterExtensions: ExtensionType[] = [
+ EmbedIframeBlockHtmlAdapterExtension,
+ EmbedIframeBlockMarkdownAdapterExtension,
+ EmbedIframeBlockPlainTextAdapterExtension,
+];
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/adapters/markdown.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/adapters/markdown.ts
new file mode 100644
index 0000000000..35fe625540
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/adapters/markdown.ts
@@ -0,0 +1,46 @@
+import { EmbedIframeBlockSchema } from '@blocksuite/affine-model';
+import { BlockMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters';
+
+import { createEmbedBlockMarkdownAdapterMatcher } from '../../common/adapters/markdown.js';
+
+export const embedIframeBlockMarkdownAdapterMatcher =
+ createEmbedBlockMarkdownAdapterMatcher(EmbedIframeBlockSchema.model.flavour, {
+ fromBlockSnapshot: {
+ enter: (o, context) => {
+ const { walkerContext } = context;
+ // Parse as link
+ if (
+ typeof o.node.props.title !== 'string' ||
+ typeof o.node.props.url !== 'string'
+ ) {
+ return;
+ }
+ walkerContext
+ .openNode(
+ {
+ type: 'paragraph',
+ children: [],
+ },
+ 'children'
+ )
+ .openNode(
+ {
+ type: 'link',
+ url: o.node.props.url,
+ children: [
+ {
+ type: 'text',
+ value: o.node.props.title,
+ },
+ ],
+ },
+ 'children'
+ )
+ .closeNode()
+ .closeNode();
+ },
+ },
+ });
+
+export const EmbedIframeBlockMarkdownAdapterExtension =
+ BlockMarkdownAdapterExtension(embedIframeBlockMarkdownAdapterMatcher);
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/adapters/plain-text.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/adapters/plain-text.ts
new file mode 100644
index 0000000000..a9ad9efc05
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/adapters/plain-text.ts
@@ -0,0 +1,31 @@
+import { EmbedIframeBlockSchema } from '@blocksuite/affine-model';
+import { BlockPlainTextAdapterExtension } from '@blocksuite/affine-shared/adapters';
+
+import { createEmbedBlockPlainTextAdapterMatcher } from '../../common/adapters/plain-text';
+
+export const embedIframeBlockPlainTextAdapterMatcher =
+ createEmbedBlockPlainTextAdapterMatcher(
+ EmbedIframeBlockSchema.model.flavour,
+ {
+ fromBlockSnapshot: {
+ enter: (o, context) => {
+ const { textBuffer } = context;
+ // Parse as link
+ if (
+ typeof o.node.props.title !== 'string' ||
+ typeof o.node.props.url !== 'string'
+ ) {
+ return;
+ }
+ const buffer = `[${o.node.props.title}](${o.node.props.url})`;
+ if (buffer.length > 0) {
+ textBuffer.content += buffer;
+ textBuffer.content += '\n';
+ }
+ },
+ },
+ }
+ );
+
+export const EmbedIframeBlockPlainTextAdapterExtension =
+ BlockPlainTextAdapterExtension(embedIframeBlockPlainTextAdapterMatcher);
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/components/embed-iframe-create-modal.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/components/embed-iframe-create-modal.ts
new file mode 100644
index 0000000000..2f32513eea
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/components/embed-iframe-create-modal.ts
@@ -0,0 +1,366 @@
+import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
+import { isValidUrl, stopPropagation } from '@blocksuite/affine-shared/utils';
+import type { BlockStdScope } from '@blocksuite/block-std';
+import { WithDisposable } from '@blocksuite/global/lit';
+import { CloseIcon, EmbedIcon } from '@blocksuite/icons/lit';
+import type { BlockModel } from '@blocksuite/store';
+import { css, html, LitElement, nothing } from 'lit';
+import { property, query, state } from 'lit/decorators.js';
+
+import { EmbedIframeService } from '../extension/embed-iframe-service';
+
+export class EmbedIframeCreateModal extends WithDisposable(LitElement) {
+ static override styles = css`
+ .embed-iframe-create-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1;
+ }
+
+ .embed-iframe-create-modal-mask {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ .modal-main-wrapper {
+ position: relative;
+ box-sizing: border-box;
+ width: 340px;
+ padding: 0 24px;
+ border-radius: 12px;
+ background: ${unsafeCSSVarV2('layer/background/overlayPanel')};
+ box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
+ z-index: var(--affine-z-index-modal);
+ }
+
+ .modal-content-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ }
+
+ .modal-close-button {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ cursor: pointer;
+ color: var(--affine-icon-color);
+ border-radius: 4px;
+ }
+ .modal-close-button:hover {
+ background-color: var(--affine-hover-color);
+ }
+
+ .modal-content-header {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ .icon-container {
+ padding-top: 48px;
+ padding-bottom: 16px;
+ display: flex;
+ justify-content: center;
+
+ .icon-background {
+ display: flex;
+ width: 64px;
+ height: 64px;
+ justify-content: center;
+ align-items: center;
+ border-radius: 50%;
+ background: var(--affine-background-secondary-color);
+ color: ${unsafeCSSVarV2('icon/primary')};
+ }
+ }
+
+ .title,
+ .description {
+ text-align: center;
+ }
+
+ .title {
+ /* Client/h6 */
+ font-family: Inter;
+ font-size: 18px;
+ font-style: normal;
+ font-weight: 600;
+ line-height: 26px; /* 144.444% */
+ letter-spacing: -0.24px;
+ color: ${unsafeCSSVarV2('text/primary')};
+ }
+
+ .description {
+ font-feature-settings:
+ 'liga' off,
+ 'clig' off;
+ /* Client/xs */
+ font-family: Inter;
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 20px; /* 166.667% */
+ color: ${unsafeCSSVarV2('text/secondary')};
+ }
+ }
+
+ .input-container {
+ width: 100%;
+
+ .link-input {
+ box-sizing: border-box;
+ width: 100%;
+ display: flex;
+ padding: 4px 10px;
+ align-items: center;
+ gap: 8px;
+ align-self: stretch;
+ border-radius: 8px;
+ border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
+ background: ${unsafeCSSVarV2('input/background')};
+ }
+
+ .link-input:focus {
+ border-color: var(--affine-blue-700);
+ box-shadow: var(--affine-active-shadow);
+ outline: none;
+ }
+ .link-input::placeholder {
+ color: var(--affine-placeholder-color);
+ }
+ }
+
+ .button-container {
+ display: flex;
+ justify-content: center;
+ padding: 20px 0px;
+ cursor: pointer;
+
+ .confirm-button {
+ width: 100%;
+ height: 32px;
+ line-height: 32px;
+ text-align: center;
+ justify-content: center;
+ align-items: center;
+ border-radius: 8px;
+ background: ${unsafeCSSVarV2('button/primary')};
+ border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
+
+ color: ${unsafeCSSVarV2('button/pureWhiteText')};
+ /* Client/xsMedium */
+ font-family: Inter;
+ font-size: 12px;
+ font-style: normal;
+ font-weight: 500;
+ }
+
+ .confirm-button[disabled] {
+ opacity: 0.5;
+ }
+ }
+ `;
+
+ private readonly _onClose = () => {
+ this.remove();
+ };
+
+ private readonly _isInputEmpty = () => {
+ return this._linkInputValue.trim() === '';
+ };
+
+ private readonly _addBookmark = (url: string) => {
+ if (!isValidUrl(url)) {
+ // notify user that the url is invalid
+ return;
+ }
+
+ const blockId = this.std.store.addBlock(
+ 'affine:bookmark',
+ {
+ url,
+ },
+ this.parentModel.id,
+ this.index
+ );
+
+ return blockId;
+ };
+
+ private readonly _onConfirm = async () => {
+ if (this._isInputEmpty()) {
+ return;
+ }
+
+ try {
+ const embedIframeService = this.std.get(EmbedIframeService);
+ if (!embedIframeService) {
+ console.error('iframe EmbedIframeService not found');
+ return;
+ }
+
+ const url = this.input.value;
+ // check if the url can be embedded
+ const canEmbed = embedIframeService.canEmbed(url);
+ // if can not be embedded, try to add as a bookmark
+ if (!canEmbed) {
+ console.log('iframe can not be embedded, add as a bookmark', url);
+ this._addBookmark(url);
+ return;
+ }
+
+ // create a new embed iframe block
+ const embedIframeBlock = embedIframeService.addEmbedIframeBlock(
+ {
+ url,
+ },
+ this.parentModel.id,
+ this.index
+ );
+
+ return embedIframeBlock;
+ } catch (error) {
+ console.error('Error in embed iframe creation:', error);
+ return;
+ } finally {
+ this._onClose();
+ }
+ };
+
+ private readonly _handleInput = (e: InputEvent) => {
+ const target = e.target as HTMLInputElement;
+ this._linkInputValue = target.value;
+ };
+
+ private readonly _handleKeyDown = async (e: KeyboardEvent) => {
+ e.stopPropagation();
+ if (e.key === 'Enter' && !e.isComposing) {
+ await this._onConfirm();
+ }
+ };
+
+ override connectedCallback() {
+ super.connectedCallback();
+
+ this.updateComplete
+ .then(() => {
+ requestAnimationFrame(() => {
+ this.input.focus();
+ });
+ })
+ .catch(console.error);
+ this.disposables.addFromEvent(this, 'cut', stopPropagation);
+ this.disposables.addFromEvent(this, 'copy', stopPropagation);
+ this.disposables.addFromEvent(this, 'paste', stopPropagation);
+ }
+
+ override render() {
+ const { showCloseButton } = this;
+ return html`
+
+
+
+ ${showCloseButton
+ ? html`
+
+ ${CloseIcon({ width: '20', height: '20' })}
+
+ `
+ : nothing}
+
+
+
+
+ `;
+ }
+
+ @state()
+ private accessor _linkInputValue = '';
+
+ @query('input')
+ accessor input!: HTMLInputElement;
+
+ @property({ attribute: false })
+ accessor parentModel!: BlockModel;
+
+ @property({ attribute: false })
+ accessor index: number | undefined = undefined;
+
+ @property({ attribute: false })
+ accessor std!: BlockStdScope;
+
+ @property({ attribute: false })
+ accessor onConfirm: () => void = () => {};
+
+ @property({ attribute: false })
+ accessor showCloseButton: boolean = true;
+}
+
+export async function toggleEmbedIframeCreateModal(
+ std: BlockStdScope,
+ createOptions: {
+ parentModel: BlockModel;
+ index?: number;
+ }
+): Promise {
+ std.selection.clear();
+
+ const embedIframeCreateModal = new EmbedIframeCreateModal();
+ embedIframeCreateModal.std = std;
+ embedIframeCreateModal.parentModel = createOptions.parentModel;
+ embedIframeCreateModal.index = createOptions.index;
+
+ document.body.append(embedIframeCreateModal);
+
+ return new Promise(resolve => {
+ embedIframeCreateModal.onConfirm = () => resolve();
+ });
+}
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/components/embed-iframe-error-card.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/components/embed-iframe-error-card.ts
new file mode 100644
index 0000000000..994287194b
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/components/embed-iframe-error-card.ts
@@ -0,0 +1,223 @@
+import { createLitPortal } from '@blocksuite/affine-components/portal';
+import type { EmbedIframeBlockModel } from '@blocksuite/affine-model';
+import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
+import { stopPropagation } from '@blocksuite/affine-shared/utils';
+import type { BlockStdScope } from '@blocksuite/block-std';
+import { WithDisposable } from '@blocksuite/global/lit';
+import { EditIcon, InformationIcon, ResetIcon } from '@blocksuite/icons/lit';
+import { flip, offset } from '@floating-ui/dom';
+import { baseTheme } from '@toeverything/theme';
+import { css, html, LitElement, unsafeCSS } from 'lit';
+import { property, query } from 'lit/decorators.js';
+
+const LINK_EDIT_POPUP_OFFSET = 12;
+
+export class EmbedIframeErrorCard extends WithDisposable(LitElement) {
+ static override styles = css`
+ :host {
+ width: 100%;
+ }
+
+ .affine-embed-iframe-error-card {
+ display: flex;
+ box-sizing: border-box;
+ width: 100%;
+ user-select: none;
+ height: 114px;
+ padding: 12px;
+ align-items: flex-start;
+ gap: 12px;
+ overflow: hidden;
+ border-radius: 8px;
+ border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
+ background: ${unsafeCSSVarV2('layer/background/secondary')};
+ font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
+ user-select: none;
+
+ .error-content {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ flex: 1 0 0;
+
+ .error-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ overflow: hidden;
+
+ .error-icon {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: ${unsafeCSSVarV2('status/error')};
+ }
+
+ .error-title-text {
+ color: ${unsafeCSSVarV2('text/primary')};
+ text-align: justify;
+ /* Client/smBold */
+ font-size: var(--affine-font-sm);
+ font-style: normal;
+ font-weight: 600;
+ line-height: 22px; /* 157.143% */
+ }
+ }
+
+ .error-message {
+ display: flex;
+ height: 40px;
+ align-items: flex-start;
+ align-self: stretch;
+ color: ${unsafeCSSVarV2('text/secondary')};
+ overflow: hidden;
+ font-feature-settings:
+ 'liga' off,
+ 'clig' off;
+ text-overflow: ellipsis;
+ /* Client/xs */
+ font-size: var(--affine-font-xs);
+ font-style: normal;
+ font-weight: 400;
+ line-height: 20px; /* 166.667% */
+ }
+
+ .error-info {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ overflow: hidden;
+ .button {
+ display: flex;
+ padding: 0px 4px;
+ align-items: center;
+ border-radius: 4px;
+ cursor: pointer;
+
+ .icon {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ .text {
+ padding: 0px 4px;
+ font-size: var(--affine-font-xs);
+ font-style: normal;
+ font-weight: 500;
+ line-height: 20px; /* 166.667% */
+ }
+ }
+
+ .button.edit {
+ color: ${unsafeCSSVarV2('text/secondary')};
+ }
+
+ .button.retry {
+ color: ${unsafeCSSVarV2('text/emphasis')};
+ }
+ }
+ }
+
+ .error-banner {
+ width: 204px;
+ height: 102px;
+ }
+ }
+ `;
+
+ private _editAbortController: AbortController | null = null;
+ private readonly _toggleEdit = (e: MouseEvent) => {
+ e.stopPropagation();
+ if (!this._editButton) {
+ return;
+ }
+
+ if (this._editAbortController) {
+ this._editAbortController.abort();
+ }
+
+ this._editAbortController = new AbortController();
+
+ createLitPortal({
+ template: html``,
+ portalStyles: {
+ zIndex: 'var(--affine-z-index-popover)',
+ },
+ container: this.host,
+ computePosition: {
+ referenceElement: this._editButton,
+ placement: 'bottom-start',
+ middleware: [flip(), offset(LINK_EDIT_POPUP_OFFSET)],
+ autoUpdate: { animationFrame: true },
+ },
+ abortController: this._editAbortController,
+ closeOnClickAway: true,
+ });
+ };
+
+ override connectedCallback() {
+ super.connectedCallback();
+ this.disposables.addFromEvent(this, 'click', stopPropagation);
+ }
+
+ override render() {
+ return html`
+
+
+
+
+ ${InformationIcon({ width: '16px', height: '16px' })}
+
+
This link couldn’t be loaded.
+
+
+ ${this.error?.message || 'Failed to load embedded content'}
+
+
+
+ ${EditIcon({ width: '16px', height: '16px' })}
+ Edit
+
+
+ ${ResetIcon({ width: '16px', height: '16px' })}
+ Reload
+
+
+
+
+
+ `;
+ }
+
+ get host() {
+ return this.std.host;
+ }
+
+ @query('.button.edit')
+ accessor _editButton: HTMLElement | null = null;
+
+ @property({ attribute: false })
+ accessor error: Error | null = null;
+
+ @property({ attribute: false })
+ accessor onRetry!: () => void;
+
+ @property({ attribute: false })
+ accessor model!: EmbedIframeBlockModel;
+
+ @property({ attribute: false })
+ accessor std!: BlockStdScope;
+}
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/components/embed-iframe-link-edit-popup.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/components/embed-iframe-link-edit-popup.ts
new file mode 100644
index 0000000000..1f3e3e3ada
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/components/embed-iframe-link-edit-popup.ts
@@ -0,0 +1,206 @@
+import { type EmbedIframeBlockModel } from '@blocksuite/affine-model';
+import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
+import { isValidUrl, stopPropagation } from '@blocksuite/affine-shared/utils';
+import { BlockSelection, type BlockStdScope } from '@blocksuite/block-std';
+import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
+import { DoneIcon } from '@blocksuite/icons/lit';
+import { css, html, LitElement } from 'lit';
+import { property, query, state } from 'lit/decorators.js';
+
+import { EmbedIframeService } from '../extension/embed-iframe-service';
+
+export class EmbedIframeLinkEditPopup extends SignalWatcher(
+ WithDisposable(LitElement)
+) {
+ static override styles = css`
+ .embed-iframe-link-edit-popup {
+ display: flex;
+ padding: 12px;
+ align-items: center;
+ gap: 12px;
+ color: var(--affine-text-primary-color);
+ border-radius: 8px;
+ border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
+ background: ${unsafeCSSVarV2('layer/background/primary')};
+ box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
+
+ .input-container {
+ display: flex;
+ width: 280px;
+ align-items: center;
+ border: 1px solid var(--affine-border-color);
+ border-radius: 4px;
+ padding: 0 8px;
+ background-color: var(--affine-background-color);
+ gap: 8px;
+
+ .input-label {
+ color: var(--affine-text-secondary-color);
+ font-size: 14px;
+ margin-right: 8px;
+ white-space: nowrap;
+ }
+
+ .link-input {
+ flex: 1;
+ border: none;
+ outline: none;
+ padding: 8px 0;
+ background: transparent;
+ font-size: 14px;
+ }
+ }
+
+ .input-container:focus-within {
+ border-color: var(--affine-blue-700);
+ outline: none;
+ }
+
+ .confirm-button {
+ cursor: pointer;
+ display: flex;
+ padding: 2px;
+ justify-content: center;
+ align-items: center;
+ color: ${unsafeCSSVarV2('icon/activated')};
+ }
+
+ .confirm-button[disabled] {
+ color: ${unsafeCSSVarV2('icon/primary')};
+ }
+ }
+ `;
+
+ /**
+ * Try to add a bookmark model and remove the current embed iframe model
+ * @param url The url to add as a bookmark
+ */
+ private readonly _tryToAddBookmark = (url: string) => {
+ if (!isValidUrl(url)) {
+ // notify user that the url is invalid
+ console.warn('can not add bookmark', url);
+ return;
+ }
+
+ const { model } = this;
+ const { parent } = model;
+ const index = parent?.children.indexOf(model);
+ const flavour = 'affine:bookmark';
+
+ this.store.transact(() => {
+ const blockId = this.store.addBlock(flavour, { url }, parent, index);
+
+ this.store.deleteBlock(model);
+ this.std.selection.setGroup('note', [
+ this.std.selection.create(BlockSelection, { blockId }),
+ ]);
+ });
+
+ this.abortController.abort();
+ };
+
+ private readonly _onConfirm = () => {
+ if (this._isInputEmpty()) {
+ return;
+ }
+
+ const canEmbed = this.EmbedIframeService.canEmbed(this._linkInputValue);
+ // If the url is not embeddable, try to add it as a bookmark
+ if (!canEmbed) {
+ console.warn('can not embed', this._linkInputValue);
+ this._tryToAddBookmark(this._linkInputValue);
+ return;
+ }
+
+ // Update the embed iframe model
+ this.store.updateBlock(this.model, {
+ url: this._linkInputValue,
+ iframeUrl: '',
+ title: '',
+ description: '',
+ });
+
+ this.abortController.abort();
+ };
+
+ private readonly _handleInput = (e: InputEvent) => {
+ const target = e.target as HTMLInputElement;
+ this._linkInputValue = target.value;
+ };
+
+ private readonly _isInputEmpty = () => {
+ return this._linkInputValue.trim() === '';
+ };
+
+ private readonly _handleKeyDown = (e: KeyboardEvent) => {
+ e.stopPropagation();
+ if (e.key === 'Enter' && !e.isComposing) {
+ this._onConfirm();
+ }
+ };
+
+ override connectedCallback() {
+ super.connectedCallback();
+ this.updateComplete
+ .then(() => {
+ requestAnimationFrame(() => {
+ this.input.focus();
+ });
+ })
+ .catch(console.error);
+ this.disposables.addFromEvent(this, 'cut', stopPropagation);
+ this.disposables.addFromEvent(this, 'copy', stopPropagation);
+ this.disposables.addFromEvent(this, 'paste', stopPropagation);
+ }
+
+ override render() {
+ const isInputEmpty = this._isInputEmpty();
+ const { url$ } = this.model;
+
+ return html`
+
+ `;
+ }
+
+ get store() {
+ return this.model.doc;
+ }
+
+ get EmbedIframeService() {
+ return this.store.get(EmbedIframeService);
+ }
+
+ @state()
+ private accessor _linkInputValue = '';
+
+ @query('input')
+ accessor input!: HTMLInputElement;
+
+ @property({ attribute: false })
+ accessor model!: EmbedIframeBlockModel;
+
+ @property({ attribute: false })
+ accessor abortController!: AbortController;
+
+ @property({ attribute: false })
+ accessor std!: BlockStdScope;
+}
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/components/embed-iframe-loading-card.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/components/embed-iframe-loading-card.ts
new file mode 100644
index 0000000000..17ae685813
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/components/embed-iframe-loading-card.ts
@@ -0,0 +1,112 @@
+import { ThemeProvider } from '@blocksuite/affine-shared/services';
+import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
+import type { BlockStdScope } from '@blocksuite/block-std';
+import { EmbedIcon } from '@blocksuite/icons/lit';
+import { css, html, LitElement } from 'lit';
+import { property } from 'lit/decorators.js';
+
+import { getEmbedCardIcons } from '../../common/utils';
+
+export class EmbedIframeLoadingCard extends LitElement {
+ static override styles = css`
+ :host {
+ width: 100%;
+ }
+
+ .affine-embed-iframe-loading-card {
+ display: flex;
+ box-sizing: border-box;
+ width: 100%;
+ border-radius: 8px;
+ user-select: none;
+ height: 114px;
+ padding: 12px;
+ align-items: flex-start;
+ gap: 12px;
+ overflow: hidden;
+ border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
+ background: ${unsafeCSSVarV2('layer/white')};
+
+ .loading-content {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ flex: 1 0 0;
+ align-self: stretch;
+
+ .loading-spinner {
+ display: flex;
+ width: 24px;
+ height: 24px;
+ justify-content: center;
+ align-items: center;
+ }
+
+ .loading-text {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 1;
+ flex: 1 0 0;
+ overflow: hidden;
+ color: ${unsafeCSSVarV2('text/primary')};
+ text-overflow: ellipsis;
+ /* Client/smMedium */
+ font-family: Inter;
+ font-size: var(--affine-font-sm);
+ font-style: normal;
+ font-weight: 500;
+ line-height: 22px; /* 157.143% */
+ }
+ }
+
+ .loading-banner {
+ display: flex;
+ width: 204px;
+ box-sizing: border-box;
+ padding: 3.139px 42.14px 0px 42.14px;
+ justify-content: center;
+ align-items: center;
+ flex-shrink: 0;
+
+ .icon-box {
+ display: flex;
+ width: 106px;
+ height: 106px;
+ transform: rotate(8deg);
+ justify-content: center;
+ align-items: center;
+ flex-shrink: 0;
+ border-radius: 4px 4px 0px 0px;
+ background: ${unsafeCSSVarV2('slashMenu/background')};
+ box-shadow: 0px 0px 5px 0px rgba(66, 65, 73, 0.17);
+
+ svg {
+ fill: black;
+ fill-opacity: 0.07;
+ }
+ }
+ }
+ }
+ `;
+
+ override render() {
+ const theme = this.std.get(ThemeProvider).theme;
+ const { LoadingIcon } = getEmbedCardIcons(theme);
+ return html`
+
+
+
${LoadingIcon}
+
Loading...
+
+
+
+ ${EmbedIcon({ width: '66px', height: '66px' })}
+
+
+
+ `;
+ }
+
+ @property({ attribute: false })
+ accessor std!: BlockStdScope;
+}
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/index.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/index.ts
new file mode 100644
index 0000000000..95c1691d05
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/index.ts
@@ -0,0 +1,2 @@
+export * from './providers';
+export * from './toolbar';
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/providers/google-drive.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/providers/google-drive.ts
new file mode 100644
index 0000000000..96d0f89ad5
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/providers/google-drive.ts
@@ -0,0 +1,192 @@
+import { EmbedIframeConfigExtension } from '../../extension/embed-iframe-config';
+import {
+ type EmbedIframeUrlValidationOptions,
+ validateEmbedIframeUrl,
+} from '../../utils';
+
+const GOOGLE_DRIVE_DEFAULT_WIDTH = '100%';
+const GOOGLE_DRIVE_DEFAULT_HEIGHT = '480px';
+const GOOGLE_DRIVE_EMBED_FOLDER_URL =
+ 'https://drive.google.com/embeddedfolderview';
+const GOOGLE_DRIVE_EMBED_FILE_URL = 'https://drive.google.com/file/d/';
+
+const googleDriveUrlValidationOptions: EmbedIframeUrlValidationOptions = {
+ protocols: ['https:'],
+ hostnames: ['drive.google.com'],
+};
+
+/**
+ * Checks if the URL has a valid sharing parameter
+ * @param parsedUrl Parsed URL object
+ * @returns Boolean indicating if the URL has a valid sharing parameter
+ */
+function hasValidSharingParam(parsedUrl: URL): boolean {
+ const usp = parsedUrl.searchParams.get('usp');
+ return usp === 'sharing';
+}
+
+/**
+ * Check if the url is a valid google drive file url
+ * @param parsedUrl Parsed URL object
+ * @returns Boolean indicating if the URL is a valid Google Drive file URL
+ */
+function isValidGoogleDriveFileUrl(parsedUrl: URL): boolean {
+ const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
+ return (
+ pathSegments[0] === 'file' &&
+ pathSegments[1] === 'd' &&
+ pathSegments.length >= 3 &&
+ !!pathSegments[2]
+ );
+}
+
+/**
+ * Check if the url is a valid google drive folder url
+ * @param parsedUrl Parsed URL object
+ * @returns Boolean indicating if the URL is a valid Google Drive folder URL
+ */
+function isValidGoogleDriveFolderUrl(parsedUrl: URL): boolean {
+ const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
+ return (
+ pathSegments[0] === 'drive' &&
+ pathSegments[1] === 'folders' &&
+ pathSegments.length >= 3 &&
+ !!pathSegments[2]
+ );
+}
+/**
+ * Validates if a URL is a valid Google Drive path URL
+ * @param parsedUrl Parsed URL object
+ * @returns Boolean indicating if the URL is valid
+ */
+function isValidGoogleDrivePathUrl(parsedUrl: URL): boolean {
+ const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
+
+ // Should have at least 2 segments
+ if (pathSegments.length < 2) {
+ return false;
+ }
+
+ // Check for file pattern: /file/d/file-id/view
+ if (isValidGoogleDriveFileUrl(parsedUrl)) {
+ return true;
+ }
+
+ // Check for folder pattern: /drive/folders/folder-id
+ if (isValidGoogleDriveFolderUrl(parsedUrl)) {
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Safely validates if a URL is a valid Google Drive URL
+ * https://drive.google.com/file/d/your-file-id/view?usp=sharing
+ * https://drive.google.com/drive/folders/your-folder-id?usp=sharing
+ * @param url The URL to validate
+ * @param strictMode Whether to strictly validate sharing parameters
+ * @returns Boolean indicating if the URL is a valid Google Drive URL
+ */
+function isValidGoogleDriveUrl(url: string, strictMode = true): boolean {
+ try {
+ if (!validateEmbedIframeUrl(url, googleDriveUrlValidationOptions)) {
+ return false;
+ }
+
+ const parsedUrl = new URL(url);
+
+ // Check sharing parameter if in strict mode
+ if (strictMode && !hasValidSharingParam(parsedUrl)) {
+ return false;
+ }
+
+ // Check hostname and path structure
+ return isValidGoogleDrivePathUrl(parsedUrl);
+ } catch (e) {
+ // URL parsing failed
+ console.warn('Invalid Google Drive URL:', e);
+ return false;
+ }
+}
+
+/**
+ * Build embed URL for Google Drive files
+ * @param fileId File ID
+ * @returns Embed URL
+ */
+function buildGoogleDriveFileEmbedUrl(fileId: string): string | undefined {
+ const embedUrl = new URL(
+ 'preview',
+ `${GOOGLE_DRIVE_EMBED_FILE_URL}${fileId}/`
+ );
+ embedUrl.searchParams.set('usp', 'embed_googleplus');
+ return embedUrl.toString();
+}
+
+/**
+ * Build embed URL for Google Drive folders
+ * @param folderId Folder ID
+ * @returns Embed URL
+ */
+function buildGoogleDriveFolderEmbedUrl(folderId: string): string | undefined {
+ const embedUrl = new URL(GOOGLE_DRIVE_EMBED_FOLDER_URL);
+ embedUrl.searchParams.set('id', folderId);
+ embedUrl.hash = 'list';
+ return embedUrl.toString();
+}
+
+/**
+ * Build embed URL for Google Drive paths
+ * @param url The URL to embed
+ * @returns The embed URL
+ */
+function buildGoogleDriveEmbedUrl(url: string): string | undefined {
+ try {
+ const parsedUrl = new URL(url);
+ const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
+
+ // Should have at least 2 segments
+ if (pathSegments.length < 2) {
+ return undefined;
+ }
+
+ // Handle file URL: /file/d/file-id/view
+ if (isValidGoogleDriveFileUrl(parsedUrl)) {
+ return buildGoogleDriveFileEmbedUrl(pathSegments[2]);
+ }
+
+ // Handle folder URL: /drive/folders/folder-id
+ if (isValidGoogleDriveFolderUrl(parsedUrl)) {
+ return buildGoogleDriveFolderEmbedUrl(pathSegments[2]);
+ }
+
+ return undefined;
+ } catch (e) {
+ console.warn('Failed to parse Google Drive path URL:', e);
+ return undefined;
+ }
+}
+
+const googleDriveConfig = {
+ name: 'google-drive',
+ match: (url: string) => isValidGoogleDriveUrl(url),
+ buildOEmbedUrl: (url: string) => {
+ if (!isValidGoogleDriveUrl(url)) {
+ return undefined;
+ }
+
+ // If is a valid google drive url, build the embed url
+ return buildGoogleDriveEmbedUrl(url);
+ },
+ useOEmbedUrlDirectly: true,
+ options: {
+ defaultWidth: GOOGLE_DRIVE_DEFAULT_WIDTH,
+ defaultHeight: GOOGLE_DRIVE_DEFAULT_HEIGHT,
+ allowFullscreen: true,
+ style: 'border: none; border-radius: 8px;',
+ },
+};
+
+export const GoogleDriveEmbedConfig =
+ EmbedIframeConfigExtension(googleDriveConfig);
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/providers/index.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/providers/index.ts
new file mode 100644
index 0000000000..68e96acf8f
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/providers/index.ts
@@ -0,0 +1,7 @@
+import { GoogleDriveEmbedConfig } from './google-drive';
+import { SpotifyEmbedConfig } from './spotify';
+
+export const EmbedIframeConfigExtensions = [
+ SpotifyEmbedConfig,
+ GoogleDriveEmbedConfig,
+];
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/providers/spotify.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/providers/spotify.ts
new file mode 100644
index 0000000000..72556d78a1
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/providers/spotify.ts
@@ -0,0 +1,42 @@
+import { EmbedIframeConfigExtension } from '../../extension/embed-iframe-config';
+import {
+ type EmbedIframeUrlValidationOptions,
+ validateEmbedIframeUrl,
+} from '../../utils';
+
+const SPOTIFY_DEFAULT_WIDTH = '100%';
+const SPOTIFY_DEFAULT_HEIGHT = '152px';
+
+// https://developer.spotify.com/documentation/embeds/reference/oembed
+const spotifyEndpoint = 'https://open.spotify.com/oembed';
+
+const spotifyUrlValidationOptions: EmbedIframeUrlValidationOptions = {
+ protocols: ['https:'],
+ hostnames: ['open.spotify.com', 'spotify.link'],
+};
+
+const spotifyConfig = {
+ name: 'spotify',
+ match: (url: string) =>
+ validateEmbedIframeUrl(url, spotifyUrlValidationOptions),
+ buildOEmbedUrl: (url: string) => {
+ const match = validateEmbedIframeUrl(url, spotifyUrlValidationOptions);
+ if (!match) {
+ return undefined;
+ }
+ const encodedUrl = encodeURIComponent(url);
+ const oEmbedUrl = `${spotifyEndpoint}?url=${encodedUrl}`;
+ return oEmbedUrl;
+ },
+ useOEmbedUrlDirectly: false,
+ options: {
+ defaultWidth: SPOTIFY_DEFAULT_WIDTH,
+ defaultHeight: SPOTIFY_DEFAULT_HEIGHT,
+ allow:
+ 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture',
+ style: 'border-radius: 8px;',
+ allowFullscreen: true,
+ },
+};
+
+export const SpotifyEmbedConfig = EmbedIframeConfigExtension(spotifyConfig);
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/slash-menu/slash-menu.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/slash-menu/slash-menu.ts
new file mode 100644
index 0000000000..4a6d67e1f7
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/slash-menu/slash-menu.ts
@@ -0,0 +1,43 @@
+import { FeatureFlagService } from '@blocksuite/affine-shared/services';
+import type { SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
+import { EmbedIcon } from '@blocksuite/icons/lit';
+
+import { toggleEmbedIframeCreateModal } from '../../components/embed-iframe-create-modal';
+import { EmbedIframeTooltip } from './tooltip';
+
+export const embedIframeSlashMenuConfig: SlashMenuConfig = {
+ items: [
+ {
+ name: 'Embed',
+ description: 'For PDFs, and more.',
+ icon: EmbedIcon(),
+ tooltip: {
+ figure: EmbedIframeTooltip,
+ caption: 'Embed',
+ },
+ group: '4_Content & Media@10',
+ when: ({ model, std }) => {
+ const featureFlagService = std.get(FeatureFlagService);
+ return (
+ featureFlagService.getFlag('enable_embed_iframe_block') &&
+ model.doc.schema.flavourSchemaMap.has('affine:embed-iframe')
+ );
+ },
+ action: ({ std, model }) => {
+ (async () => {
+ const { host } = std;
+ const parentModel = host.doc.getParent(model);
+ if (!parentModel) {
+ return;
+ }
+ const index = parentModel.children.indexOf(model) + 1;
+ await toggleEmbedIframeCreateModal(std, {
+ parentModel,
+ index,
+ });
+ if (model.text?.length === 0) std.store.deleteBlock(model);
+ })().catch(console.error);
+ },
+ },
+ ],
+};
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/slash-menu/tooltip.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/slash-menu/tooltip.ts
new file mode 100644
index 0000000000..589ba36739
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/slash-menu/tooltip.ts
@@ -0,0 +1,74 @@
+import { html } from 'lit';
+
+export const EmbedIframeTooltip = html`
+
+`;
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/toolbar.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/toolbar.ts
new file mode 100644
index 0000000000..5f0b4ba5f5
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/toolbar.ts
@@ -0,0 +1,235 @@
+import { toast } from '@blocksuite/affine-components/toast';
+import {
+ BookmarkStyles,
+ EmbedIframeBlockModel,
+} from '@blocksuite/affine-model';
+import {
+ ActionPlacement,
+ type ToolbarAction,
+ type ToolbarActionGroup,
+ type ToolbarModuleConfig,
+} from '@blocksuite/affine-shared/services';
+import { getBlockProps } from '@blocksuite/affine-shared/utils';
+import { BlockSelection } from '@blocksuite/block-std';
+import {
+ CaptionIcon,
+ CopyIcon,
+ DeleteIcon,
+ DuplicateIcon,
+ ResetIcon,
+} from '@blocksuite/icons/lit';
+import { Slice, Text } from '@blocksuite/store';
+import { signal } from '@preact/signals-core';
+import { html } from 'lit';
+import { keyed } from 'lit/directives/keyed.js';
+import * as Y from 'yjs';
+
+import { EmbedIframeBlockComponent } from '../embed-iframe-block';
+
+const trackBaseProps = {
+ segment: 'doc',
+ page: 'doc editor',
+ module: 'toolbar',
+ category: 'bookmark',
+ type: 'card view',
+};
+
+export const builtinToolbarConfig = {
+ actions: [
+ {
+ id: 'b.conversions',
+ actions: [
+ {
+ id: 'inline',
+ label: 'Inline view',
+ run(ctx) {
+ const model = ctx.getCurrentModelByType(
+ BlockSelection,
+ EmbedIframeBlockModel
+ );
+ if (!model) return;
+
+ const { title, caption, url, parent } = model;
+ const index = parent?.children.indexOf(model);
+
+ const yText = new Y.Text();
+ const insert = title || caption || url;
+ yText.insert(0, insert);
+ yText.format(0, insert.length, { link: url });
+
+ const text = new Text(yText);
+
+ ctx.store.addBlock('affine:paragraph', { text }, parent, index);
+
+ ctx.store.deleteBlock(model);
+
+ // Clears
+ ctx.reset();
+ ctx.select('note');
+
+ ctx.track('SelectedView', {
+ ...trackBaseProps,
+ control: 'select view',
+ type: 'inline view',
+ });
+ },
+ },
+ {
+ id: 'card',
+ label: 'Card view',
+ run(ctx) {
+ const model = ctx.getCurrentModelByType(
+ BlockSelection,
+ EmbedIframeBlockModel
+ );
+ if (!model) return;
+
+ const { url, caption, parent } = model;
+ const index = parent?.children.indexOf(model);
+
+ const flavour = 'affine:bookmark';
+ const style =
+ BookmarkStyles.find(s => s !== 'vertical' && s !== 'cube') ??
+ BookmarkStyles[1];
+
+ const blockId = ctx.store.addBlock(
+ flavour,
+ { url, caption, style },
+ parent,
+ index
+ );
+
+ ctx.store.deleteBlock(model);
+
+ // Selects new block
+ ctx.select('note', [
+ ctx.selection.create(BlockSelection, { blockId }),
+ ]);
+
+ ctx.track('SelectedView', {
+ ...trackBaseProps,
+ control: 'select view',
+ type: 'card view',
+ });
+ },
+ },
+ {
+ id: 'embed',
+ label: 'Embed view',
+ disabled: true,
+ },
+ ],
+ content(ctx) {
+ const model = ctx.getCurrentModelByType(
+ BlockSelection,
+ EmbedIframeBlockModel
+ );
+ if (!model) return null;
+
+ const actions = this.actions.map(action => ({ ...action }));
+ const toggle = (e: CustomEvent) => {
+ const opened = e.detail;
+ if (!opened) return;
+
+ ctx.track('OpenedViewSelector', {
+ ...trackBaseProps,
+ control: 'switch view',
+ });
+ };
+
+ return html`${keyed(
+ model,
+ html``
+ )}`;
+ },
+ } satisfies ToolbarActionGroup,
+ {
+ id: 'c.caption',
+ tooltip: 'Caption',
+ icon: CaptionIcon(),
+ run(ctx) {
+ const component = ctx.getCurrentBlockComponentBy(
+ BlockSelection,
+ EmbedIframeBlockComponent
+ );
+ component?.captionEditor?.show();
+
+ ctx.track('OpenedCaptionEditor', {
+ ...trackBaseProps,
+ control: 'add caption',
+ });
+ },
+ },
+ {
+ placement: ActionPlacement.More,
+ id: 'a.clipboard',
+ actions: [
+ {
+ id: 'copy',
+ label: 'Copy',
+ icon: CopyIcon(),
+ run(ctx) {
+ const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
+ if (!model) return;
+
+ const slice = Slice.fromModels(ctx.store, [model]);
+ ctx.clipboard
+ .copySlice(slice)
+ .then(() => toast(ctx.host, 'Copied to clipboard'))
+ .catch(console.error);
+ },
+ },
+ {
+ id: 'duplicate',
+ label: 'Duplicate',
+ icon: DuplicateIcon(),
+ run(ctx) {
+ const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
+ if (!model) return;
+
+ const { flavour, parent } = model;
+ const props = getBlockProps(model);
+ const index = parent?.children.indexOf(model);
+
+ ctx.store.addBlock(flavour, props, parent, index);
+ },
+ },
+ ],
+ },
+ {
+ placement: ActionPlacement.More,
+ id: 'b.reload',
+ label: 'Reload',
+ icon: ResetIcon(),
+ run(ctx) {
+ const component = ctx.getCurrentBlockComponentBy(
+ BlockSelection,
+ EmbedIframeBlockComponent
+ );
+ component?.refreshData().catch(console.error);
+ },
+ },
+ {
+ placement: ActionPlacement.More,
+ id: 'c.delete',
+ label: 'Delete',
+ icon: DeleteIcon(),
+ variant: 'destructive',
+ run(ctx) {
+ const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
+ if (!model) return;
+
+ ctx.store.deleteBlock(model);
+
+ // Clears
+ ctx.select('note');
+ ctx.reset();
+ },
+ },
+ ],
+} as const satisfies ToolbarModuleConfig;
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/embed-iframe-block.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/embed-iframe-block.ts
new file mode 100644
index 0000000000..771f329740
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/embed-iframe-block.ts
@@ -0,0 +1,257 @@
+import {
+ CaptionedBlockComponent,
+ SelectedStyle,
+} from '@blocksuite/affine-components/caption';
+import type { EmbedIframeBlockModel } from '@blocksuite/affine-model';
+import {
+ FeatureFlagService,
+ LinkPreviewerService,
+} from '@blocksuite/affine-shared/services';
+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';
+import { embedIframeBlockStyles } from './style.js';
+
+export type EmbedIframeStatus = 'idle' | 'loading' | 'success' | 'error';
+const DEFAULT_IFRAME_HEIGHT = 152;
+
+export class EmbedIframeBlockComponent extends CaptionedBlockComponent {
+ selectedStyle$: ReadonlySignal | null = computed(
+ () => ({
+ 'selected-style': this.selected$.value,
+ })
+ );
+
+ blockDraggable = true;
+
+ static override styles = embedIframeBlockStyles;
+
+ readonly status$ = signal('idle');
+ readonly error$ = signal(null);
+
+ readonly isLoading$ = computed(() => this.status$.value === 'loading');
+ readonly hasError$ = computed(() => this.status$.value === 'error');
+ readonly isSuccess$ = computed(() => this.status$.value === 'success');
+
+ private _iframeOptions: IframeOptions | undefined = undefined;
+
+ get embedIframeService() {
+ return this.std.get(EmbedIframeService);
+ }
+
+ get linkPreviewService() {
+ return this.std.get(LinkPreviewerService);
+ }
+
+ open = () => {
+ const link = this.model.url;
+ window.open(link, '_blank');
+ };
+
+ refreshData = async () => {
+ try {
+ // set loading status
+ this.status$.value = 'loading';
+ this.error$.value = null;
+
+ // get embed data
+ const embedIframeService = this.embedIframeService;
+ const linkPreviewService = this.linkPreviewService;
+ if (!embedIframeService || !linkPreviewService) {
+ throw new BlockSuiteError(
+ ErrorCode.ValueNotExists,
+ 'EmbedIframeService or LinkPreviewerService not found'
+ );
+ }
+
+ const { url } = this.model;
+ if (!url) {
+ throw new BlockSuiteError(
+ ErrorCode.ValueNotExists,
+ 'No original URL provided'
+ );
+ }
+
+ // get embed data and preview data in a promise
+ const [embedData, previewData] = await Promise.all([
+ embedIframeService.getEmbedIframeData(url),
+ linkPreviewService.query(url),
+ ]);
+
+ // if the embed data is not found, and the iframeUrl is not set, throw an error
+ const currentIframeUrl = this.model.iframeUrl;
+ if (!embedData && !currentIframeUrl) {
+ throw new BlockSuiteError(
+ ErrorCode.ValueNotExists,
+ 'Failed to get embed data'
+ );
+ }
+
+ // update model
+ this.doc.updateBlock(this.model, {
+ iframeUrl: embedData?.iframe_url,
+ title: embedData?.title || previewData?.title,
+ description: embedData?.description || previewData?.description,
+ });
+
+ // update iframe options, to ensure the iframe is rendered with the correct options
+ this._updateIframeOptions(url);
+
+ // set success status
+ this.status$.value = 'success';
+ } catch (err) {
+ // set error status
+ this.status$.value = 'error';
+ this.error$.value = err instanceof Error ? err : new Error(String(err));
+ console.error('Failed to refresh iframe data:', err);
+ }
+ };
+
+ private readonly _updateIframeOptions = (url: string) => {
+ const config = this.embedIframeService?.getConfig(url);
+ if (config) {
+ this._iframeOptions = config.options;
+ }
+ };
+
+ private readonly _handleDoubleClick = (event: MouseEvent) => {
+ event.stopPropagation();
+ this.open();
+ };
+
+ private readonly _selectBlock = () => {
+ const selectionManager = this.host.selection;
+ const blockSelection = selectionManager.create(BlockSelection, {
+ blockId: this.blockId,
+ });
+ selectionManager.setGroup('note', [blockSelection]);
+ };
+
+ protected readonly _handleClick = (event: MouseEvent) => {
+ event.stopPropagation();
+ this._selectBlock();
+ };
+
+ private readonly _handleRetry = async () => {
+ 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,
+ style,
+ allow,
+ referrerpolicy,
+ scrolling,
+ allowFullscreen,
+ } = this._iframeOptions ?? {};
+ return html`
+
+ `;
+ };
+
+ private readonly _renderContent = () => {
+ if (this.isLoading$.value) {
+ return html``;
+ }
+
+ if (this.hasError$.value) {
+ return html``;
+ }
+
+ return this._renderIframe();
+ };
+
+ override connectedCallback() {
+ super.connectedCallback();
+
+ this.contentEditable = 'false';
+
+ if (!this.model.iframeUrl) {
+ this.doc.withoutTransact(() => {
+ this.refreshData().catch(console.error);
+ });
+ } else {
+ // update iframe options, to ensure the iframe is rendered with the correct options
+ this._updateIframeOptions(this.model.url);
+ this.status$.value = 'success';
+ }
+
+ // refresh data when original url changes
+ this.disposables.add(
+ this.model.propsUpdated.subscribe(({ key }) => {
+ if (key === 'url') {
+ this.refreshData().catch(console.error);
+ }
+ })
+ );
+ }
+
+ override renderBlock() {
+ if (!this._embedIframeBlockEnabled$.value) {
+ return nothing;
+ }
+
+ const classes = classMap({
+ 'affine-embed-iframe-block': true,
+ ...this.selectedStyle$?.value,
+ });
+
+ const style = styleMap({
+ width: '100%',
+ });
+
+ return html`
+
+ ${this._renderContent()}
+
+ `;
+ }
+
+ override accessor blockContainerStyles = { margin: '18px 0' };
+
+ override accessor useCaptionEditor = true;
+
+ override accessor useZeroWidth = true;
+
+ override accessor selectedStyle = SelectedStyle.Border;
+}
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/embed-iframe-spec.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/embed-iframe-spec.ts
new file mode 100644
index 0000000000..5ca2d7967e
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/embed-iframe-spec.ts
@@ -0,0 +1,31 @@
+import { EmbedIframeBlockSchema } from '@blocksuite/affine-model';
+import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
+import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
+import {
+ BlockFlavourIdentifier,
+ BlockViewExtension,
+ FlavourExtension,
+} from '@blocksuite/block-std';
+import type { ExtensionType } from '@blocksuite/store';
+import { literal } from 'lit/static-html.js';
+
+import { EmbedIframeBlockAdapterExtensions } from './adapters';
+import { embedIframeSlashMenuConfig } from './configs/slash-menu/slash-menu';
+import { builtinToolbarConfig } from './configs/toolbar';
+
+const flavour = EmbedIframeBlockSchema.model.flavour;
+
+export const EmbedIframeBlockSpec: ExtensionType[] = [
+ FlavourExtension(flavour),
+ BlockViewExtension(flavour, model => {
+ return model.parent?.flavour === 'affine:surface'
+ ? literal`affine-embed-edgeless-iframe-block`
+ : literal`affine-embed-iframe-block`;
+ }),
+ EmbedIframeBlockAdapterExtensions,
+ ToolbarModuleExtension({
+ id: BlockFlavourIdentifier(flavour),
+ config: builtinToolbarConfig,
+ }),
+ SlashMenuConfigExtension(flavour, embedIframeSlashMenuConfig),
+].flat();
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/extension/embed-iframe-config.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/extension/embed-iframe-config.ts
new file mode 100644
index 0000000000..b92bb2cf3e
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/extension/embed-iframe-config.ts
@@ -0,0 +1,94 @@
+import {
+ createIdentifier,
+ type ServiceIdentifier,
+} from '@blocksuite/global/di';
+import type { ExtensionType } from '@blocksuite/store';
+
+/**
+ * The options for the iframe
+ * @example
+ * {
+ * defaultWidth: '100%',
+ * defaultHeight: '152px',
+ * style: 'border-radius: 12px;',
+ * allow: 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture',
+ * }
+ *
+ */
+export type IframeOptions = {
+ defaultWidth?: string;
+ defaultHeight?: string;
+ style?: string;
+ referrerpolicy?: string;
+ scrolling?: boolean;
+ allow?: string;
+ allowFullscreen?: boolean;
+};
+
+/**
+ * Define the config of an embed iframe block
+ * @example
+ * {
+ * name: 'spotify',
+ * match: (url: string) => spotifyRegex.test(url),
+ * buildOEmbedUrl: (url: string) => {
+ * const match = url.match(spotifyRegex);
+ * if (!match) {
+ * return undefined;
+ * }
+ * const encodedUrl = encodeURIComponent(url);
+ * const oEmbedUrl = `${spotifyEndpoint}?url=${encodedUrl}`;
+ * return oEmbedUrl;
+ * },
+ * useOEmbedUrlDirectly: false,
+ * options: {
+ * defaultWidth: '100%',
+ * defaultHeight: '152px',
+ * allow: 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture'
+ * }
+ * }
+ */
+export type EmbedIframeConfig = {
+ /**
+ * The name of the embed iframe block
+ */
+ name: string;
+ /**
+ * The function to match the url
+ */
+ match: (url: string) => boolean;
+ /**
+ * The function to build the oEmbed URL for fetching embed data
+ */
+ buildOEmbedUrl: (url: string) => string | undefined;
+ /**
+ * Use oEmbed URL directly as iframe src without fetching oEmbed data
+ */
+ useOEmbedUrlDirectly: boolean;
+ /**
+ * The options for the iframe
+ */
+ options?: IframeOptions;
+};
+
+export const EmbedIframeConfigIdentifier =
+ createIdentifier('EmbedIframeConfig');
+
+export function EmbedIframeConfigExtension(
+ config: EmbedIframeConfig
+): ExtensionType & {
+ identifier: ServiceIdentifier;
+} {
+ const identifier = EmbedIframeConfigIdentifier(config.name);
+ return {
+ setup: di => {
+ di.addImpl(identifier, () => config);
+ },
+ identifier,
+ };
+}
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/extension/embed-iframe-service.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/extension/embed-iframe-service.ts
new file mode 100644
index 0000000000..4f5d454125
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/extension/embed-iframe-service.ts
@@ -0,0 +1,157 @@
+import type { EmbedIframeBlockProps } from '@blocksuite/affine-model';
+import { createIdentifier } from '@blocksuite/global/di';
+import { type Store, StoreExtension } from '@blocksuite/store';
+
+import {
+ type EmbedIframeConfig,
+ EmbedIframeConfigIdentifier,
+} from './embed-iframe-config';
+
+export type EmbedIframeData = {
+ html?: string;
+ iframe_url?: string;
+ width?: number | string;
+ height?: number | string;
+ title?: string;
+ description?: string;
+ provider_name?: string;
+ provider_url?: string;
+ version?: string;
+ thumbnail_url?: string;
+ thumbnail_width?: number;
+ thumbnail_height?: number;
+ type?: string;
+};
+
+/**
+ * Service for handling embeddable URLs
+ */
+export interface EmbedIframeProvider {
+ /**
+ * Check if a URL can be embedded
+ * @param url URL to check
+ * @returns true if the URL can be embedded, false otherwise
+ */
+ canEmbed: (url: string) => boolean;
+
+ /**
+ * Build a API URL for fetching embed data
+ * @param url URL to build API URL
+ * @returns API URL if the URL can be embedded, undefined otherwise
+ */
+ buildOEmbedUrl: (url: string) => string | undefined;
+
+ /**
+ * Get the embed iframe config
+ * @param url URL to get embed iframe config
+ * @returns Embed iframe config if the URL can be embedded, undefined otherwise
+ */
+ getConfig: (url: string) => EmbedIframeConfig | undefined;
+
+ /**
+ * Get embed iframe data
+ * @param url URL to get embed iframe data
+ * @returns Embed iframe data if the URL can be embedded, undefined otherwise
+ */
+ getEmbedIframeData: (url: string) => Promise;
+
+ /**
+ * Parse an embeddable URL and add an EmbedIframeBlock to doc
+ * @param url Original url to embed
+ * @param parentId Parent block ID
+ * @param index Optional index to insert at
+ * @returns Created block id if successful, undefined if the URL cannot be embedded
+ */
+ addEmbedIframeBlock: (
+ props: Partial,
+ parentId: string,
+ index?: number
+ ) => string | undefined;
+}
+
+export const EmbedIframeProvider = createIdentifier(
+ 'EmbedIframeProvider'
+);
+
+export class EmbedIframeService
+ extends StoreExtension
+ implements EmbedIframeProvider
+{
+ static override key = 'embed-iframe-service';
+
+ private readonly _configs: EmbedIframeConfig[];
+
+ constructor(store: Store) {
+ super(store);
+ this._configs = Array.from(
+ store.provider.getAll(EmbedIframeConfigIdentifier).values()
+ );
+ }
+
+ canEmbed = (url: string): boolean => {
+ return this._configs.some(config => config.match(url));
+ };
+
+ buildOEmbedUrl = (url: string): string | undefined => {
+ return this._configs.find(config => config.match(url))?.buildOEmbedUrl(url);
+ };
+
+ getConfig = (url: string): EmbedIframeConfig | undefined => {
+ return this._configs.find(config => config.match(url));
+ };
+
+ getEmbedIframeData = async (
+ url: string,
+ signal?: AbortSignal
+ ): Promise => {
+ try {
+ const config = this._configs.find(config => config.match(url));
+ if (!config) {
+ return null;
+ }
+
+ const oEmbedUrl = config.buildOEmbedUrl(url);
+ if (!oEmbedUrl) {
+ return null;
+ }
+
+ // if the config useOEmbedUrlDirectly is true, return the url directly as iframe_url
+ if (config.useOEmbedUrlDirectly) {
+ return {
+ iframe_url: oEmbedUrl,
+ };
+ }
+
+ // otherwise, fetch the oEmbed data
+ const response = await fetch(oEmbedUrl, { signal });
+ if (!response.ok) {
+ console.warn(
+ `Failed to fetch oEmbed data: ${response.status} ${response.statusText}`
+ );
+ return null;
+ }
+
+ const data = await response.json();
+ return data as EmbedIframeData;
+ } catch (error) {
+ if (error instanceof Error && error.name !== 'AbortError') {
+ console.error('Error fetching embed iframe data:', error);
+ }
+ return null;
+ }
+ };
+
+ addEmbedIframeBlock = (
+ props: Partial,
+ parentId: string,
+ index?: number
+ ): string | undefined => {
+ const blockId = this.store.addBlock(
+ 'affine:embed-iframe',
+ props,
+ parentId,
+ index
+ );
+ return blockId;
+ };
+}
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/extension/index.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/extension/index.ts
new file mode 100644
index 0000000000..62f2399af5
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/extension/index.ts
@@ -0,0 +1,2 @@
+export * from './embed-iframe-config';
+export * from './embed-iframe-service';
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/index.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/index.ts
new file mode 100644
index 0000000000..319964e78d
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/index.ts
@@ -0,0 +1,6 @@
+export * from './adapters';
+export * from './components/embed-iframe-create-modal';
+export * from './configs';
+export * from './embed-iframe-block';
+export * from './embed-iframe-spec';
+export * from './extension';
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/style.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/style.ts
new file mode 100644
index 0000000000..e9a4c0b67d
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/style.ts
@@ -0,0 +1,12 @@
+import { css } from 'lit';
+
+export const embedIframeBlockStyles = css`
+ .affine-embed-iframe-block {
+ display: flex;
+ width: 100%;
+ border-radius: 8px;
+ user-select: none;
+ align-items: center;
+ justify-content: center;
+ }
+`;
diff --git a/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/utils.ts b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/utils.ts
new file mode 100644
index 0000000000..9c0f246e8a
--- /dev/null
+++ b/blocksuite/affine/blocks/block-embed/src/embed-iframe-block/utils.ts
@@ -0,0 +1,31 @@
+/**
+ * The options for the embed iframe url validation
+ */
+export interface EmbedIframeUrlValidationOptions {
+ protocols: string[]; // Allowed protocols, e.g. ['https']
+ hostnames: string[]; // Allowed hostnames, e.g. ['docs.google.com']
+}
+
+/**
+ * Validate the url is allowed to embed in the iframe
+ * @param url URL to validate
+ * @param options Validation options
+ * @returns Whether the url is valid
+ */
+export function validateEmbedIframeUrl(
+ url: string,
+ options: EmbedIframeUrlValidationOptions
+): boolean {
+ try {
+ const parsedUrl = new URL(url);
+
+ const { protocols, hostnames } = options;
+ return (
+ protocols.includes(parsedUrl.protocol) &&
+ hostnames.includes(parsedUrl.hostname)
+ );
+ } catch (e) {
+ console.warn(`Invalid embed iframe url: ${url}`, e);
+ return false;
+ }
+}
diff --git a/blocksuite/affine/blocks/block-embed/src/index.ts b/blocksuite/affine/blocks/block-embed/src/index.ts
index 7e87f978cd..52074e5b8a 100644
--- a/blocksuite/affine/blocks/block-embed/src/index.ts
+++ b/blocksuite/affine/blocks/block-embed/src/index.ts
@@ -3,6 +3,7 @@ import type { ExtensionType } from '@blocksuite/store';
import { EmbedFigmaBlockSpec } from './embed-figma-block';
import { EmbedGithubBlockSpec } from './embed-github-block';
import { EmbedHtmlBlockSpec } from './embed-html-block';
+import { EmbedIframeBlockSpec } from './embed-iframe-block';
import { EmbedLinkedDocBlockSpec } from './embed-linked-doc-block';
import { EmbedLoomBlockSpec } from './embed-loom-block';
import { EmbedSyncedDocBlockSpec } from './embed-synced-doc-block';
@@ -19,6 +20,7 @@ export const EmbedExtensions: ExtensionType[] = [
EmbedHtmlBlockSpec,
EmbedLinkedDocBlockSpec,
EmbedSyncedDocBlockSpec,
+ EmbedIframeBlockSpec,
].flat();
export { createEmbedBlockHtmlAdapterMatcher } from './common/adapters/html';
@@ -32,6 +34,7 @@ export * from './common/utils';
export * from './embed-figma-block';
export * from './embed-github-block';
export * from './embed-html-block';
+export * from './embed-iframe-block';
export * from './embed-linked-doc-block';
export * from './embed-loom-block';
export * from './embed-synced-doc-block';
diff --git a/blocksuite/affine/model/src/blocks/embed/iframe/iframe-model.ts b/blocksuite/affine/model/src/blocks/embed/iframe/iframe-model.ts
new file mode 100644
index 0000000000..030001c318
--- /dev/null
+++ b/blocksuite/affine/model/src/blocks/embed/iframe/iframe-model.ts
@@ -0,0 +1,38 @@
+import {
+ type GfxCommonBlockProps,
+ GfxCompatible,
+ type GfxElementGeometry,
+} from '@blocksuite/block-std/gfx';
+import { BlockModel } from '@blocksuite/store';
+
+import { type EmbedCardStyle } from '../../../utils/index.js';
+
+export const EmbedIframeStyles: EmbedCardStyle[] = ['figma'] as const;
+
+export type EmbedIframeBlockProps = {
+ url: string; // the original url that user input
+ iframeUrl?: string; // the url that will be used to iframe src
+ width?: number;
+ height?: number;
+ caption: string | null;
+ title: string | null;
+ description: string | null;
+} & Omit;
+
+export const defaultEmbedIframeProps: EmbedIframeBlockProps = {
+ url: '',
+ iframeUrl: '',
+ width: undefined,
+ height: undefined,
+ caption: null,
+ title: null,
+ description: null,
+ xywh: '[0,0,0,0]',
+ index: 'a0',
+ lockedBySelf: false,
+ scale: 1,
+};
+
+export class EmbedIframeBlockModel
+ extends GfxCompatible(BlockModel)
+ implements GfxElementGeometry {}
diff --git a/blocksuite/affine/model/src/blocks/embed/iframe/iframe-schema.ts b/blocksuite/affine/model/src/blocks/embed/iframe/iframe-schema.ts
new file mode 100644
index 0000000000..33f43ae306
--- /dev/null
+++ b/blocksuite/affine/model/src/blocks/embed/iframe/iframe-schema.ts
@@ -0,0 +1,17 @@
+import { BlockSchemaExtension, defineBlockSchema } from '@blocksuite/store';
+
+import { defaultEmbedIframeProps, EmbedIframeBlockModel } from './iframe-model';
+
+export const EmbedIframeBlockSchema = defineBlockSchema({
+ flavour: 'affine:embed-iframe',
+ props: () => defaultEmbedIframeProps,
+ metadata: {
+ version: 1,
+ role: 'content',
+ },
+ toModel: () => new EmbedIframeBlockModel(),
+});
+
+export const EmbedIframeBlockSchemaExtension = BlockSchemaExtension(
+ EmbedIframeBlockSchema
+);
diff --git a/blocksuite/affine/model/src/blocks/embed/iframe/index.ts b/blocksuite/affine/model/src/blocks/embed/iframe/index.ts
new file mode 100644
index 0000000000..7dea0f00c1
--- /dev/null
+++ b/blocksuite/affine/model/src/blocks/embed/iframe/index.ts
@@ -0,0 +1,2 @@
+export * from './iframe-model';
+export * from './iframe-schema';
diff --git a/blocksuite/affine/model/src/blocks/embed/index.ts b/blocksuite/affine/model/src/blocks/embed/index.ts
index a47175311d..77e380dc69 100644
--- a/blocksuite/affine/model/src/blocks/embed/index.ts
+++ b/blocksuite/affine/model/src/blocks/embed/index.ts
@@ -1,6 +1,7 @@
export * from './figma/index';
export * from './github/index';
export * from './html/index';
+export * from './iframe/index';
export * from './linked-doc/index';
export * from './loom/index';
export * from './synced-doc/index';
diff --git a/blocksuite/affine/model/src/blocks/embed/types.ts b/blocksuite/affine/model/src/blocks/embed/types.ts
index 2d08cb98ef..446588d2db 100644
--- a/blocksuite/affine/model/src/blocks/embed/types.ts
+++ b/blocksuite/affine/model/src/blocks/embed/types.ts
@@ -3,6 +3,7 @@ import type { BlockModel } from '@blocksuite/store';
import { EmbedFigmaModel } from './figma';
import { EmbedGithubModel } from './github';
import type { EmbedHtmlModel } from './html';
+import type { EmbedIframeBlockModel } from './iframe/';
import { EmbedLinkedDocModel } from './linked-doc';
import { EmbedLoomModel } from './loom';
import { EmbedSyncedDocModel } from './synced-doc';
@@ -24,11 +25,13 @@ export type ExternalEmbedModel = (typeof ExternalEmbedModels)[number];
export type InternalEmbedModel = (typeof InternalEmbedModels)[number];
-export type LinkableEmbedModel = InstanceType<
+export type EmbedCardModel = InstanceType<
ExternalEmbedModel | InternalEmbedModel
>;
-export type BuiltInEmbedModel = LinkableEmbedModel | EmbedHtmlModel;
+export type LinkableEmbedModel = EmbedCardModel | EmbedIframeBlockModel;
+
+export type BuiltInEmbedModel = EmbedCardModel | EmbedHtmlModel;
export function isExternalEmbedModel(
model: BlockModel
diff --git a/blocksuite/affine/shared/src/services/feature-flag-service.ts b/blocksuite/affine/shared/src/services/feature-flag-service.ts
index 303646a508..10db4cb694 100644
--- a/blocksuite/affine/shared/src/services/feature-flag-service.ts
+++ b/blocksuite/affine/shared/src/services/feature-flag-service.ts
@@ -18,6 +18,7 @@ export interface BlockSuiteFlags {
enable_mobile_linked_doc_menu: boolean;
enable_block_meta: boolean;
enable_callout: boolean;
+ enable_embed_iframe_block: boolean;
}
export class FeatureFlagService extends StoreExtension {
@@ -40,6 +41,7 @@ export class FeatureFlagService extends StoreExtension {
enable_mobile_linked_doc_menu: false,
enable_block_meta: false,
enable_callout: false,
+ enable_embed_iframe_block: false,
});
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
diff --git a/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts b/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts
index b1fe176d70..8546147f44 100644
--- a/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts
+++ b/packages/frontend/core/src/blocksuite/extensions/editor-config/toolbar/index.ts
@@ -21,6 +21,7 @@ import { BookmarkBlockComponent } from '@blocksuite/affine/blocks/bookmark';
import {
EmbedFigmaBlockComponent,
EmbedGithubBlockComponent,
+ EmbedIframeBlockComponent,
EmbedLinkedDocBlockComponent,
EmbedLoomBlockComponent,
EmbedSyncedDocBlockComponent,
@@ -818,6 +819,86 @@ const inlineReferenceToolbarConfig = {
],
} as const satisfies ToolbarModuleConfig;
+const embedIframeToolbarConfig = {
+ actions: [
+ {
+ id: 'a.copy-link-and-edit',
+ actions: [
+ {
+ id: 'copy-link',
+ tooltip: 'Copy link',
+ icon: CopyIcon(),
+ run(ctx) {
+ const model = ctx.getCurrentBlockComponentBy(
+ BlockSelection,
+ EmbedIframeBlockComponent
+ )?.model;
+ if (!model) return;
+
+ const { url } = model;
+
+ navigator.clipboard.writeText(url).catch(console.error);
+ toast(ctx.host, 'Copied link to clipboard');
+
+ ctx.track('CopiedLink', {
+ segment: 'doc',
+ page: 'doc editor',
+ module: 'toolbar',
+ category: matchModels(model, [BookmarkBlockModel])
+ ? 'bookmark'
+ : 'link',
+ type: 'card view',
+ control: 'copy link',
+ });
+ },
+ },
+ {
+ id: 'edit',
+ tooltip: 'Edit',
+ icon: EditIcon(),
+ run(ctx) {
+ const component = ctx.getCurrentBlockComponentBy(
+ BlockSelection,
+ EmbedIframeBlockComponent
+ );
+ if (!component) return;
+
+ ctx.hide();
+
+ const model = component.model;
+ const abortController = new AbortController();
+ abortController.signal.onabort = () => ctx.show();
+
+ toggleEmbedCardEditModal(
+ ctx.host,
+ model,
+ 'card',
+ undefined,
+ undefined,
+ (_std, _component, props) => {
+ ctx.store.updateBlock(model, props);
+ component.requestUpdate();
+ },
+ abortController
+ );
+
+ ctx.track('OpenedAliasPopup', {
+ segment: 'doc',
+ page: 'doc editor',
+ module: 'toolbar',
+ category: matchModels(model, [BookmarkBlockModel])
+ ? 'bookmark'
+ : 'link',
+ type: 'card view',
+ control: 'edit',
+ });
+ },
+ },
+ ],
+ },
+ ],
+} as const satisfies ToolbarModuleConfig;
+
export const createCustomToolbarExtension = (
baseUrl: string
): ExtensionType[] => {
@@ -866,5 +947,10 @@ export const createCustomToolbarExtension = (
id: BlockFlavourIdentifier('custom:affine:reference'),
config: inlineReferenceToolbarConfig,
}),
+
+ ToolbarModuleExtension({
+ id: BlockFlavourIdentifier('custom:affine:embed-iframe'),
+ config: embedIframeToolbarConfig,
+ }),
];
};
diff --git a/packages/frontend/core/src/modules/feature-flag/constant.ts b/packages/frontend/core/src/modules/feature-flag/constant.ts
index aa2469ca31..6ca45fc6ff 100644
--- a/packages/frontend/core/src/modules/feature-flag/constant.ts
+++ b/packages/frontend/core/src/modules/feature-flag/constant.ts
@@ -123,6 +123,17 @@ export const AFFINE_FLAGS = {
configurable: isCanaryBuild,
defaultState: false,
},
+ enable_embed_iframe_block: {
+ category: 'blocksuite',
+ bsFlag: 'enable_embed_iframe_block',
+ displayName:
+ 'com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.name',
+ description:
+ 'com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.description',
+ configurable: isCanaryBuild,
+ defaultState: false,
+ },
+
enable_emoji_folder_icon: {
category: 'affine',
displayName:
diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts
index e4b1666efd..65a0c12367 100644
--- a/packages/frontend/i18n/src/i18n.gen.ts
+++ b/packages/frontend/i18n/src/i18n.gen.ts
@@ -5403,6 +5403,14 @@ export function useAFFiNEI18N(): {
* `Let your words stand out.`
*/
["com.affine.settings.workspace.experimental-features.enable-callout.description"](): string;
+ /**
+ * Embed Iframe Block
+ */
+ ["com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.name"](): string;
+ /**
+ * `Enables Embed Iframe Block.`
+ */
+ ["com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.description"](): string;
/**
* `Emoji Folder Icon`
*/
diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json
index e3055b2dcf..b423cb351a 100644
--- a/packages/frontend/i18n/src/resources/en.json
+++ b/packages/frontend/i18n/src/resources/en.json
@@ -1348,6 +1348,8 @@
"com.affine.settings.workspace.experimental-features.enable-block-meta.description": "Once enabled, all blocks will have created time, updated time, created by and updated by.",
"com.affine.settings.workspace.experimental-features.enable-callout.name": "Callout",
"com.affine.settings.workspace.experimental-features.enable-callout.description": "Let your words stand out.",
+ "com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.name": "Embed Iframe Block",
+ "com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.description": "Enables Embed Iframe Block.",
"com.affine.settings.workspace.experimental-features.enable-emoji-folder-icon.name": "Emoji Folder Icon",
"com.affine.settings.workspace.experimental-features.enable-emoji-folder-icon.description": "Once enabled, you can use an emoji as the folder icon. When the first character of the folder name is an emoji, it will be extracted and used as its icon.",
"com.affine.settings.workspace.experimental-features.enable-emoji-doc-icon.name": "Emoji Doc Icon",