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
index e2f85384a..0405995a8 100644
--- a/packages/editor/src/extensions/key-map/__tests__/key-map.test.ts
+++ b/packages/editor/src/extensions/key-map/__tests__/key-map.test.ts
@@ -21,6 +21,8 @@ import {
createEditor,
h,
p,
+ ul,
+ li,
outlineList,
outlineListItem
} from "../../../../test-utils/index.js";
@@ -28,6 +30,8 @@ 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 () => {
@@ -135,4 +139,34 @@ describe("key-map", () => {
).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(
+ `
para 1
para 2
`
+ );
+ });
});
diff --git a/packages/editor/src/extensions/key-map/key-map.ts b/packages/editor/src/extensions/key-map/key-map.ts
index d84917c6f..2c979083a 100644
--- a/packages/editor/src/extensions/key-map/key-map.ts
+++ b/packages/editor/src/extensions/key-map/key-map.ts
@@ -17,27 +17,18 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
-import { Editor, Extension } from "@tiptap/core";
-import { CodeBlock } from "../code-block/index.js";
+import { tiptapKeys } from "@notesnook/common";
+import { Extension } from "@tiptap/core";
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 {
- findParentNodeClosestToPos,
- 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";
-import { Blockquote } from "../blockquote/blockquote.js";
-import { Table } from "../table/table.js";
-import { BulletList } from "../bullet-list/bullet-list.js";
-import OrderedList from "@tiptap/extension-ordered-list";
-import { TaskListNode } from "../task-list/task-list.js";
-import { CheckList } from "../check-list/check-list.js";
-import { OutlineList } from "../outline-list/outline-list.js";
+ moveNodeUp,
+ moveNodeDown,
+ moveParentUp,
+ moveParentDown
+} from "./move-node.js";
export const KeyMap = Extension.create({
name: "key-map",
@@ -89,7 +80,7 @@ export const KeyMap = Extension.create({
},
[tiptapKeys.moveLineUp.keys]: ({ editor }) => {
try {
- return moveNode(editor, "up", "itself");
+ return moveNodeUp(editor);
} catch (e) {
console.error("Error moving node up:", e);
return false;
@@ -97,7 +88,7 @@ export const KeyMap = Extension.create({
},
[tiptapKeys.moveLineDown.keys]: ({ editor }) => {
try {
- return moveNode(editor, "down", "itself");
+ return moveNodeDown(editor);
} catch (e) {
console.error("Error moving node down:", e);
return false;
@@ -105,7 +96,7 @@ export const KeyMap = Extension.create({
},
[tiptapKeys.moveNodeUp.keys]: ({ editor }) => {
try {
- return moveNode(editor, "up", "parent");
+ return moveParentUp(editor);
} catch (e) {
console.error("Error moving node up:", e);
return false;
@@ -113,7 +104,7 @@ export const KeyMap = Extension.create({
},
[tiptapKeys.moveNodeDown.keys]: ({ editor }) => {
try {
- return moveNode(editor, "down", "parent");
+ return moveParentDown(editor);
} catch (e) {
console.error("Error moving node down:", e);
return false;
@@ -122,145 +113,3 @@ export const KeyMap = Extension.create({
};
}
});
-
-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
- * @param mode - "itself" | "parent" - whether to move the current node itself or its immediate valid parent node
- */
-function moveNode(
- editor: Editor,
- dir: "up" | "down",
- mode: "itself" | "parent"
-) {
- const isDown = dir === "down";
- const { state } = editor;
- if (!state.selection.empty) {
- return false;
- }
-
- const { $from } = state.selection;
-
- let targetType = $from.node().type;
- let currentResolved = findParentNodeOfType(targetType)(state.selection);
-
- if (mode === "parent") {
- const validParents = [
- Callout.name,
- Table.name,
- BulletList.name,
- OrderedList.name,
- TaskListNode.name,
- CheckList.name,
- OutlineList.name,
- Blockquote.name
- ];
- const parent = findParentNodeClosestToPos($from, (node) =>
- validParents.includes(node.type.name)
- );
- if (parent) {
- targetType = parent.node.type;
- currentResolved = findParentNodeOfType(targetType)(state.selection);
- } else {
- currentResolved = undefined;
- }
- } else {
- if (isListActive(editor)) {
- const currentNode = $from.node();
- const parentNode = $from.node($from.depth - 1);
- const isFirstParagraph =
- currentNode.type.name === "paragraph" &&
- parentNode.firstChild === currentNode;
-
- // move the entire list item
- if (isFirstParagraph) {
- targetType = $from.node($from.depth - 1).type;
- if (
- targetType.name === Callout.name ||
- targetType.name === Blockquote.name
- ) {
- targetType = $from.node($from.depth - 2).type;
- }
- }
-
- currentResolved = findParentNodeOfType(targetType)(state.selection);
- }
-
- if (
- findParentNodeClosestToPos(
- $from,
- (node) => node.type.name === Callout.name
- )
- ) {
- const currentNode = $from.node();
- const parentNode = $from.node($from.depth - 1);
- const isFirstHeading =
- currentNode.type.name === "heading" &&
- parentNode.firstChild === currentNode;
-
- // move the entire callout
- if (isFirstHeading) {
- targetType = $from.node($from.depth - 1).type;
- }
-
- currentResolved = findParentNodeOfType(targetType)(state.selection);
- }
- }
-
- 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;
- }
-
- 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;
-}
diff --git a/packages/editor/src/extensions/key-map/move-node.ts b/packages/editor/src/extensions/key-map/move-node.ts
new file mode 100644
index 000000000..88b6ad899
--- /dev/null
+++ b/packages/editor/src/extensions/key-map/move-node.ts
@@ -0,0 +1,215 @@
+/*
+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 { 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 { isListActive } from "../../utils/list.js";
+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";
+
+type ResolvedNode = {
+ pos: number;
+ start: number;
+ depth: number;
+ node: Node;
+};
+
+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;
+}
+
+function resolveNode(editor: Editor) {
+ const { state } = editor;
+ const { $from } = state.selection;
+
+ let targetType = $from.node().type;
+ let currentResolved = findParentNodeOfType(targetType)(state.selection);
+
+ if (isListActive(editor)) {
+ const currentNode = $from.node();
+ const parentNode = $from.node($from.depth - 1);
+ const isFirstParagraph =
+ currentNode.type.name === "paragraph" &&
+ parentNode.firstChild === currentNode;
+
+ // move the entire list item
+ if (isFirstParagraph) {
+ targetType = $from.node($from.depth - 1).type;
+ if (
+ targetType.name === Callout.name ||
+ targetType.name === Blockquote.name
+ ) {
+ targetType = $from.node($from.depth - 2).type;
+ }
+ }
+
+ currentResolved = findParentNodeOfType(targetType)(state.selection);
+ }
+
+ if (
+ findParentNodeClosestToPos($from, (node) => node.type.name === Callout.name)
+ ) {
+ const currentNode = $from.node();
+ const parentNode = $from.node($from.depth - 1);
+ const isFirstHeading =
+ currentNode.type.name === "heading" &&
+ parentNode.firstChild === currentNode;
+
+ // move the entire callout
+ if (isFirstHeading) {
+ targetType = $from.node($from.depth - 1).type;
+ }
+
+ currentResolved = findParentNodeOfType(targetType)(state.selection);
+ }
+
+ return currentResolved;
+}
+
+function resolveParentNode(editor: Editor) {
+ const { state } = editor;
+ const { $from } = state.selection;
+
+ const validParents = [
+ Callout.name,
+ Table.name,
+ BulletList.name,
+ OrderedList.name,
+ TaskListNode.name,
+ CheckList.name,
+ OutlineList.name,
+ Blockquote.name
+ ];
+
+ const parent = findParentNodeClosestToPos($from, (node) =>
+ validParents.includes(node.type.name)
+ );
+
+ if (!parent) return undefined;
+
+ const targetType = parent.node.type;
+ return findParentNodeOfType(targetType)(state.selection);
+}
+
+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);
+}