mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
feat(editor): support embed iframe block (#10740)
To close: [BS-2660](https://linear.app/affine-design/issue/BS-2660/slash-menu-支持-iframe-embed) [BS-2661](https://linear.app/affine-design/issue/BS-2661/iframe-embed-block-model-and-block-component) [BS-2662](https://linear.app/affine-design/issue/BS-2662/iframe-embed-block-toolbar) [BS-2768](https://linear.app/affine-design/issue/BS-2768/iframe-embed-block-loading-和-error-态) [BS-2670](https://linear.app/affine-design/issue/BS-2670/iframe-embed-block-导出) # PR Description # Add Embed Iframe Block Support ## Overview This PR introduces a new `EmbedIframeBlock` to enhance content embedding capabilities within our editor. This block allows users to seamlessly embed external content from various providers (Google Drive, Spotify, etc.) directly into their docs. ## New Blocks ### EmbedIframeBlock The core block that renders embedded iframe content. This block: * Displays external content within a secure iframe * Handles loading states with visual feedback * Provides error handling with edit and retry options * Supports customization of width, height, and other iframe attributes ### Supporting Components * **EmbedIframeCreateModal**: Modal interface for creating new iframe embeds * **EmbedIframeLinkEditPopup**: UI for editing existing embed links * **EmbedIframeLoadingCard**: Visual feedback during content loading * **EmbedIframeErrorCard**: Error handling with retry functionality ## New Store Extensions ### EmbedIframeConfigExtension This extension provides configuration for different embed providers: ```typescript /** * The options for the iframe * @example * { * defaultWidth: '100%', * defaultHeight: '152px', * style: 'border-radius: 8px;', * allow: 'autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture', * } * => * <iframe * width="100%" * height="152px" * style="border-radius: 8px;" * allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" * ></iframe> */ 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 provider */ export type EmbedIframeConfig = { /** * The name of the embed iframe block provider */ 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>('EmbedIframeConfig'); export function EmbedIframeConfigExtension( config: EmbedIframeConfig ): ExtensionType & { identifier: ServiceIdentifier<EmbedIframeConfig>; } { const identifier = EmbedIframeConfigIdentifier(config.name); return { setup: di => { di.addImpl(identifier, () => config); }, identifier, }; } ``` **example:** ```typescript // blocksuite/affine/blocks/block-embed/src/embed-iframe-block/configs/providers/spotify.ts 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: 12px;', allowFullscreen: true, }, }; // add the config extension to store export const SpotifyEmbedConfig = EmbedIframeConfigExtension(spotifyConfig); ``` **Key features:** * Provider registration and discovery * URL pattern matching * Provider-specific embed options (width, height, features) ### EmbedIframeService This service provides abilities to handle URL validation, data fetching, and block creation **Type:** ```typescript /** * 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<EmbedIframeData | null>; /** * 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<EmbedIframeBlockProps>, parentId: string, index?: number ) => string | undefined; } ``` **Implemetation:** ```typescript 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<EmbedIframeData | null> => { 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<EmbedIframeBlockProps>, parentId: string, index?: number ): string | undefined => { const blockId = this.store.addBlock( 'affine:embed-iframe', props, parentId, index ); return blockId; }; } ``` **Usage:** ```typescript // Usage example const embedIframeService = this.std.get(EmbedIframeService); // Check if a URL can be embedded const canEmbed = embedIframeService.canEmbed(url); // Get embed data for a URL const embedData = await embedIframeService.getEmbedIframeData(url); // Add an embed iframe block to the document const block = embedIframeService.addEmbedIframeBlock({ url, iframeUrl: embedData.iframe_url, title: embedData.title, description: embedData.description }, parentId, index); ``` **Key features:** * URL validation and transformation * Provider-specific data fetching * Block creation and management ## Adaptations ### Toolbar Integration Added toolbar actions for embedded content: * Copy link * Edit embed title and description * Toggle between inline/card views * Add caption * And more ### Slash Menu Integration Added a new slash menu option for embedding content: * Embed item for inserting embed iframe block * Conditional rendering based on feature flags ### Adapters Implemented adapters for various formats: * **HTML Adapter**: Exports embed original urls as html links * **Markdown Adapter**: Exports embed original urls as markdown links * **Plain Text Adapter**: Exports embed original urls as link text ## To Be Continued: - [ ] **UI Optimization** - [ ] **Edgeless Mode Support** - [ ] **Mobile Support**
This commit is contained in:
@@ -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<GfxCommonBlockProps, 'rotate'>;
|
||||
|
||||
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<EmbedIframeBlockProps>(BlockModel)
|
||||
implements GfxElementGeometry {}
|
||||
@@ -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
|
||||
);
|
||||
2
blocksuite/affine/model/src/blocks/embed/iframe/index.ts
Normal file
2
blocksuite/affine/model/src/blocks/embed/iframe/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './iframe-model';
|
||||
export * from './iframe-schema';
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user