Claude Code should be able to work inside the user's open editor tab. This first stage connects the two and lets the agent look: what the user sees and a picture of it. Changing the project comes in stage 2. - An agent token per account opens only /mcp, no API route. The dialog "Agent tokens..." in the account menu creates it once, shows the ready "claude mcp add" line and revokes it. The server keeps only its sha256 (api/lib/AgentTokens.php). - Wrong tokens count into the sign-in brake from #427. Changing the password, renaming or deleting an account ends its tokens. - The relay (tools/mcp/) is a small Node service without packages, WebSocket server included. It runs in the container feuerwerk-mcp because PHP-FPM cannot hold connections. Whether a token or a tab ticket is valid is still decided by the PHP API, through two routes that answer only with the relay key. - The tab joins over WebSocket on the same origin with the normal session, an Origin check and a one-time ticket. It connects only after "Agent" is switched on in the account menu. One tab per account is active, a new one takes over. Guests get nothing. - Tools: get_view (project, tab, document, selection with names, camera, play state, open dialog) and screenshot. The resource feuerwerk://view can be subscribed. Claude Code does not act on that reliably, so a changed view is also noted on the next answer. - A badge next to the account menu shows the connected agents. It counts only agents with an open stream or a request in the last two minutes. - CI starts the relay in front of php -S (tools/ci/serve.sh), and e2e 71 runs the whole path in the browser. Found on the live test: the badge showed four agents because old sessions of revoked tokens were still counted. Tests: - Gate on atx: 316 files, 4888 tests - relay protocol test with fake API and fake tabs: 23/23 - get_view, tools, socketUrl: 16/16 - PHP agents 29, rights 103 checks - e2e 71 on atx 10/10, against the live site 10/10, no CSP violation - real Claude Code against the live site: get_view answered - ZAP against /mcp: nothing at High or Medium - every new check fails when the guard it covers is taken out (6) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1800 lines
73 KiB
TypeScript
1800 lines
73 KiB
TypeScript
import type { Application, Ticker } from 'pixi.js';
|
||
import type { Asset } from '../core/model/Asset';
|
||
import { findAsset, findObjectHolder, ROUTING_ID } from '../core/model/Project';
|
||
import { assetTitle } from '../core/music/embedded';
|
||
import { mixOf, videoBusesOf } from '../core/music/mixer';
|
||
import type { AgentStore, MadeAgentToken } from '../core/store/AgentStore';
|
||
import type { Role } from '../core/store/AuthStore';
|
||
import { MCP_PATH, socketUrl } from '../core/site';
|
||
import type { DemoStore } from '../core/store/DemoStore';
|
||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||
import { pluginsOf } from '../core/store/projectPlugins';
|
||
import type { FontStore } from '../core/store/FontStore';
|
||
import type { SnapshotStore } from '../core/store/SnapshotStore';
|
||
import type { SoundStore } from '../core/store/SoundStore';
|
||
import type { VideoStore } from '../core/store/VideoStore';
|
||
import { StreamKeeper } from './sound/StreamKeeper';
|
||
import type { TextureStore } from '../core/store/TextureStore';
|
||
import type { UsersStore } from '../core/store/UsersStore';
|
||
import { FIXED_STEP, gameClock, timeStep } from '../core/time';
|
||
import {
|
||
clamp,
|
||
HStack,
|
||
isRendering,
|
||
Label,
|
||
lastRenderTime,
|
||
Menu,
|
||
type MenuEntry,
|
||
Panel,
|
||
Popup,
|
||
renderCount,
|
||
requestRender,
|
||
Splitter,
|
||
Theme,
|
||
tooltips,
|
||
ui,
|
||
VStack,
|
||
type Widget,
|
||
} from '../ui';
|
||
import { TabStrip, type TabPlace } from './TabStrip';
|
||
import type { AssetTabEditor } from './AssetTabEditor';
|
||
import { closeDocument, type DocumentRow, neighbourOf, openDocument, replaceDocument } from './documents';
|
||
import { openingBox, type WindowBox } from './floating';
|
||
import { FloatWindow } from './FloatWindow';
|
||
import { ConsolePanel } from './console/ConsolePanel';
|
||
import { GamePane } from './viewport/GamePane';
|
||
import { ScenePane } from './viewport/ScenePane';
|
||
import { GameViewport } from './viewport/GameViewport';
|
||
import { SceneViewport } from './viewport/SceneViewport';
|
||
import { leaveFullscreen, onFullscreenChange } from '../viewport/fullscreen';
|
||
import { soundLibrary } from '../viewport/SoundLibrary';
|
||
import { TAB_OF_ASSET } from './assetTabs';
|
||
import { attachAutosave } from './autosave';
|
||
import { PasswordDialog } from './auth/PasswordDialog';
|
||
import type { OpenProjectDialog } from './OpenProjectDialog';
|
||
import type { SnapshotsDialog } from './SnapshotsDialog';
|
||
import type { ReleaseStore } from '../core/store/ReleaseStore';
|
||
import type { ReleaseDialog } from './ReleaseDialog';
|
||
import type { ProjectSettingsDialog } from './ProjectSettingsDialog';
|
||
import { dialogSlot } from './FormDialog';
|
||
import { generalSettings, keepGeneralSettings } from './generalSettings';
|
||
import { GeneralSettingsDialog } from './GeneralSettingsDialog';
|
||
import { useCursorFiles } from './editorCursors';
|
||
import { ContainerEditor } from './container/ContainerEditor';
|
||
import { CompositionEditor } from './composition/CompositionEditor';
|
||
import { EditorRun } from './EditorRun';
|
||
import { GraphEditor } from './graph/GraphEditor';
|
||
import { Inspector } from './inspector/Inspector';
|
||
import { valuesTitle, VariablesEditor } from './variables/VariablesEditor';
|
||
import { valuesKey } from './variables/target';
|
||
import { pickView } from './objectPick';
|
||
import { keepPanelSize, panelSize } from './panelSizes';
|
||
import { AnimationEditor } from './animation/AnimationEditor';
|
||
import { TileEditor } from './tile/TileEditor';
|
||
import { AssetPanel } from './panels/AssetPanel';
|
||
import {
|
||
ASSET_ICON_SVG,
|
||
GAME_ICON_SVG,
|
||
LIST_ICON_SVG,
|
||
PLUGINS_ICON_SVG,
|
||
USERS_ICON_SVG,
|
||
} from './panels/assetIcons';
|
||
import { HierarchyPanel } from './panels/HierarchyPanel';
|
||
import { TilemapPanel } from './panels/TilemapPanel';
|
||
import { Toolbar } from './panels/Toolbar';
|
||
import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
|
||
import { type RecordingUpload, RecordingUploads } from './recordingUploads';
|
||
import { EditorRecorder } from './EditorRecorder';
|
||
import { EditorRecordingDialog } from './EditorRecordingDialog';
|
||
import { editorRecordingSettings, keepEditorRecordingSettings } from './editorRecordingSettings';
|
||
import type { MadeRecording } from '../viewport/RecordingTake';
|
||
import type { RecordingUploadOptions } from '../core/recording/recording';
|
||
import { ProjectSession } from './ProjectSession';
|
||
import { ParticlesEditor } from './particles/ParticlesEditor';
|
||
import { ShaderEditor } from './shader/ShaderEditor';
|
||
import { SoundFxEditor } from './soundfx/SoundFxEditor';
|
||
import { InstrumentEditor } from './instrument/InstrumentEditor';
|
||
import { PatternEditor } from './pattern/PatternEditor';
|
||
import { SongEditor } from './song/SongEditor';
|
||
import { StateMachineEditor } from './statemachine/StateMachineEditor';
|
||
import { installShortcuts } from './shortcuts';
|
||
import type { PlayFrom } from '../core/model/playSettings';
|
||
import type { EditorState, ValuesTarget } from './state';
|
||
import { copySelection } from './clipboard';
|
||
import { PixelArtEditor } from './pixelart/PixelArtEditor';
|
||
import { VectorArtEditor } from './vectorart/VectorArtEditor';
|
||
import { CurveEditor } from './curve/CurveEditor';
|
||
import { SoundEditor } from './sound/SoundEditor';
|
||
import { VideoEditor } from './video/VideoEditor';
|
||
import { TextureEditor } from './texture/TextureEditor';
|
||
import { installFileImport } from './fileImport';
|
||
import type { PluginManager } from './plugins/loader';
|
||
import { PluginsPane } from './plugins/PluginsPane';
|
||
import { ManagementPane } from './users/ManagementPane';
|
||
import { UsersPane } from './users/UsersPane';
|
||
import { RoutingEditor } from './routing/RoutingEditor';
|
||
import { CompressorEditor } from './soundfx/CompressorEditor';
|
||
import { EqEditor } from './soundfx/EqEditor';
|
||
import { NODE_TAB_KINDS, nodeOfTab, nodeTabLabel, type NodeTabKind, nodeTabOf } from './soundfx/nodeTabs';
|
||
import { AgentLink } from './agent/AgentLink';
|
||
import { runTool } from './agent/agentTools';
|
||
import { AgentTokensDialog } from './agent/AgentTokensDialog';
|
||
import { type AgentView, agentView, viewSummary } from './agent/agentView';
|
||
import { screenshot } from './agent/screenshot';
|
||
|
||
export type CentreTab =
|
||
| 'scene'
|
||
| 'graph'
|
||
| 'statemachine'
|
||
| 'shader'
|
||
| 'particles'
|
||
| 'sound'
|
||
| 'soundfx'
|
||
| 'instrument'
|
||
| 'pattern'
|
||
| 'song'
|
||
| 'game'
|
||
| 'video'
|
||
| 'texture'
|
||
| 'vectorart'
|
||
| 'pixelart'
|
||
| 'animation'
|
||
| 'curve'
|
||
| 'tile'
|
||
| 'container'
|
||
| 'composition'
|
||
| 'variables'
|
||
| 'routing'
|
||
| 'eq'
|
||
| 'compressor'
|
||
| 'filter'
|
||
| 'management';
|
||
|
||
/** 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 what a Variables tab shows (#252) by its
|
||
* `valuesKey` – as many of those as were opened. The user management (#179) and the master routing
|
||
* (#351) are no documents, but they open and close like one. A Parametric EQ has a tab per node (#370),
|
||
* and so have a Compressor (#378) and a Filter (#385).
|
||
*/
|
||
export type DocumentId =
|
||
`asset:${string}` | `values:${string}` | `${NodeTabKind}:${string}` | 'routing' | 'management';
|
||
/** The account behind this session; the menu for it sits at the far right of the toolbar. */
|
||
export interface EditorAccount {
|
||
/** Who is signed in – the menu says so, since the toolbar has no room for a second label. */
|
||
user: string;
|
||
/** What the editor offers: an administrator gets the user management (#180, #179). */
|
||
role: Role;
|
||
/** Somebody trying a demo copy without an account (#428): nothing they do is kept. */
|
||
guest?: boolean;
|
||
users: UsersStore;
|
||
/** An administrator's: making the demo copy of the open project (#428). */
|
||
demos?: DemoStore;
|
||
/** The agent tokens and the relay's tickets (#429); a guest has none. */
|
||
agents?: AgentStore;
|
||
logOut: () => void;
|
||
/** Rejects with `WrongCredentialsError` when the current password does not match (#182). */
|
||
changePassword: (current: string, next: string) => Promise<void>;
|
||
}
|
||
|
||
/** A button in the tab row. */
|
||
export type StripTab = FixedTab | PluginTabId | DocumentId;
|
||
|
||
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 | 'variables' | 'routing' | NodeTabKind | 'management'>;
|
||
|
||
const FIXED_TABS = ['scene', 'game'] as const;
|
||
|
||
/** `sessionStorage` key: the agent was on in this browser tab (#429). */
|
||
const AGENT_KEY = 'feuerwerk.agent';
|
||
|
||
/** A dialog's bold first line, or a menu's first entry: what tells one popup from another. */
|
||
function popupTitle(popup: Popup): string {
|
||
const first = popup.content.children.find((child) => child instanceof Label);
|
||
return first instanceof Label ? first.text : '';
|
||
}
|
||
|
||
const PANE_ORDER = [
|
||
'scene',
|
||
'game',
|
||
'graph',
|
||
'statemachine',
|
||
'shader',
|
||
'particles',
|
||
'sound',
|
||
'soundfx',
|
||
'instrument',
|
||
'pattern',
|
||
'song',
|
||
'video',
|
||
'texture',
|
||
'vectorart',
|
||
'pixelart',
|
||
'animation',
|
||
'curve',
|
||
'tile',
|
||
'container',
|
||
'composition',
|
||
'variables',
|
||
'routing',
|
||
'eq',
|
||
'compressor',
|
||
'filter',
|
||
'management',
|
||
] as const;
|
||
|
||
/** 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',
|
||
graph: 'Behavior',
|
||
statemachine: 'State machine',
|
||
shader: 'Shader',
|
||
particles: 'Particles',
|
||
sound: 'Sound',
|
||
soundfx: 'Sound FX',
|
||
instrument: 'Instrument',
|
||
pattern: 'Pattern',
|
||
song: 'Song',
|
||
video: 'Video',
|
||
texture: 'Texture',
|
||
vectorart: 'Vector art',
|
||
pixelart: 'Pixel art',
|
||
animation: 'Animation',
|
||
curve: 'Curve',
|
||
tile: 'Tile',
|
||
container: 'Container',
|
||
composition: 'Composition',
|
||
variables: 'Variables',
|
||
routing: 'Master routing',
|
||
eq: 'Parametric EQ',
|
||
compressor: 'Compressor',
|
||
filter: 'Filter',
|
||
management: 'User management',
|
||
};
|
||
|
||
/** Icons of the fixed tabs (#176); a plugin's own tab has none, it brings no picture of its own. */
|
||
const FIXED_TAB_ICONS: Partial<Record<FixedTab, string>> = {
|
||
scene: ASSET_ICON_SVG.scene,
|
||
game: GAME_ICON_SVG,
|
||
};
|
||
|
||
function isDocument(tab: StripTab): tab is DocumentId {
|
||
return (
|
||
tab === 'management' ||
|
||
tab === 'routing' ||
|
||
tab.startsWith('asset:') ||
|
||
tab.startsWith('values:') ||
|
||
nodeTabOfDocument(tab) !== null
|
||
);
|
||
}
|
||
|
||
/** The kind of a node's tab (#370, #378) a document is, or null for any other. */
|
||
function nodeTabOfDocument(doc: string): NodeTabKind | null {
|
||
return NODE_TAB_KINDS.find((kind) => doc.startsWith(`${kind}:`)) ?? null;
|
||
}
|
||
|
||
const MIN_LEFT_WIDTH = 160;
|
||
const MAX_LEFT_WIDTH = 500;
|
||
const MIN_RIGHT_WIDTH = 200;
|
||
const MAX_RIGHT_WIDTH = 600;
|
||
// The floor is the console's own header: dragged all the way shut it stays visible to drag open.
|
||
const MIN_CONSOLE_HEIGHT = Panel.HEADER_HEIGHT;
|
||
const MAX_CONSOLE_HEIGHT = 600;
|
||
// Either panel of the left column keeps at least its header, for the same reason as the console.
|
||
const MIN_PANEL_HEIGHT = Panel.HEADER_HEIGHT;
|
||
// What a share read back from the browser is held to before the column has a height to measure by.
|
||
const MIN_KEPT_SHARE = 0.03;
|
||
|
||
/**
|
||
* Composes toolbar, panels, viewports and graph editor into the one application that edits and
|
||
* plays. What it owns itself is the layout, the tab strip and the frame ticker; running the
|
||
* project is `PlaySession`, loading it is `ProjectSession`.
|
||
*/
|
||
export class Editor implements PlayHost {
|
||
/** Scene tab: the editable model view (live positions while playing). */
|
||
readonly viewport: SceneViewport;
|
||
/** Game tab: the running world through its camera. */
|
||
readonly gameView: GameViewport;
|
||
readonly gamePane: GamePane;
|
||
readonly graphEditor: GraphEditor;
|
||
readonly stateMachineEditor: StateMachineEditor;
|
||
readonly shaderEditor: ShaderEditor;
|
||
readonly particlesEditor: ParticlesEditor;
|
||
readonly soundEditor: SoundEditor;
|
||
readonly videoEditor: VideoEditor;
|
||
/** Makes the copies streamed sounds play from (#349). */
|
||
readonly streamKeeper: StreamKeeper;
|
||
readonly soundFxEditor: SoundFxEditor;
|
||
readonly instrumentEditor: InstrumentEditor;
|
||
readonly patternEditor: PatternEditor;
|
||
readonly songEditor: SongEditor;
|
||
readonly textureEditor: TextureEditor;
|
||
readonly vectorArtEditor: VectorArtEditor;
|
||
readonly pixelArtEditor: PixelArtEditor;
|
||
readonly animationEditor: AnimationEditor;
|
||
readonly curveEditor: CurveEditor;
|
||
readonly tileEditor: TileEditor;
|
||
readonly containerEditor: ContainerEditor;
|
||
readonly compositionEditor: CompositionEditor;
|
||
readonly variablesEditor: VariablesEditor;
|
||
readonly routingEditor: RoutingEditor;
|
||
readonly eqEditor: EqEditor;
|
||
readonly compressorEditor: CompressorEditor;
|
||
readonly filterEditor: EqEditor;
|
||
/** What each open Variables tab shows, by its document. */
|
||
private readonly valueTargets = new Map<DocumentId, ValuesTarget>();
|
||
/** The node behind each tab of an EQ (#370) or a Compressor (#378). */
|
||
private readonly nodeTargets = new Map<DocumentId, { assetId: string; nodeId: string }>();
|
||
/** The switch per file in `data/plugins/`, in the user management tab (#179). */
|
||
readonly pluginsPane = new PluginsPane();
|
||
/** The accounts, above the plugins; null unless an administrator is signed in. */
|
||
readonly usersPane: UsersPane | null;
|
||
private readonly managementPane: ManagementPane;
|
||
readonly hierarchy: HierarchyPanel;
|
||
readonly assets: AssetPanel;
|
||
readonly inspector: Inspector;
|
||
/** Beside the scene view, while exactly one placed tilemap is selected there (#142). */
|
||
readonly tilemapPanel: TilemapPanel;
|
||
readonly tilemapSplitter = new Splitter('x');
|
||
/** The scene view, and the tilemap panel to its right when there is one. */
|
||
private readonly sceneRow = new HStack();
|
||
/** The scene tab: its header above `sceneRow`. */
|
||
readonly scenePane: ScenePane;
|
||
readonly console = new ConsolePanel();
|
||
tab: TabId = 'scene';
|
||
/** Frame rate, refreshed twice a second; null until the first measurement. */
|
||
fps: number | null = null;
|
||
private readonly play: PlaySession;
|
||
private readonly recordings: RecordingUploads;
|
||
/** Records the whole editor from the main menu. */
|
||
private readonly editorRecorder: EditorRecorder;
|
||
private readonly editorRun: EditorRun;
|
||
private readonly projects: ProjectSession;
|
||
private readonly leftColumn: VStack;
|
||
readonly leftSplitter = new Splitter('x');
|
||
readonly rightSplitter = new Splitter('x');
|
||
readonly consoleSplitter = new Splitter('y');
|
||
/** Between the asset panel and the scene objects below it. */
|
||
readonly columnSplitter = new Splitter('y');
|
||
private readonly workspace: HStack;
|
||
private readonly centre: VStack;
|
||
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 };
|
||
/**
|
||
* The kinds whose pane is in a window of its own (#371), and the document each window shows. A
|
||
* kind has one editor, so it has one window: another document of it opens there, not in a tab.
|
||
*/
|
||
private readonly floats = new Map<CentreTab, { doc: DocumentId; window: FloatWindow }>();
|
||
/** The window the last press went into; null when it went into the editor. Keys follow it. */
|
||
private pressedWindow: CentreTab | null = 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;
|
||
private readonly toolbar: Toolbar;
|
||
private readonly passwordDialog = dialogSlot<PasswordDialog>();
|
||
private readonly generalDialog = dialogSlot<GeneralSettingsDialog>();
|
||
private readonly recordingDialog = dialogSlot<EditorRecordingDialog>();
|
||
private readonly agentTokensDialog = dialogSlot<AgentTokensDialog>();
|
||
/** The tab's end of the MCP relay (#429); null for a guest. */
|
||
readonly agent: AgentLink | null;
|
||
/** Drawn-frame count at the last measurement, not a counter of its own. */
|
||
private fpsFrames = 0;
|
||
private fpsElapsed = 0;
|
||
/** Whether a node's face (#380) moved in the last frame: a meter falling back still needs drawing. */
|
||
private facesMoving = false;
|
||
/** What this editor registered outside itself, as the calls that take it away again (#177). */
|
||
private readonly cleanups: (() => void)[] = [];
|
||
private readonly autosave: { flush: () => Promise<void>; stop: () => void };
|
||
private disposed = false;
|
||
private readonly guest: boolean;
|
||
// Each starts where its divider was left in this browser (`panelSizes.ts`), else at the number here.
|
||
private leftWidth = panelSize('left', 250, MIN_LEFT_WIDTH, MAX_LEFT_WIDTH);
|
||
private rightWidth = panelSize('right', 350, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
|
||
/** Starts out as wide as the inspector, then goes its own way. */
|
||
private tilemapWidth = panelSize('tilemap', this.rightWidth, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
|
||
private consoleHeight = panelSize('console', 300, MIN_CONSOLE_HEIGHT, MAX_CONSOLE_HEIGHT);
|
||
/** The asset panel's part of the left column's height; the scene objects get the rest. */
|
||
private assetsShare = panelSize('assets-share', 0.5, MIN_KEPT_SHARE, 1 - MIN_KEPT_SHARE);
|
||
|
||
constructor(
|
||
app: Application,
|
||
private readonly root: VStack,
|
||
private readonly state: EditorState,
|
||
store: ProjectStore,
|
||
textures: TextureStore,
|
||
fonts: FontStore,
|
||
sounds: SoundStore,
|
||
videos: VideoStore,
|
||
snapshots: SnapshotStore,
|
||
releases: ReleaseStore,
|
||
account: EditorAccount,
|
||
) {
|
||
this.viewport = new SceneViewport(state);
|
||
this.gameView = new GameViewport(state);
|
||
this.gamePane = new GamePane(this.gameView);
|
||
this.graphEditor = new GraphEditor(state);
|
||
this.stateMachineEditor = new StateMachineEditor(state);
|
||
this.shaderEditor = new ShaderEditor(state);
|
||
this.particlesEditor = new ParticlesEditor(state);
|
||
this.streamKeeper = new StreamKeeper(state, sounds);
|
||
this.soundEditor = new SoundEditor(state, this.streamKeeper);
|
||
this.videoEditor = new VideoEditor(state, videos);
|
||
this.soundFxEditor = new SoundFxEditor(state);
|
||
this.instrumentEditor = new InstrumentEditor(state);
|
||
this.patternEditor = new PatternEditor(state, sounds);
|
||
this.songEditor = new SongEditor(state, sounds);
|
||
this.textureEditor = new TextureEditor(state);
|
||
this.vectorArtEditor = new VectorArtEditor(state);
|
||
this.pixelArtEditor = new PixelArtEditor(state);
|
||
this.animationEditor = new AnimationEditor(state);
|
||
this.curveEditor = new CurveEditor(state);
|
||
this.tileEditor = new TileEditor(state);
|
||
this.containerEditor = new ContainerEditor(state);
|
||
this.compositionEditor = new CompositionEditor(state);
|
||
this.variablesEditor = new VariablesEditor(state);
|
||
this.routingEditor = new RoutingEditor(state);
|
||
this.eqEditor = new EqEditor(state);
|
||
this.compressorEditor = new CompressorEditor(state);
|
||
this.filterEditor = new EqEditor(state, 'filter');
|
||
this.hierarchy = new HierarchyPanel(state);
|
||
this.compositionEditor.onLiveItems = () => {
|
||
if (this.tab === 'composition') this.hierarchy.refresh();
|
||
};
|
||
this.inspector = new Inspector(state);
|
||
this.tilemapPanel = new TilemapPanel(state);
|
||
this.recordings = new RecordingUploads({ state, videos });
|
||
this.editorRecorder = new EditorRecorder({
|
||
log: (line) => {
|
||
state.setNotice(line);
|
||
console.warn('[recording]', line);
|
||
},
|
||
saved: (recording, options) => this.recordings.add(recording, options),
|
||
showBadge: (on) => this.toolbar.showRecording(on),
|
||
badgeRoom: () => this.toolbar.recordingRoom(),
|
||
});
|
||
// Undone on dispose: the next session's editor shares the renderer and must find Pixi's own styles.
|
||
this.cleanups.push(useCursorFiles(app.renderer.events, app.canvas));
|
||
this.play = new PlaySession(state, this);
|
||
this.editorRun = new EditorRun(state, {
|
||
// The game view is the one place where no key means anything to the editor, so that is where
|
||
// a behavior may have them – unless a widget in it is focused and wants them itself.
|
||
focused: () => this.tab === 'game' && ui().focus.focused === null,
|
||
onWorld: (world) => {
|
||
this.gameView.setEditorWorld(world);
|
||
this.viewport.setEditorWorld(world);
|
||
},
|
||
});
|
||
this.projects = new ProjectSession(
|
||
state,
|
||
store,
|
||
() => {
|
||
if (this.play.isPlaying) this.play.stop();
|
||
},
|
||
{ snapshots, releases, demos: account.demos, flush: () => this.autosave.flush() },
|
||
{ guest: account.guest === true, role: account.role },
|
||
// Every project brings its own plugins (#119); until `startPlugins` has run there is no
|
||
// manager, and a project with plugins simply has none of them.
|
||
async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))),
|
||
);
|
||
this.guest = account.guest === true;
|
||
|
||
this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId);
|
||
this.graphEditor.canvas.timeProvider = () => this.play.time;
|
||
// Only one of the two ever runs a given machine, for the reason the live variables are one call.
|
||
this.stateMachineEditor.canvas.stateProvider = (assetId) => {
|
||
const playing = this.play.machineStates(assetId);
|
||
return playing.length > 0 ? playing : this.editorRun.machineStates(assetId);
|
||
};
|
||
// Only one of the two ever runs a given asset: playing stops the editor run (see `EditorRun.modeChanged`).
|
||
this.inspector.liveVariables = (assetId) =>
|
||
this.play.liveVariables(assetId) ?? this.editorRun.liveVariables(assetId);
|
||
this.variablesEditor.liveVariables = this.inspector.liveVariables;
|
||
|
||
this.usersPane =
|
||
account.role === 'admin'
|
||
? new UsersPane(account.users, {
|
||
self: account.user,
|
||
openProject: (id) => this.projects.openProject(id),
|
||
notice: (message, level) => state.setNotice(message, level),
|
||
})
|
||
: null;
|
||
this.managementPane = new ManagementPane(this.usersPane, this.pluginsPane);
|
||
|
||
this.toolbar = new Toolbar(
|
||
state,
|
||
{
|
||
openMainMenu: (anchor) =>
|
||
this.projects.openMainMenu(anchor, this.managementEntries(), [
|
||
{ label: 'Master routing', onSelect: () => this.openDocument('routing') },
|
||
]),
|
||
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
|
||
togglePause: () => this.play.togglePause(),
|
||
step: () => this.play.step(),
|
||
openAccountMenu: (anchor) => this.openAccountMenu(anchor, account),
|
||
stopAgent: () => this.switchAgent(false),
|
||
},
|
||
account.guest === true,
|
||
);
|
||
this.agent = account.agents ? this.makeAgentLink(app, account.agents) : null;
|
||
root.add(this.toolbar);
|
||
|
||
this.workspace = new HStack();
|
||
this.leftColumn = new VStack();
|
||
this.assets = new AssetPanel(state, textures, fonts, sounds, videos);
|
||
this.leftColumn.add(this.assets, this.assetsShare);
|
||
this.cleanups.push(installFileImport(app.canvas, { state, textures, fonts, sounds, videos }));
|
||
this.leftColumn.add(this.columnSplitter);
|
||
this.leftColumn.add(this.hierarchy, 1 - this.assetsShare);
|
||
// A share rather than a height, so a taller or shorter window keeps the split the user chose.
|
||
this.columnSplitter.onDrag = (delta) => {
|
||
const room = this.leftColumn.h - Splitter.THICKNESS;
|
||
if (room <= 0) return;
|
||
const floor = Math.min(0.5, MIN_PANEL_HEIGHT / room);
|
||
this.assetsShare = clamp(this.assetsShare + delta / room, floor, 1 - floor);
|
||
this.assets.flex = this.assetsShare;
|
||
this.hierarchy.flex = 1 - this.assetsShare;
|
||
this.leftColumn.relayout();
|
||
keepPanelSize('assets-share', this.assetsShare);
|
||
};
|
||
|
||
this.centre = new VStack();
|
||
this.tabs = new TabStrip<StripTab>(
|
||
FIXED_TABS.map((tab) => ({ tab, label: TAB_LABELS[tab], icon: FIXED_TAB_ICONS[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
|
||
// adding a tab a type error everywhere it has to be handled instead of a silent omission.
|
||
this.sceneRow.add(this.viewport, 1);
|
||
this.sceneRow.add(this.tilemapSplitter);
|
||
this.sceneRow.add(this.tilemapPanel);
|
||
this.tilemapPanel.minWidth = this.tilemapWidth;
|
||
this.tilemapSplitter.onDrag = (delta) => {
|
||
this.tilemapWidth = clamp(this.tilemapWidth - delta, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
|
||
this.tilemapPanel.minWidth = this.tilemapWidth;
|
||
this.sceneRow.relayout();
|
||
keepPanelSize('tilemap', this.tilemapWidth);
|
||
};
|
||
this.tilemapPanel.onTargetChange = () => this.syncTilemapPanel();
|
||
this.syncTilemapPanel();
|
||
this.scenePane = new ScenePane(state, this.viewport, this.sceneRow);
|
||
this.assetEditors = {
|
||
graph: this.graphEditor,
|
||
statemachine: this.stateMachineEditor,
|
||
shader: this.shaderEditor,
|
||
particles: this.particlesEditor,
|
||
sound: this.soundEditor,
|
||
video: this.videoEditor,
|
||
soundfx: this.soundFxEditor,
|
||
instrument: this.instrumentEditor,
|
||
pattern: this.patternEditor,
|
||
song: this.songEditor,
|
||
texture: this.textureEditor,
|
||
vectorart: this.vectorArtEditor,
|
||
pixelart: this.pixelArtEditor,
|
||
animation: this.animationEditor,
|
||
curve: this.curveEditor,
|
||
tile: this.tileEditor,
|
||
container: this.containerEditor,
|
||
composition: this.compositionEditor,
|
||
};
|
||
this.panes = {
|
||
scene: this.scenePane,
|
||
game: this.gamePane,
|
||
...this.assetEditors,
|
||
variables: this.variablesEditor,
|
||
routing: this.routingEditor,
|
||
eq: this.eqEditor,
|
||
compressor: this.compressorEditor,
|
||
filter: this.filterEditor,
|
||
management: this.managementPane,
|
||
};
|
||
// Following a call goes on in the same tab: the tab changes its document, not its place.
|
||
this.graphEditor.onRetarget = (from, to) => {
|
||
const float = this.floats.get('graph');
|
||
if (float?.doc === `asset:${from}`) {
|
||
this.showInWindow('graph', `asset:${to}`);
|
||
return;
|
||
}
|
||
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 PANE_ORDER) {
|
||
this.centre.add(this.panes[tab], 1);
|
||
this.panes[tab].visible = tab === 'scene';
|
||
}
|
||
this.gameView.visible = false;
|
||
// The console sits below whichever tab is showing, so it is a sibling of the panes, not of
|
||
// the workspace: switching tabs must not move it.
|
||
this.centre.add(this.consoleSplitter);
|
||
this.centre.add(this.console);
|
||
this.console.minHeight = this.consoleHeight;
|
||
this.consoleSplitter.onDrag = (delta) => {
|
||
this.consoleHeight = clamp(this.consoleHeight - delta, MIN_CONSOLE_HEIGHT, MAX_CONSOLE_HEIGHT);
|
||
this.console.minHeight = this.consoleHeight;
|
||
this.centre.relayout();
|
||
keepPanelSize('console', this.consoleHeight);
|
||
};
|
||
|
||
this.leftColumn.minWidth = this.leftWidth;
|
||
this.inspector.minWidth = this.rightWidth;
|
||
this.leftSplitter.onDrag = (delta) => {
|
||
this.leftWidth = clamp(this.leftWidth + delta, MIN_LEFT_WIDTH, MAX_LEFT_WIDTH);
|
||
this.leftColumn.minWidth = this.leftWidth;
|
||
this.workspace.relayout();
|
||
keepPanelSize('left', this.leftWidth);
|
||
};
|
||
this.rightSplitter.onDrag = (delta) => {
|
||
this.rightWidth = clamp(this.rightWidth - delta, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH);
|
||
this.inspector.minWidth = this.rightWidth;
|
||
this.workspace.relayout();
|
||
keepPanelSize('right', this.rightWidth);
|
||
};
|
||
this.workspace.add(this.leftColumn);
|
||
this.workspace.add(this.leftSplitter);
|
||
this.workspace.add(this.centre, 1);
|
||
this.workspace.add(this.rightSplitter);
|
||
this.workspace.add(this.inspector);
|
||
root.add(this.workspace, 1);
|
||
root.resize(app.screen.width, app.screen.height);
|
||
// A press in the editor takes the keys back from a window (#371).
|
||
this.workspace.on('pointerdowncapture', () => (this.pressedWindow = null));
|
||
|
||
// A guest's project lives in the tab alone (#428): there is nowhere to save it to.
|
||
this.autosave = this.guest
|
||
? { flush: () => Promise.resolve(), stop: () => {} }
|
||
: attachAutosave(state, store);
|
||
this.cleanups.push(
|
||
installShortcuts(state, {
|
||
play: () => this.play.play(),
|
||
stop: () => this.play.stop(),
|
||
togglePause: () => this.play.togglePause(),
|
||
step: () => this.play.step(),
|
||
copy: () =>
|
||
(this.keyTab() === 'animation' && this.animationEditor.canvas.copyKeys()) ||
|
||
(this.keyTab() === 'pattern' && this.patternEditor.canvas.copy()) ||
|
||
(this.keyTab() === 'song' && this.songEditor.canvas.copy()) ||
|
||
copySelection(state),
|
||
paste: () => this.paste(),
|
||
removePoint: () => this.removePoint(),
|
||
}),
|
||
);
|
||
const onTick = (ticker: Ticker) => this.tick(ticker.deltaMS / 1000);
|
||
app.ticker.add(onTick);
|
||
this.cleanups.push(() => app.ticker.remove(onTick));
|
||
// Only the game tab's button asks for fullscreen, so every change is the game view's (#267).
|
||
this.cleanups.push(onFullscreenChange((on) => this.showGameFullscreen(on)));
|
||
// In the game view Tab is a game key (an inventory, a target switch) – like every other key there.
|
||
const focus = ui().focus;
|
||
focus.tabBelongsElsewhere = () => this.tab === 'game';
|
||
this.cleanups.push(() => (focus.tabBelongsElsewhere = () => false));
|
||
// A node, a frame, a clip 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', () => {
|
||
if (state.glancing) return;
|
||
const s = state.selection;
|
||
if (s?.kind === 'node' && s.assetId === ROUTING_ID) this.openDocument('routing');
|
||
else if (
|
||
s?.kind === 'node' ||
|
||
s?.kind === 'frame' ||
|
||
s?.kind === 'clip' ||
|
||
s?.kind === 'song' ||
|
||
s?.kind === 'composition'
|
||
)
|
||
this.openDocument(`asset:${s.assetId}`);
|
||
const holderType = s?.kind === 'object' ? findAsset(state.project, s.holderId)?.type : undefined;
|
||
if (s?.kind === 'object' && (holderType === 'container' || holderType === 'composition'))
|
||
this.openDocument(`asset:${s.holderId}`);
|
||
});
|
||
state.on('open', () => {
|
||
const s = state.selection;
|
||
if (s?.kind !== 'asset') return;
|
||
if (findAsset(state.project, s.assetId)?.type === 'scene') this.showTab('scene');
|
||
else this.openDocument(`asset:${s.assetId}`);
|
||
});
|
||
// A tab per target: a list opened from inside a tab gets one beside it, not that tab (#252).
|
||
state.on('values', () => {
|
||
const target = state.openedValues;
|
||
if (!target) return;
|
||
const doc: DocumentId = `values:${valuesKey(target)}`;
|
||
if (!this.valueTargets.has(doc)) this.valueTargets.set(doc, target);
|
||
this.openDocument(doc);
|
||
});
|
||
// A tab per Parametric EQ (#370), Compressor (#378) or Filter (#385) node, opened by a double-click on it.
|
||
state.on('nodeTab', () => {
|
||
const target = state.openedNode;
|
||
const kind = target && nodeTabOf(target.type);
|
||
if (!target || !kind) return;
|
||
const doc: DocumentId = `${kind}:${target.assetId}:${target.nodeId}`;
|
||
if (!this.nodeTargets.has(doc)) this.nodeTargets.set(doc, target);
|
||
this.openDocument(doc);
|
||
});
|
||
// The tabs play through the buses and the master as the game would (#325).
|
||
const syncMix = () => void soundLibrary.setMix(mixOf(state.project), videoBusesOf(state.project));
|
||
syncMix();
|
||
state.on('project', () => {
|
||
syncMix();
|
||
this.play.syncFromModel();
|
||
this.editorRun.invalidate();
|
||
this.syncDocuments();
|
||
});
|
||
state.on('load', () => {
|
||
syncMix();
|
||
this.editorRun.invalidate();
|
||
for (const kind of [...this.floats.keys()]) this.closeWindow(kind);
|
||
for (const doc of this.documents.docs) this.tabs.removeTab(doc);
|
||
this.documents = { docs: [], active: null };
|
||
this.valueTargets.clear();
|
||
this.nodeTargets.clear();
|
||
this.eqEditor.close();
|
||
this.compressorEditor.close();
|
||
this.filterEditor.close();
|
||
this.showTab('scene');
|
||
});
|
||
state.on('mode', () => this.editorRun.modeChanged());
|
||
state.on('pick', () => this.followPick());
|
||
}
|
||
|
||
/** The tab the eyedropper was started from, when it had to leave it for the view it picks in. */
|
||
private pickReturn: { tab: TabId; doc: DocumentId | null } | null = null;
|
||
|
||
/**
|
||
* Brings up the view the eyedropper picks in (#276) – a field in a Variables tab has none beside
|
||
* it – and goes back there once it is taken or cancelled. Dropped, it stays: the user went elsewhere.
|
||
*/
|
||
private followPick(): void {
|
||
const pick = this.state.objectPick;
|
||
if (!pick) {
|
||
const back = this.pickReturn;
|
||
this.pickReturn = null;
|
||
if (!back || this.state.pickEnd === 'drop') return;
|
||
if (back.doc) this.openDocument(back.doc);
|
||
else this.showTab(back.tab);
|
||
return;
|
||
}
|
||
if (this.showsPick()) return;
|
||
this.pickReturn ??= { tab: this.tab, doc: this.documents.active };
|
||
if (pickView(this.state, pick.holderId) === 'container') this.openDocument(`asset:${pick.holderId}`);
|
||
else this.showTab('scene');
|
||
}
|
||
|
||
/** Whether the tab showing is the view the running eyedropper picks in. */
|
||
private showsPick(): boolean {
|
||
const pick = this.state.objectPick;
|
||
if (!pick) return false;
|
||
if (this.shows('container') && this.containerEditor.currentAssetId === pick.holderId) return true;
|
||
return this.tab === 'scene' && pickView(this.state, pick.holderId) === 'scene';
|
||
}
|
||
|
||
/**
|
||
* Where Delete, Copy and Paste go: the window pressed last (#371), else the tab showing.
|
||
*/
|
||
private keyTab(): TabId {
|
||
return this.pressedWindow && this.floats.has(this.pressedWindow) ? this.pressedWindow : this.tab;
|
||
}
|
||
|
||
/** Whether a kind's pane is on screen, in the tab showing or in a window of its own. */
|
||
private shows(kind: CentreTab): boolean {
|
||
return this.tab === kind || this.floats.has(kind);
|
||
}
|
||
|
||
/** Delete takes the point the visible tab has picked (#288), wherever the keyboard focus is. */
|
||
private removePoint(): boolean {
|
||
switch (this.keyTab()) {
|
||
case 'scene':
|
||
return this.viewport.removePickedCorner();
|
||
case 'container':
|
||
return this.containerEditor.canvas.removePickedCorner();
|
||
case 'composition':
|
||
return this.compositionEditor.preview.removePickedCorner();
|
||
case 'texture':
|
||
return this.textureEditor.canvas.removePickedPoint();
|
||
case 'vectorart':
|
||
return this.vectorArtEditor.canvas.removePickedPoint();
|
||
case 'curve':
|
||
return this.curveEditor.canvas.removePickedPoint();
|
||
case 'pattern':
|
||
return this.patternEditor.canvas.deleteSelection();
|
||
case 'song':
|
||
return this.songEditor.canvas.deleteSelection();
|
||
case 'eq':
|
||
// The selection is the EQ's node, still selected from the double-click: it is never what goes.
|
||
this.eqEditor.removePicked();
|
||
return true;
|
||
case 'filter':
|
||
// A Filter has its one band and nothing else to take; its node stays as well.
|
||
return true;
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Ctrl+V lands in whatever the visible tab edits (#240). */
|
||
private paste(): boolean {
|
||
switch (this.keyTab()) {
|
||
case 'animation':
|
||
return this.animationEditor.canvas.pasteKeys();
|
||
case 'scene':
|
||
return this.viewport.pasteHere();
|
||
case 'container':
|
||
return this.containerEditor.canvas.pasteHere();
|
||
case 'composition':
|
||
return this.compositionEditor.canvas.paste();
|
||
case 'graph':
|
||
return this.graphEditor.canvas.paste();
|
||
case 'statemachine':
|
||
return this.stateMachineEditor.canvas.paste();
|
||
case 'shader':
|
||
return this.shaderEditor.canvas.paste();
|
||
case 'particles':
|
||
return this.particlesEditor.canvas.paste();
|
||
case 'soundfx':
|
||
return this.soundFxEditor.canvas.paste();
|
||
case 'instrument':
|
||
return this.instrumentEditor.canvas.paste();
|
||
case 'pattern':
|
||
return this.patternEditor.canvas.paste();
|
||
case 'song':
|
||
return this.songEditor.canvas.paste();
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The main menu's last groups: recording the editor, then the user management for an
|
||
* administrator and the plugins for everybody (#179) – both open the one tab, which holds the
|
||
* plugins below the accounts.
|
||
*/
|
||
private managementEntries(): MenuEntry[] {
|
||
const open = () => this.openDocument('management');
|
||
const recorder = this.editorRecorder;
|
||
return [
|
||
{ separator: true },
|
||
recorder.isRecording
|
||
? { label: 'Stop recording', onSelect: () => recorder.stop() }
|
||
: { label: 'Start recording…', onSelect: () => this.openEditorRecording() },
|
||
{ separator: true },
|
||
{ label: 'General settings…', onSelect: () => this.openGeneralSettings() },
|
||
...(this.usersPane ? [{ label: 'User management', onSelect: open }] : []),
|
||
{ label: 'Plugins', onSelect: open },
|
||
];
|
||
}
|
||
|
||
/** What the editor does for whoever sits at it, kept in this browser (#309). */
|
||
openGeneralSettings(): GeneralSettingsDialog {
|
||
return this.generalDialog.open(new GeneralSettingsDialog(generalSettings(), keepGeneralSettings));
|
||
}
|
||
|
||
/** The open "General settings" dialog, for the check scripts. */
|
||
generalSettingsDialog(): GeneralSettingsDialog | null {
|
||
return this.generalDialog.current();
|
||
}
|
||
|
||
/** "Start recording…": how the editor is recorded, starting on the last choice made in this browser. */
|
||
openEditorRecording(): EditorRecordingDialog {
|
||
return this.recordingDialog.open(
|
||
new EditorRecordingDialog(editorRecordingSettings(), (settings) => {
|
||
keepEditorRecordingSettings(settings);
|
||
this.editorRecorder.start(settings);
|
||
}),
|
||
);
|
||
}
|
||
|
||
/** The open "Start recording" dialog, for the check scripts. */
|
||
editorRecordingDialog(): EditorRecordingDialog | null {
|
||
return this.recordingDialog.current();
|
||
}
|
||
|
||
/**
|
||
* The account menu under its button (#182). The name stands in it as an entry that cannot be
|
||
* picked: it says who is signed in without costing the crowded toolbar another label.
|
||
*/
|
||
private openAccountMenu(anchor: Widget, account: EditorAccount): void {
|
||
if (this.guest) {
|
||
new Menu([
|
||
{ label: 'Guest – nothing is saved', disabled: true, onSelect: () => {} },
|
||
{ separator: true },
|
||
{ label: 'Sign in…', onSelect: () => account.logOut() },
|
||
]).openBelow(anchor);
|
||
return;
|
||
}
|
||
new Menu([
|
||
{ label: `Signed in as ${account.user}`, disabled: true, onSelect: () => {} },
|
||
{ separator: true },
|
||
{
|
||
label: 'Change password…',
|
||
onSelect: () => {
|
||
const dialog = this.passwordDialog.open(
|
||
new PasswordDialog(account.changePassword, () =>
|
||
this.state.setNotice('Password changed – your other sessions and agent tokens were ended'),
|
||
),
|
||
);
|
||
dialog.focusFirstField();
|
||
},
|
||
},
|
||
...this.agentEntries(account),
|
||
{ separator: true },
|
||
{ label: 'Logout', onSelect: () => account.logOut() },
|
||
]).openBelow(anchor);
|
||
}
|
||
|
||
/** The agent's switch and its tokens, in the account menu (#429). */
|
||
private agentEntries(account: EditorAccount): MenuEntry[] {
|
||
const link = this.agent;
|
||
const agents = account.agents;
|
||
if (!link || !agents) return [];
|
||
return [
|
||
{ separator: true },
|
||
{ label: 'Agent', checked: () => link.on, onSelect: () => this.switchAgent(!link.on) },
|
||
{ label: 'Agent tokens…', onSelect: () => void this.openAgentTokens(agents) },
|
||
];
|
||
}
|
||
|
||
private makeAgentLink(app: Application, agents: AgentStore): AgentLink {
|
||
const link = new AgentLink(socketUrl(MCP_PATH, location), {
|
||
ticket: () => agents.ticket(),
|
||
view: () => {
|
||
const view = this.agentView();
|
||
return { view, summary: viewSummary(view) };
|
||
},
|
||
summary: () => viewSummary(this.agentView(false)),
|
||
run: (tool, args) =>
|
||
runTool(tool, args, {
|
||
view: () => this.agentView(),
|
||
screenshot: (maxWidth) => screenshot(app, maxWidth),
|
||
}),
|
||
replaced: () => {
|
||
sessionStorage.removeItem(AGENT_KEY);
|
||
this.state.setNotice('Agent switched off: another tab of your account has it now');
|
||
},
|
||
});
|
||
link.onChange = () => this.syncAgentBadge();
|
||
return link;
|
||
}
|
||
|
||
/**
|
||
* Switches the agent on or off. Kept for this browser tab alone, so a reload – a deployment –
|
||
* comes back the way it was, and another tab does not take over by itself.
|
||
*/
|
||
switchAgent(on: boolean): void {
|
||
if (!this.agent) return;
|
||
if (on) {
|
||
sessionStorage.setItem(AGENT_KEY, '1');
|
||
this.agent.start();
|
||
} else {
|
||
sessionStorage.removeItem(AGENT_KEY);
|
||
this.agent.stop();
|
||
}
|
||
}
|
||
|
||
private syncAgentBadge(): void {
|
||
const link = this.agent;
|
||
if (!link?.on) {
|
||
this.toolbar.showAgent(null);
|
||
return;
|
||
}
|
||
const off = ' Click to switch it off.';
|
||
if (link.status === 'on') {
|
||
this.toolbar.showAgent(
|
||
link.agents > 0 ? `Agent · ${link.agents}` : 'Agent on',
|
||
`An agent may work in this tab (${link.agents} connected).${off}`,
|
||
);
|
||
} else if (link.status === 'retrying') {
|
||
this.toolbar.showAgent('Agent offline', `The agent relay is out of reach; trying again.${off}`);
|
||
} else {
|
||
this.toolbar.showAgent('Agent…', `Connecting to the agent relay.${off}`);
|
||
}
|
||
}
|
||
|
||
/** "Agent tokens…": the list, and the token just made when there is one (shown once). */
|
||
private async openAgentTokens(agents: AgentStore, made: MadeAgentToken | null = null): Promise<void> {
|
||
const reopen = (next: MadeAgentToken | null = null) => void this.openAgentTokens(agents, next);
|
||
const attempt = async (work: () => Promise<void>) => {
|
||
try {
|
||
await work();
|
||
} catch (error) {
|
||
this.state.setNotice(
|
||
`Agent tokens: ${error instanceof Error ? error.message : String(error)}`,
|
||
'error',
|
||
);
|
||
reopen();
|
||
}
|
||
};
|
||
await attempt(async () => {
|
||
const tokens = await agents.list();
|
||
const dialog = this.agentTokensDialog.open(
|
||
new AgentTokensDialog(tokens, made, `${location.origin}${MCP_PATH}`, {
|
||
onCreate: (name, days) =>
|
||
void attempt(async () => {
|
||
const token = await agents.create(name, days);
|
||
this.state.setNotice(`Agent token "${token.name}" made – copy it now`);
|
||
reopen(token);
|
||
}),
|
||
onRevoke: (token) =>
|
||
void attempt(async () => {
|
||
await agents.revoke(token.id);
|
||
this.state.setNotice(`Agent token "${token.name}" revoked`);
|
||
reopen();
|
||
}),
|
||
onCopy: (text, what) =>
|
||
void navigator.clipboard.writeText(text).then(
|
||
() => this.state.setNotice(`${what} copied`),
|
||
() => this.state.setNotice(`${what} could not be copied – select it and press Ctrl+C`, 'error'),
|
||
),
|
||
}),
|
||
);
|
||
if (!made) dialog.focusName();
|
||
});
|
||
}
|
||
|
||
/** The agent's badge in the toolbar, null while hidden (test hook). */
|
||
agentBadge(): string | null {
|
||
return this.toolbar.agentBadge();
|
||
}
|
||
|
||
/** The open "Agent tokens" dialog, for the check scripts. */
|
||
agentTokens(): AgentTokensDialog | null {
|
||
return this.agentTokensDialog.current();
|
||
}
|
||
|
||
/**
|
||
* What the user sees, for the agent (#429). `withCamera` is off for the line the link compares
|
||
* twice a second: the camera is not news.
|
||
*/
|
||
agentView(withCamera = true): AgentView {
|
||
const { state } = this;
|
||
const view = agentView({
|
||
project: state.project,
|
||
mode: state.mode,
|
||
paused: state.paused,
|
||
saveStatus: state.saveStatus,
|
||
tab: { id: this.tab, label: this.tabLabel(this.tab) },
|
||
document: this.documents.active,
|
||
windows: [...this.floats].map(([kind, float]) => ({ kind, document: float.doc })),
|
||
selection: state.selection,
|
||
popups: ui()
|
||
.overlay.children.filter((child): child is Popup => child instanceof Popup && child.visible)
|
||
.map((popup) => ({ kind: popup instanceof Menu ? 'menu' : 'dialog', title: popupTitle(popup) })),
|
||
});
|
||
const camera = withCamera ? this.camera() : undefined;
|
||
return camera ? { ...view, camera } : view;
|
||
}
|
||
|
||
private tabLabel(tab: TabId): string {
|
||
return tab.startsWith('plugin:') ? tab.slice('plugin:'.length) : TAB_LABELS[tab as CentreTab];
|
||
}
|
||
|
||
/** The camera of the tab showing, where it has one: the scene, a container, a graph. */
|
||
private camera(): Record<string, number> | undefined {
|
||
if (this.tab === 'scene') return this.viewport.camera();
|
||
const editor = (this.assetEditors as Partial<Record<TabId, AssetTabEditor<Asset>>>)[this.tab];
|
||
const canvas = editor?.canvas as { camera?: () => Record<string, number> } | undefined;
|
||
return typeof canvas?.camera === 'function' ? canvas.camera() : undefined;
|
||
}
|
||
|
||
/** The open "Change password" dialog, for the check scripts. */
|
||
accountDialog(): PasswordDialog | null {
|
||
return this.passwordDialog.current();
|
||
}
|
||
|
||
/** The open "Project settings" dialog (#183), for the check scripts. */
|
||
projectSettingsDialog(): ProjectSettingsDialog | null {
|
||
return this.projects.projectSettings();
|
||
}
|
||
|
||
/** The open "Open project" dialog (#183), for the check scripts. */
|
||
projectPickerDialog(): OpenProjectDialog | null {
|
||
return this.projects.projectPicker();
|
||
}
|
||
|
||
/** The open "Snapshots" dialog (#231), for the check scripts. */
|
||
snapshotsDialog(): SnapshotsDialog | null {
|
||
return this.projects.snapshotList();
|
||
}
|
||
|
||
/** The open "Release" dialog (#266), for the check scripts. */
|
||
releaseDialog(): ReleaseDialog | null {
|
||
return this.projects.releaseView();
|
||
}
|
||
|
||
/** Whether `id` names a tab already – a built-in one by its own id, a plugin's by the id it gave. */
|
||
hasTab(id: string): boolean {
|
||
return Object.hasOwn(TAB_LABELS, id) || this.pluginPanes.has(`plugin:${id}`);
|
||
}
|
||
|
||
/** 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}`;
|
||
content.visible = false;
|
||
this.centre.addChildAt(content, this.centre.getChildIndex(this.consoleSplitter));
|
||
content.flex = 1;
|
||
this.tabs.addTab(tab, label, FIXED_TABS.length + this.pluginPanes.size);
|
||
this.pluginPanes.set(tab, content);
|
||
this.centre.relayout();
|
||
}
|
||
|
||
/**
|
||
* Takes a plugin's tab away again – the plugin was switched off, or its `activate` failed after
|
||
* adding it. Whoever was looking at it is put back on the scene.
|
||
*/
|
||
removePluginTab(id: string): void {
|
||
const tab: PluginTabId = `plugin:${id}`;
|
||
const pane = this.pluginPanes.get(tab);
|
||
if (!pane) return;
|
||
this.pluginPanes.delete(tab);
|
||
pane.destroy({ children: true });
|
||
this.tabs.removeTab(tab);
|
||
if (this.tab === tab) this.showTab('scene');
|
||
else this.centre.relayout();
|
||
}
|
||
|
||
/** What a plugin put into its tab, for the check scripts. */
|
||
pluginPane(id: string): Widget | undefined {
|
||
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 {
|
||
const kind = this.kindOf(doc);
|
||
if (!kind) return;
|
||
if (this.floats.has(kind)) {
|
||
this.showInWindow(kind, doc);
|
||
return;
|
||
}
|
||
if (this.documents.active === doc) {
|
||
// Still worth a look at the name: a Variables tab's owner may have been renamed.
|
||
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 {
|
||
const floating = [...this.floats].find(([, float]) => float.doc === doc)?.[0];
|
||
if (floating) {
|
||
this.closeWindow(floating);
|
||
return;
|
||
}
|
||
if (!this.documents.docs.includes(doc)) return;
|
||
const wasShowing = this.documents.active === doc;
|
||
const at = this.documents.docs.indexOf(doc);
|
||
this.leaveRow(doc);
|
||
this.forget(doc);
|
||
if (wasShowing) this.showBeside(at);
|
||
}
|
||
|
||
/** Takes a document out of the tab row, without anything else that closing it does. */
|
||
private leaveRow(doc: DocumentId): void {
|
||
this.documents = closeDocument(this.documents, doc);
|
||
this.tabs.removeTab(doc);
|
||
}
|
||
|
||
/** Lets go of what a document kept besides its tab: its editor's view, what its tab was for. */
|
||
private forget(doc: DocumentId): void {
|
||
const kind = this.kindOf(doc);
|
||
if (kind && kind in this.assetEditors)
|
||
this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
|
||
this.valueTargets.delete(doc);
|
||
if (this.nodeTargets.delete(doc)) {
|
||
if (kind === 'eq') this.eqEditor.close();
|
||
if (kind === 'compressor') this.compressorEditor.close();
|
||
if (kind === 'filter') this.filterEditor.close();
|
||
}
|
||
if (doc === 'routing') this.routingEditor.close();
|
||
}
|
||
|
||
/**
|
||
* Shows what comes in place of the document that stood at `at` in the row: its left neighbour,
|
||
* else its right one, else the scene – passing over documents whose kind is in a window.
|
||
*/
|
||
private showBeside(at: number): void {
|
||
const next = neighbourOf(this.documents.docs, at, (doc) => {
|
||
const kind = this.kindOf(doc);
|
||
return kind !== null && !this.floats.has(kind);
|
||
});
|
||
if (next) this.showDocument(next);
|
||
else this.showTab('scene');
|
||
}
|
||
|
||
/**
|
||
* Moves a document out of the tab row into a window of its own (#371), its kind's pane with it.
|
||
* With a window for the kind open already, the document goes there instead.
|
||
*/
|
||
floatDocument(doc: DocumentId): void {
|
||
const kind = this.kindOf(doc);
|
||
if (!kind) return;
|
||
if (this.floats.has(kind)) {
|
||
this.showInWindow(kind, doc);
|
||
return;
|
||
}
|
||
const at = this.documents.docs.indexOf(doc);
|
||
const showing = this.documents.active;
|
||
const centreHadIt = this.tab === kind;
|
||
if (at >= 0) this.leaveRow(doc);
|
||
const window = new FloatWindow({
|
||
onDock: () => this.dockWindow(kind),
|
||
onClose: () => this.closeWindow(kind),
|
||
onPress: () => (this.pressedWindow = kind),
|
||
});
|
||
const pane = this.panes[kind];
|
||
this.centre.removeChild(pane);
|
||
window.setContent(pane);
|
||
const screen = ui().app.screen;
|
||
window.open(openingBox(screen.width, screen.height, this.floats.size));
|
||
this.floats.set(kind, { doc, window });
|
||
this.showInWindow(kind, doc);
|
||
// The tab row showed this kind – the document itself, or another one of it: the pane is gone.
|
||
if (!centreHadIt) return;
|
||
const from = showing && showing !== doc ? this.documents.docs.indexOf(showing) : at;
|
||
this.showBeside(from >= 0 ? from : this.documents.docs.length);
|
||
}
|
||
|
||
/** Puts a window's document back into the tab row, showing, and its pane back below the row. */
|
||
dockWindow(kind: CentreTab): void {
|
||
const float = this.floats.get(kind);
|
||
if (!float) return;
|
||
this.takeBack(kind);
|
||
this.documents = openDocument(this.documents, float.doc);
|
||
this.syncStrip();
|
||
this.showDocument(float.doc);
|
||
// Back at the size it had below the row, the pane would not lay itself out for the move.
|
||
this.panes[kind].layout();
|
||
this.panes[kind].redraw();
|
||
}
|
||
|
||
/** Closes a window and the document in it, as the × of its tab would. */
|
||
closeWindow(kind: CentreTab): void {
|
||
const float = this.floats.get(kind);
|
||
if (!float) return;
|
||
this.takeBack(kind);
|
||
this.forget(float.doc);
|
||
}
|
||
|
||
/** The pane comes home below the tab row, hidden until its kind shows there; the window goes. */
|
||
private takeBack(kind: CentreTab): void {
|
||
const float = this.floats.get(kind)!;
|
||
this.floats.delete(kind);
|
||
if (this.pressedWindow === kind) this.pressedWindow = null;
|
||
const pane = this.panes[kind];
|
||
pane.visible = false;
|
||
this.centre.addChildAt(pane, this.centre.getChildIndex(this.consoleSplitter));
|
||
pane.flex = 1;
|
||
float.window.close();
|
||
float.window.destroy({ children: true });
|
||
this.centre.relayout();
|
||
}
|
||
|
||
/** Points a window at `doc`: another document of its kind takes the place of the one it had. */
|
||
private showInWindow(kind: CentreTab, doc: DocumentId): void {
|
||
const float = this.floats.get(kind)!;
|
||
if (float.doc !== doc) {
|
||
const old = float.doc;
|
||
float.doc = doc;
|
||
// A document has one place: its tab goes when it comes into the window.
|
||
if (this.documents.docs.includes(doc)) this.leaveRow(doc);
|
||
this.forget(old);
|
||
}
|
||
this.pointPane(kind, doc);
|
||
float.window.setTitle(this.labelOf(doc), this.iconOf(doc));
|
||
float.window.toFront();
|
||
this.pressedWindow = kind;
|
||
}
|
||
|
||
/** The windows open, for the check scripts: what each shows, where, and where its controls are. */
|
||
floatWindows(): {
|
||
kind: CentreTab;
|
||
doc: DocumentId;
|
||
title: string;
|
||
box: WindowBox;
|
||
bar: { x: number; y: number };
|
||
grip: { x: number; y: number };
|
||
dock: { x: number; y: number };
|
||
close: { x: number; y: number };
|
||
front: boolean;
|
||
}[] {
|
||
const centre = (w: Widget) => {
|
||
const at = w.getGlobalPosition();
|
||
return { x: at.x + w.w / 2, y: at.y + w.h / 2 };
|
||
};
|
||
return [...this.floats].map(([kind, { doc, window }]) => ({
|
||
kind,
|
||
doc,
|
||
title: window.titleText,
|
||
box: window.box,
|
||
bar: window.barPoint(),
|
||
grip: window.gripPoint(),
|
||
dock: centre(window.dockButton),
|
||
close: centre(window.closeButton),
|
||
front: window.parent?.children.at(-1) === window,
|
||
}));
|
||
}
|
||
|
||
/** Where Delete, Copy and Paste go now, for the check scripts (#371). */
|
||
keyTarget(): TabId {
|
||
return this.keyTab();
|
||
}
|
||
|
||
/** A right-click on a document's tab (#371). */
|
||
private openTabMenu(doc: DocumentId, x: number, y: number): void {
|
||
new Menu([
|
||
{ label: 'Float', onSelect: () => this.floatDocument(doc) },
|
||
{ label: 'Close', onSelect: () => this.closeDocument(doc) },
|
||
]).open(x, y);
|
||
}
|
||
|
||
/**
|
||
* 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 === 'variables') {
|
||
const doc = [...this.documents.docs].reverse().find((d) => d.startsWith('values:'));
|
||
if (doc) this.openDocument(doc);
|
||
return;
|
||
}
|
||
if (tab === 'management' || tab === 'routing') {
|
||
this.openDocument(tab);
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* In fullscreen the game view is all the page shows: it moves from its tab to the root, and the
|
||
* toolbar and the workspace step aside. Leaving puts it back where it was, below the tab header.
|
||
*/
|
||
private showGameFullscreen(on: boolean): void {
|
||
if (on === this.gameView.isFullscreen) return;
|
||
this.toolbar.visible = !on;
|
||
this.workspace.visible = !on;
|
||
for (const { window } of this.floats.values()) window.visible = !on;
|
||
if (on) this.root.add(this.gameView, 1);
|
||
else this.gamePane.add(this.gameView, 1);
|
||
this.gameView.setFullscreen(on);
|
||
this.root.relayout();
|
||
// The pane may come back at the size it had, and a resize to the same size lays nothing out.
|
||
this.gamePane.relayout();
|
||
}
|
||
|
||
/** Back to the editor now, not when the browser reports it: the caller goes on with the layout. */
|
||
private endGameFullscreen(): void {
|
||
leaveFullscreen();
|
||
this.showGameFullscreen(false);
|
||
}
|
||
|
||
private kindOf(doc: DocumentId): CentreTab | null {
|
||
if (doc.startsWith('values:')) {
|
||
const target = this.valueTargets.get(doc);
|
||
return target && valuesTitle(this.state, target) !== null ? 'variables' : null;
|
||
}
|
||
if (doc === 'management' || doc === 'routing') return doc;
|
||
const nodeTab = nodeTabOfDocument(doc);
|
||
if (nodeTab) {
|
||
const target = this.nodeTargets.get(doc);
|
||
return target && nodeOfTab(this.state.project, nodeTab, target.assetId, target.nodeId) ? nodeTab : 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 (this.floats.has(kind)) {
|
||
this.showInWindow(kind, doc);
|
||
return;
|
||
}
|
||
this.pointPane(kind, doc);
|
||
this.display(kind, doc);
|
||
}
|
||
|
||
/** Points the editor of a kind at a document, wherever its pane is. */
|
||
private pointPane(kind: CentreTab, doc: DocumentId): void {
|
||
if (kind in this.assetEditors) this.assetEditors[kind as AssetKind].showAsset(doc.slice('asset:'.length));
|
||
const target = this.valueTargets.get(doc);
|
||
if (kind === 'variables' && target) this.variablesEditor.show(target);
|
||
// Read on every showing: an account may have been changed from another tab in the meantime.
|
||
if (kind === 'management') void this.usersPane?.reload();
|
||
if (kind === 'routing') this.routingEditor.open();
|
||
const node = this.nodeTargets.get(doc);
|
||
if (kind === 'eq' && node) this.eqEditor.show(node.assetId, node.nodeId);
|
||
if (kind === 'compressor' && node) this.compressorEditor.show(node.assetId, node.nodeId);
|
||
if (kind === 'filter' && node) this.filterEditor.show(node.assetId, node.nodeId);
|
||
}
|
||
|
||
/** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */
|
||
private labelOf(doc: DocumentId): string {
|
||
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
|
||
if (doc === 'routing') return TAB_LABELS.routing;
|
||
const node = this.nodeTargets.get(doc);
|
||
const nodeTab = nodeTabOfDocument(doc);
|
||
if (node && nodeTab) return nodeTabLabel(this.state.project, nodeTab, node.assetId, node.nodeId);
|
||
const target = this.valueTargets.get(doc);
|
||
if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? '';
|
||
const asset = findAsset(this.state.project, doc.slice('asset:'.length));
|
||
return asset ? assetTitle(this.state.project, asset) : '';
|
||
}
|
||
|
||
/** The icon in front of a document's name: its asset type's, as in the asset panel. */
|
||
private iconOf(doc: DocumentId): string {
|
||
if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG;
|
||
if (doc === 'routing') return ASSET_ICON_SVG.bus;
|
||
if (nodeTabOfDocument(doc)) return ASSET_ICON_SVG.soundfx;
|
||
const target = this.valueTargets.get(doc);
|
||
if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables;
|
||
const type = 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])
|
||
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: doc === 'management' || doc === 'routing' ? this.labelOf(doc) : TAB_LABELS[kind],
|
||
icon: this.iconOf(doc),
|
||
onClose: () => this.closeDocument(doc),
|
||
onMenu: (x, y) => this.openTabMenu(doc, x, y),
|
||
});
|
||
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);
|
||
for (const [kind, { doc, window }] of [...this.floats])
|
||
if (!this.kindOf(doc)) this.closeWindow(kind);
|
||
else window.setTitle(this.labelOf(doc), this.iconOf(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 {
|
||
// Fullscreen is the game view's; a tab that takes its place (Stop goes back to the scene) ends it.
|
||
if (tab !== 'game') this.endGameFullscreen();
|
||
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)
|
||
: tab === 'composition'
|
||
? () => this.compositionEditor.shownHolder()
|
||
: () => this.state.activeScene,
|
||
);
|
||
// A pane in a window shows there whatever the row shows.
|
||
for (const key of PANE_ORDER) if (!this.floats.has(key)) 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.gameView.visible = tab === 'game';
|
||
this.syncTilemapPanel();
|
||
this.tabs.setActive(strip);
|
||
this.centre.relayout();
|
||
// Hidden, the view was left out when its row was laid out – the eyedropper's bar may have
|
||
// come up over it meanwhile – and its pane may come back at the same size and not relayout.
|
||
if (tab === 'scene') this.sceneRow.relayout();
|
||
// Hidden, it skipped every change; its pane may come back at the same size and not relayout.
|
||
this.gameView.redraw();
|
||
this.viewport.catchUp();
|
||
ui().focus.validate();
|
||
// Picking goes on in its own view only; another tab brought up ends it without writing.
|
||
if (this.state.objectPick && !this.showsPick()) this.state.endObjectPick('drop');
|
||
}
|
||
|
||
/** The tilemap panel belongs to the scene tab and needs a map to paint; otherwise the view gets its room. */
|
||
private syncTilemapPanel(): void {
|
||
const shown = this.tab === 'scene' && this.tilemapPanel.hasTarget;
|
||
if (this.tilemapPanel.visible === shown) return;
|
||
this.tilemapPanel.visible = shown;
|
||
this.tilemapSplitter.visible = shown;
|
||
this.sceneRow.relayout();
|
||
}
|
||
|
||
/**
|
||
* 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<StripTab>[];
|
||
hidden: StripTab[];
|
||
order: StripTab[];
|
||
labels: Record<string, string>;
|
||
} {
|
||
return {
|
||
shown: this.tabs.places(),
|
||
hidden: this.tabs.hidden(),
|
||
order: [...this.tabs.tabs],
|
||
labels: Object.fromEntries(this.documents.docs.map((doc) => [doc, this.labelOf(doc)])),
|
||
};
|
||
}
|
||
|
||
async boot(): Promise<void> {
|
||
await this.projects.boot();
|
||
if (sessionStorage.getItem(AGENT_KEY) === '1') this.agent?.start();
|
||
if (this.guest) {
|
||
this.state.setNotice(
|
||
'You are trying a demo as a guest: change anything, play it, record it – nothing is saved. Sign in from the menu at the far right to keep your work.',
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Takes the editor off the screen and lets go of everything it registered elsewhere – ticker,
|
||
* shortcuts, file drops, plugins, the autosave. Signing out does this instead of reloading the
|
||
* page (#177): the canvas, the renderer and the UI context stay, only what is drawn changes.
|
||
*
|
||
* Unsaved work is written first, while the session that owns it is still good.
|
||
*/
|
||
async dispose(): Promise<void> {
|
||
if (this.disposed) return;
|
||
this.disposed = true;
|
||
this.endGameFullscreen();
|
||
await this.autosave.flush();
|
||
this.autosave.stop();
|
||
if (this.play.isPlaying) this.play.stop();
|
||
this.editorRecorder.stop();
|
||
// Signed out, the next account in this tab starts without the agent.
|
||
this.switchAgent(false);
|
||
this.editorRun.shutDown();
|
||
await this.plugins?.stopAll();
|
||
for (const cleanup of this.cleanups.splice(0).reverse()) cleanup();
|
||
// Before the overlay is emptied: a window listens to the renderer, and its pane goes home with the rest.
|
||
for (const kind of [...this.floats.keys()]) this.closeWindow(kind);
|
||
// Closed rather than just thrown away with the overlay below, so the slot lets go of it too.
|
||
this.passwordDialog.current()?.close();
|
||
this.generalDialog.current()?.close();
|
||
this.recordingDialog.current()?.close();
|
||
// The tooltip is not the editor's to destroy: it leaves the overlay before that is emptied.
|
||
tooltips.dismiss();
|
||
// A menu or a picker left open belongs to widgets that are about to be destroyed.
|
||
for (const popup of ui().overlay.removeChildren()) popup.destroy({ children: true });
|
||
ui().focus.blur();
|
||
this.root.removeChild(this.toolbar, this.workspace);
|
||
this.toolbar.destroy({ children: true });
|
||
this.workspace.destroy({ children: true });
|
||
// The sign-in screen sets `root` to the size of the window as well, and `resize` lays out only
|
||
// when the size changed – the same reason `App.ts` forgets the size after signing in.
|
||
this.root.resize(0, 0);
|
||
}
|
||
|
||
showGame(): void {
|
||
this.showTab('game');
|
||
this.showPerformance();
|
||
}
|
||
|
||
leaveGame(): void {
|
||
if (this.tab === 'game') this.showTab('scene');
|
||
}
|
||
|
||
private tick(dt: number): void {
|
||
const started = performance.now();
|
||
const drawnBefore = this.gameView.drawTime;
|
||
const sceneDraws = this.viewport.redrawCount;
|
||
const gameDraws = this.gameView.redrawCount;
|
||
this.measureFps(dt);
|
||
// One step for everything below: a world gets exactly the seconds the particle, shader and
|
||
// animation clocks moved, so the global time scale cannot pull them apart.
|
||
// The game's own pause holds the clock as well, so particles and shaders freeze with the world,
|
||
// and so does a run that waits for its videos at the start (#404).
|
||
// A single step out of the pause moves by one fixed step, however long the frame took.
|
||
// A recording not in real time (#423) moves it by one frame of the recording, or holds it.
|
||
const stepping = this.play.beginFrame();
|
||
const recorded = stepping ? null : this.play.recordingStep();
|
||
const frame = stepping ? FIXED_STEP : (recorded ?? dt);
|
||
const held = this.state.paused && !stepping;
|
||
const step = gameClock.advance(frame, held || this.play.isGamePaused || this.play.isWaiting);
|
||
this.play.tick(step, timeStep(frame, gameClock.scale, held));
|
||
this.editorRun.tick(step);
|
||
this.viewport.animate(sceneDraws);
|
||
this.gameView.animate(gameDraws);
|
||
this.play.recordFrame();
|
||
this.console.refresh();
|
||
if (this.shows('animation')) this.animationEditor.tick(step);
|
||
if (this.shows('composition')) this.compositionEditor.tick(dt);
|
||
else this.compositionEditor.hidden();
|
||
if (this.shows('tile')) this.tileEditor.tick(step);
|
||
// The preview is the editor's own, so it runs on while the game is paused.
|
||
if (this.shows('curve')) this.curveEditor.tick(dt);
|
||
if (this.shows('pattern')) this.patternEditor.tick();
|
||
if (this.shows('song')) this.songEditor.tick();
|
||
else this.songEditor.hideMeters();
|
||
if (this.shows('sound')) this.soundEditor.tick();
|
||
if (this.shows('video')) this.videoEditor.tick();
|
||
// The tabs of an EQ or a Compressor and their nodes (#380, #381) listen to the same taps: what
|
||
// is not seen lets go of them first, so it cannot take them from what is.
|
||
const faces = [
|
||
['soundfx', this.soundFxEditor.canvas],
|
||
['instrument', this.instrumentEditor.canvas],
|
||
] as const;
|
||
if (!this.shows('eq')) this.eqEditor.hideSpectrum();
|
||
if (!this.shows('filter')) this.filterEditor.hideSpectrum();
|
||
if (!this.shows('compressor')) this.compressorEditor.hideLevels();
|
||
for (const [kind, canvas] of faces) if (!this.shows(kind)) canvas.hideFaces();
|
||
if (this.shows('eq')) this.eqEditor.tick();
|
||
if (this.shows('filter')) this.filterEditor.tick();
|
||
if (this.shows('compressor')) this.compressorEditor.tick();
|
||
this.facesMoving = false;
|
||
for (const [kind, canvas] of faces)
|
||
if (this.shows(kind)) this.facesMoving = canvas.tickFaces() || this.facesMoving;
|
||
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
|
||
// to do it, and its states move just the same.
|
||
if (this.shows('statemachine') && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
|
||
if (this.shows('particles')) this.particlesEditor.tick();
|
||
if (this.shows('shader')) this.shaderEditor.tick();
|
||
if (this.shows('pixelart')) this.pixelArtEditor.tick();
|
||
if (this.movesByItself()) requestRender();
|
||
const drawn = this.gameView.drawTime - drawnBefore;
|
||
if (this.play.measureFrame(dt, performance.now() - started, drawn, lastRenderTime()))
|
||
this.showPerformance();
|
||
}
|
||
|
||
private showPerformance(): void {
|
||
const reading = this.play.performance();
|
||
if (reading) this.gameView.performanceOverlay.show({ fps: this.fps, ...reading });
|
||
}
|
||
|
||
/**
|
||
* What changes without anyone touching it, and therefore cannot be woken by an event: the
|
||
* running world, a Time shader on screen, the previews that are moving pictures.
|
||
*/
|
||
private movesByItself(): boolean {
|
||
return (
|
||
this.play.isPlaying ||
|
||
this.editorRun.isRunning ||
|
||
(this.viewport.visible && this.viewport.animates) ||
|
||
(this.gameView.visible && this.gameView.animates) ||
|
||
this.shows('particles') ||
|
||
this.shows('pixelart') ||
|
||
(this.shows('shader') && this.shaderEditor.animates) ||
|
||
(this.shows('animation') && this.animationEditor.isPlaying) ||
|
||
(this.shows('composition') && this.compositionEditor.isPlaying) ||
|
||
(this.shows('tile') && this.tileEditor.isPlaying) ||
|
||
(this.shows('curve') && this.curveEditor.isPlaying) ||
|
||
(this.shows('pattern') && this.patternEditor.isPlaying) ||
|
||
(this.shows('song') && this.songEditor.isPlaying) ||
|
||
(this.shows('sound') && this.soundEditor.isPlaying) ||
|
||
(this.shows('video') && this.videoEditor.isPlaying) ||
|
||
(this.shows('eq') && this.eqEditor.hasSpectrum) ||
|
||
(this.shows('filter') && this.filterEditor.hasSpectrum) ||
|
||
(this.shows('compressor') && this.compressorEditor.moving) ||
|
||
this.facesMoving
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Counts drawn frames over the time the renderer was awake. Idle seconds are not slow seconds:
|
||
* since the editor stopped drawing what nobody changed, counting them would drag the readout to
|
||
* zero while everything is fine. It answers "how smooth is it while it moves", and holds its
|
||
* last answer in between.
|
||
*/
|
||
private measureFps(dt: number): void {
|
||
if (!isRendering()) return;
|
||
this.fpsElapsed += dt;
|
||
if (this.fpsElapsed < 0.5) return;
|
||
const drawn = renderCount();
|
||
this.fps = Math.round((drawn - this.fpsFrames) / this.fpsElapsed);
|
||
this.fpsFrames = drawn;
|
||
this.fpsElapsed = 0;
|
||
this.toolbar.setFps(this.fps);
|
||
}
|
||
|
||
/** Read-only view of the running world for the check scripts. */
|
||
worldSnapshot(): WorldSnapshot | null {
|
||
return this.play.snapshot();
|
||
}
|
||
|
||
/** What is running without Play, for the check scripts. */
|
||
editorRunInfo(): { running: boolean; time: number; behaviors: string[]; keyboard: boolean } {
|
||
return this.editorRun.debugInfo();
|
||
}
|
||
|
||
/** Which scene Play would start, and under which of the two rules – for the check scripts. */
|
||
playFromInfo(): { mode: PlayFrom; scene: string } {
|
||
return { mode: this.state.playFrom, scene: this.state.sceneToPlay()?.name ?? '' };
|
||
}
|
||
|
||
/** The two run buttons of the top bar (#183): what they show and whether they take a click. */
|
||
runButtons(): ReturnType<Toolbar['runButtons']> {
|
||
return this.toolbar.runButtons();
|
||
}
|
||
|
||
/** The top bar's icon buttons by name, so a check can click what carries no caption. */
|
||
get toolbarControls(): Toolbar['controls'] {
|
||
return this.toolbar.controls;
|
||
}
|
||
|
||
/**
|
||
* Play state for the check scripts; `time` is the world clock a pause has to freeze. `sceneId`
|
||
* is the scene the run is *in* – the only way a check can see a `Load scene` happen, since the
|
||
* swap leaves the editor's own active scene alone.
|
||
*/
|
||
playInfo(): {
|
||
playing: boolean;
|
||
paused: boolean;
|
||
gamePaused: boolean;
|
||
/**
|
||
* Held at the start until the Game tab's driven videos have a picture (#404), or while a
|
||
* recording sets up its encoders.
|
||
*/
|
||
waiting: boolean;
|
||
time: number;
|
||
sceneId: string;
|
||
panelUpdates: { structure: number; values: number };
|
||
performance: ReturnType<PlaySession['performance']>;
|
||
} {
|
||
return {
|
||
playing: this.play.isPlaying,
|
||
paused: this.play.isPaused,
|
||
gamePaused: this.play.isGamePaused,
|
||
waiting: this.play.isWaiting,
|
||
time: this.play.time,
|
||
sceneId: this.play.sceneId,
|
||
panelUpdates: this.play.panelUpdates(),
|
||
performance: this.play.performance(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The recordings of the game view (#405), those of the editor, and what became of them on the way
|
||
* into the project.
|
||
*/
|
||
recordingInfo(): ReturnType<PlaySession['recordingInfo']> & {
|
||
editor: ReturnType<EditorRecorder['info']>;
|
||
uploads: RecordingUpload[];
|
||
} {
|
||
return {
|
||
...this.play.recordingInfo(),
|
||
editor: this.editorRecorder.info(),
|
||
uploads: this.recordings.info(),
|
||
};
|
||
}
|
||
|
||
recordingSaved(recording: MadeRecording, options: RecordingUploadOptions): void {
|
||
this.recordings.add(recording, options);
|
||
}
|
||
}
|