Tabs are open documents: Scene, Game and Plugins stay as fixed tabs, every other asset opens in its own tab (double click or Enter in the asset panel, on creation, from the list button), several assets of one kind can be open at once, a tab is labelled with its kind dimmed before the name, cut at 160 px and renamed with its asset, closes with its x or a middle click and goes away when its asset is deleted; a tab always shows its own document instead of following the selection, the picker in the tab head is gone, zoom and scroll are kept per document for the session and dropped on close, a double click on a call node stays in the same tab with breadcrumbs per tab; one editor per kind is switched between documents, the placement rules live in the pure module documents.ts, and the test hook showTab stays as a compatibility path; new e2e module 44 documents (#171)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4f546c0e08
commit
cc285af10a
33 files changed
+895
-225
No files matched your search
@@ -1,58 +1,57 @@
|
||||
import type { Asset } from '../core/model/Asset';
|
||||
import { findAsset } from '../core/model/Project';
|
||||
import { Button, Dropdown, Label, Menu, type MenuItem, Spacer, VStack, type Widget } from '../ui';
|
||||
import { Button, Label, Spacer, VStack, type Widget } from '../ui';
|
||||
import type { EditorState } from './state';
|
||||
import { TabHeader } from './TabHeader';
|
||||
|
||||
/**
|
||||
* The canvas each tab wraps: the header's Fit button and the tab's own plumbing need this much.
|
||||
* A canvas without `fit` belongs to a tab whose Fit frames something else (see `AssetTabEditor.fit`).
|
||||
* `saveView`/`restoreView` carry zoom and scroll across a switch to another document and back.
|
||||
*/
|
||||
export interface TabCanvas {
|
||||
readonly currentAssetId: string;
|
||||
show(assetId: string): void;
|
||||
fit?(): void;
|
||||
saveView?(): unknown;
|
||||
restoreView?(view: unknown): void;
|
||||
}
|
||||
|
||||
export interface AssetTabOptions {
|
||||
/** Shown left of the picker, and in the empty state as "No <noun>". */
|
||||
noun: string;
|
||||
/** Width reserved for the right-hand readout; a stack measures the label once, while empty. */
|
||||
infoWidth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Behavior, State machine, Texture and Container tabs; each keeps its last asset when the selection moves elsewhere.
|
||||
* A subclass must call `mount()`: header and canvas depend on each other, so the base cannot build them.
|
||||
* The Behavior, State machine, Texture, Container … tabs. One editor per kind serves every open
|
||||
* document of that kind (#171): the tab row tells it which one to show, and it keeps each one's
|
||||
* view for when it comes back. A subclass must call `mount()`: header and canvas depend on each
|
||||
* other, so the base cannot build them.
|
||||
*/
|
||||
export abstract class AssetTabEditor<
|
||||
A extends Asset,
|
||||
C extends TabCanvas & Widget = TabCanvas & Widget,
|
||||
> extends VStack {
|
||||
/** Public for the check scripts. */
|
||||
readonly picker: Dropdown;
|
||||
readonly fitButton: Button;
|
||||
protected readonly info: Label;
|
||||
/** Assigned by `mount`. Public because the check hooks and the play loop reach for it. */
|
||||
canvas!: C;
|
||||
private readonly views = new Map<string, unknown>();
|
||||
|
||||
constructor(
|
||||
protected readonly state: EditorState,
|
||||
private readonly options: AssetTabOptions,
|
||||
options: AssetTabOptions,
|
||||
) {
|
||||
super();
|
||||
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
|
||||
this.fitButton = new Button('Fit', { onClick: () => this.fit() });
|
||||
this.info = new Label('', { style: 'small', align: 'right' });
|
||||
this.info.minWidth = options.infoWidth;
|
||||
}
|
||||
|
||||
/** Builds the header (with `strip` below it) around `canvas`, wires the state events and shows the first asset. */
|
||||
/** Builds the header (with `strip` below it) around `canvas` and wires the state events. */
|
||||
protected mount(canvas: C, extras: readonly Widget[] = [], strip?: Widget): void {
|
||||
this.canvas = canvas;
|
||||
const header = new TabHeader();
|
||||
header.add(new Label(this.options.noun, { style: 'muted' }));
|
||||
header.add(this.picker);
|
||||
for (const widget of extras) header.add(widget);
|
||||
header.add(this.fitButton);
|
||||
header.add(new Spacer(), 1);
|
||||
@@ -62,13 +61,13 @@ export abstract class AssetTabEditor<
|
||||
if (strip) this.add(strip);
|
||||
this.add(this.body(canvas), 1);
|
||||
|
||||
this.state.on('selection', () => this.followSelection());
|
||||
this.state.on('project', () => this.refresh());
|
||||
this.state.on('load', () => {
|
||||
this.views.clear();
|
||||
this.canvas.show('');
|
||||
this.followSelection();
|
||||
this.refresh();
|
||||
});
|
||||
this.followSelection();
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** The asset currently shown, `''` for none – the check scripts read this. */
|
||||
@@ -81,10 +80,27 @@ export abstract class AssetTabEditor<
|
||||
return this.info.text;
|
||||
}
|
||||
|
||||
/** The selection's id for this kind of asset, `''` when the selection is about something else. */
|
||||
protected abstract selectedId(): string;
|
||||
/** Shows a document, with the view it had when it was last on screen. */
|
||||
showAsset(assetId: string): void {
|
||||
const from = this.canvas.currentAssetId;
|
||||
if (from !== assetId) {
|
||||
if (from) this.views.set(from, this.saveView());
|
||||
this.canvas.show(assetId);
|
||||
const view = this.views.get(assetId);
|
||||
if (view !== undefined) this.restoreView(view);
|
||||
}
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
protected abstract isMine(asset: Asset): asset is A;
|
||||
/** A closed tab: opened again, the document starts from a fitted view. */
|
||||
forgetView(assetId: string): void {
|
||||
this.views.delete(assetId);
|
||||
}
|
||||
|
||||
/** The selection's id for this kind of asset, `''` when the selection is about something else. */
|
||||
abstract selectedId(): string;
|
||||
|
||||
abstract isMine(asset: Asset): asset is A;
|
||||
|
||||
/** The right-hand readout; `null` is the empty state. */
|
||||
protected abstract infoText(asset: A | null): string;
|
||||
@@ -99,6 +115,15 @@ export abstract class AssetTabEditor<
|
||||
this.canvas.fit?.();
|
||||
}
|
||||
|
||||
/** The view to come back to; override when the tab has more than one. */
|
||||
protected saveView(): unknown {
|
||||
return this.canvas.saveView?.();
|
||||
}
|
||||
|
||||
protected restoreView(view: unknown): void {
|
||||
this.canvas.restoreView?.(view);
|
||||
}
|
||||
|
||||
/** Enable/disable whatever `extras` the subclass passed to `mount`. */
|
||||
protected syncExtras(_asset: A | null): void {}
|
||||
|
||||
@@ -107,36 +132,9 @@ export abstract class AssetTabEditor<
|
||||
return asset && this.isMine(asset) ? asset : null;
|
||||
}
|
||||
|
||||
protected candidates(): A[] {
|
||||
return this.state.project.assets
|
||||
.filter((a): a is A => this.isMine(a))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
protected followSelection(): void {
|
||||
const id = this.selectedId();
|
||||
// An empty id merely means "the selection is about something else"; the tab keeps its subject.
|
||||
if (id) this.canvas.show(id);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
protected refresh(): void {
|
||||
// A deleted asset leaves the canvas empty; fall back to whatever is left of this kind.
|
||||
if (!this.asset()) this.canvas.show(this.candidates()[0]?.id ?? '');
|
||||
const asset = this.asset();
|
||||
this.picker.setText(asset ? asset.name : `No ${this.options.noun.toLowerCase()}`);
|
||||
this.info.setText(asset ? this.infoText(asset) : '');
|
||||
this.syncExtras(asset);
|
||||
}
|
||||
|
||||
private openPicker(): void {
|
||||
const current = this.canvas.currentAssetId;
|
||||
const items: MenuItem[] = this.candidates().map((asset) => ({
|
||||
label: asset.id === current ? `● ${asset.name}` : asset.name,
|
||||
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
|
||||
}));
|
||||
if (items.length === 0)
|
||||
items.push({ label: `No ${this.options.noun.toLowerCase()}s yet`, onSelect: () => undefined });
|
||||
new Menu(items, { searchable: true }).openBelow(this.picker);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { CanvasTextMetrics, type FederatedPointerEvent, Rectangle, Text } from 'pixi.js';
|
||||
import { Button, centreY, Theme, textStyle } from '../ui';
|
||||
|
||||
const GAP = 4;
|
||||
const CLOSE = '×';
|
||||
/** The × is small; the hit around it is not, or closing a tab would take aim. */
|
||||
const CLOSE_SLOP = 4;
|
||||
/** A long name is cut here rather than pushing every other tab behind the `»` button. */
|
||||
const MAX_NAME_WIDTH = 160;
|
||||
|
||||
/**
|
||||
* A tab that stands for an open document (#171): its kind in muted type before the name, so three
|
||||
* assets called "Player" can be told apart, and an × that closes it – as does a middle click.
|
||||
*/
|
||||
export class DocumentTab extends Button {
|
||||
private readonly kindText: Text;
|
||||
private readonly closeText: Text;
|
||||
|
||||
constructor(
|
||||
label: string,
|
||||
private readonly kind: string,
|
||||
onPick: () => void,
|
||||
private readonly onClose: () => void,
|
||||
) {
|
||||
super(label, { variant: 'ghost', padding: 4, align: 'left', onClick: onPick });
|
||||
this.kindText = new Text({ text: kind, style: textStyle('muted') });
|
||||
this.closeText = new Text({ text: CLOSE, style: textStyle('muted') });
|
||||
this.closeText.eventMode = 'static';
|
||||
this.closeText.cursor = Theme.hoverCursor;
|
||||
// Stopped on the way down as well, or the tab under it would count the press as its own click.
|
||||
this.closeText.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
|
||||
this.closeText.on('pointerup', (e: FederatedPointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
this.onClose();
|
||||
});
|
||||
this.on('pointerup', (e: FederatedPointerEvent) => {
|
||||
if (e.button === 1) this.onClose();
|
||||
});
|
||||
this.addChild(this.kindText, this.closeText);
|
||||
}
|
||||
|
||||
/** The × in global coordinates, for the check scripts. */
|
||||
closePoint(): { x: number; y: number } {
|
||||
const at = this.closeText.getGlobalPosition();
|
||||
return { x: at.x + this.closeText.width / 2, y: at.y + this.closeText.height / 2 };
|
||||
}
|
||||
|
||||
get kindLabel(): string {
|
||||
return this.kind;
|
||||
}
|
||||
|
||||
override measure(): { w: number; h: number } {
|
||||
const own = super.measure();
|
||||
return { w: Math.min(own.w, MAX_NAME_WIDTH + this.padding * 2) + this.extraWidth(), h: own.h };
|
||||
}
|
||||
|
||||
protected override labelSlot(): number {
|
||||
return super.labelSlot() - this.extraWidth();
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
super.redraw();
|
||||
const style = textStyle(this.toggled ? 'accent' : 'muted');
|
||||
this.kindText.style = style;
|
||||
this.closeText.style = style;
|
||||
// On the accent fill the muted grey would vanish; the accent text, dimmed, stays the quieter one.
|
||||
this.kindText.alpha = this.toggled ? 0.7 : 1;
|
||||
this.kindText.position.set(this.padding, centreY(this.h, this.kindText.height));
|
||||
this.labelText.x = this.padding + this.kindText.width + GAP;
|
||||
const closeX = this.w - this.padding - this.closeText.width;
|
||||
this.closeText.position.set(closeX, centreY(this.h, this.closeText.height));
|
||||
this.closeText.hitArea = new Rectangle(
|
||||
-CLOSE_SLOP,
|
||||
-CLOSE_SLOP,
|
||||
this.closeText.width + CLOSE_SLOP * 2,
|
||||
this.closeText.height + CLOSE_SLOP * 2,
|
||||
);
|
||||
}
|
||||
|
||||
private extraWidth(): number {
|
||||
const style = textStyle('muted');
|
||||
const kind = CanvasTextMetrics.measureText(this.kind, style).width;
|
||||
const close = CanvasTextMetrics.measureText(CLOSE, style).width;
|
||||
return Math.ceil(kind + close) + GAP * 2;
|
||||
}
|
||||
}
|
||||
+179
-39
@@ -1,4 +1,5 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import type { Asset } from '../core/model/Asset';
|
||||
import type { AssetType } from '../core/model/AssetTypes';
|
||||
import { findAsset, findObjectHolder } from '../core/model/Project';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
type Widget,
|
||||
} from '../ui';
|
||||
import { TabStrip, type TabPlace } from './TabStrip';
|
||||
import type { AssetTabEditor } from './AssetTabEditor';
|
||||
import { closeDocument, type DocumentRow, openDocument, replaceDocument } from './documents';
|
||||
import { ConsolePanel } from './console/ConsolePanel';
|
||||
import { GameViewport } from './viewport/GameViewport';
|
||||
import { SceneViewport } from './viewport/SceneViewport';
|
||||
@@ -29,6 +32,7 @@ import { EditorRun } from './EditorRun';
|
||||
import { GraphEditor } from './graph/GraphEditor';
|
||||
import { Inspector } from './inspector/Inspector';
|
||||
import { ListEditor } from './list/ListEditor';
|
||||
import { resolveList } from './list/slot';
|
||||
import { AnimationEditor } from './animation/AnimationEditor';
|
||||
import { TileEditor } from './tile/TileEditor';
|
||||
import { AssetPanel } from './panels/AssetPanel';
|
||||
@@ -64,9 +68,20 @@ export type CentreTab =
|
||||
|
||||
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
|
||||
export type PluginTabId = `plugin:${string}`;
|
||||
/** What is showing: a fixed tab, a plugin's, or the kind of the open document on screen. */
|
||||
export type TabId = CentreTab | PluginTabId;
|
||||
/** An open document (#171): an asset by its id, or the list the List tab edits – there is one. */
|
||||
export type DocumentId = `asset:${string}` | 'list';
|
||||
/** A button in the tab row. */
|
||||
export type StripTab = FixedTab | PluginTabId | DocumentId;
|
||||
|
||||
const TAB_ORDER = [
|
||||
type FixedTab = (typeof FIXED_TABS)[number];
|
||||
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */
|
||||
type AssetKind = Exclude<CentreTab, FixedTab | 'list'>;
|
||||
|
||||
const FIXED_TABS = ['scene', 'game', 'plugins'] as const;
|
||||
|
||||
const PANE_ORDER = [
|
||||
'scene',
|
||||
'game',
|
||||
'graph',
|
||||
@@ -81,6 +96,7 @@ const TAB_ORDER = [
|
||||
'plugins',
|
||||
] as const;
|
||||
|
||||
/** The fixed tabs' names, and the kind shown in front of a document's. */
|
||||
const TAB_LABELS: Record<CentreTab, string> = {
|
||||
scene: 'Scene',
|
||||
game: 'Game',
|
||||
@@ -109,6 +125,10 @@ const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
|
||||
container: 'container',
|
||||
};
|
||||
|
||||
function isDocument(tab: StripTab): tab is DocumentId {
|
||||
return tab === 'list' || tab.startsWith('asset:');
|
||||
}
|
||||
|
||||
const MIN_LEFT_WIDTH = 160;
|
||||
const MAX_LEFT_WIDTH = 500;
|
||||
const MIN_RIGHT_WIDTH = 200;
|
||||
@@ -163,8 +183,10 @@ export class Editor implements PlayHost {
|
||||
readonly columnSplitter = new Splitter('y');
|
||||
private readonly workspace: HStack;
|
||||
private readonly centre: VStack;
|
||||
private readonly tabs: TabStrip<TabId>;
|
||||
private readonly tabs: TabStrip<StripTab>;
|
||||
private readonly panes: Record<CentreTab, Widget>;
|
||||
private readonly assetEditors: Record<AssetKind, AssetTabEditor<Asset>>;
|
||||
private documents: DocumentRow<DocumentId> = { docs: [], active: null };
|
||||
private readonly pluginPanes = new Map<PluginTabId, Widget>();
|
||||
/** The plugins of this editor and their switches; null until `startPlugins` has run. */
|
||||
plugins: PluginManager | null = null;
|
||||
@@ -255,9 +277,9 @@ export class Editor implements PlayHost {
|
||||
};
|
||||
|
||||
this.centre = new VStack();
|
||||
this.tabs = new TabStrip<TabId>(
|
||||
TAB_ORDER.map((tab) => ({ tab, label: TAB_LABELS[tab] })),
|
||||
(tab) => this.showTab(tab),
|
||||
this.tabs = new TabStrip<StripTab>(
|
||||
FIXED_TABS.map((tab) => ({ tab, label: TAB_LABELS[tab] })),
|
||||
(tab) => this.pickTab(tab),
|
||||
);
|
||||
this.tabs.minHeight = Theme.controlHeight + 8;
|
||||
// Written out per tab rather than derived, because a `Record<CentreTab, …>` is what makes
|
||||
@@ -287,8 +309,26 @@ export class Editor implements PlayHost {
|
||||
list: this.listEditor,
|
||||
plugins: this.pluginsPane,
|
||||
};
|
||||
this.assetEditors = {
|
||||
graph: this.graphEditor,
|
||||
statemachine: this.stateMachineEditor,
|
||||
shader: this.shaderEditor,
|
||||
particles: this.particlesEditor,
|
||||
texture: this.textureEditor,
|
||||
animation: this.animationEditor,
|
||||
tile: this.tileEditor,
|
||||
container: this.containerEditor,
|
||||
};
|
||||
// Following a call goes on in the same tab: the tab changes its document, not its place.
|
||||
this.graphEditor.onRetarget = (from, to) => {
|
||||
const next = replaceDocument<DocumentId>(this.documents, `asset:${from}`, `asset:${to}`);
|
||||
if (next === this.documents) return;
|
||||
this.documents = next;
|
||||
this.syncStrip();
|
||||
this.showDocument(`asset:${to}`);
|
||||
};
|
||||
this.centre.add(this.tabs);
|
||||
for (const tab of TAB_ORDER) {
|
||||
for (const tab of PANE_ORDER) {
|
||||
this.centre.add(this.panes[tab], 1);
|
||||
this.panes[tab].visible = tab === 'scene';
|
||||
}
|
||||
@@ -332,45 +372,37 @@ export class Editor implements PlayHost {
|
||||
pasteKeys: () => this.tab === 'animation' && this.animationEditor.canvas.pasteKeys(),
|
||||
});
|
||||
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
|
||||
// A node, a frame and a template object exist only in their own tab, so selecting one
|
||||
// switches to it. Selecting an asset never does: that is what opening it (double-click,
|
||||
// Enter, creating it) is for – see the `open` event below.
|
||||
// A node, a frame and a template object exist only in their own document, so selecting one
|
||||
// brings it up. Selecting an asset never does: that is what opening it (double-click, Enter,
|
||||
// creating it) is for – see the `open` event below.
|
||||
state.on('selection', () => {
|
||||
const s = state.selection;
|
||||
if (s?.kind === 'node') {
|
||||
const tab = state.selectedStateMachineId
|
||||
? 'statemachine'
|
||||
: state.selectedShaderId
|
||||
? 'shader'
|
||||
: state.selectedParticlesId
|
||||
? 'particles'
|
||||
: 'graph';
|
||||
if (this.tab !== tab) this.showTab(tab);
|
||||
}
|
||||
if (s?.kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
|
||||
if (
|
||||
s?.kind === 'object' &&
|
||||
this.tab !== 'container' &&
|
||||
findAsset(state.project, s.holderId)?.type === 'container'
|
||||
)
|
||||
this.showTab('container');
|
||||
if (s?.kind === 'node' || s?.kind === 'frame') this.openDocument(`asset:${s.assetId}`);
|
||||
if (s?.kind === 'object' && findAsset(state.project, s.holderId)?.type === 'container')
|
||||
this.openDocument(`asset:${s.holderId}`);
|
||||
});
|
||||
state.on('open', () => {
|
||||
const s = state.selection;
|
||||
const tab =
|
||||
s?.kind === 'asset' ? TAB_OF_ASSET[findAsset(state.project, s.assetId)?.type ?? 'sprite'] : undefined;
|
||||
if (tab) this.showTab(tab);
|
||||
if (s?.kind !== 'asset') return;
|
||||
if (findAsset(state.project, s.assetId)?.type === 'scene') this.showTab('scene');
|
||||
else this.openDocument(`asset:${s.assetId}`);
|
||||
});
|
||||
// Opening a list is a request to look at it, so this switches even when the slot is unchanged.
|
||||
state.on('list', () => {
|
||||
if (state.listSlot) this.showTab('list');
|
||||
else if (this.tab === 'list') this.showTab('scene');
|
||||
if (state.listSlot) this.openDocument('list');
|
||||
else this.closeDocument('list');
|
||||
});
|
||||
state.on('project', () => {
|
||||
this.play.syncFromModel();
|
||||
this.editorRun.invalidate();
|
||||
this.syncDocuments();
|
||||
});
|
||||
state.on('load', () => {
|
||||
this.editorRun.invalidate();
|
||||
for (const doc of this.documents.docs) this.tabs.removeTab(doc);
|
||||
this.documents = { docs: [], active: null };
|
||||
this.showTab('scene');
|
||||
});
|
||||
state.on('load', () => this.editorRun.invalidate());
|
||||
state.on('mode', () => this.editorRun.invalidate());
|
||||
}
|
||||
|
||||
@@ -379,14 +411,14 @@ export class Editor implements PlayHost {
|
||||
return Object.hasOwn(TAB_LABELS, id) || this.pluginPanes.has(`plugin:${id}`);
|
||||
}
|
||||
|
||||
/** Appends a plugin's tab after the built-in ones; `hasTab` has said the id is free. */
|
||||
/** Puts a plugin's tab after the fixed ones, ahead of the documents; `hasTab` has said the id is free. */
|
||||
addPluginTab(id: string, label: string, content: Widget): void {
|
||||
const tab: PluginTabId = `plugin:${id}`;
|
||||
this.pluginPanes.set(tab, content);
|
||||
content.visible = false;
|
||||
this.centre.addChildAt(content, this.centre.getChildIndex(this.consoleSplitter));
|
||||
content.flex = 1;
|
||||
this.tabs.addTab(tab, label);
|
||||
this.tabs.addTab(tab, label, FIXED_TABS.length + this.pluginPanes.size);
|
||||
this.pluginPanes.set(tab, content);
|
||||
this.centre.relayout();
|
||||
}
|
||||
|
||||
@@ -410,20 +442,128 @@ export class Editor implements PlayHost {
|
||||
return this.pluginPanes.get(`plugin:${id}`);
|
||||
}
|
||||
|
||||
/** The documents open in the tab row, in order, and the one showing – for the check scripts. */
|
||||
openDocuments(): { docs: DocumentId[]; active: DocumentId | null } {
|
||||
return { docs: [...this.documents.docs], active: this.documents.active };
|
||||
}
|
||||
|
||||
/** Brings a document up, opening a tab for it if it has none. Unknown assets are ignored. */
|
||||
openDocument(doc: DocumentId): void {
|
||||
if (!this.kindOf(doc)) return;
|
||||
if (this.documents.active === doc) {
|
||||
// Still worth a look at the name: the List tab may have been pointed at another list.
|
||||
this.syncStrip();
|
||||
return;
|
||||
}
|
||||
this.documents = openDocument(this.documents, doc);
|
||||
this.syncStrip();
|
||||
this.showDocument(doc);
|
||||
}
|
||||
|
||||
/** Closes a document's tab; nothing is lost, every edit is in the model already. */
|
||||
closeDocument(doc: DocumentId): void {
|
||||
if (!this.documents.docs.includes(doc)) return;
|
||||
const wasShowing = this.documents.active === doc;
|
||||
this.documents = closeDocument(this.documents, doc);
|
||||
this.tabs.removeTab(doc);
|
||||
const kind = this.kindOf(doc);
|
||||
if (kind && kind !== 'list') this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
|
||||
// The List tab edits the one slot there is; a closed tab lets go of it.
|
||||
if (doc === 'list' && this.state.listSlot) this.state.openList(null);
|
||||
if (!wasShowing) return;
|
||||
if (this.documents.active) this.showDocument(this.documents.active);
|
||||
else this.showTab('scene');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a tab by what it is. A fixed or plugin tab is shown as such; a kind of document shows the
|
||||
* one the selection is about, else the one its editor last had – the way the check scripts reach
|
||||
* a document without knowing its tab.
|
||||
*/
|
||||
showTab(tab: TabId): void {
|
||||
if (tab === 'list') {
|
||||
if (this.state.listSlot) this.openDocument('list');
|
||||
return;
|
||||
}
|
||||
if (tab in this.assetEditors) {
|
||||
const editor = this.assetEditors[tab as AssetKind];
|
||||
const id =
|
||||
editor.selectedId() ||
|
||||
editor.currentAssetId ||
|
||||
this.state.project.assets.find((a) => editor.isMine(a))?.id;
|
||||
if (id && findAsset(this.state.project, id)) this.openDocument(`asset:${id}`);
|
||||
else this.display(tab, null);
|
||||
return;
|
||||
}
|
||||
this.display(tab, tab as FixedTab | PluginTabId);
|
||||
}
|
||||
|
||||
private pickTab(tab: StripTab): void {
|
||||
if (isDocument(tab)) this.openDocument(tab);
|
||||
else this.showTab(tab);
|
||||
}
|
||||
|
||||
private kindOf(doc: DocumentId): CentreTab | null {
|
||||
if (doc === 'list') return this.state.listSlot ? 'list' : null;
|
||||
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
|
||||
const kind = type ? TAB_OF_ASSET[type] : undefined;
|
||||
return kind && kind !== 'scene' ? kind : null;
|
||||
}
|
||||
|
||||
private showDocument(doc: DocumentId): void {
|
||||
const kind = this.kindOf(doc);
|
||||
if (!kind) return;
|
||||
if (kind !== 'list') this.assetEditors[kind as AssetKind].showAsset(doc.slice('asset:'.length));
|
||||
this.display(kind, doc);
|
||||
}
|
||||
|
||||
/** What a document's tab says: the asset's name, or the list's owner and name, as its tab heads it. */
|
||||
private labelOf(doc: DocumentId): string {
|
||||
if (doc === 'list') {
|
||||
const slot = this.state.listSlot;
|
||||
return (slot && resolveList(this.state, slot)?.title) ?? '';
|
||||
}
|
||||
return findAsset(this.state.project, doc.slice('asset:'.length))?.name ?? '';
|
||||
}
|
||||
|
||||
/** Brings the row in line with `documents`: new tabs in their place, gone ones out, names current. */
|
||||
private syncStrip(): void {
|
||||
for (const tab of [...this.tabs.tabs])
|
||||
if (isDocument(tab) && !this.documents.docs.includes(tab)) this.tabs.removeTab(tab);
|
||||
const base = FIXED_TABS.length + this.pluginPanes.size;
|
||||
this.documents.docs.forEach((doc, index) => {
|
||||
const kind = this.kindOf(doc);
|
||||
if (!this.tabs.has(doc) && kind)
|
||||
this.tabs.addTab(doc, this.labelOf(doc), base + index, {
|
||||
kind: TAB_LABELS[kind],
|
||||
onClose: () => this.closeDocument(doc),
|
||||
});
|
||||
else this.tabs.setLabel(doc, this.labelOf(doc));
|
||||
});
|
||||
}
|
||||
|
||||
/** After an edit: a deleted asset takes its tab along, a renamed one renames it. */
|
||||
private syncDocuments(): void {
|
||||
for (const doc of this.documents.docs) if (!this.kindOf(doc)) this.closeDocument(doc);
|
||||
this.syncStrip();
|
||||
}
|
||||
|
||||
/** Shows the pane for `tab`, with `strip` lit in the row – none for an empty kind reached by `showTab`. */
|
||||
private display(tab: TabId, strip: StripTab | null): void {
|
||||
this.tab = tab;
|
||||
this.documents = { docs: this.documents.docs, active: strip && isDocument(strip) ? strip : null };
|
||||
// The hierarchy lists what the visible tab edits; every other tab is about the scene.
|
||||
this.hierarchy.showObjectsOf(
|
||||
tab === 'container'
|
||||
? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId)
|
||||
: () => this.state.activeScene,
|
||||
);
|
||||
for (const key of TAB_ORDER) this.panes[key].visible = key === tab;
|
||||
for (const key of PANE_ORDER) this.panes[key].visible = key === tab;
|
||||
for (const [key, pane] of this.pluginPanes) pane.visible = key === tab;
|
||||
// The view checks its own flag before it redraws, and the row around it is what switches now.
|
||||
this.viewport.visible = tab === 'scene';
|
||||
this.syncTilemapPanel();
|
||||
this.tabs.setActive(tab);
|
||||
this.tabs.setActive(strip);
|
||||
this.centre.relayout();
|
||||
ui().focus.validate();
|
||||
}
|
||||
@@ -441,8 +581,8 @@ export class Editor implements PlayHost {
|
||||
* Global x-extent of the tab buttons in the row, from the widget box – not `getBounds()`, which
|
||||
* on an untoggled ghost tab sees nothing but the label (the background is not drawn at all).
|
||||
*/
|
||||
tabRow(): { shown: TabPlace<TabId>[]; hidden: TabId[] } {
|
||||
return { shown: this.tabs.places(), hidden: this.tabs.hidden() };
|
||||
tabRow(): { shown: TabPlace<StripTab>[]; hidden: StripTab[]; order: StripTab[] } {
|
||||
return { shown: this.tabs.places(), hidden: this.tabs.hidden(), order: [...this.tabs.tabs] };
|
||||
}
|
||||
|
||||
boot(): Promise<void> {
|
||||
|
||||
+57
-27
@@ -1,4 +1,5 @@
|
||||
import { Button, HStack, Menu } from '../ui';
|
||||
import { DocumentTab } from './DocumentTab';
|
||||
import { tabRowLayout } from './tabRow';
|
||||
|
||||
/** Fixed, so that the count of hidden tabs cannot feed back into how much room the row has. */
|
||||
@@ -11,6 +12,15 @@ export interface TabPlace<T extends string> {
|
||||
/** Top edge and height, so a check can tell the tab from a label of the same text elsewhere. */
|
||||
y: number;
|
||||
h: number;
|
||||
/** A document's kind as shown before its name, and where its × is. */
|
||||
kind?: string;
|
||||
close?: { x: number; y: number };
|
||||
}
|
||||
|
||||
/** A document's tab: its kind before the name, and an × that calls `onClose`. */
|
||||
export interface DocumentSpec {
|
||||
kind: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,11 +29,10 @@ export interface TabPlace<T extends string> {
|
||||
*/
|
||||
export class TabStrip<T extends string> extends HStack {
|
||||
private readonly order: T[] = [];
|
||||
private readonly labels = new Map<T, string>();
|
||||
private readonly buttons = new Map<T, Button>();
|
||||
private readonly overflow: Button;
|
||||
private hiddenTabs: T[] = [];
|
||||
private active: T;
|
||||
private active: T | null;
|
||||
|
||||
constructor(
|
||||
tabs: readonly { tab: T; label: string }[],
|
||||
@@ -37,44 +46,59 @@ export class TabStrip<T extends string> extends HStack {
|
||||
onClick: () => this.openMenu(),
|
||||
});
|
||||
this.add(this.overflow);
|
||||
for (const { tab, label } of tabs) this.place(tab, label);
|
||||
for (const { tab, label } of tabs) this.place(tab, label, this.order.length);
|
||||
this.buttons.get(this.active)!.setToggled(true);
|
||||
}
|
||||
|
||||
/** Appends a tab at the end of the row, in front of the `»` button. */
|
||||
addTab(tab: T, label: string): void {
|
||||
this.place(tab, label);
|
||||
/** Puts a tab at `at` in the row (default: last, in front of the `»` button). */
|
||||
addTab(tab: T, label: string, at = this.order.length, document?: DocumentSpec): void {
|
||||
this.place(tab, label, at, document);
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
/** Takes a tab out of the row again; a plugin switched off (#114). Unknown tabs are ignored. */
|
||||
/** Takes a tab out of the row again. Unknown tabs are ignored. */
|
||||
removeTab(tab: T): void {
|
||||
const button = this.buttons.get(tab);
|
||||
if (!button) return;
|
||||
this.order.splice(this.order.indexOf(tab), 1);
|
||||
this.labels.delete(tab);
|
||||
this.buttons.delete(tab);
|
||||
this.hiddenTabs = this.hiddenTabs.filter((hidden) => hidden !== tab);
|
||||
button.destroy({ children: true });
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
private place(tab: T, label: string): void {
|
||||
// Half the theme padding: nine tabs at the full 8 px overflow the centre column of a 1280 px
|
||||
// window by 27 px, and a button past its edge sits under the inspector (measured, CI run 5).
|
||||
const button = new Button(label, {
|
||||
variant: 'ghost',
|
||||
padding: 4,
|
||||
onClick: () => this.onPick(tab),
|
||||
});
|
||||
this.order.push(tab);
|
||||
this.labels.set(tab, label);
|
||||
this.buttons.set(tab, button);
|
||||
this.add(button);
|
||||
this.setChildIndex(this.overflow, this.children.length - 1);
|
||||
/** A renamed document; the row measures again, since the name decides the width. */
|
||||
setLabel(tab: T, label: string): void {
|
||||
const button = this.buttons.get(tab);
|
||||
if (!button || button.text === label) return;
|
||||
button.setText(label);
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
setActive(tab: T): void {
|
||||
has(tab: T): boolean {
|
||||
return this.buttons.has(tab);
|
||||
}
|
||||
|
||||
/** Every tab in row order, hidden ones included. */
|
||||
get tabs(): readonly T[] {
|
||||
return this.order;
|
||||
}
|
||||
|
||||
private place(tab: T, label: string, at: number, document?: DocumentSpec): void {
|
||||
// Half the theme padding: nine tabs at the full 8 px overflow the centre column of a 1280 px
|
||||
// window by 27 px, and a button past its edge sits under the inspector (measured, CI run 5).
|
||||
const button = document
|
||||
? new DocumentTab(label, document.kind, () => this.onPick(tab), document.onClose)
|
||||
: new Button(label, { variant: 'ghost', padding: 4, onClick: () => this.onPick(tab) });
|
||||
this.order.splice(at, 0, tab);
|
||||
this.buttons.set(tab, button);
|
||||
// The `»` button is the first child, so a tab's index among the children is one past its place.
|
||||
this.addChildAt(button, at + 1);
|
||||
this.setChildIndex(this.overflow, this.children.length - 1);
|
||||
button.setToggled(tab === this.active);
|
||||
}
|
||||
|
||||
setActive(tab: T | null): void {
|
||||
this.active = tab;
|
||||
for (const [key, button] of this.buttons) button.setToggled(key === tab);
|
||||
// Not `resize`: the strip keeps its size while a different tab has to come into view.
|
||||
@@ -88,7 +112,12 @@ export class TabStrip<T extends string> extends HStack {
|
||||
const button = this.buttons.get(tab)!;
|
||||
if (!button.visible) continue;
|
||||
const at = button.toGlobal({ x: 0, y: 0 });
|
||||
out.push({ tab, x: at.x, right: at.x + button.w, y: at.y, h: button.h });
|
||||
const place: TabPlace<T> = { tab, x: at.x, right: at.x + button.w, y: at.y, h: button.h };
|
||||
if (button instanceof DocumentTab) {
|
||||
place.kind = button.kindLabel;
|
||||
place.close = button.closePoint();
|
||||
}
|
||||
out.push(place);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -118,10 +147,11 @@ export class TabStrip<T extends string> extends HStack {
|
||||
}
|
||||
|
||||
private openMenu(): void {
|
||||
const items = this.hiddenTabs.map((tab) => ({
|
||||
label: this.labels.get(tab)!,
|
||||
onSelect: () => this.onPick(tab),
|
||||
}));
|
||||
const items = this.hiddenTabs.map((tab) => {
|
||||
const button = this.buttons.get(tab)!;
|
||||
const kind = button instanceof DocumentTab ? `${button.kindLabel} · ` : '';
|
||||
return { label: `${kind}${button.text}`, onSelect: () => this.onPick(tab) };
|
||||
});
|
||||
new Menu(items).openBelow(this.overflow);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelineCanv
|
||||
readonly preview: PreviewCanvas;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Animation', infoWidth: INFO_WIDTH });
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.rootPicker = new Dropdown('—', { width: ROOT_WIDTH, onClick: () => this.openRootPicker() });
|
||||
this.addRowButton = new Button('+ Row', { onClick: () => this.openRowDialog() });
|
||||
this.playButton = new Button('Play', { width: 64, onClick: () => this.togglePlay() });
|
||||
@@ -44,6 +44,18 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelineCanv
|
||||
this.preview.fit();
|
||||
}
|
||||
|
||||
protected override saveView(): unknown {
|
||||
return { timeline: this.canvas.saveView(), preview: this.preview.saveView() };
|
||||
}
|
||||
|
||||
protected override restoreView(view: unknown): void {
|
||||
const saved = view as { timeline: unknown; preview: unknown };
|
||||
this.canvas.restoreView(saved.timeline);
|
||||
// Pointed at the document first: `syncExtras` would do it later and fit it over the restored view.
|
||||
this.preview.show(this.currentAssetId);
|
||||
this.preview.restoreView(saved.preview);
|
||||
}
|
||||
|
||||
/** The preview above, the timeline below, each taking half. */
|
||||
protected override body(canvas: TimelineCanvas): Widget {
|
||||
const stack = new VStack();
|
||||
@@ -65,11 +77,11 @@ export class AnimationEditor extends AssetTabEditor<AnimationAsset, TimelineCanv
|
||||
return this.canvas.isPlaying;
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
override selectedId(): string {
|
||||
return this.state.selectedAnimationId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is AnimationAsset {
|
||||
override isMine(asset: Asset): asset is AnimationAsset {
|
||||
return asset.type === 'animation';
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,17 @@ export class PreviewCanvas extends FreeViewport {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
override saveView(): unknown {
|
||||
return { view: super.saveView(), fitPending: this.fitPending };
|
||||
}
|
||||
|
||||
override restoreView(saved: unknown): void {
|
||||
const { view, fitPending } = saved as { view: unknown; fitPending: boolean };
|
||||
if (fitPending) return;
|
||||
this.fitPending = false;
|
||||
super.restoreView(view);
|
||||
}
|
||||
|
||||
override fit(): void {
|
||||
this.fitPending = false;
|
||||
super.fit();
|
||||
|
||||
@@ -143,6 +143,15 @@ export class TimelineCanvas extends Widget {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
saveView(): TimelineView {
|
||||
return { ...this.view };
|
||||
}
|
||||
|
||||
restoreView(view: unknown): void {
|
||||
this.view = { ...(view as TimelineView) };
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
get isPlaying(): boolean {
|
||||
return this.playingNow;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Asset, ContainerAsset } from '../../core/model/Asset';
|
||||
import { activeDrag } from '../../ui';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import type { EditorState } from '../state';
|
||||
import { ContainerCanvas } from './ContainerCanvas';
|
||||
@@ -9,7 +8,7 @@ const INFO_WIDTH = 110;
|
||||
|
||||
export class ContainerEditor extends AssetTabEditor<ContainerAsset, ContainerCanvas> {
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Container', infoWidth: INFO_WIDTH });
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.mount(new ContainerCanvas(state));
|
||||
}
|
||||
|
||||
@@ -29,14 +28,11 @@ export class ContainerEditor extends AssetTabEditor<ContainerAsset, ContainerCan
|
||||
return { x: p.x, y: p.y, w: this.canvas.w, h: this.canvas.h };
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
// A dragged row selects on release, and whether that lands before or after the drop is up
|
||||
// to Pixi's event order. Following it in between would swap the tab to the very container
|
||||
// being dragged, which would then nest in itself, so the target stays put while a drag runs.
|
||||
return activeDrag() ? '' : (this.state.selectedContainerId ?? '');
|
||||
override selectedId(): string {
|
||||
return this.state.selectedContainerId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is ContainerAsset {
|
||||
override isMine(asset: Asset): asset is ContainerAsset {
|
||||
return asset.type === 'container';
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import { soundLibrary } from '../viewport/SoundLibrary';
|
||||
import { textureLibrary } from '../viewport/TextureLibrary';
|
||||
import { currentFramesDialog } from './animation/FramesDialog';
|
||||
import { currentRowDialog } from './animation/RowDialog';
|
||||
import type { Editor, TabId } from './Editor';
|
||||
import type { DocumentId, Editor, TabId } from './Editor';
|
||||
import { NodeBoard } from './plugins/NodeBoard';
|
||||
import { currentFontPicker } from './font/FontPicker';
|
||||
import { currentNodePalette } from './graph/NodePalette';
|
||||
@@ -365,15 +365,12 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
// The canvas, not the whole tab: a centre measured through the header sits above the
|
||||
// drawing area.
|
||||
center: () => centreOf(graphEditor.canvas),
|
||||
pickerWidth: () => graphEditor.picker.w,
|
||||
pickerAt: () => centreOf(graphEditor.picker),
|
||||
snapping: () => graphEditor.canvas.snapEnabled,
|
||||
zoom: () => graphEditor.canvas.zoomLevel,
|
||||
crumbs: () => graphEditor.breadcrumb.crumbsDrawn(),
|
||||
},
|
||||
stateMachine: {
|
||||
current: () => editor.stateMachineEditor.currentAssetId,
|
||||
pickerAt: () => centreOf(editor.stateMachineEditor.picker),
|
||||
nodeAt: (nodeId: string) => editor.stateMachineEditor.canvas.nodeGlobal(nodeId),
|
||||
nodeLabel: (nodeId: string) => editor.stateMachineEditor.canvas.nodeLabel(nodeId),
|
||||
nodeRect: (nodeId: string) => editor.stateMachineEditor.canvas.nodeRect(nodeId),
|
||||
@@ -401,7 +398,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
},
|
||||
particles: {
|
||||
current: () => editor.particlesEditor.currentAssetId,
|
||||
pickerAt: () => centreOf(editor.particlesEditor.picker),
|
||||
info: () => editor.particlesEditor.shownInfo,
|
||||
nodeAt: (nodeId: string) => editor.particlesEditor.canvas.nodeGlobal(nodeId),
|
||||
nodeLabel: (nodeId: string) => editor.particlesEditor.canvas.nodeLabel(nodeId),
|
||||
@@ -426,7 +422,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
},
|
||||
shader: {
|
||||
current: () => editor.shaderEditor.currentAssetId,
|
||||
pickerAt: () => centreOf(editor.shaderEditor.picker),
|
||||
info: () => editor.shaderEditor.shownInfo,
|
||||
nodeAt: (nodeId: string) => editor.shaderEditor.canvas.nodeGlobal(nodeId),
|
||||
nodeLabel: (nodeId: string) => editor.shaderEditor.canvas.nodeLabel(nodeId),
|
||||
@@ -474,7 +469,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
gameTextInfo: (id: string) => gameView.textInfo(id),
|
||||
texture: {
|
||||
current: () => textureEditor.currentAssetId,
|
||||
pickerWidth: () => textureEditor.picker.w,
|
||||
/** Where an image pixel of the shown sheet sits on screen (frames are dragged in image coordinates). */
|
||||
pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y),
|
||||
rect: () => textureEditor.canvasRect(),
|
||||
@@ -630,7 +624,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
},
|
||||
container: {
|
||||
current: () => containerEditor.currentAssetId,
|
||||
pickerWidth: () => containerEditor.picker.w,
|
||||
center: () => centreOf(containerEditor),
|
||||
rect: () => containerEditor.canvasRect(),
|
||||
/** Where a template object is drawn, so a check can click or sample it. */
|
||||
@@ -782,6 +775,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
?.desynchronized === true,
|
||||
tab: () => editor.tab,
|
||||
showTab: (tab: TabId) => editor.showTab(tab),
|
||||
/** The open documents in tab order and the one showing (#171); a document is `asset:<id>` or `list`. */
|
||||
documents: () => editor.openDocuments(),
|
||||
openDocument: (doc: DocumentId) => editor.openDocument(doc),
|
||||
closeDocument: (doc: DocumentId) => editor.closeDocument(doc),
|
||||
plugins: {
|
||||
/** Every file in `data/plugins/` and what became of it. */
|
||||
records: () => [...(editor.plugins?.records ?? [])],
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The open documents of the tab row (#171): which tabs there are, in which order, and which one is
|
||||
* showing. `null` as the active one means a fixed tab (Scene, Game, Plugins, a plugin's) is showing.
|
||||
*/
|
||||
export interface DocumentRow<T> {
|
||||
docs: readonly T[];
|
||||
active: T | null;
|
||||
}
|
||||
|
||||
/** Opens `doc`: an open one merely becomes active, a new one goes right of the active document, or last. */
|
||||
export function openDocument<T>(row: DocumentRow<T>, doc: T): DocumentRow<T> {
|
||||
if (row.docs.includes(doc)) return { docs: row.docs, active: doc };
|
||||
const at = row.active === null ? -1 : row.docs.indexOf(row.active);
|
||||
const docs = [...row.docs];
|
||||
docs.splice(at < 0 ? docs.length : at + 1, 0, doc);
|
||||
return { docs, active: doc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes `doc`. Closing the one on screen shows its left neighbour, or the right one when it was
|
||||
* first; with none left, `active` is null and the caller goes back to the scene.
|
||||
*/
|
||||
export function closeDocument<T>(row: DocumentRow<T>, doc: T): DocumentRow<T> {
|
||||
const at = row.docs.indexOf(doc);
|
||||
if (at < 0) return row;
|
||||
const docs = row.docs.filter((d) => d !== doc);
|
||||
if (row.active !== doc) return { docs, active: row.active };
|
||||
return { docs, active: docs[Math.max(0, at - 1)] ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts `to` where `from` is – a tab that goes on to another document, like a behavior followed
|
||||
* into a call. Where `to` was open already, that tab goes: a document has one tab.
|
||||
*/
|
||||
export function replaceDocument<T>(row: DocumentRow<T>, from: T, to: T): DocumentRow<T> {
|
||||
if (from === to || !row.docs.includes(from)) return row;
|
||||
const docs = row.docs.filter((d) => d !== to).map((d) => (d === from ? to : d));
|
||||
return { docs, active: row.active === from || row.active === to ? to : row.active };
|
||||
}
|
||||
@@ -240,6 +240,19 @@ export class GraphCanvas extends Widget {
|
||||
this.fitView();
|
||||
}
|
||||
|
||||
saveView(): GraphViewState {
|
||||
return { pan: { ...this.pan }, zoom: this.zoom, needsFit: this.needsFit };
|
||||
}
|
||||
|
||||
restoreView(view: unknown): void {
|
||||
const saved = view as GraphViewState;
|
||||
if (saved.needsFit) return;
|
||||
this.pan = { ...saved.pan };
|
||||
this.zoom = saved.zoom;
|
||||
this.needsFit = false;
|
||||
this.applyView();
|
||||
}
|
||||
|
||||
/** Takes effect on the next drag only – turning it on must not shuffle the graph. */
|
||||
setSnapping(on: boolean): void {
|
||||
this.snapping = on;
|
||||
@@ -1322,6 +1335,13 @@ export class GraphCanvas extends Widget {
|
||||
|
||||
type Rect = { x: number; y: number; w: number; h: number };
|
||||
|
||||
/** Zoom and scroll of one graph, kept while another document is on screen (#171). */
|
||||
interface GraphViewState {
|
||||
pan: { x: number; y: number };
|
||||
zoom: number;
|
||||
needsFit: boolean;
|
||||
}
|
||||
|
||||
function globalPoint(target: Container, local: { x: number; y: number }): { x: number; y: number } {
|
||||
const g = target.toGlobal({ x: local.x, y: local.y });
|
||||
return { x: g.x, y: g.y };
|
||||
|
||||
@@ -14,9 +14,11 @@ export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
|
||||
private readonly snap = new SnapToggle(() => this.canvas);
|
||||
readonly breadcrumb = new Breadcrumb();
|
||||
private trail: Trail = [];
|
||||
/** A call followed into another behavior: the tab goes on to that document (#171). */
|
||||
onRetarget: (from: string, to: string) => void = () => undefined;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Behavior', infoWidth: INFO_WIDTH });
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.breadcrumb.onPick = (index) => this.open(this.trail[index]!, backTo(this.trail, index));
|
||||
this.mount(new GraphCanvas(state), this.snap.widgets, this.breadcrumb);
|
||||
this.canvas.onOpenBehavior = (id) => this.open(id, descend(this.trail, this.currentAssetId, id));
|
||||
@@ -29,10 +31,26 @@ export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
|
||||
* appearing afterwards would cut the bottom of the fitted graph off.
|
||||
*/
|
||||
private open(assetId: string, trail: Trail): void {
|
||||
const from = this.currentAssetId;
|
||||
this.trail = trail;
|
||||
this.showTrail();
|
||||
// A fresh look: fitted below this trail, not the view (and trail) kept from an earlier visit.
|
||||
this.forgetView(assetId);
|
||||
this.showAsset(assetId);
|
||||
this.onRetarget(from, assetId);
|
||||
this.state.select({ kind: 'asset', assetId });
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** The trail belongs to the tab's document, so it comes back with the view. */
|
||||
protected override saveView(): unknown {
|
||||
return { canvas: super.saveView(), trail: this.trail };
|
||||
}
|
||||
|
||||
protected override restoreView(view: unknown): void {
|
||||
const saved = view as { canvas: unknown; trail: Trail };
|
||||
this.trail = saved.trail;
|
||||
this.showTrail();
|
||||
super.restoreView(saved.canvas);
|
||||
}
|
||||
|
||||
protected override refresh(): void {
|
||||
@@ -49,11 +67,11 @@ export class GraphEditor extends AssetTabEditor<BehaviorAsset, GraphCanvas> {
|
||||
if (this.breadcrumb.show(names)) this.relayout();
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
override selectedId(): string {
|
||||
return this.state.selectedBehaviorId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is BehaviorAsset {
|
||||
override isMine(asset: Asset): asset is BehaviorAsset {
|
||||
return asset.type === 'behavior';
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas>
|
||||
private previewWidth = 280;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Particles', infoWidth: INFO_WIDTH });
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.preview = new ParticlePreview(state);
|
||||
this.slider.onChange = (value) => {
|
||||
this.preview.hold(value);
|
||||
@@ -130,11 +130,11 @@ export class ParticlesEditor extends AssetTabEditor<ParticlesAsset, GraphCanvas>
|
||||
this.slider.setValue(holding ? time : time % this.span);
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
override selectedId(): string {
|
||||
return this.state.selectedParticlesId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is ParticlesAsset {
|
||||
override isMine(asset: Asset): asset is ParticlesAsset {
|
||||
return asset.type === 'particles';
|
||||
}
|
||||
|
||||
|
||||
@@ -17,17 +17,17 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
|
||||
private readonly snap = new SnapToggle(() => this.canvas);
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Shader', infoWidth: INFO_WIDTH });
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.mount(new GraphCanvas(state, 'shader'), this.snap.widgets);
|
||||
// Play swaps in the runtime copy of the project, and back.
|
||||
state.on('mode', () => this.refresh());
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
override selectedId(): string {
|
||||
return this.state.selectedShaderId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is ShaderAsset {
|
||||
override isMine(asset: Asset): asset is ShaderAsset {
|
||||
return asset.type === 'shader';
|
||||
}
|
||||
|
||||
|
||||
@@ -17,17 +17,17 @@ export class StateMachineEditor extends AssetTabEditor<StateMachineAsset, GraphC
|
||||
private readonly snap = new SnapToggle(() => this.canvas);
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'State machine', infoWidth: INFO_WIDTH });
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.mount(new GraphCanvas(state, 'state machine'), this.snap.widgets);
|
||||
// Play swaps in the runtime copy of the project, and back.
|
||||
state.on('mode', () => this.refresh());
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
override selectedId(): string {
|
||||
return this.state.selectedStateMachineId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is StateMachineAsset {
|
||||
override isMine(asset: Asset): asset is StateMachineAsset {
|
||||
return asset.type === 'statemachine';
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface TabRowInput<T extends string> {
|
||||
gap: number;
|
||||
/** Width of the button that opens the hidden ones; only claimed when something is hidden. */
|
||||
overflowWidth: number;
|
||||
active: T;
|
||||
active: T | null;
|
||||
}
|
||||
|
||||
export interface TabRowLayout<T extends string> {
|
||||
|
||||
@@ -178,6 +178,18 @@ export class TextureCanvas extends Widget {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
saveView(): { view: SheetView; needsFit: boolean } {
|
||||
return { view: { ...this.view }, needsFit: this.needsFit };
|
||||
}
|
||||
|
||||
restoreView(saved: unknown): void {
|
||||
const { view, needsFit } = saved as { view: SheetView; needsFit: boolean };
|
||||
if (needsFit) return;
|
||||
this.view = { ...view };
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
setCollisionMode(on: boolean): void {
|
||||
if (this.collision === on) return;
|
||||
this.collision = on;
|
||||
|
||||
@@ -15,7 +15,7 @@ export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
|
||||
private readonly collisionButton: Button;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Texture', infoWidth: INFO_WIDTH });
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.sliceButton = new Button('Slice…', { onClick: () => this.openSlice() });
|
||||
this.collisionButton = new Button('Collision', {
|
||||
toggle: true,
|
||||
@@ -40,11 +40,11 @@ export class TextureEditor extends AssetTabEditor<TextureAsset, TextureCanvas> {
|
||||
return { x: p.x, y: p.y, w: this.canvas.w, h: this.canvas.h };
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
override selectedId(): string {
|
||||
return this.state.selectedTextureId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is TextureAsset {
|
||||
override isMine(asset: Asset): asset is TextureAsset {
|
||||
return asset.type === 'texture';
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,18 @@ export class TileCanvas extends Widget {
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
saveView(): { view: GraphView; needsFit: boolean } {
|
||||
return { view: { pan: { ...this.view.pan }, zoom: this.view.zoom }, needsFit: this.needsFit };
|
||||
}
|
||||
|
||||
restoreView(saved: unknown): void {
|
||||
const { view, needsFit } = saved as { view: GraphView; needsFit: boolean };
|
||||
if (needsFit) return;
|
||||
this.view = { pan: { ...view.pan }, zoom: view.zoom };
|
||||
this.needsFit = false;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Advances the animation; only called while the tab is visible (see `Editor.tick`). */
|
||||
tick(dt: number): void {
|
||||
if (!this.animates) return;
|
||||
|
||||
@@ -25,7 +25,7 @@ export class TileEditor extends AssetTabEditor<TileAsset, TileCanvas> {
|
||||
readonly strip: PictureStrip;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Tile', infoWidth: INFO_WIDTH });
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.kindPicker = new Dropdown('—', { width: KIND_WIDTH, onClick: () => this.openKindPicker() });
|
||||
this.strip = new PictureStrip(state);
|
||||
this.mount(new TileCanvas(state), [this.kindPicker]);
|
||||
@@ -49,11 +49,11 @@ export class TileEditor extends AssetTabEditor<TileAsset, TileCanvas> {
|
||||
return this.canvas.animates;
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
override selectedId(): string {
|
||||
return this.state.selectedTileId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is TileAsset {
|
||||
override isMine(asset: Asset): asset is TileAsset {
|
||||
return asset.type === 'tile';
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,16 @@ export abstract class FreeViewport extends WorldViewport {
|
||||
this.screenOrigin = this.getGlobalPosition();
|
||||
}
|
||||
|
||||
/** Where the camera stands, to come back to (#171). */
|
||||
saveView(): unknown {
|
||||
return { ...this.view };
|
||||
}
|
||||
|
||||
restoreView(view: unknown): void {
|
||||
this.view = { ...(view as FreeViewport['view']) };
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
protected resetView(): void {
|
||||
this.view = { x: 0, y: 0, zoom: 1 };
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface ButtonOptions {
|
||||
|
||||
export class Button extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly labelText: Text;
|
||||
protected readonly labelText: Text;
|
||||
/** The label as set; `labelText` holds the possibly truncated version on screen. */
|
||||
private fullText: string;
|
||||
private pressed = false;
|
||||
@@ -32,7 +32,7 @@ export class Button extends Widget {
|
||||
variant: NonNullable<ButtonOptions['variant']>;
|
||||
protected readonly align: NonNullable<ButtonOptions['align']>;
|
||||
protected readonly fixedWidth: number;
|
||||
private readonly padding: number;
|
||||
protected readonly padding: number;
|
||||
private readonly style: TextStyleToken;
|
||||
private readonly toggle: boolean;
|
||||
onClick?: () => void;
|
||||
|
||||
Reference in new issue
Block a user