Skip to content

Commit 44a67db

Browse files
torturadofehmerMiodec
authored
feat(commandline): add download screenshot command (@torturado) (monkeytypegame#6532)
### Description This Pull Request introduces the functionality to download the result screenshot directly as a PNG file, in addition to the existing option to copy it to the clipboard. This is achieved by: 1. Refactoring the screenshot logic in `test-ui.ts`. 2. Adding a new `Download screenshot` command to the result screen command list. **Changes Made:** * **`frontend/src/ts/test/test-ui.ts`**: * Created an internal function `generateScreenshotCanvas` that encapsulates UI preparation, canvas generation with `html2canvas`, and UI restoration. * Modified the exported `screenshot` function to use `generateScreenshotCanvas` and **only** handle copying the resulting Blob to the clipboard (with the fallback of opening in a new tab). * Added a new exported function `getScreenshotBlob` that uses `generateScreenshotCanvas` and returns the resulting image Blob. * **`frontend/src/ts/commandline/lists/result-screen.ts`**: * Renamed the `saveScreenshot` command to `copyScreenshot` (updating `id`, `icon`, and `alias`) to more accurately reflect its action. It still uses `TestUI.screenshot()`. * Added a new `downloadScreenshot` command (`id`, `display`, `icon`, `alias`) that: * Calls `TestUI.getScreenshotBlob()` to get the image data. * If a Blob is obtained, creates a temporary object URL. * Creates a temporary `<a>` element, sets the `href` to the object URL, and the `download` attribute with a filename (e.g., `monkeytype-result-TIMESTAMP.png`). * Simulates a click on the link to initiate the file download. * Revokes the object URL. * Displays success or error notifications. ### Checks - [ ] Adding quotes? - [ ] Make sure to include translations for the quotes in the description (or another comment) so we can verify their content. - [ ] Adding a language or a theme? - [ ] If is a language, did you edit `_list.json`, `_groups.json` and add `languages.json`? - [ ] If is a theme, did you add the theme.css? - Also please add a screenshot of the theme, it would be extra awesome if you do so! - [ ] Check if any open issues are related to this PR; if so, be sure to tag them below. - [x] Make sure the PR title follows the Conventional Commits standard. (https://www.conventionalcommits.org for more info) - [ ] Make sure to include your GitHub username prefixed with @ inside parentheses at the end of the PR title. <!-- Remember to add your username here! --> --------- Co-authored-by: fehmer <[email protected]> Co-authored-by: Miodec <[email protected]>
1 parent be62681 commit 44a67db

File tree

3 files changed

+304
-190
lines changed

3 files changed

+304
-190
lines changed

frontend/src/ts/commandline/lists/result-screen.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as TestWords from "../../test/test-words";
77
import Config from "../../config";
88
import * as PractiseWords from "../../test/practise-words";
99
import { Command, CommandsSubgroup } from "../types";
10+
import * as TestScreenshot from "../../test/test-screenshot";
1011

1112
const practiceSubgroup: CommandsSubgroup = {
1213
title: "Practice words...",
@@ -92,13 +93,27 @@ const commands: Command[] = [
9293
},
9394
},
9495
{
95-
id: "saveScreenshot",
96+
id: "copyScreenshot",
9697
display: "Copy screenshot to clipboard",
97-
icon: "fa-image",
98-
alias: "save",
98+
icon: "fa-copy",
99+
alias: "copy image clipboard",
99100
exec: (): void => {
100101
setTimeout(() => {
101-
void TestUI.screenshot();
102+
void TestScreenshot.copyToClipboard();
103+
}, 500);
104+
},
105+
available: (): boolean => {
106+
return TestUI.resultVisible;
107+
},
108+
},
109+
{
110+
id: "downloadScreenshot",
111+
display: "Download screenshot",
112+
icon: "fa-download",
113+
alias: "save image download file",
114+
exec: (): void => {
115+
setTimeout(async () => {
116+
void TestScreenshot.download();
102117
}, 500);
103118
},
104119
available: (): boolean => {
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import * as Loader from "../elements/loader";
2+
import * as Replay from "./replay";
3+
import * as Misc from "../utils/misc";
4+
import { isAuthenticated } from "../firebase";
5+
import { getActiveFunboxesWithFunction } from "./funbox/list";
6+
import * as DB from "../db";
7+
import * as ThemeColors from "../elements/theme-colors";
8+
import { format } from "date-fns/format";
9+
10+
import { getHtmlByUserFlags } from "../controllers/user-flag-controller";
11+
import * as Notifications from "../elements/notifications";
12+
import { convertRemToPixels } from "../utils/numbers";
13+
14+
async function gethtml2canvas(): Promise<typeof import("html2canvas").default> {
15+
return (await import("html2canvas")).default;
16+
}
17+
18+
let revealReplay = false;
19+
let revertCookie = false;
20+
21+
function revert(): void {
22+
Loader.hide();
23+
$("#ad-result-wrapper").removeClass("hidden");
24+
$("#ad-result-small-wrapper").removeClass("hidden");
25+
$("#testConfig").removeClass("hidden");
26+
$(".pageTest .screenshotSpacer").remove();
27+
$("#notificationCenter").removeClass("hidden");
28+
$("#commandLineMobileButton").removeClass("hidden");
29+
$(".pageTest .ssWatermark").addClass("hidden");
30+
$(".pageTest .ssWatermark").text("monkeytype.com"); // Reset watermark text
31+
$(".pageTest .buttons").removeClass("hidden");
32+
$("noscript").removeClass("hidden");
33+
$("#nocss").removeClass("hidden");
34+
$("header, footer").removeClass("invisible");
35+
$("#result").removeClass("noBalloons");
36+
$(".wordInputHighlight").removeClass("hidden");
37+
$(".highlightContainer").removeClass("hidden");
38+
if (revertCookie) $("#cookiesModal").removeClass("hidden");
39+
if (revealReplay) $("#resultReplay").removeClass("hidden");
40+
if (!isAuthenticated()) {
41+
$(".pageTest .loginTip").removeClass("hidden");
42+
}
43+
(document.querySelector("html") as HTMLElement).style.scrollBehavior =
44+
"smooth";
45+
for (const fb of getActiveFunboxesWithFunction("applyGlobalCSS")) {
46+
fb.functions.applyGlobalCSS();
47+
}
48+
}
49+
50+
let firefoxClipboardNotificatoinShown = false;
51+
52+
/**
53+
* Prepares UI, generates screenshot canvas using html2canvas, and reverts UI changes.
54+
* Returns the generated canvas element or null on failure.
55+
* Handles its own loader and basic error notifications for canvas generation.
56+
*/
57+
async function generateCanvas(): Promise<HTMLCanvasElement | null> {
58+
Loader.show();
59+
60+
if (!$("#resultReplay").hasClass("hidden")) {
61+
revealReplay = true;
62+
Replay.pauseReplay();
63+
}
64+
if (
65+
Misc.isElementVisible("#cookiesModal") ||
66+
document.contains(document.querySelector("#cookiesModal"))
67+
) {
68+
revertCookie = true;
69+
}
70+
71+
// --- UI Preparation ---
72+
const dateNow = new Date(Date.now());
73+
$("#resultReplay").addClass("hidden");
74+
$(".pageTest .ssWatermark").removeClass("hidden");
75+
76+
const snapshot = DB.getSnapshot();
77+
const ssWatermark = [format(dateNow, "dd MMM yyyy HH:mm"), "monkeytype.com"];
78+
if (snapshot?.name !== undefined) {
79+
const userText = `${snapshot?.name}${getHtmlByUserFlags(snapshot, {
80+
iconsOnly: true,
81+
})}`;
82+
ssWatermark.unshift(userText);
83+
}
84+
$(".pageTest .ssWatermark").html(
85+
ssWatermark
86+
.map((el) => `<span>${el}</span>`)
87+
.join("<span class='pipe'>|</span>")
88+
);
89+
$(".pageTest .buttons").addClass("hidden");
90+
$("#notificationCenter").addClass("hidden");
91+
$("#commandLineMobileButton").addClass("hidden");
92+
$(".pageTest .loginTip").addClass("hidden");
93+
$("noscript").addClass("hidden");
94+
$("#nocss").addClass("hidden");
95+
$("#ad-result-wrapper").addClass("hidden");
96+
$("#ad-result-small-wrapper").addClass("hidden");
97+
$("#testConfig").addClass("hidden");
98+
// Ensure spacer is removed before adding a new one if function is called rapidly
99+
$(".pageTest .screenshotSpacer").remove();
100+
$(".page.pageTest").prepend("<div class='screenshotSpacer'></div>");
101+
$("header, footer").addClass("invisible");
102+
$("#result").addClass("noBalloons");
103+
$(".wordInputHighlight").addClass("hidden");
104+
$(".highlightContainer").addClass("hidden");
105+
if (revertCookie) $("#cookiesModal").addClass("hidden");
106+
107+
for (const fb of getActiveFunboxesWithFunction("clearGlobal")) {
108+
fb.functions.clearGlobal();
109+
}
110+
111+
(document.querySelector("html") as HTMLElement).style.scrollBehavior = "auto";
112+
window.scrollTo({ top: 0, behavior: "instant" as ScrollBehavior }); // Use instant scroll
113+
114+
// --- Target Element Calculation ---
115+
const src = $("#result .wrapper");
116+
if (!src.length) {
117+
console.error("Result wrapper not found for screenshot");
118+
Notifications.add("Screenshot target element not found", -1);
119+
revert();
120+
return null;
121+
}
122+
// Ensure offset calculations happen *after* potential layout shifts from UI prep
123+
await new Promise((resolve) => setTimeout(resolve, 50)); // Small delay for render updates
124+
125+
const sourceX = src.offset()?.left ?? 0;
126+
const sourceY = src.offset()?.top ?? 0;
127+
const sourceWidth = src.outerWidth(true) as number;
128+
const sourceHeight = src.outerHeight(true) as number;
129+
130+
// --- Canvas Generation ---
131+
try {
132+
const paddingX = convertRemToPixels(2);
133+
const paddingY = convertRemToPixels(2);
134+
135+
const canvas = await (
136+
await gethtml2canvas()
137+
)(document.body, {
138+
backgroundColor: await ThemeColors.get("bg"),
139+
width: sourceWidth + paddingX * 2,
140+
height: sourceHeight + paddingY * 2,
141+
x: sourceX - paddingX,
142+
y: sourceY - paddingY,
143+
logging: false, // Suppress html2canvas logs in console
144+
useCORS: true, // May be needed if user flags/icons are external
145+
});
146+
147+
revert(); // Revert UI *after* canvas is successfully generated
148+
return canvas;
149+
} catch (e) {
150+
Notifications.add(
151+
Misc.createErrorMessage(e, "Error creating screenshot canvas"),
152+
-1
153+
);
154+
revert(); // Ensure UI is reverted on error
155+
return null;
156+
}
157+
}
158+
159+
/**
160+
* Generates screenshot and attempts to copy it to the clipboard.
161+
* Falls back to opening in a new tab if clipboard access fails.
162+
* Handles notifications related to the copy action.
163+
* (This function should be used by the 'copy' command or the original button)
164+
*/
165+
export async function copyToClipboard(): Promise<void> {
166+
const canvas = await generateCanvas();
167+
if (!canvas) {
168+
// Error notification handled by generateScreenshotCanvas
169+
return;
170+
}
171+
172+
canvas.toBlob(async (blob) => {
173+
if (!blob) {
174+
Notifications.add("Failed to generate image data (blob is null)", -1);
175+
return;
176+
}
177+
try {
178+
// Attempt to copy using ClipboardItem API
179+
const clipItem = new ClipboardItem(
180+
Object.defineProperty({}, blob.type, {
181+
value: blob,
182+
enumerable: true,
183+
})
184+
);
185+
await navigator.clipboard.write([clipItem]);
186+
Notifications.add("Copied screenshot to clipboard", 1, { duration: 2 });
187+
} catch (e) {
188+
// Handle clipboard write error
189+
console.error("Error saving image to clipboard", e);
190+
191+
// Firefox specific message (only show once)
192+
if (
193+
navigator.userAgent.toLowerCase().includes("firefox") &&
194+
!firefoxClipboardNotificatoinShown
195+
) {
196+
firefoxClipboardNotificatoinShown = true;
197+
Notifications.add(
198+
"On Firefox you can enable the asyncClipboard.clipboardItem permission in about:config to enable copying straight to the clipboard",
199+
0,
200+
{ duration: 10 }
201+
);
202+
}
203+
204+
// General fallback notification and action
205+
Notifications.add(
206+
"Could not copy screenshot to clipboard. Opening in new tab instead (make sure popups are allowed)",
207+
0,
208+
{ duration: 5 }
209+
);
210+
try {
211+
// Fallback: Open blob in a new tab
212+
const blobUrl = URL.createObjectURL(blob);
213+
window.open(blobUrl);
214+
// No need to revoke URL immediately as the new tab needs it.
215+
// Browser usually handles cleanup when tab is closed or navigated away.
216+
} catch (openError) {
217+
Notifications.add("Failed to open screenshot in new tab", -1);
218+
console.error("Error opening blob URL:", openError);
219+
}
220+
}
221+
});
222+
}
223+
224+
/**
225+
* Generates screenshot canvas and returns the image data as a Blob.
226+
* Handles notifications for canvas/blob generation errors.
227+
* (This function is intended to be used by the 'download' command)
228+
*/
229+
async function getBlob(): Promise<Blob | null> {
230+
const canvas = await generateCanvas();
231+
if (!canvas) {
232+
// Notification already handled by generateScreenshotCanvas
233+
return null;
234+
}
235+
236+
return new Promise((resolve) => {
237+
canvas.toBlob((blob) => {
238+
if (!blob) {
239+
Notifications.add("Failed to convert canvas to Blob for download", -1);
240+
resolve(null);
241+
} else {
242+
resolve(blob); // Return the generated blob
243+
}
244+
}, "image/png"); // Explicitly request PNG format
245+
});
246+
}
247+
248+
export async function download(): Promise<void> {
249+
try {
250+
const blob = await getBlob();
251+
252+
if (!blob) {
253+
Notifications.add("Failed to generate screenshot data", -1);
254+
return;
255+
}
256+
257+
const url = URL.createObjectURL(blob);
258+
259+
const link = document.createElement("a");
260+
link.href = url;
261+
262+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
263+
link.download = `monkeytype-result-${timestamp}.png`;
264+
265+
document.body.appendChild(link);
266+
link.click();
267+
document.body.removeChild(link);
268+
269+
URL.revokeObjectURL(url);
270+
271+
Notifications.add("Screenshot download started", 1);
272+
} catch (error) {
273+
console.error("Error downloading screenshot:", error);
274+
Notifications.add("Failed to download screenshot", -1);
275+
}
276+
}
277+
278+
$(".pageTest").on("click", "#saveScreenshotButton", (event) => {
279+
if (event.shiftKey) {
280+
void download();
281+
} else {
282+
void copyToClipboard();
283+
}
284+
});

0 commit comments

Comments
 (0)