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