From 075cae3d55197b96d997d72cfb498c870788453a Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Tue, 22 Apr 2025 12:20:54 +0200 Subject: [chore/frontend] Reorder JS a little bit to avoid visible text changes (#4039) --- web/source/blurhash/index.js | 146 ------------------ web/source/frontend/index.js | 116 ++------------- web/source/frontend_prerender/index.js | 263 +++++++++++++++++++++++++++++++++ web/source/index.js | 6 +- 4 files changed, 275 insertions(+), 256 deletions(-) delete mode 100644 web/source/blurhash/index.js create mode 100644 web/source/frontend_prerender/index.js (limited to 'web/source') diff --git a/web/source/blurhash/index.js b/web/source/blurhash/index.js deleted file mode 100644 index c964f69c4..000000000 --- a/web/source/blurhash/index.js +++ /dev/null @@ -1,146 +0,0 @@ -/* - GoToSocial - Copyright (C) GoToSocial Authors admin@gotosocial.org - SPDX-License-Identifier: AGPL-3.0-or-later - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -import { decode } from "blurhash"; - -// Generate a blurhash canvas for each image for -// each blurhash container and put it in the summary. -Array.from(document.getElementsByClassName('blurhash-container')).forEach(blurhashContainer => { - const hash = blurhashContainer.dataset.blurhashHash; - const thumbHeight = blurhashContainer.dataset.blurhashHeight; - const thumbWidth = blurhashContainer.dataset.blurhashWidth; - const thumbAspect = blurhashContainer.dataset.blurhashAspect; - - /* - It's very expensive to draw big canvases - with blurhashes, so use tiny ones, keeping - aspect ratio of the original thumbnail. - */ - var useWidth = 32; - var useHeight = 32; - switch (true) { - case thumbWidth > thumbHeight: - useHeight = Math.round(useWidth / thumbAspect); - break; - case thumbHeight > thumbWidth: - useWidth = Math.round(useHeight * thumbAspect); - break; - } - - const pixels = decode( - hash, - useWidth, - useHeight, - ); - - // Create canvas of appropriate size. - const canvas = document.createElement("canvas"); - canvas.width = useWidth; - canvas.height = useHeight; - - // Draw the image data into the canvas. - const ctx = canvas.getContext("2d"); - const imageData = ctx.createImageData( - useWidth, - useHeight, - ); - imageData.data.set(pixels); - ctx.putImageData(imageData, 0, 0); - - // Put the canvas inside the container. - blurhashContainer.appendChild(canvas); -}); - -// Add a smooth transition from blurhash -// to image for each sensitive image. -Array.from(document.getElementsByTagName('img')).forEach(img => { - if (!img.dataset.blurhashHash) { - // Has no blurhash, - // can't transition smoothly. - return; - } - - if (img.dataset.sensitive !== "true") { - // Not sensitive, smooth - // transition doesn't matter. - return; - } - - if (img.complete) { - // Image already loaded, - // don't stub it with anything. - return; - } - - const parentSlide = img.closest(".photoswipe-slide"); - if (!parentSlide) { - // Parent slide was nil, - // can't do anything. - return; - } - - const blurhashContainer = document.querySelector("div[data-blurhash-hash=\"" + img.dataset.blurhashHash + "\"]"); - if (!blurhashContainer) { - // Blurhash div was nil, - // can't do anything. - return; - } - - const canvas = blurhashContainer.children[0]; - if (!canvas) { - // Canvas was nil, - // can't do anything. - return; - } - - // "Replace" the hidden img with a canvas - // that will show initially when it's clicked. - const clone = canvas.cloneNode(true); - clone.getContext("2d").drawImage(canvas, 0, 0); - parentSlide.prepend(clone); - img.className = img.className + " hidden"; - - // Add a listener so that when the spoiler - // is opened, loading of the image begins. - const parentSummary = img.closest(".media-spoiler"); - parentSummary.addEventListener("toggle", (_) => { - if (parentSummary.hasAttribute("open") && !img.complete) { - img.loading = "eager"; - } - }); - - // Add a callback that triggers - // when image loading is complete. - img.addEventListener("load", () => { - // Show the image now that it's loaded. - img.className = img.className.replace(" hidden", ""); - - // Reset the lazy loading tag to its initial - // value. This doesn't matter too much since - // it's already loaded but it feels neater. - img.loading = "lazy"; - - // Remove the temporary blurhash - // canvas so only the image shows. - const canvas = parentSlide.getElementsByTagName("canvas")[0]; - if (canvas) { - canvas.remove(); - } - }); -}); diff --git a/web/source/frontend/index.js b/web/source/frontend/index.js index da158ed77..47879b2e2 100644 --- a/web/source/frontend/index.js +++ b/web/source/frontend/index.js @@ -17,6 +17,15 @@ along with this program. If not, see . */ +/* + WHAT SHOULD GO IN THIS FILE? + + This script is loaded in the document head, and deferred + async, + so it's *usually* run after the user is already looking at the page. + Put stuff in here that doesn't shift the layout, and it doesn't really + matter whether it loads immediately. So, progressive enhancement stuff. +*/ + const Photoswipe = require("photoswipe/dist/umd/photoswipe.umd.min.js"); const PhotoswipeLightbox = require("photoswipe/dist/umd/photoswipe-lightbox.umd.min.js"); const PhotoswipeCaptionPlugin = require("photoswipe-dynamic-caption-plugin").default; @@ -165,89 +174,6 @@ lightbox.on('uiRegister', function() { lightbox.init(); -function dynamicSpoiler(className, updateFunc) { - Array.from(document.getElementsByClassName(className)).forEach((spoiler) => { - const update = updateFunc(spoiler); - if (update) { - update(); - spoiler.addEventListener("toggle", update); - } - }); -} - -dynamicSpoiler("text-spoiler", (details) => { - const summary = details.children[0]; - const button = details.querySelector(".button"); - - // Use button *instead of summary* - // to toggle post visibility. - summary.tabIndex = "-1"; - button.tabIndex = "0"; - button.setAttribute("aria-role", "button"); - button.onclick = (e) => { - e.preventDefault(); - return details.hasAttribute("open") - ? details.removeAttribute("open") - : details.setAttribute("open", ""); - }; - - // Let enter also trigger the button - // (for those using keyboard to navigate). - button.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - button.click(); - } - }); - - // Change button text depending on - // whether spoiler is open or closed rn. - return () => { - button.textContent = details.open - ? "Show less" - : "Show more"; - }; -}); - -dynamicSpoiler("media-spoiler", (details) => { - const summary = details.children[0]; - const button = details.querySelector(".eye.button"); - const video = details.querySelector(".plyr-video"); - const loopingAuto = !reduceMotion.matches && video != null && video.classList.contains("gifv"); - - // Use button *instead of summary* - // to toggle media visibility. - summary.tabIndex = "-1"; - button.tabIndex = "0"; - button.setAttribute("aria-role", "button"); - button.onclick = (e) => { - e.preventDefault(); - return details.hasAttribute("open") - ? details.removeAttribute("open") - : details.setAttribute("open", ""); - }; - - // Let enter also trigger the button - // (for those using keyboard to navigate). - button.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - button.click(); - } - }); - - return () => { - if (details.open) { - button.setAttribute("aria-label", "Hide media"); - } else { - button.setAttribute("aria-label", "Show media"); - if (video && !loopingAuto) { - video.pause(); - } - } - }; -}); - Array.from(document.getElementsByClassName("plyr-video")).forEach((video) => { const loopingAuto = !reduceMotion.matches && video.classList.contains("gifv"); let player = new Plyr(video, { @@ -315,30 +241,6 @@ function inLightbox(element) { lightbox.pswp.currSlide.data.attachmentId; } -// Define + reuse one DateTimeFormat (cheaper). -const dateTimeFormat = Intl.DateTimeFormat( - undefined, - { - year: 'numeric', - month: 'short', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false - }, -); - -// Reformat time text to browser locale. -Array.from(document.getElementsByTagName('time')).forEach(timeTag => { - const datetime = timeTag.getAttribute('datetime'); - const currentText = timeTag.textContent.trim(); - // Only format if current text contains precise time. - if (currentText.match(/\d{2}:\d{2}/)) { - const date = new Date(datetime); - timeTag.textContent = dateTimeFormat.format(date); - } -}); - // When clicking anywhere that's not an open // stats-info-more-content details dropdown, // close that open dropdown. diff --git a/web/source/frontend_prerender/index.js b/web/source/frontend_prerender/index.js new file mode 100644 index 000000000..294c1ddb1 --- /dev/null +++ b/web/source/frontend_prerender/index.js @@ -0,0 +1,263 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +/* + WHAT SHOULD GO IN THIS FILE? + + This script is loaded just before the end of the HTML body, so + put stuff in here that should be run *before* the user sees the page. + So, stuff that shifts the layout or causes elements to jump around. +*/ + +import { decode } from "blurhash"; + +const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + +// Generate a blurhash canvas for each image for +// each blurhash container and put it in the summary. +Array.from(document.getElementsByClassName('blurhash-container')).forEach(blurhashContainer => { + const hash = blurhashContainer.dataset.blurhashHash; + const thumbHeight = blurhashContainer.dataset.blurhashHeight; + const thumbWidth = blurhashContainer.dataset.blurhashWidth; + const thumbAspect = blurhashContainer.dataset.blurhashAspect; + + /* + It's very expensive to draw big canvases + with blurhashes, so use tiny ones, keeping + aspect ratio of the original thumbnail. + */ + var useWidth = 32; + var useHeight = 32; + switch (true) { + case thumbWidth > thumbHeight: + useHeight = Math.round(useWidth / thumbAspect); + break; + case thumbHeight > thumbWidth: + useWidth = Math.round(useHeight * thumbAspect); + break; + } + + const pixels = decode( + hash, + useWidth, + useHeight, + ); + + // Create canvas of appropriate size. + const canvas = document.createElement("canvas"); + canvas.width = useWidth; + canvas.height = useHeight; + + // Draw the image data into the canvas. + const ctx = canvas.getContext("2d"); + const imageData = ctx.createImageData( + useWidth, + useHeight, + ); + imageData.data.set(pixels); + ctx.putImageData(imageData, 0, 0); + + // Put the canvas inside the container. + blurhashContainer.appendChild(canvas); +}); + +// Add a smooth transition from blurhash +// to image for each sensitive image. +Array.from(document.getElementsByTagName('img')).forEach(img => { + if (!img.dataset.blurhashHash) { + // Has no blurhash, + // can't transition smoothly. + return; + } + + if (img.dataset.sensitive !== "true") { + // Not sensitive, smooth + // transition doesn't matter. + return; + } + + if (img.complete) { + // Image already loaded, + // don't stub it with anything. + return; + } + + const parentSlide = img.closest(".photoswipe-slide"); + if (!parentSlide) { + // Parent slide was nil, + // can't do anything. + return; + } + + const blurhashContainer = document.querySelector("div[data-blurhash-hash=\"" + img.dataset.blurhashHash + "\"]"); + if (!blurhashContainer) { + // Blurhash div was nil, + // can't do anything. + return; + } + + const canvas = blurhashContainer.children[0]; + if (!canvas) { + // Canvas was nil, + // can't do anything. + return; + } + + // "Replace" the hidden img with a canvas + // that will show initially when it's clicked. + const clone = canvas.cloneNode(true); + clone.getContext("2d").drawImage(canvas, 0, 0); + parentSlide.prepend(clone); + img.className = img.className + " hidden"; + + // Add a listener so that when the spoiler + // is opened, loading of the image begins. + const parentSummary = img.closest(".media-spoiler"); + parentSummary.addEventListener("toggle", (_) => { + if (parentSummary.hasAttribute("open") && !img.complete) { + img.loading = "eager"; + } + }); + + // Add a callback that triggers + // when image loading is complete. + img.addEventListener("load", () => { + // Show the image now that it's loaded. + img.className = img.className.replace(" hidden", ""); + + // Reset the lazy loading tag to its initial + // value. This doesn't matter too much since + // it's already loaded but it feels neater. + img.loading = "lazy"; + + // Remove the temporary blurhash + // canvas so only the image shows. + const canvas = parentSlide.getElementsByTagName("canvas")[0]; + if (canvas) { + canvas.remove(); + } + }); +}); + +// Change the spoiler / content warning boxes from generic +// "toggle visibility" to show/hide depending on state, +// and add keyboard functionality to spoiler buttons. +function dynamicSpoiler(className, updateFunc) { + Array.from(document.getElementsByClassName(className)).forEach((spoiler) => { + const update = updateFunc(spoiler); + if (update) { + update(); + spoiler.addEventListener("toggle", update); + } + }); +} +dynamicSpoiler("text-spoiler", (details) => { + const summary = details.children[0]; + const button = details.querySelector(".button"); + + // Use button *instead of summary* + // to toggle post visibility. + summary.tabIndex = "-1"; + button.tabIndex = "0"; + button.setAttribute("aria-role", "button"); + button.onclick = (e) => { + e.preventDefault(); + return details.hasAttribute("open") + ? details.removeAttribute("open") + : details.setAttribute("open", ""); + }; + + // Let enter also trigger the button + // (for those using keyboard to navigate). + button.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + button.click(); + } + }); + + // Change button text depending on + // whether spoiler is open or closed rn. + return () => { + button.textContent = details.open + ? "Show less" + : "Show more"; + }; +}); +dynamicSpoiler("media-spoiler", (details) => { + const summary = details.children[0]; + const button = details.querySelector(".eye.button"); + const video = details.querySelector(".plyr-video"); + const loopingAuto = !reduceMotion.matches && video != null && video.classList.contains("gifv"); + + // Use button *instead of summary* + // to toggle media visibility. + summary.tabIndex = "-1"; + button.tabIndex = "0"; + button.setAttribute("aria-role", "button"); + button.onclick = (e) => { + e.preventDefault(); + return details.hasAttribute("open") + ? details.removeAttribute("open") + : details.setAttribute("open", ""); + }; + + // Let enter also trigger the button + // (for those using keyboard to navigate). + button.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + button.click(); + } + }); + + return () => { + if (details.open) { + button.setAttribute("aria-label", "Hide media"); + } else { + button.setAttribute("aria-label", "Show media"); + if (video && !loopingAuto) { + video.pause(); + } + } + }; +}); + +// Reformat time text to browser locale. +// Define + reuse one DateTimeFormat (cheaper). +const dateTimeFormat = Intl.DateTimeFormat( + undefined, + { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false + }, +); +Array.from(document.getElementsByTagName('time')).forEach(timeTag => { + const datetime = timeTag.getAttribute('datetime'); + const currentText = timeTag.textContent.trim(); + // Only format if current text contains precise time. + if (currentText.match(/\d{2}:\d{2}/)) { + const date = new Date(datetime); + timeTag.textContent = dateTimeFormat.format(date); + } +}); diff --git a/web/source/index.js b/web/source/index.js index c47e9c5bb..d66afe757 100644 --- a/web/source/index.js +++ b/web/source/index.js @@ -64,9 +64,9 @@ skulk({ }] ], }, - blurhash: { - entryFile: "blurhash", - outputFile: "blurhash.js", + frontend_prerender: { + entryFile: "frontend_prerender", + outputFile: "frontend_prerender.js", preset: ["js"], prodCfg: prodCfg, transform: [ -- cgit v1.2.3