diff --git a/issue-16-drag-drop-import.png b/issue-16-drag-drop-import.png new file mode 100644 index 0000000..7d22bbd Binary files /dev/null and b/issue-16-drag-drop-import.png differ diff --git a/scripts/playwright-issue-16-smoke.mjs b/scripts/playwright-issue-16-smoke.mjs new file mode 100644 index 0000000..f1cec37 --- /dev/null +++ b/scripts/playwright-issue-16-smoke.mjs @@ -0,0 +1,129 @@ +import fs from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' + +function run(args) { + const result = spawnSync('npx', ['playwright-cli', ...args], { + cwd: process.cwd(), + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024, + }) + if (result.stdout) process.stdout.write(result.stdout) + if (result.stderr) process.stderr.write(result.stderr) + if (result.error) console.error(result.error) + return result.status ?? 1 +} + +const demo = path.resolve('test-fixtures/demo') +const names = ['photo.jpg', 'photo2.jpg', 'theme.wav'] +const payload = Object.fromEntries( + names.map((name) => [name, fs.readFileSync(path.join(demo, name)).toString('base64')]), +) +payload['slideshow.json'] = Buffer.from(JSON.stringify({ + globalSettings: { + fitMode: 'cover', + imageDurationSecs: 3, + kenBurns: true, + transitionType: 'cut', + }, + schemaVersion: 1, + slides: [ + { + durationInFrames: 90, + excluded: false, + filename: 'photo.jpg', + id: 'photo.jpg-1781145972852', + type: 'image', + }, + { + durationInFrames: 90, + excluded: false, + filename: 'photo2.jpg', + id: 'photo2.jpg-1781145972852', + type: 'image', + }, + ], +}, null, 2)).toString('base64') + +const code = `async page => { + const payload = ${JSON.stringify(payload)}; + await page.goto('http://localhost:5173/'); + await page.evaluate((files) => { + function decodeBase64(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index); + return bytes; + } + class MockFileHandle { + constructor(name, base64) { + this.kind = 'file'; + this.name = name; + this._base64 = base64; + } + async getFile() { + const bytes = decodeBase64(this._base64); + const type = this.name.endsWith('.jpg') ? 'image/jpeg' + : this.name.endsWith('.wav') ? 'audio/wav' + : 'application/json'; + return new File([bytes], this.name, { type, lastModified: 1781145972852 }); + } + async createWritable() { + const handle = this; + return { + write: async (data) => { + const buffer = data instanceof Blob ? await data.arrayBuffer() : data; + handle._base64 = btoa(String.fromCharCode(...new Uint8Array(buffer))); + }, + close: async () => {}, + }; + } + } + class MockDirHandle { + constructor(files) { + this.name = 'demo'; + this._files = files; + } + async getFileHandle(name, options) { + const handle = this._files.get(name); + if (handle) return handle; + if (options?.create) { + const created = new MockFileHandle(name, ''); + this._files.set(name, created); + return created; + } + throw new DOMException('NotFoundError'); + } + async *values() { + for (const handle of this._files.values()) yield handle; + } + } + const handles = new Map( + Object.entries(files).map(([name, base64]) => [name, new MockFileHandle(name, base64)]), + ); + window.showDirectoryPicker = async () => new MockDirHandle(handles); + }, payload); + await page.getByRole('button', { name: 'Open Folder' }).click(); + await page.getByText('photo.jpg').first().waitFor(); + await page.getByText('photo2.jpg').first().waitFor(); + + await page.evaluate(async () => { + const transfer = new DataTransfer(); + transfer.items.add(new File(['new image'], 'imported.jpg', { type: 'image/jpeg', lastModified: 1781145972852 })); + transfer.items.add(new File(['duplicate'], 'photo.jpg', { type: 'image/jpeg', lastModified: 1781145972852 })); + transfer.items.add(new File(['text'], 'notes.txt', { type: 'text/plain', lastModified: 1781145972852 })); + window.dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer: transfer })); + }); + + await page.getByText('imported.jpg').first().waitFor(); + await page.getByText('photo-1.jpg').first().waitFor(); + await page.getByText('Skipped unsupported file type: notes.txt').waitFor(); + await page.screenshot({ path: 'issue-16-drag-drop-import.png', fullPage: true }); +}` + +let status = run(['open', 'http://localhost:5173/', '--browser=chrome']) +if (status !== 0) process.exit(status) + +status = run(['run-code', code]) +run(['close']) +process.exit(status) diff --git a/specs/issue-16-drag-drop-import.md b/specs/issue-16-drag-drop-import.md new file mode 100644 index 0000000..25da63e --- /dev/null +++ b/specs/issue-16-drag-drop-import.md @@ -0,0 +1,28 @@ +# Issue 16 — Drag-and-drop import to folder + +## Goal + +Drop media files onto the app window → copy into the open project folder → append to the storyboard grid. + +## Pure module (`src/project-store/import-media.ts`) + +- `resolveUniqueFilename(existingFilenames, desiredFilename)` — suffix `-1`, `-2`, … before extension when name collides. +- `importDroppedMediaFiles(dirHandle, files, existingFilenames)` — validate type first (no writes for unsupported), write blob, return `{ imported: MediaSlide[], skipped: { filename, reason }[] }`. +- Reuse `createMediaSlideFromFile` extracted from `media-loader.ts`. + +## Editor shell (thin) + +- `useProject.importDroppedFiles(files)` — list folder filenames, call import module, append slides, set dismissible notice for skipped types. +- `DropImportLayer` — `dragover`/`drop` on window when `folderOpen`; ignores non-file drops. + +## Tests + +In-memory dir fake (blob writable). No live FS/network. Vertical TDD on acceptance criteria. + +## Acceptance mapping + +| Criterion | Verification | +| --- | --- | +| Files in folder + end of grid | import test + playwright smoke | +| Duplicate suffixed, both playable | import test | +| Unsupported skipped + notice, no partial writes | import test + UI notice | diff --git a/src/editor-shell/App.tsx b/src/editor-shell/App.tsx index 64e4d27..155fcd5 100644 --- a/src/editor-shell/App.tsx +++ b/src/editor-shell/App.tsx @@ -13,6 +13,7 @@ import type { GlobalSettings, SlideOverrides, ThemeName } from '../timeline-core import { applyTheme } from '../timeline-core' import { plan } from '../sequence-planner' import { AppHeader } from './AppHeader' +import { DropImportLayer } from './DropImportLayer' import { EditorLayout } from './EditorLayout' import { EditorSidebar } from './EditorSidebar' import { EmptyState } from './EmptyState' @@ -40,6 +41,7 @@ export function App() { error, corruptError, folderOpen, + importNotice, recentProjects, } = project @@ -110,6 +112,7 @@ export function App() { return (
+ )} + {importNotice ? ( +
+ {importNotice} + +
+ ) : null} + {loading && (
Loading media… diff --git a/src/editor-shell/DropImportLayer.tsx b/src/editor-shell/DropImportLayer.tsx new file mode 100644 index 0000000..81f7756 --- /dev/null +++ b/src/editor-shell/DropImportLayer.tsx @@ -0,0 +1,33 @@ +import { useEffect } from 'react' + +type Props = { + enabled: boolean + onDropFiles: (files: File[]) => void +} + +export function DropImportLayer({ enabled, onDropFiles }: Props) { + useEffect(() => { + if (!enabled) return + + function handleDragOver(event: DragEvent) { + if (!event.dataTransfer?.types.includes('Files')) return + event.preventDefault() + } + + function handleDrop(event: DragEvent) { + if (!event.dataTransfer?.types.includes('Files')) return + event.preventDefault() + const files = [...event.dataTransfer.files] + if (files.length > 0) onDropFiles(files) + } + + window.addEventListener('dragover', handleDragOver) + window.addEventListener('drop', handleDrop) + return () => { + window.removeEventListener('dragover', handleDragOver) + window.removeEventListener('drop', handleDrop) + } + }, [enabled, onDropFiles]) + + return null +} diff --git a/src/editor-shell/useProject.ts b/src/editor-shell/useProject.ts index b4477bd..133898b 100644 --- a/src/editor-shell/useProject.ts +++ b/src/editor-shell/useProject.ts @@ -6,6 +6,7 @@ import type { GlobalSettings, ThemeName } from '../timeline-core' import { addRecentProject, enumerateAudioTracks, + importDroppedMediaFiles, listRecentProjects, openProject, requestHandlePermission, @@ -23,6 +24,14 @@ import { FPS } from './PlayerPane' const AUTOSAVE_DELAY = 2000 +async function listFolderFilenames(handle: FileSystemDirectoryHandle): Promise> { + const names = new Set() + for await (const entry of handle.values()) { + if (entry.kind === 'file') names.add(entry.name) + } + return names +} + type Options = { onFolderLoaded?: () => void } @@ -38,6 +47,7 @@ export function useProject({ onFolderLoaded }: Options = {}) { const [error, setError] = useState(null) const [corruptError, setCorruptError] = useState(null) const [folderOpen, setFolderOpen] = useState(false) + const [importNotice, setImportNotice] = useState(null) const [recentProjects, setRecentProjects] = useState([]) const dirHandleRef = useRef(null) @@ -203,6 +213,41 @@ export function useProject({ onFolderLoaded }: Options = {}) { setCorruptError(null) }, []) + const dismissImportNotice = useCallback(() => { + setImportNotice(null) + }, []) + + const importDroppedFiles = useCallback(async (files: File[]) => { + const handle = dirHandleRef.current + if (!handle || files.length === 0) return + + try { + const existingFilenames = await listFolderFilenames(handle) + const { imported, skipped } = await importDroppedMediaFiles(handle, files, existingFilenames) + + if (imported.length > 0) { + pendingRevokeRef.current = latestSlidesRef.current + setSlides((previousSlides) => [ + ...previousSlides, + ...imported.map((slide) => ( + slide.type === 'image' + ? { ...slide, durationInFrames: Math.round(globalSettings.imageDurationSecs * FPS) } + : slide + )), + ]) + } + + if (skipped.length > 0) { + const names = skipped.map((entry) => entry.filename).join(', ') + setImportNotice(`Skipped unsupported file type${skipped.length > 1 ? 's' : ''}: ${names}`) + } else { + setImportNotice(null) + } + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to import files') + } + }, [globalSettings]) + return { audioTracks, globalSettings, @@ -215,7 +260,10 @@ export function useProject({ onFolderLoaded }: Options = {}) { loading, error, corruptError, + dismissImportNotice, folderOpen, + importDroppedFiles, + importNotice, recentProjects, pickFolder, refresh, diff --git a/src/project-store/import-media.test.ts b/src/project-store/import-media.test.ts new file mode 100644 index 0000000..b5268d4 --- /dev/null +++ b/src/project-store/import-media.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { importDroppedMediaFiles, resolveUniqueFilename } from './import-media' + +class FakeWritable { + public written: Blob | null = null + async write(data: Blob) { this.written = data } + async close() {} +} + +class FakeFileHandle { + public writable = new FakeWritable() + async createWritable() { return this.writable } +} + +class FakeDirHandle { + public files = new Map() + + async getFileHandle(name: string, opts?: { create?: boolean }) { + if (!this.files.has(name) && opts?.create) { + this.files.set(name, new FakeFileHandle()) + } + const handle = this.files.get(name) + if (!handle) throw new Error(`File not found: ${name}`) + return handle + } +} + +beforeEach(() => { + vi.stubGlobal('URL', { + ...URL, + createObjectURL: vi.fn(() => 'blob:mock-url'), + revokeObjectURL: vi.fn(), + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('resolveUniqueFilename', () => { + it('returns the desired filename when it is unused', () => { + const existing = new Set(['other.jpg']) + expect(resolveUniqueFilename(existing, 'photo.jpg')).toBe('photo.jpg') + }) + + it('suffixes duplicate filenames before the extension', () => { + const existing = new Set(['photo.jpg']) + expect(resolveUniqueFilename(existing, 'photo.jpg')).toBe('photo-1.jpg') + }) + + it('increments the suffix until the filename is free', () => { + const existing = new Set(['photo.jpg', 'photo-1.jpg', 'photo-2.jpg']) + expect(resolveUniqueFilename(existing, 'photo.jpg')).toBe('photo-3.jpg') + }) +}) + +describe('importDroppedMediaFiles', () => { + it('copies supported media into the folder and returns slides for them', async () => { + const dir = new FakeDirHandle() + const file = new File(['image'], 'sunset.jpg', { type: 'image/jpeg' }) + + const result = await importDroppedMediaFiles(dir, [file], new Set()) + + expect(result.skipped).toEqual([]) + expect(result.imported).toHaveLength(1) + expect(result.imported[0]?.filename).toBe('sunset.jpg') + expect(dir.files.has('sunset.jpg')).toBe(true) + expect(dir.files.get('sunset.jpg')!.writable.written).toBeInstanceOf(Blob) + }) + + it('saves duplicate names under a suffixed filename', async () => { + const dir = new FakeDirHandle() + dir.files.set('photo.jpg', new FakeFileHandle()) + const file = new File(['image'], 'photo.jpg', { type: 'image/jpeg' }) + + const result = await importDroppedMediaFiles(dir, [file], new Set(['photo.jpg'])) + + expect(result.imported).toHaveLength(1) + expect(result.imported[0]?.filename).toBe('photo-1.jpg') + expect(dir.files.has('photo-1.jpg')).toBe(true) + expect(dir.files.has('photo.jpg')).toBe(true) + }) + + it('skips unsupported types without writing partial files', async () => { + const dir = new FakeDirHandle() + const supported = new File(['image'], 'good.jpg', { type: 'image/jpeg' }) + const unsupported = new File(['text'], 'notes.txt', { type: 'text/plain' }) + + const result = await importDroppedMediaFiles(dir, [unsupported, supported], new Set()) + + expect(result.imported).toHaveLength(1) + expect(result.imported[0]?.filename).toBe('good.jpg') + expect(result.skipped).toEqual([{ filename: 'notes.txt', reason: 'unsupported-type' }]) + expect(dir.files.has('good.jpg')).toBe(true) + expect(dir.files.has('notes.txt')).toBe(false) + }) +}) diff --git a/src/project-store/import-media.ts b/src/project-store/import-media.ts new file mode 100644 index 0000000..73391f8 --- /dev/null +++ b/src/project-store/import-media.ts @@ -0,0 +1,77 @@ +import { isSupportedMedia } from '../timeline-core' +import type { MediaSlide } from '../timeline-core/types' +import { createMediaSlideFromFile } from './media-loader' + +interface WritableHandle { + close(): Promise + write(data: Blob): Promise +} + +interface ImportFileHandle { + createWritable(): Promise +} + +export interface ImportDirHandle { + getFileHandle(name: string, options?: { create?: boolean }): Promise +} + +export type SkippedImport = { + filename: string + reason: 'unsupported-type' +} + +export type ImportDroppedMediaResult = { + imported: MediaSlide[] + skipped: SkippedImport[] +} + +export function resolveUniqueFilename( + existingFilenames: ReadonlySet, + desiredFilename: string, +): string { + if (!existingFilenames.has(desiredFilename)) return desiredFilename + + const dotIndex = desiredFilename.lastIndexOf('.') + const base = dotIndex > 0 ? desiredFilename.slice(0, dotIndex) : desiredFilename + const extension = dotIndex > 0 ? desiredFilename.slice(dotIndex) : '' + + let counter = 1 + while (true) { + const candidate = `${base}-${counter}${extension}` + if (!existingFilenames.has(candidate)) return candidate + counter += 1 + } +} + +export async function importDroppedMediaFiles( + dirHandle: ImportDirHandle, + files: File[], + existingFilenames: ReadonlySet, +): Promise { + const imported: MediaSlide[] = [] + const skipped: SkippedImport[] = [] + const reservedFilenames = new Set(existingFilenames) + + for (const file of files) { + if (!isSupportedMedia(file.name)) { + skipped.push({ filename: file.name, reason: 'unsupported-type' }) + continue + } + + const filename = resolveUniqueFilename(reservedFilenames, file.name) + reservedFilenames.add(filename) + + const fileHandle = await dirHandle.getFileHandle(filename, { create: true }) + const writable = await fileHandle.createWritable() + await writable.write(file) + await writable.close() + + const namedFile = new File([file], filename, { + lastModified: file.lastModified, + type: file.type, + }) + imported.push(await createMediaSlideFromFile(namedFile)) + } + + return { imported, skipped } +} diff --git a/src/project-store/index.ts b/src/project-store/index.ts index 7f7c5c1..9eeeefc 100644 --- a/src/project-store/index.ts +++ b/src/project-store/index.ts @@ -1,5 +1,7 @@ export { enumerateAudioTracks, revokeAudioBlobUrls } from './audio-loader' export type { AudioTrack } from './audio-loader' +export { importDroppedMediaFiles, resolveUniqueFilename } from './import-media' +export type { ImportDirHandle, ImportDroppedMediaResult, SkippedImport } from './import-media' export { createFreshProject, openProject, saveProject } from './project-store' export type { DirHandle, OpenResult } from './project-store' export { addRecentProject, listRecentProjects, removeRecentProject, requestHandlePermission } from './recent-projects' diff --git a/src/project-store/media-loader.ts b/src/project-store/media-loader.ts index 2edcb69..ae877d7 100644 --- a/src/project-store/media-loader.ts +++ b/src/project-store/media-loader.ts @@ -25,6 +25,25 @@ async function getVideoDurationFrames(file: File): Promise { } } +export async function createMediaSlideFromFile(file: File): Promise { + const filename = file.name + const type = getMediaType(filename) + const blobUrl = URL.createObjectURL(file) + const durationInFrames = + type === 'video' + ? await getVideoDurationFrames(file) + : IMAGE_DURATION_FRAMES + + return { + blobUrl, + durationInFrames, + excluded: false, + filename, + id: `${filename}-${file.lastModified}`, + type, + } +} + export async function enumerateFolder( dirHandle: FileSystemDirectoryHandle, ): Promise { @@ -37,36 +56,22 @@ export async function enumerateFolder( files.push(file) } - const sorted = sortByFilename(files.map((f) => f.name)) - const filesByName = new Map(files.map((f) => [f.name, f])) + const sorted = sortByFilename(files.map((file) => file.name)) + const filesByName = new Map(files.map((file) => [file.name, file])) const createdUrls: string[] = [] try { const slides: MediaSlide[] = [] for (const filename of sorted) { const file = filesByName.get(filename)! - const type = getMediaType(filename) - const blobUrl = URL.createObjectURL(file) - createdUrls.push(blobUrl) - - const durationInFrames = - type === 'video' - ? await getVideoDurationFrames(file) - : IMAGE_DURATION_FRAMES - - slides.push({ - id: `${filename}-${file.lastModified}`, - filename, - type, - blobUrl, - durationInFrames, - excluded: false, - }) + const slide = await createMediaSlideFromFile(file) + createdUrls.push(slide.blobUrl) + slides.push(slide) } return slides - } catch (e) { + } catch (error) { createdUrls.forEach((url) => URL.revokeObjectURL(url)) - throw e + throw error } }