293 lines
9.6 KiB
TypeScript
293 lines
9.6 KiB
TypeScript
"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>
|
||
)
|
||
}
|