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 insertAfterSingleSelectedNode(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;
56 requestAnimationFrame(() => {
58 const newParagraph = $createParagraphNode();
59 nearestBlock.insertAfter(newParagraph);
60 newParagraph.select();
63 event?.preventDefault();
71 function focusAdjacentOrInsertForSingleSelectNode(editor: LexicalEditor, event: KeyboardEvent|null, after: boolean = true): boolean {
72 const selectionNodes = getLastSelection(editor)?.getNodes() || [];
73 if (!isSingleSelectedNode(selectionNodes)) {
77 event?.preventDefault();
79 const node = selectionNodes[0];
80 const nearestBlock = $getNearestNodeBlockParent(node) || node;
81 let target = after ? nearestBlock.getNextSibling() : nearestBlock.getPreviousSibling();
85 target = $createParagraphNode();
87 nearestBlock.insertAfter(target)
89 nearestBlock.insertBefore(target);
100 * Insert a new node after a details node, if inside a details node that's
101 * the last element, and if the cursor is at the last block within the details node.
103 function insertAfterDetails(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
104 const scenario = getDetailsScenario(editor);
105 if (scenario === null || scenario.detailsSibling) {
109 editor.update(() => {
110 const newParagraph = $createParagraphNode();
111 scenario.parentDetails.insertAfter(newParagraph);
112 newParagraph.select();
114 event?.preventDefault();
120 * If within a details block, move after it, creating a new node if required, if we're on
121 * the last empty block element within the details node.
123 function moveAfterDetailsOnEmptyLine(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
124 const scenario = getDetailsScenario(editor);
125 if (scenario === null) {
129 if (scenario.parentBlock.getTextContent() !== '') {
133 event?.preventDefault()
135 const nextSibling = scenario.parentDetails.getNextSibling();
136 editor.update(() => {
138 nextSibling.selectStart();
140 const newParagraph = $createParagraphNode();
141 scenario.parentDetails.insertAfter(newParagraph);
142 newParagraph.select();
144 scenario.parentBlock.remove();
151 * Get the common nodes used for a details node scenario, relative to current selection.
152 * Returns null if not found, or if the parent block is not the last in the parent details node.
154 function getDetailsScenario(editor: LexicalEditor): {
155 parentDetails: DetailsNode;
156 parentBlock: LexicalNode;
157 detailsSibling: LexicalNode | null
159 const selection = getLastSelection(editor);
160 const firstNode = selection?.getNodes()[0];
165 const block = $getNearestNodeBlockParent(firstNode);
166 const details = $getParentOfType(firstNode, $isDetailsNode);
167 if (!$isDetailsNode(details) || block === null) {
171 if (block.getKey() !== details.getLastChild()?.getKey()) {
175 const nextSibling = details.getNextSibling();
177 parentDetails: details,
179 detailsSibling: nextSibling,
183 function $isSingleListItem(nodes: LexicalNode[]): boolean {
184 if (nodes.length !== 1) {
188 const node = nodes[0];
189 return $isListItemNode(node) || $isListItemNode(node.getParent());
193 * Inset the nodes within selection when a range of nodes is selected
194 * or if a list node is selected.
196 function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boolean {
197 const change = event?.shiftKey ? -40 : 40;
198 const selection = $getSelection();
199 const nodes = selection?.getNodes() || [];
200 if (nodes.length > 1 || $isSingleListItem(nodes)) {
201 editor.update(() => {
202 $setInsetForSelection(editor, change);
204 event?.preventDefault();
211 export function registerKeyboardHandling(context: EditorUiContext): () => void {
212 const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => {
213 deleteSingleSelectedNode(context.editor);
215 }, COMMAND_PRIORITY_LOW);
217 const unregisterDelete = context.editor.registerCommand(KEY_DELETE_COMMAND, (): boolean => {
218 deleteSingleSelectedNode(context.editor);
220 }, COMMAND_PRIORITY_LOW);
222 const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => {
223 return insertAfterSingleSelectedNode(context.editor, event)
224 || moveAfterDetailsOnEmptyLine(context.editor, event);
225 }, COMMAND_PRIORITY_LOW);
227 const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => {
228 return handleInsetOnTab(context.editor, event);
229 }, COMMAND_PRIORITY_LOW);
231 const unregisterUp = context.editor.registerCommand(KEY_ARROW_UP_COMMAND, (event): boolean => {
232 return focusAdjacentOrInsertForSingleSelectNode(context.editor, event, false);
233 }, COMMAND_PRIORITY_LOW);
235 const unregisterDown = context.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event): boolean => {
236 return insertAfterDetails(context.editor, event)
237 || focusAdjacentOrInsertForSingleSelectNode(context.editor, event, true)
238 }, COMMAND_PRIORITY_LOW);
241 unregisterBackspace();