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 { attachment, url }: Props = $props();
|
||||||
let root: HTMLElement | null = $state(null);
|
let root: HTMLElement | null = $state(null);
|
||||||
let objectUrl = $state<string | null>(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");
|
let status = $state<"idle" | "loading" | "ready" | "missing" | "expired" | "unauthorized" | "network" | "failed">("idle");
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
if (!url || status === "loading" || status === "ready") return;
|
if (!url || status === "loading" || status === "ready") return;
|
||||||
|
const version = sourceVersion;
|
||||||
status = "loading";
|
status = "loading";
|
||||||
try {
|
try {
|
||||||
const blob = await loadConsoleAttachment(url);
|
const blob = await loadConsoleAttachment(url);
|
||||||
if (disposed) return;
|
if (version !== sourceVersion) return;
|
||||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
objectUrl = URL.createObjectURL(blob);
|
objectUrl = URL.createObjectURL(blob);
|
||||||
status = "ready";
|
status = "ready";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (disposed) return;
|
if (version !== sourceVersion) return;
|
||||||
status = error instanceof ConsoleAttachmentFetchError ? error.kind : "failed";
|
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(() => {
|
$effect(() => {
|
||||||
if (!root || !url || status !== "idle") return;
|
if (!root || !url || status !== "idle") return;
|
||||||
if (typeof IntersectionObserver === "undefined") {
|
if (typeof IntersectionObserver === "undefined") {
|
||||||
@@ -59,20 +95,47 @@
|
|||||||
observer.observe(root);
|
observer.observe(root);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => () => {
|
|
||||||
disposed = true;
|
|
||||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="console-attachment" bind:this={root} data-attachment-id={attachment.attachment_id}>
|
<div class="console-attachment" bind:this={root} data-attachment-id={attachment.attachment_id}>
|
||||||
{#if status === "ready" && objectUrl}
|
{#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}
|
{:else}
|
||||||
<span>{statusText()}</span>
|
<span>{statusText()}</span>
|
||||||
{#if url && ["missing", "expired", "unauthorized", "network", "failed"].includes(status)}
|
{#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}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +146,20 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: var(--font-size-compact);
|
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;
|
display: block;
|
||||||
max-width: min(100%, 52rem);
|
max-width: min(100%, 52rem);
|
||||||
max-height: 42rem;
|
max-height: 42rem;
|
||||||
@@ -91,7 +167,67 @@
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 0.5rem;
|
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);
|
margin-left: var(--space-2);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user