From 89f50f774f316b342a9d4d0eac73b92a987ccff2 Mon Sep 17 00:00:00 2001 From: Aden Linday Date: Sun, 12 Apr 2026 20:06:03 +0930 Subject: [PATCH] feat: image full screen overlay for popoups --- src/css/injected.scss | 87 ++++++++++ .../Openers/OpenEngageParentsAnnouncement.ts | 3 + .../utils/Openers/OpenPrivacyNotification.ts | 3 + src/seqta/utils/Openers/OpenWhatsNewPopup.ts | 2 + .../Openers/attachPopupMediaFullscreen.ts | 158 ++++++++++++++++++ 5 files changed, 253 insertions(+) create mode 100644 src/seqta/utils/Openers/attachPopupMediaFullscreen.ts diff --git a/src/css/injected.scss b/src/css/injected.scss index e31230a0..62a56537 100644 --- a/src/css/injected.scss +++ b/src/css/injected.scss @@ -3692,6 +3692,93 @@ div.day-empty { object-position: center; } +.popup-media-fullscreenable { + cursor: pointer; + transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; +} +.popup-media-fullscreenable:hover { + opacity: 0.95; +} +.popup-media-fullscreenable:focus { + outline: none; +} +.popup-media-fullscreenable:focus-visible { + outline: 2px solid color-mix(in srgb, var(--text-primary) 70%, transparent); + outline-offset: 4px; +} + +.bsplus-popup-media-overlay-backdrop { + position: fixed; + inset: 0; + z-index: 2147483646; + display: flex; + align-items: center; + justify-content: center; + padding: clamp(20px, 4vw, 48px); + box-sizing: border-box; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(6px); + opacity: 0; + transition: opacity 0.28s cubic-bezier(0.22, 0.03, 0.26, 1); +} + +.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay-backdrop--visible { + opacity: 1; +} + +.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay--instant { + transition: none; +} + +.bsplus-popup-media-overlay-inner { + position: relative; + display: flex; + flex-direction: column; + align-items: stretch; + width: 100%; + max-width: min(96vw, 1320px); + max-height: calc(100vh - clamp(40px, 10vw, 96px)); + border-radius: 20px; + overflow: hidden; + background: var(--background-primary); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35); + opacity: 0; + transform: scale(0.94) translateY(12px); + transition: + opacity 0.28s cubic-bezier(0.22, 0.03, 0.26, 1), + transform 0.28s cubic-bezier(0.22, 0.03, 0.26, 1); +} + +.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay-backdrop--visible + .bsplus-popup-media-overlay-inner { + opacity: 1; + transform: scale(1) translateY(0); +} + +.bsplus-popup-media-overlay-backdrop.bsplus-popup-media-overlay--instant + .bsplus-popup-media-overlay-inner { + transition: none; +} + +.bsplus-popup-media-overlay-slot { + width: 100%; + max-height: inherit; + display: flex; + align-items: center; + justify-content: center; + padding: clamp(16px, 3vw, 28px); + box-sizing: border-box; +} + +.bsplus-popup-media-overlay-media { + max-width: 100%; + max-height: calc(100vh - clamp(120px, 22vh, 200px)); + width: auto; + height: auto; + object-fit: contain; + border-radius: 12px; +} + @keyframes shimmer { 0% { background-position: -1000px 0; diff --git a/src/seqta/utils/Openers/OpenEngageParentsAnnouncement.ts b/src/seqta/utils/Openers/OpenEngageParentsAnnouncement.ts index ca8b4747..0bb08fb5 100644 --- a/src/seqta/utils/Openers/OpenEngageParentsAnnouncement.ts +++ b/src/seqta/utils/Openers/OpenEngageParentsAnnouncement.ts @@ -1,6 +1,7 @@ import stringToHTML from "../stringToHTML"; import { settingsState } from "../listeners/SettingsState"; import { openPopup } from "./PopupManager"; +import { attachPopupMediaFullscreenIfPresent } from "./attachPopupMediaFullscreen"; /** Same hosting pattern as the privacy statement branding images (avoids page-relative extension URLs on Engage). */ const ENGAGE_PROMO_IMG_URL = @@ -49,6 +50,8 @@ export function showEngageParentsAnnouncement(onDismissed?: () => void) { `).firstChild as HTMLElement; + attachPopupMediaFullscreenIfPresent(text, ".engageParentsPromoImg"); + settingsState.engageParentsAnnouncementShown = true; openPopup({ diff --git a/src/seqta/utils/Openers/OpenPrivacyNotification.ts b/src/seqta/utils/Openers/OpenPrivacyNotification.ts index b205c641..b42268f5 100644 --- a/src/seqta/utils/Openers/OpenPrivacyNotification.ts +++ b/src/seqta/utils/Openers/OpenPrivacyNotification.ts @@ -1,6 +1,7 @@ import stringToHTML from "../stringToHTML"; import { settingsState } from "../listeners/SettingsState"; import { openPopup } from "./PopupManager"; +import { attachPopupMediaFullscreenIfPresent } from "./attachPopupMediaFullscreen"; const PRIVACY_STATEMENT_VERSION = "2025-12-19"; @@ -59,6 +60,8 @@ export function showPrivacyNotification(onDismissed?: () => void) { `).firstChild as HTMLElement; + attachPopupMediaFullscreenIfPresent(text, "img.aboutImg"); + settingsState.privacyStatementLastUpdated = "2025-12-20"; settingsState.privacyStatementShown = true; diff --git a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts index fe5a8028..09ed6c26 100644 --- a/src/seqta/utils/Openers/OpenWhatsNewPopup.ts +++ b/src/seqta/utils/Openers/OpenWhatsNewPopup.ts @@ -2,6 +2,7 @@ import stringToHTML from "../stringToHTML"; import browser from "webextension-polyfill"; import kofi from "@/resources/kofi.png?base64"; import { openPopup } from "./PopupManager"; +import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen"; export function OpenWhatsNewPopup(onDismissed?: () => void) { const header = stringToHTML( @@ -28,6 +29,7 @@ export function OpenWhatsNewPopup(onDismissed?: () => void) { video.appendChild(source); video.classList.add("whatsnewImg"); imageContainer.appendChild(video); + attachPopupMediaFullscreen(video); const text = stringToHTML(/* html */ `
diff --git a/src/seqta/utils/Openers/attachPopupMediaFullscreen.ts b/src/seqta/utils/Openers/attachPopupMediaFullscreen.ts new file mode 100644 index 00000000..8990989c --- /dev/null +++ b/src/seqta/utils/Openers/attachPopupMediaFullscreen.ts @@ -0,0 +1,158 @@ +/** + * Makes popup hero images/videos open a padded overlay (not browser fullscreen) on click. + * Escape or backdrop click dismisses it. Clicks use stopPropagation so the + * parent SEQTA popup does not close. + */ + +import { settingsState } from "../listeners/SettingsState"; + +const FULLSCREENABLE_CLASS = "popup-media-fullscreenable"; +const OVERLAY_VISIBLE_CLASS = "bsplus-popup-media-overlay-backdrop--visible"; +const OVERLAY_ANIM_MS = 280; + +function isImageOrVideo(el: Element): el is HTMLImageElement | HTMLVideoElement { + return el instanceof HTMLImageElement || el instanceof HTMLVideoElement; +} + +export function attachPopupMediaFullscreen(el: HTMLImageElement | HTMLVideoElement) { + el.classList.add(FULLSCREENABLE_CLASS); + el.setAttribute("tabindex", "0"); + el.setAttribute("role", "button"); + el.setAttribute("aria-label", "View larger"); + el.title = "Click to view larger"; + + const open = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + openMediaOverlayViewer(el); + }; + + el.addEventListener("click", open); + el.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + open(e); + } + }); +} + +function openMediaOverlayViewer(source: HTMLImageElement | HTMLVideoElement) { + const backdrop = document.createElement("div"); + backdrop.id = "bsplus-popup-media-overlay"; + backdrop.className = "bsplus-popup-media-overlay-backdrop"; + + const inner = document.createElement("div"); + inner.className = "bsplus-popup-media-overlay-inner"; + + const slot = document.createElement("div"); + slot.className = "bsplus-popup-media-overlay-slot"; + + let media: HTMLImageElement | HTMLVideoElement; + if (source instanceof HTMLVideoElement) { + const v = source; + const nv = document.createElement("video"); + nv.classList.add("bsplus-popup-media-overlay-media"); + nv.controls = true; + nv.playsInline = true; + nv.loop = v.loop; + nv.muted = v.muted; + nv.volume = v.volume; + for (const s of v.querySelectorAll("source")) { + const ns = document.createElement("source"); + ns.src = (s as HTMLSourceElement).src; + const t = (s as HTMLSourceElement).type; + if (t) ns.type = t; + nv.appendChild(ns); + } + nv.addEventListener( + "loadeddata", + () => { + try { + nv.currentTime = v.currentTime; + } catch { + /* ignore */ + } + void nv.play().catch(() => {}); + }, + { once: true }, + ); + v.pause(); + nv.load(); + media = nv; + } else { + const img = document.createElement("img"); + img.classList.add("bsplus-popup-media-overlay-media"); + img.src = source.currentSrc || source.src; + img.alt = source.alt || ""; + media = img; + } + + media.addEventListener("click", (e) => e.stopPropagation()); + + slot.appendChild(media); + inner.append(slot); + backdrop.appendChild(inner); + document.body.append(backdrop); + + if (!settingsState.animations) { + backdrop.classList.add("bsplus-popup-media-overlay--instant"); + backdrop.classList.add(OVERLAY_VISIBLE_CLASS); + } else { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + backdrop.classList.add(OVERLAY_VISIBLE_CLASS); + }); + }); + } + + inner.addEventListener("click", (e) => e.stopPropagation()); + + let done = false; + const removeOverlay = () => { + if (source instanceof HTMLVideoElement && media instanceof HTMLVideoElement) { + try { + source.currentTime = media.currentTime; + } catch { + /* ignore */ + } + void source.play().catch(() => {}); + } + backdrop.remove(); + }; + + const close = () => { + if (done) return; + done = true; + document.removeEventListener("keydown", onDocKey, true); + + if (!settingsState.animations) { + removeOverlay(); + return; + } + + backdrop.classList.remove(OVERLAY_VISIBLE_CLASS); + window.setTimeout(removeOverlay, OVERLAY_ANIM_MS); + }; + + const onDocKey = (ev: KeyboardEvent) => { + if (ev.key === "Escape") { + ev.stopPropagation(); + close(); + } + }; + + document.addEventListener("keydown", onDocKey, true); + + backdrop.addEventListener("click", () => { + close(); + }); +} + +export function attachPopupMediaFullscreenIfPresent( + root: ParentNode, + selector: string, +) { + const el = root.querySelector(selector); + if (el && isImageOrVideo(el)) { + attachPopupMediaFullscreen(el); + } +}