]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/services/keyboard-handling.ts
Lexical: Added mulitple methods to escape details block
[bookstack] / resources / js / wysiwyg / services / keyboard-handling.ts
1 import {EditorUiContext} from "../ui/framework/core";
2 import {
3     $createParagraphNode,
4     $getSelection,
5     $isDecoratorNode,
6     COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND,
7     KEY_BACKSPACE_COMMAND,
8     KEY_DELETE_COMMAND,
9     KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
10     LexicalEditor,
11     LexicalNode
12 } from "lexical";
13 import {$isImageNode} from "@lexical/rich-text/LexicalImageNode";
14 import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode";
15 import {getLastSelection} from "../utils/selection";
16 import {$getNearestNodeBlockParent, $getParentOfType} from "../utils/nodes";
17 import {$setInsetForSelection} from "../utils/lists";
18 import {$isListItemNode} from "@lexical/list";
19 import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode";
20
21 function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
22     if (nodes.length === 1) {
23         const node = nodes[0];
24         if ($isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node)) {
25             return true;
26         }
27     }
28
29     return false;
30 }
31
32 /**
33  * Delete the current node in the selection if the selection contains a single
34  * selected node (like image, media etc...).
35  */
36 function deleteSingleSelectedNode(editor: LexicalEditor) {
37     const selectionNodes = getLastSelection(editor)?.getNodes() || [];
38     if (isSingleSelectedNode(selectionNodes)) {
39         editor.update(() => {
40             selectionNodes[0].remove();
41         });
42     }
43 }
44
45 /**
46  * Insert a new empty node after the selection if the selection contains a single
47  * selected node (like image, media etc...).
48  */
49 function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
50     const selectionNodes = getLastSelection(editor)?.getNodes() || [];
51     if (isSingleSelectedNode(selectionNodes)) {
52         const node = selectionNodes[0];
53         const nearestBlock = $getNearestNodeBlockParent(node) || node;
54         if (nearestBlock) {
55             requestAnimationFrame(() => {
56                 editor.update(() => {
57                     const newParagraph = $createParagraphNode();
58                     nearestBlock.insertAfter(newParagraph);
59                     newParagraph.select();
60                 });
61             });
62             event?.preventDefault();
63             return true;
64         }
65     }
66
67     return false;
68 }
69
70 /**
71  * Insert a new node after a details node, if inside a details node that's
72  * the last element, and if the cursor is at the last block within the details node.
73  */
74 function insertAfterDetails(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
75     const scenario = getDetailsScenario(editor);
76     if (scenario === null || scenario.detailsSibling) {
77         return false;
78     }
79
80     editor.update(() => {
81         const newParagraph = $createParagraphNode();
82         scenario.parentDetails.insertAfter(newParagraph);
83         newParagraph.select();
84     });
85     event?.preventDefault();
86
87     return true;
88 }
89
90 /**
91  * If within a details block, move after it, creating a new node if required, if we're on
92  * the last empty block element within the details node.
93  */
94 function moveAfterDetailsOnEmptyLine(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
95     const scenario = getDetailsScenario(editor);
96     if (scenario === null) {
97         return false;
98     }
99
100     if (scenario.parentBlock.getTextContent() !== '') {
101         return false;
102     }
103
104     event?.preventDefault()
105
106     const nextSibling = scenario.parentDetails.getNextSibling();
107     editor.update(() => {
108         if (nextSibling) {
109             nextSibling.selectStart();
110         } else {
111             const newParagraph = $createParagraphNode();
112             scenario.parentDetails.insertAfter(newParagraph);
113             newParagraph.select();
114         }
115         scenario.parentBlock.remove();
116     });
117
118     return true;
119 }
120
121 /**
122  * Get the common nodes used for a details node scenario, relative to current selection.
123  * Returns null if not found, or if the parent block is not the last in the parent details node.
124  */
125 function getDetailsScenario(editor: LexicalEditor): {
126     parentDetails: DetailsNode;
127     parentBlock: LexicalNode;
128     detailsSibling: LexicalNode | null
129 } | null {
130     const selection = getLastSelection(editor);
131     const firstNode = selection?.getNodes()[0];
132     if (!firstNode) {
133         return null;
134     }
135
136     const block = $getNearestNodeBlockParent(firstNode);
137     const details = $getParentOfType(firstNode, $isDetailsNode);
138     if (!$isDetailsNode(details) || block === null) {
139         return null;
140     }
141
142     if (block.getKey() !== details.getLastChild()?.getKey()) {
143         return null;
144     }
145
146     const nextSibling = details.getNextSibling();
147     return {
148         parentDetails: details,
149         parentBlock: block,
150         detailsSibling: nextSibling,
151     }
152 }
153
154 /**
155  * Inset the nodes within selection when a range of nodes is selected
156  * or if a list node is selected.
157  */
158 function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
159     const change = event?.shiftKey ? -40 : 40;
160     const selection = $getSelection();
161     const nodes = selection?.getNodes() || [];
162     if (nodes.length > 1 || (nodes.length === 1 && $isListItemNode(nodes[0].getParent()))) {
163         editor.update(() => {
164             $setInsetForSelection(editor, change);
165         });
166         event?.preventDefault();
167         return true;
168     }
169
170     return false;
171 }
172
173 export function registerKeyboardHandling(context: EditorUiContext): () => void {
174     const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => {
175         deleteSingleSelectedNode(context.editor);
176         return false;
177     }, COMMAND_PRIORITY_LOW);
178
179     const unregisterDelete = context.editor.registerCommand(KEY_DELETE_COMMAND, (): boolean => {
180         deleteSingleSelectedNode(context.editor);
181         return false;
182     }, COMMAND_PRIORITY_LOW);
183
184     const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => {
185         return insertAfterSingleSelectedNode(context.editor, event)
186             || moveAfterDetailsOnEmptyLine(context.editor, event);
187     }, COMMAND_PRIORITY_LOW);
188
189     const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => {
190         return handleInsetOnTab(context.editor, event);
191     }, COMMAND_PRIORITY_LOW);
192
193     const unregisterDown = context.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event): boolean => {
194         return insertAfterDetails(context.editor, event);
195     }, COMMAND_PRIORITY_LOW);
196
197     return () => {
198         unregisterBackspace();
199         unregisterDelete();
200         unregisterEnter();
201         unregisterTab();
202         unregisterDown();
203     };
204 }