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 (