Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Replaced suite page metadata with drive-specific metadata for document and drawing editors. - Introduced new `driveEditorPageMetadata` function to manage titles and favicons based on editor type. - Updated layout components for document and drawing editors to utilize the new metadata structure. - Enhanced document title handling in various editor components to reflect the current editing context. - Added new SVG icons for UltiDocs, UltiSheets, UltiSlides, and UltiDraw to improve visual consistency across editors. - Improved print styles and layout handling for better document rendering in print and PDF formats.
505 lines
16 KiB
TypeScript
505 lines
16 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 editLateralTop = isHeader ? zoneTop : footerChromeTop
|
|
const editLateralHeight = contentHeight + DOCS_HF_CHROME_BAR_PX
|
|
|
|
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
|
|
/>
|
|
|
|
<div
|
|
className="docs-hf-lateral-border pointer-events-none absolute border-l border-[#dadce0]"
|
|
style={{
|
|
top: editLateralTop,
|
|
left: 0,
|
|
height: editLateralHeight,
|
|
}}
|
|
aria-hidden
|
|
/>
|
|
<div
|
|
className="docs-hf-lateral-border pointer-events-none absolute border-r border-[#dadce0]"
|
|
style={{
|
|
top: editLateralTop,
|
|
right: 0,
|
|
width: 0,
|
|
height: editLateralHeight,
|
|
}}
|
|
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}
|
|
</>
|
|
)
|
|
}
|