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