142 lines
6.9 KiB
TypeScript
142 lines
6.9 KiB
TypeScript
/**
|
||
* 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, AddRigidbodyCommand, rigidbodyRefusal } 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, tilesUsing, 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;
|
||
}
|
||
|
||
/** 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; selects the rigidbody. */
|
||
export function addRigidbody(state: EditorState, holderId: string, objectId: string): boolean {
|
||
const command = new AddRigidbodyCommand(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 tiles = asset.type === 'texture' ? tilesUsing(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 || 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 colour in its place.`;
|
||
}
|