ultisuite-client/components/drive/richtext/docs-page-view.tsx
R3D347HR4Y 8e420509a8
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
imports docx 1
2026-06-10 00:27:44 +02:00

293 lines
9.6 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, useEffect, useRef, useState } from "react"
import { EditorContent, type Editor } from "@tiptap/react"
import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
import { cn } from "@/lib/utils"
import { focusEditorAtPointer } from "@/lib/drive/focus-editor-at-pointer"
const PAGE_GAP_PX = 12
/** Actual block layout height — ignores CSS min-height on ProseMirror (page stack). */
function measureProseContentHeight(prose: HTMLElement): number {
if (prose.childElementCount === 0) {
return 0
}
let maxBottom = 0
for (const child of prose.children) {
const el = child as HTMLElement
maxBottom = Math.max(maxBottom, el.offsetTop + el.offsetHeight)
}
return maxBottom
}
function DocsPageViewInner({
editor,
pageLayout,
zoom,
editable,
onPageCountChange,
}: {
editor: Editor
pageLayout: DocPageLayout
zoom: number
editable: boolean
onPageCountChange?: (count: number) => void
}) {
const pageWidth = pageLayout.widthPx
const pageHeight = pageLayout.heightPx
const margins = pageLayout.marginsPx
const canvasRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const [pageCount, setPageCount] = useState(1)
const [narrowViewport, setNarrowViewport] = useState(false)
const onPageCountChangeRef = useRef(onPageCountChange)
onPageCountChangeRef.current = onPageCountChange
const scale = zoom / 100
const scaledWidth = pageWidth * scale
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const syncViewport = () => {
setNarrowViewport(canvas.clientWidth < scaledWidth)
}
syncViewport()
const ro = new ResizeObserver(syncViewport)
ro.observe(canvas)
return () => ro.disconnect()
}, [scaledWidth])
useEffect(() => {
const surface = contentRef.current
if (!surface) return
let rafId = 0
const measure = () => {
const prose = surface.querySelector(".ProseMirror") as HTMLElement | null
if (!prose) return
const contentHeight = measureProseContentHeight(prose)
const paddedHeight = margins.top + margins.bottom + contentHeight
const count = Math.max(1, Math.ceil(paddedHeight / pageHeight))
setPageCount((prev) => (prev === count ? prev : count))
}
const scheduleMeasure = () => {
if (rafId) cancelAnimationFrame(rafId)
rafId = requestAnimationFrame(measure)
}
scheduleMeasure()
const prose = surface.querySelector(".ProseMirror") as HTMLElement | null
const ro = prose ? new ResizeObserver(scheduleMeasure) : null
if (prose && ro) ro.observe(prose)
const onTransaction = () => scheduleMeasure()
editor.on("transaction", onTransaction)
return () => {
if (rafId) cancelAnimationFrame(rafId)
ro?.disconnect()
editor.off("transaction", onTransaction)
}
}, [margins.bottom, margins.top, pageHeight, editor])
useEffect(() => {
onPageCountChangeRef.current?.(pageCount)
}, [pageCount])
const stackHeight = pageCount * pageHeight + (pageCount - 1) * PAGE_GAP_PX
const innerMinHeight = Math.max(pageHeight - margins.top - margins.bottom, stackHeight - margins.top - margins.bottom)
const scaledHeight = stackHeight * scale
const verticalPadding = narrowViewport ? 32 : 64
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}
</>
)
return (
<div
ref={canvasRef}
className="ultidrive-docs-canvas min-h-0 flex-1 overflow-auto bg-[#f9fbfd] dark:bg-[#202124]"
>
<div
className={cn("mx-auto", narrowViewport ? "pb-8 pt-0" : "py-8")}
style={{
width: scaledWidth,
minHeight: scaledHeight + verticalPadding,
}}
>
<div
className="relative mx-auto"
style={{
width: scaledWidth,
height: scaledHeight,
}}
>
<div
className="absolute left-1/2 top-0 origin-top -translate-x-1/2"
style={{
width: pageWidth,
height: stackHeight,
transform: `scale(${scale})`,
}}
>
{Array.from({ length: pageCount }, (_, index) => (
<div
key={index}
className={cn(
"ultidrive-docs-page absolute left-0 overflow-hidden dark:bg-white",
sheetBorderCss && "ultidrive-docs-page--imported-border"
)}
style={{
top: index * (pageHeight + PAGE_GAP_PX),
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>
))}
{textAreaBorderCss
? Array.from({ length: pageCount }, (_, index) => (
<div
key={`text-border-${index}`}
className="pointer-events-none absolute z-[5] box-border"
style={{
top: index * (pageHeight + PAGE_GAP_PX) + margins.top,
left: margins.left,
width: pageWidth - margins.left - margins.right,
height: pageHeight - margins.top - margins.bottom,
borderTop: textAreaBorderCss.top ?? "none",
borderRight: textAreaBorderCss.right ?? "none",
borderBottom: textAreaBorderCss.bottom ?? "none",
borderLeft: textAreaBorderCss.left ?? "none",
}}
aria-hidden
/>
))
: null}
<div
ref={contentRef}
className="ultidrive-docs-editor-surface relative z-10"
style={{
padding: `${margins.top}px ${margins.right}px ${margins.bottom}px ${margins.left}px`,
minHeight: stackHeight,
["--docs-prose-min-height" as string]: `${innerMinHeight}px`,
}}
onMouseDown={(event) => {
if (!editable) 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,
className,
}: {
pageLayout: DocPageLayout
pageCount: number
className?: string
}) {
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 1 sur {pageCount}</span>
<span>
{pageLayout.format.label} ({pageLayout.format.widthMm} × {pageLayout.format.heightMm} mm)
</span>
</div>
)
}