import type { BuiltInEmbedBlockComponent, BuiltInEmbedModel, } from '@blocksuite/affine-block-bookmark'; import { isInternalEmbedModel, toggleEmbedCardEditModal, } from '@blocksuite/affine-block-bookmark'; import { getDocContentWithMaxLength, getEmbedCardIcons, } from '@blocksuite/affine-block-embed'; import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface'; import { CaptionIcon, CenterPeekIcon, CopyIcon, EditIcon, ExpandFullSmallIcon, OpenIcon, PaletteIcon, SmallArrowDownIcon, } from '@blocksuite/affine-components/icons'; import { notifyLinkedDocSwitchedToEmbed } from '@blocksuite/affine-components/notification'; import { isPeekable, peek } from '@blocksuite/affine-components/peek'; import { toast } from '@blocksuite/affine-components/toast'; import { type MenuItem, renderToolbarSeparator, } from '@blocksuite/affine-components/toolbar'; import { type AliasInfo, BookmarkStyles, type EmbedCardStyle, } from '@blocksuite/affine-model'; import { EMBED_CARD_HEIGHT, EMBED_CARD_WIDTH, } from '@blocksuite/affine-shared/consts'; import { EmbedOptionProvider, type EmbedOptions, GenerateDocUrlProvider, type GenerateDocUrlService, type LinkEventType, type TelemetryEvent, TelemetryProvider, ThemeProvider, } from '@blocksuite/affine-shared/services'; import { getHostName, referenceToNode } from '@blocksuite/affine-shared/utils'; import type { BlockStdScope } from '@blocksuite/block-std'; import { Bound, WithDisposable } from '@blocksuite/global/utils'; import { css, html, LitElement, nothing, type TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { join } from 'lit/directives/join.js'; import { repeat } from 'lit/directives/repeat.js'; import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; import { isBookmarkBlock, isEmbedGithubBlock, isEmbedHtmlBlock, isEmbedLinkedDocBlock, isEmbedSyncedDocBlock, } from '../../edgeless/utils/query.js'; export class EdgelessChangeEmbedCardButton extends WithDisposable(LitElement) { static override styles = css` .affine-link-preview { display: flex; justify-content: flex-start; width: 140px; padding: var(--1, 0px); border-radius: var(--1, 0px); opacity: var(--add, 1); user-select: none; cursor: pointer; color: var(--affine-link-color); font-feature-settings: 'clig' off, 'liga' off; font-family: var(--affine-font-family); font-size: var(--affine-font-sm); font-style: normal; font-weight: 400; text-decoration: none; text-wrap: nowrap; } .affine-link-preview > span { display: inline-block; -webkit-line-clamp: 1; -webkit-box-orient: vertical; text-overflow: ellipsis; overflow: hidden; opacity: var(--add, 1); } editor-icon-button.doc-title .label { max-width: 110px; display: inline-block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; user-select: none; cursor: pointer; color: var(--affine-link-color); font-feature-settings: 'clig' off, 'liga' off; font-family: var(--affine-font-family); font-size: var(--affine-font-sm); font-style: normal; font-weight: 400; text-decoration: none; text-wrap: nowrap; } `; get crud() { return this.edgeless.std.get(EdgelessCRUDIdentifier); } private readonly _convertToCardView = () => { if (this._isCardView) { return; } const block = this._blockComponent; if (block && 'convertToCard' in block) { block.convertToCard(); return; } if (!('url' in this.model)) { return; } const { id, url, xywh, style, caption } = this.model; let targetFlavour = 'affine:bookmark', targetStyle = style; if (this._embedOptions && this._embedOptions.viewType === 'card') { const { flavour, styles } = this._embedOptions; targetFlavour = flavour; targetStyle = styles.includes(style) ? style : styles[0]; } else { targetStyle = BookmarkStyles.includes(style) ? style : BookmarkStyles[0]; } const bound = Bound.deserialize(xywh); bound.w = EMBED_CARD_WIDTH[targetStyle]; bound.h = EMBED_CARD_HEIGHT[targetStyle]; const newId = this.crud.addBlock( targetFlavour, { url, xywh: bound.serialize(), style: targetStyle, caption }, this.edgeless.surface.model ); this.std.command.exec('reassociateConnectors', { oldId: id, newId, }); this.edgeless.service.selection.set({ editing: false, elements: [newId], }); this._doc.deleteBlock(this.model); }; private readonly _convertToEmbedView = () => { if (this._isEmbedView) { return; } const block = this._blockComponent; if (block && 'convertToEmbed' in block) { const referenceInfo = block.referenceInfo$.peek(); block.convertToEmbed(); if (referenceInfo.title || referenceInfo.description) notifyLinkedDocSwitchedToEmbed(this.std); return; } if (!('url' in this.model)) { return; } if (!this._embedOptions) return; const { flavour, styles } = this._embedOptions; const { id, url, xywh, style } = this.model; const targetStyle = styles.includes(style) ? style : styles[0]; const bound = Bound.deserialize(xywh); bound.w = EMBED_CARD_WIDTH[targetStyle]; bound.h = EMBED_CARD_HEIGHT[targetStyle]; const newId = this.crud.addBlock( flavour, { url, xywh: bound.serialize(), style: targetStyle, }, this.edgeless.surface.model ); if (!newId) return; this.std.command.exec('reassociateConnectors', { oldId: id, newId, }); this.edgeless.service.selection.set({ editing: false, elements: [newId], }); this._doc.deleteBlock(this.model); }; private readonly _copyUrl = () => { let url!: ReturnType; if ('url' in this.model) { url = this.model.url; } else if (isInternalEmbedModel(this.model)) { url = this.std .getOptional(GenerateDocUrlProvider) ?.generateDocUrl(this.model.pageId, this.model.params); } if (!url) return; navigator.clipboard.writeText(url).catch(console.error); toast(this.std.host, 'Copied link to clipboard'); this.edgeless.service.selection.clear(); track(this.std, this.model, this._viewType, 'CopiedLink', { control: 'copy link', }); }; private _embedOptions: EmbedOptions | null = null; private readonly _getScale = () => { if ('scale' in this.model) { return this.model.scale ?? 1; } else if (isEmbedHtmlBlock(this.model)) { return 1; } const bound = Bound.deserialize(this.model.xywh); return bound.h / EMBED_CARD_HEIGHT[this.model.style]; }; private readonly _open = () => { this._blockComponent?.open(); }; private readonly _openEditPopup = (e: MouseEvent) => { e.stopPropagation(); if (isEmbedHtmlBlock(this.model)) return; this.std.selection.clear(); const originalDocInfo = this._originalDocInfo; toggleEmbedCardEditModal( this.std.host, this.model, this._viewType, originalDocInfo ); track(this.std, this.model, this._viewType, 'OpenedAliasPopup', { control: 'edit', }); }; private readonly _peek = () => { if (!this._blockComponent) return; peek(this._blockComponent); }; private readonly _setCardStyle = (style: EmbedCardStyle) => { const bounds = Bound.deserialize(this.model.xywh); bounds.w = EMBED_CARD_WIDTH[style]; bounds.h = EMBED_CARD_HEIGHT[style]; const xywh = bounds.serialize(); this.model.doc.updateBlock(this.model, { style, xywh }); track(this.std, this.model, this._viewType, 'SelectedCardStyle', { control: 'select card style', type: style, }); }; private readonly _setEmbedScale = (scale: number) => { if (isEmbedHtmlBlock(this.model)) return; const bound = Bound.deserialize(this.model.xywh); if ('scale' in this.model) { const oldScale = this.model.scale ?? 1; const ratio = scale / oldScale; bound.w *= ratio; bound.h *= ratio; const xywh = bound.serialize(); this.model.doc.updateBlock(this.model, { scale, xywh }); } else { bound.h = EMBED_CARD_HEIGHT[this.model.style] * scale; bound.w = EMBED_CARD_WIDTH[this.model.style] * scale; const xywh = bound.serialize(); this.model.doc.updateBlock(this.model, { xywh }); } this._embedScale = scale; track(this.std, this.model, this._viewType, 'SelectedCardScale', { control: 'select card scale', type: `${scale}`, }); }; private readonly _toggleCardScaleSelector = (e: Event) => { const opened = (e as CustomEvent).detail; if (!opened) return; track(this.std, this.model, this._viewType, 'OpenedCardScaleSelector', { control: 'switch card scale', }); }; private readonly _toggleCardStyleSelector = (e: Event) => { const opened = (e as CustomEvent).detail; if (!opened) return; track(this.std, this.model, this._viewType, 'OpenedCardStyleSelector', { control: 'switch card style', }); }; private readonly _toggleViewSelector = (e: Event) => { const opened = (e as CustomEvent).detail; if (!opened) return; track(this.std, this.model, this._viewType, 'OpenedViewSelector', { control: 'switch view', }); }; private readonly _trackViewSelected = (type: string) => { track(this.std, this.model, this._viewType, 'SelectedView', { control: 'select view', type: `${type} view`, }); }; private get _blockComponent() { const blockSelection = this.edgeless.service.selection.surfaceSelections.filter(sel => sel.elements.includes(this.model.id) ); if (blockSelection.length !== 1) { return; } const blockComponent = this.std.view.getBlock( blockSelection[0].blockId ) as BuiltInEmbedBlockComponent | null; if (!blockComponent) return; return blockComponent; } private get _canConvertToEmbedView() { const block = this._blockComponent; // synced doc entry controlled by awareness flag if (!!block && isEmbedLinkedDocBlock(block.model)) { const isSyncedDocEnabled = block.doc.awarenessStore.getFlag( 'enable_synced_doc_block' ); if (!isSyncedDocEnabled) { return false; } } return ( (block && 'convertToEmbed' in block) || this._embedOptions?.viewType === 'embed' ); } private get _canShowCardStylePanel() { return ( isBookmarkBlock(this.model) || isEmbedGithubBlock(this.model) || isEmbedLinkedDocBlock(this.model) ); } private get _canShowFullScreenButton() { return isEmbedHtmlBlock(this.model); } private get _canShowUrlOptions() { return ( 'url' in this.model && (isBookmarkBlock(this.model) || isEmbedGithubBlock(this.model) || isEmbedLinkedDocBlock(this.model)) ); } private get _doc() { return this.model.doc; } private get _embedViewButtonDisabled() { if (this._doc.readonly) { return true; } return ( isEmbedLinkedDocBlock(this.model) && (referenceToNode(this.model) || !!this._blockComponent?.closest('affine-embed-synced-doc-block') || this.model.pageId === this._doc.id) ); } private get _getCardStyleOptions(): { style: EmbedCardStyle; Icon: TemplateResult<1>; tooltip: string; }[] { const theme = this.std.get(ThemeProvider).theme; const { EmbedCardHorizontalIcon, EmbedCardListIcon, EmbedCardVerticalIcon, EmbedCardCubeIcon, } = getEmbedCardIcons(theme); return [ { style: 'horizontal', Icon: EmbedCardHorizontalIcon, tooltip: 'Large horizontal style', }, { style: 'list', Icon: EmbedCardListIcon, tooltip: 'Small horizontal style', }, { style: 'vertical', Icon: EmbedCardVerticalIcon, tooltip: 'Large vertical style', }, { style: 'cube', Icon: EmbedCardCubeIcon, tooltip: 'Small vertical style', }, ]; } private get _isCardView() { if (isBookmarkBlock(this.model) || isEmbedLinkedDocBlock(this.model)) { return true; } return this._embedOptions?.viewType === 'card'; } private get _isEmbedView() { return ( !isBookmarkBlock(this.model) && (isEmbedSyncedDocBlock(this.model) || this._embedOptions?.viewType === 'embed') ); } get _openButtonDisabled() { return ( isEmbedLinkedDocBlock(this.model) && this.model.pageId === this._doc.id ); } get _originalDocInfo(): AliasInfo | undefined { const model = this.model; const doc = isInternalEmbedModel(model) ? this.std.collection.getDoc(model.pageId) : null; if (doc) { const title = doc.meta?.title; const description = isEmbedLinkedDocBlock(model) ? getDocContentWithMaxLength(doc) : undefined; return { title, description }; } return undefined; } get _originalDocTitle() { const model = this.model; const doc = isInternalEmbedModel(model) ? this.std.collection.getDoc(model.pageId) : null; return doc?.meta?.title || 'Untitled'; } private get _viewType(): 'inline' | 'embed' | 'card' { if (this._isCardView) { return 'card'; } if (this._isEmbedView) { return 'embed'; } // unreachable return 'inline'; } private get std() { return this.edgeless.std; } private _openMenuButton() { const buttons: MenuItem[] = []; if ( isEmbedLinkedDocBlock(this.model) || isEmbedSyncedDocBlock(this.model) ) { buttons.push({ type: 'open-this-doc', label: 'Open this doc', icon: ExpandFullSmallIcon, action: this._open, disabled: this._openButtonDisabled, }); } else if (this._canShowFullScreenButton) { buttons.push({ type: 'open-this-doc', label: 'Open this doc', icon: ExpandFullSmallIcon, action: this._open, }); } // open in new tab if (this._blockComponent && isPeekable(this._blockComponent)) { buttons.push({ type: 'open-in-center-peek', label: 'Open in center peek', icon: CenterPeekIcon, action: () => this._peek(), }); } // open in split view if (buttons.length === 0) { return nothing; } return html` ${OpenIcon}${SmallArrowDownIcon} `} >
${repeat( buttons, button => button.label, ({ label, icon, action, disabled }) => html` ${icon}${label} ` )}
`; } private _showCaption() { this._blockComponent?.captionEditor?.show(); track(this.std, this.model, this._viewType, 'OpenedCaptionEditor', { control: 'add caption', }); } private _viewSelector() { if (this._canConvertToEmbedView || this._isEmbedView) { const buttons = [ { type: 'card', label: 'Card view', action: () => this._convertToCardView(), disabled: this.model.doc.readonly, }, { type: 'embed', label: 'Embed view', action: () => this._convertToEmbedView(), disabled: this.model.doc.readonly || this._embedViewButtonDisabled, }, ]; return html`
${this._viewType} view
${SmallArrowDownIcon} `} @toggle=${this._toggleViewSelector} >
${repeat( buttons, button => button.type, ({ type, label, action, disabled }) => html` { action(); this._trackViewSelected(type); }} > ${label} ` )}
`; } return nothing; } override connectedCallback() { super.connectedCallback(); this._embedScale = this._getScale(); } override render() { const model = this.model; const isHtmlBlockModel = isEmbedHtmlBlock(model); if ('url' in this.model) { this._embedOptions = this.std .get(EmbedOptionProvider) .getEmbedBlockOptions(this.model.url); } const buttons = [ this._openMenuButton(), this._canShowUrlOptions && 'url' in model ? html` ${getHostName(model.url)} ` : nothing, // internal embed model isEmbedLinkedDocBlock(model) && model.title ? html` ${this._originalDocTitle} ` : nothing, isHtmlBlockModel ? nothing : html` ${CopyIcon} ${EditIcon} `, this._viewSelector(), 'style' in model && this._canShowCardStylePanel ? html` ${PaletteIcon} `} @toggle=${this._toggleCardStyleSelector} > ` : nothing, 'caption' in model ? html` ${CaptionIcon} ` : nothing, this.quickConnectButton, isHtmlBlockModel ? nothing : html` ${Math.round(this._embedScale * 100) + '%'} ${SmallArrowDownIcon} `} @toggle=${this._toggleCardScaleSelector} > `, ]; return join( buttons.filter(button => button !== nothing), renderToolbarSeparator ); } @state() private accessor _embedScale = 1; @property({ attribute: false }) accessor edgeless!: EdgelessRootBlockComponent; @property({ attribute: false }) accessor model!: BuiltInEmbedModel; @property({ attribute: false }) accessor quickConnectButton!: TemplateResult<1> | typeof nothing; } export function renderEmbedButton( edgeless: EdgelessRootBlockComponent, models?: EdgelessChangeEmbedCardButton['model'][], quickConnectButton?: TemplateResult<1>[] ) { if (models?.length !== 1) return nothing; return html` `; } function track( std: BlockStdScope, model: BuiltInEmbedModel, viewType: string, event: LinkEventType, props: Partial ) { std.getOptional(TelemetryProvider)?.track(event, { segment: 'toolbar', page: 'whiteboard editor', module: 'element toolbar', type: `${viewType} view`, category: isInternalEmbedModel(model) ? 'linked doc' : 'link', ...props, }); }