diff --git a/app/package.json b/app/package.json index 7187584..9ec4d1b 100644 --- a/app/package.json +++ b/app/package.json @@ -15,6 +15,7 @@ "@ai-sdk/anthropic": "^4.0.4", "@ai-sdk/openai": "^4.0.4", "@ai-sdk/openai-compatible": "^3.0.2", + "@dnd-kit/react": "^0.5.0", "@paper-design/shaders-react": "^0.0.78", "@reel/edl": "0.1.0", "@remotion/bundler": "^4.0.0", diff --git a/app/src/renderer/src/App.tsx b/app/src/renderer/src/App.tsx index 32fa257..8e65077 100644 --- a/app/src/renderer/src/App.tsx +++ b/app/src/renderer/src/App.tsx @@ -1,4 +1,5 @@ import { type CSSProperties, memo, useEffect } from "react"; +import { DragDropProvider } from "@dnd-kit/react"; import { EditorHeader } from "./components/EditorHeader"; import { FloatingToolbar } from "./components/FloatingToolbar"; import { PreviewStage } from "./components/PreviewStage"; @@ -141,45 +142,49 @@ export function App(): JSX.Element { {view === "home" ? ( ) : ( -
- - {/* Figma 346:9830: the timeline lives INSIDE the left column (it ends + // The provider spans the Assets tab (drag sources) and the timeline + // lanes (drop targets); the Timeline monitors drops itself. + +
+ + {/* Figma 346:9830: the timeline lives INSIDE the left column (it ends where the panel starts); the right panel runs full height. */} -
-
-
- - {!panelsHidden && } +
+
+
+ + {!panelsHidden && } +
+ {!panelsHidden && ( + <> + + + + )}
{!panelsHidden && ( <> - - + + )}
- {!panelsHidden && ( - <> - - - + + {!hasEdl && ( +
+ {loadError ? `Could not load project: ${loadError}` : "Loading project…"} +
)}
- - {!hasEdl && ( -
- {loadError ? `Could not load project: ${loadError}` : "Loading project…"} -
- )} -
+ )} {notices.length > 0 && (
diff --git a/app/src/renderer/src/components/Timeline.tsx b/app/src/renderer/src/components/Timeline.tsx index b706f0f..2f4c00e 100644 --- a/app/src/renderer/src/components/Timeline.tsx +++ b/app/src/renderer/src/components/Timeline.tsx @@ -1,10 +1,10 @@ -import { type DragEvent, type MouseEvent, useRef, useState } from "react"; +import { type MouseEvent, type ReactNode, useRef, useState } from "react"; +import { useDragDropMonitor, useDroppable } from "@dnd-kit/react"; import { durationSeconds, MAX_TIMELINE_SEC, type Asset, type Track } from "@reel/edl"; import { useEditor } from "../store"; import { addAssets, addTrack, renameTrack } from "../lib/edl-edit"; import { pathsFrom } from "../lib/files"; import { - ASSET_MIME, assetDurationFor, clipsOf, commit, @@ -56,10 +56,56 @@ export function Timeline(): JSX.Element { // NOTE: the Space play/pause shortcut lives in App.tsx (editor-level), not // here — this component unmounts in Cmd+\ focus mode. + const pxPerSec = PX_PER_SEC * zoom; + + // Drops from the Assets tab (dnd-kit). The monitor registers once with a + // stable callback; the ref keeps the handler's closures (zoom, store) fresh + // across renders. Reads go through getState so a stale EDL can't be used. + const dropAssetAt = (assetId: string, kind: string, trackId: string, sec: number) => { + const s = useEditor.getState(); + const asset = s.edl?.assets.find((a) => a.id === assetId); + if (!asset) return; + const at = round(sec); + if (kind === "video") { + s.updateEdl((d) => { + const t = d.tracks.find((x) => x.id === trackId); + if (t?.type === "video") { + t.clips.push({ + id: `c-${assetId}-${Date.now().toString(36)}`, + assetId, + start: at, + in: 0, + out: round(asset.durationSec ?? 3), + volume: 1, + }); + } + }); + } else if (kind === "audio") { + s.updateEdl((d) => placeAudioOnTrack(d, trackId, assetId, asset.durationSec, at)); + } + }; + const dropRef = useRef(dropAssetAt); + dropRef.current = dropAssetAt; + const pxPerSecRef = useRef(pxPerSec); + pxPerSecRef.current = pxPerSec; + + useDragDropMonitor({ + onDragEnd(event) { + if (event.canceled) return; + const { source, target, position } = event.operation; + if (!source || !target) return; + const data = source.data as { assetId?: string; kind?: string } | undefined; + const lane = target.data as { trackId?: string } | undefined; + if (!data?.assetId || !data.kind || !lane?.trackId) return; + const rect = (target as { element?: Element }).element?.getBoundingClientRect(); + const sec = rect ? Math.max(0, (position.current.x - rect.left) / pxPerSecRef.current) : 0; + dropRef.current(data.assetId, data.kind, lane.trackId, sec); + }, + }); + if (!edl) return
; const fps = edl.format.fps; - const pxPerSec = PX_PER_SEC * zoom; // Clamp so a corrupt/hostile EDL can never drive the tick loop or lane width // unbounded (schema bounds timings too; this is belt-and-braces). const dur = Math.min(Math.max(durationSeconds(edl), 6), MAX_TIMELINE_SEC); @@ -237,34 +283,6 @@ export function Timeline(): JSX.Element { }); }; - // Drops from the Assets tab (existing assets). - const onAssetDrop = (e: DragEvent, track: LaneTrack) => { - const raw = e.dataTransfer.getData(ASSET_MIME); - if (!raw) return; - e.preventDefault(); - const { assetId, kind } = JSON.parse(raw) as { assetId: string; kind: string }; - const asset = edl.assets.find((a) => a.id === assetId); - if (!asset) return; - const at = round(laneSec(e)); - if (track.type === "video" && kind === "video") { - updateEdl((d) => { - const t = d.tracks.find((x) => x.id === track.id); - if (t?.type === "video") { - t.clips.push({ - id: `c-${assetId}-${Date.now().toString(36)}`, - assetId, - start: at, - in: 0, - out: round(asset.durationSec ?? 3), - volume: 1, - }); - } - }); - } else if (track.type === "audio" && kind === "audio") { - updateEdl((d) => placeAudioOnTrack(d, track.id, assetId, asset.durationSec, at)); - } - }; - const ticks: number[] = []; for (let s = 0; s <= Math.ceil(dur); s += 2) ticks.push(s); @@ -356,19 +374,15 @@ export function Timeline(): JSX.Element { : undefined } /> -
{ if (track.type === "text") sketchText(e, track); }} onClick={(e) => { if (track.type !== "text") pickForLane(e, track); }} - onDragOver={(e) => { - if (e.dataTransfer.types.includes(ASSET_MIME)) e.preventDefault(); - }} - onDrop={(e) => onAssetDrop(e, track)} > {clipsOf(track).map((clip) => { const geom = geomFor(track.type, clip, drag); @@ -416,7 +430,7 @@ export function Timeline(): JSX.Element { {track.clips.length === 0 && !ghost && (
{emptyHint(track.type)}
)} -
+
))} @@ -456,6 +470,41 @@ export function Timeline(): JSX.Element { ); } +/* ---------- droppable lane (dnd-kit target for Assets-tab drags) ---------- */ + +function DroppableLane({ + track, + lanePx, + onMouseDown, + onClick, + children, +}: { + track: LaneTrack; + lanePx: number; + onMouseDown: (e: MouseEvent) => void; + onClick: (e: MouseEvent) => void; + children: ReactNode; +}): JSX.Element { + const { ref, isDropTarget } = useDroppable({ + id: `lane-${track.id}`, + // Type-based acceptance: video lanes take video assets, audio lanes take + // audio; text lanes accept nothing (their clips are sketched, not dropped). + accept: track.type === "video" ? ["video"] : track.type === "audio" ? ["audio"] : [], + data: { trackId: track.id }, + }); + return ( +
+ {children} +
+ ); +} + /* ---------- chip content per track type ---------- */ function ChipBody({ diff --git a/app/src/renderer/src/components/panel/AssetsTab.tsx b/app/src/renderer/src/components/panel/AssetsTab.tsx index 2eba0da..a206e1b 100644 --- a/app/src/renderer/src/components/panel/AssetsTab.tsx +++ b/app/src/renderer/src/components/panel/AssetsTab.tsx @@ -1,7 +1,8 @@ import { type DragEvent, useRef, useState } from "react"; +import { useDraggable } from "@dnd-kit/react"; +import type { Asset } from "@reel/edl"; import { useEditor } from "../../store"; import { addAssets, addAudioClip } from "../../lib/edl-edit"; -import { ASSET_MIME } from "../../lib/timeline-geometry"; import { pathsFrom } from "../../lib/files"; import { Icon, Menu, MenuHeader, MenuItem, MenuSection } from "../ui"; @@ -124,23 +125,7 @@ export function AssetsTab(): JSX.Element { {clips.length > 0 && (
{clips.map((a) => ( -
- e.dataTransfer.setData(ASSET_MIME, JSON.stringify({ assetId: a.id, kind: a.kind })) - } - > - {slug && - (a.kind === "image" ? ( - - ) : ( -
+ ))}
)} @@ -184,18 +169,7 @@ export function AssetsTab(): JSX.Element { ) : (
{audio.map((a) => ( -
- e.dataTransfer.setData(ASSET_MIME, JSON.stringify({ assetId: a.id, kind: a.kind })) - } - > - - {a.src.replace(/^assets\//, "")} -
+ ))}
)} @@ -203,3 +177,46 @@ export function AssetsTab(): JSX.Element {
); } + +/** dnd-kit drag source: a clip cell the timeline's video lanes accept. */ +function DraggableClipCell({ asset, slug }: { asset: Asset; slug: string | null }): JSX.Element { + const { ref, isDragging } = useDraggable({ + id: `asset-${asset.id}`, + type: asset.kind, + data: { assetId: asset.id, kind: asset.kind }, + }); + return ( +
+ {slug && + (asset.kind === "image" ? ( + + ) : ( +
+ ); +} + +/** dnd-kit drag source: an audio row the timeline's audio lanes accept. */ +function DraggableAudioRow({ asset }: { asset: Asset }): JSX.Element { + const { ref, isDragging } = useDraggable({ + id: `asset-${asset.id}`, + type: asset.kind, + data: { assetId: asset.id, kind: asset.kind }, + }); + return ( +
+ + {asset.src.replace(/^assets\//, "")} +
+ ); +} diff --git a/app/src/renderer/src/lib/timeline-geometry.ts b/app/src/renderer/src/lib/timeline-geometry.ts index bff498d..00cda40 100644 --- a/app/src/renderer/src/lib/timeline-geometry.ts +++ b/app/src/renderer/src/lib/timeline-geometry.ts @@ -6,7 +6,6 @@ import type { Edl, Track } from "@reel/edl"; export const LABEL_W = 68; export const PX_PER_SEC = 60; export const MIN_DUR = 0.2; -export const ASSET_MIME = "application/x-aperture-asset"; export type DragMode = "move" | "left" | "right"; /** Timeline lanes render clip-bearing tracks only (captions burn in via preview). */ diff --git a/app/src/renderer/src/styles/editor.css b/app/src/renderer/src/styles/editor.css index 7770bb4..169f52b 100644 --- a/app/src/renderer/src/styles/editor.css +++ b/app/src/renderer/src/styles/editor.css @@ -701,6 +701,11 @@ cursor: grab; } +.panel-cell.dragging, +.panel-row.dragging { + opacity: 0.5; +} + .panel-cell video, .panel-cell img { width: 100%; @@ -1456,6 +1461,13 @@ button.tl-gutter-btn:hover { height: 100%; } +/* dnd-kit: an Assets-tab drag is hovering a lane that accepts it. */ +.tl-lane.drop-target { + background: color-mix(in srgb, var(--ap-color-foreground-accent) 7%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ap-color-foreground-accent) 35%, transparent); + border-radius: var(--ap-radius-md); +} + .tl-lane-hint { position: sticky; left: 80px; diff --git a/package-lock.json b/package-lock.json index 3a90f0b..3d9f9b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@ai-sdk/anthropic": "^4.0.4", "@ai-sdk/openai": "^4.0.4", "@ai-sdk/openai-compatible": "^3.0.2", + "@dnd-kit/react": "^0.5.0", "@paper-design/shaders-react": "^0.0.78", "@reel/edl": "0.1.0", "@remotion/bundler": "^4.0.0", @@ -1156,6 +1157,77 @@ "node": ">=6.0.0" } }, + "node_modules/@dnd-kit/abstract": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/abstract/-/abstract-0.5.0.tgz", + "integrity": "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA==", + "license": "MIT", + "dependencies": { + "@dnd-kit/geometry": "^0.5.0", + "@dnd-kit/state": "^0.5.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@dnd-kit/collision": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/collision/-/collision-0.5.0.tgz", + "integrity": "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/abstract": "^0.5.0", + "@dnd-kit/geometry": "^0.5.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@dnd-kit/dom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/dom/-/dom-0.5.0.tgz", + "integrity": "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/abstract": "^0.5.0", + "@dnd-kit/collision": "^0.5.0", + "@dnd-kit/geometry": "^0.5.0", + "@dnd-kit/state": "^0.5.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@dnd-kit/geometry": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/geometry/-/geometry-0.5.0.tgz", + "integrity": "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A==", + "license": "MIT", + "dependencies": { + "@dnd-kit/state": "^0.5.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@dnd-kit/react": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/react/-/react-0.5.0.tgz", + "integrity": "sha512-abQPLI8lmfVE+v/n+pqy5WFxrw6T2Yg0UQZsL78dp5DKci7dKTVDjvLWqvass+XTFtzJmsZEjk1NdqE6xG8Jiw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/abstract": "^0.5.0", + "@dnd-kit/dom": "^0.5.0", + "@dnd-kit/state": "^0.5.0", + "tslib": "^2.6.2" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@dnd-kit/state": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/state/-/state-0.5.0.tgz", + "integrity": "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w==", + "license": "MIT", + "dependencies": { + "@preact/signals-core": "^1.10.0", + "tslib": "^2.6.2" + } + }, "node_modules/@electron/get": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", @@ -2843,6 +2915,16 @@ } } }, + "node_modules/@preact/signals-core": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz", + "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@reel/edl": { "resolved": "packages/edl", "link": true @@ -9304,7 +9386,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, "license": "0BSD" }, "node_modules/type-check": {