ultisuite-client/components/drive/richtext/docs-page-view.tsx
R3D347HR4Y 2a7c153748
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wrap page
2026-06-10 12:48:27 +02:00

578 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<HTMLDivElement | null>
onPageCountChange?: (count: number) => void
onNarrowViewportChange?: (narrow: boolean) => void
onCanvasHeightChange?: (height: number) => void
onRegionContentChange?: (
region: DocsHeaderFooterRegion,
content: Record<string, unknown>,
meta: { pageIndex: number; contentHeightPx: number }
) => void
onPageSetupChange?: (patch: Partial<DocPageSetup>) => 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<DocsHeaderFooterEditTarget>(null)
const [pageRegionHeights, setPageRegionHeights] = useState<
Record<string, number>
>({})
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<HTMLDivElement>(null)
const canvasRef = canvasRefProp ?? localCanvasRef
const contentRef = useRef<HTMLDivElement>(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<typeof setTimeout> | 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 ? (
<div
key={`bg-gradient-${index}`}
className="pointer-events-none absolute inset-0 z-[1]"
style={{ background: backgroundLayers.gradientCss }}
aria-hidden
/>
) : null}
{backgroundLayers?.fillImageStyle ? (
<div
key={`bg-image-${index}`}
className="pointer-events-none absolute inset-0 z-[1]"
style={backgroundLayers.fillImageStyle}
aria-hidden
/>
) : null}
{backgroundLayers?.watermarkStyle ? (
<div
key={`bg-watermark-${index}`}
className="pointer-events-none absolute inset-0 z-[2] flex items-center justify-center overflow-hidden"
aria-hidden
>
{backgroundLayers.watermarkStyle.imageSrc ? (
<img
src={backgroundLayers.watermarkStyle.imageSrc}
alt=""
className="max-h-[70%] max-w-[70%] select-none object-contain"
style={{
opacity: backgroundLayers.watermarkStyle.opacity,
transform: `rotate(${backgroundLayers.watermarkStyle.rotationDeg}deg)`,
}}
/>
) : (
<span
className="select-none whitespace-nowrap text-[72px] font-light leading-none"
style={{
color: backgroundLayers.watermarkStyle.color,
opacity: backgroundLayers.watermarkStyle.opacity,
transform: `rotate(${backgroundLayers.watermarkStyle.rotationDeg}deg)`,
}}
>
{backgroundLayers.watermarkStyle.text}
</span>
)}
</div>
) : null}
</>
)
const bodyDimmed = editingTarget != null
return (
<div ref={canvasRef} className={cn(
"ultidrive-docs-canvas h-full min-h-0 overflow-auto",
showLayout ? "bg-[#f9fbfd] dark:bg-[#202124]" : "bg-white dark:bg-background",
showNonPrintableChars && "docs-show-non-printable",
editorMode === "suggest" && "docs-editor-mode-suggest",
editorMode === "view" && "docs-editor-mode-view"
)}>
<div
className="mx-auto"
style={{
width: scaledWidth,
paddingTop: verticalPaddingTop,
paddingBottom: verticalPaddingBottom,
minHeight: (showLayout ? scaledHeight : proseMinHeight + margins.top + margins.bottom) + verticalPaddingTop + verticalPaddingBottom,
}}
>
<div
className="relative mx-auto overflow-hidden"
style={{ width: scaledWidth, height: showLayout ? scaledHeight : undefined }}
>
<div
data-docs-page-stack
className="absolute left-1/2 top-0 -translate-x-1/2 overflow-hidden"
style={{
width: pageWidth,
height: stackHeight,
transform: `scale(${scale})`,
transformOrigin: "top center",
}}
>
{showLayout
? Array.from({ length: pageCount }, (_, index) => {
const pageTop = index * (pageHeight + DOCS_PAGE_GAP_PX)
return (
<div
key={index}
className={cn(
"ultidrive-docs-page absolute left-0 overflow-hidden dark:bg-white",
sheetBorderCss && "ultidrive-docs-page--imported-border"
)}
style={{
top: pageTop,
width: pageWidth,
height: pageHeight,
backgroundColor: pageBackground,
boxShadow:
"0 1px 3px 1px rgba(60,64,67,.15), 0 1px 2px 0 rgba(60,64,67,.3)",
...(sheetBorderCss
? {
borderTop: sheetBorderCss.top ?? "none",
borderRight: sheetBorderCss.right ?? "none",
borderBottom: sheetBorderCss.bottom ?? "none",
borderLeft: sheetBorderCss.left ?? "none",
}
: {}),
}}
aria-hidden
>
{renderPageBackground(index)}
</div>
)
})
: null}
{showLayout && textAreaBorderCss
? Array.from({ length: pageCount }, (_, index) => (
<div
key={`text-border-${index}`}
className="pointer-events-none absolute z-[5] box-border"
style={{
top: index * (pageHeight + DOCS_PAGE_GAP_PX) + effectiveMargins.top,
left: effectiveMargins.left,
width: pageWidth - effectiveMargins.left - effectiveMargins.right,
height: pageHeight - effectiveMargins.top - effectiveMargins.bottom,
borderTop: textAreaBorderCss.top ?? "none",
borderRight: textAreaBorderCss.right ?? "none",
borderBottom: textAreaBorderCss.bottom ?? "none",
borderLeft: textAreaBorderCss.left ?? "none",
}}
aria-hidden
/>
))
: null}
{showLayout ? (
<DocsBodyMarginMasks
pageCount={pageCount}
pageLayout={pageLayout}
pageWidth={pageWidth}
pageHeight={pageHeight}
pageColor={pageBackground}
pageRegionHeights={pageRegionHeights}
/>
) : null}
{showLayout
? Array.from({ length: pageCount }, (_, index) => {
const pageTop = index * (pageHeight + DOCS_PAGE_GAP_PX)
return (
<div key={`hf-${index}`}>
<DocsHeaderFooterBand
region="header"
pageLayout={pageLayout}
pageIndex={index}
pageTop={pageTop}
pageWidth={pageWidth}
pageHeight={pageHeight}
margins={margins}
editable={editable}
editingTarget={editingTarget}
onStartEdit={startRegionEdit}
onStopEdit={stopRegionEdit}
onContentChange={(region, content, meta) =>
onRegionContentChange?.(region, content, meta)
}
onPageSetupChange={(patch) => onPageSetupChange?.(patch)}
onRegionHeightMeasure={handleRegionHeightMeasure}
onRegionEditorReady={
editingTarget?.region === "header" &&
editingTarget.pageIndex === index
? handleRegionEditorReady
: undefined
}
/>
<DocsHeaderFooterBand
region="footer"
pageLayout={pageLayout}
pageIndex={index}
pageTop={pageTop}
pageWidth={pageWidth}
pageHeight={pageHeight}
margins={margins}
editable={editable}
editingTarget={editingTarget}
onStartEdit={startRegionEdit}
onStopEdit={stopRegionEdit}
onContentChange={(region, content, meta) =>
onRegionContentChange?.(region, content, meta)
}
onPageSetupChange={(patch) => onPageSetupChange?.(patch)}
onRegionHeightMeasure={handleRegionHeightMeasure}
onRegionEditorReady={
editingTarget?.region === "footer" &&
editingTarget.pageIndex === index
? handleRegionEditorReady
: undefined
}
/>
</div>
)
})
: null}
{bodyDimmed ? (
<div
className="pointer-events-none absolute inset-0 z-[12] bg-[#e8eaed]/40"
style={{ height: stackHeight }}
aria-hidden
/>
) : null}
<div
ref={contentRef}
className={cn(
"ultidrive-docs-editor-surface relative",
showLayout && "ultidrive-docs-editor-surface--paginated",
!showLayout && "ultidrive-docs-editor-surface--compact",
bodyDimmed && "ultidrive-docs-editor-surface--dimmed"
)}
style={{
padding: `${effectiveMargins.top}px ${effectiveMargins.right}px ${effectiveMargins.bottom}px ${effectiveMargins.left}px`,
height: showLayout ? stackHeight : undefined,
minHeight: showLayout
? undefined
: proseMinHeight + effectiveMargins.top + effectiveMargins.bottom,
["--docs-stack-height" as string]: `${stackHeight}px`,
["--docs-prose-min-height" as string]: `${proseMinHeight}px`,
["--docs-body-area-h" as string]: `${metrics.bodyAreaHeight}px`,
["--docs-inter-page-spacer" as string]: `${metrics.interPageSpacer}px`,
}}
onMouseDown={(event) => {
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)
}}
>
<EditorContent
editor={editor}
className={cn(!editable && "pointer-events-none select-text")}
/>
</div>
</div>
</div>
</div>
</div>
)
}
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 (
<div
className={cn(
"flex shrink-0 items-center justify-between border-t border-[#dadce0] bg-[#edf2fa] px-4 py-1 text-xs text-[#5f6368] dark:border-border dark:bg-muted/40 dark:text-muted-foreground",
className
)}
>
<span>
Page {pageLabel} sur {pageCount}
</span>
<span>
{pageLayout.format.label} ({pageLayout.format.widthMm} × {pageLayout.format.heightMm} mm)
</span>
</div>
)
}