Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added issue-16-drag-drop-import.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
129 changes: 129 additions & 0 deletions scripts/playwright-issue-16-smoke.mjs
Original file line number Diff line number Diff line change
@@ -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)
28 changes: 28 additions & 0 deletions specs/issue-16-drag-drop-import.md
Original file line number Diff line number Diff line change
@@ -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 |
12 changes: 12 additions & 0 deletions src/editor-shell/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -40,6 +41,7 @@ export function App() {
error,
corruptError,
folderOpen,
importNotice,
recentProjects,
} = project

Expand Down Expand Up @@ -110,6 +112,7 @@ export function App() {

return (
<div className="flex h-full flex-col overflow-hidden">
<DropImportLayer enabled={folderOpen} onDropFiles={project.importDroppedFiles} />
<AppHeader
folderOpen={folderOpen}
loading={loading}
Expand All @@ -134,6 +137,15 @@ export function App() {
</div>
)}

{importNotice ? (
<div className="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-950/40 px-4 py-2 text-sm text-amber-200">
<span>{importNotice}</span>
<Button size="xs" variant="outline" onClick={project.dismissImportNotice}>
Dismiss
</Button>
</div>
) : null}

{loading && (
<div className="shrink-0 border-b bg-blue-950/40 px-4 py-1.5 text-sm text-blue-300">
Loading media…
Expand Down
33 changes: 33 additions & 0 deletions src/editor-shell/DropImportLayer.tsx
Original file line number Diff line number Diff line change
@@ -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
}
48 changes: 48 additions & 0 deletions src/editor-shell/useProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { GlobalSettings, ThemeName } from '../timeline-core'
import {
addRecentProject,
enumerateAudioTracks,
importDroppedMediaFiles,
listRecentProjects,
openProject,
requestHandlePermission,
Expand All @@ -23,6 +24,14 @@ import { FPS } from './PlayerPane'

const AUTOSAVE_DELAY = 2000

async function listFolderFilenames(handle: FileSystemDirectoryHandle): Promise<Set<string>> {
const names = new Set<string>()
for await (const entry of handle.values()) {
if (entry.kind === 'file') names.add(entry.name)
}
return names
}

type Options = {
onFolderLoaded?: () => void
}
Expand All @@ -38,6 +47,7 @@ export function useProject({ onFolderLoaded }: Options = {}) {
const [error, setError] = useState<string | null>(null)
const [corruptError, setCorruptError] = useState<string | null>(null)
const [folderOpen, setFolderOpen] = useState(false)
const [importNotice, setImportNotice] = useState<string | null>(null)
const [recentProjects, setRecentProjects] = useState<RecentProject[]>([])

const dirHandleRef = useRef<FileSystemDirectoryHandle | null>(null)
Expand Down Expand Up @@ -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,
Expand All @@ -215,7 +260,10 @@ export function useProject({ onFolderLoaded }: Options = {}) {
loading,
error,
corruptError,
dismissImportNotice,
folderOpen,
importDroppedFiles,
importNotice,
recentProjects,
pickFolder,
refresh,
Expand Down
Loading
Loading