Compare commits

...

6 Commits

Author SHA1 Message Date
Johnpaul Chiwetelu
f5e9ed15b3 Merge branch 'main' into fix/drag-drop-url-filename 2026-03-26 03:58:00 +01:00
Johnpaul
1962e3aab1 refactor: consolidate mimeTypeUtil into src/utils
Move MIME type mapping to src/utils/mimeTypeUtil.ts with both
getMimeType (ext→mime) and getExtension (mime→ext) derived from a
single map. Browser test helpers now import from the shared util.
2026-03-26 03:57:12 +01:00
Johnpaul
6b0ebe6112 refactor: move MIME mapping to shared mimeTypeUtil
Address review feedback: extract MIME-to-extension map into a shared
util with both getMimeType and getExtension derived from a single map.
2026-03-26 03:57:12 +01:00
Alexander Brown
d4efb095dc Merge branch 'main' into fix/drag-drop-url-filename 2026-03-25 18:14:24 -07:00
Johnpaul
8d99cfbf0d fix: let URI drops on Vue nodes bubble to document handler
The @drop.prevent modifier called preventDefault() unconditionally,
causing the document-level drop handler to skip the event.
2026-03-25 23:30:57 +01:00
Johnpaul
543d21f6c6 fix: extract filename from URL when uploading dropped images
Dropping an image URL onto the canvas used the raw URL as the filename
in the multipart upload, causing a 500 error from the backend.
2026-03-25 22:26:44 +01:00
7 changed files with 75 additions and 20 deletions

View File

@@ -4,7 +4,7 @@ import { basename } from 'path'
import type { Locator, Page } from '@playwright/test'
import type { KeyboardHelper } from './KeyboardHelper'
import { getMimeType } from './mimeTypeUtil'
import { getMimeType } from '../../../src/utils/mimeTypeUtil'
export class ClipboardHelper {
constructor(

View File

@@ -3,7 +3,7 @@ import { readFileSync } from 'fs'
import type { Page } from '@playwright/test'
import type { Position } from '../types'
import { getMimeType } from './mimeTypeUtil'
import { getMimeType } from '../../../src/utils/mimeTypeUtil'
export class DragDropHelper {
constructor(

View File

@@ -1,13 +0,0 @@
export function getMimeType(fileName: string): string {
const name = fileName.toLowerCase()
if (name.endsWith('.png')) return 'image/png'
if (name.endsWith('.jpg') || name.endsWith('.jpeg')) return 'image/jpeg'
if (name.endsWith('.webp')) return 'image/webp'
if (name.endsWith('.svg')) return 'image/svg+xml'
if (name.endsWith('.avif')) return 'image/avif'
if (name.endsWith('.webm')) return 'video/webm'
if (name.endsWith('.mp4')) return 'video/mp4'
if (name.endsWith('.json')) return 'application/json'
if (name.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'
}

View File

@@ -33,7 +33,7 @@
@contextmenu="handleContextMenu"
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop.prevent="handleDrop"
@drop="handleDrop"
>
<!-- Selection/Execution Outline Overlay -->
<AppOutput
@@ -834,6 +834,9 @@ function handleDrop(event: DragEvent) {
if (!node?.onDragDrop) return
const handled = node.onDragDrop(event)
if (handled === true) event.stopPropagation()
if (handled === true) {
event.preventDefault()
event.stopPropagation()
}
}
</script>

View File

@@ -108,8 +108,8 @@ describe('eventUtils', () => {
expect(actual).toEqual([file1, file2])
})
it('should fetch URI and return as File when text/uri-list is present', async () => {
const uri = 'https://example.com/api/view?filename=test.png&type=input'
it('should fetch URI and return as File with extracted filename', async () => {
const uri = 'https://example.com/images/photo.png?w=1200&format=auto'
const imageBlob = new Blob([new Uint8Array([0x89, 0x50])], {
type: 'image/png'
})
@@ -126,6 +126,25 @@ describe('eventUtils', () => {
expect(actual).toHaveLength(1)
expect(actual[0]).toBeInstanceOf(File)
expect(actual[0].type).toBe('image/png')
expect(actual[0].name).toBe('photo.png')
})
it('should use fallback filename when URL path has no extension', async () => {
const uri = 'https://example.com/api/view?filename=test.png&type=input'
const imageBlob = new Blob([new Uint8Array([0x89, 0x50])], {
type: 'image/png'
})
fetchSpy.mockResolvedValue(new Response(imageBlob))
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/uri-list', uri)
const actual = await extractFilesFromDragEvent(
new FakeDragEvent('drop', { dataTransfer })
)
expect(actual).toHaveLength(1)
expect(actual[0].name).toBe('downloaded.png')
})
it('should handle text/x-moz-url type', async () => {

View File

@@ -1,3 +1,17 @@
import { getExtension } from '@/utils/mimeTypeUtil'
function extractFilenameFromUri(uri: string, mimeType: string): string {
try {
const pathname = new URL(uri).pathname
const basename = pathname.split('/').pop()
if (basename && basename.includes('.')) return basename
} catch {
// Not a valid URL, fall through
}
return `downloaded${getExtension(mimeType)}`
}
export async function extractFilesFromDragEvent(
event: DragEvent
): Promise<File[]> {
@@ -23,7 +37,8 @@ export async function extractFilesFromDragEvent(
try {
const response = await fetch(uri)
const blob = await response.blob()
return [new File([blob], uri, { type: blob.type })]
const filename = extractFilenameFromUri(uri, blob.type)
return [new File([blob], filename, { type: blob.type })]
} catch {
return []
}

31
src/utils/mimeTypeUtil.ts Normal file
View File

@@ -0,0 +1,31 @@
const EXT_TO_MIME: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.avif': 'image/avif',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
'.mp4': 'video/mp4',
'.webm': 'video/webm'
}
const MIME_TO_EXT: Record<string, string> = Object.fromEntries(
Object.entries(EXT_TO_MIME)
.filter(([ext]) => ext !== '.jpeg')
.map(([ext, mime]) => [mime, ext])
)
export function getMimeType(fileName: string): string {
const name = fileName.toLowerCase()
const ext = name.slice(name.lastIndexOf('.'))
return EXT_TO_MIME[ext] ?? 'application/octet-stream'
}
export function getExtension(mimeType: string): string {
return MIME_TO_EXT[mimeType] ?? ''
}