Give the three asset tabs one base, and move the viewports into the editor

src/viewport/ held three classes only src/editor/ ever used, and framing.ts sat
under graph/ although the scene view wants the same bounds sum. The graph, the
texture and the container tab repeated the same header, subscription and
disposal code with two silent divergences: the graph tab said "nothing shown"
with null instead of '', and the texture tab refell back to the first asset on
a selection change but not on a project event.

These arrive together because GraphCanvas ties them: it reads framing from its
new home, hands its fold marks to folding.ts, and its '' change is what
GraphEditor must agree with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-10 07:14:38 +02:00
1 parent a2f2f8465c
commit 11981b40d0
15 files changed
+408 -425

No files matched your search

+17 -95
View File
@@ -1,8 +1,7 @@
import { Graphics } from 'pixi.js';
import { SliceFramesCommand } from '../../core/commands/frameCommands';
import type { TextureAsset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui';
import type { Asset, TextureAsset } from '../../core/model/Asset';
import { Button } from '../../ui';
import { AssetTabEditor } from '../AssetTabEditor';
import type { EditorState } from '../state';
import { SliceDialog } from './SliceDialog';
import { TextureCanvas } from './TextureCanvas';
@@ -10,61 +9,13 @@ import { TextureCanvas } from './TextureCanvas';
/** Room kept for the "W × H px · N frames" readout, wide enough for a four-digit sheet. */
const INFO_WIDTH = 190;
/**
* The Texture tab. It follows the selection, but keeps showing the last texture when the
* selection moves elsewhere – clicking a sprite must not empty the tab being worked in.
*/
export class TextureEditor extends VStack {
private readonly canvas: TextureCanvas;
private readonly picker: Dropdown;
private readonly info: Label;
export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
private readonly sliceButton: Button;
private readonly headerBg = new Graphics();
constructor(private readonly state: EditorState) {
super();
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
header.minHeight = Theme.controlHeight + Theme.padding * 2;
header.addChildAt(this.headerBg, 0);
header.add(new Label('Texture', { style: 'muted' }));
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
header.add(this.picker);
constructor(state: EditorState) {
super(state, { noun: 'Texture', infoWidth: INFO_WIDTH });
this.sliceButton = new Button('Slice…', { onClick: () => this.openSlice() });
header.add(this.sliceButton);
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
header.add(new Spacer(), 1);
this.info = new Label('', { style: 'small', align: 'right' });
// Reserved, not measured: a stack sizes the label once, from the empty text it was built with.
this.info.minWidth = INFO_WIDTH;
header.add(this.info);
this.canvas = new TextureCanvas(state);
this.add(header);
this.add(this.canvas, 1);
state.on('selection', () => this.followSelection());
state.on('project', () => this.refresh());
state.on('load', () => {
this.canvas.show('');
this.followSelection();
});
this.followSelection();
}
/** The texture currently shown – the check scripts read this. */
get currentAssetId(): string {
return this.canvas.currentAssetId;
}
/** For the check scripts: the picker's width must not follow the name it happens to show. */
get pickerWidth(): number {
return this.picker.w;
}
show(assetId: string): void {
this.canvas.show(assetId);
this.refresh();
this.mount(new TextureCanvas(state), [this.sliceButton]);
}
/** Where an image pixel of the shown sheet sits on screen – for the check scripts. */
@@ -72,32 +23,22 @@ export class TextureEditor extends VStack {
return this.canvas.imageToScreen(x, y);
}
private followSelection(): void {
const id = this.state.selectedTextureId;
// Null merely means "the selection is about something else"; the tab keeps its subject.
if (id) this.canvas.show(id);
else if (!this.asset()) this.canvas.show(this.textures()[0]?.id ?? '');
this.refresh();
protected override selectedId(): string {
return this.state.selectedTextureId ?? '';
}
private asset(): TextureAsset | null {
const asset = findAsset(this.state.project, this.canvas.currentAssetId);
return asset && asset.type === 'texture' ? asset : null;
protected override isMine(asset: Asset): asset is TextureAsset {
return asset.type === 'texture';
}
private textures(): TextureAsset[] {
return this.state.project.assets
.filter((a): a is TextureAsset => a.type === 'texture')
.sort((a, b) => a.name.localeCompare(b.name));
protected override infoText(asset: TextureAsset): string {
const count = asset.frames.length;
return `${asset.width} × ${asset.height} px · ${count} ${count === 1 ? 'frame' : 'frames'}`;
}
private openPicker(): void {
const items: MenuItem[] = this.textures().map((asset) => ({
label: asset.id === this.canvas.currentAssetId ? `● ${asset.name}` : asset.name,
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
}));
if (items.length === 0) items.push({ label: 'No textures yet', onSelect: () => undefined });
new Menu(items, { searchable: true }).openBelow(this.picker);
protected override syncExtras(asset: TextureAsset | null): void {
// Slicing needs a known picture size; without one it could only fail with a notice.
this.sliceButton.setEnabled(Boolean(asset && asset.width > 0 && asset.height > 0));
}
private openSlice(): void {
@@ -107,23 +48,4 @@ export class TextureEditor extends VStack {
this.state.execute(new SliceFramesCommand(asset.id, options, replace));
}).openCentered();
}
private refresh(): void {
const asset = this.asset();
this.picker.setText(asset ? asset.name : 'No texture');
const count = asset?.frames.length ?? 0;
this.info.setText(
asset ? `${asset.width} × ${asset.height} px · ${count} ${count === 1 ? 'frame' : 'frames'}` : '',
);
// Slicing needs a known picture size; without one it could only fail with a notice.
this.sliceButton.setEnabled(Boolean(asset && asset.width > 0 && asset.height > 0));
}
override redraw(): void {
super.redraw();
this.headerBg.clear();
const h = Theme.controlHeight + Theme.padding * 2;
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
}
}