|
| 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