tiptap: use shared commands

This commit is contained in:
fantasticit 2022-06-05 00:01:39 +08:00
parent bfddd4cc6e
commit a589db9978
5 changed files with 221 additions and 507 deletions

View File

@ -4,7 +4,7 @@ import Suggestion from '@tiptap/suggestion';
import { Plugin, PluginKey } from 'prosemirror-state'; import { Plugin, PluginKey } from 'prosemirror-state';
import tippy from 'tippy.js'; import tippy from 'tippy.js';
import { EXTENSION_PRIORITY_HIGHEST } from 'tiptap/core/constants'; import { EXTENSION_PRIORITY_HIGHEST } from 'tiptap/core/constants';
import { QUICK_INSERT_ITEMS } from 'tiptap/core/menus/quick-insert'; import { insertMenuLRUCache, QUICK_INSERT_COMMANDS, transformToCommands } from 'tiptap/core/menus/commands';
import { MenuList } from 'tiptap/core/wrappers/menu-list'; import { MenuList } from 'tiptap/core/wrappers/menu-list';
export const QuickInsertPluginKey = new PluginKey('quickInsert'); export const QuickInsertPluginKey = new PluginKey('quickInsert');
@ -25,7 +25,8 @@ export const QuickInsert = Node.create({
const $from = state.selection.$from; const $from = state.selection.$from;
const tr = state.tr.deleteRange($from.start(), $from.pos); const tr = state.tr.deleteRange($from.start(), $from.pos);
dispatch(tr); dispatch(tr);
props?.command(editor, props.user); props?.action(editor, props.user);
insertMenuLRUCache.put(props.label);
editor?.view?.focus(); editor?.view?.focus();
}, },
}, },
@ -33,57 +34,26 @@ export const QuickInsert = Node.create({
}, },
addProseMirrorPlugins() { addProseMirrorPlugins() {
const { editor } = this;
return [ return [
Suggestion({ Suggestion({
editor: this.editor, editor: this.editor,
...this.options.suggestion, ...this.options.suggestion,
}), }),
new Plugin({ new Plugin({
key: new PluginKey('evokeMenuPlaceholder'), key: new PluginKey('evokeMenuPlaceholder'),
props: {
// decorations: (state) => {
// if (!editor.isEditable) return;
// const parent = findParentNode((node) => node.type.name === 'paragraph')(state.selection);
// if (!parent) {
// return;
// }
// const decorations: Decoration[] = [];
// const isEmpty = parent && parent.node.content.size === 0;
// const isSlash = parent && parent.node.textContent === '/';
// const isTopLevel = state.selection.$from.depth === 1;
// const hasOtherChildren = parent && parent.node.content.childCount > 1;
// if (isTopLevel) {
// if (isEmpty) {
// decorations.push(
// Decoration.node(parent.pos, parent.pos + parent.node.nodeSize, {
// 'class': 'is-empty',
// 'data-placeholder': '输入 / 唤起更多',
// })
// );
// }
// if (isSlash && !hasOtherChildren) {
// decorations.push(
// Decoration.node(parent.pos, parent.pos + parent.node.nodeSize, {
// 'class': 'is-empty',
// 'data-placeholder': ` 继续输入进行过滤`,
// })
// );
// }
// return DecorationSet.create(state.doc, decorations);
// }
// return null;
// },
},
}), }),
]; ];
}, },
}).configure({ }).configure({
suggestion: { suggestion: {
items: ({ query }) => { items: ({ query }) => {
return QUICK_INSERT_ITEMS.filter((command) => command.key.startsWith(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(recentUsed), ...restCommands].filter(
(command) => !('title' in command) && command.label && command.label.startsWith(query)
);
}, },
render: () => { render: () => {
let component; let component;
@ -130,9 +100,8 @@ export const QuickInsert = Node.create({
return component.ref?.onKeyDown(props); return component.ref?.onKeyDown(props);
}, },
onExit(props) { onExit() {
if (!isEditable) return; if (!isEditable) return;
popup[0].destroy(); popup[0].destroy();
component.destroy(); component.destroy();
}, },

View File

@ -0,0 +1,183 @@
import { Dropdown, Popover } from '@douyinfe/semi-ui';
import { IUser } from '@think/domains';
import { GridSelect } from 'components/grid-select';
import {
IconAttachment,
IconCallout,
IconCodeBlock,
IconCountdown,
IconDocument,
IconFlow,
IconImage,
IconLink,
IconMath,
IconMind,
IconStatus,
IconTable,
IconTableOfContents,
} from 'components/icons';
import { createKeysLocalStorageLRUCache } from 'helpers/lru-cache';
import { Editor } from 'tiptap/core';
import { createCountdown } from './countdown/service';
export type ITitle = {
title: string;
};
type IBaseCommand = {
icon: React.ReactNode;
label: string;
user?: IUser;
};
type IAction = (editor: Editor, user?: IUser) => void;
export type ILabelRenderCommand = IBaseCommand & {
action: IAction;
};
type ICustomRenderCommand = IBaseCommand & {
custom: (editor: Editor, runCommand: (arg: { label: string; action: IAction }) => any) => React.ReactNode;
};
export type ICommand = ITitle | ILabelRenderCommand | ICustomRenderCommand;
export const insertMenuLRUCache = createKeysLocalStorageLRUCache('TIPTAP_INSERT_MENU', 3);
export const COMMANDS: ICommand[] = [
{
title: '通用',
},
{
icon: <IconTableOfContents />,
label: '目录',
action: (editor) => editor.chain().focus().setTableOfContents().run(),
},
{
icon: <IconTable />,
label: '表格',
custom: (editor, runCommand) => (
<Popover
key="table"
showArrow
position="rightTop"
zIndex={10000}
content={
<div style={{ padding: 0 }}>
<GridSelect
onSelect={({ rows, cols }) => {
return runCommand({
label: '表格',
action: () => editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run(),
})();
}}
/>
</div>
}
>
<Dropdown.Item>
<IconTable />
</Dropdown.Item>
</Popover>
),
},
{
icon: <IconCodeBlock />,
label: '代码块',
action: (editor) => editor.chain().focus().toggleCodeBlock().run(),
},
{
icon: <IconImage />,
label: '图片',
// @ts-ignore
action: (editor) => editor.chain().focus().setEmptyImage({ width: '100%' }).run(),
},
{
icon: <IconAttachment />,
label: '附件',
action: (editor) => editor.chain().focus().setAttachment().run(),
},
{
icon: <IconCountdown />,
label: '倒计时',
action: (editor) => createCountdown(editor),
},
{
icon: <IconLink />,
label: '外链',
action: (editor) => editor.chain().focus().setIframe({ url: '' }).run(),
},
{
title: '卡片',
},
{
icon: <IconFlow />,
label: '流程图',
action: (editor) => {
editor.chain().focus().setFlow({ width: '100%' }).run();
},
},
{
icon: <IconMind />,
label: '思维导图',
action: (editor) => {
// @ts-ignore
editor.chain().focus().setMind({ width: '100%' }).run();
},
},
{
icon: <IconMath />,
label: '数学公式',
action: (editor, user) => editor.chain().focus().setKatex({ defaultShowPicker: true, createUser: user.name }).run(),
},
{
icon: <IconStatus />,
label: '状态',
action: (editor, user) =>
editor.chain().focus().setStatus({ defaultShowPicker: true, createUser: user.name }).run(),
},
{
icon: <IconCallout />,
label: '高亮块',
action: (editor) => editor.chain().focus().setCallout().run(),
},
{
title: '内容引用',
},
{
icon: <IconDocument />,
label: '文档',
action: (editor) => editor.chain().focus().setDocumentReference().run(),
},
{
icon: <IconDocument />,
label: '子文档',
action: (editor) => editor.chain().focus().setDocumentChildren().run(),
},
];
export const QUICK_INSERT_COMMANDS = [
...COMMANDS.slice(0, 1),
{
icon: <IconTable />,
label: '表格',
action: (editor: Editor) => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
},
...COMMANDS.slice(3),
];
export const transformToCommands = (data: string[]) => {
return data
.map((label) => {
return COMMANDS.find((command) => {
if ('title' in command) {
return false;
}
return command.label === label;
});
})
.filter(Boolean);
};

View File

@ -1,146 +1,14 @@
import { IconPlus } from '@douyinfe/semi-icons'; import { IconPlus } from '@douyinfe/semi-icons';
import { Button, Dropdown, Popover } from '@douyinfe/semi-ui'; import { Button, Dropdown } from '@douyinfe/semi-ui';
import { GridSelect } from 'components/grid-select';
import {
IconAttachment,
IconCallout,
IconCodeBlock,
IconCountdown,
IconDocument,
IconFlow,
IconImage,
IconLink,
IconMath,
IconMind,
IconStatus,
IconTable,
IconTableOfContents,
} from 'components/icons';
import { Tooltip } from 'components/tooltip'; import { Tooltip } from 'components/tooltip';
import { useUser } from 'data/user'; import { useUser } from 'data/user';
import { createKeysLocalStorageLRUCache } from 'helpers/lru-cache';
import { useToggle } from 'hooks/use-toggle'; import { useToggle } from 'hooks/use-toggle';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Editor } from 'tiptap/core'; import { Editor } from 'tiptap/core';
import { Title } from 'tiptap/core/extensions/title'; import { Title } from 'tiptap/core/extensions/title';
import { useActive } from 'tiptap/core/hooks/use-active'; import { useActive } from 'tiptap/core/hooks/use-active';
import { createCountdown } from '../countdown/service'; import { COMMANDS, insertMenuLRUCache, transformToCommands } from '../commands';
const insertMenuLRUCache = createKeysLocalStorageLRUCache('TIPTAP_INSERT_MENU', 3);
const COMMANDS = [
{
title: '通用',
},
{
icon: <IconTableOfContents />,
label: '目录',
action: (editor) => editor.chain().focus().setTableOfContents().run(),
},
{
icon: <IconTable />,
label: '表格',
custom: (editor, runCommand) => (
<Popover
key="table"
showArrow
position="rightTop"
zIndex={10000}
content={
<div style={{ padding: 0 }}>
<GridSelect
onSelect={({ rows, cols }) => {
return runCommand({
label: '表格',
action: () => editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run(),
})();
}}
/>
</div>
}
>
<Dropdown.Item>
<IconTable />
</Dropdown.Item>
</Popover>
),
},
{
icon: <IconCodeBlock />,
label: '代码块',
action: (editor) => editor.chain().focus().toggleCodeBlock().run(),
},
{
icon: <IconImage />,
label: '图片',
action: (editor) => {
editor.chain().focus().setEmptyImage({ width: '100%' }).run();
},
},
{
icon: <IconAttachment />,
label: '附件',
action: (editor) => editor.chain().focus().setAttachment().run(),
},
{
icon: <IconCountdown />,
label: '倒计时',
action: (editor) => createCountdown(editor),
},
{
icon: <IconLink />,
label: '外链',
action: (editor) => editor.chain().focus().setIframe({ url: '' }).run(),
},
{
title: '卡片',
},
{
icon: <IconFlow />,
label: '流程图',
action: (editor) => {
editor.chain().focus().setFlow({ width: '100%' }).run();
},
},
{
icon: <IconMind />,
label: '思维导图',
action: (editor) => {
editor.chain().focus().setMind({ width: '100%' }).run();
},
},
{
icon: <IconMath />,
label: '数学公式',
action: (editor, user) => editor.chain().focus().setKatex({ defaultShowPicker: true, createUser: user.name }).run(),
},
{
icon: <IconStatus />,
label: '状态',
action: (editor, user) =>
editor.chain().focus().setStatus({ defaultShowPicker: true, createUser: user.name }).run(),
},
{
icon: <IconCallout />,
label: '高亮块',
action: (editor) => editor.chain().focus().setCallout().run(),
},
{
title: '内容引用',
},
{
icon: <IconDocument />,
label: '文档',
action: (editor) => editor.chain().focus().setDocumentReference().run(),
},
{
icon: <IconDocument />,
label: '子文档',
action: (editor) => editor.chain().focus().setDocumentChildren().run(),
},
];
export const Insert: React.FC<{ editor: Editor }> = ({ editor }) => { export const Insert: React.FC<{ editor: Editor }> = ({ editor }) => {
const { user } = useUser(); const { user } = useUser();
@ -153,14 +21,6 @@ export const Insert: React.FC<{ editor: Editor }> = ({ editor }) => {
[recentUsed] [recentUsed]
); );
const transformToCommands = useCallback((data: string[]) => {
return data
.map((label) => {
return COMMANDS.find((command) => command.label && command.label === label);
})
.filter(Boolean);
}, []);
const runCommand = useCallback( const runCommand = useCallback(
(command) => { (command) => {
return () => { return () => {
@ -170,14 +30,14 @@ export const Insert: React.FC<{ editor: Editor }> = ({ editor }) => {
toggleVisible(false); toggleVisible(false);
}; };
}, },
[editor, toggleVisible, transformToCommands, user] [editor, toggleVisible, user]
); );
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return;
insertMenuLRUCache.syncFromStorage(); insertMenuLRUCache.syncFromStorage();
setRecentUsed(transformToCommands(insertMenuLRUCache.get() as string[])); setRecentUsed(transformToCommands(insertMenuLRUCache.get() as string[]));
}, [visible, transformToCommands]); }, [visible]);
return ( return (
<Dropdown <Dropdown

View File

@ -1,305 +0,0 @@
import { IconList, IconOrderedList } from '@douyinfe/semi-icons';
import { Space } from '@douyinfe/semi-ui';
import {
IconAttachment,
IconCallout,
IconCodeBlock,
IconCountdown,
IconDocument,
IconFlow,
IconHeading1,
IconHeading2,
IconHeading3,
IconHorizontalRule,
IconImage,
IconLink,
IconMath,
IconMind,
IconQuote,
IconStatus,
IconTable,
IconTableOfContents,
IconTask,
} from 'components/icons';
import { Editor } from 'tiptap/core';
import { createCountdown } from './countdown/service';
import { createOrToggleLink } from './link/service';
export const QUICK_INSERT_ITEMS = [
{
key: '标题1',
label: (
<Space>
<IconHeading1 />
1
</Space>
),
command: (editor: Editor) => editor.chain().focus().toggleHeading({ level: 1 }).run(),
},
{
key: '标题2',
label: (
<Space>
<IconHeading2 />
2
</Space>
),
command: (editor: Editor) => editor.chain().focus().toggleHeading({ level: 2 }).run(),
},
{
key: '标题1',
label: (
<Space>
<IconHeading3 />
3
</Space>
),
command: (editor: Editor) => editor.chain().focus().toggleHeading({ level: 3 }).run(),
},
{
key: '无序列表',
label: (
<Space>
<IconList />
</Space>
),
command: (editor: Editor) => editor.chain().focus().toggleBulletList().run(),
},
{
key: '有序列表',
label: (
<Space>
<IconOrderedList />
</Space>
),
command: (editor: Editor) => editor.chain().focus().toggleOrderedList().run(),
},
{
key: '任务列表',
label: (
<Space>
<IconTask />
</Space>
),
command: (editor: Editor) => editor.chain().focus().toggleTaskList().run(),
},
{
key: '链接',
label: (
<Space>
<IconLink />
</Space>
),
command: (editor: Editor) => createOrToggleLink(editor),
},
{
key: '引用',
label: (
<Space>
<IconQuote />
</Space>
),
command: (editor: Editor) => editor.chain().focus().toggleBlockquote().run(),
},
{
key: '分割线',
label: (
<Space>
<IconHorizontalRule />
线
</Space>
),
command: (editor: Editor) => editor.chain().focus().setHorizontalRule().run(),
},
{
key: '目录',
label: (
<Space>
<IconTableOfContents />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setTableOfContents().run(),
},
{
key: '表格',
label: (
<Space>
<IconTable />
</Space>
),
command: (editor: Editor) => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
},
{
key: '代码块',
label: (
<Space>
<IconCodeBlock />
</Space>
),
command: (editor: Editor) => editor.chain().focus().toggleCodeBlock().run(),
},
{
key: '图片',
label: () => (
<Space>
<IconImage />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setEmptyImage().run(),
},
{
key: '附件',
label: () => (
<Space>
<IconAttachment />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setAttachment().run(),
},
{
key: '倒计时',
label: () => (
<Space>
<IconCountdown />
</Space>
),
command: (editor: Editor) => createCountdown(editor),
},
{
key: '外链',
label: (
<Space>
<IconLink />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setIframe({ url: '' }).run(),
},
{
key: '流程图',
label: (
<Space>
<IconFlow />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setFlow({ width: '100%' }).run(),
},
{
key: '思维导图',
label: (
<Space>
<IconMind />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setMind().run(),
},
{
key: '数学公式',
label: (
<Space>
<IconMath />
</Space>
),
command: (editor: Editor, user) => {
console.log('user', user);
if (!user) return;
editor
.chain()
.focus()
.setKatex({
defaultShowPicker: true,
createUser: user.name,
})
.run();
},
},
{
key: '状态',
label: (
<Space>
<IconStatus />
</Space>
),
command: (editor: Editor, user) => {
if (!user) return;
editor
.chain()
.focus()
.setStatus({
defaultShowPicker: true,
createUser: user.name,
})
.run();
},
},
{
key: '高亮块',
label: (
<Space>
<IconCallout />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setCallout().run(),
},
{
key: '文档',
label: (
<Space>
<IconDocument />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setDocumentReference().run(),
},
{
key: '子文档',
label: (
<Space>
<IconDocument />
</Space>
),
command: (editor: Editor) => editor.chain().focus().setDocumentChildren().run(),
},
];

View File

@ -1,15 +1,17 @@
import { Space } from '@douyinfe/semi-ui';
import { Editor } from '@tiptap/core'; import { Editor } from '@tiptap/core';
import cls from 'classnames'; import cls from 'classnames';
import { useUser } from 'data/user'; import { useUser } from 'data/user';
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import scrollIntoView from 'scroll-into-view-if-needed'; import scrollIntoView from 'scroll-into-view-if-needed';
import { ILabelRenderCommand } from 'tiptap/core/menus/commands';
import styles from './index.module.scss'; import styles from './index.module.scss';
interface IProps { interface IProps {
editor: Editor; editor: Editor;
items: Array<{ label: React.ReactNode | ((editor: Editor) => React.ReactNode) }>; items: ILabelRenderCommand[];
command: any; command: (command: ILabelRenderCommand) => void;
} }
export const MenuList: React.FC<IProps> = forwardRef((props, ref) => { export const MenuList: React.FC<IProps> = forwardRef((props, ref) => {
@ -18,12 +20,12 @@ export const MenuList: React.FC<IProps> = forwardRef((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index) => { const selectItem = (index) => {
const item = props.items[index]; const command = props.items[index];
if (item) { if (command) {
// @ts-ignore // 注入用户信息
item.user = user; // 注入用户信息 command.user = user;
props.command(item); props.command(command);
} }
}; };
@ -72,15 +74,20 @@ export const MenuList: React.FC<IProps> = forwardRef((props, ref) => {
<div className={styles.items}> <div className={styles.items}>
<div ref={$container}> <div ref={$container}>
{props.items.length ? ( {props.items.length ? (
props.items.map((item, index) => ( props.items.map((item, index) => {
<span return (
className={cls(styles.item, index === selectedIndex ? styles['is-selected'] : '')} <span
key={index} className={cls(styles.item, index === selectedIndex ? styles['is-selected'] : '')}
onClick={() => selectItem(index)} key={index}
> onClick={() => selectItem(index)}
{typeof item.label === 'function' ? item.label(props.editor) : item.label} >
</span> <Space>
)) {item.icon}
{item.label}
</Space>
</span>
);
})
) : ( ) : (
<div className={styles.item}></div> <div className={styles.item}></div>
)} )}