Skip to content

Commit

Permalink
Improve content handling for canvas and inputs (#726)
Browse files Browse the repository at this point in the history
  • Loading branch information
MatthewHerbst authored Aug 19, 2024
1 parent 26e9a52 commit 1bc08fc
Show file tree
Hide file tree
Showing 5 changed files with 43 additions and 63 deletions.
11 changes: 1 addition & 10 deletions examples/ComponentToPrint/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,6 @@ export const ComponentToPrint = React.forwardRef<HTMLDivElement | null, Componen
const { text } = props;

const canvasEl = React.useRef<HTMLCanvasElement>(null);
const [checked, setChecked] = React.useState(false);

const handleCheckboxOnChange = React.useCallback(() => {
setChecked(!!checked);
}, [checked]);

React.useEffect(() => {
const ctx = canvasEl.current?.getContext("2d");
Expand Down Expand Up @@ -115,11 +110,7 @@ export const ComponentToPrint = React.forwardRef<HTMLDivElement | null, Componen
<tr>
<td>Input: Checkbox</td>
<td>
<input
checked={checked}
onChange={handleCheckboxOnChange}
type="checkbox"
/>
<input type="checkbox" />
</td>
</tr>
<tr>
Expand Down
17 changes: 9 additions & 8 deletions src/hooks/useReactToPrint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)[] = [];
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/utils/appendPrintWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
74 changes: 30 additions & 44 deletions src/utils/handlePrintWindowOnLoad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLImageElement>;
/** Clones video nodes. User for pre-loading */
clonedVideoNodes: never[] | NodeListOf<HTMLVideoElement>;
/** The total number of resources to load. Printing will start once all have been loaded */
numResourcesToLoad: number;
renderComponentImgNodes: never[] | NodeListOf<HTMLImageElement>;
renderComponentVideoNodes: never[] | NodeListOf<HTMLVideoElement>;
/** The original canvas nodes. Used to apply paints not copied when cloning the nodes */
originalCanvasNodes: never[] | NodeListOf<HTMLCanvasElement>;
};
type MarkLoaded = (resource: Element | Font | FontFace, errorMessages?: unknown[]) => void;

Expand Down Expand Up @@ -39,10 +44,10 @@ export function handlePrintWindowOnLoad(
) {
const {
clonedContentNode,
contentNode,
clonedImgNodes,
clonedVideoNodes,
numResourcesToLoad,
renderComponentImgNodes,
renderComponentVideoNodes,
originalCanvasNodes,
} = data;

const {
Expand Down Expand Up @@ -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) {
Expand All @@ -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 <img> tag with an empty "src" attribute. This prevents pre-loading it. The <img> is:', imgNode]);
markLoaded(imgNode, ['Found an <img> 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();
Expand All @@ -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')
Expand All @@ -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']");

Expand Down
3 changes: 2 additions & 1 deletion src/utils/logMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 1bc08fc

Please sign in to comment.