/** * 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 { annotationNoun, RemoveAnnotationCommand } from '../core/commands/annotationCommands'; import { DeleteAssetCommand } from '../core/commands/assetCommands'; import { AddColliderCommand } from '../core/commands/colliderCommands'; import { oneStep } from '../core/commands/CompoundCommand'; import { CreateContainerCommand } from '../core/commands/containerCommands'; import { RemoveNodeCommand } from '../core/commands/graphCommands'; import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands'; import type { AssetType } from '../core/model/AssetTypes'; import { isLiveInstance, isTemplateContent } from '../core/model/instance'; import type { Command } from '../core/commands/Command'; import { findAnnotation } from '../core/graph'; import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project'; import type { SceneObject } from '../core/model/SceneObject'; import { textureRefIds } from '../core/model/texture'; import { animationsUsing, findPropertyUsages, findUsages } from '../core/model/usages'; import { Dialog } from '../ui'; import { type EditorState, objectSelection } from './state'; /** 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, so no place offers what it would refuse. */ export function canAddCollider(project: Project, holder: ObjectHolder, object: SceneObject): boolean { const type = findAsset(project, object.assetId)?.type; return ( type !== undefined && type !== 'collider' && !isTemplateContent(holder, object.id) && !isLiveInstance(project, holder, object) ); } /** Gives the object a collider child sized like it; selects the collider. */ export function addCollider(state: EditorState, holderId: string, objectId: string): boolean { const command = new AddColliderCommand(holderId, objectId); 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)); } /** 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)); } /** * Deletes everything a graph selection holds – nodes and boxes 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. */ export function removeGraphSelection( state: EditorState, assetId: string, nodeIds: readonly string[], annotationIds: readonly string[], ): boolean { const graph = findGraphAsset(state.project, assetId)?.graph; const commands: Command[] = nodeIds.map((id) => new RemoveNodeCommand(assetId, id)); let noun = ''; 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) return false; const label = commands.length === 1 && noun !== '' ? `Delete ${noun}` : nodeIds.length === commands.length ? `Delete ${commands.length} nodes` : `Delete ${commands.length} items`; return state.execute(oneStep(label, commands)); } /** 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 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) : [assetId]; const references = findPropertyUsages(state.project, ids).length; const animations = asset.type === 'texture' ? animationsUsing(state.project, ids).length : 0; const detail = asset.type === 'scene' ? `The scene and its ${asset.objects.length} object(s) are removed.` : references > 0 || animations > 0 ? wornDetail(references, animations, 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, 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)` : '', ].filter(Boolean); return `${who.join(' and ')} reference it and show the plain colour in its place.`; }