diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index c6053d334..14d903fae 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -6,7 +6,6 @@ module.exports = { globals: { $: "readonly", jQuery: "readonly", - html2canvas: "readonly", ClipboardItem: "readonly", grecaptcha: "readonly", }, diff --git a/frontend/package.json b/frontend/package.json index 210fcbd7f..6042f0222 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/styles/animations.scss b/frontend/src/styles/animations.scss index a08824103..8894c2bd6 100644 --- a/frontend/src/styles/animations.scss +++ b/frontend/src/styles/animations.scss @@ -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); } } diff --git a/frontend/src/ts/elements/loader.ts b/frontend/src/ts/elements/loader.ts index 369823120..4a4793dc3 100644 --- a/frontend/src/ts/elements/loader.ts +++ b/frontend/src/ts/elements/loader.ts @@ -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; } diff --git a/frontend/src/ts/test/test-screenshot.ts b/frontend/src/ts/test/test-screenshot.ts index 66eb9d352..d388d6f59 100644 --- a/frontend/src/ts/test/test-screenshot.ts +++ b/frontend/src/ts/test/test-screenshot.ts @@ -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 { - 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 { - 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 { } (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 { 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 { // 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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9b8ebcdc..508d4deb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}