Files
feuerwerk/src/editor/panels/AssetPanel.ts
T
root d7c8d3e579 Measure a deviation against the base, not the innermost template
An instance edit was dropped as soon as its value matched the template
object. With nested containers the template is not what the value falls
back to: the outer container holds an edit for the object below it, and
that one wins. If the inner template happened to carry the same value the
user typed, the edit was thrown away and the value jumped to whatever the
container in between says.

Reported against the Jump-and-Run project: a coin narrowed to 8 inside
"level", then set back to the sprite's own 24 in the scene, snapped back
to 8. Reproduced through the inspector before touching anything.

InstanceTarget now offers `base`: the template with the edits of every
container in between woven in. Everything that judges deviation uses it -
setOverride/setName/setPosition, both Revert commands, and the gold marks
in the inspector. Writing still goes into the real template, which is
where "apply to container" belongs.

`base` is built on every read. Apply writes into the template and then
asks whether the edit still deviates, so a base captured beforehand would
answer for the template as it was - the e2e suite caught exactly that.

Also in here, because it corrects the commit before it: placing an asset
by double-clicking its row is gone, as asked for in asset-doppelklick.md.
Dragging is the only way to place now. That took AssetPanel.place, the
PlacementTarget interface, Editor.placementTarget and the two worldCenter()
methods that existed only to feed it. A double-click on a scene asset still
makes it the active scene.
2026-09-09 13:45:27 +02:00

125 lines
4.7 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 { CreateAssetCommand, SetActiveSceneCommand } from '../../core/commands/assetCommands';
import { nextAssetName } from '../../core/commands/naming';
import {
ASSET_TYPE_LABELS,
ASSET_TYPES,
type AssetType,
hasColorSwatch,
isPlaceableType,
} from '../../core/model/AssetTypes';
import { findAsset } from '../../core/model/Project';
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 { pickAndImport } from '../textureImport';
/** Lists all assets of the project; creates and deletes them. Placing is a drag into a view. */
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,
})),
);
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): void {
const command = new CreateAssetCommand(type, nextAssetName(this.state.project, ASSET_TYPE_LABELS[type]));
if (this.state.execute(command)) this.state.select({ kind: 'asset', assetId: command.asset.id });
}
/**
* A texture is an uploaded picture, so its menu entry opens the file dialog instead of
* creating an empty asset.
*/
private importTextures(): void {
if (!this.textures) {
this.state.setNotice('Image upload is not available.');
return;
}
pickAndImport({ 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);
}
/** 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,
});
}
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
const menu = new Menu(items);
menu.open(x, y);
return menu;
}
}