Drag clips of videos and sounds straight onto a track (#425)

A clip of a video could not be laid on a composition track at all, and
neither could a clip of a sound, although #418 and the wiki said so.
The only place a drag starts is the asset panel, and it listed assets,
not clips. The texture label of an item playing a video clip was dead
too, because the reference lookup did not know video clips.

- The asset panel gives a sound or video with clips a chevron of its
  own. It unfolds one row per clip and starts closed.
- A clip row drags like an asset. One click shows the clip in the
  inspector without leaving the tab (`EditorState.glance`). A double
  click opens its sound or video with the clip picked.
- A video clip dropped on a composition track becomes an item as long
  as the clip. It wears the sprite of its whole video, so a composition
  keeps one sprite per video, and the clip is a texture override on
  the object. Sprite, item and override are one undo step
  (`droppedPicture`).
- A sound clip becomes an audio item as long as the clip. The song tab
  takes sound clips on the same way, through `findSound`.
- `referencedTarget` lets a reference label link to the clip itself.
  One click pings the clip row, a double click opens the asset with the
  clip picked. The Plays row of an audio item reads "Sound / Clip".
- `clipOwner` in `sound.ts` finds the sound or video a clip id belongs
  to. `activateAssetOrClip` uses it for the double clicks.

Found on the way: the Blip of the e2e seed is only 0.25 s long, so a
clip of 0.2 to 0.7 s on it is cut to one frame. 69s takes the Drone
(0.75 s) instead.

Tests:
- tsc, eslint, Prettier clean
- Unit on stein2 (composition, references, sound, video, editor):
  94 files, 1238 tests green
- e2e 69 on melektaus-atx: ALL PASSED, 25 checks, 2 of them new (69s)
- Gate on melektaus-atx with the fix for 68k on top: CHECK EXIT 0,
  313 files, 4841 tests
- Counter checks, all red: no clip rows in e2e 69 (no drag starts),
  no clip override in the unit test, the link aimed at the asset
  instead of the clip
- Not covered by its own e2e check: sound clips on the song tab

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-15 08:01:04 +02:00
1 parent 0701fe3856
commit 406839baea
20 files changed
+469 -94

No files matched your search

+1
View File
@@ -643,6 +643,7 @@ export class Editor implements PlayHost {
// brings it up. Selecting an asset never does: that is what opening it (double-click, Enter,
// creating it) is for – see the `open` event below.
state.on('selection', () => {
if (state.glancing) return;
const s = state.selection;
if (s?.kind === 'node' && s.assetId === ROUTING_ID) this.openDocument('routing');
else if (
+8
View File
@@ -32,6 +32,7 @@ import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../c
import { ownerOf } from '../core/music/embedded';
import type { SceneObject } from '../core/model/SceneObject';
import { pixelArtRefIds } from '../core/model/pixelart';
import { clipOwner } from '../core/model/sound';
import { textureRefIds } from '../core/model/texture';
import { videoRefIds } from '../core/model/video';
import {
@@ -54,6 +55,13 @@ export function activateAsset(state: EditorState, assetId: string): void {
state.openAsset(assetId);
}
/** The same for an id that may name a clip of a sound or a video (#425): its tab opens with the clip picked. */
export function activateAssetOrClip(state: EditorState, id: string): void {
const owner = clipOwner(state.project, id);
if (owner) state.select({ kind: 'clip', assetId: owner.id, clipId: id });
else activateAsset(state, id);
}
/** Groups the objects into a new container asset placed in their stead; selects the container. */
export function createContainer(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
const command = new CreateContainerCommand(holderId, objectIds);
+14 -15
View File
@@ -12,7 +12,7 @@ import {
removeFromComposition,
SetCompositionCommand,
} from '../../core/commands/compositionCommands';
import { addPictureItem, wornPicture } from '../../core/commands/compositionSprites';
import { addPictureItem, droppedPicture } from '../../core/commands/compositionSprites';
import { newId } from '../../core/ids';
import { isPlaceableType } from '../../core/model/AssetTypes';
import type {
@@ -906,10 +906,10 @@ export class CompositionTimeline extends Widget {
}
/**
* Follows an asset dragged in from the panel: a placeable one, a picture or a video (#419) lands as
* an item under the pointer, as long as its media or 3 s, over whatever lies there; a sound, a clip
* of one, a pattern or a song as an audio item as long as one pass of it (#418); an animation as a
* clip one pass long, where it fits whole.
* Follows an asset dragged in from the panel: a placeable one, a picture, a video or a clip of one
* (#419, #425) lands as an item under the pointer, as long as its media or 3 s, over whatever lies
* there; a sound, a clip of one, a pattern or a song as an audio item as long as one pass of it
* (#418); an animation as a clip one pass long, where it fits whole.
*/
private followDrop(asset: CompositionAsset, assetId: string, local: Vec2, g: Vec2): void {
const project = this.state.project;
@@ -917,10 +917,11 @@ export class CompositionTimeline extends Widget {
const audio = audioKindOf(project, assetId);
const row = trackAtY(this.view, asset.tracks.length, g.y);
const inside = local.x >= HEAD_WIDTH && local.x <= this.w && local.y >= RULER_HEIGHT && local.y <= this.h;
const picture = dragged ? wornPicture(dragged) : null;
const picture = droppedPicture(project, assetId);
const usable =
audio !== null ||
(dragged && (dragged.type === 'animation' || isPlaceableType(dragged.type) || picture !== null));
picture !== null ||
(dragged && (dragged.type === 'animation' || isPlaceableType(dragged.type)));
if (!inside || row === null || !usable) {
if (this.drop) {
this.drop = null;
@@ -942,7 +943,7 @@ export class CompositionTimeline extends Widget {
length: fittedLength(project, asset, item),
rejection: '',
};
} else if (dragged!.type === 'animation') {
} else if (dragged?.type === 'animation') {
const length = clipLength(this.state.project, asset, {
id: '',
track,
@@ -971,7 +972,7 @@ export class CompositionTimeline extends Widget {
assetId,
track,
start: at,
length: droppedLength(this.state.project, asset, dragged!),
length: droppedLength(this.state.project, asset, assetId),
rejection: picture === null ? placementRejection(this.state.project, asset, dragged!) : '',
};
this.redraw();
@@ -1025,12 +1026,10 @@ export class CompositionTimeline extends Widget {
if (this.state.execute(command)) this.pickItems([added.id]);
return;
}
const dragged = findAsset(this.state.project, drop.assetId);
const picture = dragged ? wornPicture(dragged) : null;
const { command, objectId } =
dragged && picture !== null
? addPictureItem(this.state.project, asset.id, picture, dragged.name, item)
: addCompositionItem(this.state.project, asset.id, drop.assetId, item);
const picture = droppedPicture(this.state.project, drop.assetId);
const { command, objectId } = picture
? addPictureItem(this.state.project, asset.id, picture, item)
: addCompositionItem(this.state.project, asset.id, drop.assetId, item);
if (this.state.execute(command)) this.state.select(objectSelection(asset.id, [objectId]));
}
+2
View File
@@ -428,6 +428,8 @@ export function uiHooks({ app, state, editor }: HookContext) {
/** An asset's row, its group unfolded first as a user would; `assetRowShown` leaves it folded. */
assetRow: (id: string) => assets.rowGlobal(id, true),
assetRowShown: (id: string) => assets.rowGlobal(id),
/** The chevron that folds the clips of a sound or a video (#425); null while its row has none. */
assetChevron: (id: string) => assets.chevronGlobal(id),
/** The asset row lighting up after a ping (#221) and how brightly, or null once it has faded. */
assetPing: () => assets.pingState(),
assetRowColor: (id: string) => assets.rowColor(id),
+8 -8
View File
@@ -1,7 +1,7 @@
import type { PropertyBag } from '../../core/model/Properties';
import type { FederatedPointerEvent } from 'pixi.js';
import { Label, Panel, pressCount, ScrollContainer, ui, type Widget } from '../../ui';
import { activateAsset } from '../actions';
import { activateAssetOrClip } from '../actions';
import { type EditorState, sameSelection, type Selection } from '../state';
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection';
@@ -66,13 +66,13 @@ export class Inspector extends Panel {
}
/**
* A link in a reference row (#221): a click pings the asset in the asset panel, a double-click
* opens it. Pinging leaves the selection alone, so this panel keeps showing the object and both
* presses land on the same link.
* A link in a reference row (#221): a click pings the asset or clip (#425) in the asset panel, a
* double-click opens it. Pinging leaves the selection alone, so this panel keeps showing the object
* and both presses land on the same link.
*/
private followReference(assetId: string, e: FederatedPointerEvent): void {
if (pressCount(e, `inspector-link:${assetId}`) === 2) activateAsset(this.state, assetId);
else this.state.pingAsset(assetId);
private followReference(id: string, e: FederatedPointerEvent): void {
if (pressCount(e, `inspector-link:${id}`) === 2) activateAssetOrClip(this.state, id);
else this.state.pingAsset(id);
}
/** Signing out destroys the panel while the page stays up (#177), so the handler goes with it. */
@@ -106,7 +106,7 @@ export class Inspector extends Panel {
private emptyHost(): InspectorHost {
return emptyHost(this.state, this.scroll, {
liveVariables: (assetId) => this.liveVariables(assetId),
followReference: (assetId, e) => this.followReference(assetId, e),
followReference: (id, e) => this.followReference(id, e),
listLimit: INLINE_LIMIT,
});
}
+1 -1
View File
@@ -439,7 +439,7 @@ function buildAudioItem(
): void {
addTitle(host, AUDIO_TITLES[audio.kind]);
const played = playedAsset(host.state.project, audio);
if (played) assetLink(host, PLAYS_ROW, 'Plays', played);
if (played) assetLink(host, PLAYS_ROW, 'Plays', played, audio.asset);
else addNote(host, `Its ${audio.kind} is gone.`, 'warning');
addTimingRows(host, assetId, item.id);
const edit = (label: string, change: Partial<CompositionAudio>) => {
+6 -5
View File
@@ -39,7 +39,7 @@ import {
type PropertyDef,
type StructFieldDef,
} from '../../core/properties/PropertySchema';
import { referencedAssetId } from '../../core/properties/references';
import { referencedTarget } from '../../core/properties/references';
import {
NO_TEXTURE_LABEL,
pictureChoices,
@@ -80,10 +80,11 @@ import type { ObjectPicker } from '../objectPick';
export interface FieldContext {
project(): Project;
/**
* A press on the label of a reference row that names an asset (#221). Without it the label stays
* plain text – only the inspector knows what a click (ping) and a double-click (open) do.
* A press on the label of a reference row that names an asset (#221) or a clip of one (#425).
* Without it the label stays plain text – only the inspector knows what a click (ping) and a
* double-click (open) do.
*/
followReference?(assetId: string, e: FederatedPointerEvent): void;
followReference?(id: string, e: FederatedPointerEvent): void;
/** Items a list draws inline before "… N more" (#252); none means all of them. */
listLimit?: number;
/**
@@ -291,7 +292,7 @@ export function createFieldRow(
let modified = false;
let target: string | null = null;
const retarget = (v: PropertyValue): void => {
target = referencedAssetId(ctx.project(), def, v);
target = referencedTarget(ctx.project(), def, v);
paintLabel(target, modified);
};
retarget(value);
+12 -3
View File
@@ -31,6 +31,7 @@ import { type PropertyValue, textOf } from '../../core/model/Properties';
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
import { objectName, type SceneObject } from '../../core/model/SceneObject';
import { markedName } from '../../core/music/embedded';
import { assetRefLabel } from '../../core/properties/assetRef';
import {
COMMON_PROPS,
isCommonProp,
@@ -336,11 +337,19 @@ const PART_OF_ROW = '#part-of';
/**
* What the object was placed from: the label is a link like on any reference row (click pings,
* double-click opens), the name next to it only reads – changing it would mean another object.
* `named` is a clip of the asset where the row names one (#425); the link goes to the clip then.
*/
export function assetLink(host: InspectorHost, key: string, text: string, asset: Asset): void {
export function assetLink(
host: InspectorHost,
key: string,
text: string,
asset: Asset,
named = asset.id,
): void {
const { row, label } = labelledRow(text);
linkLabel(host, label)(asset.id);
const name = new TextField(markedName(asset), { readOnly: true });
linkLabel(host, label)(named);
const shown = named === asset.id ? markedName(asset) : assetRefLabel(host.state.project, named);
const name = new TextField(shown, { readOnly: true });
row.add(name, 1);
host.body.add(row);
host.fields.set(key, { row, setValue: (v) => name.setValue(textOf(v)), setModified: () => {} });
+68 -16
View File
@@ -18,6 +18,7 @@ import { generatesPictures } from '../../core/model/generated';
import { PALETTE_KEY, pixelArtWornId } from '../../core/model/pixelart';
import type { PropertyBag } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { clipOwner, clipRefName, clipsOf, hasClips } from '../../core/model/sound';
import { listedAssets, ownerOf } from '../../core/music/embedded';
import { spriteFromTexture } from '../../core/properties/aspect';
import { assetDeviates } from '../../core/properties/deviation';
@@ -42,7 +43,7 @@ import { type AssetCategory, type AssetCategoryGroup, ASSET_GROUPS, groupAssets
import { ASSET_ICON_SVG } from './assetIcons';
import { assetMenuCategories } from './assetMenu';
import { TOOLBAR_ICON_SVG } from './toolbarIcons';
import { activateAsset, confirmDeleteAsset } from '../actions';
import { activateAssetOrClip, confirmDeleteAsset } from '../actions';
import { openReferenceItems } from '../objectMenu';
import { generalSettings, onGeneralSettings } from '../generalSettings';
import { scrollTween } from '../viewTween';
@@ -76,6 +77,8 @@ export class AssetPanel extends Panel {
private readonly unfolded = new Set<AssetType>();
/** Categories folded away, for the session; every category starts unfolded, showing its groups. */
private readonly foldedCategories = new Set<AssetCategory>();
/** Sounds and videos whose clips are shown under them (#425), for the session; all start folded. */
private readonly unfoldedClips = new Set<string>();
/** Ids of the rows as shown, headings included. */
private rows: string[] = [];
/**
@@ -104,14 +107,15 @@ export class AssetPanel extends Panel {
this.header.add(add);
this.list.emptyText = 'No assets – press + or right-click';
this.list.onSelect = (id) => state.select(id ? { kind: 'asset', assetId: id } : null);
this.list.onSelect = (id) => this.pick(id);
this.list.onActivate = (id) => this.activate(id);
this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y);
this.list.onListMenu = (x, y, headingId) =>
new Menu(this.addMenuEntries(headingId), { title: ADD_MENU_TITLE }).open(x, y);
this.list.onDragOut = (id) => this.startPlacementDrag(id);
this.list.onToggle = (id, expand) => {
if (id.startsWith(CATEGORY))
if (findAsset(state.project, id)) this.setClipsFolded(id, !expand);
else if (id.startsWith(CATEGORY))
this.setCategoryFolded(id.slice(CATEGORY.length) as AssetCategory, !expand);
else this.setFolded(id.slice(HEADING.length) as AssetType, !expand);
};
@@ -128,6 +132,7 @@ export class AssetPanel extends Panel {
state.on('load', () => {
this.unfolded.clear();
this.foldedCategories.clear();
this.unfoldedClips.clear();
this.scrolledTo = null;
this.refresh();
});
@@ -148,17 +153,26 @@ export class AssetPanel extends Panel {
}
/**
* Unfolds and scrolls to the pinged asset and lets its row light up; the selection stays. What a
* song or a composition keeps to itself has no row, so its owner lights up (#355, #419).
* Unfolds and scrolls to the pinged asset or clip (#425) and lets its row light up; the selection
* stays. What a song or a composition keeps to itself has no row, so its owner lights up (#355,
* #419).
*/
private ping(): void {
const asset = findAsset(this.state.project, this.state.pingedAssetId ?? '');
const pinged = this.state.pingedAssetId ?? '';
const asset = findAsset(this.state.project, pinged) ?? clipOwner(this.state.project, pinged);
if (!asset) return;
const id = ownerOf(this.state.project, asset)?.id ?? asset.id;
const id = ownerOf(this.state.project, asset)?.id ?? pinged;
this.reveal(id);
this.list.ping(id);
}
/** A row picked in the list: an asset, or a clip (#425), looked at in the inspector without its tab. */
private pick(id: string | null): void {
const owner = id ? clipOwner(this.state.project, id) : undefined;
if (owner && id) this.state.glance({ kind: 'clip', assetId: owner.id, clipId: id });
else this.state.select(id ? { kind: 'asset', assetId: id } : null);
}
/** The row lighting up after a ping and how brightly, or null (test hook). */
pingState(): { id: string; alpha: number } | null {
return this.list.pingState();
@@ -224,14 +238,19 @@ export class AssetPanel extends Panel {
faint: assets.length === 0,
});
if (!expanded) continue;
for (const asset of assets)
for (const asset of assets) {
const clips = hasClips(asset) ? clipsOf<{ id: string; name: string }>(asset) : [];
const clipsShown = this.unfoldedClips.has(asset.id);
items.push({
id: asset.id,
label: asset.name,
marks: asset.type === 'scene' ? this.sceneMarks(asset.id) : undefined,
depth: 2,
overridden: assetDeviates(project, asset),
expanded: clips.length > 0 ? clipsShown : undefined,
});
if (clipsShown) for (const clip of clips) items.push({ id: clip.id, label: clip.name, depth: 3 });
}
}
}
this.rows = items.map((item) => item.id);
@@ -255,6 +274,13 @@ export class AssetPanel extends Panel {
this.refresh();
}
/** Unlike a group, folding keeps a picked clip: it may be the one being worked on in its tab. */
private setClipsFolded(assetId: string, fold: boolean): void {
if (fold) this.unfoldedClips.delete(assetId);
else this.unfoldedClips.add(assetId);
this.refresh();
}
private setCategoryFolded(category: AssetCategory, fold: boolean): void {
if (fold) {
// Like a group: folding away the selected asset lets go of it.
@@ -273,7 +299,7 @@ export class AssetPanel extends Panel {
private syncSelection(): void {
const s = this.state.selection;
const id = s?.kind === 'asset' ? s.assetId : null;
const id = s?.kind === 'asset' ? s.assetId : s?.kind === 'clip' ? s.clipId : null;
// A selection made anywhere else – a graph node, a picker, a fresh asset – has to be reachable
// in the list: its category and group unfold, and with many assets the row is often below the fold.
const type = id ? findAsset(this.state.project, id)?.type : undefined;
@@ -281,6 +307,12 @@ export class AssetPanel extends Panel {
this.setFolded(type, false);
return;
}
// Only a clip picked here unfolds its sound or video: one picked in its tab leaves the list alone.
if (s?.kind === 'clip' && !this.rows.includes(s.clipId)) {
this.list.select(null, true);
this.scrolledTo = null;
return;
}
this.list.select(id, true);
if (id && id !== this.scrolledTo) this.scrollTo(id);
this.scrolledTo = id;
@@ -420,9 +452,12 @@ export class AssetPanel extends Panel {
openVideoPicker({ state: this.state, videos: this.videos });
}
/** Double-click / Enter opens the asset's tab; a scene also becomes the active one. Placing is a drag. */
/**
* Double-click / Enter opens the asset's tab; a scene also becomes the active one, a clip (#425) is
* picked in its tab. Placing is a drag.
*/
private activate(id: string): void {
activateAsset(this.state, id);
activateAssetOrClip(this.state, id);
}
/**
@@ -434,11 +469,21 @@ export class AssetPanel extends Panel {
return this.list.rowGlobal(assetId);
}
/** Unfolds the asset's group and scrolls to its row – before a ping, and as a user would before clicking it (test hooks). */
private reveal(assetId: string): void {
const type = findAsset(this.state.project, assetId)?.type;
/**
* Unfolds the asset's group – and for a clip (#425) its sound or video – and scrolls to its row:
* before a ping, and as a user would before clicking it (test hooks).
*/
private reveal(id: string): void {
const owner = clipOwner(this.state.project, id);
const type = findAsset(this.state.project, owner?.id ?? id)?.type;
if (type && !this.shows(type)) this.setFolded(type, false);
this.scrollTo(assetId);
if (owner && !this.unfoldedClips.has(owner.id)) this.setClipsFolded(owner.id, false);
this.scrollTo(id);
}
/** Centre of a shown row's chevron (test hook). */
chevronGlobal(id: string): { x: number; y: number } | null {
return this.list.chevronGlobal(id);
}
/** Centre of a group heading's icon (test hook). */
@@ -455,9 +500,16 @@ export class AssetPanel extends Panel {
/**
* Hands the asset to the view under the pointer: a scene places it, a song lays out a pattern or a
* sound (#334), a composition an animation as a clip (#417), a picture or video as an item (#419)
* and a sound, pattern or song as an audio item (#418). A scene has nowhere to go.
* and a sound, pattern or song as an audio item (#418). A scene has nowhere to go. A clip of a sound
* or a video goes where its asset would, to a song or a composition (#425).
*/
private startPlacementDrag(assetId: string): void {
const owner = clipOwner(this.state.project, assetId);
if (owner) {
const clip = clipsOf<{ id: string; name: string }>(owner).find((c) => c.id === assetId)!;
startDrag({ kind: 'asset', id: assetId }, clipRefName(owner.name, clip.name));
return;
}
const asset = findAsset(this.state.project, assetId);
const laid =
asset?.type === 'pattern' ||
+13 -8
View File
@@ -34,7 +34,8 @@ import {
import { ticksOfSeconds, ticksPerBar } from '../../core/music/time';
import type { Asset, SongAsset } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset, findAssetOf } from '../../core/model/Project';
import { findAssetOf } from '../../core/model/Project';
import { findSound } from '../../core/model/sound';
import {
activeDrag,
crispText,
@@ -1065,9 +1066,13 @@ export class SongTimeline extends Widget {
this.invalidate();
}
/** Follows an asset dragged in from the panel: a pattern or a sound lands on the lane under the pointer. */
/**
* Follows an asset dragged in from the panel: a pattern, a sound or a clip of one (#425) lands on the
* lane under the pointer.
*/
private trackDrop(asset: SongAsset, assetId: string, local: Vec2, g: Vec2): void {
const dragged = findAsset(this.state.project, assetId);
const pattern = findAssetOf(this.state.project, assetId, 'pattern');
const sound = pattern ? null : findSound(this.state.project, assetId);
// Left of tick 0 is there to look at: nothing lands there.
const inside =
local.x >= HEAD_WIDTH &&
@@ -1076,7 +1081,7 @@ export class SongTimeline extends Widget {
local.y <= this.h &&
xToTick(this.view, g.x) >= 0;
const row = trackAtY(this.view, asset.tracks.length, g.y);
if (!inside || row === null || !dragged || (dragged.type !== 'pattern' && dragged.type !== 'sound')) {
if (!inside || row === null || !(pattern || sound)) {
if (this.drop) {
this.drop = null;
this.invalidate();
@@ -1087,14 +1092,14 @@ export class SongTimeline extends Widget {
const tick = Math.max(0, snapDown(laneTick(this.view, g.x), grid));
const bar = ticksPerBar(songMeter(asset.props));
let length: number;
if (dragged.type === 'pattern')
length = droppedLength(null, patternLength(dragged), songTempo(asset.props), bar);
if (pattern) length = droppedLength(null, patternLength(pattern), songTempo(asset.props), bar);
else {
const played = playedSoundOf({ asset: dragged, clip: null });
const played = playedSoundOf(sound!);
this.ensureLoaded(played);
length = droppedLength(soundLibrary.duration(played), null, songTempo(asset.props), bar);
}
this.drop = { kind: dragged.type, asset: dragged.id, track: asset.tracks[row]!.id, tick, length };
const kind = pattern ? 'pattern' : 'sound';
this.drop = { kind, asset: assetId, track: asset.tracks[row]!.id, tick, length };
this.invalidate();
}
+18 -3
View File
@@ -294,8 +294,10 @@ export class EditorState {
* working rather than something the project remembers.
*/
tilemapPaint: TilemapPaint = { tool: 'select', tileId: '', layerId: '', focus: 'all' };
/** The asset the last `pingAsset` pointed at. */
/** The asset or clip (#425) the last `pingAsset` pointed at. */
pingedAssetId: string | null = null;
/** Set while `glance` selects: the selection stays where it is shown. */
glancing = false;
/** Objects the scene view is dragging: a running game holds their bodies to the pointer. */
heldObjectIds: ReadonlySet<string> = new Set();
/** The eyedropper of an object's place while it runs (#276). */
@@ -570,14 +572,27 @@ export class EditorState {
}
/**
* Shows where an asset is listed without selecting it – what one click on a link in the inspector
* does (#221), so the inspector stays on the object the link belongs to.
* Shows where an asset or a clip of one (#425) is listed without selecting it – what one click on a
* link in the inspector does (#221), so the inspector stays on the object the link belongs to.
*/
pingAsset(assetId: string): void {
this.pingedAssetId = assetId;
this.emit('ping');
}
/**
* Selects without bringing up the tab a clip lives in: a click on a clip's row in the asset panel
* is a look at the inspector, as one on an asset's is (#425).
*/
glance(selection: Selection): void {
this.glancing = true;
try {
this.select(selection);
} finally {
this.glancing = false;
}
}
select(selection: Selection): void {
// Test hooks pass the short form `{ kind: 'object', holderId, objectId }` without `objectIds`.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition