From 8ff6629f1451eff6d8dc3154c39f691e5d1bf9c0 Mon Sep 17 00:00:00 2001 From: Emmanuel Ogbizi Date: Thu, 19 Dec 2024 11:19:57 -0500 Subject: [PATCH] feat(export-element): add export to png feature (#1267) * feat(export-element): add export to png feature * wip: remove unused parameter * fix: remove unused dependency * fix: update target on mouse up action * wip: add image download link target * wip: use canvas to blob over data url * wip: render clone in page before downloading * fix: properly wrap content for rendering as image * fix: render image at full size before scaling for window fit --- libraries/html2canvas.ts | 4 + package.json | 3 +- pnpm-lock.yaml | 39 +++++++ scripts/export-element/index.user.ts | 146 +++++++++++++++++++++++---- 4 files changed, 169 insertions(+), 23 deletions(-) create mode 100644 libraries/html2canvas.ts diff --git a/libraries/html2canvas.ts b/libraries/html2canvas.ts new file mode 100644 index 00000000..59c8d155 --- /dev/null +++ b/libraries/html2canvas.ts @@ -0,0 +1,4 @@ +// rigmarole to get html2canvas types to work with typescript imports +import { default as Html2Canvas } from "html2canvas"; +const html2canvasFn = require("html2canvas") as typeof Html2Canvas; +export { html2canvasFn as html2canvas }; diff --git a/package.json b/package.json index 67379ad2..edb581b7 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ } }, "dependencies": { - "cheerio": "git+http://github.com/iamogbz/cheerio-web.git" + "cheerio": "git+http://github.com/iamogbz/cheerio-web.git", + "html2canvas": "^1.4.1" }, "devDependencies": { "@commitlint/cli": "^19.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38a02c54..8fa9e6ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: cheerio: specifier: git+http://github.com/iamogbz/cheerio-web.git version: git+http://github.com/iamogbz/cheerio-web.git#4407cd49f753558d2d19a983079155dd7e026e6b + html2canvas: + specifier: ^1.4.1 + version: 1.4.1 devDependencies: '@commitlint/cli': specifier: ^19.6.1 @@ -1227,6 +1230,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1574,6 +1581,9 @@ packages: resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} engines: {node: '>=12'} + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + css-select@2.0.2: resolution: {integrity: sha512-dSpYaDVoWaELjvZ3mS6IKZM/y2PMPa/XYoEfYNZePL4U/XgyxZNroHEHReDx/d+VgXh9VbCTtFqLkFbmeqeaRQ==} @@ -2420,6 +2430,10 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + htmlparser2@3.10.1: resolution: {integrity: sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==} @@ -4246,6 +4260,9 @@ packages: resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==} engines: {node: '>=8'} + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -4508,6 +4525,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -6211,6 +6231,8 @@ snapshots: balanced-match@1.0.2: {} + base64-arraybuffer@1.0.2: {} + base64-js@1.5.1: {} basic-auth@2.0.1: @@ -6588,6 +6610,10 @@ snapshots: dependencies: type-fest: 1.4.0 + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + css-select@2.0.2: dependencies: boolbase: 1.0.0 @@ -7617,6 +7643,11 @@ snapshots: html-escaper@2.0.2: {} + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + htmlparser2@3.10.1: dependencies: domelementtype: 1.3.1 @@ -9639,6 +9670,10 @@ snapshots: text-extensions@2.4.0: {} + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + text-table@0.2.0: {} then-request@6.0.2: @@ -9910,6 +9945,10 @@ snapshots: util-deprecate@1.0.2: {} + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.1.0: diff --git a/scripts/export-element/index.user.ts b/scripts/export-element/index.user.ts index ad3c666d..b6393dd0 100644 --- a/scripts/export-element/index.user.ts +++ b/scripts/export-element/index.user.ts @@ -1,19 +1,25 @@ +import { html2canvas } from "libraries/html2canvas"; + (function () { "use strict"; + const transparentColor = "transparent"; + const Types = Object.freeze({ PDF: "pdf", - // PNG: "png", // TODO: add png support + PNG: "png", }); + type ExportType = (typeof Types)[keyof typeof Types]; + const params: { + matchWords: string[]; eventTarget: EventTarget | null; target: Node | null; - type: (typeof Types)[keyof typeof Types]; } = { + matchWords: [], eventTarget: null, target: null, - type: Types.PDF, }; /** @@ -41,28 +47,35 @@ } // Add context menu to user script - document.addEventListener("contextmenu", function (event) { + function handleUpdateTarget(event: MouseEvent) { params.eventTarget = event.target; + const selection = window.getSelection?.() ?? + document.getSelection?.() ?? { + anchorNode: event.target as Node, + focusNode: event.target as Node, + toString: (): string => + Object.getOwnPropertyDescriptor( + document, + "selection", + )?.value?.createRange?.()?.text || "", + }; - const selectedText = - window.getSelection?.()?.toString() ?? - document.getSelection?.()?.toString() ?? - Object.getOwnPropertyDescriptor( - document, - "selection", - )?.value?.createRange?.()?.text ?? - ""; + const selectedText = selection.toString(); const matchText = sanitizeText(selectedText); - const matchWords = matchText.split(" "); + params.matchWords = matchText.split(" "); - params.target = findLastNodeWithPredicate(event.target as Node, (node) => { - const nodeText = sanitizeText((node as HTMLElement).innerText); - return containsAll(nodeText, matchWords); - }); - }); + params.target = findLastNodeWithPredicate( + selection.anchorNode ?? selection.focusNode, + (node) => { + const nodeText = sanitizeText((node as HTMLElement).innerText); + return containsAll(nodeText, params.matchWords); + }, + ); + } + document.addEventListener("contextmenu", handleUpdateTarget); + document.addEventListener("mouseup", handleUpdateTarget); function findBackgroundColor(element: Element) { - const transparentColor = "transparent"; const backgroundElement = findLastNodeWithPredicate(element, (node) => { // return true if the node is an element with a background-color that is not transparent if (!(node instanceof Element)) { @@ -172,21 +185,110 @@ }); } + /** + * Clone node as image and trigger download + */ + async function cloneAndDownloadImage(node: Node) { + const clone = cloneNodeWithStyles(window, node); + + const modalContent = document.createElement("div"); + modalContent.style.backgroundColor = findBackgroundColor(node as Element); + modalContent.style.cursor = "pointer"; + modalContent.style.display = "block"; + modalContent.style.height = "fit-content"; + modalContent.style.outlineColor = modalContent.style.backgroundColor; + modalContent.style.outlineStyle = "solid"; + modalContent.style.outlineWidth = "1vw"; + modalContent.style.margin = "1vw auto"; + modalContent.style.position = "relative"; + modalContent.style.width = "fit-content"; + + const modalWrapper = document.createElement("div"); + modalWrapper.style.alignItems = "center"; + modalWrapper.style.backdropFilter = "blur(10px)"; + modalWrapper.style.backgroundColor = `color-mix(in srgb, ${clone.style.backgroundColor}, ${transparentColor} 50%)`; + modalWrapper.style.display = "block"; + modalWrapper.style.height = "100vh"; + modalWrapper.style.justifyContent = "center"; + modalWrapper.style.left = "0"; + modalWrapper.style.opacity = "1"; + modalWrapper.style.overflow = "auto"; + modalWrapper.style.position = "fixed"; + modalWrapper.style.top = "0"; + modalWrapper.style.userSelect = "none"; + modalWrapper.style.width = "100vw"; + modalWrapper.style.visibility = "visible"; + modalWrapper.style.zIndex = `${Number.MIN_SAFE_INTEGER}`; + + // place the clone in a hidden div to enable html2canvas to render it + modalContent.appendChild(clone); + modalWrapper.appendChild(modalContent); + document.body.appendChild(modalWrapper); + + // scale and position clone to fit the window + const positionPreview = () => { + const scale = { + x: window.innerWidth / clone.clientWidth, + y: window.innerHeight / clone.clientHeight, + }; + modalContent.style.scale = `${Math.min(1, scale.x, scale.y)}`; + modalContent.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "center", + }); + modalWrapper.style.zIndex = `${Number.MAX_SAFE_INTEGER}`; + }; + + // https://stackoverflow.com/questions/3906142/how-to-save-a-png-from-javascript-variable + const canvas = await html2canvas(modalContent); + positionPreview(); // wait for clone to be rendered before positioning it + const imageType = "image/png"; + const dataBlob = await new Promise((resolve) => { + canvas.toBlob((blob) => (blob ? resolve(blob) : undefined), imageType); + }); + const dataURI = URL.createObjectURL(dataBlob); // canvas.toDataURL(imageType); + const filenameGlue = "-"; + const filename = `${["screenshot", ...params.matchWords.slice(0, 10)] + .map((w) => w.trim()) + .filter(Boolean) + .join(filenameGlue) + .toLowerCase() + .replace(/[/\\?%*:|"<>]+/g, filenameGlue) + .replace(/[-]+/g, filenameGlue)}.${imageType.split("/")[1]}`; + + const imageLink = document.createElement("a"); + imageLink.target = "_blank"; + imageLink.href = dataURI; + imageLink.download = filename; + + modalWrapper.addEventListener("click", () => modalWrapper.remove()); + const downloadImage = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + return imageLink.click(); + }; + clone.addEventListener("click", downloadImage); + } + /** * Print the context node as specified type */ - function printNodeAs(type: (typeof params)["type"]) { + function printNodeAs(type: ExportType) { if (params.target) { switch (type) { case Types.PDF: { return cloneAndPrintNode(params.target); } + case Types.PNG: { + return cloneAndDownloadImage(params.target); + } default: { - alert(`Unsupported type: ${type}`); + return alert(`Unsupported type: ${type}`); } } } else { - console.error("Node not found!", params.eventTarget); + console.error("Node not found!", params); } }