From dbac283928f0711fd0e8be7ecf4e9c00cb8984e1 Mon Sep 17 00:00:00 2001 From: 01zulfi <85733202+01zulfi@users.noreply.github.com> Date: Thu, 16 Oct 2025 14:15:47 +0500 Subject: [PATCH] editor: add move line up/down keybinding Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com> --- docs/help/contents/keyboard-shortcuts.md | 124 ++++++++-------- packages/common/src/utils/keybindings.ts | 14 ++ .../key-map/__tests__/key-map.test.ts | 138 ++++++++++++++++++ .../editor/src/extensions/key-map/key-map.ts | 122 +++++++++++++++- 4 files changed, 335 insertions(+), 63 deletions(-) create mode 100644 packages/editor/src/extensions/key-map/__tests__/key-map.test.ts diff --git a/docs/help/contents/keyboard-shortcuts.md b/docs/help/contents/keyboard-shortcuts.md index bd6a38f3d..00650b40c 100644 --- a/docs/help/contents/keyboard-shortcuts.md +++ b/docs/help/contents/keyboard-shortcuts.md @@ -9,71 +9,71 @@ The following keyboard shortcuts will help you navigate Notesnook faster. ### General -| Description | Web | Windows/Linux | Mac | -| -------------------------------------------------- | ------ | ------------- | --- | -| Search in notes list view if editor is not focused | Ctrl F | Ctrl F | ⌘ F | -| Settings | Ctrl , | Ctrl , | ⌘ , | -| Keyboard shortcuts | Ctrl / | Ctrl / | ⌘ / | -| New note | - | Ctrl N | ⌘ N | +| Description | Web | Windows/Linux | Mac | +| --- | --- | --- | --- | +| Search in notes list view if editor is not focused | Ctrl F | Ctrl F | ⌘ F | +| Settings | Ctrl , | Ctrl , | ⌘ , | +| Keyboard shortcuts | Ctrl / | Ctrl / | ⌘ / | +| New note | - | Ctrl N | ⌘ N | ### Navigation -| Description | Web | Windows/Linux | Mac | -| ---------------- | ------------------------- | ------------- | ------- | -| Next tab | Ctrl Alt → / Ctrl Alt ⇧ → | Ctrl tab | ⌘ tab | -| Previous tab | Ctrl Alt ← / Ctrl Alt ⇧ ← | Ctrl ⇧ tab | ⌘ ⇧ tab | -| Command palette | Ctrl ⇧ P | Ctrl ⇧ P | ⌘ ⇧ P | -| Quick open | Ctrl P | Ctrl P | ⌘ P | -| New tab | - | Ctrl T | ⌘ T | -| Close active tab | - | Ctrl W | ⌘ W | -| Close all tabs | - | Ctrl ⇧ W | ⌘ ⇧ W | +| Description | Web | Windows/Linux | Mac | +| --- | --- | --- | --- | +| Next tab | Ctrl Alt → / Ctrl Alt ⇧ → | Ctrl tab | ⌘ tab | +| Previous tab | Ctrl Alt ← / Ctrl Alt ⇧ ← | Ctrl ⇧ tab | ⌘ ⇧ tab | +| Command palette | Ctrl ⇧ P | Ctrl ⇧ P | ⌘ ⇧ P | +| Quick open | Ctrl P | Ctrl P | ⌘ P | +| New tab | - | Ctrl T | ⌘ T | +| Close active tab | - | Ctrl W | ⌘ W | +| Close all tabs | - | Ctrl ⇧ W | ⌘ ⇧ W | ### Editor -| Description | Web | Windows/Linux | Mac | -| ---------------------------------- | ----------------- | ----------------- | ----------- | -| Add attachment | Ctrl ⇧ A | Ctrl ⇧ A | ⌘ ⇧ A | -| Insert blockquote | Ctrl ⇧ B | Ctrl ⇧ B | ⌘ ⇧ B | -| Toggle bold | Ctrl B | Ctrl B | ⌘ B | -| Toggle bullet list | Ctrl ⇧ 8 | Ctrl ⇧ 8 | ⌘ ⇧ 8 | -| Toggle check list | Ctrl ⇧ 9 | Ctrl ⇧ 9 | ⌘ ⇧ 9 | -| Split list item | ↵ | ↵ | ↵ | -| Lift list item | ⇧ Tab | ⇧ Tab | ⇧ Tab | -| Sink list item | Ctrl ⇧ Down | Ctrl ⇧ Down | ⌘ ⇧ Down | -| Toggle code | Ctrl E | Ctrl E | ⌘ E | -| Toggle code block | Ctrl ⇧ C | Ctrl ⇧ C | ⌘ ⇧ C | -| Insert date | Alt D | Alt D | ⌥ D | -| Insert time | Alt T | Alt T | ⌥ T | -| Insert date and time | Ctrl Alt D | Ctrl Alt D | ⌘ ⌥ D | -| Insert date and time with timezone | Ctrl Alt Z | Ctrl Alt Z | ⌘ ⌥ Z | -| Increase font size | Ctrl [ | Ctrl [ | ⌘ [ | -| Decrease font size | Ctrl ] | Ctrl ] | ⌘ ] | -| Insert paragraph | Ctrl ⇧ 0 | Ctrl ⇧ 0 | ⌘ ⇧ 0 | -| Insert heading 1 | Ctrl Alt 1 | Ctrl Alt 1 | ⌘ ⌥ 1 | -| Insert heading 2 | Ctrl Alt 2 | Ctrl Alt 2 | ⌘ ⌥ 2 | -| Insert heading 3 | Ctrl Alt 3 | Ctrl Alt 3 | ⌘ ⌥ 3 | -| Insert heading 4 | Ctrl Alt 4 | Ctrl Alt 4 | ⌘ ⌥ 4 | -| Insert heading 5 | Ctrl Alt 5 | Ctrl Alt 5 | ⌘ ⌥ 5 | -| Insert heading 6 | Ctrl Alt 6 | Ctrl Alt 6 | ⌘ ⌥ 6 | -| Undo | Ctrl Z | Ctrl Z | ⌘ Z | -| Redo | Ctrl ⇧ Z / Ctrl Y | Ctrl ⇧ Z / Ctrl Y | ⌘ ⇧ Z / ⌘ Y | -| Add image | Ctrl ⇧ I | Ctrl ⇧ I | ⌘ ⇧ I | -| Toggle italic | Ctrl I | Ctrl I | ⌘ I | -| Remove formatting in selection | Ctrl \ | Ctrl \ | ⌘ \ | -| Insert internal link | Ctrl ⇧ K | Ctrl ⇧ K | ⌘ ⇧ K | -| Insert link | Ctrl K | Ctrl K | ⌘ K | -| Insert math block | Ctrl ⇧ M | Ctrl ⇧ M | ⌘ ⇧ M | -| Toggle ordered list | Ctrl ⇧ 7 | Ctrl ⇧ 7 | ⌘ ⇧ 7 | -| Toggle outline list | Ctrl ⇧ O | Ctrl ⇧ O | ⌘ ⇧ O | -| Toggle outline list expand | Ctrl Space | Ctrl Space | ⌘ Space | -| Open search | Ctrl F | Ctrl F | ⌘ F | -| Toggle strike | Ctrl ⇧ S | Ctrl ⇧ S | ⌘ ⇧ S | -| Toggle subscript | Ctrl , | Ctrl , | ⌘ , | -| Toggle superscript | Ctrl . | Ctrl . | ⌘ . | -| Toggle task list | Ctrl ⇧ T | Ctrl ⇧ T | ⌘ ⇧ T | -| Text align center | Ctrl ⇧ E | Ctrl ⇧ E | ⌘ ⇧ E | -| Text align justify | Ctrl ⇧ J | Ctrl ⇧ J | ⌘ ⇧ J | -| Text align left | Ctrl ⇧ L | Ctrl ⇧ L | ⌘ ⇧ L | -| Text align right | Ctrl ⇧ R | Ctrl ⇧ R | ⌘ ⇧ R | -| Underline | Ctrl U | Ctrl U | ⌘ U | -| Toggle highlight | Ctrl Alt H | Ctrl Alt H | ⌘ ⌥ H | +| Description | Web | Windows/Linux | Mac | +| --- | --- | --- | --- | +| Add attachment | Ctrl ⇧ A | Ctrl ⇧ A | ⌘ ⇧ A | +| Insert blockquote | Ctrl ⇧ B | Ctrl ⇧ B | ⌘ ⇧ B | +| Toggle bold | Ctrl B | Ctrl B | ⌘ B | +| Toggle bullet list | Ctrl ⇧ 8 | Ctrl ⇧ 8 | ⌘ ⇧ 8 | +| Toggle check list | Ctrl ⇧ 9 | Ctrl ⇧ 9 | ⌘ ⇧ 9 | +| Split list item | ↵ | ↵ | ↵ | +| Lift list item | ⇧ Tab | ⇧ Tab | ⇧ Tab | +| Sink list item | Ctrl ⇧ Down | Ctrl ⇧ Down | ⌘ ⇧ Down | +| Toggle code | Ctrl E | Ctrl E | ⌘ E | +| Toggle code block | Ctrl ⇧ C | Ctrl ⇧ C | ⌘ ⇧ C | +| Insert date | Alt D | Alt D | ⌥ D | +| Insert time | Alt T | Alt T | ⌥ T | +| Insert date and time | Ctrl Alt D | Ctrl Alt D | ⌘ ⌥ D | +| Insert date and time with timezone | Ctrl Alt Z | Ctrl Alt Z | ⌘ ⌥ Z | +| Increase font size | Ctrl [ | Ctrl [ | ⌘ [ | +| Decrease font size | Ctrl ] | Ctrl ] | ⌘ ] | +| Insert paragraph | Ctrl Alt 0 | Ctrl Alt 0 | ⌘ ⌥ 0 | +| Insert heading 1 | Ctrl Alt 1 | Ctrl Alt 1 | ⌘ ⌥ 1 | +| Insert heading 2 | Ctrl Alt 2 | Ctrl Alt 2 | ⌘ ⌥ 2 | +| Insert heading 3 | Ctrl Alt 3 | Ctrl Alt 3 | ⌘ ⌥ 3 | +| Insert heading 4 | Ctrl Alt 4 | Ctrl Alt 4 | ⌘ ⌥ 4 | +| Insert heading 5 | Ctrl Alt 5 | Ctrl Alt 5 | ⌘ ⌥ 5 | +| Insert heading 6 | Ctrl Alt 6 | Ctrl Alt 6 | ⌘ ⌥ 6 | +| Undo | Ctrl Z | Ctrl Z | ⌘ Z | +| Redo | Ctrl ⇧ Z / Ctrl Y | Ctrl ⇧ Z / Ctrl Y | ⌘ ⇧ Z / ⌘ Y | +| Add image | Ctrl ⇧ I | Ctrl ⇧ I | ⌘ ⇧ I | +| Toggle italic | Ctrl I | Ctrl I | ⌘ I | +| Remove formatting in selection | Ctrl \ | Ctrl \ | ⌘ \ | +| Insert internal link | Ctrl ⇧ K | Ctrl ⇧ K | ⌘ ⇧ K | +| Insert link | Ctrl K | Ctrl K | ⌘ K | +| Insert math block | Ctrl ⇧ M | Ctrl ⇧ M | ⌘ ⇧ M | +| Toggle ordered list | Ctrl ⇧ 7 | Ctrl ⇧ 7 | ⌘ ⇧ 7 | +| Toggle outline list | Ctrl ⇧ O | Ctrl ⇧ O | ⌘ ⇧ O | +| Toggle outline list expand | Ctrl Space | Ctrl Space | ⌘ Space | +| Open search | Ctrl F | Ctrl F | ⌘ F | +| Toggle strike | Ctrl ⇧ S | Ctrl ⇧ S | ⌘ ⇧ S | +| Toggle subscript | Ctrl , | Ctrl , | ⌘ , | +| Toggle superscript | Ctrl . | Ctrl . | ⌘ . | +| Toggle task list | Ctrl ⇧ T | Ctrl ⇧ T | ⌘ ⇧ T | +| Text align center | Ctrl ⇧ E | Ctrl ⇧ E | ⌘ ⇧ E | +| Text align justify | Ctrl ⇧ J | Ctrl ⇧ J | ⌘ ⇧ J | +| Text align left | Ctrl ⇧ L | Ctrl ⇧ L | ⌘ ⇧ L | +| Text align right | Ctrl ⇧ R | Ctrl ⇧ R | ⌘ ⇧ R | +| Underline | Ctrl U | Ctrl U | ⌘ U | +| Toggle highlight | Ctrl Alt H | Ctrl Alt H | ⌘ ⌥ H | \ No newline at end of file diff --git a/packages/common/src/utils/keybindings.ts b/packages/common/src/utils/keybindings.ts index 031e85d4e..e1563d1c0 100644 --- a/packages/common/src/utils/keybindings.ts +++ b/packages/common/src/utils/keybindings.ts @@ -394,6 +394,18 @@ export const tiptapKeys = { description: "Toggle highlight", 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" } } satisfies Record; @@ -451,6 +463,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(); } diff --git a/packages/editor/src/extensions/key-map/__tests__/key-map.test.ts b/packages/editor/src/extensions/key-map/__tests__/key-map.test.ts new file mode 100644 index 000000000..e2f85384a --- /dev/null +++ b/packages/editor/src/extensions/key-map/__tests__/key-map.test.ts @@ -0,0 +1,138 @@ +/* +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 . +*/ + +import { + createEditor, + h, + p, + 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"; + +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(`

para 2

para 1

para 3

`); + }); + + 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(`

para 2

para 1

para 3

`); + }); + + 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); + }); +}); diff --git a/packages/editor/src/extensions/key-map/key-map.ts b/packages/editor/src/extensions/key-map/key-map.ts index 49627861a..0c0c48b0e 100644 --- a/packages/editor/src/extensions/key-map/key-map.ts +++ b/packages/editor/src/extensions/key-map/key-map.ts @@ -17,12 +17,17 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -import { Extension } from "@tiptap/core"; +import { Editor, 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 { isInTable } from "../table/prosemirror-tables/util.js"; +import { findParentNodeOfType } from "../../utils/prosemirror.js"; +import { Fragment, Node, Slice } from "@tiptap/pm/model"; +import { ReplaceStep } from "@tiptap/pm/transform"; +import { Selection } from "@tiptap/pm/state"; +import { Callout } from "../callout/callout.js"; export const KeyMap = Extension.create({ name: "key-map", @@ -71,7 +76,122 @@ export const KeyMap = Extension.create({ [tiptapKeys.insertLink.keys]: ({ editor }) => { showLinkPopup(editor); return true; + }, + [tiptapKeys.moveLineUp.keys]: ({ editor }) => { + return moveNode(editor, "up"); + }, + [tiptapKeys.moveLineDown.keys]: ({ editor }) => { + return moveNode(editor, "down"); } }; } }); + +function mapChildren( + 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; +} + +/** + * implementation inspired from https://discuss.prosemirror.net/t/keymap-to-move-a-line/3645/5 + */ +function moveNode(editor: Editor, dir: "up" | "down") { + const isDown = dir === "down"; + const { state } = editor; + if (!state.selection.empty) { + return false; + } + + const { $from } = state.selection; + + let currentResolved; + let targetType; + + const outlineListItem = findParentNodeOfType( + editor.schema.nodes.outlineListItem + )(state.selection); + const taskItem = findParentNodeOfType(editor.schema.nodes.taskItem)( + state.selection + ); + const listItem = findParentNodeOfType(editor.schema.nodes.listItem)( + state.selection + ); + if (outlineListItem) { + currentResolved = outlineListItem; + targetType = editor.schema.nodes.outlineListItem; + } else if (taskItem) { + currentResolved = taskItem; + targetType = editor.schema.nodes.taskItem; + } else if (listItem) { + currentResolved = listItem; + targetType = editor.schema.nodes.listItem; + } else { + const type = $from.node().type; + currentResolved = findParentNodeOfType(type)(state.selection); + targetType = type; + } + + if (!currentResolved) { + return false; + } + + const { node: currentNode } = currentResolved; + const parentDepth = currentResolved.depth - 1; + const parent = $from.node(parentDepth); + const parentPos = $from.start(parentDepth); + + if (currentNode.type !== targetType) { + return false; + } + + if ( + (targetType === editor.schema.nodes.outlineListItem && + parent.type.name !== "outlineList") || + (targetType === editor.schema.nodes.taskItem && + parent.type.name !== "taskList") || + (targetType === editor.schema.nodes.listItem && + !["bulletList", "orderedList"].includes(parent.type.name)) + ) { + return false; + } + + let arr = mapChildren(parent, (node) => node); + let index = arr.indexOf(currentNode); + 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; +}