ultisuite-client/components/drive/richtext/docs-page-view.tsx
2026-06-09 17:06:20 +02:00

215 lines
6.1 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 {
getPageFormat,
PAGE_MARGIN_PX,
pageFormatHeightPx,
pageFormatWidthPx,
type PageFormatId,
} from "@/lib/drive/page-formats"
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,
pageFormatId,
zoom,
editable,
onPageCountChange,
}: {
editor: Editor
pageFormatId: PageFormatId
zoom: number
editable: boolean
onPageCountChange?: (count: number) => void
}) {
const format = getPageFormat(pageFormatId)
const pageWidth = pageFormatWidthPx(format)
const pageHeight = pageFormatHeightPx(format)
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 = PAGE_MARGIN_PX * 2 + contentHeight
const count = Math.max(1, Math.ceil(paddedHeight / pageHeight))
setPageCount((prev) => {
if (prev === count) return prev
onPageCountChangeRef.current?.(count)
return 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)
}
}, [pageHeight, editor])
const stackHeight = pageCount * pageHeight + (pageCount - 1) * PAGE_GAP_PX
const proseMinHeight = stackHeight - PAGE_MARGIN_PX * 2
const scaledHeight = stackHeight * scale
const verticalPadding = narrowViewport ? 32 : 64
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="ultidrive-docs-page absolute left-0 bg-white dark:bg-white"
style={{
top: index * (pageHeight + PAGE_GAP_PX),
width: pageWidth,
height: pageHeight,
boxShadow:
"0 1px 3px 1px rgba(60,64,67,.15), 0 1px 2px 0 rgba(60,64,67,.3)",
}}
aria-hidden
/>
))}
<div
ref={contentRef}
className="ultidrive-docs-editor-surface relative z-10"
style={{
padding: PAGE_MARGIN_PX,
minHeight: stackHeight,
["--docs-prose-min-height" as string]: `${Math.max(
pageHeight - PAGE_MARGIN_PX * 2,
proseMinHeight
)}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({
pageFormatId,
pageCount,
className,
}: {
pageFormatId: PageFormatId
pageCount: number
className?: string
}) {
const format = getPageFormat(pageFormatId)
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>
{format.label} ({format.widthMm} × {format.heightMm} mm)
</span>
</div>
)
}