import { toast } from '@blocksuite/affine-components/toast'; import { EditorChevronDown } from '@blocksuite/affine-components/toolbar'; import { DEFAULT_NOTE_HEIGHT, DEFAULT_NOTE_WIDTH, EmbedSyncedDocModel, NoteBlockModel, NoteDisplayMode, type NoteProps, type ParagraphProps, } from '@blocksuite/affine-model'; import { draftSelectedModelsCommand, duplicateSelectedModelsCommand, } from '@blocksuite/affine-shared/commands'; import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts'; import { ActionPlacement, EditorSettingProvider, type LinkEventType, type OpenDocMode, type ToolbarAction, type ToolbarActionGroup, type ToolbarContext, type ToolbarModuleConfig, ToolbarModuleExtension, } from '@blocksuite/affine-shared/services'; import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; import { getBlockProps, matchModels } from '@blocksuite/affine-shared/utils'; import { Bound } from '@blocksuite/global/gfx'; import { CaptionIcon, CopyIcon, DeleteIcon, DuplicateIcon, ExpandFullIcon, InsertIntoPageIcon, OpenInNewIcon, } from '@blocksuite/icons/lit'; import { BlockFlavourIdentifier, isGfxBlockComponent } from '@blocksuite/std'; import { type BlockModel, type ExtensionType, Slice, Text, } from '@blocksuite/store'; import { computed, signal } from '@preact/signals-core'; import { html } from 'lit'; import { ifDefined } from 'lit/directives/if-defined.js'; import { keyed } from 'lit/directives/keyed.js'; import { repeat } from 'lit/directives/repeat.js'; import { EmbedSyncedDocBlockComponent } from '../embed-synced-doc-block'; const trackBaseProps = { category: 'linked doc', type: 'embed view', }; const createOnToggleFn = ( ctx: ToolbarContext, name: Extract< LinkEventType, 'OpenedViewSelector' | 'OpenedCardScaleSelector' >, control: 'switch view' | 'switch card scale' ) => (e: CustomEvent) => { e.stopPropagation(); const opened = e.detail; if (!opened) return; ctx.track(name, { ...trackBaseProps, control }); }; const openDocActions = [ { mode: 'open-in-active-view', id: 'a.open-in-active-view', label: 'Open this doc', icon: ExpandFullIcon(), }, ] as const satisfies (Pick & { mode: OpenDocMode; })[]; const openDocActionGroup = { placement: ActionPlacement.Start, id: 'A.open-doc', content(ctx) { const block = ctx.getCurrentBlockByType(EmbedSyncedDocBlockComponent); if (!block) return null; const actions = openDocActions.map(action => { const openMode = action.mode; const shouldOpenInActiveView = openMode === 'open-in-active-view'; return { ...action, disabled: shouldOpenInActiveView ? block.model.props.pageId === ctx.store.id : false, when: true, run: (_ctx: ToolbarContext) => block.open({ openMode }), }; }); return html` ${OpenInNewIcon()} ${EditorChevronDown} `} >
${repeat( actions, action => action.id, ({ label, icon, run, disabled }) => html` run?.(ctx)} > ${icon}${label} ` )}
`; }, } as const satisfies ToolbarAction; const conversionsActionGroup = { id: 'a.conversions', actions: [ { id: 'inline', label: 'Inline view', run(ctx) { const block = ctx.getCurrentBlockByType(EmbedSyncedDocBlockComponent); block?.convertToInline(); // Clears ctx.select('note'); ctx.reset(); ctx.track('SelectedView', { ...trackBaseProps, control: 'select view', type: 'inline view', }); }, when: ctx => !ctx.hasSelectedSurfaceModels, }, { id: 'card', label: 'Card view', run(ctx) { const block = ctx.getCurrentBlockByType(EmbedSyncedDocBlockComponent); if (isGfxBlockComponent(block)) { const editorSetting = ctx.std.getOptional(EditorSettingProvider); editorSetting?.set?.( 'docCanvasPreferView', 'affine:embed-linked-doc' ); } block?.convertToCard(); ctx.track('SelectedView', { ...trackBaseProps, control: 'select view', type: 'card view', }); }, }, { id: 'embed', label: 'Embed view', disabled: true, }, ], content(ctx) { const model = ctx.getCurrentModelByType(EmbedSyncedDocModel); if (!model) return null; const actions = this.actions.map(action => ({ ...action })); const viewType$ = signal('Embed view'); const onToggle = createOnToggleFn(ctx, 'OpenedViewSelector', 'switch view'); return html`${keyed( model, html`` )}`; }, } as const satisfies ToolbarActionGroup; const captionAction = { id: 'd.caption', tooltip: 'Caption', icon: CaptionIcon(), run(ctx) { const block = ctx.getCurrentBlockByType(EmbedSyncedDocBlockComponent); block?.captionEditor?.show(); ctx.track('OpenedCaptionEditor', { ...trackBaseProps, control: 'add caption', }); }, } as const satisfies ToolbarAction; const builtinToolbarConfig = { actions: [ openDocActionGroup, conversionsActionGroup, captionAction, { placement: ActionPlacement.More, id: 'a.clipboard', actions: [ { id: 'copy', label: 'Copy', icon: CopyIcon(), run(ctx) { const model = ctx.getCurrentModelByType(EmbedSyncedDocModel); 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.getCurrentModelByType(EmbedSyncedDocModel); 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: 'c.delete', label: 'Delete', icon: DeleteIcon(), variant: 'destructive', run(ctx) { const model = ctx.getCurrentModelByType(EmbedSyncedDocModel); if (!model) return; ctx.store.deleteBlock(model); // Clears ctx.select('note'); ctx.reset(); }, }, ], } as const satisfies ToolbarModuleConfig; const builtinSurfaceToolbarConfig = { actions: [ openDocActionGroup, conversionsActionGroup, { id: 'b.insert-to-page', label: 'Insert to page', tooltip: 'Insert to page', icon: InsertIntoPageIcon(), run: ctx => { const model = ctx.getCurrentModelByType(EmbedSyncedDocModel); if (!model) return; const lastVisibleNote = ctx.store .getModelsByFlavour('affine:note') .findLast( (note): note is NoteBlockModel => matchModels(note, [NoteBlockModel]) && note.props.displayMode !== NoteDisplayMode.EdgelessOnly ); ctx.store.captureSync(); ctx.chain .pipe(duplicateSelectedModelsCommand, { selectedModels: [model], parentModel: lastVisibleNote, }) .run(); ctx.track('BlockCreated', { page: 'whiteboard editor', module: 'toolbar', segment: 'toolbar', blockType: 'affine:embed-linked-doc', control: 'toolbar:general', other: 'insert to page', }); }, }, { id: 'c.duplicate-as-note', label: 'Duplicate as note', tooltip: 'Duplicate as note to create an editable copy, the original remains unchanged.', icon: DuplicateIcon(), run: ctx => { const { gfx } = ctx; const syncedDocModel = ctx.getCurrentModelByType(EmbedSyncedDocModel); if (!syncedDocModel) return; let contentModels: BlockModel[] = []; { const doc = ctx.store.workspace.getDoc(syncedDocModel.props.pageId); // TODO(@L-Sun): clear query cache const store = doc?.getStore({ readonly: true }); if (!store) return; contentModels = store .getModelsByFlavour('affine:note') .filter( (note): note is NoteBlockModel => matchModels(note, [NoteBlockModel]) && note.props.displayMode !== NoteDisplayMode.EdgelessOnly ) .flatMap(note => note.children); } if (contentModels.length === 0) return; ctx.store.captureSync(); ctx.chain .pipe(draftSelectedModelsCommand, { selectedModels: contentModels, }) .pipe(({ std, draftedModels }, next) => { (async () => { const PADDING = 20; const x = syncedDocModel.elementBound.x + syncedDocModel.elementBound.w + PADDING; const y = syncedDocModel.elementBound.y; const children = await draftedModels; const noteId = std.store.addBlock( 'affine:note', { xywh: new Bound( x, y, DEFAULT_NOTE_WIDTH, DEFAULT_NOTE_HEIGHT ).serialize(), index: gfx.layer.generateIndex(), displayMode: NoteDisplayMode.EdgelessOnly, } satisfies Partial, ctx.store.root ); std.store.addBlock( 'affine:paragraph', { text: new Text([ { insert: REFERENCE_NODE, attributes: { reference: { type: 'LinkedPage', pageId: syncedDocModel.props.pageId, }, }, }, ]), } satisfies Partial, noteId ); await std.clipboard.duplicateSlice( Slice.fromModels(std.store, children), std.store, noteId ); gfx.selection.set({ elements: [noteId], editing: false, }); })().catch(console.error); return next(); }) .run(); ctx.track('CanvasElementAdded', { page: 'whiteboard editor', type: 'note', module: 'toolbar', segment: 'toolbar', control: 'conversation', other: 'duplicate as note', }); }, }, captionAction, { id: 'e.scale', content(ctx) { const model = ctx.getCurrentBlockByType( EmbedSyncedDocBlockComponent )?.model; if (!model) return null; const scale$ = computed(() => Math.round(100 * (model.props.scale$.value ?? 1)) ); const onSelect = (e: CustomEvent) => { e.stopPropagation(); const scale = e.detail / 100; const oldScale = model.props.scale ?? 1; const ratio = scale / oldScale; const bounds = Bound.deserialize(model.xywh); bounds.h *= ratio; bounds.w *= ratio; const xywh = bounds.serialize(); ctx.store.updateBlock(model, { scale, xywh }); ctx.track('SelectedCardScale', { ...trackBaseProps, control: 'select card scale', }); }; const onToggle = createOnToggleFn( ctx, 'OpenedCardScaleSelector', 'switch card scale' ); const format = (value: number) => `${value}%`; return html`${keyed( model, html`` )}`; }, }, ], when: ctx => ctx.getSurfaceModelsByType(EmbedSyncedDocModel).length === 1, } as const satisfies ToolbarModuleConfig; export const createBuiltinToolbarConfigExtension = ( flavour: string ): ExtensionType[] => { const name = flavour.split(':').pop(); return [ ToolbarModuleExtension({ id: BlockFlavourIdentifier(flavour), config: builtinToolbarConfig, }), ToolbarModuleExtension({ id: BlockFlavourIdentifier(`affine:surface:${name}`), config: builtinSurfaceToolbarConfig, }), ]; };