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 - - +
+ + +
); diff --git a/components/popup/PopupUnlockPages.tsx b/components/popup/PopupUnlockPages.tsx index 2bcef200..e9b2fc29 100644 --- a/components/popup/PopupUnlockPages.tsx +++ b/components/popup/PopupUnlockPages.tsx @@ -37,13 +37,15 @@ const PopupUnlockPages = ({ data: { confirmUnlock } }: PopupData

{t("unlockPagesWarning")}

- - +
+ + +
); diff --git a/components/popup/PopupUnlockScenes.tsx b/components/popup/PopupUnlockScenes.tsx index 930b6dcf..607a0bf1 100644 --- a/components/popup/PopupUnlockScenes.tsx +++ b/components/popup/PopupUnlockScenes.tsx @@ -37,13 +37,15 @@ const PopupUnlockScenes = ({ data: { confirmUnlock } }: PopupData

{t("unlockWarning")}

- - +
+ + +
); diff --git a/components/popup/PopupUploadToCloud.tsx b/components/popup/PopupUploadToCloud.tsx index 58218b8e..08f8d28f 100644 --- a/components/popup/PopupUploadToCloud.tsx +++ b/components/popup/PopupUploadToCloud.tsx @@ -55,20 +55,22 @@ const PopupUploadToCloud = ({ data: { projectId } }: PopupData{t("body")}

{info && } - - +
+ + +
); diff --git a/components/utils/ColorPicker.module.css b/components/utils/ColorPicker.module.css index 784bb451..3ae29462 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 3f1571f5..1181c450 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 ad9928ad..2d38fd12 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 889c2462..5350ca71 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", diff --git a/messages/en.json b/messages/en.json index 24107f8a..4aade80b 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", diff --git a/messages/es.json b/messages/es.json index 4f1fb4b1..534460e6 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", diff --git a/messages/fr.json b/messages/fr.json index 2a41c334..b38866ca 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", diff --git a/messages/ja.json b/messages/ja.json index f8b5f921..997b31f8 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -382,7 +382,7 @@ "paste": "貼り付け", "highlight": "ハイライト", "addCharacter": "登場人物を追加", - "addComment": "コメントを追加", + "addComment": "コメント", "searchOnWeb": "ウェブ検索", "noSuggestions": "候補なし", "addToDictionary": "辞書に追加", diff --git a/messages/ko.json b/messages/ko.json index 5e3b911f..98e6f3f8 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -382,7 +382,7 @@ "paste": "붙여넣기", "highlight": "하이라이트", "addCharacter": "인물 추가", - "addComment": "댓글 추가", + "addComment": "댓글", "searchOnWeb": "웹 검색", "noSuggestions": "제안 없음", "addToDictionary": "사전에 추가", diff --git a/messages/pl.json b/messages/pl.json index c78feb68..1caf69ba 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", diff --git a/messages/zh.json b/messages/zh.json index b1fe3406..ac973006 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -382,7 +382,7 @@ "paste": "粘贴", "highlight": "高亮", "addCharacter": "添加角色", - "addComment": "添加评论", + "addComment": "评论", "searchOnWeb": "网页搜索", "noSuggestions": "无建议", "addToDictionary": "添加到字典", diff --git a/src/lib/adapters/pdf/pdf-adapter.ts b/src/lib/adapters/pdf/pdf-adapter.ts index b3fb8875..2e91f293 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 c1eb331f..81429b13 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"; @@ -553,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); @@ -566,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); @@ -588,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(() => { diff --git a/src/lib/project/project-repository.ts b/src/lib/project/project-repository.ts index f8b37bc7..4ea322ac 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]; @@ -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/scene-locking-extension.ts b/src/lib/screenplay/extensions/scene-locking-extension.ts index fec51525..24c445da 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/scene-locking.ts b/src/lib/screenplay/scene-locking.ts index cc14d023..acf6cd8f 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 c89fa974..ce1b89b0 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/styles/scriptio.css b/styles/scriptio.css index ebfeaa2e..1629bc7a 100644 --- a/styles/scriptio.css +++ b/styles/scriptio.css @@ -226,32 +226,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; } - /* 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; - font-style: normal; - } /* Normal weight scene headings (when bold disabled) */ &.scene-heading-normal .scene { From 79e089a7f6e6ffb21e25f41215cfa8f44742df5f Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Mon, 1 Jun 2026 01:44:59 +0200 Subject: [PATCH 3/4] fixed pagination issue because of font lazy loading, removed top margin of top-page nodes --- .../extensions/pagination-extension.ts | 157 ++++++++++++------ styles/scriptio.css | 11 ++ 2 files changed, 119 insertions(+), 49 deletions(-) diff --git a/src/lib/screenplay/extensions/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts index 7df545d9..cc8a29ed 100644 --- a/src/lib/screenplay/extensions/pagination-extension.ts +++ b/src/lib/screenplay/extensions/pagination-extension.ts @@ -454,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, @@ -677,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: { @@ -688,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"); @@ -760,9 +770,7 @@ 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; @@ -872,23 +880,13 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: // 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) - ) { + 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 - ) { + 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 @@ -901,12 +899,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: 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 topHeight = getHTMLHeight(topElement, editorDOM, node.type.name, options); const bottomHeight = Math.max(0, height - topHeight); pagePos += topHeight; @@ -957,8 +950,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: // 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; + const effectiveHeight = currentSceneOmitted && nodeType !== ScreenplayElement.Scene ? 0 : height; // Accumulate height on current page pagePos += effectiveHeight; @@ -1008,7 +1000,13 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: // 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 } } @@ -1027,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) { @@ -1124,9 +1118,7 @@ const createPaginationPlugin = (extension: { options: PaginationOptions; editor: // 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); + 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 @@ -1235,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; } @@ -1257,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) { @@ -1398,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() { @@ -1489,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() { diff --git a/styles/scriptio.css b/styles/scriptio.css index 1629bc7a..d27d2064 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, From 8e618cff895d7591aa53ddbc8fe106232e471ff9 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Mon, 1 Jun 2026 02:50:47 +0200 Subject: [PATCH 4/4] added a centralized draft locking button, added a settings button to production panel --- components/navbar/ProductionPanel.module.css | 6 + components/navbar/ProductionPanel.tsx | 258 ++++++++++++------- components/navbar/ScreenplaySearch.tsx | 17 ++ components/popup/Popup.tsx | 4 + components/popup/PopupUnlockDraft.tsx | 54 ++++ components/popup/PopupUnlockScenes.tsx | 2 +- messages/de.json | 8 +- messages/en.json | 6 + messages/es.json | 8 +- messages/fr.json | 8 +- messages/ja.json | 8 +- messages/ko.json | 8 +- messages/pl.json | 8 +- messages/zh.json | 8 +- src/lib/screenplay/popup.ts | 15 +- 15 files changed, 315 insertions(+), 103 deletions(-) create mode 100644 components/popup/PopupUnlockDraft.tsx diff --git a/components/navbar/ProductionPanel.module.css b/components/navbar/ProductionPanel.module.css index 1f45f8e5..0de9f091 100644 --- a/components/navbar/ProductionPanel.module.css +++ b/components/navbar/ProductionPanel.module.css @@ -27,6 +27,12 @@ color: var(--primary-text); } +.header_actions { + display: flex; + align-items: center; + gap: 4px; +} + .close_btn { display: flex; align-items: center; diff --git a/components/navbar/ProductionPanel.tsx b/components/navbar/ProductionPanel.tsx index afc9c664..b2297d47 100644 --- a/components/navbar/ProductionPanel.tsx +++ b/components/navbar/ProductionPanel.tsx @@ -2,13 +2,14 @@ import { useCallback, useContext, useEffect, useMemo, useRef } from "react"; import { useTranslations } from "next-intl"; -import { Lock, X, Layers } from "lucide-react"; +import { X, BookOpen, Clapperboard, PencilLine, Settings } from "lucide-react"; import { ProjectContext } from "@src/context/ProjectContext"; import { UserContext } from "@src/context/UserContext"; +import { DashboardContext } from "@src/context/DashboardContext"; import { computeSceneLabels } from "@src/lib/screenplay/scene-locking"; import { computeSceneItems } from "@src/lib/screenplay/scenes"; -import { unlockPagesPopup, unlockScenesPopup } from "@src/lib/screenplay/popup"; +import { unlockDraftPopup, unlockPagesPopup, unlockScenesPopup } from "@src/lib/screenplay/popup"; import { getPageAnchors, getPageAnchorInfo } from "@src/lib/screenplay/extensions/pagination-extension"; import Switch from "@components/utils/Switch"; @@ -47,9 +48,15 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { isReadOnly, } = useContext(ProjectContext); const userCtx = useContext(UserContext); + const { openDashboard } = useContext(DashboardContext); const panelRef = useRef(null); + const handleOpenSettings = () => { + onClose(); + openDashboard("Production"); + }; + // Click outside to close useEffect(() => { if (!isOpen) return; @@ -94,36 +101,45 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { }); }, [repository]); + // Writes-only scene relock: assigns a frozen token to every scene that + // `computeSceneLabels` reports as provisional. Idempotent — scenes that + // already hold a token keep it. Must be called inside `repository.transact` + // so it can be composed with the page writes for a combined draft lock. + const relockScenesWrites = useCallback(() => { + if (!repository) return; + const currentScreenplay = repository.screenplay; + const scenes = computeSceneItems(currentScreenplay); + const uuids = scenes.map((s) => s.id).filter((id): id is string => !!id); + + // Re-read fresh persistent data + const persistentSnapshot = repository.scenes; + + const currentLabels = computeSceneLabels( + uuids, + persistentSnapshot, + sceneNumberingStyle, + skippedSceneLetters, + ); + + currentLabels.forEach((label) => { + if (label.status === "provisional") { + repository.upsertScene(label.uuid, { token: label.token }); + } + }); + }, [repository, sceneNumberingStyle, skippedSceneLetters]); + + // Lock = freeze the current provisional tokens, then flip the flag on. + const lockScenesWrites = useCallback(() => { + if (!repository) return; + relockScenesWrites(); + repository.setSceneLocking(true); + }, [repository, relockScenesWrites]); + const handleSceneLockingToggle = (next: boolean) => { if (!repository || isReadOnly) return; if (next) { repository.transact(() => { - const currentScreenplay = repository.screenplay; - const scenes = computeSceneItems(currentScreenplay); - const uuids = scenes.map(s => s.id).filter((id): id is string => !!id); - - // Idempotent: any scene that already has a token (e.g. left - // over from an earlier session, or that survived an unlock - // in read-only mode) keeps its frozen label. Only scenes - // computed as provisional by `computeSceneLabels` get a new - // token written. On a fresh lock-on with no existing - // tokens, this falls through to baseToken(idx+1) for every - // scene, matching the previous behaviour. - const persistentSnapshot = repository.scenes; - const labels = computeSceneLabels( - uuids, - persistentSnapshot, - sceneNumberingStyle, - skippedSceneLetters, - ); - - labels.forEach((label) => { - if (label.status === "provisional") { - repository.upsertScene(label.uuid, { token: label.token }); - } - }); - - repository.setSceneLocking(true); + lockScenesWrites(); }); } else { unlockScenesPopup(performUnlock, userCtx); @@ -133,25 +149,7 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { const handleRelock = () => { if (!repository || isReadOnly) return; repository.transact(() => { - const currentScreenplay = repository.screenplay; - const scenes = computeSceneItems(currentScreenplay); - const uuids = scenes.map(s => s.id).filter((id): id is string => !!id); - - // Re-read fresh persistent data - const persistentSnapshot = repository.scenes; - - const currentLabels = computeSceneLabels( - uuids, - persistentSnapshot, - sceneNumberingStyle, - skippedSceneLetters, - ); - - currentLabels.forEach((label) => { - if (label.status === "provisional") { - repository.upsertScene(label.uuid, { token: label.token }); - } - }); + relockScenesWrites(); }); }; @@ -187,37 +185,45 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { }); }, [repository]); + // Writes-only page relock. Idempotent: any anchor that already has a token + // keeps it; only provisional anchors (no token yet) get a freshly-computed + // one. 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. Must be called inside + // `repository.transact`. + const relockPagesWrites = useCallback(() => { + if (!repository || !editor) return; + const anchorInfos = getPageAnchorInfo(editor); + const anchors = anchorInfos.map((a) => a.anchorId); + const persistentSnapshot = repository.pages; + const currentLabels = computeSceneLabels( + anchors, + persistentSnapshot, + "suffix", + skippedSceneLetters, + ); + currentLabels.forEach((label, idx) => { + if (label.status === "provisional") { + repository.upsertPage(label.uuid, { + token: label.token, + splitOffset: anchorInfos[idx]?.splitOffset, + }); + } + }); + }, [repository, editor, skippedSceneLetters]); + + const lockPagesWrites = useCallback(() => { + if (!repository || !editor) return; + relockPagesWrites(); + repository.setPageLocking(true); + }, [repository, editor, relockPagesWrites]); + const handlePageLockingToggle = (next: boolean) => { if (!repository || isReadOnly) return; if (next) { if (!editor) return; repository.transact(() => { - 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 - // one. A fresh lock-on with no existing tokens assigns every - // page baseToken(idx+1) — same shape as scene locking. - const computed = computeSceneLabels( - anchors, - persistentSnapshot, - "suffix", - skippedSceneLetters, - ); - computed.forEach((label, idx) => { - if (label.status === "provisional") { - // 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); + lockPagesWrites(); }); } else { unlockPagesPopup(performPageUnlock, userCtx); @@ -227,23 +233,49 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => { const handlePageRelock = () => { if (!repository || isReadOnly || !editor) return; repository.transact(() => { - const anchorInfos = getPageAnchorInfo(editor); - const anchors = anchorInfos.map((a) => a.anchorId); - const persistentSnapshot = repository.pages; - const currentLabels = computeSceneLabels( - anchors, - persistentSnapshot, - "suffix", - skippedSceneLetters, - ); - currentLabels.forEach((label, idx) => { - if (label.status === "provisional") { - repository.upsertPage(label.uuid, { - token: label.token, - splitOffset: anchorInfos[idx]?.splitOffset, - }); - } + relockPagesWrites(); + }); + }; + + // -------------- Draft locking (scenes + pages together) -------------- + // The draft toggle reflects whether *everything* is locked. Turning it on + // locks scenes and pages in a single transaction (each write is idempotent, + // so a partially-locked draft is brought fully in line); turning it off + // clears both via one confirmation popup. + const draftLocking = sceneLocking && pageLocking; + + const hasProvisionalDraft = + (sceneLocking && provisionalLabels.length > 0) || + (pageLocking && provisionalPageLabels.length > 0); + + const performDraftUnlock = useCallback(() => { + if (!repository) return; + repository.transact(() => { + repository.clearSceneLocks(); + repository.setSceneLocking(false); + repository.clearPageLocks(); + repository.setPageLocking(false); + }); + }, [repository]); + + const handleDraftLockingToggle = (next: boolean) => { + if (!repository || isReadOnly) return; + if (next) { + if (!editor) return; + repository.transact(() => { + lockScenesWrites(); + lockPagesWrites(); }); + } else { + unlockDraftPopup(performDraftUnlock, userCtx); + } + }; + + const handleDraftRelock = () => { + if (!repository || isReadOnly) return; + repository.transact(() => { + if (sceneLocking) relockScenesWrites(); + if (pageLocking) relockPagesWrites(); }); }; @@ -253,16 +285,54 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => {
{t("title")} - +
+ + +
+
+ + {/* Draft Locking (scenes + pages together) */} +
+
+
+ + {t("draftLocking")} +
+
+ {draftLocking && hasProvisionalDraft && ( + + )} + +
+
{/* Scene Locking */}
- + {t("sceneLocking")}
@@ -306,7 +376,7 @@ const ProductionPanel = ({ isOpen, onClose }: ProductionPanelProps) => {
- + {t("pageLocking")}
diff --git a/components/navbar/ScreenplaySearch.tsx b/components/navbar/ScreenplaySearch.tsx index c91b71ca..b6337444 100644 --- a/components/navbar/ScreenplaySearch.tsx +++ b/components/navbar/ScreenplaySearch.tsx @@ -88,6 +88,23 @@ const ScreenplaySearch = () => { setReplaceValue(""); }, [setSearchTerm]); + // Click outside to close — only when the search field is empty. If there's + // an in-progress search, keep the panel open so it isn't lost on a stray + // click. Reads the live input value (uncontrolled) rather than the debounced + // context term so it stays accurate before the debounce fires. + useEffect(() => { + if (!isOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + if (!inputRef.current?.value) { + handleClose(); + } + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen, handleClose]); + // Use uncontrolled input with debounced updates to context const handleSearchChange = useCallback( (e: React.ChangeEvent) => { diff --git a/components/popup/Popup.tsx b/components/popup/Popup.tsx index f1e40989..9555e7a5 100644 --- a/components/popup/Popup.tsx +++ b/components/popup/Popup.tsx @@ -7,6 +7,7 @@ import { PopupImportFileData, PopupSceneData, PopupType, + PopupUnlockDraftData, PopupUnlockPagesData, PopupUnlockScenesData, PopupUploadToCloudData, @@ -15,6 +16,7 @@ import { useContext } from "react"; import PopupCharacterItem from "./PopupCharacterItem"; import PopupImportFile from "./PopupImportFile"; import PopupSceneItem from "./PopupSceneItem"; +import PopupUnlockDraft from "./PopupUnlockDraft"; import PopupUnlockPages from "./PopupUnlockPages"; import PopupUnlockScenes from "./PopupUnlockScenes"; import PopupUploadToCloud from "./PopupUploadToCloud"; @@ -38,6 +40,8 @@ export const Popup = () => { return )} />; case PopupType.UnlockPages: return )} />; + case PopupType.UnlockDraft: + return )} />; default: return null; } diff --git a/components/popup/PopupUnlockDraft.tsx b/components/popup/PopupUnlockDraft.tsx new file mode 100644 index 00000000..6cca7f57 --- /dev/null +++ b/components/popup/PopupUnlockDraft.tsx @@ -0,0 +1,54 @@ +"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, PopupUnlockDraftData, closePopup } from "@src/lib/screenplay/popup"; +import { UserContext } from "@src/context/UserContext"; + +const PopupUnlockDraft = ({ data: { confirmUnlock } }: PopupData) => { + const userCtx = useContext(UserContext); + const { position, handleMouseDown, isDragging } = useDraggable(); + const t = useTranslations("production"); + + const onConfirm = () => { + confirmUnlock(); + closePopup(userCtx); + }; + + return ( +
+
+
+

{t("unlockDraftTitle")}

+ closePopup(userCtx)} /> +
+
+

{t("unlockDraftWarning")}

+
+
+ + +
+
+
+ ); +}; + +export default PopupUnlockDraft; diff --git a/components/popup/PopupUnlockScenes.tsx b/components/popup/PopupUnlockScenes.tsx index 607a0bf1..dcedfafe 100644 --- a/components/popup/PopupUnlockScenes.tsx +++ b/components/popup/PopupUnlockScenes.tsx @@ -35,7 +35,7 @@ const PopupUnlockScenes = ({ data: { confirmUnlock } }: PopupData closePopup(userCtx)} />
-

{t("unlockWarning")}

+

{t.rich("unlockWarning", { b: (chunks) => {chunks} })}