New assets could only be added through the + button. A right-click on a category heading or on the empty space below the rows now opens the same menu at the pointer. - ListView gets the callback onListMenu. It fires on a right-click on empty space or on a heading. Only the asset panel has headings, so other lists are not affected. - AssetPanel opens its + menu from there. - A right-click on a heading does not fold or unfold the category. A right-click on empty space clears the selection like a left-click. - A right-click on an asset still opens the menu of that asset. - The empty list now says "No assets - press + or right-click". Tests: - ./check.sh: 178 files, 2573 tests, CHECK EXIT 0 - e2e 1-4: ALL PASSED, 134 checks - 3 new checks in e2e 04 with real right-clicks: menu on the "Scenes" heading (21 types, heading stays folded), menu on empty space, and "Behavior" from that menu creates one, undone again. - Counter-probe: without the menu both right-click checks fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
15 KiB
TypeScript
373 lines
15 KiB
TypeScript
import {
|
||
AddAnimationTrackCommand,
|
||
SetAnimationKeyCommand,
|
||
SetAnimationRootCommand,
|
||
} from '../../core/commands/animationCommands';
|
||
import {
|
||
CreateAssetCommand,
|
||
SetActiveSceneCommand,
|
||
SetStartSceneCommand,
|
||
} from '../../core/commands/assetCommands';
|
||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||
import { nextAssetName } from '../../core/commands/naming';
|
||
import { ASSET_TYPE_LABELS, type AssetType, isPlaceableType } from '../../core/model/AssetTypes';
|
||
import type { PixelArtAsset, TextureAsset } from '../../core/model/Asset';
|
||
import { generatesPictures } from '../../core/model/generated';
|
||
import { PALETTE_KEY, pixelArtWornId } from '../../core/model/pixelart';
|
||
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 { schemaForAsset } from '../../core/properties/PropertySchema';
|
||
import { resolveAssetProps } from '../../core/properties/resolve';
|
||
import type { FontStore } from '../../core/store/FontStore';
|
||
import type { SoundStore } from '../../core/store/SoundStore';
|
||
import type { TextureStore } from '../../core/store/TextureStore';
|
||
import {
|
||
Button,
|
||
type ListItem,
|
||
ListView,
|
||
Menu,
|
||
type MenuEntry,
|
||
Panel,
|
||
ScrollContainer,
|
||
startDrag,
|
||
UI_ICON_SVG,
|
||
} from '../../ui';
|
||
import { ASSET_GROUP_LABELS, groupAssets } from './assetGroups';
|
||
import { ASSET_ICON_SVG } from './assetIcons';
|
||
import { assetMenuGroups } from './assetMenu';
|
||
import { TOOLBAR_ICON_SVG } from './toolbarIcons';
|
||
import { activateAsset, confirmDeleteAsset } from '../actions';
|
||
import { openReferenceItems } from '../objectMenu';
|
||
import type { EditorState } from '../state';
|
||
import { openFontPicker } from '../font/FontPicker';
|
||
import { openSoundPicker } from '../sound/SoundPicker';
|
||
import { openImagePicker } from '../texture/ImagePicker';
|
||
|
||
/** Row ids of the group headings are this plus the type; an asset id never starts with it. */
|
||
const HEADING = 'group:';
|
||
const headingId = (type: AssetType): string => HEADING + type;
|
||
|
||
/**
|
||
* Lists all assets of the project, grouped by type under a folding heading (#176); 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();
|
||
/** Types whose group is unfolded, for the session; every group starts folded. */
|
||
private readonly unfolded = new Set<AssetType>();
|
||
/** Ids of the rows as shown, headings included. */
|
||
private rows: string[] = [];
|
||
|
||
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,
|
||
/** The same for typefaces and the Font entry. */
|
||
private readonly fonts: FontStore | null = null,
|
||
/** And for sounds and the Sound entry. */
|
||
private readonly sounds: SoundStore | null = null,
|
||
) {
|
||
super({ title: 'Assets', padding: 0, gap: 0 });
|
||
this.minWidth = 160;
|
||
|
||
const add = new Button('+', { variant: 'ghost', width: 28 });
|
||
add.onClick = () => new Menu(this.addMenuEntries()).openBelow(add);
|
||
this.header.add(add);
|
||
|
||
this.list.emptyText = 'No assets – press + or right-click';
|
||
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.onListMenu = (x, y) => new Menu(this.addMenuEntries()).open(x, y);
|
||
this.list.onDragOut = (id) => this.startPlacementDrag(id);
|
||
this.list.onToggle = (id, expand) => this.setFolded(id.slice(HEADING.length) as AssetType, !expand);
|
||
this.scroll.setContent(this.list);
|
||
this.content.add(this.scroll, 1);
|
||
|
||
state.on('project', () => this.refresh());
|
||
// A different project brings different assets – start folded again.
|
||
state.on('load', () => {
|
||
this.unfolded.clear();
|
||
this.refresh();
|
||
});
|
||
state.on('selection', () => this.syncSelection());
|
||
state.on('ping', () => this.ping());
|
||
this.refresh();
|
||
}
|
||
|
||
/** Unfolds and scrolls to the pinged asset and lets its row light up; the selection stays. */
|
||
private ping(): void {
|
||
const id = this.state.pingedAssetId;
|
||
if (!id || !findAsset(this.state.project, id)) return;
|
||
this.reveal(id);
|
||
this.list.ping(id);
|
||
}
|
||
|
||
/** The row lighting up after a ping and how brightly, or null (test hook). */
|
||
pingState(): { id: string; alpha: number } | null {
|
||
return this.list.pingState();
|
||
}
|
||
|
||
/** One entry per asset type, the groups of `assetMenuGroups` kept apart by a line. */
|
||
private addMenuEntries(): MenuEntry[] {
|
||
const entry = (type: AssetType): MenuEntry => ({
|
||
label: ASSET_TYPE_LABELS[type],
|
||
icon: ASSET_ICON_SVG[type],
|
||
onSelect: () => this.addAsset(type),
|
||
});
|
||
return assetMenuGroups().flatMap((group, index) =>
|
||
index === 0 ? group.map(entry) : [{ separator: true }, ...group.map(entry)],
|
||
);
|
||
}
|
||
|
||
/**
|
||
* What a scene row says about itself: a dot for the one the editor shows, play for the one a run
|
||
* begins with. Two marks rather than one, because they are two different questions and often two
|
||
* different scenes – the level you are editing versus the title screen.
|
||
*/
|
||
private sceneMarks(id: string): string[] {
|
||
const { activeSceneId, startSceneId } = this.state.project;
|
||
return [
|
||
...(id === startSceneId ? [TOOLBAR_ICON_SVG.play] : []),
|
||
...(id === activeSceneId ? [UI_ICON_SVG.current] : []),
|
||
];
|
||
}
|
||
|
||
private refresh(): void {
|
||
const project = this.state.project;
|
||
const items: ListItem[] = [];
|
||
for (const { type, assets } of groupAssets(project.assets)) {
|
||
const expanded = this.unfolded.has(type);
|
||
items.push({
|
||
id: headingId(type),
|
||
label: ASSET_GROUP_LABELS[type],
|
||
hint: String(assets.length),
|
||
icon: ASSET_ICON_SVG[type],
|
||
heading: true,
|
||
expanded,
|
||
});
|
||
if (!expanded) continue;
|
||
for (const asset of assets)
|
||
items.push({
|
||
id: asset.id,
|
||
label: asset.name,
|
||
marks: asset.type === 'scene' ? this.sceneMarks(asset.id) : undefined,
|
||
depth: 1,
|
||
overridden: assetDeviates(project, asset),
|
||
});
|
||
}
|
||
this.rows = items.map((item) => item.id);
|
||
this.list.setItems(items);
|
||
// Measured first: scrolling to the selection needs the new height of the list.
|
||
this.scroll.relayoutContent();
|
||
this.syncSelection();
|
||
}
|
||
|
||
private setFolded(type: AssetType, fold: boolean): void {
|
||
if (fold) {
|
||
// A selected asset unfolds its group again – folding lets go of it first.
|
||
const s = this.state.selection;
|
||
if (s?.kind === 'asset' && findAsset(this.state.project, s.assetId)?.type === type)
|
||
this.state.select(null);
|
||
this.unfolded.delete(type);
|
||
} else this.unfolded.add(type);
|
||
this.refresh();
|
||
}
|
||
|
||
private syncSelection(): void {
|
||
const s = this.state.selection;
|
||
const id = s?.kind === 'asset' ? s.assetId : null;
|
||
// A selection made anywhere else – a graph node, a picker, a fresh asset – has to be reachable
|
||
// in the list: its group unfolds, and with many assets the row is often below the fold.
|
||
const type = id ? findAsset(this.state.project, id)?.type : undefined;
|
||
if (type && !this.unfolded.has(type)) {
|
||
this.setFolded(type, false);
|
||
return;
|
||
}
|
||
this.list.select(id, true);
|
||
if (id) this.scrollTo(id);
|
||
}
|
||
|
||
private scrollTo(id: string): void {
|
||
const index = this.rows.indexOf(id);
|
||
if (index >= 0) this.scroll.ensureVisible(index * this.list.rowHeight, this.list.rowHeight);
|
||
}
|
||
|
||
/** The groups as shown, in order (test hook). */
|
||
groups(): { type: AssetType; label: string; expanded: boolean; ids: string[] }[] {
|
||
return groupAssets(this.state.project.assets).map(({ type, assets }) => ({
|
||
type,
|
||
label: ASSET_GROUP_LABELS[type],
|
||
expanded: this.unfolded.has(type),
|
||
ids: assets.map((a) => a.id),
|
||
}));
|
||
}
|
||
|
||
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.openAsset(command.asset.id);
|
||
}
|
||
|
||
/**
|
||
* A sprite wearing the texture and an animation over it whose one row keys the frames in order,
|
||
* one per frame – what a picture sequence is on a timeline. One undo step for all of it.
|
||
*/
|
||
private createAnimation(picture: TextureAsset | PixelArtAsset): void {
|
||
const project = this.state.project;
|
||
const pictures = picture.frames.length > 0 ? picture.frames.map((f) => f.id) : [picture.id];
|
||
const sprite = new CreateAssetCommand(
|
||
'sprite',
|
||
nextAssetName(project, picture.name),
|
||
spriteFromTexture(project, pictures[0] ?? picture.id),
|
||
);
|
||
const create = new CreateAssetCommand('animation', nextAssetName(project, picture.name));
|
||
const root = new SetAnimationRootCommand(create.asset.id, sprite.asset.id);
|
||
const row = new AddAnimationTrackCommand(create.asset.id, '', 'texture');
|
||
const keys = pictures.map(
|
||
(id, frame) => new SetAnimationKeyCommand(create.asset.id, row.trackId, frame, id),
|
||
);
|
||
if (this.state.execute(oneStep(create.label, [sprite, create, root, row, ...keys])))
|
||
this.state.openAsset(create.asset.id);
|
||
}
|
||
|
||
/**
|
||
* A texture is a picture, a font and a sound are files, so their menu entries offer what the
|
||
* server already holds instead of creating an empty asset. Uploading a new one is a button
|
||
* inside that dialog.
|
||
*/
|
||
private addAsset(type: AssetType): void {
|
||
if (type === 'texture') this.importTextures();
|
||
else if (type === 'font') this.importFonts();
|
||
else if (type === 'sound') this.importSounds();
|
||
else if (type === 'pixelart') this.createPixelArt();
|
||
else this.createAsset(type);
|
||
}
|
||
|
||
/**
|
||
* Pixel art is painted in a palette the project shares (#130, answer 2): a new one takes the first
|
||
* palette there is, and where there is none a palette is created along with it, in one undo step.
|
||
*/
|
||
private createPixelArt(): void {
|
||
const project = this.state.project;
|
||
const existing = groupAssets(project.assets).find((group) => group.type === 'palette')?.assets[0];
|
||
const palette = existing
|
||
? null
|
||
: new CreateAssetCommand('palette', nextAssetName(project, ASSET_TYPE_LABELS.palette));
|
||
const art = new CreateAssetCommand('pixelart', nextAssetName(project, ASSET_TYPE_LABELS.pixelart), {
|
||
[PALETTE_KEY]: existing?.id ?? palette?.asset.id ?? '',
|
||
});
|
||
const commands = palette ? [palette, art] : [art];
|
||
if (this.state.execute(oneStep(art.label, commands))) this.state.openAsset(art.asset.id);
|
||
}
|
||
|
||
private importTextures(): void {
|
||
if (!this.textures) {
|
||
this.state.setNotice('Image upload is not available.');
|
||
return;
|
||
}
|
||
openImagePicker({ state: this.state, textures: this.textures });
|
||
}
|
||
|
||
private importFonts(): void {
|
||
if (!this.fonts) {
|
||
this.state.setNotice('Font upload is not available.');
|
||
return;
|
||
}
|
||
openFontPicker({ state: this.state, fonts: this.fonts });
|
||
}
|
||
|
||
private importSounds(): void {
|
||
if (!this.sounds) {
|
||
this.state.setNotice('Sound upload is not available.');
|
||
return;
|
||
}
|
||
openSoundPicker({ state: this.state, sounds: this.sounds });
|
||
}
|
||
|
||
/** Double-click / Enter opens the asset's tab; a scene also becomes the active one. Placing is a drag. */
|
||
private activate(id: string): void {
|
||
activateAsset(this.state, id);
|
||
}
|
||
|
||
/**
|
||
* Where an asset's row sits on screen, so a check can pick it up by id (test hook); with `reveal`
|
||
* its group is unfolded first, otherwise a folded group's row is `null`.
|
||
*/
|
||
rowGlobal(assetId: string, reveal = false): { x: number; y: number } | null {
|
||
if (reveal) this.reveal(assetId);
|
||
return this.list.rowGlobal(assetId);
|
||
}
|
||
|
||
/** Unfolds the asset's group and scrolls to its row – before a ping, and as a user would before clicking it (test hooks). */
|
||
private reveal(assetId: string): void {
|
||
const type = findAsset(this.state.project, assetId)?.type;
|
||
if (type && !this.unfolded.has(type)) this.setFolded(type, false);
|
||
this.scrollTo(assetId);
|
||
}
|
||
|
||
/** Centre of a group heading's icon (test hook). */
|
||
groupIcon(type: AssetType): { x: number; y: number } | null {
|
||
return this.list.iconGlobal(headingId(type));
|
||
}
|
||
|
||
/** Label colour of a row as `#rrggbb` – gold means the asset deviates from the defaults (test hook). */
|
||
rowColor(assetId: string): string | null {
|
||
this.reveal(assetId);
|
||
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 project = this.state.project;
|
||
const items = openReferenceItems(
|
||
this.state,
|
||
schemaForAsset(project, asset),
|
||
resolveAssetProps(project, asset),
|
||
);
|
||
if (asset.type === 'scene') {
|
||
items.push({
|
||
label: 'Set as active scene',
|
||
onSelect: () => this.state.execute(new SetActiveSceneCommand(id)),
|
||
disabled: this.state.project.activeSceneId === id,
|
||
});
|
||
const isStart = this.state.project.startSceneId === id;
|
||
items.push({
|
||
label: isStart ? 'Clear start scene' : 'Set as start scene',
|
||
onSelect: () => this.state.execute(new SetStartSceneCommand(isStart ? '' : id)),
|
||
});
|
||
}
|
||
// A drawn picture (#185) is worn like a texture, so it makes a sprite the same way. Only the
|
||
// animation needs frames, which a drawn picture has none of.
|
||
if (asset.type === 'texture' || generatesPictures(asset.type)) {
|
||
// A sequence of pixel art frames makes a sprite of its first frame rather than of the strip.
|
||
const worn = asset.type === 'pixelart' ? pixelArtWornId(asset) : id;
|
||
items.push({
|
||
label: asset.type === 'texture' ? 'Create sprite from texture' : 'Create sprite from picture',
|
||
onSelect: () => this.createAsset('sprite', asset.name, spriteFromTexture(this.state.project, worn)),
|
||
});
|
||
if (asset.type === 'texture')
|
||
items.push({ label: 'Create animation from texture', onSelect: () => this.createAnimation(asset) });
|
||
if (asset.type === 'pixelart' && asset.frames.length > 1)
|
||
items.push({ label: 'Create animation from frames', 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;
|
||
}
|
||
}
|