feat(drive): update document printing and PDF export functionality
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
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.
This commit is contained in:
parent
76eff3c351
commit
bd9605c853
@ -671,12 +671,13 @@ export function RichTextDocumentEditor({
|
||||
const handlePrintDocument = useCallback(async () => {
|
||||
const snapshot = getExportSnapshot()
|
||||
if (!snapshot) {
|
||||
window.print()
|
||||
toast.error("Impossible d'imprimer le document")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await printDocsDocument(snapshot)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("[docs] print failed", error)
|
||||
toast.error("Impossible d'imprimer le document")
|
||||
}
|
||||
}, [getExportSnapshot])
|
||||
|
||||
@ -24,6 +24,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -193,7 +194,13 @@ function DocsToolbarInner({
|
||||
sepAfter: false,
|
||||
node: (
|
||||
<>
|
||||
<ToolbarIconBtn label="Imprimer" onClick={() => onPrint?.() ?? window.print()}>
|
||||
<ToolbarIconBtn
|
||||
label="Imprimer"
|
||||
onClick={() => {
|
||||
if (onPrint) onPrint()
|
||||
else toast.error("Impossible d'imprimer le document")
|
||||
}}
|
||||
>
|
||||
<Printer className="size-4" />
|
||||
</ToolbarIconBtn>
|
||||
<ToolbarIconBtn
|
||||
|
||||
255
lib/drive/docs-page-capture.ts
Normal file
255
lib/drive/docs-page-capture.ts
Normal file
@ -0,0 +1,255 @@
|
||||
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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
import { DOCS_PAGE_GAP_PX } from "@/lib/drive/docs-page-layout-constants"
|
||||
import type { DocsExportSnapshot } from "@/lib/drive/docs-export-snapshot"
|
||||
import { prepareDocsPrintEnvironment } from "@/lib/drive/docs-print"
|
||||
import { captureDocsPagesFromCanvas } from "@/lib/drive/docs-page-capture"
|
||||
import { exportFileName } from "@/lib/drive/docs-export-download"
|
||||
|
||||
function downloadBlob(blob: Blob, fileName: string) {
|
||||
@ -13,23 +12,16 @@ function downloadBlob(blob: Blob, fileName: string) {
|
||||
}
|
||||
|
||||
export async function exportDocsToPdf(snapshot: DocsExportSnapshot): Promise<void> {
|
||||
const cleanup = await prepareDocsPrintEnvironment(snapshot, "pdf-capture")
|
||||
const canvases = await captureDocsPagesFromCanvas(snapshot, { scale: 2 })
|
||||
|
||||
try {
|
||||
const stack = snapshot.getPageStackElement()
|
||||
if (!stack) throw new Error("Page stack introuvable")
|
||||
const [{ jsPDF }] = await Promise.all([import("jspdf")])
|
||||
|
||||
const canvasHost = stack.closest(".ultidrive-docs-canvas") as HTMLElement | null
|
||||
if (!canvasHost) throw new Error("Canvas introuvable")
|
||||
|
||||
const pageHeight = Number(stack.dataset.docsPageHeight ?? snapshot.pageLayout.heightPx)
|
||||
const pageWidth = Number(stack.dataset.docsPageWidth ?? snapshot.pageLayout.widthPx)
|
||||
const pageCount = snapshot.pageCount
|
||||
|
||||
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
|
||||
import("html2canvas"),
|
||||
import("jspdf"),
|
||||
])
|
||||
const pageWidth = Number(
|
||||
snapshot.getPageStackElement()?.dataset.docsPageWidth ?? snapshot.pageLayout.widthPx
|
||||
)
|
||||
const pageHeight = Number(
|
||||
snapshot.getPageStackElement()?.dataset.docsPageHeight ?? snapshot.pageLayout.heightPx
|
||||
)
|
||||
|
||||
const pdf = new jsPDF({
|
||||
orientation: pageWidth > pageHeight ? "landscape" : "portrait",
|
||||
@ -40,25 +32,8 @@ export async function exportDocsToPdf(snapshot: DocsExportSnapshot): Promise<voi
|
||||
],
|
||||
})
|
||||
|
||||
const hostRect = canvasHost.getBoundingClientRect()
|
||||
const stackRect = stack.getBoundingClientRect()
|
||||
|
||||
for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
|
||||
const yOffset = pageIndex * (pageHeight + DOCS_PAGE_GAP_PX)
|
||||
const canvas = await html2canvas(canvasHost, {
|
||||
backgroundColor: "#ffffff",
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
x: stackRect.left - hostRect.left,
|
||||
y: stackRect.top - hostRect.top + yOffset,
|
||||
windowWidth: canvasHost.scrollWidth,
|
||||
windowHeight: canvasHost.scrollHeight,
|
||||
})
|
||||
|
||||
const imgData = canvas.toDataURL("image/jpeg", 0.92)
|
||||
for (let pageIndex = 0; pageIndex < canvases.length; pageIndex += 1) {
|
||||
const imgData = canvases[pageIndex].toDataURL("image/jpeg", 0.92)
|
||||
if (pageIndex > 0) pdf.addPage()
|
||||
pdf.addImage(
|
||||
imgData,
|
||||
@ -72,7 +47,4 @@ export async function exportDocsToPdf(snapshot: DocsExportSnapshot): Promise<voi
|
||||
|
||||
const blob = pdf.output("blob")
|
||||
downloadBlob(blob, exportFileName(snapshot.sourceName, "pdf"))
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
import type { DocPageLayout } from "@/lib/drive/doc-page-setup"
|
||||
import { buildParagraphStylesCss } from "@/lib/drive/docs-paragraph-styles-css"
|
||||
import type { DocParagraphStylesCatalog } from "@/lib/drive/docs-paragraph-styles"
|
||||
import type { DocsExportSnapshot } from "@/lib/drive/docs-export-snapshot"
|
||||
import { DOCS_PAGE_GAP_PX } from "@/lib/drive/docs-page-layout-constants"
|
||||
import { captureDocsPagesFromCanvas } from "@/lib/drive/docs-page-capture"
|
||||
|
||||
const PRINT_STYLE_ID = "docs-print-dynamic-styles"
|
||||
const PRINT_IFRAME_ID = "docs-print-iframe"
|
||||
|
||||
function buildPageRule(pageLayout: DocPageLayout): string {
|
||||
@ -15,257 +12,147 @@ function buildPageRule(pageLayout: DocPageLayout): string {
|
||||
return `@page { size: ${size}; margin: 0; }`
|
||||
}
|
||||
|
||||
function buildPrintLayoutRules(pageLayout: DocPageLayout): string {
|
||||
function buildPrintStylesheet(pageLayout: DocPageLayout): string {
|
||||
const pageWidth = pageLayout.widthPx
|
||||
const pageHeight = pageLayout.heightPx
|
||||
return `
|
||||
html, body {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
background: white !important;
|
||||
width: ${pageWidth}px !important;
|
||||
min-height: 0 !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.docs-print-root {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: ${pageWidth}px !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
[data-docs-page-stack] {
|
||||
position: relative !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
transform: none !important;
|
||||
transform-origin: top left !important;
|
||||
width: ${pageWidth}px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.ultidrive-docs-page {
|
||||
width: ${pageWidth}px !important;
|
||||
height: ${pageHeight}px !important;
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
${buildPageRule(pageLayout)}
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: white;
|
||||
}
|
||||
.docs-print-page {
|
||||
width: ${pageWidth}px;
|
||||
height: ${pageHeight}px;
|
||||
overflow: hidden;
|
||||
break-after: page;
|
||||
page-break-after: always;
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.ultidrive-docs-page:last-child {
|
||||
}
|
||||
.docs-print-page:last-child {
|
||||
break-after: auto;
|
||||
page-break-after: auto;
|
||||
}
|
||||
|
||||
.docs-body-margin-mask {
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
.docs-hf-chrome,
|
||||
.docs-graphic-handle,
|
||||
.docs-graphic-rotate-handle,
|
||||
.docs-graphic-outline,
|
||||
.docs-graphic-snap-guides {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.docs-hf-band .docs-region-editor-root {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
`
|
||||
}
|
||||
.docs-print-page img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
type StyleRestore = () => void
|
||||
|
||||
function patchInlineStyle(el: HTMLElement, property: string, value: string): StyleRestore {
|
||||
const previous = el.style.getPropertyValue(property)
|
||||
const priority = el.style.getPropertyPriority(property)
|
||||
el.style.setProperty(property, value)
|
||||
return () => {
|
||||
if (previous) {
|
||||
el.style.setProperty(property, previous, priority)
|
||||
} else {
|
||||
el.style.removeProperty(property)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Collapse on-screen page gaps and zoom offsets so print matches page size. */
|
||||
function adjustPageStackForPrint(
|
||||
stack: HTMLElement,
|
||||
pageLayout: DocPageLayout,
|
||||
pageCount: number
|
||||
): StyleRestore {
|
||||
const restores: StyleRestore[] = []
|
||||
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
|
||||
|
||||
restores.push(patchInlineStyle(stack, "transform", "none"))
|
||||
restores.push(patchInlineStyle(stack, "left", "0"))
|
||||
restores.push(patchInlineStyle(stack, "width", `${pageWidth}px`))
|
||||
|
||||
let wrapper = stack.parentElement
|
||||
while (wrapper && !wrapper.classList.contains("ultidrive-docs-canvas")) {
|
||||
restores.push(patchInlineStyle(wrapper, "width", `${pageWidth}px`))
|
||||
restores.push(patchInlineStyle(wrapper, "height", "auto"))
|
||||
restores.push(patchInlineStyle(wrapper, "min-height", "0"))
|
||||
restores.push(patchInlineStyle(wrapper, "padding-top", "0"))
|
||||
restores.push(patchInlineStyle(wrapper, "padding-bottom", "0"))
|
||||
restores.push(patchInlineStyle(wrapper, "margin-left", "0"))
|
||||
restores.push(patchInlineStyle(wrapper, "margin-right", "0"))
|
||||
wrapper = wrapper.parentElement
|
||||
}
|
||||
|
||||
const stackHeight = parseFloat(stack.style.height)
|
||||
if (Number.isFinite(stackHeight) && gapTotal > 0) {
|
||||
restores.push(patchInlineStyle(stack, "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)
|
||||
const nextTop = top - pageIndex * DOCS_PAGE_GAP_PX
|
||||
if (nextTop !== top) {
|
||||
restores.push(patchInlineStyle(el, "top", `${nextTop}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) {
|
||||
restores.push(
|
||||
patchInlineStyle(spacer, "height", `${height - DOCS_PAGE_GAP_PX}px`)
|
||||
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) resolve(blob)
|
||||
else reject(new Error("Impossible de convertir la page en image"))
|
||||
},
|
||||
"image/jpeg",
|
||||
0.92
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const surface = stack.querySelector<HTMLElement>(".ultidrive-docs-editor-surface")
|
||||
if (surface) {
|
||||
const surfaceHeight = parseFloat(surface.style.height)
|
||||
if (Number.isFinite(surfaceHeight) && gapTotal > 0) {
|
||||
restores.push(
|
||||
patchInlineStyle(surface, "height", `${surfaceHeight - gapTotal}px`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (let i = restores.length - 1; i >= 0; i -= 1) {
|
||||
restores[i]()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildDynamicPrintStyles(
|
||||
pageLayout: DocPageLayout,
|
||||
paragraphStyles: DocParagraphStylesCatalog
|
||||
): string {
|
||||
return [
|
||||
buildPageRule(pageLayout),
|
||||
buildPrintLayoutRules(pageLayout),
|
||||
buildParagraphStylesCss(paragraphStyles, ".ultidrive-richtext-editor, .ultidrive-richtext-region-editor"),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function injectPrintStyles(
|
||||
pageLayout: DocPageLayout,
|
||||
paragraphStyles: DocParagraphStylesCatalog
|
||||
): () => void {
|
||||
const existing = document.getElementById(PRINT_STYLE_ID)
|
||||
existing?.remove()
|
||||
|
||||
const style = document.createElement("style")
|
||||
style.id = PRINT_STYLE_ID
|
||||
style.textContent = buildDynamicPrintStyles(pageLayout, paragraphStyles)
|
||||
document.head.appendChild(style)
|
||||
|
||||
return () => {
|
||||
style.remove()
|
||||
}
|
||||
}
|
||||
|
||||
function copyDocumentStyles(targetDoc: Document): void {
|
||||
document.querySelectorAll('style, link[rel="stylesheet"]').forEach((node) => {
|
||||
targetDoc.head.appendChild(node.cloneNode(true))
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForDocumentReady(doc: Document): Promise<void> {
|
||||
const links = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'))
|
||||
await Promise.all(
|
||||
links.map(
|
||||
(link) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const sheet = link as HTMLLinkElement
|
||||
if (sheet.sheet) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
link.addEventListener("load", () => resolve(), { once: true })
|
||||
link.addEventListener("error", () => resolve(), { once: true })
|
||||
})
|
||||
)
|
||||
)
|
||||
if (doc.fonts?.ready) {
|
||||
await doc.fonts.ready
|
||||
}
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
|
||||
function removePrintIframe(): void {
|
||||
document.getElementById(PRINT_IFRAME_ID)?.remove()
|
||||
}
|
||||
|
||||
async function printStackInIframe(
|
||||
stack: HTMLElement,
|
||||
snapshot: DocsExportSnapshot
|
||||
async function mountPrintDocument(
|
||||
doc: Document,
|
||||
canvases: HTMLCanvasElement[],
|
||||
pageLayout: DocPageLayout
|
||||
): Promise<() => void> {
|
||||
const pageWidth = pageLayout.widthPx
|
||||
const pageHeight = pageLayout.heightPx
|
||||
const objectUrls: string[] = []
|
||||
|
||||
doc.documentElement.lang = "fr"
|
||||
doc.head.replaceChildren()
|
||||
doc.body.replaceChildren()
|
||||
|
||||
const style = doc.createElement("style")
|
||||
style.textContent = buildPrintStylesheet(pageLayout)
|
||||
doc.head.appendChild(style)
|
||||
|
||||
for (const canvas of canvases) {
|
||||
const blob = await canvasToBlob(canvas)
|
||||
const url = URL.createObjectURL(blob)
|
||||
objectUrls.push(url)
|
||||
|
||||
const page = doc.createElement("div")
|
||||
page.className = "docs-print-page"
|
||||
page.style.width = `${pageWidth}px`
|
||||
page.style.height = `${pageHeight}px`
|
||||
|
||||
const img = doc.createElement("img")
|
||||
img.alt = ""
|
||||
img.width = pageWidth
|
||||
img.height = pageHeight
|
||||
img.src = url
|
||||
|
||||
page.appendChild(img)
|
||||
doc.body.appendChild(page)
|
||||
await img.decode().catch(() => undefined)
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const url of objectUrls) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function printPageCanvasesInIframe(
|
||||
canvases: HTMLCanvasElement[],
|
||||
pageLayout: DocPageLayout
|
||||
): Promise<void> {
|
||||
if (canvases.length === 0) {
|
||||
throw new Error("Aucune page à imprimer")
|
||||
}
|
||||
|
||||
removePrintIframe()
|
||||
|
||||
const iframe = document.createElement("iframe")
|
||||
iframe.id = PRINT_IFRAME_ID
|
||||
iframe.setAttribute("aria-hidden", "true")
|
||||
iframe.style.cssText =
|
||||
"position:fixed;right:0;bottom:0;width:0;height:0;border:0;opacity:0;pointer-events:none"
|
||||
"position:fixed;left:-100000px;top:0;width:1px;height:1px;border:0;opacity:0;pointer-events:none"
|
||||
|
||||
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 fenêtre d'impression"))
|
||||
}
|
||||
iframe.onerror = () => reject(new Error("Impossible de préparer la fenêtre d'impression"))
|
||||
})
|
||||
|
||||
iframe.src = "about:blank"
|
||||
document.body.appendChild(iframe)
|
||||
|
||||
const doc = iframe.contentDocument
|
||||
const doc = await docReady
|
||||
const win = iframe.contentWindow
|
||||
if (!doc || !win) {
|
||||
if (!win) {
|
||||
removePrintIframe()
|
||||
throw new Error("Impossible de préparer la fenêtre d'impression")
|
||||
}
|
||||
|
||||
copyDocumentStyles(doc)
|
||||
let revokeObjectUrls = () => {}
|
||||
try {
|
||||
revokeObjectUrls = await mountPrintDocument(doc, canvases, pageLayout)
|
||||
|
||||
const printStyle = doc.createElement("style")
|
||||
printStyle.textContent = buildDynamicPrintStyles(
|
||||
snapshot.pageLayout,
|
||||
snapshot.paragraphStyles
|
||||
)
|
||||
doc.head.appendChild(printStyle)
|
||||
|
||||
doc.documentElement.classList.add("docs-printing")
|
||||
|
||||
const root = doc.createElement("div")
|
||||
root.className = "docs-print-root"
|
||||
root.appendChild(stack.cloneNode(true))
|
||||
doc.body.replaceChildren(root)
|
||||
|
||||
await waitForDocumentReady(doc)
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
if (doc.fonts?.ready) {
|
||||
await doc.fonts.ready
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
win.removeEventListener("afterprint", onAfterPrint)
|
||||
revokeObjectUrls()
|
||||
removePrintIframe()
|
||||
}
|
||||
|
||||
@ -292,57 +179,24 @@ async function printStackInIframe(
|
||||
}
|
||||
}, 2000)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForPrintLayout(): Promise<void> {
|
||||
if (typeof document !== "undefined" && document.fonts?.ready) {
|
||||
await document.fonts.ready
|
||||
} catch (error) {
|
||||
revokeObjectUrls()
|
||||
removePrintIframe()
|
||||
throw error
|
||||
}
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
|
||||
export type DocsPrintMode = "print" | "pdf-capture"
|
||||
|
||||
/** @deprecated Live DOM is no longer mutated; kept for PDF export compatibility. */
|
||||
export async function prepareDocsPrintEnvironment(
|
||||
snapshot: DocsExportSnapshot,
|
||||
mode: DocsPrintMode = "print"
|
||||
_snapshot: DocsExportSnapshot,
|
||||
_mode: DocsPrintMode = "print"
|
||||
): Promise<() => void> {
|
||||
const root = document.documentElement
|
||||
root.classList.add("docs-printing")
|
||||
if (mode === "pdf-capture") {
|
||||
root.classList.add("docs-pdf-capture")
|
||||
}
|
||||
|
||||
const removeStyles = injectPrintStyles(snapshot.pageLayout, snapshot.paragraphStyles)
|
||||
|
||||
const stack = snapshot.getPageStackElement()
|
||||
const restoreStackLayout =
|
||||
stack != null
|
||||
? adjustPageStackForPrint(stack, snapshot.pageLayout, snapshot.pageCount)
|
||||
: () => {}
|
||||
|
||||
await waitForPrintLayout()
|
||||
|
||||
return () => {
|
||||
restoreStackLayout()
|
||||
root.classList.remove("docs-printing", "docs-pdf-capture")
|
||||
removeStyles()
|
||||
}
|
||||
return () => {}
|
||||
}
|
||||
|
||||
export async function printDocsDocument(snapshot: DocsExportSnapshot): Promise<void> {
|
||||
const cleanup = await prepareDocsPrintEnvironment(snapshot, "print")
|
||||
|
||||
try {
|
||||
const stack = snapshot.getPageStackElement()
|
||||
if (!stack) {
|
||||
window.print()
|
||||
return
|
||||
}
|
||||
|
||||
await printStackInIframe(stack, snapshot)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
const canvases = await captureDocsPagesFromCanvas(snapshot, { scale: 2 })
|
||||
await printPageCanvasesInIframe(canvases, snapshot.pageLayout)
|
||||
}
|
||||
|
||||
@ -176,12 +176,13 @@ export function useDocsFileMenu({
|
||||
const handlePrint = useCallback(async () => {
|
||||
const snapshot = getExportSnapshot()
|
||||
if (!snapshot) {
|
||||
window.print()
|
||||
toast.error("Impossible d'imprimer le document")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await printDocsDocument(snapshot)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("[docs] print failed", error)
|
||||
toast.error("Impossible d'imprimer le document")
|
||||
}
|
||||
}, [getExportSnapshot])
|
||||
|
||||
@ -99,7 +99,7 @@
|
||||
"fflate": "^0.8.3",
|
||||
"fractional-indexing": "^3.2.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"html2canvas-pro": "^2.0.4",
|
||||
"idb": "^8.0.3",
|
||||
"input-otp": "1.4.2",
|
||||
"jspdf": "^4.2.1",
|
||||
|
||||
@ -245,9 +245,9 @@ importers:
|
||||
fuse.js:
|
||||
specifier: ^7.3.0
|
||||
version: 7.3.0
|
||||
html2canvas:
|
||||
specifier: ^1.4.1
|
||||
version: 1.4.1
|
||||
html2canvas-pro:
|
||||
specifier: ^2.0.4
|
||||
version: 2.0.4
|
||||
idb:
|
||||
specifier: ^8.0.3
|
||||
version: 8.0.3
|
||||
@ -2720,6 +2720,10 @@ packages:
|
||||
hash.js@1.1.7:
|
||||
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
|
||||
|
||||
html2canvas-pro@2.0.4:
|
||||
resolution: {integrity: sha512-tfL8XNvuITvYQJKgAx4bvANauuLKc88C+ZSZt7HZJveqQBWjBDtkqs/It06UzlqbM+sSq7Cv45rFbuUxOFgmow==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@ -5981,10 +5985,16 @@ snapshots:
|
||||
inherits: 2.0.4
|
||||
minimalistic-assert: 1.0.1
|
||||
|
||||
html2canvas-pro@2.0.4:
|
||||
dependencies:
|
||||
css-line-break: 2.1.0
|
||||
text-segmentation: 1.0.3
|
||||
|
||||
html2canvas@1.4.1:
|
||||
dependencies:
|
||||
css-line-break: 2.1.0
|
||||
text-segmentation: 1.0.3
|
||||
optional: true
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
|
||||
@ -51,7 +51,6 @@
|
||||
|
||||
.docs-printing.docs-pdf-capture .ultidrive-docs-page {
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.docs-printing.docs-pdf-capture .docs-body-margin-mask {
|
||||
@ -59,8 +58,7 @@
|
||||
}
|
||||
|
||||
.docs-printing.docs-pdf-capture .docs-page-gap-band,
|
||||
.docs-printing.docs-pdf-capture .docs-page-inter-margin-gutter,
|
||||
.docs-printing.docs-pdf-capture .docs-page-rim {
|
||||
.docs-printing.docs-pdf-capture .docs-page-inter-margin-gutter {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user