From 0366e7ff695ef348d8b5135e1c33587f5451cff4 Mon Sep 17 00:00:00 2001 From: Heston Hoffman Date: Thu, 13 Aug 2026 10:57:38 -0700 Subject: [PATCH 1/2] (feat) Img component --- astro/src/components/Img/Img.astro | 59 ++++ .../components/Img/ImgController.module.css | 81 +++++ astro/src/components/Img/ImgController.tsx | 286 ++++++++++++++++++ astro/src/components/Img/ImgVideo.module.css | 10 + astro/src/components/Img/ImgVideo.tsx | 36 +++ astro/src/components/Img/plaintext/Img.ts | 35 +++ .../Img/plaintext/tests/unit.test.ts | 57 ++++ .../Img/tests/ImgController.unit.test.ts | 206 +++++++++++++ .../Img/tests/ImgVideo.unit.test.ts | 65 ++++ .../src/components/Img/tests/browser.test.ts | 95 ++++++ astro/src/components/Img/tests/unit.test.ts | 135 +++++++++ astro/src/config/images.ts | 8 +- .../src/content/en/dd_e2e/components/img.mdoc | 51 ++++ .../tests/twinTransform.unit.test.ts | 48 +++ astro/src/lib/plaintext/twinTransform.ts | 10 + astro/tests/headless/markdocSchema.test.ts | 40 +++ 16 files changed, 1220 insertions(+), 2 deletions(-) create mode 100644 astro/src/components/Img/Img.astro create mode 100644 astro/src/components/Img/ImgController.module.css create mode 100644 astro/src/components/Img/ImgController.tsx create mode 100644 astro/src/components/Img/ImgVideo.module.css create mode 100644 astro/src/components/Img/ImgVideo.tsx create mode 100644 astro/src/components/Img/plaintext/Img.ts create mode 100644 astro/src/components/Img/plaintext/tests/unit.test.ts create mode 100644 astro/src/components/Img/tests/ImgController.unit.test.ts create mode 100644 astro/src/components/Img/tests/ImgVideo.unit.test.ts create mode 100644 astro/src/components/Img/tests/browser.test.ts create mode 100644 astro/src/components/Img/tests/unit.test.ts create mode 100644 astro/src/content/en/dd_e2e/components/img.mdoc create mode 100644 astro/tests/headless/markdocSchema.test.ts diff --git a/astro/src/components/Img/Img.astro b/astro/src/components/Img/Img.astro new file mode 100644 index 00000000000..dfc5645d40b --- /dev/null +++ b/astro/src/components/Img/Img.astro @@ -0,0 +1,59 @@ +--- +import ImgController from "./ImgController"; +import ImgVideo from "./ImgVideo"; +import { IMAGES_URL } from "@config/images"; + +interface Props { + src: string; + alt?: string; + caption?: string; + width?: string; + height?: string; + widthPercent?: number; + video?: boolean; + inline?: boolean; + popup?: boolean; +} + +const { + src, + alt, + caption, + width, + height, + widthPercent, + video = false, + inline = false, + popup = true, +} = Astro.props; + +const imageUrl = `${IMAGES_URL}/images/${src}`; + +const srcset = `${imageUrl}?auto=format&fit=max&w=850 1x, ${imageUrl}?auto=format&fit=max&w=850&dpr=2 2x`; +const popupHref = `${imageUrl}?fit=max&auto=format`; +--- + +{ + video ? ( + + ) : ( + + ) +} diff --git a/astro/src/components/Img/ImgController.module.css b/astro/src/components/Img/ImgController.module.css new file mode 100644 index 00000000000..3ab9123ad89 --- /dev/null +++ b/astro/src/components/Img/ImgController.module.css @@ -0,0 +1,81 @@ +.img__figure { + margin: 0 0 1rem; +} + +.img__image { + max-width: 100%; + height: auto; + /* matches Hugo's $ddgray (#d6d6d6); no equivalent design token exists yet */ + border: 1px solid #d6d6d6; +} + +.img__link--popup { + cursor: zoom-in; +} + +/* Ported from hugo/layouts/partials/global-modals/global-modals.html + + hugo/assets/scripts/components/global-modals.js (Bootstrap 5 Modal). */ + +.img-lightbox__overlay { + position: fixed; + inset: 0; + background: var(--hugo-modal-overlay); + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; +} + +.img-lightbox__overlay[hidden] { + display: none; +} + +.img-lightbox__dialog { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + max-width: 90vw; + max-height: 90vh; +} + +.img-lightbox__spinner, +.img-lightbox__spinner::after { + border-radius: 50%; + width: 5em; + height: 5em; +} + +.img-lightbox__spinner { + font-size: 10px; + position: relative; + text-indent: -9999em; + border-top: 0.55em solid rgba(234, 234, 234, 0.5); + border-right: 0.55em solid rgba(234, 234, 234, 0.5); + border-bottom: 0.55em solid rgba(234, 234, 234, 0.5); + border-left: 0.55em solid #ffffff; + transform: translateZ(0); + animation: img-lightbox-spin 1.1s infinite linear; +} + +@keyframes img-lightbox-spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.img-lightbox__image { + max-width: 90vw; + max-height: 90vh; + object-fit: contain; +} + +.img-lightbox__caption { + color: #ffffff; + text-align: center; + margin: 0.5rem 0 0; +} diff --git a/astro/src/components/Img/ImgController.tsx b/astro/src/components/Img/ImgController.tsx new file mode 100644 index 00000000000..a7da0a29ef0 --- /dev/null +++ b/astro/src/components/Img/ImgController.tsx @@ -0,0 +1,286 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import type { JSX } from "preact"; +import styles from "./ImgController.module.css"; +import { classListFactory } from "@lib/cssUtils/classListFactory"; + +const cl = classListFactory(styles); + +interface ImgControllerProps { + srcset: string; + popupHref: string; + alt?: string; + caption?: string; + width?: string; + height?: string; + widthPercent?: number; + inline?: boolean; + popup?: boolean; +} + +interface DisplayedImage { + src: string; + alt?: string; + caption?: string; +} + +/** + * Resizes an element/container pair to a max-90vw/90vh box, preserving the + * image's aspect ratio. Ported from Hugo's global-modals.js `resize()` so + * the lightbox scales images identically to the Bootstrap modal it replaces. + */ +function applyLightboxResize( + imageElement: HTMLImageElement, + dialogElement: HTMLDivElement, + naturalWidth: number, + naturalHeight: number, +) { + if (!naturalWidth || !naturalHeight) return; + + const parentWidth = (window.innerWidth / 100) * 90; + const parentHeight = (window.innerHeight / 100) * 90; + + imageElement.style.width = ""; + imageElement.style.height = ""; + dialogElement.style.width = ""; + dialogElement.style.height = ""; + + let ratio = Math.max( + naturalWidth / (parentWidth - 1), + naturalHeight / (parentHeight - 1), + ); + + let width: number; + let height: number; + if (ratio > 1) { + ratio = naturalHeight / Math.floor(naturalHeight / ratio); + width = naturalWidth / ratio; + height = naturalHeight / ratio; + } else { + width = naturalWidth; + height = naturalHeight; + } + + imageElement.style.width = `${width}px`; + imageElement.style.height = `${height}px`; + dialogElement.style.width = `${width}px`; + dialogElement.style.height = `${height}px`; +} + +interface SizingProps { + width?: string; + height?: string; + widthPercent?: number; +} + +function PictureImage({ + srcset, + alt, + width, + height, + widthPercent, +}: SizingProps & { srcset: string; alt?: string }) { + return ( + + {alt} + + ); +} + +interface FigureWithLightboxProps extends SizingProps { + srcset: string; + alt?: string; + caption?: string; + popup: boolean; + popupHref: string; + onTriggerClick: (e: MouseEvent) => void; +} + +function FigureWithLightbox({ + srcset, + alt, + caption, + popup, + popupHref, + width, + height, + widthPercent, + onTriggerClick, +}: FigureWithLightboxProps) { + const image = ( + + ); + + return ( +
+ {popup ? ( + + {image} + + ) : ( + image + )} + {caption &&
{caption}
} +
+ ); +} + +export default function ImgController({ + srcset, + popupHref, + alt, + caption, + width, + height, + widthPercent, + inline = false, + popup = true, +}: ImgControllerProps) { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [displayed, setDisplayed] = useState(null); + const overlayRef = useRef(null); + const dialogRef = useRef(null); + const lightboxImageRef = useRef(null); + + useEffect(() => { + if (!open) return; + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("keydown", handleKey); + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", handleKey); + document.body.style.overflow = ""; + }; + }, [open]); + + useEffect(() => { + if (!open) { + setDisplayed(null); + setLoading(false); + return; + } + function handleResize() { + const imageElement = lightboxImageRef.current; + const dialogElement = dialogRef.current; + if (!imageElement || !dialogElement) return; + applyLightboxResize( + imageElement, + dialogElement, + imageElement.naturalWidth, + imageElement.naturalHeight, + ); + } + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [open]); + + function handleTriggerClick(e: MouseEvent) { + e.preventDefault(); + setDisplayed({ src: popupHref, alt, caption }); + setLoading(true); + setOpen(true); + } + + function handleOverlayClick(e: MouseEvent) { + if (e.target === overlayRef.current) setOpen(false); + } + + function handleLightboxImageLoad() { + const imageElement = lightboxImageRef.current; + const dialogElement = dialogRef.current; + if (imageElement && dialogElement) { + applyLightboxResize( + imageElement, + dialogElement, + imageElement.naturalWidth, + imageElement.naturalHeight, + ); + } + setLoading(false); + } + + let media: JSX.Element; + const showsLightbox = !inline && popup; + if (inline) { + media = ( + + ); + } else { + media = ( + + ); + } + + if (!showsLightbox) { + return media; + } + + return ( + <> + {media} +