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")}

+
+ + {/* Scene Locking */} +
+
+
+ + {t("sceneLocking")} +
+
+ {sceneLocking && provisionalLabels.length > 0 && ( + + )} + +
+
+ + {sceneLocking && provisionalLabels.length > 0 && ( +
+
{t("provisionalTitle")}
+
+ {provisionalLabels.map((l, idx) => ( + + {l.label} + + ))} +
+
+ )} +
+ + {/* Page Locking */} +
+
+
+ + {t("pageLocking")} +
+
+ {pageLocking && provisionalPageLabels.length > 0 && ( + + )} + +
+
+ + {pageLocking && provisionalPageLabels.length > 0 && ( +
+
{t("pageProvisionalTitle")}
+
+ {provisionalPageLabels.map((l, idx) => ( + + {l.label} + + ))} +
+
+ )} +
+ + {/* 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/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.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..f1e40989 100644 --- a/components/popup/Popup.tsx +++ b/components/popup/Popup.tsx @@ -7,12 +7,16 @@ import { PopupImportFileData, PopupSceneData, PopupType, + PopupUnlockPagesData, + PopupUnlockScenesData, PopupUploadToCloudData, } from "@src/lib/screenplay/popup"; 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"; export const Popup = () => { @@ -30,6 +34,10 @@ export const Popup = () => { return )} />; case PopupType.UploadToCloud: return )} />; + case PopupType.UnlockScenes: + return )} />; + case PopupType.UnlockPages: + 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/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/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..889c2462 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,31 @@ "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", + "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.", + "suffixName": "Suffix", + "prefixName": "Präfix", + "skippedLettersTitle": "Übersprungene Buchstaben", + "skippedLettersHelp": "Buchstaben, die bei der Szenennummerierung weggelassen werden, meist um Verwechslungen zu vermeiden.", + "skipLetterAriaLabel": "Buchstabe {letter} überspringen" + }, "saves": { "title": "Versionsverlauf", "saveCurrentVersion": "Aktuelle Version speichern", diff --git a/messages/en.json b/messages/en.json index 797eb080..24107f8a 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,31 @@ "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", + "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.", + "suffixName": "Suffix", + "prefixName": "Prefix", + "skippedLettersTitle": "Skipped letters", + "skippedLettersHelp": "Letters to omit when generating scene numbers usually to avoid confusion.", + "skipLetterAriaLabel": "Skip letter {letter}" + }, "saves": { "title": "Version History", "saveCurrentVersion": "Save Current Version", diff --git a/messages/es.json b/messages/es.json index 0cda76fd..4f1fb4b1 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,31 @@ "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", + "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.", + "suffixName": "Sufijo", + "prefixName": "Prefijo", + "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", "saveCurrentVersion": "Guardar versión actual", diff --git a/messages/fr.json b/messages/fr.json index 5253634c..2a41c334 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,31 @@ "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", + "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.", + "suffixName": "Suffixe", + "prefixName": "Préfixe", + "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", "saveCurrentVersion": "Enregistrer la version actuelle", diff --git a/messages/ja.json b/messages/ja.json index 2beb8a09..f8b5f921 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,31 @@ "titlePlaceholder": "タイトル", "descriptionPlaceholder": "説明" }, + "production": { + "title": "プロダクション", + "sceneLocking": "シーンロック", + "pageLocking": "ページロック", + "revisions": "改訂", + "relock": "ロック", + "provisionalTitle": "未ロックのシーン", + "unlockTitle": "シーン番号のロックを解除しますか?", + "unlockWarning": "ロック済みおよび省略されたシーンの固定番号はすべて失われます。", + "unlockInfo": "この操作はすべてのコラボレーターに影響します。脚本は位置ベースの番号に戻ります。OMITTED シーンは省略のまま残ります — 内容を復元したい場合は個別に省略を解除してください。", + "unlock": "ロック解除", + "pageRelock": "再ロック", + "pageProvisionalTitle": "未ロックのページ", + "unlockPagesTitle": "ページのロックを解除", + "unlockPagesWarning": "すべてのページのロック番号が失われ、自然なページ番号付けに戻ります。", + "unlockPages": "ページのロック解除", + "cancel": "キャンセル", + "numberingStyleTitle": "シーン番号", + "numberingStyleHelp": "ロック済みの2つのシーンの間に挿入された新しいシーンの番号付け方法を決定します。", + "suffixName": "サフィックス", + "prefixName": "プレフィックス", + "skippedLettersTitle": "スキップする文字", + "skippedLettersHelp": "シーン番号生成時に省略する文字。通常は混同を避けるために使用します。", + "skipLetterAriaLabel": "{letter} をスキップ" + }, "saves": { "title": "バージョン履歴", "saveCurrentVersion": "現在のバージョンを保存", diff --git a/messages/ko.json b/messages/ko.json index c9edda13..5e3b911f 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,31 @@ "titlePlaceholder": "제목", "descriptionPlaceholder": "설명" }, + "production": { + "title": "프로덕션", + "sceneLocking": "씬 잠금", + "pageLocking": "페이지 잠금", + "revisions": "개정", + "relock": "잠그기", + "provisionalTitle": "잠기지 않은 씬", + "unlockTitle": "씬 번호 잠금을 해제하시겠습니까?", + "unlockWarning": "잠긴 씬과 생략된 씬의 고정 번호가 모두 사라집니다.", + "unlockInfo": "이 작업은 모든 협업자에게 영향을 미칩니다. 시나리오는 위치 기반 씬 번호로 되돌아갑니다. OMITTED 씬은 그대로 유지되며, 내용을 복원하려면 개별적으로 생략을 해제하세요.", + "unlock": "잠금 해제", + "pageRelock": "다시 잠그기", + "pageProvisionalTitle": "잠기지 않은 페이지", + "unlockPagesTitle": "페이지 잠금 해제", + "unlockPagesWarning": "모든 페이지의 잠긴 번호가 사라지고 자연스러운 페이지 번호로 되돌아갑니다.", + "unlockPages": "페이지 잠금 해제", + "cancel": "취소", + "numberingStyleTitle": "씬 번호", + "numberingStyleHelp": "잠긴 두 씬 사이에 새로 삽입된 씬의 번호 매김 방식을 결정합니다.", + "suffixName": "접미사", + "prefixName": "접두사", + "skippedLettersTitle": "건너뛸 문자", + "skippedLettersHelp": "씬 번호를 생성할 때 제외할 문자입니다. 보통 혼동을 피하기 위해 사용합니다.", + "skipLetterAriaLabel": "{letter} 문자 건너뛰기" + }, "saves": { "title": "버전 히스토리", "saveCurrentVersion": "현재 버전 저장", diff --git a/messages/pl.json b/messages/pl.json index 72f512bf..c78feb68 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,31 @@ "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", + "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.", + "suffixName": "Sufiks", + "prefixName": "Prefiks", + "skippedLettersTitle": "Pomijane litery", + "skippedLettersHelp": "Litery pomijane podczas generowania numerów scen, zwykle aby uniknąć pomyłek.", + "skipLetterAriaLabel": "Pomiń literę {letter}" + }, "saves": { "title": "Historia wersji", "saveCurrentVersion": "Zapisz aktualną wersję", diff --git a/messages/zh.json b/messages/zh.json index ea963b3f..b1fe3406 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,31 @@ "titlePlaceholder": "标题", "descriptionPlaceholder": "描述" }, + "production": { + "title": "制作", + "sceneLocking": "锁定场景", + "pageLocking": "锁定页面", + "revisions": "修订", + "relock": "锁定", + "provisionalTitle": "未锁定的场景", + "unlockTitle": "解锁场景编号?", + "unlockWarning": "所有锁定和省略的场景都将失去固定编号。", + "unlockInfo": "此操作会影响所有协作者。剧本将恢复为按位置编号。OMITTED 场景仍保持省略状态 — 如需恢复内容,请单独取消省略。", + "unlock": "解锁", + "pageRelock": "重新锁定", + "pageProvisionalTitle": "未锁定的页面", + "unlockPagesTitle": "解锁页面?", + "unlockPagesWarning": "所有页面将失去锁定的编号,恢复为自然分页。", + "unlockPages": "解锁页面", + "cancel": "取消", + "numberingStyleTitle": "场景编号", + "numberingStyleHelp": "决定两个锁定场景之间新插入场景的编号方式。", + "suffixName": "后缀", + "prefixName": "前缀", + "skippedLettersTitle": "跳过的字母", + "skippedLettersHelp": "生成场景编号时省略的字母,通常用于避免混淆。", + "skipLetterAriaLabel": "跳过字母 {letter}" + }, "saves": { "title": "版本历史", "saveCurrentVersion": "保存当前版本", diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index f13d1f2b..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"; @@ -20,10 +21,12 @@ import { CollaboratorInfo, ConnectionStatus, LayoutData, + ProductionData, useProjectYjs, ElementStyle, PageMargin, DEFAULT_PAGE_MARGINS, + DEFAULT_SKIPPED_SCENE_LETTERS, ShelfEntry, ProjectStatus, } from "@src/lib/project/project-state"; @@ -97,6 +100,26 @@ export interface ProjectContextType { elementStyles: Record; setElementStyles: (styles: Record) => void; + // Production + sceneLocking: boolean; + 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. */ + 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; @@ -173,6 +196,16 @@ const defaultContextValue: ProjectContextType = { setElementMargins: () => {}, elementStyles: {}, setElementStyles: () => {}, + sceneLocking: false, + setSceneLocking: () => {}, + sceneNumberingStyle: "suffix", + setSceneNumberingStyle: () => {}, + skippedSceneLetters: DEFAULT_SKIPPED_SCENE_LETTERS, + setSkippedSceneLetters: () => {}, + persistentScenes: {}, + pageLocking: false, + setPageLocking: () => {}, + persistentPages: {}, characters: {}, locations: {}, scenes: [], @@ -292,6 +325,14 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = Record >({}); const [elementStyles, setElementStylesState] = useState>({}); + 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 [pageLocking, setPageLockingState] = useState(false); + const [persistentPages, setPersistentPagesState] = useState({}); const [connectionStatus, setConnectionStatus] = useState("disconnected"); const [users, setUsers] = useState([]); @@ -471,6 +512,27 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = } } + // 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 (initialProduction.skippedSceneLetters !== undefined) { + setSkippedSceneLettersState(initialProduction.skippedSceneLetters); + } + if (initialProduction.pageLocking !== undefined) { + setPageLockingState(initialProduction.pageLocking); + } + } + + // Read initial persistent scenes & pages + setPersistentScenesState(repository.scenes); + setPersistentPagesState(repository.pages); + // Observe layout changes const unsubscribeLayout = repository.observeLayout((layout: Partial) => { const _pageSize = layout.pageSize; @@ -511,6 +573,27 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = } }); + // Observe production changes + const unsubscribeProduction = repository.observeProduction((production: Partial) => { + if (production.sceneLocking !== undefined) { + setSceneLockingState(production.sceneLocking); + } + if (production.sceneNumberingStyle !== undefined) { + setSceneNumberingStyleState(production.sceneNumberingStyle); + } + 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 const unsubscribeCharacters = repository.observeCharacters((_characters: CharacterMap) => { const currentScreenplay = repository.screenplay; @@ -530,6 +613,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) @@ -551,6 +635,8 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = return () => { repository.unregisterScreenplayCallback(recomputeFromScreenplay); unsubscribeLayout(); + unsubscribeProduction(); + unsubscribePages(); unsubscribeCharacters(); unsubscribeLocations(); unsubscribeScenes(); @@ -707,6 +793,38 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = [repository], ); + const setSceneLocking = useCallback( + (locked: boolean) => { + setSceneLockingState(locked); + repository?.setSceneLocking(locked); + }, + [repository], + ); + + const setPageLocking = useCallback( + (locked: boolean) => { + setPageLockingState(locked); + repository?.setPageLocking(locked); + }, + [repository], + ); + + const setSceneNumberingStyle = useCallback( + (style: "suffix" | "prefix") => { + setSceneNumberingStyleState(style); + repository?.setSceneNumberingStyle(style); + }, + [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 @@ -791,6 +909,16 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = setElementMargins, elementStyles, setElementStyles, + sceneLocking, + setSceneLocking, + sceneNumberingStyle, + setSceneNumberingStyle, + skippedSceneLetters, + setSkippedSceneLetters, + persistentScenes, + pageLocking, + setPageLocking, + persistentPages, screenplay, scenes, updateScenes, @@ -855,6 +983,16 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = setElementMargins, elementStyles, setElementStyles, + sceneLocking, + setSceneLocking, + sceneNumberingStyle, + setSceneNumberingStyle, + skippedSceneLetters, + setSkippedSceneLetters, + persistentScenes, + pageLocking, + setPageLocking, + persistentPages, screenplay, scenes, updateScenes, diff --git a/src/lib/adapters/pdf/pdf-adapter.ts b/src/lib/adapters/pdf/pdf-adapter.ts index 36ca1821..b3fb8875 100644 --- a/src/lib/adapters/pdf/pdf-adapter.ts +++ b/src/lib/adapters/pdf/pdf-adapter.ts @@ -65,13 +65,18 @@ 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]; + // 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; @@ -148,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; } @@ -164,6 +174,20 @@ 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 + ? { + 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 let nodeType: string | undefined; @@ -200,12 +224,17 @@ 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); } // 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"); @@ -224,7 +253,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 +399,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 +500,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 +525,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, @@ -537,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/adapters/scriptio/scriptio-adapter.ts b/src/lib/adapters/scriptio/scriptio-adapter.ts index 21464087..fbb61d66 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"; @@ -76,9 +76,11 @@ 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, + production: project.production().toJSON() as ProductionData, comments: project.comments().toJSON(), }; payload = new TextEncoder().encode(JSON.stringify(data, null, 2)); @@ -126,9 +128,11 @@ 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, + production: tmpDoc.production().toJSON() as ProductionData, comments: tmpDoc.comments().toJSON(), }; } catch (error) { @@ -171,6 +175,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/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..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 { @@ -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,12 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum setSearchMatches, contdLabel, moreLabel, + sceneLocking, + sceneNumberingStyle, + skippedSceneLetters, + persistentScenes, + pageLocking, + persistentPages, } = projectCtx; const projectState = repository?.getState(); @@ -163,6 +173,12 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum searchFilters, currentSearchIndex, setSearchMatches, + sceneLocking, + sceneNumberingStyle, + skippedSceneLetters, + persistentScenes, + pageLocking, + persistentPages, // eslint-disable-next-line react-hooks/exhaustive-deps }), []); ext.highlightedCharacters = highlightedCharacters; @@ -176,6 +192,12 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum ext.searchFilters = searchFilters; ext.currentSearchIndex = currentSearchIndex; ext.setSearchMatches = setSearchMatches; + ext.sceneLocking = sceneLocking; + ext.sceneNumberingStyle = sceneNumberingStyle; + ext.skippedSceneLetters = skippedSceneLetters; + ext.persistentScenes = persistentScenes; + ext.pageLocking = pageLocking; + ext.persistentPages = persistentPages; const lastReportedElementRef = useRef(null); @@ -251,6 +273,15 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum }) : null; + const sceneLockingExtension = features.sceneLocking + ? createSceneLockingExtension({ + getSceneLocking: () => !!ext.sceneLocking, + getScenes: () => ext.repository?.scenes ?? {}, + getNumberingStyle: () => ext.sceneNumberingStyle ?? "suffix", + getSkippedLetters: () => ext.skippedSceneLetters ?? [], + }) + : null; + const commentMarkExtension = features.comments ? CommentMark.configure({ onCommentActivated: (commentId: string | null) => { @@ -337,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, @@ -363,6 +397,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum ...(characterHighlightExtension ? [characterHighlightExtension] : []), ...(searchHighlightExtension ? [searchHighlightExtension] : []), ...(sceneBookmarkExtension ? [sceneBookmarkExtension] : []), + ...(sceneLockingExtension ? [sceneLockingExtension] : []), ...(nodeIdDedupExtension ? [nodeIdDedupExtension] : []), ...(spellcheckExtension ? [spellcheckExtension] : []), ], @@ -569,6 +604,23 @@ 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, 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 936c2c7f..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"; // -------------------------------- // @@ -103,6 +104,44 @@ export type LayoutData = { elementStyles: Record; }; +// -------------------------------- // +// PRODUCTION // +// -------------------------------- // + +export type ProductionData = { + 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"; + /** + * 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[]; + /** + * 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. */ +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 // // -------------------------------- // @@ -138,10 +177,12 @@ export type ProjectData = { titlepage?: JSONContent[]; characters: Record; scenes: Record; + pages: Record; locations: Record; metadata: ProjectMetadata; board: BoardData; layout: LayoutData; + production: ProductionData; comments?: Record; shelf?: Record; }; @@ -172,10 +213,12 @@ export class ProjectState extends Y.Doc { TITLEPAGE: "titlepage", CHARACTERS: "characters", SCENES: "scenes", + PAGES: "pages", LOCATIONS: "locations", METADATA: "metadata", BOARD: "board", LAYOUT: "layout", + PRODUCTION: "production", COMMENTS: "comments", DICTIONARY: "dictionary", SHELF: "shelf", @@ -215,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; } @@ -223,6 +270,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 5402e11e..51f79fe9 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, @@ -17,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"; @@ -244,20 +246,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; } @@ -361,6 +373,139 @@ export class ProjectRepository { 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.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); + } + setSkippedSceneLetters(letters: string[]) { + if (this.guardWrite("setSkippedSceneLetters")) return; + this.ydoc.production().set("skippedSceneLetters", letters); + } + + /** + * 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); + } + } + } + + /** + * 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. + */ + transact(fn: () => void): void { + if (this.guardWrite("transact")) return; + this.ydoc.transact(fn); + } + // -------------------------------- // // COMMENTS // // -------------------------------- // 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/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/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts index fd0fe4c8..108f0166 100644 --- a/src/lib/screenplay/extensions/pagination-extension.ts +++ b/src/lib/screenplay/extensions/pagination-extension.ts @@ -3,8 +3,12 @@ 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"; // --------------------------------------------------------------------------- // Constants @@ -33,6 +37,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 +114,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 +131,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 +211,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 +248,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 +272,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 +312,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 +333,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 +377,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 +400,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 +412,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 +629,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 +667,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 +735,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 +792,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 +805,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 +832,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 +860,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 +882,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 +917,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,31 +969,263 @@ 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() { 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; @@ -927,6 +1316,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); } @@ -954,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: @@ -1062,3 +1504,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/extensions/scene-locking-extension.ts b/src/lib/screenplay/extensions/scene-locking-extension.ts new file mode 100644 index 00000000..fec51525 --- /dev/null +++ b/src/lib/screenplay/extensions/scene-locking-extension.ts @@ -0,0 +1,261 @@ +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; + getSkippedLetters: () => readonly string[]; +}; + +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 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, + 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. + if (!locking && !hasAnyOmitted(scenes)) return DecorationSet.empty; + + 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, + skippedLetters, + ); + + 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, getSkippedLetters } = config; + + return [ + new Plugin({ + key: sceneLockingPluginKey, + state: { + init(_, { doc }) { + return computeDecorations( + doc, + getSceneLocking(), + getScenes(), + getNumberingStyle(), + getSkippedLetters(), + ); + }, + 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(), + getSkippedLetters(), + ); + } + + 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(), + getSkippedLetters(), + ); + }, + }, + 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/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 fcc6c681..0cb26320 100644 --- a/src/lib/screenplay/popup.ts +++ b/src/lib/screenplay/popup.ts @@ -21,6 +21,14 @@ export type PopupUploadToCloudData = { projectId: string; }; +export type PopupUnlockScenesData = { + confirmUnlock: () => void; +}; + +export type PopupUnlockPagesData = { + confirmUnlock: () => void; +}; + // ------------------------------ // // GENERIC POPUP // // ------------------------------ // @@ -28,7 +36,9 @@ export type PopupUnionData = | PopupImportFileData | PopupCharacterData | PopupSceneData - | PopupUploadToCloudData; + | PopupUploadToCloudData + | PopupUnlockScenesData + | PopupUnlockPagesData; export enum PopupType { NewCharacter, @@ -36,6 +46,8 @@ export enum PopupType { ImportFile, EditScene, UploadToCloud, + UnlockScenes, + UnlockPages, } export type PopupData = { @@ -85,3 +97,17 @@ export const uploadToCloudPopup = (projectId: string, userCtx: UserContextType) data: { projectId }, }); }; + +export const unlockScenesPopup = (confirmUnlock: () => void, userCtx: UserContextType) => { + userCtx.updatePopup({ + type: PopupType.UnlockScenes, + data: { confirmUnlock }, + }); +}; + +export const unlockPagesPopup = (confirmUnlock: () => void, userCtx: UserContextType) => { + userCtx.updatePopup({ + type: PopupType.UnlockPages, + data: { confirmUnlock }, + }); +}; diff --git a/src/lib/screenplay/scene-locking.ts b/src/lib/screenplay/scene-locking.ts new file mode 100644 index 00000000..cc14d023 --- /dev/null +++ b/src/lib/screenplay/scene-locking.ts @@ -0,0 +1,477 @@ +/** + * 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"; + +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 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 = ""; + while (n > 0) { + const m = (n - 1) % base; + const ch = alphabet[m]; + out = (lower ? ch.toLowerCase() : ch) + out; + n = Math.floor((n - 1) / base); + } + 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, 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, 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, alphabet); + } + 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: 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. */ +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, + alphabetSize: number, +): SceneToken | null => { + const fromLevels = levelsOf(from, axis); + const targetLevels = levelsOf(target, axis); + const bound = wedgeBound(axis, alphabetSize); + 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, + alphabetSize: number, +): 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", alphabetSize) : 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", alphabetSize) : 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", + skippedLetters: readonly string[] = [], +): SceneLabel[] => { + const alphabet = buildSceneAlphabet(skippedLetters); + const alphabetSize = alphabet.length; + 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, alphabet), + status: entry.omitted ? "omitted" : "locked", + }; + continue; + } + + const token = computeProvisionalToken( + prevLocked[i], + nextLocked[i], + segmentIdx[i], + style, + alphabetSize, + ); + result[i] = { + uuid, + token, + label: compileSceneLabel(token, alphabet), + 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/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; } 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, }; 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;