From 4e716f40b43dfa086b24c6ee6cd5c3656454b430 Mon Sep 17 00:00:00 2001
From: Hugo Bois
Date: Thu, 28 May 2026 01:07:13 +0200
Subject: [PATCH 1/4] fixes to page locking, fixing to last page in pagination,
fix to redirect
---
README.md | 8 +-
components/navbar/ProductionPanel.tsx | 26 +-
src/lib/editor/use-document-editor.ts | 9 +-
src/lib/project/project-repository.ts | 2 +-
.../extensions/pagination-extension.ts | 249 ++++++++++++++++--
src/lib/screenplay/page-locking.ts | 6 +
src/lib/utils/redirects.ts | 6 +-
styles/scriptio.css | 9 +
8 files changed, 273 insertions(+), 42 deletions(-)
diff --git a/README.md b/README.md
index 0eaac3cd..9f139bfa 100644
--- a/README.md
+++ b/README.md
@@ -19,16 +19,18 @@
- **Cloud Synchronization**
- **Real-time Collaboration**
- **Cross-platform** (Windows, MacOS, browser)
-- **Industry-standard Formats** (PDF, Fountain, Final Draft)
+- **Industry Formatting** (layout, page breaks, dual dialogue)
+- **Compatibility Formats** (PDF, Fountain, Final Draft)
+- **Production Ready** (revisions, scene & page locking)
- **Scene Management** (easy navigation, reordering)
- **Character Management** (color highlighting, synopsis)
- **Beat Board** (story cards, outlining)
-- **Smart formatting** (context aware auto-completion)
+- **Smart Editor** (context aware auto-completion, spellchecker)
- **Customization** (themes & custom keybinds)
- **Statistics** (distribution, frequency)
- **Search & Replace** (advanced filtering)
- **Script Comments** (inline annotations)
-- **Focus mode** (distraction-free writing)
+- **Focus Mode** (distraction-free writing)
# Core Values
diff --git a/components/navbar/ProductionPanel.tsx b/components/navbar/ProductionPanel.tsx
index dee0c271..afc9c664 100644
--- a/components/navbar/ProductionPanel.tsx
+++ b/components/navbar/ProductionPanel.tsx
@@ -9,7 +9,7 @@ import { UserContext } from "@src/context/UserContext";
import { computeSceneLabels } from "@src/lib/screenplay/scene-locking";
import { computeSceneItems } from "@src/lib/screenplay/scenes";
import { unlockPagesPopup, unlockScenesPopup } from "@src/lib/screenplay/popup";
-import { getPageAnchors } from "@src/lib/screenplay/extensions/pagination-extension";
+import { getPageAnchors, getPageAnchorInfo } from "@src/lib/screenplay/extensions/pagination-extension";
import Switch from "@components/utils/Switch";
import styles from "./ProductionPanel.module.css";
@@ -192,7 +192,8 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => {
if (next) {
if (!editor) return;
repository.transact(() => {
- const anchors = getPageAnchors(editor);
+ const anchorInfos = getPageAnchorInfo(editor);
+ const anchors = anchorInfos.map((a) => a.anchorId);
const persistentSnapshot = repository.pages;
// Idempotent: any anchor that already has a token keeps it.
// Only provisional anchors (no token yet) get a freshly-computed
@@ -204,9 +205,16 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => {
"suffix",
skippedSceneLetters,
);
- computed.forEach((label) => {
+ computed.forEach((label, idx) => {
if (label.status === "provisional") {
- repository.upsertPage(label.uuid, { token: label.token });
+ // splitOffset is captured alongside the token so the
+ // pagination plugin can reproduce mid-node splits
+ // (straddling dialogues) on recompute instead of
+ // force-pushing the whole anchor node forward.
+ repository.upsertPage(label.uuid, {
+ token: label.token,
+ splitOffset: anchorInfos[idx]?.splitOffset,
+ });
}
});
repository.setPageLocking(true);
@@ -219,7 +227,8 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => {
const handlePageRelock = () => {
if (!repository || isReadOnly || !editor) return;
repository.transact(() => {
- const anchors = getPageAnchors(editor);
+ const anchorInfos = getPageAnchorInfo(editor);
+ const anchors = anchorInfos.map((a) => a.anchorId);
const persistentSnapshot = repository.pages;
const currentLabels = computeSceneLabels(
anchors,
@@ -227,9 +236,12 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => {
"suffix",
skippedSceneLetters,
);
- currentLabels.forEach((label) => {
+ currentLabels.forEach((label, idx) => {
if (label.status === "provisional") {
- repository.upsertPage(label.uuid, { token: label.token });
+ repository.upsertPage(label.uuid, {
+ token: label.token,
+ splitOffset: anchorInfos[idx]?.splitOffset,
+ });
}
});
});
diff --git a/src/lib/editor/use-document-editor.ts b/src/lib/editor/use-document-editor.ts
index f8ce413b..c1eb331f 100644
--- a/src/lib/editor/use-document-editor.ts
+++ b/src/lib/editor/use-document-editor.ts
@@ -371,6 +371,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum
getPageLocking: () => !!ext.pageLocking,
getPageLocks: () => ext.persistentPages ?? {},
getSkippedLetters: () => ext.skippedSceneLetters ?? [],
+ getScenes: () => ext.repository?.scenes ?? {},
}
: {
pageGap: 20,
@@ -615,11 +616,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 51f79fe9..f8b37bc7 100644
--- a/src/lib/project/project-repository.ts
+++ b/src/lib/project/project-repository.ts
@@ -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];
diff --git a/src/lib/screenplay/extensions/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts
index 108f0166..7df545d9 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" {
@@ -551,6 +562,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 +624,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,
+ };
}
}
@@ -748,7 +766,16 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor:
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 +788,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 +839,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 +868,109 @@ 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,6 +1000,10 @@ 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;
@@ -962,6 +1104,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 +1116,17 @@ 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
@@ -1360,19 +1513,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 +1540,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 +1708,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/page-locking.ts b/src/lib/screenplay/page-locking.ts
index 5729c932..8c538add 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/utils/redirects.ts b/src/lib/utils/redirects.ts
index d1da9414..f3b8921d 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 82ed8f4d..ebfeaa2e 100644
--- a/styles/scriptio.css
+++ b/styles/scriptio.css
@@ -238,6 +238,15 @@
[data-omitted-body="true"] {
display: none;
}
+ /* Both the OMITTED overlay and the scene-number widgets are spans inside
+ the scene heading, which the `.ProseMirror > p span` rule above pins to
+ --editor-text. Re-grey them here so they share the omitted scene's
+ --secondary-text color instead of standing out at full text color. */
+ .scene-omitted-overlay,
+ .scene[data-omitted-overlay="true"] .scene-label-left,
+ .scene[data-omitted-overlay="true"] .scene-label-right {
+ color: var(--secondary-text);
+ }
.scene-omitted-overlay {
user-select: none;
pointer-events: none;
From 08215530b8d28b51d337c7c2872f4e25ae481cf1 Mon Sep 17 00:00:00 2001
From: Hugo Bois
Date: Thu, 28 May 2026 15:32:31 +0200
Subject: [PATCH 2/4] scene omit tweaks, tweaked translations, etc
---
.../project/ProductionSettings.module.css | 7 +-
.../editor/sidebar/ContextMenu.module.css | 2 -
components/editor/sidebar/ContextMenu.tsx | 16 ++--
components/popup/Popup.module.css | 19 +++-
components/popup/PopupImportFile.tsx | 14 +--
components/popup/PopupUnlockPages.tsx | 16 ++--
components/popup/PopupUnlockScenes.tsx | 16 ++--
components/popup/PopupUploadToCloud.tsx | 30 ++++---
components/utils/ColorPicker.module.css | 4 +-
components/utils/Form.module.css | 1 +
components/utils/ModalBtn.module.css | 14 ++-
messages/de.json | 2 +-
messages/en.json | 4 +-
messages/es.json | 2 +-
messages/fr.json | 2 +-
messages/ja.json | 2 +-
messages/ko.json | 2 +-
messages/pl.json | 2 +-
messages/zh.json | 2 +-
src/lib/adapters/pdf/pdf-adapter.ts | 3 +-
src/lib/editor/use-document-editor.ts | 24 +++--
src/lib/project/project-repository.ts | 11 ++-
.../extensions/scene-locking-extension.ts | 38 ++------
src/lib/screenplay/scene-locking.ts | 87 +++++++++++++++++--
src/lib/screenplay/scenes.ts | 2 +
styles/scriptio.css | 30 ++-----
26 files changed, 215 insertions(+), 137 deletions(-)
diff --git a/components/dashboard/project/ProductionSettings.module.css b/components/dashboard/project/ProductionSettings.module.css
index f0669ec1..ea66a6c8 100644
--- a/components/dashboard/project/ProductionSettings.module.css
+++ b/components/dashboard/project/ProductionSettings.module.css
@@ -6,7 +6,7 @@
}
.styleName {
- font-size: 0.7rem;
+ font-size: 0.85rem;
color: var(--secondary-text);
text-transform: uppercase;
letter-spacing: 0.06em;
@@ -18,12 +18,15 @@
align-items: center;
gap: 6px;
margin-left: auto;
- font-family: var(--font-screenplay);
font-size: 0.9rem;
font-weight: 600;
color: var(--primary-text);
}
+.styleExample span {
+ font-family: var(--font-screenplay);
+}
+
.arrowIcon {
opacity: 0.5;
}
diff --git a/components/editor/sidebar/ContextMenu.module.css b/components/editor/sidebar/ContextMenu.module.css
index 02a9ffc8..fa554e32 100644
--- a/components/editor/sidebar/ContextMenu.module.css
+++ b/components/editor/sidebar/ContextMenu.module.css
@@ -18,10 +18,8 @@
align-items: center;
gap: 10px;
- border-radius: 6px;
padding-block: 6px;
padding-inline: 12px;
- margin-inline: 6px;
font-size: 14px;
cursor: pointer;
diff --git a/components/editor/sidebar/ContextMenu.tsx b/components/editor/sidebar/ContextMenu.tsx
index 0aa60f00..387eb583 100644
--- a/components/editor/sidebar/ContextMenu.tsx
+++ b/components/editor/sidebar/ContextMenu.tsx
@@ -117,13 +117,13 @@ const SceneItemMenu = ({ props }: SubMenuProps) => {
const canUnomit = !!scene.id && !!scene.omitted;
const handleOmit = () => {
- if (!repository || !scene.id) return;
- omitSceneByUuid(repository, scene.id);
+ if (!editor || !repository || !scene.id) return;
+ omitSceneByUuid(editor, repository, scene.id);
};
const handleUnomit = () => {
- if (!repository || !scene.id) return;
- unomitSceneByUuid(repository, scene.id);
+ if (!editor || !repository || !scene.id) return;
+ unomitSceneByUuid(editor, repository, scene.id);
};
return (
@@ -508,14 +508,14 @@ const EditorContextMenu = ({ props }: SubMenuProps) => {
})();
const handleOmitScene = () => {
- if (!repository || !sceneInfo) return;
- omitSceneByUuid(repository, sceneInfo.uuid);
+ if (!editor || !repository || !sceneInfo) return;
+ omitSceneByUuid(editor, repository, sceneInfo.uuid);
updateContextMenu(undefined);
};
const handleUnomitScene = () => {
- if (!repository || !sceneInfo) return;
- unomitSceneByUuid(repository, sceneInfo.uuid);
+ if (!editor || !repository || !sceneInfo) return;
+ unomitSceneByUuid(editor, repository, sceneInfo.uuid);
updateContextMenu(undefined);
};
diff --git a/components/popup/Popup.module.css b/components/popup/Popup.module.css
index e8759d59..96ac22b3 100644
--- a/components/popup/Popup.module.css
+++ b/components/popup/Popup.module.css
@@ -1,11 +1,11 @@
.container {
position: absolute;
width: 500px;
- min-height: 250px;
- padding: 16px;
+ padding: 20px;
+ padding-bottom: 24px;
+ background-color: var(--primary);
border: 2px solid var(--separator);
box-shadow: 0 5px 30px rgba(0, 0, 0, 0.3);
- background-color: var(--primary);
border-radius: 12px;
pointer-events: auto;
}
@@ -49,7 +49,7 @@
display: flex;
flex-direction: column;
gap: 18px;
- margin-bottom: 32px;
+ margin-bottom: 20px;
}
.info_btns {
@@ -115,6 +115,17 @@
these classes STANDALONE — do not pair with form.btn, since its
!important border/radius rules would override the composed styling. */
+.buttons {
+ display: flex;
+ gap: 10px;
+}
+
+.buttons > * {
+ flex: 1;
+ width: auto !important;
+ margin-top: 0 !important;
+}
+
.confirm {
composes: modalBtn from "../utils/ModalBtn.module.css";
width: 100% !important;
diff --git a/components/popup/PopupImportFile.tsx b/components/popup/PopupImportFile.tsx
index d788ebf3..2eb1c11c 100644
--- a/components/popup/PopupImportFile.tsx
+++ b/components/popup/PopupImportFile.tsx
@@ -37,12 +37,14 @@ const PopupImportFile = ({ data: { confirmImport } }: PopupData
-
-
+