From cb0856d99a666890a1fbb9aed7c0bcff46502aba Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Mon, 8 Jun 2026 02:23:41 +0200 Subject: [PATCH 1/2] page locking, fixed scene omitting, etc --- .../EditorSidebarNavigation.module.css | 15 +- .../navbar/ViewOptionsDropdown.module.css | 7 +- components/navbar/ViewOptionsDropdown.tsx | 10 + components/project/SplitPanelContainer.tsx | 13 + messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/ja.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + messages/zh.json | 1 + src/context/ViewContext.tsx | 11 +- src/lib/editor/use-document-editor.ts | 25 +- src/lib/project/project-repository.ts | 5 +- .../extensions/pagination-extension.ts | 441 +++++++++-------- .../extensions/scene-locking-extension.ts | 33 +- src/lib/screenplay/page-locking.ts | 16 + src/lib/screenplay/scene-locking.ts | 180 ++++++- src/lib/screenplay/scenes.ts | 15 + .../repro/omitted-scene-cut-restore.test.ts | 468 ++++++++++++++++++ .../repro/page-lock-edit-drops-break.test.ts | 157 ++++++ styles/scriptio.css | 9 +- 23 files changed, 1138 insertions(+), 275 deletions(-) create mode 100644 src/tests/repro/omitted-scene-cut-restore.test.ts create mode 100644 src/tests/repro/page-lock-edit-drops-break.test.ts diff --git a/components/editor/sidebar/EditorSidebarNavigation.module.css b/components/editor/sidebar/EditorSidebarNavigation.module.css index 6587655f..d10e97fa 100644 --- a/components/editor/sidebar/EditorSidebarNavigation.module.css +++ b/components/editor/sidebar/EditorSidebarNavigation.module.css @@ -91,25 +91,27 @@ .tab_bar { display: flex; flex-direction: row; + justify-content: center; border-top: 1px solid var(--separator); margin-top: auto; padding-top: 4px; padding-inline: 12px; - gap: 4px; + gap: 8px; } .tab_btn { - flex: 1; display: flex; align-items: center; justify-content: center; - padding: 8px 0; + padding: 8px 12px; border-radius: 8px; cursor: pointer; color: var(--secondary-text); background: none; border: none; - transition: color 0.15s, background-color 0.15s; + transition: + color 0.15s, + background-color 0.15s; } .tab_btn:hover { @@ -119,4 +121,9 @@ .tab_btn_active { color: var(--primary-text); + background-color: var(--editor-sidebar-hover); +} + +.tab_btn_active:hover { + color: var(--primary-text); } diff --git a/components/navbar/ViewOptionsDropdown.module.css b/components/navbar/ViewOptionsDropdown.module.css index 4f458720..963402b1 100644 --- a/components/navbar/ViewOptionsDropdown.module.css +++ b/components/navbar/ViewOptionsDropdown.module.css @@ -68,11 +68,16 @@ color 0.15s ease; } -.dropdown_item:hover { +.dropdown_item:hover:not(:disabled) { background-color: var(--secondary-hover); color: var(--primary-text); } +.dropdown_item:disabled { + opacity: 0.4; + cursor: default; +} + .dropdown_item_active { background-color: var(--secondary-hover); color: var(--primary-text); diff --git a/components/navbar/ViewOptionsDropdown.tsx b/components/navbar/ViewOptionsDropdown.tsx index 2eeac571..a0aa77b4 100644 --- a/components/navbar/ViewOptionsDropdown.tsx +++ b/components/navbar/ViewOptionsDropdown.tsx @@ -13,6 +13,7 @@ import { Minimize, PanelRight, PanelRightClose, + ArrowLeftRight, Eye, } from "lucide-react"; @@ -31,6 +32,7 @@ const ViewOptionsDropdown = () => { isSplit, primaryPanel, setSecondaryPanel, + swapPanels, } = useViewContext(); const handleSplitToggle = useCallback(() => { @@ -142,6 +144,14 @@ const ViewOptionsDropdown = () => { {isSplit ? : } {isSplit ? t("unsplitPanel") : t("splitPanel")} + )} diff --git a/components/project/SplitPanelContainer.tsx b/components/project/SplitPanelContainer.tsx index 33776f47..c2156b81 100644 --- a/components/project/SplitPanelContainer.tsx +++ b/components/project/SplitPanelContainer.tsx @@ -13,6 +13,7 @@ import DragHandle from "./DragHandle"; import { SuggestionData } from "@components/editor/SuggestionMenu"; import { Archive, + ArrowLeftRight, ChevronLeft, ChevronRight, Clapperboard, @@ -82,6 +83,7 @@ const PanelSwitcherMenu = ({ currentPanel, side }: { currentPanel: PanelType; si isSplit, primaryPanel, setSecondaryPanel, + swapPanels, isEndlessScroll, setIsEndlessScroll, showComments, @@ -177,6 +179,17 @@ const PanelSwitcherMenu = ({ currentPanel, side }: { currentPanel: PanelType; si {isSplit ? : } {isSplit ? t("unsplitPanel") : t("splitPanel")} +
{SWITCHABLE_PANELS.map(({ type, icon: Icon, labelKey }) => ( + +
+ + ) : ( + <> + {node.title} +
+ {isFolder && ( + <> + + + + )} + + +
+ + )} + + + {isFolder && + isOpen && + childrenOf(node.id).map((child) => ( + + ))} + + ); +}; + +export default DocumentTreeItem; diff --git a/components/editor/sidebar/DocumentTreeSidebarView.tsx b/components/editor/sidebar/DocumentTreeSidebarView.tsx new file mode 100644 index 00000000..9da6ed06 --- /dev/null +++ b/components/editor/sidebar/DocumentTreeSidebarView.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { useCallback, useContext, 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 { join } from "@src/lib/utils/misc"; +import { FilePlus, FolderPlus, FolderTree, LayoutDashboard } from "lucide-react"; +import DocumentTreeItem, { DropPosition } from "./DocumentTreeItem"; + +import form from "./../../utils/Form.module.css"; +import sidebar_nav from "./EditorSidebarNavigation.module.css"; + +const DocumentTreeSidebarView = () => { + const t = useTranslations("editorSidebar"); + const { documents, repository, activeDocument, setActiveDocument } = useContext(ProjectContext); + const { setSecondaryPanel } = useViewContext(); + + 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); + + // Children of a parent (null = root), sorted by fractional `order`. + const childrenOf = useCallback( + (parentId: string | null): DocumentNode[] => + Object.values(documents) + .filter((n) => (n.parentId ?? null) === (parentId ?? null)) + .sort((a, b) => a.order - b.order), + [documents], + ); + + 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], + ); + + const toggle = useCallback((id: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + 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"); + } + }, + [setActiveDocument, setSecondaryPanel], + ); + + const createChild = useCallback( + (parentId: string | null, type: "folder" | "editor") => { + if (!repository) return; + if (type === "folder") repository.createFolder(t("untitledFolder"), 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. + const removed = new Set(); + const stack = [id]; + while (stack.length) { + const cur = stack.pop()!; + 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); + repository.deleteDocument(id); + }, + [repository, documents, activeDocument, setActiveDocument], + ); + + // ---- Drag & drop ---- + const onDragStart = useCallback((id: string) => setDraggingId(id), []); + + const onDragOverNode = useCallback( + (id: string, pos: DropPosition) => { + setRootDrop(false); + setDropTarget((prev) => (prev?.id === id && prev.pos === pos ? prev : { id, pos })); + }, + [], + ); + + const resetDrag = useCallback(() => { + setDraggingId(null); + setDropTarget(null); + setRootDrop(false); + }, []); + + const onDropNode = useCallback( + (targetId: string) => { + const dragId = draggingId; + const target = documents[targetId]; + const pos = dropTarget?.pos; + resetDrag(); + if (!repository || !dragId || !target || !pos || dragId === targetId) return; + + if (pos === "into") { + repository.moveDocument(dragId, target.id, appendOrder(target.id, dragId)); + setExpanded((prev) => new Set(prev).add(target.id)); + 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.moveDocument(dragId, parentId, order); + }, + [draggingId, documents, dropTarget, repository, appendOrder, childrenOf, resetDrag], + ); + + const onDropRoot = useCallback(() => { + const dragId = draggingId; + resetDrag(); + if (!repository || !dragId) return; + repository.moveDocument(dragId, null, appendOrder(null, dragId)); + }, [draggingId, repository, appendOrder, resetDrag]); + + return ( + <> +
+ +

{t("documents")}

+
+
+ + + +
+
+
{ + if (!draggingId) return; + e.preventDefault(); + setDropTarget(null); + setRootDrop(true); + }} + onDrop={(e) => { + e.preventDefault(); + onDropRoot(); + }} + > + {roots.length !== 0 ? ( + roots.map((node) => ( + + )) + ) : ( +
{t("documentsEmpty")}
+ )} +
+ + ); +}; + +export default DocumentTreeSidebarView; diff --git a/components/editor/sidebar/EditorSidebarNavigation.module.css b/components/editor/sidebar/EditorSidebarNavigation.module.css index d10e97fa..bf020344 100644 --- a/components/editor/sidebar/EditorSidebarNavigation.module.css +++ b/components/editor/sidebar/EditorSidebarNavigation.module.css @@ -82,6 +82,45 @@ 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/EditorSidebarNavigation.tsx b/components/editor/sidebar/EditorSidebarNavigation.tsx index dae8d2db..a4f5d422 100644 --- a/components/editor/sidebar/EditorSidebarNavigation.tsx +++ b/components/editor/sidebar/EditorSidebarNavigation.tsx @@ -8,10 +8,11 @@ import { useViewContext } from "@src/context/ViewContext"; import { Scene } from "@src/lib/screenplay/scenes"; import { focusOnPosition } from "@src/lib/screenplay/editor"; import { computeSceneLabels } from "@src/lib/screenplay/scene-locking"; -import { Archive, Clapperboard, MessageSquare } from "lucide-react"; +import { Archive, Clapperboard, FolderTree, MessageSquare } from "lucide-react"; import SidebarSceneItem from "./SidebarSceneItem"; import ShelfSidebarView from "./ShelfSidebarView"; import CommentSidebarView from "./CommentSidebarView"; +import DocumentTreeSidebarView from "./DocumentTreeSidebarView"; import form from "./../../utils/Form.module.css"; import sidebar_nav from "./EditorSidebarNavigation.module.css"; @@ -29,7 +30,7 @@ const EditorSidebarNavigation = () => { } = useContext(ProjectContext); const { leftSidebarOpen } = useViewContext(); - const [activeTab, setActiveTab] = useState<"scenes" | "shelf" | "comments">("scenes"); + const [activeTab, setActiveTab] = useState<"scenes" | "shelf" | "comments" | "documents">("scenes"); const [dragIndex, setDragIndex] = useState(null); // indicatorIndex represents the gap where the item will be inserted. @@ -256,6 +257,8 @@ const EditorSidebarNavigation = () => { ) : activeTab === "shelf" ? ( + ) : activeTab === "documents" ? ( + ) : ( )} @@ -266,6 +269,12 @@ const EditorSidebarNavigation = () => { > +