@mkljczk/lexical-remark
Version:
This package contains Markdown helpers and functionality for Lexical using remark-parse.
166 lines (165 loc) • 9.32 kB
JavaScript
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js';
import { $findMatchingParent, mergeRegister } from '@lexical/utils';
import { COMMAND_PRIORITY_LOW, DELETE_CHARACTER_COMMAND, INSERT_PARAGRAPH_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, $createParagraphNode, $createTextNode, $getPreviousSelection, $getSelection, $insertNodes, $isElementNode, $isRangeSelection, $setSelection, createCommand, } from 'lexical';
import { useEffect } from 'react';
import { $createCollapsibleContainerNode, $isCollapsibleContainerNode, CollapsibleContainerNode, } from './container/node.js';
import { $createCollapsibleContentNode, $isCollapsibleContentNode, CollapsibleContentNode } from './content/node.js';
import { $createCollapsibleTitleNode, $isCollapsibleTitleNode, CollapsibleTitleNode } from './title/node.js';
/**
* A command to insert a collapsible section. The (optional) argument is the summary text content.
*/
export const INSERT_COLLAPSIBLE_COMMAND = createCommand();
/**
* A command to toggle a collapsible section open or closed. The argument is thle key of the collapsible node.
*/
export const TOGGLE_COLLAPSIBLE_COMMAND = createCommand();
/**
* A Lexical plugin to register commands and event handlers related to the associated Collapsible nodes
*/
export function CollapsiblePlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!editor.hasNodes([CollapsibleContainerNode, CollapsibleTitleNode, CollapsibleContentNode])) {
throw new Error('CollapsiblePlugin: CollapsibleContainerNode, CollapsibleTitleNode, or CollapsibleContentNode not registered on editor');
}
const onEscapeUp = () => {
const selection = $getSelection();
if ($isRangeSelection(selection) && selection.isCollapsed() && selection.anchor.offset === 0) {
const container = $findMatchingParent(selection.anchor.getNode(), $isCollapsibleContainerNode);
if ($isCollapsibleContainerNode(container)) {
const parent = container.getParent();
if (parent !== null &&
parent.getFirstChild() === container &&
selection.anchor.key === container.getFirstDescendant()?.getKey()) {
container.insertBefore($createParagraphNode());
}
}
}
return false;
};
const onEscapeDown = () => {
const selection = $getSelection();
if ($isRangeSelection(selection) && selection.isCollapsed()) {
const container = $findMatchingParent(selection.anchor.getNode(), $isCollapsibleContainerNode);
if ($isCollapsibleContainerNode(container)) {
const parent = container.getParent();
if (parent !== null && parent.getLastChild() === container) {
const lastDescendant = container.getLastDescendant();
if (lastDescendant !== null &&
selection.anchor.key === lastDescendant.getKey() &&
selection.anchor.offset === lastDescendant.getTextContentSize()) {
container.insertAfter($createParagraphNode());
}
}
}
}
return false;
};
return mergeRegister(
// Structure enforcing transformers for each node type. In case nesting structure is not
// "Container > Title + Content" it'll unwrap nodes and convert it back
// to regular content.
editor.registerNodeTransform(CollapsibleContentNode, (node) => {
const parent = node.getParent();
if (!$isCollapsibleContainerNode(parent)) {
const children = node.getChildren();
for (const child of children) {
node.insertBefore(child);
}
node.remove();
}
}), editor.registerNodeTransform(CollapsibleTitleNode, (node) => {
const parent = node.getParent();
if (!$isCollapsibleContainerNode(parent)) {
node.replace($createParagraphNode().append(...node.getChildren()));
return;
}
}), editor.registerNodeTransform(CollapsibleContainerNode, (node) => {
const children = node.getChildren();
if (children.length !== 2 || !$isCollapsibleTitleNode(children[0]) || !$isCollapsibleContentNode(children[1])) {
for (const child of children) {
node.insertBefore(child);
}
node.remove();
}
}),
// This handles the case when container is collapsed and we delete its previous sibling
// into it, it would cause collapsed content deleted (since it's display: none, and selection
// swallows it when deletes single char). Instead we expand container, which is although
// not perfect, but avoids bigger problem
editor.registerCommand(DELETE_CHARACTER_COMMAND, () => {
const selection = $getSelection();
if (!$isRangeSelection(selection) || !selection.isCollapsed() || selection.anchor.offset !== 0) {
return false;
}
const anchorNode = selection.anchor.getNode();
const topLevelElement = anchorNode.getTopLevelElement();
if (topLevelElement === null) {
return false;
}
const container = topLevelElement.getPreviousSibling();
if (!$isCollapsibleContainerNode(container) || container.getOpen()) {
return false;
}
container.setOpen(true);
return true;
}, COMMAND_PRIORITY_LOW), editor.registerCommand(DELETE_CHARACTER_COMMAND, () => {
const selection = $getSelection();
if (!$isRangeSelection(selection) || !selection.isCollapsed() || selection.anchor.offset !== 0) {
return false;
}
const anchorNode = selection.anchor.getNode();
if (!$isCollapsibleTitleNode(anchorNode)) {
return false;
}
if (anchorNode.getTextContentSize() !== 0) {
return false;
}
anchorNode.getParent()?.remove();
return true;
}, COMMAND_PRIORITY_LOW),
// When collapsible is the last child pressing down/right arrow will insert paragraph
// below it to allow adding more content. It's similar what $insertBlockNode
// (mainly for decorators), except it'll always be possible to continue adding
// new content even if trailing paragraph is accidentally deleted
editor.registerCommand(KEY_ARROW_DOWN_COMMAND, onEscapeDown, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, onEscapeDown, COMMAND_PRIORITY_LOW),
// When collapsible is the first child pressing up/left arrow will insert paragraph
// above it to allow adding more content. It's similar what $insertBlockNode
// (mainly for decorators), except it'll always be possible to continue adding
// new content even if leading paragraph is accidentally deleted
editor.registerCommand(KEY_ARROW_UP_COMMAND, onEscapeUp, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_ARROW_LEFT_COMMAND, onEscapeUp, COMMAND_PRIORITY_LOW),
// Handling CMD+Enter to toggle collapsible element collapsed state
editor.registerCommand(INSERT_PARAGRAPH_COMMAND, () => {
// @ts-expect-error cast to keyboard event
const windowEvent = editor._window?.event;
if (windowEvent && (windowEvent.ctrlKey || windowEvent.metaKey) && windowEvent.key === 'Enter') {
const selection = $getPreviousSelection();
if ($isRangeSelection(selection) && selection.isCollapsed()) {
const parent = $findMatchingParent(selection.anchor.getNode(), (node) => $isElementNode(node) && !node.isInline());
if ($isCollapsibleTitleNode(parent)) {
const container = parent.getParent();
if ($isCollapsibleContainerNode(container)) {
container.toggleOpen();
$setSelection(selection.clone());
return true;
}
}
}
}
return false;
}, COMMAND_PRIORITY_LOW), editor.registerCommand(INSERT_COLLAPSIBLE_COMMAND, (payload) => {
editor.update(() => {
const title = $createCollapsibleTitleNode();
if (payload) {
title.append($createTextNode(payload));
}
$insertNodes([
$createCollapsibleContainerNode(true).append(title, $createCollapsibleContentNode().append($createParagraphNode())),
]);
title.select();
});
return true;
}, COMMAND_PRIORITY_LOW));
}, [editor]);
return null;
}