]> BookStack Code Mirror - bookstack/blobdiff - resources/js/wysiwyg/services/keyboard-handling.ts
Lexical: Added tests to cover recent changes
[bookstack] / resources / js / wysiwyg / services / keyboard-handling.ts
index 3f0b0c495e0c724337dd5f8831ca62c5c1898630..a7f1ec7f0b08342aaa277321b3afa0f4fbdce12a 100644 (file)
@@ -3,19 +3,20 @@ import {
     $createParagraphNode,
     $getSelection,
     $isDecoratorNode,
-    COMMAND_PRIORITY_LOW,
+    COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND,
     KEY_BACKSPACE_COMMAND,
     KEY_DELETE_COMMAND,
     KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
     LexicalEditor,
     LexicalNode
 } from "lexical";
-import {$isImageNode} from "../nodes/image";
-import {$isMediaNode} from "../nodes/media";
+import {$isImageNode} from "@lexical/rich-text/LexicalImageNode";
+import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode";
 import {getLastSelection} from "../utils/selection";
-import {$getNearestNodeBlockParent} from "../utils/nodes";
-import {$isCustomListItemNode} from "../nodes/custom-list-item";
+import {$getNearestNodeBlockParent, $getParentOfType} from "../utils/nodes";
 import {$setInsetForSelection} from "../utils/lists";
+import {$isListItemNode} from "@lexical/list";
+import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
 
 function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
     if (nodes.length === 1) {
@@ -28,6 +29,10 @@ function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
     return false;
 }
 
+/**
+ * Delete the current node in the selection if the selection contains a single
+ * selected node (like image, media etc...).
+ */
 function deleteSingleSelectedNode(editor: LexicalEditor) {
     const selectionNodes = getLastSelection(editor)?.getNodes() || [];
     if (isSingleSelectedNode(selectionNodes)) {
@@ -37,6 +42,10 @@ function deleteSingleSelectedNode(editor: LexicalEditor) {
     }
 }
 
+/**
+ * Insert a new empty node before/after the selection if the selection contains a single
+ * selected node (like image, media etc...).
+ */
 function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
     const selectionNodes = getLastSelection(editor)?.getNodes() || [];
     if (isSingleSelectedNode(selectionNodes)) {
@@ -58,11 +67,136 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve
     return false;
 }
 
+function focusAdjacentOrInsertForSingleSelectNode(editor: LexicalEditor, event: KeyboardEvent|null, after: boolean = true): boolean {
+    const selectionNodes = getLastSelection(editor)?.getNodes() || [];
+    if (!isSingleSelectedNode(selectionNodes)) {
+        return false;
+    }
+
+    event?.preventDefault();
+
+    const node = selectionNodes[0];
+    const nearestBlock = $getNearestNodeBlockParent(node) || node;
+    let target = after ? nearestBlock.getNextSibling() : nearestBlock.getPreviousSibling();
+
+    editor.update(() => {
+        if (!target) {
+            target = $createParagraphNode();
+            if (after) {
+                nearestBlock.insertAfter(target)
+            } else {
+                nearestBlock.insertBefore(target);
+            }
+        }
+
+        target.selectStart();
+    });
+
+    return true;
+}
+
+/**
+ * Insert a new node after a details node, if inside a details node that's
+ * the last element, and if the cursor is at the last block within the details node.
+ */
+function insertAfterDetails(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
+    const scenario = getDetailsScenario(editor);
+    if (scenario === null || scenario.detailsSibling) {
+        return false;
+    }
+
+    editor.update(() => {
+        const newParagraph = $createParagraphNode();
+        scenario.parentDetails.insertAfter(newParagraph);
+        newParagraph.select();
+    });
+    event?.preventDefault();
+
+    return true;
+}
+
+/**
+ * If within a details block, move after it, creating a new node if required, if we're on
+ * the last empty block element within the details node.
+ */
+function moveAfterDetailsOnEmptyLine(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
+    const scenario = getDetailsScenario(editor);
+    if (scenario === null) {
+        return false;
+    }
+
+    if (scenario.parentBlock.getTextContent() !== '') {
+        return false;
+    }
+
+    event?.preventDefault()
+
+    const nextSibling = scenario.parentDetails.getNextSibling();
+    editor.update(() => {
+        if (nextSibling) {
+            nextSibling.selectStart();
+        } else {
+            const newParagraph = $createParagraphNode();
+            scenario.parentDetails.insertAfter(newParagraph);
+            newParagraph.select();
+        }
+        scenario.parentBlock.remove();
+    });
+
+    return true;
+}
+
+/**
+ * Get the common nodes used for a details node scenario, relative to current selection.
+ * Returns null if not found, or if the parent block is not the last in the parent details node.
+ */
+function getDetailsScenario(editor: LexicalEditor): {
+    parentDetails: DetailsNode;
+    parentBlock: LexicalNode;
+    detailsSibling: LexicalNode | null
+} | null {
+    const selection = getLastSelection(editor);
+    const firstNode = selection?.getNodes()[0];
+    if (!firstNode) {
+        return null;
+    }
+
+    const block = $getNearestNodeBlockParent(firstNode);
+    const details = $getParentOfType(firstNode, $isDetailsNode);
+    if (!$isDetailsNode(details) || block === null) {
+        return null;
+    }
+
+    if (block.getKey() !== details.getLastChild()?.getKey()) {
+        return null;
+    }
+
+    const nextSibling = details.getNextSibling();
+    return {
+        parentDetails: details,
+        parentBlock: block,
+        detailsSibling: nextSibling,
+    }
+}
+
+function $isSingleListItem(nodes: LexicalNode[]): boolean {
+    if (nodes.length !== 1) {
+        return false;
+    }
+
+    const node = nodes[0];
+    return $isListItemNode(node) || $isListItemNode(node.getParent());
+}
+
+/**
+ * Inset the nodes within selection when a range of nodes is selected
+ * or if a list node is selected.
+ */
 function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
     const change = event?.shiftKey ? -40 : 40;
     const selection = $getSelection();
     const nodes = selection?.getNodes() || [];
-    if (nodes.length > 1 || (nodes.length === 1 && $isCustomListItemNode(nodes[0].getParent()))) {
+    if (nodes.length > 1 || $isSingleListItem(nodes)) {
         editor.update(() => {
             $setInsetForSelection(editor, change);
         });
@@ -85,17 +219,29 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void {
     }, COMMAND_PRIORITY_LOW);
 
     const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => {
-        return insertAfterSingleSelectedNode(context.editor, event);
+        return insertAfterSingleSelectedNode(context.editor, event)
+            || moveAfterDetailsOnEmptyLine(context.editor, event);
     }, COMMAND_PRIORITY_LOW);
 
     const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => {
         return handleInsetOnTab(context.editor, event);
     }, COMMAND_PRIORITY_LOW);
 
+    const unregisterUp = context.editor.registerCommand(KEY_ARROW_UP_COMMAND, (event): boolean => {
+        return focusAdjacentOrInsertForSingleSelectNode(context.editor, event, false);
+    }, COMMAND_PRIORITY_LOW);
+
+    const unregisterDown = context.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event): boolean => {
+        return insertAfterDetails(context.editor, event)
+            || focusAdjacentOrInsertForSingleSelectNode(context.editor, event, true)
+    }, COMMAND_PRIORITY_LOW);
+
     return () => {
         unregisterBackspace();
         unregisterDelete();
         unregisterEnter();
         unregisterTab();
+        unregisterUp();
+        unregisterDown();
     };
 }
\ No newline at end of file