Select composition items with a band and copy clips too (#417)
game-maker / gate (push) Successful in 4m57s
game-maker / suite (push) Successful in 18m29s

The composition tab only took single clicks and Ctrl-clicks, and
animation clips could not be copied at all. A band selection and
Copy/Paste for clips make longer compositions quicker to arrange.

- Dragging from an empty spot draws a band. It selects every item it
  touches, across tracks. Ctrl adds to the selection, because Shift
  plus drag already pans the view.
- With Alt held the band selects clips instead and may start on an
  item, since items fill most of a track.
- Ctrl+C copies the selected clips as their own clipboard kind.
  Ctrl+V places them like items: under the pointer over the tracks,
  otherwise at the playhead on the selected track.
- Clips of a track never overlap. A paste onto taken frames moves the
  whole copy on frame by frame until every clip fits (`pastedClips`).
- The paste is one undo step and selects the new clips.

Tests:
- Gate on melektaus-atx: tsc, eslint, Prettier clean,
  305 files, 4724 tests (2 new).
- e2e 69: ALL PASSED, 17 checks (69m new). Band over three items on
  two tracks, Alt band from an item picks both clips, first paste at
  frames 50 and 60, second paste moves on to 56 and 66.
- Counter-check: without moving on, the paste unit test is red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-13 06:56:25 +02:00
1 parent 5df2173559
commit 0648853356
8 files changed
+289 -13

No files matched your search

+56 -1
View File
@@ -25,13 +25,20 @@ import {
mediaMarks,
movedClips,
movedItems,
pastedClips,
pastedItems,
resizedItem,
snappedToMedia,
trimmedItem,
} from '../../src/core/model/compositionEdit';
import { copyObjects } from '../../src/core/model/clipboard';
import { hitAt, overlaps, uncoveredEnd } from '../../src/editor/composition/compositionView';
import {
clipsInBand,
hitAt,
itemsInBand,
overlaps,
uncoveredEnd,
} from '../../src/editor/composition/compositionView';
import { placementRejection } from '../../src/core/model/placement';
import { findAsset, findAssetOf } from '../../src/core/model/Project';
import { makeProject } from '../helpers';
@@ -282,6 +289,54 @@ describe('items and their objects', () => {
expect(pastedItems(copied, ids, 0, tracks, 2).map((i) => i.track)).toEqual(['t2', 't3']);
});
it('pasted clips keep their timing and move on until none lies over another clip', () => {
const clip = (id: string, track: string, start: number) =>
({ id, track, animation: 'anim', start }) satisfies CompositionClip;
// Every clip is 10 frames long.
const lengthOf = () => 10;
const tracks = ['t1', 't2'];
const copied = [clip('a', 't1', 0), clip('b', 't2', 15)];
const ids = new Map([
['a', 'a2'],
['b', 'b2'],
]);
// Room where they land: there they stay.
expect(pastedClips(copied, [clip('x', 't1', 50)], ids, 30, tracks, lengthOf)).toEqual([
clip('a2', 't1', 30),
clip('b2', 't2', 45),
]);
// `a2` at 45 would lie over `x` (50–60), `b2` over `y` (60–70): `a2` waits until 60, `b2` comes along.
const taken = [clip('x', 't1', 50), clip('y', 't2', 60)];
expect(pastedClips(copied, taken, ids, 45, tracks, lengthOf)).toEqual([
clip('a2', 't1', 60),
clip('b2', 't2', 75),
]);
// Onto the clips they were copied from, they go right behind them, shifted to a row as far as it goes.
expect(pastedClips([clip('a', 't1', 0)], [clip('a', 't1', 0)], ids, 0, tracks, lengthOf, 1)).toEqual([
clip('a2', 't2', 0),
]);
expect(pastedClips([clip('a', 't1', 0)], [clip('a', 't1', 0)], ids, 0, tracks, lengthOf)).toEqual([
clip('a2', 't1', 10),
]);
});
it('a band picks the items and clips it touches', () => {
const view = { panX: 0, pxPerTick: 1, panY: 0, trackHeight: 40 };
const tracks = [
{ id: 't1', name: 'One' },
{ id: 't2', name: 'Two' },
];
const items = [item('a', 't1', 0, 50), item('b', 't1', 100, 50), item('c', 't2', 0, 50)];
const clips = [{ id: 'k', track: 't2', animation: 'anim', start: 20 }];
// Across the first track, from 40 to 60: only `a` reaches in.
expect(itemsInBand(view, tracks, items, { x: 40, y: 5 }, { x: 60, y: 30 })).toEqual(['a']);
// Down over both tracks at the start, drawn from bottom right to top left.
expect(itemsInBand(view, tracks, items, { x: 10, y: 70 }, { x: 5, y: 10 })).toEqual(['a', 'c']);
// Clips lie in the lower part of their track: a band through its upper part misses them.
expect(clipsInBand(view, tracks, clips, () => 10, { x: 0, y: 42 }, { x: 60, y: 50 })).toEqual([]);
expect(clipsInBand(view, tracks, clips, () => 10, { x: 0, y: 42 }, { x: 60, y: 70 })).toEqual(['k']);
});
it('a copied item is pasted with its object and overrides under new ids, in one undo step', () => {
const cut = makeCut();
const id = cut.add(10, 60);