mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00: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`](c178debf2d/blocksuite/affine/shared/src/services/toolbar-service/config.ts). 1. Add toolbar configuration file to a block type, such as bookmark block: [`config.ts`](c178debf2d/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](c178debf2d/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](c178debf2d/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:
@@ -1,44 +1,28 @@
|
||||
import '../../components/ask-ai-button';
|
||||
|
||||
import {
|
||||
type AffineFormatBarWidget,
|
||||
toolbarDefaultConfig,
|
||||
ActionPlacement,
|
||||
type ToolbarModuleConfig,
|
||||
} from '@blocksuite/affine/blocks';
|
||||
import { html, type TemplateResult } from 'lit';
|
||||
import { html } from 'lit';
|
||||
|
||||
import { pageAIGroups } from '../../_common/config';
|
||||
|
||||
export function setupFormatBarAIEntry(formatBar: AffineFormatBarWidget) {
|
||||
toolbarDefaultConfig(formatBar);
|
||||
formatBar.addRawConfigItems(
|
||||
[
|
||||
export function toolbarAIEntryConfig(): ToolbarModuleConfig {
|
||||
return {
|
||||
actions: [
|
||||
{
|
||||
type: 'custom' as const,
|
||||
render(formatBar: AffineFormatBarWidget): TemplateResult | null {
|
||||
const richText = getRichText();
|
||||
if (richText?.dataset.disableAskAi !== undefined) return null;
|
||||
return html`
|
||||
<ask-ai-toolbar-button
|
||||
.host=${formatBar.host}
|
||||
.actionGroups=${pageAIGroups}
|
||||
></ask-ai-toolbar-button>
|
||||
`;
|
||||
},
|
||||
placement: ActionPlacement.Start,
|
||||
id: 'A.ai',
|
||||
score: -1,
|
||||
when: ({ flags }) => !flags.isNative(),
|
||||
content: ({ host }) => html`
|
||||
<ask-ai-toolbar-button
|
||||
.host=${host}
|
||||
.actionGroups=${pageAIGroups}
|
||||
></ask-ai-toolbar-button>
|
||||
`,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
],
|
||||
0
|
||||
);
|
||||
};
|
||||
}
|
||||
const getRichText = () => {
|
||||
const selection = getSelection();
|
||||
if (!selection) return null;
|
||||
if (selection.rangeCount === 0) return null;
|
||||
const range = selection.getRangeAt(0);
|
||||
const commonAncestorContainer =
|
||||
range.commonAncestorContainer instanceof Element
|
||||
? range.commonAncestorContainer
|
||||
: range.commonAncestorContainer.parentElement;
|
||||
if (!commonAncestorContainer) return null;
|
||||
return commonAncestorContainer.closest('rich-text');
|
||||
};
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { LifeCycleWatcher } from '@blocksuite/affine/block-std';
|
||||
import {
|
||||
AffineFormatBarWidget,
|
||||
BlockFlavourIdentifier,
|
||||
LifeCycleWatcher,
|
||||
} from '@blocksuite/affine/block-std';
|
||||
import {
|
||||
AffineSlashMenuWidget,
|
||||
EdgelessElementToolbarWidget,
|
||||
EdgelessRootBlockSpec,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine/blocks';
|
||||
import type { ExtensionType } from '@blocksuite/affine/store';
|
||||
import type { FrameworkProvider } from '@toeverything/infra';
|
||||
|
||||
import { buildAIPanelConfig } from '../ai-panel';
|
||||
import { toolbarAIEntryConfig } from '../entries';
|
||||
import {
|
||||
setupEdgelessCopilot,
|
||||
setupEdgelessElementToolbarAIEntry,
|
||||
} from '../entries/edgeless/index';
|
||||
import { setupFormatBarAIEntry } from '../entries/format-bar/setup-format-bar';
|
||||
import { setupSlashMenuAIEntry } from '../entries/slash-menu/setup-slash-menu';
|
||||
import { setupSpaceAIEntry } from '../entries/space/setup-space';
|
||||
import { CopilotTool } from '../tool/copilot-tool';
|
||||
@@ -35,6 +38,10 @@ export function createAIEdgelessRootBlockSpec(
|
||||
aiPanelWidget,
|
||||
edgelessCopilotWidget,
|
||||
getAIEdgelessRootWatcher(framework),
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('custom:affine:note'),
|
||||
config: toolbarAIEntryConfig(),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -64,10 +71,6 @@ function getAIEdgelessRootWatcher(framework: FrameworkProvider) {
|
||||
setupEdgelessElementToolbarAIEntry(component);
|
||||
}
|
||||
|
||||
if (component instanceof AffineFormatBarWidget) {
|
||||
setupFormatBarAIEntry(component);
|
||||
}
|
||||
|
||||
if (component instanceof AffineSlashMenuWidget) {
|
||||
setupSlashMenuAIEntry(component);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { LifeCycleWatcher } from '@blocksuite/affine/block-std';
|
||||
import {
|
||||
AffineFormatBarWidget,
|
||||
BlockFlavourIdentifier,
|
||||
LifeCycleWatcher,
|
||||
} from '@blocksuite/affine/block-std';
|
||||
import {
|
||||
AffineSlashMenuWidget,
|
||||
PageRootBlockSpec,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine/blocks';
|
||||
import type { ExtensionType } from '@blocksuite/affine/store';
|
||||
import type { FrameworkProvider } from '@toeverything/infra';
|
||||
|
||||
import { buildAIPanelConfig } from '../ai-panel';
|
||||
import { setupFormatBarAIEntry } from '../entries/format-bar/setup-format-bar';
|
||||
import { toolbarAIEntryConfig } from '../entries';
|
||||
import { setupSlashMenuAIEntry } from '../entries/slash-menu/setup-slash-menu';
|
||||
import { setupSpaceAIEntry } from '../entries/space/setup-space';
|
||||
import {
|
||||
@@ -34,10 +37,6 @@ function getAIPageRootWatcher(framework: FrameworkProvider) {
|
||||
setupSpaceAIEntry(component);
|
||||
}
|
||||
|
||||
if (component instanceof AffineFormatBarWidget) {
|
||||
setupFormatBarAIEntry(component);
|
||||
}
|
||||
|
||||
if (component instanceof AffineSlashMenuWidget) {
|
||||
setupSlashMenuAIEntry(component);
|
||||
}
|
||||
@@ -50,5 +49,13 @@ function getAIPageRootWatcher(framework: FrameworkProvider) {
|
||||
export function createAIPageRootBlockSpec(
|
||||
framework: FrameworkProvider
|
||||
): ExtensionType[] {
|
||||
return [...PageRootBlockSpec, aiPanelWidget, getAIPageRootWatcher(framework)];
|
||||
return [
|
||||
...PageRootBlockSpec,
|
||||
aiPanelWidget,
|
||||
getAIPageRootWatcher(framework),
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('custom:affine:note'),
|
||||
config: toolbarAIEntryConfig(),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
} from '@blocksuite/affine/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/affine/block-std/gfx';
|
||||
import {
|
||||
AFFINE_FORMAT_BAR_WIDGET,
|
||||
AFFINE_VIEWPORT_OVERLAY_WIDGET,
|
||||
type AffineViewportOverlayWidget,
|
||||
DocModeProvider,
|
||||
@@ -12,6 +11,8 @@ import {
|
||||
NotificationProvider,
|
||||
stopPropagation,
|
||||
ThemeProvider,
|
||||
ToolbarFlag,
|
||||
ToolbarRegistryIdentifier,
|
||||
} from '@blocksuite/affine/blocks';
|
||||
import type { BaseSelection } from '@blocksuite/affine/store';
|
||||
import {
|
||||
@@ -519,27 +520,24 @@ export class AffineAIPanelWidget extends WidgetComponent {
|
||||
protected override willUpdate(changed: PropertyValues): void {
|
||||
const prevState = changed.get('state');
|
||||
if (prevState) {
|
||||
if (prevState === 'hidden') {
|
||||
const shouldBeHidden = prevState === 'hidden';
|
||||
|
||||
if (shouldBeHidden) {
|
||||
this._selection = this.host.selection.value;
|
||||
} else {
|
||||
this.restoreSelection();
|
||||
}
|
||||
|
||||
// tell format bar to show or hide
|
||||
const rootBlockId = this.host.doc.root?.id;
|
||||
const formatBar = rootBlockId
|
||||
? this.host.view.getWidget(AFFINE_FORMAT_BAR_WIDGET, rootBlockId)
|
||||
: null;
|
||||
|
||||
if (formatBar) {
|
||||
formatBar.requestUpdate();
|
||||
}
|
||||
// tell toolbar to show or hide
|
||||
this.std
|
||||
.get(ToolbarRegistryIdentifier)
|
||||
.flags.toggle(ToolbarFlag.Hiding, shouldBeHidden);
|
||||
}
|
||||
|
||||
if (this.state !== 'hidden') {
|
||||
this.viewportOverlayWidget?.lock();
|
||||
} else {
|
||||
if (this.state === 'hidden') {
|
||||
this.viewportOverlayWidget?.unlock();
|
||||
} else {
|
||||
this.viewportOverlayWidget?.lock();
|
||||
}
|
||||
|
||||
this.dataset.state = this.state;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WorkspaceServerService } from '@affine/core/modules/cloud';
|
||||
import { EditorSettingService } from '@affine/core/modules/editor-setting';
|
||||
import {
|
||||
DatabaseConfigExtension,
|
||||
@@ -10,12 +11,18 @@ import type { FrameworkProvider } from '@toeverything/infra';
|
||||
|
||||
import { createDatabaseOptionsConfig } from './database';
|
||||
import { createLinkedWidgetConfig } from './linked';
|
||||
import { createToolbarMoreMenuConfig } from './toolbar';
|
||||
import {
|
||||
createCustomToolbarExtension,
|
||||
createToolbarMoreMenuConfig,
|
||||
} from './toolbar';
|
||||
|
||||
export function getEditorConfigExtension(
|
||||
framework: FrameworkProvider
|
||||
): ExtensionType[] {
|
||||
const editorSettingService = framework.get(EditorSettingService);
|
||||
const workspaceServerService = framework.get(WorkspaceServerService);
|
||||
const baseUrl = workspaceServerService.server?.baseUrl ?? location.origin;
|
||||
|
||||
return [
|
||||
EditorSettingExtension(editorSettingService.editorSetting.settingSignal),
|
||||
DatabaseConfigExtension(createDatabaseOptionsConfig(framework)),
|
||||
@@ -23,5 +30,7 @@ export function getEditorConfigExtension(
|
||||
linkedWidget: createLinkedWidgetConfig(framework),
|
||||
}),
|
||||
ToolbarMoreMenuConfigExtension(createToolbarMoreMenuConfig(framework)),
|
||||
];
|
||||
|
||||
createCustomToolbarExtension(baseUrl),
|
||||
].flat();
|
||||
}
|
||||
|
||||
@@ -8,13 +8,66 @@ 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 type {
|
||||
import {
|
||||
BlockFlavourIdentifier,
|
||||
BlockSelection,
|
||||
TextSelection,
|
||||
} from '@blocksuite/affine/block-std';
|
||||
import {
|
||||
GfxBlockElementModel,
|
||||
GfxPrimitiveElementModel,
|
||||
} from '@blocksuite/affine/block-std/gfx';
|
||||
import type { MenuContext, MenuItemGroup } from '@blocksuite/affine/blocks';
|
||||
import { LinkIcon } from '@blocksuite/icons/lit';
|
||||
import {
|
||||
ActionPlacement,
|
||||
AffineReference,
|
||||
BookmarkBlockComponent,
|
||||
BookmarkBlockModel,
|
||||
EmbedFigmaBlockComponent,
|
||||
EmbedGithubBlockComponent,
|
||||
EmbedLinkedDocBlockComponent,
|
||||
EmbedLinkedDocModel,
|
||||
EmbedLoomBlockComponent,
|
||||
EmbedSyncedDocBlockComponent,
|
||||
EmbedSyncedDocModel,
|
||||
EmbedYoutubeBlockComponent,
|
||||
GenerateDocUrlProvider,
|
||||
getDocContentWithMaxLength,
|
||||
getSelectedModelsCommand,
|
||||
ImageSelection,
|
||||
isPeekable,
|
||||
matchModels,
|
||||
type MenuContext,
|
||||
type MenuItemGroup,
|
||||
notifyLinkedDocClearedAliases,
|
||||
notifyLinkedDocSwitchedToCard,
|
||||
type OpenDocMode,
|
||||
peek,
|
||||
toast,
|
||||
toggleEmbedCardEditModal,
|
||||
toggleReferencePopup,
|
||||
type ToolbarAction,
|
||||
type ToolbarActionGroup,
|
||||
type ToolbarContext,
|
||||
type ToolbarModuleConfig,
|
||||
ToolbarModuleExtension,
|
||||
} from '@blocksuite/affine/blocks';
|
||||
import type { ExtensionType } from '@blocksuite/affine/store';
|
||||
import {
|
||||
ArrowDownSmallIcon,
|
||||
CenterPeekIcon,
|
||||
CopyAsImgaeIcon,
|
||||
CopyIcon,
|
||||
EditIcon,
|
||||
ExpandFullIcon,
|
||||
LinkIcon,
|
||||
OpenInNewIcon,
|
||||
SplitViewIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import type { FrameworkProvider } from '@toeverything/infra';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import { createCopyAsPngMenuItem } from './copy-as-image';
|
||||
|
||||
@@ -136,3 +189,673 @@ function createCopyLinkToBlockMenuItem(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createToolbarMoreMenuConfigV2(baseUrl?: string) {
|
||||
return {
|
||||
actions: [
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'a.clipboard',
|
||||
actions: [
|
||||
{
|
||||
id: 'copy-as-image',
|
||||
label: 'Copy as Image',
|
||||
icon: CopyAsImgaeIcon(),
|
||||
when: ({ isEdgelessMode, gfx }) =>
|
||||
isEdgelessMode && gfx.selection.selectedElements.length > 0,
|
||||
},
|
||||
{
|
||||
id: 'copy-link-to-block',
|
||||
label: 'Copy link to block',
|
||||
icon: LinkIcon(),
|
||||
when: ({ isPageMode, selection, gfx }) => {
|
||||
const items = selection
|
||||
.getGroup('note')
|
||||
.filter(item =>
|
||||
[TextSelection, BlockSelection, ImageSelection].some(t =>
|
||||
item.is(t)
|
||||
)
|
||||
);
|
||||
const hasNoteSelection = items.length > 0;
|
||||
|
||||
if (isPageMode) {
|
||||
const item = items[0];
|
||||
if (item && item.is(TextSelection)) {
|
||||
return (
|
||||
!item.isCollapsed() &&
|
||||
Boolean(item.from.length + (item.to?.length ?? 0))
|
||||
);
|
||||
}
|
||||
return hasNoteSelection;
|
||||
}
|
||||
|
||||
// Linking blocks in notes is currently not supported under edgeless.
|
||||
if (hasNoteSelection) return false;
|
||||
|
||||
// Linking single block/element in edgeless mode.
|
||||
return gfx.selection.selectedElements.length === 1;
|
||||
},
|
||||
run({ isPageMode, std, store, gfx, workspace, editorMode }) {
|
||||
const pageId = store.doc.id;
|
||||
const mode = editorMode;
|
||||
const workspaceId = workspace.id;
|
||||
const options: UseSharingUrl = { workspaceId, pageId, mode };
|
||||
let type = '';
|
||||
|
||||
if (isPageMode) {
|
||||
const [ok, { selectedModels = [] }] = std.command.exec(
|
||||
getSelectedModelsCommand
|
||||
);
|
||||
|
||||
if (!ok || !selectedModels.length) return;
|
||||
|
||||
options.blockIds = selectedModels.map(model => model.id);
|
||||
type = selectedModels[0].flavour;
|
||||
} else {
|
||||
const firstElement = gfx.selection.firstElement;
|
||||
if (!firstElement) return;
|
||||
|
||||
const ids = [firstElement.id];
|
||||
if (firstElement instanceof GfxPrimitiveElementModel) {
|
||||
type = firstElement.type;
|
||||
options.elementIds = ids;
|
||||
} else if (firstElement instanceof GfxBlockElementModel) {
|
||||
type = firstElement.flavour;
|
||||
options.blockIds = ids;
|
||||
}
|
||||
}
|
||||
|
||||
if (!type) return;
|
||||
|
||||
const str = generateUrl({
|
||||
...options,
|
||||
baseUrl: baseUrl ?? location.origin,
|
||||
});
|
||||
|
||||
if (!str) return;
|
||||
|
||||
copyLinkToBlockStdScopeClipboard(str, std.clipboard)
|
||||
.then(ok => {
|
||||
if (!ok) return;
|
||||
|
||||
notify.success({ title: I18n['Copied link to clipboard']() });
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
track.doc.editor.toolbar.copyBlockToLink({ type });
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
}
|
||||
|
||||
function createExternalLinkableToolbarConfig(
|
||||
kclass:
|
||||
| typeof BookmarkBlockComponent
|
||||
| typeof EmbedFigmaBlockComponent
|
||||
| typeof EmbedGithubBlockComponent
|
||||
| typeof EmbedLoomBlockComponent
|
||||
| typeof EmbedYoutubeBlockComponent
|
||||
) {
|
||||
return {
|
||||
actions: [
|
||||
{
|
||||
id: 'a.preview.after.copy-link-and-edit',
|
||||
actions: [
|
||||
{
|
||||
id: 'copy-link',
|
||||
tooltip: 'Copy link',
|
||||
icon: CopyIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentBlockComponentBy(
|
||||
BlockSelection,
|
||||
kclass
|
||||
)?.model;
|
||||
if (!model) return;
|
||||
|
||||
const { url } = model;
|
||||
|
||||
navigator.clipboard.writeText(url).catch(console.error);
|
||||
toast(ctx.host, 'Copied link to clipboard');
|
||||
|
||||
ctx.track('CopiedLink', {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
category: matchModels(model, [BookmarkBlockModel])
|
||||
? 'bookmark'
|
||||
: 'link',
|
||||
type: 'card view',
|
||||
control: 'copy link',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'edit',
|
||||
tooltip: 'Edit',
|
||||
icon: EditIcon(),
|
||||
run(ctx) {
|
||||
const component = ctx.getCurrentBlockComponentBy(
|
||||
BlockSelection,
|
||||
kclass
|
||||
);
|
||||
if (!component) return;
|
||||
|
||||
ctx.hide();
|
||||
|
||||
const model = component.model;
|
||||
const abortController = new AbortController();
|
||||
abortController.signal.onabort = () => ctx.show();
|
||||
|
||||
toggleEmbedCardEditModal(
|
||||
ctx.host,
|
||||
model,
|
||||
'card',
|
||||
undefined,
|
||||
undefined,
|
||||
(_std, _component, props) => {
|
||||
ctx.store.updateBlock(model, props);
|
||||
component.requestUpdate();
|
||||
},
|
||||
abortController
|
||||
);
|
||||
|
||||
ctx.track('OpenedAliasPopup', {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
category: matchModels(model, [BookmarkBlockModel])
|
||||
? 'bookmark'
|
||||
: 'link',
|
||||
type: 'card view',
|
||||
control: 'edit',
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
}
|
||||
|
||||
const openDocActions = [
|
||||
{
|
||||
id: 'open-in-active-view',
|
||||
label: I18n['com.affine.peek-view-controls.open-doc'](),
|
||||
icon: ExpandFullIcon(),
|
||||
},
|
||||
{
|
||||
id: 'open-in-new-view',
|
||||
label: I18n['com.affine.peek-view-controls.open-doc-in-split-view'](),
|
||||
icon: SplitViewIcon(),
|
||||
when: () => BUILD_CONFIG.isElectron,
|
||||
},
|
||||
{
|
||||
id: 'open-in-new-tab',
|
||||
label: I18n['com.affine.peek-view-controls.open-doc-in-new-tab'](),
|
||||
icon: OpenInNewIcon(),
|
||||
},
|
||||
{
|
||||
id: 'open-in-center-peek',
|
||||
label: I18n['com.affine.peek-view-controls.open-doc-in-center-peek'](),
|
||||
icon: CenterPeekIcon(),
|
||||
},
|
||||
] as const satisfies ToolbarAction[];
|
||||
|
||||
function createOpenDocActionGroup(
|
||||
klass:
|
||||
| typeof EmbedLinkedDocBlockComponent
|
||||
| typeof EmbedSyncedDocBlockComponent
|
||||
) {
|
||||
return {
|
||||
placement: ActionPlacement.Start,
|
||||
id: 'A.open-doc',
|
||||
actions: openDocActions,
|
||||
content(ctx) {
|
||||
const component = ctx.getCurrentBlockComponentBy(BlockSelection, klass);
|
||||
if (!component) return null;
|
||||
|
||||
const actions = this.actions
|
||||
.map<ToolbarAction>(action => {
|
||||
const shouldOpenInCenterPeek = action.id === 'open-in-center-peek';
|
||||
const shouldOpenInActiveView = action.id === 'open-in-active-view';
|
||||
const allowed =
|
||||
typeof action.when === 'function'
|
||||
? action.when(ctx)
|
||||
: (action.when ?? true);
|
||||
return {
|
||||
...action,
|
||||
disabled: shouldOpenInActiveView
|
||||
? component.model.pageId === ctx.store.id
|
||||
: false,
|
||||
when:
|
||||
allowed &&
|
||||
(shouldOpenInCenterPeek ? isPeekable(component) : true),
|
||||
run: shouldOpenInCenterPeek
|
||||
? (_ctx: ToolbarContext) => peek(component)
|
||||
: (_ctx: ToolbarContext) =>
|
||||
component.open({
|
||||
openMode: action.id as OpenDocMode,
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter(action => {
|
||||
if (typeof action.when === 'function') return action.when(ctx);
|
||||
return action.when ?? true;
|
||||
});
|
||||
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="Open doc" .tooltip=${'Open doc'}>
|
||||
${OpenInNewIcon()} ${ArrowDownSmallIcon()}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="small" data-orientation="vertical">
|
||||
${repeat(
|
||||
actions,
|
||||
action => action.id,
|
||||
({ label, icon, run, disabled }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${ifDefined(label)}
|
||||
?disabled=${ifDefined(
|
||||
typeof disabled === 'function' ? disabled(ctx) : disabled
|
||||
)}
|
||||
@click=${() => run?.(ctx)}
|
||||
>
|
||||
${icon}<span class="label">${label}</span>
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>;
|
||||
}
|
||||
|
||||
const embedLinkedDocToolbarConfig = {
|
||||
actions: [
|
||||
createOpenDocActionGroup(EmbedLinkedDocBlockComponent),
|
||||
{
|
||||
id: 'a.doc-title.after.copy-link-and-edit',
|
||||
actions: [
|
||||
{
|
||||
id: 'copy-link',
|
||||
tooltip: 'Copy link',
|
||||
icon: CopyIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(
|
||||
BlockSelection,
|
||||
EmbedLinkedDocModel
|
||||
);
|
||||
if (!model) return;
|
||||
|
||||
const { pageId, params } = model;
|
||||
|
||||
const url = ctx.std
|
||||
.getOptional(GenerateDocUrlProvider)
|
||||
?.generateDocUrl(pageId, params);
|
||||
|
||||
if (!url) return;
|
||||
|
||||
navigator.clipboard.writeText(url).catch(console.error);
|
||||
toast(ctx.host, 'Copied link to clipboard');
|
||||
|
||||
ctx.track('CopiedLink', {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
category: 'linked doc',
|
||||
type: 'card view',
|
||||
control: 'copy link',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'edit',
|
||||
tooltip: 'Edit',
|
||||
icon: EditIcon(),
|
||||
run(ctx) {
|
||||
const component = ctx.getCurrentBlockComponentBy(
|
||||
BlockSelection,
|
||||
EmbedLinkedDocBlockComponent
|
||||
);
|
||||
if (!component) return;
|
||||
|
||||
ctx.hide();
|
||||
|
||||
const model = component.model;
|
||||
const doc = ctx.workspace.getDoc(model.pageId);
|
||||
const abortController = new AbortController();
|
||||
abortController.signal.onabort = () => ctx.show();
|
||||
|
||||
toggleEmbedCardEditModal(
|
||||
ctx.host,
|
||||
component.model,
|
||||
'card',
|
||||
doc
|
||||
? {
|
||||
title: doc.meta?.title,
|
||||
description: getDocContentWithMaxLength(doc),
|
||||
}
|
||||
: undefined,
|
||||
std => {
|
||||
component.refreshData();
|
||||
notifyLinkedDocClearedAliases(std);
|
||||
},
|
||||
(_std, _component, props) => {
|
||||
ctx.store.updateBlock(model, props);
|
||||
component.requestUpdate();
|
||||
},
|
||||
abortController
|
||||
);
|
||||
|
||||
ctx.track('OpenedAliasPopup', {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
category: 'linked doc',
|
||||
type: 'embed view',
|
||||
control: 'edit',
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
const embedSyncedDocToolbarConfig = {
|
||||
actions: [
|
||||
createOpenDocActionGroup(EmbedSyncedDocBlockComponent),
|
||||
{
|
||||
placement: ActionPlacement.Start,
|
||||
id: 'B.copy-link-and-edit',
|
||||
actions: [
|
||||
{
|
||||
id: 'copy-link',
|
||||
tooltip: 'Copy link',
|
||||
icon: CopyIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(
|
||||
BlockSelection,
|
||||
EmbedSyncedDocModel
|
||||
);
|
||||
if (!model) return;
|
||||
|
||||
const { pageId, params } = model;
|
||||
|
||||
const url = ctx.std
|
||||
.getOptional(GenerateDocUrlProvider)
|
||||
?.generateDocUrl(pageId, params);
|
||||
|
||||
if (!url) return;
|
||||
|
||||
navigator.clipboard.writeText(url).catch(console.error);
|
||||
toast(ctx.host, 'Copied link to clipboard');
|
||||
|
||||
ctx.track('CopiedLink', {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
category: 'linked doc',
|
||||
type: 'embed view',
|
||||
control: 'copy link',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'edit',
|
||||
tooltip: 'Edit',
|
||||
icon: EditIcon(),
|
||||
run(ctx) {
|
||||
const component = ctx.getCurrentBlockComponentBy(
|
||||
BlockSelection,
|
||||
EmbedSyncedDocBlockComponent
|
||||
);
|
||||
if (!component) return;
|
||||
|
||||
ctx.hide();
|
||||
|
||||
const model = component.model;
|
||||
const doc = ctx.workspace.getDoc(model.pageId);
|
||||
const abortController = new AbortController();
|
||||
abortController.signal.onabort = () => ctx.show();
|
||||
|
||||
toggleEmbedCardEditModal(
|
||||
ctx.host,
|
||||
model,
|
||||
'embed',
|
||||
doc ? { title: doc.meta?.title } : undefined,
|
||||
undefined,
|
||||
(std, _component, props) => {
|
||||
component.convertToCard(props);
|
||||
|
||||
notifyLinkedDocSwitchedToCard(std);
|
||||
},
|
||||
abortController
|
||||
);
|
||||
|
||||
ctx.track('OpenedAliasPopup', {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
category: 'linked doc',
|
||||
type: 'embed view',
|
||||
control: 'edit',
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
const inlineReferenceToolbarConfig = {
|
||||
actions: [
|
||||
{
|
||||
placement: ActionPlacement.Start,
|
||||
id: 'A.open-doc',
|
||||
actions: openDocActions,
|
||||
content(ctx) {
|
||||
const registry = ctx.toolbarRegistry;
|
||||
const target = registry.message$.peek()?.element;
|
||||
if (!(target instanceof AffineReference)) return null;
|
||||
|
||||
const actions = this.actions
|
||||
.map<ToolbarAction>(action => {
|
||||
const shouldOpenInCenterPeek = action.id === 'open-in-center-peek';
|
||||
const shouldOpenInActiveView = action.id === 'open-in-active-view';
|
||||
const allowed =
|
||||
typeof action.when === 'function'
|
||||
? action.when(ctx)
|
||||
: (action.when ?? true);
|
||||
return {
|
||||
...action,
|
||||
disabled: shouldOpenInActiveView
|
||||
? target.referenceInfo.pageId === ctx.store.id
|
||||
: false,
|
||||
when:
|
||||
allowed && (shouldOpenInCenterPeek ? isPeekable(target) : true),
|
||||
run: shouldOpenInCenterPeek
|
||||
? (_ctx: ToolbarContext) => peek(target)
|
||||
: (_ctx: ToolbarContext) =>
|
||||
target.open({
|
||||
openMode: action.id as OpenDocMode,
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter(action => {
|
||||
if (typeof action.when === 'function') return action.when(ctx);
|
||||
return action.when ?? true;
|
||||
});
|
||||
|
||||
return html`${keyed(
|
||||
target,
|
||||
html`
|
||||
<editor-menu-button
|
||||
.contentPadding="${'8px'}"
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Open doc"
|
||||
.tooltip=${'Open doc'}
|
||||
>
|
||||
${OpenInNewIcon()} ${ArrowDownSmallIcon()}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="small" data-orientation="vertical">
|
||||
${repeat(
|
||||
actions,
|
||||
action => action.id,
|
||||
({ label, icon, run, disabled }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${ifDefined(label)}
|
||||
?disabled=${ifDefined(
|
||||
typeof disabled === 'function'
|
||||
? disabled(ctx)
|
||||
: disabled
|
||||
)}
|
||||
@click=${() => run?.(ctx)}
|
||||
>
|
||||
${icon}<span class="label">${label}</span>
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
id: 'b.copy-link-and-edit',
|
||||
actions: [
|
||||
{
|
||||
id: 'copy-link',
|
||||
tooltip: 'Copy link',
|
||||
icon: CopyIcon(),
|
||||
run(ctx) {
|
||||
const target = ctx.message$.peek()?.element;
|
||||
if (!(target instanceof AffineReference)) return;
|
||||
|
||||
const { pageId, params } = target.referenceInfo;
|
||||
|
||||
const url = ctx.std
|
||||
.getOptional(GenerateDocUrlProvider)
|
||||
?.generateDocUrl(pageId, params);
|
||||
|
||||
if (!url) return;
|
||||
|
||||
// Clears
|
||||
ctx.reset();
|
||||
|
||||
navigator.clipboard.writeText(url).catch(console.error);
|
||||
toast(ctx.host, 'Copied link to clipboard');
|
||||
|
||||
ctx.track('CopiedLink', {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
category: 'linked doc',
|
||||
type: 'inline view',
|
||||
control: 'copy link',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'edit',
|
||||
tooltip: 'Edit',
|
||||
icon: EditIcon(),
|
||||
run(ctx) {
|
||||
const target = ctx.message$.peek()?.element;
|
||||
if (!(target instanceof AffineReference)) return;
|
||||
|
||||
// Clears
|
||||
ctx.reset();
|
||||
|
||||
const { inlineEditor, selfInlineRange, docTitle, referenceInfo } =
|
||||
target;
|
||||
if (!inlineEditor || !selfInlineRange) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
const popover = toggleReferencePopup(
|
||||
ctx.std,
|
||||
docTitle,
|
||||
referenceInfo,
|
||||
inlineEditor,
|
||||
selfInlineRange,
|
||||
abortController
|
||||
);
|
||||
abortController.signal.onabort = () => popover.remove();
|
||||
|
||||
ctx.track('OpenedAliasPopup', {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
category: 'linked doc',
|
||||
type: 'inline view',
|
||||
control: 'edit',
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const createCustomToolbarExtension = (
|
||||
baseUrl: string
|
||||
): ExtensionType[] => {
|
||||
return [
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('custom:affine:*'),
|
||||
config: createToolbarMoreMenuConfigV2(baseUrl),
|
||||
}),
|
||||
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('custom:affine:bookmark'),
|
||||
config: createExternalLinkableToolbarConfig(BookmarkBlockComponent),
|
||||
}),
|
||||
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('custom:affine:embed-figma'),
|
||||
config: createExternalLinkableToolbarConfig(EmbedFigmaBlockComponent),
|
||||
}),
|
||||
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('custom:affine:embed-github'),
|
||||
config: createExternalLinkableToolbarConfig(EmbedGithubBlockComponent),
|
||||
}),
|
||||
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('custom:affine:embed-loom'),
|
||||
config: createExternalLinkableToolbarConfig(EmbedLoomBlockComponent),
|
||||
}),
|
||||
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('custom:affine:embed-youtube'),
|
||||
config: createExternalLinkableToolbarConfig(EmbedYoutubeBlockComponent),
|
||||
}),
|
||||
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('custom:affine:embed-linked-doc'),
|
||||
config: embedLinkedDocToolbarConfig,
|
||||
}),
|
||||
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('custom:affine:embed-synced-doc'),
|
||||
config: embedSyncedDocToolbarConfig,
|
||||
}),
|
||||
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier('custom:affine:reference'),
|
||||
config: inlineReferenceToolbarConfig,
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -13,14 +13,13 @@ import type {
|
||||
import {
|
||||
codeToolbarWidget,
|
||||
DocModeProvider,
|
||||
embedCardToolbarWidget,
|
||||
FeatureFlagService,
|
||||
formatBarWidget,
|
||||
imageToolbarWidget,
|
||||
ParagraphBlockService,
|
||||
ReferenceNodeConfigIdentifier,
|
||||
slashMenuWidget,
|
||||
surfaceRefToolbarWidget,
|
||||
toolbarWidget,
|
||||
VirtualKeyboardProvider as BSVirtualKeyboardProvider,
|
||||
} from '@blocksuite/affine/blocks';
|
||||
import type {
|
||||
@@ -167,11 +166,10 @@ export function enableMobileExtension(
|
||||
specBuilder: SpecBuilder,
|
||||
framework: FrameworkProvider
|
||||
): void {
|
||||
specBuilder.omit(formatBarWidget);
|
||||
specBuilder.omit(embedCardToolbarWidget);
|
||||
specBuilder.omit(slashMenuWidget);
|
||||
specBuilder.omit(codeToolbarWidget);
|
||||
specBuilder.omit(imageToolbarWidget);
|
||||
specBuilder.omit(surfaceRefToolbarWidget);
|
||||
specBuilder.omit(toolbarWidget);
|
||||
specBuilder.extend([MobileSpecsPatches, KeyboardToolbarExtension(framework)]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user