/** * 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 { 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 { 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 { ownerSong } 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 } 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); } /** 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 { 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; } } /** Asks before deleting an asset, spelling out what goes with it. */ export function confirmDeleteAsset(state: EditorState, assetId: string): void { const asset = findAsset(state.project, assetId); if (!asset) return; const song = ownerSong(state.project, asset); if (song) { state.setNotice(`"${asset.name}" lives in the song "${song.name}" and goes with what uses it there`); return; } const usages = findUsages(state.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(state.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(state.project, ids).length : 0; const tiles = picture ? tilesUsing(state.project, ids).length : 0; const eased = asset.type === 'curve' ? curveUsages(state.project, assetId) : { keys: 0, nodes: 0 }; const typed = asset.type === 'struct' ? structTypeUsages(state.project, assetId) : { places: 0, lists: 0, nodes: 0 }; const lists = typed.lists > 0 ? `, ${typed.lists} list(s)` : ''; const painted = asset.type === 'tile' ? cellsPainted(state.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).`; new Dialog(`Delete "${asset.name}"?`, detail, [ { label: 'Cancel' }, { label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(assetId)) }, ]).openCentered(); } 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.`; }