Files
feuerwerk/src/editor/panels/AssetPanel.ts
T
adminandClaude Opus 5.5 e0b471c354
game-maker / gate (push) Successful in 3m24s
game-maker / suite (push) Successful in 10m9s
Open the add menu with a right-click in the asset panel (#244)
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>
2026-09-27 12:27:34 +02:00

373 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}