/** * 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, ); })} ); }