Skip to content

Commit

Permalink
feat(export-element): add export to png feature (#1267)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
iamogbz authored Dec 19, 2024
1 parent f03f21c commit 8ff6629
Show file tree
Hide file tree
Showing 4 changed files with 169 additions and 23 deletions.
4 changes: 4 additions & 0 deletions libraries/html2canvas.ts
Original file line number Diff line number Diff line change
@@ -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 };
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

146 changes: 124 additions & 22 deletions scripts/export-element/index.user.ts
Original file line number Diff line number Diff line change
@@ -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,
};

/**
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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<Blob>((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);
}
}

Expand Down

0 comments on commit 8ff6629

Please sign in to comment.