mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
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**