editor: improve escaping from & deleting of nodes

This commit is contained in:
Abdullah Atta
2023-08-28 16:29:13 +05:00
committed by Abdullah Atta
parent cafa5ff821
commit 58de07073d
10 changed files with 188 additions and 156 deletions

View File

@@ -299,43 +299,6 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
to: codeblock.pos + codeblock.node.nodeSize - 1
});
},
// remove code block when at start of document or code block is empty
Backspace: ({ editor }) => {
const { empty, $anchor } = editor.state.selection;
const currentNode = $anchor.parent;
const nextNode = editor.state.doc.nodeAt($anchor.pos + 1);
const isCodeBlock = (node: ProsemirrorNode | null) =>
node && node.type.name === this.name;
const isAtStart = $anchor.pos === 1;
if (!empty) {
return false;
}
if (
isAtStart ||
(isCodeBlock(currentNode) && !currentNode.textContent.length)
) {
return this.editor.commands.deleteNode(this.type);
}
// on android due to composition issues with various keyboards,
// sometimes backspace is detected one node behind. We need to
// manually handle this case.
else if (
nextNode &&
isCodeBlock(nextNode) &&
!nextNode.textContent.length
) {
return this.editor.commands.command(({ tr }) => {
tr.delete($anchor.pos + 1, $anchor.pos + 1 + nextNode.nodeSize);
return true;
});
}
return false;
},
// exit node on triple enter
Enter: ({ editor }) => {
const { state } = editor;
@@ -354,28 +317,6 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
if (indentation) return indentOnEnter(editor, $from, indentation);
return false;
},
// exit node on arrow up
ArrowUp: ({ editor }) => {
if (!this.options.exitOnArrowUp) {
return false;
}
const { state } = editor;
const { selection } = state;
const { $anchor, empty } = selection;
if (!empty || $anchor.parent.type !== this.type) {
return false;
}
const isAtStart = $anchor.pos === 1;
if (!isAtStart) {
return false;
}
return editor.commands.insertContentAt(0, "<p></p>");
},
// exit node on arrow down
ArrowDown: ({ editor }) => {
if (!this.options.exitOnArrowDown) {

View File

@@ -1,56 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { EditorState } from "prosemirror-state";
import { NodeType } from "prosemirror-model";
import { Editor } from "@tiptap/core";
import { findParentNodeOfType } from "../../utils/prosemirror";
export function onArrowUpPressed(editor: Editor, name: string, type: NodeType) {
const { selection } = editor.state;
const { empty } = selection;
if (!empty || !isFirstOfType(type, editor.state)) return false;
const parentList = getListFromListItem(type, editor.state);
if (editor.state.doc.firstChild === parentList)
return editor.commands.insertContentAt(0, "<p></p>");
return false;
}
const isFirstOfType = (type: NodeType, state: EditorState) => {
const block = findParentNodeOfType(type)(state.selection);
if (!block) return false;
const { pos } = block;
const resolved = state.doc.resolve(pos);
return !resolved.nodeBefore;
};
const getListFromListItem = (type: NodeType, state: EditorState) => {
const block = findParentNodeOfType(type)(state.selection);
if (!block) return undefined;
const { pos } = block;
const resolved = state.doc.resolve(pos);
if (
!resolved.parent.type.spec.group ||
resolved.parent.type.spec.group?.indexOf("list") <= -1
)
return undefined;
return resolved.parent;
};

View File

@@ -18,7 +18,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { ListItem as TiptapListItem } from "@tiptap/extension-list-item";
import { onArrowUpPressed } from "./commands";
export const ListItem = TiptapListItem.extend({
addKeyboardShortcuts() {
@@ -33,8 +32,7 @@ export const ListItem = TiptapListItem.extend({
if ($from.parent.type.name === "codeblock") return false;
return this.parent?.()?.Tab(props) || false;
},
ArrowUp: ({ editor }) => onArrowUpPressed(editor, this.name, this.type)
}
};
}
});

View File

@@ -26,7 +26,6 @@ import {
} from "@tiptap/core";
import { NodeType } from "prosemirror-model";
import { findParentNodeOfTypeClosestToPos } from "../../utils/prosemirror";
import { onArrowUpPressed } from "../list-item/commands";
import { OutlineList } from "../outline-list/outline-list";
export interface ListItemOptions {
@@ -110,8 +109,7 @@ export const OutlineListItem = Node.create<ListItemOptions>({
return this.editor.commands.splitListItem(this.name);
},
Tab: () => this.editor.commands.sinkListItem(this.name),
"Shift-Tab": () => this.editor.commands.liftListItem(this.name),
ArrowUp: ({ editor }) => onArrowUpPressed(editor, this.name, this.type)
"Shift-Tab": () => this.editor.commands.liftListItem(this.name)
};
},

View File

@@ -0,0 +1,21 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export * from "./quirks";
export { Quirks as default } from "./quirks";

View File

@@ -0,0 +1,115 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Editor, Extension, findParentNode } from "@tiptap/core";
import { EditorState, Selection } from "@tiptap/pm/state";
import { isAndroid } from "../../utils/platform";
export type QuirksOptions = {
/**
* List of node types that do not get removed on pressing Backspace
* even when they are empty.
*/
irremovableNodesOnBackspace: string[];
/**
* Nodes that should be easily escapable if at the beginning of the
* document by pressing the ArrowUp key. Pressing the ArrowUp key
* will create an empty paragraph before the node.
*/
escapableNodesIfAtDocumentStart: string[];
};
export const Quirks = Extension.create<QuirksOptions>({
name: "quirks",
addOptions() {
return {
escapableNodesIfAtDocumentStart: [],
irremovableNodesOnBackspace: []
};
},
addKeyboardShortcuts() {
return {
// exit node on arrow up
ArrowUp: ({ editor }) =>
escapeNode(editor, this.options.escapableNodesIfAtDocumentStart),
ArrowLeft: ({ editor }) =>
escapeNode(editor, this.options.escapableNodesIfAtDocumentStart),
ArrowRight: ({ editor }) =>
escapeNode(editor, this.options.escapableNodesIfAtDocumentStart),
Backspace: ({ editor }) => {
const { empty, $anchor } = editor.state.selection;
const nextNode = editor.state.doc.nodeAt($anchor.pos + 1);
const node = findFromParentNodes(
editor.state,
this.options.irremovableNodesOnBackspace
);
if (!empty) {
return false;
}
if (node && !node.node.textContent.length) {
return this.editor.commands.deleteNode(node.node.type);
}
// on android due to composition issues with various keyboards,
// sometimes backspace is detected one node behind. We need to
// manually handle this case.
else if (
isAndroid &&
nextNode &&
this.options.irremovableNodesOnBackspace.includes(
nextNode.type.name
) &&
!nextNode.textContent.length
) {
return this.editor.commands.command(({ tr }) => {
tr.delete($anchor.pos + 1, $anchor.pos + 1 + nextNode.nodeSize);
return true;
});
}
return false;
}
};
}
});
const findFromParentNodes = (state: EditorState, types: string[]) => {
return findParentNode((node) => types.includes(node.type.name))(
state.selection
);
};
function escapeNode(editor: Editor, escapableNodes: string[]) {
const { state } = editor;
const { selection } = state;
const { $anchor, empty } = selection;
const documentStartPos = Selection.atStart(editor.state.doc).$head.pos;
const isAtStart = $anchor.pos === documentStartPos;
console.log(isAtStart);
if (!empty || !isAtStart || !findFromParentNodes(state, escapableNodes)) {
return false;
}
return editor.commands.insertContentAt(0, "<p></p>");
}

View File

@@ -121,7 +121,7 @@ export function TaskItemComponent(
toggle();
}}
onTouchEnd={(e) => {
if (globalThis["keyboardShown"] || isiOS()) {
if (globalThis["keyboardShown"] || isiOS) {
e.preventDefault();
toggle();
}

View File

@@ -18,7 +18,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { mergeAttributes } from "@tiptap/core";
import { onArrowUpPressed } from "../list-item/commands";
import { TaskItem } from "@tiptap/extension-task-item";
import { TaskItemComponent } from "./component";
import { createNodeView } from "../react";
@@ -73,8 +72,7 @@ export const TaskItemNode = TaskItem.extend({
addKeyboardShortcuts() {
return {
...this.parent?.(),
ArrowUp: ({ editor }) => onArrowUpPressed(editor, this.name, this.type)
...this.parent?.()
};
},

View File

@@ -77,6 +77,8 @@ import { DownloadOptions } from "./utils/downloader";
import { Heading } from "./extensions/heading";
import Clipboard, { ClipboardOptions } from "./extensions/clipboard";
import Blockquote from "./extensions/blockquote";
import { Quirks } from "./extensions/quirks";
import { LIST_NODE_TYPES } from "./utils/node-types";
declare global {
// eslint-disable-next-line no-var
@@ -220,22 +222,6 @@ const useTiptap = (
OrderedList.configure({ keepMarks: true, keepAttributes: true }),
TaskItemNode.configure({ nested: true }),
TaskListNode,
ListKeymap.configure({
listTypes: [
{
itemName: ListItem.name,
wrapperNames: [BulletList.name, OrderedList.name]
},
{
itemName: TaskItemNode.name,
wrapperNames: [TaskListNode.name]
},
{
itemName: OutlineListItem.name,
wrapperNames: [OutlineList.name]
}
]
}),
Link.extend({
inclusive: true
}).configure({
@@ -286,7 +272,38 @@ const useTiptap = (
}),
DateTime.configure({ dateFormat, timeFormat }),
KeyMap,
WebClipNode
WebClipNode,
// Quirks handlers
Quirks.configure({
irremovableNodesOnBackspace: [
CodeBlock.name,
TaskListNode.name,
Table.name
],
escapableNodesIfAtDocumentStart: [
CodeBlock.name,
Table.name,
Blockquote.name,
...LIST_NODE_TYPES
]
}),
ListKeymap.configure({
listTypes: [
{
itemName: ListItem.name,
wrapperNames: [BulletList.name, OrderedList.name]
},
{
itemName: TaskItemNode.name,
wrapperNames: [TaskListNode.name]
},
{
itemName: OutlineListItem.name,
wrapperNames: [OutlineList.name]
}
]
})
],
onBeforeCreate: ({ editor }) => {
editor.storage.portalProviderAPI = PortalProviderAPI;

View File

@@ -17,17 +17,17 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export function isiOS(): boolean {
return (
[
"iPad Simulator",
"iPhone Simulator",
"iPod Simulator",
"iPad",
"iPhone",
"iPod"
].includes(navigator.platform) ||
// iPad on iOS 13 detection
(navigator.userAgent.includes("Mac") && "ontouchend" in document)
);
}
export const isiOS =
[
"iPad Simulator",
"iPhone Simulator",
"iPod Simulator",
"iPad",
"iPhone",
"iPod"
].includes(navigator.platform) ||
// iPad on iOS 13 detection
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
export const isAndroid =
navigator.userAgent.toLowerCase().indexOf("android") > -1;