feat: add enlarged previews for console images
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import ConsoleAttachmentImage from "./ConsoleAttachmentImage.svelte";
|
||||
import {
|
||||
ConsoleAttachmentFetchError,
|
||||
loadConsoleAttachment,
|
||||
} from "./attachment-loader.ts";
|
||||
|
||||
vi.mock("./attachment-loader.ts", async (importOriginal) => ({
|
||||
...await importOriginal<typeof import("./attachment-loader.ts")>(),
|
||||
loadConsoleAttachment: vi.fn(),
|
||||
}));
|
||||
|
||||
const attachment = {
|
||||
attachment_id: "image-a",
|
||||
media_type: "image/png",
|
||||
byte_len: 3,
|
||||
};
|
||||
const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" });
|
||||
|
||||
beforeEach(() => {
|
||||
// Exercise the loader's no-observer fallback deterministically.
|
||||
vi.stubGlobal("IntersectionObserver", undefined);
|
||||
vi.mocked(loadConsoleAttachment).mockReset().mockResolvedValue(blob);
|
||||
let nextUrl = 0;
|
||||
vi.spyOn(URL, "createObjectURL").mockImplementation(() => `blob:image-${++nextUrl}`);
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("clicking a loaded image opens a modal using the same blob, without another fetch", async () => {
|
||||
const view = render(ConsoleAttachmentImage, { attachment, url: "/image/a" });
|
||||
const trigger = await view.findByRole("button", { name: "Enlarge tool output image" });
|
||||
expect(trigger.getAttribute("type")).toBe("button");
|
||||
expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
|
||||
expect(view.queryByRole("dialog")).toBeNull();
|
||||
trigger.focus();
|
||||
await fireEvent.click(trigger);
|
||||
|
||||
const dialog = view.getByRole("dialog", { name: "Image preview" }) as HTMLDialogElement;
|
||||
expect(dialog.open).toBe(true);
|
||||
expect(view.getByAltText("Enlarged tool output attachment").getAttribute("src"))
|
||||
.toBe(view.getByAltText("Tool output attachment").getAttribute("src"));
|
||||
expect(document.activeElement).toBe(view.getByRole("button", { name: "Close" }));
|
||||
expect(loadConsoleAttachment).toHaveBeenCalledTimes(1);
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
||||
|
||||
await fireEvent.click(view.getByAltText("Enlarged tool output attachment"));
|
||||
expect(dialog.open).toBe(true);
|
||||
await fireEvent.click(view.getByRole("button", { name: "Close" }));
|
||||
expect(dialog.open).toBe(false);
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Escape, native cancel, and background close the preview and return focus", async () => {
|
||||
const view = render(ConsoleAttachmentImage, { attachment, url: "/image/a" });
|
||||
const trigger = await view.findByRole("button", { name: "Enlarge tool output image" });
|
||||
await fireEvent.click(trigger);
|
||||
const dialog = view.getByRole("dialog") as HTMLDialogElement;
|
||||
const consoleShortcut = vi.fn();
|
||||
window.addEventListener("keydown", consoleShortcut);
|
||||
try {
|
||||
await fireEvent.keyDown(view.getByRole("button", { name: "Close" }), { key: "Escape" });
|
||||
expect(dialog.open).toBe(false);
|
||||
expect(consoleShortcut).not.toHaveBeenCalled();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
} finally {
|
||||
window.removeEventListener("keydown", consoleShortcut);
|
||||
}
|
||||
|
||||
await fireEvent.click(trigger);
|
||||
await fireEvent(dialog, new Event("cancel", { cancelable: true }));
|
||||
expect(dialog.open).toBe(false);
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
await fireEvent.click(trigger);
|
||||
await fireEvent.click(view.getByRole("button", { name: "Dismiss image preview" }));
|
||||
expect(dialog.open).toBe(false);
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
expect(loadConsoleAttachment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("unavailable or failed images do not offer a preview; Retry retains existing behavior", async () => {
|
||||
const view = render(ConsoleAttachmentImage, { attachment, url: null });
|
||||
expect(view.getByText("Image Session is unavailable")).toBeTruthy();
|
||||
expect(view.queryByRole("button")).toBeNull();
|
||||
vi.mocked(loadConsoleAttachment).mockRejectedValueOnce(new ConsoleAttachmentFetchError("expired", "HTTP 410"));
|
||||
await view.rerender({ attachment, url: "/image/a" });
|
||||
await view.findByText("Image retention has expired");
|
||||
expect(view.queryByRole("button", { name: "Enlarge tool output image" })).toBeNull();
|
||||
await fireEvent.click(view.getByRole("button", { name: "Retry" }));
|
||||
await view.findByRole("button", { name: "Enlarge tool output image" });
|
||||
expect(loadConsoleAttachment).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("replacing the attachment URL closes its preview and releases the old blob", async () => {
|
||||
const view = render(ConsoleAttachmentImage, { attachment, url: "/image/a" });
|
||||
await fireEvent.click(await view.findByRole("button", { name: "Enlarge tool output image" }));
|
||||
const oldDialog = view.getByRole("dialog") as HTMLDialogElement;
|
||||
await view.rerender({ attachment, url: "/image/b" });
|
||||
await waitFor(() => expect(view.getByAltText("Tool output attachment").getAttribute("src")).toBe("blob:image-2"));
|
||||
expect(oldDialog.open).toBe(false);
|
||||
expect(view.queryByRole("dialog")).toBeNull();
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:image-1");
|
||||
await fireEvent.click(view.getByRole("button", { name: "Enlarge tool output image" }));
|
||||
expect(view.getByAltText("Enlarged tool output attachment").getAttribute("src")).toBe("blob:image-2");
|
||||
const currentDialog = view.getByRole("dialog") as HTMLDialogElement;
|
||||
view.unmount();
|
||||
expect(currentDialog.open).toBe(false);
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:image-2");
|
||||
});
|
||||
|
||||
test("a late response from a replaced attachment cannot replace the current preview", async () => {
|
||||
let resolveOld!: (value: Blob) => void;
|
||||
vi.mocked(loadConsoleAttachment).mockImplementationOnce(() => new Promise((resolve) => resolveOld = resolve));
|
||||
const view = render(ConsoleAttachmentImage, { attachment, url: "/image/a" });
|
||||
await waitFor(() => expect(loadConsoleAttachment).toHaveBeenCalledWith("/image/a"));
|
||||
await view.rerender({ attachment, url: "/image/b" });
|
||||
await view.findByRole("button", { name: "Enlarge tool output image" });
|
||||
resolveOld(blob);
|
||||
await Promise.resolve();
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
||||
expect(view.getByAltText("Tool output attachment").getAttribute("src")).toBe("blob:image-1");
|
||||
});
|
||||
|
||||
test("unmounting during loading does not create a leaked blob or preview", async () => {
|
||||
let resolveLoad!: (value: Blob) => void;
|
||||
vi.mocked(loadConsoleAttachment).mockImplementationOnce(() => new Promise((resolve) => resolveLoad = resolve));
|
||||
const view = render(ConsoleAttachmentImage, { attachment, url: "/image/a" });
|
||||
await waitFor(() => expect(loadConsoleAttachment).toHaveBeenCalledOnce());
|
||||
view.unmount();
|
||||
resolveLoad(blob);
|
||||
await Promise.resolve();
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -13,20 +13,24 @@
|
||||
let { attachment, url }: Props = $props();
|
||||
let root: HTMLElement | null = $state(null);
|
||||
let objectUrl = $state<string | null>(null);
|
||||
let disposed = false;
|
||||
let previewDialog: HTMLDialogElement | null = $state(null);
|
||||
let previewTrigger: HTMLButtonElement | null = $state(null);
|
||||
let closeButton: HTMLButtonElement | null = $state(null);
|
||||
let sourceVersion = 0;
|
||||
let status = $state<"idle" | "loading" | "ready" | "missing" | "expired" | "unauthorized" | "network" | "failed">("idle");
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (!url || status === "loading" || status === "ready") return;
|
||||
const version = sourceVersion;
|
||||
status = "loading";
|
||||
try {
|
||||
const blob = await loadConsoleAttachment(url);
|
||||
if (disposed) return;
|
||||
if (version !== sourceVersion) return;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
status = "ready";
|
||||
} catch (error) {
|
||||
if (disposed) return;
|
||||
if (version !== sourceVersion) return;
|
||||
status = error instanceof ConsoleAttachmentFetchError ? error.kind : "failed";
|
||||
}
|
||||
}
|
||||
@@ -44,6 +48,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openPreview(): void {
|
||||
if (!previewDialog || previewDialog.open || status !== "ready") return;
|
||||
previewDialog.showModal();
|
||||
closeButton?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function closePreview(): void {
|
||||
previewDialog?.close();
|
||||
previewTrigger?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function previewKeydown(event: KeyboardEvent): void {
|
||||
// Console shortcuts must not run while the modal owns keyboard input.
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closePreview();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// A reused Console row must not retain another attachment's preview.
|
||||
url;
|
||||
status = "idle";
|
||||
objectUrl = null;
|
||||
return () => {
|
||||
sourceVersion += 1;
|
||||
previewDialog?.close();
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!root || !url || status !== "idle") return;
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
@@ -59,20 +95,47 @@
|
||||
observer.observe(root);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => () => {
|
||||
disposed = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="console-attachment" bind:this={root} data-attachment-id={attachment.attachment_id}>
|
||||
{#if status === "ready" && objectUrl}
|
||||
<img src={objectUrl} alt="Tool output attachment" />
|
||||
<button
|
||||
type="button"
|
||||
class="image-preview-trigger"
|
||||
aria-label="Enlarge tool output image"
|
||||
aria-haspopup="dialog"
|
||||
title="Click to enlarge"
|
||||
bind:this={previewTrigger}
|
||||
onclick={openPreview}
|
||||
>
|
||||
<img class="thumbnail" src={objectUrl} alt="Tool output attachment" />
|
||||
</button>
|
||||
<dialog
|
||||
class="image-preview"
|
||||
aria-label="Image preview"
|
||||
bind:this={previewDialog}
|
||||
onkeydown={previewKeydown}
|
||||
oncancel={(event) => { event.preventDefault(); closePreview(); }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="image-preview-backdrop"
|
||||
aria-label="Dismiss image preview"
|
||||
tabindex="-1"
|
||||
onclick={closePreview}
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="image-preview-close"
|
||||
bind:this={closeButton}
|
||||
onclick={closePreview}
|
||||
>Close</button>
|
||||
<img class="image-preview-full" src={objectUrl} alt="Enlarged tool output attachment" />
|
||||
</dialog>
|
||||
{:else}
|
||||
<span>{statusText()}</span>
|
||||
{#if url && ["missing", "expired", "unauthorized", "network", "failed"].includes(status)}
|
||||
<button type="button" onclick={() => void load()}>Retry</button>
|
||||
<button class="retry" type="button" onclick={() => void load()}>Retry</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -83,7 +146,20 @@
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
img {
|
||||
.image-preview-trigger {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.image-preview-trigger:focus-visible {
|
||||
outline: 2px solid var(--text-strong);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.thumbnail {
|
||||
display: block;
|
||||
max-width: min(100%, 52rem);
|
||||
max-height: 42rem;
|
||||
@@ -91,7 +167,67 @@
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
button {
|
||||
.image-preview {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right))
|
||||
max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
|
||||
border: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.image-preview[open] {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.image-preview::backdrop {
|
||||
background: rgb(0 0 0 / 85%);
|
||||
}
|
||||
.image-preview-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
.image-preview-close {
|
||||
position: relative;
|
||||
justify-self: end;
|
||||
padding: 0.5rem 1rem;
|
||||
color: white;
|
||||
background: #222;
|
||||
border: 1px solid #888;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.image-preview-close:focus-visible {
|
||||
outline: 2px solid white;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.image-preview-full {
|
||||
position: relative;
|
||||
display: block;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.retry {
|
||||
margin-left: var(--space-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user