improve slash menu extension

This commit is contained in:
fantasticit 2023-01-08 14:21:25 +08:00
parent 7465ed6de3
commit 79ecf7c62e
4 changed files with 165 additions and 138 deletions

View File

@ -1,136 +0,0 @@
import { Node } from '@tiptap/core';
import { ReactRenderer } from '@tiptap/react';
import Suggestion from '@tiptap/suggestion';
import { Plugin, PluginKey } from 'prosemirror-state';
import tippy from 'tippy.js';
import { EXTENSION_PRIORITY_HIGHEST } from 'tiptap/core/constants';
import { insertMenuLRUCache, QUICK_INSERT_COMMANDS, transformToCommands } from 'tiptap/core/menus/commands';
import { MenuList } from 'tiptap/core/wrappers/menu-list';
export const QuickInsertPluginKey = new PluginKey('quickInsert');
const extensionName = 'quickInsert';
export const QuickInsert = Node.create({
name: extensionName,
priority: EXTENSION_PRIORITY_HIGHEST,
addOptions() {
return {
HTMLAttributes: {},
suggestion: {
char: '/',
pluginKey: QuickInsertPluginKey,
command: ({ editor, range, props }) => {
const { state, dispatch } = editor.view;
const { $head, $from, $to } = state.selection;
// 删除快捷指令
const end = $from.pos;
const from = $head.nodeBefore
? end - $head.nodeBefore.text.substring($head.nodeBefore.text.indexOf('/')).length
: $from.start();
const tr = state.tr.deleteRange(from, end);
dispatch(tr);
props?.action(editor, props.user);
insertMenuLRUCache.put(props.label);
editor?.view?.focus();
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
new Plugin({
key: new PluginKey('evokeMenuPlaceholder'),
}),
];
},
addStorage() {
return {
rect: {
width: 0,
height: 0,
left: 0,
top: 0,
right: 0,
bottom: 0,
},
};
},
}).configure({
suggestion: {
items: ({ query }) => {
const recentUsed = insertMenuLRUCache.get() as string[];
const restCommands = QUICK_INSERT_COMMANDS.filter((command) => {
return !('title' in command) && !('custom' in command) && !recentUsed.includes(command.label);
});
return [...transformToCommands(QUICK_INSERT_COMMANDS, recentUsed), ...restCommands].filter(
(command) => !('title' in command) && command.label && command.label.startsWith(query)
);
},
render: () => {
let component;
let popup;
let isEditable;
return {
onStart: (props) => {
isEditable = props.editor.isEditable;
if (!isEditable) return;
component = new ReactRenderer(MenuList, {
props,
editor: props.editor,
});
popup = tippy('body', {
getReferenceClientRect: props.clientRect || (() => props.editor.storage[extensionName].rect),
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
});
},
onUpdate(props) {
if (!isEditable) return;
component.updateProps(props);
props.editor.storage[extensionName].rect = props.clientRect();
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props) {
if (!isEditable) return;
if (props.event.key === 'Escape') {
popup[0].hide();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
if (!isEditable) return;
popup[0].destroy();
component.destroy();
},
};
},
},
});

View File

@ -0,0 +1,143 @@
import { Node } from '@tiptap/core';
import { ReactRenderer } from '@tiptap/react';
import Suggestion from '@tiptap/suggestion';
import { Plugin, PluginKey } from 'prosemirror-state';
import tippy from 'tippy.js';
import { EXTENSION_PRIORITY_HIGHEST } from 'tiptap/core/constants';
import { insertMenuLRUCache, QUICK_INSERT_COMMANDS, transformToCommands } from 'tiptap/core/menus/commands';
import { MenuList } from 'tiptap/core/wrappers/menu-list';
const createSlashExtension = (char: string) => {
const extensionName = `quickInsert-${char}`;
const extensionPluginKey = new PluginKey('quickInsert');
const slashExtension = Node.create({
name: extensionName,
priority: EXTENSION_PRIORITY_HIGHEST,
addOptions() {
return {
HTMLAttributes: {},
suggestion: {
char: char,
pluginKey: extensionPluginKey,
command: ({ editor, range, props }) => {
const { state, dispatch } = editor.view;
const { $head, $from, $to } = state.selection;
// 删除快捷指令
const end = $from.pos;
const from = $head.nodeBefore
? end - $head.nodeBefore.text.substring($head.nodeBefore.text.indexOf(char)).length
: $from.start();
const tr = state.tr.deleteRange(from, end);
dispatch(tr);
props?.action(editor, props.user);
insertMenuLRUCache.put(props.label);
editor?.view?.focus();
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
new Plugin({
key: new PluginKey('evokeMenuPlaceholder'),
}),
];
},
addStorage() {
return {
rect: {
width: 0,
height: 0,
left: 0,
top: 0,
right: 0,
bottom: 0,
},
};
},
}).configure({
suggestion: {
items: ({ query }) => {
const recentUsed = insertMenuLRUCache.get() as string[];
const restCommands = QUICK_INSERT_COMMANDS.filter((command) => {
return !('title' in command) && !('custom' in command) && !recentUsed.includes(command.label);
});
return [...transformToCommands(QUICK_INSERT_COMMANDS, recentUsed), ...restCommands].filter(
(command) =>
!('title' in command) &&
((command.label && command.label.startsWith(query)) || (command.pinyin && command.pinyin.startsWith(query)))
);
},
render: () => {
let component;
let popup;
let isEditable;
return {
onStart: (props) => {
isEditable = props.editor.isEditable;
if (!isEditable) return;
component = new ReactRenderer(MenuList, {
props,
editor: props.editor,
});
popup = tippy('body', {
getReferenceClientRect: props.clientRect || (() => props.editor.storage[extensionName].rect),
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
});
},
onUpdate(props) {
if (!isEditable) return;
component.updateProps(props);
props.editor.storage[extensionName].rect = props.clientRect();
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props) {
if (!isEditable) return;
if (props.event.key === 'Escape') {
popup[0].hide();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
if (!isEditable) return;
popup[0].destroy();
component.destroy();
},
};
},
},
});
return slashExtension;
};
export const EnSlashExtension = createSlashExtension('/');
export const ZhSlashExtension = createSlashExtension('、');

View File

@ -30,6 +30,7 @@ type IBaseCommand = {
isBlock?: boolean; isBlock?: boolean;
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
pinyin: string;
user?: IUser; user?: IUser;
}; };
@ -55,12 +56,14 @@ export const COMMANDS: ICommand[] = [
{ {
icon: <IconTableOfContents />, icon: <IconTableOfContents />,
label: '目录', label: '目录',
pinyin: 'mulu',
action: (editor) => editor.chain().focus().setTableOfContents().run(), action: (editor) => editor.chain().focus().setTableOfContents().run(),
}, },
{ {
isBlock: true, isBlock: true,
icon: <IconTable />, icon: <IconTable />,
label: '表格', label: '表格',
pinyin: 'biaoge',
custom: (editor, runCommand) => ( custom: (editor, runCommand) => (
<Popover <Popover
key="custom-table" key="custom-table"
@ -94,6 +97,7 @@ export const COMMANDS: ICommand[] = [
isBlock: true, isBlock: true,
icon: <IconLayout />, icon: <IconLayout />,
label: '布局', label: '布局',
pinyin: 'buju',
custom: (editor, runCommand) => ( custom: (editor, runCommand) => (
<Popover <Popover
key="custom-columns" key="custom-columns"
@ -129,30 +133,35 @@ export const COMMANDS: ICommand[] = [
isBlock: true, isBlock: true,
icon: <IconCodeBlock />, icon: <IconCodeBlock />,
label: '代码块', label: '代码块',
pinyin: 'daimakuai',
action: (editor) => editor.chain().focus().toggleCodeBlock().run(), action: (editor) => editor.chain().focus().toggleCodeBlock().run(),
}, },
{ {
isBlock: true, isBlock: true,
icon: <IconImage />, icon: <IconImage />,
label: '图片', label: '图片',
pinyin: 'tupian',
action: (editor) => editor.chain().focus().setEmptyImage({ width: '100%' }).run(), action: (editor) => editor.chain().focus().setEmptyImage({ width: '100%' }).run(),
}, },
{ {
isBlock: true, isBlock: true,
icon: <IconAttachment />, icon: <IconAttachment />,
label: '附件', label: '附件',
pinyin: 'fujian',
action: (editor) => editor.chain().focus().setAttachment().run(), action: (editor) => editor.chain().focus().setAttachment().run(),
}, },
{ {
isBlock: true, isBlock: true,
icon: <IconCountdown />, icon: <IconCountdown />,
label: '倒计时', label: '倒计时',
pinyin: 'daojishi',
action: (editor) => createCountdown(editor), action: (editor) => createCountdown(editor),
}, },
{ {
isBlock: true, isBlock: true,
icon: <IconLink />, icon: <IconLink />,
label: '外链', label: '外链',
pinyin: 'wailian',
action: (editor, user) => action: (editor, user) =>
editor.chain().focus().setIframe({ url: '', defaultShowPicker: true, createUser: user.id }).run(), editor.chain().focus().setIframe({ url: '', defaultShowPicker: true, createUser: user.id }).run(),
}, },
@ -163,6 +172,7 @@ export const COMMANDS: ICommand[] = [
isBlock: true, isBlock: true,
icon: <IconFlow />, icon: <IconFlow />,
label: '流程图', label: '流程图',
pinyin: 'liuchengtu',
action: (editor, user) => { action: (editor, user) => {
editor.chain().focus().setFlow({ width: '100%', defaultShowPicker: true, createUser: user.id }).run(); editor.chain().focus().setFlow({ width: '100%', defaultShowPicker: true, createUser: user.id }).run();
}, },
@ -171,6 +181,7 @@ export const COMMANDS: ICommand[] = [
isBlock: true, isBlock: true,
icon: <IconMind />, icon: <IconMind />,
label: '思维导图', label: '思维导图',
pinyin: 'siweidaotu',
action: (editor, user) => { action: (editor, user) => {
editor.chain().focus().setMind({ width: '100%', defaultShowPicker: true, createUser: user.id }).run(); editor.chain().focus().setMind({ width: '100%', defaultShowPicker: true, createUser: user.id }).run();
}, },
@ -179,6 +190,7 @@ export const COMMANDS: ICommand[] = [
isBlock: true, isBlock: true,
icon: <IconMind />, icon: <IconMind />,
label: '绘图', label: '绘图',
pinyin: 'huitu',
action: (editor, user) => { action: (editor, user) => {
editor.chain().focus().setExcalidraw({ width: '100%', defaultShowPicker: true, createUser: user.id }).run(); editor.chain().focus().setExcalidraw({ width: '100%', defaultShowPicker: true, createUser: user.id }).run();
}, },
@ -187,17 +199,20 @@ export const COMMANDS: ICommand[] = [
isBlock: true, isBlock: true,
icon: <IconMath />, icon: <IconMath />,
label: '数学公式', label: '数学公式',
pinyin: 'shuxuegongshi',
action: (editor, user) => editor.chain().focus().setKatex({ defaultShowPicker: true, createUser: user.id }).run(), action: (editor, user) => editor.chain().focus().setKatex({ defaultShowPicker: true, createUser: user.id }).run(),
}, },
{ {
icon: <IconStatus />, icon: <IconStatus />,
label: '状态', label: '状态',
pinyin: 'zhuangtai',
action: (editor, user) => editor.chain().focus().setStatus({ defaultShowPicker: true, createUser: user.id }).run(), action: (editor, user) => editor.chain().focus().setStatus({ defaultShowPicker: true, createUser: user.id }).run(),
}, },
{ {
isBlock: true, isBlock: true,
icon: <IconCallout />, icon: <IconCallout />,
label: '高亮块', label: '高亮块',
pinyin: 'gaoliangkuai',
action: (editor) => editor.chain().focus().setCallout().run(), action: (editor) => editor.chain().focus().setCallout().run(),
}, },
{ {
@ -207,6 +222,7 @@ export const COMMANDS: ICommand[] = [
isBlock: true, isBlock: true,
icon: <IconDocument />, icon: <IconDocument />,
label: '文档', label: '文档',
pinyin: 'wendang',
action: (editor, user) => action: (editor, user) =>
editor.chain().focus().setDocumentReference({ defaultShowPicker: true, createUser: user.id }).run(), editor.chain().focus().setDocumentReference({ defaultShowPicker: true, createUser: user.id }).run(),
}, },
@ -214,6 +230,7 @@ export const COMMANDS: ICommand[] = [
isBlock: true, isBlock: true,
icon: <IconDocument />, icon: <IconDocument />,
label: '子文档', label: '子文档',
pinyin: 'ziwendang',
action: (editor) => editor.chain().focus().setDocumentChildren().run(), action: (editor) => editor.chain().focus().setDocumentChildren().run(),
}, },
]; ];
@ -223,12 +240,14 @@ export const QUICK_INSERT_COMMANDS = [
{ {
icon: <IconTable />, icon: <IconTable />,
label: '表格', label: '表格',
pinyin: 'biaoge',
action: (editor: Editor) => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(), action: (editor: Editor) => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
}, },
{ {
isBlock: true, isBlock: true,
icon: <IconLayout />, icon: <IconLayout />,
label: '布局', label: '布局',
pinyin: 'buju',
action: (editor) => editor.chain().focus().insertColumns({ cols: 2 }).run(), action: (editor) => editor.chain().focus().insertColumns({ cols: 2 }).run(),
}, },
...COMMANDS.slice(4), ...COMMANDS.slice(4),

View File

@ -47,9 +47,9 @@ import { OrderedList } from 'tiptap/core/extensions/ordered-list';
import { Paragraph } from 'tiptap/core/extensions/paragraph'; import { Paragraph } from 'tiptap/core/extensions/paragraph';
import { Paste } from 'tiptap/core/extensions/paste'; import { Paste } from 'tiptap/core/extensions/paste';
import { Placeholder } from 'tiptap/core/extensions/placeholder'; import { Placeholder } from 'tiptap/core/extensions/placeholder';
import { QuickInsert } from 'tiptap/core/extensions/quick-insert';
import { Scroll2Cursor } from 'tiptap/core/extensions/scroll-to-cursor'; import { Scroll2Cursor } from 'tiptap/core/extensions/scroll-to-cursor';
import { SearchNReplace } from 'tiptap/core/extensions/search'; import { SearchNReplace } from 'tiptap/core/extensions/search';
import { EnSlashExtension, ZhSlashExtension } from 'tiptap/core/extensions/slash';
import { Status } from 'tiptap/core/extensions/status'; import { Status } from 'tiptap/core/extensions/status';
import { Strike } from 'tiptap/core/extensions/strike'; import { Strike } from 'tiptap/core/extensions/strike';
import { Subscript } from 'tiptap/core/extensions/subscript'; import { Subscript } from 'tiptap/core/extensions/subscript';
@ -176,7 +176,8 @@ export const CollaborationKit = [
Mind.configure({ Mind.configure({
getCreateUserId, getCreateUserId,
}), }),
QuickInsert, EnSlashExtension,
ZhSlashExtension,
SearchNReplace, SearchNReplace,
Status, Status,
TableOfContents.configure({ TableOfContents.configure({