From 43b131d308b49df5260fe44db97f0b615b2cbaca Mon Sep 17 00:00:00 2001 From: Christopher Sim Date: Fri, 7 Aug 2026 12:05:18 -0700 Subject: [PATCH] feat(assets): asset-to-timeline drag runs on dnd kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the native HTML5 dataTransfer drag between the Assets tab and the timeline with @dnd-kit/react. Asset cells and audio rows become useDraggable sources typed by asset kind, lanes become useDroppable targets with built-in type acceptance (video lanes take video, audio lanes audio, text lanes nothing), and the Timeline resolves drops itself through useDragDropMonitor — computing the drop time from the lane rect and pointer position at the current zoom, with refs keeping the handler fresh across renders. dnd kit's default feedback replaces the browser drag ghost, accepting lanes highlight while hovered, dragged items dim, and the keyboard sensor makes the flow accessible for free. The ASSET_MIME constant and native drop plumbing are gone. Co-authored-by: Cursor --- app/package.json | 1 + app/src/renderer/src/App.tsx | 65 ++++----- app/src/renderer/src/components/Timeline.tsx | 127 ++++++++++++------ .../src/components/panel/AssetsTab.tsx | 77 ++++++----- app/src/renderer/src/lib/timeline-geometry.ts | 1 - app/src/renderer/src/styles/editor.css | 12 ++ package-lock.json | 83 +++++++++++- 7 files changed, 265 insertions(+), 101 deletions(-) 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": {