From 4ab8a7f5460d8e636f249c1f6531e725a8889ed5 Mon Sep 17 00:00:00 2001 From: Christopher Sim Date: Fri, 7 Aug 2026 12:26:13 -0700 Subject: [PATCH 1/2] feat: sortable asset panel (dnd-kit Sortable) with droppable timeline lanes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panel clip cells and audio rows become useSortable items (per-family groups, keyboard plugin kept) so they reorder within the panel and remain the drag sources the droppable lanes accept. OptimisticSortingPlugin is deliberately omitted: it shuffles sibling DOM nodes whenever a drag-out sweeps across the grid and only restores them on *canceled* drags, so state stays the single source of order and reorders commit once on drop — resolved by asset id (live-reload safe) and persisted through updateEdl (undo + edl.json). Co-authored-by: Cursor --- app/package.json | 1 + .../src/components/panel/AssetsTab.tsx | 89 +++++++++++++++---- app/src/renderer/src/lib/edl-edit.test.ts | 55 +++++++++++- app/src/renderer/src/lib/edl-edit.ts | 26 ++++++ app/src/renderer/src/styles/editor.css | 7 ++ package-lock.json | 1 + 6 files changed, 161 insertions(+), 18 deletions(-) diff --git a/app/package.json b/app/package.json index 9ec4d1b..d93d05c 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/dom": "^0.5.0", "@dnd-kit/react": "^0.5.0", "@paper-design/shaders-react": "^0.0.78", "@reel/edl": "0.1.0", diff --git a/app/src/renderer/src/components/panel/AssetsTab.tsx b/app/src/renderer/src/components/panel/AssetsTab.tsx index a206e1b..fb98bd4 100644 --- a/app/src/renderer/src/components/panel/AssetsTab.tsx +++ b/app/src/renderer/src/components/panel/AssetsTab.tsx @@ -1,11 +1,28 @@ import { type DragEvent, useRef, useState } from "react"; -import { useDraggable } from "@dnd-kit/react"; +import { useDragDropMonitor } from "@dnd-kit/react"; +import { isSortable, useSortable } from "@dnd-kit/react/sortable"; +import { SortableKeyboardPlugin } from "@dnd-kit/dom/sortable"; import type { Asset } from "@reel/edl"; import { useEditor } from "../../store"; -import { addAssets, addAudioClip } from "../../lib/edl-edit"; +import { addAssets, addAudioClip, reorderAssets } from "../../lib/edl-edit"; import { pathsFrom } from "../../lib/files"; import { Icon, Menu, MenuHeader, MenuItem, MenuSection } from "../ui"; +/** Sortable groups; also the discriminator for the reorder-commit handler. */ +const CLIP_GROUP = "panel-clips"; +const AUDIO_GROUP = "panel-audio"; + +/** + * Sortable plugins WITHOUT OptimisticSortingPlugin. The optimistic plugin + * physically shuffles sibling DOM nodes whenever the pointer crosses another + * cell — but the common gesture here is dragging a cell *out* of the grid + * toward the timeline, which sweeps across siblings, and the plugin only + * restores its DOM mutations on *canceled* drags (not on drops that land + * elsewhere, like a lane). Without it, React state stays the single source of + * order: reorders commit once, on drop. + */ +const PANEL_SORT_PLUGINS = [SortableKeyboardPlugin]; + /** * Assets tab: everything imported into the project — clips (draggable onto * the timeline) and audio. Upload here replaces the old left rail's Clips + @@ -21,6 +38,26 @@ export function AssetsTab(): JSX.Element { const clipInput = useRef(null); const audioInput = useRef(null); + // Commits panel reorders. Drops onto the timeline never reach this branch + // (a lane isn't sortable), and the Timeline's own monitor ignores sortable + // targets (no trackId) — the two dragend handlers are disjoint by + // construction. Ids come from drag data and are re-resolved against the + // freshest EDL inside updateEdl, so a mid-drag live reload can't misplace. + useDragDropMonitor({ + onDragEnd(event) { + if (event.canceled) return; + const { source, target } = event.operation; + if (!source || !target || !isSortable(source) || !isSortable(target)) return; + if (source.group !== target.group) return; + if (source.group !== CLIP_GROUP && source.group !== AUDIO_GROUP) return; + const src = (source.data as { assetId?: string } | undefined)?.assetId; + const tgt = (target.data as { assetId?: string } | undefined)?.assetId; + if (!src || !tgt || src === tgt) return; + const family = source.group === AUDIO_GROUP ? "audio" : "clip"; + useEditor.getState().updateEdl((d) => reorderAssets(d, family, src, tgt)); + }, + }); + if (!edl) return
; const clips = edl.assets.filter((a) => a.kind !== "audio"); @@ -124,8 +161,8 @@ export function AssetsTab(): JSX.Element { {clips.length > 0 && (
- {clips.map((a) => ( - + {clips.map((a, i) => ( + ))}
)} @@ -168,8 +205,8 @@ export function AssetsTab(): JSX.Element {

No audio yet — add a music bed, or record a voiceover in Settings.

) : (
- {audio.map((a) => ( - + {audio.map((a, i) => ( + ))}
)} @@ -178,18 +215,34 @@ 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({ +/** + * Sortable drag source: a clip cell. Reorders within the grid (drop on a + * sibling) and doubles as the drag source the timeline's video lanes accept — + * `type`/`data` are what the lane `accept` rules and drop handler read. + */ +function SortableClipCell({ + asset, + index, + slug, +}: { + asset: Asset; + index: number; + slug: string | null; +}): JSX.Element { + const { ref, isDragging, isDropTarget } = useSortable({ id: `asset-${asset.id}`, + index, + group: CLIP_GROUP, type: asset.kind, + accept: ["video", "image"], data: { assetId: asset.id, kind: asset.kind }, + plugins: PANEL_SORT_PLUGINS, }); return (
{slug && (asset.kind === "image" ? ( @@ -202,18 +255,22 @@ function DraggableClipCell({ asset, slug }: { asset: Asset; slug: string | null ); } -/** dnd-kit drag source: an audio row the timeline's audio lanes accept. */ -function DraggableAudioRow({ asset }: { asset: Asset }): JSX.Element { - const { ref, isDragging } = useDraggable({ +/** Sortable drag source: an audio row; the timeline's audio lanes accept it. */ +function SortableAudioRow({ asset, index }: { asset: Asset; index: number }): JSX.Element { + const { ref, isDragging, isDropTarget } = useSortable({ id: `asset-${asset.id}`, + index, + group: AUDIO_GROUP, type: asset.kind, + accept: ["audio"], data: { assetId: asset.id, kind: asset.kind }, + plugins: PANEL_SORT_PLUGINS, }); return (
{asset.src.replace(/^assets\//, "")} diff --git a/app/src/renderer/src/lib/edl-edit.test.ts b/app/src/renderer/src/lib/edl-edit.test.ts index 406f24c..be49a0b 100644 --- a/app/src/renderer/src/lib/edl-edit.test.ts +++ b/app/src/renderer/src/lib/edl-edit.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import { parseEdl, type AudioTrack } from "@reel/edl"; -import { addAssets, addAudioClip, findVideoClip, mutateTextClip, mutateVideoClip } from "./edl-edit"; +import { parseEdl, type Asset, type AudioTrack } from "@reel/edl"; +import { + addAssets, + addAudioClip, + findVideoClip, + mutateTextClip, + mutateVideoClip, + reorderAssets, +} from "./edl-edit"; function baseEdl() { return parseEdl({ @@ -79,6 +86,50 @@ describe("addAudioClip", () => { }); }); +describe("reorderAssets", () => { + // Clips and audio interleaved on purpose: reordering one family must keep + // the other family's slots fixed. + const mixed = (): Asset[] => [ + { id: "c1", kind: "video", src: "assets/c1.mp4" }, + { id: "m1", kind: "audio", src: "assets/m1.mp3" }, + { id: "c2", kind: "video", src: "assets/c2.mp4" }, + { id: "c3", kind: "image", src: "assets/c3.jpg" }, + { id: "m2", kind: "audio", src: "assets/m2.mp3" }, + ]; + + it("moves a clip forward, landing after the target (arrayMove semantics)", () => { + const edl = baseEdl(); + edl.assets = mixed(); + reorderAssets(edl, "clip", "c1", "c3"); + expect(edl.assets.map((a) => a.id)).toEqual(["c2", "m1", "c3", "c1", "m2"]); + }); + + it("moves a clip backward, landing before the target", () => { + const edl = baseEdl(); + edl.assets = mixed(); + reorderAssets(edl, "clip", "c3", "c1"); + expect(edl.assets.map((a) => a.id)).toEqual(["c3", "m1", "c1", "c2", "m2"]); + }); + + it("reorders audio without disturbing clip positions", () => { + const edl = baseEdl(); + edl.assets = mixed(); + reorderAssets(edl, "audio", "m2", "m1"); + expect(edl.assets.map((a) => a.id)).toEqual(["c1", "m2", "c2", "c3", "m1"]); + }); + + it("no-ops on identical ids or ids missing from the family", () => { + const edl = baseEdl(); + edl.assets = mixed(); + const before = edl.assets.map((a) => a.id); + reorderAssets(edl, "clip", "c1", "c1"); + reorderAssets(edl, "clip", "c1", "ghost"); + // m1 is audio — not in the clip family view, so this must not move c1. + reorderAssets(edl, "clip", "c1", "m1"); + expect(edl.assets.map((a) => a.id)).toEqual(before); + }); +}); + describe("mutators", () => { it("mutateVideoClip edits the matching clip", () => { const edl = baseEdl(); diff --git a/app/src/renderer/src/lib/edl-edit.ts b/app/src/renderer/src/lib/edl-edit.ts index a86237f..4b51d95 100644 --- a/app/src/renderer/src/lib/edl-edit.ts +++ b/app/src/renderer/src/lib/edl-edit.ts @@ -70,6 +70,32 @@ export function addAudioClip( } } +/** + * Move one asset to another's position within its Assets-tab family (clips = + * everything non-audio, or audio). `edl.assets` interleaves both families, so + * only that family's slots are re-filled — the other family's positions are + * untouched. Resolves by id, not index: a drop stays safe even if the EDL was + * live-reloaded (agent write, undo) mid-drag. Unknown ids are a no-op. + */ +export function reorderAssets( + edl: Edl, + family: "clip" | "audio", + sourceId: string, + targetId: string, +): void { + if (sourceId === targetId) return; + const inFamily = (a: Asset) => (family === "audio" ? a.kind === "audio" : a.kind !== "audio"); + const subset = edl.assets.filter(inFamily); + const from = subset.findIndex((a) => a.id === sourceId); + const to = subset.findIndex((a) => a.id === targetId); + if (from === -1 || to === -1) return; + // arrayMove semantics (`to` measured before removal): dragging forward lands + // after the target, dragging backward lands before it. + subset.splice(to, 0, ...subset.splice(from, 1)); + let i = 0; + edl.assets = edl.assets.map((a) => (inFamily(a) ? subset[i++] : a)); +} + /** Append a new empty timeline layer of the given type. */ export function addTrack(edl: Edl, type: "video" | "text" | "audio", name?: string): void { const id = `${type}-${Date.now().toString(36)}`; diff --git a/app/src/renderer/src/styles/editor.css b/app/src/renderer/src/styles/editor.css index 169f52b..5e872b5 100644 --- a/app/src/renderer/src/styles/editor.css +++ b/app/src/renderer/src/styles/editor.css @@ -706,6 +706,13 @@ opacity: 0.5; } +/* Sortable reorder affordance: the cell the drag would land on. */ +.panel-cell.drop-target, +.panel-row.drop-target { + outline: 1.5px solid var(--ap-color-foreground-accent); + outline-offset: -1.5px; +} + .panel-cell video, .panel-cell img { width: 100%; diff --git a/package-lock.json b/package-lock.json index 3d9f9b8..10773b1 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/dom": "^0.5.0", "@dnd-kit/react": "^0.5.0", "@paper-design/shaders-react": "^0.0.78", "@reel/edl": "0.1.0", From 338bce10720fa7099bd98a60509512a879605b16 Mon Sep 17 00:00:00 2001 From: Christopher Sim Date: Fri, 7 Aug 2026 12:27:58 -0700 Subject: [PATCH 2/2] style: prettier formatting for reorderAssets Co-authored-by: Cursor --- app/src/renderer/src/lib/edl-edit.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/app/src/renderer/src/lib/edl-edit.ts b/app/src/renderer/src/lib/edl-edit.ts index 4b51d95..02a6bb6 100644 --- a/app/src/renderer/src/lib/edl-edit.ts +++ b/app/src/renderer/src/lib/edl-edit.ts @@ -77,12 +77,7 @@ export function addAudioClip( * untouched. Resolves by id, not index: a drop stays safe even if the EDL was * live-reloaded (agent write, undo) mid-drag. Unknown ids are a no-op. */ -export function reorderAssets( - edl: Edl, - family: "clip" | "audio", - sourceId: string, - targetId: string, -): void { +export function reorderAssets(edl: Edl, family: "clip" | "audio", sourceId: string, targetId: string): void { if (sourceId === targetId) return; const inFamily = (a: Asset) => (family === "audio" ? a.kind === "audio" : a.kind !== "audio"); const subset = edl.assets.filter(inFamily);