tiptap: try add a new paragraph before insert block node; fix #86

This commit is contained in:
fantasticit 2022-06-19 23:18:54 +08:00
parent 15b14fd173
commit da9c8151ee
5 changed files with 277 additions and 7 deletions

View File

@ -6,6 +6,7 @@ import tippy from 'tippy.js';
import { EXTENSION_PRIORITY_HIGHEST } from 'tiptap/core/constants'; import { EXTENSION_PRIORITY_HIGHEST } from 'tiptap/core/constants';
import { insertMenuLRUCache, QUICK_INSERT_COMMANDS, transformToCommands } from 'tiptap/core/menus/commands'; 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';
import { createNewParagraphAbove } from 'tiptap/prose-utils';
export const QuickInsertPluginKey = new PluginKey('quickInsert'); export const QuickInsertPluginKey = new PluginKey('quickInsert');
@ -22,9 +23,21 @@ export const QuickInsert = Node.create({
pluginKey: QuickInsertPluginKey, pluginKey: QuickInsertPluginKey,
command: ({ editor, range, props }) => { command: ({ editor, range, props }) => {
const { state, dispatch } = editor.view; const { state, dispatch } = editor.view;
const $from = state.selection.$from; const { $head, $from, $to } = state.selection;
const tr = state.tr.deleteRange($from.start(), $from.pos);
// 删除快捷指令
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); dispatch(tr);
if (props.isBlock) {
createNewParagraphAbove(state, dispatch);
}
props?.action(editor, props.user); props?.action(editor, props.user);
insertMenuLRUCache.put(props.label); insertMenuLRUCache.put(props.label);
editor?.view?.focus(); editor?.view?.focus();
@ -51,7 +64,7 @@ export const QuickInsert = Node.create({
const restCommands = QUICK_INSERT_COMMANDS.filter((command) => { const restCommands = QUICK_INSERT_COMMANDS.filter((command) => {
return !('title' in command) && !('custom' in command) && !recentUsed.includes(command.label); 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) (command) => !('title' in command) && command.label && command.label.startsWith(query)
); );
}, },

View File

@ -26,6 +26,7 @@ export type ITitle = {
}; };
type IBaseCommand = { type IBaseCommand = {
isBlock?: boolean;
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
user?: IUser; user?: IUser;
@ -55,6 +56,7 @@ export const COMMANDS: ICommand[] = [
action: (editor) => editor.chain().focus().setTableOfContents().run(), action: (editor) => editor.chain().focus().setTableOfContents().run(),
}, },
{ {
isBlock: true,
icon: <IconTable />, icon: <IconTable />,
label: '表格', label: '表格',
custom: (editor, runCommand) => ( custom: (editor, runCommand) => (
@ -84,26 +86,31 @@ export const COMMANDS: ICommand[] = [
), ),
}, },
{ {
isBlock: true,
icon: <IconCodeBlock />, icon: <IconCodeBlock />,
label: '代码块', label: '代码块',
action: (editor) => editor.chain().focus().toggleCodeBlock().run(), action: (editor) => editor.chain().focus().toggleCodeBlock().run(),
}, },
{ {
isBlock: true,
icon: <IconImage />, icon: <IconImage />,
label: '图片', label: '图片',
action: (editor) => editor.chain().focus().setEmptyImage({ width: '100%' }).run(), action: (editor) => editor.chain().focus().setEmptyImage({ width: '100%' }).run(),
}, },
{ {
isBlock: true,
icon: <IconAttachment />, icon: <IconAttachment />,
label: '附件', label: '附件',
action: (editor) => editor.chain().focus().setAttachment().run(), action: (editor) => editor.chain().focus().setAttachment().run(),
}, },
{ {
isBlock: true,
icon: <IconCountdown />, icon: <IconCountdown />,
label: '倒计时', label: '倒计时',
action: (editor) => createCountdown(editor), action: (editor) => createCountdown(editor),
}, },
{ {
isBlock: true,
icon: <IconLink />, icon: <IconLink />,
label: '外链', label: '外链',
action: (editor, user) => action: (editor, user) =>
@ -113,6 +120,7 @@ export const COMMANDS: ICommand[] = [
title: '卡片', title: '卡片',
}, },
{ {
isBlock: true,
icon: <IconFlow />, icon: <IconFlow />,
label: '流程图', label: '流程图',
action: (editor, user) => { action: (editor, user) => {
@ -120,6 +128,7 @@ export const COMMANDS: ICommand[] = [
}, },
}, },
{ {
isBlock: true,
icon: <IconMind />, icon: <IconMind />,
label: '思维导图', label: '思维导图',
action: (editor, user) => { action: (editor, user) => {
@ -127,6 +136,7 @@ export const COMMANDS: ICommand[] = [
}, },
}, },
{ {
isBlock: true,
icon: <IconMath />, icon: <IconMath />,
label: '数学公式', label: '数学公式',
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(),
@ -137,6 +147,7 @@ export const COMMANDS: ICommand[] = [
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,
icon: <IconCallout />, icon: <IconCallout />,
label: '高亮块', label: '高亮块',
action: (editor) => editor.chain().focus().setCallout().run(), action: (editor) => editor.chain().focus().setCallout().run(),
@ -145,12 +156,14 @@ export const COMMANDS: ICommand[] = [
title: '内容引用', title: '内容引用',
}, },
{ {
isBlock: true,
icon: <IconDocument />, icon: <IconDocument />,
label: '文档', label: '文档',
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(),
}, },
{ {
isBlock: true,
icon: <IconDocument />, icon: <IconDocument />,
label: '子文档', label: '子文档',
action: (editor) => editor.chain().focus().setDocumentChildren().run(), action: (editor) => editor.chain().focus().setDocumentChildren().run(),
@ -167,10 +180,10 @@ export const QUICK_INSERT_COMMANDS = [
...COMMANDS.slice(3), ...COMMANDS.slice(3),
]; ];
export const transformToCommands = (data: string[]) => { export const transformToCommands = (commands, data: string[]) => {
return data return data
.map((label) => { .map((label) => {
return COMMANDS.find((command) => { return commands.find((command) => {
if ('title' in command) { if ('title' in command) {
return false; return false;
} }

View File

@ -7,6 +7,7 @@ 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 { createNewParagraphBelow } from 'tiptap/prose-utils';
import { COMMANDS, insertMenuLRUCache, transformToCommands } from '../commands'; import { COMMANDS, insertMenuLRUCache, transformToCommands } from '../commands';
@ -25,7 +26,12 @@ export const Insert: React.FC<{ editor: Editor }> = ({ editor }) => {
(command) => { (command) => {
return () => { return () => {
insertMenuLRUCache.put(command.label); 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); command.action(editor, user);
toggleVisible(false); toggleVisible(false);
}; };
@ -36,7 +42,7 @@ export const Insert: React.FC<{ editor: Editor }> = ({ editor }) => {
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return;
insertMenuLRUCache.syncFromStorage(); insertMenuLRUCache.syncFromStorage();
setRecentUsed(transformToCommands(insertMenuLRUCache.get() as string[])); setRecentUsed(transformToCommands(COMMANDS, insertMenuLRUCache.get() as string[]));
}, [visible]); }, [visible]);
return ( return (

View File

@ -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;
};
}

View File

@ -3,6 +3,7 @@ export * from './clamp';
export * from './code'; export * from './code';
export * from './color'; export * from './color';
export * from './copy-node'; export * from './copy-node';
export * from './create-node';
export * from './debug'; export * from './debug';
export * from './delete-node'; export * from './delete-node';
export * from './dom'; export * from './dom';