paste images attachment

This commit is contained in:
Moon.eth
2026-07-29 20:06:01 +09:00
parent 17f385c1c3
commit 081460c7f6
2 changed files with 130 additions and 8 deletions
+53 -8
View File
@@ -129,15 +129,23 @@
pollOptions = pollOptions.filter((_, optionIndex) => optionIndex !== index)
}
async function onFiles(event: Event): Promise<void> {
const input = event.currentTarget as HTMLInputElement
const files = Array.from(input.files ?? [])
if (files.length === 0) return
async function uploadFiles(files: File[]): Promise<void> {
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<void> {
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)
},
}
}
</script>
{#if session.signedIn}
<form class="composer" onsubmit={submit} use:composerShortcuts>
<form class="composer" onsubmit={submit} use:composerInteractions>
{#if error}
<p class="error-note" role="alert">{error}</p>
{/if}
+77
View File
@@ -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()
})
})