mirror of
https://github.com/streetwriters/notesnook.git
synced 2025-12-16 19:57:52 +01:00
editor: improve escaping from & deleting of nodes
This commit is contained in:
committed by
Abdullah Atta
parent
cafa5ff821
commit
58de07073d
@@ -299,43 +299,6 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
|||||||
to: codeblock.pos + codeblock.node.nodeSize - 1
|
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
|
// exit node on triple enter
|
||||||
Enter: ({ editor }) => {
|
Enter: ({ editor }) => {
|
||||||
const { state } = editor;
|
const { state } = editor;
|
||||||
@@ -354,28 +317,6 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
|||||||
if (indentation) return indentOnEnter(editor, $from, indentation);
|
if (indentation) return indentOnEnter(editor, $from, indentation);
|
||||||
return false;
|
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
|
// exit node on arrow down
|
||||||
ArrowDown: ({ editor }) => {
|
ArrowDown: ({ editor }) => {
|
||||||
if (!this.options.exitOnArrowDown) {
|
if (!this.options.exitOnArrowDown) {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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 { ListItem as TiptapListItem } from "@tiptap/extension-list-item";
|
||||||
import { onArrowUpPressed } from "./commands";
|
|
||||||
|
|
||||||
export const ListItem = TiptapListItem.extend({
|
export const ListItem = TiptapListItem.extend({
|
||||||
addKeyboardShortcuts() {
|
addKeyboardShortcuts() {
|
||||||
@@ -33,8 +32,7 @@ export const ListItem = TiptapListItem.extend({
|
|||||||
if ($from.parent.type.name === "codeblock") return false;
|
if ($from.parent.type.name === "codeblock") return false;
|
||||||
|
|
||||||
return this.parent?.()?.Tab(props) || false;
|
return this.parent?.()?.Tab(props) || false;
|
||||||
},
|
}
|
||||||
ArrowUp: ({ editor }) => onArrowUpPressed(editor, this.name, this.type)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
} from "@tiptap/core";
|
} from "@tiptap/core";
|
||||||
import { NodeType } from "prosemirror-model";
|
import { NodeType } from "prosemirror-model";
|
||||||
import { findParentNodeOfTypeClosestToPos } from "../../utils/prosemirror";
|
import { findParentNodeOfTypeClosestToPos } from "../../utils/prosemirror";
|
||||||
import { onArrowUpPressed } from "../list-item/commands";
|
|
||||||
import { OutlineList } from "../outline-list/outline-list";
|
import { OutlineList } from "../outline-list/outline-list";
|
||||||
|
|
||||||
export interface ListItemOptions {
|
export interface ListItemOptions {
|
||||||
@@ -110,8 +109,7 @@ export const OutlineListItem = Node.create<ListItemOptions>({
|
|||||||
return this.editor.commands.splitListItem(this.name);
|
return this.editor.commands.splitListItem(this.name);
|
||||||
},
|
},
|
||||||
Tab: () => this.editor.commands.sinkListItem(this.name),
|
Tab: () => this.editor.commands.sinkListItem(this.name),
|
||||||
"Shift-Tab": () => this.editor.commands.liftListItem(this.name),
|
"Shift-Tab": () => this.editor.commands.liftListItem(this.name)
|
||||||
ArrowUp: ({ editor }) => onArrowUpPressed(editor, this.name, this.type)
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
21
packages/editor/src/extensions/quirks/index.ts
Normal file
21
packages/editor/src/extensions/quirks/index.ts
Normal 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";
|
||||||
115
packages/editor/src/extensions/quirks/quirks.ts
Normal file
115
packages/editor/src/extensions/quirks/quirks.ts
Normal 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>");
|
||||||
|
}
|
||||||
@@ -121,7 +121,7 @@ export function TaskItemComponent(
|
|||||||
toggle();
|
toggle();
|
||||||
}}
|
}}
|
||||||
onTouchEnd={(e) => {
|
onTouchEnd={(e) => {
|
||||||
if (globalThis["keyboardShown"] || isiOS()) {
|
if (globalThis["keyboardShown"] || isiOS) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toggle();
|
toggle();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { mergeAttributes } from "@tiptap/core";
|
import { mergeAttributes } from "@tiptap/core";
|
||||||
import { onArrowUpPressed } from "../list-item/commands";
|
|
||||||
import { TaskItem } from "@tiptap/extension-task-item";
|
import { TaskItem } from "@tiptap/extension-task-item";
|
||||||
import { TaskItemComponent } from "./component";
|
import { TaskItemComponent } from "./component";
|
||||||
import { createNodeView } from "../react";
|
import { createNodeView } from "../react";
|
||||||
@@ -73,8 +72,7 @@ export const TaskItemNode = TaskItem.extend({
|
|||||||
|
|
||||||
addKeyboardShortcuts() {
|
addKeyboardShortcuts() {
|
||||||
return {
|
return {
|
||||||
...this.parent?.(),
|
...this.parent?.()
|
||||||
ArrowUp: ({ editor }) => onArrowUpPressed(editor, this.name, this.type)
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ import { DownloadOptions } from "./utils/downloader";
|
|||||||
import { Heading } from "./extensions/heading";
|
import { Heading } from "./extensions/heading";
|
||||||
import Clipboard, { ClipboardOptions } from "./extensions/clipboard";
|
import Clipboard, { ClipboardOptions } from "./extensions/clipboard";
|
||||||
import Blockquote from "./extensions/blockquote";
|
import Blockquote from "./extensions/blockquote";
|
||||||
|
import { Quirks } from "./extensions/quirks";
|
||||||
|
import { LIST_NODE_TYPES } from "./utils/node-types";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
// eslint-disable-next-line no-var
|
// eslint-disable-next-line no-var
|
||||||
@@ -220,22 +222,6 @@ const useTiptap = (
|
|||||||
OrderedList.configure({ keepMarks: true, keepAttributes: true }),
|
OrderedList.configure({ keepMarks: true, keepAttributes: true }),
|
||||||
TaskItemNode.configure({ nested: true }),
|
TaskItemNode.configure({ nested: true }),
|
||||||
TaskListNode,
|
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({
|
Link.extend({
|
||||||
inclusive: true
|
inclusive: true
|
||||||
}).configure({
|
}).configure({
|
||||||
@@ -286,7 +272,38 @@ const useTiptap = (
|
|||||||
}),
|
}),
|
||||||
DateTime.configure({ dateFormat, timeFormat }),
|
DateTime.configure({ dateFormat, timeFormat }),
|
||||||
KeyMap,
|
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 }) => {
|
onBeforeCreate: ({ editor }) => {
|
||||||
editor.storage.portalProviderAPI = PortalProviderAPI;
|
editor.storage.portalProviderAPI = PortalProviderAPI;
|
||||||
|
|||||||
@@ -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/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function isiOS(): boolean {
|
export const isiOS =
|
||||||
return (
|
[
|
||||||
[
|
"iPad Simulator",
|
||||||
"iPad Simulator",
|
"iPhone Simulator",
|
||||||
"iPhone Simulator",
|
"iPod Simulator",
|
||||||
"iPod Simulator",
|
"iPad",
|
||||||
"iPad",
|
"iPhone",
|
||||||
"iPhone",
|
"iPod"
|
||||||
"iPod"
|
].includes(navigator.platform) ||
|
||||||
].includes(navigator.platform) ||
|
// iPad on iOS 13 detection
|
||||||
// iPad on iOS 13 detection
|
(navigator.userAgent.includes("Mac") && "ontouchend" in document);
|
||||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document)
|
|
||||||
);
|
export const isAndroid =
|
||||||
}
|
navigator.userAgent.toLowerCase().indexOf("android") > -1;
|
||||||
|
|||||||
Reference in New Issue
Block a user