mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-01-10 02:57:36 +00:00
ace8af077d
- Made tab work on empty list items - Improved select preservation on single list item tab - Altered test context creation for more standard testing
213 lines
No EOL
6.7 KiB
TypeScript
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();
|
|
};
|
|
} |