diff --git a/src/components/blog/Composer.svelte b/src/components/blog/Composer.svelte index 5f26a23..3554afb 100644 --- a/src/components/blog/Composer.svelte +++ b/src/components/blog/Composer.svelte @@ -129,15 +129,23 @@ pollOptions = pollOptions.filter((_, optionIndex) => optionIndex !== index) } - async function onFiles(event: Event): Promise { - const input = event.currentTarget as HTMLInputElement - const files = Array.from(input.files ?? []) - if (files.length === 0) return + async function uploadFiles(files: File[]): Promise { + if (files.length === 0 || uploading) return + if (showPoll) { + error = 'Remove the poll before adding media.' + return + } + + const availableSlots = maxAttachments - attachments.length + if (availableSlots <= 0) { + error = `This server allows up to ${maxAttachments} attachments.` + return + } uploading = true error = null try { - for (const file of files.slice(0, maxAttachments - attachments.length)) { + for (const file of files.slice(0, availableSlots)) { const media = await endpoints.uploadMedia(session.api, file) attachments = [...attachments, media] } @@ -145,10 +153,30 @@ error = cause instanceof Error ? cause.message : 'Upload failed.' } finally { uploading = false + } + } + + async function onFiles(event: Event): Promise { + const input = event.currentTarget as HTMLInputElement + try { + await uploadFiles(Array.from(input.files ?? [])) + } finally { input.value = '' } } + function clipboardImages(event: ClipboardEvent): File[] { + const itemImages = Array.from(event.clipboardData?.items ?? []) + .filter((item) => item.kind === 'file' && item.type.startsWith('image/')) + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null) + + if (itemImages.length > 0) return itemImages + return Array.from(event.clipboardData?.files ?? []).filter((file) => + file.type.startsWith('image/'), + ) + } + function removeAttachment(id: string): void { attachments = attachments.filter((media) => media.id !== id) } @@ -189,22 +217,39 @@ } } - function composerShortcuts(form: HTMLFormElement): { destroy(): void } { + function composerInteractions(form: HTMLFormElement): { destroy(): void } { function keydown(event: KeyboardEvent): void { if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) return event.preventDefault() if (canPost) form.requestSubmit() } + function paste(event: ClipboardEvent): void { + const target = event.target + if (!(target instanceof HTMLTextAreaElement) || !target.classList.contains('composer-body')) { + return + } + + const images = clipboardImages(event) + if (images.length === 0) return + + event.preventDefault() + void uploadFiles(images) + } + form.addEventListener('keydown', keydown) + form.addEventListener('paste', paste) return { - destroy: () => form.removeEventListener('keydown', keydown), + destroy: () => { + form.removeEventListener('keydown', keydown) + form.removeEventListener('paste', paste) + }, } } {#if session.signedIn} -
+ {#if error} {/if} diff --git a/src/components/blog/Composer.test.ts b/src/components/blog/Composer.test.ts index 7d22ae8..1c36f57 100644 --- a/src/components/blog/Composer.test.ts +++ b/src/components/blog/Composer.test.ts @@ -102,4 +102,81 @@ describe('Composer', () => { await waitFor(() => expect(postStatus).toHaveBeenCalledOnce()) expect(postStatus.mock.calls[0][1].status).toBe('Keyboard-posted entry') }) + + it('uploads a pasted clipboard image and attaches it to the post', async () => { + const pastedImage = new File(['image bytes'], 'pasted-image.png', { type: 'image/png' }) + const uploadMedia = vi.fn().mockResolvedValue({ + id: 'pasted-media', + type: 'image', + url: 'https://media.example/pasted-image.png', + preview_url: 'https://media.example/pasted-image-preview.png', + description: null, + }) + const postStatus = vi.fn().mockResolvedValue(status()) + const services = testServices({ + session: session({ + token: 'token', + me: account(), + signedIn: true, + instance: { + title: 'Test server', + version: '1.0.0', + configuration: { statuses: { max_media_attachments: 4 } }, + }, + }), + endpoints: { uploadMedia, postStatus }, + }) + const view = render(Composer, { + props: { initialText: 'A pasted picture' }, + context: new Map([[APP_SERVICES, services]]), + }) + const textarea = view.getByRole('textbox', { name: 'Entry text' }) + + await fireEvent.paste(textarea, { + clipboardData: { + items: [ + { + kind: 'file', + type: 'image/png', + getAsFile: () => pastedImage, + }, + ], + files: [pastedImage], + }, + }) + + await waitFor(() => expect(uploadMedia).toHaveBeenCalledOnce()) + expect(uploadMedia.mock.calls[0][1]).toBe(pastedImage) + expect(view.container.querySelector('.composer-attachment img')).toHaveAttribute( + 'src', + 'https://media.example/pasted-image-preview.png', + ) + + await fireEvent.click(view.getByRole('button', { name: 'Post Entry' })) + + await waitFor(() => expect(postStatus).toHaveBeenCalledOnce()) + expect(postStatus.mock.calls[0][1].media_ids).toEqual(['pasted-media']) + }) + + it('leaves ordinary text-only paste alone', async () => { + const uploadMedia = vi.fn() + const services = testServices({ + session: session({ token: 'token', me: account(), signedIn: true }), + endpoints: { uploadMedia }, + }) + const view = render(Composer, { + context: new Map([[APP_SERVICES, services]]), + }) + const textarea = view.getByRole('textbox', { name: 'Entry text' }) + + const wasNotCancelled = await fireEvent.paste(textarea, { + clipboardData: { + items: [{ kind: 'string', type: 'text/plain', getAsFile: () => null }], + files: [], + }, + }) + + expect(wasNotCancelled).toBe(true) + expect(uploadMedia).not.toHaveBeenCalled() + }) })