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
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
89 changes: 73 additions & 16 deletions app/src/renderer/src/components/panel/AssetsTab.tsx
Original file line number Diff line number Diff line change
@@ -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 +
Expand All @@ -21,6 +38,26 @@ export function AssetsTab(): JSX.Element {
const clipInput = useRef<HTMLInputElement>(null);
const audioInput = useRef<HTMLInputElement>(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 <div />;

const clips = edl.assets.filter((a) => a.kind !== "audio");
Expand Down Expand Up @@ -124,8 +161,8 @@ export function AssetsTab(): JSX.Element {

{clips.length > 0 && (
<div className="panel-grid">
{clips.map((a) => (
<DraggableClipCell key={a.id} asset={a} slug={slug} />
{clips.map((a, i) => (
<SortableClipCell key={a.id} asset={a} index={i} slug={slug} />
))}
</div>
)}
Expand Down Expand Up @@ -168,8 +205,8 @@ export function AssetsTab(): JSX.Element {
<p className="panel-hint">No audio yet — add a music bed, or record a voiceover in Settings.</p>
) : (
<div className="panel-list">
{audio.map((a) => (
<DraggableAudioRow key={a.id} asset={a} />
{audio.map((a, i) => (
<SortableAudioRow key={a.id} asset={a} index={i} />
))}
</div>
)}
Expand All @@ -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 (
<div
ref={ref}
className={`panel-cell ${isDragging ? "dragging" : ""}`}
title={`${asset.src} — drag onto the timeline`}
className={`panel-cell ${isDragging ? "dragging" : ""} ${isDropTarget ? "drop-target" : ""}`}
title={`${asset.src} — drag onto the timeline, or over a sibling to reorder`}
>
{slug &&
(asset.kind === "image" ? (
Expand All @@ -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 (
<div
ref={ref}
className={`panel-row ${isDragging ? "dragging" : ""}`}
title={`${asset.src} — drag onto an audio layer`}
className={`panel-row ${isDragging ? "dragging" : ""} ${isDropTarget ? "drop-target" : ""}`}
title={`${asset.src} — drag onto an audio layer, or over a sibling to reorder`}
>
<Icon name="voice-high" size={14} />
<span className="panel-row-name">{asset.src.replace(/^assets\//, "")}</span>
Expand Down
55 changes: 53 additions & 2 deletions app/src/renderer/src/lib/edl-edit.test.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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();
Expand Down
21 changes: 21 additions & 0 deletions app/src/renderer/src/lib/edl-edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,27 @@ 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)}`;
Expand Down
7 changes: 7 additions & 0 deletions app/src/renderer/src/styles/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading