Images in chat and descriptions open large on click

The channel description preview stays open underneath while an image from it
is shown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 23:07:24 +02:00
co-authored by Claude Opus 5.5
parent e7b317313a
commit 2510633538
6 changed files with 72 additions and 3 deletions
+14 -1
View File
@@ -15,6 +15,7 @@
import ChannelDialog from './ui/ChannelDialog.svelte';
import PublicServers from './ui/PublicServers.svelte';
import ServerInfoDialog from './ui/ServerInfoDialog.svelte';
import ImageViewer from './ui/ImageViewer.svelte';
import ShareDialog from './ui/ShareDialog.svelte';
import { share } from './lib/share.svelte.ts';
import ResizeHandle from './ui/ResizeHandle.svelte';
@@ -63,13 +64,24 @@
else ui.panelOpen = false;
}
// Images in rendered text (messages, descriptions, the quick preview) and image embeds open
// large on click. Not in editors, link preview cards or the YouTube card.
function imageClick(e: MouseEvent) {
if (e.button !== 0 || !(e.target instanceof HTMLImageElement)) return;
const img = e.target;
if (!img.closest('.html, .embed') || img.closest('[contenteditable], .rich, .card, .yt')) return;
e.preventDefault();
e.stopPropagation();
ui.image = img.currentSrc || img.src;
}
const closeOverlays = () => {
navOpen = false;
if (!panelInline) ui.panelOpen = false;
};
</script>
<svelte:window bind:innerWidth={width} />
<svelte:window bind:innerWidth={width} onclickcapture={imageClick} />
{#snippet rail()}
<ServerRail onadd={() => (editing = { server: null })} onedit={s => (editing = { server: s })} onbrowse={() => (ui.publicServers = true)}
@@ -134,6 +146,7 @@
{/if}
{#if share.dialog}<ShareDialog onclose={() => (share.dialog = null)} />{/if}
{#if ui.prompt}<PromptDialog {...ui.prompt} onclose={() => { const p = ui.prompt; ui.prompt = null; p?.oncancel?.(); }} />{/if}
<ImageViewer />
<ContextMenu />
<style>
+4
View File
@@ -73,6 +73,10 @@ label { display: block; font-size: 12px; font-weight: 600; text-transform: upper
.icon-btn:hover { background: var(--bg-hover); color: var(--text); }
.icon-btn.on { color: var(--danger); }
/* Images in rendered text open large on click (App.svelte) */
.html img, .embed img { cursor: zoom-in; }
.card img { cursor: inherit; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--bg-3); border: 2px solid transparent; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background-color: var(--bg-active); }
+2
View File
@@ -30,6 +30,8 @@ class Ui {
settingsTab = $state<'voice' | 'sounds' | 'appearance' | 'accessibility' | 'files' | 'identities'>('voice');
publicServers = $state(false);
serverInfo = $state(false);
// Image shown large (clicked in the chat or in a description)
image = $state<string | null>(null);
// Set by the toolbar to open the own profile with the description editor
editDescription = $state(false);
// Add-server dialog, optionally prefilled (from the public list)
+14 -2
View File
@@ -49,8 +49,20 @@
// Short grace period, so the pointer can move from the row into the preview
function leaveRow() {
clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => (preview = null), 250);
// An image opened from the preview covers it; the preview stays underneath
hoverTimer = setTimeout(() => { if (!ui.image) preview = null; }, 250);
}
// Back from the image: the preview stays while the pointer is on it, and goes when it is not
let popEl = $state<HTMLElement>();
let hadImage = false;
$effect(() => {
const open = !!ui.image;
if (hadImage && !open && preview && !popEl?.matches(':hover')) {
clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => { if (!popEl?.matches(':hover')) preview = null; }, 1500);
}
hadImage = open;
});
function showDescription() {
clearTimeout(hoverTimer);
preview = null;
@@ -131,7 +143,7 @@
{/if}
</div>
{#if preview}
<div class="desc-pop" role="tooltip" style:left="{preview.x}px" style:top="{preview.y}px"
<div class="desc-pop" role="tooltip" bind:this={popEl} style:left="{preview.x}px" style:top="{preview.y}px"
onmouseenter={() => clearTimeout(hoverTimer)} onmouseleave={leaveRow}>
<strong>{channel.name || 'Root'}</strong>
{#if descriptionHtml}<div class="html">{@html descriptionHtml}</div>{:else}<p>Loading description...</p>{/if}
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import { ui } from '../lib/ui.svelte.ts';
// An image from the chat or a description, as large as the window allows. A click anywhere
// or Escape closes it.
const src = $derived(ui.image);
const remote = $derived(!!src && /^https?:/i.test(src));
</script>
<svelte:window onkeydown={e => { if (src && e.key === 'Escape') { e.stopPropagation(); ui.image = null; } }} />
{#if src}
<div class="viewer" role="dialog" aria-modal="true" aria-label="Image">
<button class="shade" aria-label="Close image" onclick={() => (ui.image = null)}></button>
<img {src} alt="" />
{#if remote}<a class="open" href={src} target="_blank" rel="noopener noreferrer">Open original</a>{/if}
</div>
{/if}
<style>
.viewer { position: fixed; inset: 0; z-index: 300; display: flex; align-items: center; justify-content: center; padding: 16px; }
.shade { position: absolute; inset: 0; background: rgb(0 0 0 / 0.85); cursor: zoom-out; }
img { position: relative; max-width: 100%; max-height: 100%; object-fit: contain; pointer-events: none; box-shadow: 0 16px 48px rgb(0 0 0 / 0.6); }
.open { position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%); padding: 4px 10px; font-size: 13px; color: #fff; background: rgb(0 0 0 / 0.7); }
</style>
+13
View File
@@ -856,6 +856,19 @@ try {
assert.ok(inline.length <= 131072, `inline image within the server limit (${inline.length})`);
console.log(`ok: inline image without upload host (${inline.length} chars)`);
// A click on an image in the chat shows it large; Escape or a click closes it
await page.locator('.chat .msg .html img').last().click();
const viewer = page.getByRole('dialog', { name: 'Image' });
await viewer.waitFor();
const shown = await viewer.locator('img').evaluate((i: HTMLImageElement) => [i.naturalWidth > 0, i.getBoundingClientRect().width]);
assert.ok(shown[0] && (shown[1] as number) > 300, `image shown large: ${shown}`);
await page.keyboard.press('Escape');
await viewer.waitFor({ state: 'detached' });
await page.locator('.chat .msg .html img').last().click();
await viewer.getByRole('button', { name: 'Close image' }).click({ position: { x: 5, y: 5 } });
await viewer.waitFor({ state: 'detached' });
console.log('ok: images open large on click');
// ─── Several servers at once ─────────────────────────────────────────────
const userState = (name: string) => [...bob.users.values()].find(u => u.name === name);
const until2 = (label: string, cond: () => boolean) => within(new Promise<void>(res => {