diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d1739611..a016bdeea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f ### Fixes & Improvements +- The image viewer now opens with the whole image fitted to the screen on every device, instead of at 100%; images smaller than the screen stay at their real size. The zoom percentage is always relative to the image's real pixels, at every Interface Scale. Zoom out goes down to 10%, or further to the fitted size for very large images, in ×1.25 steps. **Fit to screen** and **1:1** (actual size) replace **Reset zoom**. Zooming keeps the centre of the view in place, and a zoomed-in image can now be scrolled to every edge; before, the parts outside the starting size were cut off. The viewer covers exactly the window at Compact, Comfortable and Large Interface Scale, and its buttons follow the **Touch Targets** setting, wrapping on narrow screens instead of being clipped. - Balance the composer icons on phones and narrow toolbars with a slightly smaller effort-level icon and a larger Slow-mode snail, while keeping menu icon sizes unchanged. - Keep the model picker on the left and reasoning effort, Fast, Slow, microphone, and Send/Stop controls in a right-aligned composer group, including wrapped mobile rows. - The Provider Usage panel now shows each account's email instead of omp's redacted form (`an*`); accounts without an email show as **Account N**. Only the email, plan and limits reach the browser. diff --git a/app/globals.css b/app/globals.css index 403544de2..2eeaf535e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1287,9 +1287,11 @@ pre, code { } .image-lightbox-dialog { - width: 100vw; + /* Viewport units ignore the root zoom (Interface Scale): divide so the + viewer covers exactly the window and "fit" fits the visible area. */ + width: calc(100vw / var(--ui-scale)); max-width: none; - height: 100dvh; + height: calc(100dvh / var(--ui-scale)); max-height: none; margin: 0; padding: 0; @@ -1307,8 +1309,9 @@ pre, code { } .image-lightbox-toolbar { display: flex; + flex-wrap: wrap; align-items: center; - gap: 10px; + gap: 6px 10px; min-height: 40px; padding: 7px 10px; background: var(--bg-panel); @@ -1326,12 +1329,15 @@ pre, code { } .image-lightbox-actions { display: flex; + flex-wrap: wrap; + justify-content: flex-end; align-items: center; gap: 6px; margin-left: auto; } .image-lightbox-stepper { display: flex; + flex: none; align-items: center; height: 24px; overflow: hidden; @@ -1365,6 +1371,18 @@ pre, code { border: 1px solid var(--border); border-radius: 5px; } +.image-lightbox-actual-size { + flex-basis: auto; + width: auto; + padding: 0 6px; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; +} +.image-lightbox-icon-button[aria-pressed="true"] { + background: var(--bg-selected); + color: var(--text); +} .image-lightbox-stepper button:hover:not(:disabled), .image-lightbox-icon-button:hover { background: var(--bg-hover); @@ -1400,10 +1418,10 @@ pre, code { } .image-lightbox-dialog .image-lightbox-img { display: block; + flex: none; margin: auto; max-width: none; - height: auto; - transition: transform var(--dur-fast) var(--ease-out-warm); + max-height: none; } @media (max-width: 480px) { @@ -1419,6 +1437,12 @@ pre, code { padding: 12px; } } +html[data-touch-targets="accessible"] .image-lightbox-stepper, +html[data-touch-targets="accessible"] :is(.image-lightbox-stepper button, .image-lightbox-icon-button, .image-lightbox-zoom-value) { + min-width: 44px; + height: 44px; + line-height: 44px; +} /* File viewer toolbar */ .file-viewer-toolbar { diff --git a/components/ImageLightbox.test.mjs b/components/ImageLightbox.test.mjs index b75047344..3d78e4f27 100644 --- a/components/ImageLightbox.test.mjs +++ b/components/ImageLightbox.test.mjs @@ -10,7 +10,7 @@ const jiti = createJiti(import.meta.url, { }); const { MessageView } = await jiti.import("./MessageView.tsx"); const { MarkdownBody } = await jiti.import("./MarkdownBody.tsx"); -const { ClickableImage } = await jiti.import("./ImageLightbox.tsx"); +const { ClickableImage, fitZoom, stepZoom } = await jiti.import("./ImageLightbox.tsx"); test("user image blocks render as click-to-preview thumbnails", () => { const html = renderToStaticMarkup(React.createElement(MessageView, { @@ -113,3 +113,23 @@ test("whitespace-only text around an image link still unwraps the anchor", () => test("missing image sources render nothing", () => { assert.equal(renderToStaticMarkup(React.createElement(ClickableImage, { src: "" })), ""); }); + +test("fitZoom fits big images to the viewport and keeps small ones at real size", () => { + // 6000×4000 in a 366×700 phone viewport: limited by width. + assert.equal(fitZoom({ width: 6000, height: 4000 }, { width: 366, height: 700 }), 0.061); + // Limited by height on a wide desktop. + assert.equal(fitZoom({ width: 1000, height: 2000 }, { width: 1800, height: 1000 }), 0.5); + assert.equal(fitZoom({ width: 200, height: 100 }, { width: 1800, height: 1000 }), 1); + // Not yet laid out or no intrinsic size: real size rather than 0 or Infinity. + assert.equal(fitZoom({ width: 6000, height: 4000 }, { width: 0, height: 0 }), 1); + assert.equal(fitZoom({ width: 0, height: 0 }, { width: 366, height: 700 }), 1); +}); + +test("stepZoom moves by ×1.25 down to min(fit, 10%) and up to 800%", () => { + assert.equal(stepZoom(1, 1, 1), 1.25); + assert.equal(stepZoom(1, -1, 1), 0.8); + assert.equal(stepZoom(0.061 * 1.25, -1, 0.061), 0.061); + assert.equal(stepZoom(0.061, -1, 0.061), 0.061, "cannot zoom out past the fitted size of a huge image"); + assert.equal(stepZoom(0.11, -1, 0.5), 0.1); + assert.equal(stepZoom(7, 1, 0.5), 8); +}); diff --git a/components/ImageLightbox.tsx b/components/ImageLightbox.tsx index 1eeb6bcba..de3fd59e4 100644 --- a/components/ImageLightbox.tsx +++ b/components/ImageLightbox.tsx @@ -1,12 +1,28 @@ "use client"; -import { useEffect, useRef, useState, type ImgHTMLAttributes } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, type ImgHTMLAttributes } from "react"; import { createPortal } from "react-dom"; +import { Maximize } from "lucide-react"; import { useI18n } from "@/lib/i18n"; -const ZOOM_MIN = 0.25; const ZOOM_MAX = 8; -const ZOOM_STEP = 0.25; +/** Lowest zoom when the fitted size is larger; big images can go below it down to their fit. */ +const ZOOM_FLOOR = 0.1; +const ZOOM_FACTOR = 1.25; + +interface Size { width: number; height: number } + +/** Scale that fits `image` inside `box`; never above 1, so small images keep their real size. */ +export function fitZoom(image: Size, box: Size): number { + if (image.width <= 0 || image.height <= 0 || box.width <= 0 || box.height <= 0) return 1; + return Math.min(1, box.width / image.width, box.height / image.height); +} + +/** One zoom step in or out, clamped to [min(fit, 10%), 800%]. */ +export function stepZoom(zoom: number, direction: 1 | -1, fit: number): number { + const next = direction > 0 ? zoom * ZOOM_FACTOR : zoom / ZOOM_FACTOR; + return Math.min(ZOOM_MAX, Math.max(Math.min(fit, ZOOM_FLOOR), next)); +} interface ClickableImageProps extends Omit, "src"> { /** Image source: string URL (data:, http(s):, blob:, /api/files/...) or Blob. */ @@ -74,9 +90,58 @@ export function ClickableImage({ src, alt, ...imgProps }: ClickableImageProps) { function ImageLightbox({ src, alt, onClose }: { src: ClickableImageProps["src"]; alt: string; onClose: () => void }) { const dialogRef = useRef(null); - const [zoom, setZoom] = useState(1); + const viewportRef = useRef(null); + // "fit" follows the window as it resizes; a number is a zoom the user chose. + const [zoom, setZoom] = useState("fit"); + const [natural, setNatural] = useState(null); + // Load finished either way: a broken image is shown as the browser renders it. + const [settled, setSettled] = useState(false); + const [box, setBox] = useState(null); + // Centre of the view as a fraction of the content, kept across a zoom change. + const anchorRef = useRef<{ x: number; y: number } | null>(null); + const [uiScale, setUiScale] = useState(1); const { t } = useI18n(); + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + // Interface Scale zooms the root, so layout pixels are uiScale screen + // pixels. Measure in screen pixels so 100% is one image pixel per pixel. + const uiScale = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--ui-scale")) || 1; + setUiScale(uiScale); + // contentRect excludes the viewport padding, so "fit" leaves the margin visible. + const observer = new ResizeObserver(([entry]) => { + setBox({ width: entry.contentRect.width * uiScale, height: entry.contentRect.height * uiScale }); + }); + observer.observe(viewport); + return () => observer.disconnect(); + }, []); + + const ready = natural !== null && box !== null; + const fit = ready ? fitZoom(natural, box) : 1; + const scale = zoom === "fit" ? fit : zoom; + const minZoom = Math.min(fit, ZOOM_FLOOR); + + const zoomTo = (next: number | "fit") => { + const viewport = viewportRef.current; + if (viewport) { + anchorRef.current = { + x: (viewport.scrollLeft + viewport.clientWidth / 2) / viewport.scrollWidth, + y: (viewport.scrollTop + viewport.clientHeight / 2) / viewport.scrollHeight, + }; + } + setZoom(next); + }; + + useLayoutEffect(() => { + const viewport = viewportRef.current; + const anchor = anchorRef.current; + if (!viewport || !anchor) return; + anchorRef.current = null; + viewport.scrollLeft = anchor.x * viewport.scrollWidth - viewport.clientWidth / 2; + viewport.scrollTop = anchor.y * viewport.scrollHeight - viewport.clientHeight / 2; + }, [scale]); + useEffect(() => { const dialog = dialogRef.current; if (!dialog) return; @@ -123,8 +188,8 @@ function ImageLightbox({ src, alt, onClose }: { src: ClickableImageProps["src"];
- {Math.round(zoom * 100)}% + {ready ? `${Math.round(scale * 100)}%` : "…"} +
{ if (event.target === event.currentTarget) onClose(); @@ -180,9 +255,18 @@ function ImageLightbox({ src, alt, onClose }: { src: ClickableImageProps["src"]; src={src} alt={alt} className="image-lightbox-img" - // `zoom` is non-standard (a no-op in Firefox < 126); transform - // scale is composited and supported everywhere. - style={{ transform: `scale(${zoom})` }} + onLoad={(event) => { + const { naturalWidth: width, naturalHeight: height } = event.currentTarget; + // Some engines report 0×0 for SVGs without an intrinsic size: show those unsized. + if (width > 0 && height > 0) setNatural({ width, height }); + setSettled(true); + }} + onError={() => setSettled(true)} + // Real layout size, not transform: scale, so a zoomed-in image + // scrolls to every edge. Hidden until it can be sized to fit. + style={ready + ? { width: natural.width * scale / uiScale, height: natural.height * scale / uiScale } + : settled ? undefined : { visibility: "hidden" }} />
diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index 074c3e095..3ae7cdcb4 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -802,7 +802,8 @@ "imagePreview.imageTitle": "Image", "imagePreview.open": "Open image preview", "imagePreview.openWithAlt": "Open image preview: {alt}", - "imagePreview.resetZoom": "Reset zoom", + "imagePreview.actualSize": "Actual size (100%)", + "imagePreview.fitToScreen": "Fit to screen", "imagePreview.viewerLabel": "Image preview", "imagePreview.zoomIn": "Zoom in", "imagePreview.zoomOut": "Zoom out", diff --git a/lib/i18n/locales/ja.json b/lib/i18n/locales/ja.json index 8408e7047..b3c31deac 100644 --- a/lib/i18n/locales/ja.json +++ b/lib/i18n/locales/ja.json @@ -768,7 +768,8 @@ "imagePreview.imageTitle": "画像", "imagePreview.open": "画像プレビューを開く", "imagePreview.openWithAlt": "画像プレビューを開く: {alt}", - "imagePreview.resetZoom": "ズームをリセット", + "imagePreview.actualSize": "実寸(100%)", + "imagePreview.fitToScreen": "画面に合わせる", "imagePreview.viewerLabel": "画像プレビュー", "imagePreview.zoomIn": "拡大", "imagePreview.zoomOut": "縮小", diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json index b6de27b5f..ccb24e865 100644 --- a/lib/i18n/locales/zh-CN.json +++ b/lib/i18n/locales/zh-CN.json @@ -768,7 +768,8 @@ "imagePreview.imageTitle": "图片", "imagePreview.open": "打开图片预览", "imagePreview.openWithAlt": "打开图片预览:{alt}", - "imagePreview.resetZoom": "重置缩放", + "imagePreview.actualSize": "实际大小(100%)", + "imagePreview.fitToScreen": "适应屏幕", "imagePreview.viewerLabel": "图片预览", "imagePreview.zoomIn": "放大", "imagePreview.zoomOut": "缩小",