From 126a5a3a6241a778c5d1e852e559fba6f0244a3f Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 13 Oct 2026 08:03:35 +0200 Subject: [PATCH] Let a composition item's clips move along with it A clip only hung at its track and frame and worked on whatever item it overlapped. Moving an item left its clips behind, so they started to animate the next item that came along. - A clip belongs to the item that lies on its track at its start frame. With two there, it belongs to the one in front. A clip that only reaches into an item stays with the item before. The model does not change, the rule is worked out each time. - Dragging an item, also onto another track, and typing its Start in the inspector take its clips along in the same undo step. - Where a carried clip would land on a foreign one, the whole move holds at the last place where everything fits, as dragging clips already does. - Pressing Alt during the drag moves the item alone. Alt on the press already starts the clip band since 0648853. - Copying an item takes its clips along with new ids. On a collision items and clips slide on together. - Deleting an item deletes its clips. Trimming and dragging its edges leave them where they are. Tests: - tsc, eslint, Prettier clean - Unit composition-edit 36 green (6 new), composition-sprites 11 - Without the rule 5 of the 6 new tests are red - e2e 69 ALL PASSED, 18 checks (69n new) - Gate on melektaus-atx green: 305 files, 4736 tests Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/commands/compositionCommands.ts | 23 +++- src/core/model/clipboard.ts | 7 ++ src/core/model/compositionEdit.ts | 114 ++++++++++++++++-- src/editor/composition/CompositionTimeline.ts | 97 ++++++++------- src/editor/inspector/compositionSection.ts | 31 +++-- tests/core/composition-edit.test.ts | 98 +++++++++++++++ tools/e2e/69-composition-tab.mjs | 55 +++++++++ 8 files changed, 365 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 4c1bd433..bad24b13 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2059 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2060 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/compositionCommands.ts b/src/core/commands/compositionCommands.ts index a78b79cb..8a767676 100644 --- a/src/core/commands/compositionCommands.ts +++ b/src/core/commands/compositionCommands.ts @@ -3,14 +3,16 @@ * are written whole once the pointer lets go. An item comes and goes together with its object. */ import type { CompositionAsset, CompositionClip, CompositionItem, CompositionTrack } from '../model/Asset'; +import { newId } from '../ids'; import { + clipLength, fittedLength, heldCompositionClips, heldCompositionItems, heldCompositionTracks, } from '../model/composition'; import type { ObjectClip } from '../model/clipboard'; -import { mediaKey, pastedItems } from '../model/compositionEdit'; +import { carriedClips, mediaKey, pastedItemsWithClips } from '../model/compositionEdit'; import type { Project } from '../model/Project'; import type { Command } from './Command'; import { oneStep } from './CompoundCommand'; @@ -84,7 +86,8 @@ export function addCompositionItem( /** * Copied items put back into a composition: their objects where the copied ones stood, the items - * timed as `pastedItems` says. Null when the copy holds anything but items – a scene's objects have + * timed as `pastedItems` says, the clips that came with them moved by as much – all moved on together + * where one of those clips would lie over a clip already there. Null when the copy holds anything but items – a scene's objects have * no time to land at. `itemIds` are the new items', to pick them afterwards. */ export function pasteCompositionItems( @@ -99,21 +102,28 @@ export function pasteCompositionItems( const comp = requireComposition(project, assetId); const paste = new PasteObjectsCommand(assetId, clip, {}, { x: 0, y: 0 }); const ids = new Map(clip.roots.map((r, n) => [r.id, paste.rootIds[n]!])); - const items = pastedItems( + for (const c of clip.clips ?? []) ids.set(c.id, newId()); + const { items, clips } = pastedItemsWithClips( clip.roots.map((r) => copied.get(r.id)!), + clip.clips ?? [], + comp.clips, ids, at, comp.tracks.map((t) => t.id), + (c) => Math.max(1, clipLength(project, comp, c)), row, ); - const set = new SetCompositionCommand(assetId, { items: [...comp.items, ...items] }); + const set = new SetCompositionCommand(assetId, { + items: [...comp.items, ...items], + clips: [...comp.clips, ...clips], + }); return { command: oneStep(items.length === 1 ? 'Paste item' : 'Paste items', [paste, set]), itemIds: items.map((i) => i.id), }; } -/** Items and clips taken off together, the items' objects with them. */ +/** Items and clips taken off together, the items' objects and the clips that belong to them with them. */ export function removeFromComposition( project: Project, assetId: string, @@ -121,7 +131,8 @@ export function removeFromComposition( clipIds: readonly string[], ): Command { const comp = requireComposition(project, assetId); - const gone = new Set([...itemIds, ...clipIds]); + const carried = carriedClips(comp.items, comp.clips, new Set(itemIds)); + const gone = new Set([...itemIds, ...clipIds, ...carried]); const objects = comp.objects.filter((o) => gone.has(o.id)).map((o) => o.id); const set = new SetCompositionCommand(assetId, { items: comp.items.filter((i) => !gone.has(i.id)), diff --git a/src/core/model/clipboard.ts b/src/core/model/clipboard.ts index b906dd4e..c6a082b0 100644 --- a/src/core/model/clipboard.ts +++ b/src/core/model/clipboard.ts @@ -16,6 +16,7 @@ import { findObject, subtreeIds, subtreeRoots, treeOrder } from './hierarchy'; import { isLiveInstance, isTemplateContent, resolvedHolder, splitInstanceId } from './instance'; import type { CompositionClip, CompositionItem, SpriteAsset } from './Asset'; import { isCompositionSprite } from './composition'; +import { carriedClips } from './compositionEdit'; import type { Mat } from './matrix'; import type { Position } from './Properties'; import type { ObjectHolder, Project } from './Project'; @@ -38,6 +39,8 @@ export interface ObjectClip { roots: ClipRoot[]; /** Copied out of a composition (#417): the items of the roots that are items, for a paste there. */ items?: CompositionItem[]; + /** The animation clips that belong to those items, to go with them. */ + clips?: CompositionClip[]; /** The sprites of a composition's own the objects wear (#419), back for a paste after a cut. */ sprites?: SpriteAsset[]; } @@ -89,12 +92,16 @@ export function copyObjects( .filter((o) => members.has(o.id) && !shownByInstance(o.id)) .map((o) => ({ ...structuredClone(o), editorOffset: { x: 0, y: 0 } })); const items = holder.type === 'composition' ? holder.items.filter((i) => roots.includes(i.id)) : []; + const carried = + holder.type === 'composition' ? carriedClips(holder.items, holder.clips, new Set(roots)) : new Set(); + const clips = holder.type === 'composition' ? holder.clips.filter((c) => carried.has(c.id)) : []; const worn = new Set(objects.map((o) => o.assetId)); const sprites = project.assets.filter(isCompositionSprite).filter((a) => worn.has(a.id)); return { kind: 'objects', objects, ...(items.length > 0 && { items: structuredClone(items) }), + ...(clips.length > 0 && { clips: structuredClone(clips) }), ...(sprites.length > 0 && { sprites: structuredClone(sprites) }), roots: roots.map((id) => { const object = findObject(view, id)!; diff --git a/src/core/model/compositionEdit.ts b/src/core/model/compositionEdit.ts index 31574561..8b379e07 100644 --- a/src/core/model/compositionEdit.ts +++ b/src/core/model/compositionEdit.ts @@ -111,6 +111,69 @@ export function movedItems( return moved(items, picked, trackIds, frames, tracks); } +/** + * The clips that belong to the picked items: a clip belongs to the item lying on its track at its + * first frame, of two there the one in front – the later start, at the same start the later in the + * list. A clip that begins before an item and only reaches into it belongs to the one before. + */ +export function carriedClips( + items: readonly CompositionItem[], + clips: readonly CompositionClip[], + picked: ReadonlySet, +): Set { + const carried = new Set(); + if (picked.size === 0) return carried; + for (const clip of clips) { + let owner: CompositionItem | undefined; + for (const item of items) + if (item.track === clip.track && item.start <= clip.start && clip.start < itemEnd(item)) + if (!owner || item.start >= owner.start) owner = item; + if (owner && picked.has(owner.id)) carried.add(clip.id); + } + return carried; +} + +/** + * The picked items moved, their clips with them unless `alone`; null where an item would start + * before 0 or leave the tracks, or a clip would come to lie over another. + */ +export function movedWithClips( + items: readonly CompositionItem[], + clips: readonly CompositionClip[], + picked: ReadonlySet, + trackIds: readonly string[], + frames: number, + tracks: number, + lengthOf: (clip: CompositionClip) => number, + alone = false, +): { items: CompositionItem[]; clips: CompositionClip[] } | null { + const outItems = movedItems(items, picked, trackIds, frames, tracks); + const carried = alone ? new Set() : carriedClips(items, clips, picked); + if (!outItems) return null; + if (carried.size === 0) return { items: outItems, clips: [...clips] }; + const outClips = movedClips(clips, carried, trackIds, frames, tracks, lengthOf); + return outClips && { items: outItems, clips: outClips }; +} + +/** + * Where a move asked to go `wanted` lands when only places that `fit` will do: on the way back to + * `last`, on the track asked for, else on the one it was on; `last` itself when nothing fits. + */ +export function heldMove( + wanted: { frames: number; tracks: number }, + last: { frames: number; tracks: number }, + fits: (frames: number, tracks: number) => boolean, +): { frames: number; tracks: number } { + for (const on of [wanted.tracks, last.tracks]) { + const step = Math.sign(last.frames - wanted.frames); + for (let at = wanted.frames; ; at += step) { + if (fits(at, on)) return { frames: at, tracks: on }; + if (at === last.frames) break; + } + } + return last; +} + /** The picked clips moved like items; null, too, where one would come to lie over another. */ export function movedClips( clips: readonly CompositionClip[], @@ -162,6 +225,32 @@ export function pastedClips( } } +/** + * Copied items placed as `pastedItems` places them, the copied clips that came with them moved by + * as much; then both moved on frame by frame as long as one of the clips would lie over a clip of + * `clips` – clips of a track never overlap. + */ +export function pastedItemsWithClips( + items: readonly CompositionItem[], + copied: readonly CompositionClip[], + clips: readonly CompositionClip[], + ids: ReadonlyMap, + at: number, + trackIds: readonly string[], + lengthOf: (clip: CompositionClip) => number, + row?: number, +): { items: CompositionItem[]; clips: CompositionClip[] } { + const place = placing(items, at, trackIds, row); + const placedItems = items.map((i) => place(i, ids)); + const placedClips = copied.map((c) => place(c, ids)); + const last = Math.max(0, ...clips.map((c) => c.start + Math.max(1, lengthOf(c)))); + for (let by = 0; ; by++) { + const moved = placedClips.map((c) => ({ ...c, start: c.start + by })); + if (apart([...clips, ...moved], lengthOf) || moved.every((c) => c.start >= last)) + return { items: placedItems.map((i) => ({ ...i, start: i.start + by })), clips: moved }; + } +} + function pasted( items: readonly T[], ids: ReadonlyMap, @@ -169,19 +258,30 @@ function pasted( trackIds: readonly string[], row?: number, ): T[] { - if (items.length === 0) return []; + const place = placing(items, at, trackIds, row); + return items.map((i) => place(i, ids)); +} + +/** How a paste moves what was copied: the earliest of `items` to `at`, their rows as `pastedItems` says. */ +function placing( + items: readonly Placed[], + at: number, + trackIds: readonly string[], + row?: number, +): (t: T, ids: ReadonlyMap) => T { + if (items.length === 0) return (t) => t; const first = Math.min(...items.map((i) => i.start)); const index = new Map(trackIds.map((id, i) => [id, i])); const rows = items.map((i) => index.get(i.track) ?? 0); const top = Math.min(...rows); const bottom = Math.max(...rows); const shift = row === undefined ? 0 : Math.max(-top, Math.min(trackIds.length - 1 - bottom, row - top)); - return items.map((i, n) => ({ - ...i, - id: ids.get(i.id) ?? i.id, - start: Math.max(0, Math.round(at)) + (i.start - first), - track: trackIds[rows[n]! + shift] ?? i.track, - })); + return (t, ids) => ({ + ...t, + id: ids.get(t.id) ?? t.id, + start: Math.max(0, Math.round(at)) + (t.start - first), + track: trackIds[(index.get(t.track) ?? 0) + shift] ?? t.track, + }); } /** The item with its end edge dragged to make it `wanted` frames long, at least one; its length is its own from then on. */ diff --git a/src/editor/composition/CompositionTimeline.ts b/src/editor/composition/CompositionTimeline.ts index 5705243a..c41b3925 100644 --- a/src/editor/composition/CompositionTimeline.ts +++ b/src/editor/composition/CompositionTimeline.ts @@ -32,9 +32,11 @@ import { droppedSpan, itemMedia, type ItemMedia, + heldMove, mediaMarks, movedClips, movedItems, + movedWithClips, pastedClips, resizedItem, snappedToMedia, @@ -118,6 +120,8 @@ type Gesture = moved: boolean; frames: number; tracks: number; + /** Alt held while dragging items: they go without their clips. */ + alone: boolean; } | { kind: 'resize' | 'trim'; anchor: CompositionItem; item: CompositionItem; media: ItemMedia | null }; @@ -564,7 +568,17 @@ export class CompositionTimeline extends Widget { g: Vec2, ): void { const grab = xToTick(this.view, g.x) - anchor.start; - this.gesture = { kind: 'move', what, anchor, grab, pointer: g, moved: false, frames: 0, tracks: 0 }; + this.gesture = { + kind: 'move', + what, + anchor, + grab, + pointer: g, + moved: false, + frames: 0, + tracks: 0, + alone: false, + }; } private onPointerMove(e: FederatedPointerEvent): void { @@ -617,13 +631,13 @@ export class CompositionTimeline extends Widget { if (!gesture.moved && Math.hypot(g.x - gesture.pointer.x, g.y - gesture.pointer.y) < DRAG_THRESHOLD) return; gesture.moved = true; + gesture.alone = gesture.what === 'item' && e.altKey; const row = nearestTrack(this.view, asset.tracks.length, g.y); - const held = this.heldMove( + const held = this.heldDrag( asset, - gesture.what, + gesture, Math.round(frame - gesture.grab) - gesture.anchor.start, row - asset.tracks.findIndex((t) => t.id === gesture.anchor.track), - gesture, ); gesture.frames = held.frames; gesture.tracks = held.tracks; @@ -653,16 +667,17 @@ export class CompositionTimeline extends Widget { /** * Where a move goes that the pointer asks to go `frames` and `tracks`: held to frame 0 and the - * tracks there are. Items go there whatever lies there; clips stop short of another clip – on the - * way back to the last place they fitted, on the track asked for, else on the one they were on. + * tracks there are. Items go there whatever lies there; clips – the picked ones, or those that go + * with the picked items – stop short of another clip: on the way back to the last place they + * fitted, on the track asked for, else on the one they were on. */ - private heldMove( + private heldDrag( asset: CompositionAsset, - what: 'item' | 'clip', + gesture: Extract, frames: number, tracks: number, - last: { frames: number; tracks: number }, ): { frames: number; tracks: number } { + const what = gesture.what; const picked = new Set(what === 'item' ? this.pickedItems(asset) : this.pickedClips(asset)); const list = what === 'item' ? asset.items : asset.clips; const wanted = clampedMove( @@ -672,27 +687,40 @@ export class CompositionTimeline extends Widget { frames, tracks, ); - if (what === 'item') return wanted; - for (const on of [wanted.tracks, last.tracks]) { - const step = Math.sign(last.frames - wanted.frames); - for (let at = wanted.frames; ; at += step) { - if (this.movedList(asset, what, at, on)) return { frames: at, tracks: on }; - if (at === last.frames) break; - } - } - return last; + const fits = (f: number, t: number) => this.movedParts(asset, gesture, f, t) !== null; + // Alt let go, the last place may not hold the clips: back to where the drag began. + const last = fits(gesture.frames, gesture.tracks) ? gesture : { frames: 0, tracks: 0 }; + return heldMove(wanted, { frames: last.frames, tracks: last.tracks }, fits); } - private movedList( + /** The items and clips as a move by `frames` and `tracks` would leave them; null where it may not go. */ + private movedParts( asset: CompositionAsset, - what: 'item' | 'clip', + gesture: Extract, frames: number, tracks: number, - ): CompositionItem[] | CompositionClip[] | null { + ): { items: CompositionItem[]; clips: CompositionClip[] } | null { const ids = asset.tracks.map((t) => t.id); - return what === 'item' - ? movedItems(asset.items, new Set(this.pickedItems(asset)), ids, frames, tracks) - : movedClips(asset.clips, new Set(this.pickedClips(asset)), ids, frames, tracks, this.lengthOf(asset)); + if (gesture.what === 'item') + return movedWithClips( + asset.items, + asset.clips, + new Set(this.pickedItems(asset)), + ids, + frames, + tracks, + this.lengthOf(asset), + gesture.alone, + ); + const clips = movedClips( + asset.clips, + new Set(this.pickedClips(asset)), + ids, + frames, + tracks, + this.lengthOf(asset), + ); + return clips && { items: asset.items, clips }; } private onPointerUp(): void { @@ -718,14 +746,10 @@ export class CompositionTimeline extends Widget { if (gesture.what === 'item') this.state.select(objectSelection(asset.id, [gesture.anchor.id])); break; } - const list = this.movedList(asset, gesture.what, gesture.frames, gesture.tracks); - if (!list || (gesture.frames === 0 && gesture.tracks === 0)) break; + const parts = this.movedParts(asset, gesture, gesture.frames, gesture.tracks); + if (!parts || (gesture.frames === 0 && gesture.tracks === 0)) break; const several = (gesture.what === 'item' ? this.pickedItems(asset) : this.pickedClips(asset)).length > 1; - const parts = - gesture.what === 'item' - ? { items: list as CompositionItem[] } - : { clips: list as CompositionClip[] }; const label = `Move ${gesture.what}${several ? 's' : ''}`; this.commit(asset, parts, label); break; @@ -1014,17 +1038,8 @@ export class CompositionTimeline extends Widget { private shownClips(asset: CompositionAsset): CompositionClip[] { const g = this.gesture; - if (g?.kind === 'move' && g.what === 'clip' && g.moved) - return ( - movedClips( - asset.clips, - new Set(this.pickedClips(asset)), - asset.tracks.map((t) => t.id), - g.frames, - g.tracks, - this.lengthOf(asset), - ) ?? asset.clips - ); + if (g?.kind === 'move' && g.moved) + return this.movedParts(asset, g, g.frames, g.tracks)?.clips ?? asset.clips; return asset.clips; } diff --git a/src/editor/inspector/compositionSection.ts b/src/editor/inspector/compositionSection.ts index 0979acc8..b34896b4 100644 --- a/src/editor/inspector/compositionSection.ts +++ b/src/editor/inspector/compositionSection.ts @@ -8,7 +8,7 @@ import { isCompositionSprite, itemVideo, } from '../../core/model/composition'; -import { resizedItem } from '../../core/model/compositionEdit'; +import { heldMove, movedWithClips, resizedItem } from '../../core/model/compositionEdit'; import { findAsset, findAssetOf, type Project } from '../../core/model/Project'; import type { PropertyDef } from '../../core/properties/PropertySchema'; import { Button, formatNumber, type MenuEntry } from '../../ui'; @@ -139,13 +139,30 @@ export function addTimingRows(host: InspectorHost, holderId: string, objectId: s host.state.execute(new SetCompositionCommand(holderId, { items }, label)); }; const frames = (value: unknown) => Math.round(Number(value) * fps); + // The item's clips go with it, held short where one would come to lie over another clip. + const moveTo = (value: unknown) => { + const current = compositionItemOf(host.state.project, holderId, objectId); + if (!current) return; + const { comp: c, item: i } = current; + const project = host.state.project; + const moved = (by: number) => + movedWithClips( + c.items, + c.clips, + new Set([objectId]), + c.tracks.map((t) => t.id), + by, + 0, + (clip) => Math.max(1, clipLength(project, c, clip)), + ); + const wanted = { frames: Math.max(0, frames(value)) - i.start, tracks: 0 }; + const held = heldMove(wanted, { frames: 0, tracks: 0 }, (by) => moved(by) !== null); + const parts = moved(held.frames); + if (parts && SetCompositionCommand.changes(project, holderId, parts)) + host.state.execute(new SetCompositionCommand(holderId, parts, 'Move item')); + }; const rows: [string, string, number, (value: unknown) => void, boolean?][] = [ - [ - START_ROW, - 'Start', - item.start / fps, - (value) => write('Move item', (i) => ({ ...i, start: Math.max(0, frames(value)) })), - ], + [START_ROW, 'Start', item.start / fps, moveTo], [ LENGTH_ROW, 'Length', diff --git a/tests/core/composition-edit.test.ts b/tests/core/composition-edit.test.ts index d3239486..71c05bc6 100644 --- a/tests/core/composition-edit.test.ts +++ b/tests/core/composition-edit.test.ts @@ -17,16 +17,20 @@ import { import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import type { CompositionClip, CompositionItem } from '../../src/core/model/Asset'; import { + carriedClips, clampedMove, droppedLength, droppedSpan, + heldMove, type ItemMedia, mediaEdges, mediaMarks, movedClips, movedItems, + movedWithClips, pastedClips, pastedItems, + pastedItemsWithClips, resizedItem, snappedToMedia, trimmedItem, @@ -123,6 +127,68 @@ describe('moving', () => { }); }); +const clip = (id: string, track: string, start: number) => + ({ id, track, animation: 'anim', start }) satisfies CompositionClip; + +describe('clips that go with their item', () => { + const tracks = ['t1', 't2']; + // Every clip is 10 frames long. + const lengthOf = () => 10; + + it('belong to the item at their first frame, of two there the one in front', () => { + const items = [item('a', 't1', 0, 60), item('b', 't1', 40, 60), item('c', 't1', 40, 10)]; + // `x` starts in `a` alone; `y` where `a`, `b` and `c` lie – `c`, the later in the list, is in + // front; `z` starts before `b` and only reaches into it, so it stays with `a`. + const clips = [clip('x', 't1', 10), clip('y', 't1', 45), clip('z', 't1', 35), clip('w', 't2', 10)]; + expect([...carriedClips(items, clips, new Set(['a']))]).toEqual(['x', 'z']); + expect([...carriedClips(items, clips, new Set(['b']))]).toEqual([]); + expect([...carriedClips(items, clips, new Set(['c']))]).toEqual(['y']); + // One past an item's end it is not the item's any more. + expect([...carriedClips(items, [clip('v', 't1', 100)], new Set(['b']))]).toEqual([]); + }); + + it('move with their item, also onto another track; with Alt they stay', () => { + const items = [item('a', 't1', 0, 60), item('b', 't2', 0, 60)]; + const clips = [clip('x', 't1', 10), clip('y', 't2', 50)]; + const moved = movedWithClips(items, clips, new Set(['a']), tracks, 5, 1, lengthOf)!; + expect(moved.items[0]).toMatchObject({ start: 5, track: 't2' }); + expect(moved.clips).toEqual([clip('x', 't2', 15), clip('y', 't2', 50)]); + const alone = movedWithClips(items, clips, new Set(['a']), tracks, 5, 1, lengthOf, true)!; + expect(alone.clips).toEqual(clips); + }); + + it('hold the move short of another clip, the item with them', () => { + const items = [item('a', 't1', 0, 60)]; + const clips = [clip('x', 't1', 10), clip('y', 't1', 70)]; + const picked = new Set(['a']); + const fits = (by: number) => movedWithClips(items, clips, picked, tracks, by, 0, lengthOf) !== null; + // `x` at 10 + 55 would lie over `y` (70–80): the move stops where `x` ends right at 70. + expect(movedWithClips(items, clips, picked, tracks, 55, 0, lengthOf)).toBeNull(); + expect(heldMove({ frames: 55, tracks: 0 }, { frames: 0, tracks: 0 }, fits)).toEqual({ + frames: 50, + tracks: 0, + }); + // Past `y` it fits again. + expect(heldMove({ frames: 70, tracks: 0 }, { frames: 0, tracks: 0 }, fits)).toEqual({ + frames: 70, + tracks: 0, + }); + }); + + it('are pasted with their items, all moved on until the clips lie apart', () => { + const items = [item('a', 't1', 20, 60)]; + const copied = [clip('x', 't1', 30)]; + const ids = new Map([ + ['a', 'a2'], + ['x', 'x2'], + ]); + // Landing at 100, `x2` would start at 110, over `y` (105–115): both move on by 5. + const out = pastedItemsWithClips(items, copied, [clip('y', 't1', 105)], ids, 100, tracks, lengthOf); + expect(out.items).toEqual([item('a2', 't1', 105, 60)]); + expect(out.clips).toEqual([clip('x2', 't1', 115)]); + }); +}); + describe('the edges of an item', () => { const b = item('b', 't1', 100, 60, { fitMedia: true, offset: 1 }); @@ -365,6 +431,38 @@ describe('items and their objects', () => { expect(pasteCompositionItems(cut.project, cut.compId, plain, 0)).toBeNull(); }); + it('a deleted item takes its clips with it, in the same undo step', () => { + const cut = makeCut(); + const a = cut.add(0, 60); + const b = cut.add(60, 60); + const clips = [clip('mine', cut.track, 10), clip('next', cut.track, 70)]; + cut.stack.execute(new SetCompositionCommand(cut.compId, { clips })); + cut.stack.execute(removeFromComposition(cut.project, cut.compId, [a], [])); + expect(cut.comp().items.map((i) => i.id)).toEqual([b]); + expect(cut.comp().clips.map((c) => c.id)).toEqual(['next']); + cut.stack.undo(); + expect(cut.comp().clips.map((c) => c.id)).toEqual(['mine', 'next']); + }); + + it('a copied item is pasted with its clips under new ids, moved on with them where a clip lies', () => { + const cut = makeCut(); + const a = cut.add(0, 60); + cut.stack.execute(new SetCompositionCommand(cut.compId, { clips: [clip('mine', cut.track, 10)] })); + const copy = copyObjects(cut.project, cut.comp(), [a])!; + expect(copy.clips?.map((c) => c.id)).toEqual(['mine']); + // Its animation is not there, so the clip is a frame long. Landing at 0 the copy of the clip + // would lie right over `mine`: item and clip move on by a frame. + const pasted = pasteCompositionItems(cut.project, cut.compId, copy, 0)!; + cut.stack.execute(pasted.command); + const [b] = pasted.itemIds; + expect(cut.comp().items.find((i) => i.id === b)?.start).toBe(1); + const added = cut.comp().clips.filter((c) => c.id !== 'mine'); + expect(added).toHaveLength(1); + expect(added[0]).toMatchObject({ track: cut.track, animation: 'anim', start: 11 }); + cut.stack.undo(); + expect(cut.comp().clips.map((c) => c.id)).toEqual(['mine']); + }); + it('a sprite wearing a video is dropped in as long as the video, anything else 3 s', () => { const cut = makeCut(); const screen = new CreateAssetCommand('sprite', 'Screen', { texture: cut.videoId }); diff --git a/tools/e2e/69-composition-tab.mjs b/tools/e2e/69-composition-tab.mjs index 4a557d9a..081caa11 100644 --- a/tools/e2e/69-composition-tab.mjs +++ b/tools/e2e/69-composition-tab.mjs @@ -431,6 +431,61 @@ export async function compositionTab(t) { JSON.stringify({ banded: banded?.objectIds, altBanded, firstPaste, secondPaste, pastedPick }), ); + // 69n. A dragged item takes the clips that begin on it along; with Alt pressed once the drag runs + // it goes alone. The clip of the item next to it stays where it is. + await mutate(` + const c = __gm.state.project.assets.find((a) => a.id === '${CUT}'); + c.items = [ + { id: 'ct69-b1', track: 'ct69-t1', start: 0, length: 30, offset: 0 }, + { id: 'ct69-b2', track: 'ct69-t1', start: 70, length: 30, offset: 0 }, + { id: 'ct69-b3', track: 'ct69-t2', start: 0, length: 30, offset: 0 }, + ]; + c.clips = [ + { id: 'ct69-k1', track: 'ct69-t1', animation: '${SLIDE}', start: 5 }, + { id: 'ct69-k2', track: 'ct69-t1', animation: '${SLIDE}', start: 75 }, + ];`); + await sleep(100); + const frameWidth = + ((await json(`__gm.composition.laneAt('ct69-t1', 100)`)).x - + (await json(`__gm.composition.laneAt('ct69-t1', 0)`)).x) / + 100; + /** Drags the item by `frames` from its middle – where no clip lies –, Alt held only while it moves. */ + const dragItem = async (id, frames, alt) => { + const box = await json(`__gm.composition.box('${id}')`); + const from = { x: box.x + box.w / 2, y: box.y + box.h / 2 }; + const to = from.x + frames * frameWidth; + await mouse('mouseMoved', from.x, from.y); + await mouse('mousePressed', from.x, from.y); + for (let i = 1; i <= 12; i++) { + await mouse('mouseMoved', from.x + ((to - from.x) * i) / 12, from.y, 'left', { + buttons: 1, + modifiers: alt ? 1 : 0, + }); + await sleep(16); + } + await mouse('mouseReleased', to, from.y); + await sleep(150); + const now = await model(); + return { + from, + picked: (await json('__gm.state.selection'))?.objectIds, + item: now.items.find((i) => i.id === id)?.start, + clips: Object.fromEntries(now.clips.map((c) => [c.id, c.start])), + }; + }; + const along = await dragItem('ct69-b1', 20, false); + const alone = await dragItem('ct69-b1', -15, true); + check( + 'a dragged item takes the clips that begin on it along, with Alt it goes alone', + along.item === 20 && + along.clips['ct69-k1'] === 25 && + along.clips['ct69-k2'] === 75 && + alone.item === 5 && + alone.clips['ct69-k1'] === 25 && + alone.clips['ct69-k2'] === 75, + JSON.stringify({ frameWidth, along, alone }), + ); + await showTab('scene'); await mutate(` const p = __gm.state.project;