mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-05-18 05:05:36 +02:00
editor: add move line up/down keybinding (#7078)
* editor: add move line up/down keybinding Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> * docs: update keyboard shortcuts * editor: add missing changes * editor: add move parent node up/down keyboard shortcut Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> * editor: refactor moveNode fn && add a test case Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> * editor: improve move node logic Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> --------- Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
This commit is contained in:
@@ -78,4 +78,8 @@ The following keyboard shortcuts will help you navigate Notesnook faster.
|
||||
| Text align right | Ctrl ⇧ R | Ctrl ⇧ R | ⌘ ⇧ R |
|
||||
| Underline | Ctrl U | Ctrl U | ⌘ U |
|
||||
| Toggle highlight | Ctrl Alt H | Ctrl Alt H | ⌘ ⌥ H |
|
||||
| Toggle text color | Ctrl Alt C | Ctrl Alt C | ⌘ ⌥ C |
|
||||
| Toggle text color | Ctrl Alt C | Ctrl Alt C | ⌘ ⌥ C |
|
||||
| Move line up | Alt ↑ | Alt ↑ | ⌥ ↑ |
|
||||
| Move line down | Alt ↓ | Alt ↓ | ⌥ ↓ |
|
||||
| Move parent node up | Alt ⇧ ↑ | Alt ⇧ ↑ | ⌥ ⇧ ↑ |
|
||||
| Move parent node down | Alt ⇧ ↓ | Alt ⇧ ↓ | ⌥ ⇧ ↓ |
|
||||
|
||||
@@ -409,6 +409,30 @@ export const tiptapKeys = {
|
||||
description: "Toggle text color",
|
||||
category: "Editor",
|
||||
type: "tiptap"
|
||||
},
|
||||
moveLineUp: {
|
||||
keys: "Alt-ArrowUp",
|
||||
description: "Move line up",
|
||||
category: "Editor",
|
||||
type: "tiptap"
|
||||
},
|
||||
moveLineDown: {
|
||||
keys: "Alt-ArrowDown",
|
||||
description: "Move line down",
|
||||
category: "Editor",
|
||||
type: "tiptap"
|
||||
},
|
||||
moveNodeUp: {
|
||||
keys: "Alt-Shift-ArrowUp",
|
||||
description: "Move parent node up",
|
||||
category: "Editor",
|
||||
type: "tiptap"
|
||||
},
|
||||
moveNodeDown: {
|
||||
keys: "Alt-Shift-ArrowDown",
|
||||
description: "Move parent node down",
|
||||
category: "Editor",
|
||||
type: "tiptap"
|
||||
}
|
||||
} satisfies Record<string, TipTapKey>;
|
||||
|
||||
@@ -466,6 +490,8 @@ export function formatKey(key: string, isMac = false, separator = " ") {
|
||||
.replace(/\bright\b/gi, "→")
|
||||
.replace(/\bleft\b/gi, "←")
|
||||
.replace(/\benter\b/gi, "↵")
|
||||
.replace(/\barrowup\b/gi, "↑")
|
||||
.replace(/\barrowdown\b/gi, "↓")
|
||||
.replace(/\b\w\b/gi, (e) => e.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
172
packages/editor/src/extensions/key-map/__tests__/key-map.test.ts
Normal file
172
packages/editor/src/extensions/key-map/__tests__/key-map.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
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 {
|
||||
createEditor,
|
||||
h,
|
||||
p,
|
||||
ul,
|
||||
li,
|
||||
outlineList,
|
||||
outlineListItem
|
||||
} from "../../../../test-utils/index.js";
|
||||
import { test, expect, describe } from "vitest";
|
||||
import { KeyMap } from "../key-map.js";
|
||||
import { OutlineList } from "../../outline-list/outline-list.js";
|
||||
import { OutlineListItem } from "../../outline-list-item/outline-list-item.js";
|
||||
import { BulletList } from "../../bullet-list/bullet-list.js";
|
||||
import { ListItem } from "../../list-item/list-item.js";
|
||||
|
||||
describe("key-map", () => {
|
||||
test("move paragraph up", async () => {
|
||||
const el = h("div", [p(["para 1"]), p(["para 2"]), p(["para 3"])]);
|
||||
const editorElement = h("div");
|
||||
const { editor } = createEditor({
|
||||
element: editorElement,
|
||||
initialContent: el.outerHTML,
|
||||
extensions: {
|
||||
KeyMap: KeyMap
|
||||
}
|
||||
});
|
||||
|
||||
editor.commands.setTextSelection({ from: 10, to: 10 });
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "ArrowUp",
|
||||
altKey: true
|
||||
});
|
||||
editor.view.dom.dispatchEvent(event);
|
||||
|
||||
expect(editor.getHTML()).toBe(`<p>para 2</p><p>para 1</p><p>para 3</p>`);
|
||||
});
|
||||
|
||||
test("move paragraph down", async () => {
|
||||
const el = h("div", [p(["para 1"]), p(["para 2"]), p(["para 3"])]);
|
||||
const editorElement = h("div");
|
||||
const { editor } = createEditor({
|
||||
element: editorElement,
|
||||
initialContent: el.outerHTML,
|
||||
extensions: {
|
||||
KeyMap: KeyMap
|
||||
}
|
||||
});
|
||||
|
||||
editor.commands.setTextSelection(0);
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "ArrowDown",
|
||||
altKey: true
|
||||
});
|
||||
editor.view.dom.dispatchEvent(event);
|
||||
|
||||
expect(editor.getHTML()).toBe(`<p>para 2</p><p>para 1</p><p>para 3</p>`);
|
||||
});
|
||||
|
||||
test("move outline list item up", async () => {
|
||||
const el = outlineList(
|
||||
outlineListItem(["item 1"]),
|
||||
outlineListItem(["item 2"]),
|
||||
outlineListItem(["item 3"])
|
||||
);
|
||||
const editorElement = h("div");
|
||||
const { editor } = createEditor({
|
||||
element: editorElement,
|
||||
initialContent: el.outerHTML,
|
||||
extensions: {
|
||||
KeyMap: KeyMap,
|
||||
outlineList: OutlineList,
|
||||
outlineListItem: OutlineListItem
|
||||
}
|
||||
});
|
||||
|
||||
editor.commands.setTextSelection({ from: 15, to: 15 });
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "ArrowUp",
|
||||
altKey: true
|
||||
});
|
||||
editor.view.dom.dispatchEvent(event);
|
||||
|
||||
const expectedHTML = outlineList(
|
||||
outlineListItem(["item 2"]),
|
||||
outlineListItem(["item 1"]),
|
||||
outlineListItem(["item 3"])
|
||||
).outerHTML;
|
||||
expect(editor.getHTML()).toBe(expectedHTML);
|
||||
});
|
||||
|
||||
test("move outline list item down", async () => {
|
||||
const el = outlineList(
|
||||
outlineListItem(["item 1"]),
|
||||
outlineListItem(["item 2"]),
|
||||
outlineListItem(["item 3"])
|
||||
);
|
||||
const editorElement = h("div");
|
||||
const { editor } = createEditor({
|
||||
element: editorElement,
|
||||
initialContent: el.outerHTML,
|
||||
extensions: {
|
||||
KeyMap: KeyMap,
|
||||
outlineList: OutlineList,
|
||||
outlineListItem: OutlineListItem
|
||||
}
|
||||
});
|
||||
|
||||
editor.commands.setTextSelection(0);
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "ArrowDown",
|
||||
altKey: true
|
||||
});
|
||||
editor.view.dom.dispatchEvent(event);
|
||||
|
||||
const expectedHTML = outlineList(
|
||||
outlineListItem(["item 2"]),
|
||||
outlineListItem(["item 1"]),
|
||||
outlineListItem(["item 3"])
|
||||
).outerHTML;
|
||||
expect(editor.getHTML()).toBe(expectedHTML);
|
||||
});
|
||||
|
||||
test("move entire bullet list down", async () => {
|
||||
const el = h("div", [
|
||||
p(["para 1"]),
|
||||
ul([li([p(["list item 1"])]), li([p(["list item 2"])])]),
|
||||
p(["para 2"])
|
||||
]);
|
||||
const editorElement = h("div");
|
||||
const { editor } = createEditor({
|
||||
element: editorElement,
|
||||
initialContent: el.innerHTML,
|
||||
extensions: {
|
||||
KeyMap: KeyMap,
|
||||
bulletList: BulletList,
|
||||
listItem: ListItem
|
||||
}
|
||||
});
|
||||
|
||||
editor.commands.setTextSelection({ from: 10, to: 10 });
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "ArrowDown",
|
||||
altKey: true,
|
||||
shiftKey: true
|
||||
});
|
||||
editor.view.dom.dispatchEvent(event);
|
||||
|
||||
expect(editor.getHTML()).toBe(
|
||||
`<p>para 1</p><p>para 2</p><ul><li><p>list item 1</p></li><li><p>list item 2</p></li></ul>`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -17,14 +17,20 @@ 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 { tiptapKeys } from "@notesnook/common";
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { CodeBlock } from "../code-block/index.js";
|
||||
import { showLinkPopup } from "../../toolbar/popups/link-popup.js";
|
||||
import { isListActive } from "../../utils/list.js";
|
||||
import { tiptapKeys } from "@notesnook/common";
|
||||
import { CodeBlock } from "../code-block/index.js";
|
||||
import { isInTable } from "../table/prosemirror-tables/util.js";
|
||||
import { config } from "../../utils/config.js";
|
||||
import { DEFAULT_COLORS } from "../../toolbar/tools/colors.js";
|
||||
import {
|
||||
moveNodeUp,
|
||||
moveNodeDown,
|
||||
moveParentUp,
|
||||
moveParentDown
|
||||
} from "./move-node.js";
|
||||
|
||||
export const KeyMap = Extension.create({
|
||||
name: "key-map",
|
||||
@@ -78,6 +84,38 @@ export const KeyMap = Extension.create({
|
||||
const color =
|
||||
config.get<"string">("textColor") || DEFAULT_COLORS.text[0];
|
||||
return editor.commands.toggleMark("textStyle", { color });
|
||||
},
|
||||
[tiptapKeys.moveLineUp.keys]: ({ editor }) => {
|
||||
try {
|
||||
return moveNodeUp(editor);
|
||||
} catch (e) {
|
||||
console.error("Error moving node up:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[tiptapKeys.moveLineDown.keys]: ({ editor }) => {
|
||||
try {
|
||||
return moveNodeDown(editor);
|
||||
} catch (e) {
|
||||
console.error("Error moving node down:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[tiptapKeys.moveNodeUp.keys]: ({ editor }) => {
|
||||
try {
|
||||
return moveParentUp(editor);
|
||||
} catch (e) {
|
||||
console.error("Error moving node up:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[tiptapKeys.moveNodeDown.keys]: ({ editor }) => {
|
||||
try {
|
||||
return moveParentDown(editor);
|
||||
} catch (e) {
|
||||
console.error("Error moving node down:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
217
packages/editor/src/extensions/key-map/move-node.ts
Normal file
217
packages/editor/src/extensions/key-map/move-node.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
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 } from "@tiptap/core";
|
||||
import OrderedList from "@tiptap/extension-ordered-list";
|
||||
import { Fragment, Node, Slice } from "@tiptap/pm/model";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { ReplaceStep } from "@tiptap/pm/transform";
|
||||
import {
|
||||
findParentNodeClosestToPos,
|
||||
findParentNodeOfType
|
||||
} from "../../utils/prosemirror.js";
|
||||
import { Blockquote } from "../blockquote/blockquote.js";
|
||||
import { BulletList } from "../bullet-list/bullet-list.js";
|
||||
import { Callout } from "../callout/callout.js";
|
||||
import { CheckList } from "../check-list/check-list.js";
|
||||
import { OutlineList } from "../outline-list/outline-list.js";
|
||||
import { Table } from "../table/table.js";
|
||||
import { TaskListNode } from "../task-list/task-list.js";
|
||||
import { ListItem } from "../list-item/list-item.js";
|
||||
import { CheckListItem } from "../check-list-item/check-list-item.js";
|
||||
import { TaskItemNode } from "../task-item/task-item.js";
|
||||
import { OutlineListItem } from "../outline-list-item/outline-list-item.js";
|
||||
|
||||
type ResolvedNode = {
|
||||
start: number;
|
||||
depth: number;
|
||||
node: Node;
|
||||
};
|
||||
|
||||
function mapChildren<T>(
|
||||
node: Node | Fragment,
|
||||
callback: (child: Node, index: number, frag: Fragment) => T
|
||||
): T[] {
|
||||
const array = [];
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
array.push(
|
||||
callback(node.child(i), i, node instanceof Fragment ? node : node.content)
|
||||
);
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
const listItems = [
|
||||
ListItem.name,
|
||||
CheckListItem.name,
|
||||
TaskItemNode.name,
|
||||
OutlineListItem.name
|
||||
];
|
||||
|
||||
function resolveNode(editor: Editor): ResolvedNode | undefined {
|
||||
const { state } = editor;
|
||||
const { $from } = state.selection;
|
||||
|
||||
const currentNode = $from.node();
|
||||
const parentNode = $from.node($from.depth - 1);
|
||||
let targetType = currentNode.type;
|
||||
let currentResolved: ResolvedNode | undefined = {
|
||||
start: $from.start(),
|
||||
depth: $from.depth,
|
||||
node: currentNode
|
||||
};
|
||||
|
||||
if (listItems.includes(parentNode.type.name)) {
|
||||
const isFirstParagraph =
|
||||
currentNode.type.name === "paragraph" &&
|
||||
parentNode.firstChild === currentNode;
|
||||
|
||||
if (isFirstParagraph) {
|
||||
targetType = parentNode.type;
|
||||
currentResolved = findParentNodeOfType(targetType)(state.selection);
|
||||
}
|
||||
}
|
||||
|
||||
if (parentNode.type.name === Callout.name) {
|
||||
const isFirstHeading =
|
||||
currentNode.type.name === "heading" &&
|
||||
parentNode.firstChild === currentNode;
|
||||
|
||||
if (isFirstHeading) {
|
||||
targetType = parentNode.type;
|
||||
currentResolved = findParentNodeOfType(targetType)(state.selection);
|
||||
}
|
||||
}
|
||||
|
||||
return currentResolved;
|
||||
}
|
||||
|
||||
const validParents = [
|
||||
Callout.name,
|
||||
Table.name,
|
||||
BulletList.name,
|
||||
OrderedList.name,
|
||||
TaskListNode.name,
|
||||
CheckList.name,
|
||||
OutlineList.name,
|
||||
Blockquote.name
|
||||
];
|
||||
|
||||
function resolveParentNode(editor: Editor): ResolvedNode | undefined {
|
||||
const { state } = editor;
|
||||
const { $from } = state.selection;
|
||||
|
||||
const parent = findParentNodeClosestToPos($from, (node) =>
|
||||
validParents.includes(node.type.name)
|
||||
);
|
||||
|
||||
if (!parent) return undefined;
|
||||
|
||||
return {
|
||||
start: parent.start,
|
||||
depth: parent.depth,
|
||||
node: parent.node
|
||||
};
|
||||
}
|
||||
|
||||
function swapNodeWithSibling(
|
||||
editor: Editor,
|
||||
resolvedNode: ResolvedNode,
|
||||
isDown: boolean
|
||||
): boolean {
|
||||
const { state } = editor;
|
||||
const { $from } = state.selection;
|
||||
|
||||
const parentDepth = resolvedNode.depth - 1;
|
||||
const parent = $from.node(parentDepth);
|
||||
const parentPos = $from.start(parentDepth);
|
||||
|
||||
let arr = mapChildren(parent, (node) => node);
|
||||
let index = arr.indexOf(resolvedNode.node);
|
||||
let swapWith = isDown ? index + 1 : index - 1;
|
||||
|
||||
if (swapWith >= arr.length || swapWith < 0) {
|
||||
return false;
|
||||
}
|
||||
if (swapWith === 0 && parent.type.name === Callout.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const swapWithNodeSize = arr[swapWith].nodeSize;
|
||||
|
||||
[arr[index], arr[swapWith]] = [arr[swapWith], arr[index]];
|
||||
|
||||
let tr = state.tr;
|
||||
let replaceStart = parentPos;
|
||||
let replaceEnd = $from.end(parentDepth);
|
||||
|
||||
const slice = new Slice(Fragment.fromArray(arr), 0, 0);
|
||||
|
||||
tr = tr.step(new ReplaceStep(replaceStart, replaceEnd, slice, false));
|
||||
tr = tr.setSelection(
|
||||
Selection.near(
|
||||
tr.doc.resolve(
|
||||
isDown ? $from.pos + swapWithNodeSize : $from.pos - swapWithNodeSize
|
||||
)
|
||||
)
|
||||
);
|
||||
tr.scrollIntoView();
|
||||
editor.view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function moveNodeUp(editor: Editor): boolean {
|
||||
const { state } = editor;
|
||||
if (!state.selection.empty) return false;
|
||||
|
||||
const resolved = resolveNode(editor);
|
||||
if (!resolved) return false;
|
||||
|
||||
return swapNodeWithSibling(editor, resolved, false);
|
||||
}
|
||||
|
||||
export function moveNodeDown(editor: Editor): boolean {
|
||||
const { state } = editor;
|
||||
if (!state.selection.empty) return false;
|
||||
|
||||
const resolved = resolveNode(editor);
|
||||
if (!resolved) return false;
|
||||
|
||||
return swapNodeWithSibling(editor, resolved, true);
|
||||
}
|
||||
|
||||
export function moveParentUp(editor: Editor): boolean {
|
||||
const { state } = editor;
|
||||
if (!state.selection.empty) return false;
|
||||
|
||||
const resolved = resolveParentNode(editor);
|
||||
if (!resolved) return false;
|
||||
|
||||
return swapNodeWithSibling(editor, resolved, false);
|
||||
}
|
||||
|
||||
export function moveParentDown(editor: Editor): boolean {
|
||||
const { state } = editor;
|
||||
if (!state.selection.empty) return false;
|
||||
|
||||
const resolved = resolveParentNode(editor);
|
||||
if (!resolved) return false;
|
||||
|
||||
return swapNodeWithSibling(editor, resolved, true);
|
||||
}
|
||||
Reference in New Issue
Block a user