+
+ {/* 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" ? (
-

- ) : (
-
- ))}
-
{a.src.replace(/^assets\//, "")}
-
+
))}
)}
@@ -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" ? (
+

+ ) : (
+
+ ))}
+
{asset.src.replace(/^assets\//, "")}
+
+ );
+}
+
+/** 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": {