mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-17 22:37:04 +08: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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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`
|
||||
<div class="embed-iframe-create-modal">
|
||||
<div
|
||||
class="embed-iframe-create-modal-mask"
|
||||
@click=${this._onClose}
|
||||
></div>
|
||||
<div class="modal-main-wrapper">
|
||||
${showCloseButton
|
||||
? html`
|
||||
<div class="modal-close-button" @click=${this._onClose}>
|
||||
${CloseIcon({ width: '20', height: '20' })}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<div class="modal-content-wrapper">
|
||||
<div class="modal-content-header">
|
||||
<div class="icon-container">
|
||||
<div class="icon-background">
|
||||
${EmbedIcon({ width: '32px', height: '32px' })}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="title">Embed Link</div>
|
||||
<div class="description">
|
||||
Works with links of PDFs, Google Drive, Google Maps, CodePen…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-container">
|
||||
<input
|
||||
class="link-input"
|
||||
type="text"
|
||||
placeholder="Paste in https://…"
|
||||
@input=${this._handleInput}
|
||||
@keydown=${this._handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-container">
|
||||
<div
|
||||
class="confirm-button"
|
||||
@click=${this._onConfirm}
|
||||
?disabled=${this._isInputEmpty()}
|
||||
>
|
||||
Confirm
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@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<void> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -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`<embed-iframe-link-edit-popup
|
||||
.model=${this.model}
|
||||
.abortController=${this._editAbortController}
|
||||
.std=${this.std}
|
||||
></embed-iframe-link-edit-popup>`,
|
||||
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`
|
||||
<div class="affine-embed-iframe-error-card">
|
||||
<div class="error-content">
|
||||
<div class="error-title">
|
||||
<div class="error-icon">
|
||||
${InformationIcon({ width: '16px', height: '16px' })}
|
||||
</div>
|
||||
<div class="error-title-text">This link couldn’t be loaded.</div>
|
||||
</div>
|
||||
<div class="error-message">
|
||||
${this.error?.message || 'Failed to load embedded content'}
|
||||
</div>
|
||||
<div class="error-info">
|
||||
<div class="button edit" @click=${this._toggleEdit}>
|
||||
<span class="icon"
|
||||
>${EditIcon({ width: '16px', height: '16px' })}</span
|
||||
>
|
||||
<span class="text">Edit</span>
|
||||
</div>
|
||||
<div class="button retry" @click=${this.onRetry}>
|
||||
<span class="icon"
|
||||
>${ResetIcon({ width: '16px', height: '16px' })}</span
|
||||
>
|
||||
<span class="text">Reload</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="error-banner">
|
||||
<!-- TODO: add error banner icon -->
|
||||
<div class="icon-box"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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`
|
||||
<div class="embed-iframe-link-edit-popup">
|
||||
<div class="input-container">
|
||||
<span class="input-label">Link</span>
|
||||
<input
|
||||
class="link-input"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
placeholder=${url$.value}
|
||||
@input=${this._handleInput}
|
||||
@keydown=${this._handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="confirm-button"
|
||||
?disabled=${isInputEmpty}
|
||||
@click=${this._onConfirm}
|
||||
>
|
||||
${DoneIcon({ width: '24px', height: '24px' })}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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`
|
||||
<div class="affine-embed-iframe-loading-card">
|
||||
<div class="loading-content">
|
||||
<div class="loading-spinner">${LoadingIcon}</div>
|
||||
<div class="loading-text">Loading...</div>
|
||||
</div>
|
||||
<div class="loading-banner">
|
||||
<div class="icon-box">
|
||||
${EmbedIcon({ width: '66px', height: '66px' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './providers';
|
||||
export * from './toolbar';
|
||||
@@ -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);
|
||||
@@ -0,0 +1,7 @@
|
||||
import { GoogleDriveEmbedConfig } from './google-drive';
|
||||
import { SpotifyEmbedConfig } from './spotify';
|
||||
|
||||
export const EmbedIframeConfigExtensions = [
|
||||
SpotifyEmbedConfig,
|
||||
GoogleDriveEmbedConfig,
|
||||
];
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
File diff suppressed because one or more lines are too long
@@ -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<boolean>) => {
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedViewSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch view',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-view-dropdown-menu
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.toggle=${toggle}
|
||||
.viewType$=${signal(actions[2].label)}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
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;
|
||||
@@ -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<EmbedIframeBlockModel> {
|
||||
selectedStyle$: ReadonlySignal<ClassInfo> | null = computed<ClassInfo>(
|
||||
() => ({
|
||||
'selected-style': this.selected$.value,
|
||||
})
|
||||
);
|
||||
|
||||
blockDraggable = true;
|
||||
|
||||
static override styles = embedIframeBlockStyles;
|
||||
|
||||
readonly status$ = signal<EmbedIframeStatus>('idle');
|
||||
readonly error$ = signal<Error | null>(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`
|
||||
<iframe
|
||||
width=${defaultWidth ?? '100%'}
|
||||
height=${defaultHeight ?? DEFAULT_IFRAME_HEIGHT}
|
||||
?allowfullscreen=${allowFullscreen}
|
||||
loading="lazy"
|
||||
frameborder="0"
|
||||
src=${ifDefined(iframeUrl)}
|
||||
allow=${ifDefined(allow)}
|
||||
referrerpolicy=${ifDefined(referrerpolicy)}
|
||||
scrolling=${ifDefined(scrolling)}
|
||||
style=${ifDefined(style)}
|
||||
></iframe>
|
||||
`;
|
||||
};
|
||||
|
||||
private readonly _renderContent = () => {
|
||||
if (this.isLoading$.value) {
|
||||
return html`<embed-iframe-loading-card
|
||||
.std=${this.std}
|
||||
></embed-iframe-loading-card>`;
|
||||
}
|
||||
|
||||
if (this.hasError$.value) {
|
||||
return html`<embed-iframe-error-card
|
||||
.error=${this.error$.value}
|
||||
.model=${this.model}
|
||||
.onRetry=${this._handleRetry}
|
||||
.std=${this.std}
|
||||
></embed-iframe-error-card>`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div
|
||||
draggable=${this.blockDraggable ? 'true' : 'false'}
|
||||
class=${classes}
|
||||
style=${style}
|
||||
@click=${this._handleClick}
|
||||
@dblclick=${this._handleDoubleClick}
|
||||
>
|
||||
${this._renderContent()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override accessor blockContainerStyles = { margin: '18px 0' };
|
||||
|
||||
override accessor useCaptionEditor = true;
|
||||
|
||||
override accessor useZeroWidth = true;
|
||||
|
||||
override accessor selectedStyle = SelectedStyle.Border;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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',
|
||||
* }
|
||||
* <iframe
|
||||
* width="100%"
|
||||
* height="152px"
|
||||
* style="border-radius: 12px;"
|
||||
* allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||
* ></iframe>
|
||||
*/
|
||||
export type IframeOptions = {
|
||||
defaultWidth?: string;
|
||||
defaultHeight?: string;
|
||||
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>('EmbedIframeConfig');
|
||||
|
||||
export function EmbedIframeConfigExtension(
|
||||
config: EmbedIframeConfig
|
||||
): ExtensionType & {
|
||||
identifier: ServiceIdentifier<EmbedIframeConfig>;
|
||||
} {
|
||||
const identifier = EmbedIframeConfigIdentifier(config.name);
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(identifier, () => config);
|
||||
},
|
||||
identifier,
|
||||
};
|
||||
}
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
export const EmbedIframeProvider = createIdentifier<EmbedIframeProvider>(
|
||||
'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<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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './embed-iframe-config';
|
||||
export * from './embed-iframe-service';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user