1 import {EditorUiContext} from "../ui/framework/core";
6 COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND,
9 KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
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";
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)) {
34 * Delete the current node in the selection if the selection contains a single
35 * selected node (like image, media etc...).
37 function deleteSingleSelectedNode(editor: LexicalEditor) {
38 const selectionNodes = getLastSelection(editor)?.getNodes() || [];
39 if (isSingleSelectedNode(selectionNodes)) {
41 selectionNodes[0].remove();
47 * Insert a new empty node before/after the selection if the selection contains a single
48 * selected node (like image, media etc...).
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;
57 requestAnimationFrame(() => {
59 const newParagraph = $createParagraphNode();
61 nearestBlock.insertBefore(newParagraph);
63 nearestBlock.insertAfter(newParagraph);
65 newParagraph.select();
68 event?.preventDefault();
76 function focusAdjacentOrInsertForSingleSelectNode(editor: LexicalEditor, event: KeyboardEvent|null, after: boolean = true): boolean {
77 const selectionNodes = getLastSelection(editor)?.getNodes() || [];
78 if (!isSingleSelectedNode(selectionNodes)) {
82 event?.preventDefault();
83 const node = selectionNodes[0];
89 node.selectPrevious();
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.
100 function insertAfterDetails(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
101 const scenario = getDetailsScenario(editor);
102 if (scenario === null || scenario.detailsSibling) {
106 editor.update(() => {
107 const newParagraph = $createParagraphNode();
108 scenario.parentDetails.insertAfter(newParagraph);
109 newParagraph.select();
111 event?.preventDefault();
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.
120 function moveAfterDetailsOnEmptyLine(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
121 const scenario = getDetailsScenario(editor);
122 if (scenario === null) {
126 if (scenario.parentBlock.getTextContent() !== '') {
130 event?.preventDefault()
132 const nextSibling = scenario.parentDetails.getNextSibling();
133 editor.update(() => {
135 nextSibling.selectStart();
137 const newParagraph = $createParagraphNode();
138 scenario.parentDetails.insertAfter(newParagraph);
139 newParagraph.select();
141 scenario.parentBlock.remove();
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.
151 function getDetailsScenario(editor: LexicalEditor): {
152 parentDetails: DetailsNode;
153 parentBlock: LexicalNode;
154 detailsSibling: LexicalNode | null
156 const selection = getLastSelection(editor);
157 const firstNode = selection?.getNodes()[0];
162 const block = $getNearestNodeBlockParent(firstNode);
163 const details = $getParentOfType(firstNode, $isDetailsNode);
164 if (!$isDetailsNode(details) || block === null) {
168 if (block.getKey() !== details.getLastChild()?.getKey()) {
172 const nextSibling = details.getNextSibling();
174 parentDetails: details,
176 detailsSibling: nextSibling,
180 function $isSingleListItem(nodes: LexicalNode[]): boolean {
181 if (nodes.length !== 1) {
185 const node = nodes[0];
186 return $isListItemNode(node) || $isListItemNode(node.getParent());
190 * Inset the nodes within selection when a range of nodes is selected
191 * or if a list node is selected.
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);
201 event?.preventDefault();
208 export function registerKeyboardHandling(context: EditorUiContext): () => void {
209 const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => {
210 deleteSingleSelectedNode(context.editor);
212 }, COMMAND_PRIORITY_LOW);
214 const unregisterDelete = context.editor.registerCommand(KEY_DELETE_COMMAND, (): boolean => {
215 deleteSingleSelectedNode(context.editor);
217 }, COMMAND_PRIORITY_LOW);
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);
224 const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => {
225 return handleInsetOnTab(context.editor, event);
226 }, COMMAND_PRIORITY_LOW);
228 const unregisterUp = context.editor.registerCommand(KEY_ARROW_UP_COMMAND, (event): boolean => {
229 return focusAdjacentOrInsertForSingleSelectNode(context.editor, event, false);
230 }, COMMAND_PRIORITY_LOW);
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);
238 unregisterBackspace();