Select composition items with a band and copy clips too (#417)
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:
1 parent
5df2173559
commit
0648853356
8 files changed
+289
-13
No files matched your search
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2056 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2057 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { editorTransform, parentTransform, positionOf } from '../properties/transform';
|
import { editorTransform, parentTransform, positionOf } from '../properties/transform';
|
||||||
import { findObject, subtreeIds, subtreeRoots, treeOrder } from './hierarchy';
|
import { findObject, subtreeIds, subtreeRoots, treeOrder } from './hierarchy';
|
||||||
import { isLiveInstance, isTemplateContent, resolvedHolder, splitInstanceId } from './instance';
|
import { isLiveInstance, isTemplateContent, resolvedHolder, splitInstanceId } from './instance';
|
||||||
import type { CompositionItem, SpriteAsset } from './Asset';
|
import type { CompositionClip, CompositionItem, SpriteAsset } from './Asset';
|
||||||
import { isCompositionSprite } from './composition';
|
import { isCompositionSprite } from './composition';
|
||||||
import type { Mat } from './matrix';
|
import type { Mat } from './matrix';
|
||||||
import type { Position } from './Properties';
|
import type { Position } from './Properties';
|
||||||
@@ -50,7 +50,13 @@ export interface GraphClip {
|
|||||||
annotations: { kind: AnnotationKind; annotation: GraphAnnotation }[];
|
annotations: { kind: AnnotationKind; annotation: GraphAnnotation }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Clip = ObjectClip | GraphClip;
|
/** Animation clips of a composition (#417), for a paste into the same one or another. */
|
||||||
|
export interface CompositionClipsClip {
|
||||||
|
kind: 'compositionClips';
|
||||||
|
clips: CompositionClip[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Clip = ObjectClip | GraphClip | CompositionClipsClip;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The selected objects with everything below them. A placed container keeps showing its template,
|
* The selected objects with everything below them. A placed container keeps showing its template,
|
||||||
|
|||||||
@@ -136,6 +136,39 @@ export function pastedItems(
|
|||||||
trackIds: readonly string[],
|
trackIds: readonly string[],
|
||||||
row?: number,
|
row?: number,
|
||||||
): CompositionItem[] {
|
): CompositionItem[] {
|
||||||
|
return pasted(items, ids, at, trackIds, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copied clips placed as `pastedItems` places items, then moved on frame by frame as long as one
|
||||||
|
* would lie over a clip of `clips` or over another of the copy – clips of a track never overlap.
|
||||||
|
*/
|
||||||
|
export function pastedClips(
|
||||||
|
copied: readonly CompositionClip[],
|
||||||
|
clips: readonly CompositionClip[],
|
||||||
|
ids: ReadonlyMap<string, string>,
|
||||||
|
at: number,
|
||||||
|
trackIds: readonly string[],
|
||||||
|
lengthOf: (clip: CompositionClip) => number,
|
||||||
|
row?: number,
|
||||||
|
): CompositionClip[] {
|
||||||
|
const placed = pasted(copied, ids, at, trackIds, row);
|
||||||
|
if (placed.length === 0) return [];
|
||||||
|
// Past the end of every clip there is room, so the search ends there at the latest.
|
||||||
|
const last = Math.max(0, ...clips.map((c) => c.start + Math.max(1, lengthOf(c))));
|
||||||
|
for (let by = 0; ; by++) {
|
||||||
|
const moved = placed.map((c) => ({ ...c, start: c.start + by }));
|
||||||
|
if (apart([...clips, ...moved], lengthOf) || moved.every((c) => c.start >= last)) return moved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pasted<T extends Placed>(
|
||||||
|
items: readonly T[],
|
||||||
|
ids: ReadonlyMap<string, string>,
|
||||||
|
at: number,
|
||||||
|
trackIds: readonly string[],
|
||||||
|
row?: number,
|
||||||
|
): T[] {
|
||||||
if (items.length === 0) return [];
|
if (items.length === 0) return [];
|
||||||
const first = Math.min(...items.map((i) => i.start));
|
const first = Math.min(...items.map((i) => i.start));
|
||||||
const index = new Map(trackIds.map((id, i) => [id, i]));
|
const index = new Map(trackIds.map((id, i) => [id, i]));
|
||||||
|
|||||||
+14
-1
@@ -18,6 +18,7 @@ import { findObject } from '../core/model/hierarchy';
|
|||||||
import { isLiveInstance } from '../core/model/instance';
|
import { isLiveInstance } from '../core/model/instance';
|
||||||
import type { Vec2 } from '../core/model/Properties';
|
import type { Vec2 } from '../core/model/Properties';
|
||||||
import {
|
import {
|
||||||
|
findAssetOf,
|
||||||
findGraphAsset,
|
findGraphAsset,
|
||||||
findObjectHolder,
|
findObjectHolder,
|
||||||
type GraphHolder,
|
type GraphHolder,
|
||||||
@@ -59,10 +60,22 @@ export function copyObjectIds(state: EditorState, holderId: string, ids: readonl
|
|||||||
return put(state, clip, roots, counted(roots.length, clip.items ? 'item' : 'object'));
|
return put(state, clip, roots, counted(roots.length, clip.items ? 'item' : 'object'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Copies the selected objects, or the selected nodes and boxes; false when nothing of that is selected. */
|
/** Copies animation clips of a composition; false when none of them is there. */
|
||||||
|
export function copyCompositionClips(state: EditorState, assetId: string, ids: readonly string[]): boolean {
|
||||||
|
const asset = findAssetOf(state.project, assetId, 'composition');
|
||||||
|
const clips = asset?.clips.filter((c) => ids.includes(c.id)).map((c) => ({ ...c })) ?? [];
|
||||||
|
if (clips.length === 0) return false;
|
||||||
|
return put(state, { kind: 'compositionClips', clips }, [], counted(clips.length, 'clip'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the selected objects, the selected clips of a composition, or the selected nodes and boxes;
|
||||||
|
* false when nothing of that is selected.
|
||||||
|
*/
|
||||||
export function copySelection(state: EditorState): boolean {
|
export function copySelection(state: EditorState): boolean {
|
||||||
const s = state.selection;
|
const s = state.selection;
|
||||||
if (s?.kind === 'object') return copyObjectIds(state, s.holderId, s.objectIds);
|
if (s?.kind === 'object') return copyObjectIds(state, s.holderId, s.objectIds);
|
||||||
|
if (s?.kind === 'composition') return copyCompositionClips(state, s.assetId, s.clipIds);
|
||||||
if (s?.kind === 'node' || s?.kind === 'annotation') {
|
if (s?.kind === 'node' || s?.kind === 'annotation') {
|
||||||
const graph = findGraphAsset(state.project, s.assetId)?.graph;
|
const graph = findGraphAsset(state.project, s.assetId)?.graph;
|
||||||
const nodeIds = s.kind === 'node' ? s.nodeIds : [];
|
const nodeIds = s.kind === 'node' ? s.nodeIds : [];
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
mediaMarks,
|
mediaMarks,
|
||||||
movedClips,
|
movedClips,
|
||||||
movedItems,
|
movedItems,
|
||||||
|
pastedClips,
|
||||||
resizedItem,
|
resizedItem,
|
||||||
snappedToMedia,
|
snappedToMedia,
|
||||||
trimmedItem,
|
trimmedItem,
|
||||||
@@ -46,6 +47,7 @@ import { objectName } from '../../core/model/SceneObject';
|
|||||||
import {
|
import {
|
||||||
activeDrag,
|
activeDrag,
|
||||||
crispText,
|
crispText,
|
||||||
|
drawBand,
|
||||||
endDrag,
|
endDrag,
|
||||||
Menu,
|
Menu,
|
||||||
onDragChange,
|
onDragChange,
|
||||||
@@ -73,11 +75,13 @@ import { type EditorState, objectSelection } from '../state';
|
|||||||
import { axisTween } from '../viewTween';
|
import { axisTween } from '../viewTween';
|
||||||
import {
|
import {
|
||||||
clipBox,
|
clipBox,
|
||||||
|
clipsInBand,
|
||||||
type CompositionView,
|
type CompositionView,
|
||||||
HEAD_WIDTH,
|
HEAD_WIDTH,
|
||||||
type Hit,
|
type Hit,
|
||||||
hitAt,
|
hitAt,
|
||||||
itemBox,
|
itemBox,
|
||||||
|
itemsInBand,
|
||||||
MAX_PX_PER_FRAME,
|
MAX_PX_PER_FRAME,
|
||||||
overlaps,
|
overlaps,
|
||||||
type Part,
|
type Part,
|
||||||
@@ -103,6 +107,8 @@ CLIP_LABEL.fill = Theme.colors.text;
|
|||||||
type Gesture =
|
type Gesture =
|
||||||
| { kind: 'pan'; pointer: Vec2; panX: number; panY: number }
|
| { kind: 'pan'; pointer: Vec2; panX: number; panY: number }
|
||||||
| { kind: 'scrub' }
|
| { kind: 'scrub' }
|
||||||
|
/** A band from an empty spot picks the items it touches, with Alt from anywhere the clips; Ctrl adds. */
|
||||||
|
| { kind: 'band'; start: Vec2; current: Vec2; active: boolean; add: boolean; clips: boolean }
|
||||||
| {
|
| {
|
||||||
kind: 'move';
|
kind: 'move';
|
||||||
what: 'item' | 'clip';
|
what: 'item' | 'clip';
|
||||||
@@ -137,8 +143,8 @@ interface Placed {
|
|||||||
* the items on their tracks and the animation clips over them, narrower. An asset dragged in from
|
* the items on their tracks and the animation clips over them, narrower. An asset dragged in from
|
||||||
* the panel becomes an item – an animation a clip –, an item is moved, its ends dragged; a clip is
|
* the panel becomes an item – an animation a clip –, an item is moved, its ends dragged; a clip is
|
||||||
* moved. Items of a track may overlap, the later one drawn over the earlier; clips of a track do not:
|
* moved. Items of a track may overlap, the later one drawn over the earlier; clips of a track do not:
|
||||||
* a dropped clip lands in the gap it falls into, a moved one stops short. The ruler scrubs the
|
* a dropped clip lands in the gap it falls into, a moved one stops short. A band picks items, with
|
||||||
* playhead the picture above shows.
|
* Alt clips. The ruler scrubs the playhead the picture above shows.
|
||||||
*/
|
*/
|
||||||
export class CompositionTimeline extends Widget {
|
export class CompositionTimeline extends Widget {
|
||||||
private readonly bg = new Graphics();
|
private readonly bg = new Graphics();
|
||||||
@@ -317,22 +323,47 @@ export class CompositionTimeline extends Widget {
|
|||||||
/**
|
/**
|
||||||
* Puts copied items in (Ctrl+V): the earliest under the pointer while it is over the lanes, the
|
* Puts copied items in (Ctrl+V): the earliest under the pointer while it is over the lanes, the
|
||||||
* topmost on its track; else at the playhead, on the picked track or the track of the picked item,
|
* topmost on its track; else at the playhead, on the picked track or the track of the picked item,
|
||||||
* or on the tracks they came from. False when the copy is not items of a composition.
|
* or on the tracks they came from. Copied clips go the same way, on to the first frame where none
|
||||||
|
* lies over another. False when the copy is neither items nor clips of a composition.
|
||||||
*/
|
*/
|
||||||
paste(): boolean {
|
paste(): boolean {
|
||||||
const asset = this.asset();
|
const asset = this.asset();
|
||||||
const clip = this.state.clipboard?.clip;
|
const clip = this.state.clipboard?.clip;
|
||||||
if (!asset || clip?.kind !== 'objects') return false;
|
if (!asset || (clip?.kind !== 'objects' && clip?.kind !== 'compositionClips')) return false;
|
||||||
const spot = this.pasteSpot(asset);
|
const spot = this.pasteSpot(asset);
|
||||||
const picked = this.pickedTrack(asset);
|
const picked = this.pickedTrack(asset);
|
||||||
const row = spot?.row ?? (picked >= 0 ? picked : undefined);
|
const row = spot?.row ?? (picked >= 0 ? picked : undefined);
|
||||||
const at = spot?.frame ?? Math.floor(this.playhead + 1e-6);
|
const at = spot?.frame ?? Math.floor(this.playhead + 1e-6);
|
||||||
|
if (clip.kind === 'compositionClips') return this.pasteClips(asset, clip.clips, at, row);
|
||||||
const pasted = pasteCompositionItems(this.state.project, asset.id, clip, at, row);
|
const pasted = pasteCompositionItems(this.state.project, asset.id, clip, at, row);
|
||||||
if (!pasted || !this.state.execute(pasted.command)) return false;
|
if (!pasted || !this.state.execute(pasted.command)) return false;
|
||||||
this.state.select(objectSelection(asset.id, pasted.itemIds));
|
this.state.select(objectSelection(asset.id, pasted.itemIds));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private pasteClips(
|
||||||
|
asset: CompositionAsset,
|
||||||
|
copied: readonly CompositionClip[],
|
||||||
|
at: number,
|
||||||
|
row: number | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (asset.tracks.length === 0) return false;
|
||||||
|
const ids = new Map(copied.map((c) => [c.id, newId()]));
|
||||||
|
const trackIds = asset.tracks.map((t) => t.id);
|
||||||
|
const placed = pastedClips(copied, asset.clips, ids, at, trackIds, this.lengthOf(asset), row);
|
||||||
|
if (placed.length === 0) return false;
|
||||||
|
const label = placed.length === 1 ? 'Paste clip' : 'Paste clips';
|
||||||
|
const command = new SetCompositionCommand(asset.id, { clips: [...asset.clips, ...placed] }, label);
|
||||||
|
if (!this.state.execute(command)) return false;
|
||||||
|
this.state.select({
|
||||||
|
kind: 'composition',
|
||||||
|
assetId: asset.id,
|
||||||
|
trackId: placed[0]!.track,
|
||||||
|
clipIds: placed.map((c) => c.id),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Where a paste goes with the pointer over the lanes: the frame under it and the nearest track; else null. */
|
/** Where a paste goes with the pointer over the lanes: the frame under it and the nearest track; else null. */
|
||||||
private pasteSpot(asset: CompositionAsset): { frame: number; row: number } | null {
|
private pasteSpot(asset: CompositionAsset): { frame: number; row: number } | null {
|
||||||
const p = this.pointer;
|
const p = this.pointer;
|
||||||
@@ -490,11 +521,11 @@ export class CompositionTimeline extends Widget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hit = this.hitAt(asset, g);
|
const hit = this.hitAt(asset, g);
|
||||||
if (!hit) {
|
const toggle = e.ctrlKey || e.metaKey;
|
||||||
this.state.select({ kind: 'asset', assetId: asset.id });
|
if (!hit || e.altKey) {
|
||||||
|
this.gesture = { kind: 'band', start: g, current: g, active: false, add: toggle, clips: e.altKey };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const toggle = e.ctrlKey || e.metaKey;
|
|
||||||
if (hit.kind === 'clip') {
|
if (hit.kind === 'clip') {
|
||||||
const clip = asset.clips.find((c) => c.id === hit.id)!;
|
const clip = asset.clips.find((c) => c.id === hit.id)!;
|
||||||
const picked = this.pickedClips(asset);
|
const picked = this.pickedClips(asset);
|
||||||
@@ -575,6 +606,13 @@ export class CompositionTimeline extends Widget {
|
|||||||
case 'scrub':
|
case 'scrub':
|
||||||
this.scrub(Math.max(0, Math.round(frame)));
|
this.scrub(Math.max(0, Math.round(frame)));
|
||||||
return;
|
return;
|
||||||
|
case 'band': {
|
||||||
|
gesture.current = g;
|
||||||
|
const span = Math.max(Math.abs(g.x - gesture.start.x), Math.abs(g.y - gesture.start.y));
|
||||||
|
if (!gesture.active && span < DRAG_THRESHOLD) return;
|
||||||
|
gesture.active = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'move': {
|
case 'move': {
|
||||||
if (!gesture.moved && Math.hypot(g.x - gesture.pointer.x, g.y - gesture.pointer.y) < DRAG_THRESHOLD)
|
if (!gesture.moved && Math.hypot(g.x - gesture.pointer.x, g.y - gesture.pointer.y) < DRAG_THRESHOLD)
|
||||||
return;
|
return;
|
||||||
@@ -672,6 +710,9 @@ export class CompositionTimeline extends Widget {
|
|||||||
if (!gesture || !asset) return;
|
if (!gesture || !asset) return;
|
||||||
this.hover = null;
|
this.hover = null;
|
||||||
switch (gesture.kind) {
|
switch (gesture.kind) {
|
||||||
|
case 'band':
|
||||||
|
this.pickBand(asset, gesture);
|
||||||
|
break;
|
||||||
case 'move': {
|
case 'move': {
|
||||||
if (!gesture.moved) {
|
if (!gesture.moved) {
|
||||||
if (gesture.what === 'item') this.state.select(objectSelection(asset.id, [gesture.anchor.id]));
|
if (gesture.what === 'item') this.state.select(objectSelection(asset.id, [gesture.anchor.id]));
|
||||||
@@ -704,6 +745,30 @@ export class CompositionTimeline extends Widget {
|
|||||||
this.redraw();
|
this.redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks what a band touched, with Ctrl what was picked before as well. Let go without dragging, or
|
||||||
|
* catching nothing, it picks the composition, as a click on an empty spot always did.
|
||||||
|
*/
|
||||||
|
private pickBand(asset: CompositionAsset, band: Extract<Gesture, { kind: 'band' }>): void {
|
||||||
|
if (band.clips) {
|
||||||
|
const caught = band.active
|
||||||
|
? clipsInBand(this.view, asset.tracks, asset.clips, this.lengthOf(asset), band.start, band.current)
|
||||||
|
: [];
|
||||||
|
const ids = [...new Set([...(band.add ? this.pickedClips(asset) : []), ...caught])];
|
||||||
|
const first = asset.clips.find((c) => c.id === ids[0]);
|
||||||
|
if (first)
|
||||||
|
this.state.select({ kind: 'composition', assetId: asset.id, trackId: first.track, clipIds: ids });
|
||||||
|
else this.state.select({ kind: 'asset', assetId: asset.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const caught = band.active
|
||||||
|
? itemsInBand(this.view, asset.tracks, this.liveItems(asset), band.start, band.current)
|
||||||
|
: [];
|
||||||
|
const ids = [...new Set([...(band.add ? this.pickedItems(asset) : []), ...caught])];
|
||||||
|
if (ids.length > 0) this.state.select(objectSelection(asset.id, ids));
|
||||||
|
else this.state.select({ kind: 'asset', assetId: asset.id });
|
||||||
|
}
|
||||||
|
|
||||||
private commit(
|
private commit(
|
||||||
asset: CompositionAsset,
|
asset: CompositionAsset,
|
||||||
parts: ConstructorParameters<typeof SetCompositionCommand>[1],
|
parts: ConstructorParameters<typeof SetCompositionCommand>[1],
|
||||||
@@ -996,6 +1061,9 @@ export class CompositionTimeline extends Widget {
|
|||||||
this.drawDrop(asset);
|
this.drawDrop(asset);
|
||||||
this.drawRuler(asset, ruler);
|
this.drawRuler(asset, ruler);
|
||||||
this.drawPlayhead();
|
this.drawPlayhead();
|
||||||
|
const g = this.gesture;
|
||||||
|
const local = (p: Vec2) => ({ x: p.x + HEAD_WIDTH, y: p.y + RULER_HEIGHT });
|
||||||
|
if (g?.kind === 'band' && g.active) drawBand(this.over, local(g.start), local(g.current));
|
||||||
}
|
}
|
||||||
syncTexts(this.itemTexts, this.itemLayer, items, textStyle('small'));
|
syncTexts(this.itemTexts, this.itemLayer, items, textStyle('small'));
|
||||||
syncTexts(this.clipTexts, this.clipLayer, clips, CLIP_LABEL);
|
syncTexts(this.clipTexts, this.clipLayer, clips, CLIP_LABEL);
|
||||||
|
|||||||
@@ -93,6 +93,53 @@ export function hitAt(
|
|||||||
return edge?.hit ?? body;
|
return edge?.hit ?? body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The items a band from `a` to `b` touches, as in the Song tab. */
|
||||||
|
export function itemsInBand(
|
||||||
|
view: CompositionView,
|
||||||
|
tracks: readonly CompositionTrack[],
|
||||||
|
items: readonly CompositionItem[],
|
||||||
|
a: Vec2,
|
||||||
|
b: Vec2,
|
||||||
|
): string[] {
|
||||||
|
return touched(tracks, items, (row, item) => itemBox(view, row, item), a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The clips a band from `a` to `b` touches. */
|
||||||
|
export function clipsInBand(
|
||||||
|
view: CompositionView,
|
||||||
|
tracks: readonly CompositionTrack[],
|
||||||
|
clips: readonly CompositionClip[],
|
||||||
|
clipLength: (clip: CompositionClip) => number,
|
||||||
|
a: Vec2,
|
||||||
|
b: Vec2,
|
||||||
|
): string[] {
|
||||||
|
const box = (row: number, clip: CompositionClip) =>
|
||||||
|
clipBox(view, row, { start: clip.start, length: clipLength(clip) });
|
||||||
|
return touched(tracks, clips, box, a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function touched<T extends { id: string; track: string }>(
|
||||||
|
tracks: readonly CompositionTrack[],
|
||||||
|
list: readonly T[],
|
||||||
|
boxOf: (row: number, t: T) => Box,
|
||||||
|
a: Vec2,
|
||||||
|
b: Vec2,
|
||||||
|
): string[] {
|
||||||
|
const rows = new Map(tracks.map((t, i) => [t.id, i]));
|
||||||
|
const x0 = Math.min(a.x, b.x);
|
||||||
|
const x1 = Math.max(a.x, b.x);
|
||||||
|
const y0 = Math.min(a.y, b.y);
|
||||||
|
const y1 = Math.max(a.y, b.y);
|
||||||
|
return list
|
||||||
|
.filter((t) => {
|
||||||
|
const row = rows.get(t.track);
|
||||||
|
if (row === undefined) return false;
|
||||||
|
const box = boxOf(row, t);
|
||||||
|
return box.x <= x1 && box.x + box.w >= x0 && box.y <= y1 && box.y + box.h >= y0;
|
||||||
|
})
|
||||||
|
.map((t) => t.id);
|
||||||
|
}
|
||||||
|
|
||||||
/** Where items of a track overlap, in frames: one stretch for each pair. */
|
/** Where items of a track overlap, in frames: one stretch for each pair. */
|
||||||
export function overlaps(items: readonly CompositionItem[]): { track: string; start: number; end: number }[] {
|
export function overlaps(items: readonly CompositionItem[]): { track: string; start: number; end: number }[] {
|
||||||
const out: { track: string; start: number; end: number }[] = [];
|
const out: { track: string; start: number; end: number }[] = [];
|
||||||
|
|||||||
@@ -25,13 +25,20 @@ import {
|
|||||||
mediaMarks,
|
mediaMarks,
|
||||||
movedClips,
|
movedClips,
|
||||||
movedItems,
|
movedItems,
|
||||||
|
pastedClips,
|
||||||
pastedItems,
|
pastedItems,
|
||||||
resizedItem,
|
resizedItem,
|
||||||
snappedToMedia,
|
snappedToMedia,
|
||||||
trimmedItem,
|
trimmedItem,
|
||||||
} from '../../src/core/model/compositionEdit';
|
} from '../../src/core/model/compositionEdit';
|
||||||
import { copyObjects } from '../../src/core/model/clipboard';
|
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 { placementRejection } from '../../src/core/model/placement';
|
||||||
import { findAsset, findAssetOf } from '../../src/core/model/Project';
|
import { findAsset, findAssetOf } from '../../src/core/model/Project';
|
||||||
import { makeProject } from '../helpers';
|
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']);
|
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', () => {
|
it('a copied item is pasted with its object and overrides under new ids, in one undo step', () => {
|
||||||
const cut = makeCut();
|
const cut = makeCut();
|
||||||
const id = cut.add(10, 60);
|
const id = cut.add(10, 60);
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
* to begin is opened ahead, so the cut to it shows its picture at once, and Play waits for a video
|
* to begin is opened ahead, so the cut to it shows its picture at once, and Play waits for a video
|
||||||
* whose player is still loading. A copied item is pasted under the pointer or at the playhead. A
|
* whose player is still loading. A copied item is pasted under the pointer or at the playhead. A
|
||||||
* video dragged in wears a sprite the composition keeps to itself. An item dragged in the picture
|
* video dragged in wears a sprite the composition keeps to itself. An item dragged in the picture
|
||||||
* follows the pointer while the button is down.
|
* follows the pointer while the button is down. A band picks items, with Alt clips, and copied clips
|
||||||
|
* are pasted where they fit.
|
||||||
*
|
*
|
||||||
* Everything it adds carries a `ct69` id and goes again at the end.
|
* Everything it adds carries a `ct69` id and goes again at the end.
|
||||||
*/
|
*/
|
||||||
@@ -377,6 +378,59 @@ export async function compositionTab(t) {
|
|||||||
JSON.stringify({ front, startAt, during, xBefore, xAfter }),
|
JSON.stringify({ front, startAt, during, xBefore, xAfter }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 69m. A band from an empty spot picks the items it touches, with Alt – from anywhere, an item too –
|
||||||
|
// the clips. Ctrl+C and Ctrl+V take the clips under the pointer; a second paste onto the first
|
||||||
|
// moves on until the clips fit, as clips of a track never overlap.
|
||||||
|
await mutate(`
|
||||||
|
const c = __gm.state.project.assets.find((a) => a.id === '${CUT}');
|
||||||
|
const like = structuredClone(c.objects[0]);
|
||||||
|
c.objects = ['ct69-b1', 'ct69-b2', 'ct69-b3'].map((id) => ({ ...like, id, assetId: '${BOX}' }));
|
||||||
|
c.items = [
|
||||||
|
{ id: 'ct69-b1', track: 'ct69-t1', start: 0, length: 30, offset: 0 },
|
||||||
|
{ id: 'ct69-b2', track: 'ct69-t1', start: 40, 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: 0 },
|
||||||
|
{ id: 'ct69-k2', track: 'ct69-t2', animation: '${SLIDE}', start: 10 },
|
||||||
|
];`);
|
||||||
|
await sleep(100);
|
||||||
|
const empty = await json(`__gm.composition.laneAt('ct69-t2', 90)`);
|
||||||
|
const into = await json(`__gm.composition.laneAt('ct69-t1', 25)`);
|
||||||
|
await drag(empty.x, empty.y, into.x, into.y);
|
||||||
|
const banded = await json('__gm.state.selection');
|
||||||
|
const k1 = await json(`__gm.composition.box('ct69-k1')`);
|
||||||
|
const k2 = await json(`__gm.composition.box('ct69-k2')`);
|
||||||
|
await drag(k1.x + 1, k1.y - 3, k2.x + k2.w + 3, k2.y + k2.h - 1, { modifiers: { alt: true } });
|
||||||
|
const altBanded = await json('__gm.state.selection');
|
||||||
|
await press('c', { ctrl: true });
|
||||||
|
const pasteAt = await json(`__gm.composition.laneAt('ct69-t1', 50)`);
|
||||||
|
await mouse('mouseMoved', pasteAt.x, pasteAt.y);
|
||||||
|
await press('v', { ctrl: true });
|
||||||
|
const firstPaste = (await model()).clips.slice(2);
|
||||||
|
await press('v', { ctrl: true });
|
||||||
|
m = await model();
|
||||||
|
const secondPaste = m.clips.slice(4);
|
||||||
|
const pastedPick = await json('__gm.state.selection');
|
||||||
|
check(
|
||||||
|
'a band picks the items it touches, with Alt the clips, and copied clips paste where they fit',
|
||||||
|
JSON.stringify([...(banded?.objectIds ?? [])].sort()) ===
|
||||||
|
JSON.stringify(['ct69-b1', 'ct69-b2', 'ct69-b3']) &&
|
||||||
|
altBanded?.kind === 'composition' &&
|
||||||
|
JSON.stringify([...altBanded.clipIds].sort()) === JSON.stringify(['ct69-k1', 'ct69-k2']) &&
|
||||||
|
firstPaste.length === 2 &&
|
||||||
|
firstPaste[0].track === 'ct69-t1' &&
|
||||||
|
firstPaste[0].start === 50 &&
|
||||||
|
firstPaste[1].track === 'ct69-t2' &&
|
||||||
|
firstPaste[1].start === 60 &&
|
||||||
|
secondPaste.length === 2 &&
|
||||||
|
secondPaste[0].start - firstPaste[0].start >= 5 &&
|
||||||
|
secondPaste[0].start - firstPaste[0].start <= 6 &&
|
||||||
|
secondPaste[1].start - secondPaste[0].start === 10 &&
|
||||||
|
JSON.stringify(pastedPick?.clipIds) === JSON.stringify(secondPaste.map((c) => c.id)),
|
||||||
|
JSON.stringify({ banded: banded?.objectIds, altBanded, firstPaste, secondPaste, pastedPick }),
|
||||||
|
);
|
||||||
|
|
||||||
await showTab('scene');
|
await showTab('scene');
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const p = __gm.state.project;
|
const p = __gm.state.project;
|
||||||
|
|||||||
Reference in new issue
Block a user