Archived
Add click-to-enlarge modal viewer for chat images
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user