From b0b6ab40a43292b804e9c89c9cc81cc6eaed2333 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Wed, 13 May 2026 01:00:22 +0200 Subject: [PATCH 1/6] fixed shelving logic for nodes with comments --- src/lib/shelf/shelf-utils.ts | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/lib/shelf/shelf-utils.ts b/src/lib/shelf/shelf-utils.ts index a92ff999..ad3c2562 100644 --- a/src/lib/shelf/shelf-utils.ts +++ b/src/lib/shelf/shelf-utils.ts @@ -11,6 +11,24 @@ export interface ShelveCandidate { content: JSONContent[]; } +/** + * Removes 'comment' marks from a node's JSON to prevent it from being + * dropped by the shelf editor which lacks the comment extension. + */ +function stripComments(nodeJson: JSONContent): JSONContent { + const result = { ...nodeJson }; + if (result.marks) { + result.marks = result.marks.filter((m) => m.type !== "comment"); + if (result.marks.length === 0) { + delete result.marks; + } + } + if (result.content) { + result.content = result.content.map(stripComments); + } + return result; +} + /** * Given a position in the document, determine the shelvable content. * Returns null if the node at the position is not shelvable. @@ -32,7 +50,7 @@ export function extractShelveCandidate(editor: Editor, pos: number): ShelveCandi case ScreenplayElement.Character: return extractDialogueBlockContent(doc, docChildIndex, nodeId, node.textContent); case ScreenplayElement.Action: - return { nodeId, title: node.textContent, type: "action", content: [node.toJSON()] }; + return { nodeId, title: node.textContent, type: "action", content: [stripComments(node.toJSON())] }; default: return null; } @@ -48,12 +66,12 @@ function extractSceneContent( const content: JSONContent[] = []; const count = doc.childCount; - content.push(doc.child(startIndex).toJSON()); + content.push(stripComments(doc.child(startIndex).toJSON())); for (let i = startIndex + 1; i < count; i++) { const child = doc.child(i); if (child.attrs.class === ScreenplayElement.Scene) break; - content.push(child.toJSON()); + content.push(stripComments(child.toJSON())); } return { nodeId, title, type: "scene", content }; @@ -69,7 +87,7 @@ function extractDialogueBlockContent( const content: JSONContent[] = []; const count = doc.childCount; - content.push(doc.child(startIndex).toJSON()); + content.push(stripComments(doc.child(startIndex).toJSON())); for (let i = startIndex + 1; i < count; i++) { const cls = doc.child(i).attrs.class; @@ -77,7 +95,7 @@ function extractDialogueBlockContent( cls === ScreenplayElement.Dialogue || cls === ScreenplayElement.Parenthetical ) { - content.push(doc.child(i).toJSON()); + content.push(stripComments(doc.child(i).toJSON())); } else { break; } From 2ca7d650b9b38a384b6178793f36112ba0112e1c Mon Sep 17 00:00:00 2001 From: Hugo Date: Tue, 19 May 2026 17:19:03 +0200 Subject: [PATCH 2/6] added production logic for scene numbering locking --- components/dashboard/DashboardModal.tsx | 5 +- components/dashboard/DashboardSidebar.tsx | 6 +- .../dashboard/account/DashboardAuth.tsx | 1 - .../dashboard/account/ProfileSettings.tsx | 5 +- .../dashboard/project/DangerZone.module.css | 40 -- components/dashboard/project/DangerZone.tsx | 5 +- .../project/ProductionSettings.module.css | 56 +++ .../dashboard/project/ProductionSettings.tsx | 49 ++ components/editor/DocumentEditorPanel.tsx | 8 + components/editor/SuggestionMenu.module.css | 39 -- components/editor/SuggestionMenu.tsx | 25 +- .../editor/sidebar/ContextMenu.module.css | 2 + components/editor/sidebar/ContextMenu.tsx | 81 +++- .../sidebar/EditorSidebarNavigation.tsx | 23 +- .../editor/sidebar/SidebarItem.module.css | 6 + .../editor/sidebar/SidebarSceneItem.tsx | 12 +- components/navbar/ProductionPanel.module.css | 174 +++++++ components/navbar/ProductionPanel.tsx | 235 +++++++++ components/navbar/ProjectNavbar.tsx | 23 + components/popup/Popup.module.css | 34 +- components/popup/Popup.tsx | 4 + components/popup/PopupCharacterItem.tsx | 2 +- components/popup/PopupImportFile.tsx | 6 +- components/popup/PopupSceneItem.tsx | 2 +- components/popup/PopupUnlockScenes.tsx | 52 ++ components/popup/PopupUploadToCloud.tsx | 6 +- components/utils/Dropdown.module.css | 3 +- components/utils/ModalBtn.module.css | 42 ++ components/utils/Switch.module.css | 37 ++ components/utils/Switch.tsx | 34 ++ messages/de.json | 25 +- messages/en.json | 25 +- messages/es.json | 25 +- messages/fr.json | 25 +- messages/ja.json | 25 +- messages/ko.json | 25 +- messages/pl.json | 25 +- messages/zh.json | 25 +- src/context/ProjectContext.tsx | 61 +++ src/lib/adapters/pdf/pdf-adapter.ts | 93 +++- src/lib/editor/document-editor-config.ts | 4 + src/lib/editor/use-document-editor.ts | 29 ++ src/lib/project/project-doc.ts | 10 + src/lib/project/project-repository.ts | 66 ++- src/lib/screenplay/editor.ts | 12 +- .../extensions/fountain-extension.ts | 4 + .../extensions/node-id-dedup-extension.ts | 3 + .../extensions/scene-locking-extension.ts | 244 ++++++++++ src/lib/screenplay/nodes/scene-node.ts | 81 ++++ src/lib/screenplay/popup.ts | 15 +- src/lib/screenplay/scene-locking.ts | 449 ++++++++++++++++++ src/lib/screenplay/scenes.ts | 25 +- src/lib/shelf/shelf-editor-config.ts | 1 + styles/scriptio.css | 59 +++ 54 files changed, 2209 insertions(+), 164 deletions(-) create mode 100644 components/dashboard/project/ProductionSettings.module.css create mode 100644 components/dashboard/project/ProductionSettings.tsx delete mode 100644 components/editor/SuggestionMenu.module.css create mode 100644 components/navbar/ProductionPanel.module.css create mode 100644 components/navbar/ProductionPanel.tsx create mode 100644 components/popup/PopupUnlockScenes.tsx create mode 100644 components/utils/ModalBtn.module.css create mode 100644 components/utils/Switch.module.css create mode 100644 components/utils/Switch.tsx create mode 100644 src/lib/screenplay/extensions/scene-locking-extension.ts create mode 100644 src/lib/screenplay/scene-locking.ts diff --git a/components/dashboard/DashboardModal.tsx b/components/dashboard/DashboardModal.tsx index a2d22443..7fd6c06f 100644 --- a/components/dashboard/DashboardModal.tsx +++ b/components/dashboard/DashboardModal.tsx @@ -11,7 +11,7 @@ import CollaboratorsSettings from "./project/CollaboratorsSettings"; import styles from "./DashboardModal.module.css"; import ExportProject from "./project/ExportProject"; -import { CreditCard, FileDown, Folder, Globe, Keyboard, Palette, PanelsTopLeft, User, Users, X } from "lucide-react"; +import { CreditCard, FileDown, Folder, Globe, Keyboard, Lock, Palette, PanelsTopLeft, User, Users, X } from "lucide-react"; import { useTranslations } from "next-intl"; import KeybindsSettings from "./preferences/KeybindsSettings"; import AppearanceSettings from "./preferences/AppearanceSettings"; @@ -19,6 +19,7 @@ import LanguageSettings from "./preferences/LanguageSettings"; import ProfileSettings from "./account/ProfileSettings"; import SubscriptionSettings from "./account/SubscriptionSettings"; import LayoutSettings from "./project/LayoutSettings"; +import ProductionSettings from "./project/ProductionSettings"; import DashboardAuth from "./account/DashboardAuth"; import AboutSettings from "./AboutSettings"; @@ -33,6 +34,7 @@ const DashboardModal = () => { items: [ { id: "General", label: t("tabs.General"), icon: }, { id: "Layout", label: t("tabs.Layout"), icon: }, + { id: "Production", label: t("tabs.Production"), icon: }, { id: "Export", label: t("tabs.Export"), icon: }, { id: "Collaborators", label: t("tabs.Collaborators"), icon: }, ], @@ -134,6 +136,7 @@ const DashboardModal = () => { {/* Project tabs - only rendered when in project context */} {isInProject && activeTab === "General" && setDangerOpen((v) => !v)} />} {isInProject && activeTab === "Layout" && } + {isInProject && activeTab === "Production" && } {isInProject && activeTab === "Export" && } {isInProject && activeTab === "Collaborators" && } {/* Preferences tabs */} diff --git a/components/dashboard/DashboardSidebar.tsx b/components/dashboard/DashboardSidebar.tsx index 54db8fa9..73f2b50e 100644 --- a/components/dashboard/DashboardSidebar.tsx +++ b/components/dashboard/DashboardSidebar.tsx @@ -8,6 +8,7 @@ import { useTranslations } from "next-intl"; import styles from "./DashboardModal.module.css"; import dangerStyles from "./project/DangerZone.module.css"; +import modal from "../utils/ModalBtn.module.css"; import { signOut } from "next-auth/react"; import { isTauri } from "@tauri-apps/api/core"; import { useCookieUser } from "@src/lib/utils/hooks"; @@ -15,6 +16,7 @@ import { useCookieUser } from "@src/lib/utils/hooks"; export type Category = | "General" | "Layout" + | "Production" | "Export" | "Collaborators" | "Profile" @@ -72,13 +74,13 @@ const SidebarMenu = ({ structure, activeTab, onTabChange }: SidebarMenuProps) =>

{t("logOutConfirmDesc")}

+ +
+ +

{t("appliesToNewOnly")}

+ + + ); +}; + +export default ProductionSettings; diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index cf5dada4..24603169 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -74,6 +74,7 @@ const DocumentEditorPanel = ({ moreLabel, elementMargins, elementStyles, + sceneLocking, setFocusedEditorType, setSelectedTitlePageElement, repository, @@ -172,6 +173,12 @@ const DocumentEditorPanel = ({ editorElement.classList.remove("scene-number-right"); } + if (sceneLocking) { + editorElement.classList.add("production-locked"); + } else { + editorElement.classList.remove("production-locked"); + } + editorElement.style.setProperty("--contd-label", `"${contdLabel}"`); editorElement.style.setProperty("--more-label", `"${moreLabel}"`); @@ -246,6 +253,7 @@ const DocumentEditorPanel = ({ moreLabel, elementMargins, elementStyles, + sceneLocking, ]); // ---- Pagination update (title page only) ---- diff --git a/components/editor/SuggestionMenu.module.css b/components/editor/SuggestionMenu.module.css deleted file mode 100644 index 2f55d3a1..00000000 --- a/components/editor/SuggestionMenu.module.css +++ /dev/null @@ -1,39 +0,0 @@ -.menu { - position: fixed; - display: flex; - flex-direction: column; - padding-block: 6px; - z-index: 100; - border-radius: 12px; - background-color: var(--context-menu-bg); - box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2); - min-width: 120px; - max-width: 220px; - max-height: 180px; - overflow-y: auto; - overflow-x: hidden; -} - -.menu_item { - display: flex; - align-items: center; - padding-block: 6px; - padding-inline: 12px; - font-size: 14px; - cursor: pointer; -} - -.menu_item:hover { - background-color: var(--context-menu-item-hover); -} - -.selected { - background-color: var(--context-menu-item-hover); -} - -.item { - overflow-x: hidden; - white-space: nowrap; - text-overflow: ellipsis; - margin: 0; -} diff --git a/components/editor/SuggestionMenu.tsx b/components/editor/SuggestionMenu.tsx index 0892b9f5..00d48e09 100644 --- a/components/editor/SuggestionMenu.tsx +++ b/components/editor/SuggestionMenu.tsx @@ -2,7 +2,7 @@ import { useContext, useEffect, useState, useCallback, useRef } from "react"; -import styles from "./SuggestionMenu.module.css"; +import styles from "../utils/Dropdown.module.css"; import { pasteTextAt, insertElement } from "@src/lib/screenplay/editor"; import { ScreenplayElement } from "@src/lib/utils/enums"; import { ProjectContext } from "@src/context/ProjectContext"; @@ -124,10 +124,19 @@ const SuggestionMenu = ({ suggestionData, suggestions, onSelect }: Props) => { return (
{suggestions.map((suggestion: string, index: number) => ( @@ -135,11 +144,19 @@ const SuggestionMenu = ({ suggestionData, suggestions, onSelect }: Props) => { ref={(el) => { itemRefs.current[index] = el; }} - className={`${styles.menu_item} ${index === selectedIdx ? styles.selected : ""}`} + className={styles.dropdown_item} onClick={() => selectSuggestion(index)} key={index} + style={{ + padding: "6px 12px", + backgroundColor: index === selectedIdx ? "var(--context-menu-item-hover)" : "transparent" + }} > -

{suggestion}

+
+

+ {suggestion} +

+
))} diff --git a/components/editor/sidebar/ContextMenu.module.css b/components/editor/sidebar/ContextMenu.module.css index fa554e32..02a9ffc8 100644 --- a/components/editor/sidebar/ContextMenu.module.css +++ b/components/editor/sidebar/ContextMenu.module.css @@ -18,8 +18,10 @@ align-items: center; gap: 10px; + border-radius: 6px; padding-block: 6px; padding-inline: 12px; + margin-inline: 6px; font-size: 14px; cursor: pointer; diff --git a/components/editor/sidebar/ContextMenu.tsx b/components/editor/sidebar/ContextMenu.tsx index 36a21ac2..0aa60f00 100644 --- a/components/editor/sidebar/ContextMenu.tsx +++ b/components/editor/sidebar/ContextMenu.tsx @@ -21,6 +21,8 @@ import { ClipboardPaste, Columns2, Copy, + EyeOff, + Eye, Highlighter, Loader2, LucideIcon, @@ -32,6 +34,7 @@ import { } from "lucide-react"; 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"; /* ==================== */ @@ -107,9 +110,22 @@ export type SceneContextProps = { const SceneItemMenu = ({ props }: SubMenuProps) => { const t = useTranslations("contextMenu"); const userCtx = useContext(UserContext); - const { editor, isReadOnly } = useContext(ProjectContext); + const { editor, isReadOnly, repository } = useContext(ProjectContext); const scene: Scene = props.scene; + const canOmit = !!scene.id && !scene.omitted; + const canUnomit = !!scene.id && !!scene.omitted; + + const handleOmit = () => { + if (!repository || !scene.id) return; + omitSceneByUuid(repository, scene.id); + }; + + const handleUnomit = () => { + if (!repository || !scene.id) return; + unomitSceneByUuid(repository, scene.id); + }; + return ( <> ) => { text={t("cut")} action={() => cutText(editor!, scene.position, scene.nextPosition)} /> + {canOmit && ( + + )} + {canUnomit && ( + + )} )} @@ -456,12 +478,47 @@ export type EditorContextMenuProps = { const EditorContextMenu = ({ props }: SubMenuProps) => { const t = useTranslations("contextMenu"); - const { editor, repository, isReadOnly } = useContext(ProjectContext); + const { editor, repository, isReadOnly, persistentScenes } = + useContext(ProjectContext); const { worker } = useSpellcheck(); const { updateContextMenu } = useContext(UserContext); const { from, to, onAddComment, spellError, nodePos, nodeClass } = props; const hasSelection = from !== to; + // 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

+ // node itself — rather than calling `doc.nodeAt(nodePos)` which would + // return the inner text node. + const sceneInfo = (() => { + if (nodeClass !== ScreenplayElement.Scene || nodePos === undefined || !editor) { + return null; + } + let sceneNode; + try { + sceneNode = editor.state.doc.resolve(nodePos).node(1); + } catch { + return null; + } + if (!sceneNode || sceneNode.attrs?.class !== ScreenplayElement.Scene) return null; + const uuid: string | undefined = sceneNode.attrs?.["data-id"]; + if (!uuid) return null; + const entry = persistentScenes[uuid]; + return { uuid, isOmitted: !!entry?.omitted }; + })(); + + const handleOmitScene = () => { + if (!repository || !sceneInfo) return; + omitSceneByUuid(repository, sceneInfo.uuid); + updateContextMenu(undefined); + }; + + const handleUnomitScene = () => { + if (!repository || !sceneInfo) return; + unomitSceneByUuid(repository, sceneInfo.uuid); + updateContextMenu(undefined); + }; + const [suggestions, setSuggestions] = useState(null); const displaySuggestions = spellError && !worker ? [] : suggestions; @@ -624,6 +681,26 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { )} )} + + {/* Production: Omit / Unomit on a locked scene heading */} + {sceneInfo && !isReadOnly && ( + <> +

+ {sceneInfo.isOmitted ? ( + + ) : ( + + )} + + )} ); }; diff --git a/components/editor/sidebar/EditorSidebarNavigation.tsx b/components/editor/sidebar/EditorSidebarNavigation.tsx index dcca095e..4461fb37 100644 --- a/components/editor/sidebar/EditorSidebarNavigation.tsx +++ b/components/editor/sidebar/EditorSidebarNavigation.tsx @@ -1,12 +1,13 @@ "use client"; import { join } from "@src/lib/utils/misc"; -import { useContext, useState, useCallback, useRef, useEffect } from "react"; +import { useContext, useState, useCallback, useRef, useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; import { ProjectContext } from "@src/context/ProjectContext"; 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 SidebarSceneItem from "./SidebarSceneItem"; import ShelfSidebarView from "./ShelfSidebarView"; @@ -17,7 +18,7 @@ import sidebar_nav from "./EditorSidebarNavigation.module.css"; const EditorSidebarNavigation = () => { const t = useTranslations("editorSidebar"); - const { scenes, updateScenes, editor } = useContext(ProjectContext); + const { scenes, updateScenes, editor, sceneLocking, sceneNumberingStyle, persistentScenes } = useContext(ProjectContext); const { leftSidebarOpen } = useViewContext(); const [activeTab, setActiveTab] = useState<"scenes" | "shelf" | "comments">("scenes"); @@ -31,6 +32,21 @@ const EditorSidebarNavigation = () => { // Track which scene the cursor is currently in const [currentSceneIndex, setCurrentSceneIndex] = useState(null); + // Compute display labels and omitted flags for every scene. When locking is + // off we fall back to positional numbers so the user always has a number to + // navigate by. + const sceneDisplays = useMemo(() => { + if (sceneLocking) { + const uuids = scenes.map((s) => s.id ?? ""); + const labels = computeSceneLabels(uuids, persistentScenes, sceneNumberingStyle); + return scenes.map((_, i) => ({ + label: labels[i]?.label ?? `${i + 1}`, + isOmitted: labels[i]?.status === "omitted", + })); + } + return scenes.map((_, i) => ({ label: `${i + 1}`, isOmitted: false })); + }, [scenes, sceneLocking, sceneNumberingStyle, persistentScenes]); + const listRef = useRef(null); const currentSceneRef = useRef(null); const scenesRef = useRef(scenes); @@ -202,12 +218,15 @@ const EditorSidebarNavigation = () => { indicatorIndex === dragIndex + 1; const showIndicator = !isNoOp && indicatorIndex === index; const isCurrent = index === currentSceneIndex; + const display = sceneDisplays[index]; return ( ; onPointerDown: (index: number, e: React.PointerEvent) => void; onDoubleClick: (scene: Scene) => void; }; -const SidebarSceneItem = memo(({ scene, index, showDropIndicator, isDragging, isCurrent, scrollRef, onPointerDown, onDoubleClick }: SidebarSceneItemProps) => { +const SidebarSceneItem = memo(({ scene, index, showDropIndicator, isDragging, isCurrent, label, isOmitted, scrollRef, onPointerDown, onDoubleClick }: SidebarSceneItemProps) => { const { updateContextMenu } = useContext(UserContext); const handleDropdown = (e: React.MouseEvent) => { @@ -43,6 +47,7 @@ const SidebarSceneItem = memo(({ scene, index, showDropIndicator, isDragging, is // Show synopsis if available, otherwise show preview const displayText = scene.synopsis || scene.preview; + const titleText = isOmitted ? "OMITTED" : scene.title; const containerClass = join( nav_item.container, @@ -64,8 +69,9 @@ const SidebarSceneItem = memo(({ scene, index, showDropIndicator, isDragging, is {scene.color && ( )} -

{scene.title}

- {/*scene.id && */} +

+ {label}. {titleText} +

diff --git a/components/navbar/ProductionPanel.module.css b/components/navbar/ProductionPanel.module.css new file mode 100644 index 00000000..1f45f8e5 --- /dev/null +++ b/components/navbar/ProductionPanel.module.css @@ -0,0 +1,174 @@ +.container { + composes: panel from "./navbar-shared.module.css"; + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 100; + width: 340px; + max-height: 480px; + display: flex; + flex-direction: column; + border-radius: 16px; + overflow: hidden; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--separator); + flex-shrink: 0; +} + +.title { + font-size: 0.85rem; + font-weight: 600; + color: var(--primary-text); +} + +.close_btn { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + border: none; + background: none; + color: var(--secondary-text); + cursor: pointer; + border-radius: 4px; +} + +.close_btn:hover { + background-color: var(--tertiary); + color: var(--primary-text); +} + +/* Section */ +.section { + padding: 12px 16px; + border-bottom: 1px solid var(--separator); +} + +.section:last-child { + border-bottom: none; +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 22px; +} + +.row_main { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.row_actions { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.row_icon { + color: var(--secondary-text); + flex-shrink: 0; +} + +.row_label { + font-size: 0.85rem; + color: var(--primary-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Provisional labels preview — boxed sub-section under the locking toggle. */ +.provisional_box { + margin-top: 10px; + padding: 10px 12px; + background-color: var(--primary); + border: 1px solid var(--separator); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.provisional_title { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--secondary-text); + opacity: 0.8; +} + +.provisional_list { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 120px; + overflow-y: auto; +} + +.provisional_label { + display: inline-flex; + align-items: center; + padding: 2px 8px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--primary-text); + background: color-mix(in srgb, var(--primary-text) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--primary-text) 30%, transparent); + border-radius: 20px; +} + +.relock_btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 3px 10px; + border: none; + background-color: var(--tertiary); + color: var(--primary-text); + border-radius: 12px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.02em; + cursor: pointer; + transition: opacity 0.15s ease; +} + +.relock_btn:hover:not(:disabled) { + opacity: 0.75; +} + +.relock_btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Revision swatches */ +.swatches { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 10px; +} + +.swatch { + width: 18px; + height: 18px; + border-radius: 50%; + border: 1px solid var(--separator); + opacity: 0.5; + cursor: not-allowed; +} diff --git a/components/navbar/ProductionPanel.tsx b/components/navbar/ProductionPanel.tsx new file mode 100644 index 00000000..580487b0 --- /dev/null +++ b/components/navbar/ProductionPanel.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useCallback, useContext, useEffect, useMemo, useRef } from "react"; +import { useTranslations } from "next-intl"; +import { Lock, X } from "lucide-react"; + +import { ProjectContext } from "@src/context/ProjectContext"; +import { UserContext } from "@src/context/UserContext"; +import { computeSceneLabels } from "@src/lib/screenplay/scene-locking"; +import { computeSceneItems } from "@src/lib/screenplay/scenes"; +import { unlockScenesPopup } from "@src/lib/screenplay/popup"; +import Switch from "@components/utils/Switch"; + +import styles from "./ProductionPanel.module.css"; + +interface ProductionPanelProps { + isOpen: boolean; + onClose: () => void; +} + +const REVISION_COLORS = [ + "#ffffff", // white + "#bbdfff", // blue + "#ffb6c1", // pink + "#ffea7a", // yellow + "#a5d6a7", // green + "#d4a017", // goldenrod + "#e0c58b", // buff + "#fa8072", // salmon + "#9b1c2a", // cherry +]; + +const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { + const t = useTranslations("production"); + const { sceneLocking, sceneNumberingStyle, persistentScenes, scenes, repository, isReadOnly } = + useContext(ProjectContext); + const userCtx = useContext(UserContext); + + const panelRef = useRef(null); + + // Click outside to close + useEffect(() => { + if (!isOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen, onClose]); + + const sceneUuids = useMemo( + () => scenes.map((s) => s.id).filter((id): id is string => !!id), + [scenes], + ); + + const labels = useMemo( + () => + sceneLocking + ? computeSceneLabels(sceneUuids, persistentScenes, sceneNumberingStyle) + : [], + [sceneLocking, sceneUuids, persistentScenes, sceneNumberingStyle], + ); + + const provisionalLabels = useMemo( + () => labels.filter((l) => l.status === "provisional"), + [labels], + ); + + // Stable across renders: the popup keeps a reference to this callback. + const performUnlock = useCallback(() => { + if (!repository) return; + repository.transact(() => { + repository.clearSceneLocks(); + repository.setSceneLocking(false); + }); + }, [repository]); + + const handleSceneLockingToggle = (next: boolean) => { + if (!repository || isReadOnly) return; + if (next) { + repository.transact(() => { + const currentScreenplay = repository.screenplay; + const scenes = computeSceneItems(currentScreenplay); + const uuids = scenes.map(s => s.id).filter((id): id is string => !!id); + + // Idempotent: any scene that already has a token (e.g. left + // over from an earlier session, or that survived an unlock + // in read-only mode) keeps its frozen label. Only scenes + // computed as provisional by `computeSceneLabels` get a new + // token written. On a fresh lock-on with no existing + // tokens, this falls through to baseToken(idx+1) for every + // scene, matching the previous behaviour. + const persistentSnapshot = repository.scenes; + const labels = computeSceneLabels(uuids, persistentSnapshot, sceneNumberingStyle); + + labels.forEach((label) => { + if (label.status === "provisional") { + repository.upsertScene(label.uuid, { token: label.token }); + } + }); + + repository.setSceneLocking(true); + }); + } else { + unlockScenesPopup(performUnlock, userCtx); + } + }; + + const handleRelock = () => { + if (!repository || isReadOnly) return; + repository.transact(() => { + const currentScreenplay = repository.screenplay; + const scenes = computeSceneItems(currentScreenplay); + const uuids = scenes.map(s => s.id).filter((id): id is string => !!id); + + // Re-read fresh persistent data + const persistentSnapshot = repository.scenes; + + const currentLabels = computeSceneLabels( + uuids, + persistentSnapshot, + sceneNumberingStyle, + ); + + console.log("[ProductionPanel] RELOCKING PROVISIONAL. Full snapshot:", currentLabels.map(l => ({ + uuid: l.uuid, + label: l.label, + status: l.status, + token: l.token + }))); + + let relockedCount = 0; + currentLabels.forEach((label) => { + if (label.status === "provisional") { + console.log(`[ProductionPanel] -> Freezing ${label.uuid} as "${label.label}"`); + repository.upsertScene(label.uuid, { token: label.token }); + relockedCount++; + } + }); + + console.log(`[ProductionPanel] Relock complete. Persisted ${relockedCount} tokens.`); + }); + }; + + if (!isOpen) return null; + + return ( +
+
+ {t("title")} + +
+ + {/* Scene Locking */} +
+
+
+ + {t("sceneLocking")} +
+
+ {sceneLocking && provisionalLabels.length > 0 && ( + + )} + +
+
+ + {sceneLocking && provisionalLabels.length > 0 && ( +
+
{t("provisionalTitle")}
+
+ {provisionalLabels.map((l, idx) => ( + + {l.label} + + ))} +
+
+ )} +
+ + {/* Page Locking (inert in v1) */} +
+
+
+ {t("pageLocking")} +
+ {}} ariaLabel={t("pageLocking")} /> +
+
+ + {/* Revisions (inert in v1) */} +
+
+
+ {t("revisions")} +
+ {}} ariaLabel={t("revisions")} /> +
+
+ {REVISION_COLORS.map((color, idx) => ( + + ))} +
+
+
+ ); +}; + +export default ProductionPanel; diff --git a/components/navbar/ProjectNavbar.tsx b/components/navbar/ProjectNavbar.tsx index 839b9011..3d9e8edb 100644 --- a/components/navbar/ProjectNavbar.tsx +++ b/components/navbar/ProjectNavbar.tsx @@ -20,6 +20,7 @@ import { CircleCheckBig, CloudUpload, History, + Lock, Monitor, Settings, WifiOff, @@ -27,6 +28,7 @@ import { } from "lucide-react"; import AnalyticsModal from "@components/analytics/AnalyticsModal"; import SavesPanel from "./SavesPanel"; +import ProductionPanel from "./ProductionPanel"; import navbar from "./ProjectNavbar.module.css"; import navBtn from "@components/utils/NavbarIconButton.module.css"; @@ -97,6 +99,7 @@ const ProjectNavbar = () => { const [projectTitle, setProjectTitle] = useState(""); const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false); const [isSavesOpen, setIsSavesOpen] = useState(false); + const [isProductionOpen, setIsProductionOpen] = useState(false); const [isLocalOnly, setIsLocalOnly] = useState(null); const isLocalEdit = useRef(false); @@ -250,6 +253,26 @@ const ProjectNavbar = () => { isPro={isPro} /> +
+
setIsProductionOpen(!isProductionOpen)} + > + +
+ setIsProductionOpen(false)} + /> +
)} diff --git a/components/popup/Popup.module.css b/components/popup/Popup.module.css index 031a81cf..e8759d59 100644 --- a/components/popup/Popup.module.css +++ b/components/popup/Popup.module.css @@ -108,33 +108,25 @@ font-size: 16px !important; } -/* Popup confirm button */ -.import_confirm { - border-width: 2px !important; - border-style: dashed !important; - border-color: var(--error) !important; -} +/* Popup buttons — reuse the rounded "modal button" styling from DangerZone + so every modal/popup shares one consistent look. The popup container + stretches them to fill its width via `width: 100%` (DangerZone uses a + flex-column wrapper instead, so its variant doesn't need that). Use + these classes STANDALONE — do not pair with form.btn, since its + !important border/radius rules would override the composed styling. */ .confirm { - background-color: var(--secondary); - border-radius: 8px !important; - transition: background-color 0.15s ease !important; -} - -.confirm:hover { - background-color: var(--secondary-hover) !important; + composes: modalBtn from "../utils/ModalBtn.module.css"; + width: 100% !important; } -.confirm:disabled { - filter: brightness(60%); - cursor: not-allowed; +.import_confirm { + composes: modalBtn modalBtnDanger from "../utils/ModalBtn.module.css"; + width: 100% !important; } .cancel { + composes: modalBtn modalBtnCancel from "../utils/ModalBtn.module.css"; + width: 100% !important; margin-top: 10px; - background-color: var(--tertiary) !important; -} - -.cancel:hover { - background-color: var(--tertiary-hover) !important; } diff --git a/components/popup/Popup.tsx b/components/popup/Popup.tsx index 570411d9..f0950752 100644 --- a/components/popup/Popup.tsx +++ b/components/popup/Popup.tsx @@ -7,12 +7,14 @@ import { PopupImportFileData, PopupSceneData, PopupType, + PopupUnlockScenesData, PopupUploadToCloudData, } from "@src/lib/screenplay/popup"; import { useContext } from "react"; import PopupCharacterItem from "./PopupCharacterItem"; import PopupImportFile from "./PopupImportFile"; import PopupSceneItem from "./PopupSceneItem"; +import PopupUnlockScenes from "./PopupUnlockScenes"; import PopupUploadToCloud from "./PopupUploadToCloud"; export const Popup = () => { @@ -30,6 +32,8 @@ export const Popup = () => { return )} />; case PopupType.UploadToCloud: return )} />; + case PopupType.UnlockScenes: + return )} />; default: return null; } diff --git a/components/popup/PopupCharacterItem.tsx b/components/popup/PopupCharacterItem.tsx index 221804a5..8fe8a818 100644 --- a/components/popup/PopupCharacterItem.tsx +++ b/components/popup/PopupCharacterItem.tsx @@ -274,7 +274,7 @@ export const PopupCharacterItem = ({ type, data: { character } }: PopupData - diff --git a/components/popup/PopupSceneItem.tsx b/components/popup/PopupSceneItem.tsx index 7814df40..72ac3fee 100644 --- a/components/popup/PopupSceneItem.tsx +++ b/components/popup/PopupSceneItem.tsx @@ -74,7 +74,7 @@ export const PopupSceneItem = ({ data: { scene } }: PopupData) = placeholder={t("synopsisPlaceholder")} /> - diff --git a/components/popup/PopupUnlockScenes.tsx b/components/popup/PopupUnlockScenes.tsx new file mode 100644 index 00000000..930b6dcf --- /dev/null +++ b/components/popup/PopupUnlockScenes.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useContext } from "react"; +import { useTranslations } from "next-intl"; +import { X, Unlock } from "lucide-react"; + +import popup from "./Popup.module.css"; + +import { useDraggable } from "@src/lib/utils/hooks"; +import { PopupData, PopupUnlockScenesData, closePopup } from "@src/lib/screenplay/popup"; +import { UserContext } from "@src/context/UserContext"; + +const PopupUnlockScenes = ({ data: { confirmUnlock } }: PopupData) => { + const userCtx = useContext(UserContext); + const { position, handleMouseDown, isDragging } = useDraggable(); + const t = useTranslations("production"); + + const onConfirm = () => { + confirmUnlock(); + closePopup(userCtx); + }; + + return ( +
+
+
+

{t("unlockTitle")}

+ closePopup(userCtx)} /> +
+
+

{t("unlockWarning")}

+
+ + +
+
+ ); +}; + +export default PopupUnlockScenes; diff --git a/components/popup/PopupUploadToCloud.tsx b/components/popup/PopupUploadToCloud.tsx index faf1c3ba..58218b8e 100644 --- a/components/popup/PopupUploadToCloud.tsx +++ b/components/popup/PopupUploadToCloud.tsx @@ -1,10 +1,8 @@ "use client"; import popup from "./Popup.module.css"; -import form from "../utils/Form.module.css"; import { X } from "lucide-react"; -import { join } from "@src/lib/utils/misc"; import { useDraggable } from "@src/lib/utils/hooks"; import { PopupData, PopupUploadToCloudData, closePopup } from "@src/lib/screenplay/popup"; import { useContext, useState } from "react"; @@ -58,14 +56,14 @@ const PopupUploadToCloud = ({ data: { projectId } }: PopupData {info && } + ); +}; + +export default Switch; diff --git a/messages/de.json b/messages/de.json index 1471557c..cc8993df 100644 --- a/messages/de.json +++ b/messages/de.json @@ -81,6 +81,7 @@ "tabs": { "General": "Allgemein", "Layout": "Layout", + "Production": "Produktion", "Export": "Import/Export", "Collaborators": "Mitwirkende", "Keybinds": "Tastenkürzel", @@ -390,7 +391,9 @@ "shelve": "Ablegen", "shelveScene": "Szene ablegen", "shelveDialogue": "Dialog ablegen", - "shelveAction": "Aktion ablegen" + "shelveAction": "Aktion ablegen", + "omitScene": "Szene auslassen", + "unomitScene": "Szene wiederherstellen" }, "popup": { "character": { @@ -446,6 +449,26 @@ "titlePlaceholder": "Titel", "descriptionPlaceholder": "Beschreibung" }, + "production": { + "title": "Produktion", + "sceneLocking": "Szenen sperren", + "pageLocking": "Seiten sperren", + "revisions": "Revisionen", + "relock": "Sperren", + "provisionalTitle": "Nicht gesperrte Szenen", + "unlockTitle": "Szenennummern entsperren?", + "unlockWarning": "Alle gesperrten und ausgelassenen Szenen verlieren ihre fixierte Nummerierung.", + "unlockInfo": "Dies betrifft alle Mitarbeiter. Das Drehbuch kehrt zu positionalen Szenennummern zurück. OMITTED-Szenen bleiben ausgelassen — heben Sie die Auslassung einzeln auf, um den Inhalt wiederherzustellen.", + "unlock": "Entsperren", + "cancel": "Abbrechen", + "numberingStyleTitle": "Szenennummerierung", + "numberingStyleHelp": "Wie neu eingefügte Szenen zwischen zwei gesperrten Szenen nummeriert werden. Suffix verweist auf die vorherige Szene, Präfix auf die nächste.", + "suffixName": "Suffix", + "prefixName": "Präfix", + "suffixExample": "2 → 2A", + "prefixExample": "2 → A3", + "appliesToNewOnly": "Bereits gesperrte Szenen behalten ihre Nummer — nur vorläufige Szenen und zukünftige Sperrungen sind betroffen." + }, "saves": { "title": "Versionsverlauf", "saveCurrentVersion": "Aktuelle Version speichern", diff --git a/messages/en.json b/messages/en.json index 797eb080..3c775230 100644 --- a/messages/en.json +++ b/messages/en.json @@ -80,6 +80,7 @@ "tabs": { "General": "General", "Layout": "Layout", + "Production": "Production", "Export": "Import/Export", "Collaborators": "Collaborators", "Keybinds": "Keybinds", @@ -389,7 +390,9 @@ "shelve": "Shelve", "shelveScene": "Shelve scene", "shelveDialogue": "Shelve dialogue", - "shelveAction": "Shelve action" + "shelveAction": "Shelve action", + "omitScene": "Omit scene", + "unomitScene": "Unomit scene" }, "popup": { "character": { @@ -445,6 +448,26 @@ "titlePlaceholder": "Title", "descriptionPlaceholder": "Description" }, + "production": { + "title": "Production", + "sceneLocking": "Scene locking", + "pageLocking": "Page locking", + "revisions": "Revisions", + "relock": "Relock", + "provisionalTitle": "Non-locked scenes", + "unlockTitle": "Unlock scenes", + "unlockWarning": "All scenes will lose their locked numbering, reverting to their initial positional numbering.", + "unlockInfo": "This affects every collaborator. The screenplay will revert to positional scene numbers. OMITTED scenes stay omitted — unomit them individually if you want their content back.", + "unlock": "Unlock", + "cancel": "Cancel", + "numberingStyleTitle": "Scene numbering", + "numberingStyleHelp": "How newly inserted scenes are numbered between two locked scenes. Suffix references the previous scene, prefix references the next.", + "suffixName": "Suffix", + "prefixName": "Prefix", + "suffixExample": "2 → 2A", + "prefixExample": "2 → A3", + "appliesToNewOnly": "Already-locked scenes keep their stored number — only provisional scenes and future locks are affected." + }, "saves": { "title": "Version History", "saveCurrentVersion": "Save Current Version", diff --git a/messages/es.json b/messages/es.json index 0cda76fd..d809cea1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -80,6 +80,7 @@ "tabs": { "General": "General", "Layout": "Diseño", + "Production": "Producción", "Export": "Importar/Exportar", "Collaborators": "Colaboradores", "Keybinds": "Atajos de teclado", @@ -389,7 +390,9 @@ "shelve": "Archivar", "shelveScene": "Archivar escena", "shelveDialogue": "Archivar diálogo", - "shelveAction": "Archivar acción" + "shelveAction": "Archivar acción", + "omitScene": "Omitir escena", + "unomitScene": "Restaurar escena" }, "popup": { "character": { @@ -445,6 +448,26 @@ "titlePlaceholder": "Título", "descriptionPlaceholder": "Descripción" }, + "production": { + "title": "Producción", + "sceneLocking": "Bloquear escenas", + "pageLocking": "Bloquear páginas", + "revisions": "Revisiones", + "relock": "Bloquear", + "provisionalTitle": "Escenas no bloqueadas", + "unlockTitle": "¿Desbloquear los números de escena?", + "unlockWarning": "Todas las escenas bloqueadas y omitidas perderán su numeración fija.", + "unlockInfo": "Esto afecta a todos los colaboradores. El guion volverá a una numeración posicional. Las escenas OMITTED siguen omitidas — anula la omisión individualmente si quieres recuperar su contenido.", + "unlock": "Desbloquear", + "cancel": "Cancelar", + "numberingStyleTitle": "Numeración de escenas", + "numberingStyleHelp": "Cómo se numeran las escenas recién insertadas entre dos escenas bloqueadas. El sufijo se refiere a la escena anterior, el prefijo a la siguiente.", + "suffixName": "Sufijo", + "prefixName": "Prefijo", + "suffixExample": "2 → 2A", + "prefixExample": "2 → A3", + "appliesToNewOnly": "Las escenas ya bloqueadas conservan su número — solo se ven afectadas las escenas provisionales y los futuros bloqueos." + }, "saves": { "title": "Historial de versiones", "saveCurrentVersion": "Guardar versión actual", diff --git a/messages/fr.json b/messages/fr.json index 5253634c..88569350 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -81,6 +81,7 @@ "tabs": { "General": "Général", "Layout": "Mise en page", + "Production": "Production", "Export": "Import/Export", "Collaborators": "Collaborateurs", "Keybinds": "Raccourcis", @@ -390,7 +391,9 @@ "shelve": "Mettre de côté", "shelveScene": "Mettre la scène de côté", "shelveDialogue": "Mettre le dialogue de côté", - "shelveAction": "Mettre l'action de côté" + "shelveAction": "Mettre l'action de côté", + "omitScene": "Omettre la scène", + "unomitScene": "Restaurer la scène" }, "popup": { "character": { @@ -446,6 +449,26 @@ "titlePlaceholder": "Titre", "descriptionPlaceholder": "Description" }, + "production": { + "title": "Production", + "sceneLocking": "Verrouillage des scènes", + "pageLocking": "Verrouillage des pages", + "revisions": "Révisions", + "relock": "Verrouiller", + "provisionalTitle": "Scènes non verrouillées", + "unlockTitle": "Déverrouiller les numéros de scène ?", + "unlockWarning": "Toutes les scènes verrouillées et omises perdront leur numérotation figée.", + "unlockInfo": "Ceci affecte tous les collaborateurs. Le scénario reviendra à une numérotation positionnelle. Les scènes OMITTED restent omises — démasquez-les individuellement si vous souhaitez en récupérer le contenu.", + "unlock": "Déverrouiller", + "cancel": "Annuler", + "numberingStyleTitle": "Numérotation des scènes", + "numberingStyleHelp": "Comment les scènes nouvellement insérées sont numérotées entre deux scènes verrouillées. Le suffixe se réfère à la scène précédente, le préfixe à la suivante.", + "suffixName": "Suffixe", + "prefixName": "Préfixe", + "suffixExample": "2 → 2A", + "prefixExample": "2 → A3", + "appliesToNewOnly": "Les scènes déjà verrouillées conservent leur numéro — seules les scènes provisoires et les futurs verrouillages sont concernés." + }, "saves": { "title": "Historique des versions", "saveCurrentVersion": "Enregistrer la version actuelle", diff --git a/messages/ja.json b/messages/ja.json index 2beb8a09..2a981d11 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -80,6 +80,7 @@ "tabs": { "General": "全般", "Layout": "レイアウト", + "Production": "プロダクション", "Export": "インポート/エクスポート", "Collaborators": "共同作業者", "Keybinds": "ショートカットキー", @@ -389,7 +390,9 @@ "shelve": "保管する", "shelveScene": "シーンを保管する", "shelveDialogue": "セリフを保管する", - "shelveAction": "アクションを保管する" + "shelveAction": "アクションを保管する", + "omitScene": "シーンを省略", + "unomitScene": "シーンを復元" }, "popup": { "character": { @@ -445,6 +448,26 @@ "titlePlaceholder": "タイトル", "descriptionPlaceholder": "説明" }, + "production": { + "title": "プロダクション", + "sceneLocking": "シーンロック", + "pageLocking": "ページロック", + "revisions": "改訂", + "relock": "ロック", + "provisionalTitle": "未ロックのシーン", + "unlockTitle": "シーン番号のロックを解除しますか?", + "unlockWarning": "ロック済みおよび省略されたシーンの固定番号はすべて失われます。", + "unlockInfo": "この操作はすべてのコラボレーターに影響します。脚本は位置ベースの番号に戻ります。OMITTED シーンは省略のまま残ります — 内容を復元したい場合は個別に省略を解除してください。", + "unlock": "ロック解除", + "cancel": "キャンセル", + "numberingStyleTitle": "シーン番号", + "numberingStyleHelp": "ロック済みの2つのシーンの間に挿入された新しいシーンの番号付け方法。サフィックスは前のシーン、プレフィックスは次のシーンを参照します。", + "suffixName": "サフィックス", + "prefixName": "プレフィックス", + "suffixExample": "2 → 2A", + "prefixExample": "2 → A3", + "appliesToNewOnly": "すでにロックされているシーンは番号を保持します — 仮シーンと今後のロックのみが影響を受けます。" + }, "saves": { "title": "バージョン履歴", "saveCurrentVersion": "現在のバージョンを保存", diff --git a/messages/ko.json b/messages/ko.json index c9edda13..93fbb859 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -80,6 +80,7 @@ "tabs": { "General": "일반", "Layout": "레이아웃", + "Production": "프로덕션", "Export": "가져오기/내보내기", "Collaborators": "공동 작업자", "Keybinds": "단축키", @@ -389,7 +390,9 @@ "shelve": "보관하기", "shelveScene": "장면 보관하기", "shelveDialogue": "대사 보관하기", - "shelveAction": "행동 보관하기" + "shelveAction": "행동 보관하기", + "omitScene": "씬 생략", + "unomitScene": "씬 복원" }, "popup": { "character": { @@ -445,6 +448,26 @@ "titlePlaceholder": "제목", "descriptionPlaceholder": "설명" }, + "production": { + "title": "프로덕션", + "sceneLocking": "씬 잠금", + "pageLocking": "페이지 잠금", + "revisions": "개정", + "relock": "잠그기", + "provisionalTitle": "잠기지 않은 씬", + "unlockTitle": "씬 번호 잠금을 해제하시겠습니까?", + "unlockWarning": "잠긴 씬과 생략된 씬의 고정 번호가 모두 사라집니다.", + "unlockInfo": "이 작업은 모든 협업자에게 영향을 미칩니다. 시나리오는 위치 기반 씬 번호로 되돌아갑니다. OMITTED 씬은 그대로 유지되며, 내용을 복원하려면 개별적으로 생략을 해제하세요.", + "unlock": "잠금 해제", + "cancel": "취소", + "numberingStyleTitle": "씬 번호", + "numberingStyleHelp": "잠긴 두 씬 사이에 새로 삽입된 씬의 번호 매김 방식. 접미사는 이전 씬을, 접두사는 다음 씬을 참조합니다.", + "suffixName": "접미사", + "prefixName": "접두사", + "suffixExample": "2 → 2A", + "prefixExample": "2 → A3", + "appliesToNewOnly": "이미 잠긴 씬은 번호를 유지합니다 — 임시 씬과 향후 잠금에만 영향을 미칩니다." + }, "saves": { "title": "버전 히스토리", "saveCurrentVersion": "현재 버전 저장", diff --git a/messages/pl.json b/messages/pl.json index 72f512bf..e21382c1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -80,6 +80,7 @@ "tabs": { "General": "Ogólne", "Layout": "Układ", + "Production": "Produkcja", "Export": "Import/Eksport", "Collaborators": "Współpracownicy", "Keybinds": "Skróty klawiszowe", @@ -389,7 +390,9 @@ "shelve": "Odłóż", "shelveScene": "Odłóż scenę", "shelveDialogue": "Odłóż dialog", - "shelveAction": "Odłóż akcję" + "shelveAction": "Odłóż akcję", + "omitScene": "Pomiń scenę", + "unomitScene": "Przywróć scenę" }, "popup": { "character": { @@ -445,6 +448,26 @@ "titlePlaceholder": "Tytuł", "descriptionPlaceholder": "Opis" }, + "production": { + "title": "Produkcja", + "sceneLocking": "Blokowanie scen", + "pageLocking": "Blokowanie stron", + "revisions": "Wersje", + "relock": "Zablokuj", + "provisionalTitle": "Niezablokowane sceny", + "unlockTitle": "Odblokować numery scen?", + "unlockWarning": "Wszystkie zablokowane i pominięte sceny utracą zamrożoną numerację.", + "unlockInfo": "Wpłynie to na wszystkich współpracowników. Scenariusz powróci do numeracji pozycyjnej. Sceny OMITTED pozostają pominięte — anuluj pominięcie pojedynczo, aby odzyskać ich zawartość.", + "unlock": "Odblokuj", + "cancel": "Anuluj", + "numberingStyleTitle": "Numeracja scen", + "numberingStyleHelp": "Jak są numerowane nowo wstawione sceny między dwiema zablokowanymi. Sufiks odnosi się do poprzedniej sceny, prefiks do następnej.", + "suffixName": "Sufiks", + "prefixName": "Prefiks", + "suffixExample": "2 → 2A", + "prefixExample": "2 → A3", + "appliesToNewOnly": "Już zablokowane sceny zachowują swój numer — wpływa tylko na sceny tymczasowe i przyszłe blokady." + }, "saves": { "title": "Historia wersji", "saveCurrentVersion": "Zapisz aktualną wersję", diff --git a/messages/zh.json b/messages/zh.json index ea963b3f..09623192 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -80,6 +80,7 @@ "tabs": { "General": "常规", "Layout": "布局", + "Production": "制作", "Export": "导入/导出", "Collaborators": "协作", "Keybinds": "键位", @@ -389,7 +390,9 @@ "shelve": "搁置", "shelveScene": "搁置场景", "shelveDialogue": "搁置对白", - "shelveAction": "搁置动作" + "shelveAction": "搁置动作", + "omitScene": "省略场景", + "unomitScene": "恢复场景" }, "popup": { "character": { @@ -445,6 +448,26 @@ "titlePlaceholder": "标题", "descriptionPlaceholder": "描述" }, + "production": { + "title": "制作", + "sceneLocking": "锁定场景", + "pageLocking": "锁定页面", + "revisions": "修订", + "relock": "锁定", + "provisionalTitle": "未锁定的场景", + "unlockTitle": "解锁场景编号?", + "unlockWarning": "所有锁定和省略的场景都将失去固定编号。", + "unlockInfo": "此操作会影响所有协作者。剧本将恢复为按位置编号。OMITTED 场景仍保持省略状态 — 如需恢复内容,请单独取消省略。", + "unlock": "解锁", + "cancel": "取消", + "numberingStyleTitle": "场景编号", + "numberingStyleHelp": "两个锁定场景之间新插入场景的编号方式。后缀参考上一个场景,前缀参考下一个场景。", + "suffixName": "后缀", + "prefixName": "前缀", + "suffixExample": "2 → 2A", + "prefixExample": "2 → A3", + "appliesToNewOnly": "已锁定的场景保留其编号 — 仅影响临时场景和将来的锁定。" + }, "saves": { "title": "版本历史", "saveCurrentVersion": "保存当前版本", diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index f13d1f2b..e30c5a05 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -97,6 +97,16 @@ export interface ProjectContextType { elementStyles: Record; setElementStyles: (styles: Record) => void; + // Production + sceneLocking: boolean; + setSceneLocking: (locked: boolean) => void; + sceneNumberingStyle: "suffix" | "prefix"; + setSceneNumberingStyle: (style: "suffix" | "prefix") => void; + /** Raw persistent scene map (UUID → PersistentScene). Includes synopsis, + * color, and production-lock fields (token, omitted) for every scene that + * has been persisted. */ + persistentScenes: PersistentSceneMap; + // Search state searchTerm: string; setSearchTerm: (term: string) => void; @@ -173,6 +183,11 @@ const defaultContextValue: ProjectContextType = { setElementMargins: () => {}, elementStyles: {}, setElementStyles: () => {}, + sceneLocking: false, + setSceneLocking: () => {}, + sceneNumberingStyle: "suffix", + setSceneNumberingStyle: () => {}, + persistentScenes: {}, characters: {}, locations: {}, scenes: [], @@ -292,6 +307,10 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = Record >({}); const [elementStyles, setElementStylesState] = useState>({}); + const [sceneLocking, setSceneLockingState] = useState(false); + const [sceneNumberingStyle, setSceneNumberingStyleState] = + useState<"suffix" | "prefix">("suffix"); + const [persistentScenes, setPersistentScenesState] = useState({}); const [connectionStatus, setConnectionStatus] = useState("disconnected"); const [users, setUsers] = useState([]); @@ -469,8 +488,17 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = if (initialLayout.elementStyles !== undefined) { setElementStylesState(initialLayout.elementStyles); } + if (initialLayout.sceneLocking !== undefined) { + setSceneLockingState(initialLayout.sceneLocking); + } + if (initialLayout.sceneNumberingStyle !== undefined) { + setSceneNumberingStyleState(initialLayout.sceneNumberingStyle); + } } + // Read initial persistent scenes + setPersistentScenesState(repository.scenes); + // Observe layout changes const unsubscribeLayout = repository.observeLayout((layout: Partial) => { const _pageSize = layout.pageSize; @@ -509,6 +537,12 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = if (layout.elementStyles !== undefined) { setElementStylesState(layout.elementStyles); } + if (layout.sceneLocking !== undefined) { + setSceneLockingState(layout.sceneLocking); + } + if (layout.sceneNumberingStyle !== undefined) { + setSceneNumberingStyleState(layout.sceneNumberingStyle); + } }); // Observe character changes - get current screenplay from repository @@ -530,6 +564,7 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = const currentScreenplay = repository.screenplay; const allScenes = mergeScenesData(_scenes, currentScreenplay); updateScenes(allScenes); + setPersistentScenesState(_scenes); }); // Observe metadata changes (for title page placeholders) @@ -707,6 +742,22 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = [repository], ); + const setSceneLocking = useCallback( + (locked: boolean) => { + setSceneLockingState(locked); + repository?.setSceneLocking(locked); + }, + [repository], + ); + + const setSceneNumberingStyle = useCallback( + (style: "suffix" | "prefix") => { + setSceneNumberingStyleState(style); + repository?.setSceneNumberingStyle(style); + }, + [repository], + ); + const setSearchTerm = useCallback((term: string) => { setSearchTermState(term); // Reset to first match when search term changes @@ -791,6 +842,11 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = setElementMargins, elementStyles, setElementStyles, + sceneLocking, + setSceneLocking, + sceneNumberingStyle, + setSceneNumberingStyle, + persistentScenes, screenplay, scenes, updateScenes, @@ -855,6 +911,11 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = setElementMargins, elementStyles, setElementStyles, + sceneLocking, + setSceneLocking, + sceneNumberingStyle, + setSceneNumberingStyle, + persistentScenes, screenplay, scenes, updateScenes, diff --git a/src/lib/adapters/pdf/pdf-adapter.ts b/src/lib/adapters/pdf/pdf-adapter.ts index 36ca1821..9b23880c 100644 --- a/src/lib/adapters/pdf/pdf-adapter.ts +++ b/src/lib/adapters/pdf/pdf-adapter.ts @@ -1,11 +1,13 @@ import { BaseExportOptions, ProjectAdapter } from "../screenplay-adapter"; import { ProjectData, ProjectState } from "@src/lib/project/project-state"; +import type { PersistentSceneMap } from "@src/lib/screenplay/scenes"; import { PageFormat } from "@src/lib/utils/enums"; import { getFontForCodePoint, ScriptFont } from "./pdf-utils"; import type { TextRun } from "./pdf.worker"; import { BASE_URL } from "@src/lib/utils/constants"; import { PAGE_SIZES } from "@src/lib/screenplay/extensions/pagination-extension"; +import { computeSceneLabels } from "@src/lib/screenplay/scene-locking"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -21,6 +23,10 @@ export type PDFExportOptions = BaseExportOptions & { moreLabel?: string; editorElement?: HTMLElement; titlePageElement?: HTMLElement; + /** Production-mode flag + persistent scene entries, set internally from the ProjectState. */ + sceneLocking?: boolean; + sceneNumberingStyle?: "suffix" | "prefix"; + persistentScenes?: PersistentSceneMap; }; import type { WorkerMessage, WorkerPayload, VisualLine } from "./pdf.worker"; @@ -72,13 +78,27 @@ export class PDFAdapter extends ProjectAdapter { const format = options.format; const pdfPageSize = PDF_PAGE_SIZES[format]; + // Resolve production state from the project. Persistent scenes carry + // synopsis/color (irrelevant here) and token/omitted (used for labels). + const layout = project.layout().toJSON() as { + sceneLocking?: boolean; + sceneNumberingStyle?: "suffix" | "prefix"; + }; + const persistentScenes = project.scenes().toJSON() as PersistentSceneMap; + const enrichedOptions: PDFExportOptions = { + ...options, + sceneLocking: !!layout.sceneLocking, + sceneNumberingStyle: layout.sceneNumberingStyle ?? "suffix", + persistentScenes, + }; + // ── Collect all visual lines from the browser DOM ─────────────────── const titlePageEl = options.titlePageElement; - const titlePageLines = titlePageEl ? this.collectLines(titlePageEl, options) : []; + const titlePageLines = titlePageEl ? this.collectLines(titlePageEl, enrichedOptions) : []; const titlePageLeftPx = titlePageEl ? this.getPageLeftPx(titlePageEl) : 0; - const screenplayLines = this.collectLines(editorEl, options); + const screenplayLines = this.collectLines(editorEl, enrichedOptions); const screenplayLeftPx = this.getPageLeftPx(editorEl); return new Promise((resolve, reject) => { @@ -142,6 +162,28 @@ export class PDFAdapter extends ProjectAdapter { let sceneCount = 0; let yOffset = 0; + // Pre-compute label per scene UUID when production lock is on. + const sceneLabels: { label: string; omitted: boolean }[] = []; + let sceneLabelIdx = 0; + if (options.sceneLocking) { + const uuids: string[] = []; + for (let i = 0; i < editorEl.children.length; i++) { + const child = editorEl.children[i] as HTMLElement; + if (child?.tagName === "P" && child.classList.contains("scene")) { + const uuid = child.getAttribute("data-id"); + if (uuid) uuids.push(uuid); + } + } + const computed = computeSceneLabels( + uuids, + options.persistentScenes ?? {}, + options.sceneNumberingStyle ?? "suffix", + ); + for (const l of computed) { + sceneLabels.push({ label: l.label, omitted: l.status === "omitted" }); + } + } + for (let i = 0; i < editorEl.children.length; i++) { const el = editorEl.children[i] as HTMLElement; if (!el) continue; @@ -164,6 +206,11 @@ export class PDFAdapter extends ProjectAdapter { const isScene = el.classList.contains("scene"); if (isScene) sceneCount++; + const sceneInfo = isScene + ? options.sceneLocking + ? sceneLabels[sceneLabelIdx++] ?? { label: String(sceneCount), omitted: false } + : { label: String(sceneCount), omitted: false } + : undefined; // Extract the paragraph type from classList let nodeType: string | undefined; @@ -200,7 +247,7 @@ export class PDFAdapter extends ProjectAdapter { if (yOffset > 0) { for (const line of beforeLines) line.y -= yOffset; } - this.injectPseudoContent(el, beforeLines, options, isScene ? sceneCount : undefined); + this.injectPseudoContent(el, beforeLines, options, sceneInfo); allLines.push(...beforeLines); } @@ -224,7 +271,7 @@ export class PDFAdapter extends ProjectAdapter { for (const line of paragraphLines) line.y -= yOffset; } // ── Pseudo-element content (not captured by TreeWalker) ── - this.injectPseudoContent(el, paragraphLines, options, isScene ? sceneCount : undefined); + this.injectPseudoContent(el, paragraphLines, options, sceneInfo); allLines.push(...paragraphLines); } else { // Empty paragraph — no text nodes, so collectParagraphLines @@ -370,7 +417,24 @@ export class PDFAdapter extends ProjectAdapter { // ── Measure position ───────────────────────────────────── range.setStart(textNode, ci); range.setEnd(textNode, ci + 1); - const rect = range.getBoundingClientRect(); + // WebKit (Safari, Tauri on macOS) has a long-standing quirk: + // for the FIRST character of a wrapped line, the single-char + // range straddles a line boundary because position `ci` is + // bidi-ambiguous between the end of the previous line and the + // start of the new one. `getBoundingClientRect()` returns the + // UNION of both lines — `rect.top` then lands on the *previous* + // line, so we mistakenly attribute the char to it. The visible + // result in PDFs is "one letter at the end of every wrapped + // line plus a leading space on the next". + // + // `getClientRects()` returns one rect per line box the range + // intersects; the LAST rect is always the actual rendering + // line. For normal (single-line) chars only one rect is + // returned, so this is a no-op everywhere else. + const rects = range.getClientRects(); + const rect = rects.length > 0 + ? rects[rects.length - 1] + : range.getBoundingClientRect(); // If height is 0, it is usually a trailing wrapped space or hidden char if (rect.height === 0) { @@ -454,20 +518,21 @@ export class PDFAdapter extends ProjectAdapter { el: HTMLElement, paragraphLines: VisualLine[], options: PDFExportOptions, - sceneNumber?: number, + sceneInfo?: { label: string; omitted: boolean }, ): void { const firstLine = paragraphLines[0]; const lastLine = paragraphLines[paragraphLines.length - 1]; - if (sceneNumber !== undefined && options.displaySceneNumbers) { + if (sceneInfo && options.displaySceneNumbers) { const elStyle = getComputedStyle(el); - // Left scene number — mirrors CSS `right: 100%; margin-right: -120px` on .scene::before: - // right edge lands at scene_element_left + 120px. + const paddingLeft = parseFloat(elStyle.paddingLeft) || 0; + + // Left scene number — mirrors CSS `right: 100%; margin-right: -120px` + // on .scene::before: right edge lands at scene_element_left + 120px. if (firstLine.runs.length > 0) { const leadRun = firstLine.runs[0]; - const paddingLeft = parseFloat(elStyle.paddingLeft) || 0; firstLine.runs.unshift({ - text: String(sceneNumber), + text: sceneInfo.label, x: leadRun.x - paddingLeft + 120, fontFamily: leadRun.fontFamily, bold: leadRun.bold, @@ -478,12 +543,12 @@ export class PDFAdapter extends ProjectAdapter { }); } - // Right scene number — mirrors CSS `left: 100%; margin-left: -85px` on .scene::after: - // left edge lands at scene_element_right - 85px. + // Right scene number — mirrors CSS `left: 100%; margin-left: -85px` + // on .scene::after: left edge lands at scene_element_right - 85px. if (options.sceneNumberOnRight && firstLine.runs.length > 0) { const tailRun = firstLine.runs[firstLine.runs.length - 1]; firstLine.runs.push({ - text: String(sceneNumber), + text: sceneInfo.label, x: el.getBoundingClientRect().right - 85, fontFamily: tailRun.fontFamily, bold: tailRun.bold, diff --git a/src/lib/editor/document-editor-config.ts b/src/lib/editor/document-editor-config.ts index fb456ea0..b1a61dda 100644 --- a/src/lib/editor/document-editor-config.ts +++ b/src/lib/editor/document-editor-config.ts @@ -18,6 +18,8 @@ export interface DocumentEditorFeatures { searchHighlights: boolean; /** Scene color bookmark decorations. */ sceneBookmarks: boolean; + /** Production-mode scene labels + OMITTED placeholders. */ + sceneLocking: boolean; /** Prevent duplicate data-ids on paste. */ nodeIdDedup: boolean; /** Character / location autocomplete menus. */ @@ -67,6 +69,7 @@ export const SCREENPLAY_EDITOR_CONFIG: DocumentEditorConfig = { characterHighlights: true, searchHighlights: true, sceneBookmarks: true, + sceneLocking: true, nodeIdDedup: true, suggestions: true, orphanPrevention: true, @@ -89,6 +92,7 @@ export const TITLEPAGE_EDITOR_CONFIG: DocumentEditorConfig = { characterHighlights: false, searchHighlights: false, sceneBookmarks: false, + sceneLocking: false, nodeIdDedup: false, suggestions: false, orphanPrevention: false, diff --git a/src/lib/editor/use-document-editor.ts b/src/lib/editor/use-document-editor.ts index 81a9c31b..7a6ac17f 100644 --- a/src/lib/editor/use-document-editor.ts +++ b/src/lib/editor/use-document-editor.ts @@ -27,6 +27,10 @@ import { createSceneBookmarkExtension, refreshSceneBookmarks, } from "@src/lib/screenplay/extensions/scene-bookmark-extension"; +import { + createSceneLockingExtension, + refreshSceneLocking, +} from "@src/lib/screenplay/extensions/scene-locking-extension"; import { createNodeIdDedupExtension } from "@src/lib/screenplay/extensions/node-id-dedup-extension"; import { CommentMark } from "@src/lib/screenplay/extensions/comment-highlight-extension"; import { createSpellcheckExtension, refreshSpellcheck } from "@src/lib/spellcheck/spellcheck-extension"; @@ -71,6 +75,9 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum setSearchMatches, contdLabel, moreLabel, + sceneLocking, + sceneNumberingStyle, + persistentScenes, } = projectCtx; const projectState = repository?.getState(); @@ -163,6 +170,9 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum searchFilters, currentSearchIndex, setSearchMatches, + sceneLocking, + sceneNumberingStyle, + persistentScenes, // eslint-disable-next-line react-hooks/exhaustive-deps }), []); ext.highlightedCharacters = highlightedCharacters; @@ -176,6 +186,9 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum ext.searchFilters = searchFilters; ext.currentSearchIndex = currentSearchIndex; ext.setSearchMatches = setSearchMatches; + ext.sceneLocking = sceneLocking; + ext.sceneNumberingStyle = sceneNumberingStyle; + ext.persistentScenes = persistentScenes; const lastReportedElementRef = useRef(null); @@ -251,6 +264,14 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum }) : null; + const sceneLockingExtension = features.sceneLocking + ? createSceneLockingExtension({ + getSceneLocking: () => !!ext.sceneLocking, + getScenes: () => ext.repository?.scenes ?? {}, + getNumberingStyle: () => ext.sceneNumberingStyle ?? "suffix", + }) + : null; + const commentMarkExtension = features.comments ? CommentMark.configure({ onCommentActivated: (commentId: string | null) => { @@ -363,6 +384,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum ...(characterHighlightExtension ? [characterHighlightExtension] : []), ...(searchHighlightExtension ? [searchHighlightExtension] : []), ...(sceneBookmarkExtension ? [sceneBookmarkExtension] : []), + ...(sceneLockingExtension ? [sceneLockingExtension] : []), ...(nodeIdDedupExtension ? [nodeIdDedupExtension] : []), ...(spellcheckExtension ? [spellcheckExtension] : []), ], @@ -569,6 +591,13 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum } }, [editor, scenes, features.sceneBookmarks]); + // Refresh scene locking decorations when the lock map or toggle changes + useEffect(() => { + if (editor && features.sceneLocking) { + refreshSceneLocking(editor); + } + }, [editor, sceneLocking, sceneNumberingStyle, persistentScenes, features.sceneLocking]); + // Refresh search highlights useEffect(() => { if (editor && features.searchHighlights) { diff --git a/src/lib/project/project-doc.ts b/src/lib/project/project-doc.ts index 936c2c7f..f729c449 100644 --- a/src/lib/project/project-doc.ts +++ b/src/lib/project/project-doc.ts @@ -101,6 +101,16 @@ export type LayoutData = { moreLabel: string; elementMargins: Record; elementStyles: Record; + sceneLocking?: boolean; + /** + * How provisional scenes inserted under production lock are labeled. + * - "suffix" (default): scene inserted between 3 and 4 → "3A". + * - "prefix": scene inserted between 3 and 4 → "A4". Letters decrease + * going forward (closest to L_next gets "A"). + * Only affects scenes that are computed/locked AFTER this setting is set; + * already-locked scenes keep their stored label. + */ + sceneNumberingStyle?: "suffix" | "prefix"; }; // -------------------------------- // diff --git a/src/lib/project/project-repository.ts b/src/lib/project/project-repository.ts index 5402e11e..23d6a96a 100644 --- a/src/lib/project/project-repository.ts +++ b/src/lib/project/project-repository.ts @@ -244,20 +244,30 @@ export class ProjectRepository { /** * Create or update a scene's persistent data. + * + * Fields that appear in `data` (including ones explicitly set to undefined) + * overwrite the corresponding existing fields; everything else is preserved. + * Any final undefined values are stripped before writing. + * * Returns the scene id. */ upsertScene(sceneId: string, data: Partial): string { if (this.guardWrite("upsertScene")) return sceneId; const map = this.ydoc.scenes(); - const existing = map.get(sceneId) as PersistentScene | undefined; + const existing = (map.get(sceneId) as PersistentScene | undefined) ?? {}; - const sceneData: PersistentScene = { - synopsis: data.synopsis ?? existing?.synopsis ?? "", - color: "color" in data ? data.color : existing?.color, - }; + const merged: PersistentScene = { ...existing }; + const FIELDS = ["synopsis", "color", "token", "omitted"] as const; + for (const key of FIELDS) { + if (key in data) { + (merged as Record)[key] = data[key]; + } + } + for (const key of FIELDS) { + if (merged[key] === undefined) delete merged[key]; + } - map.set(sceneId, sceneData); - console.log(`[Scenes] Upserted scene: ${sceneId}`); + map.set(sceneId, merged); return sceneId; } @@ -360,6 +370,48 @@ export class ProjectRepository { if (this.guardWrite("setElementStyles")) return; this.ydoc.layout().set("elementStyles", styles); } + setSceneLocking(locked: boolean) { + if (this.guardWrite("setSceneLocking")) return; + this.ydoc.layout().set("sceneLocking", locked); + } + setSceneNumberingStyle(style: "suffix" | "prefix") { + if (this.guardWrite("setSceneNumberingStyle")) return; + this.ydoc.layout().set("sceneNumberingStyle", style); + } + + /** + * Strip the frozen production `token` from every persistent scene entry. + * Entries that have no remaining content (no `synopsis`, `color`, or + * `omitted` flag) are deleted outright. Used by the Production panel + * when the user unlocks scenes. The `omitted` flag is preserved — omit + * is independent of production lock and survives unlock. + */ + clearSceneLocks(): void { + if (this.guardWrite("clearSceneLocks")) return; + const map = this.ydoc.scenes(); + const entries: [string, PersistentScene][] = []; + map.forEach((value, key) => { + entries.push([key, value as PersistentScene]); + }); + for (const [uuid, scene] of entries) { + const next: PersistentScene = { ...scene }; + delete next.token; + if (!next.synopsis && !next.color && !next.omitted) { + map.delete(uuid); + } else { + map.set(uuid, next); + } + } + } + + /** + * Run a function inside a single Y.js transaction. + * Useful for batching multiple repository mutations into one collab update. + */ + transact(fn: () => void): void { + if (this.guardWrite("transact")) return; + this.ydoc.transact(fn); + } // -------------------------------- // // COMMENTS // diff --git a/src/lib/screenplay/editor.ts b/src/lib/screenplay/editor.ts index 8f4eded5..6c2fc7e9 100644 --- a/src/lib/screenplay/editor.ts +++ b/src/lib/screenplay/editor.ts @@ -5,7 +5,7 @@ import { ScreenplayElement, Style, TitlePageElement } from "../utils/enums"; import Document from "@tiptap/extension-document"; import Text from "@tiptap/extension-text"; -import { ScreenplayNodes, ScriptioBold, ScriptioItalic, ScriptioUnderline } from "@src/lib/screenplay/nodes"; +import { ScreenplayNodes, ScriptioBold, ScriptioItalic, ScriptioUnderline, generateNodeId } from "@src/lib/screenplay/nodes"; import { Placeholder } from "./extensions/placeholder-extension"; import { PAGE_SIZES } from "./extensions/pagination-extension"; import { ContdExtension } from "./extensions/contd-extension"; @@ -19,8 +19,14 @@ export const applyMarkToggle = (editor: Editor, style: Style) => { }; export const applyElement = (editor: Editor, element: ScreenplayElement) => { - // Use the element value directly as the node name since they now match - editor.chain().focus().setNode(element, { class: element }).run(); + // Pass a fresh data-id explicitly: Tiptap pre-resolves the schema's + // function defaults at setup time (see @tiptap/core + // helpers/getAttributesFromExtensions.ts), so the data-id default is a + // static string after init. Without this, every type-conversion would + // produce a duplicate that the dedup extension renames — and the + // rename would transfer any locked persistent entry to the new node, + // silently breaking scene locks. + editor.chain().focus().setNode(element, { class: element, "data-id": generateNodeId() }).run(); }; export const focusOnPosition = (editor: Editor, position: number) => { diff --git a/src/lib/screenplay/extensions/fountain-extension.ts b/src/lib/screenplay/extensions/fountain-extension.ts index 41852b4a..5bfa9922 100644 --- a/src/lib/screenplay/extensions/fountain-extension.ts +++ b/src/lib/screenplay/extensions/fountain-extension.ts @@ -3,6 +3,7 @@ import { Node } from "@tiptap/pm/model"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { ReplaceStep, Step } from "@tiptap/pm/transform"; import { ScreenplayElement } from "../../utils/enums"; +import { generateNodeId } from "../nodes"; const fountainInputRulesPluginKey = new PluginKey("fountainInputRules"); @@ -116,6 +117,7 @@ export const FountainExtension = Extension.create({ // Change the node type to the new element type tr.setNodeMarkup(nodeStart, targetNodeType, { class: forcedElement, + "data-id": generateNodeId(), }); // Then remove the prefix character @@ -138,6 +140,7 @@ export const FountainExtension = Extension.create({ const tr = newState.tr; tr.setNodeMarkup(nodeStart, targetNodeType, { class: ScreenplayElement.Note, + "data-id": generateNodeId(), }); // Remove the [[ prefix @@ -175,6 +178,7 @@ export const FountainExtension = Extension.create({ const tr = newState.tr; tr.setNodeMarkup(nodeStart, targetNodeType, { class: ScreenplayElement.Character, + "data-id": generateNodeId(), }); return tr; diff --git a/src/lib/screenplay/extensions/node-id-dedup-extension.ts b/src/lib/screenplay/extensions/node-id-dedup-extension.ts index 12a89bfa..31bbaab5 100644 --- a/src/lib/screenplay/extensions/node-id-dedup-extension.ts +++ b/src/lib/screenplay/extensions/node-id-dedup-extension.ts @@ -16,6 +16,9 @@ type NodeIdDedupConfig = { * This plugin only handles the duplicate case: when a node is copy-pasted, both the * original and copy share the same data-id. A new ID is generated for the copy, and * for persistent scene headings, the persistent scene data is duplicated as well. + * + * NOTE: production sceneLocks are intentionally NOT duplicated here — a pasted scene + * should start unlocked/provisional, not inherit the source's frozen label. */ export const createNodeIdDedupExtension = (config: NodeIdDedupConfig) => { return Extension.create({ diff --git a/src/lib/screenplay/extensions/scene-locking-extension.ts b/src/lib/screenplay/extensions/scene-locking-extension.ts new file mode 100644 index 00000000..9de747ef --- /dev/null +++ b/src/lib/screenplay/extensions/scene-locking-extension.ts @@ -0,0 +1,244 @@ +import { Editor, Extension } from "@tiptap/core"; +import { Node } from "@tiptap/pm/model"; +import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +import type { PersistentScene } from "../scenes"; +import { computeSceneLabels, SceneNumberingStyle } from "../scene-locking"; +import { ScreenplayElement } from "../../utils/enums"; + +const sceneLockingPluginKey = new PluginKey("sceneLocking"); +const REFRESH_META = "sceneLockingRefresh"; + +type SceneLockingConfig = { + getSceneLocking: () => boolean; + getScenes: () => Record; + getNumberingStyle: () => SceneNumberingStyle; +}; + +type SceneEntry = { uuid: string; pos: number; nodeSize: number }; + +const collectSceneEntries = (doc: Node): SceneEntry[] => { + const out: SceneEntry[] = []; + doc.forEach((node, pos) => { + if (node.attrs?.class !== ScreenplayElement.Scene) return; + const uuid: string | undefined = node.attrs?.["data-id"]; + if (!uuid) return; + out.push({ uuid, pos, nodeSize: node.nodeSize }); + }); + return out; +}; + +/** + * Does any step in this transaction touch a Scene node or any node that sits + * between Scene boundaries? Used as a cheap early-exit so we don't rebuild + * decorations on every keystroke inside an action paragraph far away from + * any omitted scene. We have to be conservative when omitted scenes exist + * because hiding the body of an omitted scene means body-paragraph edits + * must trigger decoration recomputation too. + */ +const didSceneNodesChange = (tr: Transaction): boolean => { + if (!tr.docChanged) return false; + for (const step of tr.steps) { + const stepMap = step.getMap(); + let affected = false; + stepMap.forEach((oldStart: number, oldEnd: number, newStart: number, newEnd: number) => { + try { + const oldDoc = tr.docs[0]; + if (oldDoc) { + oldDoc.nodesBetween(oldStart, oldEnd, (node: Node) => { + if (node.attrs?.class === ScreenplayElement.Scene) affected = true; + }); + } + } catch { /* range out of bounds */ } + try { + tr.doc.nodesBetween(newStart, newEnd, (node: Node) => { + if (node.attrs?.class === ScreenplayElement.Scene) affected = true; + }); + } catch { /* range out of bounds */ } + }); + if (affected) return true; + } + return false; +}; + +const buildLabelWidget = (label: string, side: "left" | "right"): HTMLElement => { + const span = document.createElement("span"); + span.className = side === "left" ? "scene-label scene-label-left" : "scene-label scene-label-right"; + span.contentEditable = "false"; + span.textContent = label; + return span; +}; + +const buildOmittedWidget = (): HTMLElement => { + const span = document.createElement("span"); + span.className = "scene-omitted-overlay"; + span.contentEditable = "false"; + span.textContent = "OMITTED"; + return span; +}; + +const computeDecorations = ( + doc: Node, + locking: boolean, + scenes: Record, + style: SceneNumberingStyle, +): DecorationSet => { + const entries = collectSceneEntries(doc); + if (entries.length === 0) return DecorationSet.empty; + + const decorations: Decoration[] = []; + + // Scene-number labels are only meaningful under production lock. + if (locking) { + const labels = computeSceneLabels( + entries.map((e) => e.uuid), + scenes, + style, + ); + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const info = labels[i]; + const keyBase = `${entry.uuid}-${info.label}-${info.status}`; + + decorations.push( + Decoration.widget(entry.pos + 1, () => buildLabelWidget(info.label, "left"), { + side: -1, + key: `scene-label-l-${keyBase}`, + }), + ); + decorations.push( + Decoration.widget(entry.pos + 1, () => buildLabelWidget(info.label, "right"), { + side: -1, + key: `scene-label-r-${keyBase}`, + }), + ); + } + } + + // OMITTED decorations are independent of production lock — the user can + // omit any scene at any time and the original heading + body are kept + // in the document; we just hide them visually until they unomit. + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (!scenes[entry.uuid]?.omitted) continue; + + decorations.push( + Decoration.node(entry.pos, entry.pos + entry.nodeSize, { + "data-omitted-overlay": "true", + }), + ); + decorations.push( + Decoration.widget(entry.pos + 1, () => buildOmittedWidget(), { + side: -1, + key: `scene-omitted-${entry.uuid}`, + }), + ); + + // Hide the original heading text behind the OMITTED widget. Skip + // empty headings — there's nothing to hide and the inline range + // would be degenerate. + if (entry.nodeSize > 2) { + decorations.push( + Decoration.inline(entry.pos + 1, entry.pos + entry.nodeSize - 1, { + class: "scene-heading-omitted-text", + }), + ); + } + + // Hide every top-level paragraph between this heading and the next + // scene heading. We tag them with `data-omitted-body` so CSS can + // collapse them while leaving the underlying document untouched. + const nextEntry = entries[i + 1]; + const bodyEnd = nextEntry ? nextEntry.pos : doc.content.size; + const bodyStart = entry.pos + entry.nodeSize; + doc.forEach((node, pos) => { + if (pos >= bodyStart && pos < bodyEnd) { + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { + "data-omitted-body": "true", + }), + ); + } + }); + } + + return DecorationSet.create(doc, decorations); +}; + +/** + * Tiptap extension that renders scene-number labels under production lock + * and OMITTED overlays (independent of lock state). + * + * Hot-path notes: + * - `apply` runs on every transaction. We early-exit when no scene nodes + * were touched, simply mapping existing decorations forward through the + * transaction. Full recomputation only happens on an explicit refresh + * signal or when a scene node was actually modified. + */ +export const createSceneLockingExtension = (config: SceneLockingConfig) => { + return Extension.create({ + name: "sceneLocking", + + addProseMirrorPlugins() { + const { getSceneLocking, getScenes, getNumberingStyle } = config; + + return [ + new Plugin({ + key: sceneLockingPluginKey, + state: { + init(_, { doc }) { + return computeDecorations( + doc, + getSceneLocking(), + getScenes(), + getNumberingStyle(), + ); + }, + apply(tr, oldDecorations, _oldState, newState) { + // Explicit refresh (lock toggle, lock-map change) → recompute. + if (tr.getMeta(REFRESH_META)) { + return computeDecorations( + newState.doc, + getSceneLocking(), + getScenes(), + getNumberingStyle(), + ); + } + + if (!tr.docChanged) return oldDecorations; + + // Doc edits that don't touch a scene node only shift + // existing decorations — no need to rebuild widgets. + if (!didSceneNodesChange(tr)) { + return oldDecorations.map(tr.mapping, newState.doc); + } + + return computeDecorations( + newState.doc, + getSceneLocking(), + getScenes(), + getNumberingStyle(), + ); + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, + }), + ]; + }, + }); +}; + +/** + * Force a recompute of scene label decorations. + * Call when sceneLocking toggles or the sceneLocks map changes. + */ +export const refreshSceneLocking = (editor: Editor | null) => { + if (!editor || !editor.view) return; + editor.view.dispatch(editor.state.tr.setMeta(REFRESH_META, true)); +}; diff --git a/src/lib/screenplay/nodes/scene-node.ts b/src/lib/screenplay/nodes/scene-node.ts index 457d84d3..6a03c3cc 100644 --- a/src/lib/screenplay/nodes/scene-node.ts +++ b/src/lib/screenplay/nodes/scene-node.ts @@ -1,4 +1,5 @@ import { Node, mergeAttributes } from "@tiptap/core"; +import { TextSelection } from "@tiptap/pm/state"; import { ScreenplayElement } from "../../utils/enums"; import { ALIGN_CLASSES, generateNodeId } from "./index"; @@ -77,4 +78,84 @@ export const SceneNode = Node.create({ renderHTML({ HTMLAttributes }) { return ["p", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; }, + + addKeyboardShortcuts() { + return { + Backspace: ({ editor }) => { + const { state, view } = editor; + const { $from, empty } = state.selection; + if (!empty || $from.parentOffset !== 0) return false; + + // Case 1: cursor inside an empty Scene heading → delete the + // Scene itself (unless it's the only block in the document). + // After deletion, drop the cursor at the end of the previous + // block — default PM selection mapping moves it forward into + // the *next* block instead, which feels wrong here. + if ($from.parent.type === this.type) { + if ($from.parent.textContent.length === 0 && state.doc.childCount > 1) { + const pos = $from.before(); + const tr = state.tr.delete(pos, pos + $from.parent.nodeSize); + if (pos > 0) { + tr.setSelection(TextSelection.create(tr.doc, pos - 1)); + } + view.dispatch(tr); + return true; + } + return false; + } + + // Case 2: cursor at the start of an empty block whose + // immediately preceding sibling is a Scene heading. Default + // ProseMirror `joinBackward` would delete the Scene above + // (joinMaybeClear deletes the empty `before` block), leaving + // the empty current block orphaned. Intercept and delete the + // current block instead, dropping the cursor into the Scene. + if ($from.parent.textContent.length !== 0) return false; + + const curStart = $from.before(); + if (curStart === 0) return false; + + const prev = state.doc.resolve(curStart).nodeBefore; + if (!prev || prev.type !== this.type) return false; + + const cursorTarget = curStart - 1; + const tr = state.tr.delete(curStart, $from.after()); + tr.setSelection(TextSelection.create(tr.doc, cursorTarget)); + view.dispatch(tr); + return true; + }, + Enter: ({ editor }) => { + const { state, view } = editor; + const { $from, empty } = state.selection; + + if (empty && $from.parent.type === this.type && $from.parentOffset === 0) { + // If the node is completely empty, let Tiptap handle it (converts to Action) + if ($from.parent.textContent.length === 0) { + return false; + } + + // Split the block. We want the node AFTER the split (which contains the text) + // to keep its original data-id, and the new empty node BEFORE the split + // to get a new data-id. Since tr.split by default copies the original node's + // attributes to both halves, we can split, and then update the attributes of + // the newly created empty node (which will be right before $from.pos). + const originalAttrs = $from.parent.attrs; + let tr = state.tr.split($from.pos, 1, [{ type: this.type, attrs: originalAttrs }]); + + // After the split, the original node with its text is pushed down. + // A new empty node is created above it. The new node starts at $from.pos - 1 + // (the start of the block). We update its data-id. + const newNodePos = $from.pos - 1; + tr = tr.setNodeMarkup(newNodePos, undefined, { + ...originalAttrs, + "data-id": generateNodeId() + }); + + view.dispatch(tr); + return true; + } + return false; + }, + }; + }, }); \ No newline at end of file diff --git a/src/lib/screenplay/popup.ts b/src/lib/screenplay/popup.ts index fcc6c681..e9b54903 100644 --- a/src/lib/screenplay/popup.ts +++ b/src/lib/screenplay/popup.ts @@ -21,6 +21,10 @@ export type PopupUploadToCloudData = { projectId: string; }; +export type PopupUnlockScenesData = { + confirmUnlock: () => void; +}; + // ------------------------------ // // GENERIC POPUP // // ------------------------------ // @@ -28,7 +32,8 @@ export type PopupUnionData = | PopupImportFileData | PopupCharacterData | PopupSceneData - | PopupUploadToCloudData; + | PopupUploadToCloudData + | PopupUnlockScenesData; export enum PopupType { NewCharacter, @@ -36,6 +41,7 @@ export enum PopupType { ImportFile, EditScene, UploadToCloud, + UnlockScenes, } export type PopupData = { @@ -85,3 +91,10 @@ export const uploadToCloudPopup = (projectId: string, userCtx: UserContextType) data: { projectId }, }); }; + +export const unlockScenesPopup = (confirmUnlock: () => void, userCtx: UserContextType) => { + userCtx.updatePopup({ + type: PopupType.UnlockScenes, + data: { confirmUnlock }, + }); +}; diff --git a/src/lib/screenplay/scene-locking.ts b/src/lib/screenplay/scene-locking.ts new file mode 100644 index 00000000..6e6a33ab --- /dev/null +++ b/src/lib/screenplay/scene-locking.ts @@ -0,0 +1,449 @@ +/** + * Token-based scene labeling for production lock. + * + * Every locked scene stores a `SceneToken` — a structural, mode-independent + * encoding of its logical position in the screenplay. The display label is + * derived from the token via `compileSceneLabel(token)`. Because letter case + * is baked into each level (`lower: true | false`), toggling the global + * `SceneNumberingStyle` setting never alters the label of an already-locked + * scene. + * + * Convention + * ---------- + * - `baseNumber` is the integer anchor (1, 2, 3, …) that the rest of the + * token attaches to. + * - `prefixes` are letter levels rendered BEFORE the base. Stored + * **inner-first** — `prefixes[0]` is the letter closest to the base. + * Rendering reverses the array. + * - `suffixes` are letter levels rendered AFTER the base. Stored + * **shallowest-first** — `suffixes[0]` is the letter immediately after + * the base. + * + * Cases are encoded per-level. Uppercase = "AFTER" depth; lowercase = a + * wedge insertion that goes BEFORE its same-position uppercase sibling in + * production order. + * + * "1" → { base:1 } + * "1A" → { base:1, suffixes:[{1,U}] } + * "1B" → { base:1, suffixes:[{2,U}] } + * "1AA" → { base:1, suffixes:[{1,U},{1,U}] } ← sub-scene of 1A, between 1A and 1B + * "1aA" → { base:1, suffixes:[{1,L},{1,U}] } ← wedge between 1 and 1A + * "A2" → { base:2, prefixes:[{1,U}] } + * "AA2" → { base:2, prefixes:[{1,U},{1,U}] } ← deeper before A2, between 1 and A2 + * + * Production order + * ---------------- + * A total order on tokens: + * 1. Compare `baseNumber`. + * 2. Element-wise on `prefixes`. A SHORTER prefix array is LATER + * (longer prefix = deeper level = comes earlier in the doc). + * At each position, compare `value`, then case (lower < upper). + * 3. Element-wise on `suffixes`. A SHORTER suffix array is EARLIER + * (longer suffix = deeper sub-scene, comes after the parent). + * At each position, compare `value`, then case (lower < upper). + * + * Together these give: 1 < 1aA < 1A < 1AA < 1AB < 1B < 2. + */ + +import type { ProjectRepository } from "../project/project-repository"; + +// -------------------------------------------------------------------------- +// TYPES +// -------------------------------------------------------------------------- + +export type SceneLevel = { value: number; lower: boolean }; + +export type SceneToken = { + baseNumber: number; + prefixes: SceneLevel[]; + suffixes: SceneLevel[]; +}; + +/** Minimal shape needed by `computeSceneLabels`. Persistent scenes match it. */ +export type LockReadable = { + token?: SceneToken; + omitted?: boolean; +}; + +export type SceneLabelStatus = "locked" | "provisional" | "omitted"; + +export type SceneLabel = { + uuid: string; + /** Structural representation. Stable across style toggles when locked. */ + token: SceneToken; + /** Display string, derived from `token`. */ + label: string; + status: SceneLabelStatus; +}; + +export type SceneNumberingStyle = "suffix" | "prefix"; + +// -------------------------------------------------------------------------- +// ENCODING & DISPLAY +// -------------------------------------------------------------------------- + +/** Excel-style alphabetic letter: 1 → A, 26 → Z, 27 → AA, … */ +const letterFromValue = (n: number, lower: boolean): string => { + let out = ""; + const charBase = lower ? 97 : 65; + while (n > 0) { + const m = (n - 1) % 26; + out = String.fromCharCode(charBase + m) + out; + n = Math.floor((n - 1) / 26); + } + return out; +}; + +/** + * Render a token to its display string. Style-independent — the case of + * each level is taken directly from `level.lower`, so the result is the + * same regardless of the project's `SceneNumberingStyle` setting. + */ +export const compileSceneLabel = (token: SceneToken): string => { + // Prefixes are stored inner-first (closest to base at index 0). Render + // outer-to-inner, i.e. reverse before joining. + let out = ""; + for (let i = token.prefixes.length - 1; i >= 0; i--) { + const lvl = token.prefixes[i]; + out += letterFromValue(lvl.value, lvl.lower); + } + out += String(token.baseNumber); + for (let i = 0; i < token.suffixes.length; i++) { + const lvl = token.suffixes[i]; + out += letterFromValue(lvl.value, lvl.lower); + } + return out; +}; + +/** Total order on `SceneToken`. See file header for the rules. */ +export const compareTokens = (a: SceneToken, b: SceneToken): number => { + if (a.baseNumber !== b.baseNumber) return a.baseNumber - b.baseNumber; + + const pLen = Math.max(a.prefixes.length, b.prefixes.length); + for (let i = 0; i < pLen; i++) { + const ai = a.prefixes[i] as SceneLevel | undefined; + const bi = b.prefixes[i] as SceneLevel | undefined; + // For prefixes: longer = earlier ⇒ missing > defined. + if (ai === undefined) return 1; + if (bi === undefined) return -1; + if (ai.value !== bi.value) return ai.value - bi.value; + if (ai.lower !== bi.lower) return ai.lower ? -1 : 1; + } + + const sLen = Math.max(a.suffixes.length, b.suffixes.length); + for (let i = 0; i < sLen; i++) { + const ai = a.suffixes[i] as SceneLevel | undefined; + const bi = b.suffixes[i] as SceneLevel | undefined; + // For suffixes: longer = later ⇒ missing < defined. + if (ai === undefined) return -1; + if (bi === undefined) return 1; + if (ai.value !== bi.value) return ai.value - bi.value; + if (ai.lower !== bi.lower) return ai.lower ? -1 : 1; + } + + return 0; +}; + +// Convenience constructors. +const sceneLevel = (value: number, lower: boolean): SceneLevel => ({ value, lower }); + +/** Token for a bare integer scene number ("1", "2", …). */ +export const baseToken = (baseNumber: number): SceneToken => ({ + baseNumber, + prefixes: [], + suffixes: [], +}); + +const levelEq = (a: SceneLevel, b: SceneLevel): boolean => + a.value === b.value && a.lower === b.lower; + +// -------------------------------------------------------------------------- +// PROVISIONAL TOKEN COMPUTATION +// -------------------------------------------------------------------------- +// +// Each provisional scene gets a token derived from its immediate locked +// neighbours and its 1-based position within the segment (`k`). The rules +// preserve the existing user-visible behaviour: +// +// suffix mode, between locked 1 and 2: 1A, 1B, 1C, … +// suffix mode, between locked 1A and 1B (1 also locked): 1AA, 1AB, 1AC +// suffix mode, between locked 1 and 1A: 1aA, 1aB, 1aC +// prefix mode, between locked 1 and 2: A2, B2, C2 +// prefix mode, before locked 1: A1, B1, C1 +// prefix mode, between locked 1 and A2: AA2, BA2, CA2 +// +// Both modes are duals of one another and share the same three operations +// applied along a single "axis" (suffix or prefix): +// +// 1. CONTINUE: bump the deepest level of an anchor along the axis. +// 2. NEST: append a new uppercase level to an anchor along the axis. +// 3. WEDGE: walk the OTHER token's path along the axis looking for a +// point where a lowercase wedge level slots strictly between +// the two anchors. +// +// SUFFIX mode anchors on `prev` and grows rightward toward `next`; PREFIX +// mode anchors on `next` and grows leftward toward `prev`. Each candidate +// is verified strictly-between by `compareTokens` before being returned — +// if the chosen style's strategies all fall outside the range (as can +// happen with cross-axis anchors, e.g. prefix-mode insertion between plain +// "1" and suffix-bearing "1A"), we fall back to the dual style. + +type Axis = "suffix" | "prefix"; + +const levelsOf = (t: SceneToken, axis: Axis): SceneLevel[] => + axis === "suffix" ? t.suffixes : t.prefixes; + +const withLevels = (t: SceneToken, axis: Axis, levels: SceneLevel[]): SceneToken => + axis === "suffix" + ? { baseNumber: t.baseNumber, prefixes: t.prefixes, suffixes: levels } + : { baseNumber: t.baseNumber, prefixes: levels, suffixes: t.suffixes }; + +// Wedge convention per axis. lowercase < uppercase at the same value (see +// `compareTokens`). Suffix levels count UP, so 'a' (value 1) is the deepest +// wedge — decrementing past it means descending a level. Prefix levels are +// mirrored: 'z' (value 26) is the bound; incrementing past it descends. +const wedgeBound = (axis: Axis): number => (axis === "suffix" ? 1 : 26); +const wedgeStep = (axis: Axis): number => (axis === "suffix" ? -1 : 1); + +/** Bump the deepest level of `anchor` along `axis` by k. */ +const continueAlong = (anchor: SceneToken, k: number, axis: Axis): SceneToken | null => { + const path = levelsOf(anchor, axis); + if (path.length === 0) { + // Suffix axis can fall through to bumping the base. Prefix axis + // has nothing to continue when there's no outermost prefix. + if (axis === "suffix") { + return { baseNumber: anchor.baseNumber + k, prefixes: anchor.prefixes, suffixes: [] }; + } + return null; + } + const last = path[path.length - 1]; + const newPath = path.slice(0, -1).concat([sceneLevel(last.value + k, last.lower)]); + return withLevels(anchor, axis, newPath); +}; + +/** Append a fresh uppercase level (value k) to anchor's path along axis. */ +const nestAlong = (anchor: SceneToken, k: number, axis: Axis): SceneToken => + withLevels(anchor, axis, [...levelsOf(anchor, axis), sceneLevel(k, false)]); + +/** + * Walk `target`'s path (skipping any shared prefix with `from`) and slot in + * a lowercase wedge level just before its first divergent uppercase level. + * Returns a token whose label sorts strictly between `from` and `target`, + * or null if `target`'s path doesn't extend past the shared prefix (caller + * needs a different strategy). + * + * suffix axis: `from` = prev, `target` = next. Wedge bound is 'a'. + * prefix axis: `from` = next, `target` = prev. Wedge bound is 'z'. + */ +const wedgeAlong = ( + from: SceneToken, + target: SceneToken, + k: number, + axis: Axis, +): SceneToken | null => { + const fromLevels = levelsOf(from, axis); + const targetLevels = levelsOf(target, axis); + const bound = wedgeBound(axis); + const step = wedgeStep(axis); + + let i = 0; + while ( + i < fromLevels.length && + i < targetLevels.length && + levelEq(fromLevels[i], targetLevels[i]) + ) { + i++; + } + + if (i >= targetLevels.length) return null; + + const levels = targetLevels.slice(0, i); + while (i < targetLevels.length) { + const div = targetLevels[i]; + if (div.lower && div.value === bound) { + // Already a wedge at this level — descend one deeper. + levels.push(div); + i++; + continue; + } + // We can wedge here. Decrement an existing lowercase level (e.g. + // suffix 'b' → 'a') or convert an uppercase to its lowercase + // wedge equivalent ('A' → 'a'). + const wedgeValue = div.lower ? div.value + step : div.value; + levels.push(sceneLevel(wedgeValue, true)); + return withLevels(target, axis, [...levels, sceneLevel(k, false)]); + } + + // All of target's diverging levels were already at the wedge bound — + // append one more wedge level to land strictly below them. + levels.push(sceneLevel(bound, true)); + return withLevels(target, axis, [...levels, sceneLevel(k, false)]); +}; + +const isStrictlyBetween = ( + prev: SceneToken | null, + next: SceneToken | null, + cand: SceneToken, +): boolean => { + if (prev && compareTokens(prev, cand) >= 0) return false; + if (next && compareTokens(cand, next) >= 0) return false; + return true; +}; + +const computeProvisionalToken = ( + prev: SceneToken | null, + next: SceneToken | null, + k: number, + style: SceneNumberingStyle, +): SceneToken => { + if (!prev && !next) return baseToken(k); + + const pick = (cands: Array): SceneToken | null => { + for (const c of cands) if (c && isStrictlyBetween(prev, next, c)) return c; + return null; + }; + + // Suffix-style candidates grow rightward from prev. + const suffixCandidates = (): Array => { + if (prev) { + return [ + continueAlong(prev, k, "suffix"), + nestAlong(prev, k, "suffix"), + next ? wedgeAlong(prev, next, k, "suffix") : null, + ]; + } + // No prev — nothing to grow from on the suffix axis. The natural + // dual is to nest leftward into next. + return next ? [nestAlong(next, k, "prefix")] : []; + }; + + // Prefix-style candidates grow leftward from next. + const prefixCandidates = (): Array => { + if (next) { + return [ + prev ? continueAlong(prev, k, "prefix") : null, + nestAlong(next, k, "prefix"), + prev ? wedgeAlong(next, prev, k, "prefix") : null, + ]; + } + return prev ? [continueAlong(prev, k, "suffix")] : []; + }; + + const primary = style === "suffix" ? pick(suffixCandidates()) : pick(prefixCandidates()); + if (primary) return primary; + + // Cross-style fallback: anchors don't line up along the requested + // axis (e.g. prefix mode trying to fit something between bases 1 and + // 1A — there's no valid prefix-only token there). + const fallback = style === "suffix" ? pick(prefixCandidates()) : pick(suffixCandidates()); + if (fallback) return fallback; + + // Pathological input (prev >= next). Should never happen for a valid + // scene sequence, but emit a deterministic token rather than throwing. + if (prev) return nestAlong(prev, k, "suffix"); + if (next) return nestAlong(next, k, "prefix"); + return baseToken(k); +}; + +// -------------------------------------------------------------------------- +// MAIN API +// -------------------------------------------------------------------------- + +/** + * Compute display labels (and structural tokens) for an ordered list of + * scene UUIDs. Locked scenes get their persisted token; provisional ones + * get a token computed from their segment's immediate neighbours. + * + * O(N) — two linear passes precompute prev/next/segment-index, one final + * pass emits the result. + */ +export const computeSceneLabels = ( + sceneUuids: string[], + persistent: Record, + style: SceneNumberingStyle = "suffix", +): SceneLabel[] => { + const n = sceneUuids.length; + const result: SceneLabel[] = new Array(n); + + const prevLocked: (SceneToken | null)[] = new Array(n); + const nextLocked: (SceneToken | null)[] = new Array(n); + const segmentIdx: number[] = new Array(n); + + let lastToken: SceneToken | null = null; + let runCount = 0; + for (let i = 0; i < n; i++) { + const entry = persistent[sceneUuids[i]]; + if (entry?.token) { + prevLocked[i] = lastToken; + segmentIdx[i] = 0; + lastToken = entry.token; + runCount = 0; + } else { + prevLocked[i] = lastToken; + runCount++; + segmentIdx[i] = runCount; + } + } + + let upcomingToken: SceneToken | null = null; + for (let i = n - 1; i >= 0; i--) { + const entry = persistent[sceneUuids[i]]; + if (entry?.token) { + nextLocked[i] = upcomingToken; + upcomingToken = entry.token; + } else { + nextLocked[i] = upcomingToken; + } + } + + for (let i = 0; i < n; i++) { + const uuid = sceneUuids[i]; + const entry = persistent[uuid]; + + if (entry?.token) { + result[i] = { + uuid, + token: entry.token, + label: compileSceneLabel(entry.token), + status: entry.omitted ? "omitted" : "locked", + }; + continue; + } + + const token = computeProvisionalToken( + prevLocked[i], + nextLocked[i], + segmentIdx[i], + style, + ); + result[i] = { + uuid, + token, + label: compileSceneLabel(token), + status: "provisional", + }; + } + + return result; +}; + +// -------------------------------------------------------------------------- +// ACTIONS +// -------------------------------------------------------------------------- + +/** + * Mark a scene as OMITTED. The scene's heading text and body content are + * preserved in the document; the editor overlays "OMITTED" and hides the + * underlying content via decorations so the original screenplay survives an + * unomit. Works regardless of production lock state. + */ +export const omitSceneByUuid = (repository: ProjectRepository, uuid: string): void => { + repository.upsertScene(uuid, { omitted: true }); +}; + +/** Clear an OMITTED scene's `omitted` flag, restoring the heading + body. */ +export const unomitSceneByUuid = (repository: ProjectRepository, uuid: string): void => { + const scene = repository.getScene(uuid); + if (!scene?.omitted) return; + repository.upsertScene(uuid, { omitted: undefined }); +}; diff --git a/src/lib/screenplay/scenes.ts b/src/lib/screenplay/scenes.ts index d39e6186..c89fa974 100644 --- a/src/lib/screenplay/scenes.ts +++ b/src/lib/screenplay/scenes.ts @@ -22,6 +22,8 @@ import { getNodeData } from "./screenplay"; import { ScreenplayElement } from "../utils/enums"; import { Screenplay } from "../utils/types"; import { JSONContent } from "@tiptap/react"; +import type { SceneToken } from "./scene-locking"; +import { compileSceneLabel } from "./scene-locking"; /** * Recursively compute the ProseMirror nodeSize of a JSONContent node. @@ -53,12 +55,21 @@ export type TransientScene = { /** * Persistent scene metadata stored in Yjs. - * Only contains user-editable fields. * Keyed by scene id (UUID) in the Yjs map. + * + * Contains both user-editable fields (synopsis, color) and production-mode + * fields (token, omitted). `token` is the structural, mode-independent + * representation of the scene's frozen number under production lock; the + * display label is derived from it via `compileSceneLabel`. `omitted` + * flags the scene as an OMITTED placeholder. */ export type PersistentScene = { synopsis?: string; color?: string; + /** Frozen structural position under production lock. */ + token?: SceneToken; + /** True when the scene is an OMITTED placeholder (only meaningful with `token`). */ + omitted?: boolean; }; /** @@ -69,10 +80,19 @@ export type PersistentSceneMap = { [id: string]: PersistentScene }; /** * Full scene data combining transient and persistent data. * This is what gets exposed to the UI. + * + * `token` is the structural lock (when persisted); `label` is the derived + * display string (compiled from the token). Both are absent for scenes + * that have not been locked. UI code that needs *provisional* labels + * should call `computeSceneLabels()` over the full ordered scene list + * instead of reading `Scene.label` directly. */ export type Scene = TransientScene & { synopsis?: string; color?: string; + token?: SceneToken; + label?: string; + omitted?: boolean; }; // -------------------------------- // @@ -172,6 +192,9 @@ export const mergeScenesData = (persistentScenes: PersistentSceneMap, screenplay ...item, synopsis: persistent.synopsis, color: persistent.color, + token: persistent.token, + label: persistent.token ? compileSceneLabel(persistent.token) : undefined, + omitted: persistent.omitted, }; } diff --git a/src/lib/shelf/shelf-editor-config.ts b/src/lib/shelf/shelf-editor-config.ts index 7af1c86d..a4e23b99 100644 --- a/src/lib/shelf/shelf-editor-config.ts +++ b/src/lib/shelf/shelf-editor-config.ts @@ -13,6 +13,7 @@ export function createShelfEditorConfig(nodeId: string, versionId: string): Docu characterHighlights: false, searchHighlights: false, sceneBookmarks: false, + sceneLocking: false, nodeIdDedup: true, suggestions: false, orphanPrevention: false, diff --git a/styles/scriptio.css b/styles/scriptio.css index a6f359ab..82ed8f4d 100644 --- a/styles/scriptio.css +++ b/styles/scriptio.css @@ -185,6 +185,65 @@ display: none; } + /* Production lock: suppress CSS counter pseudo-elements — labels come + from widget decorations (scene-locking-extension) so we can render + suffixed / OMITTED labels that CSS counters can't express. */ + &.production-locked .scene::before, + &.production-locked .scene::after { + display: none; + } + + /* Widget decoration for the left scene label (mirrors the ::before slot). + text-transform: none !important is required to defeat the parent + .scene's text-transform: uppercase (matching the same pattern used by + .collab-caret-name above) so lowercase suffix markers like "3aA" + render with their original case. */ + .scene-label-left { + position: absolute; + right: 100%; + margin-right: -120px; + user-select: none; + pointer-events: none; + text-transform: none !important; + } + + /* Widget decoration for the right scene label (mirrors the ::after slot). */ + .scene-label-right { + position: absolute; + top: 0; + left: 100%; + margin-left: -85px; + user-select: none; + pointer-events: none; + display: none; + text-transform: none !important; + } + &.scene-number-right .scene-label-right { + display: block; + } + &.hide-scene-numbers .scene-label-left, + &.hide-scene-numbers .scene-label-right { + display: none; + } + + /* OMITTED scene placeholder. The heading text and body paragraphs are + preserved in the document so the user can restore them; we just hide + them visually and overlay "OMITTED" in place of the heading. */ + .scene[data-omitted-overlay="true"] { + color: var(--secondary-text); + } + .scene-heading-omitted-text { + display: none; + } + [data-omitted-body="true"] { + display: none; + } + .scene-omitted-overlay { + user-select: none; + pointer-events: none; + font-style: normal; + } + /* Normal weight scene headings (when bold disabled) */ &.scene-heading-normal .scene { font-weight: normal; From e57147de5940659e901cb4650cbda19e0898fcde Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 23 May 2026 13:47:21 +0200 Subject: [PATCH 3/6] optimized scene locking --- .../screenplay/extensions/scene-locking-extension.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/lib/screenplay/extensions/scene-locking-extension.ts b/src/lib/screenplay/extensions/scene-locking-extension.ts index 9de747ef..ea7fd1ec 100644 --- a/src/lib/screenplay/extensions/scene-locking-extension.ts +++ b/src/lib/screenplay/extensions/scene-locking-extension.ts @@ -78,12 +78,23 @@ const buildOmittedWidget = (): HTMLElement => { return span; }; +const hasAnyOmitted = (scenes: Record): boolean => { + for (const key in scenes) { + if (scenes[key]?.omitted) return true; + } + return false; +}; + const computeDecorations = ( doc: Node, locking: boolean, scenes: Record, style: SceneNumberingStyle, ): DecorationSet => { + // Nothing to render: no production lock and no omitted scenes. Skip the + // doc traversal entirely — this is the common case for most users. + if (!locking && !hasAnyOmitted(scenes)) return DecorationSet.empty; + const entries = collectSceneEntries(doc); if (entries.length === 0) return DecorationSet.empty; From 68576fec7571068279a4046e157edf326dda8381 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sat, 23 May 2026 15:23:52 +0200 Subject: [PATCH 4/6] moved production settings in its own yjs entry, added letter skipping to scene numbering, tweaked styling of production settings --- .../project/ProductionSettings.module.css | 66 +++++------- .../dashboard/project/ProductionSettings.tsx | 101 +++++++++++++++--- .../sidebar/EditorSidebarNavigation.tsx | 19 +++- components/navbar/ProductionPanel.tsx | 28 ++++- messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/ja.json | 8 +- messages/ko.json | 8 +- messages/pl.json | 8 +- messages/zh.json | 8 +- src/context/ProjectContext.tsx | 52 +++++++-- src/lib/adapters/pdf/pdf-adapter.ts | 66 ++++-------- src/lib/adapters/scriptio/scriptio-adapter.ts | 5 +- src/lib/editor/use-document-editor.ts | 6 +- src/lib/project/project-doc.ts | 26 +++++ src/lib/project/project-repository.ts | 25 ++++- src/lib/project/project-state.ts | 3 + .../extensions/scene-locking-extension.ts | 8 +- src/lib/screenplay/scene-locking.ts | 60 ++++++++--- 21 files changed, 359 insertions(+), 170 deletions(-) diff --git a/components/dashboard/project/ProductionSettings.module.css b/components/dashboard/project/ProductionSettings.module.css index 6ee695e7..f0669ec1 100644 --- a/components/dashboard/project/ProductionSettings.module.css +++ b/components/dashboard/project/ProductionSettings.module.css @@ -5,52 +5,44 @@ margin-top: 10px; } -.styleOption { - display: flex; - flex-direction: row; - align-items: baseline; - justify-content: center; - gap: 8px; - padding: 8px 12px; - border: 1px solid var(--separator); - border-radius: 6px; - background: var(--main-bg); - color: var(--primary-text); - cursor: pointer; - transition: border-color 0.15s ease, background 0.15s ease; +.styleName { + font-size: 0.7rem; + color: var(--secondary-text); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; } -.styleOption:hover:not(:disabled) { - border-color: var(--primary-text); +.styleExample { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + font-family: var(--font-screenplay); + font-size: 0.9rem; + font-weight: 600; + color: var(--primary-text); } -.styleOption:disabled { +.arrowIcon { opacity: 0.5; - cursor: not-allowed; } -.styleOptionActive { - border-color: var(--primary-text); - background: color-mix(in srgb, var(--primary-text) 8%, transparent); -} - -.styleExample { - font-family: var(--font-screenplay); - font-size: 0.85rem; - font-weight: 600; +.letterToggles { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; + margin-top: 10px; } -.styleName { - font-size: 0.7rem; - color: var(--secondary-text); - text-transform: uppercase; - letter-spacing: 0.04em; +.letterCard { + justify-content: center; + padding: 12px; } -.note { - margin-top: 16px; - font-size: 0.78rem; - color: var(--secondary-text); - font-style: italic; - line-height: 1.4; +.letter { + font-family: var(--font-screenplay); + font-size: 1rem; + font-weight: 600; + color: var(--primary-text); } diff --git a/components/dashboard/project/ProductionSettings.tsx b/components/dashboard/project/ProductionSettings.tsx index 4620bbce..e585576f 100644 --- a/components/dashboard/project/ProductionSettings.tsx +++ b/components/dashboard/project/ProductionSettings.tsx @@ -2,16 +2,39 @@ import { useContext } from "react"; import { useTranslations } from "next-intl"; +import { ArrowRight } from "lucide-react"; import { ProjectContext } from "@src/context/ProjectContext"; +import { TOGGLEABLE_SCENE_LETTERS } from "@src/lib/project/project-state"; import form from "./../../utils/Form.module.css"; import sharedStyles from "./ProjectSettings.module.css"; +import optionCard from "./OptionCard.module.css"; import styles from "./ProductionSettings.module.css"; +const Arrow = () => ; + const ProductionSettings = () => { const t = useTranslations("production"); - const { sceneNumberingStyle, setSceneNumberingStyle, isReadOnly } = - useContext(ProjectContext); + const { + sceneNumberingStyle, + setSceneNumberingStyle, + skippedSceneLetters, + setSkippedSceneLetters, + isReadOnly, + } = useContext(ProjectContext); + + const isLetterSkipped = (letter: string) => + skippedSceneLetters.includes(letter.toUpperCase()); + + const toggleLetter = (letter: string) => { + if (isReadOnly) return; + const upper = letter.toUpperCase(); + const next = isLetterSkipped(upper) + ? skippedSceneLetters.filter((l) => l.toUpperCase() !== upper) + : [...skippedSceneLetters, upper]; + next.sort(); + setSkippedSceneLetters(next); + }; return (
@@ -20,27 +43,71 @@ const ProductionSettings = () => {

{t("numberingStyleHelp")}

- -
+
!isReadOnly && setSceneNumberingStyle("prefix")} + role="radio" + aria-checked={sceneNumberingStyle === "prefix"} + aria-label={t("prefixName")} > - {t("prefixExample")} +
+ {sceneNumberingStyle === "prefix" &&
} +
{t("prefixName")} - + + 1 + + A2 + + 2 + +
+
-

{t("appliesToNewOnly")}

+
+ +

{t("skippedLettersHelp")}

+ +
+ {TOGGLEABLE_SCENE_LETTERS.map((letter) => { + const active = isLetterSkipped(letter); + return ( +
toggleLetter(letter)} + role="button" + aria-pressed={active} + aria-label={t("skipLetterAriaLabel", { letter })} + > +
+ {active &&
} +
+ {letter} +
+ ); + })} +
); diff --git a/components/editor/sidebar/EditorSidebarNavigation.tsx b/components/editor/sidebar/EditorSidebarNavigation.tsx index 4461fb37..dae8d2db 100644 --- a/components/editor/sidebar/EditorSidebarNavigation.tsx +++ b/components/editor/sidebar/EditorSidebarNavigation.tsx @@ -18,7 +18,15 @@ import sidebar_nav from "./EditorSidebarNavigation.module.css"; const EditorSidebarNavigation = () => { const t = useTranslations("editorSidebar"); - const { scenes, updateScenes, editor, sceneLocking, sceneNumberingStyle, persistentScenes } = useContext(ProjectContext); + const { + scenes, + updateScenes, + editor, + sceneLocking, + sceneNumberingStyle, + skippedSceneLetters, + persistentScenes, + } = useContext(ProjectContext); const { leftSidebarOpen } = useViewContext(); const [activeTab, setActiveTab] = useState<"scenes" | "shelf" | "comments">("scenes"); @@ -38,14 +46,19 @@ const EditorSidebarNavigation = () => { const sceneDisplays = useMemo(() => { if (sceneLocking) { const uuids = scenes.map((s) => s.id ?? ""); - const labels = computeSceneLabels(uuids, persistentScenes, sceneNumberingStyle); + const labels = computeSceneLabels( + uuids, + persistentScenes, + sceneNumberingStyle, + skippedSceneLetters, + ); return scenes.map((_, i) => ({ label: labels[i]?.label ?? `${i + 1}`, isOmitted: labels[i]?.status === "omitted", })); } return scenes.map((_, i) => ({ label: `${i + 1}`, isOmitted: false })); - }, [scenes, sceneLocking, sceneNumberingStyle, persistentScenes]); + }, [scenes, sceneLocking, sceneNumberingStyle, skippedSceneLetters, persistentScenes]); const listRef = useRef(null); const currentSceneRef = useRef(null); diff --git a/components/navbar/ProductionPanel.tsx b/components/navbar/ProductionPanel.tsx index 580487b0..a334f3b8 100644 --- a/components/navbar/ProductionPanel.tsx +++ b/components/navbar/ProductionPanel.tsx @@ -32,8 +32,15 @@ const REVISION_COLORS = [ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { const t = useTranslations("production"); - const { sceneLocking, sceneNumberingStyle, persistentScenes, scenes, repository, isReadOnly } = - useContext(ProjectContext); + const { + sceneLocking, + sceneNumberingStyle, + skippedSceneLetters, + persistentScenes, + scenes, + repository, + isReadOnly, + } = useContext(ProjectContext); const userCtx = useContext(UserContext); const panelRef = useRef(null); @@ -58,9 +65,14 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { const labels = useMemo( () => sceneLocking - ? computeSceneLabels(sceneUuids, persistentScenes, sceneNumberingStyle) + ? computeSceneLabels( + sceneUuids, + persistentScenes, + sceneNumberingStyle, + skippedSceneLetters, + ) : [], - [sceneLocking, sceneUuids, persistentScenes, sceneNumberingStyle], + [sceneLocking, sceneUuids, persistentScenes, sceneNumberingStyle, skippedSceneLetters], ); const provisionalLabels = useMemo( @@ -93,7 +105,12 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { // tokens, this falls through to baseToken(idx+1) for every // scene, matching the previous behaviour. const persistentSnapshot = repository.scenes; - const labels = computeSceneLabels(uuids, persistentSnapshot, sceneNumberingStyle); + const labels = computeSceneLabels( + uuids, + persistentSnapshot, + sceneNumberingStyle, + skippedSceneLetters, + ); labels.forEach((label) => { if (label.status === "provisional") { @@ -122,6 +139,7 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { uuids, persistentSnapshot, sceneNumberingStyle, + skippedSceneLetters, ); console.log("[ProductionPanel] RELOCKING PROVISIONAL. Full snapshot:", currentLabels.map(l => ({ diff --git a/messages/de.json b/messages/de.json index cc8993df..0302bcda 100644 --- a/messages/de.json +++ b/messages/de.json @@ -462,12 +462,12 @@ "unlock": "Entsperren", "cancel": "Abbrechen", "numberingStyleTitle": "Szenennummerierung", - "numberingStyleHelp": "Wie neu eingefügte Szenen zwischen zwei gesperrten Szenen nummeriert werden. Suffix verweist auf die vorherige Szene, Präfix auf die nächste.", + "numberingStyleHelp": "Bestimmt, wie neu eingefügte Szenen zwischen zwei gesperrten Szenen nummeriert werden.", "suffixName": "Suffix", "prefixName": "Präfix", - "suffixExample": "2 → 2A", - "prefixExample": "2 → A3", - "appliesToNewOnly": "Bereits gesperrte Szenen behalten ihre Nummer — nur vorläufige Szenen und zukünftige Sperrungen sind betroffen." + "skippedLettersTitle": "Übersprungene Buchstaben", + "skippedLettersHelp": "Buchstaben, die bei der Szenennummerierung weggelassen werden, meist um Verwechslungen zu vermeiden.", + "skipLetterAriaLabel": "Buchstabe {letter} überspringen" }, "saves": { "title": "Versionsverlauf", diff --git a/messages/en.json b/messages/en.json index 3c775230..81b9fb7e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -461,12 +461,12 @@ "unlock": "Unlock", "cancel": "Cancel", "numberingStyleTitle": "Scene numbering", - "numberingStyleHelp": "How newly inserted scenes are numbered between two locked scenes. Suffix references the previous scene, prefix references the next.", + "numberingStyleHelp": "Dictates how newly inserted scenes should be numbered between two locked scenes.", "suffixName": "Suffix", "prefixName": "Prefix", - "suffixExample": "2 → 2A", - "prefixExample": "2 → A3", - "appliesToNewOnly": "Already-locked scenes keep their stored number — only provisional scenes and future locks are affected." + "skippedLettersTitle": "Skipped letters", + "skippedLettersHelp": "Letters to omit when generating scene numbers usually to avoid confusion.", + "skipLetterAriaLabel": "Skip letter {letter}" }, "saves": { "title": "Version History", diff --git a/messages/es.json b/messages/es.json index d809cea1..365185cb 100644 --- a/messages/es.json +++ b/messages/es.json @@ -461,12 +461,12 @@ "unlock": "Desbloquear", "cancel": "Cancelar", "numberingStyleTitle": "Numeración de escenas", - "numberingStyleHelp": "Cómo se numeran las escenas recién insertadas entre dos escenas bloqueadas. El sufijo se refiere a la escena anterior, el prefijo a la siguiente.", + "numberingStyleHelp": "Determina cómo se numeran las escenas recién insertadas entre dos escenas bloqueadas.", "suffixName": "Sufijo", "prefixName": "Prefijo", - "suffixExample": "2 → 2A", - "prefixExample": "2 → A3", - "appliesToNewOnly": "Las escenas ya bloqueadas conservan su número — solo se ven afectadas las escenas provisionales y los futuros bloqueos." + "skippedLettersTitle": "Letras omitidas", + "skippedLettersHelp": "Letras que se omiten al generar los números de escena, generalmente para evitar confusiones.", + "skipLetterAriaLabel": "Omitir la letra {letter}" }, "saves": { "title": "Historial de versiones", diff --git a/messages/fr.json b/messages/fr.json index 88569350..0e2b96b5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -462,12 +462,12 @@ "unlock": "Déverrouiller", "cancel": "Annuler", "numberingStyleTitle": "Numérotation des scènes", - "numberingStyleHelp": "Comment les scènes nouvellement insérées sont numérotées entre deux scènes verrouillées. Le suffixe se réfère à la scène précédente, le préfixe à la suivante.", + "numberingStyleHelp": "Détermine comment les scènes nouvellement insérées sont numérotées entre deux scènes verrouillées.", "suffixName": "Suffixe", "prefixName": "Préfixe", - "suffixExample": "2 → 2A", - "prefixExample": "2 → A3", - "appliesToNewOnly": "Les scènes déjà verrouillées conservent leur numéro — seules les scènes provisoires et les futurs verrouillages sont concernés." + "skippedLettersTitle": "Lettres ignorées", + "skippedLettersHelp": "Lettres à omettre lors de la génération des numéros de scène, généralement pour éviter toute confusion.", + "skipLetterAriaLabel": "Ignorer la lettre {letter}" }, "saves": { "title": "Historique des versions", diff --git a/messages/ja.json b/messages/ja.json index 2a981d11..5c473138 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -461,12 +461,12 @@ "unlock": "ロック解除", "cancel": "キャンセル", "numberingStyleTitle": "シーン番号", - "numberingStyleHelp": "ロック済みの2つのシーンの間に挿入された新しいシーンの番号付け方法。サフィックスは前のシーン、プレフィックスは次のシーンを参照します。", + "numberingStyleHelp": "ロック済みの2つのシーンの間に挿入された新しいシーンの番号付け方法を決定します。", "suffixName": "サフィックス", "prefixName": "プレフィックス", - "suffixExample": "2 → 2A", - "prefixExample": "2 → A3", - "appliesToNewOnly": "すでにロックされているシーンは番号を保持します — 仮シーンと今後のロックのみが影響を受けます。" + "skippedLettersTitle": "スキップする文字", + "skippedLettersHelp": "シーン番号生成時に省略する文字。通常は混同を避けるために使用します。", + "skipLetterAriaLabel": "{letter} をスキップ" }, "saves": { "title": "バージョン履歴", diff --git a/messages/ko.json b/messages/ko.json index 93fbb859..17d8160e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -461,12 +461,12 @@ "unlock": "잠금 해제", "cancel": "취소", "numberingStyleTitle": "씬 번호", - "numberingStyleHelp": "잠긴 두 씬 사이에 새로 삽입된 씬의 번호 매김 방식. 접미사는 이전 씬을, 접두사는 다음 씬을 참조합니다.", + "numberingStyleHelp": "잠긴 두 씬 사이에 새로 삽입된 씬의 번호 매김 방식을 결정합니다.", "suffixName": "접미사", "prefixName": "접두사", - "suffixExample": "2 → 2A", - "prefixExample": "2 → A3", - "appliesToNewOnly": "이미 잠긴 씬은 번호를 유지합니다 — 임시 씬과 향후 잠금에만 영향을 미칩니다." + "skippedLettersTitle": "건너뛸 문자", + "skippedLettersHelp": "씬 번호를 생성할 때 제외할 문자입니다. 보통 혼동을 피하기 위해 사용합니다.", + "skipLetterAriaLabel": "{letter} 문자 건너뛰기" }, "saves": { "title": "버전 히스토리", diff --git a/messages/pl.json b/messages/pl.json index e21382c1..bf9f2ea0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -461,12 +461,12 @@ "unlock": "Odblokuj", "cancel": "Anuluj", "numberingStyleTitle": "Numeracja scen", - "numberingStyleHelp": "Jak są numerowane nowo wstawione sceny między dwiema zablokowanymi. Sufiks odnosi się do poprzedniej sceny, prefiks do następnej.", + "numberingStyleHelp": "Określa, jak numerowane są nowo wstawione sceny między dwiema zablokowanymi scenami.", "suffixName": "Sufiks", "prefixName": "Prefiks", - "suffixExample": "2 → 2A", - "prefixExample": "2 → A3", - "appliesToNewOnly": "Już zablokowane sceny zachowują swój numer — wpływa tylko na sceny tymczasowe i przyszłe blokady." + "skippedLettersTitle": "Pomijane litery", + "skippedLettersHelp": "Litery pomijane podczas generowania numerów scen, zwykle aby uniknąć pomyłek.", + "skipLetterAriaLabel": "Pomiń literę {letter}" }, "saves": { "title": "Historia wersji", diff --git a/messages/zh.json b/messages/zh.json index 09623192..fec25d70 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -461,12 +461,12 @@ "unlock": "解锁", "cancel": "取消", "numberingStyleTitle": "场景编号", - "numberingStyleHelp": "两个锁定场景之间新插入场景的编号方式。后缀参考上一个场景,前缀参考下一个场景。", + "numberingStyleHelp": "决定两个锁定场景之间新插入场景的编号方式。", "suffixName": "后缀", "prefixName": "前缀", - "suffixExample": "2 → 2A", - "prefixExample": "2 → A3", - "appliesToNewOnly": "已锁定的场景保留其编号 — 仅影响临时场景和将来的锁定。" + "skippedLettersTitle": "跳过的字母", + "skippedLettersHelp": "生成场景编号时省略的字母,通常用于避免混淆。", + "skipLetterAriaLabel": "跳过字母 {letter}" }, "saves": { "title": "版本历史", diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index e30c5a05..9fd6b465 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -20,10 +20,12 @@ import { CollaboratorInfo, ConnectionStatus, LayoutData, + ProductionData, useProjectYjs, ElementStyle, PageMargin, DEFAULT_PAGE_MARGINS, + DEFAULT_SKIPPED_SCENE_LETTERS, ShelfEntry, ProjectStatus, } from "@src/lib/project/project-state"; @@ -102,6 +104,8 @@ export interface ProjectContextType { setSceneLocking: (locked: boolean) => void; sceneNumberingStyle: "suffix" | "prefix"; setSceneNumberingStyle: (style: "suffix" | "prefix") => void; + skippedSceneLetters: string[]; + setSkippedSceneLetters: (letters: string[]) => void; /** Raw persistent scene map (UUID → PersistentScene). Includes synopsis, * color, and production-lock fields (token, omitted) for every scene that * has been persisted. */ @@ -187,6 +191,8 @@ const defaultContextValue: ProjectContextType = { setSceneLocking: () => {}, sceneNumberingStyle: "suffix", setSceneNumberingStyle: () => {}, + skippedSceneLetters: DEFAULT_SKIPPED_SCENE_LETTERS, + setSkippedSceneLetters: () => {}, persistentScenes: {}, characters: {}, locations: {}, @@ -310,6 +316,8 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = const [sceneLocking, setSceneLockingState] = useState(false); const [sceneNumberingStyle, setSceneNumberingStyleState] = useState<"suffix" | "prefix">("suffix"); + const [skippedSceneLetters, setSkippedSceneLettersState] = + useState(DEFAULT_SKIPPED_SCENE_LETTERS); const [persistentScenes, setPersistentScenesState] = useState({}); const [connectionStatus, setConnectionStatus] = useState("disconnected"); const [users, setUsers] = useState([]); @@ -488,11 +496,19 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = if (initialLayout.elementStyles !== undefined) { setElementStylesState(initialLayout.elementStyles); } - if (initialLayout.sceneLocking !== undefined) { - setSceneLockingState(initialLayout.sceneLocking); + } + + // Read initial production data (separate Y.Map from layout). + const initialProduction = repository.getProduction(); + if (initialProduction) { + if (initialProduction.sceneLocking !== undefined) { + setSceneLockingState(initialProduction.sceneLocking); + } + if (initialProduction.sceneNumberingStyle !== undefined) { + setSceneNumberingStyleState(initialProduction.sceneNumberingStyle); } - if (initialLayout.sceneNumberingStyle !== undefined) { - setSceneNumberingStyleState(initialLayout.sceneNumberingStyle); + if (initialProduction.skippedSceneLetters !== undefined) { + setSkippedSceneLettersState(initialProduction.skippedSceneLetters); } } @@ -537,11 +553,18 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = if (layout.elementStyles !== undefined) { setElementStylesState(layout.elementStyles); } - if (layout.sceneLocking !== undefined) { - setSceneLockingState(layout.sceneLocking); + }); + + // Observe production changes + const unsubscribeProduction = repository.observeProduction((production: Partial) => { + if (production.sceneLocking !== undefined) { + setSceneLockingState(production.sceneLocking); + } + if (production.sceneNumberingStyle !== undefined) { + setSceneNumberingStyleState(production.sceneNumberingStyle); } - if (layout.sceneNumberingStyle !== undefined) { - setSceneNumberingStyleState(layout.sceneNumberingStyle); + if (production.skippedSceneLetters !== undefined) { + setSkippedSceneLettersState(production.skippedSceneLetters); } }); @@ -586,6 +609,7 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = return () => { repository.unregisterScreenplayCallback(recomputeFromScreenplay); unsubscribeLayout(); + unsubscribeProduction(); unsubscribeCharacters(); unsubscribeLocations(); unsubscribeScenes(); @@ -758,6 +782,14 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = [repository], ); + const setSkippedSceneLetters = useCallback( + (letters: string[]) => { + setSkippedSceneLettersState(letters); + repository?.setSkippedSceneLetters(letters); + }, + [repository], + ); + const setSearchTerm = useCallback((term: string) => { setSearchTermState(term); // Reset to first match when search term changes @@ -846,6 +878,8 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = setSceneLocking, sceneNumberingStyle, setSceneNumberingStyle, + skippedSceneLetters, + setSkippedSceneLetters, persistentScenes, screenplay, scenes, @@ -915,6 +949,8 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = setSceneLocking, sceneNumberingStyle, setSceneNumberingStyle, + skippedSceneLetters, + setSkippedSceneLetters, persistentScenes, screenplay, scenes, diff --git a/src/lib/adapters/pdf/pdf-adapter.ts b/src/lib/adapters/pdf/pdf-adapter.ts index 9b23880c..245e84e5 100644 --- a/src/lib/adapters/pdf/pdf-adapter.ts +++ b/src/lib/adapters/pdf/pdf-adapter.ts @@ -1,13 +1,11 @@ import { BaseExportOptions, ProjectAdapter } from "../screenplay-adapter"; import { ProjectData, ProjectState } from "@src/lib/project/project-state"; -import type { PersistentSceneMap } from "@src/lib/screenplay/scenes"; import { PageFormat } from "@src/lib/utils/enums"; import { getFontForCodePoint, ScriptFont } from "./pdf-utils"; import type { TextRun } from "./pdf.worker"; import { BASE_URL } from "@src/lib/utils/constants"; import { PAGE_SIZES } from "@src/lib/screenplay/extensions/pagination-extension"; -import { computeSceneLabels } from "@src/lib/screenplay/scene-locking"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -23,10 +21,6 @@ export type PDFExportOptions = BaseExportOptions & { moreLabel?: string; editorElement?: HTMLElement; titlePageElement?: HTMLElement; - /** Production-mode flag + persistent scene entries, set internally from the ProjectState. */ - sceneLocking?: boolean; - sceneNumberingStyle?: "suffix" | "prefix"; - persistentScenes?: PersistentSceneMap; }; import type { WorkerMessage, WorkerPayload, VisualLine } from "./pdf.worker"; @@ -71,34 +65,25 @@ export class PDFAdapter extends ProjectAdapter { label = "PDF"; extension = "pdf"; - async convertTo(project: ProjectState, options: PDFExportOptions): Promise { + async convertTo(_project: ProjectState, options: PDFExportOptions): Promise { const editorEl = options.editorElement; if (!editorEl) throw new Error("Editor element is required for DOM-based PDF export"); const format = options.format; const pdfPageSize = PDF_PAGE_SIZES[format]; - // Resolve production state from the project. Persistent scenes carry - // synopsis/color (irrelevant here) and token/omitted (used for labels). - const layout = project.layout().toJSON() as { - sceneLocking?: boolean; - sceneNumberingStyle?: "suffix" | "prefix"; - }; - const persistentScenes = project.scenes().toJSON() as PersistentSceneMap; - const enrichedOptions: PDFExportOptions = { - ...options, - sceneLocking: !!layout.sceneLocking, - sceneNumberingStyle: layout.sceneNumberingStyle ?? "suffix", - persistentScenes, - }; + // Scene labels (under production lock) and OMITTED state are already + // rendered as ProseMirror decoration widgets inside each scene

. + // `collectLines` reads them directly from the DOM, so we don't need to + // re-run the scene-labeling logic here. // ── Collect all visual lines from the browser DOM ─────────────────── const titlePageEl = options.titlePageElement; - const titlePageLines = titlePageEl ? this.collectLines(titlePageEl, enrichedOptions) : []; + const titlePageLines = titlePageEl ? this.collectLines(titlePageEl, options) : []; const titlePageLeftPx = titlePageEl ? this.getPageLeftPx(titlePageEl) : 0; - const screenplayLines = this.collectLines(editorEl, enrichedOptions); + const screenplayLines = this.collectLines(editorEl, options); const screenplayLeftPx = this.getPageLeftPx(editorEl); return new Promise((resolve, reject) => { @@ -162,28 +147,6 @@ export class PDFAdapter extends ProjectAdapter { let sceneCount = 0; let yOffset = 0; - // Pre-compute label per scene UUID when production lock is on. - const sceneLabels: { label: string; omitted: boolean }[] = []; - let sceneLabelIdx = 0; - if (options.sceneLocking) { - const uuids: string[] = []; - for (let i = 0; i < editorEl.children.length; i++) { - const child = editorEl.children[i] as HTMLElement; - if (child?.tagName === "P" && child.classList.contains("scene")) { - const uuid = child.getAttribute("data-id"); - if (uuid) uuids.push(uuid); - } - } - const computed = computeSceneLabels( - uuids, - options.persistentScenes ?? {}, - options.sceneNumberingStyle ?? "suffix", - ); - for (const l of computed) { - sceneLabels.push({ label: l.label, omitted: l.status === "omitted" }); - } - } - for (let i = 0; i < editorEl.children.length; i++) { const el = editorEl.children[i] as HTMLElement; if (!el) continue; @@ -206,10 +169,19 @@ export class PDFAdapter extends ProjectAdapter { const isScene = el.classList.contains("scene"); if (isScene) sceneCount++; + // Label widgets are injected by `scene-locking-extension` when + // production lock is on. Read whichever side is present (left or + // right) and fall back to a positional number when neither is. const sceneInfo = isScene - ? options.sceneLocking - ? sceneLabels[sceneLabelIdx++] ?? { label: String(sceneCount), omitted: false } - : { label: String(sceneCount), omitted: false } + ? { + label: + (el.querySelector(".scene-label-left") as HTMLElement | null)?.textContent + ?.trim() || + (el.querySelector(".scene-label-right") as HTMLElement | null)?.textContent + ?.trim() || + String(sceneCount), + omitted: el.getAttribute("data-omitted-overlay") === "true", + } : undefined; // Extract the paragraph type from classList diff --git a/src/lib/adapters/scriptio/scriptio-adapter.ts b/src/lib/adapters/scriptio/scriptio-adapter.ts index 21464087..cb1bdcb8 100644 --- a/src/lib/adapters/scriptio/scriptio-adapter.ts +++ b/src/lib/adapters/scriptio/scriptio-adapter.ts @@ -1,4 +1,4 @@ -import { BoardData, LayoutData, ProjectData, ProjectMetadata, ProjectState, screenplayOf, titlepageOf } from "@src/lib/project/project-state"; +import { BoardData, LayoutData, ProductionData, ProjectData, ProjectMetadata, ProjectState, screenplayOf, titlepageOf } from "@src/lib/project/project-state"; import { BaseExportOptions, ProjectAdapter } from "../screenplay-adapter"; import { replaceScreenplay } from "../../screenplay/editor"; import { Editor } from "@tiptap/react"; @@ -79,6 +79,7 @@ export class ScriptioAdapter extends ProjectAdapter { locations: project.locations().toJSON(), board: project.board().toJSON() as BoardData, layout: project.layout().toJSON() as LayoutData, + production: project.production().toJSON() as ProductionData, comments: project.comments().toJSON(), }; payload = new TextEncoder().encode(JSON.stringify(data, null, 2)); @@ -129,6 +130,7 @@ export class ScriptioAdapter extends ProjectAdapter { locations: tmpDoc.locations().toJSON(), board: tmpDoc.board().toJSON() as BoardData, layout: tmpDoc.layout().toJSON() as LayoutData, + production: tmpDoc.production().toJSON() as ProductionData, comments: tmpDoc.comments().toJSON(), }; } catch (error) { @@ -171,6 +173,7 @@ export class ScriptioAdapter extends ProjectAdapter { ydoc.locations().clear(); ydoc.board().clear(); ydoc.layout().clear(); + ydoc.production().clear(); ydoc.comments().clear(); }); diff --git a/src/lib/editor/use-document-editor.ts b/src/lib/editor/use-document-editor.ts index 7a6ac17f..4d19fa14 100644 --- a/src/lib/editor/use-document-editor.ts +++ b/src/lib/editor/use-document-editor.ts @@ -77,6 +77,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum moreLabel, sceneLocking, sceneNumberingStyle, + skippedSceneLetters, persistentScenes, } = projectCtx; @@ -172,6 +173,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum setSearchMatches, sceneLocking, sceneNumberingStyle, + skippedSceneLetters, persistentScenes, // eslint-disable-next-line react-hooks/exhaustive-deps }), []); @@ -188,6 +190,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum ext.setSearchMatches = setSearchMatches; ext.sceneLocking = sceneLocking; ext.sceneNumberingStyle = sceneNumberingStyle; + ext.skippedSceneLetters = skippedSceneLetters; ext.persistentScenes = persistentScenes; const lastReportedElementRef = useRef(null); @@ -269,6 +272,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum getSceneLocking: () => !!ext.sceneLocking, getScenes: () => ext.repository?.scenes ?? {}, getNumberingStyle: () => ext.sceneNumberingStyle ?? "suffix", + getSkippedLetters: () => ext.skippedSceneLetters ?? [], }) : null; @@ -596,7 +600,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum if (editor && features.sceneLocking) { refreshSceneLocking(editor); } - }, [editor, sceneLocking, sceneNumberingStyle, persistentScenes, features.sceneLocking]); + }, [editor, sceneLocking, sceneNumberingStyle, skippedSceneLetters, persistentScenes, features.sceneLocking]); // Refresh search highlights useEffect(() => { diff --git a/src/lib/project/project-doc.ts b/src/lib/project/project-doc.ts index f729c449..c8ecf342 100644 --- a/src/lib/project/project-doc.ts +++ b/src/lib/project/project-doc.ts @@ -101,6 +101,13 @@ export type LayoutData = { moreLabel: string; elementMargins: Record; elementStyles: Record; +}; + +// -------------------------------- // +// PRODUCTION // +// -------------------------------- // + +export type ProductionData = { sceneLocking?: boolean; /** * How provisional scenes inserted under production lock are labeled. @@ -111,8 +118,21 @@ export type LayoutData = { * already-locked scenes keep their stored label. */ sceneNumberingStyle?: "suffix" | "prefix"; + /** + * Uppercase letters to omit from generated scene labels (e.g. "I" and "O" + * are visually confused with "1" and "0"). Stored explicitly so the user's + * choice survives — when `undefined`, callers fall back to + * `DEFAULT_SKIPPED_SCENE_LETTERS`. + */ + skippedSceneLetters?: string[]; }; +/** Letters skipped by default in newly-created projects. */ +export const DEFAULT_SKIPPED_SCENE_LETTERS: string[] = ["I", "O"]; + +/** Letters the user can toggle via Production Settings. */ +export const TOGGLEABLE_SCENE_LETTERS: readonly string[] = ["I", "O", "Q", "Z"]; + // -------------------------------- // // BOARD // // -------------------------------- // @@ -152,6 +172,7 @@ export type ProjectData = { metadata: ProjectMetadata; board: BoardData; layout: LayoutData; + production: ProductionData; comments?: Record; shelf?: Record; }; @@ -186,6 +207,7 @@ export class ProjectState extends Y.Doc { METADATA: "metadata", BOARD: "board", LAYOUT: "layout", + PRODUCTION: "production", COMMENTS: "comments", DICTIONARY: "dictionary", SHELF: "shelf", @@ -233,6 +255,10 @@ export class ProjectState extends Y.Doc { return this.getMap(this.KEYS.LAYOUT) as unknown as TypedMap; } + production(): TypedMap { + return this.getMap(this.KEYS.PRODUCTION) as unknown as TypedMap; + } + comments(): Y.Map { return this.getMap(this.KEYS.COMMENTS); } diff --git a/src/lib/project/project-repository.ts b/src/lib/project/project-repository.ts index 23d6a96a..bc7f51de 100644 --- a/src/lib/project/project-repository.ts +++ b/src/lib/project/project-repository.ts @@ -5,6 +5,7 @@ import { ScreenplaySchema } from "../screenplay/editor"; import { Comment, CommentReply, Screenplay } from "../utils/types"; import { LayoutData, + ProductionData, ProjectMetadata, ProjectState, ElementStyle, @@ -370,13 +371,33 @@ export class ProjectRepository { if (this.guardWrite("setElementStyles")) return; this.ydoc.layout().set("elementStyles", styles); } + + // -------------------------------- // + // PRODUCTION // + // -------------------------------- // + + getProduction(): Partial { + return this.ydoc.production().toJSON() as Partial; + } + + observeProduction(callback: (production: Partial) => void): () => void { + const map = this.ydoc.production(); + const observer = () => callback(map.toJSON() as Partial); + map.observe(observer); + return () => map.unobserve(observer); + } + setSceneLocking(locked: boolean) { if (this.guardWrite("setSceneLocking")) return; - this.ydoc.layout().set("sceneLocking", locked); + this.ydoc.production().set("sceneLocking", locked); } setSceneNumberingStyle(style: "suffix" | "prefix") { if (this.guardWrite("setSceneNumberingStyle")) return; - this.ydoc.layout().set("sceneNumberingStyle", style); + this.ydoc.production().set("sceneNumberingStyle", style); + } + setSkippedSceneLetters(letters: string[]) { + if (this.guardWrite("setSkippedSceneLetters")) return; + this.ydoc.production().set("skippedSceneLetters", letters); } /** diff --git a/src/lib/project/project-state.ts b/src/lib/project/project-state.ts index 9196005a..aadb2a33 100644 --- a/src/lib/project/project-state.ts +++ b/src/lib/project/project-state.ts @@ -22,6 +22,8 @@ export { DEFAULT_PAGE_MARGINS, DEFAULT_ELEMENT_MARGINS, DEFAULT_ELEMENT_STYLES, + DEFAULT_SKIPPED_SCENE_LETTERS, + TOGGLEABLE_SCENE_LETTERS, } from "./project-doc"; export type { ShelfEntryType, @@ -32,6 +34,7 @@ export type { PageMargin, ElementStyle, LayoutData, + ProductionData, BoardCardData, BoardArrowData, BoardData, diff --git a/src/lib/screenplay/extensions/scene-locking-extension.ts b/src/lib/screenplay/extensions/scene-locking-extension.ts index ea7fd1ec..fec51525 100644 --- a/src/lib/screenplay/extensions/scene-locking-extension.ts +++ b/src/lib/screenplay/extensions/scene-locking-extension.ts @@ -14,6 +14,7 @@ type SceneLockingConfig = { getSceneLocking: () => boolean; getScenes: () => Record; getNumberingStyle: () => SceneNumberingStyle; + getSkippedLetters: () => readonly string[]; }; type SceneEntry = { uuid: string; pos: number; nodeSize: number }; @@ -90,6 +91,7 @@ const computeDecorations = ( locking: boolean, scenes: Record, style: SceneNumberingStyle, + skippedLetters: readonly string[], ): DecorationSet => { // Nothing to render: no production lock and no omitted scenes. Skip the // doc traversal entirely — this is the common case for most users. @@ -106,6 +108,7 @@ const computeDecorations = ( entries.map((e) => e.uuid), scenes, style, + skippedLetters, ); for (let i = 0; i < entries.length; i++) { @@ -193,7 +196,7 @@ export const createSceneLockingExtension = (config: SceneLockingConfig) => { name: "sceneLocking", addProseMirrorPlugins() { - const { getSceneLocking, getScenes, getNumberingStyle } = config; + const { getSceneLocking, getScenes, getNumberingStyle, getSkippedLetters } = config; return [ new Plugin({ @@ -205,6 +208,7 @@ export const createSceneLockingExtension = (config: SceneLockingConfig) => { getSceneLocking(), getScenes(), getNumberingStyle(), + getSkippedLetters(), ); }, apply(tr, oldDecorations, _oldState, newState) { @@ -215,6 +219,7 @@ export const createSceneLockingExtension = (config: SceneLockingConfig) => { getSceneLocking(), getScenes(), getNumberingStyle(), + getSkippedLetters(), ); } @@ -231,6 +236,7 @@ export const createSceneLockingExtension = (config: SceneLockingConfig) => { getSceneLocking(), getScenes(), getNumberingStyle(), + getSkippedLetters(), ); }, }, diff --git a/src/lib/screenplay/scene-locking.ts b/src/lib/screenplay/scene-locking.ts index 6e6a33ab..cc14d023 100644 --- a/src/lib/screenplay/scene-locking.ts +++ b/src/lib/screenplay/scene-locking.ts @@ -78,18 +78,38 @@ export type SceneLabel = { export type SceneNumberingStyle = "suffix" | "prefix"; +const FULL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +/** + * Build the effective alphabet by removing any letters the user wants to + * skip (e.g. "I" and "O" are commonly skipped because they're confused with + * digits). Always returns at least 2 letters so the labeling math doesn't + * degenerate — pathological skip lists fall back to the full alphabet. + */ +export const buildSceneAlphabet = (skipped: readonly string[] = []): string => { + const skipSet = new Set(skipped.map((s) => s.toUpperCase())); + const filtered = FULL_ALPHABET.split("").filter((c) => !skipSet.has(c)).join(""); + return filtered.length >= 2 ? filtered : FULL_ALPHABET; +}; + // -------------------------------------------------------------------------- // ENCODING & DISPLAY // -------------------------------------------------------------------------- -/** Excel-style alphabetic letter: 1 → A, 26 → Z, 27 → AA, … */ -const letterFromValue = (n: number, lower: boolean): string => { +/** + * Excel-style alphabetic letter over a configurable alphabet: + * 1 → alphabet[0], alphabet.length → last letter, alphabet.length+1 → "AA", … + * The alphabet defaults to A–Z but callers can pass a filtered one (e.g. + * with "I" and "O" removed). + */ +const letterFromValue = (n: number, lower: boolean, alphabet: string = FULL_ALPHABET): string => { + const base = alphabet.length; let out = ""; - const charBase = lower ? 97 : 65; while (n > 0) { - const m = (n - 1) % 26; - out = String.fromCharCode(charBase + m) + out; - n = Math.floor((n - 1) / 26); + const m = (n - 1) % base; + const ch = alphabet[m]; + out = (lower ? ch.toLowerCase() : ch) + out; + n = Math.floor((n - 1) / base); } return out; }; @@ -99,18 +119,18 @@ const letterFromValue = (n: number, lower: boolean): string => { * each level is taken directly from `level.lower`, so the result is the * same regardless of the project's `SceneNumberingStyle` setting. */ -export const compileSceneLabel = (token: SceneToken): string => { +export const compileSceneLabel = (token: SceneToken, alphabet: string = FULL_ALPHABET): string => { // Prefixes are stored inner-first (closest to base at index 0). Render // outer-to-inner, i.e. reverse before joining. let out = ""; for (let i = token.prefixes.length - 1; i >= 0; i--) { const lvl = token.prefixes[i]; - out += letterFromValue(lvl.value, lvl.lower); + out += letterFromValue(lvl.value, lvl.lower, alphabet); } out += String(token.baseNumber); for (let i = 0; i < token.suffixes.length; i++) { const lvl = token.suffixes[i]; - out += letterFromValue(lvl.value, lvl.lower); + out += letterFromValue(lvl.value, lvl.lower, alphabet); } return out; }; @@ -201,8 +221,10 @@ const withLevels = (t: SceneToken, axis: Axis, levels: SceneLevel[]): SceneToken // Wedge convention per axis. lowercase < uppercase at the same value (see // `compareTokens`). Suffix levels count UP, so 'a' (value 1) is the deepest // wedge — decrementing past it means descending a level. Prefix levels are -// mirrored: 'z' (value 26) is the bound; incrementing past it descends. -const wedgeBound = (axis: Axis): number => (axis === "suffix" ? 1 : 26); +// mirrored: the alphabet's last letter (value = alphabet.length) is the +// bound; incrementing past it descends. +const wedgeBound = (axis: Axis, alphabetSize: number): number => + axis === "suffix" ? 1 : alphabetSize; const wedgeStep = (axis: Axis): number => (axis === "suffix" ? -1 : 1); /** Bump the deepest level of `anchor` along `axis` by k. */ @@ -240,10 +262,11 @@ const wedgeAlong = ( target: SceneToken, k: number, axis: Axis, + alphabetSize: number, ): SceneToken | null => { const fromLevels = levelsOf(from, axis); const targetLevels = levelsOf(target, axis); - const bound = wedgeBound(axis); + const bound = wedgeBound(axis, alphabetSize); const step = wedgeStep(axis); let i = 0; @@ -295,6 +318,7 @@ const computeProvisionalToken = ( next: SceneToken | null, k: number, style: SceneNumberingStyle, + alphabetSize: number, ): SceneToken => { if (!prev && !next) return baseToken(k); @@ -309,7 +333,7 @@ const computeProvisionalToken = ( return [ continueAlong(prev, k, "suffix"), nestAlong(prev, k, "suffix"), - next ? wedgeAlong(prev, next, k, "suffix") : null, + next ? wedgeAlong(prev, next, k, "suffix", alphabetSize) : null, ]; } // No prev — nothing to grow from on the suffix axis. The natural @@ -323,7 +347,7 @@ const computeProvisionalToken = ( return [ prev ? continueAlong(prev, k, "prefix") : null, nestAlong(next, k, "prefix"), - prev ? wedgeAlong(next, prev, k, "prefix") : null, + prev ? wedgeAlong(next, prev, k, "prefix", alphabetSize) : null, ]; } return prev ? [continueAlong(prev, k, "suffix")] : []; @@ -361,7 +385,10 @@ export const computeSceneLabels = ( sceneUuids: string[], persistent: Record, style: SceneNumberingStyle = "suffix", + skippedLetters: readonly string[] = [], ): SceneLabel[] => { + const alphabet = buildSceneAlphabet(skippedLetters); + const alphabetSize = alphabet.length; const n = sceneUuids.length; const result: SceneLabel[] = new Array(n); @@ -404,7 +431,7 @@ export const computeSceneLabels = ( result[i] = { uuid, token: entry.token, - label: compileSceneLabel(entry.token), + label: compileSceneLabel(entry.token, alphabet), status: entry.omitted ? "omitted" : "locked", }; continue; @@ -415,11 +442,12 @@ export const computeSceneLabels = ( nextLocked[i], segmentIdx[i], style, + alphabetSize, ); result[i] = { uuid, token, - label: compileSceneLabel(token), + label: compileSceneLabel(token, alphabet), status: "provisional", }; } From 8b0853bd53587cba19db49463318ed74d1b7977d Mon Sep 17 00:00:00 2001 From: Hugo Date: Mon, 25 May 2026 11:32:08 +0200 Subject: [PATCH 5/6] started page locking feature --- components/editor/CommentCards.tsx | 40 +- components/navbar/ProductionPanel.tsx | 139 +++++- components/navbar/SavesPanel.tsx | 24 +- components/popup/Popup.tsx | 4 + components/popup/PopupUnlockPages.tsx | 52 ++ messages/de.json | 5 + messages/en.json | 5 + messages/es.json | 5 + messages/fr.json | 5 + messages/ja.json | 5 + messages/ko.json | 5 + messages/pl.json | 5 + messages/zh.json | 5 + src/context/ProjectContext.tsx | 43 +- src/lib/adapters/scriptio/scriptio-adapter.ts | 2 + src/lib/editor/use-document-editor.ts | 21 +- src/lib/project/project-doc.ts | 15 + src/lib/project/project-repository.ts | 72 +++ .../extensions/pagination-extension.ts | 469 ++++++++++++++++-- src/lib/screenplay/page-locking.ts | 28 ++ src/lib/screenplay/popup.ts | 15 +- src/lib/utils/hooks.ts | 30 ++ 22 files changed, 897 insertions(+), 97 deletions(-) create mode 100644 components/popup/PopupUnlockPages.tsx create mode 100644 src/lib/screenplay/page-locking.ts diff --git a/components/editor/CommentCards.tsx b/components/editor/CommentCards.tsx index 29571f3e..6071f4ab 100644 --- a/components/editor/CommentCards.tsx +++ b/components/editor/CommentCards.tsx @@ -3,44 +3,29 @@ import { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { Comment, CommentReply } from "@src/lib/utils/types"; import { Send, Trash2, X } from "lucide-react"; -import { useUser } from "@src/lib/utils/hooks"; +import { useFormatTimestamp, useUser } from "@src/lib/utils/hooks"; import { getCommentPositions } from "@src/lib/screenplay/extensions/comment-highlight-extension"; import { useViewContext } from "@src/context/ViewContext"; import { Editor } from "@tiptap/react"; import { Transaction } from "@tiptap/pm/state"; import styles from "./CommentCard.module.css"; -function formatTimestamp(ts: number): string { - const date = new Date(ts); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - - if (diffMins < 1) return "just now"; - if (diffMins < 60) return `${diffMins}m ago`; - - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h ago`; - - const diffDays = Math.floor(diffHours / 24); - if (diffDays < 7) return `${diffDays}d ago`; - - return date.toLocaleDateString(); -} - // -------------------------------- // // Reply bubble // // -------------------------------- // -const ReplyBubble = ({ reply }: { reply: CommentReply }) => ( -

-
- {reply.author} - {formatTimestamp(reply.createdAt)} +const ReplyBubble = ({ reply }: { reply: CommentReply }) => { + const formatTimestamp = useFormatTimestamp(); + return ( +
+
+ {reply.author} + {formatTimestamp(reply.createdAt)} +
+
{reply.text}
-
{reply.text}
-
-); + ); +}; // -------------------------------- // // Comment card // @@ -62,6 +47,7 @@ const CommentCard = ({ comment, isActive, onActivate, onDeactivate, onSave, onDe const [draft, setDraft] = useState(comment.text); const [replyDraft, setReplyDraft] = useState(""); const textareaRef = useRef(null); + const formatTimestamp = useFormatTimestamp(); useEffect(() => { if (isEditing && textareaRef.current) { diff --git a/components/navbar/ProductionPanel.tsx b/components/navbar/ProductionPanel.tsx index a334f3b8..dee0c271 100644 --- a/components/navbar/ProductionPanel.tsx +++ b/components/navbar/ProductionPanel.tsx @@ -2,13 +2,14 @@ import { useCallback, useContext, useEffect, useMemo, useRef } from "react"; import { useTranslations } from "next-intl"; -import { Lock, X } from "lucide-react"; +import { Lock, X, Layers } from "lucide-react"; import { ProjectContext } from "@src/context/ProjectContext"; import { UserContext } from "@src/context/UserContext"; import { computeSceneLabels } from "@src/lib/screenplay/scene-locking"; import { computeSceneItems } from "@src/lib/screenplay/scenes"; -import { unlockScenesPopup } from "@src/lib/screenplay/popup"; +import { unlockPagesPopup, unlockScenesPopup } from "@src/lib/screenplay/popup"; +import { getPageAnchors } from "@src/lib/screenplay/extensions/pagination-extension"; import Switch from "@components/utils/Switch"; import styles from "./ProductionPanel.module.css"; @@ -37,7 +38,11 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { sceneNumberingStyle, skippedSceneLetters, persistentScenes, + pageLocking, + persistentPages, scenes, + screenplay, + editor, repository, isReadOnly, } = useContext(ProjectContext); @@ -131,7 +136,7 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { const currentScreenplay = repository.screenplay; const scenes = computeSceneItems(currentScreenplay); const uuids = scenes.map(s => s.id).filter((id): id is string => !!id); - + // Re-read fresh persistent data const persistentSnapshot = repository.scenes; @@ -142,23 +147,91 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { skippedSceneLetters, ); - console.log("[ProductionPanel] RELOCKING PROVISIONAL. Full snapshot:", currentLabels.map(l => ({ - uuid: l.uuid, - label: l.label, - status: l.status, - token: l.token - }))); - - let relockedCount = 0; currentLabels.forEach((label) => { if (label.status === "provisional") { - console.log(`[ProductionPanel] -> Freezing ${label.uuid} as "${label.label}"`); repository.upsertScene(label.uuid, { token: label.token }); - relockedCount++; } }); + }); + }; + + // -------------- Page locking -------------- + // Pulls anchors from the live pagination state; recomputed whenever the + // screenplay or the persistent maps change. Each render is cheap (a single + // Set traversal over the plugin state); we don't subscribe to pagination + // events because the production panel is only meaningful as a snapshot + // when the user opens it. + const pageAnchors = useMemo(() => { + if (!editor) return []; + return getPageAnchors(editor); + // `screenplay` and `persistentPages` are listed so the memo refreshes + // when content/locks change — they're not used inside the body. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editor, screenplay, persistentPages, persistentScenes]); + + const pageLabels = useMemo(() => { + if (!pageLocking || pageAnchors.length === 0) return []; + return computeSceneLabels(pageAnchors, persistentPages, "suffix", skippedSceneLetters); + }, [pageLocking, pageAnchors, persistentPages, skippedSceneLetters]); + + const provisionalPageLabels = useMemo( + () => pageLabels.filter((l) => l.status === "provisional"), + [pageLabels], + ); - console.log(`[ProductionPanel] Relock complete. Persisted ${relockedCount} tokens.`); + const performPageUnlock = useCallback(() => { + if (!repository) return; + repository.transact(() => { + repository.clearPageLocks(); + repository.setPageLocking(false); + }); + }, [repository]); + + const handlePageLockingToggle = (next: boolean) => { + if (!repository || isReadOnly) return; + if (next) { + if (!editor) return; + repository.transact(() => { + const anchors = getPageAnchors(editor); + const persistentSnapshot = repository.pages; + // Idempotent: any anchor that already has a token keeps it. + // Only provisional anchors (no token yet) get a freshly-computed + // one. A fresh lock-on with no existing tokens assigns every + // page baseToken(idx+1) — same shape as scene locking. + const computed = computeSceneLabels( + anchors, + persistentSnapshot, + "suffix", + skippedSceneLetters, + ); + computed.forEach((label) => { + if (label.status === "provisional") { + repository.upsertPage(label.uuid, { token: label.token }); + } + }); + repository.setPageLocking(true); + }); + } else { + unlockPagesPopup(performPageUnlock, userCtx); + } + }; + + const handlePageRelock = () => { + if (!repository || isReadOnly || !editor) return; + repository.transact(() => { + const anchors = getPageAnchors(editor); + const persistentSnapshot = repository.pages; + const currentLabels = computeSceneLabels( + anchors, + persistentSnapshot, + "suffix", + skippedSceneLetters, + ); + currentLabels.forEach((label) => { + if (label.status === "provisional") { + repository.upsertPage(label.uuid, { token: label.token }); + } + }); }); }; @@ -217,14 +290,48 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { )}
- {/* Page Locking (inert in v1) */} + {/* Page Locking */}
+ {t("pageLocking")}
- {}} ariaLabel={t("pageLocking")} /> +
+ {pageLocking && provisionalPageLabels.length > 0 && ( + + )} + +
+ + {pageLocking && provisionalPageLabels.length > 0 && ( +
+
{t("pageProvisionalTitle")}
+
+ {provisionalPageLabels.map((l, idx) => ( + + {l.label} + + ))} +
+
+ )}
{/* Revisions (inert in v1) */} diff --git a/components/navbar/SavesPanel.tsx b/components/navbar/SavesPanel.tsx index 77b3d80c..fa04eac1 100644 --- a/components/navbar/SavesPanel.tsx +++ b/components/navbar/SavesPanel.tsx @@ -3,7 +3,7 @@ import { useContext, useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { DashboardContext } from "@src/context/DashboardContext"; -import { useCookieUser } from "@src/lib/utils/hooks"; +import { useCookieUser, useFormatTimestamp } from "@src/lib/utils/hooks"; import { X, Save, @@ -35,10 +35,10 @@ interface SavesPanelProps { const SavesPanel = ({ projectId, isOpen, onClose, isPro }: SavesPanelProps) => { const t = useTranslations("saves"); - const tDates = useTranslations("dates"); const { openDashboard } = useContext(DashboardContext); const { user } = useCookieUser(); const isSignedIn = !!user; + const formatDate = useFormatTimestamp(); const handleUpgrade = () => { onClose(); @@ -150,26 +150,6 @@ const SavesPanel = ({ projectId, isOpen, onClose, isPro }: SavesPanelProps) => { setConfirmDeleteKey(null); }; - const formatDate = (iso: string) => { - const date = new Date(iso); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMs / 3600000); - const diffDays = Math.floor(diffMs / 86400000); - - if (diffMins < 1) return tDates("justNow"); - if (diffMins < 60) return tDates("minutesAgo", { mins: diffMins }); - if (diffHours < 24) return tDates("hoursAgo", { hours: diffHours }); - if (diffDays < 7) return tDates("daysAgo", { days: diffDays }); - - return date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, - }); - }; - const formatFullDate = (iso: string) => { return new Date(iso).toLocaleString(undefined, { month: "short", diff --git a/components/popup/Popup.tsx b/components/popup/Popup.tsx index f0950752..f1e40989 100644 --- a/components/popup/Popup.tsx +++ b/components/popup/Popup.tsx @@ -7,6 +7,7 @@ import { PopupImportFileData, PopupSceneData, PopupType, + PopupUnlockPagesData, PopupUnlockScenesData, PopupUploadToCloudData, } from "@src/lib/screenplay/popup"; @@ -14,6 +15,7 @@ import { useContext } from "react"; import PopupCharacterItem from "./PopupCharacterItem"; import PopupImportFile from "./PopupImportFile"; import PopupSceneItem from "./PopupSceneItem"; +import PopupUnlockPages from "./PopupUnlockPages"; import PopupUnlockScenes from "./PopupUnlockScenes"; import PopupUploadToCloud from "./PopupUploadToCloud"; @@ -34,6 +36,8 @@ export const Popup = () => { return )} />; case PopupType.UnlockScenes: return )} />; + case PopupType.UnlockPages: + return )} />; default: return null; } diff --git a/components/popup/PopupUnlockPages.tsx b/components/popup/PopupUnlockPages.tsx new file mode 100644 index 00000000..2bcef200 --- /dev/null +++ b/components/popup/PopupUnlockPages.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useContext } from "react"; +import { useTranslations } from "next-intl"; +import { X, Unlock } from "lucide-react"; + +import popup from "./Popup.module.css"; + +import { useDraggable } from "@src/lib/utils/hooks"; +import { PopupData, PopupUnlockPagesData, closePopup } from "@src/lib/screenplay/popup"; +import { UserContext } from "@src/context/UserContext"; + +const PopupUnlockPages = ({ data: { confirmUnlock } }: PopupData) => { + const userCtx = useContext(UserContext); + const { position, handleMouseDown, isDragging } = useDraggable(); + const t = useTranslations("production"); + + const onConfirm = () => { + confirmUnlock(); + closePopup(userCtx); + }; + + return ( +
+
+
+

{t("unlockPagesTitle")}

+ closePopup(userCtx)} /> +
+
+

{t("unlockPagesWarning")}

+
+ + +
+
+ ); +}; + +export default PopupUnlockPages; diff --git a/messages/de.json b/messages/de.json index 0302bcda..889c2462 100644 --- a/messages/de.json +++ b/messages/de.json @@ -460,6 +460,11 @@ "unlockWarning": "Alle gesperrten und ausgelassenen Szenen verlieren ihre fixierte Nummerierung.", "unlockInfo": "Dies betrifft alle Mitarbeiter. Das Drehbuch kehrt zu positionalen Szenennummern zurück. OMITTED-Szenen bleiben ausgelassen — heben Sie die Auslassung einzeln auf, um den Inhalt wiederherzustellen.", "unlock": "Entsperren", + "pageRelock": "Erneut sperren", + "pageProvisionalTitle": "Nicht gesperrte Seiten", + "unlockPagesTitle": "Seiten entsperren", + "unlockPagesWarning": "Alle Seiten verlieren ihre gesperrte Nummerierung und kehren zur natürlichen Paginierung zurück.", + "unlockPages": "Seiten entsperren", "cancel": "Abbrechen", "numberingStyleTitle": "Szenennummerierung", "numberingStyleHelp": "Bestimmt, wie neu eingefügte Szenen zwischen zwei gesperrten Szenen nummeriert werden.", diff --git a/messages/en.json b/messages/en.json index 81b9fb7e..24107f8a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -459,6 +459,11 @@ "unlockWarning": "All scenes will lose their locked numbering, reverting to their initial positional numbering.", "unlockInfo": "This affects every collaborator. The screenplay will revert to positional scene numbers. OMITTED scenes stay omitted — unomit them individually if you want their content back.", "unlock": "Unlock", + "pageRelock": "Relock", + "pageProvisionalTitle": "Non-locked pages", + "unlockPagesTitle": "Unlock pages", + "unlockPagesWarning": "All pages will lose their locked numbering, reverting to their natural pagination-based numbering.", + "unlockPages": "Unlock pages", "cancel": "Cancel", "numberingStyleTitle": "Scene numbering", "numberingStyleHelp": "Dictates how newly inserted scenes should be numbered between two locked scenes.", diff --git a/messages/es.json b/messages/es.json index 365185cb..4f1fb4b1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -459,6 +459,11 @@ "unlockWarning": "Todas las escenas bloqueadas y omitidas perderán su numeración fija.", "unlockInfo": "Esto afecta a todos los colaboradores. El guion volverá a una numeración posicional. Las escenas OMITTED siguen omitidas — anula la omisión individualmente si quieres recuperar su contenido.", "unlock": "Desbloquear", + "pageRelock": "Rebloquear", + "pageProvisionalTitle": "Páginas no bloqueadas", + "unlockPagesTitle": "Desbloquear páginas", + "unlockPagesWarning": "Todas las páginas perderán su numeración bloqueada y volverán a la paginación natural.", + "unlockPages": "Desbloquear páginas", "cancel": "Cancelar", "numberingStyleTitle": "Numeración de escenas", "numberingStyleHelp": "Determina cómo se numeran las escenas recién insertadas entre dos escenas bloqueadas.", diff --git a/messages/fr.json b/messages/fr.json index 0e2b96b5..2a41c334 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -460,6 +460,11 @@ "unlockWarning": "Toutes les scènes verrouillées et omises perdront leur numérotation figée.", "unlockInfo": "Ceci affecte tous les collaborateurs. Le scénario reviendra à une numérotation positionnelle. Les scènes OMITTED restent omises — démasquez-les individuellement si vous souhaitez en récupérer le contenu.", "unlock": "Déverrouiller", + "pageRelock": "Reverrouiller", + "pageProvisionalTitle": "Pages non verrouillées", + "unlockPagesTitle": "Déverrouiller les pages", + "unlockPagesWarning": "Toutes les pages perdront leur numérotation verrouillée et reviendront à la pagination naturelle.", + "unlockPages": "Déverrouiller les pages", "cancel": "Annuler", "numberingStyleTitle": "Numérotation des scènes", "numberingStyleHelp": "Détermine comment les scènes nouvellement insérées sont numérotées entre deux scènes verrouillées.", diff --git a/messages/ja.json b/messages/ja.json index 5c473138..f8b5f921 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -459,6 +459,11 @@ "unlockWarning": "ロック済みおよび省略されたシーンの固定番号はすべて失われます。", "unlockInfo": "この操作はすべてのコラボレーターに影響します。脚本は位置ベースの番号に戻ります。OMITTED シーンは省略のまま残ります — 内容を復元したい場合は個別に省略を解除してください。", "unlock": "ロック解除", + "pageRelock": "再ロック", + "pageProvisionalTitle": "未ロックのページ", + "unlockPagesTitle": "ページのロックを解除", + "unlockPagesWarning": "すべてのページのロック番号が失われ、自然なページ番号付けに戻ります。", + "unlockPages": "ページのロック解除", "cancel": "キャンセル", "numberingStyleTitle": "シーン番号", "numberingStyleHelp": "ロック済みの2つのシーンの間に挿入された新しいシーンの番号付け方法を決定します。", diff --git a/messages/ko.json b/messages/ko.json index 17d8160e..5e3b911f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -459,6 +459,11 @@ "unlockWarning": "잠긴 씬과 생략된 씬의 고정 번호가 모두 사라집니다.", "unlockInfo": "이 작업은 모든 협업자에게 영향을 미칩니다. 시나리오는 위치 기반 씬 번호로 되돌아갑니다. OMITTED 씬은 그대로 유지되며, 내용을 복원하려면 개별적으로 생략을 해제하세요.", "unlock": "잠금 해제", + "pageRelock": "다시 잠그기", + "pageProvisionalTitle": "잠기지 않은 페이지", + "unlockPagesTitle": "페이지 잠금 해제", + "unlockPagesWarning": "모든 페이지의 잠긴 번호가 사라지고 자연스러운 페이지 번호로 되돌아갑니다.", + "unlockPages": "페이지 잠금 해제", "cancel": "취소", "numberingStyleTitle": "씬 번호", "numberingStyleHelp": "잠긴 두 씬 사이에 새로 삽입된 씬의 번호 매김 방식을 결정합니다.", diff --git a/messages/pl.json b/messages/pl.json index bf9f2ea0..c78feb68 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -459,6 +459,11 @@ "unlockWarning": "Wszystkie zablokowane i pominięte sceny utracą zamrożoną numerację.", "unlockInfo": "Wpłynie to na wszystkich współpracowników. Scenariusz powróci do numeracji pozycyjnej. Sceny OMITTED pozostają pominięte — anuluj pominięcie pojedynczo, aby odzyskać ich zawartość.", "unlock": "Odblokuj", + "pageRelock": "Zablokuj ponownie", + "pageProvisionalTitle": "Niezablokowane strony", + "unlockPagesTitle": "Odblokować strony?", + "unlockPagesWarning": "Wszystkie strony utracą zablokowaną numerację i powrócą do naturalnej paginacji.", + "unlockPages": "Odblokuj strony", "cancel": "Anuluj", "numberingStyleTitle": "Numeracja scen", "numberingStyleHelp": "Określa, jak numerowane są nowo wstawione sceny między dwiema zablokowanymi scenami.", diff --git a/messages/zh.json b/messages/zh.json index fec25d70..b1fe3406 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -459,6 +459,11 @@ "unlockWarning": "所有锁定和省略的场景都将失去固定编号。", "unlockInfo": "此操作会影响所有协作者。剧本将恢复为按位置编号。OMITTED 场景仍保持省略状态 — 如需恢复内容,请单独取消省略。", "unlock": "解锁", + "pageRelock": "重新锁定", + "pageProvisionalTitle": "未锁定的页面", + "unlockPagesTitle": "解锁页面?", + "unlockPagesWarning": "所有页面将失去锁定的编号,恢复为自然分页。", + "unlockPages": "解锁页面", "cancel": "取消", "numberingStyleTitle": "场景编号", "numberingStyleHelp": "决定两个锁定场景之间新插入场景的编号方式。", diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index 9fd6b465..bc0622be 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -13,6 +13,7 @@ import { Editor } from "@tiptap/react"; import { CharacterMap, mergeCharactersData } from "@src/lib/screenplay/characters"; import { LocationMap, mergeLocationsData } from "@src/lib/screenplay/locations"; import { mergeScenesData, PersistentSceneMap, Scene } from "@src/lib/screenplay/scenes"; +import { PersistentPageMap } from "@src/lib/screenplay/page-locking"; import { ProjectMembershipPayload } from "@src/server/repository/project-repository"; import { ProjectRole } from "@src/generated/client/browser"; import { useUser } from "@src/lib/utils/hooks"; @@ -111,6 +112,14 @@ export interface ProjectContextType { * has been persisted. */ persistentScenes: PersistentSceneMap; + /** Page-locking master switch (production lock for page numbering). */ + pageLocking: boolean; + setPageLocking: (locked: boolean) => void; + /** Raw persistent page-lock map (anchor data-id → PersistentPage). + * Keyed by `PAGE_ONE_KEY` for page 1, by the top-level node's data-id + * for subsequent pages. */ + persistentPages: PersistentPageMap; + // Search state searchTerm: string; setSearchTerm: (term: string) => void; @@ -194,6 +203,9 @@ const defaultContextValue: ProjectContextType = { skippedSceneLetters: DEFAULT_SKIPPED_SCENE_LETTERS, setSkippedSceneLetters: () => {}, persistentScenes: {}, + pageLocking: false, + setPageLocking: () => {}, + persistentPages: {}, characters: {}, locations: {}, scenes: [], @@ -319,6 +331,8 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = const [skippedSceneLetters, setSkippedSceneLettersState] = useState(DEFAULT_SKIPPED_SCENE_LETTERS); const [persistentScenes, setPersistentScenesState] = useState({}); + const [pageLocking, setPageLockingState] = useState(false); + const [persistentPages, setPersistentPagesState] = useState({}); const [connectionStatus, setConnectionStatus] = useState("disconnected"); const [users, setUsers] = useState([]); @@ -510,10 +524,14 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = if (initialProduction.skippedSceneLetters !== undefined) { setSkippedSceneLettersState(initialProduction.skippedSceneLetters); } + if (initialProduction.pageLocking !== undefined) { + setPageLockingState(initialProduction.pageLocking); + } } - // Read initial persistent scenes + // Read initial persistent scenes & pages setPersistentScenesState(repository.scenes); + setPersistentPagesState(repository.pages); // Observe layout changes const unsubscribeLayout = repository.observeLayout((layout: Partial) => { @@ -566,6 +584,14 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = if (production.skippedSceneLetters !== undefined) { setSkippedSceneLettersState(production.skippedSceneLetters); } + if (production.pageLocking !== undefined) { + setPageLockingState(production.pageLocking); + } + }); + + // Observe page-lock changes + const unsubscribePages = repository.observePages((pages: PersistentPageMap) => { + setPersistentPagesState(pages); }); // Observe character changes - get current screenplay from repository @@ -610,6 +636,7 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = repository.unregisterScreenplayCallback(recomputeFromScreenplay); unsubscribeLayout(); unsubscribeProduction(); + unsubscribePages(); unsubscribeCharacters(); unsubscribeLocations(); unsubscribeScenes(); @@ -774,6 +801,14 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = [repository], ); + const setPageLocking = useCallback( + (locked: boolean) => { + setPageLockingState(locked); + repository?.setPageLocking(locked); + }, + [repository], + ); + const setSceneNumberingStyle = useCallback( (style: "suffix" | "prefix") => { setSceneNumberingStyleState(style); @@ -881,6 +916,9 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = skippedSceneLetters, setSkippedSceneLetters, persistentScenes, + pageLocking, + setPageLocking, + persistentPages, screenplay, scenes, updateScenes, @@ -952,6 +990,9 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = skippedSceneLetters, setSkippedSceneLetters, persistentScenes, + pageLocking, + setPageLocking, + persistentPages, screenplay, scenes, updateScenes, diff --git a/src/lib/adapters/scriptio/scriptio-adapter.ts b/src/lib/adapters/scriptio/scriptio-adapter.ts index cb1bdcb8..fbb61d66 100644 --- a/src/lib/adapters/scriptio/scriptio-adapter.ts +++ b/src/lib/adapters/scriptio/scriptio-adapter.ts @@ -76,6 +76,7 @@ export class ScriptioAdapter extends ProjectAdapter { metadata: project.metadata().toJSON() as ProjectMetadata, characters: project.characters().toJSON(), scenes: project.scenes().toJSON(), + pages: project.pages().toJSON(), locations: project.locations().toJSON(), board: project.board().toJSON() as BoardData, layout: project.layout().toJSON() as LayoutData, @@ -127,6 +128,7 @@ export class ScriptioAdapter extends ProjectAdapter { metadata: tmpDoc.metadata().toJSON() as ProjectMetadata, characters: tmpDoc.characters().toJSON(), scenes: tmpDoc.scenes().toJSON(), + pages: tmpDoc.pages().toJSON(), locations: tmpDoc.locations().toJSON(), board: tmpDoc.board().toJSON() as BoardData, layout: tmpDoc.layout().toJSON() as LayoutData, diff --git a/src/lib/editor/use-document-editor.ts b/src/lib/editor/use-document-editor.ts index 4d19fa14..f8ce413b 100644 --- a/src/lib/editor/use-document-editor.ts +++ b/src/lib/editor/use-document-editor.ts @@ -11,7 +11,7 @@ import { ScreenplayElement, Style, TitlePageElement } from "@src/lib/utils/enums import { getRandomColor } from "@src/lib/utils/misc"; import { useUser } from "@src/lib/utils/hooks"; import { getStylesFromMarks, SCREENPLAY_FORMATS } from "@src/lib/screenplay/editor"; -import { ScriptioPagination } from "@src/lib/screenplay/extensions/pagination-extension"; +import { ScriptioPagination, refreshPageLocking } from "@src/lib/screenplay/extensions/pagination-extension"; import { KeybindsExtension } from "@src/lib/screenplay/extensions/keybinds-extension"; import { executeKeybindAction, KeybindId } from "@src/lib/utils/keybinds"; import { @@ -79,6 +79,8 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum sceneNumberingStyle, skippedSceneLetters, persistentScenes, + pageLocking, + persistentPages, } = projectCtx; const projectState = repository?.getState(); @@ -175,6 +177,8 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum sceneNumberingStyle, skippedSceneLetters, persistentScenes, + pageLocking, + persistentPages, // eslint-disable-next-line react-hooks/exhaustive-deps }), []); ext.highlightedCharacters = highlightedCharacters; @@ -192,6 +196,8 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum ext.sceneNumberingStyle = sceneNumberingStyle; ext.skippedSceneLetters = skippedSceneLetters; ext.persistentScenes = persistentScenes; + ext.pageLocking = pageLocking; + ext.persistentPages = persistentPages; const lastReportedElementRef = useRef(null); @@ -362,6 +368,9 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum }, footerRight: "", ...SCREENPLAY_FORMATS[pageSize], + getPageLocking: () => !!ext.pageLocking, + getPageLocks: () => ext.persistentPages ?? {}, + getSkippedLetters: () => ext.skippedSceneLetters ?? [], } : { pageGap: 20, @@ -602,6 +611,16 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum } }, [editor, sceneLocking, sceneNumberingStyle, skippedSceneLetters, persistentScenes, features.sceneLocking]); + // Refresh pagination when page locking or the page-lock map changes. + // Pagination only reads these via getter closures on its options, so + // we must explicitly kick it to re-run; otherwise stale labels render + // until the user types. + useEffect(() => { + if (editor && config.features.paginationMode === "screenplay") { + refreshPageLocking(editor); + } + }, [editor, pageLocking, persistentPages, skippedSceneLetters, config.features.paginationMode]); + // Refresh search highlights useEffect(() => { if (editor && features.searchHighlights) { diff --git a/src/lib/project/project-doc.ts b/src/lib/project/project-doc.ts index c8ecf342..c9456705 100644 --- a/src/lib/project/project-doc.ts +++ b/src/lib/project/project-doc.ts @@ -17,6 +17,7 @@ import type { PageFormat } from "../utils/enums"; import type { CharacterItem } from "../screenplay/characters"; import type { LocationItem } from "../screenplay/locations"; import type { PersistentScene } from "../screenplay/scenes"; +import type { PersistentPage } from "../screenplay/page-locking"; import type { Comment } from "../utils/types"; // -------------------------------- // @@ -125,6 +126,14 @@ export type ProductionData = { * `DEFAULT_SKIPPED_SCENE_LETTERS`. */ skippedSceneLetters?: string[]; + /** + * Page-locking master switch. When true, pagination freezes the numbering + * of each page using anchors stored in the `pages` Y.Map. Pages inserted + * between locks get suffix-style labels (e.g. "4A"); pages appended after + * the last lock continue the integer sequence; deletion of a locked page's + * content leaves an empty page slot in its place. + */ + pageLocking?: boolean; }; /** Letters skipped by default in newly-created projects. */ @@ -168,6 +177,7 @@ export type ProjectData = { titlepage?: JSONContent[]; characters: Record; scenes: Record; + pages: Record; locations: Record; metadata: ProjectMetadata; board: BoardData; @@ -203,6 +213,7 @@ export class ProjectState extends Y.Doc { TITLEPAGE: "titlepage", CHARACTERS: "characters", SCENES: "scenes", + PAGES: "pages", LOCATIONS: "locations", METADATA: "metadata", BOARD: "board", @@ -247,6 +258,10 @@ export class ProjectState extends Y.Doc { return this.getMap(this.KEYS.SCENES); } + pages(): Y.Map { + return this.getMap(this.KEYS.PAGES); + } + board(): TypedMap { return this.getMap(this.KEYS.BOARD) as unknown as TypedMap; } diff --git a/src/lib/project/project-repository.ts b/src/lib/project/project-repository.ts index bc7f51de..51f79fe9 100644 --- a/src/lib/project/project-repository.ts +++ b/src/lib/project/project-repository.ts @@ -18,6 +18,7 @@ import { import { CharacterMap } from "../screenplay/characters"; import { LocationMap } from "../screenplay/locations"; import { PersistentScene, PersistentSceneMap } from "../screenplay/scenes"; +import { PersistentPage, PersistentPageMap } from "../screenplay/page-locking"; import { PageFormat } from "../utils/enums"; import { generateNodeId } from "../screenplay/nodes"; import { JSONContent } from "@tiptap/react"; @@ -391,6 +392,10 @@ export class ProjectRepository { if (this.guardWrite("setSceneLocking")) return; this.ydoc.production().set("sceneLocking", locked); } + setPageLocking(locked: boolean) { + if (this.guardWrite("setPageLocking")) return; + this.ydoc.production().set("pageLocking", locked); + } setSceneNumberingStyle(style: "suffix" | "prefix") { if (this.guardWrite("setSceneNumberingStyle")) return; this.ydoc.production().set("sceneNumberingStyle", style); @@ -425,6 +430,73 @@ export class ProjectRepository { } } + /** + * Raw persistent page-lock map keyed by anchor data-id (with the + * sentinel `PAGE_ONE_KEY` for page 1). Empty when page locking has + * never been enabled. + */ + get pages(): PersistentPageMap { + return this.ydoc.pages().toJSON() as PersistentPageMap; + } + + getPage(anchorId: string): PersistentPage | undefined { + const map = this.ydoc.pages(); + return map.get(anchorId) as PersistentPage | undefined; + } + + /** + * Create or update a page lock keyed by its anchor data-id. + * Fields present in `data` (including explicit `undefined`s) overwrite + * the existing fields; everything else is preserved. Final undefined + * values are stripped before writing. + */ + upsertPage(anchorId: string, data: Partial): string { + if (this.guardWrite("upsertPage")) return anchorId; + const map = this.ydoc.pages(); + const existing = (map.get(anchorId) as PersistentPage | undefined) ?? {}; + + const merged: PersistentPage = { ...existing }; + const FIELDS = ["token"] as const; + for (const key of FIELDS) { + if (key in data) { + (merged as Record)[key] = data[key]; + } + } + for (const key of FIELDS) { + if (merged[key] === undefined) delete merged[key]; + } + + map.set(anchorId, merged); + return anchorId; + } + + deletePage(anchorId: string): void { + if (this.guardWrite("deletePage")) return; + const map = this.ydoc.pages(); + if (map.has(anchorId)) { + map.delete(anchorId); + } + } + + /** + * Wipe every persistent page-lock entry. Used when the user toggles + * page locking off — pagination reverts to plain integer numbering. + */ + clearPageLocks(): void { + if (this.guardWrite("clearPageLocks")) return; + const map = this.ydoc.pages(); + const keys: string[] = []; + map.forEach((_, key) => keys.push(key)); + for (const key of keys) map.delete(key); + } + + observePages(callback: (pages: PersistentPageMap) => void): () => void { + const map = this.ydoc.pages(); + const observer = () => callback(map.toJSON() as PersistentPageMap); + map.observe(observer); + return () => map.unobserve(observer); + } + /** * Run a function inside a single Y.js transaction. * Useful for batching multiple repository mutations into one collab update. diff --git a/src/lib/screenplay/extensions/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts index fd0fe4c8..52c1b1df 100644 --- a/src/lib/screenplay/extensions/pagination-extension.ts +++ b/src/lib/screenplay/extensions/pagination-extension.ts @@ -6,6 +6,9 @@ import { Node } from "@tiptap/pm/model"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { compareTokens, computeSceneLabels, SceneToken } from "@src/lib/screenplay/scene-locking"; +import { PAGE_ONE_KEY, PersistentPageMap } from "@src/lib/screenplay/page-locking"; + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -33,6 +36,8 @@ interface NodeInfo { type: ScreenplayElement; height: number; positionTop: number; + /** data-id of the top-level node, used by page locking to anchor breaks. */ + dataId?: string; } interface BreakLogic { @@ -108,6 +113,15 @@ export interface PaginationOptions { customFooter: Record; /** Element types that force a page break before them. */ startNewPageTypes: Set; + /** + * Production page-lock getters. When the editor is wired with page + * locking, these expose the live toggle and lock map. Optional so test + * harnesses and benchmarks can keep their lean Pagination.configure calls. + */ + getPageLocking?: () => boolean; + getPageLocks?: () => PersistentPageMap; + /** Letters skipped from generated labels (shared with scene locking). */ + getSkippedLetters?: () => readonly string[]; } export interface PageBreakInfo { @@ -116,6 +130,19 @@ export interface PageBreakInfo { freespace: number; // empty space remaining at the bottom of the ending page's content area contdName: string; // non-empty only for dialogue splits: Character cue name for the (CONT'D) label splitNodeType: ScreenplayElement | null; // non-null when the break is mid-node (sentence split); drives overlay escape + /** data-id of the top-level node that begins the page after this break. + * Set on every non-synthetic break; used by page locking to detect orphan locks. */ + anchorId?: string; + /** True for synthetic breaks that represent an entirely empty (orphan-locked) page. + * The widget renders the empty content area + the next page's chrome on top of + * the normal break chrome. */ + isEmpty?: boolean; + /** Display label for the page beginning after this break (e.g. "4", "4A"). + * Equals String(pagenum) when no page-lock is in effect. */ + label?: string; + /** Display label for the page ending before this break — used by the footer of + * the previous page. Undefined for the first break (footer uses page-1 label). */ + prevLabel?: string; } declare module "@tiptap/core" { @@ -183,27 +210,27 @@ function syncVars(dom: HTMLElement, o: PaginationOptions) { // Decoration builders // --------------------------------------------------------------------------- -function renderHeader(pagenum: number, options: PaginationOptions): string { +function renderHeader(pagenum: number, label: string, options: PaginationOptions): string { const custom = options.customHeader[pagenum]; const left = custom?.headerLeft ?? options.headerLeft; - const right = (custom?.headerRight ?? options.headerRight).replace("{page}", `${pagenum}`); + const right = (custom?.headerRight ?? options.headerRight).replace("{page}", label); if (!left && !right) return ""; return ( `${left}` + `${right}` ); } -function renderFooter(pagenum: number, options: PaginationOptions): string { +function renderFooter(pagenum: number, label: string, options: PaginationOptions): string { const custom = options.customFooter[pagenum]; const left = custom?.footerLeft ?? options.footerLeft; - const right = (custom?.footerRight ?? options.footerRight).replace("{page}", `${pagenum}`); + const right = (custom?.footerRight ?? options.footerRight).replace("{page}", label); if (!left && !right) return ""; return ( `${left}` + `${right}` ); } -function createFirstPageWidget(options: PaginationOptions): HTMLElement { +function createFirstPageWidget(firstPageLabel: string, options: PaginationOptions): HTMLElement { const container = document.createElement("div"); container.className = "pagination-first-page"; container.contentEditable = "false"; @@ -220,7 +247,7 @@ function createFirstPageWidget(options: PaginationOptions): HTMLElement { const headerArea = document.createElement("div"); headerArea.className = "pagination-header-area"; headerArea.style.height = `${options.marginTop}px`; - headerArea.innerHTML = renderHeader(1, options); + headerArea.innerHTML = renderHeader(1, firstPageLabel, options); overlay.appendChild(headerArea); container.appendChild(spacer); @@ -244,9 +271,23 @@ function createPageBreakWidget(breakInfo: PageBreakInfo, options: PaginationOpti container.className = "pagination-page-break"; container.contentEditable = "false"; + const contentHeight = options.pageHeight - options.marginTop - options.marginBottom; + const isEmpty = !!breakInfo.isEmpty; + + // Empty (orphan-locked) pages append `contentHeight` worth of blank + // content to the normal break chrome — the prev→empty transition is + // rendered here (footer of prev, gap, header of the empty page, then + // the empty content area). The empty→next transition is handled by + // the break that follows this one in the breaks array (a lock force- + // break, or a subsequent orphan synthetic, or the last-page widget). + // Splitting it this way keeps each page transition rendered exactly + // once and lets the synthetic absorb the previous page's freespace. + const emptyPageExtension = isEmpty ? contentHeight : 0; + // Spacer: pushes text in the document flow past the entire page boundary. // Includes freespace because the spacer is the only thing that moves text. - const spacerHeight = breakInfo.freespace + options.marginBottom + options.pageGap + options.marginTop; + const spacerHeight = + breakInfo.freespace + options.marginBottom + options.pageGap + options.marginTop + emptyPageExtension; const spacer = document.createElement("div"); spacer.className = "pagination-spacer"; spacer.style.height = `${spacerHeight}px`; @@ -270,11 +311,16 @@ function createPageBreakWidget(breakInfo: PageBreakInfo, options: PaginationOpti overlay.style.right = `calc(-1 * ${rightVar})`; } + // Labels for the surrounding pages. Defaults preserve legacy behavior + // (pagenum-1 / pagenum) when no labels were assigned (page locking off). + const prevLabel = breakInfo.prevLabel ?? String(breakInfo.pagenum - 1); + const thisLabel = breakInfo.label ?? String(breakInfo.pagenum); + // Footer area of the ending page (fixed size = marginBottom) const footerArea = document.createElement("div"); footerArea.className = "pagination-footer-area"; footerArea.style.height = `${options.marginBottom}px`; - footerArea.innerHTML = renderFooter(breakInfo.pagenum - 1, options); + footerArea.innerHTML = renderFooter(breakInfo.pagenum - 1, prevLabel, options); // Visual gap between pages (fixed size = pageGap) const divider = document.createElement("div"); @@ -286,12 +332,25 @@ function createPageBreakWidget(breakInfo: PageBreakInfo, options: PaginationOpti const headerArea = document.createElement("div"); headerArea.className = "pagination-header-area"; headerArea.style.height = `${options.marginTop}px`; - headerArea.innerHTML = renderHeader(breakInfo.pagenum, options); + headerArea.innerHTML = renderHeader(breakInfo.pagenum, thisLabel, options); overlay.appendChild(footerArea); overlay.appendChild(divider); overlay.appendChild(headerArea); + if (isEmpty) { + // Empty content area for the orphan-locked page. Renders a faint + // label centred in the page so the user can see which locked + // number is being preserved. The empty→next transition (footer of + // this empty page, gap, header of the next page) is rendered by + // the break that follows this synthetic in the breaks array. + const emptyArea = document.createElement("div"); + emptyArea.className = "pagination-empty-page"; + emptyArea.style.height = `${contentHeight}px`; + emptyArea.textContent = thisLabel; + overlay.appendChild(emptyArea); + } + // For dialogue/parenthetical splits: add (MORE) at the end of the current page // and CHARACTER (CONT'D) at the top of the next page. // Both are position:absolute inside the overlay so they don't affect flow layout. @@ -317,7 +376,12 @@ function createPageBreakWidget(breakInfo: PageBreakInfo, options: PaginationOpti return container; } -function createLastPageWidget(pagenum: number, freespace: number, options: PaginationOptions): HTMLElement { +function createLastPageWidget( + pagenum: number, + label: string, + freespace: number, + options: PaginationOptions, +): HTMLElement { const container = document.createElement("div"); container.className = "pagination-last-page"; container.contentEditable = "false"; @@ -335,7 +399,7 @@ function createLastPageWidget(pagenum: number, freespace: number, options: Pagin const footerArea = document.createElement("div"); footerArea.className = "pagination-footer-area"; footerArea.style.height = `${options.marginBottom}px`; - footerArea.innerHTML = renderFooter(pagenum, options); + footerArea.innerHTML = renderFooter(pagenum, label, options); overlay.appendChild(footerArea); container.appendChild(spacer); @@ -347,39 +411,49 @@ function buildDecorations( doc: Node, breaks: PageBreakInfo[], lastPageFreespace: number, + firstPageLabel: string, options: PaginationOptions, ): DecorationSet { const decorations: Decoration[] = []; // First page top margin / header decorations.push( - Decoration.widget(0, createFirstPageWidget(options), { + Decoration.widget(0, createFirstPageWidget(firstPageLabel, options), { side: -1, - key: "page-1-header", + key: `page-1-header-${firstPageLabel}`, }), ); // Page breaks // The key MUST include every value that affects the widget DOM (freespace, - // contdName, splitNodeType) — not just pagenum. ProseMirror's WidgetType.eq - // short-circuits on matching keys and reuses the old DOM element, so a key - // that omits e.g. freespace causes stale spacer heights after content edits. + // contdName, splitNodeType, label, isEmpty) — not just pagenum. ProseMirror's + // WidgetType.eq short-circuits on matching keys and reuses the old DOM element, + // so a key that omits e.g. freespace causes stale spacer heights after content edits. for (const b of breaks) { decorations.push( Decoration.widget(b.pos, createPageBreakWidget(b, options), { side: -1, - key: `pb-${b.pagenum}-${b.freespace}-${b.contdName}-${b.splitNodeType}`, + key: `pb-${b.pagenum}-${b.freespace}-${b.contdName}-${b.splitNodeType}-${b.label ?? ""}-${b.prevLabel ?? ""}-${b.isEmpty ? "E" : ""}`, }), ); } - // Last page bottom margin / footer + // Last page bottom margin / footer. + // Label of the last page = label of the most recent break (or firstPageLabel + // when no breaks exist). const lastPagenum = breaks.length > 0 ? breaks[breaks.length - 1].pagenum : 1; + const lastPageLabel = breaks.length > 0 + ? breaks[breaks.length - 1].label ?? String(lastPagenum) + : firstPageLabel; decorations.push( - Decoration.widget(doc.content.size, createLastPageWidget(lastPagenum, lastPageFreespace, options), { - side: 1, - key: `lp-${lastPagenum}-${lastPageFreespace}`, - }), + Decoration.widget( + doc.content.size, + createLastPageWidget(lastPagenum, lastPageLabel, lastPageFreespace, options), + { + side: 1, + key: `lp-${lastPagenum}-${lastPageLabel}-${lastPageFreespace}`, + }, + ), ); return DecorationSet.create(doc, decorations); @@ -554,6 +628,34 @@ interface PaginationState { decset: DecorationSet; breaks: PageBreakInfo[]; lastPageFreespace: number; + firstPageLabel: string; +} + +/** + * Compute display labels for every page using the same token math that + * powers scene locking. Page 1 is anchored to the sentinel PAGE_ONE_KEY; + * later pages are anchored to the data-id of the top-level node that + * begins them. Returns one label per page (length = breaks.length + 1). + * + * Synthetic empty-page breaks consume one "logical page" each — their + * anchorId comes from the page-lock map, and the page that physically + * follows the empty slot gets its own label slot in the result. + */ +function computePageLabels( + breaks: PageBreakInfo[], + pageLocks: PersistentPageMap, + skippedLetters: readonly string[], +): string[] { + const anchors: string[] = [PAGE_ONE_KEY]; + for (const b of breaks) { + // Empty pages anchor to the orphan lock's anchorId. Real pages anchor + // to the data-id of the top-level node where the page starts. If + // anchorId is somehow missing, fall back to a unique synthetic key + // so the label-computer still produces a usable result. + anchors.push(b.anchorId ?? `__break_${b.pos}_${b.pagenum}__`); + } + const labels = computeSceneLabels(anchors, pageLocks, "suffix", skippedLetters); + return labels.map((l) => l.label); } const createPaginationPlugin = (extension: { options: PaginationOptions; editor: Editor }) => @@ -564,6 +666,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: decset: DecorationSet.empty, breaks: [], lastPageFreespace: 0, + firstPageLabel: "1", }), apply(tr, value: PaginationState, oldState, newState): PaginationState { const options = extension.options as PaginationOptions; @@ -631,6 +734,21 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: const serializer = DOMSerializer.fromSchema(newState.schema); + // --- Page-lock setup --- + // Hot-path discipline: when locking is off (the common case), + // pageLocks/lockedAnchorIds stay null and the per-node check + // short-circuits on the first `&&` — zero allocations, zero + // map lookups. The set is rebuilt once per pass when locking + // is active; lock counts are typically tens, never thousands. + const pageLocking = options.getPageLocking?.() ?? false; + const pageLocks: PersistentPageMap | null = pageLocking + ? options.getPageLocks?.() ?? null + : null; + const lockedAnchorIds: Set | null = pageLocks + ? new Set(Object.keys(pageLocks).filter((k) => k !== PAGE_ONE_KEY)) + : null; + const skippedLetters = options.getSkippedLetters?.() ?? []; + const contentHeight = options.pageHeight - options.marginTop - options.marginBottom; const breaks: PageBreakInfo[] = []; let pagePos = 0; @@ -673,6 +791,8 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: lastCharName = node.textContent.trim(); } + const dataId: string | undefined = node.attrs["data-id"]; + // --- Force page break for "start new page" elements --- // If this node type is configured to start a new page and we're // not already at the top of a page, insert a break before it. @@ -684,6 +804,24 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: freespace: Math.max(0, freespace), contdName: "", splitNodeType: null, + anchorId: dataId, + }); + pagePos = 0; + lastNodes = new CircularBuffer(3); + } + + // --- Force page break for locked page anchors --- + // O(1) Set.has when locking is on; the leading `lockedAnchorIds &&` + // short-circuits to false when locking is disabled — hot-path safe. + if (lockedAnchorIds && dataId && pagePos > 0 && lockedAnchorIds.has(dataId)) { + const freespace = contentHeight - pagePos; + breaks.push({ + pos, + pagenum: ++pagenum, + freespace: Math.max(0, freespace), + contdName: "", + splitNodeType: null, + anchorId: dataId, }); pagePos = 0; lastNodes = new CircularBuffer(3); @@ -693,7 +831,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: pagePos += height; // We keep the last 3 nodes for orphan resolution on page break - lastNodes.push({ pos, type: nodeType, height, positionTop: pagePos - height }); + lastNodes.push({ pos, type: nodeType, height, positionTop: pagePos - height, dataId }); // Page break needed — record it and reset page position if (pagePos > contentHeight) { @@ -721,11 +859,13 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: contdName: logic.showMoreContd ? lastCharName : "", // splitNodeType drives the overlay padding-escape in createPageBreakWidget. splitNodeType: nodeType, + // Anchor for page locking: the node being split owns both halves. + anchorId: dataId, }); // The bottom half of the split node is the first item on the new page. pagePos = split.bottomHeight; lastNodes = new CircularBuffer(3); - lastNodes.push({ pos, type: nodeType, height: split.bottomHeight, positionTop: 0 }); + lastNodes.push({ pos, type: nodeType, height: split.bottomHeight, positionTop: 0, dataId }); continue; // split handled — skip orphan resolution for this node } } @@ -741,6 +881,16 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: for (let back = 1; back <= 2; back++) { const prev = lastNodes.at(back); // at(1) = last fitted, at(2) = one before if (!prev) break; + // A locked anchor owns its page and must never be displaced by + // walkback — otherwise the next overflow would yank it onto an + // A page and the locked frame would lose its head. + if ( + lockedAnchorIds && + prev.dataId && + lockedAnchorIds.has(prev.dataId) + ) { + break; + } if (BREAK_LOGIC[prev.type]?.keepWithNext) { breakPos = prev.pos; carryHeight += prev.height; @@ -766,12 +916,18 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: (firstMovingType === ScreenplayElement.Dialogue || firstMovingType === ScreenplayElement.Parenthetical); + // Anchor = data-id of the first node that moved to the new page. + // When backCount==0 the current node is the one moving (no walkback); + // otherwise the carried-back node from the buffer owns the anchor. + const anchorDataId = backCount === 0 ? dataId : firstMovingNode?.dataId; + const breakInfo: PageBreakInfo = { pos: breakPos, pagenum: pagenum + 1, freespace: Math.max(0, freespace), contdName: isDialogueSplit ? lastCharName : "", splitNodeType: null, + anchorId: anchorDataId, }; breaks.push(breakInfo); pagenum++; @@ -812,26 +968,218 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: } // Compute remaining space on the last page so the last-page widget - // can pad it to full page height. - const lastPageFreespace = Math.max(0, contentHeight - pagePos); + // can pad it to full page height. Mutable because orphan handling + // may consume it: when an orphan synthetic empty page lands at + // doc end, it absorbs this freespace so the last real page stays + // at its full height and the empty page renders after it. + let lastPageFreespace = Math.max(0, contentHeight - pagePos); + + // --- Orphan page handling --- + // A locked page whose anchor data-id is no longer present in the doc + // becomes an "orphan" — we insert a synthetic empty-page break so the + // page still appears in the layout (preserving its locked number). + // Orphans are placed at the doc position of the next surviving lock + // (or doc end) and consume a full content-height of vertical space. + if (pageLocks) { + const seenAnchors = new Set(); + for (const b of breaks) { + if (b.anchorId) seenAnchors.add(b.anchorId); + } + + // Tokens for ordered comparison. Provisional pages (no token in + // the lock map) aren't relevant here — only locked entries can be + // orphans. We need the orphan list in TOKEN order so insertions + // happen at the right spots. + type OrphanEntry = { anchorId: string; token: SceneToken }; + const orphans: OrphanEntry[] = []; + for (const [anchorId, page] of Object.entries(pageLocks)) { + if (anchorId === PAGE_ONE_KEY) continue; + if (!page?.token) continue; + if (seenAnchors.has(anchorId)) continue; + orphans.push({ anchorId, token: page.token }); + } + + if (orphans.length > 0) { + // Build an ordered list of live-locked anchors keyed by token, + // so we can find the "next live lock after orphan X" quickly. + type LiveLock = { anchorId: string; token: SceneToken; pos: number }; + const liveLocks: LiveLock[] = []; + for (const b of breaks) { + if (!b.anchorId) continue; + const lock = pageLocks[b.anchorId]; + if (lock?.token) liveLocks.push({ anchorId: b.anchorId, token: lock.token, pos: b.pos }); + } + liveLocks.sort((a, b) => compareTokens(a.token, b.token)); + orphans.sort((a, b) => compareTokens(a.token, b.token)); + + const docSize = newState.doc.content.size; + + for (const orphan of orphans) { + // Token-gap segment this orphan belongs in: bounded by the + // greatest live lock with a smaller token (prev) and the + // smallest live lock with a larger token (next). Positions + // of those bounding locks define the doc-position window + // where this orphan can be slotted in. + let prevLive: LiveLock | null = null; + let nextLive: LiveLock | null = null; + for (const l of liveLocks) { + if (compareTokens(l.token, orphan.token) < 0) { + if (!prevLive || compareTokens(prevLive.token, l.token) < 0) { + prevLive = l; + } + } else if (compareTokens(l.token, orphan.token) > 0) { + if (!nextLive || compareTokens(l.token, nextLive.token) < 0) { + nextLive = l; + } + } + } + const segmentStart = prevLive?.pos ?? 0; + const segmentEnd = nextLive?.pos ?? docSize; + + // First try to consume an existing provisional break inside + // this segment. That break is the natural overflow from the + // previous page — by re-anchoring it to the orphan, we make + // the overflow content flow INTO the empty deleted-page slot + // (Final Draft-style) instead of producing a phantom A page + // alongside a separately-rendered empty page. + let consumed = false; + for (let j = 0; j < breaks.length; j++) { + const b = breaks[j]; + if (b.pos < segmentStart) continue; + if (b.pos >= segmentEnd) break; + const bLock = b.anchorId ? pageLocks[b.anchorId] : undefined; + if (bLock?.token) continue; // already a locked break — skip + // Provisional in the orphan's segment: reassign anchorId + // so the label flips from "NA" to the orphan's frozen label. + b.anchorId = orphan.anchorId; + liveLocks.push({ + anchorId: orphan.anchorId, + token: orphan.token, + pos: b.pos, + }); + liveLocks.sort((a, b) => compareTokens(a.token, b.token)); + consumed = true; + break; + } + + if (consumed) continue; + + // No provisional to absorb the orphan — fall back to a + // synthetic empty-page break at the segment's end position. + // + // Insert index walks the breaks list. We want the synthetic + // to land at segmentEnd, AFTER any break at the same pos + // whose token is smaller (so multiple orphans at one + // segmentEnd line up in token order: orphan-2, orphan-3, + // then the live lock that bounds the segment). + let insertIdx = breaks.length; + for (let j = 0; j < breaks.length; j++) { + const b = breaks[j]; + if (b.pos > segmentEnd) { + insertIdx = j; + break; + } + if (b.pos === segmentEnd) { + const bLock = b.anchorId ? pageLocks[b.anchorId] : undefined; + if ( + bLock?.token && + compareTokens(orphan.token, bLock.token) < 0 + ) { + insertIdx = j; + break; + } + } + } + + // Freespace transfer: the synthetic empty page's widget + // renders the prev→empty transition (footer of previous + // page + chrome + empty content area). For the previous + // page to keep its full height, the synthetic must absorb + // its bottom freespace. That freespace currently lives on + // the break that the synthetic is being inserted BEFORE + // (either a lock force-break at the same pos, or the last- + // page widget at doc end). Transfer it, then zero out the + // donor — its "previous page" is now the empty synthetic, + // which already gets a full `contentHeight` slot, so no + // additional freespace is needed there. + let syntheticFreespace = 0; + if ( + insertIdx < breaks.length && + breaks[insertIdx].pos === segmentEnd + ) { + syntheticFreespace = breaks[insertIdx].freespace; + breaks[insertIdx].freespace = 0; + } else if (insertIdx === breaks.length) { + // Doc end — the synthetic is the new "last empty page", + // and the existing last-page widget would have padded + // out the freespace below the previous real page. + // Transfer that to the synthetic. + syntheticFreespace = lastPageFreespace; + lastPageFreespace = 0; + } + + const synthetic: PageBreakInfo = { + pos: segmentEnd, + pagenum: 0, // re-numbered below + freespace: syntheticFreespace, + contdName: "", + splitNodeType: null, + anchorId: orphan.anchorId, + isEmpty: true, + }; + breaks.splice(insertIdx, 0, synthetic); + liveLocks.push({ + anchorId: orphan.anchorId, + token: orphan.token, + pos: segmentEnd, + }); + liveLocks.sort((a, b) => compareTokens(a.token, b.token)); + } + + // Renumber pagenums after insertions (synthetic breaks have pagenum: 0). + for (let i = 0; i < breaks.length; i++) { + breaks[i].pagenum = i + 2; // page 1 has no break; first break starts page 2. + } + } + } + + // --- Label assignment --- + // Run computeSceneLabels over [page1Anchor, ...breakAnchors] so locked + // pages keep their frozen labels, provisional inserts get suffix labels + // (e.g. "4A"), and pages past the last lock continue the integer sequence. + let firstPageLabel = "1"; + if (pageLocks) { + const labels = computePageLabels(breaks, pageLocks, skippedLetters); + firstPageLabel = labels[0]; + for (let i = 0; i < breaks.length; i++) { + const label = labels[i + 1]; + const prevLabel = labels[i]; + breaks[i].label = label; + breaks[i].prevLabel = prevLabel; + } + } // Check if breaks actually changed compared to mapped old breaks. const breaksChanged = fullRemeasure || lastPageFreespace !== value.lastPageFreespace || + firstPageLabel !== value.firstPageLabel || breaks.length !== mappedOldBreaks.length || breaks.some( (b, i) => b.pos !== mappedOldBreaks[i].pos || b.freespace !== mappedOldBreaks[i].freespace || - b.contdName !== mappedOldBreaks[i].contdName, + b.contdName !== mappedOldBreaks[i].contdName || + b.label !== mappedOldBreaks[i].label || + b.prevLabel !== mappedOldBreaks[i].prevLabel || + !!b.isEmpty !== !!mappedOldBreaks[i].isEmpty, ); const decset = breaksChanged - ? buildDecorations(newState.doc, breaks, lastPageFreespace, options) + ? buildDecorations(newState.doc, breaks, lastPageFreespace, firstPageLabel, options) : value.decset.map(tr.mapping, tr.doc); - return { decset, breaks, lastPageFreespace }; + return { decset, breaks, lastPageFreespace, firstPageLabel }; }, }, appendTransaction() { @@ -927,6 +1275,20 @@ export const ScriptioPagination = Extension.create({ .pagination-footer-right { text-align: right; } + + .pagination-empty-page { + display: flex; + align-items: center; + justify-content: center; + background: var(--editor-script-bg); + color: var(--secondary-text); + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.08em; + opacity: 0.35; + text-transform: uppercase; + box-sizing: border-box; + } `; document.head.appendChild(style); } @@ -1062,3 +1424,52 @@ export function getPageForPos(editor: Editor, pos: number): number { } return page; } + +/** + * Returns the display label (e.g. "4", "4A") for the page containing + * the given document position. Falls back to the integer pagenum when + * page locking isn't active. + */ +export function getPageLabelForPos(editor: Editor, pos: number): string { + const state = paginationKey.getState(editor.state) as PaginationState | undefined; + if (!state) return "1"; + if (state.breaks.length === 0) return state.firstPageLabel; + let label = state.firstPageLabel; + for (const b of state.breaks) { + if (b.pos > pos) break; + label = b.label ?? String(b.pagenum); + } + return label; +} + +/** + * Returns the ordered list of page anchors for the current document + * (page 1 sentinel first, then the data-id of each subsequent page's + * first top-level node). Used by the ProductionPanel to snapshot the + * current layout when locking pages and to compute provisional labels. + * + * Synthetic empty-page breaks contribute their orphan anchor id, so the + * sequence stays aligned with what the user sees in the editor. + */ +export function getPageAnchors(editor: Editor): string[] { + const state = paginationKey.getState(editor.state) as PaginationState | undefined; + if (!state) return [PAGE_ONE_KEY]; + const out: string[] = [PAGE_ONE_KEY]; + for (const b of state.breaks) { + if (b.anchorId) out.push(b.anchorId); + } + return out; +} + +/** + * Force a pagination recompute. Call when the page-lock map or the + * page-locking toggle changes — layout may shift even though the + * document content did not. + */ +export function refreshPageLocking(editor: Editor | null): void { + if (!editor || !editor.view) return; + const tr = editor.state.tr; + tr.setMeta("forcePaginationUpdate", true); + tr.setMeta("addToHistory", false); + editor.view.dispatch(tr); +} diff --git a/src/lib/screenplay/page-locking.ts b/src/lib/screenplay/page-locking.ts new file mode 100644 index 00000000..5729c932 --- /dev/null +++ b/src/lib/screenplay/page-locking.ts @@ -0,0 +1,28 @@ +/** + * Page-locking primitives. + * + * Page locks are anchored to the top-level node that begins each locked page + * (every screenplay node already carries a stable `data-id`). The first page + * has no anchor node — it is keyed by the sentinel `PAGE_ONE_KEY` so the lock + * map can always describe page 1 explicitly. + * + * Numbering uses the same `SceneToken` machinery as scene locking. We pass + * the ordered list of page anchors to `computeSceneLabels`; locked pages get + * their frozen token, intermediate provisional pages get suffix labels + * ("4A", "4B"), and pages appended after the last lock get the next integer. + * + * Token math, label compilation, and order comparison all live in + * `scene-locking.ts` and are re-used here unchanged. + */ + +import type { SceneToken } from "./scene-locking"; + +/** Sentinel key used for the first page (which has no anchor node). */ +export const PAGE_ONE_KEY = "__page1__"; + +export type PersistentPage = { + /** Frozen structural position under production page-lock. */ + token?: SceneToken; +}; + +export type PersistentPageMap = { [anchorId: string]: PersistentPage }; diff --git a/src/lib/screenplay/popup.ts b/src/lib/screenplay/popup.ts index e9b54903..0cb26320 100644 --- a/src/lib/screenplay/popup.ts +++ b/src/lib/screenplay/popup.ts @@ -25,6 +25,10 @@ export type PopupUnlockScenesData = { confirmUnlock: () => void; }; +export type PopupUnlockPagesData = { + confirmUnlock: () => void; +}; + // ------------------------------ // // GENERIC POPUP // // ------------------------------ // @@ -33,7 +37,8 @@ export type PopupUnionData = | PopupCharacterData | PopupSceneData | PopupUploadToCloudData - | PopupUnlockScenesData; + | PopupUnlockScenesData + | PopupUnlockPagesData; export enum PopupType { NewCharacter, @@ -42,6 +47,7 @@ export enum PopupType { EditScene, UploadToCloud, UnlockScenes, + UnlockPages, } export type PopupData = { @@ -98,3 +104,10 @@ export const unlockScenesPopup = (confirmUnlock: () => void, userCtx: UserContex data: { confirmUnlock }, }); }; + +export const unlockPagesPopup = (confirmUnlock: () => void, userCtx: UserContextType) => { + userCtx.updatePopup({ + type: PopupType.UnlockPages, + data: { confirmUnlock }, + }); +}; diff --git a/src/lib/utils/hooks.ts b/src/lib/utils/hooks.ts index 4d6c8b17..2f8be8f6 100644 --- a/src/lib/utils/hooks.ts +++ b/src/lib/utils/hooks.ts @@ -13,6 +13,7 @@ import { DEFAULT_KEYBINDS, executeKeybindAction, KeybindId } from "./keybinds"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { ProjectRole } from "../../generated/client/browser"; import { isTauri } from "@tauri-apps/api/core"; +import { useTranslations } from "next-intl"; interface Position { x: number; @@ -502,6 +503,34 @@ const useDesktopBridgeAuth = () => { return { completeBridgeAuth }; }; +const useFormatTimestamp = () => { + const t = useTranslations("dates"); + return useCallback( + (ts: number | string | Date): string => { + const date = new Date(ts); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return t("justNow"); + if (diffMins < 60) return t("minutesAgo", { mins: diffMins }); + + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return t("hoursAgo", { hours: diffHours }); + + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 7) return t("daysAgo", { days: diffDays }); + + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, + }); + }, + [t], + ); +}; + export { useDraggable, useUser, @@ -518,4 +547,5 @@ export { useCachedProjectInfo, useProjectIdFromUrl, useDesktopBridgeAuth, + useFormatTimestamp, }; From 000808973b1574a147fcaa7b16c1ce169913159c Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Tue, 26 May 2026 01:36:59 +0200 Subject: [PATCH 6/6] fixed page locking, fixed page numbering in pdf export --- src/lib/adapters/pdf/pdf-adapter.ts | 32 +++++++- src/lib/adapters/pdf/pdf.worker.ts | 23 +++++- .../extensions/pagination-extension.ts | 82 ++++++++++++++++++- 3 files changed, 131 insertions(+), 6 deletions(-) diff --git a/src/lib/adapters/pdf/pdf-adapter.ts b/src/lib/adapters/pdf/pdf-adapter.ts index 245e84e5..b3fb8875 100644 --- a/src/lib/adapters/pdf/pdf-adapter.ts +++ b/src/lib/adapters/pdf/pdf-adapter.ts @@ -153,7 +153,12 @@ export class PDFAdapter extends ProjectAdapter { // ── Direct-child pagination widget → explicit page break ── if (el.classList.contains("pagination-page-break")) { - allLines.push({ runs: [], y: 0, type: "__page_break__" }); + allLines.push({ + runs: [], + y: 0, + type: "__page_break__", + pageLabel: this.extractPageLabel(el), + }); continue; } @@ -224,7 +229,12 @@ export class PDFAdapter extends ProjectAdapter { } // Emit page break sentinel - allLines.push({ runs: [], y: 0, type: "__page_break__" }); + allLines.push({ + runs: [], + y: 0, + type: "__page_break__", + pageLabel: this.extractPageLabel(splitWidget), + }); // Collect lines AFTER the split widget const afterLines = this.collectParagraphLines(el, nodeType, splitWidget, "after"); @@ -574,6 +584,24 @@ export class PDFAdapter extends ProjectAdapter { } } + /** + * Read the user-visible page label out of a `.pagination-page-break` + * widget. The widget renders its destination page's header inside + * `.pagination-header-area > .pagination-header-right` (the configured + * headerRight template, with `{page}` already substituted) — so the + * textContent IS the final label string the user sees. Under page + * locking this string is the frozen "4A." form; otherwise it's the + * default sequential "4.". Returns undefined when no header is + * present so the worker falls back to its integer pageNumber. + */ + private extractPageLabel(widget: HTMLElement): string | undefined { + const right = widget.querySelector( + ".pagination-header-area .pagination-header-right", + ) as HTMLElement | null; + if (!right) return undefined; + return right.textContent?.trim() ?? undefined; + } + // ── VisualLine[] → PDF ─────────────────────────────────────────────────── /** diff --git a/src/lib/adapters/pdf/pdf.worker.ts b/src/lib/adapters/pdf/pdf.worker.ts index 4314db7a..5bd6f7d8 100644 --- a/src/lib/adapters/pdf/pdf.worker.ts +++ b/src/lib/adapters/pdf/pdf.worker.ts @@ -19,6 +19,12 @@ export interface VisualLine { runs: TextRun[]; y: number; // browser Y position in pixels (for line-spacing within a page) type?: string; // e.g. "dialogue", "character", "scene", "__page_break__" + /** Header text for the page that begins AFTER this sentinel. + * Only set on `__page_break__` lines. Carries the user-visible page + * label ("4.", "4A.", a custom-templated string) read straight from + * the pagination widget's DOM, so page-lock labels propagate to PDF + * exports unchanged. */ + pageLabel?: string; } /** Font file descriptor for registration in jsPDF. */ @@ -284,7 +290,7 @@ async function renderLines( // Page number header on pages 2+ if (showPageNumbers) { - drawPageHeader(doc, currentPage, pageSize); + drawPageHeader(doc, currentPage, pageSize, line.pageLabel); } // Draw Character Name (CONT'D) at the top of the new page @@ -415,12 +421,23 @@ async function drawMultiFontText( } } -function drawPageHeader(doc: jsPDF, pageNumber: number, pageSize: { width: number; height: number }): void { +function drawPageHeader( + doc: jsPDF, + pageNumber: number, + pageSize: { width: number; height: number }, + label?: string, +): void { if (pageNumber <= 1) return; + // Prefer the label captured from the pagination widget — under page + // locking this carries the frozen "4A." style label, otherwise it's the + // sequential "4." rendered by the default headerRight template. An empty + // string means the editor's custom header is intentionally blank (e.g. + // page 1's customHeader override) — honour that and skip drawing. + const text = label ?? `${pageNumber}.`; + if (!text) return; doc.setFont("CourierPrime", "normal"); doc.setFontSize(FONT_SIZE); doc.setTextColor(0, 0, 0); - const text = `${pageNumber}.`; doc.text(text, pageSize.width - PAGE_RIGHT, HEADER_Y, { align: "right", baseline: "top", diff --git a/src/lib/screenplay/extensions/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts index 52c1b1df..108f0166 100644 --- a/src/lib/screenplay/extensions/pagination-extension.ts +++ b/src/lib/screenplay/extensions/pagination-extension.ts @@ -3,8 +3,9 @@ import { CircularBuffer } from "@src/lib/utils/circular-buffer"; import { ScreenplayElement } from "@src/lib/utils/enums"; import { Editor, Extension } from "@tiptap/core"; import { Node } from "@tiptap/pm/model"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { ySyncPluginKey } from "@tiptap/y-tiptap"; import { compareTokens, computeSceneLabels, SceneToken } from "@src/lib/screenplay/scene-locking"; import { PAGE_ONE_KEY, PersistentPageMap } from "@src/lib/screenplay/page-locking"; @@ -1185,6 +1186,46 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: appendTransaction() { return null; }, + filterTransaction(tr) { + // Page-lock guard: prevent content from spilling upward out of a + // locked page. The signature of that spill — Backspace at the + // start of a locked anchor, Delete at the end of the node before + // it, or a selection delete that swallows the anchor — is the + // locked anchor's data-id disappearing from the top-level node + // list. If that would happen, reject the transaction so the + // cursor stays put and no content moves across the lock. + if (!tr.docChanged) return true; + + // Allow yjs sync (remote updates and yjs-based undo/redo) through + // unconditionally — cross-peer consistency must not be blocked by + // local lock enforcement, and the lock map itself lives in the + // Yjs doc so peers agree on which pages are locked. + if (tr.getMeta(ySyncPluginKey)) return true; + + const opts = extension.options as PaginationOptions; + if (!opts.getPageLocking?.()) return true; + + const pageLocks = opts.getPageLocks?.(); + if (!pageLocks) return true; + + // PAGE_ONE_KEY has no node to defend — page 1 can't lose its + // lock through doc edits. + const lockedAnchors: string[] = []; + for (const key of Object.keys(pageLocks)) { + if (key !== PAGE_ONE_KEY) lockedAnchors.push(key); + } + if (lockedAnchors.length === 0) return true; + + const present = new Set(); + tr.doc.forEach((node) => { + const dataId = node.attrs?.["data-id"]; + if (typeof dataId === "string") present.add(dataId); + }); + for (const anchor of lockedAnchors) { + if (!present.has(anchor)) return false; + } + return true; + }, props: { decorations(state) { return (paginationKey.getState(state) as PaginationState)?.decset ?? DecorationSet.empty; @@ -1316,6 +1357,45 @@ export const ScriptioPagination = Extension.create({ return [createPaginationPlugin(this)]; }, + addKeyboardShortcuts() { + return { + Backspace: ({ editor }) => { + // joinBackward has a variant — joinMaybeClear — that deletes + // the PREVIOUS block instead of the current one. It fires when + // both blocks are empty. If that previous block is a locked + // page anchor, the plugin's filterTransaction rejects the + // resulting transaction (the anchor's data-id would go + // missing), and the user sees the cursor stuck on the second + // empty line. Patch the case by deleting the current empty + // block ourselves and parking the cursor inside the preserved + // anchor — the natural "step up one line" behavior. + const { state, view } = editor; + const { $from, empty } = state.selection; + if (!empty || $from.parentOffset !== 0) return false; + if ($from.parent.textContent.length !== 0) return false; + + const opts = this.options as PaginationOptions; + if (!opts.getPageLocking?.()) return false; + const pageLocks = opts.getPageLocks?.(); + if (!pageLocks) return false; + + const curStart = $from.before(); + if (curStart === 0) return false; + + const prev = state.doc.resolve(curStart).nodeBefore; + if (!prev || prev.textContent.length !== 0) return false; + + const prevDataId = prev.attrs?.["data-id"]; + if (typeof prevDataId !== "string" || !pageLocks[prevDataId]) return false; + + const tr = state.tr.delete(curStart, $from.after()); + tr.setSelection(TextSelection.create(tr.doc, curStart - 1)); + view.dispatch(tr); + return true; + }, + }; + }, + addCommands() { return { updatePageSize: