feat(ui): drag bin tiles onto the timeline (video clip, sound lane, clip anchor, new scene, overlay)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-04 03:18:06 +03:00
co-authored by Claude Sonnet 5.5
parent 5d7972d2ef
commit edd5372281
8 changed files with 84 additions and 5 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ linkPipeline({ project, pipeline, onOpen: (fn) => on((what) => { if (what === "o
const frame = build(document.getElementById("app"));
const selection = createSelection({ project });
selection.on(() => say("select"));
const app = { editorSettings: [], timelineLanes: [], sceneSections: [], clipTags: [], has: new Set(), actions: [], inputHooks: [], lastRun: { typed: undefined, n: 0 }, project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, mode: createMode(), panel: frame.togglePanel, closePanels: frame.closePanels, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } };
const app = { editorSettings: [], timelineLanes: [], timelineView: {}, sceneSections: [], clipTags: [], has: new Set(), actions: [], inputHooks: [], lastRun: { typed: undefined, n: 0 }, project, pipeline, generate, api, on, say, title: frame.setZoneTitle, selection, playhead: createPlayhead(), theme: window.ComposerTheme, mode: createMode(), panel: frame.togglePanel, closePanels: frame.closePanels, dock: createDock(), maintenance: createMaintenance({ api, flush: async () => { await project.flush(); if (project.unsaved) throw new Error("Your latest edits could not be saved, so nothing was changed. Try again in a moment."); } }), keys: createKeys(), resetLayout: () => { frame.resetLayout(); app.dock.reset(); } };
const settings = createSettings({ menubar: hostFor("menubar.menus") });
app.openSettings = (id) => settings.open(id);
const result = await loadFeatures(paths, { app });
+5 -1
View File
@@ -41,7 +41,7 @@ function thumbOf(item, cls = "cx-cell-thumb", marks = true) {
* The part every view of a collection has: items, which are selected, and what
* a click on one means. `cellFor` is the only difference between them.
*/
function collection({ items = [], empty = EMPTY, selection = [], onActivate, onContext } = {},
function collection({ items = [], empty = EMPTY, selection = [], onActivate, onContext, drag } = {},
host, cellFor) {
const chosen = new Set(selection);
@@ -57,6 +57,10 @@ function collection({ items = [], empty = EMPTY, selection = [], onActivate, onC
if (onContext) {
cell.addEventListener("contextmenu", (e) => { e.preventDefault(); onContext(item, e); });
}
if (drag) { // { type, data(item) }: a tile that can be dropped elsewhere
cell.draggable = true;
cell.addEventListener("dragstart", (e) => { e.dataTransfer.setData(drag.type, JSON.stringify(drag.data(item))); e.dataTransfer.effectAllowed = "copy"; });
}
host.append(cell);
}
}
+25 -1
View File
@@ -2,6 +2,8 @@
import { composer as c } from "../../composer/composer.js";
import { addVideoClip, addAudioTrack } from "../../shell/imports.js";
import { learn } from "../../shell/bin.js";
import { onMediaDrop } from "../../shell/dnd.js";
import { addScene } from "../../shell/edits.js";
/** How long a bin file is, from the browser's own reading of it (null when it cannot say). */
const lengthOf = (asset) => new Promise((resolve) => {
@@ -42,6 +44,28 @@ export default {
const menu = c.button.menu({ label: "+ Import", tone: "ghost", onClick: () => c.menu.dropdown({ anchor: menu, items: [{ id: "video", label: "Video clip…", disabled: !p.project }, { id: "audio", label: "Audio track…", disabled: !p.project }],
onPick: (id) => choose(id) }) });
host.append(menu.node);
return () => menu.node.remove();
// Dropping a bin tile on the timeline: a video makes a clip, a sound a lane, a picture a scene's anchor (on a clip) or a new scene (beside them).
const at = (e) => (app.timelineView && app.timelineView.timeAt ? app.timelineView.timeAt(e.clientX) : app.playhead.at);
const asset = async (item) => { let list = []; try { list = (await app.api.media()).media || []; } catch { /* the drop does nothing */ } return list.find((m) => m.id === item.id); };
const offs = [
onMediaDrop(".cx-nle-lane-video", async (item, lane, e) => {
const a = await asset(item);
if (!a || !p.project) return;
const clip = e.target.closest && e.target.closest(".cx-nle-clip[data-id]"), id = clip && clip.dataset.id;
if (a.kind === "video") return add(a);
if (a.kind !== "image") return c.toast.warn({ text: "A sound goes on an audio lane." });
if (id && p.scenes.some((s) => s.id === id)) { p.setScene(id, "source", { type: "image", media_ref: a.id }); return c.toast.good({ text: "Set as that clip's starting picture." }); }
const made = p.edit((pr) => { const sc = addScene(pr, "image"); sc.source = { type: "image", media_ref: a.id }; return sc; });
if (made) p.select(made.id);
}),
onMediaDrop(".cx-nle-lane-audio", async (item, lane, e) => {
const a = await asset(item);
if (!a || a.kind !== "audio" || !p.project) return a && c.toast.warn({ text: "Only a sound can go on an audio lane." });
const seconds = await lengthOf(a);
p.edit((pr) => addAudioTrack(pr, a, at(e), seconds));
}),
];
return () => { offs.forEach((f) => f()); menu.node.remove(); };
},
};
+2 -1
View File
@@ -1,6 +1,7 @@
// The media bin: files brought in (reference images, clips), kept apart from what runs produce.
import { composer as c } from "../../composer/composer.js";
import { learn } from "../../shell/bin.js";
import { MEDIA_DRAG } from "../../shell/dnd.js";
const base = (id) => `/funpack/api/media/${encodeURIComponent(id)}`;
@@ -52,7 +53,7 @@ export default {
} catch (err) { tell(err.message); }
},
};
const props = { id: "media", items: [], empty: "No media yet. Drop images or clips here.", onActivate: pick, onContext: context };
const props = { id: "media", items: [], empty: "No media yet. Drop images or clips here.", onActivate: pick, onContext: context, drag: { type: MEDIA_DRAG, data: (it) => ({ id: it.id, kind: (items.find((m) => m.id === it.id) || {}).kind }) } };
const galleries = { adaptive: c.gallery.adaptive(props), list: c.gallery.list(props), icons: c.gallery.icons(props) };
const shelf = c.region.stack({ gap: "none", children: [galleries.adaptive] });
const drop = c.dropzone.default({ label: "Drop or choose files", hint: "images, clips, audio",
+9 -1
View File
@@ -2,6 +2,7 @@
import { composer as c } from "../../composer/composer.js";
import * as ov from "../../shell/overlays.js";
import { learn } from "../../shell/bin.js";
import { onMediaDrop } from "../../shell/dnd.js";
import { openDialog } from "./dialog.js";
const ID = "o:";
@@ -57,7 +58,14 @@ export default {
c.modal.dialogue({ title: "Remove lane", message: `Remove “${top.label}” and everything on it?`, tone: "danger", confirmLabel: "Remove" }).result.then((yes) => yes && edit((pr) => ov.removeLane(pr, top.id)));
} }) });
host.append(add.node);
const offDrop = onMediaDrop(".cx-nle-lane-overlay", async (item, lane, e) => { // a picture dropped on an overlay lane becomes an overlay there
if (item.kind !== "image" || !p.project) return c.toast.warn({ text: "Only a picture can be laid over the cut." });
let name = "Image"; try { name = ((await app.api.media()).media || []).find((m) => m.id === item.id).name; } catch { /* keep the generic label */ }
const row = [...document.querySelectorAll(".cx-nle-lane-overlay")].indexOf(lane), lanes = ov.lanesOf(p.project), laneId = (lanes[lanes.length - 1 - row] || {}).id; // lanes draw top-first
const sec = app.timelineView && app.timelineView.timeAt ? app.timelineView.timeAt(e.clientX) : app.playhead.at;
edit((pr) => ov.addImage(pr, item.id, name, sec, laneId));
});
const off = app.on(() => { add.setDisabled && add.setDisabled(!p.project); });
return () => { off(); const at = app.timelineLanes.indexOf(entry); if (at >= 0) app.timelineLanes.splice(at, 1); add.node.remove(); app.say("timeline.lanes"); };
return () => { off(); offDrop(); const at = app.timelineLanes.indexOf(entry); if (at >= 0) app.timelineLanes.splice(at, 1); add.node.remove(); app.say("timeline.lanes"); };
},
};
+1
View File
@@ -45,6 +45,7 @@ const stage = {
const draw = () => { const open = p.project; view.setLanes(open ? [videoLane(open, sel.ids, sel.focus, clipActions, ghostActions, (sc) => (app.clipTags || []).flatMap((f) => { try { return f(sc); } catch { return []; } })), audioLane(open, sel.ids), ...extra.flatMap((e) => { try { return e.lanes(open); } catch { return []; } })].filter(Boolean) : []); }; // a feature's lanes failing must not stop the baseline's
const setZoom = (z) => { zoom = Math.min(ZOOM.length - 1, Math.max(0, z)); view.setZoom(ZOOM[zoom]); };
host.append(view.node);
if (app.timelineView) app.timelineView.timeAt = view.timeAt; // so a drop can say where on the timeline it landed
const off = [app.on((what) => {
if (what === "zoom.in") setZoom(zoom + 1); else if (what === "zoom.out") setZoom(zoom - 1);
else if (what === "zoom.fit") { const total = p.project ? totalSeconds(p.project) : 0; const fit = ZOOM.filter((z) => z * (total + 2) <= view.node.clientWidth); setZoom(fit.length ? ZOOM.indexOf(fit[fit.length - 1]) : 0); }
+21
View File
@@ -0,0 +1,21 @@
// Dragging a media-bin tile onto something. The bin says what it carries; whoever owns a drop target says what that means.
export const MEDIA_DRAG = "application/funpack-media";
/** When a bin tile is dropped on an element matching `selector`, call fn({id, kind}, element, event). -> remover. */
export function onMediaDrop(selector, fn, doc = document) {
const carries = (e) => e.dataTransfer && [...(e.dataTransfer.types || [])].includes(MEDIA_DRAG);
const target = (e) => (doc.querySelector('[role="dialog"]') ? null : e.target.closest && e.target.closest(selector));
const over = (e) => { if (carries(e) && target(e)) { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; } };
const drop = (e) => {
if (!carries(e)) return;
const el = target(e);
if (!el) return;
let item;
try { item = JSON.parse(e.dataTransfer.getData(MEDIA_DRAG)); } catch { return; }
e.preventDefault();
fn(item, el, e);
};
doc.addEventListener("dragover", over);
doc.addEventListener("drop", drop);
return () => { doc.removeEventListener("dragover", over); doc.removeEventListener("drop", drop); };
}
+20
View File
@@ -0,0 +1,20 @@
import test from "node:test";
import assert from "node:assert/strict";
import { onMediaDrop, MEDIA_DRAG } from "../dnd.js";
const fake = () => { const l = {}; return { addEventListener: (n, f) => { l[n] = f; }, removeEventListener: (n) => { delete l[n]; }, querySelector: () => null, l }; };
const ev = (payload, hit) => ({ dataTransfer: { types: payload ? [MEDIA_DRAG] : ["Files"], getData: () => JSON.stringify(payload), set dropEffect(v) {} }, target: { closest: () => hit }, preventDefault() { this.stopped = true; } });
test("a bin tile dropped on a matching target reaches the handler; files and other targets are left alone", () => {
const doc = fake(), got = [];
const off = onMediaDrop(".lane", (item, el) => got.push([item, el]), doc);
const hit = {}, a = ev({ id: "m", kind: "image" }, hit);
doc.l.dragover(a); doc.l.drop(a);
assert.deepEqual(got, [[{ id: "m", kind: "image" }, hit]]);
assert.equal(a.stopped, true);
const b = ev(null, hit), c = ev({ id: "x" }, null);
doc.l.drop(b); doc.l.drop(c);
assert.equal(got.length, 1);
off();
assert.deepEqual(Object.keys(doc.l), []);
});