UNPKG

@mkljczk/lexical-remark

Version:

This package contains Markdown helpers and functionality for Lexical using remark-parse.

256 lines (224 loc) 9.37 kB
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, type ElementNode, type LexicalNode, type NodeKey, } 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<string | void>(); /** * 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<NodeKey>(); /** * A Lexical plugin to register commands and event handlers related to the associated Collapsible nodes */ export function CollapsiblePlugin(): null { 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<ElementNode>(); if ( parent !== null && parent.getFirstChild<LexicalNode>() === container && selection.anchor.key === container.getFirstDescendant<LexicalNode>()?.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<ElementNode>(); if (parent !== null && parent.getLastChild<LexicalNode>() === container) { const lastDescendant = container.getLastDescendant<LexicalNode>(); 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<ElementNode>(); if (!$isCollapsibleContainerNode(parent)) { const children = node.getChildren<LexicalNode>(); for (const child of children) { node.insertBefore(child); } node.remove(); } }), editor.registerNodeTransform(CollapsibleTitleNode, (node) => { const parent = node.getParent<ElementNode>(); if (!$isCollapsibleContainerNode(parent)) { node.replace($createParagraphNode().append(...node.getChildren<LexicalNode>())); return; } }), editor.registerNodeTransform(CollapsibleContainerNode, (node) => { const children = node.getChildren<LexicalNode>(); 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<LexicalNode>(); 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<CollapsibleContainerNode>()?.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: KeyboardEvent | undefined = 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<ElementNode>(); 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; }