"use client" import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react" import { EditorContent, type Editor } from "@tiptap/react" import type { DocPageLayout, DocPageSetup } from "@/lib/drive/doc-page-setup" import { DocsHeaderFooterBand, type DocsHeaderFooterEditTarget, type DocsHeaderFooterRegion, } from "@/components/drive/richtext/docs-header-footer-region" import { DocsBodyMarginMasks } from "@/components/drive/richtext/docs-body-margin-masks" import { DOCS_CANVAS_PADDING_TOP_NARROW_PX, DOCS_CANVAS_PADDING_Y_PX, DOCS_PAGE_GAP_PX, } from "@/lib/drive/docs-page-layout-constants" import { effectiveMarginsPx, } from "@/lib/drive/docs-header-footer-layout" import { computePageCount, computePageMetrics, computeProseMinHeight, computeStackHeight, } from "@/lib/drive/docs-page-metrics" import { docsPageLengthToScreen, docsZoomToScale } from "@/lib/drive/docs-ruler-scale" import { cn } from "@/lib/utils" import { focusEditorAtPointer } from "@/lib/drive/focus-editor-at-pointer" import { applyPageFlowLayout, computeSimulatedLayoutHeight, readPageFlowMetrics } from "@/lib/drive/extensions/docs-page-flow-decoration" /** Total layout height inside ProseMirror (blocks + flow spacers). */ function measureProseContentHeight(prose: HTMLElement): number { const metrics = readPageFlowMetrics(prose) if (!metrics) return 0 const blocks: Array<{ height: number }> = [] for (const child of prose.children) { const el = child as HTMLElement if (el.classList.contains("docs-page-flow-spacer")) continue const style = getComputedStyle(el) const marginBottom = parseFloat(style.marginBottom) || 0 blocks.push({ height: el.offsetHeight + marginBottom }) } if (blocks.length === 0) return 0 const simulated = computeSimulatedLayoutHeight( blocks, metrics.bodyAreaH, metrics.interPageSpacer ) let maxBottom = 0 for (const child of prose.children) { const el = child as HTMLElement maxBottom = Math.max(maxBottom, el.offsetTop + el.offsetHeight) } return Math.max(simulated, maxBottom) } function DocsPageViewInner({ editor, pageLayout, zoom, editable, showLayout, showNonPrintableChars, editorMode, canvasRef: canvasRefProp, onPageCountChange, onNarrowViewportChange, onCanvasHeightChange, onRegionContentChange, onPageSetupChange, onRegionEditorChange, }: { editor: Editor pageLayout: DocPageLayout zoom: number editable: boolean showLayout: boolean showRuler: boolean showNonPrintableChars: boolean editorMode: "edit" | "suggest" | "view" canvasRef?: RefObject onPageCountChange?: (count: number) => void onNarrowViewportChange?: (narrow: boolean) => void onCanvasHeightChange?: (height: number) => void onRegionContentChange?: ( region: DocsHeaderFooterRegion, content: Record, meta: { pageIndex: number; contentHeightPx: number } ) => void onPageSetupChange?: (patch: Partial) => void onRegionEditorChange?: (editor: Editor | null) => void }) { const pageWidth = pageLayout.widthPx const pageHeight = pageLayout.heightPx const margins = pageLayout.marginsPx const [pageCount, setPageCount] = useState(1) const [narrowViewport, setNarrowViewport] = useState(false) const [editingTarget, setEditingTarget] = useState(null) const [pageRegionHeights, setPageRegionHeights] = useState< Record >({}) const handleRegionHeightMeasure = useCallback( (payload: { region: DocsHeaderFooterRegion pageIndex: number heightPx: number }) => { const key = `${payload.region}-${payload.pageIndex}` setPageRegionHeights((prev) => { if (prev[key] === payload.heightPx) return prev return { ...prev, [key]: payload.heightPx } }) }, [] ) const measuredRegionHeights = useMemo(() => { let header = 0 let footer = 0 for (const [key, heightPx] of Object.entries(pageRegionHeights)) { if (key.startsWith("header-")) header = Math.max(header, heightPx) if (key.startsWith("footer-")) footer = Math.max(footer, heightPx) } return { ...(header > 0 ? { header } : {}), ...(footer > 0 ? { footer } : {}), } }, [pageRegionHeights]) const effectiveMargins = useMemo( () => effectiveMarginsPx( pageLayout, null, editingTarget ? undefined : measuredRegionHeights ), [pageLayout, editingTarget, measuredRegionHeights] ) const metrics = useMemo( () => computePageMetrics({ ...pageLayout, effectiveMarginsPx: effectiveMargins }), [pageLayout, effectiveMargins] ) const localCanvasRef = useRef(null) const canvasRef = canvasRefProp ?? localCanvasRef const contentRef = useRef(null) const onPageCountChangeRef = useRef(onPageCountChange) onPageCountChangeRef.current = onPageCountChange const scale = docsZoomToScale(zoom) const scaledWidth = docsPageLengthToScreen(pageWidth, scale) const stopRegionEdit = useCallback(() => { setEditingTarget(null) onRegionEditorChange?.(null) }, [onRegionEditorChange]) const startRegionEdit = useCallback( (region: DocsHeaderFooterRegion, pageIndex: number) => { setEditingTarget({ region, pageIndex }) }, [] ) const handleRegionEditorReady = useCallback( (editor: Editor | null) => { onRegionEditorChange?.(editor) }, [onRegionEditorChange] ) useEffect(() => { const onKey = (event: KeyboardEvent) => { if (event.key === "Escape" && editingTarget) { event.preventDefault() stopRegionEdit() } } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) }, [editingTarget, stopRegionEdit]) useEffect(() => { const canvas = canvasRef.current if (!canvas) return const syncViewport = () => { const narrow = canvas.clientWidth < scaledWidth setNarrowViewport(narrow) onNarrowViewportChange?.(narrow) onCanvasHeightChange?.(canvas.clientHeight) } syncViewport() const ro = new ResizeObserver(syncViewport) ro.observe(canvas) return () => ro.disconnect() }, [onCanvasHeightChange, onNarrowViewportChange, scaledWidth, canvasRef]) useEffect(() => { if (!showLayout) return const surface = contentRef.current if (!surface) return let debounceId: ReturnType | null = null let cancelled = false const measurePageCount = () => { const prose = surface.querySelector(".ProseMirror") as HTMLElement | null if (!prose) return const contentHeight = measureProseContentHeight(prose) const count = computePageCount(contentHeight, metrics) setPageCount((prev) => (prev === count ? prev : count)) } const runLayoutPasses = (passesLeft: number) => { if (cancelled || editor.isDestroyed) return requestAnimationFrame(() => { if (cancelled || editor.isDestroyed) return const changed = applyPageFlowLayout(editor) if (changed && passesLeft > 1) { runLayoutPasses(passesLeft - 1) return } measurePageCount() }) } let flushPending = false const scheduleLayout = () => { if (!flushPending) { flushPending = true requestAnimationFrame(() => { flushPending = false runLayoutPasses(2) }) } if (debounceId) clearTimeout(debounceId) debounceId = setTimeout(() => { debounceId = null runLayoutPasses(2) }, 32) } scheduleLayout() const onTransaction = ({ transaction }: { transaction: { docChanged: boolean } }) => { if (!transaction.docChanged) return scheduleLayout() } editor.on("transaction", onTransaction) return () => { cancelled = true if (debounceId) clearTimeout(debounceId) editor.off("transaction", onTransaction) } }, [editor, metrics, pageRegionHeights, showLayout]) useEffect(() => { onPageCountChangeRef.current?.(pageCount) }, [pageCount]) const stackHeight = computeStackHeight(pageCount, pageHeight) const proseMinHeight = computeProseMinHeight(pageCount, metrics) const scaledHeight = docsPageLengthToScreen(stackHeight, scale) const verticalPaddingTop = narrowViewport ? DOCS_CANVAS_PADDING_TOP_NARROW_PX : DOCS_CANVAS_PADDING_Y_PX const verticalPaddingBottom = DOCS_CANVAS_PADDING_Y_PX const textAreaBorderCss = pageLayout.textAreaBorderCss const sheetBorderCss = pageLayout.sheetBorderCss const pageBackground = pageLayout.pageColor const backgroundLayers = pageLayout.pageBackgroundLayers const renderPageBackground = (index: number) => ( <> {backgroundLayers?.gradientCss ? (
) : null} {backgroundLayers?.fillImageStyle ? (
) : null} {backgroundLayers?.watermarkStyle ? (
{backgroundLayers.watermarkStyle.imageSrc ? ( ) : ( {backgroundLayers.watermarkStyle.text} )}
) : null} ) const bodyDimmed = editingTarget != null return (
{showLayout ? Array.from({ length: pageCount }, (_, index) => { const pageTop = index * (pageHeight + DOCS_PAGE_GAP_PX) return (
{renderPageBackground(index)}
) }) : null} {showLayout && textAreaBorderCss ? Array.from({ length: pageCount }, (_, index) => (
)) : null} {showLayout ? ( ) : null} {showLayout ? Array.from({ length: pageCount }, (_, index) => { const pageTop = index * (pageHeight + DOCS_PAGE_GAP_PX) return (
onRegionContentChange?.(region, content, meta) } onPageSetupChange={(patch) => onPageSetupChange?.(patch)} onRegionHeightMeasure={handleRegionHeightMeasure} onRegionEditorReady={ editingTarget?.region === "header" && editingTarget.pageIndex === index ? handleRegionEditorReady : undefined } /> onRegionContentChange?.(region, content, meta) } onPageSetupChange={(patch) => onPageSetupChange?.(patch)} onRegionHeightMeasure={handleRegionHeightMeasure} onRegionEditorReady={ editingTarget?.region === "footer" && editingTarget.pageIndex === index ? handleRegionEditorReady : undefined } />
) }) : null} {bodyDimmed ? (
) : null}
{ if (bodyDimmed) { const target = event.target as HTMLElement if ( !target.closest(".docs-hf-band") && !target.closest(".docs-hf-chrome") ) { stopRegionEdit() } return } if (!editable || editingTarget) return const target = event.target as HTMLElement if (target.closest(".ProseMirror")) return event.preventDefault() focusEditorAtPointer(editor, event.clientX, event.clientY) }} >
) } export const DocsPageView = memo(DocsPageViewInner) export function DocsStatusBar({ pageLayout, pageCount, currentPage = 1, className, }: { pageLayout: DocPageLayout pageCount: number currentPage?: number className?: string }) { const pageLabel = Math.min(Math.max(1, currentPage), Math.max(1, pageCount)) return (
Page {pageLabel} sur {pageCount} {pageLayout.format.label} ({pageLayout.format.widthMm} × {pageLayout.format.heightMm} mm)
) }