feat: add image lightbox for full-size preview on click

Clicking a thumbnail in the editor, closed-panel view, or note card
opens a full-screen overlay with the full-size image. Closes on Escape,
background click, or X button. Escape is scoped so it won't close the
editor underneath.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-08 22:37:50 +01:00
committed by Fred Cabassut
co-authored by Claude Opus 4.6
parent bd7ce284f6
commit a3c2fb67b7
5 changed files with 101 additions and 3 deletions
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import XIcon from 'lucide-svelte/icons/x';
interface Props {
src: string;
alt: string;
onClose: () => void;
}
const { src, alt, onClose }: Props = $props();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
}
function handleBackgroundClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 animate-[fade-in_150ms_ease-out]"
onclick={handleBackgroundClick}
data-testid="image-lightbox"
>
<button
onclick={onClose}
class="absolute right-4 top-4 rounded-sm p-2 text-white hover:bg-white/10"
aria-label="Close lightbox"
data-testid="lightbox-close"
>
<XIcon class="h-6 w-6" />
</button>
<img
{src}
{alt}
class="max-h-[90vh] max-w-[90vw] object-contain rounded-sm animate-[pop-in_150ms_ease-out]"
data-testid="lightbox-image"
/>
</div>
+10 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import XIcon from 'lucide-svelte/icons/x';
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
import ImageLightbox from './ImageLightbox.svelte';
import type { Attachment } from '$lib/types/index.js';
import { optimizeImage } from '$lib/utils/image-optimize.js';
import { browser } from '$app/environment';
@@ -18,6 +19,8 @@
let uploading = $state(false);
let optimizing = $state(false);
let dragOver = $state(false);
let lightboxSrc = $state<string | null>(null);
let lightboxAlt = $state('');
async function handleFiles(files: FileList | null) {
if (!files || !noteId) return;
@@ -108,10 +111,12 @@
{@const isPending = 'pending' in attachment && attachment.pending}
{@const imgSrc = isPending ? attachment.path : `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`}
<div class="group relative">
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
<img
src={imgSrc}
alt={attachment.filename}
class="h-20 w-20 rounded-sm object-cover {isPending ? 'opacity-70' : ''}"
class="h-20 w-20 cursor-pointer rounded-sm object-cover {isPending ? 'opacity-70' : ''}"
onclick={() => { lightboxSrc = imgSrc; lightboxAlt = attachment.filename; }}
data-testid="attachment-thumbnail"
/>
{#if isPending}
@@ -161,3 +166,7 @@
{/if}
</div>
</div>
{#if lightboxSrc}
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
{/if}
+9 -1
View File
@@ -3,6 +3,7 @@
import { renderMarkdown } from '$lib/utils/markdown.js';
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, currentFilter } from '$lib/stores/notes.js';
import TagChip from './TagChip.svelte';
import ImageLightbox from './ImageLightbox.svelte';
import type { Note } from '$lib/types/index.js';
import Undo2 from 'lucide-svelte/icons/undo-2';
import Trash2 from 'lucide-svelte/icons/trash-2';
@@ -29,6 +30,8 @@
});
let cardStyle = $state('');
let lightboxSrc = $state<string | null>(null);
let lightboxAlt = $state('');
const renderedContent = $derived(renderMarkdown(note.content));
@@ -83,11 +86,13 @@
<div class="-mx-4 -mt-4 mb-3 flex overflow-hidden rounded-t-sm" data-testid="card-thumbnails">
{#each note.attachments.slice(0, 3) as attachment}
<div class="relative min-w-0 flex-1">
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
<img
src="/api/notes/{note.id}/attachments?attachmentId={attachment.id}&thumb=1"
alt={attachment.filename}
class="h-24 w-full object-cover"
class="h-24 w-full cursor-pointer object-cover"
loading="lazy"
onclick={(e) => { e.stopPropagation(); lightboxSrc = `/api/notes/${note.id}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
data-testid="card-thumbnail"
/>
</div>
@@ -207,6 +212,9 @@
{/if}
</div>
{#if lightboxSrc}
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
{/if}
</article>
<style>
+11 -1
View File
@@ -4,6 +4,7 @@
import FormattingToolbar from './FormattingToolbar.svelte';
import TiptapEditor from './TiptapEditor.svelte';
import ImageUpload from './ImageUpload.svelte';
import ImageLightbox from './ImageLightbox.svelte';
import { updateNote, createNote } from '$lib/stores/notes.js';
import { NOTE_COLORS } from '$lib/utils/colors.js';
import type { Editor } from '@tiptap/core';
@@ -37,6 +38,8 @@
let textareaEl: HTMLTextAreaElement | undefined = $state();
let tiptapEditor: Editor | undefined = $state();
let editorTick = $state(0);
let lightboxSrc = $state<string | null>(null);
let lightboxAlt = $state('');
// Mutable note identity — allows transitioning from new → saved without closing
// svelte-ignore state_referenced_locally
@@ -203,16 +206,23 @@
<div class="border-t border-[var(--border-subtle)] px-4 py-2">
<div class="flex flex-wrap gap-2">
{#each attachmentsList as attachment}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions a11y_click_events_have_key_events -->
<img
src="/api/notes/{noteId}/attachments?attachmentId={attachment.id}"
alt={attachment.filename}
class="h-20 w-20 rounded-sm object-cover"
class="h-20 w-20 cursor-pointer rounded-sm object-cover"
onclick={() => { lightboxSrc = `/api/notes/${noteId}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
data-testid="attachment-thumbnail"
/>
{/each}
</div>
</div>
{/if}
{#if lightboxSrc}
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
{/if}
<!-- Formatting toolbar -->
{#if !rawMarkdownMode && !checklistMode}
<FormattingToolbar editor={tiptapEditor} tick={editorTick} />
+22
View File
@@ -84,6 +84,28 @@ test.describe('Image Attachments', () => {
await expect(card.getByTestId('card-thumbnail')).not.toBeVisible();
});
test('Scenario: Clicking an image thumbnail opens a full-size lightbox', async ({ authenticatedPage: page }) => {
// Given a note with an uploaded image exists
await createAndReopenNote(page, 'Lightbox Test');
await page.getByTestId('image-toggle').click();
await page.getByTestId('file-input').setInputFiles(TEST_IMAGE_PATH);
await expect(page.getByTestId('attachment-thumbnail').first()).toBeVisible();
// When the user clicks the thumbnail
await page.getByTestId('attachment-thumbnail').first().click();
// Then a full-size lightbox overlay is displayed
await expect(page.getByTestId('image-lightbox')).toBeVisible();
await expect(page.getByTestId('lightbox-image')).toBeVisible();
// When the user presses Escape
await page.keyboard.press('Escape');
// Then the lightbox closes but the editor remains open
await expect(page.getByTestId('image-lightbox')).not.toBeVisible();
await expect(page.getByTestId('note-editor')).toBeVisible();
});
test('Scenario: Image toggle on new note auto-saves and opens upload panel', async ({ authenticatedPage: page }) => {
// When the user opens a new note and clicks the image toggle
await page.getByTestId('new-note-btn').click();