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