Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Replaced `html2canvas` with `html2canvas-pro` for improved rendering in document capture. - Enhanced error handling in print functions to provide user feedback on print failures. - Introduced new `docs-page-capture` module to streamline page capture logic for PDF exports. - Refactored PDF export process to utilize captured canvases, improving performance and reliability. - Updated print styles for better document layout during printing and PDF generation.
256 lines
7.8 KiB
TypeScript
256 lines
7.8 KiB
TypeScript
import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
|
|
import { DOCS_PAGE_GAP_PX } from "@/lib/drive/docs-page-layout-constants"
|
|
import type { DocsExportSnapshot } from "@/lib/drive/docs-export-snapshot"
|
|
|
|
export type DocsPageCaptureTarget = {
|
|
stack: HTMLElement
|
|
pageWidth: number
|
|
pageHeight: number
|
|
pageCount: number
|
|
}
|
|
|
|
export function resolveDocsPageCaptureTarget(
|
|
snapshot: DocsExportSnapshot
|
|
): DocsPageCaptureTarget | null {
|
|
const stack = snapshot.getPageStackElement()
|
|
if (!stack) return null
|
|
|
|
return {
|
|
stack,
|
|
pageWidth: Number(stack.dataset.docsPageWidth ?? snapshot.pageLayout.widthPx),
|
|
pageHeight: Number(stack.dataset.docsPageHeight ?? snapshot.pageLayout.heightPx),
|
|
pageCount: snapshot.pageCount,
|
|
}
|
|
}
|
|
|
|
function normalizeStackCloneForCapture(
|
|
stack: HTMLElement,
|
|
pageLayout: DocPageLayout,
|
|
pageCount: number
|
|
): void {
|
|
const pageWidth = pageLayout.widthPx
|
|
const pageHeight = pageLayout.heightPx
|
|
const pageStep = pageHeight + DOCS_PAGE_GAP_PX
|
|
const gapTotal = Math.max(0, pageCount - 1) * DOCS_PAGE_GAP_PX
|
|
|
|
stack.style.transform = "none"
|
|
stack.style.transformOrigin = "top left"
|
|
stack.style.left = "0"
|
|
stack.style.marginLeft = "0"
|
|
stack.style.position = "relative"
|
|
stack.style.width = `${pageWidth}px`
|
|
stack.classList.remove("left-1/2", "-translate-x-1/2", "top-0")
|
|
|
|
stack.querySelectorAll<HTMLElement>(".docs-page-gap-band").forEach((el) => {
|
|
el.style.display = "none"
|
|
})
|
|
stack.querySelectorAll<HTMLElement>(".docs-page-inter-margin-gutter").forEach((el) => {
|
|
el.style.display = "none"
|
|
})
|
|
|
|
const stackHeight = parseFloat(stack.style.height)
|
|
if (Number.isFinite(stackHeight) && gapTotal > 0) {
|
|
stack.style.height = `${stackHeight - gapTotal}px`
|
|
}
|
|
|
|
stack.querySelectorAll<HTMLElement>("[style]").forEach((el) => {
|
|
const top = parseFloat(el.style.top)
|
|
if (!Number.isFinite(top) || top <= 0) return
|
|
const pageIndex = Math.floor(top / pageStep)
|
|
el.style.top = `${top - pageIndex * DOCS_PAGE_GAP_PX}px`
|
|
})
|
|
|
|
stack.querySelectorAll<HTMLElement>(".docs-page-flow-spacer").forEach((spacer) => {
|
|
const height = parseFloat(spacer.style.height)
|
|
if (Number.isFinite(height) && height >= DOCS_PAGE_GAP_PX) {
|
|
spacer.style.height = `${height - DOCS_PAGE_GAP_PX}px`
|
|
}
|
|
})
|
|
|
|
const surface = stack.querySelector<HTMLElement>(".ultidrive-docs-editor-surface")
|
|
if (surface) {
|
|
const surfaceHeight = parseFloat(surface.style.height)
|
|
if (Number.isFinite(surfaceHeight) && gapTotal > 0) {
|
|
surface.style.height = `${surfaceHeight - gapTotal}px`
|
|
}
|
|
surface.removeAttribute("contenteditable")
|
|
}
|
|
|
|
stack.querySelectorAll<HTMLElement>(".ProseMirror").forEach((el) => {
|
|
el.removeAttribute("contenteditable")
|
|
})
|
|
}
|
|
|
|
async function cloneStylesIntoDocument(targetDoc: Document): Promise<void> {
|
|
const head = targetDoc.head
|
|
const loads: Promise<void>[] = []
|
|
|
|
const styleNodes = document.querySelectorAll('link[rel="stylesheet"], style')
|
|
for (const node of styleNodes) {
|
|
if (node instanceof HTMLLinkElement) {
|
|
const href = node.href
|
|
if (!href) continue
|
|
const link = targetDoc.createElement("link")
|
|
link.rel = "stylesheet"
|
|
link.href = href
|
|
if (node.media) link.media = node.media
|
|
loads.push(
|
|
new Promise<void>((resolve) => {
|
|
link.onload = () => resolve()
|
|
link.onerror = () => resolve()
|
|
})
|
|
)
|
|
head.appendChild(link)
|
|
continue
|
|
}
|
|
head.appendChild(node.cloneNode(true))
|
|
}
|
|
|
|
targetDoc.documentElement.lang = document.documentElement.lang
|
|
targetDoc.documentElement.className = document.documentElement.className
|
|
targetDoc.documentElement.classList.remove("dark")
|
|
targetDoc.documentElement.style.background = "#ffffff"
|
|
|
|
await Promise.all(loads)
|
|
if (targetDoc.fonts?.ready) {
|
|
await targetDoc.fonts.ready
|
|
}
|
|
}
|
|
|
|
async function createCaptureFrame(
|
|
stack: HTMLElement,
|
|
pageLayout: DocPageLayout,
|
|
pageCount: number
|
|
): Promise<{ root: HTMLElement; dispose: () => void }> {
|
|
const pageWidth = pageLayout.widthPx
|
|
const pageHeight = pageLayout.heightPx
|
|
const stageHeight = pageCount * pageHeight
|
|
|
|
const iframe = document.createElement("iframe")
|
|
iframe.setAttribute("aria-hidden", "true")
|
|
iframe.style.cssText =
|
|
"position:fixed;top:0;left:0;width:1px;height:1px;border:0;opacity:0;pointer-events:none;overflow:hidden"
|
|
|
|
const docReady = new Promise<Document>((resolve, reject) => {
|
|
iframe.onload = () => {
|
|
const doc = iframe.contentDocument
|
|
if (doc) resolve(doc)
|
|
else reject(new Error("Impossible de préparer la capture"))
|
|
}
|
|
iframe.onerror = () => reject(new Error("Impossible de préparer la capture"))
|
|
})
|
|
|
|
iframe.src = "about:blank"
|
|
document.body.appendChild(iframe)
|
|
|
|
const doc = await docReady
|
|
await cloneStylesIntoDocument(doc)
|
|
|
|
const clone = stack.cloneNode(true) as HTMLElement
|
|
normalizeStackCloneForCapture(clone, pageLayout, pageCount)
|
|
|
|
doc.body.replaceChildren()
|
|
doc.body.style.cssText = `margin:0;padding:0;background:#ffffff;width:${pageWidth}px;height:${stageHeight}px;overflow:hidden`
|
|
|
|
const mount = doc.createElement("div")
|
|
mount.style.cssText = `position:relative;width:${pageWidth}px;height:${stageHeight}px;overflow:hidden`
|
|
mount.appendChild(clone)
|
|
doc.body.appendChild(mount)
|
|
|
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
|
|
|
return {
|
|
root: clone,
|
|
dispose: () => {
|
|
iframe.remove()
|
|
},
|
|
}
|
|
}
|
|
|
|
async function rasterizePageSlice(
|
|
element: HTMLElement,
|
|
pageWidth: number,
|
|
pageHeight: number,
|
|
yOffset: number,
|
|
scale: number
|
|
): Promise<HTMLCanvasElement> {
|
|
const { default: html2canvas } = await import("html2canvas-pro")
|
|
const canvas = await html2canvas(element, {
|
|
backgroundColor: "#ffffff",
|
|
scale,
|
|
useCORS: true,
|
|
logging: false,
|
|
width: pageWidth,
|
|
height: pageHeight,
|
|
x: 0,
|
|
y: yOffset,
|
|
scrollX: 0,
|
|
scrollY: 0,
|
|
windowWidth: pageWidth,
|
|
windowHeight: element.scrollHeight,
|
|
})
|
|
|
|
if (canvas.width === 0 || canvas.height === 0) {
|
|
throw new Error("Capture page vide")
|
|
}
|
|
|
|
return canvas
|
|
}
|
|
|
|
async function withFrozenViewport<T>(fn: () => Promise<T>): Promise<T> {
|
|
const scrollX = window.scrollX
|
|
const scrollY = window.scrollY
|
|
const html = document.documentElement
|
|
const body = document.body
|
|
const prevHtmlOverflow = html.style.overflow
|
|
const prevBodyOverflow = body.style.overflow
|
|
|
|
html.style.overflow = "hidden"
|
|
body.style.overflow = "hidden"
|
|
|
|
try {
|
|
return await fn()
|
|
} finally {
|
|
html.style.overflow = prevHtmlOverflow
|
|
body.style.overflow = prevBodyOverflow
|
|
window.scrollTo(scrollX, scrollY)
|
|
}
|
|
}
|
|
|
|
/** Rasterize each page from an isolated iframe clone — no live UI mutation. */
|
|
export async function captureDocsPagesFromCanvas(
|
|
snapshot: DocsExportSnapshot,
|
|
options?: { scale?: number }
|
|
): Promise<HTMLCanvasElement[]> {
|
|
const target = resolveDocsPageCaptureTarget(snapshot)
|
|
if (!target) {
|
|
throw new Error("Page stack introuvable")
|
|
}
|
|
|
|
const { stack, pageWidth, pageHeight, pageCount } = target
|
|
const preferredScale = options?.scale ?? 2
|
|
|
|
return withFrozenViewport(async () => {
|
|
const { root, dispose } = await createCaptureFrame(stack, snapshot.pageLayout, pageCount)
|
|
|
|
try {
|
|
const canvases: HTMLCanvasElement[] = []
|
|
for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
|
|
const yOffset = pageIndex * pageHeight
|
|
let canvas: HTMLCanvasElement
|
|
try {
|
|
canvas = await rasterizePageSlice(root, pageWidth, pageHeight, yOffset, preferredScale)
|
|
} catch (firstError) {
|
|
if (preferredScale === 1) throw firstError
|
|
canvas = await rasterizePageSlice(root, pageWidth, pageHeight, yOffset, 1)
|
|
}
|
|
canvases.push(canvas)
|
|
}
|
|
return canvases
|
|
} finally {
|
|
dispose()
|
|
}
|
|
})
|
|
}
|