diff --git a/frontend/src/ts/commandline/lists/result-screen.ts b/frontend/src/ts/commandline/lists/result-screen.ts index af4f77494..a733aeb94 100644 --- a/frontend/src/ts/commandline/lists/result-screen.ts +++ b/frontend/src/ts/commandline/lists/result-screen.ts @@ -7,6 +7,7 @@ import * as TestWords from "../../test/test-words"; import Config from "../../config"; import * as PractiseWords from "../../test/practise-words"; import { Command, CommandsSubgroup } from "../types"; +import * as TestScreenshot from "../../test/test-screenshot"; const practiceSubgroup: CommandsSubgroup = { title: "Practice words...", @@ -92,13 +93,27 @@ const commands: Command[] = [ }, }, { - id: "saveScreenshot", + id: "copyScreenshot", display: "Copy screenshot to clipboard", - icon: "fa-image", - alias: "save", + icon: "fa-copy", + alias: "copy image clipboard", exec: (): void => { setTimeout(() => { - void TestUI.screenshot(); + void TestScreenshot.copyToClipboard(); + }, 500); + }, + available: (): boolean => { + return TestUI.resultVisible; + }, + }, + { + id: "downloadScreenshot", + display: "Download screenshot", + icon: "fa-download", + alias: "save image download file", + exec: (): void => { + setTimeout(async () => { + void TestScreenshot.download(); }, 500); }, available: (): boolean => { diff --git a/frontend/src/ts/test/test-screenshot.ts b/frontend/src/ts/test/test-screenshot.ts new file mode 100644 index 000000000..8228ffc02 --- /dev/null +++ b/frontend/src/ts/test/test-screenshot.ts @@ -0,0 +1,284 @@ +import * as Loader from "../elements/loader"; +import * as Replay from "./replay"; +import * as Misc from "../utils/misc"; +import { isAuthenticated } from "../firebase"; +import { getActiveFunboxesWithFunction } from "./funbox/list"; +import * as DB from "../db"; +import * as ThemeColors from "../elements/theme-colors"; +import { format } from "date-fns/format"; + +import { getHtmlByUserFlags } from "../controllers/user-flag-controller"; +import * as Notifications from "../elements/notifications"; +import { convertRemToPixels } from "../utils/numbers"; + +async function gethtml2canvas(): Promise { + return (await import("html2canvas")).default; +} + +let revealReplay = false; +let revertCookie = false; + +function revert(): void { + Loader.hide(); + $("#ad-result-wrapper").removeClass("hidden"); + $("#ad-result-small-wrapper").removeClass("hidden"); + $("#testConfig").removeClass("hidden"); + $(".pageTest .screenshotSpacer").remove(); + $("#notificationCenter").removeClass("hidden"); + $("#commandLineMobileButton").removeClass("hidden"); + $(".pageTest .ssWatermark").addClass("hidden"); + $(".pageTest .ssWatermark").text("monkeytype.com"); // Reset watermark text + $(".pageTest .buttons").removeClass("hidden"); + $("noscript").removeClass("hidden"); + $("#nocss").removeClass("hidden"); + $("header, footer").removeClass("invisible"); + $("#result").removeClass("noBalloons"); + $(".wordInputHighlight").removeClass("hidden"); + $(".highlightContainer").removeClass("hidden"); + if (revertCookie) $("#cookiesModal").removeClass("hidden"); + if (revealReplay) $("#resultReplay").removeClass("hidden"); + if (!isAuthenticated()) { + $(".pageTest .loginTip").removeClass("hidden"); + } + (document.querySelector("html") as HTMLElement).style.scrollBehavior = + "smooth"; + for (const fb of getActiveFunboxesWithFunction("applyGlobalCSS")) { + fb.functions.applyGlobalCSS(); + } +} + +let firefoxClipboardNotificatoinShown = false; + +/** + * Prepares UI, generates screenshot canvas using html2canvas, 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 { + Loader.show(); + + if (!$("#resultReplay").hasClass("hidden")) { + revealReplay = true; + Replay.pauseReplay(); + } + if ( + Misc.isElementVisible("#cookiesModal") || + document.contains(document.querySelector("#cookiesModal")) + ) { + revertCookie = true; + } + + // --- UI Preparation --- + const dateNow = new Date(Date.now()); + $("#resultReplay").addClass("hidden"); + $(".pageTest .ssWatermark").removeClass("hidden"); + + const snapshot = DB.getSnapshot(); + const ssWatermark = [format(dateNow, "dd MMM yyyy HH:mm"), "monkeytype.com"]; + if (snapshot?.name !== undefined) { + const userText = `${snapshot?.name}${getHtmlByUserFlags(snapshot, { + iconsOnly: true, + })}`; + ssWatermark.unshift(userText); + } + $(".pageTest .ssWatermark").html( + ssWatermark + .map((el) => `${el}`) + .join("|") + ); + $(".pageTest .buttons").addClass("hidden"); + $("#notificationCenter").addClass("hidden"); + $("#commandLineMobileButton").addClass("hidden"); + $(".pageTest .loginTip").addClass("hidden"); + $("noscript").addClass("hidden"); + $("#nocss").addClass("hidden"); + $("#ad-result-wrapper").addClass("hidden"); + $("#ad-result-small-wrapper").addClass("hidden"); + $("#testConfig").addClass("hidden"); + // Ensure spacer is removed before adding a new one if function is called rapidly + $(".pageTest .screenshotSpacer").remove(); + $(".page.pageTest").prepend("
"); + $("header, footer").addClass("invisible"); + $("#result").addClass("noBalloons"); + $(".wordInputHighlight").addClass("hidden"); + $(".highlightContainer").addClass("hidden"); + if (revertCookie) $("#cookiesModal").addClass("hidden"); + + for (const fb of getActiveFunboxesWithFunction("clearGlobal")) { + fb.functions.clearGlobal(); + } + + (document.querySelector("html") as HTMLElement).style.scrollBehavior = "auto"; + window.scrollTo({ top: 0, behavior: "instant" as ScrollBehavior }); // Use instant scroll + + // --- Target Element Calculation --- + const src = $("#result .wrapper"); + if (!src.length) { + console.error("Result wrapper not found for screenshot"); + Notifications.add("Screenshot target element not found", -1); + 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 + + 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; + + // --- Canvas Generation --- + try { + const paddingX = convertRemToPixels(2); + const paddingY = convertRemToPixels(2); + + const canvas = await ( + await gethtml2canvas() + )(document.body, { + 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 + }); + + revert(); // Revert UI *after* canvas is successfully generated + return canvas; + } catch (e) { + Notifications.add( + Misc.createErrorMessage(e, "Error creating screenshot canvas"), + -1 + ); + revert(); // Ensure UI is reverted on error + return null; + } +} + +/** + * Generates screenshot and attempts to copy it to the clipboard. + * Falls back to opening in a new tab if clipboard access fails. + * Handles notifications related to the copy action. + * (This function should be used by the 'copy' command or the original button) + */ +export async function copyToClipboard(): Promise { + const canvas = await generateCanvas(); + if (!canvas) { + // Error notification handled by generateScreenshotCanvas + return; + } + + canvas.toBlob(async (blob) => { + if (!blob) { + Notifications.add("Failed to generate image data (blob is null)", -1); + return; + } + try { + // Attempt to copy using ClipboardItem API + const clipItem = new ClipboardItem( + Object.defineProperty({}, blob.type, { + value: blob, + enumerable: true, + }) + ); + await navigator.clipboard.write([clipItem]); + Notifications.add("Copied screenshot to clipboard", 1, { duration: 2 }); + } catch (e) { + // Handle clipboard write error + console.error("Error saving image to clipboard", e); + + // Firefox specific message (only show once) + if ( + navigator.userAgent.toLowerCase().includes("firefox") && + !firefoxClipboardNotificatoinShown + ) { + firefoxClipboardNotificatoinShown = true; + Notifications.add( + "On Firefox you can enable the asyncClipboard.clipboardItem permission in about:config to enable copying straight to the clipboard", + 0, + { duration: 10 } + ); + } + + // General fallback notification and action + Notifications.add( + "Could not copy screenshot to clipboard. Opening in new tab instead (make sure popups are allowed)", + 0, + { duration: 5 } + ); + try { + // Fallback: Open blob in a new tab + const blobUrl = URL.createObjectURL(blob); + window.open(blobUrl); + // No need to revoke URL immediately as the new tab needs it. + // Browser usually handles cleanup when tab is closed or navigated away. + } catch (openError) { + Notifications.add("Failed to open screenshot in new tab", -1); + console.error("Error opening blob URL:", openError); + } + } + }); +} + +/** + * Generates screenshot canvas and returns the image data as a Blob. + * Handles notifications for canvas/blob generation errors. + * (This function is intended to be used by the 'download' command) + */ +async function getBlob(): Promise { + const canvas = await generateCanvas(); + if (!canvas) { + // Notification already handled by generateScreenshotCanvas + return null; + } + + return new Promise((resolve) => { + canvas.toBlob((blob) => { + if (!blob) { + Notifications.add("Failed to convert canvas to Blob for download", -1); + resolve(null); + } else { + resolve(blob); // Return the generated blob + } + }, "image/png"); // Explicitly request PNG format + }); +} + +export async function download(): Promise { + try { + const blob = await getBlob(); + + if (!blob) { + Notifications.add("Failed to generate screenshot data", -1); + return; + } + + const url = URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.href = url; + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + link.download = `monkeytype-result-${timestamp}.png`; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + URL.revokeObjectURL(url); + + Notifications.add("Screenshot download started", 1); + } catch (error) { + console.error("Error downloading screenshot:", error); + Notifications.add("Failed to download screenshot", -1); + } +} + +$(".pageTest").on("click", "#saveScreenshotButton", (event) => { + if (event.shiftKey) { + void download(); + } else { + void copyToClipboard(); + } +}); diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts index 4e647984b..03d53863e 100644 --- a/frontend/src/ts/test/test-ui.ts +++ b/frontend/src/ts/test/test-ui.ts @@ -1,13 +1,11 @@ import * as Notifications from "../elements/notifications"; import * as ThemeColors from "../elements/theme-colors"; import Config, * as UpdateConfig from "../config"; -import * as DB from "../db"; import * as TestWords from "./test-words"; import * as TestInput from "./test-input"; import * as CustomText from "./custom-text"; import * as Caret from "./caret"; import * as OutOfFocus from "./out-of-focus"; -import * as Replay from "./replay"; import * as Misc from "../utils/misc"; import * as Strings from "../utils/strings"; import * as JSONData from "../utils/json-data"; @@ -17,29 +15,18 @@ import * as SlowTimer from "../states/slow-timer"; import * as CompositionState from "../states/composition"; import * as ConfigEvent from "../observables/config-event"; import * as Hangul from "hangul-js"; -import { format } from "date-fns/format"; -import { isAuthenticated } from "../firebase"; import { debounce } from "throttle-debounce"; import * as ResultWordHighlight from "../elements/result-word-highlight"; import * as ActivePage from "../states/active-page"; import Format from "../utils/format"; -import * as Loader from "../elements/loader"; -import { getHtmlByUserFlags } from "../controllers/user-flag-controller"; import { TimerColor, TimerOpacity, } from "@monkeytype/contracts/schemas/configs"; import { convertRemToPixels } from "../utils/numbers"; -import { - findSingleActiveFunboxWithFunction, - getActiveFunboxesWithFunction, -} from "./funbox/list"; +import { findSingleActiveFunboxWithFunction } from "./funbox/list"; import * as TestState from "./test-state"; -async function gethtml2canvas(): Promise { - return (await import("html2canvas")).default; -} - function createHintsHtml( incorrectLtrIndices: number[][], activeWordLetters: NodeListOf, @@ -627,174 +614,6 @@ export function colorful(tc: boolean): void { } } -let firefoxClipboardNotificatoinShown = false; -export async function screenshot(): Promise { - Loader.show(); - let revealReplay = false; - - let revertCookie = false; - if ( - Misc.isElementVisible("#cookiesModal") || - document.contains(document.querySelector("#cookiesModal")) - ) { - revertCookie = true; - } - - function revertScreenshot(): void { - Loader.hide(); - $("#ad-result-wrapper").removeClass("hidden"); - $("#ad-result-small-wrapper").removeClass("hidden"); - $("#testConfig").removeClass("hidden"); - $(".pageTest .screenshotSpacer").remove(); - $("#notificationCenter").removeClass("hidden"); - $("#commandLineMobileButton").removeClass("hidden"); - $(".pageTest .ssWatermark").addClass("hidden"); - $(".pageTest .ssWatermark").text("monkeytype.com"); - $(".pageTest .buttons").removeClass("hidden"); - $("noscript").removeClass("hidden"); - $("#nocss").removeClass("hidden"); - $("header, footer").removeClass("invisible"); - $("#result").removeClass("noBalloons"); - $(".wordInputHighlight").removeClass("hidden"); - $(".highlightContainer").removeClass("hidden"); - if (revertCookie) $("#cookiesModal").removeClass("hidden"); - if (revealReplay) $("#resultReplay").removeClass("hidden"); - if (!isAuthenticated()) { - $(".pageTest .loginTip").removeClass("hidden"); - } - (document.querySelector("html") as HTMLElement).style.scrollBehavior = - "smooth"; - for (const fb of getActiveFunboxesWithFunction("applyGlobalCSS")) { - fb.functions.applyGlobalCSS(); - } - } - - if (!$("#resultReplay").hasClass("hidden")) { - revealReplay = true; - Replay.pauseReplay(); - } - const dateNow = new Date(Date.now()); - $("#resultReplay").addClass("hidden"); - $(".pageTest .ssWatermark").removeClass("hidden"); - - const snapshot = DB.getSnapshot(); - const ssWatermark = [format(dateNow, "dd MMM yyyy HH:mm"), "monkeytype.com"]; - if (snapshot?.name !== undefined) { - const userText = `${snapshot?.name}${getHtmlByUserFlags(snapshot, { - iconsOnly: true, - })}`; - ssWatermark.unshift(userText); - } - $(".pageTest .ssWatermark").html( - ssWatermark - .map((el) => `${el}`) - .join("|") - ); - $(".pageTest .buttons").addClass("hidden"); - $("#notificationCenter").addClass("hidden"); - $("#commandLineMobileButton").addClass("hidden"); - $(".pageTest .loginTip").addClass("hidden"); - $("noscript").addClass("hidden"); - $("#nocss").addClass("hidden"); - $("#ad-result-wrapper").addClass("hidden"); - $("#ad-result-small-wrapper").addClass("hidden"); - $("#testConfig").addClass("hidden"); - $(".page.pageTest").prepend("
"); - $("header, footer").addClass("invisible"); - $("#result").addClass("noBalloons"); - $(".wordInputHighlight").addClass("hidden"); - $(".highlightContainer").addClass("hidden"); - if (revertCookie) $("#cookiesModal").addClass("hidden"); - - for (const fb of getActiveFunboxesWithFunction("clearGlobal")) { - fb.functions.clearGlobal(); - } - - (document.querySelector("html") as HTMLElement).style.scrollBehavior = "auto"; - window.scrollTo({ - top: 0, - }); - const src = $("#result .wrapper"); - const sourceX = src.offset()?.left ?? 0; /*X position from div#target*/ - const sourceY = src.offset()?.top ?? 0; /*Y position from div#target*/ - const sourceWidth = src.outerWidth( - true - ) as number; /*clientWidth/offsetWidth from div#target*/ - const sourceHeight = src.outerHeight( - true - ) as number; /*clientHeight/offsetHeight from div#target*/ - try { - const paddingX = convertRemToPixels(2); - const paddingY = convertRemToPixels(2); - - const canvas = await ( - await gethtml2canvas() - )(document.body, { - backgroundColor: await ThemeColors.get("bg"), - width: sourceWidth + paddingX * 2, - height: sourceHeight + paddingY * 2, - x: sourceX - paddingX, - y: sourceY - paddingY, - }); - canvas.toBlob(async (blob) => { - try { - if (blob === null) { - throw new Error("Could not create image, blob is null"); - } - const clipItem = new ClipboardItem( - Object.defineProperty({}, blob.type, { - value: blob, - enumerable: true, - }) - ); - await navigator.clipboard.write([clipItem]); - Notifications.add("Copied to clipboard", 1, { - duration: 2, - }); - } catch (e) { - console.error("Error while saving image to clipboard", e); - if (blob) { - //check if on firefox - if ( - navigator.userAgent.toLowerCase().includes("firefox") && - !firefoxClipboardNotificatoinShown - ) { - firefoxClipboardNotificatoinShown = true; - Notifications.add( - "On Firefox you can enable the asyncClipboard.clipboardItem permission in about:config to enable copying straight to the clipboard", - 0, - { - duration: 10, - } - ); - } - - Notifications.add( - "Could not save image to clipboard. Opening in new tab instead (make sure popups are allowed)", - 0, - { - duration: 5, - } - ); - open(URL.createObjectURL(blob)); - } else { - Notifications.add( - Misc.createErrorMessage(e, "Error saving image to clipboard"), - -1 - ); - } - } - revertScreenshot(); - }); - } catch (e) { - Notifications.add(Misc.createErrorMessage(e, "Error creating image"), -1); - revertScreenshot(); - } - setTimeout(() => { - revertScreenshot(); - }, 3000); -} - export async function updateActiveWordLetters( inputOverride?: string ): Promise { @@ -1727,10 +1546,6 @@ function updateLiveStatsColor(value: TimerColor): void { } } -$(".pageTest").on("click", "#saveScreenshotButton", () => { - void screenshot(); -}); - $(".pageTest #copyWordsListButton").on("click", async () => { let words; if (Config.mode === "zen") {