Asset panel grouped by kind with icons: assets are listed under a header per kind (Sprites, Behaviors, ...) with the count on the right, empty groups are not shown, every group starts folded when a project is opened, a click on the header folds and unfolds it, selecting an asset elsewhere unfolds its group and folding the group of the selected asset clears the selection; every asset kind has an SVG icon in src/editor/panels/icons/ drawn before the name, the pure rule for grouping and sorting lives in assetGroups.ts; the same icons replace the kind word in document tabs and the overflow menu (a list icon for the List tab, which also fixes the close x lying over the end of the active tab's text), stand before every entry of the + menu (Button got an icon option) and on the right of scene object rows instead of the type name; the colour swatch next to sprite names is gone from both panels; the SVG loader moved to src/ui/svg.ts for both icon sets; test hook assetRow unfolds the group and scrolls to the row, assetRowShown only reads, new hook menuIcons; e2e 4g and 4h, 14 and 44a check the icons (#176)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No files matched your search
@@ -9,21 +9,21 @@ const CLOSE_SLOP = 4;
|
||||
const MAX_NAME_WIDTH = 160;
|
||||
|
||||
/**
|
||||
* A tab that stands for an open document (#171): its kind in muted type before the name, so three
|
||||
* A tab that stands for an open document (#171): the icon of its kind 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,
|
||||
icon: 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') });
|
||||
// `toggle`: measured bold, so the active tab does not cut its own name short.
|
||||
super(label, { variant: 'ghost', padding: 4, align: 'left', toggle: true, icon, onClick: onPick });
|
||||
this.closeText = new Text({ text: CLOSE, style: textStyle('muted') });
|
||||
this.closeText.eventMode = 'static';
|
||||
this.closeText.cursor = Theme.hoverCursor;
|
||||
@@ -37,7 +37,7 @@ export class DocumentTab extends Button {
|
||||
this.on('pointerup', (e: FederatedPointerEvent) => {
|
||||
if (e.button === 1) this.onClose();
|
||||
});
|
||||
this.addChild(this.kindText, this.closeText);
|
||||
this.addChild(this.closeText);
|
||||
}
|
||||
|
||||
/** The × in global coordinates, for the check scripts. */
|
||||
@@ -46,28 +46,24 @@ export class DocumentTab extends Button {
|
||||
return { x: at.x + this.closeText.width / 2, y: at.y + this.closeText.height / 2 };
|
||||
}
|
||||
|
||||
/** The kind the icon stands for, as a word – the icon itself says nothing to a check script. */
|
||||
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 };
|
||||
const cap = MAX_NAME_WIDTH + this.padding * 2 + this.iconSpace();
|
||||
return { w: Math.min(own.w, cap) + this.closeWidth(), h: own.h };
|
||||
}
|
||||
|
||||
protected override labelSlot(): number {
|
||||
return super.labelSlot() - this.extraWidth();
|
||||
return super.labelSlot() - this.closeWidth();
|
||||
}
|
||||
|
||||
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;
|
||||
this.closeText.style = textStyle(this.toggled ? 'accent' : 'muted');
|
||||
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(
|
||||
@@ -78,10 +74,7 @@ export class DocumentTab extends Button {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
private closeWidth(): number {
|
||||
return Math.ceil(CanvasTextMetrics.measureText(CLOSE, textStyle('muted')).width) + GAP;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { resolveList } from './list/slot';
|
||||
import { AnimationEditor } from './animation/AnimationEditor';
|
||||
import { TileEditor } from './tile/TileEditor';
|
||||
import { AssetPanel } from './panels/AssetPanel';
|
||||
import { ASSET_ICON_SVG, LIST_ICON_SVG } from './panels/assetIcons';
|
||||
import { HierarchyPanel } from './panels/HierarchyPanel';
|
||||
import { TilemapPanel } from './panels/TilemapPanel';
|
||||
import { Toolbar } from './panels/Toolbar';
|
||||
@@ -96,7 +97,7 @@ const PANE_ORDER = [
|
||||
'plugins',
|
||||
] as const;
|
||||
|
||||
/** The fixed tabs' names, and the kind shown in front of a document's. */
|
||||
/** The fixed tabs' names, and a document's kind as a word (its tab shows the icon). */
|
||||
const TAB_LABELS: Record<CentreTab, string> = {
|
||||
scene: 'Scene',
|
||||
game: 'Game',
|
||||
@@ -526,6 +527,12 @@ export class Editor implements PlayHost {
|
||||
return findAsset(this.state.project, doc.slice('asset:'.length))?.name ?? '';
|
||||
}
|
||||
|
||||
/** The icon in front of a document's name: its asset type's, as in the asset panel. */
|
||||
private iconOf(doc: DocumentId): string {
|
||||
const type = doc === 'list' ? undefined : findAsset(this.state.project, doc.slice('asset:'.length))?.type;
|
||||
return type ? ASSET_ICON_SVG[type] : LIST_ICON_SVG;
|
||||
}
|
||||
|
||||
/** 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])
|
||||
@@ -536,6 +543,7 @@ export class Editor implements PlayHost {
|
||||
if (!this.tabs.has(doc) && kind)
|
||||
this.tabs.addTab(doc, this.labelOf(doc), base + index, {
|
||||
kind: TAB_LABELS[kind],
|
||||
icon: this.iconOf(doc),
|
||||
onClose: () => this.closeDocument(doc),
|
||||
});
|
||||
else this.tabs.setLabel(doc, this.labelOf(doc));
|
||||
|
||||
@@ -17,9 +17,11 @@ export interface TabPlace<T extends string> {
|
||||
close?: { x: number; y: number };
|
||||
}
|
||||
|
||||
/** A document's tab: its kind before the name, and an × that calls `onClose`. */
|
||||
/** A document's tab: the icon of its kind before the name, and an × that calls `onClose`. */
|
||||
export interface DocumentSpec {
|
||||
/** The kind as a word, for the check scripts; the tab shows `icon`. */
|
||||
kind: string;
|
||||
icon: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -30,6 +32,8 @@ export interface DocumentSpec {
|
||||
export class TabStrip<T extends string> extends HStack {
|
||||
private readonly order: T[] = [];
|
||||
private readonly buttons = new Map<T, Button>();
|
||||
/** A document's icon, which the `»` menu shows in front of its name too. */
|
||||
private readonly icons = new Map<T, string>();
|
||||
private readonly overflow: Button;
|
||||
private hiddenTabs: T[] = [];
|
||||
private active: T | null;
|
||||
@@ -62,6 +66,7 @@ export class TabStrip<T extends string> extends HStack {
|
||||
if (!button) return;
|
||||
this.order.splice(this.order.indexOf(tab), 1);
|
||||
this.buttons.delete(tab);
|
||||
this.icons.delete(tab);
|
||||
this.hiddenTabs = this.hiddenTabs.filter((hidden) => hidden !== tab);
|
||||
button.destroy({ children: true });
|
||||
this.relayout();
|
||||
@@ -88,10 +93,11 @@ export class TabStrip<T extends string> extends HStack {
|
||||
// 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 DocumentTab(label, document.kind, document.icon, () => 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);
|
||||
if (document) this.icons.set(tab, document.icon);
|
||||
// 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);
|
||||
@@ -149,8 +155,7 @@ export class TabStrip<T extends string> extends HStack {
|
||||
private openMenu(): void {
|
||||
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) };
|
||||
return { label: button.text, icon: this.icons.get(tab), onSelect: () => this.onPick(tab) };
|
||||
});
|
||||
new Menu(items).openBelow(this.overflow);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type Application, Color, type ColorSource, type Container, Mesh, Rectangle, Text } from 'pixi.js';
|
||||
import { NameTransitionCommand } from '../core/commands/graphCommands';
|
||||
import { previewObjectId } from '../core/model/animationPreview';
|
||||
import type { AssetType } from '../core/model/AssetTypes';
|
||||
import { resolveObjects } from '../core/model/instance';
|
||||
import { findAsset, findAssetOf, findObjectHolder } from '../core/model/Project';
|
||||
import type { FrameRect } from '../core/model/texture';
|
||||
@@ -259,6 +260,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
overlayRect,
|
||||
/** Entries of the open menu in drawn order, `null` for a separator line (#43). */
|
||||
menuRows: () => currentMenu()?.rowsDrawn() ?? null,
|
||||
/** Per entry of `menuRows`, whether an icon stands in front of it. */
|
||||
menuIcons: () => currentMenu()?.iconsDrawn() ?? null,
|
||||
textOverflows,
|
||||
labelsDropped,
|
||||
shownLabel,
|
||||
@@ -304,8 +307,13 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
/** The panel-to-view drag in flight, if any – a drop that never started looks like a refused one. */
|
||||
dragPayload: () => activeDrag(),
|
||||
/** An asset row by id – two assets can share a name with the container picker's label. */
|
||||
assetRow: (id: string) => assets.rowGlobal(id),
|
||||
/** An asset's row, its group unfolded first as a user would; `assetRowShown` leaves it folded. */
|
||||
assetRow: (id: string) => assets.rowGlobal(id, true),
|
||||
assetRowShown: (id: string) => assets.rowGlobal(id),
|
||||
assetRowColor: (id: string) => assets.rowColor(id),
|
||||
/** The asset panel's groups in order; a heading's row is `assetRow('group:<type>')`. */
|
||||
assetGroups: () => assets.groups(),
|
||||
assetGroupIcon: (type: AssetType) => assets.groupIcon(type),
|
||||
/** The panel's own rectangle – what tells a row in view from one scrolled past its edge. */
|
||||
assetsRect: () => rectOf(assets),
|
||||
gameInfo: () => gameView.debugInfo(),
|
||||
@@ -655,6 +663,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
/** Centre of a hierarchy row, clear of the chevron – for clicks that must hit the row itself. */
|
||||
hierarchyRow: (id: string) => hierarchy.rowPosition(id),
|
||||
hierarchyRowColor: (id: string) => hierarchy.rowColor(id),
|
||||
/** Centre of the type icon at the right edge of a hierarchy row. */
|
||||
hierarchyIcon: (id: string) => hierarchy.iconPosition(id),
|
||||
hierarchyExpandAll: () => hierarchy.expandAll(),
|
||||
hierarchyCollapseAll: () => hierarchy.collapseAll(),
|
||||
pixelAt,
|
||||
|
||||
@@ -10,12 +10,7 @@ import {
|
||||
} from '../../core/commands/assetCommands';
|
||||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||
import { nextAssetName } from '../../core/commands/naming';
|
||||
import {
|
||||
ASSET_TYPE_LABELS,
|
||||
type AssetType,
|
||||
hasColorSwatch,
|
||||
isPlaceableType,
|
||||
} from '../../core/model/AssetTypes';
|
||||
import { ASSET_TYPE_LABELS, type AssetType, isPlaceableType } from '../../core/model/AssetTypes';
|
||||
import type { TextureAsset } from '../../core/model/Asset';
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
@@ -26,6 +21,7 @@ import type { SoundStore } from '../../core/store/SoundStore';
|
||||
import type { TextureStore } from '../../core/store/TextureStore';
|
||||
import {
|
||||
Button,
|
||||
type ListItem,
|
||||
ListView,
|
||||
Menu,
|
||||
type MenuEntry,
|
||||
@@ -34,6 +30,8 @@ import {
|
||||
ScrollContainer,
|
||||
startDrag,
|
||||
} from '../../ui';
|
||||
import { ASSET_GROUP_LABELS, groupAssets } from './assetGroups';
|
||||
import { ASSET_ICON_SVG } from './assetIcons';
|
||||
import { assetMenuGroups } from './assetMenu';
|
||||
import { confirmDeleteAsset } from '../actions';
|
||||
import type { EditorState } from '../state';
|
||||
@@ -41,8 +39,13 @@ 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; creates and deletes them. Placing is a drag into a view.
|
||||
* 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.
|
||||
@@ -50,6 +53,10 @@ import { openImagePicker } from '../texture/ImagePicker';
|
||||
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,
|
||||
@@ -72,11 +79,16 @@ export class AssetPanel extends Panel {
|
||||
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.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());
|
||||
state.on('load', () => this.refresh());
|
||||
// A different project brings different assets – start folded again.
|
||||
state.on('load', () => {
|
||||
this.unfolded.clear();
|
||||
this.refresh();
|
||||
});
|
||||
state.on('selection', () => this.syncSelection());
|
||||
this.refresh();
|
||||
}
|
||||
@@ -85,6 +97,7 @@ export class AssetPanel extends Panel {
|
||||
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) =>
|
||||
@@ -97,36 +110,79 @@ export class AssetPanel extends Panel {
|
||||
* 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 sceneHint(id: string): string {
|
||||
private sceneMarks(id: string): string {
|
||||
const { activeSceneId, startSceneId } = this.state.project;
|
||||
const marks = `${id === startSceneId ? '▶' : ''}${id === activeSceneId ? '●' : ''}`;
|
||||
return marks ? `scene ${marks}` : 'scene';
|
||||
return `${id === startSceneId ? '▶' : ''}${id === activeSceneId ? '●' : ''}`;
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
this.list.setItems(
|
||||
this.state.project.assets.map((asset) => ({
|
||||
id: asset.id,
|
||||
label: asset.name,
|
||||
hint: asset.type === 'scene' ? this.sceneHint(asset.id) : asset.type,
|
||||
swatch: hasColorSwatch(asset.type) ? Number(asset.props.color ?? 0) : undefined,
|
||||
overridden: assetDeviates(this.state.project, asset),
|
||||
})),
|
||||
);
|
||||
this.syncSelection();
|
||||
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,
|
||||
hint: 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;
|
||||
this.list.select(id, true);
|
||||
// A selection made anywhere else – a graph node, a picker, a fresh asset – has to be reachable
|
||||
// in the list, and with fifteen kinds of asset in a project the row is often below the fold.
|
||||
if (id) {
|
||||
const index = this.state.project.assets.findIndex((a) => a.id === id);
|
||||
if (index >= 0) this.scroll.ensureVisible(index * this.list.rowHeight, this.list.rowHeight);
|
||||
// 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 {
|
||||
@@ -199,13 +255,30 @@ export class AssetPanel extends Panel {
|
||||
this.state.openAsset(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 {
|
||||
/**
|
||||
* 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, 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,10 @@ import type { Command } from '../../core/commands/Command';
|
||||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
|
||||
import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { hasColorSwatch } from '../../core/model/AssetTypes';
|
||||
import { ancestorIdsOf, childIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy';
|
||||
import { isLiveInstance, isTemplateContent, resolvedHolder } from '../../core/model/instance';
|
||||
import { findAsset, type ObjectHolder } from '../../core/model/Project';
|
||||
import { deviationMarks } from '../../core/properties/deviation';
|
||||
import { resolveSceneObjectProps } from '../../core/properties/resolve';
|
||||
import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui';
|
||||
import {
|
||||
addCollider,
|
||||
@@ -17,6 +15,7 @@ import {
|
||||
createContainer,
|
||||
removeObjects,
|
||||
} from '../actions';
|
||||
import { ASSET_ICON_SVG } from './assetIcons';
|
||||
import { type EditorState, objectSelection } from '../state';
|
||||
|
||||
/**
|
||||
@@ -139,6 +138,11 @@ export class HierarchyPanel extends Panel {
|
||||
return this.list.rowGlobal(id);
|
||||
}
|
||||
|
||||
/** Global centre of the type icon at a row's right edge (test hook). */
|
||||
iconPosition(id: string): { x: number; y: number } | null {
|
||||
return this.list.iconGlobal(id);
|
||||
}
|
||||
|
||||
expandAll(): void {
|
||||
for (const object of this.holder()?.objects ?? []) this.expanded.add(object.id);
|
||||
this.refresh();
|
||||
@@ -243,13 +247,12 @@ export class HierarchyPanel extends Panel {
|
||||
this.list.setItems(
|
||||
(holder ? visibleTreeOrder(holder, this.expanded) : []).map(({ object, depth, hasChildren }) => {
|
||||
const asset = findAsset(this.state.project, object.assetId);
|
||||
const props = asset ? resolveSceneObjectProps(this.state.project, asset, object) : {};
|
||||
return {
|
||||
id: object.id,
|
||||
label: object.name || asset?.name || '?',
|
||||
hint: asset?.type ?? '',
|
||||
icon: asset && ASSET_ICON_SVG[asset.type],
|
||||
iconAtEnd: true,
|
||||
depth,
|
||||
swatch: asset && hasColorSwatch(asset.type) ? Number(props.color ?? 0) : undefined,
|
||||
expanded: hasChildren ? this.expanded.has(object.id) : undefined,
|
||||
overridden: marks.has(object.id),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import type { AssetType } from '../../core/model/AssetTypes';
|
||||
|
||||
/**
|
||||
* Group headings of the asset panel. Plural, because a heading names what is under it – and so no
|
||||
* heading reads like the `+` menu entry that creates one of its kind.
|
||||
*/
|
||||
export const ASSET_GROUP_LABELS: Record<AssetType, string> = {
|
||||
scene: 'Scenes',
|
||||
camera: 'Cameras',
|
||||
sprite: 'Sprites',
|
||||
text: 'Texts',
|
||||
behavior: 'Behaviors',
|
||||
variables: 'Variables',
|
||||
collider: 'Colliders',
|
||||
rigidbody: 'Rigidbodies',
|
||||
container: 'Containers',
|
||||
texture: 'Textures',
|
||||
animation: 'Animations',
|
||||
tile: 'Tiles',
|
||||
tilemap: 'Tilemaps',
|
||||
statemachine: 'State machines',
|
||||
shader: 'Shaders',
|
||||
font: 'Fonts',
|
||||
sound: 'Sounds',
|
||||
particles: 'Particles',
|
||||
};
|
||||
|
||||
export interface AssetGroup {
|
||||
type: AssetType;
|
||||
assets: Asset[];
|
||||
}
|
||||
|
||||
/** Case aside and digits as numbers, so `Level 2` comes before `Level 10`. */
|
||||
const collator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' });
|
||||
|
||||
/**
|
||||
* The asset panel's rows (#176): one group per type that has assets, groups and the assets in
|
||||
* each alphabetical. Equal names keep the project's order, so a row does not jump on a rebuild.
|
||||
*/
|
||||
export function groupAssets(assets: readonly Asset[]): AssetGroup[] {
|
||||
const byType = new Map<AssetType, Asset[]>();
|
||||
for (const asset of assets) {
|
||||
const list = byType.get(asset.type);
|
||||
if (list) list.push(asset);
|
||||
else byType.set(asset.type, [asset]);
|
||||
}
|
||||
return [...byType]
|
||||
.map(([type, list]) => ({ type, assets: list.sort((a, b) => collator.compare(a.name, b.name)) }))
|
||||
.sort((a, b) => collator.compare(ASSET_GROUP_LABELS[a.type], ASSET_GROUP_LABELS[b.type]));
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* One icon per asset type (#176), one SVG file each in `icons/`, drawn by `drawSvg`: the asset
|
||||
* panel's groups, the `+` menu, the document tabs and the scene objects.
|
||||
*/
|
||||
import type { AssetType } from '../../core/model/AssetTypes';
|
||||
import animation from './icons/animation.svg?raw';
|
||||
import behavior from './icons/behavior.svg?raw';
|
||||
import camera from './icons/camera.svg?raw';
|
||||
import collider from './icons/collider.svg?raw';
|
||||
import container from './icons/container.svg?raw';
|
||||
import font from './icons/font.svg?raw';
|
||||
import list from './icons/list.svg?raw';
|
||||
import particles from './icons/particles.svg?raw';
|
||||
import rigidbody from './icons/rigidbody.svg?raw';
|
||||
import scene from './icons/scene.svg?raw';
|
||||
import shader from './icons/shader.svg?raw';
|
||||
import sound from './icons/sound.svg?raw';
|
||||
import sprite from './icons/sprite.svg?raw';
|
||||
import statemachine from './icons/statemachine.svg?raw';
|
||||
import text from './icons/text.svg?raw';
|
||||
import texture from './icons/texture.svg?raw';
|
||||
import tile from './icons/tile.svg?raw';
|
||||
import tilemap from './icons/tilemap.svg?raw';
|
||||
import variables from './icons/variables.svg?raw';
|
||||
|
||||
export const ASSET_ICON_SVG: Record<AssetType, string> = {
|
||||
animation,
|
||||
behavior,
|
||||
camera,
|
||||
collider,
|
||||
container,
|
||||
font,
|
||||
particles,
|
||||
rigidbody,
|
||||
scene,
|
||||
shader,
|
||||
sound,
|
||||
sprite,
|
||||
statemachine,
|
||||
text,
|
||||
texture,
|
||||
tile,
|
||||
tilemap,
|
||||
variables,
|
||||
};
|
||||
|
||||
/** The List tab's: it edits a list value, which is no asset. */
|
||||
export const LIST_ICON_SVG = list;
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<rect x="-7" y="-6" width="14" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M-2 -3 L3.5 0 L-2 3 Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 260 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<rect x="-7.5" y="-6.5" width="6" height="5" rx="1" fill="currentColor" />
|
||||
<rect x="1.5" y="1.5" width="6" height="5" rx="1" fill="currentColor" />
|
||||
<path d="M-1.5 -4 C2.5 -4 -2.5 4 1.5 4" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 343 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<rect x="-7" y="-4" width="10" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M3.5 0 L7.5 -3.5 L7.5 3.5 Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 265 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<rect x="-5.5" y="-5.5" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<rect x="-7.5" y="-7.5" width="4" height="4" fill="currentColor" />
|
||||
<rect x="3.5" y="-7.5" width="4" height="4" fill="currentColor" />
|
||||
<rect x="-7.5" y="3.5" width="4" height="4" fill="currentColor" />
|
||||
<rect x="3.5" y="3.5" width="4" height="4" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 474 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<rect x="-7" y="-7" width="14" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<rect x="-4" y="-4" width="3.5" height="3.5" fill="currentColor" />
|
||||
<rect x="0.5" y="-4" width="3.5" height="3.5" fill="currentColor" />
|
||||
<rect x="-4" y="0.5" width="3.5" height="3.5" fill="currentColor" />
|
||||
<rect x="0.5" y="0.5" width="3.5" height="3.5" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 485 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<path d="M-6 7 L0 -7 L6 7 M-3.5 2 H3.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 191 B |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<!-- Three bullet points: the List tab edits a list value, not an asset. -->
|
||||
<circle cx="-5" cy="-4.5" r="1.25" fill="currentColor" />
|
||||
<circle cx="-5" cy="0" r="1.25" fill="currentColor" />
|
||||
<circle cx="-5" cy="4.5" r="1.25" fill="currentColor" />
|
||||
<line x1="-2" y1="-4.5" x2="6.5" y2="-4.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<line x1="-2" y1="0" x2="6.5" y2="0" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<line x1="-2" y1="4.5" x2="6.5" y2="4.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 643 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<!-- As in the scene view: a hollow ring with sparks flying off it. -->
|
||||
<circle cx="0" cy="0" r="3.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<circle cx="5.5" cy="5.5" r="1.5" fill="currentColor" />
|
||||
<circle cx="-5.5" cy="5.5" r="1.5" fill="currentColor" />
|
||||
<circle cx="5.5" cy="-5.5" r="1.5" fill="currentColor" />
|
||||
<circle cx="-5.5" cy="-5.5" r="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 495 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<!-- A weight, as in the scene view: a ring with an arrow pointing the way gravity pulls. -->
|
||||
<circle cx="0" cy="0" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M0 -3.5 V1.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M-3 0.5 L0 4 L3 0.5 Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 415 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<rect x="-7" y="-6" width="14" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M-5 4 L-1.5 -0.5 L1.5 2.5 L3 1 L5 4" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<circle cx="3" cy="-2.5" r="1.25" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 366 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<circle cx="0" cy="0" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M0 -6.5 A6.5 6.5 0 0 1 0 6.5 Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 247 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<path d="M-7.5 -2.5 H-4.5 L-0.5 -6 V6 L-4.5 2.5 H-7.5 Z" fill="currentColor" />
|
||||
<path d="M2 -2.5 A3.5 3.5 0 0 1 2 2.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M4 -5.5 A7 7 0 0 1 4 5.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 365 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<circle cx="0" cy="-3.5" r="2.75" fill="currentColor" />
|
||||
<path d="M-5.5 7 C-5.5 1.5 5.5 1.5 5.5 7 Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 221 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<circle cx="-4" cy="-3.5" r="2.75" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<circle cx="4" cy="3.5" r="2.75" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M-1.5 -3.5 C2 -3.5 4 -2.5 4 0.25" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M2 -0.5 L4 2 L6 -0.5 Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 436 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<path d="M-6 -5.5 H6 M0 -5.5 V7" fill="none" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 182 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<!-- A checkerboard, what an image editor shows behind a picture. -->
|
||||
<rect x="-6.25" y="-6.25" width="12.5" height="12.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<rect x="-6.25" y="-6.25" width="6.25" height="6.25" fill="currentColor" />
|
||||
<rect x="0" y="0" width="6.25" height="6.25" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 424 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<rect x="-5.5" y="-5.5" width="11" height="11" rx="1" fill="currentColor" fill-opacity="0.35" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M-5.5 0 H5.5 M0 -5.5 V0 M-2.5 0 V5.5 M2.5 0 V5.5" fill="none" stroke="currentColor" stroke-width="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<rect x="-7" y="-7" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M-7 -2.5 H7 M-7 2.5 H7 M-2.5 -7 V7 M2.5 -7 V7" fill="none" stroke="currentColor" stroke-width="1" />
|
||||
<rect x="-2.5" y="-2.5" width="5" height="5" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 376 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<path d="M-3 -7 C-5 -7 -5 -5.5 -5 -3.5 C-5 -1.5 -5.5 0 -7 0 C-5.5 0 -5 1.5 -5 3.5 C-5 5.5 -5 7 -3 7" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M3 -7 C5 -7 5 -5.5 5 -3.5 C5 -1.5 5.5 0 7 0 C5.5 0 5 1.5 5 3.5 C5 5.5 5 7 3 7" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||
<circle cx="0" cy="0" r="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 453 B |