Files
feuerwerk/src/editor/actions.ts
T
adminandClaude Opus 5.5 2e4c0a09ea
feuerwerk / gate (push) Successful in 4m58s
feuerwerk / suite (push) Failing after 19m37s
Open clips and frames from the asset panel by double click (#430)
A double click on a clip row did not open its tab. The first click of
the double click had already selected the clip for the inspector, so
the second click selected the same thing again and the editor saw no
change. Frames of textures and pixel art had no rows at all.

- A double click now asks for the tab with openSelection, even when
  the selection is already right.
- Textures with frames and pixel art with more than one frame get a
  chevron and one row per frame, like sounds and videos. Which rows an
  asset has is decided in one place, src/core/model/assetParts.ts,
  used by the panel and the inspector link.
- A texture frame shows in the inspector on click and opens the
  texture with the frame selected on double click.
- A pixel art frame selects the pixel art on click, so an open tab
  paints on that frame. A double click opens the tab on the frame.
- A frame can be dragged onto a composition track like a video clip.
  The item carries the sprite of the whole image, the frame becomes
  the texture of its object, and it is 3 s long. The agent can do the
  same with add_composition_item.
- A texture label that names a frame now points at the frame row.

Found on the way: the e2e check from #425 only double clicked the
texture label, where the first click is a ping, so the bug never
showed there.

Tests:
- Unit: asset-parts (new, 6), composition-sprites and references
  extended, related tests 303/303, agent tests 21/21
- 5 mutations, all red in the matching test
- e2e 69 (atx): ALL PASSED, 26 checks, 69t new
- Gate (atx): CHECK EXIT 0, 325 files, 5017 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:30:29 +02:00

364 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Editor actions that several places trigger the same way (inspector, hierarchy context
* menu, keyboard): they run the command and put the selection where the user expects it.
*/
import { RemoveAnimationKeysCommand, RemoveAnimationTracksCommand } from '../core/commands/animationCommands';
import { annotationNoun, RemoveAnnotationCommand } from '../core/commands/annotationCommands';
import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands';
import {
AddColliderCommand,
AddJointCommand,
AddRigidbodyCommand,
colliderRefusal,
jointRefusal,
rigidbodyRefusal,
} from '../core/commands/colliderCommands';
import { oneStep } from '../core/commands/CompoundCommand';
import { CreateContainerCommand } from '../core/commands/containerCommands';
import { removeCompositionTrack, removeFromComposition } from '../core/commands/compositionCommands';
import { RemoveClipCommand } from '../core/commands/clipCommands';
import { RemoveFrameCommand } from '../core/commands/frameCommands';
import { DisconnectCommand, FoldEdgeCommand, RemoveNodeCommand } from '../core/commands/graphCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
import { RemoveVectorShapeCommand, RemoveVectorShapesCommand } from '../core/commands/vectorArtCommands';
import type { Asset } from '../core/model/Asset';
import { partOwner } from '../core/model/assetParts';
import type { AssetType } from '../core/model/AssetTypes';
import { curveUsages } from '../core/model/curve';
import { structTypeUsages } from '../core/model/structTypes';
import { generatesPictures } from '../core/model/generated';
import { isTemplateContent } from '../core/model/instance';
import type { Command } from '../core/commands/Command';
import { findAnnotation, findNode, nodeDef } from '../core/graph';
import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project';
import { ownerOf } from '../core/music/embedded';
import type { SceneObject } from '../core/model/SceneObject';
import { pixelArtRefIds } from '../core/model/pixelart';
import { textureRefIds } from '../core/model/texture';
import { videoRefIds } from '../core/model/video';
import {
animationsUsing,
cellsPainted,
tilesUsing,
findPropertyUsages,
findUsages,
} from '../core/model/usages';
import { Dialog } from '../ui';
import { removeSongItems, removeTrack } from './song/songActions';
import { type EditorState, graphSelection, objectSelection, type Selection } from './state';
/**
* What a double-click on an asset does, wherever it happens (asset panel, "Open …" entries, a link
* in the inspector): open its tab – and a scene, whose tab is the scene view, becomes the active one.
*/
export function activateAsset(state: EditorState, assetId: string): void {
if (findAsset(state.project, assetId)?.type === 'scene') state.execute(new SetActiveSceneCommand(assetId));
state.openAsset(assetId);
}
/**
* The same for an id that may name a part of an asset – a clip (#425), a frame (#430): its tab opens
* with the part picked, also when one click on its row has picked it already.
*/
export function activateAssetOrPart(state: EditorState, id: string): void {
const owner = partOwner(state.project, id);
if (!owner) {
activateAsset(state, id);
return;
}
if (owner.type === 'pixelart') state.showPixelFrame(owner.id, id);
state.openSelection(partSelection(owner, id));
}
/** One click on a part's row: a look at the inspector, its tab left as it is (#425). */
export function glancePart(state: EditorState, owner: Asset, id: string): void {
if (owner.type === 'pixelart') state.showPixelFrame(owner.id, id);
state.glance(partSelection(owner, id));
}
/** A frame of pixel art is painted on rather than picked (#430), so it selects its asset. */
function partSelection(owner: Asset, id: string): Selection {
if (owner.type === 'texture') return { kind: 'frame', assetId: owner.id, frameId: id };
if (owner.type === 'pixelart') return { kind: 'asset', assetId: owner.id };
return { kind: 'clip', assetId: owner.id, clipId: 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);
if (!state.execute(command)) return false;
state.select(objectSelection(holderId, [command.objectId]));
return true;
}
/** The test `AddColliderCommand` makes; template content has no structure of its own to change. */
export function canAddCollider(project: Project, holder: ObjectHolder, object: SceneObject): boolean {
return !isTemplateContent(holder, object.id) && colliderRefusal(project, holder, object) === null;
}
/**
* Gives the object a collider child – a new one sized like it, or the existing `assetId` (#222);
* selects the collider.
*/
export function addCollider(state: EditorState, holderId: string, objectId: string, assetId = ''): boolean {
const command = new AddColliderCommand(holderId, objectId, assetId);
if (!state.execute(command)) return false;
state.select(objectSelection(holderId, [command.objectId]));
return true;
}
/** The test `AddRigidbodyCommand` makes; template content has no structure of its own to change. */
export function canAddRigidbody(project: Project, holder: ObjectHolder, object: SceneObject): boolean {
return !isTemplateContent(holder, object.id) && rigidbodyRefusal(project, holder, object) === null;
}
/** Gives the object a rigidbody child, new or the existing `assetId`; selects the rigidbody. */
export function addRigidbody(state: EditorState, holderId: string, objectId: string, assetId = ''): boolean {
const command = new AddRigidbodyCommand(holderId, objectId, assetId);
if (!state.execute(command)) return false;
state.select(objectSelection(holderId, [command.objectId]));
return true;
}
/** The test `AddJointCommand` makes; template content has no structure of its own to change. */
export function canAddJoint(project: Project, holder: ObjectHolder, object: SceneObject): boolean {
return !isTemplateContent(holder, object.id) && jointRefusal(project, holder, object) === null;
}
/** Gives the object a joint child (#154), new or the existing `assetId`; selects the joint. */
export function addJoint(state: EditorState, holderId: string, objectId: string, assetId = ''): boolean {
const command = new AddJointCommand(holderId, objectId, assetId);
if (!state.execute(command)) return false;
state.select(objectSelection(holderId, [command.objectId]));
return true;
}
/** Removes the objects (with their subtrees) as one undo step. */
export function removeObjects(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
// An object of a composition is an item's (#417): the item goes with it.
if (findAsset(state.project, holderId)?.type === 'composition')
return state.execute(removeFromComposition(state.project, holderId, objectIds, []));
return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds));
}
/** Takes shapes of a vector art out as one undo step; the drawing itself stays selected. */
export function removeShapes(state: EditorState, assetId: string, shapeIds: readonly string[]): boolean {
if (shapeIds.length === 0) return false;
const removed = state.execute(
shapeIds.length === 1
? new RemoveVectorShapeCommand(assetId, shapeIds[0]!)
: new RemoveVectorShapesCommand(assetId, shapeIds),
);
if (removed) state.select({ kind: 'asset', assetId });
return removed;
}
/** Deletes graph nodes as one undo step, however many were selected. */
export function removeNodes(state: EditorState, assetId: string, nodeIds: readonly string[]): boolean {
const commands = nodeIds.map((id) => new RemoveNodeCommand(assetId, id));
return commands.length > 0 && state.execute(oneStep(`Delete ${commands.length} nodes`, commands));
}
/** What a link is called in this graph: between states it is a transition. */
export function linkNoun(state: EditorState, assetId: string): string {
return findGraphAsset(state.project, assetId)?.type === 'statemachine' ? 'transition' : 'link';
}
/**
* Hides the links as one undo step and lets go of them: a hidden link has no line left to be
* picked on, its mark at the port is the handle from then on.
*/
export function hideLinks(state: EditorState, assetId: string, edgeIds: readonly string[]): boolean {
const commands = edgeIds.map((id) => new FoldEdgeCommand(assetId, id, true));
if (
commands.length === 0 ||
!state.execute(oneStep(commands.length === 1 ? 'Hide link' : 'Hide links', commands))
)
return false;
state.select(
graphSelection(assetId, state.selectedNodeIds, state.selectedAnnotationIds) ?? { kind: 'asset', assetId },
);
return true;
}
/**
* Deletes everything a graph selection holds – nodes, boxes and links alike – as one undo step,
* since one band caught them and one keypress asked for them to go. Boxes still take none of
* their nodes. Links go first, so one at a node that goes too is not asked for twice.
*/
export function removeGraphSelection(
state: EditorState,
assetId: string,
nodeIds: readonly string[],
annotationIds: readonly string[],
edgeIds: readonly string[] = [],
): boolean {
const graph = findGraphAsset(state.project, assetId)?.graph;
const links = (graph?.edges ?? []).filter((e) => edgeIds.includes(e.id));
// A node that comes and goes on its own – a track of a song (#350) – stays, the rest goes.
const removable = nodeIds.filter((id) => !nodeDef((graph && findNode(graph, id))?.type ?? '')?.fixed);
const commands: Command[] = [
...links.map((e) => new DisconnectCommand(assetId, e.id)),
...removable.map((id) => new RemoveNodeCommand(assetId, id)),
];
let noun = links.length === 1 ? linkNoun(state, assetId) : '';
for (const id of annotationIds) {
const found = graph && findAnnotation(graph, id);
if (!found) continue;
noun = annotationNoun(found.kind);
commands.push(new RemoveAnnotationCommand(assetId, found.kind, id));
}
if (commands.length === 0) {
if (nodeIds.length > 0) state.setNotice('These nodes come and go on their own and cannot be deleted');
return false;
}
const label =
commands.length === 1 && noun !== ''
? `Delete ${noun}`
: removable.length === commands.length
? `Delete ${commands.length} nodes`
: `Delete ${commands.length} items`;
return state.execute(oneStep(label, commands));
}
/**
* What Delete does to the selection; false when nothing is selected. Every kind is a case of its
* own, so a new kind of selection cannot slip through without saying what Delete takes away.
*/
export function removeSelection(state: EditorState): boolean {
const s = state.selection;
if (!s) return false;
switch (s.kind) {
case 'asset':
confirmDeleteAsset(state, s.assetId);
return true;
case 'object':
removeObjects(state, s.holderId, s.objectIds);
return true;
case 'node':
removeGraphSelection(state, s.assetId, s.nodeIds, s.annotationIds, s.edgeIds);
return true;
case 'annotation':
removeGraphSelection(state, s.assetId, [], s.annotationIds, s.edgeIds);
return true;
case 'edge':
removeGraphSelection(state, s.assetId, [], [], s.edgeIds);
return true;
case 'frame':
state.execute(new RemoveFrameCommand(s.assetId, s.frameId));
return true;
case 'clip':
state.execute(new RemoveClipCommand(s.assetId, s.clipId));
return true;
case 'shape':
removeShapes(state, s.assetId, s.shapeIds);
return true;
case 'track':
// The keys when some are picked, else the row itself – like a frame of a texture.
state.execute(
s.keys.length > 0
? new RemoveAnimationKeysCommand(s.assetId, s.keys)
: new RemoveAnimationTracksCommand(s.assetId, [s.trackId]),
);
return true;
case 'song':
// The items when some are picked, else the track itself and what lies on it.
if (s.itemIds.length > 0) removeSongItems(state, s.assetId, s.itemIds);
else removeTrack(state, s.assetId, s.trackId);
return true;
case 'composition':
// The items or clips when some are picked, else the track and what lies on it.
if (
state.execute(
s.clipIds.length > 0 || (s.itemIds ?? []).length > 0
? removeFromComposition(state.project, s.assetId, s.itemIds ?? [], s.clipIds)
: removeCompositionTrack(state.project, s.assetId, s.trackId),
)
)
state.select({ kind: 'asset', assetId: s.assetId });
return true;
}
}
/** Asks before deleting an asset, spelling out what goes with it. */
export function confirmDeleteAsset(state: EditorState, assetId: string): void {
const question = deleteQuestion(state.project, assetId);
if (!question) return;
if ('refusal' in question) {
state.setNotice(question.refusal);
return;
}
new Dialog(question.title, question.detail, [
{ label: 'Cancel' },
{ label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(assetId)) },
]).openCentered();
}
/**
* What the user is asked before an asset is deleted – by them or by an agent (#429) –, or why it
* cannot be; null for an asset that is not there.
*/
export function deleteQuestion(
project: Project,
assetId: string,
): { title: string; detail: string } | { refusal: string } | null {
const asset = findAsset(project, assetId);
if (!asset) return null;
const owner = ownerOf(project, asset);
if (owner)
return {
refusal: `"${asset.name}" lives in the ${owner.type} "${owner.name}" and goes with what uses it there`,
};
const usages = findUsages(project, assetId).length;
// A texture is never placed but worn through a property, so counting placed instances would
// report "unused". Its frames count as the texture itself.
const ids =
asset.type === 'texture'
? textureRefIds(asset)
: asset.type === 'pixelart'
? pixelArtRefIds(asset)
: asset.type === 'video'
? videoRefIds(asset)
: [assetId];
const references = findPropertyUsages(project, ids).length;
// An asset that draws its own picture (#185) is worn the same way, only without frames.
const picture = asset.type === 'texture' || asset.type === 'video' || generatesPictures(asset.type);
const animations = picture ? animationsUsing(project, ids).length : 0;
const tiles = picture ? tilesUsing(project, ids).length : 0;
const eased = asset.type === 'curve' ? curveUsages(project, assetId) : { keys: 0, nodes: 0 };
const typed =
asset.type === 'struct' ? structTypeUsages(project, assetId) : { places: 0, lists: 0, nodes: 0 };
const lists = typed.lists > 0 ? `, ${typed.lists} list(s)` : '';
const painted = asset.type === 'tile' ? cellsPainted(project, assetId) : { tilemaps: 0, cells: 0 };
const detail =
asset.type === 'scene'
? `The scene and its ${asset.objects.length} object(s) are removed.`
: painted.cells > 0
? `${painted.cells} cell(s) on ${painted.tilemaps} tilemap(s) are painted with it and become empty.`
: eased.keys > 0 || eased.nodes > 0
? `${eased.keys} animation key(s) and ${eased.nodes} Lerp node(s) use it and blend linearly instead.`
: typed.places > 0 || typed.lists > 0 || typed.nodes > 0
? `${typed.places} struct(s)${lists} and ${typed.nodes} node(s) use it and keep its fields as their own.`
: references > 0 || animations > 0 || tiles > 0
? wornDetail(references, animations, tiles, asset.type)
: usages === 0
? 'This cannot be undone with Ctrl+Z after reload.'
: asset.type === 'container'
? `${usages} placed container(s) are removed together with their content.`
: `${usages} placed instance(s) are removed as well (scenes and container templates).`;
return { title: `Delete "${asset.name}"?`, detail };
}
function wornDetail(sprites: number, animations: number, tiles: number, type: AssetType): string {
if (type === 'font') return `${sprites} text(s) reference it and fall back to the editor's own typeface.`;
const who = [
sprites > 0 ? `${sprites} sprite(s)` : '',
animations > 0 ? `${animations} animation(s)` : '',
tiles > 0 ? `${tiles} tile(s)` : '',
].filter(Boolean);
// "a, b and c" rather than "a and b and c", which three kinds of user made reachable.
const list =
who.length > 2 ? `${who.slice(0, -1).join(', ')} and ${who[who.length - 1]}` : who.join(' and ');
return `${list} reference it and show the plain color in its place.`;
}