import { AddAnimationFramesCommand } from '../../core/commands/animationCommands'; import { CreateAssetCommand, SetActiveSceneCommand } from '../../core/commands/assetCommands'; import { oneStep } from '../../core/commands/CompoundCommand'; import { nextAssetName } from '../../core/commands/naming'; import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, hasColorSwatch, isPlaceableType, } from '../../core/model/AssetTypes'; import { framesOfTexture } from '../../core/model/animation'; import type { TextureAsset } from '../../core/model/Asset'; import type { PropertyBag } from '../../core/model/Properties'; import { findAsset } from '../../core/model/Project'; import { spriteFromTexture } from '../../core/properties/aspect'; import { assetDeviates } from '../../core/properties/deviation'; import type { TextureStore } from '../../core/store/TextureStore'; import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer, startDrag } from '../../ui'; import { confirmDeleteAsset } from '../actions'; import type { EditorState } from '../state'; import { openImagePicker } from '../texture/ImagePicker'; /** * Lists all assets of the project; creates and deletes them. Placing is a drag into a view. * * A row's name turns gold when the asset carries a property the inspector marks – the same rule, * so the list cannot promise a deviation the inspector then fails to show. */ export class AssetPanel extends Panel { private readonly list = new ListView(); private readonly scroll = new ScrollContainer(); constructor( private readonly state: EditorState, /** Where imported images go. Without it the Texture entry has nothing to upload to. */ private readonly textures: TextureStore | null = null, ) { super({ title: 'Assets', padding: 0, gap: 0 }); this.minWidth = 160; const add = new Button('+', { variant: 'ghost', width: 28 }); add.onClick = () => new Menu( ASSET_TYPES.map((type) => ({ label: ASSET_TYPE_LABELS[type], onSelect: () => (type === 'texture' ? this.importTextures() : this.createAsset(type)), })), ).openBelow(add); this.header.add(add); this.list.emptyText = 'No assets – press +'; this.list.onSelect = (id) => state.select(id ? { kind: 'asset', assetId: id } : null); this.list.onActivate = (id) => this.activate(id); this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y); this.list.onDragOut = (id) => this.startPlacementDrag(id); this.scroll.setContent(this.list); this.content.add(this.scroll, 1); state.on('project', () => this.refresh()); state.on('load', () => this.refresh()); state.on('selection', () => this.syncSelection()); this.refresh(); } private refresh(): void { const active = this.state.project.activeSceneId; this.list.setItems( this.state.project.assets.map((asset) => ({ id: asset.id, label: asset.name, hint: asset.type === 'scene' && asset.id === active ? 'scene ●' : asset.type, swatch: hasColorSwatch(asset.type) ? Number(asset.props.color ?? 0) : undefined, overridden: assetDeviates(asset), })), ); this.syncSelection(); this.scroll.relayoutContent(); } private syncSelection(): void { const s = this.state.selection; this.list.select(s?.kind === 'asset' ? s.assetId : null, true); } private createAsset(type: AssetType, name = ASSET_TYPE_LABELS[type], props: PropertyBag = {}): void { const command = new CreateAssetCommand(type, nextAssetName(this.state.project, name), props); if (this.state.execute(command)) this.state.select({ kind: 'asset', assetId: command.asset.id }); } /** One undo step for the asset and its frames. */ private createAnimation(texture: TextureAsset): void { const create = new CreateAssetCommand('animation', nextAssetName(this.state.project, texture.name)); const frames = new AddAnimationFramesCommand(create.asset.id, framesOfTexture(texture)); if (this.state.execute(oneStep(create.label, [create, frames]))) this.state.select({ kind: 'asset', assetId: create.asset.id }); } /** * A texture is a picture, so its menu entry offers the ones the server already holds instead of * creating an empty asset. Uploading a new one is a button inside that dialog. */ private importTextures(): void { if (!this.textures) { this.state.setNotice('Image upload is not available.'); return; } openImagePicker({ state: this.state, textures: this.textures }); } /** Double-click / Enter on a scene makes it the active one. Placing is a drag, not a click. */ private activate(id: string): void { const asset = findAsset(this.state.project, id); if (asset?.type === 'scene') this.state.execute(new SetActiveSceneCommand(id)); } /** Where an asset's row sits on screen, so a check can pick it up by id (test hook). */ rowGlobal(assetId: string): { x: number; y: number } | null { return this.list.rowGlobal(assetId); } /** Label colour of a row as `#rrggbb` – gold means the asset deviates from the defaults (test hook). */ rowColor(assetId: string): string | null { return this.list.rowColor(assetId); } /** Hands the asset to the viewport under the pointer; a scene has nowhere to be placed into. */ private startPlacementDrag(assetId: string): void { const asset = findAsset(this.state.project, assetId); if (!asset || !isPlaceableType(asset.type)) return; startDrag({ kind: 'asset', id: assetId }, asset.name); } private openContextMenu(id: string, x: number, y: number): Menu | undefined { const asset = findAsset(this.state.project, id); if (!asset) return undefined; const items: MenuItem[] = []; if (asset.type === 'scene') { items.push({ label: 'Set as active scene', onSelect: () => this.state.execute(new SetActiveSceneCommand(id)), disabled: this.state.project.activeSceneId === id, }); } if (asset.type === 'texture') { items.push({ label: 'Create sprite from texture', onSelect: () => this.createAsset('sprite', asset.name, spriteFromTexture(this.state.project, id)), }); items.push({ label: 'Create animation from texture', onSelect: () => this.createAnimation(asset) }); } items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) }); const menu = new Menu(items); menu.open(x, y); return menu; } }