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 { 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)
|
||||
);
|
||||
},
|
||||
|
|
|
@ -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: <IconTable />,
|
||||
label: '表格',
|
||||
custom: (editor, runCommand) => (
|
||||
|
@ -84,26 +86,31 @@ export const COMMANDS: ICommand[] = [
|
|||
),
|
||||
},
|
||||
{
|
||||
isBlock: true,
|
||||
icon: <IconCodeBlock />,
|
||||
label: '代码块',
|
||||
action: (editor) => editor.chain().focus().toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
isBlock: true,
|
||||
icon: <IconImage />,
|
||||
label: '图片',
|
||||
action: (editor) => editor.chain().focus().setEmptyImage({ width: '100%' }).run(),
|
||||
},
|
||||
{
|
||||
isBlock: true,
|
||||
icon: <IconAttachment />,
|
||||
label: '附件',
|
||||
action: (editor) => editor.chain().focus().setAttachment().run(),
|
||||
},
|
||||
{
|
||||
isBlock: true,
|
||||
icon: <IconCountdown />,
|
||||
label: '倒计时',
|
||||
action: (editor) => createCountdown(editor),
|
||||
},
|
||||
{
|
||||
isBlock: true,
|
||||
icon: <IconLink />,
|
||||
label: '外链',
|
||||
action: (editor, user) =>
|
||||
|
@ -113,6 +120,7 @@ export const COMMANDS: ICommand[] = [
|
|||
title: '卡片',
|
||||
},
|
||||
{
|
||||
isBlock: true,
|
||||
icon: <IconFlow />,
|
||||
label: '流程图',
|
||||
action: (editor, user) => {
|
||||
|
@ -120,6 +128,7 @@ export const COMMANDS: ICommand[] = [
|
|||
},
|
||||
},
|
||||
{
|
||||
isBlock: true,
|
||||
icon: <IconMind />,
|
||||
label: '思维导图',
|
||||
action: (editor, user) => {
|
||||
|
@ -127,6 +136,7 @@ export const COMMANDS: ICommand[] = [
|
|||
},
|
||||
},
|
||||
{
|
||||
isBlock: true,
|
||||
icon: <IconMath />,
|
||||
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: <IconCallout />,
|
||||
label: '高亮块',
|
||||
action: (editor) => editor.chain().focus().setCallout().run(),
|
||||
|
@ -145,12 +156,14 @@ export const COMMANDS: ICommand[] = [
|
|||
title: '内容引用',
|
||||
},
|
||||
{
|
||||
isBlock: true,
|
||||
icon: <IconDocument />,
|
||||
label: '文档',
|
||||
action: (editor, user) =>
|
||||
editor.chain().focus().setDocumentReference({ defaultShowPicker: true, createUser: user.id }).run(),
|
||||
},
|
||||
{
|
||||
isBlock: true,
|
||||
icon: <IconDocument />,
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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 './color';
|
||||
export * from './copy-node';
|
||||
export * from './create-node';
|
||||
export * from './debug';
|
||||
export * from './delete-node';
|
||||
export * from './dom';
|
||||
|
|
Loading…
Reference in New Issue