Patterns could only be heard one at a time, in their own tab or from a behavior. A song lays patterns and sounds out on tracks along one time axis and plays them through the scheduler of #332, the third piece of the music tools of #330. - New asset type song under the heading Songs, with four empty tracks, 120 BPM in 4/4 and a grid of a bar. Tempo, meter and grid are rows in the inspector. Tracks, items and the loop are the asset's data, held to their rules on load and in every command. - A track has a name, volume, pan, mute and solo. Each is heard on a bus of its own, so a change reaches what is sounding at once. - A pattern item repeats its pattern for as long as it is, with a transpose and another instrument if wanted. A sound item plays its sound as a clip at its own speed, round and round, through a sound FX if wanted. Both have a volume on top of their track's. - Cut back at the left end or split, an item starts as far into what it plays as the cut is: ticks into a pattern, seconds into a sound. - The tab shows the heads with M, S, volume and pan, the ruler of the Pattern tab and the items, a pattern with its notes, a sound with its waveform, a missing asset red. Patterns and sounds are dragged in from the Assets panel. Body, right and left end move, lengthen and cut back, Alt leaves the grid. Ctrl+C/V/D/A, Ctrl+E splits where playing starts, Delete, arrows. A double-click opens a pattern item in its tab. - What is picked is a new selection of its own, so the inspector shows the item or the track, with a button to delete it. - The scheduler plans clips beside notes. Where the playhead starts or jumps back inside a clip, it is taken up that far into its sound. A clip fades out at the end of its item or of the loop. - The editor plays one piece at a time: a pattern started stops the song and the other way round. - The rules are pure functions in core/music/song, editor/song/ songView and songEdit. Findings: - The suites of4ea752fand2a3b90bwere red at e2e 54: the Pattern pushed Struct below the fold of the + menu. The module now scrolls to it, as e2e 47 does for Vector art. It also left the menu open, which made e2e 55 fail behind it. - The Assets panel only dragged assets a scene can place. Patterns and sounds are dragged now as well, and a scene says why it does not take one instead of calling every asset a scene. Tests: - song.test.ts (new, 14) and song-edit.test.ts (new, 9): model, round trip, sequence of patterns and clips, mute and solo, cuts, clips on the scheduler, moving, sizing, trimming, splitting, duplicating, hits, head parts. sound-library.test.ts +5: clip offset, end fade, pitched clip, track bus, peaks. - e2e 65 (new) on melektaus-atx: ALL PASSED, 18 checks. e2e 54 and 55 with the fix: ALL PASSED, 33 checks. e2e 4 with 30 asset types: ALL PASSED, 62 checks. - Gate on melektaus-atx: CHECK EXIT 0, 250 test files, 3884 tests. - Counter-checks: no taking up of a clip, no pattern offset, no clip end each fail 3, 1 and 1 tests. - Probe on stein2: the tab with four tracks, before and while playing, looked at in two screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
966 lines
39 KiB
TypeScript
966 lines
39 KiB
TypeScript
import type { KeyClipboard } from '../core/commands/animationCommands';
|
||
import type { EditorClipboard } from './clipboard';
|
||
import type { ObjectPick, PickEnd } from './objectPick';
|
||
import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation';
|
||
import { type Command, CommandError } from '../core/commands/Command';
|
||
import { CommandStack } from '../core/commands/CommandStack';
|
||
import type { Asset, GraphAsset, SceneAsset } from '../core/model/Asset';
|
||
import { findAnnotation, type Graph } from '../core/graph';
|
||
import { findObject } from '../core/model/hierarchy';
|
||
import { allShapes } from '../core/model/vectorTree';
|
||
import { resolvedHolder } from '../core/model/instance';
|
||
import {
|
||
createEmptyProject,
|
||
findAsset,
|
||
findAssetOf,
|
||
findGraphAsset,
|
||
findObjectHolder,
|
||
findScene,
|
||
type Project,
|
||
startScene,
|
||
} from '../core/model/Project';
|
||
import { deserializeProject, serializeProject } from '../core/store/serialize';
|
||
import { requestRender } from '../ui';
|
||
import { consoleLog } from './console/log';
|
||
import type { GameScale, PlayFrom } from '../core/model/playSettings';
|
||
import type { PlaceableAssetType } from '../core/model/AssetTypes';
|
||
import type { VisibilityPart } from '../core/model/sceneVisibility';
|
||
import type { ValuePath } from '../core/properties/valuePath';
|
||
|
||
export type Selection =
|
||
| { kind: 'asset'; assetId: string }
|
||
/**
|
||
* One or more objects of a scene or of a container's template (`holderId` names which).
|
||
* `objectId` is the primary one (the last picked – the inspector shows it); `objectIds`
|
||
* holds the whole selection, primary included.
|
||
*/
|
||
| { kind: 'object'; holderId: string; objectId: string; objectIds: string[] }
|
||
/**
|
||
* One or more nodes of a behavior's graph, `nodeId` being the primary one (as with objects).
|
||
* `annotationIds` holds the groups and comments caught alongside them: a rubber band spans
|
||
* whatever lies under it, and the two sorts move and die together from then on. `edgeIds` holds
|
||
* the links picked alongside (#289); they do not move, but Delete takes them too.
|
||
*/
|
||
| {
|
||
kind: 'node';
|
||
assetId: string;
|
||
nodeId: string;
|
||
nodeIds: string[];
|
||
annotationIds: string[];
|
||
edgeIds: string[];
|
||
}
|
||
/** A frame rectangle inside a texture asset (the Texture tab). */
|
||
| { kind: 'frame'; assetId: string; frameId: string }
|
||
/**
|
||
* One or more shapes of a vector art asset (the Vector art tab, #129). `shapeId` is the primary
|
||
* one, whose points and style are edited; `shapeIds` holds all of them for combining (#187).
|
||
*/
|
||
| { kind: 'shape'; assetId: string; shapeId: string; shapeIds: string[] }
|
||
/** One or more groups or comments of a graph, `annotationId` being the primary one. */
|
||
| { kind: 'annotation'; assetId: string; annotationId: string; annotationIds: string[]; edgeIds: string[] }
|
||
/** One or more links of a graph and nothing else, `edgeId` being the primary one (#289). */
|
||
| { kind: 'edge'; assetId: string; edgeId: string; edgeIds: string[] }
|
||
/**
|
||
* A row of an animation's timeline (the Animation tab), and the keys picked on it or on other
|
||
* rows: `keys` is empty when the row itself was clicked. `trackId` is the row the inspector
|
||
* describes – of several keys, the one picked last.
|
||
*/
|
||
| { kind: 'track'; assetId: string; trackId: string; keys: KeyRef[] }
|
||
/**
|
||
* A track of a song (the Song tab, #334) and the items picked on it or on others: `itemIds` is
|
||
* empty when the track itself was clicked. The inspector shows the item picked last, else the track.
|
||
*/
|
||
| { kind: 'song'; assetId: string; trackId: string; itemIds: string[] }
|
||
| null;
|
||
|
||
/**
|
||
* Where a list lives. Deliberately not a `Selection`: a list is not something one selects, it is a
|
||
* value inside something already selected, and exactly three places hold one. `path` leads to a
|
||
* list nested inside the value `key` names (#239): a key per struct field, an index per list item –
|
||
* `[3, 'drops']` is the list in field `drops` of item 3.
|
||
*/
|
||
export type ListSlot = { path?: ValuePath } & (
|
||
| { kind: 'asset'; assetId: string; key: string }
|
||
| { kind: 'object'; holderId: string; objectId: string; key: string }
|
||
| { kind: 'node'; assetId: string; nodeId: string; key: string }
|
||
);
|
||
|
||
/** Whose variables a Variables tab shows (#252): a behavior or Variables asset, or a placed behavior. */
|
||
export type VariablesOwner =
|
||
{ kind: 'asset'; assetId: string } | { kind: 'object'; holderId: string; objectId: string };
|
||
|
||
/** What a Variables tab shows: every variable of an owner, or one list at any depth. */
|
||
export type ValuesTarget = { kind: 'variables'; owner: VariablesOwner } | { kind: 'list'; slot: ListSlot };
|
||
|
||
/** Builds an object selection; the last id is the primary one unless given explicitly. */
|
||
export function objectSelection(holderId: string, objectIds: readonly string[], primary?: string): Selection {
|
||
const picked = pickIds(objectIds, primary);
|
||
return picked && { kind: 'object', holderId, objectId: picked.primary, objectIds: picked.ids };
|
||
}
|
||
|
||
/** Builds a node selection; the last id is the primary one unless given explicitly. */
|
||
export function nodeSelection(
|
||
assetId: string,
|
||
nodeIds: readonly string[],
|
||
primary?: string,
|
||
annotationIds: readonly string[] = [],
|
||
edgeIds: readonly string[] = [],
|
||
): Selection {
|
||
const picked = pickIds(nodeIds, primary);
|
||
return (
|
||
picked && {
|
||
kind: 'node',
|
||
assetId,
|
||
nodeId: picked.primary,
|
||
nodeIds: picked.ids,
|
||
annotationIds: [...new Set(annotationIds)],
|
||
edgeIds: [...new Set(edgeIds)],
|
||
}
|
||
);
|
||
}
|
||
|
||
/** Builds a shape selection; the last id is the primary one unless given explicitly. */
|
||
export function shapeSelection(assetId: string, shapeIds: readonly string[], primary?: string): Selection {
|
||
const picked = pickIds(shapeIds, primary);
|
||
return picked && { kind: 'shape', assetId, shapeId: picked.primary, shapeIds: picked.ids };
|
||
}
|
||
|
||
/** Builds an annotation selection; the last id is the primary one unless given explicitly. */
|
||
export function annotationSelection(
|
||
assetId: string,
|
||
annotationIds: readonly string[],
|
||
primary?: string,
|
||
edgeIds: readonly string[] = [],
|
||
): Selection {
|
||
const picked = pickIds(annotationIds, primary);
|
||
return (
|
||
picked && {
|
||
kind: 'annotation',
|
||
assetId,
|
||
annotationId: picked.primary,
|
||
annotationIds: picked.ids,
|
||
edgeIds: [...new Set(edgeIds)],
|
||
}
|
||
);
|
||
}
|
||
|
||
/** Builds a selection of links alone; the last id is the primary one unless given explicitly. */
|
||
export function edgeSelection(assetId: string, edgeIds: readonly string[], primary?: string): Selection {
|
||
const picked = pickIds(edgeIds, primary);
|
||
return picked && { kind: 'edge', assetId, edgeId: picked.primary, edgeIds: picked.ids };
|
||
}
|
||
|
||
/**
|
||
* What a rubber band leaves selected: nodes win the kind when it caught any, so the inspector keeps
|
||
* showing a node where it did before, then boxes, and a band over links alone still selects them.
|
||
* `primary` names the one picked last, in whichever of the three lists it is.
|
||
*/
|
||
export function graphSelection(
|
||
assetId: string,
|
||
nodeIds: readonly string[],
|
||
annotationIds: readonly string[],
|
||
edgeIds: readonly string[] = [],
|
||
primary?: string,
|
||
): Selection {
|
||
return (
|
||
nodeSelection(assetId, nodeIds, primary, annotationIds, edgeIds) ??
|
||
annotationSelection(assetId, annotationIds, primary, edgeIds) ??
|
||
edgeSelection(assetId, edgeIds, primary)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Builds a track selection. Without keys it is the row itself; with keys the row is the one of the
|
||
* key picked last, unless `trackId` says otherwise. Duplicate keys collapse to one.
|
||
*/
|
||
export function trackSelection(assetId: string, keys: readonly KeyRef[], trackId?: string): Selection {
|
||
const unique: KeyRef[] = [];
|
||
for (const key of keys) if (!hasKey(unique, key)) unique.push(key);
|
||
const last = unique[unique.length - 1];
|
||
const row = trackId ?? last?.trackId;
|
||
return row ? { kind: 'track', assetId, trackId: row, keys: unique } : null;
|
||
}
|
||
|
||
function pickIds(input: readonly string[], primary?: string): { ids: string[]; primary: string } | null {
|
||
const ids = [...new Set(input)];
|
||
if (ids.length === 0) return null;
|
||
return { ids, primary: primary && ids.includes(primary) ? primary : ids[ids.length - 1]! };
|
||
}
|
||
|
||
/**
|
||
* Select leaves the scene view as it is – the map is picked up and moved like any other object.
|
||
* The others paint: brush the chosen tile cell by cell, erase clears, rect fills the box a drag
|
||
* spans, fill the connected cells of one tile (#133). Pick paints nothing – it takes the tile of the
|
||
* cell clicked. Painting is a mode on purpose; a map one cannot move would be worse than a click.
|
||
*/
|
||
export type TilemapTool = 'select' | 'brush' | 'erase' | 'rect' | 'fill' | 'pick';
|
||
|
||
/**
|
||
* How the scene view shows the layers of the selected tilemap next to the one painted on (#138):
|
||
* all as they are, the others faded, or the painted one alone. A way of looking while painting,
|
||
* so it leaves the model – and with it the game – untouched.
|
||
*/
|
||
export type TilemapFocus = 'all' | 'dim' | 'solo';
|
||
|
||
export interface TilemapPaint {
|
||
tool: TilemapTool;
|
||
tileId: string;
|
||
layerId: string;
|
||
focus: TilemapFocus;
|
||
}
|
||
|
||
export type EditorMode = 'edit' | 'play';
|
||
export type SaveStatus = 'saved' | 'dirty' | 'saving' | 'error' | 'conflict';
|
||
|
||
export type EditorEvent =
|
||
/** The model changed (command, undo, preview, load). */
|
||
| 'project'
|
||
/** A different project was loaded. */
|
||
| 'load'
|
||
| 'selection'
|
||
/** A Variables tab was asked for (`openedValues`). */
|
||
| 'values'
|
||
/** An asset was opened (double-click, Enter, or just created): its tab should show. */
|
||
| 'open'
|
||
/** An asset should light up where it is listed (`pingedAssetId`), without being selected. */
|
||
| 'ping'
|
||
| 'mode'
|
||
| 'save'
|
||
| 'notice'
|
||
/** The eyedropper of an object's place started, moved its candidate or ended (`objectPick`). */
|
||
| 'pick'
|
||
/** The running world advanced a frame (play mode): values may have changed without a command. */
|
||
| 'runtime';
|
||
|
||
type Listener = () => void;
|
||
|
||
/** How a notice reaches the console panel: as a line, as a red line, or not at all. */
|
||
export type NoticeLevel = 'info' | 'error' | 'quiet';
|
||
|
||
/**
|
||
* Single source of truth for the editor. Play mode swaps `project` for a deep clone (same ids)
|
||
* with its own undo stack; panels and commands keep working on it, and `exitPlay()` discards it.
|
||
*/
|
||
export class EditorState {
|
||
project: Project = createEmptyProject('', 'Untitled');
|
||
revision = 0;
|
||
commands = new CommandStack(this.project);
|
||
selection: Selection = null;
|
||
/** The last target asked for by `openValues`; the tabs themselves are the editor's. */
|
||
openedValues: ValuesTarget | null = null;
|
||
mode: EditorMode = 'edit';
|
||
/**
|
||
* A sub-state of play rather than a third mode: the runtime copy, the keyboard and unsaved edits all
|
||
* still apply.
|
||
*/
|
||
paused = false;
|
||
saveStatus: SaveStatus = 'saved';
|
||
/** Last user-facing message (errors, hints). */
|
||
notice = '';
|
||
/** Keys copied in the Animation tab (#102). Lives for the session, across animations and projects. */
|
||
keyClipboard: KeyClipboard | null = null;
|
||
/** Objects or graph pieces copied with Ctrl+C (#240). Gone with the project, whose ids it holds. */
|
||
clipboard: EditorClipboard | null = null;
|
||
/**
|
||
* What the scene view paints a tilemap with (#128): the tool, the tile and the layer it goes
|
||
* on. Editor state, not model state – like the picked tile of the Animation tab, it is a way of
|
||
* working rather than something the project remembers.
|
||
*/
|
||
tilemapPaint: TilemapPaint = { tool: 'select', tileId: '', layerId: '', focus: 'all' };
|
||
/** The asset the last `pingAsset` pointed at. */
|
||
pingedAssetId: string | null = null;
|
||
/** 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). */
|
||
objectPick: ObjectPick | null = null;
|
||
/** How the last one ended – what a listener of the `pick` event finds once it is gone. */
|
||
pickEnd: PickEnd | null = null;
|
||
/** The real (saved) model and its undo stack while `project` is the play-mode copy. */
|
||
private edit: { project: Project; commands: CommandStack } | null = null;
|
||
|
||
private readonly listeners = new Map<EditorEvent, Set<Listener>>();
|
||
|
||
on(event: EditorEvent, listener: Listener): () => void {
|
||
let set = this.listeners.get(event);
|
||
if (!set) this.listeners.set(event, (set = new Set()));
|
||
set.add(listener);
|
||
return () => set.delete(listener);
|
||
}
|
||
|
||
emit(event: EditorEvent): void {
|
||
// The one choke point every model change passes: what a listener repaints is drawn even when
|
||
// it repaints without going through `invalidate()`.
|
||
requestRender();
|
||
for (const listener of this.listeners.get(event) ?? []) listener();
|
||
}
|
||
|
||
loadProject(project: Project, revision: number): void {
|
||
this.endObjectPick('drop');
|
||
this.edit = null;
|
||
this.mode = 'edit';
|
||
this.paused = false;
|
||
this.project = project;
|
||
this.revision = revision;
|
||
this.commands = new CommandStack(project);
|
||
this.selection = null;
|
||
this.openedValues = null;
|
||
this.clipboard = null;
|
||
this.saveStatus = 'saved';
|
||
this.emit('load');
|
||
this.emit('project');
|
||
this.emit('selection');
|
||
this.emit('save');
|
||
this.emit('mode');
|
||
}
|
||
|
||
/** Returns the runtime copy; the selection survives because ids are identical. */
|
||
enterPlay(): Project {
|
||
if (this.mode === 'play') return this.project;
|
||
this.endObjectPick('drop');
|
||
const copy = deserializeProject(serializeProject(this.project), this.project.id);
|
||
this.edit = { project: this.project, commands: this.commands };
|
||
this.project = copy;
|
||
this.commands = new CommandStack(copy);
|
||
this.mode = 'play';
|
||
this.paused = false;
|
||
this.emit('mode');
|
||
this.emit('project');
|
||
return copy;
|
||
}
|
||
|
||
/** Ignored outside play mode. */
|
||
setPaused(paused: boolean): void {
|
||
if (this.mode !== 'play' || this.paused === paused) return;
|
||
this.paused = paused;
|
||
this.emit('mode');
|
||
}
|
||
|
||
exitPlay(): void {
|
||
if (this.mode !== 'play') return;
|
||
this.endObjectPick('drop');
|
||
if (this.edit) {
|
||
this.project = this.edit.project;
|
||
this.commands = this.edit.commands;
|
||
this.edit = null;
|
||
}
|
||
this.mode = 'edit';
|
||
this.paused = false;
|
||
this.emit('mode');
|
||
this.emit('project');
|
||
this.validateSelection();
|
||
// A save postponed while playing runs now.
|
||
this.emit('save');
|
||
}
|
||
|
||
get activeScene(): SceneAsset | undefined {
|
||
return findScene(this.project, this.project.activeSceneId);
|
||
}
|
||
|
||
/**
|
||
* Which scene Play builds its world from. Two answers rather than one, because the project has
|
||
* two: where the game begins, and what you are working on this minute. The switch says which
|
||
* question Play is asking; without a start scene the two are the same thing anyway.
|
||
*/
|
||
sceneToPlay(): SceneAsset | undefined {
|
||
return this.playFrom === 'active' ? this.activeScene : startScene(this.project);
|
||
}
|
||
|
||
get playFrom(): PlayFrom {
|
||
return this.project.playFrom;
|
||
}
|
||
|
||
get gameScale(): GameScale {
|
||
return this.project.gameScale;
|
||
}
|
||
|
||
/**
|
||
* Saved with the project but not an undo step (#226): Ctrl+Z undoes edits, not a look at the game.
|
||
* While playing it goes into the saved model too, so stopping does not throw it away.
|
||
*/
|
||
setGameScale(scale: GameScale): void {
|
||
if (scale === this.gameScale) return;
|
||
this.project.gameScale = scale;
|
||
if (this.edit) this.edit.project.gameScale = scale;
|
||
this.setSaveStatus('dirty');
|
||
}
|
||
|
||
/** Shows or hides one part of a type in the scene view – saved, and like the game scale no undo step. */
|
||
setSceneVisibility(type: PlaceableAssetType, part: VisibilityPart, on: boolean): void {
|
||
if (this.project.sceneVisibility[type][part] === on) return;
|
||
this.project.sceneVisibility[type][part] = on;
|
||
if (this.edit) this.edit.project.sceneVisibility[type][part] = on;
|
||
this.setSaveStatus('dirty');
|
||
this.emit('project');
|
||
}
|
||
|
||
/**
|
||
* Runs a command through the undo stack. Returns false (and sets a notice) when invalid.
|
||
* In play mode the command changes the runtime copy only (see `enterPlay`).
|
||
*/
|
||
execute(command: Command): boolean {
|
||
return this.runEdit(() => {
|
||
this.commands.execute(command);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
undo(): void {
|
||
this.endObjectPick('drop');
|
||
this.runEdit(() => this.commands.undo());
|
||
}
|
||
|
||
redo(): void {
|
||
this.endObjectPick('drop');
|
||
this.runEdit(() => this.commands.redo());
|
||
}
|
||
|
||
/**
|
||
* Runs one history operation. A `CommandError` is a command rejecting the edit, so it becomes
|
||
* a notice; anything else is a real defect and keeps travelling.
|
||
*/
|
||
private runEdit(operation: () => boolean): boolean {
|
||
try {
|
||
if (!operation()) return false;
|
||
} catch (error) {
|
||
if (error instanceof CommandError) {
|
||
this.setNotice(error.message);
|
||
return false;
|
||
}
|
||
throw error;
|
||
}
|
||
this.markDirty();
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* The model was mutated directly (live preview while scrubbing). Notifies listeners
|
||
* but does not touch the undo stack or the save status – the committing command does.
|
||
*/
|
||
preview(): void {
|
||
this.emit('project');
|
||
}
|
||
|
||
/** The world ticked: panels showing live values refresh (no model event, nothing to save). */
|
||
runtimeTick(): void {
|
||
this.emit('runtime');
|
||
}
|
||
|
||
/**
|
||
* A behavior running outside play mode (see `EditorRun`) wrote into the saved model. The change
|
||
* is saved like any other, but nothing was pushed onto the undo stack – it cannot be undone.
|
||
*/
|
||
runtimeEdit(): void {
|
||
this.setSaveStatus('dirty');
|
||
this.emit('runtime');
|
||
}
|
||
|
||
private markDirty(): void {
|
||
this.validateSelection();
|
||
// Play-mode edits live in the runtime copy and are never saved.
|
||
if (this.mode === 'edit') this.saveStatus = 'dirty';
|
||
this.emit('project');
|
||
this.emit('save');
|
||
}
|
||
|
||
/**
|
||
* Asks for a Variables tab on `target` – a new one, or the one already showing it. No tab knows
|
||
* the `Editor`, so this is how the button on a list or a VARIABLES header gets there.
|
||
*/
|
||
openValues(target: ValuesTarget): void {
|
||
this.openedValues = target;
|
||
this.emit('values');
|
||
}
|
||
|
||
/**
|
||
* Selects the asset and asks for its tab. Selecting alone never switches tabs (a click in the
|
||
* Assets panel is a look at the inspector); this is the way a double-click or a fresh asset gets
|
||
* there, and like `openValues` it works without knowing the `Editor`.
|
||
*/
|
||
openAsset(assetId: string): void {
|
||
this.select({ kind: 'asset', assetId });
|
||
this.emit('open');
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
pingAsset(assetId: string): void {
|
||
this.pingedAssetId = assetId;
|
||
this.emit('ping');
|
||
}
|
||
|
||
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
|
||
if (selection?.kind === 'object' && !selection.objectIds)
|
||
selection = objectSelection(selection.holderId, [selection.objectId]);
|
||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||
if (selection?.kind === 'shape' && !selection.shapeIds)
|
||
selection = shapeSelection(selection.assetId, [selection.shapeId]);
|
||
// A graph selection carries two lists, and a short form may leave out either – `{ nodeId }`
|
||
// alone, or `{ nodeIds }` without the boxes. Filling both here keeps every reader of them
|
||
// from having to ask whether they are there.
|
||
if (selection?.kind === 'node')
|
||
selection = nodeSelection(
|
||
selection.assetId,
|
||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||
selection.nodeIds ?? [selection.nodeId],
|
||
selection.nodeId,
|
||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||
selection.annotationIds ?? [],
|
||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||
selection.edgeIds ?? [],
|
||
);
|
||
if (selection?.kind === 'annotation')
|
||
selection = annotationSelection(
|
||
selection.assetId,
|
||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||
selection.annotationIds ?? [selection.annotationId],
|
||
selection.annotationId,
|
||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||
selection.edgeIds ?? [],
|
||
);
|
||
if (selection?.kind === 'edge')
|
||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||
selection = edgeSelection(selection.assetId, selection.edgeIds ?? [selection.edgeId], selection.edgeId);
|
||
// The short form `{ trackId }` names a row without keys.
|
||
if (selection?.kind === 'track')
|
||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||
selection = trackSelection(selection.assetId, selection.keys ?? [], selection.trackId);
|
||
if (sameSelection(this.selection, selection)) return;
|
||
this.endObjectPick('drop');
|
||
this.selection = selection;
|
||
this.emit('selection');
|
||
}
|
||
|
||
/** Starts the eyedropper of an object's place (#276); one running already is dropped for it. */
|
||
startObjectPick(pick: ObjectPick): void {
|
||
const before = this.objectPick;
|
||
this.objectPick = pick;
|
||
this.pickEnd = null;
|
||
before?.ended();
|
||
this.emit('pick');
|
||
}
|
||
|
||
/** Moves the eyedropper's candidate; nothing is written until `endObjectPick('take')`. */
|
||
movePick(candidate: string | null): void {
|
||
const pick = this.objectPick;
|
||
if (!pick || pick.candidate === candidate) return;
|
||
pick.candidate = candidate;
|
||
this.emit('pick');
|
||
}
|
||
|
||
/**
|
||
* Ends the eyedropper. Only `take` writes, and only a candidate the dropdown still offers and that
|
||
* is not the target already – a pick that changes nothing leaves no undo step.
|
||
*/
|
||
endObjectPick(how: PickEnd): void {
|
||
const pick = this.objectPick;
|
||
if (!pick) return;
|
||
this.objectPick = null;
|
||
this.pickEnd = how;
|
||
pick.ended();
|
||
this.emit('pick');
|
||
if (how !== 'take') return;
|
||
const ref = pick.choices().find((c) => c.objectId === pick.candidate)?.ref;
|
||
if (ref !== undefined && ref !== pick.current) pick.take(ref);
|
||
}
|
||
|
||
toggleObject(holderId: string, objectId: string): void {
|
||
const s = this.selection;
|
||
const current = s?.kind === 'object' && s.holderId === holderId ? s.objectIds : [];
|
||
const next = current.includes(objectId)
|
||
? current.filter((id) => id !== objectId)
|
||
: [...current, objectId];
|
||
this.select(objectSelection(holderId, next, current.includes(objectId) ? undefined : objectId));
|
||
}
|
||
|
||
/** Ctrl+click on a shape: in or out of the selection; emptied, the drawing itself stays selected. */
|
||
toggleShape(assetId: string, shapeId: string): void {
|
||
const s = this.selection;
|
||
const current = s?.kind === 'shape' && s.assetId === assetId ? s.shapeIds : [];
|
||
const held = current.includes(shapeId);
|
||
const next = held ? current.filter((id) => id !== shapeId) : [...current, shapeId];
|
||
this.select(shapeSelection(assetId, next, held ? undefined : shapeId) ?? { kind: 'asset', assetId });
|
||
}
|
||
|
||
get selectedShapeIds(): string[] {
|
||
return this.selection?.kind === 'shape' ? this.selection.shapeIds : [];
|
||
}
|
||
|
||
toggleNode(assetId: string, nodeId: string): void {
|
||
const s = this.selection;
|
||
const current = s?.kind === 'node' && s.assetId === assetId ? s.nodeIds : [];
|
||
const held = current.includes(nodeId);
|
||
const next = held ? current.filter((id) => id !== nodeId) : [...current, nodeId];
|
||
// Boxes and links caught alongside stay held: the toggle speaks about this node, not about them.
|
||
const mine = s?.kind === 'node' && s.assetId === assetId;
|
||
const boxes = mine ? s.annotationIds : [];
|
||
const edges = this.graphHolds(assetId) ? this.selectedEdgeIds : [];
|
||
// Emptied by the toggle, the behavior itself stays selected – the graph must not close.
|
||
this.select(
|
||
graphSelection(assetId, next, boxes, edges, held ? undefined : nodeId) ?? { kind: 'asset', assetId },
|
||
);
|
||
}
|
||
|
||
/** Ctrl+click on a link (#289): in or out of the selection, whatever else of the graph is held. */
|
||
toggleEdge(assetId: string, edgeId: string): void {
|
||
const mine = this.graphHolds(assetId);
|
||
const current = mine ? this.selectedEdgeIds : [];
|
||
const held = current.includes(edgeId);
|
||
const next = held ? current.filter((id) => id !== edgeId) : [...current, edgeId];
|
||
const s = this.selection;
|
||
// A node or box already shown in the inspector stays the primary one.
|
||
const primary = s?.kind === 'node' ? s.nodeId : s?.kind === 'annotation' ? s.annotationId : undefined;
|
||
this.select(
|
||
graphSelection(
|
||
assetId,
|
||
mine ? this.selectedNodeIds : [],
|
||
mine ? this.selectedAnnotationIds : [],
|
||
next,
|
||
mine ? (primary ?? (held ? undefined : edgeId)) : edgeId,
|
||
) ?? { kind: 'asset', assetId },
|
||
);
|
||
}
|
||
|
||
/** Whether the selection is a node, box or link of this graph. */
|
||
private graphHolds(assetId: string): boolean {
|
||
const s = this.selection;
|
||
return (s?.kind === 'node' || s?.kind === 'annotation' || s?.kind === 'edge') && s.assetId === assetId;
|
||
}
|
||
|
||
get selectedObjectIds(): string[] {
|
||
return this.selection?.kind === 'object' ? this.selection.objectIds : [];
|
||
}
|
||
|
||
get selectedNodeIds(): string[] {
|
||
return this.selection?.kind === 'node' ? this.selection.nodeIds : [];
|
||
}
|
||
|
||
/** The groups and comments selected, whether alongside nodes or on their own. */
|
||
get selectedAnnotationIds(): string[] {
|
||
const s = this.selection;
|
||
if (s?.kind === 'node') return s.annotationIds;
|
||
return s?.kind === 'annotation' ? s.annotationIds : [];
|
||
}
|
||
|
||
/** The links selected, whether alongside nodes and boxes or on their own. */
|
||
get selectedEdgeIds(): string[] {
|
||
const s = this.selection;
|
||
return s?.kind === 'node' || s?.kind === 'annotation' || s?.kind === 'edge' ? s.edgeIds : [];
|
||
}
|
||
|
||
/** Drops selected targets that no longer exist (after delete/undo). */
|
||
private validateSelection(): void {
|
||
const s = this.selection;
|
||
if (!s) return;
|
||
if (s.kind === 'asset' && !findAsset(this.project, s.assetId)) this.select(null);
|
||
if (s.kind === 'object') {
|
||
const holder = findObjectHolder(this.project, s.holderId);
|
||
const view = holder && resolvedHolder(this.project, holder);
|
||
const alive = view ? s.objectIds.filter((id) => findObject(view, id)) : [];
|
||
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.holderId, alive, s.objectId));
|
||
}
|
||
if (s.kind === 'node') {
|
||
const asset = findGraphAsset(this.project, s.assetId);
|
||
// Fall back to the asset so the graph stays open when its node vanished (undo).
|
||
if (!asset) this.select(null);
|
||
else {
|
||
const alive = s.nodeIds.filter((id) => asset.graph.nodes.some((n) => n.id === id));
|
||
const boxes = s.annotationIds.filter((id) => findAnnotation(asset.graph, id));
|
||
const edges = liveEdges(asset.graph, s.edgeIds);
|
||
if (
|
||
alive.length !== s.nodeIds.length ||
|
||
boxes.length !== s.annotationIds.length ||
|
||
edges.length !== s.edgeIds.length
|
||
)
|
||
this.select(
|
||
graphSelection(
|
||
s.assetId,
|
||
alive,
|
||
boxes,
|
||
edges,
|
||
alive.includes(s.nodeId) ? s.nodeId : undefined,
|
||
) ?? {
|
||
kind: 'asset',
|
||
assetId: s.assetId,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
if (s.kind === 'edge') {
|
||
const asset = findGraphAsset(this.project, s.assetId);
|
||
// Same fallback as a node: the graph stays open when the link it held vanished (undo).
|
||
if (!asset) this.select(null);
|
||
else {
|
||
const alive = liveEdges(asset.graph, s.edgeIds);
|
||
if (alive.length !== s.edgeIds.length)
|
||
this.select(edgeSelection(s.assetId, alive, s.edgeId) ?? { kind: 'asset', assetId: s.assetId });
|
||
}
|
||
}
|
||
if (s.kind === 'annotation') {
|
||
const asset = findGraphAsset(this.project, s.assetId);
|
||
// Same fallback as a node: the graph stays open when the box it held vanished (undo).
|
||
if (!asset) this.select(null);
|
||
else {
|
||
const alive = s.annotationIds.filter((id) => findAnnotation(asset.graph, id));
|
||
const edges = liveEdges(asset.graph, s.edgeIds);
|
||
if (alive.length !== s.annotationIds.length || edges.length !== s.edgeIds.length)
|
||
this.select(
|
||
annotationSelection(s.assetId, alive, s.annotationId, edges) ??
|
||
edgeSelection(s.assetId, edges) ?? { kind: 'asset', assetId: s.assetId },
|
||
);
|
||
}
|
||
}
|
||
if (s.kind === 'frame') {
|
||
const asset = findAssetOf(this.project, s.assetId, 'texture');
|
||
// Same fallback as a graph node: the texture stays open when its frame vanished.
|
||
if (!asset) this.select(null);
|
||
else if (!asset.frames.some((f) => f.id === s.frameId))
|
||
this.select({ kind: 'asset', assetId: s.assetId });
|
||
}
|
||
if (s.kind === 'shape') {
|
||
const asset = findAssetOf(this.project, s.assetId, 'vectorart');
|
||
// As with a frame: the drawing stays open when its shapes vanished (a combine, an undo).
|
||
if (!asset) this.select(null);
|
||
else {
|
||
const shapes = allShapes(asset);
|
||
const alive = s.shapeIds.filter((id) => shapes.some((shape) => shape.id === id));
|
||
if (alive.length !== s.shapeIds.length)
|
||
this.select(shapeSelection(s.assetId, alive, s.shapeId) ?? { kind: 'asset', assetId: s.assetId });
|
||
}
|
||
}
|
||
if (s.kind === 'track') {
|
||
const asset = findAssetOf(this.project, s.assetId, 'animation');
|
||
if (!asset) this.select(null);
|
||
else {
|
||
// A key that moved or died leaves the selection; a row that died takes its keys with it.
|
||
const alive = s.keys.filter((key) => findKey(asset, key));
|
||
const rowAlive = findTrack(asset, s.trackId) !== undefined;
|
||
if (alive.length !== s.keys.length || !rowAlive)
|
||
this.select(
|
||
trackSelection(s.assetId, alive, rowAlive ? s.trackId : undefined) ?? {
|
||
kind: 'asset',
|
||
assetId: s.assetId,
|
||
},
|
||
);
|
||
}
|
||
}
|
||
if (s.kind === 'song') {
|
||
const asset = findAssetOf(this.project, s.assetId, 'song');
|
||
if (!asset) this.select(null);
|
||
else {
|
||
// An item that died leaves the selection; a track that died hands it to the song.
|
||
const items = s.itemIds.filter((id) => asset.items.some((item) => item.id === id));
|
||
const trackAlive = asset.tracks.some((track) => track.id === s.trackId);
|
||
if (!trackAlive) this.select({ kind: 'asset', assetId: s.assetId });
|
||
else if (items.length !== s.itemIds.length) this.select({ ...s, itemIds: items });
|
||
}
|
||
}
|
||
}
|
||
|
||
/** The song the selection refers to (#334): the asset, or a track or items of it. */
|
||
get selectedSongId(): string | null {
|
||
const s = this.selection;
|
||
if (s?.kind !== 'asset' && s?.kind !== 'song') return null;
|
||
return findAssetOf(this.project, s.assetId, 'song')?.id ?? null;
|
||
}
|
||
|
||
/** The texture asset the current selection refers to (the asset itself or one of its frames). */
|
||
get selectedTextureId(): string | null {
|
||
const s = this.selection;
|
||
if (!s) return null;
|
||
if (s.kind !== 'asset' && s.kind !== 'frame') return null;
|
||
return findAssetOf(this.project, s.assetId, 'texture')?.id ?? null;
|
||
}
|
||
|
||
/** The animation the selection refers to: the asset, or a row or key of its timeline. */
|
||
get selectedAnimationId(): string | null {
|
||
const s = this.selection;
|
||
if (s?.kind !== 'asset' && s?.kind !== 'track') return null;
|
||
return findAssetOf(this.project, s.assetId, 'animation')?.id ?? null;
|
||
}
|
||
|
||
/** The vector drawing the selection is about – the asset itself or one shape of it (#129). */
|
||
get selectedVectorArtId(): string | null {
|
||
const s = this.selection;
|
||
if (s?.kind !== 'asset' && s?.kind !== 'shape') return null;
|
||
return findAssetOf(this.project, s.assetId, 'vectorart')?.id ?? null;
|
||
}
|
||
|
||
/** The tile the selection refers to; only the asset itself, it has nothing inside to pick. */
|
||
get selectedTileId(): string | null {
|
||
const s = this.selection;
|
||
if (s?.kind !== 'asset') return null;
|
||
return findAssetOf(this.project, s.assetId, 'tile')?.id ?? null;
|
||
}
|
||
|
||
/** The curve the selection refers to (#247); only the asset itself, its points are picked in the tab. */
|
||
get selectedCurveId(): string | null {
|
||
const s = this.selection;
|
||
if (s?.kind !== 'asset') return null;
|
||
return findAssetOf(this.project, s.assetId, 'curve')?.id ?? null;
|
||
}
|
||
|
||
/** The pattern the selection refers to (#333); only the asset itself, its notes are picked in the tab. */
|
||
get selectedPatternId(): string | null {
|
||
const s = this.selection;
|
||
if (s?.kind !== 'asset') return null;
|
||
return findAssetOf(this.project, s.assetId, 'pattern')?.id ?? null;
|
||
}
|
||
|
||
/** The timeline row the selection is about; undefined when it is about something else. */
|
||
get selectedTrackId(): string | undefined {
|
||
return this.selection?.kind === 'track' ? this.selection.trackId : undefined;
|
||
}
|
||
|
||
/** The keys picked in the Animation tab, none when the selection is about something else. */
|
||
get selectedKeys(): KeyRef[] {
|
||
return this.selection?.kind === 'track' ? this.selection.keys : [];
|
||
}
|
||
|
||
/** The behavior asset the current selection refers to (asset, placed object or node), if any. */
|
||
get selectedBehaviorId(): string | null {
|
||
return this.selectedGraphId('behavior');
|
||
}
|
||
|
||
/** The state machine the current selection refers to: the asset, or one of its states. */
|
||
get selectedStateMachineId(): string | null {
|
||
return this.selectedGraphId('statemachine');
|
||
}
|
||
|
||
/** The shader or shader function the current selection refers to: the asset, or one of its nodes. */
|
||
get selectedShaderId(): string | null {
|
||
return this.selectedGraphId('shader') ?? this.selectedGraphId('shaderfunction');
|
||
}
|
||
|
||
/** The particle asset the selection refers to: the asset, a placed source, or one of its nodes. */
|
||
get selectedParticlesId(): string | null {
|
||
return this.selectedGraphId('particles');
|
||
}
|
||
|
||
/** The sound FX asset the selection refers to: the asset, or one of its nodes. */
|
||
get selectedSoundFxId(): string | null {
|
||
return this.selectedGraphId('soundfx');
|
||
}
|
||
|
||
/** The instrument the selection refers to: the asset, or one of its nodes. */
|
||
get selectedInstrumentId(): string | null {
|
||
return this.selectedGraphId('instrument');
|
||
}
|
||
|
||
private selectedGraphId(type: GraphAsset['type']): string | null {
|
||
const s = this.selection;
|
||
if (!s) return null;
|
||
if (s.kind === 'frame') return null;
|
||
// A node names its asset; which tab that is depends on what the asset is. A group, a comment
|
||
// and a link name it the same way, so selecting one keeps its graph on screen.
|
||
const asset =
|
||
s.kind === 'node' || s.kind === 'annotation' || s.kind === 'edge'
|
||
? findAsset(this.project, s.assetId)
|
||
: this.assetBehind(s);
|
||
return asset && asset.type === type ? asset.id : null;
|
||
}
|
||
|
||
/**
|
||
* The container the current selection refers to: the asset itself, a placed container, or the
|
||
* template being edited – selecting one of its objects keeps the Container tab on it.
|
||
*/
|
||
get selectedContainerId(): string | null {
|
||
const s = this.selection;
|
||
if (!s) return null;
|
||
if (s.kind === 'object' && findAsset(this.project, s.holderId)?.type === 'container') return s.holderId;
|
||
const asset = this.assetBehind(s);
|
||
return asset && asset.type === 'container' ? asset.id : null;
|
||
}
|
||
|
||
/** The asset a selection stands for: itself, or the one a selected object was placed from. */
|
||
private assetBehind(s: NonNullable<Selection>): Asset | undefined {
|
||
if (s.kind === 'asset') return findAsset(this.project, s.assetId);
|
||
if (s.kind !== 'object') return undefined;
|
||
const holder = findObjectHolder(this.project, s.holderId);
|
||
const view = holder && resolvedHolder(this.project, holder);
|
||
const assetId = view && findObject(view, s.objectId)?.assetId;
|
||
return assetId ? findAsset(this.project, assetId) : undefined;
|
||
}
|
||
|
||
setSaveStatus(status: SaveStatus): void {
|
||
if (this.saveStatus === status) return;
|
||
this.saveStatus = status;
|
||
this.emit('save');
|
||
}
|
||
|
||
/**
|
||
* Shows `message` in the toolbar and, unless `quiet`, keeps it in the console panel. Quiet is
|
||
* for the play status lines, which state a mode rather than report an event.
|
||
*/
|
||
/** Changes what the scene view paints with; the inspector marks it and the view repaints. */
|
||
setTilemapPaint(patch: Partial<TilemapPaint>): void {
|
||
this.tilemapPaint = { ...this.tilemapPaint, ...patch };
|
||
this.emit('selection');
|
||
}
|
||
|
||
setNotice(message: string, level: NoticeLevel = 'info'): void {
|
||
this.notice = message;
|
||
if (message !== '' && level !== 'quiet') consoleLog.push(level, message);
|
||
this.emit('notice');
|
||
}
|
||
}
|
||
|
||
/** Whether two selections mean the same thing (same kind, same ids, same order). */
|
||
export function sameSelection(a: Selection, b: Selection): boolean {
|
||
if (a === b) return true;
|
||
if (!a || !b || a.kind !== b.kind) return false;
|
||
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
|
||
if (a.kind === 'object' && b.kind === 'object') {
|
||
return (
|
||
a.holderId === b.holderId &&
|
||
a.objectId === b.objectId &&
|
||
a.objectIds.length === b.objectIds.length &&
|
||
a.objectIds.every((id, i) => id === b.objectIds[i])
|
||
);
|
||
}
|
||
if (a.kind === 'node' && b.kind === 'node') {
|
||
return (
|
||
a.assetId === b.assetId &&
|
||
a.nodeId === b.nodeId &&
|
||
sameIds(a.nodeIds, b.nodeIds) &&
|
||
sameIds(a.annotationIds, b.annotationIds) &&
|
||
sameIds(a.edgeIds, b.edgeIds)
|
||
);
|
||
}
|
||
if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId;
|
||
if (a.kind === 'shape' && b.kind === 'shape')
|
||
return a.assetId === b.assetId && a.shapeId === b.shapeId && sameIds(a.shapeIds, b.shapeIds);
|
||
if (a.kind === 'track' && b.kind === 'track')
|
||
return (
|
||
a.assetId === b.assetId &&
|
||
a.trackId === b.trackId &&
|
||
a.keys.length === b.keys.length &&
|
||
a.keys.every((key, i) => sameKey(key, b.keys[i]!))
|
||
);
|
||
if (a.kind === 'annotation' && b.kind === 'annotation')
|
||
return (
|
||
a.assetId === b.assetId &&
|
||
a.annotationId === b.annotationId &&
|
||
sameIds(a.annotationIds, b.annotationIds) &&
|
||
sameIds(a.edgeIds, b.edgeIds)
|
||
);
|
||
if (a.kind === 'edge' && b.kind === 'edge')
|
||
return a.assetId === b.assetId && a.edgeId === b.edgeId && sameIds(a.edgeIds, b.edgeIds);
|
||
if (a.kind === 'song' && b.kind === 'song')
|
||
return a.assetId === b.assetId && a.trackId === b.trackId && sameIds(a.itemIds, b.itemIds);
|
||
return false;
|
||
}
|
||
|
||
/** Same ids in the same order – the order carries which one was picked last. */
|
||
function sameIds(a: readonly string[], b: readonly string[]): boolean {
|
||
return a.length === b.length && a.every((id, i) => id === b[i]);
|
||
}
|
||
|
||
/** The ids that still name a link of the graph. */
|
||
function liveEdges(graph: Graph, ids: readonly string[]): string[] {
|
||
return ids.filter((id) => graph.edges.some((e) => e.id === id));
|
||
}
|