ultisuite-client/components/drive/richtext/docs-header-footer-region.tsx
R3D347HR4Y 2a7c153748
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
wrap page
2026-06-10 12:48:27 +02:00

482 lines
15 KiB
TypeScript

"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { ChevronDown } from "lucide-react"
import type { Editor } from "@tiptap/react"
import type { DocPageLayout, DocPageSetup } from "@/lib/drive/doc-page-setup"
import { pxToMm } from "@/lib/drive/doc-page-setup"
import {
DOCS_HF_CHROME_BAR_PX,
defaultFooterZoneHeightPx,
defaultHeaderZoneHeightPx,
pageFooterGeometry,
pageHeaderGeometry,
resolveRegionForPage,
toggleDifferentFirstPage,
type DocsHeaderFooterRegion,
} from "@/lib/drive/docs-header-footer-layout"
import { DocsRegionEditor } from "@/components/drive/richtext/docs-region-editor"
import {
DocsHeaderFooterFormatDialog,
DocsPageNumbersDialog,
} from "@/components/drive/richtext/docs-header-footer-dialogs"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
export function extractRegionPlainText(content: Record<string, unknown> | undefined): string {
if (!content) return ""
const parts: string[] = []
const walk = (node: unknown) => {
if (!node || typeof node !== "object") return
const record = node as Record<string, unknown>
if (typeof record.text === "string") parts.push(record.text)
if (Array.isArray(record.content)) record.content.forEach(walk)
}
walk(content)
return parts.join(" ").trim()
}
export type { DocsHeaderFooterRegion }
export type DocsHeaderFooterEditTarget = {
region: DocsHeaderFooterRegion
pageIndex: number
} | null
function DocsHeaderFooterChrome({
label,
pageWidth,
barTop,
placement,
showFirstPageCheckbox,
differentFirstPage,
onDifferentFirstPageChange,
onFormatOpen,
onPageNumOpen,
onRemove,
}: {
label: string
pageWidth: number
barTop: number
placement: DocsHeaderFooterRegion
showFirstPageCheckbox: boolean
differentFirstPage: boolean
onDifferentFirstPageChange: (checked: boolean) => void
onFormatOpen: () => void
onPageNumOpen: () => void
onRemove: () => void
}) {
return (
<div
className={cn(
"docs-hf-chrome pointer-events-none absolute",
placement === "footer" && "docs-hf-chrome--footer"
)}
style={{ top: barTop, left: 0, width: pageWidth, height: DOCS_HF_CHROME_BAR_PX }}
>
<div
className={cn(
"docs-hf-chrome__bar pointer-events-auto flex h-full items-center justify-between bg-[#f1f3f4] px-4",
placement === "header" ? "border-t border-[#dadce0]" : "border-b border-[#dadce0]"
)}
onMouseDown={(event) => event.preventDefault()}
>
<span className="docs-hf-chrome__label">{label}</span>
<div className="flex items-center gap-4">
{showFirstPageCheckbox ? (
<label className="docs-hf-chrome__checkbox-label flex cursor-pointer items-center gap-2">
<Checkbox
checked={differentFirstPage}
onCheckedChange={(v) => onDifferentFirstPageChange(v === true)}
className="docs-hf-chrome__checkbox"
/>
Première page différente
</label>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="docs-hf-chrome__options h-7 gap-1 px-1.5 hover:bg-transparent dark:hover:bg-transparent"
>
Options
<ChevronDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onFormatOpen}>
{label === "En-tête"
? "Format de l'en-tête"
: "Format du pied de page"}
</DropdownMenuItem>
<DropdownMenuItem onClick={onPageNumOpen}>
Numéros de page
</DropdownMenuItem>
<DropdownMenuItem onClick={onRemove}>
{label === "En-tête"
? "Supprimer l'en-tête"
: "Supprimer le pied de page"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
)
}
export function DocsHeaderFooterBand({
region,
pageLayout,
pageIndex,
pageTop,
pageWidth,
pageHeight,
margins,
editable,
editingTarget,
onStartEdit,
onStopEdit,
onContentChange,
onPageSetupChange,
onRegionEditorReady,
onRegionHeightMeasure,
}: {
region: DocsHeaderFooterRegion
pageLayout: DocPageLayout
pageIndex: number
pageTop: number
pageWidth: number
pageHeight: number
margins: { top: number; right: number; bottom: number; left: number }
editable: boolean
editingTarget: DocsHeaderFooterEditTarget
onStartEdit: (region: DocsHeaderFooterRegion, pageIndex: number) => void
onStopEdit: () => void
onContentChange: (
region: DocsHeaderFooterRegion,
content: Record<string, unknown>,
meta: { pageIndex: number; contentHeightPx: number },
options?: { immediate?: boolean }
) => void
onPageSetupChange: (patch: Partial<DocPageSetup>) => void
onRegionEditorReady?: (editor: Editor | null) => void
onRegionHeightMeasure?: (payload: {
region: DocsHeaderFooterRegion
pageIndex: number
heightPx: number
}) => void
}) {
const isHeader = region === "header"
const isEditing =
editingTarget?.region === region && editingTarget.pageIndex === pageIndex
const canEdit = editable
const regionData = resolveRegionForPage(pageLayout, region, pageIndex)
const differentFirstPage = pageLayout.headerFooterDifferentFirstPage ?? false
const headerGeom = pageHeaderGeometry(pageLayout, pageTop, pageIndex)
const footerGeom = pageFooterGeometry(pageLayout, pageTop, pageHeight, pageIndex)
const bandLeft = margins.left
const bandWidth = pageWidth - margins.left - margins.right
const zoneHeight = isHeader ? headerGeom.zoneHeight : footerGeom.zoneHeight
const minZoneHeight = isHeader
? defaultHeaderZoneHeightPx(pageLayout)
: defaultFooterZoneHeightPx(pageLayout)
const [liveHeight, setLiveHeight] = useState(zoneHeight)
const liveHeightRef = useRef(zoneHeight)
liveHeightRef.current = liveHeight
useEffect(() => {
if (isEditing) return
setLiveHeight((height) => Math.min(height, zoneHeight))
}, [isEditing, zoneHeight])
/** View: content-sized. Edit: at least default header/footer margin band. */
const contentHeight = isEditing
? Math.max(minZoneHeight, liveHeight)
: Math.max(20, liveHeight)
const zoneTop = isHeader
? headerGeom.zoneTop
: footerGeom.zoneBottom - contentHeight
const headerChromeTop = headerGeom.zoneTop + contentHeight
const footerChromeTop =
footerGeom.zoneBottom - contentHeight - DOCS_HF_CHROME_BAR_PX
const chromeBarTop = isHeader ? headerChromeTop : footerChromeTop
const [formatOpen, setFormatOpen] = useState(false)
const [pageNumOpen, setPageNumOpen] = useState(false)
const contentPersistTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const latestContentRef = useRef<Record<string, unknown> | null>(null)
const persistRegionContent = useCallback(
(content: Record<string, unknown>, immediate = false) => {
latestContentRef.current = content
onContentChange(
region,
content,
{
pageIndex,
contentHeightPx: Math.max(minZoneHeight, liveHeightRef.current),
},
immediate ? { immediate: true } : undefined
)
},
[minZoneHeight, onContentChange, pageIndex, region]
)
const scheduleRegionContentPersist = useCallback(
(content: Record<string, unknown>) => {
latestContentRef.current = content
if (contentPersistTimer.current) clearTimeout(contentPersistTimer.current)
contentPersistTimer.current = setTimeout(() => {
persistRegionContent(content)
}, 800)
},
[persistRegionContent]
)
const pageNumber =
pageLayout.pageNumbers?.enabled &&
pageLayout.pageNumbers.placement === region &&
(pageLayout.pageNumbers.showOnFirstPage || pageIndex > 0)
? (pageLayout.pageNumbers.startAt ?? 1) + pageIndex
: null
const handleRemove = useCallback(() => {
onContentChange(
region,
{ type: "doc", content: [{ type: "paragraph" }] },
{ pageIndex, contentHeightPx: minZoneHeight },
{ immediate: true }
)
onPageSetupChange(isHeader ? { header: null, headerFirstPage: null } : { footer: null, footerFirstPage: null })
onStopEdit()
}, [isHeader, minZoneHeight, onContentChange, onPageSetupChange, onStopEdit, pageIndex, region])
const setupPatch: DocPageSetup = {
widthMm: pageLayout.format.widthMm,
heightMm: pageLayout.format.heightMm,
marginsMm: {
top: pxToMm(pageLayout.marginsPx.top),
right: pxToMm(pageLayout.marginsPx.right),
bottom: pxToMm(pageLayout.marginsPx.bottom),
left: pxToMm(pageLayout.marginsPx.left),
},
headerMarginMm: pageLayout.headerMarginMm,
footerMarginMm: pageLayout.footerMarginMm,
headerFooterDifferentFirstPage: differentFirstPage,
headerFooterDifferentOddEven: pageLayout.headerFooterDifferentOddEven,
pageNumbers: pageLayout.pageNumbers,
header: pageLayout.header,
footer: pageLayout.footer,
headerFirstPage: pageLayout.headerFirstPage,
footerFirstPage: pageLayout.footerFirstPage,
}
const hasContent =
regionData?.content && extractRegionPlainText(regionData.content).length > 0
const regionLabel = isHeader ? "En-tête" : "Pied de page"
const handleDoubleClick = (event: React.MouseEvent) => {
if (!canEdit) return
event.preventDefault()
event.stopPropagation()
onStartEdit(region, pageIndex)
}
const persistHeight = useCallback(
(heightPx: number) => {
const measured = Math.max(20, heightPx)
setLiveHeight(measured)
onRegionHeightMeasure?.({ region, pageIndex, heightPx: measured })
},
[onRegionHeightMeasure, pageIndex, region]
)
useEffect(() => {
return () => {
if (contentPersistTimer.current) clearTimeout(contentPersistTimer.current)
}
}, [])
const wasEditingRef = useRef(false)
useEffect(() => {
if (wasEditingRef.current && !isEditing) {
if (contentPersistTimer.current) {
clearTimeout(contentPersistTimer.current)
contentPersistTimer.current = null
}
persistRegionContent(
(latestContentRef.current ??
regionData?.content ??
({ type: "doc", content: [{ type: "paragraph" }] } as Record<string, unknown>)),
true
)
}
wasEditingRef.current = isEditing
}, [isEditing, persistRegionContent, regionData?.content])
return (
<>
{isEditing ? (
<>
<div
className="docs-hf-editing-backdrop pointer-events-none absolute"
style={{
top: zoneTop,
left: 0,
width: pageWidth,
height: contentHeight,
backgroundColor: pageLayout.pageColor,
}}
aria-hidden
/>
{isHeader ? (
<div
className="docs-hf-separator pointer-events-none absolute border-t border-[#dadce0]"
style={{ top: headerGeom.zoneTop, left: 0, width: pageWidth }}
aria-hidden
/>
) : (
<div
className="docs-hf-separator pointer-events-none absolute border-b border-[#dadce0]"
style={{ top: footerGeom.zoneBottom, left: 0, width: pageWidth }}
aria-hidden
/>
)}
<DocsHeaderFooterChrome
label={regionLabel}
pageWidth={pageWidth}
barTop={chromeBarTop}
placement={region}
showFirstPageCheckbox={pageIndex === 0}
differentFirstPage={differentFirstPage}
onDifferentFirstPageChange={(checked) => {
onPageSetupChange(toggleDifferentFirstPage(setupPatch, checked))
}}
onFormatOpen={() => setFormatOpen(true)}
onPageNumOpen={() => setPageNumOpen(true)}
onRemove={handleRemove}
/>
</>
) : null}
{!isEditing && !hasContent && canEdit ? (
<div
className="docs-hf-hit-area absolute cursor-text"
style={{
top: isHeader ? headerGeom.zoneTop : footerGeom.zoneBottom - minZoneHeight,
left: bandLeft,
width: bandWidth,
height: minZoneHeight,
}}
onDoubleClick={handleDoubleClick}
aria-hidden
/>
) : null}
{isEditing || hasContent ? (
<div
className={cn(
"docs-hf-band absolute",
isHeader ? "docs-hf-band--header" : "docs-hf-band--footer",
isEditing && "docs-hf-band--editing"
)}
style={{
top: zoneTop,
left: bandLeft,
width: bandWidth,
height: contentHeight,
...(isEditing
? {
backgroundColor: pageLayout.pageColor,
"--docs-hf-page-color": pageLayout.pageColor,
}
: {}),
}}
onDoubleClick={handleDoubleClick}
>
<DocsRegionEditor
content={regionData?.content}
editable={isEditing}
autoFocus={isEditing}
placeholder={regionLabel}
minHeightPx={isEditing ? minZoneHeight : undefined}
onUpdate={isEditing ? scheduleRegionContentPersist : undefined}
onBlur={
isEditing
? () => {
requestAnimationFrame(() => {
const active = document.activeElement
if (
active?.closest(".docs-hf-chrome") ||
active?.closest('[role="dialog"]') ||
active?.closest("[data-radix-popper-content-wrapper]")
) {
return
}
onStopEdit()
})
}
: undefined
}
onEditorReady={isEditing ? onRegionEditorReady : undefined}
onContentHeightChange={persistHeight}
/>
{pageNumber != null && !isEditing ? (
<div
className={cn(
"pointer-events-none absolute text-[11px] text-[#5f6368]",
pageLayout.pageNumbers?.align === "center"
? "left-1/2 -translate-x-1/2"
: pageLayout.pageNumbers?.align === "left"
? "left-0"
: "right-0",
isHeader ? "top-0" : "bottom-0"
)}
>
{pageNumber}
</div>
) : null}
</div>
) : null}
{isEditing ? (
<>
<DocsHeaderFooterFormatDialog
open={formatOpen}
onOpenChange={setFormatOpen}
pageSetup={setupPatch}
onApply={onPageSetupChange}
/>
<DocsPageNumbersDialog
open={pageNumOpen}
onOpenChange={setPageNumOpen}
settings={pageLayout.pageNumbers}
onApply={(pageNumbers) => onPageSetupChange({ pageNumbers })}
/>
</>
) : null}
</>
)
}