From da9c8151eea744bdc8617026d3fd6f7dc253fd48 Mon Sep 17 00:00:00 2001 From: fantasticit Date: Sun, 19 Jun 2022 23:18:54 +0800 Subject: [PATCH] tiptap: try add a new paragraph before insert block node; fix #86 --- .../tiptap/core/extensions/quick-insert.ts | 19 +- .../client/src/tiptap/core/menus/commands.tsx | 17 +- .../src/tiptap/core/menus/insert/index.tsx | 10 +- .../src/tiptap/prose-utils/create-node.ts | 237 ++++++++++++++++++ .../client/src/tiptap/prose-utils/index.ts | 1 + 5 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 packages/client/src/tiptap/prose-utils/create-node.ts 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';