diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 6a1345fac..08eed7645 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -3,7 +3,7 @@ import { $createParagraphNode, $getSelection, $isDecoratorNode, - COMMAND_PRIORITY_LOW, + COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, KEY_ENTER_COMMAND, KEY_TAB_COMMAND, @@ -13,9 +13,10 @@ import { import {$isImageNode} from "@lexical/rich-text/LexicalImageNode"; import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {getLastSelection} from "../utils/selection"; -import {$getNearestNodeBlockParent} from "../utils/nodes"; +import {$getNearestNodeBlockParent, $getParentOfType} from "../utils/nodes"; import {$setInsetForSelection} from "../utils/lists"; import {$isListItemNode} from "@lexical/list"; +import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; function isSingleSelectedNode(nodes: LexicalNode[]): boolean { if (nodes.length === 1) { @@ -28,6 +29,10 @@ function isSingleSelectedNode(nodes: LexicalNode[]): boolean { return false; } +/** + * Delete the current node in the selection if the selection contains a single + * selected node (like image, media etc...). + */ function deleteSingleSelectedNode(editor: LexicalEditor) { const selectionNodes = getLastSelection(editor)?.getNodes() || []; if (isSingleSelectedNode(selectionNodes)) { @@ -37,6 +42,10 @@ function deleteSingleSelectedNode(editor: LexicalEditor) { } } +/** + * Insert a new empty node after the selection if the selection contains a single + * selected node (like image, media etc...). + */ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean { const selectionNodes = getLastSelection(editor)?.getNodes() || []; if (isSingleSelectedNode(selectionNodes)) { @@ -58,6 +67,94 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve return false; } +/** + * Insert a new node after a details node, if inside a details node that's + * the last element, and if the cursor is at the last block within the details node. + */ +function insertAfterDetails(editor: LexicalEditor, event: KeyboardEvent|null): boolean { + const scenario = getDetailsScenario(editor); + if (scenario === null || scenario.detailsSibling) { + return false; + } + + editor.update(() => { + const newParagraph = $createParagraphNode(); + scenario.parentDetails.insertAfter(newParagraph); + newParagraph.select(); + }); + event?.preventDefault(); + + return true; +} + +/** + * If within a details block, move after it, creating a new node if required, if we're on + * the last empty block element within the details node. + */ +function moveAfterDetailsOnEmptyLine(editor: LexicalEditor, event: KeyboardEvent|null): boolean { + const scenario = getDetailsScenario(editor); + if (scenario === null) { + return false; + } + + if (scenario.parentBlock.getTextContent() !== '') { + return false; + } + + event?.preventDefault() + + const nextSibling = scenario.parentDetails.getNextSibling(); + editor.update(() => { + if (nextSibling) { + nextSibling.selectStart(); + } else { + const newParagraph = $createParagraphNode(); + scenario.parentDetails.insertAfter(newParagraph); + newParagraph.select(); + } + scenario.parentBlock.remove(); + }); + + return true; +} + +/** + * Get the common nodes used for a details node scenario, relative to current selection. + * Returns null if not found, or if the parent block is not the last in the parent details node. + */ +function getDetailsScenario(editor: LexicalEditor): { + parentDetails: DetailsNode; + parentBlock: LexicalNode; + detailsSibling: LexicalNode | null +} | null { + const selection = getLastSelection(editor); + const firstNode = selection?.getNodes()[0]; + if (!firstNode) { + return null; + } + + const block = $getNearestNodeBlockParent(firstNode); + const details = $getParentOfType(firstNode, $isDetailsNode); + if (!$isDetailsNode(details) || block === null) { + return null; + } + + if (block.getKey() !== details.getLastChild()?.getKey()) { + return null; + } + + const nextSibling = details.getNextSibling(); + return { + parentDetails: details, + parentBlock: block, + detailsSibling: nextSibling, + } +} + +/** + * Inset the nodes within selection when a range of nodes is selected + * or if a list node is selected. + */ function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boolean { const change = event?.shiftKey ? -40 : 40; const selection = $getSelection(); @@ -85,17 +182,23 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void { }, COMMAND_PRIORITY_LOW); const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => { - return insertAfterSingleSelectedNode(context.editor, event); + return insertAfterSingleSelectedNode(context.editor, event) + || moveAfterDetailsOnEmptyLine(context.editor, event); }, COMMAND_PRIORITY_LOW); const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => { return handleInsetOnTab(context.editor, event); }, COMMAND_PRIORITY_LOW); + const unregisterDown = context.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event): boolean => { + return insertAfterDetails(context.editor, event); + }, COMMAND_PRIORITY_LOW); + return () => { unregisterBackspace(); unregisterDelete(); unregisterEnter(); unregisterTab(); + unregisterDown(); }; } \ No newline at end of file