mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
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:
committed by
Fred Cabassut
co-authored by
Claude Opus 4.6
parent
f61a567cea
commit
e1f7a4c455
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user