Public: anyone can read this; the crew can change it.
Files render in the browser: images and SVG inline, fit to the pane, with zoom and their real size #7387
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7331-files-render-in-the-browser-images-and-s → main 6 commits fork 10 threads · 10 unresolved
README.md +7
| @@ -369,6 +369,13 @@ work here. | ||
| 369 | 369 | |
| 370 | 370 | ## What works today |
| 371 | 371 | |
| 372 | Open an image or SVG in a repository and it is drawn, fit to the pane. Press | |
| 373 | **Actual size** (or click the picture) for 100%; the pane scrolls or drags to | |
| 374 | pan. The header shows its pixel size, file size and format; **Source**, | |
| 375 | **Raw** and **Download** stay beside it. The terminal's file view draws a | |
| 376 | binary picture where the terminal can, and otherwise names the file and its | |
| 377 | size; SVG there shows as its source text. | |
| 378 | ||
| 372 | 379 | The running list of what the forge does, one bullet each, lives in |
| 373 | 380 | [](docs/STATUS.md). |
| 374 | 381 |
docs/design/areas/prose.md +9
| @@ -6,3 +6,12 @@ Markdown table cell alignment: a column whose delimiter row reads `:-:` or `--:` | ||
| 6 | 6 | |
| 7 | 7 | - `.ta-c` — a centred column (`:-:`); text-align center. |
| 8 | 8 | - `.ta-r` — a right-aligned column (`--:`); text-align right. |
| 9 | ||
| 10 | ## Image view | |
| 11 | ||
| 12 | - `.imgview` — the card a rendered image or SVG sits in (`src/web/render/image.ts`). | |
| 13 | - `.imgview-meta` — its header line: pixel size, file size, format. | |
| 14 | - `.imgview-dim` — the slot the browser fills with the pixel size. | |
| 15 | - `.imgview-pane` — the checkerboard pane; `.is-actual` shows the image at 100% and pans. | |
| 16 | - `.imgview-img` — the image itself, fit to the pane. | |
| 17 | - `.imgview-zoom` — the Actual size / Fit toggle in the header line, pushed to its right edge. |
docs/features/file-media.md +11
| @@ -87,3 +87,14 @@ path, size, body, and `media`, the media route's address; HTML out), and | ||
| 87 | 87 | name it once in `RENDERERS` (`render/renderers.ts`); `rendererFor` picks it. Point any element |
| 88 | 88 | that needs the bytes at `input.media`, never at an inline copy, so |
| 89 | 89 | repository content only ever runs under the media route's sandbox. `render/markdown.ts` is the example. |
| 90 | ||
| 91 | ## Images and SVG (#7331) | |
| 92 | ||
| 93 | `render/image.ts` draws the `image` and `svg` kinds as an `<img>` at the media | |
| 94 | route; an SVG is never inlined. The picture fits the pane; a click shows it at | |
| 95 | 100% and the pane scrolls or drags to pan. A checkerboard sits behind it for | |
| 96 | transparency. The header line carries the pixel size (read in the browser from | |
| 97 | `naturalWidth`/`naturalHeight` by a few lines in the shell script, so no new | |
| 98 | request), the file size and the format. Animated GIF and WebP play, since the | |
| 99 | browser draws the original bytes. Over `RENDER_CAP` the page shows the | |
| 100 | "too large" notice and Download. |
src/web/client.ts +2
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | * behaviour that a page depends on to be usable. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { imageViewJs } from "./render/image.ts"; | |
| 13 | 14 | import { STAGE_BADGE_JS } from "./stage-row.ts"; |
| 14 | 15 | import { paletteHelpersJs } from "./palette.ts"; |
| 15 | 16 | import { MEDIA_PLAY_JS } from "./render/player.ts"; |
| @@ -1275,6 +1276,7 @@ export const shellJs = /* js */ ` | ||
| 1275 | 1276 | (function () { |
| 1276 | 1277 | var doc = document; |
| 1277 | 1278 | ${modalJs} |
| 1279 | ${imageViewJs} | |
| 1278 | 1280 | |
| 1279 | 1281 | // Controls that only mean something with a script running stay hidden |
| 1280 | 1282 | // until one is: no dead buttons on a page with JavaScript switched off. |
src/web/render/image.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | /** | |
| 2 | * An image or an SVG as a picture: an `<img>` pointed at the media route, | |
| 3 | * never the bytes inlined, so an SVG's script cannot run (the route sends it | |
| 4 | * under a sandbox CSP). Fit to the pane; a click shows it at 100% and the | |
| 5 | * pane scrolls (or drags) to pan. The pixel size is read in the browser by | |
| 6 | * `imageViewJs`, which the shell script carries. | |
| 7 | */ | |
| 8 | ||
| 9 | import { html, when } from "../html.ts"; | |
| 10 | import { btn, fileSize } from "../ui.ts"; | |
| 11 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 12 | ||
| 13 | /** Width and height from a PNG or GIF header, so the box exists before the bytes arrive. */ | |
| 14 | function headerSize(body: Uint8Array): { w: number; h: number } | undefined { | |
| 15 | const v = new DataView(body.buffer, body.byteOffset, body.byteLength); | |
| 16 | const sig = (text: string): boolean => | |
| 17 | body.length >= text.length && [...text].every((c, i) => body[i] === c.charCodeAt(0)); | |
| 18 | const size = | |
| 19 | body.length >= 24 && sig("\x89PNG") | |
| 20 | ? { w: v.getUint32(16), h: v.getUint32(20) } | |
| 21 | : body.length >= 10 && sig("GIF8") | |
| 22 | ? { w: v.getUint16(6, true), h: v.getUint16(8, true) } | |
| 23 | : undefined; | |
| 24 | return size !== undefined && size.w > 0 && size.h > 0 ? size : undefined; | |
| 25 | } | |
| 26 | ||
| 27 | export function image(input: RenderInput): Rendered { | |
outdated
Keyboard/SR: no focusable control for zoom. Add a JS-only button here and make the pane tabindex=0 role=region aria-label. |
||
| 28 | const name = input.path.slice(input.path.lastIndexOf("/") + 1); | |
| 29 | const dot = name.lastIndexOf("."); | |
| 30 | const format = dot <= 0 ? "" : name.slice(dot + 1).toUpperCase(); | |
| 31 | const known = headerSize(input.body); | |
| 32 | return { | |
| 33 | lines: 0, | |
| 34 | content: html`<div class="card imgview"> | |
| 35 | <div class="imgview-meta muted"> | |
| 36 | <span class="imgview-dim" data-img-dim></span> | |
| 37 | <span>${fileSize(input.size)}</span> | |
| 38 | <span>${format}</span> | |
| 39 | ${btn("Actual size", { type: "button", size: "sm", pressed: false, extra: "js-only imgview-zoom" })} | |
outdated
This hand-rolls the button component. Use |
||
| 40 | </div> | |
| 41 | <div | |
| 42 | class="imgview-pane" | |
| 43 | data-img-pane | |
| 44 | tabindex="0" | |
| 45 | role="region" | |
| 46 | aria-label="${`Image ${name}; arrow keys pan when at actual size`}" | |
| 47 | > | |
| 48 | <img | |
| 49 | class="imgview-img" | |
| 50 | src="${input.media}" | |
| 51 | alt="${name}" | |
| 52 | decoding="async" | |
| 53 | ${when(known !== undefined, () => html`width="${String(known?.w)}" height="${String(known?.h)}"`)} | |
| 54 | data-img-view | |
| 55 | /> | |
| 56 | </div> | |
| 57 | </div>`, | |
| 58 | }; | |
| 59 | } | |
| 60 | ||
| 61 | /** Delegated, so it works for an image already loaded and one still coming. */ | |
| 62 | export const imageViewJs = /* js */ ` | |
outdated
moved is never cleared if mouseup happens off the image: the next click is swallowed. Clear it on mouseup. |
||
| 63 | (function () { | |
| 64 | function dims(img) { | |
| 65 | var slot = img.closest('.imgview') && img.closest('.imgview').querySelector('[data-img-dim]'); | |
| 66 | if (slot && img.naturalWidth > 0) slot.textContent = img.naturalWidth + ' \\u00d7 ' + img.naturalHeight + ' px'; | |
| 67 | } | |
| 68 | doc.addEventListener('load', function (e) { | |
| 69 | if (e.target && e.target.matches && e.target.matches('img[data-img-view]')) dims(e.target); | |
| 70 | }, true); | |
| 71 | doc.querySelectorAll('img[data-img-view]').forEach(function (img) { if (img.complete) dims(img); }); | |
| 72 | function zoom(pane) { | |
| 73 | var on = pane.classList.toggle('is-actual'); | |
| 74 | var b = pane.closest('.imgview').querySelector('.imgview-zoom'); | |
| 75 | if (b) { b.setAttribute('aria-pressed', on ? 'true' : 'false'); b.textContent = on ? 'Fit' : 'Actual size'; } | |
| 76 | } | |
| 77 | var drag = null, dragged = false; | |
| 78 | doc.addEventListener('click', function (e) { | |
| 79 | var t = e.target && e.target.closest ? e.target : null; | |
| 80 | var btn = t && t.closest('.imgview-zoom'); | |
| 81 | if (btn) { zoom(btn.closest('.imgview').querySelector('[data-img-pane]')); return; } | |
| 82 | if (t && t.closest('img[data-img-view]') && !dragged) zoom(t.closest('[data-img-pane]')); | |
| 83 | }); | |
| 84 | doc.addEventListener('pointerdown', function (e) { | |
| 85 | var pane = e.pointerType === 'mouse' && e.button === 0 && e.target.closest && e.target.closest('[data-img-pane].is-actual'); | |
| 86 | if (!pane) return; | |
| 87 | e.preventDefault(); | |
| 88 | drag = { pane: pane, x: e.clientX, y: e.clientY, l: pane.scrollLeft, t: pane.scrollTop }; | |
| 89 | }); | |
| 90 | doc.addEventListener('pointermove', function (e) { | |
| 91 | if (!drag) return; | |
| 92 | var dx = e.clientX - drag.x, dy = e.clientY - drag.y; | |
| 93 | if (Math.abs(dx) + Math.abs(dy) > 3) dragged = true; | |
| 94 | drag.pane.scrollLeft = drag.l - dx; | |
| 95 | drag.pane.scrollTop = drag.t - dy; | |
| 96 | }); | |
| 97 | function release() { drag = null; setTimeout(function () { dragged = false; }, 0); } | |
| 98 | doc.addEventListener('pointerup', release); | |
| 99 | doc.addEventListener('pointercancel', release); | |
| 100 | })(); | |
| 101 | `; |
src/web/render/renderers.ts +3
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | import type { Html } from "../html.ts"; |
| 8 | 8 | import type { Blob } from "../../view/source.ts"; |
| 9 | 9 | import { audio } from "./audio.ts"; |
| 10 | import { image } from "./image.ts"; | |
| 10 | 11 | import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts"; |
| 11 | 12 | import { markdown } from "./markdown.ts"; |
| 12 | 13 | import { video } from "./video.ts"; |
| @@ -35,6 +36,8 @@ export interface Rendered { | ||
| 35 | 36 | export type Renderer = (input: RenderInput) => Rendered; |
| 36 | 37 | |
| 37 | 38 | export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { |
| 39 | image, | |
| 40 | svg: image, | |
| 38 | 41 | markdown, |
| 39 | 42 | audio, |
| 40 | 43 | video, |
src/web/style/content.ts +10
| @@ -287,6 +287,16 @@ form.inline { display: inline; margin-left: auto; } | ||
| 287 | 287 | .media-pane .media-player { display: block; width: 100%; max-width: 100%; } |
outdated
Same here: |
||
| 288 | 288 | .media-pane video.media-player { height: auto; max-height: 80vh; background: var(--muted); border-radius: var(--radius-sm); } |
outdated
Reserve min-height on the pane so it doesn't jump when the image loads; check --muted checkerboard in both themes. |
||
| 289 | 289 | |
| 290 | /* ---- image view ---- */ | |
| 291 | .imgview-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; padding: 4px 16px; font-size: 12.5px; border-bottom: 1px solid var(--border); } | |
outdated
This forces 44px onto an sm button for every pointer. |
||
| 292 | .imgview-dim { min-width: 9ch; } | |
| 293 | .imgview-pane { overflow: auto; min-height: 160px; max-height: 80vh; text-align: center; padding: 12px; | |
| 294 | background: repeating-conic-gradient(color-mix(in srgb, var(--fg) 8%, var(--bg)) 0 25%, transparent 0 50%) 0 0 / 16px 16px; } | |
| 295 | .imgview-zoom { margin-left: auto; } | |
| 296 | .imgview-img { max-width: 100%; height: auto; cursor: zoom-in; vertical-align: middle; } | |
| 297 | .imgview-pane.is-actual { text-align: left; cursor: grab; } | |
| 298 | .imgview-pane.is-actual .imgview-img { max-width: none; cursor: zoom-out; } | |
| 299 | ||
| 290 | 300 | /* ---- prose (markdown) ---- */ |
| 291 | 301 | .prose { padding: 16px 20px; font-size: 15px; line-height: 1.65; overflow-wrap: break-word; } |
| 292 | 302 | .prose > * { max-width: var(--measure); } |
src/web/ui.ts +3 −1
| @@ -171,6 +171,8 @@ export interface BtnOptions { | ||
| 171 | 171 | external?: boolean; |
| 172 | 172 | disabled?: boolean; |
| 173 | 173 | current?: boolean; |
| 174 | /** A toggle: emits `aria-pressed` with its state. */ | |
| 175 | pressed?: boolean; | |
| 174 | 176 | extra?: string; |
| 175 | 177 | /** The element's id, for a script or a fragment link that finds it. */ |
| 176 | 178 | id?: string; |
| @@ -184,7 +186,7 @@ function btnAttrs(o: BtnOptions): Html { | ||
| 184 | 186 | ${o.label ? html`aria-label="${o.label}"` : null} |
| 185 | 187 | ${o.shortcut ? html`data-shortcut="${o.shortcut}"` : null} |
| 186 | 188 | ${o.copy ? html`data-copy="${o.copy}"` : null} |
| 187 | ${o.current ? raw('aria-current="page"') : null}`; | |
| 189 | ${o.pressed !== undefined ? html`aria-pressed="${o.pressed ? "true" : "false"}"` : null}${o.current ? raw('aria-current="page"') : null}`; | |
| 188 | 190 | } |
| 189 | 191 | |
| 190 | 192 | /** A button or a link that looks like one. Content is the visible label. */ |
test/media.test.ts +1 −1
| @@ -325,7 +325,7 @@ describe("the blob page's switch", () => { | ||
| 325 | 325 | expect(code).toContain('href="/alice/gallery/media/main/src/a.ts?download=1"'); |
| 326 | 326 | |
| 327 | 327 | const png = flat(await (await get("/alice/gallery/blob/main/art/logo.png")).text()); |
| 328 | expect(png).toContain("Binary file"); | |
| 328 | expect(png).toContain('src="/alice/gallery/media/main/art/logo.png"'); | |
| 329 | 329 | expect(png).toContain('href="/alice/gallery/media/main/art/logo.png?download=1"'); |
| 330 | 330 | }); |
| 331 | 331 | }); |
test/render-image.test.ts added +162
| @@ -0,0 +1,162 @@ | ||
| 1 | /** Image kinds on the blob page: an <img> at the media route, never inline bytes. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { renderBlob } from "../src/web/pages/repo.ts"; | |
| 11 | import type { RepoScope } from "../src/web/scope.ts"; | |
| 12 | import type { RefSet } from "../src/web/pages/refs.ts"; | |
| 13 | import { imageViewJs } from "../src/web/render/image.ts"; | |
| 14 | import { RENDER_CAP } from "../src/web/render/kinds.ts"; | |
| 15 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | /** A PNG header that says 640 x 480. */ | |
| 21 | const PNG = new Uint8Array(32); | |
| 22 | PNG.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, 2, 0x80, 0, 0, 1, 0xe0]); | |
| 23 | const GIF = new TextEncoder().encode("GIF89a\x01\x00\x01\x00"); | |
| 24 | const WEBP = new TextEncoder().encode("RIFF\x10\x00\x00\x00WEBPVP8 "); | |
| 25 | const SVG = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><rect id="UNIQUE-RECT"/></svg>\n'; | |
| 26 | ||
| 27 | let dataDir = ""; | |
| 28 | let app: App; | |
| 29 | let servers: Servers; | |
| 30 | ||
| 31 | beforeAll(async () => { | |
| 32 | dataDir = await mkdtemp(join(tmpdir(), "forge-image-")); | |
| 33 | app = createApp({ dataDir, port: 0 }); | |
| 34 | servers = await serve(app); | |
| 35 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-image-passphrase" }); | |
| 36 | if (!a.ok) throw new Error("signup failed"); | |
| 37 | const repo = await createRepo(app, a.value, { name: "pics", visibility: "public" }); | |
| 38 | if (!repo.ok) throw new Error("repo failed"); | |
| 39 | const made = await commitToBranch(app, a.value, repo.value, { | |
| 40 | message: "pictures", | |
| 41 | files: [ | |
| 42 | { path: "a.png", content: PNG }, | |
| 43 | { path: "b.gif", content: GIF }, | |
| 44 | { path: "c.webp", content: WEBP }, | |
| 45 | { path: "d.svg", content: SVG }, | |
| 46 | ], | |
| 47 | }); | |
| 48 | if (!made.ok) throw new Error("commit failed"); | |
| 49 | }); | |
| 50 | ||
| 51 | afterAll(async () => { | |
| 52 | await servers.stop(); | |
| 53 | app.close(); | |
| 54 | await rm(dataDir, { recursive: true, force: true }); | |
| 55 | }); | |
| 56 | ||
| 57 | const page = async (path: string, query = ""): Promise<string> => | |
| 58 | (await (await fetch(`${servers.url}/alice/pics/blob/main/${path}${query}`)).text()).replace(/\s+/g, " "); | |
| 59 | ||
| 60 | describe("image blob pages", () => { | |
| 61 | for (const [file, format] of [["a.png", "PNG"], ["b.gif", "GIF"], ["c.webp", "WEBP"], ["d.svg", "SVG"]]) { | |
| 62 | test(`${file} is an <img> at the media route with a dimension slot`, async () => { | |
| 63 | const html = await page(file!); | |
| 64 | expect(html).toContain(`src="/alice/pics/media/main/${file}"`); | |
| 65 | expect(html).toContain("data-img-dim"); | |
| 66 | expect(html).toContain(`<span>${format}</span>`); | |
| 67 | expect(html).toContain('href="/alice/pics/blob/main/' + file + '?view=source"'); | |
| 68 | expect(html).toContain("?download=1"); | |
| 69 | }); | |
| 70 | } | |
| 71 | ||
| 72 | test("an SVG is never inlined", async () => { | |
| 73 | const html = await page("d.svg"); | |
| 74 | expect(html).not.toContain("UNIQUE-RECT"); | |
| 75 | expect(html).not.toContain("alert(1)"); | |
| 76 | }); | |
| 77 | ||
| 78 | test("the source view stays", async () => { | |
outdated
These string checks pass trivially. Assert the button/tabindex/aria in served HTML and cover the over-cap notice. |
||
| 79 | const html = await page("d.svg", "?view=source"); | |
| 80 | expect(html).not.toContain("data-img-view"); | |
| 81 | }); | |
| 82 | ||
| 83 | test("zoom is a real button and the pane can be reached by keyboard", async () => { | |
| 84 | const html = await page("a.png"); | |
| 85 | expect(html).toMatch(/<button[^>]*type="button"[^>]*imgview-zoom[^>]*aria-pressed="false"/); | |
| 86 | expect(html).toMatch(/<button[^>]*class="btn js-only"/); | |
| 87 | expect(html).toMatch(/data-img-pane tabindex="0" role="region" aria-label="Image a\.png/); | |
| 88 | }); | |
| 89 | ||
| 90 | test("a PNG's header size reserves the box before the bytes arrive", async () => { | |
| 91 | const html = await page("a.png"); | |
| 92 | expect(html).toContain('width="640" height="480"'); | |
| 93 | expect(await page("c.webp")).not.toContain('width=""'); | |
| 94 | }); | |
| 95 | ||
outdated
This passes on main without the change. |
||
| 96 | test("the image cap decides: over it no <img>, under it one", () => { | |
| 97 | const draw = (size: number): string => | |
| 98 | renderBlob({ base: "/alice/pics", caps: { write: false }, facts: { name: "pics" } } as unknown as RepoScope, { | |
| 99 | refs: { branches: ["main"], tags: [] } as RefSet, | |
| 100 | ref: "main", | |
| 101 | path: "a.png", | |
| 102 | blob: { kind: "text" as const, body: PNG, size, oid: "0".repeat(40) } as never, | |
| 103 | }).text; | |
| 104 | expect(draw(RENDER_CAP.image)).toContain("data-img-view"); | |
| 105 | expect(draw(RENDER_CAP.image + 1)).not.toContain("data-img-view"); | |
| 106 | }); | |
| 107 | ||
| 108 | }); | |
| 109 | ||
outdated
This passes when the drag logic is broken: it only checks that two strings appear in |
||
| 110 | /** `imageViewJs` run against a small fake of the DOM it touches. */ | |
| 111 | describe("the image view's script", () => { | |
| 112 | type Fn = (e: Record<string, unknown>) => void; | |
| 113 | const make = () => { | |
| 114 | const on: Record<string, Fn[]> = {}; | |
| 115 | const slot = { textContent: "" }; | |
| 116 | const pane = { cls: new Set<string>(), scrollLeft: 0, scrollTop: 0, classList: { toggle: (c: string) => (pane.cls.has(c) ? (pane.cls.delete(c), false) : (pane.cls.add(c), true)) } } as Record<string, any>; | |
| 117 | const button = { attrs: { "aria-pressed": "false" } as Record<string, string>, textContent: "Actual size", setAttribute: (k: string, v: string) => { button.attrs[k] = v; } } as Record<string, any>; | |
| 118 | const root = { querySelector: (q: string) => ({ "[data-img-dim]": slot, ".imgview-zoom": button, "[data-img-pane]": pane })[q] }; | |
| 119 | const img = { naturalWidth: 640, naturalHeight: 480, complete: false, matches: () => true } as Record<string, any>; | |
| 120 | Object.assign(img, { closest: (q: string) => ({ ".imgview": root, "img[data-img-view]": img, "[data-img-pane]": pane, "[data-img-pane].is-actual": pane.cls.has("is-actual") ? pane : null })[q] ?? null }); | |
| 121 | Object.assign(pane, { closest: (q: string) => (q === ".imgview" ? root : null) }); | |
| 122 | Object.assign(button, { closest: (q: string) => ({ ".imgview": root, ".imgview-zoom": button })[q] ?? null }); | |
| 123 | const doc = { addEventListener: (t: string, f: Fn) => void (on[t] ??= []).push(f), querySelectorAll: () => [] }; | |
| 124 | new Function("doc", imageViewJs)(doc); | |
| 125 | const fire = (t: string, e: Record<string, unknown>) => on[t]?.forEach((f) => f(e)); | |
| 126 | return { fire, slot, pane, button, img }; | |
| 127 | }; | |
| 128 | ||
| 129 | test("a load writes the pixel size into the slot", () => { | |
| 130 | const v = make(); | |
| 131 | v.fire("load", { target: v.img }); | |
| 132 | expect(v.slot.textContent).toBe("640 \u00d7 480 px"); | |
| 133 | }); | |
| 134 | ||
| 135 | test("the button toggles actual size, aria-pressed and its label, and back", () => { | |
| 136 | const v = make(); | |
| 137 | v.fire("click", { target: v.button }); | |
| 138 | expect(v.pane.cls.has("is-actual")).toBe(true); | |
| 139 | expect(v.button.attrs["aria-pressed"]).toBe("true"); | |
| 140 | expect(v.button.textContent).toBe("Fit"); | |
| 141 | v.fire("click", { target: v.button }); | |
| 142 | expect(v.pane.cls.has("is-actual")).toBe(false); | |
| 143 | expect(v.button.attrs["aria-pressed"]).toBe("false"); | |
| 144 | expect(v.button.textContent).toBe("Actual size"); | |
| 145 | }); | |
| 146 | ||
| 147 | test("a drag past 3px does not toggle, and the click after release does", async () => { | |
| 148 | const v = make(); | |
| 149 | v.fire("click", { target: v.img }); | |
| 150 | expect(v.pane.cls.has("is-actual")).toBe(true); | |
| 151 | const mouse = { pointerType: "mouse", button: 0, target: v.img, clientX: 100, clientY: 100, preventDefault() {} }; | |
| 152 | v.fire("pointerdown", mouse); | |
| 153 | v.fire("pointermove", { ...mouse, clientX: 90, clientY: 100 }); | |
| 154 | expect(v.pane.scrollLeft).toBe(10); | |
| 155 | v.fire("pointerup", mouse); | |
| 156 | v.fire("click", { target: v.img }); | |
| 157 | expect(v.pane.cls.has("is-actual")).toBe(true); | |
| 158 | await new Promise((r) => setTimeout(r, 5)); | |
| 159 | v.fire("click", { target: v.img }); | |
| 160 | expect(v.pane.cls.has("is-actual")).toBe(false); | |
| 161 | }); | |
| 162 | }); |
Add flex-wrap for 360px once the button is in the meta line; reserve height to avoid layout shift.