BookStackApp_BookStack/resources/js/wysiwyg/services/keyboard-handling.ts
Dan Brown ace8af077d
Lexical: Improved list tab handling, Improved test utils
- Made tab work on empty list items
- Improved select preservation on single list item tab
- Altered test context creation for more standard testing
2024-12-17 14:44:10 +00:00

213 lines
No EOL
6.7 KiB
TypeScript

import {EditorUiContext} from "../ui/framework/core";
import {
$createParagraphNode,
$getSelection,
$isDecoratorNode,
COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
KEY_ENTER_COMMAND, KEY_TAB_COMMAND,
LexicalEditor,
LexicalNode
} from "lexical";
import {$isImageNode} from "@lexical/rich-text/LexicalImageNode";
import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode";
import {getLastSelection} from "../utils/selection";
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) {
const node = nodes[0];
if ($isDecoratorNode(node) || $isImageNode(node) || $isMediaNode(node)) {
return true;
}
}
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)) {
editor.update(() => {
selectionNodes[0].remove();
});
}
}
/**
* 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)) {
const node = selectionNodes[0];
const nearestBlock = $getNearestNodeBlockParent(node) || node;
if (nearestBlock) {
requestAnimationFrame(() => {
editor.update(() => {
const newParagraph = $createParagraphNode();
nearestBlock.insertAfter(newParagraph);
newParagraph.select();
});
});
event?.preventDefault();
return true;
}
}
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,
}
}
function $isSingleListItem(nodes: LexicalNode[]): boolean {
if (nodes.length !== 1) {
return false;
}
const node = nodes[0];
return $isListItemNode(node) || $isListItemNode(node.getParent());
}
/**
* 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();
const nodes = selection?.getNodes() || [];
if (nodes.length > 1 || $isSingleListItem(nodes)) {
editor.update(() => {
$setInsetForSelection(editor, change);
});
event?.preventDefault();
return true;
}
return false;
}
export function registerKeyboardHandling(context: EditorUiContext): () => void {
const unregisterBackspace = context.editor.registerCommand(KEY_BACKSPACE_COMMAND, (): boolean => {
deleteSingleSelectedNode(context.editor);
return false;
}, COMMAND_PRIORITY_LOW);
const unregisterDelete = context.editor.registerCommand(KEY_DELETE_COMMAND, (): boolean => {
deleteSingleSelectedNode(context.editor);
return false;
}, COMMAND_PRIORITY_LOW);
const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => {
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();
};
}