237 lines
7.3 KiB
TypeScript
237 lines
7.3 KiB
TypeScript
/**
|
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
*
|
|
* This source code is licensed under the MIT license found in the
|
|
* LICENSE file in the root directory of this source tree.
|
|
*
|
|
*/
|
|
|
|
import './Collapsible.css';
|
|
|
|
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
|
import {$findMatchingParent, mergeRegister} from '@lexical/utils';
|
|
import {
|
|
$createParagraphNode,
|
|
$getNodeByKey,
|
|
$getPreviousSelection,
|
|
$getSelection,
|
|
$isElementNode,
|
|
$isRangeSelection,
|
|
$setSelection,
|
|
COMMAND_PRIORITY_EDITOR,
|
|
COMMAND_PRIORITY_LOW,
|
|
createCommand,
|
|
DELETE_CHARACTER_COMMAND,
|
|
INSERT_PARAGRAPH_COMMAND,
|
|
KEY_ARROW_DOWN_COMMAND,
|
|
NodeKey,
|
|
} from 'lexical';
|
|
import {useEffect} from 'react';
|
|
|
|
import {
|
|
$createCollapsibleContainerNode,
|
|
$isCollapsibleContainerNode,
|
|
CollapsibleContainerNode,
|
|
} from './CollapsibleContainerNode';
|
|
import {
|
|
$createCollapsibleContentNode,
|
|
$isCollapsibleContentNode,
|
|
CollapsibleContentNode,
|
|
} from './CollapsibleContentNode';
|
|
import {
|
|
$createCollapsibleTitleNode,
|
|
$isCollapsibleTitleNode,
|
|
CollapsibleTitleNode,
|
|
} from './CollapsibleTitleNode';
|
|
|
|
export const INSERT_COLLAPSIBLE_COMMAND = createCommand<void>();
|
|
export const TOGGLE_COLLAPSIBLE_COMMAND = createCommand<NodeKey>();
|
|
|
|
export default function CollapsiblePlugin(): JSX.Element | null {
|
|
const [editor] = useLexicalComposerContext();
|
|
useEffect(() => {
|
|
if (
|
|
!editor.hasNodes([
|
|
CollapsibleContainerNode,
|
|
CollapsibleTitleNode,
|
|
CollapsibleContentNode,
|
|
])
|
|
) {
|
|
throw new Error(
|
|
'CollapsiblePlugin: CollapsibleContainerNode, CollapsibleTitleNode, or CollapsibleContentNode not registered on editor',
|
|
);
|
|
}
|
|
|
|
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.insertAfter(child);
|
|
}
|
|
node.remove();
|
|
}
|
|
}),
|
|
editor.registerNodeTransform(CollapsibleTitleNode, (node) => {
|
|
const parent = node.getParent();
|
|
if (!$isCollapsibleContainerNode(parent)) {
|
|
node.replace($createParagraphNode().append(...node.getChildren()));
|
|
}
|
|
}),
|
|
editor.registerNodeTransform(CollapsibleContainerNode, (node) => {
|
|
const children = node.getChildren();
|
|
if (
|
|
children.length !== 2 ||
|
|
!$isCollapsibleTitleNode(children[0]) ||
|
|
!$isCollapsibleContentNode(children[1])
|
|
) {
|
|
for (const child of children) {
|
|
node.insertAfter(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,
|
|
),
|
|
// When collapsible is the last child pressing down 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,
|
|
() => {
|
|
const selection = $getSelection();
|
|
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
|
|
return false;
|
|
}
|
|
|
|
const container = $findMatchingParent(
|
|
selection.anchor.getNode(),
|
|
$isCollapsibleContainerNode,
|
|
);
|
|
|
|
if (container === null) {
|
|
return false;
|
|
}
|
|
|
|
const parent = container.getParent();
|
|
if (parent !== null && parent.getLastChild() === container) {
|
|
parent.append($createParagraphNode());
|
|
}
|
|
return false;
|
|
},
|
|
COMMAND_PRIORITY_LOW,
|
|
),
|
|
// Handling CMD+Enter to toggle collapsible element collapsed state
|
|
editor.registerCommand(
|
|
INSERT_PARAGRAPH_COMMAND,
|
|
() => {
|
|
// @ts-ignore
|
|
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();
|
|
if ($isCollapsibleContainerNode(container)) {
|
|
container.toggleOpen();
|
|
$setSelection(selection.clone());
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
},
|
|
COMMAND_PRIORITY_LOW,
|
|
),
|
|
editor.registerCommand(
|
|
INSERT_COLLAPSIBLE_COMMAND,
|
|
() => {
|
|
editor.update(() => {
|
|
const selection = $getSelection();
|
|
|
|
if (!$isRangeSelection(selection)) {
|
|
return;
|
|
}
|
|
|
|
const title = $createCollapsibleTitleNode();
|
|
const content = $createCollapsibleContentNode().append(
|
|
$createParagraphNode(),
|
|
);
|
|
const container = $createCollapsibleContainerNode(true).append(
|
|
title,
|
|
content,
|
|
);
|
|
selection.insertNodes([container]);
|
|
title.selectStart();
|
|
});
|
|
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
editor.registerCommand(
|
|
TOGGLE_COLLAPSIBLE_COMMAND,
|
|
(key: NodeKey) => {
|
|
editor.update(() => {
|
|
const containerNode = $getNodeByKey(key);
|
|
if ($isCollapsibleContainerNode(containerNode)) {
|
|
containerNode.toggleOpen();
|
|
}
|
|
});
|
|
|
|
return true;
|
|
},
|
|
COMMAND_PRIORITY_EDITOR,
|
|
),
|
|
);
|
|
}, [editor]);
|
|
return null;
|
|
}
|