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:
39 files changed
+2450
-2
No files matched your search
@@ -5,6 +5,7 @@ import { DividerBlockHtmlAdapterExtension } from '@blocksuite/affine-block-divid
|
||||
import {
|
||||
EmbedFigmaBlockHtmlAdapterExtension,
|
||||
EmbedGithubBlockHtmlAdapterExtension,
|
||||
EmbedIframeBlockHtmlAdapterExtension,
|
||||
EmbedLinkedDocHtmlAdapterExtension,
|
||||
EmbedLoomBlockHtmlAdapterExtension,
|
||||
EmbedSyncedDocBlockHtmlAdapterExtension,
|
||||
@@ -27,6 +28,7 @@ export const defaultBlockHtmlAdapterMatchers = [
|
||||
EmbedFigmaBlockHtmlAdapterExtension,
|
||||
EmbedLoomBlockHtmlAdapterExtension,
|
||||
EmbedGithubBlockHtmlAdapterExtension,
|
||||
EmbedIframeBlockHtmlAdapterExtension,
|
||||
BookmarkBlockHtmlAdapterExtension,
|
||||
DatabaseBlockHtmlAdapterExtension,
|
||||
TableBlockHtmlAdapterExtension,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DividerBlockMarkdownAdapterExtension } from '@blocksuite/affine-block-d
|
||||
import {
|
||||
EmbedFigmaMarkdownAdapterExtension,
|
||||
EmbedGithubMarkdownAdapterExtension,
|
||||
EmbedIframeBlockMarkdownAdapterExtension,
|
||||
EmbedLinkedDocMarkdownAdapterExtension,
|
||||
EmbedLoomMarkdownAdapterExtension,
|
||||
EmbedSyncedDocMarkdownAdapterExtension,
|
||||
@@ -27,6 +28,7 @@ export const defaultBlockMarkdownAdapterMatchers = [
|
||||
EmbedLoomMarkdownAdapterExtension,
|
||||
EmbedSyncedDocMarkdownAdapterExtension,
|
||||
EmbedYoutubeMarkdownAdapterExtension,
|
||||
EmbedIframeBlockMarkdownAdapterExtension,
|
||||
ListBlockMarkdownAdapterExtension,
|
||||
ParagraphBlockMarkdownAdapterExtension,
|
||||
BookmarkBlockMarkdownAdapterExtension,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DividerBlockPlainTextAdapterExtension } from '@blocksuite/affine-block-
|
||||
import {
|
||||
EmbedFigmaBlockPlainTextAdapterExtension,
|
||||
EmbedGithubBlockPlainTextAdapterExtension,
|
||||
EmbedIframeBlockPlainTextAdapterExtension,
|
||||
EmbedLinkedDocBlockPlainTextAdapterExtension,
|
||||
EmbedLoomBlockPlainTextAdapterExtension,
|
||||
EmbedSyncedDocBlockPlainTextAdapterExtension,
|
||||
@@ -27,6 +28,7 @@ export const defaultBlockPlainTextAdapterMatchers: ExtensionType[] = [
|
||||
EmbedYoutubeBlockPlainTextAdapterExtension,
|
||||
EmbedLinkedDocBlockPlainTextAdapterExtension,
|
||||
EmbedSyncedDocBlockPlainTextAdapterExtension,
|
||||
EmbedIframeBlockPlainTextAdapterExtension,
|
||||
LatexBlockPlainTextAdapterExtension,
|
||||
DatabaseBlockPlainTextAdapterExtension,
|
||||
];
|
||||
@@ -1,5 +1,9 @@
|
||||
import { DataViewBlockSchemaExtension } from '@blocksuite/affine-block-data-view';
|
||||
import { DatabaseSelectionExtension } from '@blocksuite/affine-block-database';
|
||||
import {
|
||||
EmbedIframeConfigExtensions,
|
||||
EmbedIframeService,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import { ImageStoreSpec } from '@blocksuite/affine-block-image';
|
||||
import { SurfaceBlockSchemaExtension } from '@blocksuite/affine-block-surface';
|
||||
import { TableSelectionExtension } from '@blocksuite/affine-block-table';
|
||||
@@ -14,6 +18,7 @@ import {
|
||||
EmbedFigmaBlockSchemaExtension,
|
||||
EmbedGithubBlockSchemaExtension,
|
||||
EmbedHtmlBlockSchemaExtension,
|
||||
EmbedIframeBlockSchemaExtension,
|
||||
EmbedLinkedDocBlockSchemaExtension,
|
||||
EmbedLoomBlockSchemaExtension,
|
||||
EmbedSyncedDocBlockSchemaExtension,
|
||||
@@ -72,6 +77,7 @@ export const StoreExtensions: ExtensionType[] = [
|
||||
EmbedSyncedDocBlockSchemaExtension,
|
||||
EmbedLinkedDocBlockSchemaExtension,
|
||||
EmbedHtmlBlockSchemaExtension,
|
||||
EmbedIframeBlockSchemaExtension,
|
||||
EmbedGithubBlockSchemaExtension,
|
||||
EmbedFigmaBlockSchemaExtension,
|
||||
EmbedLoomBlockSchemaExtension,
|
||||
@@ -101,4 +107,6 @@ export const StoreExtensions: ExtensionType[] = [
|
||||
FileSizeLimitService,
|
||||
ImageStoreSpec,
|
||||
BlockMetaService,
|
||||
EmbedIframeConfigExtensions,
|
||||
EmbedIframeService,
|
||||
].flat();
|
||||
@@ -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);
|
||||
+366
@@ -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();
|
||||
});
|
||||
}
|
||||
+223
@@ -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;
|
||||
}
|
||||
+206
@@ -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;
|
||||
}
|
||||
+112
@@ -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';
|
||||
+192
@@ -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,
|
||||
];
|
||||
+42
@@ -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);
|
||||
+43
@@ -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);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { html } from 'lit';
|
||||
|
||||
export const EmbedIframeTooltip = html`
|
||||
<svg
|
||||
width="170"
|
||||
height="106"
|
||||
viewBox="0 0 170 106"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<rect width="170" height="106" rx="2" fill="white" />
|
||||
<mask
|
||||
id="mask0_888_46686"
|
||||
style="mask-type:alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="170"
|
||||
height="106"
|
||||
>
|
||||
<rect width="170" height="106" rx="2" fill="white" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_888_46686)">
|
||||
<path
|
||||
d="M10.8807 19V11.7273H15.2415V12.5085H11.7614V14.9659H14.9148V15.7472H11.7614V19H10.8807ZM18.4517 19.1136C17.9593 19.1136 17.5272 18.9964 17.1555 18.7621C16.7862 18.5277 16.4974 18.1998 16.2891 17.7784C16.0831 17.357 15.9801 16.8646 15.9801 16.3011C15.9801 15.733 16.0831 15.237 16.2891 14.8132C16.4974 14.3894 16.7862 14.0604 17.1555 13.826C17.5272 13.5916 17.9593 13.4744 18.4517 13.4744C18.9441 13.4744 19.375 13.5916 19.7443 13.826C20.116 14.0604 20.4048 14.3894 20.6108 14.8132C20.8191 15.237 20.9233 15.733 20.9233 16.3011C20.9233 16.8646 20.8191 17.357 20.6108 17.7784C20.4048 18.1998 20.116 18.5277 19.7443 18.7621C19.375 18.9964 18.9441 19.1136 18.4517 19.1136ZM18.4517 18.3608C18.8258 18.3608 19.1335 18.2649 19.375 18.0732C19.6165 17.8814 19.7952 17.6293 19.9112 17.3168C20.0272 17.0043 20.0852 16.6657 20.0852 16.3011C20.0852 15.9366 20.0272 15.5968 19.9112 15.282C19.7952 14.9671 19.6165 14.7126 19.375 14.5185C19.1335 14.3243 18.8258 14.2273 18.4517 14.2273C18.0777 14.2273 17.7699 14.3243 17.5284 14.5185C17.2869 14.7126 17.1082 14.9671 16.9922 15.282C16.8762 15.5968 16.8182 15.9366 16.8182 16.3011C16.8182 16.6657 16.8762 17.0043 16.9922 17.3168C17.1082 17.6293 17.2869 17.8814 17.5284 18.0732C17.7699 18.2649 18.0777 18.3608 18.4517 18.3608ZM22.2026 19V13.5455H23.0123V14.3693H23.0691C23.1685 14.0994 23.3484 13.8804 23.6088 13.7124C23.8693 13.5443 24.1628 13.4602 24.4895 13.4602C24.5511 13.4602 24.628 13.4614 24.7203 13.4638C24.8127 13.4661 24.8825 13.4697 24.9299 13.4744V14.3267C24.9015 14.3196 24.8364 14.3089 24.7346 14.2947C24.6351 14.2782 24.5298 14.2699 24.4185 14.2699C24.1534 14.2699 23.9166 14.3255 23.7083 14.4368C23.5023 14.5457 23.339 14.6972 23.2182 14.8913C23.0998 15.0831 23.0407 15.3021 23.0407 15.5483V19H22.2026ZM28.8494 19V11.7273H31.3068C31.8774 11.7273 32.3438 11.8303 32.706 12.0362C33.0705 12.2398 33.3404 12.5156 33.5156 12.8636C33.6908 13.2116 33.7784 13.5999 33.7784 14.0284C33.7784 14.4569 33.6908 14.8464 33.5156 15.1967C33.3428 15.5471 33.0753 15.8265 32.7131 16.0348C32.3509 16.2408 31.8868 16.3438 31.321 16.3438H29.5597V15.5625H31.2926C31.6832 15.5625 31.9969 15.495 32.2337 15.3601C32.4704 15.2251 32.642 15.0429 32.7486 14.8132C32.8575 14.5812 32.9119 14.3196 32.9119 14.0284C32.9119 13.7372 32.8575 13.4768 32.7486 13.2472C32.642 13.0175 32.4692 12.8376 32.2301 12.7074C31.991 12.5748 31.6738 12.5085 31.2784 12.5085H29.7301V19H28.8494ZM37.4414 19H35.1971V11.7273H37.5408C38.2463 11.7273 38.85 11.8729 39.3519 12.1641C39.8538 12.4529 40.2385 12.8684 40.506 13.4105C40.7736 13.9503 40.9073 14.5966 40.9073 15.3494C40.9073 16.107 40.7724 16.7592 40.5025 17.3061C40.2326 17.8506 39.8396 18.2696 39.3235 18.5632C38.8074 18.8544 38.18 19 37.4414 19ZM36.0778 18.2188H37.3846C37.9859 18.2188 38.4843 18.1027 38.8796 17.8707C39.275 17.6387 39.5697 17.3085 39.7638 16.88C39.958 16.4515 40.055 15.9413 40.055 15.3494C40.055 14.7623 39.9592 14.2569 39.7674 13.8331C39.5756 13.407 39.2892 13.0803 38.908 12.853C38.5269 12.6233 38.0522 12.5085 37.484 12.5085H36.0778V18.2188ZM42.3846 19V11.7273H46.7454V12.5085H43.2653V14.9659H46.4187V15.7472H43.2653V19H42.3846ZM52.0037 14.767L51.2509 14.9801C51.2035 14.8546 51.1337 14.7327 51.0414 14.6143C50.9514 14.4936 50.8283 14.3942 50.6721 14.3161C50.5158 14.2379 50.3158 14.1989 50.0719 14.1989C49.7381 14.1989 49.4599 14.2758 49.2374 14.4297C49.0172 14.5812 48.9071 14.7741 48.9071 15.0085C48.9071 15.2169 48.9829 15.3814 49.1344 15.5021C49.2859 15.6229 49.5227 15.7235 49.8446 15.804L50.6543 16.0028C51.142 16.1212 51.5054 16.3023 51.7445 16.5462C51.9836 16.7876 52.1032 17.099 52.1032 17.4801C52.1032 17.7926 52.0132 18.072 51.8333 18.3182C51.6557 18.5644 51.4071 18.7585 51.0875 18.9006C50.7679 19.0426 50.3962 19.1136 49.9725 19.1136C49.4161 19.1136 48.9557 18.9929 48.5911 18.7514C48.2265 18.5099 47.9957 18.1572 47.8986 17.6932L48.6941 17.4943C48.7698 17.7879 48.9131 18.008 49.1238 18.1548C49.3368 18.3016 49.615 18.375 49.9583 18.375C50.3489 18.375 50.659 18.2921 50.8887 18.1264C51.1207 17.9583 51.2367 17.7571 51.2367 17.5227C51.2367 17.3333 51.1704 17.1747 51.0378 17.0469C50.9052 16.9167 50.7016 16.8196 50.427 16.7557L49.5179 16.5426C49.0184 16.4242 48.6515 16.2408 48.4171 15.9922C48.1851 15.7412 48.0691 15.4276 48.0691 15.0511C48.0691 14.7434 48.1555 14.4711 48.3283 14.2344C48.5035 13.9976 48.7414 13.8118 49.0421 13.6768C49.3451 13.5419 49.6884 13.4744 50.0719 13.4744C50.6117 13.4744 51.0355 13.5928 51.3432 13.8295C51.6533 14.0663 51.8735 14.3788 52.0037 14.767ZM54.6147 18.0057L54.5579 18.3892C54.5176 18.6591 54.4561 18.9479 54.3732 19.2557C54.2927 19.5634 54.2087 19.8535 54.1211 20.1257C54.0335 20.398 53.9613 20.6146 53.9045 20.7756H53.2653C53.296 20.6241 53.3363 20.424 53.386 20.1754C53.4357 19.9268 53.4854 19.6487 53.5352 19.3409C53.5872 19.0355 53.6299 18.723 53.663 18.4034L53.7056 18.0057H54.6147ZM60.585 19.1278C60.2394 19.1278 59.9257 19.0627 59.644 18.9325C59.3623 18.8 59.1386 18.6094 58.9728 18.3608C58.8071 18.1098 58.7243 17.8068 58.7243 17.4517C58.7243 17.1392 5Line truncated
|
||||
fill="#7A7A7A"
|
||||
/>
|
||||
<rect
|
||||
x="8.5"
|
||||
y="28.5"
|
||||
width="169"
|
||||
height="121"
|
||||
rx="3.5"
|
||||
fill="white"
|
||||
stroke="#E6E6E6"
|
||||
/>
|
||||
<path
|
||||
d="M19.598 42.0852V45H18.6424V38.4545H19.5852V40.8899H19.646C19.761 40.6257 19.9368 40.4158 20.1733 40.2603C20.4098 40.1048 20.7188 40.027 21.1001 40.027C21.4368 40.027 21.7308 40.0962 21.9822 40.2347C22.2358 40.3732 22.4318 40.5799 22.5703 40.8548C22.7109 41.1275 22.7812 41.4684 22.7812 41.8775V45H21.8256V41.9925C21.8256 41.6325 21.733 41.3533 21.5476 41.1552C21.3622 40.9549 21.1044 40.8548 20.7741 40.8548C20.5483 40.8548 20.3459 40.9027 20.1669 40.9986C19.9901 41.0945 19.8505 41.2351 19.7482 41.4205C19.6481 41.6037 19.598 41.8253 19.598 42.0852ZM26.348 40.0909V40.858H23.6665V40.0909H26.348ZM24.3857 38.9148H25.3413V43.5586C25.3413 43.744 25.369 43.8835 25.4244 43.9773C25.4798 44.0689 25.5511 44.1317 25.6385 44.1658C25.728 44.1978 25.8249 44.2138 25.9293 44.2138C26.006 44.2138 26.0732 44.2085 26.1307 44.1978C26.1882 44.1871 26.233 44.1786 26.2649 44.1722L26.4375 44.9616C26.3821 44.983 26.3033 45.0043 26.201 45.0256C26.0987 45.049 25.9709 45.0618 25.8175 45.0639C25.5661 45.0682 25.3317 45.0234 25.1143 44.9297C24.897 44.8359 24.7212 44.6911 24.587 44.495C24.4528 44.299 24.3857 44.0529 24.3857 43.7567V38.9148ZM29.6966 40.0909V40.858H27.0152V40.0909H29.6966ZM27.7343 38.9148H28.6899V43.5586C28.6899 43.744 28.7176 43.8835 28.773 43.9773C28.8284 44.0689 28.8998 44.1317 28.9871 44.1658C29.0766 44.1978 29.1736 44.2138 29.278 44.2138C29.3547 44.2138 29.4218 44.2085 29.4793 44.1978C29.5368 44.1871 29.5816 44.1786 29.6135 44.1722L29.7861 44.9616C29.7307 44.983 29.6519 45.0043 29.5496 45.0256C29.4474 45.049 29.3195 45.0618 29.1661 45.0639C28.9147 45.0682 28.6803 45.0234 28.463 44.9297C28.2456 44.8359 28.0699 44.6911 27.9356 44.495C27.8014 44.299 27.7343 44.0529 27.7343 43.7567V38.9148ZM30.7537 46.8409V40.0909H31.687V40.8867H31.7669C31.8223 40.7844 31.9022 40.6662 32.0066 40.532C32.111 40.3977 32.2559 40.2805 32.4412 40.1804C32.6266 40.0781 32.8716 40.027 33.1763 40.027C33.5726 40.027 33.9263 40.1271 34.2374 40.3274C34.5485 40.5277 34.7924 40.8164 34.9693 41.1935C35.1483 41.5707 35.2377 42.0245 35.2377 42.555C35.2377 43.0856 35.1493 43.5405 34.9725 43.9197C34.7956 44.2969 34.5527 44.5877 34.2438 44.7923C33.9348 44.9947 33.5822 45.0959 33.1859 45.0959C32.8876 45.0959 32.6436 45.0458 32.454 44.9457C32.2665 44.8455 32.1195 44.7283 32.013 44.5941C31.9064 44.4599 31.8244 44.3406 31.7669 44.2362H31.7093V46.8409H30.7537ZM31.6902 42.5455C31.6902 42.8906 31.7402 43.1932 31.8404 43.4531C31.9405 43.7131 32.0854 43.9165 32.275 44.0636C32.4647 44.2085 32.6969 44.2809 32.9718 44.2809C33.2573 44.2809 33.4959 44.2053 33.6877 44.054C33.8794 43.9006 34.0243 43.6928 34.1223 43.4308C34.2225 43.1687 34.2725 42.8736 34.2725 42.5455C34.2725 42.2216 34.2235 41.9308 34.1255 41.6729C34.0297 41.4151 33.8848 41.2116 33.6909 41.0625C33.4991 40.9134 33.2594 40.8388 32.9718 40.8388C32.6948 40.8388 32.4604 40.9102 32.2686 41.0529C32.079 41.1957 31.9352 41.3949 31.8372 41.6506C31.7392 41.9062 31.6902 42.2045 31.6902 42.5455ZM39.987 41.2894L39.1209 41.4428C39.0847 41.332 39.0272 41.2266 38.9483 41.1264C38.8716 41.0263 38.7672 40.9442 38.6351 40.8803C38.503 40.8164 38.3379 40.7844 38.1397 40.7844C37.8691 40.7844 37.6433 40.8452 37.4622 40.9666C37.2811 41.0859 37.1905 41.2404 37.1905 41.43C37.1905 41.5941 37.2512 41.7262 37.3727 41.8263C37.4941 41.9265 37.6902 42.0085 37.9608 42.0724L38.7406 42.2514C39.1923 42.3558 39.5289 42.5167 39.7505 42.734C39.9721 42.9513 40.0829 43.2337 40.0829 43.581C40.0829 43.875 39.9977 44.1371 39.8272 44.3672C39.6589 44.5952 39.4235 44.7741 39.1209 44.9041C38.8205 45.0341 38.4721 45.0991 38.0758 45.0991C37.5261 45.0991 37.0776 44.9819 36.7303 44.7475C36.383 44.511 36.1699 44.1754 36.0911 43.7408L37.0147 43.6001C37.0723 43.8409 37.1905 44.0231 37.3695 44.1467C37.5485 44.2681 37.7818 44.3288 38.0694 44.3288C38.3826 44.3288 38.633 44.2638 38.8205 44.1339C39.008 44.0018 39.1017 43.8409 39.1017 43.6513C39.1017 43.4979 39.0442 43.369 38.9292 43.2646C38.8162 43.1602 38.6426 43.0813 38.4082 43.0281L37.5772 42.8459C37.1191 42.7415 36.7804 42.5753 36.5609 42.3473C36.3436 42.1193 36.2349 41.8306 36.2349 41.4812C36.2349 41.1914 36.3159 40.9379 36.4778 40.7205C36.6397 40.5032 36.8635 40.3338 37.149 40.2124C37.4345 40.0888 37.7615 40.027 38.1301 40.027C38.6607 40.027 39.0783 40.142 39.383 40.3722C39.6877 40.6001 39.889 40.9059 39.987 41.2894ZM41.7632 45.0607C41.5885 45.0607 41.4383 44.9989 41.3126 44.8754C41.1869 44.7496 41.124 44.5984 41.124 44.4215C41.124 44.2468 41.1869 44.0977 41.3126 43.9741C41.4383 43.8484 41.5885 43.7855 41.7632 43.7855C41.9379 43.7855 42.0882 43.8484 42.2139 43.9741C42.3396 44.0977 42.4024 44.2468 42.4024 44.4215C42.4024 44.5387 42.3726 44.6463 42.3129 44.7443C42.2554 44.8402 42.1787 44.9169 42.0828 44.9744C41.9869 45.032 41.8804 45.0607 41.7632 45.0607ZM41.7632 41.4876C41.5885 41.4876 41.4383 41.4247 41.3126 41.299C41.1869 41.1733 41.124 41.0231 41.124 40.8484C41.124 40.6737 41.1869 40.5245 41.3126 40.4009C41.4383 40.2752 41.5885 40.2124 41.7632 40.2124C41.9379 40.2124 42.0882 40.2752 42.2139 40.4009C42.339Line truncated
|
||||
fill="#141414"
|
||||
/>
|
||||
<rect
|
||||
x="16.25"
|
||||
y="53.25"
|
||||
width="153.5"
|
||||
height="133.5"
|
||||
rx="1.75"
|
||||
fill="url(#pattern0_888_46686)"
|
||||
stroke="#E6E6E6"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<pattern
|
||||
id="pattern0_888_46686"
|
||||
patternContentUnits="objectBoundingBox"
|
||||
width="1"
|
||||
height="1"
|
||||
>
|
||||
<use
|
||||
xlink:href="#image0_888_46686"
|
||||
transform="scale(0.00324675 0.00373134)"
|
||||
/>
|
||||
</pattern>
|
||||
<image
|
||||
id="image0_888_46686"
|
||||
width="308"
|
||||
height="268"
|
||||
preserveAspectRatio="none"
|
||||
xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAATQAAAEMCAYAAABKqEpfAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAImFSURBVHgB7X0HmBxHmfbXszkHSSutVmmVk63obDkHDLbB5ANzwJHzwRGOO45whANMuIOfnI4jGIONA9gG56hgyco5h9Uq7mpzmJ3w1/v11HR1T/XuzM7ObFC9elYz09NTXV3d9faXyyKBQ4dOzMjNCf9KvF0q/irJwMDAYNTAeiA3L/Dx2traw1aMzDaVlBRVFhUXkWVZZGBgYDAaEI1Gqae7lzo6Olty83KW5ebmRL5bWlrMZGZgYGAwmgABrKi4kCLRSGVXZ/evAoLjXlNYVEAGBgYGoxXFtkC2NID/LStABgYGBqMVMVNZpWEyAwODMQNDaAYGBmMGhtAMDAzGDAyhGRgYjBkYQjMwMBgzMIRmYGAwZmAIzcDAYMzAEJqBgcGYgSE0AwODMQNDaAYGBmMGhtAMDAzGDAyhGRgYjBkYQjMwMBgzMIRmYGAwZmAIzcDAYMzAEJqBgcGYgSE0A4pEImRgMBZgCO08x9q162ju3Hl0ySWX0j/8w1vor3/9K4VCITIwGI0whJYC7t0Toot/10mZxqOPPkpPPvkUZQPLli2l5cuX0ZYtW+j++++n17/+DTR79hz62c9+Ti0tLWRgMJpgCC0FnOiKUEtvlN785n+gefPm0//+7/8m7HPuXDMtWLCQv7/nnnsSvt+7dy9/t3DhIuro6Ej4ft++fXTHHa+lV73qVbRx40bKNAoKCuiPf/wjXXvttfx++vTpdPLkSfrIRz5Ct9/+atqxYycZGIwW5JJByli6dCk98MAD4u9Besc73uH67oknnqJDhw7x+9Wr19Cb3vQm1/eQvI4cOUKLFi2i0tLShLbLy8tp4sSJvN7glClTeNtrXnMHE91DDz1Es2bNpEzg+PHjbEt74onHWVr7+Mc/QS+99BLddtttgrh/RVdddRWNZBxpj9DJjuiA++UFLFo0PkAFOc42nPeDDz6Y8IApLi6mOXPm0OLFiykQsJ/9wWCQH1T5+fkJ19Zg+GEIbRC46KKL+HXt2rUJ36nbQED//d/fdS3e/OKLL/AriEIHkNmBA/uZ0PLy8ngbCPLAgQNaiW4oAIIFYY4bN47Gjx9Pt956K1133XX0vve9j/70p3vpda97vSC6x2jJkqW8P6TQqqrq+O9BCA0NDTR58mTKzbVvKfQf/Z44cRKVlBTztm3btol9asVxxruOHw6HmUSXL1+e0LcXXniBLr/88jih6PClNT30u13J2/1qSyz6w61F4tUhqX/+54/TqVOntPvPnDmTvv/979H1118vzv0cvec976XKykpDaCMQRuUcBC655GKevLAx7dq1O74dE3v16tX8HpLWiRMneKJLYJI//7xNaDfddCP96lf/S3fffTd//vvfH6O77voWPf744/Sb3/yWfvvb3zHJYB9MIuDee//En48dOxZvs6HhOP3yl7+kb33r2/TTn/40Lh2mAvQZfZszZzYVFdkLTkM6+fWvf80SaHt7O7397e9kZ8Hp06eFWlovzuN53u+RRx6l+vqZ7Fj4yU9+ytueeOIJWrFiJavVkERfeOFF3v62t/2j+Ht7wvE/+9l/E6R1BX3ve99zbd+wYQPdcIM9Tn442BpJicyAE51R+smWPu13H/jA++lzn/sc/33qU58UNsZldPDgQbrzzreJa23U75EOI6ENAlAVL7nkEjaiP/roI8JmNp+3Q9KBpDFlSh0/vb/97e/E1M6p/D3IDVIAyGL+/PlCCrqen/S7du1iQgIhvuc97xEG+Z/x/t///vfZliUBwgN+85v/o6lTp9KPfvRj+tKXvuQy3oOQPvnJT9K///u/UbJYt+4lfl21yq1WQir62te+yiS7e/duQb5/oKuvvoqJDe+hpr7rXe9mSfLHP/6R8JL+A/3857/g4/f09NCkSZOEVNlO//iP/0hr1qxhSeypp55iGx2+A0DWv/3tb/j9L37xS5YKYcsDHn74EX7Nzc3x7fuZroHVTB16wvrfvf/9HxA2zrnxzxjfN77xjcL7+7CQtr9H//mfXyKDkQsjoQ0Sl19+Gb9u2bI1vg32MZDSbbfdHldLYZuReOyxx/j15ptvppwce5JC+vnv//4f+tjHPiqI7Kfit7fG97/hhuuF2vog1dXV8ecf/ehH/PnKK68QquCfhJ3r46zO/uAHPxD2rnVCUvsFE+SXv/xlse2HlAwkyQCve90dCd9XV1fTRz/6kXj/QV5QK/fv30//+q+fpRkzZghVcitLcpDMQGYgvLvuukvss4+++tWvUmNjo5DkHokT1ZkzZ+Pto+/NzefYJnXmzBlW/ySeeeYZfoWkly7mVgXo8sk5Kf8OpA5CA+Q4ebFz5056y1veQtOmTROqeJV42Eyj9773ffyAA/AQW7RoMUuteAi84x3vFIRuq95XX301vfxy5p0/5wuMhDZIvP71rxcqyaeFBPYidXd3s2QEUgHuuOM1rGrh5n7uuefiv/nb3/7Or5ByJEAoMLq/4Q1v4M+QbCRgb6qvr487D2BjWrLkQibNz33uP3iy3X337+maa67h7y+88EKeOFdcYatvH/rQB2kgwOuKv3HjquM2Mi+gjgLNzc38HsfHecHed++997K0iMn77ne/h/v/9a9/XUiWH+bfLFy4kF/Hjx8XP4/u7i5+BfHdd9+f2UYF+x1UaRAbcPbsWdq6dSuf/4oVyykdLBZOgN/cUkw/2xqk1Y1hShUVFRX8qpKtxObNm1nS7urqopUrVwrVey5t376N/u///o8JcO3aNbwfiAyEDqKDFA8JfceOHSwdv+IVrxAS7GqaPXs2GaQHI6ENErW1tSydwIa1des23vbYY4+z9HLZZZfzBIVaiokJaQaAgRskJAlI4rWvfS2lgkOHDjOBIH7M29aFF17AkwXfnzs3cBzZhg0v8+u1116n/b65uYm+8Y1v8vtXvOJmam1tjQfe3nnnW+Pq9n/919dZfXzrW98qDOwfi/8eajHG5OKLL6Gamgm8TRIDbHdQt1/1qldym5D+pCMEsXidnZ3C1nhTvw6BgQAy+9lNhfTQgT76xfa+QbUhxwjeTi8QutPX18cq/lNPPclSMuyL1157DZsY7r//gfi+GLvq6ioh0e0Q0ufTQjLbIMblYpbSf/KTn5BB+jCElgbwZIXEgvCGo0ePsG3ohhtuEJPSFnyvuOJyfr333vs4ngsTHpMCT3EVUv1MFjBSA5hopaVlrr+ysnJ+8gNQ9QYCVFfgjW98Q8J3kJiuv/5G7veCBQsEgd3JTgEAktQ//dO7+D1Urt///vdM4l/84hfjv4dEB0kOZNXU1MQxboCUQmEzA1nB4A6pD1KuJC98h2NIdTdZvHZ2Lk0rs9uQZPbY4Qh9YXUv9YYHtrfhAXTq1En+27dvvzAH/Lcg6//i7973vvcm7I/vm5rO0n/8x3/EpUtIYldeuYrfy2shAXMAHEYAVFSpzvp5WA1Sg1E50wBCL3784x+z2llcbHsHVYkJaul//MfnWQWRagvsYulIHEAgYIeBQFV79atf7bufnDh+gGQhpcYlS5a4vtu2bTvbxSBBzZo1ix544H5WodevX8/fg8xlTNz/+38/YKnr7W9/u1A/p8TbgOoJgigsLBR9rRfOEts5AhUd3lg4VVatWsVqNCA9rNu3b+fwF0hnOHYqeOXMPPrIcou+ujZId11tS2Ygs2SB0AwvIGF++MMfottvv11LPJFImKXN7dt3cEjLsWMN8aBqmBTUdiDVq5g4sYZfVVODweBhCC0NXHrpJSxdQdWUMWKqfQz2H4R3vPTS+ngCuHxypwMp6aDN//qvr9FgAVUYth9IX7JNSJywv331q1+jtrY2lijhyZ0wwVYXn376GX5VA4rXrVvLk/Wd73RCMjZt2izUsV/z+/nz5zGp4RWAVxahKSDBf/3Xf+VthYUFoi/d/P5jH/tnfv3CF75AqeITz/TQd64ppB/eUEh37+6jr60LpvR7OHMksULSmjdvnrBvvj7u5PECEugHPvBBjhOUgG1xxozp/FAwyC4MoaWBkpISNsDjpn788SfY3X/BBRe49rnxxhs5ngv2FKgk11xzNaUKKdEhBAKA1IIsgsOHD7MkoAZ4QvIBIWAifvOb33AF9XohY+bgbOjt7aE//vFP9J3vfCceW/fBD36AJcyqqkr+DIJDOhYkv8suuyzezsmTp9gbOmGCLW08+eSTHH4CwsU5Sy8tJDr0Z926dRySASO6fABUVlZxO9/97nfpxRdfZDucdAbA+4kHBh4QA6EtGGVSWzUlhx4+mHqS/c9//nNX2EZ/gIQLjyVUe3h33/nOd7BtFWE5CLExhJZ9GBtamrjlllvi7+HtkpHyEq99rR0KgcmNRHDc7KkC5AR85CMfpa985avCZrWLQzhwrA996MP0+c9/gf7whz/QT3/6M84DRXgFyAfkAdvdTTfdLGxRH01oNzfXNsAj3KKmZhJHwIPMQCTIcgC5STIDQESwp8HZoW5H3ilsazj2K1/5KvH5VmE3C7MHFuSH9CEAoQqQBH/5y1+xKgoJTJI1PHywQf7bv/077yMlNzwIVq68iL23qASSDEBqgyGzVIFzBpmBxL7ylS/zg0ZeXxmyYZBdGEJLAUW5FuV4BB5kDUgpCDFFXmDyy+9hUxsMvvSlLzIpwPj+la98hSWYG2+8gX74wx+wKvfNb36TJQWQFgJg3/rWtwjP5Nf5t1ArIUG2tLQmtAvvIsgDdqHe3l6WupCoDkJEJoMXkLZAQF7jOPr3pje9kYNnEaoAL+t9993L0ikM39I7iLAN7AdyR0gJzkECx0MfoNr+5S8PcTydPCakP/wGhO1FXo6/BNofcobgznfi6s6wtCyBUAyM42AAlR/Xa9OmTWSQO
Line truncated
|
||||
/>
|
||||
</defs>
|
||||
</svg>
|
||||
`;
|
||||
@@ -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();
|
||||
+94
@@ -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,
|
||||
};
|
||||
}
|
||||
+157
@@ -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';
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface BlockSuiteFlags {
|
||||
enable_mobile_linked_doc_menu: boolean;
|
||||
enable_block_meta: boolean;
|
||||
enable_callout: boolean;
|
||||
enable_embed_iframe_block: boolean;
|
||||
}
|
||||
|
||||
export class FeatureFlagService extends StoreExtension {
|
||||
@@ -40,6 +41,7 @@ export class FeatureFlagService extends StoreExtension {
|
||||
enable_mobile_linked_doc_menu: false,
|
||||
enable_block_meta: false,
|
||||
enable_callout: false,
|
||||
enable_embed_iframe_block: false,
|
||||
});
|
||||
|
||||
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { BookmarkBlockComponent } from '@blocksuite/affine/blocks/bookmark';
|
||||
import {
|
||||
EmbedFigmaBlockComponent,
|
||||
EmbedGithubBlockComponent,
|
||||
EmbedIframeBlockComponent,
|
||||
EmbedLinkedDocBlockComponent,
|
||||
EmbedLoomBlockComponent,
|
||||
EmbedSyncedDocBlockComponent,
|
||||
@@ -818,6 +819,86 @@ const inlineReferenceToolbarConfig = {
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
const embedIframeToolbarConfig = {
|
||||
actions: [
|
||||
{
|
||||
id: 'a.copy-link-and-edit',
|
||||
actions: [
|
||||
{
|
||||
id: 'copy-link',
|
||||
tooltip: 'Copy link',
|
||||
icon: CopyIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentBlockComponentBy(
|
||||
BlockSelection,
|
||||
EmbedIframeBlockComponent
|
||||
)?.model;
|
||||
if (!model) return;
|
||||
|
||||
const { url } = model;
|
||||
|
||||
navigator.clipboard.writeText(url).catch(console.error);
|
||||
toast(ctx.host, 'Copied link to clipboard');
|
||||
|
||||
ctx.track('CopiedLink', {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
category: matchModels(model, [BookmarkBlockModel])
|
||||
? 'bookmark'
|
||||
: 'link',
|
||||
type: 'card view',
|
||||
control: 'copy link',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'edit',
|
||||
tooltip: 'Edit',
|
||||
icon: EditIcon(),
|
||||
run(ctx) {
|
||||
const component = ctx.getCurrentBlockComponentBy(
|
||||
BlockSelection,
|
||||
EmbedIframeBlockComponent
|
||||
);
|
||||
if (!component) return;
|
||||
|
||||
ctx.hide();
|
||||
|
||||
const model = component.model;
|
||||
const abortController = new AbortController();
|
||||
abortController.signal.onabort = () => ctx.show();
|
||||
|
||||
toggleEmbedCardEditModal(
|
||||
ctx.host,
|
||||
model,
|
||||
'card',
|
||||
undefined,
|
||||
undefined,
|
||||
(_std, _component, props) => {
|
||||
ctx.store.updateBlock(model, props);
|
||||
component.requestUpdate();
|
||||
},
|
||||
abortController
|
||||
);
|
||||
|
||||
ctx.track('OpenedAliasPopup', {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
category: matchModels(model, [BookmarkBlockModel])
|
||||
? 'bookmark'
|
||||
: 'link',
|
||||
type: 'card view',
|
||||
control: 'edit',
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const createCustomToolbarExtension = (
|
||||
baseUrl: string
|
||||
): ExtensionType[] => {
|
||||
@@ -866,5 +947,10 @@ export const createCustomToolbarExtension = (
|
||||
id: BlockFlavourIdentifier('custom:affine:reference'),
|
||||
config: inlineReferenceToolbarConfig,
|
||||
}),
|
||||
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('custom:affine:embed-iframe'),
|
||||
config: embedIframeToolbarConfig,
|
||||
}),
|
||||
];
|
||||
};
|
||||
@@ -123,6 +123,17 @@ export const AFFINE_FLAGS = {
|
||||
configurable: isCanaryBuild,
|
||||
defaultState: false,
|
||||
},
|
||||
enable_embed_iframe_block: {
|
||||
category: 'blocksuite',
|
||||
bsFlag: 'enable_embed_iframe_block',
|
||||
displayName:
|
||||
'com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.name',
|
||||
description:
|
||||
'com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.description',
|
||||
configurable: isCanaryBuild,
|
||||
defaultState: false,
|
||||
},
|
||||
|
||||
enable_emoji_folder_icon: {
|
||||
category: 'affine',
|
||||
displayName:
|
||||
|
||||
@@ -5403,6 +5403,14 @@ export function useAFFiNEI18N(): {
|
||||
* `Let your words stand out.`
|
||||
*/
|
||||
["com.affine.settings.workspace.experimental-features.enable-callout.description"](): string;
|
||||
/**
|
||||
* Embed Iframe Block
|
||||
*/
|
||||
["com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.name"](): string;
|
||||
/**
|
||||
* `Enables Embed Iframe Block.`
|
||||
*/
|
||||
["com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.description"](): string;
|
||||
/**
|
||||
* `Emoji Folder Icon`
|
||||
*/
|
||||
|
||||
@@ -1348,6 +1348,8 @@
|
||||
"com.affine.settings.workspace.experimental-features.enable-block-meta.description": "Once enabled, all blocks will have created time, updated time, created by and updated by.",
|
||||
"com.affine.settings.workspace.experimental-features.enable-callout.name": "Callout",
|
||||
"com.affine.settings.workspace.experimental-features.enable-callout.description": "Let your words stand out.",
|
||||
"com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.name": "Embed Iframe Block",
|
||||
"com.affine.settings.workspace.experimental-features.enable-embed-iframe-block.description": "Enables Embed Iframe Block.",
|
||||
"com.affine.settings.workspace.experimental-features.enable-emoji-folder-icon.name": "Emoji Folder Icon",
|
||||
"com.affine.settings.workspace.experimental-features.enable-emoji-folder-icon.description": "Once enabled, you can use an emoji as the folder icon. When the first character of the folder name is an emoji, it will be extracted and used as its icon.",
|
||||
"com.affine.settings.workspace.experimental-features.enable-emoji-doc-icon.name": "Emoji Doc Icon",
|
||||
|
||||
Reference in new issue
Block a user