ultisuite-client/components/drive/richtext/docs-page-view.tsx
R3D347HR4Y 76eff3c351
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(drive): enhance document layout with new page separators and margin masks
- Introduced `DocsPageSeparators` component to manage visual gaps between pages in the document editor.
- Updated `DocsBodyMarginMasks` to include dark mode support for improved styling.
- Refactored `DocsPageViewInner` to integrate new separators and margin masks, enhancing layout consistency.
- Adjusted layout constants to increase the gap between stacked pages for better visual separation.
- Improved test coverage for page flow calculations and layout metrics.
2026-06-15 17:28:02 +02:00

617 lines
22 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 { DocsPageSeparators } from "@/components/drive/richtext/docs-page-separators"
import { DocsPageRims } from "@/components/drive/richtext/docs-page-rims"
import {
DOCS_REGION_EDIT_EVENT,
type DocsRegionEditDetail,
} from "@/lib/drive/docs-page-elements-bridge"
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 { DocsGraphicSnapGuides } from "@/components/drive/richtext/docs-graphic-snap-guides"
import { DocsTableContextMenu } from "@/components/drive/richtext/docs-table-context-menu"
import {
applyPageFlowLayout,
measureFlowContentHeight,
} from "@/lib/drive/extensions/docs-page-flow-decoration"
import { focusEditorAtPointer } from "@/lib/drive/focus-editor-at-pointer"
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 onRegionEditRequest = (event: Event) => {
const detail = (event as CustomEvent<DocsRegionEditDetail>).detail
if (!detail?.region) return
startRegionEdit(detail.region, detail.pageIndex ?? 0)
}
window.addEventListener(DOCS_REGION_EDIT_EVENT, onRegionEditRequest)
return () => window.removeEventListener(DOCS_REGION_EDIT_EVENT, onRegionEditRequest)
}, [startRegionEdit])
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 = () => {
if (editor.isDestroyed) return
const contentHeight = measureFlowContentHeight(editor.view)
const count = computePageCount(contentHeight, metrics)
setPageCount((prev) => (prev === count ? prev : count))
}
const runLayoutPasses = () => {
if (cancelled || editor.isDestroyed) return
let passesLeft = 12
const step = () => {
if (cancelled || editor.isDestroyed) return
requestAnimationFrame(() => {
if (cancelled || editor.isDestroyed) return
const changed = applyPageFlowLayout(editor)
passesLeft -= 1
if (changed && passesLeft > 0) {
step()
return
}
measurePageCount()
})
}
step()
}
let flushPending = false
const scheduleLayout = () => {
if (!flushPending) {
flushPending = true
requestAnimationFrame(() => {
flushPending = false
runLayoutPasses()
})
}
if (debounceId) clearTimeout(debounceId)
debounceId = setTimeout(() => {
debounceId = null
runLayoutPasses()
}, 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])
useEffect(() => {
if (!showLayout || pageCount <= 1) return
const id = requestAnimationFrame(() => {
if (editor.isDestroyed) return
applyPageFlowLayout(editor)
})
return () => cancelAnimationFrame(id)
}, [editor, pageCount, showLayout])
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-visible"
style={{ width: scaledWidth, height: showLayout ? scaledHeight : undefined }}
>
<div
data-docs-page-stack
data-docs-page-height={pageHeight}
data-docs-page-width={pageWidth}
data-docs-page-scale={scale}
data-docs-page-margin-top={effectiveMargins.top}
data-docs-page-margin-right={effectiveMargins.right}
data-docs-page-margin-bottom={effectiveMargins.bottom}
data-docs-page-margin-left={effectiveMargins.left}
className="absolute left-1/2 top-0 -translate-x-1/2 overflow-visible"
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 ultidrive-docs-page--sheet absolute left-0 overflow-hidden dark:bg-white",
sheetBorderCss && "ultidrive-docs-page--imported-border"
)}
style={{
top: pageTop,
width: pageWidth,
height: pageHeight,
backgroundColor: pageBackground,
}}
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 ? (
<DocsPageSeparators
pageCount={pageCount}
pageHeight={pageHeight}
pageWidth={pageWidth}
margins={effectiveMargins}
pageColor={pageBackground}
interPageSpacer={metrics.interPageSpacer}
/>
) : null}
{showLayout ? (
<DocsPageRims
pageCount={pageCount}
pageHeight={pageHeight}
pageWidth={pageWidth}
sheetBorderCss={sheetBorderCss}
/>
) : 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
id="docs-page-graphic-layer-behind"
className="pointer-events-none absolute left-0 top-0 z-[12]"
style={{ width: pageWidth, height: stackHeight }}
/>
<div
id="docs-page-graphic-layer-front"
className="pointer-events-none absolute left-0 top-0 z-[18]"
style={{ width: pageWidth, height: stackHeight }}
/>
<DocsGraphicSnapGuides pageWidth={pageWidth} pageHeight={pageHeight} />
<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)
}}
>
<DocsTableContextMenu editor={editor} disabled={!editable || bodyDimmed}>
<div className="min-h-0 min-w-0">
<EditorContent
editor={editor}
className={cn(!editable && "pointer-events-none select-text")}
/>
</div>
</DocsTableContextMenu>
</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(
"docs-status-bar 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>
)
}