]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/services/keyboard-handling.ts
Lexical: Improved diagram selection and keyboard usage
[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, KEY_ARROW_UP_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 import {$isDiagramNode} from "../utils/diagrams";
21
22 function isSingleSelectedNode(nodes: LexicalNode[]): boolean {
23     if (nodes.length === 1) {
24         const node = nodes[0];
25         if ($isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node) || $isDiagramNode(node)) {
26             return true;
27         }
28     }
29
30     return false;
31 }
32
33 /**
34  * Delete the current node in the selection if the selection contains a single
35  * selected node (like image, media etc...).
36  */
37 function deleteSingleSelectedNode(editor: LexicalEditor) {
38     const selectionNodes = getLastSelection(editor)?.getNodes() || [];
39     if (isSingleSelectedNode(selectionNodes)) {
40         editor.update(() => {
41             selectionNodes[0].remove();
42         });
43     }
44 }
45
46 /**
47  * Insert a new empty node before/after the selection if the selection contains a single
48  * selected node (like image, media etc...).
49  */
50 function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
51     const selectionNodes = getLastSelection(editor)?.getNodes() || [];
52     if (isSingleSelectedNode(selectionNodes)) {
53         const node = selectionNodes[0];
54         const nearestBlock = $getNearestNodeBlockParent(node) || node;
55         if (nearestBlock) {
56             requestAnimationFrame(() => {
57                 editor.update(() => {
58                     const newParagraph = $createParagraphNode();
59                     nearestBlock.insertAfter(newParagraph);
60                     newParagraph.select();
61                 });
62             });
63             event?.preventDefault();
64             return true;
65         }
66     }
67
68     return false;
69 }
70
71 function focusAdjacentOrInsertForSingleSelectNode(editor: LexicalEditor, event: KeyboardEvent|null, after: boolean = true): boolean {
72     const selectionNodes = getLastSelection(editor)?.getNodes() || [];
73     if (!isSingleSelectedNode(selectionNodes)) {
74         return false;
75     }
76
77     event?.preventDefault();
78
79     const node = selectionNodes[0];
80     const nearestBlock = $getNearestNodeBlockParent(node) || node;
81     let target = after ? nearestBlock.getNextSibling() : nearestBlock.getPreviousSibling();
82
83     editor.update(() => {
84         if (!target) {
85             target = $createParagraphNode();
86             if (after) {
87                 nearestBlock.insertAfter(target)
88             } else {
89                 nearestBlock.insertBefore(target);
90             }
91         }
92
93         target.selectStart();
94     });
95
96     return true;
97 }
98
99 /**
100  * Insert a new node after a details node, if inside a details node that's
101  * the last element, and if the cursor is at the last block within the details node.
102  */
103 function insertAfterDetails(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
104     const scenario = getDetailsScenario(editor);
105     if (scenario === null || scenario.detailsSibling) {
106         return false;
107     }
108
109     editor.update(() => {
110         const newParagraph = $createParagraphNode();
111         scenario.parentDetails.insertAfter(newParagraph);
112         newParagraph.select();
113     });
114     event?.preventDefault();
115
116     return true;
117 }
118
119 /**
120  * If within a details block, move after it, creating a new node if required, if we're on
121  * the last empty block element within the details node.
122  */
123 function moveAfterDetailsOnEmptyLine(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
124     const scenario = getDetailsScenario(editor);
125     if (scenario === null) {
126         return false;
127     }
128
129     if (scenario.parentBlock.getTextContent() !== '') {
130         return false;
131     }
132
133     event?.preventDefault()
134
135     const nextSibling = scenario.parentDetails.getNextSibling();
136     editor.update(() => {
137         if (nextSibling) {
138             nextSibling.selectStart();
139         } else {
140             const newParagraph = $createParagraphNode();
141             scenario.parentDetails.insertAfter(newParagraph);
142             newParagraph.select();
143         }
144         scenario.parentBlock.remove();
145     });
146
147     return true;
148 }
149
150 /**
151  * Get the common nodes used for a details node scenario, relative to current selection.
152  * Returns null if not found, or if the parent block is not the last in the parent details node.
153  */
154 function getDetailsScenario(editor: LexicalEditor): {
155     parentDetails: DetailsNode;
156     parentBlock: LexicalNode;
157     detailsSibling: LexicalNode | null
158 } | null {
159     const selection = getLastSelection(editor);
160     const firstNode = selection?.getNodes()[0];
161     if (!firstNode) {
162         return null;
163     }
164
165     const block = $getNearestNodeBlockParent(firstNode);
166     const details = $getParentOfType(firstNode, $isDetailsNode);
167     if (!$isDetailsNode(details) || block === null) {
168         return null;
169     }
170
171     if (block.getKey() !== details.getLastChild()?.getKey()) {
172         return null;
173     }
174
175     const nextSibling = details.getNextSibling();
176     return {
177         parentDetails: details,
178         parentBlock: block,
179         detailsSibling: nextSibling,
180     }
181 }
182
183 function $isSingleListItem(nodes: LexicalNode[]): boolean {
184     if (nodes.length !== 1) {
185         return false;
186     }
187
188     const node = nodes[0];
189     return $isListItemNode(node) || $isListItemNode(node.getParent());
190 }
191
192 /**
193  * Inset the nodes within selection when a range of nodes is selected
194  * or if a list node is selected.
195  */
196 function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
197     const change = event?.shiftKey ? -40 : 40;
198     const selection = $getSelection();
199     const nodes = selection?.getNodes() || [];
200     if (nodes.length > 1 || $isSingleListItem(nodes)) {
201         editor.update(() => {
202             $setInsetForSelection(editor, change);
203         });
204         event?.preventDefault();
205         return true;
206     }
207
208     return false;
209 }
210
211 export function registerKeyboardHandling(context: EditorUiContext): () => void {
212     const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => {
213         deleteSingleSelectedNode(context.editor);
214         return false;
215     }, COMMAND_PRIORITY_LOW);
216
217     const unregisterDelete = context.editor.registerCommand(KEY_DELETE_COMMAND, (): boolean => {
218         deleteSingleSelectedNode(context.editor);
219         return false;
220     }, COMMAND_PRIORITY_LOW);
221
222     const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => {
223         return insertAfterSingleSelectedNode(context.editor, event)
224             || moveAfterDetailsOnEmptyLine(context.editor, event);
225     }, COMMAND_PRIORITY_LOW);
226
227     const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => {
228         return handleInsetOnTab(context.editor, event);
229     }, COMMAND_PRIORITY_LOW);
230
231     const unregisterUp = context.editor.registerCommand(KEY_ARROW_UP_COMMAND, (event): boolean => {
232         return focusAdjacentOrInsertForSingleSelectNode(context.editor, event, false);
233     }, COMMAND_PRIORITY_LOW);
234
235     const unregisterDown = context.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event): boolean => {
236         return insertAfterDetails(context.editor, event)
237             || focusAdjacentOrInsertForSingleSelectNode(context.editor, event, true)
238     }, COMMAND_PRIORITY_LOW);
239
240     return () => {
241         unregisterBackspace();
242         unregisterDelete();
243         unregisterEnter();
244         unregisterTab();
245         unregisterUp();
246         unregisterDown();
247     };
248 }