From 38bbbb34b9e5b14c5873eade06486388cfb6a090 Mon Sep 17 00:00:00 2001 From: Fangdun Tsai Date: Tue, 7 Jan 2025 19:52:42 +0800 Subject: [PATCH] feat(editor): add toolbar registry extension --- .../block-attachment/src/attachment-block.ts | 57 +-- .../src/attachment-edgeless-block.ts | 3 - .../block-attachment/src/attachment-spec.ts | 21 +- .../block-attachment/src/components/config.ts | 77 ---- .../src/components/context.ts | 45 --- .../src/components/options.ts | 226 ----------- .../src/components/rename-model.ts | 15 +- .../block-attachment/src/components/styles.ts | 37 -- .../block-attachment/src/configs/toolbar.ts | 247 ++++++++++++ .../affine/block-attachment/src/embed.ts | 2 +- .../affine/block-attachment/src/index.ts | 1 - blocksuite/affine/block-bookmark/package.json | 1 + .../block-bookmark/src/bookmark-spec.ts | 21 +- .../block-bookmark/src/configs/toolbar.ts | 207 ++++++++++ .../affine/block-embed/src/configs/toolbar.ts | 178 +++++++++ .../src/embed-figma-block/embed-figma-spec.ts | 23 +- .../embed-github-block/embed-github-spec.ts | 23 +- .../embed-linked-doc-spec.ts | 18 +- .../src/embed-loom-block/embed-loom-spec.ts | 23 +- .../embed-synced-doc-spec.ts | 23 +- .../embed-youtube-block/embed-youtube-spec.ts | 23 +- blocksuite/affine/block-embed/src/index.ts | 7 +- .../affine/block-image/src/configs/toolbar.ts | 54 +++ .../affine/block-image/src/image-spec.ts | 21 +- blocksuite/affine/block-note/package.json | 2 + .../block-note/src/commands/block-type.ts | 1 + .../affine/block-note/src/configs/toolbar.ts | 369 ++++++++++++++++++ blocksuite/affine/block-note/src/note-spec.ts | 16 +- blocksuite/affine/components/package.json | 5 +- .../src/card-style-dropdown/dropdown.ts | 82 ++++ .../src/card-style-dropdown/index.ts | 7 + .../components/src/link-preview/index.ts | 7 + .../components/src/link-preview/link.ts | 73 ++++ .../components/src/toolbar/icon-button.ts | 14 +- .../components/src/toolbar/menu-button.ts | 11 +- .../affine/components/src/toolbar/utils.ts | 6 +- .../components/src/view-dropdown/dropdown.ts | 81 ++++ .../components/src/view-dropdown/index.ts | 7 + .../affine/shared/src/services/index.ts | 1 + .../src/services/toolbar-service/action.ts | 56 +++ .../src/services/toolbar-service/config.ts | 10 + .../src/services/toolbar-service/context.ts | 113 ++++++ .../src/services/toolbar-service/index.ts | 6 + .../src/services/toolbar-service/module.ts | 9 + .../src/services/toolbar-service/registry.ts | 59 +++ .../src/services/toolbar-service/utils.ts | 7 + .../affine/shared/src/utils/button-popper.ts | 9 +- blocksuite/affine/widget-toolbar/package.json | 20 +- .../affine/widget-toolbar/src/renderer.ts | 188 +++++++++ .../affine/widget-toolbar/src/toolbar.ts | 353 ++++++++++++++++- blocksuite/affine/widget-toolbar/src/utils.ts | 114 ++++++ .../affine/widget-toolbar/tsconfig.json | 8 +- blocksuite/blocks/src/effects.ts | 8 +- .../root-block/edgeless/edgeless-root-spec.ts | 2 + .../src/root-block/page/page-root-spec.ts | 2 + .../change-attachment-button.ts | 22 +- .../embed-card-toolbar/embed-card-toolbar.ts | 13 +- .../widgets/embed-card-toolbar/styles.ts | 33 -- .../format-bar/components/config-renderer.ts | 1 - .../format-bar/components/paragraph-button.ts | 2 +- .../core/src/blocksuite/presets/ai/ai-spec.ts | 15 +- .../ai/entries/format-bar/setup-format-bar.ts | 36 ++ .../block-suite-editor/specs/common.ts | 2 + .../specs/custom/root-block.ts | 12 +- .../specs/custom/widgets/toolbar.ts | 45 ++- tools/utils/src/workspace.gen.ts | 2 + yarn.lock | 88 +++++ 67 files changed, 2695 insertions(+), 575 deletions(-) delete mode 100644 blocksuite/affine/block-attachment/src/components/config.ts delete mode 100644 blocksuite/affine/block-attachment/src/components/context.ts delete mode 100644 blocksuite/affine/block-attachment/src/components/options.ts create mode 100644 blocksuite/affine/block-attachment/src/configs/toolbar.ts create mode 100644 blocksuite/affine/block-bookmark/src/configs/toolbar.ts create mode 100644 blocksuite/affine/block-embed/src/configs/toolbar.ts create mode 100644 blocksuite/affine/block-image/src/configs/toolbar.ts create mode 100644 blocksuite/affine/block-note/src/configs/toolbar.ts create mode 100644 blocksuite/affine/components/src/card-style-dropdown/dropdown.ts create mode 100644 blocksuite/affine/components/src/card-style-dropdown/index.ts create mode 100644 blocksuite/affine/components/src/link-preview/index.ts create mode 100644 blocksuite/affine/components/src/link-preview/link.ts create mode 100644 blocksuite/affine/components/src/view-dropdown/dropdown.ts create mode 100644 blocksuite/affine/components/src/view-dropdown/index.ts create mode 100644 blocksuite/affine/shared/src/services/toolbar-service/action.ts create mode 100644 blocksuite/affine/shared/src/services/toolbar-service/config.ts create mode 100644 blocksuite/affine/shared/src/services/toolbar-service/context.ts create mode 100644 blocksuite/affine/shared/src/services/toolbar-service/index.ts create mode 100644 blocksuite/affine/shared/src/services/toolbar-service/module.ts create mode 100644 blocksuite/affine/shared/src/services/toolbar-service/registry.ts create mode 100644 blocksuite/affine/shared/src/services/toolbar-service/utils.ts create mode 100644 blocksuite/affine/widget-toolbar/src/renderer.ts create mode 100644 blocksuite/affine/widget-toolbar/src/utils.ts diff --git a/blocksuite/affine/block-attachment/src/attachment-block.ts b/blocksuite/affine/block-attachment/src/attachment-block.ts index aa6612d8ef14a..47c21c7fb8ffc 100644 --- a/blocksuite/affine/block-attachment/src/attachment-block.ts +++ b/blocksuite/affine/block-attachment/src/attachment-block.ts @@ -1,6 +1,5 @@ import { getEmbedCardIcons } from '@blocksuite/affine-block-embed'; import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; -import { HoverController } from '@blocksuite/affine-components/hover'; import { AttachmentIcon16, getAttachmentFileIcon, @@ -16,19 +15,16 @@ import { ThemeProvider, } from '@blocksuite/affine-shared/services'; import { humanFileSize } from '@blocksuite/affine-shared/utils'; -import { BlockSelection, TextSelection } from '@blocksuite/block-std'; +import { BlockSelection } from '@blocksuite/block-std'; import { Slice } from '@blocksuite/store'; -import { flip, offset } from '@floating-ui/dom'; -import { html, nothing } from 'lit'; +import { html } from 'lit'; import { property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; -import { ref } from 'lit/directives/ref.js'; import { styleMap } from 'lit/directives/style-map.js'; -import { AttachmentOptionsTemplate } from './components/options.js'; -import { AttachmentEmbedProvider } from './embed.js'; -import { styles } from './styles.js'; -import { checkAttachmentBlob, downloadAttachmentBlob } from './utils.js'; +import { AttachmentEmbedProvider } from './embed'; +import { styles } from './styles'; +import { checkAttachmentBlob, downloadAttachmentBlob } from './utils'; @Peekable() export class AttachmentBlockComponent extends CaptionedBlockComponent { @@ -40,43 +36,6 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent { - const selection = this.host.selection; - const textSelection = selection.find(TextSelection); - if ( - !!textSelection && - (!!textSelection.to || !!textSelection.from.length) - ) { - return null; - } - - const blockSelections = selection.filter(BlockSelection); - if ( - blockSelections.length > 1 || - (blockSelections.length === 1 && - blockSelections[0].blockId !== this.blockId) - ) { - return null; - } - - return { - template: AttachmentOptionsTemplate({ - block: this, - model: this.model, - abortController, - }), - computePosition: { - referenceElement: this, - placement: 'top-start', - middleware: [flip(), offset(4)], - autoUpdate: true, - }, - }; - } - ); - blockDraggable = true; protected containerStyleMap = styleMap({ @@ -225,11 +184,7 @@ export class AttachmentBlockComponent extends CaptionedBlockComponent +
${embedView ? html`
${embedView} diff --git a/blocksuite/affine/block-attachment/src/attachment-edgeless-block.ts b/blocksuite/affine/block-attachment/src/attachment-edgeless-block.ts index 0ad0736d0cea2..cc6c611e683bb 100644 --- a/blocksuite/affine/block-attachment/src/attachment-edgeless-block.ts +++ b/blocksuite/affine/block-attachment/src/attachment-edgeless-block.ts @@ -1,5 +1,4 @@ import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface'; -import type { HoverController } from '@blocksuite/affine-components/hover'; import { AttachmentBlockStyles } from '@blocksuite/affine-model'; import { EMBED_CARD_HEIGHT, @@ -13,8 +12,6 @@ import { AttachmentBlockComponent } from './attachment-block.js'; export class AttachmentEdgelessBlockComponent extends toGfxBlockComponent( AttachmentBlockComponent ) { - protected override _whenHover: HoverController | null = null; - override blockDraggable = false; get slots() { diff --git a/blocksuite/affine/block-attachment/src/attachment-spec.ts b/blocksuite/affine/block-attachment/src/attachment-spec.ts index 6a9859dfa36dd..40df90ab0a6de 100644 --- a/blocksuite/affine/block-attachment/src/attachment-spec.ts +++ b/blocksuite/affine/block-attachment/src/attachment-spec.ts @@ -1,17 +1,26 @@ -import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std'; +import { AttachmentBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockFlavourIdentifier, + BlockViewExtension, + FlavourExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; import { AttachmentBlockNotionHtmlAdapterExtension } from './adapters/notion-html.js'; import { AttachmentDropOption } from './attachment-service.js'; +import { builtinToolbarConfig } from './configs/toolbar'; import { AttachmentEmbedConfigExtension, AttachmentEmbedService, -} from './embed.js'; +} from './embed'; + +const flavour = AttachmentBlockSchema.model.flavour; export const AttachmentBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:attachment'), - BlockViewExtension('affine:attachment', model => { + FlavourExtension(flavour), + BlockViewExtension(flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-edgeless-attachment` : literal`affine-attachment`; @@ -20,4 +29,8 @@ export const AttachmentBlockSpec: ExtensionType[] = [ AttachmentEmbedConfigExtension(), AttachmentEmbedService, AttachmentBlockNotionHtmlAdapterExtension, + ToolbarModuleExtension({ + id: BlockFlavourIdentifier(flavour), + config: builtinToolbarConfig, + }), ]; diff --git a/blocksuite/affine/block-attachment/src/components/config.ts b/blocksuite/affine/block-attachment/src/components/config.ts deleted file mode 100644 index 5bcb411aff391..0000000000000 --- a/blocksuite/affine/block-attachment/src/components/config.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - CopyIcon, - DeleteIcon, - DownloadIcon, - DuplicateIcon, - RefreshIcon, -} from '@blocksuite/affine-components/icons'; -import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar'; - -import { cloneAttachmentProperties } from '../utils.js'; -import type { AttachmentToolbarMoreMenuContext } from './context.js'; - -export const BUILT_IN_GROUPS: MenuItemGroup[] = - [ - { - type: 'clipboard', - items: [ - { - type: 'copy', - label: 'Copy', - icon: CopyIcon, - disabled: ({ doc }) => doc.readonly, - action: ctx => ctx.blockComponent.copy(), - }, - { - type: 'duplicate', - label: 'Duplicate', - icon: DuplicateIcon, - disabled: ({ doc }) => doc.readonly, - action: ({ doc, blockComponent, close }) => { - const model = blockComponent.model; - const prop: { flavour: 'affine:attachment' } = { - flavour: 'affine:attachment', - ...cloneAttachmentProperties(model), - }; - doc.addSiblingBlocks(model, [prop]); - close(); - }, - }, - { - type: 'reload', - label: 'Reload', - icon: RefreshIcon, - disabled: ({ doc }) => doc.readonly, - action: ({ blockComponent, close }) => { - blockComponent.refreshData(); - close(); - }, - }, - { - type: 'download', - label: 'Download', - icon: DownloadIcon, - disabled: ({ doc }) => doc.readonly, - action: ({ blockComponent, close }) => { - blockComponent.download(); - close(); - }, - }, - ], - }, - { - type: 'delete', - items: [ - { - type: 'delete', - label: 'Delete', - icon: DeleteIcon, - disabled: ({ doc }) => doc.readonly, - action: ({ doc, blockComponent, close }) => { - doc.deleteBlock(blockComponent.model); - close(); - }, - }, - ], - }, - ]; diff --git a/blocksuite/affine/block-attachment/src/components/context.ts b/blocksuite/affine/block-attachment/src/components/context.ts deleted file mode 100644 index f3697fb09498a..0000000000000 --- a/blocksuite/affine/block-attachment/src/components/context.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { MenuContext } from '@blocksuite/affine-components/toolbar'; - -import type { AttachmentBlockComponent } from '../attachment-block.js'; - -export class AttachmentToolbarMoreMenuContext extends MenuContext { - override close = () => { - this.abortController.abort(); - }; - - get doc() { - return this.blockComponent.doc; - } - - get host() { - return this.blockComponent.host; - } - - get selectedBlockModels() { - if (this.blockComponent.model) return [this.blockComponent.model]; - return []; - } - - get std() { - return this.blockComponent.std; - } - - constructor( - public blockComponent: AttachmentBlockComponent, - public abortController: AbortController - ) { - super(); - } - - isEmpty() { - return false; - } - - isMultiple() { - return false; - } - - isSingle() { - return true; - } -} diff --git a/blocksuite/affine/block-attachment/src/components/options.ts b/blocksuite/affine/block-attachment/src/components/options.ts deleted file mode 100644 index 6bad4088d5e19..0000000000000 --- a/blocksuite/affine/block-attachment/src/components/options.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { - CaptionIcon, - DownloadIcon, - EditIcon, - MoreVerticalIcon, - SmallArrowDownIcon, -} from '@blocksuite/affine-components/icons'; -import { createLitPortal } from '@blocksuite/affine-components/portal'; -import { - cloneGroups, - getMoreMenuConfig, - renderGroups, - renderToolbarSeparator, -} from '@blocksuite/affine-components/toolbar'; -import { - type AttachmentBlockModel, - defaultAttachmentProps, -} from '@blocksuite/affine-model'; -import { - EMBED_CARD_HEIGHT, - EMBED_CARD_WIDTH, -} from '@blocksuite/affine-shared/consts'; -import { Bound } from '@blocksuite/global/utils'; -import { flip, offset } from '@floating-ui/dom'; -import { html, nothing } from 'lit'; -import { join } from 'lit/directives/join.js'; -import { repeat } from 'lit/directives/repeat.js'; - -import type { AttachmentBlockComponent } from '../attachment-block.js'; -import { BUILT_IN_GROUPS } from './config.js'; -import { AttachmentToolbarMoreMenuContext } from './context.js'; -import { RenameModal } from './rename-model.js'; -import { styles } from './styles.js'; - -export function attachmentViewToggleMenu({ - block, - callback, -}: { - block: AttachmentBlockComponent; - callback?: () => void; -}) { - const model = block.model; - const readonly = model.doc.readonly; - const embedded = model.embed; - const viewType = embedded ? 'embed' : 'card'; - const viewActions = [ - { - type: 'card', - label: 'Card view', - disabled: readonly || !embedded, - action: () => { - const style = defaultAttachmentProps.style!; - const width = EMBED_CARD_WIDTH[style]; - const height = EMBED_CARD_HEIGHT[style]; - const bound = Bound.deserialize(model.xywh); - bound.w = width; - bound.h = height; - model.doc.updateBlock(model, { - style, - embed: false, - xywh: bound.serialize(), - }); - callback?.(); - }, - }, - { - type: 'embed', - label: 'Embed view', - disabled: readonly || embedded || !block.embedded(), - action: () => { - block.convertTo(); - callback?.(); - }, - }, - ]; - - return html` - -
- ${viewType} - view -
- ${SmallArrowDownIcon} - - `} - > -
- ${repeat( - viewActions, - button => button.type, - ({ type, label, action, disabled }) => html` - - ${label} - - ` - )} -
-
- `; -} - -export function AttachmentOptionsTemplate({ - block, - model, - abortController, -}: { - block: AttachmentBlockComponent; - model: AttachmentBlockModel; - abortController: AbortController; -}) { - const std = block.std; - const editorHost = block.host; - const readonly = model.doc.readonly; - const context = new AttachmentToolbarMoreMenuContext(block, abortController); - const groups = getMoreMenuConfig(std).configure(cloneGroups(BUILT_IN_GROUPS)); - const moreMenuActions = renderGroups(groups, context); - - const buttons = [ - // preview - // html` - // - // ${ViewIcon} - // - // `, - - readonly - ? nothing - : html` - { - abortController.abort(); - const renameAbortController = new AbortController(); - createLitPortal({ - template: RenameModal({ - model, - editorHost, - abortController: renameAbortController, - }), - computePosition: { - referenceElement: block, - placement: 'top-start', - middleware: [flip(), offset(4)], - // It has a overlay mask, so we don't need to update the position. - // autoUpdate: true, - }, - abortController: renameAbortController, - }); - }} - > - ${EditIcon} - - `, - - attachmentViewToggleMenu({ - block, - callback: () => abortController.abort(), - }), - - readonly - ? nothing - : html` - block.download()} - > - ${DownloadIcon} - - `, - - readonly - ? nothing - : html` - block.captionEditor?.show()} - > - ${CaptionIcon} - - `, - - html` - - ${MoreVerticalIcon} - - `} - > -
- ${moreMenuActions} -
-
- `, - ]; - - return html` - - - ${join( - buttons.filter(button => button !== nothing), - renderToolbarSeparator - )} - - `; -} diff --git a/blocksuite/affine/block-attachment/src/components/rename-model.ts b/blocksuite/affine/block-attachment/src/components/rename-model.ts index 8613ea16e24c6..99c8b3499b112 100644 --- a/blocksuite/affine/block-attachment/src/components/rename-model.ts +++ b/blocksuite/affine/block-attachment/src/components/rename-model.ts @@ -5,7 +5,7 @@ import type { EditorHost } from '@blocksuite/block-std'; import { html } from 'lit'; import { createRef, ref } from 'lit/directives/ref.js'; -import { renameStyles } from './styles.js'; +import { renameStyles } from './styles'; export const RenameModal = ({ editorHost, @@ -34,6 +34,9 @@ export const RenameModal = ({ let fileName = includeExtension ? nameWithoutExtension : originalName; const extension = includeExtension ? originalExtension : ''; + const abort = () => { + abortController.abort(); + }; const onConfirm = () => { const newFileName = fileName + extension; if (!newFileName) { @@ -43,7 +46,7 @@ export const RenameModal = ({ model.doc.updateBlock(model, { name: newFileName, }); - abortController.abort(); + abort(); }; const onInput = (e: InputEvent) => { fileName = (e.target as HTMLInputElement).value; @@ -52,7 +55,7 @@ export const RenameModal = ({ e.stopPropagation(); if (e.key === 'Escape' && !e.isComposing) { - abortController.abort(); + abort(); return; } if (e.key === 'Enter' && !e.isComposing) { @@ -65,14 +68,12 @@ export const RenameModal = ({ -
+
icon-button { - display: flex; - align-items: center; - padding: 8px; - gap: 8px; - } - .affine-attachment-options-more-container > icon-button[hidden] { - display: none; - } - - .affine-attachment-options-more-container > icon-button:hover.danger { - background: var(--affine-background-error-color); - color: var(--affine-error-color); - } - .affine-attachment-options-more-container > icon-button:hover.danger > svg { - color: var(--affine-error-color); - } -`; - export const styles = css` :host { z-index: 1; diff --git a/blocksuite/affine/block-attachment/src/configs/toolbar.ts b/blocksuite/affine/block-attachment/src/configs/toolbar.ts new file mode 100644 index 0000000000000..eb2e79161ae10 --- /dev/null +++ b/blocksuite/affine/block-attachment/src/configs/toolbar.ts @@ -0,0 +1,247 @@ +import { createLitPortal } from '@blocksuite/affine-components/portal'; +import { + AttachmentBlockSchema, + defaultAttachmentProps, +} from '@blocksuite/affine-model'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { + ActionPlacement, + type ToolbarAction, + type ToolbarActionGroup, + type ToolbarModuleConfig, +} from '@blocksuite/affine-shared/services'; +import { BlockSelection } from '@blocksuite/block-std'; +import { Bound } from '@blocksuite/global/utils'; +import { + CaptionIcon, + CopyIcon, + DeleteIcon, + DownloadIcon, + DuplicateIcon, + EditIcon, + ResetIcon, +} from '@blocksuite/icons/lit'; +import { flip, offset } from '@floating-ui/dom'; +import { computed } from '@preact/signals-core'; +import { html } from 'lit'; + +import { AttachmentBlockComponent } from '../attachment-block'; +import { RenameModal } from '../components/rename-model'; +import { AttachmentEmbedProvider } from '../embed'; +import { cloneAttachmentProperties } from '../utils'; + +export const builtinToolbarConfig = { + actions: [ + { + id: 'rename', + content(cx) { + const block = cx.getCurrentBlockComponentBy( + BlockSelection, + AttachmentBlockComponent + ); + if (!block) return null; + + const abortController = new AbortController(); + abortController.signal.onabort = () => { + cx.show(); + }; + + return html` + { + cx.hide(); + createLitPortal({ + template: RenameModal({ + model: block.model, + editorHost: cx.host, + abortController, + }), + computePosition: { + referenceElement: block, + placement: 'top-start', + middleware: [flip(), offset(4)], + }, + abortController, + }); + }} + > + ${EditIcon()} + + `; + }, + }, + { + id: 'conversions', + actions: [ + { + id: 'card-view', + label: 'Card view', + run(cx) { + const model = cx.getCurrentBlockModelBy( + BlockSelection, + AttachmentBlockSchema + ); + if (!model) return; + + const style = defaultAttachmentProps.style!; + const width = EMBED_CARD_WIDTH[style]; + const height = EMBED_CARD_HEIGHT[style]; + const bound = Bound.deserialize(model.xywh); + bound.w = width; + bound.h = height; + + cx.store.updateBlock(model, { + style, + embed: false, + xywh: bound.serialize(), + }); + }, + }, + { + id: 'embed-view', + label: 'Embed view', + run(cx) { + const model = cx.getCurrentBlockComponentBy( + BlockSelection, + AttachmentBlockComponent + )?.model; + if (!model) return; + + cx.std.get(AttachmentEmbedProvider).convertTo(model); + }, + }, + ], + content(cx) { + const model = cx.getCurrentBlockComponentBy( + BlockSelection, + AttachmentBlockComponent + )?.model; + if (!model) return null; + + const actions = this.actions.map(action => ({ ...action })); + + return html` { + const [cardAction, embedAction] = actions; + const embed = model.embed$.value ?? false; + + cardAction.disabled = !embed; + embedAction.disabled = + embed && cx.std.get(AttachmentEmbedProvider).embedded(model); + + return embed ? embedAction.label : cardAction.label; + })} + >`; + }, + } satisfies ToolbarActionGroup, + { + id: 'download', + tooltip: 'Download', + icon: DownloadIcon(), + run(cx) { + const component = cx.getCurrentBlockComponentBy( + BlockSelection, + AttachmentBlockComponent + ); + component?.download(); + }, + }, + { + id: 'caption', + tooltip: 'Caption', + icon: CaptionIcon(), + run(cx) { + const component = cx.getCurrentBlockComponentBy( + BlockSelection, + AttachmentBlockComponent + ); + component?.captionEditor?.show(); + }, + }, + { + id: 'clipboard', + placement: ActionPlacement.More, + actions: [ + { + id: 'copy', + label: 'Copy', + icon: CopyIcon(), + run(cx) { + // TODO(@fundon): unify `clone` method + const component = cx.getCurrentBlockComponentBy( + BlockSelection, + AttachmentBlockComponent + ); + component?.copy(); + }, + }, + { + id: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon(), + run(cx) { + const model = cx.getCurrentBlockComponentBy( + BlockSelection, + AttachmentBlockComponent + )?.model; + if (!model) return; + + // TODO(@fundon): unify `duplicate` method + cx.store.addSiblingBlocks(model, [ + { + flavour: model.flavour, + ...cloneAttachmentProperties(model), + }, + ]); + }, + }, + ], + }, + { + id: 'refresh', + placement: ActionPlacement.More, + actions: [ + { + id: 'reload', + label: 'Reload', + icon: ResetIcon(), + run(cx) { + const component = cx.getCurrentBlockComponentBy( + BlockSelection, + AttachmentBlockComponent + ); + component?.refreshData(); + }, + }, + ], + }, + { + id: 'delete', + placement: ActionPlacement.More, + actions: [ + { + id: 'delete', + label: 'Delete', + icon: DeleteIcon(), + variant: 'destructive', + run(cx) { + const model = cx.getCurrentBlockModelBy( + BlockSelection, + AttachmentBlockSchema + ); + if (!model) return; + + cx.store.deleteBlock(model); + }, + }, + ], + }, + ], +} as const satisfies ToolbarModuleConfig; diff --git a/blocksuite/affine/block-attachment/src/embed.ts b/blocksuite/affine/block-attachment/src/embed.ts index 8fffb783eeec5..b913ba74e40fd 100644 --- a/blocksuite/affine/block-attachment/src/embed.ts +++ b/blocksuite/affine/block-attachment/src/embed.ts @@ -91,7 +91,7 @@ export class AttachmentEmbedService extends Extension { // Converts to embed view. convertTo(model: AttachmentBlockModel, maxFileSize = this._maxFileSize) { const config = this.values.find(config => config.check(model, maxFileSize)); - if (!config || !config.action) { + if (!config?.action) { model.doc.updateBlock(model, { embed: true }); return; } diff --git a/blocksuite/affine/block-attachment/src/index.ts b/blocksuite/affine/block-attachment/src/index.ts index ad03de0d56e23..c67b4a1c5bca2 100644 --- a/blocksuite/affine/block-attachment/src/index.ts +++ b/blocksuite/affine/block-attachment/src/index.ts @@ -6,7 +6,6 @@ export * from './adapters/notion-html'; export * from './attachment-block'; export * from './attachment-service'; export * from './attachment-spec'; -export { attachmentViewToggleMenu } from './components/options'; export { type AttachmentEmbedConfig, AttachmentEmbedConfigIdentifier, diff --git a/blocksuite/affine/block-bookmark/package.json b/blocksuite/affine/block-bookmark/package.json index 85679619215bd..67d2ef68bcbdb 100644 --- a/blocksuite/affine/block-bookmark/package.json +++ b/blocksuite/affine/block-bookmark/package.json @@ -29,6 +29,7 @@ "@toeverything/theme": "^1.1.7", "lit": "^3.2.0", "minimatch": "^10.0.1", + "yjs": "^13.6.23", "zod": "^3.23.8" }, "exports": { diff --git a/blocksuite/affine/block-bookmark/src/bookmark-spec.ts b/blocksuite/affine/block-bookmark/src/bookmark-spec.ts index 0bd93b6ae303e..3467c4d2773fe 100644 --- a/blocksuite/affine/block-bookmark/src/bookmark-spec.ts +++ b/blocksuite/affine/block-bookmark/src/bookmark-spec.ts @@ -1,15 +1,28 @@ -import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std'; +import { BookmarkBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockFlavourIdentifier, + BlockViewExtension, + FlavourExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { BookmarkBlockAdapterExtensions } from './adapters/extension.js'; +import { BookmarkBlockAdapterExtensions } from './adapters/extension'; +import { builtinToolbarConfig } from './configs/toolbar'; + +const flavour = BookmarkBlockSchema.model.flavour; export const BookmarkBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:bookmark'), - BlockViewExtension('affine:bookmark', model => { + FlavourExtension(flavour), + BlockViewExtension(flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-edgeless-bookmark` : literal`affine-bookmark`; }), BookmarkBlockAdapterExtensions, + ToolbarModuleExtension({ + id: BlockFlavourIdentifier(flavour), + config: builtinToolbarConfig, + }), ].flat(); diff --git a/blocksuite/affine/block-bookmark/src/configs/toolbar.ts b/blocksuite/affine/block-bookmark/src/configs/toolbar.ts new file mode 100644 index 0000000000000..55a48a10ebcd5 --- /dev/null +++ b/blocksuite/affine/block-bookmark/src/configs/toolbar.ts @@ -0,0 +1,207 @@ +import { + EmbedCardDarkHorizontalIcon, + EmbedCardDarkListIcon, + EmbedCardLightHorizontalIcon, + EmbedCardLightListIcon, +} from '@blocksuite/affine-components/icons'; +import { BookmarkBlockSchema } from '@blocksuite/affine-model'; +import { + ActionPlacement, + type ToolbarAction, + type ToolbarActionGroup, + type ToolbarModuleConfig, +} from '@blocksuite/affine-shared/services'; +import { BlockSelection } from '@blocksuite/block-std'; +import { + CaptionIcon, + CopyIcon, + DeleteIcon, + DuplicateIcon, + ResetIcon, +} from '@blocksuite/icons/lit'; +import { Text } from '@blocksuite/store'; +import { signal } from '@preact/signals-core'; +import { html } from 'lit'; +import * as Y from 'yjs'; + +import { BookmarkBlockComponent } from '../bookmark-block'; + +const cardStyleMap = { + light: { + horizontal: EmbedCardLightHorizontalIcon, + list: EmbedCardLightListIcon, + }, + dark: { + horizontal: EmbedCardDarkHorizontalIcon, + list: EmbedCardDarkListIcon, + }, +}; + +export const builtinToolbarConfig = { + actions: [ + { + id: 'preview', + content(cx) { + const model = cx.getCurrentBlockModelBy( + BlockSelection, + BookmarkBlockSchema + ); + if (!model) return null; + + const { url } = model; + + return html``; + }, + }, + { + id: 'conversions', + actions: [ + { + id: 'inline-view', + label: 'Inline view', + run(cx) { + const model = cx.getCurrentBlockModelBy( + BlockSelection, + BookmarkBlockSchema + ); + 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); + + // TODO(@fundon): should select new block + cx.store.addBlock('affine:paragraph', { text }, parent, index); + + cx.store.deleteBlock(model); + }, + }, + { + id: 'card-view', + label: 'Card view', + disabled: true, + }, + ], + content(cx) { + const model = cx.getCurrentBlockModelBy( + BlockSelection, + BookmarkBlockSchema + ); + if (!model) return null; + + const actions = this.actions.map(action => ({ ...action })); + + return html``; + }, + } satisfies ToolbarActionGroup, + { + id: 'style', + actions: [ + { + id: 'horizontal', + label: 'Large horizontal style', + }, + { + id: 'list', + label: 'Small horizontal style', + }, + ], + content(cx) { + const model = cx.getCurrentBlockModelBy( + BlockSelection, + BookmarkBlockSchema + ); + if (!model) return null; + + const [first, second] = this.actions.map(action => ({ + ...action, + run: ({ store }) => { + store.updateBlock(model, { style: action.id }); + + // TODO(@fundon): add tracking event + }, + })) satisfies ToolbarAction[]; + const { horizontal, list } = cardStyleMap[cx.theme]; + first.icon = horizontal; + second.icon = list; + + return html``; + }, + } satisfies ToolbarActionGroup, + { + id: 'caption', + tooltip: 'Caption', + icon: CaptionIcon(), + run(cx) { + const component = cx.getCurrentBlockComponentBy( + BlockSelection, + BookmarkBlockComponent + ); + component?.captionEditor?.show(); + }, + }, + { + id: 'clipboard', + placement: ActionPlacement.More, + actions: [ + { + id: 'copy', + label: 'Copy', + icon: CopyIcon(), + run(_cx) {}, + }, + { + id: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon(), + run(_cx) {}, + }, + ], + }, + { + id: 'refresh', + placement: ActionPlacement.More, + actions: [ + { + id: 'reload', + label: 'Reload', + icon: ResetIcon(), + run(cx) { + const component = cx.getCurrentBlockComponentBy( + BlockSelection, + BookmarkBlockComponent + ); + component?.refreshData(); + }, + }, + ], + }, + { + id: 'delete', + placement: ActionPlacement.More, + actions: [ + { + id: 'delete', + label: 'Delete', + icon: DeleteIcon(), + variant: 'destructive', + run(_cx) {}, + }, + ], + }, + ], +} as const satisfies ToolbarModuleConfig; diff --git a/blocksuite/affine/block-embed/src/configs/toolbar.ts b/blocksuite/affine/block-embed/src/configs/toolbar.ts new file mode 100644 index 0000000000000..9e698d4d913c3 --- /dev/null +++ b/blocksuite/affine/block-embed/src/configs/toolbar.ts @@ -0,0 +1,178 @@ +import { EmbedGithubBlockSchema } from '@blocksuite/affine-model'; +import { + ActionPlacement, + type ToolbarActionGroup, + type ToolbarModuleConfig, +} from '@blocksuite/affine-shared/services'; +import { BlockSelection } from '@blocksuite/block-std'; +import { + CaptionIcon, + CopyIcon, + DeleteIcon, + DuplicateIcon, + PaletteIcon, + ResetIcon, +} from '@blocksuite/icons/lit'; + +// External embed blocks +export const builtinToolbarConfigForExternal = { + actions: [ + { + id: 'conversions', + actions: [ + { + id: 'inline-view', + label: 'Inline view', + run(_cx) {}, + }, + { + id: 'card-view', + label: 'Card view', + run(_cx) {}, + }, + { + id: 'embed-view', + label: 'Embed view', + run(_cx) {}, + when(_cx) { + return false; + }, + }, + ], + content(_cx) { + this.actions; + return null; + }, + } satisfies ToolbarActionGroup, + { + id: 'style', + tooltip: 'Card style', + icon: PaletteIcon(), + when(cx) { + const model = cx.getCurrentBlockModelBy( + BlockSelection, + EmbedGithubBlockSchema + ); + return Boolean(model); + }, + run(_cx) {}, + }, + { + id: 'caption', + tooltip: 'Caption', + icon: CaptionIcon(), + run(_cx) {}, + }, + { + id: 'clipboard', + placement: ActionPlacement.More, + actions: [ + { + id: 'copy', + label: 'Copy', + icon: CopyIcon(), + run(_cx) {}, + }, + { + id: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon(), + run(_cx) {}, + }, + ], + }, + { + id: 'reload', + placement: ActionPlacement.More, + label: 'Reload', + icon: ResetIcon(), + run(_cx) {}, + }, + { + id: 'delete', + placement: ActionPlacement.More, + label: 'Delete', + icon: DeleteIcon(), + run(_cx) {}, + }, + ], +} as const satisfies ToolbarModuleConfig; + +// Internal embed blocks +export const builtinToolbarConfigForInternal = { + actions: [ + { + id: 'conversions', + actions: [ + { + id: 'inline-view', + label: 'Inline view', + run(_cx) {}, + }, + { + id: 'card-view', + label: 'Card view', + run(_cx) {}, + }, + { + id: 'embed-view', + label: 'Embed view', + run(_cx) {}, + when(_cx) { + return false; + }, + }, + ], + content(_cx) { + this.actions; + return null; + }, + } satisfies ToolbarActionGroup, + { + id: 'style', + tooltip: 'Card style', + icon: PaletteIcon(), + run(_cx) {}, + // linked doc: true, synced doc: false + when(_cx) { + return false; + }, + }, + { + id: 'caption', + tooltip: 'Caption', + icon: CaptionIcon(), + run(_cx) {}, + }, + { + id: 'clipboard', + placement: ActionPlacement.More, + actions: [ + { + id: 'copy', + label: 'Copy', + icon: CopyIcon(), + run(_cx) {}, + }, + { + id: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon(), + run(_cx) {}, + }, + ], + }, + { + id: 'delete', + placement: ActionPlacement.More, + actions: [ + { + id: 'delete', + label: 'Delete', + icon: DeleteIcon(), + run(_cx) {}, + }, + ], + }, + ], +} as const satisfies ToolbarModuleConfig; diff --git a/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-spec.ts b/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-spec.ts index 7aab6f8b20ef4..18d25e1324b02 100644 --- a/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-spec.ts +++ b/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-spec.ts @@ -1,17 +1,30 @@ -import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std'; +import { EmbedFigmaBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockServiceIdentifier, + BlockViewExtension, + FlavourExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { EmbedFigmaBlockAdapterExtensions } from './adapters/extension.js'; -import { EmbedFigmaBlockService } from './embed-figma-service.js'; +import { builtinToolbarConfigForExternal } from '../configs/toolbar'; +import { EmbedFigmaBlockAdapterExtensions } from './adapters/extension'; +import { EmbedFigmaBlockService } from './embed-figma-service'; + +const flavour = EmbedFigmaBlockSchema.model.flavour as BlockSuite.Flavour; export const EmbedFigmaBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:embed-figma'), + FlavourExtension(flavour), EmbedFigmaBlockService, - BlockViewExtension('affine:embed-figma', model => { + BlockViewExtension(flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-embed-edgeless-figma-block` : literal`affine-embed-figma-block`; }), EmbedFigmaBlockAdapterExtensions, + ToolbarModuleExtension({ + id: BlockServiceIdentifier(flavour), + config: builtinToolbarConfigForExternal, + }), ].flat(); diff --git a/blocksuite/affine/block-embed/src/embed-github-block/embed-github-spec.ts b/blocksuite/affine/block-embed/src/embed-github-block/embed-github-spec.ts index ac8834d225583..3928a6ee004e0 100644 --- a/blocksuite/affine/block-embed/src/embed-github-block/embed-github-spec.ts +++ b/blocksuite/affine/block-embed/src/embed-github-block/embed-github-spec.ts @@ -1,17 +1,30 @@ -import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std'; +import { EmbedGithubBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockServiceIdentifier, + BlockViewExtension, + FlavourExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { EmbedGithubBlockAdapterExtensions } from './adapters/extension.js'; -import { EmbedGithubBlockService } from './embed-github-service.js'; +import { builtinToolbarConfigForExternal } from '../configs/toolbar'; +import { EmbedGithubBlockAdapterExtensions } from './adapters/extension'; +import { EmbedGithubBlockService } from './embed-github-service'; + +const flavour = EmbedGithubBlockSchema.model.flavour as BlockSuite.Flavour; export const EmbedGithubBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:embed-github'), + FlavourExtension(flavour), EmbedGithubBlockService, - BlockViewExtension('affine:embed-github', model => { + BlockViewExtension(flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-embed-edgeless-github-block` : literal`affine-embed-github-block`; }), EmbedGithubBlockAdapterExtensions, + ToolbarModuleExtension({ + id: BlockServiceIdentifier(flavour), + config: builtinToolbarConfigForExternal, + }), ].flat(); diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-spec.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-spec.ts index 128171c90d1b6..33b7b965662e8 100644 --- a/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-spec.ts +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-spec.ts @@ -1,14 +1,26 @@ -import { BlockViewExtension } from '@blocksuite/block-std'; +import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockServiceIdentifier, + BlockViewExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { EmbedLinkedDocBlockAdapterExtensions } from './adapters/extension.js'; +import { builtinToolbarConfigForInternal } from '../configs/toolbar'; +import { EmbedLinkedDocBlockAdapterExtensions } from './adapters/extension'; + +const flavour = EmbedLinkedDocBlockSchema.model.flavour as BlockSuite.Flavour; export const EmbedLinkedDocBlockSpec: ExtensionType[] = [ - BlockViewExtension('affine:embed-linked-doc', model => { + BlockViewExtension(flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-embed-edgeless-linked-doc-block` : literal`affine-embed-linked-doc-block`; }), EmbedLinkedDocBlockAdapterExtensions, + ToolbarModuleExtension({ + id: BlockServiceIdentifier(flavour), + config: builtinToolbarConfigForInternal, + }), ].flat(); diff --git a/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-spec.ts b/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-spec.ts index 0d873ad41aaab..bfb0fc0490eea 100644 --- a/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-spec.ts +++ b/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-spec.ts @@ -1,17 +1,30 @@ -import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std'; +import { EmbedLoomBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockServiceIdentifier, + BlockViewExtension, + FlavourExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { EmbedLoomBlockAdapterExtensions } from './adapters/extension.js'; -import { EmbedLoomBlockService } from './embed-loom-service.js'; +import { builtinToolbarConfigForExternal } from '../configs/toolbar'; +import { EmbedLoomBlockAdapterExtensions } from './adapters/extension'; +import { EmbedLoomBlockService } from './embed-loom-service'; + +const flavour = EmbedLoomBlockSchema.model.flavour as BlockSuite.Flavour; export const EmbedLoomBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:embed-loom'), + FlavourExtension(flavour), EmbedLoomBlockService, - BlockViewExtension('affine:embed-loom', model => { + BlockViewExtension(flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-embed-edgeless-loom-block` : literal`affine-embed-loom-block`; }), EmbedLoomBlockAdapterExtensions, + ToolbarModuleExtension({ + id: BlockServiceIdentifier(flavour), + config: builtinToolbarConfigForExternal, + }), ].flat(); diff --git a/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-spec.ts b/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-spec.ts index 13b951488cb53..8777c6a4918cc 100644 --- a/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-spec.ts +++ b/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-spec.ts @@ -1,17 +1,30 @@ -import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std'; +import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockServiceIdentifier, + BlockViewExtension, + FlavourExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { EmbedSyncedDocBlockAdapterExtensions } from './adapters/extension.js'; -import { EmbedSyncedDocBlockService } from './embed-synced-doc-service.js'; +import { builtinToolbarConfigForInternal } from '../configs/toolbar'; +import { EmbedSyncedDocBlockAdapterExtensions } from './adapters/extension'; +import { EmbedSyncedDocBlockService } from './embed-synced-doc-service'; + +const flavour = EmbedSyncedDocBlockSchema.model.flavour as BlockSuite.Flavour; export const EmbedSyncedDocBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:embed-synced-doc'), + FlavourExtension(flavour), EmbedSyncedDocBlockService, - BlockViewExtension('affine:embed-synced-doc', model => { + BlockViewExtension(flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-embed-edgeless-synced-doc-block` : literal`affine-embed-synced-doc-block`; }), EmbedSyncedDocBlockAdapterExtensions, + ToolbarModuleExtension({ + id: BlockServiceIdentifier(flavour), + config: builtinToolbarConfigForInternal, + }), ].flat(); diff --git a/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-spec.ts b/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-spec.ts index a306c2fc37c55..9e5245a013f8b 100644 --- a/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-spec.ts +++ b/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-spec.ts @@ -1,17 +1,30 @@ -import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std'; +import { EmbedYoutubeBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockServiceIdentifier, + BlockViewExtension, + FlavourExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { EmbedYoutubeBlockAdapterExtensions } from './adapters/extension.js'; -import { EmbedYoutubeBlockService } from './embed-youtube-service.js'; +import { builtinToolbarConfigForExternal } from '../configs/toolbar'; +import { EmbedYoutubeBlockAdapterExtensions } from './adapters/extension'; +import { EmbedYoutubeBlockService } from './embed-youtube-service'; + +const flavour = EmbedYoutubeBlockSchema.model.flavour as BlockSuite.Flavour; export const EmbedYoutubeBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:embed-youtube'), + FlavourExtension(flavour), EmbedYoutubeBlockService, - BlockViewExtension('affine:embed-youtube', model => { + BlockViewExtension(flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-embed-edgeless-youtube-block` : literal`affine-embed-youtube-block`; }), EmbedYoutubeBlockAdapterExtensions, + ToolbarModuleExtension({ + id: BlockServiceIdentifier(flavour), + config: builtinToolbarConfigForExternal, + }), ].flat(); diff --git a/blocksuite/affine/block-embed/src/index.ts b/blocksuite/affine/block-embed/src/index.ts index 3ceed9ecd56c7..0c3b423f80b44 100644 --- a/blocksuite/affine/block-embed/src/index.ts +++ b/blocksuite/affine/block-embed/src/index.ts @@ -9,11 +9,14 @@ import { EmbedSyncedDocBlockSpec } from './embed-synced-doc-block'; import { EmbedYoutubeBlockSpec } from './embed-youtube-block'; export const EmbedExtensions: ExtensionType[] = [ + // External embed blocks EmbedFigmaBlockSpec, EmbedGithubBlockSpec, - EmbedHtmlBlockSpec, EmbedLoomBlockSpec, EmbedYoutubeBlockSpec, + + // Internal embed blocks + EmbedHtmlBlockSpec, EmbedLinkedDocBlockSpec, EmbedSyncedDocBlockSpec, ].flat(); @@ -22,7 +25,7 @@ export { createEmbedBlockHtmlAdapterMatcher } from './common/adapters/html'; export { createEmbedBlockMarkdownAdapterMatcher } from './common/adapters/markdown'; export { createEmbedBlockPlainTextAdapterMatcher } from './common/adapters/plain-text'; export { EmbedBlockComponent } from './common/embed-block-element'; -export { insertEmbedCard } from './common/insert-embed-card.js'; +export { insertEmbedCard } from './common/insert-embed-card'; export * from './common/render-linked-doc'; export { toEdgelessEmbedBlock } from './common/to-edgeless-embed-block'; export * from './common/utils'; diff --git a/blocksuite/affine/block-image/src/configs/toolbar.ts b/blocksuite/affine/block-image/src/configs/toolbar.ts new file mode 100644 index 0000000000000..e16c82fe19795 --- /dev/null +++ b/blocksuite/affine/block-image/src/configs/toolbar.ts @@ -0,0 +1,54 @@ +import { ActionPlacement, type ToolbarModuleConfig } from '@blocksuite/affine-shared/services'; + +export const builtinToolbarConfig = { + actions: [ + { + id: 'download', + tooltip: 'Download', + run(_cx) {}, + }, + { + id: 'caption', + tooltip: 'Caption', + run(_cx) {}, + }, + { + id: 'clipboard', + placement: ActionPlacement.More, + actions: [ + { + id: 'copy', + label: 'Copy', + run(_cx) {}, + }, + { + id: 'duplicate', + label: 'Duplicate', + run(_cx) {}, + }, + ], + }, + { + id: 'conversions', + placement: ActionPlacement.More, + actions: [ + { + id: 'turn-into-card-view', + label: 'Turn into card view', + run(_cx) {}, + }, + ], + }, + { + id: 'delete', + placement: ActionPlacement.More, + actions: [ + { + id: 'delete', + label: 'Delete', + run(_cx) {}, + }, + ], + }, + ], +} as const satisfies ToolbarModuleConfig; diff --git a/blocksuite/affine/block-image/src/image-spec.ts b/blocksuite/affine/block-image/src/image-spec.ts index 515280a3e8402..cb07a8aa34b30 100644 --- a/blocksuite/affine/block-image/src/image-spec.ts +++ b/blocksuite/affine/block-image/src/image-spec.ts @@ -1,4 +1,6 @@ +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; import { + BlockFlavourIdentifier, BlockViewExtension, FlavourExtension, WidgetViewMapExtension, @@ -6,14 +8,17 @@ import { import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { ImageBlockAdapterExtensions } from './adapters/extension.js'; -import { ImageProxyService } from './image-proxy-service.js'; -import { ImageBlockService, ImageDropOption } from './image-service.js'; +import { ImageBlockAdapterExtensions } from './adapters/extension'; +import { ImageProxyService } from './image-proxy-service'; +import { builtinToolbarConfig } from './configs/toolbar'; +import { ImageBlockService, ImageDropOption } from './image-service'; + +const flavour = 'affine:image'; export const ImageBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:image'), + FlavourExtension(flavour), ImageBlockService, - BlockViewExtension('affine:image', model => { + BlockViewExtension(flavour, model => { const parent = model.doc.getParent(model.id); if (parent?.flavour === 'affine:surface') { @@ -22,11 +27,15 @@ export const ImageBlockSpec: ExtensionType[] = [ return literal`affine-image`; }), - WidgetViewMapExtension('affine:image', { + WidgetViewMapExtension(flavour, { imageToolbar: literal`affine-image-toolbar-widget`, }), ImageDropOption, ImageBlockAdapterExtensions, + ToolbarModuleExtension({ + id: BlockFlavourIdentifier(flavour), + config: builtinToolbarConfig, + }), ].flat(); export const ImageStoreSpec: ExtensionType[] = [ImageProxyService].flat(); diff --git a/blocksuite/affine/block-note/package.json b/blocksuite/affine/block-note/package.json index fd0b90bdb2d26..2317f768c4bc1 100644 --- a/blocksuite/affine/block-note/package.json +++ b/blocksuite/affine/block-note/package.json @@ -13,12 +13,14 @@ "author": "toeverything", "license": "MIT", "dependencies": { + "@blocksuite/affine-block-database": "workspace:*", "@blocksuite/affine-block-embed": "workspace:*", "@blocksuite/affine-block-surface": "workspace:*", "@blocksuite/affine-components": "workspace:*", "@blocksuite/affine-model": "workspace:*", "@blocksuite/affine-shared": "workspace:*", "@blocksuite/block-std": "workspace:*", + "@blocksuite/data-view": "workspace:*", "@blocksuite/global": "workspace:*", "@blocksuite/icons": "^2.2.1", "@blocksuite/inline": "workspace:*", diff --git a/blocksuite/affine/block-note/src/commands/block-type.ts b/blocksuite/affine/block-note/src/commands/block-type.ts index b123807b00349..876cb7182c76e 100644 --- a/blocksuite/affine/block-note/src/commands/block-type.ts +++ b/blocksuite/affine/block-note/src/commands/block-type.ts @@ -131,6 +131,7 @@ export const updateBlockType: Command< ); const selectionManager = host.selection; const textSelection = selectionManager.find(TextSelection); + console.log('change', textSelection?.isCollapsed()); if (!textSelection) { return false; } diff --git a/blocksuite/affine/block-note/src/configs/toolbar.ts b/blocksuite/affine/block-note/src/configs/toolbar.ts new file mode 100644 index 0000000000000..381b2fb52ecca --- /dev/null +++ b/blocksuite/affine/block-note/src/configs/toolbar.ts @@ -0,0 +1,369 @@ +import { + convertToDatabase, + DATABASE_CONVERT_WHITE_LIST, +} from '@blocksuite/affine-block-database'; +import { + convertSelectedBlocksToLinkedDoc, + getTitleFromSelectedModels, + notifyDocCreated, + promptDocTitle, +} from '@blocksuite/affine-block-embed'; +import { + formatBlockCommand, + formatNativeCommand, + formatTextCommand, + isFormatSupported, + textConversionConfigs, + textFormatConfigs, +} from '@blocksuite/affine-components/rich-text'; +import { + draftSelectedModelsCommand, + getBlockSelectionsCommand, + getImageSelectionsCommand, + getSelectedBlocksCommand, + getSelectedModelsCommand, + getTextSelectionCommand, +} from '@blocksuite/affine-shared/commands'; +import type { + ToolbarAction, + ToolbarActionGenerator, + ToolbarActionGroup, + ToolbarModuleConfig, +} from '@blocksuite/affine-shared/services'; +import { + ActionPlacement, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import type { BlockComponent } from '@blocksuite/block-std'; +import { tableViewMeta } from '@blocksuite/data-view/view-presets'; +import { + ArrowDownSmallIcon, + CopyIcon, + DatabaseTableViewIcon, + DeleteIcon, + DuplicateIcon, + // TODO(@fundon): update icon size + HighLightDuotoneIcon, + LinkedPageIcon, + // TODO(@fundon): icon should support custom colors + TextBackgroundDuotoneIcon, + // TODO(@fundon): icon should support custom colors + TextColorIcon, +} from '@blocksuite/icons/lit'; +import { html } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; + +import { updateBlockType } from '../commands'; + +// Displays only in a single paragraph. +const conversionsActionGroup = { + id: 'conversions', + when: ({ chain }) => isFormatSupported(chain).run()[0], + generate({ chain }) { + const [ok, { selectedBlocks = [] }] = chain + .tryAll(chain => [ + chain.pipe(getTextSelectionCommand), + chain.pipe(getBlockSelectionsCommand), + ]) + .pipe(getSelectedBlocksCommand, { types: ['text', 'block'] }) + .run(); + const vaild = ok && selectedBlocks.length === 1; + if (!vaild) return null; + + const { model } = selectedBlocks[0]; + const conversion = + textConversionConfigs.find( + ({ flavour, type }) => + flavour === model.flavour && + (type ? 'type' in model && type === model.type : true) + ) ?? textConversionConfigs[0]; + const update = (flavour: BlockSuite.Flavour, type?: string) => { + chain + .pipe(updateBlockType, { + flavour, + ...(type && { props: { type } }), + }) + .run(); + }; + + return { + content: html` + + ${conversion.icon} ${ArrowDownSmallIcon()} + + `} + > +
+ ${repeat( + textConversionConfigs.filter(c => c.flavour !== 'affine:divider'), + item => item.name, + ({ flavour, type, name, icon }) => html` + update(flavour, type)} + > + ${icon}${name} + + ` + )} +
+
+ `, + }; + }, +} as const satisfies ToolbarActionGenerator; + +const inlineTextActionGroup = { + id: 'inline-text', + when: ({ chain }) => isFormatSupported(chain).run()[0], + actions: textFormatConfigs.map( + ({ id, name, action, activeWhen, icon }, score) => { + return { + id, + icon, + score, + tooltip: name, + run: ({ host }) => action(host), + active: ({ host }) => activeWhen(host), + }; + } + ), +} as const satisfies ToolbarActionGroup; + +const colors = [ + 'default', + 'red', + 'orange', + 'yellow', + 'green', + 'teal', + 'blue', + 'purple', + 'grey', +] as const; + +const highlightActionGroup = { + id: 'highlight', + when: ({ chain }) => isFormatSupported(chain).run()[0], + generate({ chain }) { + const updateHighlight = (styles: AffineTextAttributes) => { + const payload = { styles }; + chain + .try(chain => [ + chain.pipe(getTextSelectionCommand).pipe(formatTextCommand, payload), + chain + .pipe(getBlockSelectionsCommand) + .pipe(formatBlockCommand, payload), + chain.pipe(formatNativeCommand, payload), + ]) + .run(); + }; + const prefix = '--affine-text-highlight'; + return { + content: html` + + ${HighLightDuotoneIcon()} ${ArrowDownSmallIcon()} + + `} + > +
+
Color
+ ${repeat(colors, color => { + const isDefault = color === 'default'; + const value = isDefault + ? null + : `var(${prefix}-foreground-${color})`; + return html` + updateHighlight({ color: value })} + > + ${TextColorIcon({ style: `color: ${value ?? 'unset'}` })} + ${isDefault ? `${color} color` : color} + + `; + })} + +
Background
+ ${repeat(colors, color => { + const isDefault = color === 'default'; + const value = isDefault ? null : `var(${prefix}-${color})`; + return html` + updateHighlight({ background: value })} + > + ${TextBackgroundDuotoneIcon({ + style: `color: ${value ?? 'transparent'}`, + })} + ${isDefault ? `${color} background` : color} + + `; + })} +
+
+ `, + }; + }, +} as const satisfies ToolbarActionGenerator; + +export const turnIntoDatabase = { + id: 'convert-to-database', + tooltip: 'Create Table', + icon: DatabaseTableViewIcon(), + when({ chain }) { + const middleware = (count = 0) => { + return (cx: { selectedBlocks: BlockComponent[] }, next: () => void) => { + const { selectedBlocks } = cx; + if (!selectedBlocks || selectedBlocks.length === count) return; + + const allowed = selectedBlocks.every(block => + DATABASE_CONVERT_WHITE_LIST.includes(block.flavour) + ); + if (!allowed) return; + + next(); + }; + }; + + let [ok] = chain + .pipe(getTextSelectionCommand) + .pipe(getSelectedBlocksCommand, { + types: ['text'], + }) + .pipe(middleware(1)) + .run(); + + if (ok) return true; + + [ok] = chain + .tryAll(chain => [ + chain.pipe(getBlockSelectionsCommand), + chain.pipe(getImageSelectionsCommand), + ]) + .pipe(getSelectedBlocksCommand, { + types: ['block', 'image'], + }) + .pipe(middleware(0)) + .run(); + + return ok; + }, + run({ host }) { + convertToDatabase(host, tableViewMeta.type); + }, +} as const satisfies ToolbarAction; + +export const turnIntoLinkedDoc = { + id: 'convert-to-linked-doc', + tooltip: 'Create Linked Doc', + icon: LinkedPageIcon(), + when({ chain }) { + const [ok, { selectedModels }] = chain + .pipe(getSelectedModelsCommand, { + types: ['block', 'text'], + mode: 'flat', + }) + .run(); + return ok && Boolean(selectedModels?.length); + }, + run({ chain, store, selection, std }) { + const [ok, { draftedModels, selectedModels }] = chain + .pipe(getSelectedModelsCommand, { + types: ['block', 'text'], + mode: 'flat', + }) + .pipe(draftSelectedModelsCommand) + .run(); + if (!ok || !draftedModels || !selectedModels?.length) return; + + selection.clear(); + + const autofill = getTitleFromSelectedModels(selectedModels); + promptDocTitle(std, autofill) + .then(async title => { + if (title === null) return; + await convertSelectedBlocksToLinkedDoc( + std, + store, + draftedModels, + title + ); + notifyDocCreated(std, store); + + // TODO(@fundon): should optimize this scenario + const telemetry = std.getOptional(TelemetryProvider); + telemetry?.track('DocCreated', { + control: 'create linked doc', + page: 'doc editor', + module: 'format toolbar', + type: 'embed-linked-doc', + }); + telemetry?.track('LinkedDocCreated', { + control: 'create linked doc', + page: 'doc editor', + module: 'format toolbar', + type: 'embed-linked-doc', + }); + }) + .catch(console.error); + }, +} as const satisfies ToolbarAction; + +export const builtinToolbarConfig = { + actions: [ + conversionsActionGroup, + inlineTextActionGroup, + highlightActionGroup, + turnIntoDatabase, + turnIntoLinkedDoc, + { + id: 'clipboard', + placement: ActionPlacement.More, + actions: [ + { + id: 'copy', + label: 'Copy', + icon: CopyIcon(), + run(_cx) {}, + }, + { + id: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon(), + run(_cx) {}, + }, + ], + }, + { + id: 'delete', + placement: ActionPlacement.More, + actions: [ + { + id: 'delete', + label: 'Delete', + icon: DeleteIcon(), + variant: 'destructive', + run(_cx) {}, + }, + ], + }, + ], +} as const satisfies ToolbarModuleConfig; diff --git a/blocksuite/affine/block-note/src/note-spec.ts b/blocksuite/affine/block-note/src/note-spec.ts index a51d9e54ce1b2..09560f4b65e25 100644 --- a/blocksuite/affine/block-note/src/note-spec.ts +++ b/blocksuite/affine/block-note/src/note-spec.ts @@ -1,4 +1,9 @@ -import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockFlavourIdentifier, + BlockViewExtension, + FlavourExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; @@ -6,6 +11,7 @@ import { DocNoteBlockAdapterExtensions, EdgelessNoteBlockAdapterExtensions, } from './adapters/index.js'; +import { builtinToolbarConfig } from './configs/toolbar.js'; import { NoteBlockService } from './note-service.js'; export const NoteBlockSpec: ExtensionType[] = [ @@ -13,6 +19,10 @@ export const NoteBlockSpec: ExtensionType[] = [ NoteBlockService, BlockViewExtension('affine:note', literal`affine-note`), DocNoteBlockAdapterExtensions, + ToolbarModuleExtension({ + id: BlockFlavourIdentifier('affine:note'), + config: builtinToolbarConfig, + }), ].flat(); export const EdgelessNoteBlockSpec: ExtensionType[] = [ @@ -20,4 +30,8 @@ export const EdgelessNoteBlockSpec: ExtensionType[] = [ NoteBlockService, BlockViewExtension('affine:note', literal`affine-edgeless-note`), EdgelessNoteBlockAdapterExtensions, + ToolbarModuleExtension({ + id: BlockFlavourIdentifier('affine:note'), + config: builtinToolbarConfig, + }), ].flat(); diff --git a/blocksuite/affine/components/package.json b/blocksuite/affine/components/package.json index 5ce327b5ea22b..03d55477e6a80 100644 --- a/blocksuite/affine/components/package.json +++ b/blocksuite/affine/components/package.json @@ -61,7 +61,10 @@ "./toggle-switch": "./src/toggle-switch/index.ts", "./notification": "./src/notification/index.ts", "./block-zero-width": "./src/block-zero-width/index.ts", - "./block-selection": "./src/block-selection/index.ts" + "./block-selection": "./src/block-selection/index.ts", + "./view-dropdown": "./src/view-dropdown/index.ts", + "./card-style-dropdown": "./src/card-style-dropdown/index.ts", + "./link-preview": "./src/link-preview/index.ts" }, "files": [ "src", diff --git a/blocksuite/affine/components/src/card-style-dropdown/dropdown.ts b/blocksuite/affine/components/src/card-style-dropdown/dropdown.ts new file mode 100644 index 0000000000000..b179c43be8a24 --- /dev/null +++ b/blocksuite/affine/components/src/card-style-dropdown/dropdown.ts @@ -0,0 +1,82 @@ +import { + type ToolbarAction, + ToolbarContext, +} from '@blocksuite/affine-shared/services'; +import { + PropTypes, + requiredProperties, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { PaletteIcon } from '@blocksuite/icons/lit'; +import type { ReadonlySignal, Signal } from '@preact/signals-core'; +import { property } from 'lit/decorators.js'; +import { html } from 'lit-html'; +import { ifDefined } from 'lit-html/directives/if-defined.js'; +import { repeat } from 'lit-html/directives/repeat.js'; + +@requiredProperties({ + actions: PropTypes.array, + context: PropTypes.instanceOf(ToolbarContext), + style$: PropTypes.object, +}) +export class CardStyleDropdown extends SignalWatcher(ShadowlessElement) { + @property({ attribute: false }) + accessor actions!: ToolbarAction[]; + + @property({ attribute: false }) + accessor context!: ToolbarContext; + + @property({ attribute: false }) + accessor style$!: Signal | ReadonlySignal; + + override render() { + const { + actions, + context, + style$: { value: style }, + } = this; + + return html` + + ${PaletteIcon()} + + `} + > +
+ ${repeat( + actions, + action => action.id, + ({ id, label, icon, disabled, run }) => html` + run?.(context)} + > + ${icon} + + ` + )} +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-card-style-dropdown': CardStyleDropdown; + } +} diff --git a/blocksuite/affine/components/src/card-style-dropdown/index.ts b/blocksuite/affine/components/src/card-style-dropdown/index.ts new file mode 100644 index 0000000000000..1945e7b15d1ae --- /dev/null +++ b/blocksuite/affine/components/src/card-style-dropdown/index.ts @@ -0,0 +1,7 @@ +import { CardStyleDropdown } from './dropdown'; + +export * from './dropdown'; + +export function effects() { + customElements.define('affine-card-style-dropdown', CardStyleDropdown); +} diff --git a/blocksuite/affine/components/src/link-preview/index.ts b/blocksuite/affine/components/src/link-preview/index.ts new file mode 100644 index 0000000000000..7b8f1bc2c4ba3 --- /dev/null +++ b/blocksuite/affine/components/src/link-preview/index.ts @@ -0,0 +1,7 @@ +import { LinkPreview } from './link'; + +export * from './link'; + +export function effects() { + customElements.define('affine-link-preview', LinkPreview); +} diff --git a/blocksuite/affine/components/src/link-preview/link.ts b/blocksuite/affine/components/src/link-preview/link.ts new file mode 100644 index 0000000000000..b8c1dbcec0513 --- /dev/null +++ b/blocksuite/affine/components/src/link-preview/link.ts @@ -0,0 +1,73 @@ +import { getHostName } from '@blocksuite/affine-shared/utils'; +import { + PropTypes, + requiredProperties, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { css } from 'lit'; +import { property } from 'lit/decorators.js'; +import { html } from 'lit-html'; + +@requiredProperties({ + url: PropTypes.string, +}) +export class LinkPreview extends ShadowlessElement { + static override styles = css` + .affine-link-preview { + display: flex; + justify-content: flex-start; + min-width: 60px; + max-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); + } + `; + + @property({ attribute: false }) + accessor url!: string; + + override render() { + const { url } = this; + + return html` + + ${getHostName(url)} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-link-preview': LinkPreview; + } +} diff --git a/blocksuite/affine/components/src/toolbar/icon-button.ts b/blocksuite/affine/components/src/toolbar/icon-button.ts index c020f8d8ef5a1..de8432fde0c53 100644 --- a/blocksuite/affine/components/src/toolbar/icon-button.ts +++ b/blocksuite/affine/components/src/toolbar/icon-button.ts @@ -25,6 +25,7 @@ export class EditorIconButton extends LitElement { white-space: nowrap; box-sizing: border-box; width: var(--icon-container-width, unset); + height: var(--icon-container-height, unset); justify-content: var(--justify, unset); user-select: none; } @@ -33,6 +34,10 @@ export class EditorIconButton extends LitElement { color: var(--affine-primary-color); } + :host([active]) .icon-container.active-mode-border { + border: 1px solid var(--affine-brand-color); + } + :host([active]) .icon-container.active-mode-background { background: var(--affine-hover-color); } @@ -44,8 +49,7 @@ export class EditorIconButton extends LitElement { ::slotted(svg) { flex-shrink: 0; - width: var(--icon-size, unset); - height: var(--icon-size, unset); + font-size: var(--icon-size, 20px); } ::slotted(.label) { @@ -116,6 +120,7 @@ export class EditorIconButton extends LitElement { const padding = this.iconContainerPadding; const iconContainerStyles = styleMap({ '--icon-container-width': this.iconContainerWidth, + '--icon-container-height': this.iconContainerHeight, '--icon-container-padding': Array.isArray(padding) ? padding.map(v => `${v}px`).join(' ') : `${padding}px`, @@ -156,7 +161,7 @@ export class EditorIconButton extends LitElement { accessor active = false; @property({ attribute: false }) - accessor activeMode: 'color' | 'background' = 'color'; + accessor activeMode: 'color' | 'border' | 'background' = 'color'; @property({ attribute: false }) accessor arrow = true; @@ -179,6 +184,9 @@ export class EditorIconButton extends LitElement { @property({ attribute: false }) accessor iconContainerWidth: string | undefined = undefined; + @property({ attribute: false }) + accessor iconContainerHeight: string | undefined = undefined; + @property({ attribute: false }) accessor iconSize: string | undefined = undefined; diff --git a/blocksuite/affine/components/src/toolbar/menu-button.ts b/blocksuite/affine/components/src/toolbar/menu-button.ts index 4edb3c369f5d0..0f579e9cce85d 100644 --- a/blocksuite/affine/components/src/toolbar/menu-button.ts +++ b/blocksuite/affine/components/src/toolbar/menu-button.ts @@ -44,6 +44,7 @@ export class EditorMenuButton extends WithDisposable(LitElement) { { mainAxis: 12, ignoreShift: true, + offsetHeight: 6 * 4, } ); this._disposables.addFromEvent(this, 'keydown', (e: KeyboardEvent) => { @@ -54,9 +55,6 @@ export class EditorMenuButton extends WithDisposable(LitElement) { }); this._disposables.addFromEvent(this._trigger, 'click', (_: MouseEvent) => { this._popper.toggle(); - if (this._popper.state === 'show') { - this._content.focus({ preventScroll: true }); - } }); this._disposables.add(this._popper); } @@ -104,6 +102,7 @@ export class EditorMenuContent extends LitElement { --offset-height: calc(-1 * var(--packed-height)); display: none; outline: none; + overscroll-behavior: contain; } :host::before, @@ -154,6 +153,7 @@ export class EditorMenuContent extends LitElement { align-items: stretch; gap: unset; min-height: unset; + overflow-y: auto; } `; @@ -206,6 +206,11 @@ export class EditorMenuAction extends LitElement { color: var(--affine-icon-color); font-size: 20px; } + + ::slotted(.label) { + text-transform: capitalize !important; + color: inherit !important; + } `; override connectedCallback() { diff --git a/blocksuite/affine/components/src/toolbar/utils.ts b/blocksuite/affine/components/src/toolbar/utils.ts index b3876874b6ffb..003f658d73c6b 100644 --- a/blocksuite/affine/components/src/toolbar/utils.ts +++ b/blocksuite/affine/components/src/toolbar/utils.ts @@ -105,8 +105,10 @@ export function renderGroups(groups: MenuItemGroup[], context: T) { return renderActions(groupsToActions(groups, context)); } -export function renderToolbarSeparator() { - return html``; +export function renderToolbarSeparator(orientation?: 'horizontal') { + return html``; } export function getMoreMenuConfig(std: BlockStdScope): ToolbarMoreMenuConfig { diff --git a/blocksuite/affine/components/src/view-dropdown/dropdown.ts b/blocksuite/affine/components/src/view-dropdown/dropdown.ts new file mode 100644 index 0000000000000..d84b5cd3432af --- /dev/null +++ b/blocksuite/affine/components/src/view-dropdown/dropdown.ts @@ -0,0 +1,81 @@ +import { + type ToolbarAction, + ToolbarContext, +} from '@blocksuite/affine-shared/services'; +import { + PropTypes, + requiredProperties, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { ArrowDownSmallIcon } from '@blocksuite/icons/lit'; +import type { ReadonlySignal, Signal } from '@preact/signals-core'; +import { property } from 'lit/decorators.js'; +import { html } from 'lit-html'; +import { ifDefined } from 'lit-html/directives/if-defined.js'; +import { repeat } from 'lit-html/directives/repeat.js'; + +@requiredProperties({ + actions: PropTypes.array, + context: PropTypes.instanceOf(ToolbarContext), + viewType$: PropTypes.object, +}) +export class ViewDropdown extends SignalWatcher(ShadowlessElement) { + @property({ attribute: false }) + accessor actions!: ToolbarAction[]; + + @property({ attribute: false }) + accessor context!: ToolbarContext; + + @property({ attribute: false }) + accessor viewType$!: Signal | ReadonlySignal; + + override render() { + const { + actions, + context, + viewType$: { value: viewType }, + } = this; + + return html` + + ${viewType} + ${ArrowDownSmallIcon()} + + `} + > +
+ ${repeat( + actions, + action => action.id, + ({ id, label, disabled, run }) => html` + run?.(context)} + > + ${label} + + ` + )} +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-view-dropdown': ViewDropdown; + } +} diff --git a/blocksuite/affine/components/src/view-dropdown/index.ts b/blocksuite/affine/components/src/view-dropdown/index.ts new file mode 100644 index 0000000000000..eb3ff508afb34 --- /dev/null +++ b/blocksuite/affine/components/src/view-dropdown/index.ts @@ -0,0 +1,7 @@ +import { ViewDropdown } from './dropdown'; + +export * from './dropdown'; + +export function effects() { + customElements.define('affine-view-dropdown', ViewDropdown); +} diff --git a/blocksuite/affine/shared/src/services/index.ts b/blocksuite/affine/shared/src/services/index.ts index 69ab3b324cd10..ca4ab7fab36bc 100644 --- a/blocksuite/affine/shared/src/services/index.ts +++ b/blocksuite/affine/shared/src/services/index.ts @@ -18,3 +18,4 @@ export * from './quick-search-service'; export * from './sidebar-service'; export * from './telemetry-service'; export * from './theme-service'; +export * from './toolbar-service'; diff --git a/blocksuite/affine/shared/src/services/toolbar-service/action.ts b/blocksuite/affine/shared/src/services/toolbar-service/action.ts new file mode 100644 index 0000000000000..e1f33c9edaffd --- /dev/null +++ b/blocksuite/affine/shared/src/services/toolbar-service/action.ts @@ -0,0 +1,56 @@ +import type { TemplateResult } from 'lit'; + +import type { ToolbarContext } from './context'; + +export enum ActionPlacement { + Start = 0, + End = 1 << 1, + More = 1 << 2, +} + +type ActionBase = { + id: string; + score?: number; + when?: ((cx: ToolbarContext) => boolean) | boolean; + active?: ((cx: ToolbarContext) => boolean) | boolean; + placement?: ActionPlacement; +}; + +export type ToolbarAction = ActionBase & { + label?: string; + icon?: TemplateResult; + tooltip?: string; + variant?: 'destructive'; + disabled?: boolean; + content?: + | ((cx: ToolbarContext) => TemplateResult | null) + | (TemplateResult | null); + run?: (cx: ToolbarContext) => void; +}; + +// Generates an action at runtime +export type ToolbarActionGenerator = ActionBase & { + generate: (cx: ToolbarContext) => Omit | null; +}; + +export type ToolbarActionGroup< + T extends ActionBase = ToolbarAction | ToolbarActionGenerator, +> = ActionBase & { + actions: T[]; + content?: + | ((cx: ToolbarContext) => TemplateResult | null) + | (TemplateResult | null); +}; + +// Generates an action group at runtime +export type ToolbarActionGroupGenerator = ActionBase & { + generate: (cx: ToolbarContext) => Omit | null; +}; + +export type ToolbarGenericAction = + | ToolbarAction + | ToolbarActionGenerator + | ToolbarActionGroup + | ToolbarActionGroupGenerator; + +export type ToolbarActions = T[]; diff --git a/blocksuite/affine/shared/src/services/toolbar-service/config.ts b/blocksuite/affine/shared/src/services/toolbar-service/config.ts new file mode 100644 index 0000000000000..3598c5ee66b9a --- /dev/null +++ b/blocksuite/affine/shared/src/services/toolbar-service/config.ts @@ -0,0 +1,10 @@ +import type { ToolbarActions } from './action'; + +export type ToolbarModuleConfig = { + actions: ToolbarActions; + + // https://floating-ui.com/docs/computePosition#placement + placement?: 'top' | 'top-start'; + // https://floating-ui.com/docs/autoPlacement#allowedplacements + allowedPlacements?: ['top', 'bottom'] | ['top-start' | 'bottom-start']; +}; diff --git a/blocksuite/affine/shared/src/services/toolbar-service/context.ts b/blocksuite/affine/shared/src/services/toolbar-service/context.ts new file mode 100644 index 0000000000000..bed2d8a4eb83e --- /dev/null +++ b/blocksuite/affine/shared/src/services/toolbar-service/context.ts @@ -0,0 +1,113 @@ +import { BlockSelection, type BlockStdScope } from '@blocksuite/block-std'; +import type { + Block, + PropsGetter, + SchemaToModel, + SelectionConstructor, +} from '@blocksuite/store'; +import type { Signal } from '@preact/signals-core'; + +import { DocModeProvider } from '../doc-mode-service'; +import { ThemeProvider } from '../theme-service'; + +abstract class ToolbarContextBase { + constructor( + readonly std: BlockStdScope, + readonly flags$: Signal + ) {} + + get command() { + return this.std.command; + } + + get chain() { + return this.command.chain(); + } + + get doc() { + return this.store.doc; + } + + get host() { + return this.std.host; + } + + get selection() { + return this.std.selection; + } + + get store() { + return this.std.store; + } + + get view() { + return this.std.view; + } + + get readonly() { + return this.store.readonly; + } + + get docModeProvider() { + return this.std.get(DocModeProvider); + } + + get editorMode() { + return this.docModeProvider.getEditorMode() ?? 'page'; + } + + get isPageMode() { + return this.editorMode === 'page'; + } + + get isEdgelessMode() { + return this.editorMode === 'edgeless'; + } + + get themeProvider() { + return this.std.get(ThemeProvider); + } + + get theme() { + return this.themeProvider.theme; + } + + getCurrentBlockBy(type: T): Block | null { + const selection = this.selection.find(type ?? BlockSelection); + return (selection && this.store.getBlock(selection.blockId)) ?? null; + } + + getCurrentBlockModelBy< + T extends SelectionConstructor, + S extends { + model: { + props: PropsGetter; + flavour: string; + }; + }, + >(type: T, schema: S) { + const block = this.getCurrentBlockBy(type); + return block?.model.flavour === schema.model.flavour + ? (block.model as SchemaToModel) + : null; + } + + getCurrentBlockComponentBy< + T extends SelectionConstructor, + K extends abstract new (...args: any) => any, + >(type: T, klass: K): InstanceType | null { + const block = this.getCurrentBlockBy(type); + const component = block && this.view.getBlock(block.id); + return component instanceof klass ? (component as InstanceType) : null; + } + + show() { + this.flags$.value &= ~0b100000; + } + + hide() { + this.flags$.value |= 0b100000; + } +} + +export class ToolbarContext extends ToolbarContextBase {} diff --git a/blocksuite/affine/shared/src/services/toolbar-service/index.ts b/blocksuite/affine/shared/src/services/toolbar-service/index.ts new file mode 100644 index 0000000000000..da2e917b22ab7 --- /dev/null +++ b/blocksuite/affine/shared/src/services/toolbar-service/index.ts @@ -0,0 +1,6 @@ +export * from './action'; +export * from './config'; +export * from './context'; +export * from './module'; +export * from './registry'; +export * from './utils'; diff --git a/blocksuite/affine/shared/src/services/toolbar-service/module.ts b/blocksuite/affine/shared/src/services/toolbar-service/module.ts new file mode 100644 index 0000000000000..3892fc3f98308 --- /dev/null +++ b/blocksuite/affine/shared/src/services/toolbar-service/module.ts @@ -0,0 +1,9 @@ +import type { BlockFlavourIdentifier } from '@blocksuite/block-std'; + +import type { ToolbarModuleConfig } from './config'; + +export type ToolbarModule = { + readonly id: ReturnType; + + readonly config: ToolbarModuleConfig; +}; diff --git a/blocksuite/affine/shared/src/services/toolbar-service/registry.ts b/blocksuite/affine/shared/src/services/toolbar-service/registry.ts new file mode 100644 index 0000000000000..62599ea62a118 --- /dev/null +++ b/blocksuite/affine/shared/src/services/toolbar-service/registry.ts @@ -0,0 +1,59 @@ +import { + type BlockStdScope, + LifeCycleWatcher, + StdIdentifier, +} from '@blocksuite/block-std'; +import { + type Container, + createIdentifier, + createScope, +} from '@blocksuite/global/di'; +import type { ExtensionType } from '@blocksuite/store'; + +import type { ToolbarModule } from './module'; + +export const ToolbarModuleIdentifier = createIdentifier( + 'AffineToolbarModuleIdentifier' +); + +export const ToolbarModulesIdentifier = createIdentifier< + Map +>('AffineToolbarModulesIdentifier'); + +export const ToolbarRegistryScope = createScope('AffineToolbarRegistryScope'); + +export const ToolbarRegistryIdentifier = + createIdentifier('AffineToolbarRegistryIdentifier'); + +export function ToolbarModuleExtension(module: ToolbarModule): ExtensionType { + return { + setup: di => { + di.scope(ToolbarRegistryScope).addImpl( + ToolbarModuleIdentifier(module.id.variant), + module + ); + }, + }; +} + +export class ToolbarRegistryExtension extends LifeCycleWatcher { + constructor( + std: BlockStdScope, + readonly modules: Map + ) { + super(std); + } + + static override readonly key = 'toolbar-registry'; + + static override setup(di: Container) { + di.scope(ToolbarRegistryScope) + .addImpl(ToolbarModulesIdentifier, provider => + provider.getAll(ToolbarModuleIdentifier) + ) + .addImpl(ToolbarRegistryIdentifier, this, [ + StdIdentifier, + ToolbarModulesIdentifier, + ]); + } +} diff --git a/blocksuite/affine/shared/src/services/toolbar-service/utils.ts b/blocksuite/affine/shared/src/services/toolbar-service/utils.ts new file mode 100644 index 0000000000000..cd6ec45c1ef28 --- /dev/null +++ b/blocksuite/affine/shared/src/services/toolbar-service/utils.ts @@ -0,0 +1,7 @@ +export function generateActionIdWith( + flavour: string, + name: string, + prefix = 'com.affine.toolbar.internal' +) { + return `${prefix}.${flavour}.${name}`; +} diff --git a/blocksuite/affine/shared/src/utils/button-popper.ts b/blocksuite/affine/shared/src/utils/button-popper.ts index 2d24e953162db..6c0cf25f28fda 100644 --- a/blocksuite/affine/shared/src/utils/button-popper.ts +++ b/blocksuite/affine/shared/src/utils/button-popper.ts @@ -55,11 +55,13 @@ export function createButtonPopper( crossAxis, rootBoundary, ignoreShift, + offsetHeight, }: { mainAxis?: number; crossAxis?: number; rootBoundary?: Rect | (() => Rect | undefined); ignoreShift?: boolean; + offsetHeight?: number; } = {} ) { let display: Display = 'hidden'; @@ -87,9 +89,10 @@ export function createButtonPopper( size({ ...overflowOptions, apply({ availableHeight }) { - popperElement.style.maxHeight = originMaxHeight - ? `min(${originMaxHeight}, ${availableHeight}px)` - : `${availableHeight}px`; + popperElement.style.maxHeight = + originMaxHeight && originMaxHeight !== 'none' + ? `min(${originMaxHeight}, ${availableHeight}px)` + : `${availableHeight - (offsetHeight ?? 0)}px`; }, }), ], diff --git a/blocksuite/affine/widget-toolbar/package.json b/blocksuite/affine/widget-toolbar/package.json index 78f751a617508..7afa09ff05ee6 100644 --- a/blocksuite/affine/widget-toolbar/package.json +++ b/blocksuite/affine/widget-toolbar/package.json @@ -13,13 +13,22 @@ "author": "toeverything", "license": "MIT", "dependencies": { + "@blocksuite/affine-block-database": "workspace:*", + "@blocksuite/affine-components": "workspace:*", "@blocksuite/affine-model": "workspace:*", "@blocksuite/affine-shared": "workspace:*", "@blocksuite/block-std": "workspace:*", "@blocksuite/global": "workspace:*", + "@blocksuite/icons": "^2.2.1", + "@floating-ui/dom": "^1.6.10", "@preact/signals-core": "^1.8.0", "@toeverything/theme": "^1.1.3", - "lit": "^3.2.0" + "lit": "^3.2.0", + "lodash.groupby": "^4.6.0", + "lodash.mergewith": "^4.6.2", + "lodash.orderby": "^4.6.0", + "lodash.partition": "^4.6.0", + "lodash.topairs": "^4.3.0" }, "exports": { ".": "./src/index.ts", @@ -31,5 +40,12 @@ "!src/__tests__", "!dist/__tests__" ], - "version": "0.19.0" + "version": "0.19.0", + "devDependencies": { + "@types/lodash.groupby": "^4", + "@types/lodash.mergewith": "^4", + "@types/lodash.orderby": "^4", + "@types/lodash.partition": "^4", + "@types/lodash.topairs": "^4" + } } diff --git a/blocksuite/affine/widget-toolbar/src/renderer.ts b/blocksuite/affine/widget-toolbar/src/renderer.ts new file mode 100644 index 0000000000000..a9bbcf628c234 --- /dev/null +++ b/blocksuite/affine/widget-toolbar/src/renderer.ts @@ -0,0 +1,188 @@ +import { + type EditorToolbar, + renderToolbarSeparator, +} from '@blocksuite/affine-components/toolbar'; +import { + ActionPlacement, + type ToolbarAction, + type ToolbarActions, + type ToolbarContext, + type ToolbarModuleConfig, + ToolbarRegistryIdentifier, + ToolbarRegistryScope, +} from '@blocksuite/affine-shared/services'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import { MoreVerticalIcon } from '@blocksuite/icons/lit'; +import { html, render, type TemplateResult } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { join } from 'lit/directives/join.js'; +import { repeat } from 'lit/directives/repeat.js'; +import orderBy from 'lodash.orderby'; +import partition from 'lodash.partition'; + +import { combine } from './utils'; + +// A Renderer +export class Renderer { + get toolbarRegistry() { + const { container, provider } = this.std; + return container + .provider(ToolbarRegistryScope, provider) + .get(ToolbarRegistryIdentifier); + } + + constructor( + readonly std: BlockStdScope, + readonly context: ToolbarContext, + readonly toolbar: EditorToolbar, + readonly flavour: string + ) {} + + render() { + const { context, toolbar, toolbarRegistry, flavour } = this; + + // Merges the following configs: + // 1. `affine:note` + // 2. `custom:affine:note` + // 3. `affine:*` + // 4. `custom:affine:*` + const module = toolbarRegistry.modules.get(flavour); + if (!module) return; + const customModule = toolbarRegistry.modules.get(`custom:${flavour}`); + const customWildcardModule = toolbarRegistry.modules.get(`custom:affine:*`); + const config = module.config satisfies ToolbarModuleConfig; + const customConfig = (customModule?.config ?? { + actions: [], + }) satisfies ToolbarModuleConfig; + const customWildcardConfig = (customWildcardModule?.config ?? { + actions: [], + }) satisfies ToolbarModuleConfig; + + const combined = combine( + [ + ...config.actions, + ...customConfig.actions, + ...customWildcardConfig.actions, + ], + context + ); + + const ordered = orderBy(combined, ['placement', 'score'], ['asc', 'asc']); + + const [moreActionGroup, primaryActionGroup] = partition( + ordered, + a => a.placement === ActionPlacement.More + ); + + if (moreActionGroup.length) { + const moreMenuItems = renderActions( + moreActionGroup, + context, + renderMenuActionItem + ); + moreMenuItems.length && + primaryActionGroup.push({ + id: 'more', + content: html` + + ${MoreVerticalIcon()} + + `} + > +
+ ${join(moreMenuItems, () => + renderToolbarSeparator('horizontal') + )} +
+
+ `, + }); + } + + render( + join(renderActions(primaryActionGroup, context), () => + renderToolbarSeparator() + ), + toolbar + ); + } +} + +function renderActions( + actions: ToolbarActions, + context: ToolbarContext, + render = renderActionItem +) { + return actions + .map(action => { + let content: TemplateResult | null = null; + if ('content' in action && action.content) { + if (typeof action.content === 'function') { + content = action.content(context); + } else { + content = action.content; + } + return content; + } + + if ('actions' in action && action.actions.length) { + const combined = combine(action.actions, context); + + if (!combined.length) return content; + + const ordered = orderBy(combined, ['score', 'id'], ['asc', 'asc']); + + return repeat( + ordered, + b => b.id, + b => render(b, context) + ); + } + + if ('run' in action && action.run) { + return render(action, context); + } + + return content; + }) + .filter(action => action !== null); +} + +// TODO(@fundon): supports templates +function renderActionItem(action: ToolbarAction, context: ToolbarContext) { + return html` + action.run?.(context)} + > + ${action.icon} + ${action.label ? html`${action.label}` : null} + + `; +} + +function renderMenuActionItem(action: ToolbarAction, context: ToolbarContext) { + return html` + action.run?.(context)} + > + ${action.icon} + ${action.label ? html`${action.label}` : null} + + `; +} diff --git a/blocksuite/affine/widget-toolbar/src/toolbar.ts b/blocksuite/affine/widget-toolbar/src/toolbar.ts index b6e8472d11df2..4c3acf2b18bf3 100644 --- a/blocksuite/affine/widget-toolbar/src/toolbar.ts +++ b/blocksuite/affine/widget-toolbar/src/toolbar.ts @@ -1,5 +1,354 @@ -import { WidgetComponent } from '@blocksuite/block-std'; +import { DatabaseSelection } from '@blocksuite/affine-block-database'; +import { + getBlockSelectionsCommand, + getSelectedBlocksCommand, +} from '@blocksuite/affine-shared/commands'; +import { + ToolbarContext, + ToolbarRegistryIdentifier, + ToolbarRegistryScope, +} from '@blocksuite/affine-shared/services'; +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import { + BlockSelection, + SurfaceSelection, + TextSelection, + WidgetComponent, +} from '@blocksuite/block-std'; +import { Bound, getCommonBound, throttle } from '@blocksuite/global/utils'; +import type { Placement, ReferenceElement } from '@floating-ui/dom'; +import { batch, effect, signal } from '@preact/signals-core'; + +import { Renderer } from './renderer'; +import { autoUpdatePosition, initToolbar } from './utils'; + +enum Flag { + Surface = 0b1, + Block = 0b10, + Text = 0b100, + Native = 0b1000, + // Hovering something, e.g. inline links + Hovering = 0b10000, + // Dragging something or opening modal, e.g. drag handle, drag resources from outside, bookmark rename modal + Hiding = 0b100000, +} export const AFFINE_TOOLBAR_WIDGET = 'affine-toolbar-widget'; -export class AffineToolbarWidget extends WidgetComponent {} +export class AffineToolbarWidget extends WidgetComponent { + range$ = signal(null); + + flavour$ = signal('affine:note'); + + flags$ = signal(0b000000); + + toggleWith(flag: Flag, activated: boolean) { + if (activated) { + this.flags$.value |= flag; + return; + } + this.flags$.value &= ~flag; + } + + checkWith(flag: Flag, value = this.flags$.peek()) { + return (value & flag) === flag; + } + + containsWith(flag: number, value = this.flags$.peek()) { + return (value & flag) !== 0; + } + + refreshWith(flag: Flag) { + batch(() => { + this.toggleWith(flag, false); + this.toggleWith(flag, true); + }); + } + + toolbar = initToolbar(); + + get toolbarRegistry() { + const { container, provider } = this.std; + return container + .provider(ToolbarRegistryScope, provider) + .get(ToolbarRegistryIdentifier); + } + + override connectedCallback() { + super.connectedCallback(); + + const { + flags$, + flavour$, + range$, + disposables, + toolbar, + toolbarRegistry, + host, + std, + } = this; + const context = new ToolbarContext(std, flags$); + + // TODO(@fundon): fix toolbar position shaking when the wheel scrolls + // document.body.append(toolbar); + this.shadowRoot!.append(toolbar); + + disposables.add( + effect(() => { + const value = flags$.value; + const flavour = flavour$.value; + if (this.containsWith(Flag.Hovering | Flag.Hiding, value)) return; + if (!this.containsWith(Flag.Text | Flag.Native | Flag.Block, value)) + return; + + let virtualEl: ReferenceElement | null = null; + + if (this.checkWith(Flag.Block, value)) { + const [ok, { selectedBlocks }] = context.chain + .pipe(getBlockSelectionsCommand) + .pipe(getSelectedBlocksCommand, { types: ['block'] }) + .run(); + + if (!ok || !selectedBlocks?.length) return; + + virtualEl = { + getBoundingClientRect: () => { + const rects = selectedBlocks.map(e => e.getBoundingClientRect()); + const bounds = getCommonBound(rects.map(Bound.fromDOMRect)); + if (!bounds) return rects[0]; + return new DOMRect(bounds.x, bounds.y, bounds.w, bounds.h); + }, + getClientRects: () => + selectedBlocks.map(e => e.getBoundingClientRect()), + }; + } else { + const range = range$.value; + if (!range) return; + + virtualEl = { + getBoundingClientRect: () => range.getBoundingClientRect(), + getClientRects: () => range.getClientRects(), + }; + } + + if (!virtualEl) return; + + // TODO(@fundon): improves here + const isNote = flavour === 'affine:note'; + const placement = isNote ? ('top' as Placement) : undefined; + // const allowedPlacements = isNote + // ? (['top', 'bottom'] as Placement[]) + // : undefined; + + return autoUpdatePosition( + virtualEl, + toolbar, + placement + // allowedPlacements + ); + }) + ); + + // Formatting + // Selects text in note. + disposables.add( + std.selection.find$(TextSelection).subscribe(result => { + const activated = Boolean( + result && + !result.isCollapsed() && + result.from.length + (result.to?.length ?? 0) + ); + this.toggleWith(Flag.Text, activated); + }) + ); + + // Formatting + // Selects `native` text in database's cell. + disposables.addFromEvent(document, 'selectionchange', () => { + if (!host.event.active) return; + + let activated = false; + let range = std.range.value ?? null; + const valid = Boolean(range && !range.collapsed); + + if (valid) { + const result = std.selection.find(DatabaseSelection); + const viewSelection = result?.viewSelection; + + activated = Boolean( + viewSelection && + ((viewSelection.selectionType === 'area' && + viewSelection.isEditing) || + (viewSelection.selectionType === 'cell' && + viewSelection.isEditing)) + ); + } + + batch(() => { + range$.value = valid ? range : null; + this.toggleWith(Flag.Native, activated); + + if (activated) { + flavour$.value = 'affine:note'; + + this.refreshWith(Flag.Native); + return; + } + + if (!this.checkWith(Flag.Text)) return; + + this.refreshWith(Flag.Text); + }); + }); + + // Selects blocks in note. + disposables.add( + std.selection.filter$(BlockSelection).subscribe(result => { + const count = result.length; + let flavour = 'affine:note'; + let activated = Boolean(count); + + if (activated) { + // Handles a signal block. + const block = count === 1 && std.store.getBlock(result[0].blockId); + + // Chencks if block's config exists. + if (block) { + const modelFlavour = block.model.flavour; + const existed = + toolbarRegistry.modules.has(modelFlavour) || + toolbarRegistry.modules.has(`custom:${modelFlavour}`); + if (existed) { + flavour = modelFlavour; + } else { + activated = matchFlavours(block.model, [ + 'affine:paragraph', + 'affine:list', + 'affine:code', + 'affine:image', + ]); + } + } + } + + batch(() => { + flavour$.value = flavour; + + this.toggleWith(Flag.Block, activated); + + if (!activated) return; + + this.refreshWith(Flag.Block); + }); + }) + ); + + // Selects elements in edgeless. + // Triggered only when not in editing state. + disposables.add( + std.selection.filter$(SurfaceSelection).subscribe(result => { + const activated = + Boolean(result.length) && !result.some(e => e.editing); + this.toggleWith(Flag.Surface, activated); + }) + ); + + disposables.add( + std.selection.slots.changed.on(() => { + const value = flags$.peek(); + if (this.containsWith(Flag.Hovering | Flag.Hiding, value)) return; + + if (!this.checkWith(Flag.Text)) return; + + // refresh + this.refreshWith(Flag.Text); + }) + ); + + // TODO(@fundon): improve these cases + disposables.add( + std.store.slots.blockUpdated.on(record => { + if (record.type === 'delete') { + flags$.value = 0; + return; + } + }) + ); + + // Handles `drag and drop` + const dragStart = () => this.toggleWith(Flag.Hiding, true); + const dragEnd = () => this.toggleWith(Flag.Hiding, false); + const dragOptions = { global: true }; + const eventOptions = { passive: false }; + this.handleEvent('dragStart', dragStart, dragOptions); + this.handleEvent('dragEnd', dragEnd, dragOptions); + this.handleEvent('nativeDrop', dragEnd, dragOptions); + disposables.addFromEvent(host, 'dragenter', dragStart, eventOptions); + disposables.addFromEvent( + host, + 'dragleave', + throttle( + event => { + const { x, y, target } = event; + if (target === this) return; + const rect = host.getBoundingClientRect(); + if ( + x >= rect.left && + y >= rect.top && + x <= rect.bottom && + y <= rect.right + ) + return; + dragEnd(); + }, + 144, + { trailing: true } + ), + eventOptions + ); + + disposables.add( + flags$.subscribe(value => { + console.log('flags', value); + // Hides toolbar + if (value === 0) { + console.log('hide toolbar'); + toolbar.style.display = 'none'; + return; + } + + // Hides toolbar + if (this.checkWith(Flag.Hiding, value)) { + console.log('hiding'); + toolbar.style.display = 'none'; + return; + } + + // Shows toolbar of inline links + if (this.checkWith(Flag.Hovering, value)) { + console.log('hovering'); + return; + } + + // Shows `format-bar` + // * text: note + // * native: database + if (this.containsWith(Flag.Text | Flag.Native, value)) { + console.log('show formatting toolbar in note'); + new Renderer(std, context, toolbar, flavour$.peek()).render(); + return; + } + + // Shows normal toolbar in note + if (this.checkWith(Flag.Block, value)) { + console.log('show normal toolbar in note'); + new Renderer(std, context, toolbar, flavour$.peek()).render(); + return; + } + + // Shows toolbar in edgeles + console.log('show toolbar in edgeless'); + }) + ); + } +} diff --git a/blocksuite/affine/widget-toolbar/src/utils.ts b/blocksuite/affine/widget-toolbar/src/utils.ts new file mode 100644 index 0000000000000..903c687d66cfc --- /dev/null +++ b/blocksuite/affine/widget-toolbar/src/utils.ts @@ -0,0 +1,114 @@ +import { EditorToolbar } from '@blocksuite/affine-components/toolbar'; +import type { + ToolbarActions, + ToolbarContext, +} from '@blocksuite/affine-shared/services'; +import type { + FloatingElement, + Placement, + ReferenceElement, +} from '@floating-ui/dom'; +import { + // autoPlacement, + autoUpdate, + computePosition, + flip, + inline, + offset, + shift, +} from '@floating-ui/dom'; +import groupBy from 'lodash.groupby'; +import mergeWith from 'lodash.mergewith'; +import toPairs from 'lodash.topairs'; + +export function autoUpdatePosition( + referenceElement: ReferenceElement, + floating: FloatingElement, + placement: Placement = 'top-start' + // allowedPlacements: Placement[] = ['top-start', 'bottom-start'] +) { + const update = async () => { + const { x, y } = await computePosition(referenceElement, floating, { + placement, + middleware: [ + // offset(10), + offset(50), + inline(), + shift({ + padding: 6, + }), + flip(), + // autoPlacement({ + // allowedPlacements, + // }), + ], + }); + + Object.assign(floating.style, { + display: 'flex', + transform: `translate3d(${x}px, ${y}px, 0)`, + }); + }; + + return autoUpdate(referenceElement, floating, () => { + update().catch(console.error); + }); +} + +export function initToolbar( + style = { + position: 'absolute', + top: '0', + left: '0', + display: 'none', + width: 'max-content', + willChange: 'transform', + zIndex: 'var(--affine-z-index-popover)', + } +): EditorToolbar { + const toolbar = new EditorToolbar(); + Object.assign(toolbar.style, style); + return toolbar; +} + +export function combine(actions: ToolbarActions, context: ToolbarContext) { + const grouped = groupBy(actions, a => a.id); + + const paired = toPairs(grouped) + .map(([_, items]) => { + if (items.length === 1) return items; + const [first, ...others] = items; + if (others.length === 1) return merge({ ...first }, others[0]); + return others.reduce(merge, { ...first }); + }) + .flat(); + + const generated = paired.map(action => { + if ('generate' in action && action.generate) { + // TODO(@fundon): should delete `generate` fn + return { + ...action, + ...action.generate(context), + }; + } + return action; + }); + + const filtered = generated.filter(action => { + if (action.when) { + if (typeof action.when === 'function') return action.when(context); + return action.when; + } + return true; + }); + + return filtered; +} + +const merge = (a: any, b: any) => + mergeWith(a, b, (obj, src) => { + if (Array.isArray(obj)) { + return obj.concat(src); + } + return src; + }); diff --git a/blocksuite/affine/widget-toolbar/tsconfig.json b/blocksuite/affine/widget-toolbar/tsconfig.json index 50310f2829f8a..66f5a8a88e5a3 100644 --- a/blocksuite/affine/widget-toolbar/tsconfig.json +++ b/blocksuite/affine/widget-toolbar/tsconfig.json @@ -1,15 +1,17 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "rootDir": "./src/", - "outDir": "./dist/", - "noEmit": false + "rootDir": "./src", + "outDir": "./dist", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" }, "include": ["./src"], "references": [ + { "path": "../components" }, { "path": "../model" }, { "path": "../shared" }, { "path": "../../framework/block-std" }, + { "path": "../data-view" }, { "path": "../../framework/global" } ] } diff --git a/blocksuite/blocks/src/effects.ts b/blocksuite/blocks/src/effects.ts index ee245d37ac76f..20989be72766c 100644 --- a/blocksuite/blocks/src/effects.ts +++ b/blocksuite/blocks/src/effects.ts @@ -19,18 +19,21 @@ import { effects as componentAiItemEffects } from '@blocksuite/affine-components import { BlockSelection } from '@blocksuite/affine-components/block-selection'; import { BlockZeroWidth } from '@blocksuite/affine-components/block-zero-width'; import { effects as componentCaptionEffects } from '@blocksuite/affine-components/caption'; +import { effects as componentCardStyleDropdownEffects } from '@blocksuite/affine-components/card-style-dropdown'; import { effects as componentColorPickerEffects } from '@blocksuite/affine-components/color-picker'; import { effects as componentContextMenuEffects } from '@blocksuite/affine-components/context-menu'; import { effects as componentDatePickerEffects } from '@blocksuite/affine-components/date-picker'; import { effects as componentDropIndicatorEffects } from '@blocksuite/affine-components/drop-indicator'; import { FilterableListComponent } from '@blocksuite/affine-components/filterable-list'; import { IconButton } from '@blocksuite/affine-components/icon-button'; +import { effects as componentLinkPreviewEffects } from '@blocksuite/affine-components/link-preview'; import { effects as componentPortalEffects } from '@blocksuite/affine-components/portal'; import { effects as componentRichTextEffects } from '@blocksuite/affine-components/rich-text'; import { SmoothCorner } from '@blocksuite/affine-components/smooth-corner'; import { effects as componentToggleButtonEffects } from '@blocksuite/affine-components/toggle-button'; import { ToggleSwitch } from '@blocksuite/affine-components/toggle-switch'; import { effects as componentToolbarEffects } from '@blocksuite/affine-components/toolbar'; +import { effects as componentViewDropdownEffects } from '@blocksuite/affine-components/view-dropdown'; import { effects as widgetDragHandleEffects } from '@blocksuite/affine-widget-drag-handle/effects'; import { effects as widgetEdgelessAutoConnectEffects } from '@blocksuite/affine-widget-edgeless-auto-connect/effects'; import { effects as widgetFrameTitleEffects } from '@blocksuite/affine-widget-frame-title/effects'; @@ -213,8 +216,9 @@ export function effects() { componentToggleButtonEffects(); componentAiItemEffects(); componentColorPickerEffects(); - - widgetScrollAnchoringEffects(); + componentViewDropdownEffects(); + componentCardStyleDropdownEffects(); + componentLinkPreviewEffects(), widgetScrollAnchoringEffects(); widgetMobileToolbarEffects(); widgetLinkedDocEffects(); widgetFrameTitleEffects(); diff --git a/blocksuite/blocks/src/root-block/edgeless/edgeless-root-spec.ts b/blocksuite/blocks/src/root-block/edgeless/edgeless-root-spec.ts index eb8665c2c3633..87b1489e04768 100644 --- a/blocksuite/blocks/src/root-block/edgeless/edgeless-root-spec.ts +++ b/blocksuite/blocks/src/root-block/edgeless/edgeless-root-spec.ts @@ -5,6 +5,7 @@ import { EmbedOptionService, PageViewportServiceExtension, ThemeService, + ToolbarRegistryExtension, } from '@blocksuite/affine-shared/services'; import { AFFINE_DRAG_HANDLE_WIDGET } from '@blocksuite/affine-widget-drag-handle'; import { AFFINE_EDGELESS_AUTO_CONNECT_WIDGET } from '@blocksuite/affine-widget-edgeless-auto-connect'; @@ -99,6 +100,7 @@ const EdgelessCommonExtension: ExtensionType[] = [ PageViewportServiceExtension, RootBlockAdapterExtensions, FileDropExtension, + ToolbarRegistryExtension, ].flat(); export const EdgelessRootBlockSpec: ExtensionType[] = [ diff --git a/blocksuite/blocks/src/root-block/page/page-root-spec.ts b/blocksuite/blocks/src/root-block/page/page-root-spec.ts index 6a0c3de4fa0f0..c319c65a7844a 100644 --- a/blocksuite/blocks/src/root-block/page/page-root-spec.ts +++ b/blocksuite/blocks/src/root-block/page/page-root-spec.ts @@ -5,6 +5,7 @@ import { EmbedOptionService, PageViewportServiceExtension, ThemeService, + ToolbarRegistryExtension, } from '@blocksuite/affine-shared/services'; import { AFFINE_DRAG_HANDLE_WIDGET } from '@blocksuite/affine-widget-drag-handle'; import { AFFINE_DOC_REMOTE_SELECTION_WIDGET } from '@blocksuite/affine-widget-remote-selection'; @@ -80,6 +81,7 @@ export const PageRootBlockSpec: ExtensionType[] = [ DNDAPIExtension, RootBlockAdapterExtensions, FileDropExtension, + ToolbarRegistryExtension, ].flat(); export const PreviewPageRootBlockSpec: ExtensionType[] = [ diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-attachment-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-attachment-button.ts index 7f95ed668d951..1d87ed0bb8e74 100644 --- a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-attachment-button.ts +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-attachment-button.ts @@ -1,6 +1,6 @@ import { type AttachmentBlockComponent, - attachmentViewToggleMenu, + // attachmentViewToggleMenu, } from '@blocksuite/affine-block-attachment'; import { getEmbedCardIcons } from '@blocksuite/affine-block-embed'; import { @@ -78,16 +78,16 @@ export class EdgelessChangeAttachmentButton extends WithDisposable(LitElement) { return this.edgeless.std; } - get viewToggleMenu() { - const block = this._block; - const model = this.model; - if (!block || !model) return nothing; + // get viewToggleMenu() { + // const block = this._block; + // const model = this.model; + // if (!block || !model) return nothing; - return attachmentViewToggleMenu({ - block, - callback: () => this.requestUpdate(), - }); - } + // return attachmentViewToggleMenu({ + // block, + // callback: () => this.requestUpdate(), + // }); + // } override render() { return join( @@ -114,7 +114,7 @@ export class EdgelessChangeAttachmentButton extends WithDisposable(LitElement) { `, - this.viewToggleMenu, + // this.viewToggleMenu, html` - ${getHostName(model.url)} - - ` + ? html`` : nothing, // internal embed model diff --git a/blocksuite/blocks/src/root-block/widgets/embed-card-toolbar/styles.ts b/blocksuite/blocks/src/root-block/widgets/embed-card-toolbar/styles.ts index c042746bcf456..b62ee66b1b723 100644 --- a/blocksuite/blocks/src/root-block/widgets/embed-card-toolbar/styles.ts +++ b/blocksuite/blocks/src/root-block/widgets/embed-card-toolbar/styles.ts @@ -8,39 +8,6 @@ export const embedCardToolbarStyle = css` z-index: var(--affine-z-index-popover); } - .affine-link-preview { - display: flex; - justify-content: flex-start; - min-width: 60px; - max-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); - } - .card-style-select icon-button.selected { border: 1px solid var(--affine-brand-color); } diff --git a/blocksuite/blocks/src/root-block/widgets/format-bar/components/config-renderer.ts b/blocksuite/blocks/src/root-block/widgets/format-bar/components/config-renderer.ts index cf8fd357537fe..7e1cefe7f2024 100644 --- a/blocksuite/blocks/src/root-block/widgets/format-bar/components/config-renderer.ts +++ b/blocksuite/blocks/src/root-block/widgets/format-bar/components/config-renderer.ts @@ -48,7 +48,6 @@ export function ConfigRenderer(formatBar: AffineFormatBarWidget) { .tooltip=${item.name} @click=${() => { item.action(formatBar.std.command.chain(), formatBar); - formatBar.requestUpdate(); }} > ${typeof item.icon === 'function' ? item.icon() : item.icon} diff --git a/blocksuite/blocks/src/root-block/widgets/format-bar/components/paragraph-button.ts b/blocksuite/blocks/src/root-block/widgets/format-bar/components/paragraph-button.ts index 8f372411abeca..33fd6630a1a3f 100644 --- a/blocksuite/blocks/src/root-block/widgets/format-bar/components/paragraph-button.ts +++ b/blocksuite/blocks/src/root-block/widgets/format-bar/components/paragraph-button.ts @@ -35,7 +35,7 @@ const ParagraphPanel = ({ item => html` item.action(formatBar.std.command.chain(), formatBar)} > ${typeof item.icon === 'function' ? item.icon() : item.icon} ${item.name} diff --git a/packages/frontend/core/src/blocksuite/presets/ai/ai-spec.ts b/packages/frontend/core/src/blocksuite/presets/ai/ai-spec.ts index 6ad115b1ebfa1..840057f49bcf7 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/ai-spec.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/ai-spec.ts @@ -1,4 +1,5 @@ import { + BlockFlavourIdentifier, BlockServiceWatcher, WidgetViewMapIdentifier, } from '@blocksuite/affine/block-std'; @@ -20,6 +21,7 @@ import { pageRootWidgetViewMap, ParagraphBlockService, ParagraphBlockSpec, + ToolbarModuleExtension, } from '@blocksuite/affine/blocks'; import { assertInstanceOf } from '@blocksuite/affine/global/utils'; import type { ExtensionType } from '@blocksuite/affine/store'; @@ -32,7 +34,10 @@ import { setupEdgelessCopilot, setupEdgelessElementToolbarAIEntry, } from './entries/edgeless/index'; -import { setupFormatBarAIEntry } from './entries/format-bar/setup-format-bar'; +import { + setupFormatBarAIEntry, + toolbarAIEntryConfig, +} from './entries/format-bar/setup-format-bar'; import { setupImageToolbarAIEntry } from './entries/image-toolbar/setup-image-toolbar'; import { setupSlashMenuAIEntry } from './entries/slash-menu/setup-slash-menu'; import { setupSpaceAIEntry } from './entries/space/setup-space'; @@ -81,6 +86,10 @@ export function createAIPageRootBlockSpec( }); }, }, + ToolbarModuleExtension({ + id: BlockFlavourIdentifier('custom:affine:note'), + config: toolbarAIEntryConfig(), + }), ]; } @@ -139,6 +148,10 @@ export function createAIEdgelessRootBlockSpec( }); }, }, + ToolbarModuleExtension({ + id: BlockFlavourIdentifier('custom:affine:note'), + config: toolbarAIEntryConfig(), + }), ]; } diff --git a/packages/frontend/core/src/blocksuite/presets/ai/entries/format-bar/setup-format-bar.ts b/packages/frontend/core/src/blocksuite/presets/ai/entries/format-bar/setup-format-bar.ts index 25052bb48d04a..3928991f0d884 100644 --- a/packages/frontend/core/src/blocksuite/presets/ai/entries/format-bar/setup-format-bar.ts +++ b/packages/frontend/core/src/blocksuite/presets/ai/entries/format-bar/setup-format-bar.ts @@ -1,8 +1,10 @@ import '../../_common/components/ask-ai-button'; import { + ActionPlacement, type AffineFormatBarWidget, toolbarDefaultConfig, + type ToolbarModuleConfig, } from '@blocksuite/affine/blocks'; import { html, type TemplateResult } from 'lit'; @@ -42,3 +44,37 @@ const getRichText = () => { if (!commonAncestorContainer) return null; return commonAncestorContainer.closest('rich-text'); }; + +export function toolbarAIEntryConfig(): ToolbarModuleConfig { + return { + actions: [ + { + id: 'ai', + placement: ActionPlacement.Start, + score: -1, + when({ host, std }) { + const range = std.range.value; + if (!range) return true; + const commonAncestorContainer = + range.commonAncestorContainer instanceof Element + ? range.commonAncestorContainer + : range.commonAncestorContainer.parentElement; + if (!commonAncestorContainer) return true; + const richText = commonAncestorContainer.closest('rich-text'); + return richText + ? host.contains(richText) && + richText.dataset.disableAskAi === undefined + : true; + }, + content({ host }) { + return html` + + `; + }, + }, + ], + }; +} diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/common.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/common.ts index cc77296a06064..ea3f53c003f4a 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/common.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/common.ts @@ -23,6 +23,7 @@ import { RefNodeSlotsExtension, RichTextExtensions, TableBlockSpec, + ToolbarRegistryExtension, } from '@blocksuite/affine/blocks'; import type { ExtensionType } from '@blocksuite/affine/store'; @@ -42,6 +43,7 @@ const CommonBlockSpecs: ExtensionType[] = [ AdapterFactoryExtensions, FontLoaderService, DefaultOpenDocExtension, + ToolbarRegistryExtension, ].flat(); export const DefaultBlockSpecs: ExtensionType[] = [ diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/root-block.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/root-block.ts index b7e84d7387778..c35a07c69c241 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/root-block.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/root-block.ts @@ -10,6 +10,7 @@ import { EditorSettingService } from '@affine/core/modules/editor-setting'; import { AppThemeService } from '@affine/core/modules/theme'; import { mixpanel } from '@affine/track'; import { + BlockFlavourIdentifier, ConfigExtension, LifeCycleWatcher, StdIdentifier, @@ -35,6 +36,7 @@ import { SpecProvider, TelemetryProvider, ThemeExtensionIdentifier, + ToolbarModuleExtension, } from '@blocksuite/affine/blocks'; import type { Container } from '@blocksuite/affine/global/di'; import type { ExtensionType } from '@blocksuite/affine/store'; @@ -47,7 +49,10 @@ import { combineLatest, map } from 'rxjs'; import { getFontConfigExtension } from '../font-extension'; import { createDatabaseOptionsConfig } from './database-block'; import { createLinkedWidgetConfig } from './widgets/linked'; -import { createToolbarMoreMenuConfig } from './widgets/toolbar'; +import { + createToolbarMoreMenuConfig, + extendToolbarMoreMenuConfig, +} from './widgets/toolbar'; function getTelemetryExtension(): ExtensionType { return { @@ -236,6 +241,11 @@ function getEditorConfigExtension( linkedWidget: createLinkedWidgetConfig(framework), toolbarMoreMenu: createToolbarMoreMenuConfig(framework), } satisfies RootBlockConfig), + + ToolbarModuleExtension({ + id: BlockFlavourIdentifier('custom:affine:*'), + config: extendToolbarMoreMenuConfig, + }), ]; } diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/toolbar.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/toolbar.ts index 78b3e591aeb2b..9cca0450449f0 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/toolbar.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/toolbar.ts @@ -8,12 +8,18 @@ import { EditorService } from '@affine/core/modules/editor'; import { copyLinkToBlockStdScopeClipboard } from '@affine/core/utils/clipboard'; import { I18n } from '@affine/i18n'; import { track } from '@affine/track'; +import { SurfaceSelection } from '@blocksuite/affine/block-std'; import type { GfxBlockElementModel, GfxPrimitiveElementModel, } from '@blocksuite/affine/block-std/gfx'; -import type { MenuContext, MenuItemGroup } from '@blocksuite/affine/blocks'; -import { LinkIcon } from '@blocksuite/icons/lit'; +import { + ActionPlacement, + type MenuContext, + type MenuItemGroup, + type ToolbarModuleConfig, +} from '@blocksuite/affine/blocks'; +import { CopyAsImgaeIcon, LinkIcon } from '@blocksuite/icons/lit'; import type { FrameworkProvider } from '@toeverything/infra'; import { createCopyAsPngMenuItem } from './copy-as-image'; @@ -136,3 +142,38 @@ function createCopyLinkToBlockMenuItem( }, }; } + +export const extendToolbarMoreMenuConfig = { + actions: [ + { + id: 'clipboard', + placement: ActionPlacement.More, + actions: [ + { + id: 'copy-as-image', + label: 'Copy as Image', + icon: CopyAsImgaeIcon(), + when: ({ isEdgelessMode, selection }) => + isEdgelessMode && selection.getGroup('note').length === 0, + run() {}, + }, + { + id: 'copy-link-to-block', + label: 'Copy link to block', + icon: LinkIcon(), + when: ({ isPageMode, selection }) => { + const hasNoteSelection = selection.getGroup('note').length > 0; + if (isPageMode) return hasNoteSelection; + + // Linking blocks in notes is currently not supported in edgeless mode. + if (hasNoteSelection) return false; + + // Linking single block/element in edgeless mode. + return selection.filter(SurfaceSelection).length === 1; + }, + run() {}, + }, + ], + }, + ], +} as const satisfies ToolbarModuleConfig; diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index 9de96ae69aed5..dd00b767da1c0 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -362,9 +362,11 @@ export const PackageList = [ location: 'blocksuite/affine/widget-toolbar', name: '@blocksuite/affine-widget-toolbar', workspaceDependencies: [ + 'blocksuite/affine/components', 'blocksuite/affine/model', 'blocksuite/affine/shared', 'blocksuite/framework/block-std', + 'blocksuite/affine/data-view', 'blocksuite/framework/global', ], }, diff --git a/yarn.lock b/yarn.lock index cfdb0de2f9135..6eda358c6fcf8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3313,6 +3313,7 @@ __metadata: "@toeverything/theme": "npm:^1.1.7" lit: "npm:^3.2.0" minimatch: "npm:^10.0.1" + yjs: "npm:^13.6.23" zod: "npm:^3.23.8" languageName: unknown linkType: soft @@ -3561,12 +3562,14 @@ __metadata: version: 0.0.0-use.local resolution: "@blocksuite/affine-block-note@workspace:blocksuite/affine/block-note" dependencies: + "@blocksuite/affine-block-database": "workspace:*" "@blocksuite/affine-block-embed": "workspace:*" "@blocksuite/affine-block-surface": "workspace:*" "@blocksuite/affine-components": "workspace:*" "@blocksuite/affine-model": "workspace:*" "@blocksuite/affine-shared": "workspace:*" "@blocksuite/block-std": "workspace:*" + "@blocksuite/data-view": "workspace:*" "@blocksuite/global": "workspace:*" "@blocksuite/icons": "npm:^2.2.1" "@blocksuite/inline": "workspace:*" @@ -3862,13 +3865,27 @@ __metadata: version: 0.0.0-use.local resolution: "@blocksuite/affine-widget-toolbar@workspace:blocksuite/affine/widget-toolbar" dependencies: + "@blocksuite/affine-block-database": "workspace:*" + "@blocksuite/affine-components": "workspace:*" "@blocksuite/affine-model": "workspace:*" "@blocksuite/affine-shared": "workspace:*" "@blocksuite/block-std": "workspace:*" "@blocksuite/global": "workspace:*" + "@blocksuite/icons": "npm:^2.2.1" + "@floating-ui/dom": "npm:^1.6.10" "@preact/signals-core": "npm:^1.8.0" "@toeverything/theme": "npm:^1.1.3" + "@types/lodash.groupby": "npm:^4" + "@types/lodash.mergewith": "npm:^4" + "@types/lodash.orderby": "npm:^4" + "@types/lodash.partition": "npm:^4" + "@types/lodash.topairs": "npm:^4" lit: "npm:^3.2.0" + lodash.groupby: "npm:^4.6.0" + lodash.mergewith: "npm:^4.6.2" + lodash.orderby: "npm:^4.6.0" + lodash.partition: "npm:^4.6.0" + lodash.topairs: "npm:^4.3.0" languageName: unknown linkType: soft @@ -14680,6 +14697,13 @@ __metadata: languageName: node linkType: hard +"@toeverything/theme@npm:^1.1.3": + version: 1.1.8 + resolution: "@toeverything/theme@npm:1.1.8" + checksum: 10/0a4d61b624a18681ee4c2169992e0a59552da131419f10ec2e4c8141d12c2591ac2323ddcb26366ae6785c6715a480b3cc728527025c5deaaed0a8d1f431ed26 + languageName: node + linkType: hard + "@toeverything/theme@npm:^1.1.7": version: 1.1.7 resolution: "@toeverything/theme@npm:1.1.7" @@ -15324,6 +15348,15 @@ __metadata: languageName: node linkType: hard +"@types/lodash.groupby@npm:^4": + version: 4.6.9 + resolution: "@types/lodash.groupby@npm:4.6.9" + dependencies: + "@types/lodash": "npm:*" + checksum: 10/b8310a9f89badc42a504887ca0b9619c2a284b3fec8dc505cf72508eb6beba47b822df939c7d57c0f69bc685f51ff5a232e0480ecad6b18b7ab76fecc1d74691 + languageName: node + linkType: hard + "@types/lodash.isequal@npm:^4.5.8": version: 4.5.8 resolution: "@types/lodash.isequal@npm:4.5.8" @@ -15360,6 +15393,33 @@ __metadata: languageName: node linkType: hard +"@types/lodash.orderby@npm:^4": + version: 4.6.9 + resolution: "@types/lodash.orderby@npm:4.6.9" + dependencies: + "@types/lodash": "npm:*" + checksum: 10/f3ca3a6ba09caef431955f3110a6704e25c90934fb70973e2ed1f4dd9aa48c4776c7e8b909b78f9ae1fb169e437497f8fe8e60fafb5c74ede49c36739ac44c3e + languageName: node + linkType: hard + +"@types/lodash.partition@npm:^4": + version: 4.6.9 + resolution: "@types/lodash.partition@npm:4.6.9" + dependencies: + "@types/lodash": "npm:*" + checksum: 10/29d1fead7f9a71af8279ceb6c1b08128c7f05f06a72ed60fea02a7c010f69006a4e3dc24afb9c4a3187a033b918a1b3dcf611004781f927ff573b104bdf34060 + languageName: node + linkType: hard + +"@types/lodash.topairs@npm:^4": + version: 4.3.9 + resolution: "@types/lodash.topairs@npm:4.3.9" + dependencies: + "@types/lodash": "npm:*" + checksum: 10/8d4d9cb7be1c333b70b8d035d0673ef41158bfe24e06616046879149a15438af89673fe21abe1b1e62ddbc00eae5fe4c3795649de8a98742771ba8949100a300 + languageName: node + linkType: hard + "@types/lodash@npm:*": version: 4.17.14 resolution: "@types/lodash@npm:4.17.14" @@ -26218,6 +26278,13 @@ __metadata: languageName: node linkType: hard +"lodash.groupby@npm:^4.6.0": + version: 4.6.0 + resolution: "lodash.groupby@npm:4.6.0" + checksum: 10/98bd04e58ce4cebb2273010352508b5ea12025e94fcfd70c84c8082ef3b0689178e8e6dd53bff919f525fae9bd67b4aba228d606b75a967f30e84ec9610b5de1 + languageName: node + linkType: hard + "lodash.isarguments@npm:^3.1.0": version: 3.1.0 resolution: "lodash.isarguments@npm:3.1.0" @@ -26281,6 +26348,20 @@ __metadata: languageName: node linkType: hard +"lodash.orderby@npm:^4.6.0": + version: 4.6.0 + resolution: "lodash.orderby@npm:4.6.0" + checksum: 10/48da21dcdc25c837f7390cb94062ed6ca6ee8a6bce5bf4ebb6ba118dbb0138f976725efa3d560958d7b3d7339daa1eb31a1223caa38788dd632304cf1450c5ca + languageName: node + linkType: hard + +"lodash.partition@npm:^4.6.0": + version: 4.6.0 + resolution: "lodash.partition@npm:4.6.0" + checksum: 10/6912bdf8006e1389242d35dc670570c470c93822c6859f5e0c18426be519e9ebba576969443fca256ef5913d12319ffa1a1eb870ac403978089cc1c703d705c2 + languageName: node + linkType: hard + "lodash.snakecase@npm:^4.1.1": version: 4.1.1 resolution: "lodash.snakecase@npm:4.1.1" @@ -26302,6 +26383,13 @@ __metadata: languageName: node linkType: hard +"lodash.topairs@npm:^4.3.0": + version: 4.3.0 + resolution: "lodash.topairs@npm:4.3.0" + checksum: 10/32a0e8654e025676288310e0c1401edd8a4255ed6413693e62f38dac68c24f547734df51893ea44a5e5b2d67e99f0f77f014090de5e3b747e6129650257b4871 + languageName: node + linkType: hard + "lodash.truncate@npm:^4.4.2": version: 4.4.2 resolution: "lodash.truncate@npm:4.4.2"