);
diff --git a/components/utils/ColorPicker.module.css b/components/utils/ColorPicker.module.css
index 784bb45..3ae2946 100644
--- a/components/utils/ColorPicker.module.css
+++ b/components/utils/ColorPicker.module.css
@@ -6,7 +6,7 @@
.trigger {
width: 26px;
height: 26px;
- border-radius: 6px;
+ border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
@@ -20,7 +20,7 @@
}
.trigger_empty {
- border: 3px solid var(--secondary-text);
+ border: 4px solid var(--secondary-text);
position: relative;
}
diff --git a/components/utils/Form.module.css b/components/utils/Form.module.css
index 3f1571f..1181c45 100644
--- a/components/utils/Form.module.css
+++ b/components/utils/Form.module.css
@@ -112,6 +112,7 @@
font-size: 1.15rem;
border-radius: 7px;
padding: 8px;
+ background-color: var(--secondary);
}
.input::placeholder {
diff --git a/components/utils/ModalBtn.module.css b/components/utils/ModalBtn.module.css
index ad9928a..2d38fd1 100644
--- a/components/utils/ModalBtn.module.css
+++ b/components/utils/ModalBtn.module.css
@@ -3,16 +3,22 @@
align-items: center;
justify-content: center;
gap: 8px;
+ width: 100%;
padding: 10px 16px;
border-radius: 40px;
border: none;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
+ font-weight: bold;
transition: opacity 0.2s ease;
- transform: translateZ(0);
backface-visibility: hidden;
- width: 100%;
+ background-color: var(--secondary);
+ color: var(--primary-text);
+}
+
+.modalBtn:hover:not(:disabled) {
+ opacity: 0.85;
}
.modalBtn:disabled {
@@ -22,6 +28,7 @@
.modalBtnDanger {
background: var(--error);
+ font-weight: bold;
color: white;
}
@@ -37,6 +44,5 @@
}
.modalBtnCancel:hover:not(:disabled) {
- color: var(--primary-text);
- border-color: var(--primary-text);
+ opacity: 0.7;
}
diff --git a/messages/de.json b/messages/de.json
index 889c246..1a6df76 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -383,7 +383,7 @@
"paste": "Einfügen",
"highlight": "Hervorheben",
"addCharacter": "Charakter hinzufügen",
- "addComment": "Kommentar hinzufügen",
+ "addComment": "Kommentieren",
"searchOnWeb": "Im Web suchen",
"noSuggestions": "Keine Vorschläge",
"addToDictionary": "Zum Wörterbuch hinzufügen",
@@ -451,10 +451,16 @@
},
"production": {
"title": "Produktion",
+ "draftLocking": "Entwurf sperren",
+ "draftRelock": "Erneut sperren",
+ "settings": "Produktionseinstellungen",
+ "unlockDraftTitle": "Entwurf entsperren",
+ "unlockDraftWarning": "Sowohl Szenen als auch Seiten verlieren ihre gesperrte Nummerierung und kehren zu ihrer natürlichen Nummerierung zurück.",
+ "unlockDraft": "Entwurf entsperren",
"sceneLocking": "Szenen sperren",
"pageLocking": "Seiten sperren",
"revisions": "Revisionen",
- "relock": "Sperren",
+ "relock": "Erneut sperren",
"provisionalTitle": "Nicht gesperrte Szenen",
"unlockTitle": "Szenennummern entsperren?",
"unlockWarning": "Alle gesperrten und ausgelassenen Szenen verlieren ihre fixierte Nummerierung.",
diff --git a/messages/en.json b/messages/en.json
index 24107f8..fa5cecd 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -382,10 +382,10 @@
"paste": "Paste",
"highlight": "Highlight",
"addCharacter": "Add character",
- "addComment": "Add Comment",
+ "addComment": "Comment",
"searchOnWeb": "Search on web",
"noSuggestions": "No suggestions",
- "addToDictionary": "Add to Dictionary",
+ "addToDictionary": "Add to dictionary",
"makeDualDialogue": "Make dual dialogue",
"shelve": "Shelve",
"shelveScene": "Shelve scene",
@@ -450,6 +450,12 @@
},
"production": {
"title": "Production",
+ "draftLocking": "Draft locking",
+ "draftRelock": "Relock",
+ "settings": "Production settings",
+ "unlockDraftTitle": "Unlock draft",
+ "unlockDraftWarning": "Both scenes and pages will lose their locked numbering, reverting to their natural numbering.",
+ "unlockDraft": "Unlock draft",
"sceneLocking": "Scene locking",
"pageLocking": "Page locking",
"revisions": "Revisions",
diff --git a/messages/es.json b/messages/es.json
index 4f1fb4b..664070f 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -382,7 +382,7 @@
"paste": "Pegar",
"highlight": "Resaltar",
"addCharacter": "Añadir personaje",
- "addComment": "Añadir comentario",
+ "addComment": "Comentar",
"searchOnWeb": "Buscar en la web",
"noSuggestions": "Sin sugerencias",
"addToDictionary": "Añadir al diccionario",
@@ -450,10 +450,16 @@
},
"production": {
"title": "Producción",
+ "draftLocking": "Bloquear borrador",
+ "draftRelock": "Volver a bloquear",
+ "settings": "Ajustes de producción",
+ "unlockDraftTitle": "Desbloquear borrador",
+ "unlockDraftWarning": "Tanto las escenas como las páginas perderán su numeración bloqueada y volverán a su numeración natural.",
+ "unlockDraft": "Desbloquear borrador",
"sceneLocking": "Bloquear escenas",
"pageLocking": "Bloquear páginas",
"revisions": "Revisiones",
- "relock": "Bloquear",
+ "relock": "Volver a 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.",
diff --git a/messages/fr.json b/messages/fr.json
index 2a41c33..a586e6c 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -383,7 +383,7 @@
"paste": "Coller",
"highlight": "Surligner",
"addCharacter": "Ajouter un personnage",
- "addComment": "Ajouter un commentaire",
+ "addComment": "Commenter",
"searchOnWeb": "Rechercher sur le web",
"noSuggestions": "Aucune suggestion",
"addToDictionary": "Ajouter au dictionnaire",
@@ -451,10 +451,16 @@
},
"production": {
"title": "Production",
+ "draftLocking": "Verrouillage du brouillon",
+ "draftRelock": "Reverrouiller",
+ "settings": "Paramètres de production",
+ "unlockDraftTitle": "Déverrouiller le brouillon",
+ "unlockDraftWarning": "Les scènes et les pages perdront leur numérotation verrouillée et reviendront à leur numérotation naturelle.",
+ "unlockDraft": "Déverrouiller le brouillon",
"sceneLocking": "Verrouillage des scènes",
"pageLocking": "Verrouillage des pages",
"revisions": "Révisions",
- "relock": "Verrouiller",
+ "relock": "Reverrouiller",
"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.",
diff --git a/messages/ja.json b/messages/ja.json
index f8b5f92..19e3a58 100644
--- a/messages/ja.json
+++ b/messages/ja.json
@@ -382,7 +382,7 @@
"paste": "貼り付け",
"highlight": "ハイライト",
"addCharacter": "登場人物を追加",
- "addComment": "コメントを追加",
+ "addComment": "コメント",
"searchOnWeb": "ウェブ検索",
"noSuggestions": "候補なし",
"addToDictionary": "辞書に追加",
@@ -450,10 +450,16 @@
},
"production": {
"title": "プロダクション",
+ "draftLocking": "ドラフトロック",
+ "draftRelock": "再ロック",
+ "settings": "制作設定",
+ "unlockDraftTitle": "ドラフトのロック解除",
+ "unlockDraftWarning": "シーンとページの両方がロックされた番号を失い、それぞれの自然な番号に戻ります。",
+ "unlockDraft": "ドラフトのロック解除",
"sceneLocking": "シーンロック",
"pageLocking": "ページロック",
"revisions": "改訂",
- "relock": "ロック",
+ "relock": "再ロック",
"provisionalTitle": "未ロックのシーン",
"unlockTitle": "シーン番号のロックを解除しますか?",
"unlockWarning": "ロック済みおよび省略されたシーンの固定番号はすべて失われます。",
diff --git a/messages/ko.json b/messages/ko.json
index 5e3b911..6894a71 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -382,7 +382,7 @@
"paste": "붙여넣기",
"highlight": "하이라이트",
"addCharacter": "인물 추가",
- "addComment": "댓글 추가",
+ "addComment": "댓글",
"searchOnWeb": "웹 검색",
"noSuggestions": "제안 없음",
"addToDictionary": "사전에 추가",
@@ -450,10 +450,16 @@
},
"production": {
"title": "프로덕션",
+ "draftLocking": "드래프트 잠금",
+ "draftRelock": "다시 잠그기",
+ "settings": "프로덕션 설정",
+ "unlockDraftTitle": "드래프트 잠금 해제",
+ "unlockDraftWarning": "씬과 페이지 모두 잠긴 번호를 잃고 각자의 자연스러운 번호로 되돌아갑니다.",
+ "unlockDraft": "드래프트 잠금 해제",
"sceneLocking": "씬 잠금",
"pageLocking": "페이지 잠금",
"revisions": "개정",
- "relock": "잠그기",
+ "relock": "다시 잠그기",
"provisionalTitle": "잠기지 않은 씬",
"unlockTitle": "씬 번호 잠금을 해제하시겠습니까?",
"unlockWarning": "잠긴 씬과 생략된 씬의 고정 번호가 모두 사라집니다.",
diff --git a/messages/pl.json b/messages/pl.json
index c78feb6..ceb5035 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -382,7 +382,7 @@
"paste": "Wklej",
"highlight": "Wyróżnij",
"addCharacter": "Dodaj postać",
- "addComment": "Dodaj komentarz",
+ "addComment": "Komentuj",
"searchOnWeb": "Szukaj w sieci",
"noSuggestions": "Brak sugestii",
"addToDictionary": "Dodaj do słownika",
@@ -450,10 +450,16 @@
},
"production": {
"title": "Produkcja",
+ "draftLocking": "Blokowanie wersji roboczej",
+ "draftRelock": "Zablokuj ponownie",
+ "settings": "Ustawienia produkcji",
+ "unlockDraftTitle": "Odblokuj wersję roboczą",
+ "unlockDraftWarning": "Zarówno sceny, jak i strony stracą swoją zablokowaną numerację i powrócą do swojej naturalnej numeracji.",
+ "unlockDraft": "Odblokuj wersję roboczą",
"sceneLocking": "Blokowanie scen",
"pageLocking": "Blokowanie stron",
"revisions": "Wersje",
- "relock": "Zablokuj",
+ "relock": "Zablokuj ponownie",
"provisionalTitle": "Niezablokowane sceny",
"unlockTitle": "Odblokować numery scen?",
"unlockWarning": "Wszystkie zablokowane i pominięte sceny utracą zamrożoną numerację.",
diff --git a/messages/zh.json b/messages/zh.json
index b1fe340..40374b1 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -382,7 +382,7 @@
"paste": "粘贴",
"highlight": "高亮",
"addCharacter": "添加角色",
- "addComment": "添加评论",
+ "addComment": "评论",
"searchOnWeb": "网页搜索",
"noSuggestions": "无建议",
"addToDictionary": "添加到字典",
@@ -450,10 +450,16 @@
},
"production": {
"title": "制作",
+ "draftLocking": "锁定草稿",
+ "draftRelock": "重新锁定",
+ "settings": "制作设置",
+ "unlockDraftTitle": "解锁草稿",
+ "unlockDraftWarning": "场景和页面都将失去其锁定的编号,恢复为各自的自然编号。",
+ "unlockDraft": "解锁草稿",
"sceneLocking": "锁定场景",
"pageLocking": "锁定页面",
"revisions": "修订",
- "relock": "锁定",
+ "relock": "重新锁定",
"provisionalTitle": "未锁定的场景",
"unlockTitle": "解锁场景编号?",
"unlockWarning": "所有锁定和省略的场景都将失去固定编号。",
diff --git a/src/lib/adapters/pdf/pdf-adapter.ts b/src/lib/adapters/pdf/pdf-adapter.ts
index b3fb887..2e91f29 100644
--- a/src/lib/adapters/pdf/pdf-adapter.ts
+++ b/src/lib/adapters/pdf/pdf-adapter.ts
@@ -185,7 +185,6 @@ export class PDFAdapter extends ProjectAdapter {
(el.querySelector(".scene-label-right") as HTMLElement | null)?.textContent
?.trim() ||
String(sceneCount),
- omitted: el.getAttribute("data-omitted-overlay") === "true",
}
: undefined;
@@ -500,7 +499,7 @@ export class PDFAdapter extends ProjectAdapter {
el: HTMLElement,
paragraphLines: VisualLine[],
options: PDFExportOptions,
- sceneInfo?: { label: string; omitted: boolean },
+ sceneInfo?: { label: string },
): void {
const firstLine = paragraphLines[0];
const lastLine = paragraphLines[paragraphLines.length - 1];
diff --git a/src/lib/editor/use-document-editor.ts b/src/lib/editor/use-document-editor.ts
index f8ce413..81429b1 100644
--- a/src/lib/editor/use-document-editor.ts
+++ b/src/lib/editor/use-document-editor.ts
@@ -31,6 +31,7 @@ import {
createSceneLockingExtension,
refreshSceneLocking,
} from "@src/lib/screenplay/extensions/scene-locking-extension";
+import { SCENE_OMIT_UNDO_ORIGIN } from "@src/lib/screenplay/scene-locking";
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";
@@ -371,6 +372,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum
getPageLocking: () => !!ext.pageLocking,
getPageLocks: () => ext.persistentPages ?? {},
getSkippedLetters: () => ext.skippedSceneLetters ?? [],
+ getScenes: () => ext.repository?.scenes ?? {},
}
: {
pageGap: 20,
@@ -552,11 +554,20 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum
}
}, [userInfo, provider]);
- // Fix Yjs undo cursor restoration: y-tiptap's stack-item-popped fires AFTER
- // the undo transaction commits, so beforeTransactionSelection is captured wrong
- // by beforeAllTransactions. Patch undo/redo to pre-set it from the stack item.
+ // Post-mount UndoManager setup. y-tiptap's UndoManager is constructed to
+ // track only the editor XmlFragment (scope) and only `ySyncPluginKey`
+ // (origin), and y-tiptap's stack-item-popped fires AFTER the undo
+ // transaction commits — so a few tweaks are needed:
+ // - addToScope(scenes): scene metadata lives in a separate Y.Map; without
+ // this the UndoManager silently ignores every mutation to it.
+ // - trackedOrigins.add(SCENE_OMIT_UNDO_ORIGIN): omit/unomit bundle a PM
+ // dispatch and a Map.set into one Yjs transaction tagged with this
+ // symbol so Ctrl+Z reverts both halves atomically.
+ // - undo/redo patch: pre-seed beforeTransactionSelection from the
+ // popped stack item so the cursor restores correctly (y-tiptap
+ // otherwise captures it after the fact).
useEffect(() => {
- if (!editor || !isYjsReady) return;
+ if (!editor || !isYjsReady || !projectState) return;
const state = editor.state;
const yUndoState = yUndoPluginKey.getState(state);
@@ -565,6 +576,9 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum
const um = yUndoState.undoManager;
const binding = ySyncState.binding;
+ um.addToScope([projectState.scenes()]);
+ um.trackedOrigins.add(SCENE_OMIT_UNDO_ORIGIN);
+
const originalUndo = um.undo.bind(um);
const originalRedo = um.redo.bind(um);
@@ -587,8 +601,9 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum
return () => {
um.undo = originalUndo;
um.redo = originalRedo;
+ um.trackedOrigins.delete(SCENE_OMIT_UNDO_ORIGIN);
};
- }, [editor, isYjsReady]);
+ }, [editor, isYjsReady, projectState]);
// Refresh character highlights
useEffect(() => {
@@ -615,11 +630,17 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum
// 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.
+ //
+ // persistentScenes is included so toggling a scene's `omitted` flag
+ // re-runs pagination immediately: omitted body paragraphs collapse to
+ // zero height in the layout (mirroring the visual display:none from
+ // scene-locking-extension), so the page they sit on must shrink/grow
+ // without waiting for the next keystroke.
useEffect(() => {
if (editor && config.features.paginationMode === "screenplay") {
refreshPageLocking(editor);
}
- }, [editor, pageLocking, persistentPages, skippedSceneLetters, config.features.paginationMode]);
+ }, [editor, pageLocking, persistentPages, persistentScenes, skippedSceneLetters, config.features.paginationMode]);
// Refresh search highlights
useEffect(() => {
diff --git a/src/lib/project/project-repository.ts b/src/lib/project/project-repository.ts
index 51f79fe..4ea322a 100644
--- a/src/lib/project/project-repository.ts
+++ b/src/lib/project/project-repository.ts
@@ -259,7 +259,7 @@ export class ProjectRepository {
const existing = (map.get(sceneId) as PersistentScene | undefined) ?? {};
const merged: PersistentScene = { ...existing };
- const FIELDS = ["synopsis", "color", "token", "omitted"] as const;
+ const FIELDS = ["synopsis", "color", "token", "omitted", "originalHeading"] as const;
for (const key of FIELDS) {
if (key in data) {
(merged as Record)[key] = data[key];
@@ -456,7 +456,7 @@ export class ProjectRepository {
const existing = (map.get(anchorId) as PersistentPage | undefined) ?? {};
const merged: PersistentPage = { ...existing };
- const FIELDS = ["token"] as const;
+ const FIELDS = ["token", "splitOffset"] as const;
for (const key of FIELDS) {
if (key in data) {
(merged as Record)[key] = data[key];
@@ -500,10 +500,15 @@ export class ProjectRepository {
/**
* Run a function inside a single Y.js transaction.
* Useful for batching multiple repository mutations into one collab update.
+ *
+ * Pass `origin` to tag the transaction — required for the Y.UndoManager
+ * to track the changes (the manager ignores transactions whose origin is
+ * not in its `trackedOrigins` set). Custom origins must also be added to
+ * the editor's `trackedOrigins` set; see `use-document-editor.ts`.
*/
- transact(fn: () => void): void {
+ transact(fn: () => void, origin?: unknown): void {
if (this.guardWrite("transact")) return;
- this.ydoc.transact(fn);
+ this.ydoc.transact(fn, origin);
}
// -------------------------------- //
diff --git a/src/lib/screenplay/extensions/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts
index 108f016..cc8a29e 100644
--- a/src/lib/screenplay/extensions/pagination-extension.ts
+++ b/src/lib/screenplay/extensions/pagination-extension.ts
@@ -9,6 +9,7 @@ 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";
+import type { PersistentSceneMap } from "@src/lib/screenplay/scenes";
// ---------------------------------------------------------------------------
// Constants
@@ -123,6 +124,11 @@ export interface PaginationOptions {
getPageLocks?: () => PersistentPageMap;
/** Letters skipped from generated labels (shared with scene locking). */
getSkippedLetters?: () => readonly string[];
+ /** Persistent scene map. Used to detect omitted scenes so their hidden
+ * body paragraphs contribute zero height to the page layout — without
+ * this, omission would visually collapse the body but pagination would
+ * still allocate full height, leaving the page short on real content. */
+ getScenes?: () => PersistentSceneMap;
}
export interface PageBreakInfo {
@@ -144,6 +150,11 @@ export interface PageBreakInfo {
/** 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;
+ /** Character offset within the anchor node where the break occurs.
+ * Set for sentence-split breaks (mid-node) — both the original split and
+ * the locked re-application of it — and read by the production panel when
+ * freezing page locks so the split can be reproduced on later recomputes. */
+ splitOffset?: number;
}
declare module "@tiptap/core" {
@@ -443,9 +454,7 @@ function buildDecorations(
// 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;
+ const lastPageLabel = breaks.length > 0 ? (breaks[breaks.length - 1].label ?? String(lastPagenum)) : firstPageLabel;
decorations.push(
Decoration.widget(
doc.content.size,
@@ -551,6 +560,8 @@ const setupTestDiv = (editorDom: HTMLElement, _: PaginationOptions): HTMLElement
interface SplitResult {
/** Absolute document position of the split point (inside the straddling node's text). */
pos: number;
+ /** Character offset within the node's text where the split occurs (= pos - nodeDocPos - 1). */
+ offset: number;
/** Rendered height of the portion staying on the current page. */
topHeight: number;
/** Rendered height of the portion moving to the next page. */
@@ -611,7 +622,12 @@ function trySplitNode(
// The split position in document space:
// nodeDocPos + 1 skips the node's opening token; topText.length then walks
// through the text characters (marks are zero-width in ProseMirror's position space).
- return { pos: nodeDocPos + 1 + topText.length, topHeight, bottomHeight };
+ return {
+ pos: nodeDocPos + 1 + topText.length,
+ offset: topText.length,
+ topHeight,
+ bottomHeight,
+ };
}
}
@@ -659,7 +675,11 @@ function computePageLabels(
return labels.map((l) => l.label);
}
-const createPaginationPlugin = (extension: { options: PaginationOptions; editor: Editor }) =>
+const createPaginationPlugin = (extension: {
+ options: PaginationOptions;
+ editor: Editor;
+ storage: { fontsReady: boolean };
+}) =>
new Plugin({
key: paginationKey,
state: {
@@ -670,6 +690,14 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor:
firstPageLabel: "1",
}),
apply(tr, value: PaginationState, oldState, newState): PaginationState {
+ // Wait for the screenplay fonts to finish loading before doing
+ // anything. Measuring against the OS monospace fallback writes
+ // wrong heights into the cache; gating here keeps the cache
+ // empty until the real font is in play. onCreate dispatches a
+ // forcePaginationUpdate once fonts.ready resolves, which is
+ // what eventually pulls us past this guard.
+ if (!extension.storage.fontsReady) return value;
+
const options = extension.options as PaginationOptions;
const formatUpdate = tr.getMeta("pageFormatUpdate");
const forceUpdate = tr.getMeta("forcePaginationUpdate");
@@ -742,13 +770,20 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor:
// 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 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;
+ // Tracks locked anchors already consumed by a break in this pass.
+ // ProseMirror's split (Enter at position 0 of a node) duplicates
+ // node attrs across both halves — including data-id — so until
+ // node-id-dedup-extension runs in appendTransaction we transiently
+ // see the same locked anchor twice. Without this set, both halves
+ // would each force a page break, briefly rendering a phantom page
+ // with the same locked label until the dedup transaction fires.
+ const consumedAnchors = new Set();
const skippedLetters = options.getSkippedLetters?.() ?? [];
+ const scenesMap = options.getScenes?.() ?? null;
const contentHeight = options.pageHeight - options.marginTop - options.marginBottom;
const breaks: PageBreakInfo[] = [];
@@ -761,6 +796,24 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor:
// with "CHARACTER (CONT'D)" on the next page.
let lastCharName = "";
+ // Tracks whether the currently-open scene is OMITTED. Body
+ // paragraphs under an omitted scene are hidden via decorations
+ // (display:none) but still present in the document — pagination
+ // must mirror the visual collapse by treating them as zero-
+ // height, otherwise the page they sit on appears short while
+ // the layout still allocates their original height.
+ let currentSceneOmitted = false;
+
+ // Set when the short-circuit exits the per-node loop early.
+ // pagePos at that point reflects only the carry node(s) sitting
+ // on the new page right after the matched break — not the real
+ // last page — so the post-loop freespace computation must NOT
+ // derive from pagePos. The previous pass's lastPageFreespace is
+ // still authoritative because the short-circuit condition (matching
+ // pos / freespace / contdName past maxChangedPos) guarantees every
+ // subsequent page is byte-identical to the previous layout.
+ let shortCircuited = false;
+
let lastNodes: CircularBuffer = new CircularBuffer(3);
for (let i = 0; i < childCount; i++) {
const node = newState.doc.child(i);
@@ -794,6 +847,15 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor:
const dataId: string | undefined = node.attrs["data-id"];
+ // Update the omitted-scene flag at each Scene boundary. Body
+ // paragraphs that follow stay flagged until the next Scene
+ // resets it. Scene headings themselves always render (with
+ // the OMITTED overlay sitting on top) so their height is
+ // kept regardless.
+ if (nodeType === ScreenplayElement.Scene) {
+ currentSceneOmitted = !!(scenesMap && dataId && scenesMap[dataId]?.omitted);
+ }
+
// --- 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.
@@ -814,25 +876,93 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor:
// --- 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);
+ // The `consumedAnchors` guard ignores the transient duplicate
+ // data-id that appears after Enter splits a locked anchor — only
+ // the first occurrence in doc order is honored as the lock site,
+ // matching the post-dedup state and avoiding a phantom break.
+ if (lockedAnchorIds && dataId && lockedAnchorIds.has(dataId) && !consumedAnchors.has(dataId)) {
+ consumedAnchors.add(dataId);
+ const lockInfo = pageLocks?.[dataId];
+ const splitOffset = lockInfo?.splitOffset;
+ const textLen = node.textContent?.length ?? 0;
+
+ if (pagePos > 0 && splitOffset != null && splitOffset > 0 && splitOffset < textLen) {
+ // The lock was originally created on a mid-node sentence split
+ // (straddling dialogue or action). Reproduce that split here:
+ // top portion stays on the current page, break goes at the
+ // stored offset, bottom portion starts the locked page. Without
+ // this branch the whole node would be force-pushed to the next
+ // page, making the locked page taller than its frozen layout
+ // and forcing a phantom "A" page to be inserted before it.
+ if (!element) element = serializer.serializeNode(node) as HTMLElement;
+
+ const topText = node.textContent.slice(0, splitOffset);
+ const topElement = element.cloneNode(false) as HTMLElement;
+ topElement.textContent = topText;
+ const topHeight = getHTMLHeight(topElement, editorDOM, node.type.name, options);
+ const bottomHeight = Math.max(0, height - topHeight);
+
+ pagePos += topHeight;
+ const freespace = contentHeight - pagePos;
+
+ breaks.push({
+ pos: pos + 1 + splitOffset,
+ pagenum: ++pagenum,
+ freespace: Math.max(0, freespace),
+ contdName: logic?.showMoreContd ? lastCharName : "",
+ splitNodeType: nodeType,
+ anchorId: dataId,
+ splitOffset,
+ });
+
+ pagePos = bottomHeight;
+ lastNodes = new CircularBuffer(3);
+ lastNodes.push({
+ pos,
+ type: nodeType,
+ height: bottomHeight,
+ positionTop: 0,
+ dataId,
+ });
+ continue;
+ }
+
+ if (pagePos > 0) {
+ // Whole-node lock: force break before the anchor so the locked
+ // page begins exactly with this node.
+ 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);
+ }
}
+ // Omitted-scene body paragraphs are visually hidden by
+ // scene-locking decorations (display:none) but still live in
+ // the document. Treat them as zero-height so pagination
+ // matches what the user actually sees on the page. The
+ // measured height stays cached for cheap restoration when
+ // the scene is un-omitted.
+ const effectiveHeight = currentSceneOmitted && nodeType !== ScreenplayElement.Scene ? 0 : height;
+
// Accumulate height on current page
- pagePos += height;
+ pagePos += effectiveHeight;
// We keep the last 3 nodes for orphan resolution on page break
- lastNodes.push({ pos, type: nodeType, height, positionTop: pagePos - height, dataId });
+ lastNodes.push({
+ pos,
+ type: nodeType,
+ height: effectiveHeight,
+ positionTop: pagePos - effectiveHeight,
+ dataId,
+ });
// Page break needed — record it and reset page position
if (pagePos > contentHeight) {
@@ -862,11 +992,21 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor:
splitNodeType: nodeType,
// Anchor for page locking: the node being split owns both halves.
anchorId: dataId,
+ // splitOffset is captured by the production panel when locking,
+ // so the same mid-node split can be reproduced on later recomputes
+ // instead of force-pushing the whole node onto the locked page.
+ splitOffset: split.offset,
});
// 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, dataId });
+ lastNodes.push({
+ pos,
+ type: nodeType,
+ height: split.bottomHeight,
+ positionTop: 0,
+ dataId,
+ });
continue; // split handled — skip orphan resolution for this node
}
}
@@ -885,11 +1025,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor:
// 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)
- ) {
+ if (lockedAnchorIds && prev.dataId && lockedAnchorIds.has(prev.dataId)) {
break;
}
if (BREAK_LOGIC[prev.type]?.keepWithNext) {
@@ -962,6 +1098,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor:
// Spread preserves all fields (contdName, splitNodeType, …); override pagenum only.
breaks.push({ ...mappedOldBreaks[j], pagenum });
}
+ shortCircuited = true;
break;
}
}
@@ -973,7 +1110,15 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor:
// 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);
+ //
+ // When the per-node loop short-circuited, pagePos is the carry
+ // height after the matched break — NOT the height of the real
+ // last page — so deriving from it would make the last page's
+ // spacer grow or shrink with every edit on earlier pages. The
+ // short-circuit condition guarantees content past that break
+ // is identical to the previous pass, so the previously stored
+ // freespace is still the correct answer.
+ let lastPageFreespace = shortCircuited ? value.lastPageFreespace : Math.max(0, contentHeight - pagePos);
// --- Orphan page handling ---
// A locked page whose anchor data-id is no longer present in the doc
@@ -1082,10 +1227,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor:
}
if (b.pos === segmentEnd) {
const bLock = b.anchorId ? pageLocks[b.anchorId] : undefined;
- if (
- bLock?.token &&
- compareTokens(orphan.token, bLock.token) < 0
- ) {
+ if (bLock?.token && compareTokens(orphan.token, bLock.token) < 0) {
insertIdx = j;
break;
}
@@ -1104,10 +1246,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor:
// 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
- ) {
+ if (insertIdx < breaks.length && breaks[insertIdx].pos === segmentEnd) {
syntheticFreespace = breaks[insertIdx].freespace;
breaks[insertIdx].freespace = 0;
} else if (insertIdx === breaks.length) {
@@ -1245,7 +1384,15 @@ export const ScriptioPagination = Extension.create({
},
addStorage() {
- return { initTimer: null as ReturnType | null };
+ return {
+ initTimer: null as ReturnType | null,
+ /** False until the screenplay @font-face fonts have finished loading.
+ * The plugin's `apply` checks this flag and skips height measurement
+ * while it is false, so the cache never picks up bad heights taken
+ * with a fallback monospace font (Consolas etc.) that produces a
+ * different line-wrap from CourierPrime. */
+ fontsReady: false,
+ };
},
onCreate() {
@@ -1336,14 +1483,79 @@ export const ScriptioPagination = Extension.create({
setupTestDiv(editorDOM, this.options);
- // Trigger initial pagination after editor is ready
- this.storage.initTimer = setTimeout(() => {
- this.storage.initTimer = null;
+ // The screenplay @font-face fonts (CourierPrime + fallbacks) load
+ // asynchronously. Until the real font is applied, the test div lays
+ // text out in the OS monospace fallback (Consolas on Windows), whose
+ // slightly different character widths cause text to wrap to a
+ // different number of lines. Heights measured against the fallback
+ // disagree with what the editor will eventually render — and once
+ // cached, they keep that disagreement alive (hence the cold-open vs
+ // hard-refresh mismatch, and the shrink-on-edit symptom when a
+ // cache-miss re-measures against the now-loaded real font).
+ //
+ // We defer the first pagination run until the font is genuinely
+ // usable; until then the plugin's `apply` returns the empty initial
+ // state (no measurement, no cache writes). Once ready we flip the flag
+ // and dispatch a single force update — every subsequent measurement
+ // happens against the real font, so the heightCache fills with correct
+ // values from the start.
+ //
+ // IMPORTANT: `document.fonts.ready` is NOT enough on Chrome. Chrome
+ // loads @font-face fonts lazily (only once a rendered element needs
+ // them), so at the moment the editor mounts nothing has triggered the
+ // CourierPrime fetch yet — `fonts.ready` resolves reporting
+ // status:"loaded" while `fonts.check('12pt "CourierPrime"')` is still
+ // false and zero faces are actually loaded. Firefox eagerly starts the
+ // load, which is why it worked there but not here. The fix is to
+ // ACTIVELY request the faces with `document.fonts.load(...)`, which
+ // forces the fetch and resolves only once they are usable for layout.
+ const triggerInitialPagination = () => {
+ if (this.editor.isDestroyed) return;
+ this.storage.fontsReady = true;
const tr = this.editor.state.tr;
tr.setMeta("forcePaginationUpdate", true);
tr.setMeta("addToHistory", false);
this.editor.view.dispatch(tr);
- }, 0);
+ };
+
+ const fontsApi = typeof document !== "undefined" ? document.fonts : null;
+ if (fontsApi && typeof fontsApi.load === "function") {
+ // Actively request every CourierPrime variant the screenplay uses.
+ // `load()` forces the fetch (even on Chrome's lazy loader) and
+ // resolves once the faces are usable for measurement. `.catch` per
+ // spec keeps one failed variant from blocking the others, and a
+ // safety timeout guarantees pagination still runs if the network
+ // never delivers a font — better a fallback-font layout than a
+ // permanently blank document.
+ const specs = [
+ '12pt "CourierPrime"',
+ 'bold 12pt "CourierPrime"',
+ 'italic 12pt "CourierPrime"',
+ 'bold italic 12pt "CourierPrime"',
+ ];
+ let fired = false;
+ const fireOnce = () => {
+ if (fired) return;
+ fired = true;
+ if (this.storage.initTimer != null) {
+ clearTimeout(this.storage.initTimer);
+ this.storage.initTimer = null;
+ }
+ triggerInitialPagination();
+ };
+ Promise.all(specs.map((s) => fontsApi.load(s).catch(() => undefined)))
+ .then(() => fireOnce())
+ .catch(() => fireOnce());
+ // Safety net: never wait more than 3s on the font fetch.
+ this.storage.initTimer = setTimeout(() => fireOnce(), 3000);
+ } else {
+ // No FontFaceSet API (SSR, very old browsers): fall back to the
+ // legacy setTimeout(0) trigger so pagination still runs.
+ this.storage.initTimer = setTimeout(() => {
+ this.storage.initTimer = null;
+ triggerInitialPagination();
+ }, 0);
+ }
},
onDestroy() {
@@ -1360,19 +1572,18 @@ export const ScriptioPagination = Extension.create({
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.
+ // ProseMirror's joinMaybeClear (a joinBackward variant) deletes
+ // the PREVIOUS block instead of the current one whenever the
+ // previous block is empty and the two blocks share a type. If
+ // that empty previous block is a locked page anchor the plugin's
+ // filterTransaction rejects the resulting transaction — the
+ // anchor's data-id would disappear — and the cursor appears
+ // stuck. Patch both flavors of the case (empty current and
+ // non-empty current) so the locked anchor survives and the user
+ // still gets the natural "step up one line" / "merge up" feel.
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;
@@ -1388,7 +1599,22 @@ export const ScriptioPagination = Extension.create({
const prevDataId = prev.attrs?.["data-id"];
if (typeof prevDataId !== "string" || !pageLocks[prevDataId]) return false;
- const tr = state.tr.delete(curStart, $from.after());
+ const tr = state.tr;
+ if ($from.parent.textContent.length === 0) {
+ // Both blocks empty: drop the current empty block — the
+ // locked anchor stays put and the cursor parks inside it.
+ tr.delete(curStart, $from.after());
+ } else {
+ // Current has text: merge it INTO the empty previous block
+ // via tr.join, which keeps the before node's structure
+ // (and its locked data-id) and absorbs after's content.
+ // join requires both children to share a type; for cross-
+ // type cases we bail out and let the default chain do
+ // whatever fallback it has — those cases don't trip
+ // joinMaybeClear in the first place.
+ if (prev.type !== $from.parent.type) return false;
+ tr.join(curStart);
+ }
tr.setSelection(TextSelection.create(tr.doc, curStart - 1));
view.dispatch(tr);
return true;
@@ -1541,6 +1767,34 @@ export function getPageAnchors(editor: Editor): string[] {
return out;
}
+export interface PageAnchorInfo {
+ anchorId: string;
+ /** Character offset within the anchor node where the page begins.
+ * Set when the page starts on the bottom half of a sentence-split node;
+ * undefined for whole-node anchors. Frozen into the page lock so the
+ * split survives recomputes. */
+ splitOffset?: number;
+}
+
+/**
+ * Same ordering as {@link getPageAnchors} but each entry also carries the
+ * splitOffset (when the page begins mid-node). Used by the production panel
+ * when first locking pages so the lock map can reproduce mid-node splits
+ * on subsequent recomputes.
+ */
+export function getPageAnchorInfo(editor: Editor): PageAnchorInfo[] {
+ const state = paginationKey.getState(editor.state) as PaginationState | undefined;
+ if (!state) return [{ anchorId: PAGE_ONE_KEY }];
+ const out: PageAnchorInfo[] = [{ anchorId: PAGE_ONE_KEY }];
+ for (const b of state.breaks) {
+ if (!b.anchorId) continue;
+ const entry: PageAnchorInfo = { anchorId: b.anchorId };
+ if (b.splitOffset != null) entry.splitOffset = b.splitOffset;
+ out.push(entry);
+ }
+ return out;
+}
+
/**
* Force a pagination recompute. Call when the page-lock map or the
* page-locking toggle changes — layout may shift even though the
diff --git a/src/lib/screenplay/extensions/scene-locking-extension.ts b/src/lib/screenplay/extensions/scene-locking-extension.ts
index fec5152..24c445d 100644
--- a/src/lib/screenplay/extensions/scene-locking-extension.ts
+++ b/src/lib/screenplay/extensions/scene-locking-extension.ts
@@ -71,14 +71,6 @@ const buildLabelWidget = (label: string, side: "left" | "right"): HTMLElement =>
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;
@@ -131,39 +123,21 @@ const computeDecorations = (
}
}
- // 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.
+ // OMITTED decorations are independent of production lock. The heading
+ // text itself is replaced with "OMITTED" inside the document by
+ // `omitSceneByUuid` (the original is preserved in scene metadata), so
+ // here we only need to grey the heading via `data-scene-omitted` and
+ // collapse the body paragraphs via `data-omitted-body`.
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",
+ "data-scene-omitted": "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;
diff --git a/src/lib/screenplay/page-locking.ts b/src/lib/screenplay/page-locking.ts
index 5729c93..8c538ad 100644
--- a/src/lib/screenplay/page-locking.ts
+++ b/src/lib/screenplay/page-locking.ts
@@ -23,6 +23,12 @@ export const PAGE_ONE_KEY = "__page1__";
export type PersistentPage = {
/** Frozen structural position under production page-lock. */
token?: SceneToken;
+ /** Character offset within the anchor node where the locked page begins.
+ * Set when the page was originally split mid-node at a sentence boundary
+ * (straddling dialogue/action). Preserves the split on recompute so the
+ * locked page doesn't inherit the whole anchor node and overflow into a
+ * phantom "A" page. */
+ splitOffset?: number;
};
export type PersistentPageMap = { [anchorId: string]: PersistentPage };
diff --git a/src/lib/screenplay/popup.ts b/src/lib/screenplay/popup.ts
index 0cb2632..82bdb1e 100644
--- a/src/lib/screenplay/popup.ts
+++ b/src/lib/screenplay/popup.ts
@@ -29,6 +29,10 @@ export type PopupUnlockPagesData = {
confirmUnlock: () => void;
};
+export type PopupUnlockDraftData = {
+ confirmUnlock: () => void;
+};
+
// ------------------------------ //
// GENERIC POPUP //
// ------------------------------ //
@@ -38,7 +42,8 @@ export type PopupUnionData =
| PopupSceneData
| PopupUploadToCloudData
| PopupUnlockScenesData
- | PopupUnlockPagesData;
+ | PopupUnlockPagesData
+ | PopupUnlockDraftData;
export enum PopupType {
NewCharacter,
@@ -48,6 +53,7 @@ export enum PopupType {
UploadToCloud,
UnlockScenes,
UnlockPages,
+ UnlockDraft,
}
export type PopupData = {
@@ -111,3 +117,10 @@ export const unlockPagesPopup = (confirmUnlock: () => void, userCtx: UserContext
data: { confirmUnlock },
});
};
+
+export const unlockDraftPopup = (confirmUnlock: () => void, userCtx: UserContextType) => {
+ userCtx.updatePopup({
+ type: PopupType.UnlockDraft,
+ data: { confirmUnlock },
+ });
+};
diff --git a/src/lib/screenplay/scene-locking.ts b/src/lib/screenplay/scene-locking.ts
index cc14d02..acf6cd8 100644
--- a/src/lib/screenplay/scene-locking.ts
+++ b/src/lib/screenplay/scene-locking.ts
@@ -45,7 +45,21 @@
* Together these give: 1 < 1aA < 1A < 1AA < 1AB < 1B < 2.
*/
+import type { Editor } from "@tiptap/core";
+import type { Node } from "@tiptap/pm/model";
+
import type { ProjectRepository } from "../project/project-repository";
+import { ScreenplayElement } from "../utils/enums";
+
+const OMITTED_HEADING_TEXT = "OMITTED";
+
+/**
+ * Yjs transaction origin used by `omitSceneByUuid` / `unomitSceneByUuid` so
+ * the editor's UndoManager records both the document edit and the scene
+ * metadata change as a single, atomic undo step. `use-document-editor`
+ * registers this symbol in the UndoManager's `trackedOrigins`.
+ */
+export const SCENE_OMIT_UNDO_ORIGIN = Symbol("scene-omit-undo");
// --------------------------------------------------------------------------
// TYPES
@@ -459,19 +473,74 @@ export const computeSceneLabels = (
// ACTIONS
// --------------------------------------------------------------------------
+/** Locate the scene heading node in the document by its `data-id` UUID. */
+const findSceneHeadingByUuid = (
+ editor: Editor,
+ uuid: string,
+): { node: Node; pos: number } | null => {
+ let result: { node: Node; pos: number } | null = null;
+ editor.state.doc.descendants((node, pos) => {
+ if (result) return false;
+ if (node.attrs?.class === ScreenplayElement.Scene && node.attrs?.["data-id"] === uuid) {
+ result = { node, pos };
+ return false;
+ }
+ return true;
+ });
+ return result;
+};
+
/**
- * 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.
+ * Mark a scene as OMITTED. The heading's current text is saved into the
+ * scene's `originalHeading` metadata and the heading content in the
+ * document is replaced with "OMITTED" — so the heading remains a normal
+ * editable paragraph (cursor + Enter behave naturally) while the body
+ * paragraphs are still visually collapsed via decorations.
+ *
+ * Both the document edit and the metadata update are wrapped in a single
+ * Yjs transaction so Ctrl+Z reverts them atomically.
*/
-export const omitSceneByUuid = (repository: ProjectRepository, uuid: string): void => {
- repository.upsertScene(uuid, { omitted: true });
+export const omitSceneByUuid = (
+ editor: Editor,
+ repository: ProjectRepository,
+ uuid: string,
+): void => {
+ const heading = findSceneHeadingByUuid(editor, uuid);
+ if (!heading) return;
+
+ const currentText = heading.node.textContent;
+ repository.transact(() => {
+ const headingStart = heading.pos + 1;
+ const headingEnd = heading.pos + heading.node.nodeSize - 1;
+ const tr = editor.state.tr.insertText(OMITTED_HEADING_TEXT, headingStart, headingEnd);
+ editor.view.dispatch(tr);
+ repository.upsertScene(uuid, { omitted: true, originalHeading: currentText });
+ }, SCENE_OMIT_UNDO_ORIGIN);
};
-/** Clear an OMITTED scene's `omitted` flag, restoring the heading + body. */
-export const unomitSceneByUuid = (repository: ProjectRepository, uuid: string): void => {
+/**
+ * Clear an OMITTED scene's flag and restore its original heading text from
+ * `originalHeading` metadata. Inverse of `omitSceneByUuid`, batched in a
+ * single Yjs transaction for atomic undo.
+ */
+export const unomitSceneByUuid = (
+ editor: Editor,
+ repository: ProjectRepository,
+ uuid: string,
+): void => {
const scene = repository.getScene(uuid);
if (!scene?.omitted) return;
- repository.upsertScene(uuid, { omitted: undefined });
+ const heading = findSceneHeadingByUuid(editor, uuid);
+ if (!heading) return;
+
+ const restoreText = scene.originalHeading ?? "";
+ repository.transact(() => {
+ const headingStart = heading.pos + 1;
+ const headingEnd = heading.pos + heading.node.nodeSize - 1;
+ const tr = restoreText.length > 0
+ ? editor.state.tr.insertText(restoreText, headingStart, headingEnd)
+ : editor.state.tr.delete(headingStart, headingEnd);
+ editor.view.dispatch(tr);
+ repository.upsertScene(uuid, { omitted: undefined, originalHeading: undefined });
+ }, SCENE_OMIT_UNDO_ORIGIN);
};
diff --git a/src/lib/screenplay/scenes.ts b/src/lib/screenplay/scenes.ts
index c89fa97..ce1b89b 100644
--- a/src/lib/screenplay/scenes.ts
+++ b/src/lib/screenplay/scenes.ts
@@ -70,6 +70,8 @@ export type PersistentScene = {
token?: SceneToken;
/** True when the scene is an OMITTED placeholder (only meaningful with `token`). */
omitted?: boolean;
+ /** Original heading text saved when the scene was omitted, restored on unomit. */
+ originalHeading?: string;
};
/**
diff --git a/src/lib/utils/redirects.ts b/src/lib/utils/redirects.ts
index d1da941..f3b8921 100644
--- a/src/lib/utils/redirects.ts
+++ b/src/lib/utils/redirects.ts
@@ -1,11 +1,11 @@
-import { redirect } from "next/navigation";
+import { redirect, RedirectType } from "next/navigation";
export const redirectHome = () => {
- redirect("/projects");
+ redirect("/projects", RedirectType.replace);
};
export const redirectProject = (projectId: string) => {
- redirect(`/projects?projectId=${projectId}`);
+ redirect(`/projects?projectId=${projectId}`, RedirectType.replace);
};
// Legacy aliases for backwards compatibility during migration
diff --git a/styles/scriptio.css b/styles/scriptio.css
index 82ed8f4..d27d206 100644
--- a/styles/scriptio.css
+++ b/styles/scriptio.css
@@ -68,6 +68,17 @@
box-sizing: border-box;
}
+ /* Suppress the first paragraph's margin-top when it sits directly under
+ a pagination widget. Without this, the page-break div's margin-bottom
+ (0) collapses with the next paragraph's margin-top (16) and leaves a
+ wasted 16px gap at the top of every page. Pagination's break decisions
+ use measured outer heights in isolation, so removing the margin only
+ affects rendering — same paragraphs per page, no determinism hit. */
+ > .pagination-page-break + p,
+ > .pagination-first-page + p {
+ margin-top: 0 !important;
+ }
+
/* Spans inside paragraphs - NO display/width overrides
These must remain inline (default) to not break text flow */
> p span,
@@ -226,23 +237,20 @@
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"] {
+ /* OMITTED scene: heading text is rewritten to "OMITTED" in the document
+ by `omitSceneByUuid` (original preserved in scene metadata), so the
+ heading is a normal editable paragraph. We just grey it and collapse
+ the body paragraphs; the scene-number widgets are re-greyed because
+ the generic `.ProseMirror > p span` rule above pins them to
+ --editor-text. */
+ .scene[data-scene-omitted="true"],
+ .scene[data-scene-omitted="true"] .scene-label-left,
+ .scene[data-scene-omitted="true"] .scene-label-right {
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 {