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