diff --git a/packages/frontend/locales/en.json b/packages/frontend/locales/en.json index 5e3cbc1..f10ff64 100644 --- a/packages/frontend/locales/en.json +++ b/packages/frontend/locales/en.json @@ -36,7 +36,10 @@ "advanced": { "explanation": "By default, a securely generated password is used for each note. You can however also choose your own password, which is not included in the link.", "custom_password": "custom password" - } + }, + "pasting": "Pasting image...", + "pasted_images": "Pasted Images", + "remove": "Remove" }, "show": { "errors": { diff --git a/packages/frontend/src/lib/ui/ShowNote.svelte b/packages/frontend/src/lib/ui/ShowNote.svelte index 61eb1ec..1a3bee0 100644 --- a/packages/frontend/src/lib/ui/ShowNote.svelte +++ b/packages/frontend/src/lib/ui/ShowNote.svelte @@ -80,6 +80,15 @@ {file.type} - {prettyBytes(file.size)} + {#if file.type.startsWith('image/')} + {#key file.name} + + {/key} + {/if} {/each} {$t('show.download_all')} {/if} @@ -130,4 +139,13 @@ margin-bottom: 0.5rem; word-wrap: break-word; } + + .preview { + display: block; + max-width: 100%; + max-height: 60vh; + margin-bottom: 0.5rem; + border-radius: 0.25rem; + border: 2px solid var(--ui-bg-1); + } diff --git a/packages/frontend/src/lib/views/Create.svelte b/packages/frontend/src/lib/views/Create.svelte index a79aad6..80f4104 100644 --- a/packages/frontend/src/lib/views/Create.svelte +++ b/packages/frontend/src/lib/views/Create.svelte @@ -29,6 +29,11 @@ let customPassword: string | null = $state(null) let description = $state('') let loading: string | null = $state(null) + + // Image paste functionality + let pastedImages: { preview: string; file: File }[] = $state([]) + let isPasting = $state(false) + let pasteIndicator = $state(null) $effect(() => { if (!advanced) { @@ -57,6 +62,84 @@ } }) + async function handlePaste(e: ClipboardEvent) { + // Use the standard DataTransfer API to access pasted content + const items = e.clipboardData?.items + if (!items || items.length === 0) return + + isPasting = true + + const imagePromises: Promise[] = [] + for (let i = 0; i < items.length; i++) { + const item = items[i] + if (item.kind === 'file' && item.type.startsWith('image/')) { + const file = item.getAsFile() + if (file) { + const extension = file.type.split('/')[1] || 'png' + const renamed = new File( + [file], + `pasted-image-${Date.now()}-${Math.round(Math.random() * 1000)}.${extension}`, + { type: file.type }, + ) + imagePromises.push(Promise.resolve(renamed)) + } + } + } + + try { + const results = await Promise.all(imagePromises) + const imageFiles = results.filter((file): file is File => file !== null) + + if (imageFiles.length > 0) { + // Switch to file mode if not already + if (!isFile) { + isFile = true + } + + // Process each image for preview and add to files + for (const imageFile of imageFiles) { + // Create preview URL + const previewURL = URL.createObjectURL(imageFile) + + // Add to pasted images for preview + pastedImages = [...pastedImages, { preview: previewURL, file: imageFile }] + + // Convert to FileDTO and add to files array + const arrayBuffer = await imageFile.arrayBuffer() + const fileDTO: FileDTO = { + name: imageFile.name, + size: imageFile.size, + type: imageFile.type, + contents: new Uint8Array(arrayBuffer) + } + + // Add to files if not already present + if (!files.some(f => f.name === imageFile.name && f.size === imageFile.size)) { + files = [...files, fileDTO] + } + } + } + } catch (error) { + console.error('Error processing pasted image:', error) + } finally { + isPasting = false + } + } + + function removePastedImage(index: number) { + const removed = pastedImages.splice(index, 1)[0] + // Revoke the object URL to free memory + URL.revokeObjectURL(removed.preview) + + // Also remove from files array + files = files.filter(file => !(file.name === removed.file.name && file.size === removed.file.size)) + + // If no more pasted images and no other files, switch back to text mode + if (pastedImages.length === 0 && files.length === 0) { + isFile = false + } + } + class EmptyContentError extends Error {} async function submit(e: SubmitEvent) { @@ -110,18 +193,47 @@ {@html $status?.theme_text || $t('home.intro')} - + - {#if isFile} - - {:else} - - {/if} + + {#if isPasting} + + + {$t('home.pasting')} + + {/if} + {#if isFile} + + {:else} + + {/if} + + {#if pastedImages.length > 0} + + {$t('home.pasted_images')} + + {#each pastedImages as image, index} + + + + removePastedImage(index)} + > + {$t('home.remove')} + + + + {/each} + + + {/if} + + {#if $status?.allow_files} @@ -180,4 +292,92 @@ .grow { flex: 1; } + + /* Paste indicator styles */ + .paste-indicator { + position: relative; + min-height: 200px; + } + + .pasting-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: white; + z-index: 10; + } + + .pasting-spinner { + width: 24px; + height: 24px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top: 2px solid white; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 0.5rem; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + .pasted-images-preview { + margin-top: 1rem; + padding: 1rem; + border: 1px dashed #ccc; + border-radius: 4px; + background-color: #fafafa; + } + + .pasted-images-preview h4 { + margin-top: 0; + margin-bottom: 0.5rem; + color: #333; + } + + .images-grid { + display: flex; + flex-wrap: wrap; + gap: 1rem; + } + + .image-preview { + position: relative; + text-align: center; + } + + .preview-img { + max-width: 150px; + max-height: 150px; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + } + + .image-actions { + margin-top: 0.5rem; + } + + /* Responsive design */ + @media (max-width: 640px) { + .pasted-images-preview { + margin-top: 0.5rem; + padding: 0.5rem; + } + + .preview-img { + max-width: 120px; + max-height: 120px; + } + + .images-grid { + gap: 0.5rem; + } + } diff --git a/test/web/file/paste.spec.ts b/test/web/file/paste.spec.ts new file mode 100644 index 0000000..ff6f761 --- /dev/null +++ b/test/web/file/paste.spec.ts @@ -0,0 +1,64 @@ +import { test, expect } from '@playwright/test' +import { readFile } from 'node:fs/promises' +import { getFileChecksum } from '../../files' +import { checkLinkForDownload } from '../../utils' + +const IMG_PATH = 'test/assets/image.jpg' + +test.describe('@web', () => { + test('paste image', async ({ page, browserName }) => { + test.skip(browserName !== 'chromium', 'DataTransfer.items.add(File) is only supported in Chromium') + const checksum = await getFileChecksum(IMG_PATH) + const imgBuffer = await readFile(IMG_PATH) + const imgBase64 = imgBuffer.toString('base64') + + await page.goto('/') + await page.waitForSelector('form') + + // Create a paste event with clipboardData containing the image file + await page.evaluate( + async ({ base64, mimeType }) => { + const response = await fetch(`data:${mimeType};base64,${base64}`) + const blob = await response.blob() + const file = new File([blob], 'image.jpg', { type: mimeType }) + + const dt = new DataTransfer() + dt.items.add(file) + + const event = new ClipboardEvent('paste', { + bubbles: true, + cancelable: true, + clipboardData: dt, + }) + + document.querySelector('form')?.dispatchEvent(event) + }, + { base64: imgBase64, mimeType: 'image/jpeg' }, + ) + + // Wait for the paste to process and preview to appear + await expect(page.locator('.pasted-images-preview')).toBeVisible({ timeout: 5000 }) + + // Create the note + await page.locator('button:has-text("create")').click() + const link = await page.getByTestId('share-link').inputValue() + + // Navigate to the note and reveal it + await page.goto('/') + await page.goto(link) + await page.getByTestId('show-note-button').click() + + // Verify image preview is visible + await expect(page.locator('img.preview')).toBeVisible({ timeout: 5000 }) + + // Download and verify checksum + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.locator('[data-testid="result"] button').first().click(), + ]) + const path = await download.path() + if (!path) throw new Error('Download failed') + const cs = await getFileChecksum(path) + expect(cs).toBe(checksum) + }) +})
{@html $status?.theme_text || $t('home.intro')}