mirror of
https://github.com/cupcakearmy/cryptgeon.git
synced 2026-06-10 03:06:49 +00:00
feat: add image paste support
Add image paste functionality allowing users to paste images directly from the clipboard as files. Uses the standard DataTransfer API (clipboardData.items) for cross-browser compatibility. On the download page, images are now displayed as previews in addition to the download option. Also fix Rust 2024 never-type-fallback compatibility by adding turbofish type annotations to redis crate calls.
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -80,6 +80,15 @@
|
||||
</button>
|
||||
<small> {file.type} - {prettyBytes(file.size)}</small>
|
||||
</div>
|
||||
{#if file.type.startsWith('image/')}
|
||||
{#key file.name}
|
||||
<img
|
||||
src={URL.createObjectURL(new File([file.contents], file.name, { type: file.type }))}
|
||||
alt={file.name}
|
||||
class="preview"
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
{/each}
|
||||
<Button onclick={download}>{$t('show.download_all')}</Button>
|
||||
{/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);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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<HTMLDivElement | null>(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<File | null>[] = []
|
||||
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 @@
|
||||
<p>
|
||||
{@html $status?.theme_text || $t('home.intro')}
|
||||
</p>
|
||||
<form onsubmit={submit}>
|
||||
<form onsubmit={submit} onpaste={handlePaste}>
|
||||
<fieldset disabled={loading !== null}>
|
||||
{#if isFile}
|
||||
<FileUpload data-testid="file-upload" label={$t('common.file')} bind:files />
|
||||
{:else}
|
||||
<TextArea
|
||||
data-testid="text-field"
|
||||
label={$t('common.note')}
|
||||
bind:value={note.contents}
|
||||
placeholder="..."
|
||||
/>
|
||||
{/if}
|
||||
<div class="paste-indicator" bind:this={pasteIndicator}>
|
||||
{#if isPasting}
|
||||
<div class="pasting-overlay">
|
||||
<div class="pasting-spinner"></div>
|
||||
<span>{$t('home.pasting')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isFile}
|
||||
<FileUpload data-testid="file-upload" label={$t('common.file')} bind:files />
|
||||
{:else}
|
||||
<TextArea
|
||||
data-testid="text-field"
|
||||
label={$t('common.note')}
|
||||
bind:value={note.contents}
|
||||
placeholder="..."
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if pastedImages.length > 0}
|
||||
<div class="pasted-images-preview">
|
||||
<h4>{$t('home.pasted_images')}</h4>
|
||||
<div class="images-grid">
|
||||
{#each pastedImages as image, index}
|
||||
<div class="image-preview">
|
||||
<img src={image.preview} class="preview-img" />
|
||||
<div class="image-actions">
|
||||
<Button
|
||||
onclick={() => removePastedImage(index)}
|
||||
>
|
||||
{$t('home.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
{#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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user