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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7331-files-render-in-the-browser-images-and-s → main 6 commits fork 10 threads · 10 unresolved

  • Merged. scout/7331-files-render-in-the-browser-images-and-s was merged into main as 79cc06a.

Review

Sign in to review this change.
10 files changed +309 −2
README.md +7
@@ -369,6 +369,13 @@ work here.
369369
370370 ## What works today
371371
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
372379 The running list of what the forge does, one bullet each, lives in
373380 [`docs/STATUS.md`](docs/STATUS.md).
374381
docs/design/areas/prose.md +9
@@ -6,3 +6,12 @@ Markdown table cell alignment: a column whose delimiter row reads `:-:` or `--:`
66
77 - `.ta-c` — a centred column (`:-:`); text-align center.
88 - `.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
8787 name it once in `RENDERERS` (`render/renderers.ts`); `rendererFor` picks it. Point any element
8888 that needs the bytes at `input.media`, never at an inline copy, so
8989 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 @@
1010 * behaviour that a page depends on to be usable.
1111 */
1212
13 import { imageViewJs } from "./render/image.ts";
1314 import { STAGE_BADGE_JS } from "./stage-row.ts";
1415 import { paletteHelpersJs } from "./palette.ts";
1516 import { MEDIA_PLAY_JS } from "./render/player.ts";
@@ -1275,6 +1276,7 @@ export const shellJs = /* js */ `
12751276 (function () {
12761277 var doc = document;
12771278 ${modalJs}
1279 ${imageViewJs}
12781280
12791281 // Controls that only mean something with a script running stay hidden
12801282 // 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) }
outdated

Add flex-wrap for 360px once the button is in the meta line; reserve height to avoid layout shift.

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 btn("Actual size", { type: "button", size: "sm", extra: "js-only" }), and add pressed?: boolean to BtnOptions so that btnAttrs emits aria-pressed. The shell can find the button by a class passed through extra.

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 @@
77 import type { Html } from "../html.ts";
88 import type { Blob } from "../../view/source.ts";
99 import { audio } from "./audio.ts";
10 import { image } from "./image.ts";
1011 import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts";
1112 import { markdown } from "./markdown.ts";
1213 import { video } from "./video.ts";
@@ -35,6 +36,8 @@ export interface Rendered {
3536 export type Renderer = (input: RenderInput) => Rendered;
3637
3738 export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
39 image,
40 svg: image,
3841 markdown,
3942 audio,
4043 video,
src/web/style/content.ts +10
@@ -287,6 +287,16 @@ form.inline { display: inline; margin-left: auto; }
287287 .media-pane .media-player { display: block; width: 100%; max-width: 100%; }
outdated

Same here: min-height: 44px on the meta line makes it taller than the rest of the bar on desktop. Drop it, or move it under pointer: coarse. The text slot's min-width already keeps the line from shifting.

288288 .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.

289289
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. components.ts:69 already raises it under @media (pointer: coarse), as §4 asks. Keep only margin-left: auto.

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
290300 /* ---- prose (markdown) ---- */
291301 .prose { padding: 16px 20px; font-size: 15px; line-height: 1.65; overflow-wrap: break-word; }
292302 .prose > * { max-width: var(--measure); }
src/web/ui.ts +3 −1
@@ -171,6 +171,8 @@ export interface BtnOptions {
171171 external?: boolean;
172172 disabled?: boolean;
173173 current?: boolean;
174 /** A toggle: emits `aria-pressed` with its state. */
175 pressed?: boolean;
174176 extra?: string;
175177 /** The element's id, for a script or a fragment link that finds it. */
176178 id?: string;
@@ -184,7 +186,7 @@ function btnAttrs(o: BtnOptions): Html {
184186 ${o.label ? html`aria-label="${o.label}"` : null}
185187 ${o.shortcut ? html`data-shortcut="${o.shortcut}"` : null}
186188 ${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}`;
188190 }
189191
190192 /** 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", () => {
325325 expect(code).toContain('href="/alice/gallery/media/main/src/a.ts?download=1"');
326326
327327 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"');
329329 expect(png).toContain('href="/alice/gallery/media/main/art/logo.png?download=1"');
330330 });
331331 });
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. kind: "binary" routes to the old blobBody notice, and a real image never has that shape. Either drop it, or test the cap this pull relies on: over RENDER_CAP.image there is no <img>, under it there is.

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 shellJs. Run imageViewJs against a fake doc, the way feed-day-divider.test.ts:87 does. Then assert the dimension fill, the zoom toggle with aria-pressed and the label, and that a drag over 3px does not swallow the next click.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs