From ce2c0b82dddc71f8b07e35312912c8e8eac5d3a3 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Fri, 19 Jun 2026 02:29:10 +0200 Subject: [PATCH 1/3] fixed page break logic and page number rendering --- .../extensions/pagination-extension.ts | 70 ++++++++++++------- styles/scriptio.css | 1 + 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/src/lib/screenplay/extensions/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts index 2f3a17e..cd3c15f 100644 --- a/src/lib/screenplay/extensions/pagination-extension.ts +++ b/src/lib/screenplay/extensions/pagination-extension.ts @@ -1,5 +1,4 @@ import { DOMSerializer } from "@node_modules/prosemirror-model/dist"; -import { CircularBuffer } from "@src/lib/utils/circular-buffer"; import { ScreenplayElement } from "@src/lib/utils/enums"; import { Editor, Extension } from "@tiptap/core"; import { Node } from "@tiptap/pm/model"; @@ -682,9 +681,7 @@ function buildDecorations( }; // First page top margin / header - pushWidget(0, `page-1-header-${firstPageLabel}-${fp}`, -1, () => - createFirstPageWidget(firstPageLabel, options), - ); + pushWidget(0, `page-1-header-${firstPageLabel}-${fp}`, -1, () => createFirstPageWidget(firstPageLabel, options)); markPageStart(0, "pagination-doc-start"); if (firstPageLocked) pushPageLockBadge(0); @@ -916,8 +913,14 @@ function trySplitNode( getHTMLHeight(bottomElement, editorDOM, node.type.name, options) - nodeMarginTop, ); - // Bottom too short — not worth a split; force the whole node to the next page. - if (bottomHeight < LINE_HEIGHT * MIN_SPLIT_BOTTOM_LINES) return null; + // Bottom would be a widow (< MIN_SPLIT_BOTTOM_LINES). Don't abandon the + // split — try a SHORTER top prefix instead. A shorter top pushes the + // next sentence down too, growing the bottom past the threshold while + // the (smaller) top still fits the freespace. Heights are monotonic in + // the prefix length, so the loop keeps walking down until it finds the + // largest prefix that BOTH fits and leaves an adequate bottom; only if + // no prefix qualifies do we fall out of the loop and move the whole node. + if (bottomHeight < LINE_HEIGHT * MIN_SPLIT_BOTTOM_LINES) continue; // The split position in document space: // nodeDocPos + 1 skips the node's opening token; topText.length then walks @@ -931,7 +934,8 @@ function trySplitNode( } } - // No prefix fits — the first sentence alone is too tall; move the whole node. + // No valid split: either the first sentence alone is too tall to fit, or every + // prefix that fits would leave a too-short widow on the next page. Move the whole node. return null; } @@ -1114,14 +1118,17 @@ const createPaginationPlugin = (extension: { // subsequent page is byte-identical to the previous layout. let shortCircuited = false; - let lastNodes: CircularBuffer = new CircularBuffer(3); + // Nodes on the current page, oldest first; reset at every break, so it + // only ever holds one page's worth (bounded by page height). The orphan + // walkback scans it from the end and must see the ENTIRE trailing + // keep-with-next run — Scene → Character → Parenthetical is already 3 + // long — so a fixed 3-slot window would strand the head of longer runs. + let lastNodes: NodeInfo[] = []; for (let i = 0; i < childCount; i++) { const node = newState.doc.child(i); const pos = offset; offset += node.nodeSize; - if (!("height" in node.attrs)) continue; - const nodeType = node.type.name as ScreenplayElement; const logic = BREAK_LOGIC[nodeType]; @@ -1163,7 +1170,7 @@ const createPaginationPlugin = (extension: { }); // New page's first node margin is set by the pagePos === 0 path below. pagePos = 0; - lastNodes = new CircularBuffer(3); + lastNodes = []; } // --- Force page break for locked page anchors --- @@ -1226,7 +1233,7 @@ const createPaginationPlugin = (extension: { // split is not a page-start node) — no top margin to strip. pageStartMargin = 0; pagePos = bottomHeight; - lastNodes = new CircularBuffer(3); + lastNodes = []; lastNodes.push({ pos, type: nodeType, @@ -1252,7 +1259,7 @@ const createPaginationPlugin = (extension: { }); // New page's first node margin is set by the pagePos === 0 path below. pagePos = 0; - lastNodes = new CircularBuffer(3); + lastNodes = []; } } @@ -1266,7 +1273,8 @@ const createPaginationPlugin = (extension: { } pagePos += height; - // We keep the last 3 nodes for orphan resolution on page break + // Record this node for orphan resolution; the walkback at the next + // break scans this list backward through the trailing keep-with-next run. lastNodes.push({ pos, type: nodeType, @@ -1322,7 +1330,7 @@ const createPaginationPlugin = (extension: { // no top margin to strip. pageStartMargin = 0; pagePos = split.bottomHeight; - lastNodes = new CircularBuffer(3); + lastNodes = []; lastNodes.push({ pos, type: nodeType, @@ -1335,15 +1343,23 @@ const createPaginationPlugin = (extension: { } // --- Orphan resolution --- - // Walk back through the buffer: if the last fitted node has keepWithNext, - // slide the break back to its position (and carry its height to the next page). - // Repeat once more for the double-orphan case (e.g. Character → Parenthetical). + // Walk back through this page's nodes, sliding the break before each + // node that would otherwise be stranded as the page's last item: while + // the would-be last node has keepWithNext, carry it (and its height) to + // the next page. This handles a keep-with-next run of ANY length, e.g. + // Scene → Character → Parenthetical → Dialogue, not just the two-node + // case. It stops at the first node safe to end a page on, at a locked + // anchor (which owns its page), or before the page's first node. let breakPos = pos; let carryHeight = height; // cumulative height that moves to the next page let backCount = 0; // how many nodes slid back - for (let back = 1; back <= 2; back++) { - const prev = lastNodes.at(back); // at(1) = last fitted, at(2) = one before + // lastNodes[length - 1] is the current (overflowing) node; index + // (length - 1 - back) walks backward from it (back = 1 is the last + // fitted node). The `back < length - 1` bound never reaches index 0, + // so at least one node always stays — a break can never empty a page. + for (let back = 1; back < lastNodes.length - 1; back++) { + const prev = lastNodes[lastNodes.length - 1 - back]; if (!prev) break; // A locked anchor owns its page and must never be displaced by // walkback — otherwise the next overflow would yank it onto an @@ -1361,9 +1377,10 @@ const createPaginationPlugin = (extension: { } // freespace = space left before the first node that moved down. - // lastNodes.at(backCount) is that first node; positionTop is its accumulated - // page height just before it was added — i.e. the used space above it. - const firstMovingNode = lastNodes.at(backCount); + // That first moving node sits backCount steps back from the current node; + // positionTop is its accumulated page height just before it was added — + // i.e. the used space above it. + const firstMovingNode = lastNodes[lastNodes.length - 1 - backCount]; const freespace = contentHeight - (firstMovingNode?.positionTop ?? pagePos - height); // If the first node moving to the next page is Dialogue or Parenthetical, @@ -1407,12 +1424,13 @@ const createPaginationPlugin = (extension: { const carryNodes: NodeInfo[] = []; let carryTop = 0; for (let back = backCount; back >= 0; back--) { - const n = lastNodes.at(back)!; + const n = lastNodes[lastNodes.length - 1 - back]; carryNodes.push({ ...n, positionTop: carryTop }); carryTop += n.height; } - lastNodes = new CircularBuffer(3); - for (const n of carryNodes) lastNodes.push(n); + // carryNodes is already oldest→newest with fresh positionTop values, + // which is exactly the page-relative ordering lastNodes expects. + lastNodes = carryNodes; // Short-circuit: past the changed range and this break matches an old break // (same position, freespace, and contdName) → layout is back in sync; diff --git a/styles/scriptio.css b/styles/scriptio.css index 018e189..423d381 100644 --- a/styles/scriptio.css +++ b/styles/scriptio.css @@ -108,6 +108,7 @@ .page-number > * { color: var(--editor-text); font-family: var(--font-screenplay); + line-height: var(--line-height); } /* Editor styles */ From bb1d2c6a39fbbeb7519eb6c2903842bfa73ab7ed Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Mon, 22 Jun 2026 00:19:07 +0200 Subject: [PATCH 2/3] assets syncing to cloud, added manual page break, english spellchecker now built-in, added spellcheck toggle --- components/board/BoardCanvas.module.css | 19 + components/board/BoardCanvas.tsx | 63 +- components/dashboard/DashboardModal.tsx | 5 +- components/dashboard/DashboardSidebar.tsx | 1 + .../preferences/LanguageSettings.tsx | 40 +- .../preferences/SpellcheckSettings.module.css | 12 + .../project/StorageSettings.module.css | 175 + .../dashboard/project/StorageSettings.tsx | 279 + components/editor/DocumentEditorPanel.tsx | 35 +- components/editor/EditorPanel.module.css | 11 +- components/editor/sidebar/ContextMenu.tsx | 34 +- components/navbar/ProjectNavbar.module.css | 55 + components/navbar/ProjectNavbar.tsx | 105 +- components/project/EditorFooter.tsx | 34 +- messages/de.json | 28 +- messages/en.json | 28 +- messages/es.json | 28 +- messages/fr.json | 28 +- messages/ja.json | 28 +- messages/ko.json | 28 +- messages/pl.json | 28 +- messages/zh.json | 28 +- prisma/schema.prisma | 21 + public/dictionaries/en/index.aff | 205 + public/dictionaries/en/index.dic | 49569 ++++++++++++++++ public/dictionaries/en/license | 347 + src/app/api/internal/asset-gc/route.ts | 47 + .../[projectId]/assets/[hash]/route.ts | 44 + .../projects/[projectId]/assets/gc/route.ts | 54 + .../[projectId]/assets/missing/route.ts | 40 + .../api/projects/[projectId]/assets/route.ts | 108 + src/app/api/projects/[projectId]/route.ts | 7 + .../api/projects/[projectId]/storage/route.ts | 37 + src/app/api/users/me/storage/route.ts | 19 + src/context/SpellcheckContext.tsx | 25 +- src/lib/adapters/fountain/fountain-adapter.ts | 17 +- src/lib/assets/asset-gc.ts | 62 +- src/lib/assets/asset-orphans.ts | 20 + src/lib/assets/asset-refs.ts | 34 + src/lib/assets/asset-store.ts | 127 +- src/lib/assets/cloud-asset-sync.ts | 129 + src/lib/assets/use-asset-gc.ts | 10 +- src/lib/cloud/index.ts | 7 +- src/lib/cloud/room.ts | 137 +- src/lib/cloud/types.ts | 2 + src/lib/cloud/wrangler.toml | 13 +- .../storage-provider/local-persistence.ts | 38 + src/lib/project/project-doc.ts | 2 +- src/lib/project/project-state.ts | 59 +- src/lib/s3.ts | 61 +- src/lib/screenplay/editor.ts | 6 +- .../extensions/pagination-extension.ts | 157 +- src/lib/spellcheck/spellcheck-dictionaries.ts | 19 + src/lib/utils/api-utils.ts | 5 + src/lib/utils/storage-limits.ts | 21 + src/proxy.ts | 1 + src/server/repository/project-repository.ts | 68 + src/server/service/asset-gc-service.ts | 37 + src/server/service/project-service.ts | 45 + .../adapters/fountain-page-break.test.ts | 78 + src/tests/assets/assets.test.ts | 4 +- src/tests/assets/cloud-assets.test.ts | 59 + src/tests/project/scriptio-roundtrip.test.ts | 2 +- src/tests/repro/manual-break-delete.test.ts | 193 + src/tests/repro/manual-page-break.test.ts | 128 + 65 files changed, 52964 insertions(+), 192 deletions(-) create mode 100644 components/dashboard/project/StorageSettings.module.css create mode 100644 components/dashboard/project/StorageSettings.tsx create mode 100644 public/dictionaries/en/index.aff create mode 100644 public/dictionaries/en/index.dic create mode 100644 public/dictionaries/en/license create mode 100644 src/app/api/internal/asset-gc/route.ts create mode 100644 src/app/api/projects/[projectId]/assets/[hash]/route.ts create mode 100644 src/app/api/projects/[projectId]/assets/gc/route.ts create mode 100644 src/app/api/projects/[projectId]/assets/missing/route.ts create mode 100644 src/app/api/projects/[projectId]/assets/route.ts create mode 100644 src/app/api/projects/[projectId]/storage/route.ts create mode 100644 src/app/api/users/me/storage/route.ts create mode 100644 src/lib/assets/asset-orphans.ts create mode 100644 src/lib/assets/asset-refs.ts create mode 100644 src/lib/assets/cloud-asset-sync.ts create mode 100644 src/lib/utils/storage-limits.ts create mode 100644 src/server/service/asset-gc-service.ts create mode 100644 src/tests/adapters/fountain-page-break.test.ts create mode 100644 src/tests/assets/cloud-assets.test.ts create mode 100644 src/tests/repro/manual-break-delete.test.ts create mode 100644 src/tests/repro/manual-page-break.test.ts diff --git a/components/board/BoardCanvas.module.css b/components/board/BoardCanvas.module.css index af8ddc5..a9bd10a 100644 --- a/components/board/BoardCanvas.module.css +++ b/components/board/BoardCanvas.module.css @@ -420,6 +420,25 @@ font-variant-numeric: tabular-nums; } +.asset_error { + position: absolute; + top: 8px; + left: 50%; + transform: translateX(-50%); + z-index: 12; + display: flex; + align-items: center; + gap: 8px; + max-width: 80%; + height: 36px; + padding: 0 14px; + border-radius: 16px; + background-color: var(--error); + color: #fff; + font-size: 13px; + user-select: none; +} + .recording_stop { display: flex; align-items: center; diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx index e9598ab..360ad86 100644 --- a/components/board/BoardCanvas.tsx +++ b/components/board/BoardCanvas.tsx @@ -15,7 +15,8 @@ import { v7 as uuidv7 } from "uuid"; import { Trash2, Plus, Minus, Copy, ListTree, Mic, Square } from "lucide-react"; import { useTranslations } from "next-intl"; import { DEFAULT_ITEM_COLORS } from "@src/lib/utils/colors"; -import { importImageFile, importAudioFile } from "@src/lib/assets/asset-store"; +import { importImageFile, importAudioFile, syncAssetToCloud } from "@src/lib/assets/asset-store"; +import { CloudQuotaError } from "@src/lib/assets/cloud-asset-sync"; import { scheduleAssetGc } from "@src/lib/assets/asset-gc"; import { useAudioRecorder } from "./use-audio-recorder"; @@ -56,6 +57,9 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) const [isPanning, setIsPanning] = useState(false); const [isDraggingFile, setIsDraggingFile] = useState(false); const [isSnapping, setIsSnapping] = useState(true); + /** Transient banner shown when an asset can't be saved (e.g. cloud quota). */ + const [assetError, setAssetError] = useState(null); + const assetErrorTimer = useRef | null>(null); const recorder = useAudioRecorder(); const [prevIsVisible, setPrevIsVisible] = useState(isVisible); if (prevIsVisible !== isVisible) { @@ -527,6 +531,50 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setIsDraggingFile(false); }, []); + // Show a transient error banner (auto-dismissed). Used when an asset can't be + // persisted, e.g. the owner is out of cloud storage. + const showAssetError = useCallback((message: string) => { + setAssetError(message); + if (assetErrorTimer.current) clearTimeout(assetErrorTimer.current); + assetErrorTimer.current = setTimeout(() => setAssetError(null), 4000); + }, []); + + useEffect(() => () => { + if (assetErrorTimer.current) clearTimeout(assetErrorTimer.current); + }, []); + + // Remove cards by id (used to roll back a card whose asset can't be saved). + const removeCards = useCallback( + (ids: Set) => { + const next = cardsRef.current.filter((c) => !ids.has(c.id)); + cardsRef.current = next; // keep the ref current so concurrent removals don't race + setCards(next); + saveCards(next); + }, + [saveCards], + ); + + // Upload the new cards' assets to the cloud in the background, so the cards + // appear instantly (the bytes are already cached locally and render offline). + // If an upload is rejected for quota, roll back that card and explain why. + const syncCreatedAssets = useCallback( + (createdCards: BoardCardData[], pid: string) => { + for (const card of createdCards) { + if (!card.assetId) continue; + const cardId = card.id; + void syncAssetToCloud(pid, card.assetId).catch((err) => { + if (err instanceof CloudQuotaError) { + removeCards(new Set([cardId])); + showAssetError(t("storageLimitReached")); + } else { + console.error("[BoardCanvas] cloud asset upload failed:", err); + } + }); + } + }, + [removeCards, showAssetError, t], + ); + // Drop image files → store each in IndexedDB (deduped) and drop an image // card referencing its hash at the cursor. const handleDrop = useCallback( @@ -589,8 +637,11 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) const newCards = [...cardsRef.current, ...created]; setCards(newCards); saveCards(newCards); + + // Upload to the cloud in the background (cards already show locally). + syncCreatedAssets(created, projectId); }, - [isReadOnly, projectId, offset, scale, saveCards], + [isReadOnly, projectId, offset, scale, saveCards, syncCreatedAssets], ); // Create a text card at the given canvas-space coords (from the canvas menu). @@ -651,10 +702,13 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) const newCards = [...cardsRef.current, newCard]; setCards(newCards); saveCards(newCards); + + // Upload to the cloud in the background (card already shows locally). + syncCreatedAssets([newCard], projectId); } catch (err) { console.error("[BoardCanvas] Failed to store recording:", err); } - }, [recorder, projectId, saveCards]); + }, [recorder, projectId, saveCards, syncCreatedAssets]); // Right-clicking empty canvas opens a menu (create card / record audio). // Cards and arrows have their own menus, so bail when the click landed on one. @@ -1206,6 +1260,9 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) )} + {/* Transient asset error (e.g. cloud storage limit reached) */} + {assetError &&
{assetError}
} + {/* Recording indicator */} {recorder.isRecording && (
diff --git a/components/dashboard/DashboardModal.tsx b/components/dashboard/DashboardModal.tsx index 7fd6c06..8f2f0fc 100644 --- a/components/dashboard/DashboardModal.tsx +++ b/components/dashboard/DashboardModal.tsx @@ -11,7 +11,7 @@ import CollaboratorsSettings from "./project/CollaboratorsSettings"; import styles from "./DashboardModal.module.css"; import ExportProject from "./project/ExportProject"; -import { CreditCard, FileDown, Folder, Globe, Keyboard, Lock, Palette, PanelsTopLeft, User, Users, X } from "lucide-react"; +import { CreditCard, FileDown, Folder, Globe, HardDrive, Keyboard, Lock, Palette, PanelsTopLeft, User, Users, X } from "lucide-react"; import { useTranslations } from "next-intl"; import KeybindsSettings from "./preferences/KeybindsSettings"; import AppearanceSettings from "./preferences/AppearanceSettings"; @@ -20,6 +20,7 @@ import ProfileSettings from "./account/ProfileSettings"; import SubscriptionSettings from "./account/SubscriptionSettings"; import LayoutSettings from "./project/LayoutSettings"; import ProductionSettings from "./project/ProductionSettings"; +import StorageSettings from "./project/StorageSettings"; import DashboardAuth from "./account/DashboardAuth"; import AboutSettings from "./AboutSettings"; @@ -36,6 +37,7 @@ const DashboardModal = () => { { id: "Layout", label: t("tabs.Layout"), icon: }, { id: "Production", label: t("tabs.Production"), icon: }, { id: "Export", label: t("tabs.Export"), icon: }, + { id: "Storage", label: t("tabs.Storage"), icon: }, { id: "Collaborators", label: t("tabs.Collaborators"), icon: }, ], }), [t]); @@ -138,6 +140,7 @@ const DashboardModal = () => { {isInProject && activeTab === "Layout" && } {isInProject && activeTab === "Production" && } {isInProject && activeTab === "Export" && } + {isInProject && activeTab === "Storage" && } {isInProject && activeTab === "Collaborators" && } {/* Preferences tabs */} {activeTab === "Keybinds" && } diff --git a/components/dashboard/DashboardSidebar.tsx b/components/dashboard/DashboardSidebar.tsx index 73f2b50..163fba1 100644 --- a/components/dashboard/DashboardSidebar.tsx +++ b/components/dashboard/DashboardSidebar.tsx @@ -18,6 +18,7 @@ export type Category = | "Layout" | "Production" | "Export" + | "Storage" | "Collaborators" | "Profile" | "Subscription" diff --git a/components/dashboard/preferences/LanguageSettings.tsx b/components/dashboard/preferences/LanguageSettings.tsx index a0292f3..4fb1b2d 100644 --- a/components/dashboard/preferences/LanguageSettings.tsx +++ b/components/dashboard/preferences/LanguageSettings.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useContext, useEffect, useMemo, useState } from "react"; -import { Check, ChevronDown, Download, Loader2, Plus, X } from "lucide-react"; +import { Check, ChevronDown, Download, Loader2, Plus, SpellCheck, X } from "lucide-react"; import form from "./../../utils/Form.module.css"; import sharedStyles from "../project/ProjectSettings.module.css"; import styles from "./SpellcheckSettings.module.css"; @@ -10,7 +10,11 @@ import { useLocale } from "@src/context/LocaleContext"; import { useSettings } from "@src/lib/utils/hooks"; import { useSpellcheck } from "@src/context/SpellcheckContext"; import { ProjectContext } from "@src/context/ProjectContext"; -import { DICTIONARY_CATALOG, formatDictionarySize } from "@src/lib/spellcheck/spellcheck-dictionaries"; +import { + BUILTIN_DICTIONARY_CODE, + DICTIONARY_CATALOG, + formatDictionarySize, +} from "@src/lib/spellcheck/spellcheck-dictionaries"; import Dropdown, { DropdownOption } from "@components/utils/Dropdown"; import { useTranslations } from "next-intl"; @@ -72,12 +76,8 @@ const LanguageSettings = () => { ); const spellcheckOptions: DropdownOption[] = useMemo(() => { - const noneOption: DropdownOption = { - value: "none", - label: t("spellcheckNone"), - }; - - const dictOptions: DropdownOption[] = DICTIONARY_CATALOG.map((dict) => { + return DICTIONARY_CATALOG.map((dict) => { + const isBuiltin = dict.code === BUILTIN_DICTIONARY_CODE; const installed = installedDictionaries.find((d) => d.code === dict.code); const isDownloading = downloadProgress?.code === dict.code; @@ -87,7 +87,12 @@ const LanguageSettings = () => {
{dict.name} - {isDownloading ? ( + {isBuiltin ? ( + <> + {t("spellcheckBuiltin")} + + + ) : isDownloading ? ( ) : installed ? ( <> @@ -100,20 +105,19 @@ const LanguageSettings = () => {
), - triggerLabel: dict.name, + triggerLabel: ( + + + {dict.name} + + ), }; }); - - return [noneOption, ...dictOptions]; }, [installedDictionaries, downloadProgress, t]); const handleSpellcheckChange = useCallback( (value: string) => { - if (value === "none") { - setSpellcheckLang(null); - return; - } - const isInstalled = installedDictionaries.some((d) => d.code === value); + const isInstalled = value === BUILTIN_DICTIONARY_CODE || installedDictionaries.some((d) => d.code === value); if (isInstalled) { setSpellcheckLang(value); } else { @@ -140,7 +144,7 @@ const LanguageSettings = () => {

{t("spellcheckHelpText")}

= 1024 && i < units.length - 1) { + value /= 1024; + i++; + } + return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`; +} + +/** Owner-shared storage split into this project, the owner's other projects, and free. */ +interface StorageBreakdown { + thisUsed: number; + otherUsed: number; + quota: number; +} + +const emptyBreakdown = (): StorageBreakdown => ({ + thisUsed: 0, + otherUsed: 0, + quota: USER_STORAGE_QUOTA_BYTES, +}); + +/** Last fetched breakdown (tagged with its project id), so re-opening the section + * shows the previous figures immediately and the bar never shifts layout. */ +let cachedBreakdown: { projectId: string; breakdown: StorageBreakdown } | null = null; + +const AssetRow = ({ + projectId, + asset, + readOnly, + onDelete, +}: { + projectId: string; + asset: ProjectAssetInfo; + readOnly: boolean; + onDelete: (hash: string) => void; +}) => { + const t = useTranslations("storage"); + const isImage = asset.mime.startsWith("image/"); + const url = useAssetUrl(projectId, isImage ? asset.hash : null); + const [confirming, setConfirming] = useState(false); + + const dims = isImage && asset.width && asset.height ? `${asset.width}×${asset.height}` : null; + + return ( +
+
+ {isImage ? ( + url ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + ) + ) : ( + + )} +
+
+ {isImage ? t("image") : t("audio")} + + {dims ? `${dims} · ${formatBytes(asset.size)}` : formatBytes(asset.size)} + +
+ + {!readOnly && + (confirming ? ( +
+ + +
+ ) : ( + + ))} +
+ ); +}; + +const StorageBar = ({ breakdown, isLocalOnly }: { breakdown: StorageBreakdown; isLocalOnly: boolean }) => { + const t = useTranslations("storage"); + const { thisUsed, otherUsed, quota } = breakdown; + const used = thisUsed + otherUsed; + const free = Math.max(0, quota - used); + + const thisPct = quota > 0 ? Math.min(100, (thisUsed / quota) * 100) : 0; + const otherPct = quota > 0 ? Math.min(100 - thisPct, (otherUsed / quota) * 100) : 0; + + return ( +
+
+ {t("sharedStorage")} + + {formatBytes(used)} / {formatBytes(quota)} + +
+ +
+
+
+
+ +
+ + + {t("thisProject")} + {formatBytes(thisUsed)} + + + + {t("otherProjects")} + {formatBytes(otherUsed)} + + + + {t("free")} + {formatBytes(free)} + +
+ + {isLocalOnly &&

{t("localNote")}

} +
+ ); +}; + +const StorageSettings = () => { + const t = useTranslations("storage"); + const { projectId, repository, isReadOnly } = useContext(ProjectContext); + const { isLocalOnly } = useProjectMembership(); + const [assets, setAssets] = useState(null); + // Seed from the cache so the bar shows last-known figures immediately (and at a + // stable size); fetched values refresh it in place without shifting layout. + const [breakdown, setBreakdown] = useState(() => + cachedBreakdown?.projectId === projectId ? cachedBreakdown.breakdown : emptyBreakdown(), + ); + + const load = useCallback(async () => { + if (!projectId || !repository) { + setAssets([]); + setBreakdown(emptyBreakdown()); + return; + } + + const list = await listInUseAssets(projectId, repository.getState()); + list.sort((a, b) => b.size - a.size); + setAssets(list); + + const { fetchProjectStorage, fetchMyStorage } = await import("@src/lib/assets/cloud-asset-sync"); + let bd: StorageBreakdown; + if (isLocalOnly) { + // Not synced: this project's usage is local; "other" is the owner's + // current cloud usage (this project would add on top once synced). + const localTotal = list.reduce((sum, a) => sum + a.size, 0); + const my = await fetchMyStorage(); + bd = { thisUsed: localTotal, otherUsed: my?.used ?? 0, quota: my?.quota ?? USER_STORAGE_QUOTA_BYTES }; + } else { + const s = await fetchProjectStorage(projectId); + bd = s + ? { thisUsed: s.projectUsed, otherUsed: Math.max(0, s.ownerTotalUsed - s.projectUsed), quota: s.quota } + : emptyBreakdown(); + } + cachedBreakdown = { projectId, breakdown: bd }; + setBreakdown(bd); + }, [projectId, repository, isLocalOnly]); + + useEffect(() => { + void load(); + }, [load]); + + const deleteAsset = useCallback( + async (hash: string) => { + if (!projectId || !repository) return; + const ydoc = repository.getState(); + + // Remove every board card (and dangling arrow) referencing the asset. + ydoc.transact(() => { + ydoc.documents().forEach((node) => { + if (node.type !== "board") return; + const map = ydoc.boardData(node.id); + const rawCards = map.get("cards"); + if (!rawCards) return; + let cards: BoardCardData[]; + try { + cards = JSON.parse(rawCards); + } catch { + return; + } + const removed = new Set(cards.filter((c) => c.assetId === hash).map((c) => c.id)); + if (removed.size === 0) return; + map.set("cards", JSON.stringify(cards.filter((c) => !removed.has(c.id)))); + + const rawArrows = map.get("arrows"); + if (!rawArrows) return; + try { + const arrows = JSON.parse(rawArrows) as BoardArrowData[]; + const next = arrows.filter( + (a) => !removed.has(a.fromCardId) && !removed.has(a.toCardId), + ); + if (next.length !== arrows.length) map.set("arrows", JSON.stringify(next)); + } catch { + // leave arrows untouched if unparseable + } + }); + }); + + // Reclaim the now-orphaned bytes (local immediately; cloud follows the + // snapshot-aware GC rules) and refresh. + const { gcProjectAssets } = await import("@src/lib/assets/asset-gc"); + await gcProjectAssets(projectId, ydoc).catch(() => {}); + await load(); + }, + [projectId, repository, load], + ); + + return ( +
+
+ +

{t("description")}

+
+ + {assets === null ? null : assets.length === 0 ? ( +

{t("empty")}

+ ) : ( +
+ {assets.map((asset) => ( + + ))} +
+ )} +
+ ); +}; + +export default StorageSettings; diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index 2923539..000c78a 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -83,6 +83,10 @@ const DocumentEditorPanel = ({ const { settings } = useSettings(); const { isEndlessScroll } = useViewContext(); const { user } = useUser(); + // Localised label for the manual page-break hint rendered in the page gap. + // Injected as a CSS variable so the plain-DOM pagination widget can show it, + // mirroring how the (MORE)/(CONT'D) labels are localised. + const pageBreakHint = useTranslations("contextMenu")("pageBreakHint"); const [isEditorReady, setIsEditorReady] = useState(false); const [isScrolled, setIsScrolled] = useState(false); @@ -238,6 +242,7 @@ const DocumentEditorPanel = ({ editorElement.style.setProperty("--contd-label", `"${contdLabel}"`); editorElement.style.setProperty("--more-label", `"${moreLabel}"`); + editorElement.style.setProperty("--page-break-label", `"${pageBreakHint}"`); const elementKeys = [ "action", @@ -308,6 +313,7 @@ const DocumentEditorPanel = ({ sceneNumberOnRight, contdLabel, moreLabel, + pageBreakHint, elementMargins, elementStyles, sceneLocking, @@ -591,6 +597,20 @@ const DocumentEditorPanel = ({ } } + // Manual page break: the top-level block under the caret, plus whether + // it already forces a page break. Paginated screenplay editors only, and + // never the document's first block (there is nothing to break before it). + let pageBreak: { pos: number; active: boolean } | undefined; + if (config.features.paginationMode === "screenplay") { + const $pos = editor.state.doc.resolve(from); + if ($pos.depth >= 1) { + const nodeStart = $pos.before(1); + if (nodeStart > 0) { + pageBreak = { pos: nodeStart, active: !!$pos.node(1).attrs.pageBreak }; + } + } + } + // Comments anchor to the node under the caret, not a text range. const commentNodeId = getNodeIdAtPos(editor.state, from); const onAddComment = commentNodeId @@ -600,10 +620,21 @@ const DocumentEditorPanel = ({ updateContextMenu({ type: ContextMenuType.EditorContextMenu, position: { x: e.clientX, y: e.clientY }, - typeSpecificProps: { from, to, onAddComment, spellError, nodePos, nodeClass, outlineScene }, + // Pass the editor that was right-clicked: positions above are + // resolved against it, and ProjectContext.editor is always the + // MAIN screenplay editor — so secondary editors (tree document, + // draft, title page) must act on this instance, not that one. + typeSpecificProps: { editor, from, to, onAddComment, spellError, nodePos, nodeClass, outlineScene, pageBreak }, }); }, - [editor, updateContextMenu, addCommentToNode, config.features.shelving, config.documentId], + [ + editor, + updateContextMenu, + addCommentToNode, + config.features.shelving, + config.features.paginationMode, + config.documentId, + ], ); // Clear the open discussion when clicking elsewhere in the editor. diff --git a/components/editor/EditorPanel.module.css b/components/editor/EditorPanel.module.css index 9d5221f..ed40e21 100644 --- a/components/editor/EditorPanel.module.css +++ b/components/editor/EditorPanel.module.css @@ -21,6 +21,9 @@ width: 100%; max-width: 1000px; margin: 0 auto; + /* Breathing room so the last page doesn't butt against the viewport edge + * when scrolled to the bottom. Matches the inter-page gap (20px). */ + padding-bottom: 30%; contain: layout; } @@ -56,13 +59,7 @@ transition: opacity 0.7s ease; background: linear-gradient(to bottom, var(--editor-shadow) 0%, transparent 100%); mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%); - -webkit-mask-image: linear-gradient( - to right, - transparent 0%, - black 20%, - black 80%, - transparent 100% - ); + -webkit-mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%); } /* Zero-height sticky container so the banner overlays the editor without diff --git a/components/editor/sidebar/ContextMenu.tsx b/components/editor/sidebar/ContextMenu.tsx index 429883b..6341d7d 100644 --- a/components/editor/sidebar/ContextMenu.tsx +++ b/components/editor/sidebar/ContextMenu.tsx @@ -1,5 +1,6 @@ "use client"; +import { Editor } from "@tiptap/react"; import { ReactNode, useContext, useEffect, useState } from "react"; import { UserContext } from "@src/context/UserContext"; import { useSpellcheck } from "@src/context/SpellcheckContext"; @@ -34,6 +35,7 @@ import { MessageSquarePlus, Pencil, Scissors, + SeparatorHorizontal, Trash2, UserRound, } from "lucide-react"; @@ -485,6 +487,9 @@ const ShelveNodeMenu = ({ props }: SubMenuProps<{ pos: number; nodeClass: string /* ============================== */ export type EditorContextMenuProps = { + /** The editor that was right-clicked. All actions act on this instance — not + * ProjectContext.editor, which is always the main screenplay editor. */ + editor: Editor; from: number; to: number; /** Present when the caret sits on a node a comment can anchor to. */ @@ -494,15 +499,20 @@ export type EditorContextMenuProps = { nodeClass?: string; /** Present when the caret sits on a scene heading that can be sent to the Outline. */ outlineScene?: { refDocId: string; refId: string; title: string }; + /** Present on paginated screenplay editors: the top-level block under the + * caret (`pos`) and whether it already forces a manual page break. */ + pageBreak?: { pos: number; active: boolean }; }; const EditorContextMenu = ({ props }: SubMenuProps) => { const t = useTranslations("contextMenu"); - const { editor, repository, isReadOnly, persistentScenes } = - useContext(ProjectContext); + const { repository, isReadOnly, persistentScenes } = useContext(ProjectContext); const { worker } = useSpellcheck(); const { updateContextMenu } = useContext(UserContext); - const { from, to, onAddComment, spellError, nodePos, nodeClass, outlineScene } = props; + // Act on the editor that was right-clicked (passed through props), NOT + // ProjectContext.editor — that one is always the main screenplay editor, so + // using it would misfire actions in the tree-document / draft / title editors. + const { editor, from, to, onAddComment, spellError, nodePos, nodeClass, outlineScene, pageBreak } = props; const hasSelection = from !== to; const handleSendToOutline = () => { @@ -620,6 +630,12 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { updateContextMenu(undefined); }; + const handleTogglePageBreak = () => { + if (!editor || !pageBreak || isReadOnly) return; + editor.commands.toggleManualPageBreak(pageBreak.pos); + updateContextMenu(undefined); + }; + const canDualDialogue = (() => { if (nodeClass !== ScreenplayElement.Character || !editor || nodePos === undefined) return false; const doc = editor.state.doc; @@ -717,6 +733,18 @@ const EditorContextMenu = ({ props }: SubMenuProps) => { )} + {/* Manual page break — force a new page that begins at this block */} + {pageBreak && !isReadOnly && ( + <> + + + + )} + {/* Send a scene heading to the Outline */} {outlineScene && !isReadOnly && ( <> diff --git a/components/navbar/ProjectNavbar.module.css b/components/navbar/ProjectNavbar.module.css index 2c7110e..9dca27b 100644 --- a/components/navbar/ProjectNavbar.module.css +++ b/components/navbar/ProjectNavbar.module.css @@ -146,6 +146,61 @@ height: 20px; } +/* Storage usage panel (hover of the cloud status indicator) */ +.status_wrapper { + position: relative; + display: flex; + align-items: center; +} + +.storage_panel { + position: absolute; + top: 40px; + left: 0; + min-width: 220px; + background-color: var(--secondary); + color: var(--primary-text); + box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); + padding: 10px 12px; + border-radius: 8px; + font-size: 12px; + z-index: 100; + display: flex; + flex-direction: column; + gap: 6px; +} + +.storage_status { + font-weight: 600; +} + +.storage_separator { + height: 1px; + background-color: var(--tertiary); + margin: 1px 0; +} + +.storage_row { + display: flex; + justify-content: space-between; + gap: 16px; + color: var(--secondary-text); +} + +.storage_bar { + height: 6px; + border-radius: 3px; + background-color: var(--tertiary); + overflow: hidden; +} + +.storage_bar_fill { + height: 100%; + background-color: var(--primary); + border-radius: 3px; + transition: width 0.3s ease; +} + .failed { color: var(--error); } diff --git a/components/navbar/ProjectNavbar.tsx b/components/navbar/ProjectNavbar.tsx index 30e519e..c6bece4 100644 --- a/components/navbar/ProjectNavbar.tsx +++ b/components/navbar/ProjectNavbar.tsx @@ -12,6 +12,7 @@ import debounce from "debounce"; import { editProject } from "@src/lib/utils/requests"; import { join } from "@src/lib/utils/misc"; import { uploadToCloudPopup } from "@src/lib/screenplay/popup"; +import type { StorageUsage } from "@src/lib/assets/cloud-asset-sync"; import { DashboardContext } from "@src/context/DashboardContext"; import { BarChart2, @@ -34,8 +35,73 @@ import navBtn from "@components/utils/NavbarIconButton.module.css"; import ScreenplayFormatDropdown from "./ScreenplayFormatDropdown"; import ScreenplaySearch from "./ScreenplaySearch"; +/** Human-readable byte size, e.g. 1.4 GB. */ +const formatBytes = (bytes: number): string => { + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = bytes / 1024; + let i = 0; + while (value >= 1024 && i < units.length - 1) { + value /= 1024; + i++; + } + return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[i]}`; +}; + +/** Last fetched usage, so re-hovering shows the previous numbers immediately (the + * panel unmounts on mouse-leave) instead of placeholders. Tagged with its project + * id so switching projects doesn't flash the wrong project's figures. */ +let cachedStorage: { projectId: string; usage: StorageUsage } | null = null; + +const StorageUsageBody = ({ projectId }: { projectId: string }) => { + const t = useTranslations("navbar"); + const [usage, setUsage] = useState(() => + cachedStorage?.projectId === projectId ? cachedStorage.usage : null, + ); + + useEffect(() => { + let cancelled = false; + (async () => { + const { fetchProjectStorage } = await import("@src/lib/assets/cloud-asset-sync"); + const data = await fetchProjectStorage(projectId); + // Keep the last known value on a failed refresh rather than wiping it. + if (!cancelled && data) { + cachedStorage = { projectId, usage: data }; + setUsage(data); + } + })(); + return () => { + cancelled = true; + }; + }, [projectId]); + + // Render the full layout immediately (stable size); only the amounts fill in + // once fetched, so the panel doesn't grow after appearing. + const pct = usage && usage.quota > 0 ? Math.min(100, Math.round((usage.ownerTotalUsed / usage.quota) * 100)) : 0; + + return ( + <> +
+ {t("storageProject")} + {usage ? formatBytes(usage.projectUsed) : "—"} +
+
+ {t("storageTotal")} + + {usage ? `${formatBytes(usage.ownerTotalUsed)} / ${formatBytes(usage.quota)}` : "—"} + +
+
+
+
+ + ); +}; + const StatusIndicator = () => { const { connectionStatus } = useContext(ProjectContext); + const projectId = useProjectIdFromUrl(); + const [hovered, setHovered] = useState(false); const t = useTranslations("navbar"); const STATUS: Record = { connected: t("synced"), @@ -43,19 +109,32 @@ const StatusIndicator = () => { connecting: t("reconnecting"), }; return ( - <> -
- {connectionStatus === "connected" && ( - - )} - {connectionStatus === "disconnected" && ( - - )} - {connectionStatus === "connecting" && ( - - )} -
- +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {connectionStatus === "connected" && ( + + )} + {connectionStatus === "disconnected" && ( + + )} + {connectionStatus === "connecting" && ( + + )} + {hovered && ( +
+
{STATUS[connectionStatus]}
+ {projectId && ( + <> +
+ + + )} +
+ )} +
); }; diff --git a/components/project/EditorFooter.tsx b/components/project/EditorFooter.tsx index be03cee..2f55191 100644 --- a/components/project/EditorFooter.tsx +++ b/components/project/EditorFooter.tsx @@ -3,11 +3,12 @@ import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import type { Editor } from "@tiptap/react"; -import { FileText, Maximize, MessageSquare, MessageSquareOff, Minimize, Scroll } from "lucide-react"; +import { FileText, Maximize, Minimize, Scroll, SpellCheck } from "lucide-react"; import { ProjectContext } from "@src/context/ProjectContext"; import { UserContext } from "@src/context/UserContext"; import { useViewContext } from "@src/context/ViewContext"; +import { useSpellcheck } from "@src/context/SpellcheckContext"; import { paginationKey } from "@src/lib/screenplay/extensions/pagination-extension"; import { join } from "@src/lib/utils/misc"; import WritingTimer from "./WritingTimer"; @@ -50,17 +51,22 @@ const EditorFooter = () => { const t = useTranslations("navbar"); const { editor } = useContext(ProjectContext); const { isZenMode, updateIsZenMode } = useContext(UserContext); - const { - isEndlessScroll, - setIsEndlessScroll, - showComments, - setShowComments, - setLeftSidebarOpen, - setRightSidebarOpen, - } = useViewContext(); + const { isEndlessScroll, setIsEndlessScroll, setLeftSidebarOpen, setRightSidebarOpen } = useViewContext(); + const { spellcheckLang, setSpellcheckLang } = useSpellcheck(); const pageCount = useScreenplayPageCount(editor); + // Remember the last active language so the toggle can restore it after being turned + // off; default to English when nothing has been selected yet. + const lastSpellcheckLang = useRef("en"); + useEffect(() => { + if (spellcheckLang) lastSpellcheckLang.current = spellcheckLang; + }, [spellcheckLang]); + + const toggleSpellcheck = useCallback(() => { + setSpellcheckLang(spellcheckLang ? null : lastSpellcheckLang.current); + }, [spellcheckLang, setSpellcheckLang]); + // Sidebar open-state captured on entering focus mode so it can be restored on exit. const sidebarsBeforeFocus = useRef<{ left: boolean; right: boolean } | null>(null); @@ -124,12 +130,12 @@ const EditorFooter = () => {

@@ -38,10 +58,17 @@ const PopupImportFile = ({ data: { confirmImport } }: PopupData

- -
diff --git a/messages/de.json b/messages/de.json index 76a8ecb..874b0af 100644 --- a/messages/de.json +++ b/messages/de.json @@ -474,6 +474,7 @@ "warning": "Sind Sie sicher, dass Sie Ihr aktuelles Projekt überschreiben möchten?", "info": "Sie können Ihr Projekt exportieren, bevor Sie ein neues importieren.", "yesImport": "Ja, importieren", + "importing": "Importieren…", "no": "Nein" }, "scene": { diff --git a/messages/en.json b/messages/en.json index a6f852d..568ad64 100644 --- a/messages/en.json +++ b/messages/en.json @@ -473,6 +473,7 @@ "warning": "Are you sure you want to overwrite your current project?", "info": "You can export your project before importing a new one.", "yesImport": "Yes, import", + "importing": "Importing…", "no": "No" }, "scene": { diff --git a/messages/es.json b/messages/es.json index 88f429f..884a16f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -473,6 +473,7 @@ "warning": "¿Estás seguro de que quieres sobrescribir tu proyecto actual?", "info": "Puedes exportar tu proyecto antes de importar uno nuevo.", "yesImport": "Sí, importar", + "importing": "Importando…", "no": "No" }, "scene": { diff --git a/messages/fr.json b/messages/fr.json index 04180b7..5931bdc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -474,6 +474,7 @@ "warning": "Êtes-vous sûr de vouloir écraser votre projet actuel ?", "info": "Vous pouvez exporter votre projet avant d'en importer un nouveau.", "yesImport": "Oui, importer", + "importing": "Importation…", "no": "Non" }, "scene": { diff --git a/messages/ja.json b/messages/ja.json index b5ff5d1..9909e17 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -473,6 +473,7 @@ "warning": "現在のプロジェクトを 上書き しますか?", "info": "必要に応じて事前にエクスポートを行ってください。", "yesImport": "はい、インポートする", + "importing": "インポート中…", "no": "いいえ" }, "scene": { diff --git a/messages/ko.json b/messages/ko.json index 4727f4f..5fdc277 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -473,6 +473,7 @@ "warning": "현재 프로젝트를 덮어쓰시겠습니까?", "info": "새 프로젝트를 가져오기 전에 현재 프로젝트를 내보낼 수 있습니다.", "yesImport": "예, 가져오기", + "importing": "가져오는 중…", "no": "아니요" }, "scene": { diff --git a/messages/pl.json b/messages/pl.json index ad78300..17a8eb7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -473,6 +473,7 @@ "warning": "Czy na pewno chcesz nadpisać aktualny projekt?", "info": "Możesz wyeksportować swój projekt przed zaimportowaniem nowego.", "yesImport": "Tak, importuj", + "importing": "Importowanie…", "no": "Nie" }, "scene": { diff --git a/messages/zh.json b/messages/zh.json index c22c204..7af09df 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -473,6 +473,7 @@ "warning": "确认 覆盖 当前项目?", "info": "您可以先导出当前项目备份。", "yesImport": "确认导入", + "importing": "正在导入…", "no": "取消" }, "scene": { diff --git a/src/lib/adapters/fadein/fadein-adapter.ts b/src/lib/adapters/fadein/fadein-adapter.ts new file mode 100644 index 0000000..aca22cb --- /dev/null +++ b/src/lib/adapters/fadein/fadein-adapter.ts @@ -0,0 +1,161 @@ +import { BaseExportOptions, ProjectAdapter } from "../screenplay-adapter"; +import { XMLParser } from "@node_modules/fast-xml-parser/src/fxp"; +import { ProjectData } from "@src/lib/project/project-state"; +import { titlePageLine } from "@src/lib/titlepage/titlepage-content"; +import type { JSONContent } from "@tiptap/core"; +import * as fflate from "fflate"; + +// ─── FadeIn / Open Screenplay Format (OSF) ─────────────────────────────────────── +// +// A `.fadein` file is a ZIP archive whose single entry, `document.xml`, is an +// Open Screenplay Format document: +// +// +// +// +//