diff --git a/blocksuite/affine/block-attachment/src/attachment-spec.ts b/blocksuite/affine/block-attachment/src/attachment-spec.ts index fed3db5d084eb..bc324275b8cba 100644 --- a/blocksuite/affine/block-attachment/src/attachment-spec.ts +++ b/blocksuite/affine/block-attachment/src/attachment-spec.ts @@ -1,21 +1,29 @@ -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'; -import { AttachmentBlockNotionHtmlAdapterExtension } from './adapters/notion-html.js'; +import { AttachmentBlockNotionHtmlAdapterExtension } from './adapters/notion-html'; import { AttachmentBlockService, AttachmentDropOption, -} from './attachment-service.js'; +} from './attachment-service'; +import { builtinToolbarConfig } from './configs/toolbar'; import { AttachmentEmbedConfigExtension, AttachmentEmbedService, -} from './embed.js'; +} from './embed'; + +const Flavour = 'affine:attachment'; export const AttachmentBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:attachment'), + FlavourExtension(Flavour), AttachmentBlockService, - BlockViewExtension('affine:attachment', model => { + BlockViewExtension(Flavour, model => { return model.parent?.flavour === 'affine:surface' ? literal`affine-edgeless-attachment` : literal`affine-attachment`; @@ -24,4 +32,8 @@ export const AttachmentBlockSpec: ExtensionType[] = [ AttachmentEmbedConfigExtension(), AttachmentEmbedService, AttachmentBlockNotionHtmlAdapterExtension, + ToolbarModuleExtension({ + id: BlockFlavourIdentifier(Flavour), + config: builtinToolbarConfig, + }), ]; 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..e279dcba1c433 --- /dev/null +++ b/blocksuite/affine/block-attachment/src/configs/toolbar.ts @@ -0,0 +1,155 @@ +import { AttachmentBlockSchema } from '@blocksuite/affine-model'; +import { + ActionPlacement, + type ToolbarAction, + type ToolbarActionGroup, + type ToolbarModuleConfig, +} from '@blocksuite/affine-shared/services'; +import { BlockSelection } from '@blocksuite/block-std'; +import { + ArrowDownSmallIcon, + CaptionIcon, + CopyIcon, + DeleteIcon, + DownloadIcon, + DuplicateIcon, + EditIcon, + ResetIcon, +} from '@blocksuite/icons/lit'; +import { html } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +// import { AttachmentEmbedProvider } from '../embed'; + +export const builtinToolbarConfig = { + actions: [ + { + id: 'rename', + tooltip: 'Rename', + icon: EditIcon(), + run(_cx) {}, + }, + { + id: 'conversions', + actions: [ + { + id: 'card-view', + label: 'Card view', + run(_cx) {}, + }, + { + id: 'embed-view', + label: 'Embed view', + run(_cx) {}, + }, + ], + content(cx) { + const model = cx.getFirstBlockModelBy( + BlockSelection, + AttachmentBlockSchema + ); + if (!model) return null; + + const { embed } = model; + const viewType = embed ? 'embed' : 'card'; + const embeded = true; + // const embedded = cx.std.get(AttachmentEmbedProvider) + // .embedded(model, ) + + return html` + + ${viewType} view + ${ArrowDownSmallIcon()} + + `} + > +
+ ${repeat( + this.actions, + action => action.id, + ({ id, label, run }) => html` + run?.(cx)} + > + ${label} + + ` + )} +
+
+ `; + }, + } satisfies ToolbarActionGroup, + { + id: 'download', + tooltip: 'Download', + icon: DownloadIcon(), + 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: 'refresh', + placement: ActionPlacement.More, + actions: [ + { + id: 'reload', + label: 'Reload', + icon: ResetIcon(), + 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-bookmark/src/bookmark-spec.ts b/blocksuite/affine/block-bookmark/src/bookmark-spec.ts index ec870ba6aacb1..896ecf4404b1a 100644 --- a/blocksuite/affine/block-bookmark/src/bookmark-spec.ts +++ b/blocksuite/affine/block-bookmark/src/bookmark-spec.ts @@ -1,4 +1,6 @@ +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; import { + BlockFlavourIdentifier, BlockViewExtension, CommandExtension, FlavourExtension, @@ -6,16 +8,23 @@ import { import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { BookmarkBlockAdapterExtensions } from './adapters/extension.js'; -import { commands } from './commands/index.js'; +import { BookmarkBlockAdapterExtensions } from './adapters/extension'; +import { commands } from './commands/index'; +import { builtinToolbarConfig } from './configs/toolbar'; + +const Flavour = 'affine:bookmark'; export const BookmarkBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:bookmark'), + FlavourExtension(Flavour), CommandExtension(commands), - BlockViewExtension('affine:bookmark', model => { + 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..2847bacb3ea1c --- /dev/null +++ b/blocksuite/affine/block-bookmark/src/configs/toolbar.ts @@ -0,0 +1,118 @@ +import { BookmarkBlockSchema } from '@blocksuite/affine-model'; +import { + ActionPlacement, + type ToolbarActionGroup, + type ToolbarModuleConfig, +} from '@blocksuite/affine-shared/services'; +import { getHostName } from '@blocksuite/affine-shared/utils'; +import { BlockSelection } from '@blocksuite/block-std'; +import { + CaptionIcon, + CopyIcon, + DeleteIcon, + DuplicateIcon, + PaletteIcon, + ResetIcon, +} from '@blocksuite/icons/lit'; +import { html } from 'lit'; + +export const builtinToolbarConfig = { + actions: [ + { + id: 'preview', + content(cx) { + const model = cx.getFirstBlockModelBy( + BlockSelection, + BookmarkBlockSchema + ); + if (!model) return null; + + const { url } = model; + + return html` + + ${getHostName(url)} + + `; + }, + }, + { + id: 'conversions', + actions: [ + { + id: 'inline-view', + label: 'Inline view', + run(_cx) {}, + }, + { + id: 'card-view', + label: 'Card view', + run(_cx) {}, + }, + ], + content(_cx) { + this.actions; + return null; + }, + } satisfies ToolbarActionGroup, + { + id: 'style', + tooltip: 'Card style', + icon: PaletteIcon(), + 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: 'refresh', + placement: ActionPlacement.More, + actions: [ + { + id: 'reload', + label: 'Reload', + icon: ResetIcon(), + 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 78d17fb51d6b9..4a339aab3fc7c 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,16 +1,29 @@ -import { BlockViewExtension, CommandExtension } from '@blocksuite/block-std'; +import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model'; +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; +import { + BlockServiceIdentifier, + BlockViewExtension, + CommandExtension, +} from '@blocksuite/block-std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { EmbedLinkedDocBlockAdapterExtensions } from './adapters/extension.js'; -import { commands } from './commands/index.js'; +import { builtinToolbarConfigForInternal } from '../configs/toolbar'; +import { EmbedLinkedDocBlockAdapterExtensions } from './adapters/extension'; +import { commands } from './commands/index'; + +const flavour = EmbedLinkedDocBlockSchema.model.flavour as BlockSuite.Flavour; export const EmbedLinkedDocBlockSpec: ExtensionType[] = [ CommandExtension(commands), - 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..afe096206c5e9 100644 --- a/blocksuite/affine/block-embed/src/index.ts +++ b/blocksuite/affine/block-embed/src/index.ts @@ -1,5 +1,9 @@ import type { ExtensionType } from '@blocksuite/store'; +import { + builtinToolbarConfigForExternal, + builtinToolbarConfigForInternal, +} from './configs/toolbar'; import { EmbedFigmaBlockSpec } from './embed-figma-block'; import { EmbedGithubBlockSpec } from './embed-github-block'; import { EmbedHtmlBlockSpec } from './embed-html-block'; @@ -8,12 +12,17 @@ import { EmbedLoomBlockSpec } from './embed-loom-block'; import { EmbedSyncedDocBlockSpec } from './embed-synced-doc-block'; import { EmbedYoutubeBlockSpec } from './embed-youtube-block'; +console.log(builtinToolbarConfigForExternal, builtinToolbarConfigForInternal); + export const EmbedExtensions: ExtensionType[] = [ + // External embed blocks EmbedFigmaBlockSpec, EmbedGithubBlockSpec, - EmbedHtmlBlockSpec, EmbedLoomBlockSpec, EmbedYoutubeBlockSpec, + + // Internal embed blocks + EmbedHtmlBlockSpec, EmbedLinkedDocBlockSpec, EmbedSyncedDocBlockSpec, ].flat(); 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..8b69d72854034 --- /dev/null +++ b/blocksuite/affine/block-image/src/configs/toolbar.ts @@ -0,0 +1,54 @@ +import 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: 'more', + actions: [ + { + id: 'copy', + label: 'Copy', + run(_cx) {}, + }, + { + id: 'duplicate', + label: 'Duplicate', + run(_cx) {}, + }, + ], + }, + { + id: 'conversions', + placement: 'more', + actions: [ + { + id: 'turn-into-card-view', + label: 'Turn into card view', + run(_cx) {}, + }, + ], + }, + { + id: 'delete', + placement: '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 a1fbe453bf62e..579d201b79afb 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, CommandExtension, FlavourExtension, @@ -7,15 +9,18 @@ import { import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; -import { ImageBlockAdapterExtensions } from './adapters/extension.js'; -import { commands } from './commands/index.js'; -import { ImageBlockService, ImageDropOption } from './image-service.js'; +import { ImageBlockAdapterExtensions } from './adapters/extension'; +import { commands } from './commands/index'; +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, CommandExtension(commands), - BlockViewExtension('affine:image', model => { + BlockViewExtension(Flavour, model => { const parent = model.doc.getParent(model.id); if (parent?.flavour === 'affine:surface') { @@ -24,9 +29,13 @@ 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(); 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 9ea10e6304c8e..11c095be1ab59 100644 --- a/blocksuite/affine/block-note/src/commands/block-type.ts +++ b/blocksuite/affine/block-note/src/commands/block-type.ts @@ -113,6 +113,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..e2008400900e4 --- /dev/null +++ b/blocksuite/affine/block-note/src/configs/toolbar.ts @@ -0,0 +1,339 @@ +import { + convertToDatabase, + DATABASE_CONVERT_WHITE_LIST, +} from '@blocksuite/affine-block-database'; +import { + convertSelectedBlocksToLinkedDoc, + getTitleFromSelectedModels, + notifyDocCreated, + promptDocTitle, +} from '@blocksuite/affine-block-embed'; +import { + isFormatSupported, + textConversionConfigs, + textFormatConfigs, +} from '@blocksuite/affine-components/rich-text'; +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 { CommandKeyToData } 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'; + +// 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.getTextSelection(), chain.getBlockSelections()]) + .getSelectedBlocks({ 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 + .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 }) => { + return { + id, + icon, + 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.getTextSelection().formatText(payload), + chain.getBlockSelections().formatBlock(payload), + chain.formatNative(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: CommandKeyToData<'selectedBlocks'>, 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 + .getTextSelection() + .getSelectedBlocks({ + types: ['text'], + }) + .inline(middleware(1)) + .run(); + + if (ok) return true; + + [ok] = chain + .tryAll(chain => [chain.getBlockSelections(), chain.getImageSelections()]) + .getSelectedBlocks({ + types: ['block', 'image'], + }) + .inline(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 + .getSelectedModels({ + types: ['block', 'text'], + mode: 'flat', + }) + .run(); + return ok && Boolean(selectedModels?.length); + }, + run({ chain, store, selection, std }) { + const [ok, { draftedModels, selectedModels }] = chain + .getSelectedModels({ + types: ['block', 'text'], + mode: 'flat', + }) + .draftSelectedModels() + .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 d429d2358a1db..60d98637eebe7 100644 --- a/blocksuite/affine/block-note/src/note-spec.ts +++ b/blocksuite/affine/block-note/src/note-spec.ts @@ -1,4 +1,6 @@ +import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services'; import { + BlockFlavourIdentifier, BlockViewExtension, CommandExtension, FlavourExtension, @@ -11,6 +13,7 @@ import { EdgelessNoteBlockAdapterExtensions, } from './adapters/index.js'; import { commands } from './commands/index.js'; +import { builtinToolbarConfig } from './configs/toolbar.js'; import { NoteBlockService } from './note-service.js'; export const NoteBlockSpec: ExtensionType[] = [ @@ -19,6 +22,10 @@ export const NoteBlockSpec: ExtensionType[] = [ CommandExtension(commands), BlockViewExtension('affine:note', literal`affine-note`), DocNoteBlockAdapterExtensions, + ToolbarModuleExtension({ + id: BlockFlavourIdentifier('affine:note'), + config: builtinToolbarConfig, + }), ].flat(); export const EdgelessNoteBlockSpec: ExtensionType[] = [ @@ -27,4 +34,8 @@ export const EdgelessNoteBlockSpec: ExtensionType[] = [ CommandExtension(commands), BlockViewExtension('affine:note', literal`affine-edgeless-note`), EdgelessNoteBlockAdapterExtensions, + ToolbarModuleExtension({ + id: BlockFlavourIdentifier('affine:note'), + config: builtinToolbarConfig, + }), ].flat(); 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/shared/src/services/index.ts b/blocksuite/affine/shared/src/services/index.ts index 6cf3f6630f7da..d3ac53db19c9f 100644 --- a/blocksuite/affine/shared/src/services/index.ts +++ b/blocksuite/affine/shared/src/services/index.ts @@ -17,3 +17,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..23ef02857d726 --- /dev/null +++ b/blocksuite/affine/shared/src/services/toolbar-service/context.ts @@ -0,0 +1,79 @@ +import { BlockSelection, type BlockStdScope } from '@blocksuite/block-std'; +import type { + Block, + PropsGetter, + SchemaToModel, + SelectionConstructor, +} from '@blocksuite/store'; + +import { DocModeProvider } from '../doc-mode-service'; + +abstract class ToolbarContextBase { + constructor(readonly std: BlockStdScope) {} + + 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 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'; + } + + getFirstBlockBy(type: T): Block | null { + const selection = this.selection.find(type ?? BlockSelection); + return (selection && this.store.getBlock(selection.blockId)) ?? null; + } + + getFirstBlockModelBy< + T extends SelectionConstructor, + S extends { + model: { + props: PropsGetter; + flavour: string; + }; + }, + >(type: T, schema: S) { + const block = this.getFirstBlockBy(type); + return block?.model.flavour === schema.model.flavour + ? (block.model as SchemaToModel) + : null; + } +} + +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..e51bf22bdb54a --- /dev/null +++ b/blocksuite/affine/widget-toolbar/src/renderer.ts @@ -0,0 +1,190 @@ +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; + + console.log(flavour); + + // 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, ['id', 'score'], ['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..5d8d0b862656c 100644 --- a/blocksuite/affine/widget-toolbar/src/toolbar.ts +++ b/blocksuite/affine/widget-toolbar/src/toolbar.ts @@ -1,5 +1,350 @@ -import { WidgetComponent } from '@blocksuite/block-std'; +import { DatabaseSelection } from '@blocksuite/affine-block-database'; +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, e.g. drag handle, drag resources from outside + Dragging = 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); + + // 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.Dragging, 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 + .getBlockSelections() + .getSelectedBlocks() + .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.Dragging, 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.Dragging, true); + const dragEnd = () => this.toggleWith(Flag.Dragging, 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.Dragging, value)) { + console.log('dragging'); + 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..6691d1a407d17 --- /dev/null +++ b/blocksuite/affine/widget-toolbar/src/utils.ts @@ -0,0 +1,112 @@ +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, + 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, + }), + 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[1]); + 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/root-block/edgeless/edgeless-root-spec.ts b/blocksuite/blocks/src/root-block/edgeless/edgeless-root-spec.ts index 461a54eaf0a89..984fa03618fd0 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_FRAME_TITLE_WIDGET } from '@blocksuite/affine-widget-frame-title'; @@ -102,6 +103,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 c7ae87d84ed08..95f6039b73afe 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'; @@ -83,6 +84,7 @@ export const PageRootBlockSpec: ExtensionType[] = [ DNDAPIExtension, RootBlockAdapterExtensions, FileDropExtension, + ToolbarRegistryExtension, ].flat(); export const PreviewPageRootBlockSpec: ExtensionType[] = [ 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/spec-patchers.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx index 76a86d48cb28b..5c1a0d400a078 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx @@ -460,34 +460,28 @@ export function patchQuickSearchService(framework: FrameworkProvider) { (item.name === 'Linked Doc' || item.name === 'Link') ) { item.action = async ({ rootComponent }) => { - // @ts-expect-error fixme const { success, insertedLinkType } = - // @ts-expect-error fixme rootComponent.std.command.exec('insertLinkByQuickSearch'); if (!success) return; insertedLinkType - ?.then( - (type: { - flavour?: 'affine:embed-linked-doc' | 'affine:bookmark'; - }) => { - const flavour = type?.flavour; - if (!flavour) return; - - if (flavour === 'affine:bookmark') { - track.doc.editor.slashMenu.bookmark(); - return; - } - - if (flavour === 'affine:embed-linked-doc') { - track.doc.editor.slashMenu.linkDoc({ - control: 'linkDoc', - }); - return; - } + ?.then(type => { + const flavour = type?.flavour; + if (!flavour) return; + + if (flavour === 'affine:bookmark') { + track.doc.editor.slashMenu.bookmark(); + return; + } + + if (flavour === 'affine:embed-linked-doc') { + track.doc.editor.slashMenu.linkDoc({ + control: 'linkDoc', + }); + return; } - ) + }) .catch(console.error); }; } 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 3aca5023d15b1..0f93a34a57e74 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -347,9 +347,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 761a05442bc3f..95e57fb228729 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3555,12 +3555,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:*" @@ -3839,13 +3841,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 @@ -14579,6 +14595,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" @@ -15230,6 +15253,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" @@ -15266,6 +15298,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" @@ -26022,6 +26081,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" @@ -26092,6 +26158,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" @@ -26113,6 +26193,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"