From d41592e18794c15a42b3a020d59da5bef52b5882 Mon Sep 17 00:00:00 2001 From: Hugo Bois Date: Tue, 16 Jun 2026 08:38:36 +0200 Subject: [PATCH] fixed pagination for locked pages, added lock icons to locked pages --- .../extensions/pagination-extension.ts | 202 ++++++++++++++++-- 1 file changed, 186 insertions(+), 16 deletions(-) diff --git a/src/lib/screenplay/extensions/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts index a6fb921..2f3a17e 100644 --- a/src/lib/screenplay/extensions/pagination-extension.ts +++ b/src/lib/screenplay/extensions/pagination-extension.ts @@ -16,6 +16,7 @@ import { SceneToken, } from "@src/lib/screenplay/scene-locking"; import { PAGE_COLLAPSE_META, PAGE_ONE_KEY, PersistentPageMap, SCENE_OMIT_META } from "@src/lib/screenplay/page-locking"; +import { generateNodeId } from "@src/lib/screenplay/nodes"; import { timeApply } from "./apply-timing"; // --------------------------------------------------------------------------- @@ -157,6 +158,10 @@ 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; + /** True when the page beginning after this break holds a frozen page-lock + * token (status "locked"). Drives the subtle lock badge in the page's + * top-right corner. Only ever set while page locking is active. */ + locked?: boolean; /** 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 @@ -229,6 +234,57 @@ function syncVars(dom: HTMLElement, o: PaginationOptions) { // Decoration builders // --------------------------------------------------------------------------- +/** + * Lock badge shown in the top-right corner of a page-locked page. + * + * Built once and cloned on reuse — every locked page mounts an identical copy, + * so the SVG is parsed a single time. CSS floats it just past the page's + * top-right corner, in the gutter. It carries no text, so the PDF adapter's + * label extraction (which reads `.pagination-header-right`) and the header-area + * textContent are both untouched. + */ +let pageLockBadgeTemplate: HTMLSpanElement | null = null; + +function makePageLockBadge(): HTMLSpanElement { + if (!pageLockBadgeTemplate) { + const badge = document.createElement("span"); + badge.className = "page-lock-badge"; + badge.setAttribute("aria-hidden", "true"); + // Lucide "lock" glyph, matching the icon set used across the app. + badge.innerHTML = + '' + + ''; + pageLockBadgeTemplate = badge; + } + return pageLockBadgeTemplate.cloneNode(true) as HTMLSpanElement; +} + +/** + * Standalone lock-marker widget for a whole-node locked page. + * + * Rendered as its own decoration at the page-start boundary — a sibling of the + * page-break widget and the page's first paragraph, belonging to neither. It is + * therefore NOT a descendant of the content-visibility-contained break/first-page + * widget (whose paint containment would clip it) and NOT tied to the paragraph. + * A zero-height, full-page-width block establishes the positioning context at + * the new page's top edge; the badge inside it floats up into the top-right + * gutter via CSS. + */ +function makePageLockMarker(): HTMLDivElement { + const marker = document.createElement("div"); + marker.className = "page-lock-marker"; + marker.contentEditable = "false"; + marker.appendChild(makePageLockBadge()); + return marker; +} + +/** Append the lock badge to a page's header area when that page is locked. */ +function applyPageLock(headerArea: HTMLElement, locked: boolean | undefined): void { + if (locked) headerArea.appendChild(makePageLockBadge()); +} + /** * A header/footer span, built once per (class, html) and cloned on reuse. * @@ -312,6 +368,9 @@ function createFirstPageWidget(firstPageLabel: string, options: PaginationOption headerArea.className = "pagination-header-area"; headerArea.style.height = `${options.marginTop}px`; fillHeader(headerArea, 1, firstPageLabel, options); + // Page 1's lock badge is mounted on its first paragraph (see + // pushPageLockBadge) — not here — because this widget carries + // content-visibility, whose paint containment would hide it. overlay.appendChild(headerArea); container.appendChild(spacer); @@ -442,6 +501,12 @@ function createPageBreakWidget(breakInfo: PageBreakInfo, options: PaginationOpti // Header area of the new page (fixed size = marginTop) headerArea.style.height = `${options.marginTop}px`; fillHeader(headerArea, breakInfo.pagenum, thisLabel, options); + // Only mid-node split pages render the lock badge in the header area: their + // widget is exempt from content-visibility (its overlay escapes the box), so + // the badge actually paints. Whole-node pages would have it clipped by this + // widget's paint containment, so they mount it on the page's first paragraph + // instead (see pushPageLockBadge). + if (breakInfo.splitNodeType !== null) applyPageLock(headerArea, breakInfo.locked); if (isEmpty) { // Empty content area for the orphan-locked page. Renders a faint @@ -568,6 +633,7 @@ function buildDecorations( breaks: PageBreakInfo[], lastPageFreespace: number, firstPageLabel: string, + firstPageLocked: boolean, options: PaginationOptions, reuse?: Map, ): DecorationSet { @@ -602,11 +668,25 @@ function buildDecorations( if (node) decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: cls })); }; + // Mount the lock badge for a locked, whole-node page as its own standalone + // widget at the page-start boundary. It must live OUTSIDE the page-break / + // first-page widget — those carry content-visibility, whose paint containment + // hides anything inside them. A sibling widget in the flat list is a direct + // child of `.pagination` (not contained), so it paints into the gutter. Side + // +1 keeps it after the page-break widget at the same position, i.e. on the + // new page's side of the boundary. Mid-node split pages can't use this (their + // page has no fresh boundary node), but their break widget is + // content-visibility-exempt, so they keep the header-area badge instead. + const pushPageLockBadge = (pos: number) => { + pushWidget(pos, "page-lock-marker", 1, makePageLockMarker); + }; + // First page top margin / header pushWidget(0, `page-1-header-${firstPageLabel}-${fp}`, -1, () => createFirstPageWidget(firstPageLabel, options), ); markPageStart(0, "pagination-doc-start"); + if (firstPageLocked) pushPageLockBadge(0); // Page breaks // The key MUST include every value that affects the widget DOM (freespace, @@ -614,13 +694,18 @@ function buildDecorations( // pagenum. A matching key keeps the previously drawn DOM, so a key that // omits e.g. freespace causes stale spacer heights after content edits. for (const b of breaks) { - const key = `pb-${b.pagenum}-${b.freespace}-${b.contdName}-${b.splitNodeType}-${b.label ?? ""}-${b.prevLabel ?? ""}-${b.isEmpty ? "E" : ""}-${fp}`; + const key = `pb-${b.pagenum}-${b.freespace}-${b.contdName}-${b.splitNodeType}-${b.label ?? ""}-${b.prevLabel ?? ""}-${b.isEmpty ? "E" : ""}-${b.locked ? "L" : ""}-${fp}`; pushWidget(b.pos, key, -1, () => createPageBreakWidget(b, options)); // Only whole-node breaks start a fresh node; mid-node sentence splits // (splitNodeType !== null) keep the straddling node, which never had a // margin to reset — the old `> .pagination-page-break + p` rule didn't // match inside-

widgets either. - if (b.splitNodeType === null) markPageStart(b.pos, "pagination-break-start"); + if (b.splitNodeType === null) { + markPageStart(b.pos, "pagination-break-start"); + // Whole-node locked page → mount its badge on the new page's first + // paragraph (split locked pages render it in the header area above). + if (b.locked) pushPageLockBadge(b.pos); + } } // Last page bottom margin / footer. @@ -695,8 +780,7 @@ const ZERO_TOP_MARGIN_TYPES = new Set([ * a constant keeps this off the layout path entirely, the same way LINE_HEIGHT * mirrors --line-height. Keep in sync with the margin-top rules in scriptio.css. */ -const nodeTopMargin = (nodeType: ScreenplayElement): number => - ZERO_TOP_MARGIN_TYPES.has(nodeType) ? 0 : LINE_HEIGHT; +const nodeTopMargin = (nodeType: ScreenplayElement): number => (ZERO_TOP_MARGIN_TYPES.has(nodeType) ? 0 : LINE_HEIGHT); // eslint-disable-next-line @typescript-eslint/no-unused-vars const setupTestDiv = (editorDom: HTMLElement, _: PaginationOptions): HTMLElement => { @@ -862,6 +946,8 @@ interface PaginationState { breaks: PageBreakInfo[]; lastPageFreespace: number; firstPageLabel: string; + /** Whether page 1 carries a frozen page-lock token (drives its lock badge). */ + firstPageLocked: boolean; } /** @@ -903,6 +989,7 @@ const createPaginationPlugin = (extension: { breaks: [], lastPageFreespace: 0, firstPageLabel: "1", + firstPageLocked: false, }), apply: timeApply("pagination", (tr, value: PaginationState, oldState, newState): PaginationState => { // Wait for the screenplay fonts to finish loading before doing @@ -1395,6 +1482,7 @@ const createPaginationPlugin = (extension: { // pages keep their frozen labels, provisional inserts get suffix labels // (e.g. "4A"), and pages past the last lock continue the integer sequence. let firstPageLabel = "1"; + let firstPageLocked = false; if (pageLocks) { // Omitted scenes collapse whole locked pages out of the // document. Each such removed page contributes an "absorbed" @@ -1472,8 +1560,7 @@ const createPaginationPlugin = (extension: { // Reclaimed pages join the lock map for label computation so // they get their absorbed number (and following provisional // pages suffix off it: "18", "18A", …). - const labelLocks = - Object.keys(synthetic).length > 0 ? { ...pageLocks, ...synthetic } : pageLocks; + const labelLocks = Object.keys(synthetic).length > 0 ? { ...pageLocks, ...synthetic } : pageLocks; const pageLabels = computePageLabels(breaks, labelLocks, skippedLetters); // Fold the unreclaimed tokens into the FOLLOWING surviving @@ -1514,9 +1601,11 @@ const createPaginationPlugin = (extension: { } firstPageLabel = pageLabels[0].label; + firstPageLocked = pageLabels[0].status === "locked"; for (let i = 0; i < breaks.length; i++) { breaks[i].label = pageLabels[i + 1].label; breaks[i].prevLabel = pageLabels[i].label; + breaks[i].locked = pageLabels[i + 1].status === "locked"; } } @@ -1525,6 +1614,7 @@ const createPaginationPlugin = (extension: { fullRemeasure || lastPageFreespace !== value.lastPageFreespace || firstPageLabel !== value.firstPageLabel || + firstPageLocked !== value.firstPageLocked || breaks.length !== mappedOldBreaks.length || breaks.some( (b, i) => @@ -1533,7 +1623,8 @@ const createPaginationPlugin = (extension: { b.contdName !== mappedOldBreaks[i].contdName || b.label !== mappedOldBreaks[i].label || b.prevLabel !== mappedOldBreaks[i].prevLabel || - !!b.isEmpty !== !!mappedOldBreaks[i].isEmpty, + !!b.isEmpty !== !!mappedOldBreaks[i].isEmpty || + !!b.locked !== !!mappedOldBreaks[i].locked, ); // Always map the previous set first: it re-anchors every existing @@ -1554,6 +1645,7 @@ const createPaginationPlugin = (extension: { breaks, lastPageFreespace, firstPageLabel, + firstPageLocked, options, buildReuseMap(mapped), ); @@ -1577,12 +1669,13 @@ const createPaginationPlugin = (extension: { breaks, lastPageFreespace, firstPageLabel, + firstPageLocked, options, buildReuseMap(mapped), ); } - return { decset, breaks, lastPageFreespace, firstPageLabel }; + return { decset, breaks, lastPageFreespace, firstPageLabel, firstPageLocked }; }), }, appendTransaction() { @@ -1701,7 +1794,14 @@ export const ScriptioPagination = Extension.create({ if (!style) { style = document.createElement("style"); style.id = "pagination-style"; - style.textContent = ` + document.head.appendChild(style); + } + // Always (re)write the rules. The