import { getDocContentWithMaxLength } from '@blocksuite/affine-block-embed'; import { CaptionIcon, CenterPeekIcon, CopyIcon, EditIcon, ExpandFullSmallIcon, MoreVerticalIcon, 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 { cloneGroups, type MenuItem, type MenuItemGroup, renderGroups, renderToolbarSeparator, } from '@blocksuite/affine-components/toolbar'; import { type AliasInfo, type BookmarkBlockModel, BookmarkStyles, type EmbedGithubModel, type EmbedLinkedDocModel, type RootBlockModel, } from '@blocksuite/affine-model'; 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, WidgetComponent } from '@blocksuite/block-std'; import { type BlockModel, DocCollection } from '@blocksuite/store'; import { autoUpdate, computePosition, flip, offset } from '@floating-ui/dom'; import { html, nothing, type TemplateResult } from 'lit'; import { query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { join } from 'lit/directives/join.js'; import { repeat } from 'lit/directives/repeat.js'; import { toggleEmbedCardCaptionEditModal } from '../../../_common/components/embed-card/modal/embed-card-caption-edit-modal.js'; import { toggleEmbedCardEditModal } from '../../../_common/components/embed-card/modal/embed-card-edit-modal.js'; import { type EmbedBlockComponent, type EmbedModel, isEmbedCardBlockComponent, isInternalEmbedModel, } from '../../../_common/components/embed-card/type.js'; import type { EmbedCardStyle } from '../../../_common/types.js'; import { getEmbedCardIcons } from '../../../_common/utils/url.js'; import { getMoreMenuConfig } from '../../configs/toolbar.js'; import { isBookmarkBlock, isEmbedGithubBlock, isEmbedHtmlBlock, isEmbedLinkedDocBlock, isEmbedSyncedDocBlock, } from '../../edgeless/utils/query.js'; import type { RootBlockComponent } from '../../types.js'; import { BUILT_IN_GROUPS } from './config.js'; import { EmbedCardToolbarContext } from './context.js'; import { embedCardToolbarStyle } from './styles.js'; export const AFFINE_EMBED_CARD_TOOLBAR_WIDGET = 'affine-embed-card-toolbar'; export class EmbedCardToolbar extends WidgetComponent< RootBlockModel, RootBlockComponent > { static override styles = embedCardToolbarStyle; private _abortController = new AbortController(); private readonly _copyUrl = () => { const model = this.focusModel; if (!model) return; let url!: ReturnType; const isInternal = isInternalEmbedModel(model); if ('url' in model) { url = model.url; } else if (isInternal) { url = this.std .getOptional(GenerateDocUrlProvider) ?.generateDocUrl(model.pageId, model.params); } if (!url) return; navigator.clipboard.writeText(url).catch(console.error); toast(this.std.host, 'Copied link to clipboard'); track(this.std, model, this._viewType, 'CopiedLink', { control: 'copy link', }); }; private _embedOptions: EmbedOptions | null = null; private readonly _openEditPopup = (e: MouseEvent) => { e.stopPropagation(); const model = this.focusModel; if (!model || isEmbedHtmlBlock(model)) return; const originalDocInfo = this._originalDocInfo; this._hide(); toggleEmbedCardEditModal(this.host, model, this._viewType, originalDocInfo); track(this.std, model, this._viewType, 'OpenedAliasPopup', { control: 'edit', }); }; private readonly _resetAbortController = () => { this._abortController.abort(); this._abortController = new AbortController(); }; private readonly _showCaption = () => { const focusBlock = this.focusBlock; if (!focusBlock) { return; } try { focusBlock.captionEditor?.show(); } catch { toggleEmbedCardCaptionEditModal(focusBlock); } this._resetAbortController(); const model = this.focusModel; if (!model) return; track(this.std, model, this._viewType, 'OpenedCaptionEditor', { control: 'add caption', }); }; private readonly _toggleCardStyleSelector = (e: Event) => { const opened = (e as CustomEvent).detail; if (!opened) return; const model = this.focusModel; if (!model) return; track(this.std, model, this._viewType, 'OpenedCardStyleSelector', { control: 'switch card style', }); }; private readonly _toggleViewSelector = (e: Event) => { const opened = (e as CustomEvent).detail; if (!opened) return; const model = this.focusModel; if (!model) return; track(this.std, model, this._viewType, 'OpenedViewSelector', { control: 'switch view', }); }; private readonly _trackViewSelected = (type: string) => { const model = this.focusModel; if (!model) return; track(this.std, model, this._viewType, 'SelectedView', { control: 'selected view', type: `${type} view`, }); }; /* * Caches the more menu items. * Currently only supports configuring more menu. */ moreGroups: MenuItemGroup[] = cloneGroups(BUILT_IN_GROUPS); private get _canConvertToEmbedView() { // synced doc entry controlled by awareness flag if (this.focusModel && isEmbedLinkedDocBlock(this.focusModel)) { const isSyncedDocEnabled = this.doc.awarenessStore.getFlag( 'enable_synced_doc_block' ); if (!isSyncedDocEnabled) { return false; } } if (!this.focusBlock) return false; return ( 'convertToEmbed' in this.focusBlock || this._embedOptions?.viewType === 'embed' ); } private get _canShowUrlOptions() { return this.focusModel && 'url' in this.focusModel && this._isCardView; } private get _embedViewButtonDisabled() { if (this.doc.readonly) { return true; } return ( this.focusModel && this.focusBlock && isEmbedLinkedDocBlock(this.focusModel) && (referenceToNode(this.focusModel) || !!this.focusBlock.closest('affine-embed-synced-doc-block') || this.focusModel.pageId === this.doc.id) ); } private get _isCardView() { return ( this.focusModel && (isBookmarkBlock(this.focusModel) || isEmbedLinkedDocBlock(this.focusModel) || this._embedOptions?.viewType === 'card') ); } private get _isEmbedView() { return ( this.focusModel && !isBookmarkBlock(this.focusModel) && (isEmbedSyncedDocBlock(this.focusModel) || this._embedOptions?.viewType === 'embed') ); } get _openButtonDisabled() { return ( this.focusModel && isEmbedLinkedDocBlock(this.focusModel) && this.focusModel.pageId === this.doc.id ); } get _originalDocInfo(): AliasInfo | undefined { const model = this.focusModel; if (!model) return undefined; 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.focusModel; if (!model) return undefined; const doc = isInternalEmbedModel(model) ? this.std.collection.getDoc(model.pageId) : null; return doc?.meta?.title || 'Untitled'; } private get _selection() { return this.host.selection; } private get _viewType(): 'inline' | 'embed' | 'card' { if (this._isCardView) { return 'card'; } if (this._isEmbedView) { return 'embed'; } return 'inline'; } get focusModel(): EmbedModel | undefined { return this.focusBlock?.model; } private _canShowCardStylePanel( model: BlockModel ): model is BookmarkBlockModel | EmbedGithubModel | EmbedLinkedDocModel { return ( isBookmarkBlock(model) || isEmbedGithubBlock(model) || isEmbedLinkedDocBlock(model) ); } private _cardStyleSelector() { const model = this.focusModel; if (!model) return nothing; if (!this._canShowCardStylePanel(model)) return nothing; const theme = this.std.get(ThemeProvider).theme; const { EmbedCardHorizontalIcon, EmbedCardListIcon } = getEmbedCardIcons(theme); const buttons = [ { type: 'horizontal', label: 'Large horizontal style', icon: EmbedCardHorizontalIcon, }, { type: 'list', label: 'Small horizontal style', icon: EmbedCardListIcon, }, ] as { type: EmbedCardStyle; label: string; icon: TemplateResult<1>; }[]; return html` ${PaletteIcon} `} @toggle=${this._toggleCardStyleSelector} >
${repeat( buttons, button => button.type, ({ type, label, icon }) => html` this._setEmbedCardStyle(type)} > ${icon} ${label} ` )}
`; } private _convertToCardView() { if (this._isCardView) { return; } if (!this.focusBlock) { return; } if ('convertToCard' in this.focusBlock) { this.focusBlock.convertToCard(); return; } if (!this.focusModel || !('url' in this.focusModel)) { return; } const targetModel = this.focusModel; const { doc, url, style, caption } = targetModel; 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.filter( style => style !== 'vertical' && style !== 'cube' )[0]; } const parent = doc.getParent(targetModel); if (!parent) return; const index = parent.children.indexOf(targetModel); doc.addBlock( targetFlavour as never, { url, style: targetStyle, caption }, parent, index ); this.std.selection.setGroup('note', []); doc.deleteBlock(targetModel); } private _convertToEmbedView() { if (this._isEmbedView) { return; } if (!this.focusBlock) { return; } if ('convertToEmbed' in this.focusBlock) { const referenceInfo = this.focusBlock.referenceInfo$.peek(); this.focusBlock.convertToEmbed(); if (referenceInfo.title || referenceInfo.description) { notifyLinkedDocSwitchedToEmbed(this.std); } return; } if (!this.focusModel || !('url' in this.focusModel)) { return; } const targetModel = this.focusModel; const { doc, url, style, caption } = targetModel; if (!this._embedOptions || this._embedOptions.viewType !== 'embed') { return; } const { flavour, styles } = this._embedOptions; const targetStyle = styles.includes(style) ? style : styles.filter(style => style !== 'vertical' && style !== 'cube')[0]; const parent = doc.getParent(targetModel); if (!parent) return; const index = parent.children.indexOf(targetModel); doc.addBlock( flavour as never, { url, style: targetStyle, caption }, parent, index ); this.std.selection.setGroup('note', []); doc.deleteBlock(targetModel); } private _hide() { this._resetAbortController(); this.focusBlock = null; this.hide = true; } private _moreActions() { if (!this.focusBlock) return nothing; const context = new EmbedCardToolbarContext( this.focusBlock, this._abortController ); return renderGroups(this.moreGroups, context); } private _openMenuButton() { const buttons: MenuItem[] = []; if ( this.focusModel && (isEmbedLinkedDocBlock(this.focusModel) || isEmbedSyncedDocBlock(this.focusModel)) ) { buttons.push({ type: 'open-this-doc', label: 'Open this doc', icon: ExpandFullSmallIcon, action: () => this.focusBlock?.open(), }); } // open in new tab const element = this.focusBlock; if (element && isPeekable(element)) { buttons.push({ type: 'open-in-center-peek', label: 'Open in center peek', icon: CenterPeekIcon, action: () => peek(element), }); } // 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 _setEmbedCardStyle(style: EmbedCardStyle) { const model = this.focusModel; if (!model) return; model.doc.updateBlock(model, { style }); this.requestUpdate(); this._abortController.abort(); track(this.std, model, this._viewType, 'SelectedCardStyle', { control: 'select card style', type: style, }); } private _show() { if (!this.focusBlock) { return; } this.hide = false; this._abortController.signal.addEventListener( 'abort', autoUpdate(this.focusBlock, this, () => { if (!this.focusBlock) { return; } computePosition(this.focusBlock, this, { placement: 'top-start', middleware: [flip(), offset(8)], }) .then(({ x, y }) => { this.style.left = `${x}px`; this.style.top = `${y}px`; }) .catch(console.error); }) ); } private _turnIntoInlineView() { if (this.focusBlock && 'covertToInline' in this.focusBlock) { this.focusBlock.covertToInline(); return; } if (!this.focusModel || !('url' in this.focusModel)) { return; } const targetModel = this.focusModel; const { doc, title, caption, url } = targetModel; const parent = doc.getParent(targetModel); const index = parent?.children.indexOf(targetModel); const yText = new DocCollection.Y.Text(); const insert = title || caption || url; yText.insert(0, insert); yText.format(0, insert.length, { link: url }); const text = new doc.Text(yText); doc.addBlock( 'affine:paragraph', { text, }, parent, index ); doc.deleteBlock(targetModel); } private _viewSelector() { const buttons = []; buttons.push({ type: 'inline', label: 'Inline view', action: () => this._turnIntoInlineView(), disabled: this.doc.readonly, }); buttons.push({ type: 'card', label: 'Card view', action: () => this._convertToCardView(), disabled: this.doc.readonly, }); if (this._canConvertToEmbedView || this._isEmbedView) { buttons.push({ type: 'embed', label: 'Embed view', action: () => this._convertToEmbedView(), disabled: this.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); this._hide(); }} > ${label} ` )}
`; } override connectedCallback() { super.connectedCallback(); this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups); this.disposables.add( this._selection.slots.changed.on(() => { const hasTextSelection = this._selection.find('text'); if (hasTextSelection) { this._hide(); return; } const blockSelections = this._selection.filter('block'); if (!blockSelections || blockSelections.length !== 1) { this._hide(); return; } const block = this.std.view.getBlock(blockSelections[0].blockId); if (!block || !isEmbedCardBlockComponent(block)) { this._hide(); return; } this.focusBlock = block as EmbedBlockComponent; this._show(); }) ); } override render() { if (this.hide) return nothing; const model = this.focusModel; if (!model) return nothing; this._embedOptions = 'url' in model ? this.std.get(EmbedOptionProvider).getEmbedBlockOptions(model.url) : null; const hasUrl = this._canShowUrlOptions && 'url' in model; const buttons = [ this._openMenuButton(), hasUrl ? html` ${getHostName(model.url)} ` : nothing, // internal embed model isEmbedLinkedDocBlock(model) && model.title ? html` ${this._originalDocTitle} ` : nothing, isEmbedHtmlBlock(model) ? nothing : html` ${CopyIcon} ${EditIcon} `, this._viewSelector(), this._cardStyleSelector(), html` ${CaptionIcon} `, html` ${MoreVerticalIcon} `} >
${this._moreActions()}
`, ]; return html` ${join( buttons.filter(button => button !== nothing), renderToolbarSeparator )} `; } @query('.embed-card-toolbar-button.card-style') accessor cardStyleButton: HTMLElement | null = null; @query('.embed-card-toolbar') accessor embedCardToolbarElement!: HTMLElement; @state() accessor focusBlock: EmbedBlockComponent | null = null; @state() accessor hide: boolean = true; @query('.embed-card-toolbar-button.more-button') accessor moreButton: HTMLElement | null = null; } declare global { interface HTMLElementTagNameMap { [AFFINE_EMBED_CARD_TOOLBAR_WIDGET]: EmbedCardToolbar; } } function track( std: BlockStdScope, model: EmbedModel, viewType: string, event: LinkEventType, props: Partial ) { std.getOptional(TelemetryProvider)?.track(event, { segment: 'toolbar', page: 'doc editor', module: 'embed card toolbar', type: `${viewType} view`, category: isInternalEmbedModel(model) ? 'linked doc' : 'link', ...props, }); }