impr(screenshot): switch to modern-screenshot for enhancements (@byseif21) (#6884)

Switching the screenshot library from html2canvas to modern-screenshot.
for both visual for users and some technical/codebase benefits.

### Visual Improvements :
 * Background css filters now shows in the screenshot. 
   fix: #6862 , 
        #1613 ,

https://github.com/monkeytypegame/monkeytype/issues/6249#issuecomment-2651215569
* Sharper, higher-quality screenshots noticeably especially on high-DPI
screens.
* Backgrounds now render correctly on small screens that were previously
missing on mobile or small viewports, now included and properly scaled.
* Previously, with extra height e.g input history opened, the background
failed to cover everything even when it should have.
* The screenshot now more closely matches what users actually see across
devices and layouts.

### Non-Visual (Technical/Codebase) Improvements :
* Supporting modern css makes us now able to use css for the heatmap
instead of the JS.
     #5892 ,
      #5879
  * Reduced bundle size: Dropping html2canvas and its dependencies.
  * Up-to-date library, easier future improvements.

---------

Co-authored-by: Samuel Hautamäki <70753342+SirObby@users.noreply.github.com>
Co-authored-by: samuelhautamaki <samuelhautamaki@noreply.codeberg.org>
Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
Seif Soliman 2025-08-20 21:39:15 +03:00 committed by GitHub
parent a1293e79aa
commit e6519b166c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 125 additions and 82 deletions

View file

@ -6,7 +6,6 @@ module.exports = {
globals: {
$: "readonly",
jQuery: "readonly",
html2canvas: "readonly",
ClipboardItem: "readonly",
grecaptcha: "readonly",
},

View file

@ -96,13 +96,13 @@
"firebase": "12.0.0",
"hangul-js": "0.2.6",
"howler": "2.2.3",
"html2canvas": "1.4.1",
"idb": "8.0.3",
"jquery": "3.7.1",
"jquery-color": "2.2.0",
"jquery.easing": "1.4.1",
"konami": "1.7.0",
"lz-ts": "1.1.2",
"modern-screenshot": "4.6.5",
"object-hash": "3.0.0",
"slim-select": "2.9.2",
"stemmer": "2.0.1",

View file

@ -1,17 +1,12 @@
@keyframes loader {
0% {
width: 0;
left: 0;
transform: translateX(-100%) scaleX(0);
}
50% {
width: 100%;
left: 0;
transform: translateX(0) scaleX(1);
}
100% {
width: 0;
left: 100%;
transform: translateX(100%) scaleX(0);
}
}

View file

@ -1,5 +1,5 @@
const element = $("#backgroundLoader");
let timeout: NodeJS.Timeout | null = null;
let visible = false;
function clearTimeout(): void {
@ -9,17 +9,23 @@ function clearTimeout(): void {
}
}
export function show(): void {
export function show(instant = false): void {
if (visible) return;
timeout = setTimeout(() => {
$("#backgroundLoader").stop(true, true).show();
}, 125);
visible = true;
if (instant) {
element.stop(true, true).show();
visible = true;
} else {
timeout = setTimeout(() => {
element.stop(true, true).show();
}, 125);
visible = true;
}
}
export function hide(): void {
if (!visible) return;
clearTimeout();
$("#backgroundLoader").stop(true, true).fadeOut(125);
element.stop(true, true).fadeOut(125);
visible = false;
}

View file

@ -12,10 +12,6 @@ import { getHtmlByUserFlags } from "../controllers/user-flag-controller";
import * as Notifications from "../elements/notifications";
import { convertRemToPixels } from "../utils/numbers";
async function gethtml2canvas(): Promise<typeof import("html2canvas").default> {
return (await import("html2canvas")).default;
}
let revealReplay = false;
let revertCookie = false;
@ -48,15 +44,16 @@ function revert(): void {
}
}
let firefoxClipboardNotificatoinShown = false;
let firefoxClipboardNotificationShown = false;
/**
* Prepares UI, generates screenshot canvas using html2canvas, and reverts UI changes.
* Prepares UI, generates screenshot canvas using modern-screenshot, and reverts UI changes.
* Returns the generated canvas element or null on failure.
* Handles its own loader and basic error notifications for canvas generation.
*/
async function generateCanvas(): Promise<HTMLCanvasElement | null> {
Loader.show();
const { domToCanvas } = await import("modern-screenshot");
Loader.show(true);
if (!$("#resultReplay").hasClass("hidden")) {
revealReplay = true;
@ -110,7 +107,7 @@ async function generateCanvas(): Promise<HTMLCanvasElement | null> {
}
(document.querySelector("html") as HTMLElement).style.scrollBehavior = "auto";
window.scrollTo({ top: 0, behavior: "instant" as ScrollBehavior }); // Use instant scroll
window.scrollTo({ top: 0, behavior: "auto" });
// --- Target Element Calculation ---
const src = $("#result .wrapper");
@ -120,40 +117,117 @@ async function generateCanvas(): Promise<HTMLCanvasElement | null> {
revert();
return null;
}
// Ensure offset calculations happen *after* potential layout shifts from UI prep
await new Promise((resolve) => setTimeout(resolve, 50)); // Small delay for render updates
await Misc.sleep(50); // Small delay for render updates
const sourceX = src.offset()?.left ?? 0;
const sourceY = src.offset()?.top ?? 0;
const sourceWidth = src.outerWidth(true) as number;
const sourceHeight = src.outerHeight(true) as number;
const paddingX = convertRemToPixels(2);
const paddingY = convertRemToPixels(2);
// --- Canvas Generation ---
try {
const paddingX = convertRemToPixels(2);
const paddingY = convertRemToPixels(2);
// Compute full-document render size to keep the target area in frame on small viewports
const root = document.documentElement;
const { scrollWidth, clientWidth, scrollHeight, clientHeight } = root;
const targetWidth = Math.max(scrollWidth, clientWidth);
const targetHeight = Math.max(scrollHeight, clientHeight);
const canvas = await (
await gethtml2canvas()
)(document.body, {
// Target the HTML root to include .customBackground
const fullCanvas = await domToCanvas(root, {
backgroundColor: await ThemeColors.get("bg"),
width: sourceWidth + paddingX * 2,
height: sourceHeight + paddingY * 2,
x: sourceX - paddingX,
y: sourceY - paddingY,
logging: false, // Suppress html2canvas logs in console
useCORS: true, // May be needed if user flags/icons are external
// Sharp output
scale: window.devicePixelRatio ?? 1,
style: {
width: `${targetWidth}px`,
height: `${targetHeight}px`,
overflow: "hidden", // for scrollbar in small viewports
},
// Fetch (for custom background URLs)
fetch: {
requestInit: { mode: "cors", credentials: "omit" },
bypassingCache: true,
},
// skipping hidden elements (THAT IS SO IMPORTANT!)
filter: (el: Node): boolean => {
if (!(el instanceof HTMLElement)) return true;
const cs = getComputedStyle(el);
return !(el.classList.contains("hidden") || cs.display === "none");
},
// Normalize the background layer so its negative z-index doesn't get hidden
onCloneEachNode: (cloned) => {
if (cloned instanceof HTMLElement) {
const el = cloned;
if (el.classList.contains("customBackground")) {
el.style.zIndex = "0";
el.style.width = `${targetWidth}px`;
el.style.height = `${targetHeight}px`;
// for the inner image scales
const img = el.querySelector("img");
if (img) {
// (<= 720px viewport width) wpm & acc text wrapper!!
if (window.innerWidth <= 720) {
img.style.transform = "translateY(20vh)";
img.style.height = "100%";
} else {
img.style.width = "100%"; // safety nothing more
img.style.height = "100%"; // for image fit full screen even when words history is opened with many lines
}
}
}
}
},
});
revert(); // Revert UI *after* canvas is successfully generated
// Scale and create output canvas
const scale = fullCanvas.width / targetWidth;
const paddedWidth = sourceWidth + paddingX * 2;
const paddedHeight = sourceHeight + paddingY * 2;
const scaledPaddedWCanvas = Math.round(paddedWidth * scale);
const scaledPaddedHCanvas = Math.round(paddedHeight * scale);
const scaledPaddedWForCrop = Math.ceil(paddedWidth * scale);
const scaledPaddedHForCrop = Math.ceil(paddedHeight * scale);
const canvas = document.createElement("canvas");
canvas.width = scaledPaddedWCanvas;
canvas.height = scaledPaddedHCanvas;
const ctx = canvas.getContext("2d");
if (!ctx) {
Notifications.add("Failed to get canvas context for screenshot", -1);
return null;
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
// Calculate crop coordinates with proper clamping
const cropX = Math.max(0, Math.floor((sourceX - paddingX) * scale));
const cropY = Math.max(0, Math.floor((sourceY - paddingY) * scale));
const cropW = Math.min(scaledPaddedWForCrop, fullCanvas.width - cropX);
const cropH = Math.min(scaledPaddedHForCrop, fullCanvas.height - cropY);
ctx.drawImage(
fullCanvas,
cropX,
cropY,
cropW,
cropH,
0,
0,
canvas.width,
canvas.height
);
return canvas;
} catch (e) {
Notifications.add(
Misc.createErrorMessage(e, "Error creating screenshot canvas"),
-1
);
revert(); // Ensure UI is reverted on error
return null;
} finally {
revert(); // Ensure UI is reverted on both success and error
}
}
@ -192,9 +266,9 @@ export async function copyToClipboard(): Promise<void> {
// Firefox specific message (only show once)
if (
navigator.userAgent.toLowerCase().includes("firefox") &&
!firefoxClipboardNotificatoinShown
!firefoxClipboardNotificationShown
) {
firefoxClipboardNotificatoinShown = true;
firefoxClipboardNotificationShown = true;
Notifications.add(
"On Firefox you can enable the asyncClipboard.clipboardItem permission in about:config to enable copying straight to the clipboard",
0,

47
pnpm-lock.yaml generated
View file

@ -321,9 +321,6 @@ importers:
howler:
specifier: 2.2.3
version: 2.2.3
html2canvas:
specifier: 1.4.1
version: 1.4.1
idb:
specifier: 8.0.3
version: 8.0.3
@ -342,6 +339,9 @@ importers:
lz-ts:
specifier: 1.1.2
version: 1.1.2
modern-screenshot:
specifier: 4.6.5
version: 4.6.5
object-hash:
specifier: 3.0.0
version: 3.0.0
@ -3661,10 +3661,6 @@ packages:
bare-events:
optional: true
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==}
@ -4341,9 +4337,6 @@ packages:
resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
engines: {node: '>=4'}
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
css-select@5.1.0:
resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==}
@ -5621,10 +5614,6 @@ packages:
engines: {node: '>=6'}
hasBin: true
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
htmlparser2@5.0.1:
resolution: {integrity: sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==}
@ -6935,6 +6924,9 @@ packages:
mobx@6.13.1:
resolution: {integrity: sha512-ekLRxgjWJr8hVxj9ZKuClPwM/iHckx3euIJ3Np7zLVNtqJvfbbq7l370W/98C8EabdQ1pB5Jd3BbDWxJPNnaOg==}
modern-screenshot@4.6.5:
resolution: {integrity: sha512-0sDePJ9ssXWDO7V+yW9lwAxAu8jmVp4CXlBbjskSqrDxkIrcZO2EGqwD2mLtfTTinqZjmP4X/V6INOvNM1K7CQ==}
module-definition@6.0.0:
resolution: {integrity: sha512-sEGP5nKEXU7fGSZUML/coJbrO+yQtxcppDAYWRE9ovWsTbFoUHB2qDUx564WUzDaBHXsD46JBbIK5WVTwCyu3w==}
engines: {node: '>=18'}
@ -8727,9 +8719,6 @@ packages:
text-hex@1.0.0:
resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==}
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==}
@ -9193,9 +9182,6 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
hasBin: true
@ -13134,8 +13120,6 @@ snapshots:
bare-events: 2.6.0
optional: true
base64-arraybuffer@1.0.2: {}
base64-js@1.5.1: {}
basic-auth-connect@1.0.0: {}
@ -13893,10 +13877,6 @@ snapshots:
css-color-keywords@1.0.0: {}
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
css-select@5.1.0:
dependencies:
boolbase: 1.0.0
@ -15712,11 +15692,6 @@ snapshots:
relateurl: 0.2.7
uglify-js: 3.19.1
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
htmlparser2@5.0.1:
dependencies:
domelementtype: 2.3.0
@ -17226,6 +17201,8 @@ snapshots:
mobx@6.13.1: {}
modern-screenshot@4.6.5: {}
module-definition@6.0.0:
dependencies:
ast-module-types: 6.0.0
@ -19370,10 +19347,6 @@ snapshots:
text-hex@1.0.0: {}
text-segmentation@1.0.3:
dependencies:
utrie: 1.0.2
text-table@0.2.0: {}
thenify-all@1.6.0:
@ -19846,10 +19819,6 @@ snapshots:
utils-merge@1.0.1: {}
utrie@1.0.2:
dependencies:
base64-arraybuffer: 1.0.2
uuid@10.0.0: {}
uuid@8.3.2: {}