mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 05:25:53 +08:00
feat(editor): add toolbar registry extension (#9572)
### What's Changed! #### Added Manage various types of toolbars uniformly in one place. * `affine-toolbar-widget` * `ToolbarRegistryExtension` The toolbar currently supports and handles several scenarios: 1. Select blocks: `BlockSelection` 2. Select text: `TextSelection` or `NativeSelection` 3. Hover a link: `affine-link` and `affine-reference` #### Removed Remove redundant toolbar implementations. * `attachment` toolbar * `bookmark` toolbar * `embed` toolbar * `formatting` toolbar * `affine-link` toolbar * `affine-reference` toolbar ### How to migrate? Here is an example that can help us migrate some unrefactored toolbars: Check out the more detailed types of [`ToolbarModuleConfig`](https://github.com/toeverything/AFFiNE/blob/c178debf2d49c40b753e1bcaa6f07270bdde7401/blocksuite/affine/shared/src/services/toolbar-service/config.ts). 1. Add toolbar configuration file to a block type, such as bookmark block: [`config.ts`](https://github.com/toeverything/AFFiNE/blob/c178debf2d49c40b753e1bcaa6f07270bdde7401/blocksuite/affine/block-bookmark/src/configs/toolbar.ts) ```ts export const builtinToolbarConfig = { actions: [ { id: 'a.preview', content(ctx) { const model = ctx.getCurrentModelBy(BlockSelection, BookmarkBlockModel); if (!model) return null; const { url } = model; return html`<affine-link-preview .url=${url}></affine-link-preview>`; }, }, { id: 'b.conversions', actions: [ { id: 'inline', label: 'Inline view', run(ctx) { }, }, { id: 'card', label: 'Card view', disabled: true, }, { id: 'embed', label: 'Embed view', disabled(ctx) { }, run(ctx) { }, }, ], content(ctx) { }, } satisfies ToolbarActionGroup<ToolbarAction>, { id: 'c.style', actions: [ { id: 'horizontal', label: 'Large horizontal style', }, { id: 'list', label: 'Small horizontal style', }, ], content(ctx) { }, } satisfies ToolbarActionGroup<ToolbarAction>, { id: 'd.caption', tooltip: 'Caption', icon: CaptionIcon(), run(ctx) { }, }, { placement: ActionPlacement.More, id: 'a.clipboard', actions: [ { id: 'copy', label: 'Copy', icon: CopyIcon(), run(ctx) { }, }, { id: 'duplicate', label: 'Duplicate', icon: DuplicateIcon(), run(ctx) { }, }, ], }, { placement: ActionPlacement.More, id: 'b.refresh', label: 'Reload', icon: ResetIcon(), run(ctx) { }, }, { placement: ActionPlacement.More, id: 'c.delete', label: 'Delete', icon: DeleteIcon(), variant: 'destructive', run(ctx) { }, }, ], } as const satisfies ToolbarModuleConfig; ``` 2. Add configuration extension to a block spec: [bookmark's spec](https://github.com/toeverything/AFFiNE/blob/c178debf2d49c40b753e1bcaa6f07270bdde7401/blocksuite/affine/block-bookmark/src/bookmark-spec.ts) ```ts const flavour = BookmarkBlockSchema.model.flavour; export const BookmarkBlockSpec: ExtensionType[] = [ ..., ToolbarModuleExtension({ id: BlockFlavourIdentifier(flavour), config: builtinToolbarConfig, }), ].flat(); ``` 3. If the bock type already has a toolbar configuration built in, we can customize it in the following ways: Check out the [editor's config](https://github.com/toeverything/AFFiNE/blob/c178debf2d49c40b753e1bcaa6f07270bdde7401/packages/frontend/core/src/blocksuite/extensions/editor-config/index.ts#L51C4-L54C8) file. ```ts // Defines a toolbar configuration for the bookmark block type const customBookmarkToolbarConfig = { actions: [ ... ] } as const satisfies ToolbarModuleConfig; // Adds it into the editor's config ToolbarModuleExtension({ id: BlockFlavourIdentifier('custom:affine:bookmark'), config: customBookmarkToolbarConfig, }), ``` 4. If we want to extend the global: ```ts // Defines a toolbar configuration const customWildcardToolbarConfig = { actions: [ ... ] } as const satisfies ToolbarModuleConfig; // Adds it into the editor's config ToolbarModuleExtension({ id: BlockFlavourIdentifier('custom:affine:*'), config: customWildcardToolbarConfig, }), ``` Currently, only most toolbars in page mode have been refactored. Next is edgeless mode.
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
import {
|
||||
convertToDatabase,
|
||||
DATABASE_CONVERT_WHITE_LIST,
|
||||
} from '@blocksuite/affine-block-database';
|
||||
import {
|
||||
convertSelectedBlocksToLinkedDoc,
|
||||
getTitleFromSelectedModels,
|
||||
notifyDocCreated,
|
||||
promptDocTitle,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import {
|
||||
deleteTextCommand,
|
||||
formatBlockCommand,
|
||||
formatNativeCommand,
|
||||
formatTextCommand,
|
||||
isFormatSupported,
|
||||
textConversionConfigs,
|
||||
textFormatConfigs,
|
||||
} from '@blocksuite/affine-components/rich-text';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import {
|
||||
copySelectedModelsCommand,
|
||||
deleteSelectedModelsCommand,
|
||||
draftSelectedModelsCommand,
|
||||
duplicateSelectedModelsCommand,
|
||||
getBlockSelectionsCommand,
|
||||
getImageSelectionsCommand,
|
||||
getSelectedBlocksCommand,
|
||||
getSelectedModelsCommand,
|
||||
getTextSelectionCommand,
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import type {
|
||||
ToolbarAction,
|
||||
ToolbarActionGenerator,
|
||||
ToolbarActionGroup,
|
||||
ToolbarModuleConfig,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { ActionPlacement } from '@blocksuite/affine-shared/services';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { type BlockComponent, BlockSelection } from '@blocksuite/block-std';
|
||||
import { tableViewMeta } from '@blocksuite/data-view/view-presets';
|
||||
import {
|
||||
ArrowDownSmallIcon,
|
||||
CopyIcon,
|
||||
DatabaseTableViewIcon,
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
LinkedPageIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { toDraftModel } from '@blocksuite/store';
|
||||
import { html } from 'lit';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import { updateBlockType } from '../commands';
|
||||
|
||||
const conversionsActionGroup = {
|
||||
id: 'a.conversions',
|
||||
when: ({ chain }) => isFormatSupported(chain).run()[0],
|
||||
generate({ chain }) {
|
||||
const [ok, { selectedModels = [] }] = chain
|
||||
.tryAll(chain => [
|
||||
chain.pipe(getTextSelectionCommand),
|
||||
chain.pipe(getBlockSelectionsCommand),
|
||||
])
|
||||
.pipe(getSelectedModelsCommand, { types: ['text', 'block'] })
|
||||
.run();
|
||||
|
||||
// only support model with text
|
||||
// TODO(@fundon): displays only in a single paragraph, `length === 1`.
|
||||
const allowed = ok && selectedModels.filter(model => model.text).length > 0;
|
||||
if (!allowed) return null;
|
||||
|
||||
const model = selectedModels[0];
|
||||
const conversion =
|
||||
textConversionConfigs.find(
|
||||
({ flavour, type }) =>
|
||||
flavour === model.flavour &&
|
||||
(type ? 'type' in model && type === model.type : true)
|
||||
) ?? textConversionConfigs[0];
|
||||
const update = (flavour: string, type?: string) => {
|
||||
chain
|
||||
.pipe(updateBlockType, {
|
||||
flavour,
|
||||
...(type && { props: { type } }),
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
return {
|
||||
content: html`
|
||||
<editor-menu-button
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Conversions"
|
||||
.tooltip="${'Turn Into'}"
|
||||
>
|
||||
${conversion.icon} ${ArrowDownSmallIcon()}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${repeat(
|
||||
textConversionConfigs.filter(c => c.flavour !== 'affine:divider'),
|
||||
item => item.name,
|
||||
({ flavour, type, name, icon }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${name}
|
||||
?data-selected=${conversion.name === name}
|
||||
@click=${() => update(flavour, type)}
|
||||
>
|
||||
${icon}<span class="label">${name}</span>
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
};
|
||||
},
|
||||
} as const satisfies ToolbarActionGenerator;
|
||||
|
||||
const inlineTextActionGroup = {
|
||||
id: 'b.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 highlightActionGroup = {
|
||||
id: 'c.highlight',
|
||||
when: ({ chain }) => isFormatSupported(chain).run()[0],
|
||||
content({ 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();
|
||||
};
|
||||
return html`
|
||||
<affine-highlight-dropdown-menu
|
||||
.updateHighlight=${updateHighlight}
|
||||
></affine-highlight-dropdown-menu>
|
||||
`;
|
||||
},
|
||||
} as const satisfies ToolbarAction;
|
||||
|
||||
export const turnIntoDatabase = {
|
||||
id: 'd.convert-to-database',
|
||||
tooltip: 'Create Table',
|
||||
icon: DatabaseTableViewIcon(),
|
||||
when({ chain }) {
|
||||
const middleware = (count = 0) => {
|
||||
return (ctx: { selectedBlocks: BlockComponent[] }, next: () => void) => {
|
||||
const { selectedBlocks } = ctx;
|
||||
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: 'e.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, track }) {
|
||||
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.map(toDraftModel)
|
||||
);
|
||||
promptDocTitle(std, autofill)
|
||||
.then(async title => {
|
||||
if (title === null) return;
|
||||
await convertSelectedBlocksToLinkedDoc(
|
||||
std,
|
||||
store,
|
||||
draftedModels,
|
||||
title
|
||||
);
|
||||
notifyDocCreated(std, store);
|
||||
|
||||
track('DocCreated', {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
control: 'create linked doc',
|
||||
type: 'embed-linked-doc',
|
||||
});
|
||||
|
||||
track('LinkedDocCreated', {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
control: 'create linked doc',
|
||||
type: 'embed-linked-doc',
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
} as const satisfies ToolbarAction;
|
||||
|
||||
export const builtinToolbarConfig = {
|
||||
actions: [
|
||||
conversionsActionGroup,
|
||||
inlineTextActionGroup,
|
||||
highlightActionGroup,
|
||||
turnIntoDatabase,
|
||||
turnIntoLinkedDoc,
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'a.clipboard',
|
||||
actions: [
|
||||
{
|
||||
id: 'copy',
|
||||
label: 'Copy',
|
||||
icon: CopyIcon(),
|
||||
run({ chain, host }) {
|
||||
const [ok] = chain
|
||||
.pipe(getSelectedModelsCommand)
|
||||
.pipe(draftSelectedModelsCommand)
|
||||
.pipe(copySelectedModelsCommand)
|
||||
.run();
|
||||
|
||||
if (!ok) return;
|
||||
|
||||
toast(host, 'Copied to clipboard');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
icon: DuplicateIcon(),
|
||||
run({ chain, store, selection }) {
|
||||
store.captureSync();
|
||||
|
||||
const [ok, { selectedBlocks = [] }] = chain
|
||||
.pipe(getTextSelectionCommand)
|
||||
.pipe(getSelectedBlocksCommand, {
|
||||
types: ['text'],
|
||||
mode: 'highest',
|
||||
})
|
||||
.run();
|
||||
|
||||
// If text selection exists, convert to block selection
|
||||
if (ok && selectedBlocks.length) {
|
||||
selection.setGroup(
|
||||
'note',
|
||||
selectedBlocks.map(block =>
|
||||
selection.create(BlockSelection, {
|
||||
blockId: block.model.id,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
chain
|
||||
.pipe(getSelectedModelsCommand, {
|
||||
types: ['block', 'image'],
|
||||
mode: 'highest',
|
||||
})
|
||||
.pipe(draftSelectedModelsCommand)
|
||||
.pipe(duplicateSelectedModelsCommand)
|
||||
.run();
|
||||
},
|
||||
},
|
||||
],
|
||||
when(ctx) {
|
||||
return !ctx.flags.isNative();
|
||||
},
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'c.delete',
|
||||
actions: [
|
||||
{
|
||||
id: 'delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon(),
|
||||
variant: 'destructive',
|
||||
run({ chain }) {
|
||||
// removes text
|
||||
const [ok] = chain
|
||||
.pipe(getTextSelectionCommand)
|
||||
.pipe(deleteTextCommand)
|
||||
.run();
|
||||
|
||||
if (ok) return;
|
||||
|
||||
// removes blocks
|
||||
chain
|
||||
.tryAll(chain => [
|
||||
chain.pipe(getBlockSelectionsCommand),
|
||||
chain.pipe(getImageSelectionsCommand),
|
||||
])
|
||||
.pipe(getSelectedModelsCommand)
|
||||
.pipe(deleteSelectedModelsCommand)
|
||||
.run();
|
||||
},
|
||||
},
|
||||
],
|
||||
when(ctx) {
|
||||
return !ctx.flags.isNative();
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
@@ -1,4 +1,10 @@
|
||||
import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std';
|
||||
import { NoteBlockSchema } 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';
|
||||
|
||||
@@ -6,18 +12,29 @@ import {
|
||||
DocNoteBlockAdapterExtensions,
|
||||
EdgelessNoteBlockAdapterExtensions,
|
||||
} from './adapters/index.js';
|
||||
import { builtinToolbarConfig } from './configs/toolbar.js';
|
||||
import { NoteBlockService } from './note-service.js';
|
||||
|
||||
const flavour = NoteBlockSchema.model.flavour;
|
||||
|
||||
export const NoteBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension('affine:note'),
|
||||
FlavourExtension(flavour),
|
||||
NoteBlockService,
|
||||
BlockViewExtension('affine:note', literal`affine-note`),
|
||||
BlockViewExtension(flavour, literal`affine-note`),
|
||||
DocNoteBlockAdapterExtensions,
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier(flavour),
|
||||
config: builtinToolbarConfig,
|
||||
}),
|
||||
].flat();
|
||||
|
||||
export const EdgelessNoteBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension('affine:note'),
|
||||
FlavourExtension(flavour),
|
||||
NoteBlockService,
|
||||
BlockViewExtension('affine:note', literal`affine-edgeless-note`),
|
||||
BlockViewExtension(flavour, literal`affine-edgeless-note`),
|
||||
EdgelessNoteBlockAdapterExtensions,
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier(flavour),
|
||||
config: builtinToolbarConfig,
|
||||
}),
|
||||
].flat();
|
||||
|
||||
Reference in New Issue
Block a user