Skip to content

Commit e6519b1

Browse files
byseif21SirObbysamuelhautamakiMiodec
authored
impr(screenshot): switch to modern-screenshot for enhancements (@byseif21) (monkeytypegame#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: monkeytypegame#6862 , monkeytypegame#1613 , monkeytypegame#6249 (comment) * 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. monkeytypegame#5892 , monkeytypegame#5879 * Reduced bundle size: Dropping html2canvas and its dependencies. * Up-to-date library, easier future improvements. --------- Co-authored-by: Samuel Hautamäki <[email protected]> Co-authored-by: samuelhautamaki <[email protected]> Co-authored-by: Miodec <[email protected]>
1 parent a1293e7 commit e6519b1

File tree

6 files changed

+126
-83
lines changed

6 files changed

+126
-83
lines changed

frontend/.eslintrc.cjs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ module.exports = {
66
globals: {
77
$: "readonly",
88
jQuery: "readonly",
9-
html2canvas: "readonly",
109
ClipboardItem: "readonly",
1110
grecaptcha: "readonly",
1211
},

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,13 @@
9696
"firebase": "12.0.0",
9797
"hangul-js": "0.2.6",
9898
"howler": "2.2.3",
99-
"html2canvas": "1.4.1",
10099
"idb": "8.0.3",
101100
"jquery": "3.7.1",
102101
"jquery-color": "2.2.0",
103102
"jquery.easing": "1.4.1",
104103
"konami": "1.7.0",
105104
"lz-ts": "1.1.2",
105+
"modern-screenshot": "4.6.5",
106106
"object-hash": "3.0.0",
107107
"slim-select": "2.9.2",
108108
"stemmer": "2.0.1",

frontend/src/styles/animations.scss

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
11
@keyframes loader {
22
0% {
3-
width: 0;
4-
left: 0;
3+
transform: translateX(-100%) scaleX(0);
54
}
6-
75
50% {
8-
width: 100%;
9-
left: 0;
6+
transform: translateX(0) scaleX(1);
107
}
11-
128
100% {
13-
width: 0;
14-
left: 100%;
9+
transform: translateX(100%) scaleX(0);
1510
}
1611
}
1712

frontend/src/ts/elements/loader.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
const element = $("#backgroundLoader");
12
let timeout: NodeJS.Timeout | null = null;
2-
33
let visible = false;
44

55
function clearTimeout(): void {
@@ -9,17 +9,23 @@ function clearTimeout(): void {
99
}
1010
}
1111

12-
export function show(): void {
12+
export function show(instant = false): void {
1313
if (visible) return;
14-
timeout = setTimeout(() => {
15-
$("#backgroundLoader").stop(true, true).show();
16-
}, 125);
17-
visible = true;
14+
15+
if (instant) {
16+
element.stop(true, true).show();
17+
visible = true;
18+
} else {
19+
timeout = setTimeout(() => {
20+
element.stop(true, true).show();
21+
}, 125);
22+
visible = true;
23+
}
1824
}
1925

2026
export function hide(): void {
2127
if (!visible) return;
2228
clearTimeout();
23-
$("#backgroundLoader").stop(true, true).fadeOut(125);
29+
element.stop(true, true).fadeOut(125);
2430
visible = false;
2531
}

frontend/src/ts/test/test-screenshot.ts

Lines changed: 101 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,6 @@ import { getHtmlByUserFlags } from "../controllers/user-flag-controller";
1212
import * as Notifications from "../elements/notifications";
1313
import { convertRemToPixels } from "../utils/numbers";
1414

15-
async function gethtml2canvas(): Promise<typeof import("html2canvas").default> {
16-
return (await import("html2canvas")).default;
17-
}
18-
1915
let revealReplay = false;
2016
let revertCookie = false;
2117

@@ -48,15 +44,16 @@ function revert(): void {
4844
}
4945
}
5046

51-
let firefoxClipboardNotificatoinShown = false;
47+
let firefoxClipboardNotificationShown = false;
5248

5349
/**
54-
* Prepares UI, generates screenshot canvas using html2canvas, and reverts UI changes.
50+
* Prepares UI, generates screenshot canvas using modern-screenshot, and reverts UI changes.
5551
* Returns the generated canvas element or null on failure.
5652
* Handles its own loader and basic error notifications for canvas generation.
5753
*/
5854
async function generateCanvas(): Promise<HTMLCanvasElement | null> {
59-
Loader.show();
55+
const { domToCanvas } = await import("modern-screenshot");
56+
Loader.show(true);
6057

6158
if (!$("#resultReplay").hasClass("hidden")) {
6259
revealReplay = true;
@@ -110,7 +107,7 @@ async function generateCanvas(): Promise<HTMLCanvasElement | null> {
110107
}
111108

112109
(document.querySelector("html") as HTMLElement).style.scrollBehavior = "auto";
113-
window.scrollTo({ top: 0, behavior: "instant" as ScrollBehavior }); // Use instant scroll
110+
window.scrollTo({ top: 0, behavior: "auto" });
114111

115112
// --- Target Element Calculation ---
116113
const src = $("#result .wrapper");
@@ -120,40 +117,117 @@ async function generateCanvas(): Promise<HTMLCanvasElement | null> {
120117
revert();
121118
return null;
122119
}
123-
// Ensure offset calculations happen *after* potential layout shifts from UI prep
124-
await new Promise((resolve) => setTimeout(resolve, 50)); // Small delay for render updates
120+
await Misc.sleep(50); // Small delay for render updates
125121

126122
const sourceX = src.offset()?.left ?? 0;
127123
const sourceY = src.offset()?.top ?? 0;
128124
const sourceWidth = src.outerWidth(true) as number;
129125
const sourceHeight = src.outerHeight(true) as number;
126+
const paddingX = convertRemToPixels(2);
127+
const paddingY = convertRemToPixels(2);
130128

131-
// --- Canvas Generation ---
132129
try {
133-
const paddingX = convertRemToPixels(2);
134-
const paddingY = convertRemToPixels(2);
135-
136-
const canvas = await (
137-
await gethtml2canvas()
138-
)(document.body, {
130+
// Compute full-document render size to keep the target area in frame on small viewports
131+
const root = document.documentElement;
132+
const { scrollWidth, clientWidth, scrollHeight, clientHeight } = root;
133+
const targetWidth = Math.max(scrollWidth, clientWidth);
134+
const targetHeight = Math.max(scrollHeight, clientHeight);
135+
136+
// Target the HTML root to include .customBackground
137+
const fullCanvas = await domToCanvas(root, {
139138
backgroundColor: await ThemeColors.get("bg"),
140-
width: sourceWidth + paddingX * 2,
141-
height: sourceHeight + paddingY * 2,
142-
x: sourceX - paddingX,
143-
y: sourceY - paddingY,
144-
logging: false, // Suppress html2canvas logs in console
145-
useCORS: true, // May be needed if user flags/icons are external
139+
// Sharp output
140+
scale: window.devicePixelRatio ?? 1,
141+
style: {
142+
width: `${targetWidth}px`,
143+
height: `${targetHeight}px`,
144+
overflow: "hidden", // for scrollbar in small viewports
145+
},
146+
// Fetch (for custom background URLs)
147+
fetch: {
148+
requestInit: { mode: "cors", credentials: "omit" },
149+
bypassingCache: true,
150+
},
151+
152+
// skipping hidden elements (THAT IS SO IMPORTANT!)
153+
filter: (el: Node): boolean => {
154+
if (!(el instanceof HTMLElement)) return true;
155+
const cs = getComputedStyle(el);
156+
return !(el.classList.contains("hidden") || cs.display === "none");
157+
},
158+
// Normalize the background layer so its negative z-index doesn't get hidden
159+
onCloneEachNode: (cloned) => {
160+
if (cloned instanceof HTMLElement) {
161+
const el = cloned;
162+
if (el.classList.contains("customBackground")) {
163+
el.style.zIndex = "0";
164+
el.style.width = `${targetWidth}px`;
165+
el.style.height = `${targetHeight}px`;
166+
// for the inner image scales
167+
const img = el.querySelector("img");
168+
if (img) {
169+
// (<= 720px viewport width) wpm & acc text wrapper!!
170+
if (window.innerWidth <= 720) {
171+
img.style.transform = "translateY(20vh)";
172+
img.style.height = "100%";
173+
} else {
174+
img.style.width = "100%"; // safety nothing more
175+
img.style.height = "100%"; // for image fit full screen even when words history is opened with many lines
176+
}
177+
}
178+
}
179+
}
180+
},
146181
});
147182

148-
revert(); // Revert UI *after* canvas is successfully generated
183+
// Scale and create output canvas
184+
const scale = fullCanvas.width / targetWidth;
185+
const paddedWidth = sourceWidth + paddingX * 2;
186+
const paddedHeight = sourceHeight + paddingY * 2;
187+
188+
const scaledPaddedWCanvas = Math.round(paddedWidth * scale);
189+
const scaledPaddedHCanvas = Math.round(paddedHeight * scale);
190+
const scaledPaddedWForCrop = Math.ceil(paddedWidth * scale);
191+
const scaledPaddedHForCrop = Math.ceil(paddedHeight * scale);
192+
193+
const canvas = document.createElement("canvas");
194+
canvas.width = scaledPaddedWCanvas;
195+
canvas.height = scaledPaddedHCanvas;
196+
const ctx = canvas.getContext("2d");
197+
if (!ctx) {
198+
Notifications.add("Failed to get canvas context for screenshot", -1);
199+
return null;
200+
}
201+
202+
ctx.imageSmoothingEnabled = true;
203+
ctx.imageSmoothingQuality = "high";
204+
205+
// Calculate crop coordinates with proper clamping
206+
const cropX = Math.max(0, Math.floor((sourceX - paddingX) * scale));
207+
const cropY = Math.max(0, Math.floor((sourceY - paddingY) * scale));
208+
const cropW = Math.min(scaledPaddedWForCrop, fullCanvas.width - cropX);
209+
const cropH = Math.min(scaledPaddedHForCrop, fullCanvas.height - cropY);
210+
211+
ctx.drawImage(
212+
fullCanvas,
213+
cropX,
214+
cropY,
215+
cropW,
216+
cropH,
217+
0,
218+
0,
219+
canvas.width,
220+
canvas.height
221+
);
149222
return canvas;
150223
} catch (e) {
151224
Notifications.add(
152225
Misc.createErrorMessage(e, "Error creating screenshot canvas"),
153226
-1
154227
);
155-
revert(); // Ensure UI is reverted on error
156228
return null;
229+
} finally {
230+
revert(); // Ensure UI is reverted on both success and error
157231
}
158232
}
159233

@@ -192,9 +266,9 @@ export async function copyToClipboard(): Promise<void> {
192266
// Firefox specific message (only show once)
193267
if (
194268
navigator.userAgent.toLowerCase().includes("firefox") &&
195-
!firefoxClipboardNotificatoinShown
269+
!firefoxClipboardNotificationShown
196270
) {
197-
firefoxClipboardNotificatoinShown = true;
271+
firefoxClipboardNotificationShown = true;
198272
Notifications.add(
199273
"On Firefox you can enable the asyncClipboard.clipboardItem permission in about:config to enable copying straight to the clipboard",
200274
0,

0 commit comments

Comments
 (0)