diff --git a/app/ui/boot.js b/app/ui/boot.js index d0e349c5..9cebf407 100644 --- a/app/ui/boot.js +++ b/app/ui/boot.js @@ -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 }); diff --git a/app/ui/composer/elements/gallery.js b/app/ui/composer/elements/gallery.js index d4a5df20..921b96bf 100644 --- a/app/ui/composer/elements/gallery.js +++ b/app/ui/composer/elements/gallery.js @@ -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); } } diff --git a/app/ui/features/imports/index.js b/app/ui/features/imports/index.js index da1bfa84..35a154c2 100644 --- a/app/ui/features/imports/index.js +++ b/app/ui/features/imports/index.js @@ -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(); }; }, }; diff --git a/app/ui/features/media/index.js b/app/ui/features/media/index.js index 83839333..adf8fab2 100644 --- a/app/ui/features/media/index.js +++ b/app/ui/features/media/index.js @@ -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", diff --git a/app/ui/features/overlays/index.js b/app/ui/features/overlays/index.js index 581601f6..b7a42417 100644 --- a/app/ui/features/overlays/index.js +++ b/app/ui/features/overlays/index.js @@ -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"); }; }, }; diff --git a/app/ui/features/timeline/index.js b/app/ui/features/timeline/index.js index 272ae6c3..226d10f6 100644 --- a/app/ui/features/timeline/index.js +++ b/app/ui/features/timeline/index.js @@ -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); } diff --git a/app/ui/shell/dnd.js b/app/ui/shell/dnd.js new file mode 100644 index 00000000..ce6423db --- /dev/null +++ b/app/ui/shell/dnd.js @@ -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); }; +} diff --git a/app/ui/shell/tests/dnd.test.js b/app/ui/shell/tests/dnd.test.js new file mode 100644 index 00000000..fe0263b3 --- /dev/null +++ b/app/ui/shell/tests/dnd.test.js @@ -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), []); +});