diff --git a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame.tsx b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame.tsx index ac0e4bd2..9da781f1 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame.tsx +++ b/src/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame.tsx @@ -15,7 +15,7 @@ type CompDocDocumentFrameProps = { sections: readonly ReactNode[]; /** Greyed-out pages, as for a document marked not required. */ dimmed?: boolean; - /** Rendered over the preview, outside the dimmed layer. */ + /** Stamped over the first sheet, outside the dimmed layer. */ overlay?: ReactNode; /** Page being read and page count, updated on layout and on scroll. */ onPageChange?: (position: PagePosition) => void; @@ -39,85 +39,83 @@ export function CompDocDocumentFrame({ useCompDocPagination(sections.length, onPageChange); return ( -
-
-
-
- {pages.map((blockIndices, pageIndex) => ( +
+
+ {pages.map((blockIndices, pageIndex) => ( +
{ + pageRefs.current[pageIndex] = el; + }} + data-testid="comp-doc-page" + className="relative flex flex-col break-words" + style={{ + marginTop: pageIndex > 0 ? PAGE_GAP_PX : 0, + minHeight: pageHeight, + background: "#ffffff", + borderRadius: 8, + boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)", + overflow: "hidden", + fontFamily: "var(--font-sans)", + overflowWrap: "break-word", + wordBreak: "break-word", + }} + > +
{ - pageRefs.current[pageIndex] = el; - }} - data-testid="comp-doc-page" - className="relative flex flex-col break-words" + data-testid="comp-doc-page-top-bar" style={{ - marginTop: pageIndex > 0 ? PAGE_GAP_PX : 0, - minHeight: pageHeight, - background: "#ffffff", - borderRadius: 8, - boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)", - overflow: "hidden", - fontFamily: "var(--font-sans)", - overflowWrap: "break-word", - wordBreak: "break-word", + height: PAGE_TOP_BAR_PX, + flexShrink: 0, + background: "linear-gradient(100deg,#27AAE1,#1C75BC)", }} - > -
-
- {pageIndex === 0 && ( -
- {header} -
- )} - {blockIndices.map((blockIndex) => ( -
{ - blockRefs.current[blockIndex] = el; - }} - style={flowRoot} - > - {sections[blockIndex]} -
- ))} -
-
+ /> +
+ {pageIndex === 0 && ( +
+ {header} +
+ )} + {blockIndices.map((blockIndex) => ( +
{ + blockRefs.current[blockIndex] = el; + }} + style={flowRoot} + > + {sections[blockIndex]} +
+ ))}
- ))} -
-
-
+
+
- {overlay} + {pageIndex === 0 && overlay} +
+ ))} +
); } diff --git a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pagination.ts b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pagination.ts index 609301c5..b62308a6 100644 --- a/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pagination.ts +++ b/src/app/(protected)/workorders/_components/comp-doc/use-comp-doc-pagination.ts @@ -63,23 +63,16 @@ export function useCompDocPagination( measure(); }); - // Width changes, late font/image loads and reflow do not re-render; observe them. - const layoutKey = pages.map((page) => page.join(",")).join("|"); - useEffect(() => { - const observer = new ResizeObserver(() => measure()); - [sheetsRef.current, headerRef.current, ...blockRefs.current.slice(0, blockCount)].forEach( - (el) => el && observer.observe(el), - ); - return () => observer.disconnect(); - }, [measure, blockCount, layoutKey]); - const onPageChangeRef = useRef(onPageChange); + const totalRef = useRef(pages.length); const lastReported = useRef(null); useLayoutEffect(() => { onPageChangeRef.current = onPageChange; + totalRef.current = pages.length; }); - const report = useCallback((total: number) => { + const report = useCallback(() => { + const total = totalRef.current; const tops = pageRefs.current.slice(0, total).map((el) => el?.offsetTop ?? 0); const current = Math.min(currentPageAt(tops, scrollerRef.current?.scrollTop ?? 0), total); const last = lastReported.current; @@ -88,12 +81,23 @@ export function useCompDocPagination( onPageChangeRef.current?.({ current, total }); }, []); - const total = pages.length; + // Any re-layout can move the sheet tops under the reader, even when the page count holds. useLayoutEffect(() => { - report(total); - }, [report, total]); + report(); + }); - const onScroll = useCallback(() => report(total), [report, total]); + // Width changes, late font/image loads and reflow do not re-render; observe them. + const layoutKey = pages.map((page) => page.join(",")).join("|"); + useEffect(() => { + const observer = new ResizeObserver(() => { + measure(); + report(); + }); + [sheetsRef.current, headerRef.current, ...blockRefs.current.slice(0, blockCount)].forEach( + (el) => el && observer.observe(el), + ); + return () => observer.disconnect(); + }, [measure, report, blockCount, layoutKey]); return { pages, @@ -103,6 +107,6 @@ export function useCompDocPagination( headerRef, blockRefs, pageRefs, - onScroll, + onScroll: report, }; } diff --git a/src/test/app/(protected)/workorders/comp-doc-paginated-preview.test.tsx b/src/test/app/(protected)/workorders/comp-doc-paginated-preview.test.tsx index beec9ec1..7cc2f88e 100644 --- a/src/test/app/(protected)/workorders/comp-doc-paginated-preview.test.tsx +++ b/src/test/app/(protected)/workorders/comp-doc-paginated-preview.test.tsx @@ -15,6 +15,7 @@ const SHEET_WIDTH = 420; const LETTERHEAD_HEIGHT = 200; const BLOCK_HEIGHT = 300; const SHEET_STRIDE = 614; +let sheetStride = SHEET_STRIDE; function isLetterhead(el: HTMLElement) { return el.querySelector('img[alt="Seahaven Industries"]') !== null; @@ -33,12 +34,13 @@ function stubRect(this: HTMLElement): DOMRect { const offsetTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetTop"); beforeEach(() => { + sheetStride = SHEET_STRIDE; vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(stubRect); Object.defineProperty(HTMLElement.prototype, "offsetTop", { configurable: true, get(this: HTMLElement) { if (this.dataset.testid !== "comp-doc-page") return 0; - return Array.from(this.parentElement?.children ?? []).indexOf(this) * SHEET_STRIDE; + return Array.from(this.parentElement?.children ?? []).indexOf(this) * sheetStride; }, }); }); @@ -155,6 +157,33 @@ describe("Completion template preview pagination", () => { expect(within(sheets()[2]).getByText("Step group 3")).toBeInTheDocument(); expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 1 of 3"); }); + + it("re-reads the page being read when an edit moves the sheets but keeps the page count", () => { + const { rerender } = render( + , + ); + scrollDocumentTo(SHEET_STRIDE + 5); + expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 2 of 3"); + + // The edit makes sheet 1 taller: sheet 2 now starts below the reader's position. + sheetStride = 800; + rerender( + , + ); + + expect(sheets()).toHaveLength(3); + expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 1 of 3"); + }); }); const site: CompDocSiteInfo = { @@ -206,6 +235,27 @@ describe("Work order completion document preview pagination", () => { ); }); + it("stamps NOT REQUIRED on the first sheet, above the greyed-out content", () => { + render( + , + ); + + const [first, second] = sheets(); + const stamp = within(first).getByText(/NOT REQUIRED/); + expect(stamp.closest('[style*="opacity: 0.5"]')).toBeNull(); + expect(within(first).getByTestId("comp-doc-page-top-bar").parentElement).toHaveStyle({ + opacity: "0.5", + }); + expect(within(second).queryByText(/NOT REQUIRED/)).not.toBeInTheDocument(); + }); + it("keeps a document without comments on a single sheet", () => { render(