From 1bc08fc5e852c8e0b23ea6328929c200b77a0941 Mon Sep 17 00:00:00 2001 From: Matthew Herbst Date: Mon, 19 Aug 2024 01:29:03 -0700 Subject: [PATCH] Improve content handling for canvas and inputs (#726) --- examples/ComponentToPrint/index.tsx | 11 +---- src/hooks/useReactToPrint.ts | 17 ++++--- src/utils/appendPrintWindow.ts | 1 + src/utils/handlePrintWindowOnLoad.ts | 74 +++++++++++----------------- src/utils/logMessage.ts | 3 +- 5 files changed, 43 insertions(+), 63 deletions(-) diff --git a/examples/ComponentToPrint/index.tsx b/examples/ComponentToPrint/index.tsx index 003968d..b9e071b 100644 --- a/examples/ComponentToPrint/index.tsx +++ b/examples/ComponentToPrint/index.tsx @@ -15,11 +15,6 @@ export const ComponentToPrint = React.forwardRef(null); - const [checked, setChecked] = React.useState(false); - - const handleCheckboxOnChange = React.useCallback(() => { - setChecked(!!checked); - }, [checked]); React.useEffect(() => { const ctx = canvasEl.current?.getContext("2d"); @@ -115,11 +110,7 @@ export const ComponentToPrint = React.forwardRef Input: Checkbox - + diff --git a/src/hooks/useReactToPrint.ts b/src/hooks/useReactToPrint.ts index c386deb..7dc154d 100644 --- a/src/hooks/useReactToPrint.ts +++ b/src/hooks/useReactToPrint.ts @@ -49,19 +49,20 @@ export function useReactToPrint(options: UseReactToPrintOptions): UseReactToPrin return; } - // React components can return a bare string as a valid JSX response + // NOTE: `canvas` elements do not have their painted images copied + // https://developer.mozilla.org/en-US/docs/Web/API/Node/cloneNode const clonedContentNode = contentNode.cloneNode(true); const globalLinkNodes = document.querySelectorAll("link[rel~='stylesheet'], link[as='style']"); - const renderComponentImgNodes = (clonedContentNode as Element).querySelectorAll("img"); - const renderComponentVideoNodes = (clonedContentNode as Element).querySelectorAll("video"); + const clonedImgNodes = (clonedContentNode as Element).querySelectorAll("img"); + const clonedVideoNodes = (clonedContentNode as Element).querySelectorAll("video"); const numFonts = fonts ? fonts.length : 0; const numResourcesToLoad = (ignoreGlobalStyles ? 0 : globalLinkNodes.length) + - renderComponentImgNodes.length + - renderComponentVideoNodes.length + + clonedImgNodes.length + + clonedVideoNodes.length + numFonts; const resourcesLoaded: (Element | Font | FontFace)[] = []; const resourcesErrored: (Element | Font | FontFace)[] = []; @@ -106,10 +107,10 @@ export function useReactToPrint(options: UseReactToPrintOptions): UseReactToPrin const data: HandlePrintWindowOnLoadData = { clonedContentNode, - contentNode, + clonedImgNodes, + clonedVideoNodes, numResourcesToLoad, - renderComponentImgNodes, - renderComponentVideoNodes, + originalCanvasNodes: (contentNode as Element).querySelectorAll("canvas") } // Ensure we run `onBeforePrint` before appending the print window, which kicks off loading diff --git a/src/utils/appendPrintWindow.ts b/src/utils/appendPrintWindow.ts index d1deb17..6d20de2 100644 --- a/src/utils/appendPrintWindow.ts +++ b/src/utils/appendPrintWindow.ts @@ -3,6 +3,7 @@ import { UseReactToPrintOptions } from "../types/UseReactToPrintOptions"; import { handlePrintWindowOnLoad, HandlePrintWindowOnLoadData } from "./handlePrintWindowOnLoad"; export function appendPrintWindow( + /** The print iframe */ printWindow: HTMLIFrameElement, markLoaded: (resource: Element | Font | FontFace, errorMessages?: unknown[]) => void, data: HandlePrintWindowOnLoadData, diff --git a/src/utils/handlePrintWindowOnLoad.ts b/src/utils/handlePrintWindowOnLoad.ts index afac135..657471f 100644 --- a/src/utils/handlePrintWindowOnLoad.ts +++ b/src/utils/handlePrintWindowOnLoad.ts @@ -4,11 +4,16 @@ import { Font } from "../types/Font"; import type { UseReactToPrintOptions } from "../types/UseReactToPrintOptions"; export type HandlePrintWindowOnLoadData = { + /** The cloned content. This will be inserted into the print iframe */ clonedContentNode: Node; - contentNode: Node; + /** Cloned image nodes. Used for pre-loading */ + clonedImgNodes: never[] | NodeListOf; + /** Clones video nodes. User for pre-loading */ + clonedVideoNodes: never[] | NodeListOf; + /** The total number of resources to load. Printing will start once all have been loaded */ numResourcesToLoad: number; - renderComponentImgNodes: never[] | NodeListOf; - renderComponentVideoNodes: never[] | NodeListOf; + /** The original canvas nodes. Used to apply paints not copied when cloning the nodes */ + originalCanvasNodes: never[] | NodeListOf; }; type MarkLoaded = (resource: Element | Font | FontFace, errorMessages?: unknown[]) => void; @@ -39,10 +44,10 @@ export function handlePrintWindowOnLoad( ) { const { clonedContentNode, - contentNode, + clonedImgNodes, + clonedVideoNodes, numResourcesToLoad, - renderComponentImgNodes, - renderComponentVideoNodes, + originalCanvasNodes, } = data; const { @@ -105,15 +110,22 @@ export function handlePrintWindowOnLoad( } // Copy canvases - // NOTE: must use data from `contentNode` here as the canvass elements in - // `clonedContentNode` will not have been redrawn properly yet - const srcCanvasEls = (contentNode as Element).querySelectorAll("canvas"); const targetCanvasEls = domDoc.querySelectorAll("canvas"); - - for (let i = 0; i < srcCanvasEls.length; ++i) { - const sourceCanvas = srcCanvasEls[i]; - + for (let i = 0; i < originalCanvasNodes.length; ++i) { + // NOTE: must use original data here as the canvass elements in `clonedContentNode` will + // not have had their painted images copied properly. This is specifically mentioned in + // the [`cloneNode` docs](https://developer.mozilla.org/en-US/docs/Web/API/Node/cloneNode). + const sourceCanvas = originalCanvasNodes[i]; const targetCanvas = targetCanvasEls[i]; + + if (targetCanvas === undefined) { + logMessages({ + messages: ["A canvas element could not be copied for printing, has it loaded? `onBeforePrint` likely resolved too early.", sourceCanvas], + suppressErrors, + }); + continue; + } + const targetCanvasContext = targetCanvas.getContext("2d"); if (targetCanvasContext) { @@ -122,12 +134,12 @@ export function handlePrintWindowOnLoad( } // Pre-load images - for (let i = 0; i < renderComponentImgNodes.length; i++) { - const imgNode = renderComponentImgNodes[i]; + for (let i = 0; i < clonedImgNodes.length; i++) { + const imgNode = clonedImgNodes[i]; const imgSrc = imgNode.getAttribute("src"); if (!imgSrc) { - markLoaded(imgNode, ['Found an tag with an empty "src" attribute. This prevents pre-loading it. The is:', imgNode]); + markLoaded(imgNode, ['Found an tag with an empty "src" attribute. This prevents pre-loading it.', imgNode]); } else { // https://stackoverflow.com/questions/10240110/how-do-you-cache-an-image-in-javascript const img = new Image(); @@ -138,8 +150,8 @@ export function handlePrintWindowOnLoad( } // Pre-load videos - for (let i = 0; i < renderComponentVideoNodes.length; i++) { - const videoNode = renderComponentVideoNodes[i]; + for (let i = 0; i < clonedVideoNodes.length; i++) { + const videoNode = clonedVideoNodes[i]; videoNode.preload = 'auto'; // Hint to the browser that it should load this resource const videoPoster = videoNode.getAttribute('poster') @@ -165,32 +177,6 @@ export function handlePrintWindowOnLoad( } } - // Copy input values - // This covers most input types, though some need additional work (further down) - const inputSelector = 'input'; - const originalInputs = (contentNode as HTMLElement).querySelectorAll(inputSelector); - const copiedInputs = domDoc.querySelectorAll(inputSelector); - for (let i = 0; i < originalInputs.length; i++) { - copiedInputs[i].value = originalInputs[i].value; - } - - // Copy checkbox, radio checks - const checkedSelector = 'input[type=checkbox],input[type=radio]'; - const originalCRs = (contentNode as HTMLElement).querySelectorAll(checkedSelector); - const copiedCRs = domDoc.querySelectorAll(checkedSelector); - for (let i = 0; i < originalCRs.length; i++) { - (copiedCRs[i] as HTMLInputElement).checked = - (originalCRs[i] as HTMLInputElement).checked; - } - - // Copy select states - const selectSelector = 'select'; - const originalSelects = (contentNode as HTMLElement).querySelectorAll(selectSelector); - const copiedSelects = domDoc.querySelectorAll(selectSelector); - for (let i = 0; i < originalSelects.length; i++) { - copiedSelects[i].value = originalSelects[i].value; - } - if (!ignoreGlobalStyles) { const styleAndLinkNodes = document.querySelectorAll("style, link[rel~='stylesheet'], link[as='style']"); diff --git a/src/utils/logMessage.ts b/src/utils/logMessage.ts index f98adaf..64ee79e 100644 --- a/src/utils/logMessage.ts +++ b/src/utils/logMessage.ts @@ -7,7 +7,8 @@ type LogMessagesArgs = { suppressErrors?: boolean; } -export function logMessages({level = 'error', messages, suppressErrors = false }: LogMessagesArgs) { +/** Logs messages to the console. Uses `console.error` by default. */ +export function logMessages({ level = 'error', messages, suppressErrors = false }: LogMessagesArgs) { if (!suppressErrors) { if (level === 'error') { console.error(messages); // eslint-disable-line no-console