import type { PropertyBag } from '../../core/model/Properties'; import type { FederatedPointerEvent } from 'pixi.js'; import { Label, Panel, pressCount, ScrollContainer, ui, type Widget } from '../../ui'; import { activateAsset } from '../actions'; import { type EditorState, sameSelection, type Selection } from '../state'; import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection'; import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection'; import { buildEdgeSection, edgeSignature, refreshEdgeSection } from './edgeSection'; import { buildFrameSection, frameSignature, refreshFrameSection } from './frameSection'; import { emptyHost, type InspectorHost } from './host'; import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection'; import { buildShapeSection, refreshShapeSection, shapeSignature } from './shapeSection'; import { buildObjectSection, buildObjectsSection, objectSignature, refreshObjectSection, } from './objectSection'; import { buildTrackSection, refreshTrackSection, trackSignature } from './trackSection'; import { INLINE_LIMIT } from '../variables/target'; import { inlineListsIn } from '../variables/VariablesEditor'; import type { InlineList } from '../list/InlineList'; /** * Shows the properties of the current selection. The rows are generated from the PropertySchema * by one section per selection kind (asset, scene object, graph node, texture frame); the panel * only decides when a section has to be rebuilt and when its values merely refresh. */ export class Inspector extends Panel { private readonly scroll = new ScrollContainer(); private host: InspectorHost; private builtFor: Selection = null; /** Describes the structure the rows were built for; a change means a rebuild. */ private signature = ''; /** Last live refresh (play mode), in ms – the world ticks far more often than the eye needs. */ private lastLiveRefresh = 0; /** A rebuild that was due while a field in here had focus (see `refreshValues`). */ private rebuildWhenLeft = false; private rebuilds = 0; /** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */ liveVariables: (assetId: string) => PropertyBag | null = () => null; private readonly releaseFocusHandler: () => void; constructor(private readonly state: EditorState) { super({ title: 'Inspector', padding: 0, gap: 0 }); this.host = this.emptyHost(); this.scroll.setContent(this.host.body); this.content.add(this.scroll, 1); state.on('selection', () => this.rebuild()); state.on('load', () => this.rebuild()); state.on('project', () => this.refreshValues()); state.on('runtime', () => this.refreshLive()); this.releaseFocusHandler = ui().focus.onFocusChange(() => { if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.rebuild(); }); this.rebuild(); } /** * A link in a reference row (#221): a click pings the asset in the asset panel, a double-click * opens it. Pinging leaves the selection alone, so this panel keeps showing the object and both * presses land on the same link. */ private followReference(assetId: string, e: FederatedPointerEvent): void { if (pressCount(e, `inspector-link:${assetId}`) === 2) activateAsset(this.state, assetId); else this.state.pingAsset(assetId); } /** Signing out destroys the panel while the page stays up (#177), so the handler goes with it. */ override destroy(options?: Parameters[0]): void { this.releaseFocusHandler(); super.destroy(options); } /** For the check scripts: whether a rebuild is being held back, and how many have run. */ debugInfo(): { rebuilds: number; postponed: boolean } { return { rebuilds: this.rebuilds, postponed: this.rebuildWhenLeft }; } /** * Scrolls until `widget` is in view, when it is one of the rows in here – the check scripts * reveal a row before they click it, as a user scrolls to it. */ reveal(widget: Widget): void { let inside = false; for (let at = widget.parent; at && !inside; at = at.parent) inside = at === this.host.body; if (!inside) return; const top = widget.getGlobalPosition().y - this.host.body.getGlobalPosition().y; this.scroll.ensureVisible(top, widget.h); } /** For the check scripts: every list drawn inline in here, at any depth. */ lists(): InlineList[] { return inlineListsIn(this.host.body); } private emptyHost(): InspectorHost { return emptyHost(this.state, this.scroll, { liveVariables: (assetId) => this.liveVariables(assetId), followReference: (assetId, e) => this.followReference(assetId, e), listLimit: INLINE_LIMIT, }); } /** * Play mode: show what the behaviors are doing to the selected object, ~10× a second. * Skipped while a field in here is being edited so the typed text is not overwritten. */ private refreshLive(): void { const now = performance.now(); if (now - this.lastLiveRefresh < 100) return; if (ui().focus.isWithin(this)) return; this.lastLiveRefresh = now; this.refreshValues(); } private rebuild(): void { this.rebuildWhenLeft = false; this.rebuilds += 1; this.builtFor = this.state.selection; this.host.body.destroy({ children: true }); this.host = this.emptyHost(); this.signature = this.currentSignature(); const s = this.state.selection; if (!s) this.host.body.add(new Label('Select an asset, a scene object or a node.', { style: 'muted' })); else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId); else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId); else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId); else if (s.kind === 'shape') buildShapeSection(this.host, s.assetId, s.shapeId, s.shapeIds); else if (s.kind === 'annotation') buildAnnotationSection(this.host, s.assetId, s.annotationId); else if (s.kind === 'track') buildTrackSection(this.host, s.assetId, s.trackId, s.keys); else if (s.kind === 'edge') buildEdgeSection(this.host, s.assetId, s.edgeId, s.edgeIds); else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds); else buildObjectSection(this.host, s.holderId, s.objectId); this.scroll.setContent(this.host.body); } private currentSignature(): string { const s = this.state.selection; if (!s) return ''; if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId); if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId); if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId); if (s.kind === 'shape') return shapeSignature(this.state.project, s.assetId, s.shapeId, s.shapeIds); if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId); if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys); if (s.kind === 'edge') return edgeSignature(this.state.project, s.assetId, s.edgeId, s.edgeIds); return objectSignature(this.state, s.holderId, s.objectIds, s.objectId); } /** Pushes current model values into the widgets (after undo, drag in viewport, …). */ private refreshValues(): void { const s = this.state.selection; if (!sameSelection(s, this.builtFor) || this.currentSignature() !== this.signature) { // A rebuild throws away the widget being typed in, and a typed value can change the // structure itself (a field that decides how many ports a node has). So it waits until // the field is left; values are safe meanwhile, `TextField.setValue` ignores an edit. if (ui().focus.isWithin(this)) { this.rebuildWhenLeft = true; return; } this.rebuild(); return; } if (!s) return; const ok = s.kind === 'asset' ? refreshAssetSection(this.host, s.assetId) : s.kind === 'node' ? refreshNodeSection(this.host, s.assetId, s.nodeId) : s.kind === 'frame' ? refreshFrameSection(this.host, s.assetId, s.frameId) : s.kind === 'shape' ? refreshShapeSection(this.host, s.assetId, s.shapeId) : s.kind === 'annotation' ? refreshAnnotationSection(this.host, s.assetId, s.annotationId) : s.kind === 'track' ? refreshTrackSection(this.host, s.assetId, s.trackId, s.keys) : s.kind === 'edge' ? refreshEdgeSection(this.host, s.assetId, s.edgeId) : s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId); if (!ok) this.rebuild(); } }