Spaces:
Sleeping
Sleeping
| import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| import type { CSSProperties } from "react"; | |
| import type { FileMeta, Page, Session, TemplateFields } from "../types/session"; | |
| import { BASE_H, BASE_W } from "../lib/report"; | |
| import { JobSheetTemplate } from "./JobSheetTemplate"; | |
| type ReportPageCanvasProps = { | |
| session: Session | null; | |
| page: Page | null; | |
| pageIndex: number; | |
| pageCount: number; | |
| scale: number; | |
| template?: TemplateFields; | |
| sectionLabel?: string; | |
| className?: string; | |
| adaptive?: boolean; | |
| }; | |
| export function ReportPageCanvas({ | |
| session, | |
| page, | |
| pageIndex, | |
| pageCount, | |
| scale, | |
| template, | |
| sectionLabel, | |
| className = "", | |
| adaptive = false, | |
| }: ReportPageCanvasProps) { | |
| const items = page?.items ?? []; | |
| const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1; | |
| const pageVariant = page?.variant ?? "full"; | |
| const photos = resolvePagePhotos(session, page, pageIndex); | |
| const sheets = adaptive ? [photos] : [photos]; | |
| const sheetRefs = useRef<Array<HTMLDivElement | null>>([]); | |
| const [sheetHeights, setSheetHeights] = useState<number[]>([]); | |
| const defaultHeight = BASE_H * safeScale; | |
| const resolvedHeights = useMemo(() => { | |
| if (sheetHeights.length !== sheets.length) { | |
| return sheets.map(() => defaultHeight); | |
| } | |
| return sheetHeights.map((height) => (height > 0 ? height : defaultHeight)); | |
| }, [defaultHeight, sheetHeights, sheets.length]); | |
| const offsets = useMemo(() => { | |
| const values: number[] = []; | |
| let running = 0; | |
| resolvedHeights.forEach((height) => { | |
| values.push(running); | |
| running += height; | |
| }); | |
| return values; | |
| }, [resolvedHeights]); | |
| const containerHeight = resolvedHeights.reduce((sum, height) => sum + height, 0); | |
| const measureHeights = () => { | |
| const next = sheets.map((_, index) => { | |
| const node = sheetRefs.current[index]; | |
| if (!node) return defaultHeight; | |
| const rect = node.getBoundingClientRect(); | |
| return rect.height || defaultHeight; | |
| }); | |
| setSheetHeights((prev) => { | |
| if (prev.length === next.length && prev.every((val, idx) => Math.abs(val - next[idx]) < 1)) { | |
| return prev; | |
| } | |
| return next; | |
| }); | |
| }; | |
| useLayoutEffect(() => { | |
| measureHeights(); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [sheets.length, safeScale]); | |
| useEffect(() => { | |
| const observer = new ResizeObserver(() => measureHeights()); | |
| sheetRefs.current.forEach((node) => { | |
| if (node) observer.observe(node); | |
| }); | |
| return () => observer.disconnect(); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [sheets.length]); | |
| return ( | |
| <div | |
| className={["relative w-full", className].join(" ")} | |
| style={{ height: `${containerHeight}px` }} | |
| > | |
| <div className="absolute inset-0 pointer-events-none"> | |
| {sheets.map((sheetPhotos, sheetIndex) => ( | |
| <div | |
| key={`sheet-${sheetIndex}`} | |
| style={{ | |
| position: "absolute", | |
| top: `${offsets[sheetIndex] ?? 0}px`, | |
| left: 0, | |
| width: `${BASE_W * safeScale}px`, | |
| height: `${resolvedHeights[sheetIndex] ?? defaultHeight}px`, | |
| }} | |
| > | |
| <div | |
| ref={(node) => { | |
| sheetRefs.current[sheetIndex] = node; | |
| }} | |
| style={{ | |
| width: `${BASE_W}px`, | |
| minHeight: `${BASE_H}px`, | |
| transform: `scale(${safeScale})`, | |
| transformOrigin: "top left", | |
| }} | |
| > | |
| {page?.blank ? ( | |
| <div className="w-full h-full bg-white" /> | |
| ) : ( | |
| <JobSheetTemplate | |
| session={session} | |
| pageIndex={pageIndex} | |
| pageCount={pageCount} | |
| template={template} | |
| photos={sheetPhotos} | |
| orderLocked={page?.photo_order_locked ?? false} | |
| variant={pageVariant} | |
| sectionLabel={sectionLabel} | |
| photoLayout={page?.photo_layout} | |
| /> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| {items | |
| .slice() | |
| .sort((a, b) => (a.z ?? 0) - (b.z ?? 0)) | |
| .map((item) => { | |
| const itemStyle: CSSProperties = { | |
| position: "absolute", | |
| left: `${item.x * safeScale}px`, | |
| top: `${item.y * safeScale}px`, | |
| width: `${item.w * safeScale}px`, | |
| height: `${item.h * safeScale}px`, | |
| zIndex: item.z ?? 0, | |
| }; | |
| if (item.type === "text") { | |
| return ( | |
| <div key={item.id} style={itemStyle}> | |
| <div | |
| className="w-full h-full p-2 overflow-hidden" | |
| style={{ | |
| whiteSpace: "pre-wrap", | |
| fontSize: `${(item.style?.fontSize ?? 14) * safeScale}px`, | |
| fontWeight: item.style?.bold ? 700 : 400, | |
| fontStyle: item.style?.italic ? "italic" : "normal", | |
| textDecoration: item.style?.underline ? "underline" : "none", | |
| color: item.style?.color ?? "#111827", | |
| textAlign: item.style?.align ?? "left", | |
| }} | |
| > | |
| {item.content ?? ""} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| if (item.type === "image") { | |
| return ( | |
| <div key={item.id} style={itemStyle}> | |
| <img | |
| src={item.src} | |
| alt={item.name ?? "Image"} | |
| className="w-full h-full object-contain bg-white" | |
| loading="eager" | |
| /> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div key={item.id} style={itemStyle}> | |
| <div | |
| className="w-full h-full" | |
| style={{ | |
| background: item.style?.fill ?? "#ffffff", | |
| borderStyle: "solid", | |
| borderColor: item.style?.stroke ?? "#111827", | |
| borderWidth: `${(item.style?.strokeWidth ?? 1) * safeScale}px`, | |
| }} | |
| /> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ); | |
| } | |
| function resolvePagePhotos( | |
| session: Session | null, | |
| page: Page | null | undefined, | |
| pageIndex: number, | |
| ): FileMeta[] { | |
| if (!session) return []; | |
| const uploads = session.uploads?.photos ?? []; | |
| const byId = new Map(uploads.map((photo) => [photo.id, photo])); | |
| const explicit = page?.photo_ids ?? []; | |
| if (explicit.length) { | |
| return explicit.map((id) => byId.get(id)).filter(Boolean) as FileMeta[]; | |
| } | |
| return []; | |
| } | |