/**
* Tab strips.
*
* Tab panels are ordinary element nodes in the document, so their content
* collaborates like any other block. Only *visibility* is local: switching tabs
* must not edit the document or move other people, so the strip is rendered
* alongside the panels and toggles their DOM, leaving the model untouched.
*/
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $getRoot, $isElementNode } from "lexical";
import { $isTabNode, $isTabsNode } from "@chopin/dialect";
import type { ElementNode, LexicalEditor } from "lexical";
type Group = {
id: string;
key: string;
tabs: Array<{ id: string; key: string; label: string }>;
};
/** Read the tab structure out of the document. */
function collect(editor: LexicalEditor): Group[] {
let groups: Group[] = [];
editor.getEditorState().read(() => {
let walk = (node: ElementNode) => {
for (let child of node.getChildren()) {
if ($isTabsNode(child)) {
groups.push({
id: child.getId(),
key: child.getKey(),
tabs: child.getChildren().filter($isTabNode).map(tab => ({
id: tab.getId(),
key: tab.getKey(),
label: tab.getLabel(),
})),
});
}
if ($isElementNode(child)) walk(child);
}
};
walk($getRoot());
});
return groups;
}
function Strip(
{ group, active, onSelect }: {
group: Group;
active: string;
onSelect: (key: string) => void;
},
) {
let select = (index: number) => onSelect(group.tabs[index]!.key);
return (
{group.tabs.map((tab, position) => (
))}
);
}
export function TabsPlugin() {
let [editor] = useLexicalComposerContext();
let [groups, setGroups] = useState([]);
/** Active tab per group, by node key. Local to this viewer. */
let [active, setActive] = useState>({});
useEffect(() => {
let update = () => setGroups(collect(editor));
update();
return editor.registerUpdateListener(update);
}, [editor]);
let select = useCallback((group: string, key: string) => {
setActive(prev => ({ ...prev, [group]: key }));
}, []);
// Show exactly one panel per group without touching the document.
useEffect(() => {
for (let group of groups) {
let chosen = active[group.key] ?? group.tabs[0]?.key;
for (let tab of group.tabs) {
let element = editor.getElementByKey(tab.key);
if (!element) continue;
element.hidden = tab.key !== chosen;
element.setAttribute("id", `ace-panel-${tab.key}`);
element.setAttribute("aria-labelledby", `ace-tab-${tab.key}`);
}
}
}, [editor, groups, active]);
return (
<>
{groups.map(group => {
// The node reserves an unmanaged container for exactly this, so
// the strip can live inside the node without Lexical removing it.
let host = editor.getElementByKey(group.key)
?.querySelector("[data-plan-chrome='tabs']");
if (!host) return null;
return createPortal(
select(group.key, key)}
/>,
host,
group.key,
);
})}
>
);
}