refactor(editor): linked doc slash menu config extension (#10682)

This commit is contained in:
L-Sun
2025-03-07 14:42:56 +00:00
parent 9f3580263a
commit 63bf2b80c1
18 changed files with 220 additions and 178 deletions
@@ -1,6 +1,5 @@
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
import {
BlockFlavourIdentifier,
BlockViewExtension,
@@ -10,7 +9,7 @@ import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { BookmarkBlockAdapterExtensions } from './adapters/extension';
import { bookmarkSlashMenuConfig } from './configs/slash-menu';
import { BookmarkSlashMenuConfigExtension } from './configs/slash-menu';
import { builtinToolbarConfig } from './configs/toolbar';
const flavour = BookmarkBlockSchema.model.flavour;
@@ -27,5 +26,5 @@ export const BookmarkBlockSpec: ExtensionType[] = [
id: BlockFlavourIdentifier(flavour),
config: builtinToolbarConfig,
}),
SlashMenuConfigExtension(flavour, bookmarkSlashMenuConfig),
BookmarkSlashMenuConfigExtension,
].flat();
@@ -1,10 +1,15 @@
import { toggleEmbedCardCreateModal } from '@blocksuite/affine-components/embed-card-modal';
import { type SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
import {
type SlashMenuConfig,
SlashMenuConfigIdentifier,
} from '@blocksuite/affine-widget-slash-menu';
import { LinkIcon } from '@blocksuite/icons/lit';
import type { ExtensionType } from '@blocksuite/store';
import { LinkTooltip } from './tooltips';
export const bookmarkSlashMenuConfig: SlashMenuConfig = {
const bookmarkSlashMenuConfig: SlashMenuConfig = {
items: [
{
name: 'Link',
@@ -40,3 +45,13 @@ export const bookmarkSlashMenuConfig: SlashMenuConfig = {
},
],
};
export const BookmarkSlashMenuConfigIdentifier = SlashMenuConfigIdentifier(
BookmarkBlockSchema.model.flavour
);
export const BookmarkSlashMenuConfigExtension: ExtensionType = {
setup: di => {
di.addImpl(BookmarkSlashMenuConfigIdentifier, bookmarkSlashMenuConfig);
},
};
@@ -3,3 +3,4 @@ export * from './bookmark-block';
export * from './bookmark-spec';
export * from './commands';
export * from './components';
export { BookmarkSlashMenuConfigIdentifier } from './configs/slash-menu';
@@ -0,0 +1,95 @@
import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model';
import {
getInlineEditorByModel,
insertContent,
} from '@blocksuite/affine-rich-text';
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
import { createDefaultDoc } from '@blocksuite/affine-shared/utils';
import {
type SlashMenuConfig,
SlashMenuConfigIdentifier,
} from '@blocksuite/affine-widget-slash-menu';
import { LinkedPageIcon, PlusIcon } from '@blocksuite/icons/lit';
import { type ExtensionType } from '@blocksuite/store';
import { LinkDocTooltip, NewDocTooltip } from './tooltips';
const linkedDocSlashMenuConfig: SlashMenuConfig = {
items: [
{
name: 'New Doc',
description: 'Start a new document.',
icon: PlusIcon(),
tooltip: {
figure: NewDocTooltip,
caption: 'New Doc',
},
group: '3_Page@0',
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc'),
action: ({ std, model }) => {
const newDoc = createDefaultDoc(std.host.doc.workspace);
insertContent(std.host, model, REFERENCE_NODE, {
reference: {
type: 'LinkedPage',
pageId: newDoc.id,
},
});
},
},
{
name: 'Linked Doc',
description: 'Link to another document.',
icon: LinkedPageIcon(),
tooltip: {
figure: LinkDocTooltip,
caption: 'Link Doc',
},
searchAlias: ['dual link'],
group: '3_Page@1',
when: ({ std, model }) => {
const root = model.doc.root;
if (!root) return false;
const linkedDocWidget = std.view.getWidget(
'affine-linked-doc-widget',
root.id
);
if (!linkedDocWidget) return false;
return model.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc');
},
action: ({ model, std }) => {
const root = model.doc.root;
if (!root) return;
const linkedDocWidget = std.view.getWidget(
'affine-linked-doc-widget',
root.id
);
if (!linkedDocWidget) return;
// TODO(@L-Sun): make linked-doc-widget as extension
// @ts-expect-error same as above
const triggerKey = linkedDocWidget.config.triggerKeys[0];
insertContent(std.host, model, triggerKey);
const inlineEditor = getInlineEditorByModel(std.host, model);
// Wait for range to be updated
inlineEditor?.slots.inlineRangeSync.once(() => {
// TODO(@L-Sun): make linked-doc-widget as extension
// @ts-expect-error same as above
linkedDocWidget.show({ addTriggerKey: true });
});
},
},
],
};
export const LinkedDocSlashMenuConfigIdentifier = SlashMenuConfigIdentifier(
EmbedLinkedDocBlockSchema.model.flavour
);
export const LinkedDocSlashMenuConfigExtension: ExtensionType = {
setup: di => {
di.addImpl(LinkedDocSlashMenuConfigIdentifier, linkedDocSlashMenuConfig);
},
};
@@ -1,4 +1,17 @@
import { html } from 'lit';
// prettier-ignore
export const NewDocTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_991" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_991)">
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="22" font-weight="600" letter-spacing="0px"><tspan x="8" y="27.5">Title</tspan></text>
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="44.6364">Type &#39;/&#39; for commands</tspan></text>
</g>
</svg>
`;
// prettier-ignore
export const LinkDocTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
@@ -8,6 +8,7 @@ import type { ExtensionType } from '@blocksuite/store';
import { literal } from 'lit/static-html.js';
import { EmbedLinkedDocBlockAdapterExtensions } from './adapters/extension';
import { LinkedDocSlashMenuConfigExtension } from './configs/slash-menu';
import { builtinToolbarConfig } from './configs/toolbar';
const flavour = EmbedLinkedDocBlockSchema.model.flavour;
@@ -23,4 +24,5 @@ export const EmbedLinkedDocBlockSpec: ExtensionType[] = [
id: BlockServiceIdentifier(flavour),
config: builtinToolbarConfig,
}),
LinkedDocSlashMenuConfigExtension,
].flat();
@@ -1,4 +1,5 @@
export * from './adapters';
export * from './commands';
export { LinkedDocSlashMenuConfigIdentifier } from './configs/slash-menu';
export * from './embed-linked-doc-block';
export * from './embed-linked-doc-spec';
@@ -1,19 +0,0 @@
// TODO(@L-Sun): remove these utils
export function formatDate(date: Date) {
// yyyy-mm-dd
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const strTime = `${year}-${month}-${day}`;
return strTime;
}
export function formatTime(date: Date) {
// mm-dd hh:mm
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const strTime = `${month}-${day} ${hours}:${minutes}`;
return strTime;
}
@@ -101,7 +101,6 @@ import { cssVarV2 } from '@toeverything/theme/v2';
import type { TemplateResult } from 'lit';
import type { PageRootBlockComponent } from '../../page/page-root-block.js';
import { formatDate, formatTime } from '../../utils/misc.js';
import type { AffineLinkedDocWidget } from '../linked-doc/index.js';
import {
FigmaDuotoneIcon,
@@ -110,6 +109,7 @@ import {
TextBackgroundDuotoneIcon,
TextColorIcon,
} from './icons.js';
import { formatDate, formatTime } from './utils.js';
export type KeyboardToolbarConfig = {
items: KeyboardToolbarItem[];
@@ -22,3 +22,22 @@ export function isKeyboardToolPanelConfig(
): item is KeyboardToolPanelConfig {
return 'groups' in item;
}
export function formatDate(date: Date) {
// yyyy-mm-dd
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const strTime = `${year}-${month}-${day}`;
return strTime;
}
export function formatTime(date: Date) {
// mm-dd hh:mm
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
const strTime = `${month}-${day} ${hours}:${minutes}`;
return strTime;
}
@@ -1,20 +1,13 @@
import { toast } from '@blocksuite/affine-components/toast';
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
import {
getInlineEditorByModel,
insertContent,
} from '@blocksuite/affine-rich-text';
import { REFERENCE_NODE } from '@blocksuite/affine-shared/consts';
import { createDefaultDoc } from '@blocksuite/affine-shared/utils';
import { insertContent } from '@blocksuite/affine-rich-text';
import {
ArrowDownBigIcon,
ArrowUpBigIcon,
CopyIcon,
DeleteIcon,
DualLinkIcon,
LinkedPageIcon,
NowIcon,
PlusIcon,
TodayIcon,
TomorrowIcon,
YesterdayIcon,
@@ -38,67 +31,6 @@ export const defaultSlashMenuConfig: SlashMenuConfig = {
tomorrow.setDate(tomorrow.getDate() + 1);
return [
{
name: 'New Doc',
description: 'Start a new document.',
icon: PlusIcon(),
tooltip: slashMenuToolTips['New Doc'],
group: '3_Page@0',
when: ({ model }) =>
model.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc'),
action: ({ std, model }) => {
const newDoc = createDefaultDoc(std.host.doc.workspace);
insertContent(std.host, model, REFERENCE_NODE, {
reference: {
type: 'LinkedPage',
pageId: newDoc.id,
},
});
},
},
{
name: 'Linked Doc',
description: 'Link to another document.',
icon: LinkedPageIcon(),
tooltip: slashMenuToolTips['Linked Doc'],
searchAlias: ['dual link'],
group: '3_Page@1',
when: ({ std, model }) => {
const root = model.doc.root;
if (!root) return false;
const linkedDocWidget = std.view.getWidget(
'affine-linked-doc-widget',
root.id
);
if (!linkedDocWidget) return false;
return model.doc.schema.flavourSchemaMap.has(
'affine:embed-linked-doc'
);
},
action: ({ model, std }) => {
const root = model.doc.root;
if (!root) return;
const linkedDocWidget = std.view.getWidget(
'affine-linked-doc-widget',
root.id
);
if (!linkedDocWidget) return;
// TODO(@L-Sun): make linked-doc-widget as extension
// @ts-expect-error same as above
const triggerKey = linkedDocWidget.config.triggerKeys[0];
insertContent(std.host, model, triggerKey);
const inlineEditor = getInlineEditorByModel(std.host, model);
// Wait for range to be updated
inlineEditor?.slots.inlineRangeSync.once(() => {
// TODO(@L-Sun): make linked-doc-widget as extension
// @ts-expect-error same as above
linkedDocWidget.show({ addTriggerKey: true });
});
},
},
{
name: 'Today',
icon: TodayIcon(),
@@ -1,5 +1,4 @@
export const AFFINE_SLASH_MENU_WIDGET = 'affine-slash-menu-widget';
export const AFFINE_SLASH_MENU_TRIGGER_KEY = '/';
export const AFFINE_SLASH_MENU_TOOLTIP_TIMEOUT = 800;
// TODO(@L-Sun): Move this to styles.ts
export const AFFINE_SLASH_MENU_MAX_HEIGHT = 334;
@@ -24,7 +24,6 @@ export class SlashMenuExtension extends Extension {
di.add(this, [StdIdentifier]);
// TODO(@L-Sun): remove this after moving all configs to corresponding extensions
SlashMenuConfigExtension('default', defaultSlashMenuConfig).setup(di);
}
@@ -36,7 +35,7 @@ export class SlashMenuExtension extends Extension {
}
}
const SlashMenuConfigIdentifier = createIdentifier<SlashMenuConfig>(
export const SlashMenuConfigIdentifier = createIdentifier<SlashMenuConfig>(
`${AFFINE_SLASH_MENU_WIDGET}-config`
);
@@ -1,6 +1,3 @@
export { AFFINE_SLASH_MENU_WIDGET } from './consts';
// TODO(@L-Sun): narrow the scope of the exported symbols
export * from './extensions';
export * from './types';
// TODO(@L-Sun): remove this when refactoring quick search
export * from './widget';
@@ -1,26 +1,14 @@
import type { SlashMenuTooltip } from '../types';
import { CopyTooltip } from './copy';
import { DeleteTooltip } from './delete';
import { LinkDocTooltip } from './link-doc';
import { MoveDownTooltip } from './move-down';
import { MoveUpTooltip } from './move-up';
import { NewDocTooltip } from './new-doc';
import { NowTooltip } from './now';
import { TodayTooltip } from './today';
import { TomorrowTooltip } from './tomorrow';
import { YesterdayTooltip } from './yesterday';
export const slashMenuToolTips: Record<string, SlashMenuTooltip> = {
'New Doc': {
figure: NewDocTooltip,
caption: 'New Doc',
},
'Linked Doc': {
figure: LinkDocTooltip,
caption: 'Link Doc',
},
Today: {
figure: TodayTooltip,
caption: 'Today',
@@ -1,13 +0,0 @@
import { html } from 'lit';
// prettier-ignore
export const NewDocTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_991" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_991)">
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="22" font-weight="600" letter-spacing="0px"><tspan x="8" y="27.5">Title</tspan></text>
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="44.6364">Type &#39;/&#39; for commands</tspan></text>
</g>
</svg>
`;
@@ -12,7 +12,7 @@ export type SlashMenuTooltip = {
caption: string;
};
export type SlashMenuItemBase = {
type SlashMenuItemBase = {
name: string;
description?: string;
icon?: TemplateResult;
@@ -24,10 +24,6 @@ export type SlashMenuItemBase = {
*/
group?: `${number}_${string}@${number}`;
// TODO(@L-Sun): move this field to SlashMenuActionItem when refactoring search
/**
* The alias of the menu item for search.
*/
searchAlias?: string[];
/**
* The condition to show the menu item.
@@ -38,6 +34,10 @@ export type SlashMenuItemBase = {
export type SlashMenuActionItem = SlashMenuItemBase & {
action: (ctx: SlashMenuContext) => void;
tooltip?: SlashMenuTooltip;
/**
* The alias of the menu item for search.
*/
searchAlias?: string[];
};
export type SlashMenuSubMenu = SlashMenuItemBase & {
@@ -47,7 +47,6 @@ export type SlashMenuSubMenu = SlashMenuItemBase & {
export type SlashMenuItem = SlashMenuActionItem | SlashMenuSubMenu;
export type SlashMenuConfig = {
// TODO(@L-Sun): change this type to SlashMenuItem[] and (ctx: SlashMenuContext) => SlashMenuItem[]
/**
* The items in the slash menu. It can be generated dynamically with the context.
*/
@@ -10,14 +10,19 @@ import {
import { ExternalLinksQuickSearchSession } from '@affine/core/modules/quicksearch/impls/external-links';
import { JournalsQuickSearchSession } from '@affine/core/modules/quicksearch/impls/journals';
import { track } from '@affine/track';
import { LifeCycleWatcher } from '@blocksuite/affine/block-std';
import type { QuickSearchResult } from '@blocksuite/affine/blocks';
import type {
QuickSearchResult,
SlashMenuConfig,
SlashMenuItem,
} from '@blocksuite/affine/blocks';
import {
AffineSlashMenuWidget,
BookmarkSlashMenuConfigIdentifier,
insertLinkByQuickSearchCommand,
LinkedDocSlashMenuConfigIdentifier,
QuickSearchExtension,
} from '@blocksuite/affine/blocks';
import { Text } from '@blocksuite/affine/store';
import type { ServiceIdentifier } from '@blocksuite/affine/global/di';
import { type ExtensionType, Text } from '@blocksuite/affine/store';
import type { FrameworkProvider } from '@toeverything/infra';
import { pick } from 'lodash-es';
@@ -120,56 +125,66 @@ export function patchQuickSearchService(framework: FrameworkProvider) {
},
});
class SlashMenuQuickSearchExtension extends LifeCycleWatcher {
static override key = 'slash-menu-quick-search-extension';
const SlashMenuQuickSearchExtension: ExtensionType = {
setup: di => {
const overrideFn = (identifier: ServiceIdentifier<SlashMenuConfig>) => {
const prev = di.getFactory(identifier);
if (!prev) return;
override mounted() {
super.mounted();
const { view } = this.std;
view.viewUpdated.on(payload => {
if (payload.type !== 'widget' || payload.method !== 'add') {
return;
}
const component = payload.view;
if (component instanceof AffineSlashMenuWidget) {
component.configItemTransform = item => {
if (
'action' in item &&
(item.name === 'Linked Doc' || item.name === 'Link')
) {
item.action = ({ std }) => {
const [success, { insertedLinkType }] = std.command.exec(
insertLinkByQuickSearchCommand
);
di.override(identifier, provider => {
const prevConfig: SlashMenuConfig = prev(provider);
if (!success) return;
if (typeof prevConfig.items === 'function') {
const prevConfigItemGenerator = prevConfig.items;
prevConfig.items = ctx =>
prevConfigItemGenerator(ctx).map(modifyFn);
} else {
prevConfig.items = prevConfig.items.map(modifyFn);
}
insertedLinkType
?.then(type => {
const flavour = type?.flavour;
if (!flavour) return;
return prevConfig;
});
};
if (flavour === 'affine:bookmark') {
track.doc.editor.slashMenu.bookmark();
return;
}
overrideFn(LinkedDocSlashMenuConfigIdentifier);
overrideFn(BookmarkSlashMenuConfigIdentifier);
if (flavour === 'affine:embed-linked-doc') {
track.doc.editor.slashMenu.linkDoc({
control: 'linkDoc',
});
return;
}
})
.catch(console.error);
};
}
return item;
const modifyFn = (item: SlashMenuItem): SlashMenuItem => {
if (
'action' in item &&
(item.name === 'Linked Doc' || item.name === 'Link')
) {
item.action = ({ std }) => {
const [success, { insertedLinkType }] = std.command.exec(
insertLinkByQuickSearchCommand
);
if (!success) return;
insertedLinkType
?.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);
};
}
});
}
}
return item;
};
},
};
return [QuickSearch, SlashMenuQuickSearchExtension];
}