mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-03 02:20:19 +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,11 @@
|
||||
import { AFFINE_TOOLBAR_WIDGET, AffineToolbarWidget } from './toolbar';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(AFFINE_TOOLBAR_WIDGET, AffineToolbarWidget);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_TOOLBAR_WIDGET]: AffineToolbarWidget;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { WidgetViewExtension } from '@blocksuite/block-std';
|
||||
import { literal, unsafeStatic } from 'lit/static-html.js';
|
||||
|
||||
import { AFFINE_TOOLBAR_WIDGET } from './toolbar';
|
||||
|
||||
export * from './toolbar';
|
||||
|
||||
export const toolbarWidget = WidgetViewExtension(
|
||||
'affine:page',
|
||||
AFFINE_TOOLBAR_WIDGET,
|
||||
literal`${unsafeStatic(AFFINE_TOOLBAR_WIDGET)}`
|
||||
);
|
||||
@@ -0,0 +1,438 @@
|
||||
import { DatabaseSelection } from '@blocksuite/affine-block-database';
|
||||
import { TableSelection } from '@blocksuite/affine-block-table';
|
||||
import { EditorToolbar } from '@blocksuite/affine-components/toolbar';
|
||||
import {
|
||||
CodeBlockModel,
|
||||
ImageBlockModel,
|
||||
ListBlockModel,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
getBlockSelectionsCommand,
|
||||
getSelectedBlocksCommand,
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import {
|
||||
ToolbarContext,
|
||||
ToolbarFlag as Flag,
|
||||
ToolbarRegistryIdentifier,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BlockSelection,
|
||||
SurfaceSelection,
|
||||
TextSelection,
|
||||
WidgetComponent,
|
||||
} from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
import { Bound, getCommonBound } from '@blocksuite/global/gfx';
|
||||
import { nextTick } from '@blocksuite/global/utils';
|
||||
import type { Placement, ReferenceElement } from '@floating-ui/dom';
|
||||
import { batch, effect, signal } from '@preact/signals-core';
|
||||
import { css } from 'lit';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
|
||||
import { autoUpdatePosition, renderToolbar } from './utils';
|
||||
|
||||
export const AFFINE_TOOLBAR_WIDGET = 'affine-toolbar-widget';
|
||||
|
||||
export class AffineToolbarWidget extends WidgetComponent {
|
||||
static override styles = css`
|
||||
editor-toolbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
display: none;
|
||||
width: max-content;
|
||||
backface-visibility: hidden;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
|
||||
will-change: opacity, transform;
|
||||
transition-property: opacity, overlay, display;
|
||||
transition-duration: 120ms;
|
||||
transition-timing-function: ease-out;
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
editor-toolbar[data-open] {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transition-timing-function: ease-in;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
range$ = signal<Range | null>(null);
|
||||
|
||||
flavour$ = signal('affine:note');
|
||||
|
||||
toolbar = new EditorToolbar();
|
||||
|
||||
get toolbarRegistry() {
|
||||
return this.std.get(ToolbarRegistryIdentifier);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
const {
|
||||
flavour$,
|
||||
range$,
|
||||
disposables,
|
||||
toolbar,
|
||||
toolbarRegistry,
|
||||
host,
|
||||
std,
|
||||
} = this;
|
||||
const { flags, message$ } = toolbarRegistry;
|
||||
const context = new ToolbarContext(std);
|
||||
|
||||
// TODO(@fundon): fix toolbar position shaking when the wheel scrolls
|
||||
// document.body.append(toolbar);
|
||||
this.shadowRoot!.append(toolbar);
|
||||
|
||||
// Formatting
|
||||
// Selects text in note.
|
||||
disposables.add(
|
||||
std.selection.find$(TextSelection).subscribe(result => {
|
||||
const activated =
|
||||
context.activated &&
|
||||
Boolean(
|
||||
result &&
|
||||
!result.isCollapsed() &&
|
||||
result.from.length + (result.to?.length ?? 0)
|
||||
);
|
||||
|
||||
batch(() => {
|
||||
flags.toggle(Flag.Text, activated);
|
||||
|
||||
if (!activated) return;
|
||||
|
||||
const range = std.range.value ?? null;
|
||||
range$.value = activated ? range : null;
|
||||
|
||||
flags.refresh(Flag.Text);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Formatting
|
||||
// Selects `native` text in database's cell or in table.
|
||||
disposables.addFromEvent(document, 'selectionchange', () => {
|
||||
const range = std.range.value ?? null;
|
||||
let activated = context.activated && Boolean(range && !range.collapsed);
|
||||
|
||||
if (activated) {
|
||||
const result = std.selection.find(DatabaseSelection);
|
||||
const viewSelection = result?.viewSelection;
|
||||
|
||||
activated = Boolean(
|
||||
viewSelection &&
|
||||
((viewSelection.selectionType === 'area' &&
|
||||
viewSelection.isEditing) ||
|
||||
(viewSelection.selectionType === 'cell' &&
|
||||
viewSelection.isEditing))
|
||||
);
|
||||
|
||||
if (!activated) {
|
||||
const result = std.selection.find(TableSelection);
|
||||
const viewSelection = result?.data;
|
||||
activated = Boolean(viewSelection && viewSelection.type === 'area');
|
||||
}
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
flags.toggle(Flag.Native, activated);
|
||||
|
||||
if (!activated) return;
|
||||
|
||||
range$.value = activated ? range : null;
|
||||
flavour$.value = 'affine:note';
|
||||
|
||||
flags.refresh(Flag.Native);
|
||||
});
|
||||
});
|
||||
|
||||
// Selects blocks in note.
|
||||
disposables.add(
|
||||
std.selection.filter$(BlockSelection).subscribe(result => {
|
||||
const count = result.length;
|
||||
let flavour = 'affine:note';
|
||||
let activated = context.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 = matchModels(block.model, [
|
||||
ParagraphBlockModel,
|
||||
ListBlockModel,
|
||||
CodeBlockModel,
|
||||
ImageBlockModel,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
flavour$.value = flavour;
|
||||
|
||||
flags.toggle(Flag.Block, activated);
|
||||
|
||||
if (!activated) return;
|
||||
|
||||
flags.refresh(Flag.Block);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Selects elements in edgeless.
|
||||
// Triggered only when not in editing state.
|
||||
disposables.add(
|
||||
std.selection.filter$(SurfaceSelection).subscribe(result => {
|
||||
const activated =
|
||||
context.activated &&
|
||||
Boolean(result.length) &&
|
||||
!result.some(e => e.editing);
|
||||
flags.toggle(Flag.Surface, activated);
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
std.selection.slots.changed.on(selections => {
|
||||
if (!context.activated) return;
|
||||
|
||||
const value = flags.value$.peek();
|
||||
if (flags.contains(Flag.Hovering | Flag.Hiding, value)) return;
|
||||
if (!flags.check(Flag.Text, value)) return;
|
||||
|
||||
const hasTextSelection =
|
||||
selections.filter(s => s.is(TextSelection)).length > 0;
|
||||
if (!hasTextSelection) return;
|
||||
|
||||
const range = std.range.value ?? null;
|
||||
range$.value = range && !range.collapsed ? range : null;
|
||||
|
||||
// TODO(@fundon): maybe here can be further optimized
|
||||
// 1. Prevents flickering effects.
|
||||
// 2. We cannot use `host.getUpdateComplete()` here
|
||||
// because it would cause excessive DOM queries, leading to UI jamming.
|
||||
nextTick()
|
||||
.then(() => flags.refresh(Flag.Text))
|
||||
.catch(console.error);
|
||||
})
|
||||
);
|
||||
|
||||
// TODO(@fundon): improve these cases
|
||||
// When switch the view mode, wait until the view is created
|
||||
// `card view` or `embed view`
|
||||
disposables.add(
|
||||
std.view.viewUpdated
|
||||
.filter(view => view.type === 'block')
|
||||
.on(record => {
|
||||
if (
|
||||
flags.isBlock() &&
|
||||
std.selection
|
||||
.filter$(BlockSelection)
|
||||
.peek()
|
||||
.find(s => s.blockId === record.id)
|
||||
) {
|
||||
if (record.method === 'add') {
|
||||
flags.refresh(Flag.Block);
|
||||
}
|
||||
return;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Handles `drag and drop`
|
||||
const dragStart = () => flags.toggle(Flag.Hiding, true);
|
||||
const dragEnd = () => flags.toggle(Flag.Hiding, false);
|
||||
const eventOptions = { passive: false };
|
||||
this.handleEvent('dragStart', dragStart);
|
||||
this.handleEvent('dragEnd', dragEnd);
|
||||
this.handleEvent('nativeDrop', dragEnd);
|
||||
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
|
||||
);
|
||||
|
||||
// Handles hover elements
|
||||
disposables.add(
|
||||
toolbarRegistry.message$.subscribe(data => {
|
||||
if (
|
||||
!context.activated ||
|
||||
flags.contains(Flag.Text | Flag.Native | Flag.Block)
|
||||
) {
|
||||
flags.toggle(Flag.Hovering, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const activated = !!data;
|
||||
|
||||
batch(() => {
|
||||
flags.toggle(Flag.Hovering, activated);
|
||||
|
||||
if (!activated) return;
|
||||
|
||||
const { flavour, setFloating } = data;
|
||||
|
||||
setFloating(toolbar);
|
||||
|
||||
flavour$.value = flavour;
|
||||
|
||||
flags.refresh(Flag.Hovering);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// Should update position of notes' toolbar in edgeless
|
||||
disposables.add(
|
||||
this.std.get(GfxControllerIdentifier).viewport.viewportUpdated.on(() => {
|
||||
if (!context.activated) return;
|
||||
|
||||
if (flags.value === Flag.None || flags.check(Flag.Hiding)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.isText()) {
|
||||
flags.refresh(Flag.Text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.isNative()) {
|
||||
flags.refresh(Flag.Native);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.isBlock()) {
|
||||
flags.refresh(Flag.Block);
|
||||
return;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
flags.value$.subscribe(value => {
|
||||
// Hides toolbar
|
||||
if (value === Flag.None || flags.check(Flag.Hiding, value)) {
|
||||
delete toolbar.dataset.open;
|
||||
return;
|
||||
}
|
||||
|
||||
// Shows toolbar
|
||||
// 1. `Flag.Text`: formatting in note
|
||||
// 2. `Flag.Native`: formating in database
|
||||
// 3. `Flag.Block`: blocks in note
|
||||
// 4. `Flag.Hovering`: inline links in note/database
|
||||
if (
|
||||
flags.contains(
|
||||
Flag.Hovering | Flag.Text | Flag.Native | Flag.Block,
|
||||
value
|
||||
)
|
||||
) {
|
||||
renderToolbar(toolbar, context, flavour$.peek());
|
||||
toolbar.dataset.open = 'true';
|
||||
return;
|
||||
}
|
||||
|
||||
// Shows toolbar in edgeles
|
||||
// TODO(@fundon): handles edgeless toolbar
|
||||
})
|
||||
);
|
||||
|
||||
disposables.add(
|
||||
effect(() => {
|
||||
const value = flags.value$.value;
|
||||
const flavour = flavour$.value;
|
||||
if (!context.activated || flags.contains(Flag.Hiding, value)) return;
|
||||
if (
|
||||
!flags.contains(
|
||||
Flag.Hovering | Flag.Text | Flag.Native | Flag.Block,
|
||||
value
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
// TODO(@fundon): improves here
|
||||
const isNote = flavour === 'affine:note';
|
||||
let placement = isNote ? ('top' as Placement) : undefined;
|
||||
let virtualEl: ReferenceElement | null = null;
|
||||
|
||||
if (flags.check(Flag.Hovering, value)) {
|
||||
const message = message$.value;
|
||||
if (!message) return;
|
||||
|
||||
const { element } = message;
|
||||
|
||||
virtualEl = element;
|
||||
placement = 'top';
|
||||
} else if (flags.check(Flag.Block, value)) {
|
||||
const [ok, { selectedBlocks }] = context.chain
|
||||
.pipe(getBlockSelectionsCommand)
|
||||
.pipe(getSelectedBlocksCommand, { types: ['block'] })
|
||||
.run();
|
||||
|
||||
if (!ok || !selectedBlocks?.length) return;
|
||||
|
||||
virtualEl = {
|
||||
getBoundingClientRect: () => {
|
||||
const rects = selectedBlocks.map(e => e.getBoundingClientRect());
|
||||
const bounds = getCommonBound(rects.map(Bound.fromDOMRect));
|
||||
if (!bounds) return rects[0];
|
||||
return new DOMRect(bounds.x, bounds.y, bounds.w, bounds.h);
|
||||
},
|
||||
getClientRects: () =>
|
||||
selectedBlocks.map(e => e.getBoundingClientRect()),
|
||||
};
|
||||
} else {
|
||||
const range = range$.value;
|
||||
if (!range) return;
|
||||
|
||||
virtualEl = {
|
||||
getBoundingClientRect: () => range.getBoundingClientRect(),
|
||||
getClientRects: () =>
|
||||
Array.from(range.getClientRects()).filter(rect =>
|
||||
Math.round(rect.width)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!virtualEl) return;
|
||||
|
||||
return autoUpdatePosition(virtualEl, toolbar, placement);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import {
|
||||
type EditorToolbar,
|
||||
renderToolbarSeparator,
|
||||
} from '@blocksuite/affine-components/toolbar';
|
||||
import {
|
||||
ActionPlacement,
|
||||
type ToolbarAction,
|
||||
type ToolbarActions,
|
||||
type ToolbarContext,
|
||||
type ToolbarModuleConfig,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { BlockSelection } from '@blocksuite/block-std';
|
||||
import { nextTick } from '@blocksuite/global/utils';
|
||||
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
|
||||
import type {
|
||||
AutoUpdateOptions,
|
||||
Placement,
|
||||
ReferenceElement,
|
||||
} from '@floating-ui/dom';
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
inline,
|
||||
offset,
|
||||
shift,
|
||||
} from '@floating-ui/dom';
|
||||
import { html, render, type TemplateResult } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
import groupBy from 'lodash-es/groupBy';
|
||||
import mergeWith from 'lodash-es/mergeWith';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import partition from 'lodash-es/partition';
|
||||
import toPairs from 'lodash-es/toPairs';
|
||||
|
||||
export function autoUpdatePosition(
|
||||
referenceElement: ReferenceElement,
|
||||
toolbar: EditorToolbar,
|
||||
placement: Placement = 'top-start',
|
||||
options: AutoUpdateOptions = { elementResize: false, animationFrame: true }
|
||||
) {
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
const update = async () => {
|
||||
await Promise.race([
|
||||
new Promise(resolve => {
|
||||
const listener = () => resolve(signal.reason);
|
||||
signal.addEventListener('abort', listener, { once: true });
|
||||
|
||||
if (signal.aborted) return;
|
||||
|
||||
signal.removeEventListener('abort', listener);
|
||||
resolve(null);
|
||||
}),
|
||||
toolbar.updateComplete.then(nextTick),
|
||||
]);
|
||||
|
||||
if (signal.aborted) return;
|
||||
|
||||
const { x, y } = await computePosition(referenceElement, toolbar, {
|
||||
placement,
|
||||
middleware: [offset(10), inline(), shift({ padding: 6 }), flip()],
|
||||
});
|
||||
|
||||
toolbar.style.transform = `translate3d(${x}px, ${y}px, 0)`;
|
||||
};
|
||||
|
||||
const cleanup = autoUpdate(
|
||||
referenceElement,
|
||||
toolbar,
|
||||
() => {
|
||||
update().catch(console.error);
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
if (signal.aborted) return;
|
||||
abortController.abort();
|
||||
};
|
||||
}
|
||||
|
||||
function group(actions: ToolbarAction[]) {
|
||||
const grouped = groupBy(actions, a => a.id);
|
||||
|
||||
const paired = toPairs(grouped).map(([_, items]) => {
|
||||
if (items.length === 1) return items[0];
|
||||
const [first, ...others] = items;
|
||||
if (others.length === 1) return merge({ ...first }, others[0]);
|
||||
return others.reduce(merge, { ...first });
|
||||
});
|
||||
|
||||
return paired;
|
||||
}
|
||||
|
||||
export function combine(actions: ToolbarActions, context: ToolbarContext) {
|
||||
const grouped = group(actions);
|
||||
|
||||
const generated = grouped.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 (typeof action.when === 'function') return action.when(context);
|
||||
return action.when ?? true;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
const merge = (a: any, b: any) =>
|
||||
mergeWith(a, b, (obj, src) =>
|
||||
Array.isArray(obj) ? group(obj.concat(src)) : src
|
||||
);
|
||||
|
||||
/**
|
||||
* Renders toolbar
|
||||
*
|
||||
* Merges the following configs:
|
||||
* 1. `affine:note`
|
||||
* 2. `custom:affine:note`
|
||||
* 3. `affine:*`
|
||||
* 4. `custom:affine:*`
|
||||
*/
|
||||
export function renderToolbar(
|
||||
toolbar: EditorToolbar,
|
||||
context: ToolbarContext,
|
||||
flavour: string
|
||||
) {
|
||||
const toolbarRegistry = context.toolbarRegistry;
|
||||
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', 'id', 'score'],
|
||||
['asc', 'asc', 'asc']
|
||||
);
|
||||
|
||||
const [moreActionGroup, primaryActionGroup] = partition(
|
||||
ordered,
|
||||
a => a.placement === ActionPlacement.More
|
||||
);
|
||||
|
||||
if (moreActionGroup.length) {
|
||||
const moreMenuItems = renderActions(
|
||||
moreActionGroup,
|
||||
context,
|
||||
renderMenuActionItem
|
||||
);
|
||||
if (moreMenuItems.length) {
|
||||
// TODO(@fundon): edgeless case needs to be considered
|
||||
const key = `${flavour}:${context.getCurrentModelBy(BlockSelection)?.id}`;
|
||||
|
||||
primaryActionGroup.push({
|
||||
id: 'more',
|
||||
content: html`${keyed(
|
||||
key,
|
||||
html`
|
||||
<editor-menu-button
|
||||
class="more-menu"
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="More" .tooltip="${'More'}">
|
||||
${MoreVerticalIcon()}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${join(moreMenuItems, () =>
|
||||
renderToolbarSeparator('horizontal')
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`
|
||||
)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render(
|
||||
join(renderActions(primaryActionGroup, context), () =>
|
||||
renderToolbarSeparator()
|
||||
),
|
||||
toolbar
|
||||
);
|
||||
}
|
||||
|
||||
function renderActions(
|
||||
actions: ToolbarActions,
|
||||
context: ToolbarContext,
|
||||
render = renderActionItem
|
||||
) {
|
||||
return actions
|
||||
.map(action => {
|
||||
let content: TemplateResult | null = null;
|
||||
if ('content' in action && action.content) {
|
||||
if (typeof action.content === 'function') {
|
||||
content = action.content(context);
|
||||
} else {
|
||||
content = action.content;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
if ('actions' in action && action.actions.length) {
|
||||
const combined = combine(action.actions, context);
|
||||
|
||||
if (!combined.length) return content;
|
||||
|
||||
const ordered = orderBy(combined, ['score', 'id'], ['asc', 'asc']);
|
||||
|
||||
return repeat(
|
||||
ordered,
|
||||
b => b.id,
|
||||
b => render(b, context)
|
||||
);
|
||||
}
|
||||
|
||||
if ('run' in action && action.run) {
|
||||
return render(action, context);
|
||||
}
|
||||
|
||||
return content;
|
||||
})
|
||||
.filter(action => action !== null);
|
||||
}
|
||||
|
||||
// TODO(@fundon): supports templates
|
||||
function renderActionItem(action: ToolbarAction, context: ToolbarContext) {
|
||||
const ids = action.id.split('.');
|
||||
const id = ids[ids.length - 1];
|
||||
return html`
|
||||
<editor-icon-button
|
||||
data-testid=${ifDefined(id)}
|
||||
aria-label=${ifDefined(action.label ?? action.tooltip ?? id)}
|
||||
?active=${typeof action.active === 'function'
|
||||
? action.active(context)
|
||||
: action.active}
|
||||
.tooltip=${action.tooltip}
|
||||
@click=${() => action.run?.(context)}
|
||||
>
|
||||
${action.icon}
|
||||
${action.label ? html`<span class="label">${action.label}</span>` : null}
|
||||
</editor-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMenuActionItem(action: ToolbarAction, context: ToolbarContext) {
|
||||
const ids = action.id.split('.');
|
||||
const id = ids[ids.length - 1];
|
||||
return html`
|
||||
<editor-menu-action
|
||||
data-testid=${ifDefined(id)}
|
||||
aria-label=${ifDefined(action.label ?? action.tooltip ?? id)}
|
||||
class="${ifDefined(
|
||||
action.variant === 'destructive' ? 'delete' : undefined
|
||||
)}"
|
||||
?active=${typeof action.active === 'function'
|
||||
? action.active(context)
|
||||
: action.active}
|
||||
.tooltip=${ifDefined(action.tooltip)}
|
||||
@click=${() => action.run?.(context)}
|
||||
>
|
||||
${action.icon}
|
||||
${action.label ? html`<span class="label">${action.label}</span>` : null}
|
||||
</editor-menu-action>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user