diff --git a/packages/client/src/tiptap/core/extensions/quick-insert.ts b/packages/client/src/tiptap/core/extensions/quick-insert.ts
index 03832587..3abe180c 100644
--- a/packages/client/src/tiptap/core/extensions/quick-insert.ts
+++ b/packages/client/src/tiptap/core/extensions/quick-insert.ts
@@ -6,6 +6,7 @@ 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';
+import { createNewParagraphAbove } from 'tiptap/prose-utils';
export const QuickInsertPluginKey = new PluginKey('quickInsert');
@@ -22,9 +23,21 @@ export const QuickInsert = Node.create({
pluginKey: QuickInsertPluginKey,
command: ({ editor, range, props }) => {
const { state, dispatch } = editor.view;
- const $from = state.selection.$from;
- const tr = state.tr.deleteRange($from.start(), $from.pos);
+ 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);
+
+ if (props.isBlock) {
+ createNewParagraphAbove(state, dispatch);
+ }
+
props?.action(editor, props.user);
insertMenuLRUCache.put(props.label);
editor?.view?.focus();
@@ -51,7 +64,7 @@ export const QuickInsert = Node.create({
const restCommands = QUICK_INSERT_COMMANDS.filter((command) => {
return !('title' in command) && !('custom' in command) && !recentUsed.includes(command.label);
});
- return [...transformToCommands(recentUsed), ...restCommands].filter(
+ return [...transformToCommands(QUICK_INSERT_COMMANDS, recentUsed), ...restCommands].filter(
(command) => !('title' in command) && command.label && command.label.startsWith(query)
);
},
diff --git a/packages/client/src/tiptap/core/menus/commands.tsx b/packages/client/src/tiptap/core/menus/commands.tsx
index 0fd74d59..e1c2be90 100644
--- a/packages/client/src/tiptap/core/menus/commands.tsx
+++ b/packages/client/src/tiptap/core/menus/commands.tsx
@@ -26,6 +26,7 @@ export type ITitle = {
};
type IBaseCommand = {
+ isBlock?: boolean;
icon: React.ReactNode;
label: string;
user?: IUser;
@@ -55,6 +56,7 @@ export const COMMANDS: ICommand[] = [
action: (editor) => editor.chain().focus().setTableOfContents().run(),
},
{
+ isBlock: true,
icon: ,
label: '表格',
custom: (editor, runCommand) => (
@@ -84,26 +86,31 @@ export const COMMANDS: ICommand[] = [
),
},
{
+ isBlock: true,
icon: ,
label: '代码块',
action: (editor) => editor.chain().focus().toggleCodeBlock().run(),
},
{
+ isBlock: true,
icon: ,
label: '图片',
action: (editor) => editor.chain().focus().setEmptyImage({ width: '100%' }).run(),
},
{
+ isBlock: true,
icon: ,
label: '附件',
action: (editor) => editor.chain().focus().setAttachment().run(),
},
{
+ isBlock: true,
icon: ,
label: '倒计时',
action: (editor) => createCountdown(editor),
},
{
+ isBlock: true,
icon: ,
label: '外链',
action: (editor, user) =>
@@ -113,6 +120,7 @@ export const COMMANDS: ICommand[] = [
title: '卡片',
},
{
+ isBlock: true,
icon: ,
label: '流程图',
action: (editor, user) => {
@@ -120,6 +128,7 @@ export const COMMANDS: ICommand[] = [
},
},
{
+ isBlock: true,
icon: ,
label: '思维导图',
action: (editor, user) => {
@@ -127,6 +136,7 @@ export const COMMANDS: ICommand[] = [
},
},
{
+ isBlock: true,
icon: ,
label: '数学公式',
action: (editor, user) => editor.chain().focus().setKatex({ defaultShowPicker: true, createUser: user.id }).run(),
@@ -137,6 +147,7 @@ export const COMMANDS: ICommand[] = [
action: (editor, user) => editor.chain().focus().setStatus({ defaultShowPicker: true, createUser: user.id }).run(),
},
{
+ isBlock: true,
icon: ,
label: '高亮块',
action: (editor) => editor.chain().focus().setCallout().run(),
@@ -145,12 +156,14 @@ export const COMMANDS: ICommand[] = [
title: '内容引用',
},
{
+ isBlock: true,
icon: ,
label: '文档',
action: (editor, user) =>
editor.chain().focus().setDocumentReference({ defaultShowPicker: true, createUser: user.id }).run(),
},
{
+ isBlock: true,
icon: ,
label: '子文档',
action: (editor) => editor.chain().focus().setDocumentChildren().run(),
@@ -167,10 +180,10 @@ export const QUICK_INSERT_COMMANDS = [
...COMMANDS.slice(3),
];
-export const transformToCommands = (data: string[]) => {
+export const transformToCommands = (commands, data: string[]) => {
return data
.map((label) => {
- return COMMANDS.find((command) => {
+ return commands.find((command) => {
if ('title' in command) {
return false;
}
diff --git a/packages/client/src/tiptap/core/menus/insert/index.tsx b/packages/client/src/tiptap/core/menus/insert/index.tsx
index 3dacb391..acaa4cd1 100644
--- a/packages/client/src/tiptap/core/menus/insert/index.tsx
+++ b/packages/client/src/tiptap/core/menus/insert/index.tsx
@@ -7,6 +7,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Editor } from 'tiptap/core';
import { Title } from 'tiptap/core/extensions/title';
import { useActive } from 'tiptap/core/hooks/use-active';
+import { createNewParagraphBelow } from 'tiptap/prose-utils';
import { COMMANDS, insertMenuLRUCache, transformToCommands } from '../commands';
@@ -25,7 +26,12 @@ export const Insert: React.FC<{ editor: Editor }> = ({ editor }) => {
(command) => {
return () => {
insertMenuLRUCache.put(command.label);
- setRecentUsed(transformToCommands(insertMenuLRUCache.get() as string[]));
+ setRecentUsed(transformToCommands(COMMANDS, insertMenuLRUCache.get() as string[]));
+
+ if (command.isBlock) {
+ createNewParagraphBelow(editor.view.state, editor.view.dispatch);
+ }
+
command.action(editor, user);
toggleVisible(false);
};
@@ -36,7 +42,7 @@ export const Insert: React.FC<{ editor: Editor }> = ({ editor }) => {
useEffect(() => {
if (!visible) return;
insertMenuLRUCache.syncFromStorage();
- setRecentUsed(transformToCommands(insertMenuLRUCache.get() as string[]));
+ setRecentUsed(transformToCommands(COMMANDS, insertMenuLRUCache.get() as string[]));
}, [visible]);
return (
diff --git a/packages/client/src/tiptap/prose-utils/create-node.ts b/packages/client/src/tiptap/prose-utils/create-node.ts
new file mode 100644
index 00000000..77e648d5
--- /dev/null
+++ b/packages/client/src/tiptap/prose-utils/create-node.ts
@@ -0,0 +1,237 @@
+import { Fragment, MarkType, Node as PMNode, NodeType, ResolvedPos, Schema } from 'prosemirror-model';
+import { EditorState, NodeSelection, Selection, TextSelection, Transaction } from 'prosemirror-state';
+
+export function atTheEndOfDoc(state: EditorState): boolean {
+ const { selection, doc } = state;
+ return doc.nodeSize - selection.$to.pos - 2 === selection.$to.depth;
+}
+
+export function atTheBeginningOfDoc(state: EditorState): boolean {
+ const { selection } = state;
+ return selection.$from.pos === selection.$from.depth;
+}
+
+export function atTheEndOfBlock(state: EditorState): boolean {
+ const { selection } = state;
+ const { $to } = selection;
+ if (selection instanceof NodeSelection && selection.node.isBlock) {
+ return true;
+ }
+ return endPositionOfParent($to) === $to.pos + 1;
+}
+
+export function endPositionOfParent(resolvedPos: ResolvedPos): number {
+ return resolvedPos.end(resolvedPos.depth) + 1;
+}
+
+export function canMoveUp(state: EditorState): boolean {
+ const { selection, doc } = state;
+
+ /**
+ * If there's a media element on the selection,
+ * add text blocks with arrow navigation.
+ * Also, the selection could be media | mediaGroup.
+ */
+ if (selection instanceof NodeSelection) {
+ if (selection.node.type.name === 'media') {
+ /** Weird way of checking if the previous element is a paragraph */
+ const mediaAncestorNode = doc.nodeAt(selection.anchor - 3);
+ return !!(mediaAncestorNode && mediaAncestorNode.type.name === 'paragraph');
+ } else if (selection.node.type.name === 'mediaGroup') {
+ const mediaGroupAncestorNode = selection.$anchor.nodeBefore;
+ return !!(mediaGroupAncestorNode && mediaGroupAncestorNode.type.name === 'paragraph');
+ }
+ }
+
+ if (selection instanceof TextSelection) {
+ if (!selection.empty) {
+ return true;
+ }
+ }
+
+ return !atTheBeginningOfDoc(state);
+}
+
+export function canMoveDown(state: EditorState): boolean {
+ const { selection, doc } = state;
+
+ /**
+ * If there's a media element on the selection,
+ * add text blocks with arrow navigation.
+ * Also, the selection could be media | mediaGroup.
+ */
+ if (selection instanceof NodeSelection) {
+ if (selection.node.type.name === 'media') {
+ const nodeAfter = doc.nodeAt(selection.$head.after());
+ return !!(nodeAfter && nodeAfter.type.name === 'paragraph');
+ } else if (selection.node.type.name === 'mediaGroup') {
+ return !(selection.$head.parentOffset === selection.$anchor.parent.content.size);
+ }
+ }
+ if (selection instanceof TextSelection) {
+ if (!selection.empty) {
+ return true;
+ }
+ }
+
+ return !atTheEndOfDoc(state);
+}
+
+export function preventDefault() {
+ return function () {
+ return true;
+ };
+}
+
+export function insertNewLine() {
+ return function (state, dispatch) {
+ const { $from } = state.selection;
+ const parent = $from.parent;
+ const { hardBreak } = state.schema.nodes;
+
+ if (hardBreak) {
+ const hardBreakNode = hardBreak.createChecked();
+
+ if (parent && parent.type.validContent(Fragment.from(hardBreakNode))) {
+ if (dispatch) {
+ dispatch(state.tr.replaceSelectionWith(hardBreakNode, false));
+ }
+ return true;
+ }
+ }
+
+ if (state.selection instanceof TextSelection) {
+ if (dispatch) {
+ dispatch(state.tr.insertText('\n'));
+ }
+ return true;
+ }
+
+ return false;
+ };
+}
+
+export const createNewParagraphAbove = (state, dispatch) => {
+ const append = false;
+ if (!canMoveUp(state) && canCreateParagraphNear(state)) {
+ createParagraphNear(append)(state, dispatch);
+ return true;
+ }
+
+ return false;
+};
+
+export const createNewParagraphBelow = (state, dispatch) => {
+ const append = true;
+ if (!canMoveDown(state) && canCreateParagraphNear(state)) {
+ createParagraphNear(append)(state, dispatch);
+ return true;
+ }
+
+ return false;
+};
+
+function canCreateParagraphNear(state: EditorState): boolean {
+ const {
+ selection: { $from },
+ } = state;
+ const node = $from.node($from.depth);
+ const insideCodeBlock = !!node && node.type === state.schema.nodes.codeBlock;
+ const isNodeSelection = state.selection instanceof NodeSelection;
+ return $from.depth > 1 || isNodeSelection || insideCodeBlock;
+}
+
+export function createParagraphNear(append = true) {
+ return function (state, dispatch) {
+ const paragraph = state.schema.nodes.paragraph;
+
+ if (!paragraph) {
+ return false;
+ }
+
+ let insertPos;
+
+ if (state.selection instanceof TextSelection) {
+ if (topLevelNodeIsEmptyTextBlock(state)) {
+ return false;
+ }
+ insertPos = getInsertPosFromTextBlock(state, append);
+ } else {
+ insertPos = getInsertPosFromNonTextBlock(state, append);
+ }
+
+ const tr = state.tr.insert(insertPos, paragraph.createAndFill() as PMNode);
+ tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
+
+ if (dispatch) {
+ dispatch(tr);
+ }
+
+ return true;
+ };
+}
+
+function getInsertPosFromTextBlock(state: EditorState, append: boolean): number {
+ const { $from, $to } = state.selection;
+ let pos;
+ if (!append) {
+ pos = $from.start(0);
+ } else {
+ pos = $to.end(0);
+ }
+ return pos;
+}
+
+function getInsertPosFromNonTextBlock(state: EditorState, append: boolean): number {
+ const { $from, $to } = state.selection;
+ const nodeAtSelection = state.selection instanceof NodeSelection && state.doc.nodeAt(state.selection.$anchor.pos);
+ const isMediaSelection = nodeAtSelection && nodeAtSelection.type.name === 'mediaGroup';
+
+ let pos;
+ if (!append) {
+ // The start position is different with text block because it starts from 0
+ pos = $from.start($from.depth);
+ // The depth is different with text block because it starts from 0
+ pos = $from.depth > 0 && !isMediaSelection ? pos - 1 : pos;
+ } else {
+ pos = $to.end($to.depth);
+ pos = $to.depth > 0 && !isMediaSelection ? pos + 1 : pos;
+ }
+ return pos;
+}
+
+function topLevelNodeIsEmptyTextBlock(state: EditorState): boolean {
+ const topLevelNode = state.selection.$from.node(1);
+ return topLevelNode.isTextblock && topLevelNode.type !== state.schema.nodes.codeBlock && topLevelNode.nodeSize === 2;
+}
+
+export function addParagraphAtEnd(tr: Transaction) {
+ const {
+ doc: {
+ type: {
+ schema: {
+ nodes: { paragraph },
+ },
+ },
+ },
+ doc,
+ } = tr;
+ if (doc.lastChild && !(doc.lastChild.type === paragraph && doc.lastChild.content.size === 0)) {
+ if (paragraph) {
+ tr.insert(doc.content.size, paragraph.createAndFill() as PMNode);
+ }
+ }
+ tr.setSelection(TextSelection.create(tr.doc, tr.doc.content.size - 1));
+ tr.scrollIntoView();
+}
+
+export function createParagraphAtEnd() {
+ return function (state, dispatch) {
+ const { tr } = state;
+ addParagraphAtEnd(tr);
+ if (dispatch) {
+ dispatch(tr);
+ }
+ return true;
+ };
+}
diff --git a/packages/client/src/tiptap/prose-utils/index.ts b/packages/client/src/tiptap/prose-utils/index.ts
index aa820780..c2e676e6 100644
--- a/packages/client/src/tiptap/prose-utils/index.ts
+++ b/packages/client/src/tiptap/prose-utils/index.ts
@@ -3,6 +3,7 @@ export * from './clamp';
export * from './code';
export * from './color';
export * from './copy-node';
+export * from './create-node';
export * from './debug';
export * from './delete-node';
export * from './dom';