mirror of https://github.com/fantasticit/think.git
tiptap: try add a new paragraph before insert block node; fix #86
This commit is contained in:
parent
15b14fd173
commit
da9c8151ee
|
@ -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)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
|
@ -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';
|
||||||
|
|
Loading…
Reference in New Issue