From c3d1df5adc6855ee0584ec34a7081fced65277f3 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Tue, 9 Jun 2026 02:23:26 +0200 Subject: [PATCH] improved document and view management, added outline feature --- components/board/BoardCanvas.tsx | 57 +++- components/editor/BoardPanel.tsx | 17 +- components/editor/DocumentEditorPanel.tsx | 22 +- components/editor/TreeDocumentPanel.tsx | 13 +- .../editor/outline/OutlineItem.module.css | 139 ++++++++ components/editor/outline/OutlineItem.tsx | 149 +++++++++ components/editor/outline/OutlinePanel.tsx | 37 +++ .../editor/outline/OutlineView.module.css | 28 ++ components/editor/outline/OutlineView.tsx | 314 ++++++++++++++++++ .../editor/sidebar/ContextMenu.module.css | 29 ++ components/editor/sidebar/ContextMenu.tsx | 47 ++- .../sidebar/DocumentTreeItem.module.css | 22 +- .../editor/sidebar/DocumentTreeItem.tsx | 156 ++++----- .../sidebar/DocumentTreeSidebarView.tsx | 219 ++++++++---- .../EditorSidebarNavigation.module.css | 39 --- .../editor/sidebar/SidebarItem.module.css | 12 + .../editor/sidebar/SidebarSceneItem.tsx | 4 +- components/navbar/ViewOptionsDropdown.tsx | 20 +- .../project/SplitPanelContainer.module.css | 15 +- components/project/SplitPanelContainer.tsx | 174 +++++++--- messages/de.json | 11 + messages/en.json | 11 + messages/es.json | 11 + messages/fr.json | 11 + messages/ja.json | 11 + messages/ko.json | 11 + messages/pl.json | 11 + messages/zh.json | 11 + src/context/ProjectContext.tsx | 40 ++- src/context/ViewContext.tsx | 115 ++++--- src/lib/document-tree/document-tree-config.ts | 1 + src/lib/editor/document-editor-config.ts | 10 +- src/lib/project/project-doc.ts | 50 +++ src/lib/project/project-repository.ts | 133 +++++++- src/lib/project/project-state.ts | 3 + src/lib/utils/colors.ts | 16 + 36 files changed, 1633 insertions(+), 336 deletions(-) create mode 100644 components/editor/outline/OutlineItem.module.css create mode 100644 components/editor/outline/OutlineItem.tsx create mode 100644 components/editor/outline/OutlinePanel.tsx create mode 100644 components/editor/outline/OutlineView.module.css create mode 100644 components/editor/outline/OutlineView.tsx create mode 100644 src/lib/utils/colors.ts diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx index e7a660e..9927b30 100644 --- a/components/board/BoardCanvas.tsx +++ b/components/board/BoardCanvas.tsx @@ -6,25 +6,14 @@ import { BoardCardData, BoardArrowData } from "@src/lib/project/project-state"; import BoardCard from "./BoardCard"; import styles from "./BoardCanvas.module.css"; import { v7 as uuidv7 } from "uuid"; -import { Trash2, Plus, Minus, Copy } from "lucide-react"; +import { Trash2, Plus, Minus, Copy, ListTree } from "lucide-react"; import { useTranslations } from "next-intl"; +import { DEFAULT_ITEM_COLORS } from "@src/lib/utils/colors"; const GRID_SIZE = 20; const MIN_SCALE = 0.25; const MAX_SCALE = 2; -const DEFAULT_CARD_COLORS = [ - "#ef4444", - "#f97316", - "#eab308", - "#22c55e", - "#06b6d4", - "#3b82f6", - "#8b5cf6", - "#ec4899", - "#6b7280", -]; - interface CardContextMenuState { position: { x: number; y: number }; card: BoardCardData; @@ -36,7 +25,8 @@ interface ArrowContextMenuState { } const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string }) => { - const { repository, isYjsReady, isReadOnly } = useContext(ProjectContext); + const { repository, isYjsReady, isReadOnly, boardFocusCardId, setBoardFocusCardId } = + useContext(ProjectContext); const t = useTranslations("board"); const projectState = repository?.getState(); const containerRef = useRef(null); @@ -121,6 +111,17 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } setOffset({ x: newOffsetX, y: newOffsetY }); }, []); + // Focus a specific card when navigated to from the Outline. Waits until the + // board's cards have loaded and the target exists on this board, then centers + // on it and clears the request so it fires once. + useEffect(() => { + if (!boardFocusCardId || !isVisible) return; + const card = cards.find((c) => c.id === boardFocusCardId); + if (!card) return; + centerCameraOnCards([card]); + setBoardFocusCardId(null); + }, [boardFocusCardId, isVisible, cards, centerCameraOnCards, setBoardFocusCardId]); + // Sync cards with Yjs useEffect(() => { if (!projectState || !isYjsReady) return; @@ -475,7 +476,7 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } const x = (e.clientX - rect.left - offset.x) / scale; const y = (e.clientY - rect.top - offset.y) / scale; - const randomColor = DEFAULT_CARD_COLORS[Math.floor(Math.random() * DEFAULT_CARD_COLORS.length)]; + const randomColor = DEFAULT_ITEM_COLORS[Math.floor(Math.random() * DEFAULT_ITEM_COLORS.length)]; const newCard: BoardCardData = { id: uuidv7(), @@ -571,6 +572,23 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } [cards, saveCards], ); + // Send card to the Outline view + const handleSendToOutline = useCallback( + (card: BoardCardData) => { + repository?.addOutlineItem({ + source: "card", + refDocId: docId, + refId: card.id, + title: card.title, + preview: card.description, + color: card.color, + parentId: null, + }); + setCardContextMenu(null); + }, + [repository, docId], + ); + // Context menu for card const handleCardContextMenu = useCallback((e: React.MouseEvent, card: BoardCardData) => { setCardContextMenu({ @@ -917,7 +935,7 @@ const BoardCanvas = ({ isVisible, docId }: { isVisible: boolean; docId: string } }} >
- {DEFAULT_CARD_COLORS.map((color) => ( + {DEFAULT_ITEM_COLORS.map((color) => (
+
handleSendToOutline(cardContextMenu.card)} + > + +

{t("sendToOutline")}

+
handleDeleteCard(cardContextMenu.card.id)} diff --git a/components/editor/BoardPanel.tsx b/components/editor/BoardPanel.tsx index 0424e93..5bf8d9c 100644 --- a/components/editor/BoardPanel.tsx +++ b/components/editor/BoardPanel.tsx @@ -1,8 +1,6 @@ "use client"; -import { useContext } from "react"; import { useTranslations } from "next-intl"; -import { ProjectContext } from "@src/context/ProjectContext"; import BoardCanvas from "@components/board/BoardCanvas"; import { LayoutDashboard } from "lucide-react"; @@ -20,19 +18,16 @@ const EmptyBoardState = () => { }; /** - * Renders the board document currently selected in the document tree. The - * "board" panel is doc-aware: it reads `activeDocument` and mounts a fresh - * BoardCanvas (keyed by id) for the active board, or an empty state when the - * active document isn't a board. + * Renders the board bound to this panel side (`docId`). Each side carries its + * own docId, so two boards can be open at once. A fresh BoardCanvas is mounted + * per docId; an empty state shows when the side has no document. */ -const BoardPanel = ({ isVisible }: { isVisible: boolean }) => { - const { activeDocument } = useContext(ProjectContext); - - if (!activeDocument || activeDocument.type !== "board") { +const BoardPanel = ({ isVisible, docId }: { isVisible: boolean; docId: string | null }) => { + if (!docId) { return ; } - return ; + return ; }; export default BoardPanel; diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index 2460316..60a053a 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -500,6 +500,24 @@ const DocumentEditorPanel = ({ } } + // Detect a scene heading at the caret to offer "Send to outline". + // Independent of `shelving` so it works in editor documents too. + let outlineScene: { refDocId: string; refId: string; title: string } | undefined; + if (config.documentId) { + const $pos = editor.state.doc.resolve(from); + if ($pos.depth >= 1) { + const node = $pos.node(1); + const dataId = node.attrs?.["data-id"] as string | undefined; + if (node.attrs?.class === ScreenplayElement.Scene && dataId) { + outlineScene = { + refDocId: config.documentId, + refId: dataId, + title: node.textContent.toUpperCase(), + }; + } + } + } + const onAddComment = () => { if (!editor) return; const commentId = commentOps.addComment({ @@ -516,10 +534,10 @@ const DocumentEditorPanel = ({ updateContextMenu({ type: ContextMenuType.EditorContextMenu, position: { x: e.clientX, y: e.clientY }, - typeSpecificProps: { from, to, onAddComment, spellError, nodePos, nodeClass }, + typeSpecificProps: { from, to, onAddComment, spellError, nodePos, nodeClass, outlineScene }, }); }, - [editor, updateContextMenu, commentOps, user, config.features.shelving], + [editor, updateContextMenu, commentOps, user, config.features.shelving, config.documentId], ); // Clear active comment on mousedown diff --git a/components/editor/TreeDocumentPanel.tsx b/components/editor/TreeDocumentPanel.tsx index b014952..bae21f7 100644 --- a/components/editor/TreeDocumentPanel.tsx +++ b/components/editor/TreeDocumentPanel.tsx @@ -20,13 +20,10 @@ const EmptyDocumentState = () => { ); }; -const TreeDocumentPanel = ({ isVisible }: { isVisible: boolean }) => { - const { activeDocument, updateDocumentEditor } = useContext(ProjectContext); +const TreeDocumentPanel = ({ isVisible, docId }: { isVisible: boolean; docId: string | null }) => { + const { updateDocumentEditor } = useContext(ProjectContext); - const config = useMemo(() => { - if (!activeDocument || activeDocument.type !== "editor") return null; - return createDocumentTreeConfig(activeDocument.docId); - }, [activeDocument]); + const config = useMemo(() => (docId ? createDocumentTreeConfig(docId) : null), [docId]); const handleEditorCreated = useCallback( (editor: import("@tiptap/react").Editor | null) => { @@ -35,13 +32,13 @@ const TreeDocumentPanel = ({ isVisible }: { isVisible: boolean }) => { [updateDocumentEditor], ); - if (!config || !activeDocument || activeDocument.type !== "editor") { + if (!config || !docId) { return ; } return ( OutlineItemType[]; + resolved: Record; + onNavigate: (node: OutlineItemType) => void; + onRemove: (id: string) => void; + // Drag & drop + draggingId: string | null; + dropTarget: { id: string; pos: DropPosition } | null; + onDragStart: (id: string) => void; + onDragOverNode: (id: string, pos: DropPosition) => void; + onDropNode: (id: string) => void; + onDragEnd: () => void; +} + +const INDENT = 36; + +const OutlineItem = ({ + node, + depth, + childrenOf, + resolved, + onNavigate, + onRemove, + draggingId, + dropTarget, + onDragStart, + onDragOverNode, + onDropNode, + onDragEnd, +}: OutlineItemProps) => { + const t = useTranslations("outline"); + + const r = resolved[node.id] ?? { title: node.title, preview: node.preview, color: node.color, missing: true }; + const isCard = node.source === "card"; + const children = childrenOf(node.id); + + // Every block can nest children, so use folder-style drop thresholds: + // top 25% = before, middle 50% = into, bottom 25% = after. + const handleDragOver = useCallback( + (e: React.DragEvent) => { + if (!draggingId || draggingId === node.id) return; + e.preventDefault(); + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + const y = e.clientY - rect.top; + let pos: DropPosition; + if (y < rect.height * 0.25) pos = "before"; + else if (y > rect.height * 0.75) pos = "after"; + else pos = "into"; + onDragOverNode(node.id, pos); + }, + [draggingId, node.id, onDragOverNode], + ); + + const isDropTarget = dropTarget?.id === node.id; + const blockClass = join( + styles.block, + r.missing ? styles.block_missing : "", + isDropTarget && dropTarget?.pos === "into" ? styles.block_drop_into : "", + isDropTarget && dropTarget?.pos === "before" ? styles.block_drop_before : "", + isDropTarget && dropTarget?.pos === "after" ? styles.block_drop_after : "", + ); + + return ( + <> +
+
onNavigate(node)} + draggable + onDragStart={(e) => { + e.dataTransfer.effectAllowed = "move"; + onDragStart(node.id); + }} + onDragOver={handleDragOver} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + onDropNode(node.id); + }} + onDragEnd={onDragEnd} + > + {isCard && node.color && ( + + )} +
+
+ {!isCard && } + {r.title || t("untitled")} + {r.missing && ( + + + {t("unlinked")} + + )} + +
+ {r.preview &&

{r.preview}

} +
+
+
+ + {children.map((child) => ( + + ))} + + ); +}; + +export default OutlineItem; diff --git a/components/editor/outline/OutlinePanel.tsx b/components/editor/outline/OutlinePanel.tsx new file mode 100644 index 0000000..fdd99f7 --- /dev/null +++ b/components/editor/outline/OutlinePanel.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useContext } from "react"; +import { useTranslations } from "next-intl"; +import { ProjectContext } from "@src/context/ProjectContext"; +import OutlineView from "./OutlineView"; +import { ListTree } from "lucide-react"; + +import styles from "../EditorPanel.module.css"; + +const EmptyOutlineState = () => { + const t = useTranslations("outline"); + + return ( +
+ +

{t("empty")}

+
+ ); +}; + +/** + * The Outline view: a project-wide, ordered, nestable list of blocks that + * reference scene headings and board cards. Writers reorder/indent blocks to + * sequence their story beats. Shows an empty state until something is sent here. + */ +const OutlinePanel = ({ isVisible }: { isVisible: boolean }) => { + const { outline } = useContext(ProjectContext); + + if (Object.keys(outline).length === 0) { + return ; + } + + return ; +}; + +export default OutlinePanel; diff --git a/components/editor/outline/OutlineView.module.css b/components/editor/outline/OutlineView.module.css new file mode 100644 index 0000000..3375890 --- /dev/null +++ b/components/editor/outline/OutlineView.module.css @@ -0,0 +1,28 @@ +.outline_panel { + position: relative; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + background-color: var(--main-bg); +} + +.outline_scroll { + position: relative; + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: clip; + scrollbar-gutter: stable; +} + +.outline_list { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + max-width: 760px; + margin: 0 auto; + padding: 16px; + box-sizing: border-box; +} diff --git a/components/editor/outline/OutlineView.tsx b/components/editor/outline/OutlineView.tsx new file mode 100644 index 0000000..ea24579 --- /dev/null +++ b/components/editor/outline/OutlineView.tsx @@ -0,0 +1,314 @@ +"use client"; + +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { ProjectContext } from "@src/context/ProjectContext"; +import { SplitSide, useViewContext } from "@src/context/ViewContext"; +import { BoardCardData, MAIN_SCREENPLAY_REF, OutlineItem } from "@src/lib/project/project-state"; +import { TransientScene } from "@src/lib/screenplay/scenes"; +import { focusOnPosition } from "@src/lib/screenplay/editor"; +import OutlineBlock, { DropPosition } from "./OutlineItem"; + +import styles from "./OutlineView.module.css"; + +/** Live-resolved display data for an outline block. */ +export type ResolvedOutline = { + title: string; + preview: string; + color?: string; + /** True when the referenced source element no longer exists. */ + missing: boolean; + /** Editor position of a resolved scene (for navigation). */ + position?: number; +}; + +const PREVIEW_MAX = 80; + +const truncate = (text: string) => { + const clean = text.replace(/\s+/g, " ").trim(); + return clean.length > PREVIEW_MAX ? clean.slice(0, PREVIEW_MAX).trimEnd() + "…" : clean; +}; + +const parseCards = (raw: unknown): BoardCardData[] => { + if (!raw) return []; + try { + return typeof raw === "string" ? (JSON.parse(raw) as BoardCardData[]) : (raw as BoardCardData[]); + } catch { + return []; + } +}; + +const OutlineView = ({ isVisible }: { isVisible: boolean }) => { + const { outline, repository, scenes, editor, documentEditor, setBoardFocusCardId } = + useContext(ProjectContext); + const { isSplit, primaryPanel, setSidePanel, setFocusedSide, setSideDocument } = useViewContext(); + const projectState = repository?.getState(); + + const [draggingId, setDraggingId] = useState(null); + const [dropTarget, setDropTarget] = useState<{ id: string; pos: DropPosition } | null>(null); + + // Children of a parent (null = root), sorted by fractional `order`. + const childrenOf = useCallback( + (parentId: string | null): OutlineItem[] => + Object.values(outline) + .filter((n) => (n.parentId ?? null) === (parentId ?? null)) + .sort((a, b) => a.order - b.order), + [outline], + ); + + const roots = useMemo(() => childrenOf(null), [childrenOf]); + + const appendOrder = useCallback( + (parentId: string | null, excludeId?: string) => { + const kids = childrenOf(parentId).filter((n) => n.id !== excludeId); + return kids.length ? kids[kids.length - 1].order + 1 : 0; + }, + [childrenOf], + ); + + // ---- Live resolution of references ---- + + // Distinct source documents referenced by current blocks. + const cardBoardIds = useMemo( + () => [...new Set(Object.values(outline).filter((i) => i.source === "card").map((i) => i.refDocId))], + [outline], + ); + const editorDocIds = useMemo( + () => + [ + ...new Set( + Object.values(outline) + .filter((i) => i.source === "scene" && i.refDocId !== MAIN_SCREENPLAY_REF) + .map((i) => i.refDocId), + ), + ], + [outline], + ); + + // Bump a counter whenever a referenced board / editor-doc changes, so the + // resolver recomputes. Main-screenplay scenes come from `scenes` (reactive). + const [sourceVersion, setSourceVersion] = useState(0); + const cardKey = cardBoardIds.join(","); + const editorKey = editorDocIds.join(","); + useEffect(() => { + if (!projectState) return; + const bump = () => setSourceVersion((v) => v + 1); + const unsubs: (() => void)[] = []; + for (const id of cardBoardIds) { + const map = projectState.boardData(id); + map.observe(bump); + unsubs.push(() => map.unobserve(bump)); + } + for (const id of editorDocIds) { + const frag = projectState.documentFragment(id); + frag.observe(bump); + unsubs.push(() => frag.unobserve(bump)); + } + return () => unsubs.forEach((u) => u()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectState, cardKey, editorKey]); + + const resolved = useMemo>(() => { + const out: Record = {}; + + // Lookups for the main screenplay scenes (already live via context). + const mainScenes = new Map(scenes.filter((s) => s.id).map((s) => [s.id as string, s])); + + // Parse each referenced editor document's scenes once. + const editorScenes = new Map>(); + for (const id of editorDocIds) { + const list = repository?.getEditorDocumentScenes(id) ?? []; + editorScenes.set(id, new Map(list.filter((s) => s.id).map((s) => [s.id as string, s]))); + } + + // Parse each referenced board's cards once. + const boardCards = new Map>(); + for (const id of cardBoardIds) { + const cards = parseCards(projectState?.boardData(id).get("cards")); + boardCards.set(id, new Map(cards.map((c) => [c.id, c]))); + } + + for (const item of Object.values(outline)) { + if (item.source === "card") { + const card = boardCards.get(item.refDocId)?.get(item.refId); + out[item.id] = card + ? { title: card.title, preview: truncate(card.description), color: card.color, missing: false } + : { title: item.title, preview: item.preview, color: item.color, missing: true }; + } else { + const scene = + item.refDocId === MAIN_SCREENPLAY_REF + ? mainScenes.get(item.refId) + : editorScenes.get(item.refDocId)?.get(item.refId); + if (scene) { + const synopsis = "synopsis" in scene ? (scene.synopsis as string | undefined) : undefined; + const color = "color" in scene ? (scene.color as string | undefined) : undefined; + out[item.id] = { + title: scene.title, + preview: truncate(synopsis || scene.preview), + color, + missing: false, + position: scene.position, + }; + } else { + out[item.id] = { title: item.title, preview: item.preview, color: item.color, missing: true }; + } + } + } + return out; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [outline, scenes, sourceVersion, cardKey, editorKey, repository, projectState]); + + // Keep each block's cached snapshot fresh so the "unlinked" fallback shows + // the last-known title/preview/color. Only writes when a resolved value + // actually changed (and the source still exists), so it settles quickly. + // Gated on visibility to avoid background writes for an unseen panel. + useEffect(() => { + if (!repository || !isVisible) return; + for (const item of Object.values(outline)) { + const r = resolved[item.id]; + if (!r || r.missing) continue; + if (r.title !== item.title || r.preview !== item.preview || (r.color ?? undefined) !== (item.color ?? undefined)) { + repository.refreshOutlineSnapshot(item.id, { title: r.title, preview: r.preview, color: r.color }); + } + } + }, [repository, outline, resolved, isVisible]); + + // ---- Navigation ---- + + // Best-effort focus of a freshly opened document editor needs the latest + // instance, so track it in a ref. + const documentEditorRef = useRef(documentEditor); + documentEditorRef.current = documentEditor; + + const navigate = useCallback( + (item: OutlineItem) => { + const r = resolved[item.id]; + if (!r || r.missing) return; + + // Open in the panel next to the Outline, leaving the Outline in place. + // When unsplit there's only one panel, so the target takes it over. + const targetSide: SplitSide = isSplit ? (primaryPanel === "outline" ? "secondary" : "primary") : "primary"; + + if (item.source === "card") { + setSideDocument(targetSide, item.refDocId, "board"); + setBoardFocusCardId(item.refId); + return; + } + + // Scene + if (item.refDocId === MAIN_SCREENPLAY_REF) { + setSidePanel(targetSide, "screenplay"); + setFocusedSide(targetSide); + if (editor && r.position !== undefined) focusOnPosition(editor, r.position); + return; + } + + // Scene inside an editor document. + setSideDocument(targetSide, item.refDocId, "document"); + if (r.position !== undefined) { + const pos = r.position; + window.setTimeout(() => { + const ed = documentEditorRef.current; + if (ed) focusOnPosition(ed, pos); + }, 350); + } + }, + [resolved, editor, isSplit, primaryPanel, setSideDocument, setSidePanel, setFocusedSide, setBoardFocusCardId], + ); + + const remove = useCallback( + (id: string) => { + repository?.deleteOutlineItem(id); + }, + [repository], + ); + + // ---- Drag & drop (mirrors DocumentTreeSidebarView) ---- + + const onDragStart = useCallback((id: string) => setDraggingId(id), []); + + const onDragOverNode = useCallback((id: string, pos: DropPosition) => { + setDropTarget((prev) => (prev?.id === id && prev.pos === pos ? prev : { id, pos })); + }, []); + + const resetDrag = useCallback(() => { + setDraggingId(null); + setDropTarget(null); + }, []); + + const onDropNode = useCallback( + (targetId: string) => { + const dragId = draggingId; + const target = outline[targetId]; + const pos = dropTarget?.pos; + resetDrag(); + if (!repository || !dragId || !target || !pos || dragId === targetId) return; + + if (pos === "into") { + repository.moveOutlineItem(dragId, target.id, appendOrder(target.id, dragId)); + return; + } + + const parentId = target.parentId ?? null; + const siblings = childrenOf(parentId).filter((n) => n.id !== dragId); + const idx = siblings.findIndex((n) => n.id === targetId); + let order: number; + if (pos === "before") { + const prev = siblings[idx - 1]; + order = prev ? (prev.order + target.order) / 2 : target.order - 1; + } else { + const next = siblings[idx + 1]; + order = next ? (target.order + next.order) / 2 : target.order + 1; + } + repository.moveOutlineItem(dragId, parentId, order); + }, + [draggingId, outline, dropTarget, repository, appendOrder, childrenOf, resetDrag], + ); + + const onDropRoot = useCallback(() => { + const dragId = draggingId; + resetDrag(); + if (!repository || !dragId) return; + repository.moveOutlineItem(dragId, null, appendOrder(null, dragId)); + }, [draggingId, repository, appendOrder, resetDrag]); + + return ( +
+
{ + if (!draggingId) return; + // Over empty space (not a block): clear the block indicator; + // dropping here still moves the item to the root level. + e.preventDefault(); + setDropTarget(null); + }} + onDrop={(e) => { + e.preventDefault(); + onDropRoot(); + }} + > +
+ {roots.map((node) => ( + + ))} +
+
+
+ ); +}; + +export default OutlineView; diff --git a/components/editor/sidebar/ContextMenu.module.css b/components/editor/sidebar/ContextMenu.module.css index fa554e3..3911f80 100644 --- a/components/editor/sidebar/ContextMenu.module.css +++ b/components/editor/sidebar/ContextMenu.module.css @@ -56,6 +56,35 @@ margin: 2px 0; } +/* Right-click color picker (document tree items) */ +.colors { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 10px 12px; +} + +.color_swatch { + width: 20px; + height: 20px; + padding: 0; + border: 2px solid transparent; + border-radius: 50%; + cursor: pointer; + transition: + transform 0.15s ease, + border-color 0.15s ease; +} + +.color_swatch:hover { + transform: scale(1.15); +} + +.color_swatch_active { + border-color: var(--primary-text); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3); +} + .suggestion_item { display: flex; align-items: center; diff --git a/components/editor/sidebar/ContextMenu.tsx b/components/editor/sidebar/ContextMenu.tsx index 387eb58..b2ffe78 100644 --- a/components/editor/sidebar/ContextMenu.tsx +++ b/components/editor/sidebar/ContextMenu.tsx @@ -24,6 +24,7 @@ import { EyeOff, Eye, Highlighter, + ListTree, Loader2, LucideIcon, MessageSquarePlus, @@ -36,6 +37,7 @@ import { makeDualDialogue } from "@src/lib/screenplay/dual-dialogue"; import { extractShelveCandidate } from "@src/lib/shelf/shelf-utils"; import { omitSceneByUuid, unomitSceneByUuid } from "@src/lib/screenplay/scene-locking"; import { ScreenplayElement } from "@src/lib/utils/enums"; +import { MAIN_SCREENPLAY_REF } from "@src/lib/project/project-state"; /* ==================== */ /* Context menu */ @@ -126,6 +128,19 @@ const SceneItemMenu = ({ props }: SubMenuProps) => { unomitSceneByUuid(editor, repository, scene.id); }; + const handleSendToOutline = () => { + if (!repository || !scene.id) return; + repository.addOutlineItem({ + source: "scene", + refDocId: MAIN_SCREENPLAY_REF, + refId: scene.id, + title: scene.title, + preview: scene.synopsis || scene.preview, + color: scene.color, + parentId: null, + }); + }; + return ( <> ) => { {!isReadOnly && ( <>
+ {scene.id && ( + + )} editScenePopup(scene, userCtx)} /> ) => { @@ -482,9 +506,22 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { useContext(ProjectContext); const { worker } = useSpellcheck(); const { updateContextMenu } = useContext(UserContext); - const { from, to, onAddComment, spellError, nodePos, nodeClass } = props; + const { from, to, onAddComment, spellError, nodePos, nodeClass, outlineScene } = props; const hasSelection = from !== to; + const handleSendToOutline = () => { + if (!repository || !outlineScene) return; + repository.addOutlineItem({ + source: "scene", + refDocId: outlineScene.refDocId, + refId: outlineScene.refId, + title: outlineScene.title, + preview: "", + parentId: null, + }); + updateContextMenu(undefined); + }; + // Resolve scene UUID + lock state if right-clicked on a scene heading. // `nodePos` is the cursor position inside the paragraph (from the editor // dispatcher), so we resolve up to the depth-1 ancestor — the scene

@@ -682,6 +719,14 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { )} + {/* Send a scene heading to the Outline */} + {outlineScene && !isReadOnly && ( + <> +

+ + + )} + {/* Production: Omit / Unomit on a locked scene heading */} {sceneInfo && !isReadOnly && ( <> diff --git a/components/editor/sidebar/DocumentTreeItem.module.css b/components/editor/sidebar/DocumentTreeItem.module.css index 58f2e07..8522327 100644 --- a/components/editor/sidebar/DocumentTreeItem.module.css +++ b/components/editor/sidebar/DocumentTreeItem.module.css @@ -24,10 +24,21 @@ background-color: var(--editor-sidebar-hover); } +/* Slim accent bar on the left edge showing the node's color. */ +.color_bar { + position: absolute; + left: 0; + top: 4px; + bottom: 4px; + width: 3px; + border-radius: 0 3px 3px 0; + pointer-events: none; +} + /* Drop indicators */ .row_drop_into { background-color: var(--editor-style-bg-hover); - box-shadow: inset 0 0 0 1px var(--primary-text); + box-shadow: inset 0 0 0 1px var(--tertiary-hover); } .row_drop_before::before, @@ -37,16 +48,19 @@ left: 8px; right: 8px; height: 2px; - background-color: var(--primary-text); + background-color: var(--tertiary-hover); pointer-events: none; } +/* Centered on the row boundary so "after A" and "before B" (the same gap, which + the cursor alternates between) render at the exact same position — one line, + not two offset by a pixel. */ .row_drop_before::before { - top: 0; + top: -1px; } .row_drop_after::after { - bottom: 0; + bottom: -1px; } .chevron { diff --git a/components/editor/sidebar/DocumentTreeItem.tsx b/components/editor/sidebar/DocumentTreeItem.tsx index 35ba2cc..4960f13 100644 --- a/components/editor/sidebar/DocumentTreeItem.tsx +++ b/components/editor/sidebar/DocumentTreeItem.tsx @@ -1,19 +1,10 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; import { DocumentNode, DocumentNodeType } from "@src/lib/project/project-state"; import { join } from "@src/lib/utils/misc"; -import { - ChevronRight, - FilePlus, - FileText, - Folder, - FolderPlus, - LayoutDashboard, - Pencil, - Trash2, -} from "lucide-react"; +import { ChevronRight, FileText, Folder, LayoutDashboard } from "lucide-react"; import styles from "./DocumentTreeItem.module.css"; @@ -25,17 +16,30 @@ const TYPE_ICONS: Record = { export type DropPosition = "into" | "before" | "after"; +/** + * dataTransfer MIME used when dragging an `editor`/`board` document out of the + * tree onto a panel to open it there. Reordering within the tree uses internal + * React state, so this only matters for cross-target (panel) drops. + */ +export const DOC_DND_MIME = "application/x-scriptio-doc"; + export interface DocumentTreeItemProps { node: DocumentNode; depth: number; childrenOf: (parentId: string) => DocumentNode[]; expanded: Set; onToggle: (id: string) => void; - activeDocId: string | null; + openDocIds: Set; onOpen: (node: DocumentNode) => void; - onCreateChild: (parentId: string, type: "folder" | "editor") => void; - onRename: (id: string, title: string) => void; - onDelete: (id: string) => void; + onContextMenu: (node: DocumentNode, e: React.MouseEvent) => void; + // Inline rename (state lifted to the view so the context menu can trigger it). + renamingId: string | null; + onRenameCommit: (id: string, title: string) => void; + onRenameCancel: () => void; + // Inline delete confirmation (state lifted to the view). + confirmingDeleteId: string | null; + onConfirmDelete: (id: string) => void; + onCancelDelete: () => void; // Drag & drop draggingId: string | null; dropTarget: { id: string; pos: DropPosition } | null; @@ -53,11 +57,15 @@ const DocumentTreeItem = ({ childrenOf, expanded, onToggle, - activeDocId, + openDocIds, onOpen, - onCreateChild, - onRename, - onDelete, + onContextMenu, + renamingId, + onRenameCommit, + onRenameCancel, + confirmingDeleteId, + onConfirmDelete, + onCancelDelete, draggingId, dropTarget, onDragStart, @@ -66,15 +74,14 @@ const DocumentTreeItem = ({ onDragEnd, }: DocumentTreeItemProps) => { const t = useTranslations("editorSidebar"); - const [isRenaming, setIsRenaming] = useState(false); - const [renameValue, setRenameValue] = useState(""); - const [confirmingDelete, setConfirmingDelete] = useState(false); const renameInputRef = useRef(null); const Icon = TYPE_ICONS[node.type]; const isFolder = node.type === "folder"; const isOpen = expanded.has(node.id); - const isActive = node.type === "editor" && activeDocId === node.id; + const isActive = openDocIds.has(node.id); + const isRenaming = renamingId === node.id; + const confirmingDelete = confirmingDeleteId === node.id; const busy = isRenaming || confirmingDelete; const handleRowClick = useCallback(() => { @@ -83,21 +90,9 @@ const DocumentTreeItem = ({ else onOpen(node); }, [busy, isFolder, node, onToggle, onOpen]); - const startRename = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - setRenameValue(node.title); - setIsRenaming(true); - setTimeout(() => renameInputRef.current?.select(), 0); - }, - [node.title], - ); - const commitRename = useCallback(() => { - const trimmed = renameValue.trim(); - if (trimmed) onRename(node.id, trimmed); - setIsRenaming(false); - }, [renameValue, node.id, onRename]); + onRenameCommit(node.id, (renameInputRef.current?.value ?? "").trim()); + }, [node.id, onRenameCommit]); const handleDragOver = useCallback( (e: React.DragEvent) => { @@ -134,9 +129,19 @@ const DocumentTreeItem = ({ className={rowClass} style={{ paddingLeft: 12 + depth * INDENT, opacity: draggingId === node.id ? 0.4 : 1 }} onClick={handleRowClick} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + onContextMenu(node, e); + }} draggable={!busy} onDragStart={(e) => { - e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.effectAllowed = "copyMove"; + // Openable documents carry their id/type so they can be + // dropped onto a panel to open there (folders cannot). + if (node.type === "editor" || node.type === "board") { + e.dataTransfer.setData(DOC_DND_MIME, JSON.stringify({ id: node.id, type: node.type })); + } onDragStart(node.id); }} onDragOver={handleDragOver} @@ -147,6 +152,7 @@ const DocumentTreeItem = ({ }} onDragEnd={onDragEnd} > + {node.color && } {isFolder ? ( setRenameValue(e.target.value)} + autoFocus + onFocus={(e) => e.currentTarget.select()} onBlur={commitRename} onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { if (e.key === "Enter") commitRename(); - else if (e.key === "Escape") setIsRenaming(false); + else if (e.key === "Escape") onRenameCancel(); }} /> ) : confirmingDelete ? ( @@ -178,65 +185,16 @@ const DocumentTreeItem = ({ {isFolder ? t("confirmDeleteFolder") : t("confirmDelete")}
- -
) : ( - <> - {node.title} -
- {isFolder && ( - <> - - - - )} - - -
- + {node.title} )}
@@ -250,11 +208,15 @@ const DocumentTreeItem = ({ childrenOf={childrenOf} expanded={expanded} onToggle={onToggle} - activeDocId={activeDocId} + openDocIds={openDocIds} onOpen={onOpen} - onCreateChild={onCreateChild} - onRename={onRename} - onDelete={onDelete} + onContextMenu={onContextMenu} + renamingId={renamingId} + onRenameCommit={onRenameCommit} + onRenameCancel={onRenameCancel} + confirmingDeleteId={confirmingDeleteId} + onConfirmDelete={onConfirmDelete} + onCancelDelete={onCancelDelete} draggingId={draggingId} dropTarget={dropTarget} onDragStart={onDragStart} diff --git a/components/editor/sidebar/DocumentTreeSidebarView.tsx b/components/editor/sidebar/DocumentTreeSidebarView.tsx index 9da6ed0..c1a4de2 100644 --- a/components/editor/sidebar/DocumentTreeSidebarView.tsx +++ b/components/editor/sidebar/DocumentTreeSidebarView.tsx @@ -1,26 +1,41 @@ "use client"; -import { useCallback, useContext, useMemo, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { ProjectContext } from "@src/context/ProjectContext"; import { useViewContext } from "@src/context/ViewContext"; import { DocumentNode } from "@src/lib/project/project-state"; +import { DEFAULT_ITEM_COLORS } from "@src/lib/utils/colors"; import { join } from "@src/lib/utils/misc"; -import { FilePlus, FolderPlus, FolderTree, LayoutDashboard } from "lucide-react"; +import { FilePlus, FolderPlus, FolderTree, LayoutDashboard, Pencil, Trash2 } from "lucide-react"; import DocumentTreeItem, { DropPosition } from "./DocumentTreeItem"; +import { ContextMenuItem } from "./ContextMenu"; import form from "./../../utils/Form.module.css"; import sidebar_nav from "./EditorSidebarNavigation.module.css"; +import context from "./ContextMenu.module.css"; + +type MenuState = { x: number; y: number; node: DocumentNode | null }; const DocumentTreeSidebarView = () => { const t = useTranslations("editorSidebar"); - const { documents, repository, activeDocument, setActiveDocument } = useContext(ProjectContext); - const { setSecondaryPanel } = useViewContext(); + const { documents, repository } = useContext(ProjectContext); + const { setSideDocument, closeDocument, primaryDocId, secondaryDocId } = useViewContext(); + + // Documents currently open in a panel — highlighted in the tree. + const openDocIds = useMemo( + () => new Set([primaryDocId, secondaryDocId].filter((id): id is string => !!id)), + [primaryDocId, secondaryDocId], + ); const [expanded, setExpanded] = useState>(new Set()); const [draggingId, setDraggingId] = useState(null); const [dropTarget, setDropTarget] = useState<{ id: string; pos: DropPosition } | null>(null); - const [rootDrop, setRootDrop] = useState(false); + + // Right-click menu + inline edit state (lifted here so the menu can drive it). + const [menu, setMenu] = useState(null); + const [renamingId, setRenamingId] = useState(null); + const [confirmingDeleteId, setConfirmingDeleteId] = useState(null); // Children of a parent (null = root), sorted by fractional `order`. const childrenOf = useCallback( @@ -52,39 +67,27 @@ const DocumentTreeSidebarView = () => { const openDocument = useCallback( (node: DocumentNode) => { - if (node.type === "board") { - setActiveDocument({ docId: node.id, type: "board" }); - setSecondaryPanel("board"); - } else if (node.type === "editor") { - setActiveDocument({ docId: node.id, type: "editor" }); - setSecondaryPanel("document"); - } + if (node.type === "board") setSideDocument("secondary", node.id, "board"); + else if (node.type === "editor") setSideDocument("secondary", node.id, "document"); }, - [setActiveDocument, setSecondaryPanel], + [setSideDocument], ); - const createChild = useCallback( - (parentId: string | null, type: "folder" | "editor") => { + const createInside = useCallback( + (parentId: string | null, type: "folder" | "editor" | "board") => { if (!repository) return; + if (parentId) setExpanded((prev) => new Set(prev).add(parentId)); if (type === "folder") repository.createFolder(t("untitledFolder"), parentId); + else if (type === "board") repository.createBoardDocument(t("boardTitle"), parentId); else repository.createEditorDocument(t("untitledDocument"), parentId); }, [repository, t], ); - const createBoard = useCallback(() => { - repository?.createBoardDocument(t("boardTitle"), null); - }, [repository, t]); - - const renameDocument = useCallback( - (id: string, title: string) => repository?.renameDocument(id, title), - [repository], - ); - const deleteDocument = useCallback( (id: string) => { if (!repository) return; - // Clear the open document if it (or one of its descendants) is being removed. + // Clear the open document if it (or a descendant) is being removed. const removed = new Set(); const stack = [id]; while (stack.length) { @@ -92,27 +95,66 @@ const DocumentTreeSidebarView = () => { removed.add(cur); for (const n of Object.values(documents)) if (n.parentId === cur) stack.push(n.id); } - if (activeDocument && removed.has(activeDocument.docId)) setActiveDocument(null); + removed.forEach((rid) => closeDocument(rid)); repository.deleteDocument(id); + setConfirmingDeleteId(null); }, - [repository, documents, activeDocument, setActiveDocument], + [repository, documents, closeDocument], ); - // ---- Drag & drop ---- - const onDragStart = useCallback((id: string) => setDraggingId(id), []); + const commitRename = useCallback( + (id: string, title: string) => { + if (title) repository?.renameDocument(id, title); + setRenamingId(null); + }, + [repository], + ); - const onDragOverNode = useCallback( - (id: string, pos: DropPosition) => { - setRootDrop(false); - setDropTarget((prev) => (prev?.id === id && prev.pos === pos ? prev : { id, pos })); + // Right-click color picker. Clicking the active color again clears it. + const setColor = useCallback( + (id: string, color: string) => { + const current = documents[id]?.color; + repository?.setDocumentColor(id, current === color ? undefined : color); + setMenu(null); }, - [], + [repository, documents], ); + // ---- Right-click menu ---- + const openMenu = useCallback((node: DocumentNode | null, e: React.MouseEvent) => { + e.preventDefault(); + setMenu({ x: e.clientX, y: e.clientY, node }); + }, []); + + // Close the menu on any left-click, scroll, resize, or Escape. + useEffect(() => { + if (!menu) return; + const close = () => setMenu(null); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setMenu(null); + }; + window.addEventListener("click", close); + window.addEventListener("resize", close); + window.addEventListener("scroll", close, true); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("click", close); + window.removeEventListener("resize", close); + window.removeEventListener("scroll", close, true); + window.removeEventListener("keydown", onKey); + }; + }, [menu]); + + // ---- Drag & drop ---- + const onDragStart = useCallback((id: string) => setDraggingId(id), []); + + const onDragOverNode = useCallback((id: string, pos: DropPosition) => { + setDropTarget((prev) => (prev?.id === id && prev.pos === pos ? prev : { id, pos })); + }, []); + const resetDrag = useCallback(() => { setDraggingId(null); setDropTarget(null); - setRootDrop(false); }, []); const onDropNode = useCallback( @@ -152,39 +194,89 @@ const DocumentTreeSidebarView = () => { repository.moveDocument(dragId, null, appendOrder(null, dragId)); }, [draggingId, repository, appendOrder, resetDrag]); + const renderMenu = () => { + if (!menu) return null; + const node = menu.node; + const left = Math.min(menu.x, window.innerWidth - 230); + const top = Math.min(menu.y, window.innerHeight - 220); + // Where create actions should put new nodes: inside a right-clicked + // folder, otherwise at the root. + const parentId = node?.type === "folder" ? node.id : null; + const showCreate = !node || node.type === "folder"; + const showEdit = !!node; + + return ( +
e.preventDefault()} + > + {showCreate && ( + <> + createInside(parentId, "editor")} + /> + createInside(parentId, "folder")} + /> + createInside(parentId, "board")} + /> + + )} + {showCreate && showEdit &&
} + {showEdit && node && ( + <> +
+ {DEFAULT_ITEM_COLORS.map((color) => ( +
+ setRenamingId(node.id)} + /> + setConfirmingDeleteId(node.id)} + /> + + )} +
+ ); + }; + return ( <>

{t("documents")}

-
-
- - - -
openMenu(null, e)} onDragOver={(e) => { if (!draggingId) return; + // Over empty list space (not a row): clear the row indicator; + // dropping here still moves the item to the root level. e.preventDefault(); setDropTarget(null); - setRootDrop(true); }} onDrop={(e) => { e.preventDefault(); @@ -200,11 +292,15 @@ const DocumentTreeSidebarView = () => { childrenOf={childrenOf} expanded={expanded} onToggle={toggle} - activeDocId={activeDocument?.docId ?? null} + openDocIds={openDocIds} onOpen={openDocument} - onCreateChild={createChild} - onRename={renameDocument} - onDelete={deleteDocument} + onContextMenu={openMenu} + renamingId={renamingId} + onRenameCommit={commitRename} + onRenameCancel={() => setRenamingId(null)} + confirmingDeleteId={confirmingDeleteId} + onConfirmDelete={deleteDocument} + onCancelDelete={() => setConfirmingDeleteId(null)} draggingId={draggingId} dropTarget={dropTarget} onDragStart={onDragStart} @@ -217,6 +313,7 @@ const DocumentTreeSidebarView = () => {
{t("documentsEmpty")}
)}
+ {renderMenu()} ); }; diff --git a/components/editor/sidebar/EditorSidebarNavigation.module.css b/components/editor/sidebar/EditorSidebarNavigation.module.css index bf02034..d10e97f 100644 --- a/components/editor/sidebar/EditorSidebarNavigation.module.css +++ b/components/editor/sidebar/EditorSidebarNavigation.module.css @@ -82,45 +82,6 @@ font-size: 1rem; } -/* Header action buttons (e.g. document-tree create buttons) */ -.header_spacer { - flex: 1; -} - -.header_actions { - display: flex; - flex-direction: row; - align-items: center; - gap: 2px; - padding-right: 16px; -} - -.header_btn { - display: flex; - align-items: center; - justify-content: center; - padding: 4px; - border-radius: 6px; - border: none; - background: none; - color: var(--secondary-text); - cursor: pointer; - transition: - color 0.15s, - background-color 0.15s; -} - -.header_btn:hover { - background-color: var(--editor-sidebar-hover); - color: var(--primary-text); -} - -/* Drop-to-root target highlight */ -.tree_root_drop { - box-shadow: inset 0 0 0 1px var(--primary-text); - border-radius: 8px; -} - .list_fill { min-height: 30px; height: 100%; diff --git a/components/editor/sidebar/SidebarItem.module.css b/components/editor/sidebar/SidebarItem.module.css index c4e9b35..55e1d4a 100644 --- a/components/editor/sidebar/SidebarItem.module.css +++ b/components/editor/sidebar/SidebarItem.module.css @@ -1,4 +1,5 @@ .container { + position: relative; display: flex; flex-direction: column; justify-content: space-between; @@ -17,6 +18,17 @@ border-bottom: 1px solid var(--separator); } +/* Slim accent bar on the left edge showing the scene's color. */ +.color_bar { + position: absolute; + left: 0; + top: 4px; + bottom: 4px; + width: 3px; + border-radius: 0 3px 3px 0; + pointer-events: none; +} + .data { display: flex; flex-direction: row; diff --git a/components/editor/sidebar/SidebarSceneItem.tsx b/components/editor/sidebar/SidebarSceneItem.tsx index 5074296..feba9a6 100644 --- a/components/editor/sidebar/SidebarSceneItem.tsx +++ b/components/editor/sidebar/SidebarSceneItem.tsx @@ -64,11 +64,9 @@ const SidebarSceneItem = memo(({ scene, index, showDropIndicator, isDragging, is onDoubleClick={handleDoubleClick} className={containerClass} > + {scene.color && }
- {scene.color && ( - - )}

{label}. {titleText}

diff --git a/components/navbar/ViewOptionsDropdown.tsx b/components/navbar/ViewOptionsDropdown.tsx index a0aa77b..4684915 100644 --- a/components/navbar/ViewOptionsDropdown.tsx +++ b/components/navbar/ViewOptionsDropdown.tsx @@ -15,6 +15,7 @@ import { PanelRightClose, ArrowLeftRight, Eye, + ListTree, } from "lucide-react"; import styles from "./ViewOptionsDropdown.module.css"; @@ -33,16 +34,17 @@ const ViewOptionsDropdown = () => { primaryPanel, setSecondaryPanel, swapPanels, + focusedPanel, + setFocusedPanel, } = useViewContext(); const handleSplitToggle = useCallback(() => { if (isSplit) { setSecondaryPanel(null); } else { - const other: PanelType = - primaryPanel === "screenplay" ? "board" - : primaryPanel === "title" ? "screenplay" - : "screenplay"; + // Default the new side to a singleton view (documents need a docId, + // which only opening from the sidebar/outline provides). + const other: PanelType = primaryPanel === "screenplay" ? "title" : "screenplay"; setSecondaryPanel(other); } }, [isSplit, primaryPanel, setSecondaryPanel]); @@ -112,6 +114,16 @@ const ViewOptionsDropdown = () => { {isOpen && (
+