Add click-to-enlarge modal viewer for chat images

This commit is contained in:
Federico Jaramillo Martinez
2026-07-17 09:52:54 +02:00
parent 15d25d8c2e
commit 6cd666f9b8
4 changed files with 73 additions and 3 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Let chat images open in a full-size modal viewer on click or keyboard activation, with backdrop and Escape to dismiss, a touch-friendly close button, and safe-area handling so the viewer clears device notches.
@@ -15,6 +15,7 @@ describe("ChatView image rendering", () => {
expect(templateStaticMarkup(rendered)).toContain("<img");
expect(templateStaticMarkup(rendered)).toContain('loading="lazy"');
expect(templateStaticMarkup(rendered)).toContain('role="button"');
expect(templateValuesAfterMarker(rendered, "src=")).toEqual(["data:image/png;base64,QUJD"]);
if (!Reflect.set(view, "pinnedToBottom", true)) throw new Error("Could not set ChatView.pinnedToBottom");
@@ -25,6 +26,23 @@ describe("ChatView image rendering", () => {
expect(scrollCalls).toBe(1);
});
// Clicking an image records the zoom target so the modal dialog can present
// it at full size, and dismissing clears the target.
it("opens and closes the image zoom target on click and close", () => {
const view = new ChatView();
const rendered = renderPart(view, { type: "image", mimeType: "image/png", data: "QUJD" });
const onClick = templateEventHandler(rendered, "@click=");
expect(zoomedImage(view)).toBeUndefined();
onClick(new Event("click"));
expect(zoomedImage(view)).toEqual({ src: "data:image/png;base64,QUJD", alt: "attached image" });
const close: unknown = Reflect.get(view, "closeImageZoom");
if (typeof close !== "function") throw new Error("ChatView.closeImageZoom is not callable");
close.call(view);
expect(zoomedImage(view)).toBeUndefined();
});
// Direct rendering keeps this node-environment test focused on the dedicated
// tool-image presentation without introducing a component-wide DOM shim.
it("renders tool images as labeled standard messages with final metadata", () => {
@@ -40,11 +58,15 @@ describe("ChatView image rendering", () => {
expect(markup).not.toContain('class="msg tool"');
expect(markup).toContain("<img");
expect(templateValuesAfterMarker(rendered, '<b class="label">')).toEqual(["read output"]);
expect(templateValuesAfterMarker(rendered, "title=")).toEqual([chatMessageMetadataLabel(message)]);
expect(templateValuesAfterMarker(rendered, "title=").filter((value) => typeof value === "string")).toEqual([chatMessageMetadataLabel(message)]);
expect(templateValuesAfterMarker(rendered, "data-scroll-anchor-id=")).toEqual(["m:7"]);
});
});
function zoomedImage(view: ChatView): unknown {
return Reflect.get(view, "zoomedImage");
}
type RenderPart = (this: ChatView, part: ChatLine["parts"][number], message?: ChatLine) => TemplateResult;
type RenderToolImageOutput = (this: ChatView, message: ChatLine, index: number, toolName?: string) => TemplateResult;
type TemplateEventHandler = (event: Event) => void;
+36 -1
View File
@@ -94,7 +94,9 @@ export class ChatView extends LitElement {
@property({ attribute: false }) onClearServerQueue?: () => void;
@property({ attribute: false }) onLoadMore?: () => void;
@query(".chat") private chat?: HTMLDivElement;
@query("dialog.image-zoom") private imageZoomDialog?: HTMLDialogElement;
@state() private pinnedToBottom = true;
@state() private zoomedImage: { src: string; alt: string } | undefined = undefined;
@state() private expandedMetaKey: string | undefined;
@state() private copiedMessageKey: string | undefined;
@state() private currentConversationIndex: number | undefined;
@@ -126,6 +128,15 @@ export class ChatView extends LitElement {
private readonly onImageLoad = (): void => {
if (this.pinnedToBottom) this.scrollToBottom();
};
private readonly openImageZoom = (src: string, alt: string): void => {
this.zoomedImage = { src, alt };
};
private readonly closeImageZoom = (): void => {
if (this.zoomedImage !== undefined) this.zoomedImage = undefined;
};
private readonly onImageZoomDialogClick = (event: MouseEvent): void => {
if (event.target === this.imageZoomDialog) this.closeImageZoom();
};
private readonly onPageHide = () => {
this.saveScrollPosition();
};
@@ -200,6 +211,14 @@ export class ChatView extends LitElement {
if (changed.has("messages") || changed.has("messageStart") || changed.has("messageTotal") || changed.has("hasMore") || changed.has("loadingMore")) this.scheduleConversationRailUpdate();
if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore")) this.continuePendingScrollRestore();
if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded();
if (changed.has("zoomedImage")) this.syncImageZoomDialog();
}
private syncImageZoomDialog(): void {
const dialog = this.imageZoomDialog;
if (dialog === undefined) return;
if (this.zoomedImage !== undefined && !dialog.open) dialog.showModal();
else if (this.zoomedImage === undefined && dialog.open) dialog.close();
}
override render() {
@@ -223,6 +242,18 @@ export class ChatView extends LitElement {
</div>
${this.renderActivityDock()}
</div>
${this.renderImageZoom()}
`;
}
private renderImageZoom() {
return html`
<dialog class="image-zoom" @click=${this.onImageZoomDialogClick} @close=${this.closeImageZoom} @cancel=${this.closeImageZoom}>
${this.zoomedImage === undefined ? null : html`
<button type="button" class="image-zoom-close" aria-label="Close image" @click=${this.closeImageZoom}>×</button>
<img class="image-zoom-full" src=${this.zoomedImage.src} alt=${this.zoomedImage.alt} />
`}
</dialog>
`;
}
@@ -528,7 +559,11 @@ export class ChatView extends LitElement {
<small>read ${part.path}</small>
</div>
`;
if (part.type === "image") return html`<img class="part chat-image" src=${`data:${part.mimeType};base64,${part.data}`} alt="attached image" loading="lazy" @load=${this.onImageLoad} />`;
if (part.type === "image") {
const src = `data:${part.mimeType};base64,${part.data}`;
const alt = "attached image";
return html`<img class="part chat-image" src=${src} alt=${alt} loading="lazy" role="button" tabindex="0" title="Click to enlarge" @load=${this.onImageLoad} @click=${() => { this.openImageZoom(src, alt); }} @keydown=${(event: KeyboardEvent) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); this.openImageZoom(src, alt); } }} />`;
}
if (part.type === "toolCall") return html`<div class="part tool-line">▶ ${part.toolName}<span class="summary">${part.summary}</span></div>`;
if (part.type === "toolExecution") return html`<tool-execution-view class="part" .execution=${part}></tool-execution-view>`;
if (part.type === "toolResult") return html`
+9 -1
View File
@@ -294,7 +294,15 @@ export const chatStyles = css`
.msg.event-group.live > summary { border-bottom-color: var(--pi-success-border); background: var(--pi-success-bg); color: var(--pi-success); }
.msg.event-group > summary .label { margin: 0; }
.group-body { padding: 0 12px 12px; }
.chat-image { display: block; max-width: 100%; max-height: 320px; margin: 8px 0 0; border: 1px solid var(--pi-border-muted); border-radius: 8px; object-fit: contain; }
.chat-image { display: block; max-width: 100%; max-height: 320px; margin: 8px 0 0; border: 1px solid var(--pi-border-muted); border-radius: 8px; object-fit: contain; cursor: zoom-in; }
.chat-image:focus-visible { outline: 2px solid var(--pi-accent, var(--pi-success-border)); outline-offset: 2px; }
dialog.image-zoom { position: fixed; inset: 0; margin: auto; max-width: calc(96vw - env(safe-area-inset-left) - env(safe-area-inset-right)); max-height: calc(96vh - env(safe-area-inset-top) - env(safe-area-inset-bottom)); width: fit-content; height: fit-content; padding: 0; border: none; background: transparent; overflow: visible; }
dialog.image-zoom[open] { display: flex; }
dialog.image-zoom::backdrop { background: rgba(0, 0, 0, 0.8); }
.image-zoom-full { display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; border-radius: 8px; object-fit: contain; cursor: zoom-out; }
.image-zoom-close { position: absolute; top: max(8px, env(safe-area-inset-top)); right: max(8px, env(safe-area-inset-right)); width: 44px; height: 44px; padding: 0; display: flex; align-items: center; justify-content: center; font-size: 22px; line-height: 1; color: var(--pi-text); background: color-mix(in srgb, var(--pi-surface) 85%, transparent); border: 1px solid var(--pi-border); border-radius: 50%; cursor: pointer; }
.image-zoom-close:focus-visible { outline: 2px solid var(--pi-accent, var(--pi-success-border)); outline-offset: 2px; }
@media (min-width: 640px) { .image-zoom-close { top: -14px; right: -14px; background: var(--pi-surface); } }
.group-msg { max-width: 100%; min-width: 0; box-sizing: border-box; padding: 10px 0; border-top: 1px solid var(--pi-border-muted); color: var(--pi-text); overflow: visible; }
.group-msg.tool { color: var(--pi-warning); }
.group-msg.tool-execution-shell { color: var(--pi-text); }