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:
co-authored by
Claude Sonnet 5.5
parent
5d7972d2ef
commit
edd5372281
+1
-1
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(); };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"); };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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); };
|
||||
}
|
||||
@@ -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), []);
|
||||
});
|
||||
Reference in New Issue
Block a user