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