215 lines
6.1 KiB
TypeScript
215 lines
6.1 KiB
TypeScript
"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>
|
||
)
|
||
}
|