feat: add gap cursor for top-level block nodes in default editor (#6103)

#### What type of PR is this?

/kind feature
/area editor
/milestone 2.17.x

#### What this PR does / why we need it:

目前想对块级节点进行换行是一件比较困难的事情,尤其是两个相邻的块级节点之间的想插入额外的一行时更加困难。间隙光标可以解决这一问题。

为默认编辑器的顶级块节点(pos.depth = 1)增加间隙光标的功能。当 NodeType 属性 `allowGapCursor` 为 true 时,将会在目标节点上启用间隙光标的功能。间隙光标将可能出现在目标节点的左上方与右下方。

<img width="909" alt="image" src="https://github.com/halo-dev/halo/assets/31335418/fbbdc8fe-59c9-4ae3-a7c8-97a90607c785">

已知问题:
1. 对于 inlineContent 的节点,点击生成间隙光标时,光标会先出现在对应的内容上,会出现闪动的问题。
2. 在间隙光标上使用组合输入(例如中文输入)时,首个字母会被新增至新的一行。
3. CodeBlock 无法使用间隙光标(CodeBlock 自身问题,待适配)
~~4. 首行空文本无法被删除(与 Gap Cursor 问题无关,待适配  Paragraph)~~
~~5. 删除文本上方有可以添加间隙光标的块级节点时,无法触发间隙光标的 Backspace 事件(同 4,属于 Paragraph 适配问题)~~

目前已经启用此功能的节点:

- 表格
- 分栏卡片
- CodeBlock (无法生效)

#### How to test it?

测试间隙光标是否能够在表格与分栏卡片上出现。
测试间隙光标出现后,输入文本、使用快捷键等操作是否符合逻辑。
测试使用方向键调整间隙光标位置。

#### Does this PR introduce a user-facing change?
```release-note
为默认编辑器的块级节点增加间隙光标的功能。
```
pull/6116/head
Takagi 2024-06-26 19:18:50 +08:00 committed by GitHub
parent a93479dc34
commit 73798e86c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 902 additions and 24 deletions

View File

@ -51,7 +51,6 @@
"@tiptap/extension-color": "^2.4.0",
"@tiptap/extension-document": "^2.4.0",
"@tiptap/extension-dropcursor": "^2.4.0",
"@tiptap/extension-gapcursor": "^2.4.0",
"@tiptap/extension-hard-break": "^2.4.0",
"@tiptap/extension-heading": "^2.4.0",
"@tiptap/extension-highlight": "^2.4.0",

View File

@ -100,6 +100,7 @@ const getRenderContainer = (node: HTMLElement) => {
export default CodeBlockLowlight.extend<
CustomCodeBlockLowlightOptions & CodeBlockLowlightOptions
>({
allowGapCursor: true,
// It needs to have a higher priority than range-selection,
// otherwise the Mod-a shortcut key will be overridden.
priority: 110,

View File

@ -173,7 +173,7 @@ const Columns = Node.create({
priority: 10,
defining: true,
isolating: true,
allowGapCursor: false,
allowGapCursor: true,
content: "column{1,}",
fakeSelection: false,

View File

@ -0,0 +1,240 @@
import type { PMNode } from "@/tiptap";
import {
NodeSelection,
ResolvedPos,
Selection,
Slice,
type Mappable,
} from "@/tiptap/pm";
class GapCursorSelection extends Selection {
private start: boolean | null = false;
constructor($pos: ResolvedPos) {
super($pos, $pos);
this.start = isNodeStart($pos);
}
map(doc: PMNode, mapping: Mappable): Selection {
const $pos = doc.resolve(mapping.map(this.head));
return GapCursorSelection.valid($pos)
? new GapCursorSelection($pos)
: Selection.near($pos);
}
content() {
return Slice.empty;
}
eq(other: Selection): boolean {
return other instanceof GapCursorSelection && other.head == this.head;
}
toJSON(): any {
return { type: "node-gap-cursor", pos: this.head };
}
get isStart() {
return this.start;
}
static fromJSON(doc: PMNode, json: any): GapCursorSelection {
if (typeof json.pos != "number") {
throw new RangeError("Invalid input for GapCursorSelection.fromJSON");
}
return new GapCursorSelection(doc.resolve(json.pos));
}
getBookmark() {
return new GapBookmark(this.anchor);
}
/**
* Validates if a GapCursor can be placed at the given position
*
* This function checks whether a GapCursor can be placed at the specified position in the document.
* It ensures that the position is not within a text block, and that the node at the position allows a GapCursor.
*
* @param {ResolvedPos} $pos - The resolved position in the document to validate.
* @returns {boolean} - Returns true if a GapCursor can be placed at the given position, false otherwise.
*/
static valid($pos: ResolvedPos) {
if ($pos.depth < 1) {
return false;
}
// Get the node at the current position
const nodeOffset = $pos.doc.childBefore($pos.pos);
const root = nodeOffset.node;
if (!root) {
return false;
}
const parent = $pos.parent;
if (parent.isTextblock || (!closedBefore($pos) && !closedAfter($pos))) {
return false;
}
// Check if the node allows a GapCursor
const override = root.type.spec.allowGapCursor;
if (!override) {
return false;
}
return !root.type.inlineContent;
}
static findGapCursorFrom($pos: ResolvedPos, dir: number, mustMove = false) {
let keepSearching = true;
while (keepSearching) {
if (!mustMove && GapCursorSelection.valid($pos)) {
return $pos;
}
let pos = $pos.pos;
let next: PMNode | null = null;
// Scan up from this position
for (let d = $pos.depth; d >= 0; d--) {
const parent = $pos.node(d);
const index = dir > 0 ? $pos.indexAfter(d) : $pos.index(d) - 1;
if (dir > 0 ? index < parent.childCount : index >= 0) {
next = parent.child(index);
break;
}
if (d == 0) {
return null;
}
pos += dir;
const $cur = $pos.doc.resolve(pos);
if (GapCursorSelection.valid($cur)) {
return $cur;
}
}
// And then down into the next node
while (next) {
const inside = dir > 0 ? next.firstChild : next.lastChild;
if (!inside) {
if (
next.isAtom &&
!next.isText &&
!NodeSelection.isSelectable(next)
) {
$pos = $pos.doc.resolve(pos + next.nodeSize * dir);
mustMove = false;
break;
}
keepSearching = false;
break;
}
next = inside;
pos += dir;
const $cur = $pos.doc.resolve(pos);
if (GapCursorSelection.valid($cur)) {
return $cur;
}
}
if (!next) {
keepSearching = false;
}
}
return null;
}
}
GapCursorSelection.prototype.visible = false;
(GapCursorSelection as any).findFrom = GapCursorSelection.findGapCursorFrom;
Selection.jsonID("node-gap-cursor", GapCursorSelection);
class GapBookmark {
constructor(readonly pos: number) {}
map(mapping: Mappable) {
return new GapBookmark(mapping.map(this.pos));
}
resolve(doc: PMNode) {
const $pos = doc.resolve(this.pos);
return GapCursorSelection.valid($pos)
? new GapCursorSelection($pos)
: Selection.near($pos);
}
}
/**
* Checks if the position before the given resolved position is closed
*
* This function traverses up the document tree from the given resolved position and checks if the position
* immediately before it is closed. A position is considered closed if the previous node is closed or
* if the parent node is isolating.
*
* @param {ResolvedPos} $pos - The resolved position in the document to check.
* @returns {boolean} - Returns true if the position before the given position is closed, false otherwise.
*/
export function closedBefore($pos: ResolvedPos) {
for (let d = $pos.depth; d >= 0; d--) {
const index = $pos.index(d);
const parent = $pos.node(d);
if (index === 0) {
if (parent.type.spec.isolating) {
return true;
}
continue;
}
if (isNodeClosed(parent.child(index - 1), false)) {
return true;
}
}
return true;
}
export function closedAfter($pos: ResolvedPos) {
for (let d = $pos.depth; d >= 0; d--) {
const index = $pos.indexAfter(d);
const parent = $pos.node(d);
if (index === parent.childCount) {
if (parent.type.spec.isolating) {
return true;
}
continue;
}
if (isNodeClosed(parent.child(index), true)) {
return true;
}
}
return true;
}
function isNodeClosed(node: PMNode, isAfter: boolean): boolean {
while (node) {
if (
(node.childCount === 0 && !node.inlineContent) ||
node.isAtom ||
node.type.spec.isolating
) {
return true;
}
if (node.inlineContent) {
return false;
}
node = (isAfter ? node.firstChild : node.lastChild) as PMNode;
}
return false;
}
export function isNodeStart($pos: ResolvedPos) {
if ($pos.depth < 1) {
return null;
}
const startPos = $pos.start(1);
const endPos = $pos.end(1);
return $pos.pos < startPos + (endPos - startPos) / 2;
}
export default GapCursorSelection;

View File

@ -0,0 +1,394 @@
import type {
Dispatch,
EditorState,
EditorView,
ResolvedPos,
Transaction,
} from "@/tiptap";
import {
Extension,
callOrReturn,
getExtensionField,
isActive,
type ParentConfig,
} from "@/tiptap/core";
import {
AllSelection,
Decoration,
DecorationSet,
Plugin,
PluginKey,
TextSelection,
keydownHandler,
type Command,
} from "@/tiptap/pm";
import { deleteNodeByPos } from "@/utils";
import { isEmpty } from "@/utils/isNodeEmpty";
import GapCursorSelection from "./gap-cursor-selection";
declare module "@tiptap/core" {
interface NodeConfig<Options, Storage> {
allowGapCursor?:
| boolean
| null
| ((this: {
name: string;
options: Options;
storage: Storage;
parent: ParentConfig<NodeConfig<Options>>["allowGapCursor"];
}) => boolean | null);
}
}
/**
* Adds GapCursor to top-level nodes
*
* When the top-level nodes (nodes with a depth of 1 relative to the doc) have the {@link NodeConfig#allowGapCursor} attribute set to true,
* a GapCursor can be inserted before and after these nodes.
*
* This extension provides the ability to navigate between these nodes using the arrow keys.
*
* Note that some nodes and shortcuts may conflict with GapCursor due to their own behaviors, such as:
* - CodeBlock nodes
* - Backspace on an empty line
* - Tab key
*/
const GapCursor = Extension.create({
priority: 9999,
name: "gapCursor",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("custom-gap-cursor"),
props: {
decorations: drawGapCursor,
// If a GapCursor can be created at the current position, use GapCursor instead of other selection types
createSelectionBetween(_view, $anchor, $head) {
// TODO: When clicking outside of a node, it will first generate a GapCursorSelection,
// and then after handleClick returns false, it will turn into a TextSelection.
// The reason for this issue is that createSelectionBetween is triggered first, at which point
// GapCursorSelection.valid($head) validation fails, and then handleClick is triggered, at which point validation succeeds.
return $anchor.pos == $head.pos && GapCursorSelection.valid($head)
? new GapCursorSelection($head)
: null;
},
handleClick(view, pos, event) {
if (!view || !view.editable) {
return false;
}
const clickPos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
// Skip if the click position is inside a node.
if (clickPos && clickPos.inside > -1) {
return false;
}
const $pos = view.state.doc.resolve(pos);
if (!GapCursorSelection.valid($pos)) {
return false;
}
view.dispatch(
view.state.tr.setSelection(new GapCursorSelection($pos))
);
return true;
},
handleKeyDown: keydownHandler({
ArrowLeft: arrow("horiz", -1),
ArrowRight: arrow("horiz", 1),
ArrowUp: arrow("vert", -1),
ArrowDown: arrow("vert", 1),
Enter: (state, dispatch) => {
const tr = createParagraphNearByGapCursor(state, false);
if (tr && dispatch) {
dispatch(tr);
return true;
}
return false;
},
Backspace: (state, dispatch) => {
const { selection, tr } = state;
if (
isActive(state, "paragraph") &&
isEmpty(state.selection.$from.parent) &&
selection instanceof TextSelection &&
selection.empty
) {
const { $from } = selection;
deleteNodeByPos($from)(tr);
if (dispatch) {
const $found = arrowGapCursor(-1, "left", state)(tr);
if ($found) {
dispatch(tr);
return true;
}
}
return false;
}
if (!(selection instanceof GapCursorSelection) || !dispatch) {
return false;
}
const { isStart, $from } = selection;
const nodeOffset = state.doc.childBefore($from.pos);
const index = nodeOffset.index;
const pos = state.doc.resolve(0).posAtIndex(index);
if (isStart) {
return handleBackspaceAtStart(pos, state, dispatch);
} else if (
nodeOffset.node &&
deleteNodeByPos(state.doc.resolve(pos))(tr)
) {
dispatch(tr);
return true;
}
return false;
},
Tab: (state, dispatch) => {
const tr = createParagraphNearByGapCursor(state);
if (tr && dispatch) {
dispatch(tr);
return true;
}
return false;
},
}),
handleTextInput(view) {
const { state, dispatch } = view;
const tr = createParagraphNearByGapCursor(state);
if (tr && dispatch) {
dispatch(tr);
}
return false;
},
handleDOMEvents: {
/**
* Solve the issue of inserting text during composition input events, e.g., Chinese input
*/
beforeinput: (view, event) => {
const { state, dispatch } = view;
if (
event.inputType != "insertCompositionText" ||
!(state.selection instanceof GapCursorSelection)
) {
return false;
}
// TODO: After creating a new node, due to the change in selection, the content of the composition input
// will be inserted into the newly created node, causing the first character to be created directly.
const tr = createParagraphNearByGapCursor(state);
if (tr && dispatch) {
dispatch(tr);
}
return false;
},
},
},
}),
];
},
extendNodeSchema(extension) {
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage,
};
return {
allowGapCursor:
callOrReturn(getExtensionField(extension, "allowGapCursor", context)) ??
null,
};
},
});
export function handleBackspaceAtStart(
pos: number,
state: EditorState,
dispatch: Dispatch
) {
const { tr } = state;
if (pos == 0) {
return false;
}
const $beforePos = state.doc.resolve(pos - 1);
const parent = $beforePos.parent;
if (parent.inlineContent || parent.isTextblock) {
return handleInlineContent($beforePos, state, dispatch);
}
if (GapCursorSelection.valid($beforePos) && dispatch) {
dispatch(tr.setSelection(new GapCursorSelection($beforePos)));
return true;
}
if (deleteNodeByPos($beforePos)(tr) && dispatch) {
dispatch(tr);
return true;
}
return false;
}
export function handleInlineContent(
$beforePos: ResolvedPos,
state: EditorState,
dispatch: Dispatch
) {
if ($beforePos.parentOffset == 0 && $beforePos.pos > 1 && dispatch) {
dispatch(state.tr.delete($beforePos.pos - 1, $beforePos.pos));
return true;
}
if (dispatch) {
dispatch(
state.tr.setSelection(TextSelection.create(state.doc, $beforePos.pos))
);
}
return true;
}
/**
* Handles arrow key navigation for GapCursor
*
* This function determines the direction (vertical or horizontal) and
* the movement (positive or negative) based on the axis and direction parameters.
*
* @param {("vert" | "horiz")} axis - The axis of movement, either vertical ("vert") or horizontal ("horiz").
* @param {number} dir - The direction of movement, positive (1) or negative (-1).
*/
export function arrow(axis: "vert" | "horiz", dir: number): Command {
const dirStr =
axis == "vert" ? (dir > 0 ? "down" : "up") : dir > 0 ? "right" : "left";
return (state, dispatch, view) => {
const { tr } = state;
if (arrowGapCursor(dir, dirStr, state, view)(tr) && dispatch) {
dispatch(tr);
return true;
}
return false;
};
}
export const arrowGapCursor = (
dir: number,
dirStr: any,
state: EditorState,
view?: EditorView
) => {
return (tr: Transaction) => {
const sel = state.selection;
let $start = dir > 0 ? sel.$to : sel.$from;
let mustMove = sel.empty;
if (sel instanceof TextSelection) {
// Do nothing if the next node is not at the end of the document or is at the root node.
if ($start.depth == 0) {
return;
}
if (view && !view.endOfTextblock(dirStr)) {
return;
}
mustMove = false;
$start = state.doc.resolve(dir > 0 ? $start.after() : $start.before());
// If inside a node, check if it has reached the boundary of the node
if ($start.depth > 0) {
const pos = $start.pos;
const start = $start.start(1) + 1;
const end = $start.end(1) - 1;
if (pos != start && pos != end) {
return;
}
}
}
if (sel instanceof GapCursorSelection) {
return;
}
const $found = GapCursorSelection.findGapCursorFrom($start, dir, mustMove);
if (!$found) {
return;
}
tr.setSelection(new GapCursorSelection($found));
return $found;
};
};
/**
* Creates a new paragraph near the current GapCursor
*
* If the current selection is a GapCursorSelection, this function creates a new paragraph node
* either before or after the current node depending on the selection direction.
*
* @param {EditorState} state - The current editor state.
* @param {boolean} [changeSelection=true] - Whether to change the selection to the new paragraph.
* @returns {Transaction|undefined} - The updated transaction or undefined if no changes are made.
*/
function createParagraphNearByGapCursor(
state: EditorState,
changeSelection = true
) {
const { tr } = state;
if (!(state.selection instanceof GapCursorSelection)) {
return;
}
const { isStart, $from } = state.selection;
// Create a new paragraph node before the current node
if (state.selection instanceof AllSelection || $from.parent.inlineContent) {
return;
}
const docPos = state.doc.resolve(0);
const nodeOffset = state.doc.childBefore($from.pos);
const index = isStart ? nodeOffset.index : nodeOffset.index + 1;
const pos = docPos.posAtIndex(index);
tr.insert(pos, state.schema.nodes.paragraph.create());
if (changeSelection || !isStart) {
tr.setSelection(TextSelection.create(tr.doc, pos + 1));
tr.scrollIntoView();
}
return tr;
}
/**
* Draws a visual representation of the GapCursor
*
* If the current selection is a GapCursorSelection, this function creates a decoration set
* to visually indicate the presence of a GapCursor at the appropriate position.
*
* @param {EditorState} state - The current editor state.
* @returns {DecorationSet|null} - The decoration set for the GapCursor or null if not applicable.
*/
function drawGapCursor(state: EditorState) {
if (!(state.selection instanceof GapCursorSelection)) {
return null;
}
const $head = state.selection.$head;
if ($head.depth < 1) {
return null;
}
const node = $head.node(1);
const pos = $head.start(1) - 1;
const isStart = state.selection.isStart;
return DecorationSet.create(state.doc, [
Decoration.node(pos, pos + node.nodeSize, {
key: "node-gap-cursor",
class: `card-gap-cursor ${isStart ? "start" : "end"}-card-gap-cursor`,
}),
]);
}
export default GapCursor;

View File

@ -2,7 +2,6 @@
import { i18n } from "@/locales";
import ExtensionDocument from "@tiptap/extension-document";
import ExtensionDropcursor from "@tiptap/extension-dropcursor";
import ExtensionGapcursor from "@tiptap/extension-gapcursor";
import ExtensionHardBreak from "@tiptap/extension-hard-break";
import ExtensionHorizontalRule from "@tiptap/extension-horizontal-rule";
import ExtensionPlaceholder from "@tiptap/extension-placeholder";
@ -37,6 +36,7 @@ import ExtensionClearFormat from "./clear-format";
import { ExtensionColumn, ExtensionColumns } from "./columns";
import ExtensionDraggable from "./draggable";
import ExtensionFormatBrush from "./format-brush";
import ExtensionGapcursor from "./gap-cursor";
import ExtensionIframe from "./iframe";
import ExtensionImage from "./image";
import ExtensionIndent from "./indent";

View File

@ -1,8 +1,17 @@
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
import ToolbarSubItem from "@/components/toolbar/ToolbarSubItem.vue";
import { i18n } from "@/locales";
import type { Editor } from "@/tiptap";
import {
EditorState,
ResolvedPos,
TextSelection,
isActive,
type Dispatch,
type Editor,
} from "@/tiptap";
import type { ExtensionOptions, ToolbarItem as TypeToolbarItem } from "@/types";
import { deleteNodeByPos } from "@/utils";
import { isEmpty } from "@/utils/isNodeEmpty";
import type { ParagraphOptions } from "@tiptap/extension-paragraph";
import TiptapParagraph from "@tiptap/extension-paragraph";
import { markRaw } from "vue";
@ -85,6 +94,93 @@ const Paragraph = TiptapParagraph.extend<ExtensionOptions & ParagraphOptions>({
},
};
},
addKeyboardShortcuts() {
return {
Backspace: ({ editor }) => {
const { state, view } = editor;
const { selection } = state;
if (
!isActive(state, Paragraph.name) ||
!(selection instanceof TextSelection) ||
!selection.empty
) {
return false;
}
const { $from } = selection;
if ($from.parentOffset !== 0) {
return false;
}
const beforePos = $from.before($from.depth);
if (isEmpty($from.parent)) {
return deleteCurrentNodeAndSetSelection(
$from,
beforePos,
state,
view.dispatch
);
}
if (beforePos === 0) {
return false;
}
return handleDeletePreviousNode($from, beforePos, state, view.dispatch);
},
};
},
});
export function deleteCurrentNodeAndSetSelection(
$from: ResolvedPos,
beforePos: number,
state: EditorState,
dispatch: Dispatch
) {
const { tr } = state;
if (deleteNodeByPos($from)(tr) && dispatch) {
if (beforePos !== 0) {
tr.setSelection(TextSelection.create(tr.doc, beforePos - 1));
}
dispatch(tr);
return true;
}
return false;
}
export function handleDeletePreviousNode(
$from: ResolvedPos,
beforePos: number,
state: EditorState,
dispatch: Dispatch
) {
const { tr } = state;
if (!dispatch) {
return false;
}
const $beforePos = $from.doc.resolve(beforePos);
const nodeBefore = $beforePos.nodeBefore;
if (
!nodeBefore ||
!nodeBefore.type.isBlock ||
nodeBefore.type.isText ||
nodeBefore.type.name === Paragraph.name
) {
return false;
}
if (deleteNodeByPos($from.doc.resolve(beforePos - 1))(tr)) {
dispatch(tr);
return true;
}
return false;
}
export default Paragraph;

View File

@ -208,6 +208,7 @@ class TableView implements NodeView {
}
const Table = TiptapTable.extend<ExtensionOptions & TableOptions>({
allowGapCursor: true,
fakeSelection: false,
addExtensions() {

View File

@ -0,0 +1,43 @@
.halo-rich-text-editor {
.ProseMirror {
&.ProseMirror-focused {
.card-gap-cursor {
&::before {
display: block;
}
}
}
.card-gap-cursor {
position: relative;
}
.card-gap-cursor {
&::before {
content: "";
display: none;
position: absolute;
min-width: 1px;
height: 1em;
font-size: 1em;
border-left: 1px solid black;
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
}
.start-card-gap-cursor::before {
top: -2px;
left: -1px;
}
.end-card-gap-cursor::before {
bottom: -2px;
right: -1px;
}
}
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}

View File

@ -4,5 +4,6 @@
@import "./columns.scss";
@import "./search.scss";
@import "./format-brush.scss";
@import "./gap-cursor.scss";
@import "./node-select.scss";
@import "./range-selection.scss";

View File

@ -1,9 +1,37 @@
import { NodeSelection, Transaction, type ResolvedPos } from "@/tiptap/pm";
import type { Editor } from "@/tiptap/vue-3";
export const deleteNodeByPos = ($pos: ResolvedPos) => {
return (tr: Transaction) => {
if ($pos.depth) {
for (let d = $pos.depth; d > 0; d--) {
tr.delete($pos.before(d), $pos.after(d)).scrollIntoView();
return true;
}
} else {
const node = $pos.parent;
if (!node.isTextblock && node.nodeSize) {
tr.setSelection(
NodeSelection.create($pos.doc, $pos.pos)
).deleteSelection();
return true;
}
}
const pos = $pos.pos;
if (pos) {
tr.delete(pos, pos + $pos.node().nodeSize);
return true;
}
return false;
};
};
export const deleteNode = (nodeType: string, editor: Editor) => {
const { state } = editor;
const $pos = state.selection.$anchor;
let done = false;
const done = false;
if ($pos.depth) {
for (let d = $pos.depth; d > 0; d--) {
@ -15,7 +43,7 @@ export const deleteNode = (nodeType: string, editor: Editor) => {
editor.dispatchTransaction(
state.tr.delete($pos.before(d), $pos.after(d)).scrollIntoView()
);
done = true;
return true;
}
}
} else {
@ -23,7 +51,7 @@ export const deleteNode = (nodeType: string, editor: Editor) => {
const node = state.selection.node;
if (node && node.type.name === nodeType) {
editor.chain().deleteSelection().run();
done = true;
return true;
}
}
@ -38,7 +66,7 @@ export const deleteNode = (nodeType: string, editor: Editor) => {
if (editor.dispatchTransaction)
// @ts-ignore
editor.dispatchTransaction(state.tr.delete(pos, pos + node.nodeSize));
done = true;
return true;
}
}
}

View File

@ -0,0 +1,19 @@
import { PMNode } from "@/tiptap";
export const isEmpty = (node: PMNode) => {
return isNodeDefault(node) || isParagraphEmpty(node);
};
export const isNodeDefault = (node: PMNode) => {
const defaultContent = node.type.createAndFill()?.toJSON();
const content = node.toJSON();
return JSON.stringify(defaultContent) === JSON.stringify(content);
};
export const isParagraphEmpty = (node: PMNode) => {
if (node.type.name !== "paragraph") {
return false;
}
return node.textContent.length === 0;
};

View File

@ -470,9 +470,6 @@ importers:
'@tiptap/extension-dropcursor':
specifier: ^2.4.0
version: 2.4.0(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))(@tiptap/pm@2.4.0)
'@tiptap/extension-gapcursor':
specifier: ^2.4.0
version: 2.4.0(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))(@tiptap/pm@2.4.0)
'@tiptap/extension-hard-break':
specifier: ^2.4.0
version: 2.4.0(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))
@ -590,7 +587,7 @@ importers:
version: 16.2.1(typescript@5.4.5)
vite-plugin-dts:
specifier: ^3.9.1
version: 3.9.1(@types/node@20.14.2)(rollup@4.17.2)(typescript@5.4.5)(vite@5.2.11(@types/node@20.14.2)(less@4.2.0)(sass@1.60.0)(terser@5.31.0))
version: 3.9.1(@types/node@18.13.0)(rollup@4.17.2)(typescript@5.4.5)(vite@5.2.11(@types/node@18.13.0)(less@4.2.0)(sass@1.60.0)(terser@5.31.0))
packages/shared:
dependencies:
@ -606,7 +603,7 @@ importers:
devDependencies:
vite-plugin-dts:
specifier: ^3.9.1
version: 3.9.1(@types/node@20.14.2)(rollup@4.17.2)(typescript@5.4.5)(vite@5.2.11(@types/node@20.14.2)(less@4.2.0)(sass@1.60.0)(terser@5.31.0))
version: 3.9.1(@types/node@18.13.0)(rollup@4.17.2)(typescript@5.4.5)(vite@5.2.11(@types/node@18.13.0)(less@4.2.0)(sass@1.60.0)(terser@5.31.0))
packages/ui-plugin-bundler-kit:
dependencies:
@ -3681,12 +3678,6 @@ packages:
'@tiptap/core': ^2.0.0
'@tiptap/pm': ^2.0.0
'@tiptap/extension-gapcursor@2.4.0':
resolution: {integrity: sha512-F4y/0J2lseohkFUw9P2OpKhrJ6dHz69ZScABUvcHxjznJLd6+0Zt7014Lw5PA8/m2d/w0fX8LZQ88pZr4quZPQ==}
peerDependencies:
'@tiptap/core': ^2.0.0
'@tiptap/pm': ^2.0.0
'@tiptap/extension-hard-break@2.4.0':
resolution: {integrity: sha512-3+Z6zxevtHza5IsDBZ4lZqvNR3Kvdqwxq/QKCKu9UhJN1DUjsg/l1Jn2NilSQ3NYkBYh2yJjT8CMo9pQIu776g==}
peerDependencies:
@ -13147,6 +13138,14 @@ snapshots:
'@types/react': 18.2.41
react: 18.2.0
'@microsoft/api-extractor-model@7.28.13(@types/node@18.13.0)':
dependencies:
'@microsoft/tsdoc': 0.14.2
'@microsoft/tsdoc-config': 0.16.2
'@rushstack/node-core-library': 4.0.2(@types/node@18.13.0)
transitivePeerDependencies:
- '@types/node'
'@microsoft/api-extractor-model@7.28.13(@types/node@20.14.2)':
dependencies:
'@microsoft/tsdoc': 0.14.2
@ -13155,6 +13154,24 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
'@microsoft/api-extractor@7.43.0(@types/node@18.13.0)':
dependencies:
'@microsoft/api-extractor-model': 7.28.13(@types/node@18.13.0)
'@microsoft/tsdoc': 0.14.2
'@microsoft/tsdoc-config': 0.16.2
'@rushstack/node-core-library': 4.0.2(@types/node@18.13.0)
'@rushstack/rig-package': 0.5.2
'@rushstack/terminal': 0.10.0(@types/node@18.13.0)
'@rushstack/ts-command-line': 4.19.1(@types/node@18.13.0)
lodash: 4.17.21
minimatch: 3.0.8
resolve: 1.22.8
semver: 7.5.4
source-map: 0.6.1
typescript: 5.4.2
transitivePeerDependencies:
- '@types/node'
'@microsoft/api-extractor@7.43.0(@types/node@20.14.2)':
dependencies:
'@microsoft/api-extractor-model': 7.28.13(@types/node@20.14.2)
@ -13827,6 +13844,17 @@ snapshots:
'@rushstack/eslint-patch@1.3.2': {}
'@rushstack/node-core-library@4.0.2(@types/node@18.13.0)':
dependencies:
fs-extra: 7.0.1
import-lazy: 4.0.0
jju: 1.4.0
resolve: 1.22.8
semver: 7.5.4
z-schema: 5.0.4
optionalDependencies:
'@types/node': 18.13.0
'@rushstack/node-core-library@4.0.2(@types/node@20.14.2)':
dependencies:
fs-extra: 7.0.1
@ -13843,6 +13871,13 @@ snapshots:
resolve: 1.22.8
strip-json-comments: 3.1.1
'@rushstack/terminal@0.10.0(@types/node@18.13.0)':
dependencies:
'@rushstack/node-core-library': 4.0.2(@types/node@18.13.0)
supports-color: 8.1.1
optionalDependencies:
'@types/node': 18.13.0
'@rushstack/terminal@0.10.0(@types/node@20.14.2)':
dependencies:
'@rushstack/node-core-library': 4.0.2(@types/node@20.14.2)
@ -13850,6 +13885,15 @@ snapshots:
optionalDependencies:
'@types/node': 20.14.2
'@rushstack/ts-command-line@4.19.1(@types/node@18.13.0)':
dependencies:
'@rushstack/terminal': 0.10.0(@types/node@18.13.0)
'@types/argparse': 1.0.38
argparse: 1.0.10
string-argv: 0.3.1
transitivePeerDependencies:
- '@types/node'
'@rushstack/ts-command-line@4.19.1(@types/node@20.14.2)':
dependencies:
'@rushstack/terminal': 0.10.0(@types/node@20.14.2)
@ -14605,11 +14649,6 @@ snapshots:
'@tiptap/pm': 2.4.0
tippy.js: 6.3.7
'@tiptap/extension-gapcursor@2.4.0(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))(@tiptap/pm@2.4.0)':
dependencies:
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
'@tiptap/pm': 2.4.0
'@tiptap/extension-hard-break@2.4.0(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))':
dependencies:
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
@ -21792,6 +21831,23 @@ snapshots:
- supports-color
- terser
vite-plugin-dts@3.9.1(@types/node@18.13.0)(rollup@4.17.2)(typescript@5.4.5)(vite@5.2.11(@types/node@18.13.0)(less@4.2.0)(sass@1.60.0)(terser@5.31.0)):
dependencies:
'@microsoft/api-extractor': 7.43.0(@types/node@18.13.0)
'@rollup/pluginutils': 5.1.0(rollup@4.17.2)
'@vue/language-core': 1.8.27(typescript@5.4.5)
debug: 4.3.4(supports-color@8.1.1)
kolorist: 1.8.0
magic-string: 0.30.10
typescript: 5.4.5
vue-tsc: 1.8.27(typescript@5.4.5)
optionalDependencies:
vite: 5.2.11(@types/node@18.13.0)(less@4.2.0)(sass@1.60.0)(terser@5.31.0)
transitivePeerDependencies:
- '@types/node'
- rollup
- supports-color
vite-plugin-dts@3.9.1(@types/node@20.14.2)(rollup@2.79.1)(typescript@5.4.5)(vite@5.2.11(@types/node@20.14.2)(less@4.2.0)(sass@1.60.0)(terser@5.31.0)):
dependencies:
'@microsoft/api-extractor': 7.43.0(@types/node@20.14.2)