Console panel: capture console.*, timestamped lines, resizable (#50)
A log layer (src/editor/console/log.ts) keeps a 500-line ring buffer and
wraps console.log/info/debug/warn/error, still calling the original. Notices
and behaviour runtime errors go straight to the layer instead of through
console, so editor tests stay quiet and errors can render red. world.log is
untouched: it is per-run structured state that runtime tests and e2e read
directly, not a text stream.
The panel sits under the visible tab, resizable with Splitter('y'), with a
CONSOLE header and a clear button. Each line carries its wall-clock time in
its own dimmed column, so locate() still finds the message text.
The taller centre area drops the graph fit zoom to 0.60, which broke six e2e
checks that assumed one screen pixel is one graph unit and that a fixed
offset from the canvas centre is empty. They now ask __gm.emptySpot(),
__gm.graph.emptySpot() and __gm.graph.zoom() instead of guessing.
locate/locateAll added the unscaled Pixi text width to the global position,
so click points in the scaled graph canvas landed too far right; they use
getBounds() now.
check.sh: 831 tests. Full e2e: ALL PASSED - 671 checks. Smokes 24/19/20.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e7d82bd1a9
commit
c47fd5e4dc
25 files changed
+690
-42
No files matched your search
+21
-3
@@ -2,7 +2,8 @@ import type { Application } from 'pixi.js';
|
||||
import { findAsset, findObjectHolder } from '../core/model/Project';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import type { TextureStore } from '../core/store/TextureStore';
|
||||
import { Button, HStack, Splitter, Theme, ui, VStack, type Widget } from '../ui';
|
||||
import { Button, clamp, HStack, Panel, Splitter, Theme, ui, VStack, type Widget } from '../ui';
|
||||
import { ConsolePanel } from './console/ConsolePanel';
|
||||
import { GameViewport } from './viewport/GameViewport';
|
||||
import { SceneViewport } from './viewport/SceneViewport';
|
||||
import { attachAutosave } from './autosave';
|
||||
@@ -52,6 +53,9 @@ 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;
|
||||
|
||||
/**
|
||||
* Composes toolbar, panels, viewports and graph editor into the one application that edits and
|
||||
@@ -72,6 +76,7 @@ export class Editor implements PlayHost {
|
||||
readonly hierarchy: HierarchyPanel;
|
||||
readonly assets: AssetPanel;
|
||||
readonly inspector: Inspector;
|
||||
readonly console = new ConsolePanel();
|
||||
tab: CentreTab = 'scene';
|
||||
/** Frame rate, refreshed twice a second; null until the first measurement. */
|
||||
fps: number | null = null;
|
||||
@@ -81,6 +86,7 @@ export class Editor implements PlayHost {
|
||||
private readonly leftColumn: VStack;
|
||||
private readonly leftSplitter = new Splitter('x');
|
||||
private readonly rightSplitter = new Splitter('x');
|
||||
readonly consoleSplitter = new Splitter('y');
|
||||
private readonly workspace: HStack;
|
||||
private readonly centre: VStack;
|
||||
private readonly tabButtons: Record<CentreTab, Button>;
|
||||
@@ -88,8 +94,9 @@ export class Editor implements PlayHost {
|
||||
private readonly toolbar: Toolbar;
|
||||
private fpsFrames = 0;
|
||||
private fpsElapsed = 0;
|
||||
private leftWidth = 240;
|
||||
private rightWidth = 300;
|
||||
private leftWidth = 250;
|
||||
private rightWidth = 350;
|
||||
private consoleHeight = 300;
|
||||
|
||||
constructor(
|
||||
app: Application,
|
||||
@@ -169,6 +176,16 @@ export class Editor implements PlayHost {
|
||||
this.panes[tab].visible = tab === 'scene';
|
||||
}
|
||||
this.tabButtons.scene.setToggled(true);
|
||||
// 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();
|
||||
};
|
||||
|
||||
this.leftColumn.minWidth = this.leftWidth;
|
||||
this.inspector.minWidth = this.rightWidth;
|
||||
@@ -263,6 +280,7 @@ export class Editor implements PlayHost {
|
||||
this.measureFps(dt);
|
||||
this.play.tick(dt);
|
||||
this.editorRun.tick(dt);
|
||||
this.console.refresh();
|
||||
if (this.tab === 'animation') this.animationEditor.tick(dt);
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ export class EditorRun {
|
||||
if (!world.stopRequested) return false;
|
||||
const error = behaviorError(world);
|
||||
this.stop();
|
||||
this.state.setNotice(error ?? 'A behavior running in the editor stopped it.');
|
||||
this.state.setNotice(error ?? 'A behavior running in the editor stopped it.', 'error');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export class PlaySession {
|
||||
if (!this.world) return;
|
||||
this.state.setPaused(!this.state.paused);
|
||||
ui().focus.blur();
|
||||
this.state.setNotice(this.playNotice());
|
||||
this.state.setNotice(this.playNotice(), 'quiet');
|
||||
}
|
||||
|
||||
traceFor(assetId: string): Trace | null {
|
||||
@@ -92,7 +92,7 @@ export class PlaySession {
|
||||
this.behaviors = addGameSystems(world, runtime, this.input);
|
||||
this.host.gameView.setWorld(world);
|
||||
this.host.showGame();
|
||||
this.state.setNotice(this.playNotice());
|
||||
this.state.setNotice(this.playNotice(), 'quiet');
|
||||
if (world.stopRequested) this.stop();
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ export class PlaySession {
|
||||
this.state.exitPlay();
|
||||
this.host.leaveGame();
|
||||
const lastError = world && behaviorError(world);
|
||||
this.state.setNotice(lastError ?? '');
|
||||
this.state.setNotice(lastError ?? '', 'error');
|
||||
}
|
||||
|
||||
tick(dt: number): void {
|
||||
|
||||
@@ -30,6 +30,7 @@ export class ProjectSession {
|
||||
} catch (error) {
|
||||
this.state.setNotice(
|
||||
`Could not load project: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -86,7 +87,7 @@ export class ProjectSession {
|
||||
];
|
||||
new Menu(items).openBelow(anchor);
|
||||
};
|
||||
void open().catch((error) => this.state.setNotice(String(error)));
|
||||
void open().catch((error) => this.state.setNotice(String(error), 'error'));
|
||||
}
|
||||
|
||||
openSceneMenu(anchor: Widget): void {
|
||||
|
||||
@@ -30,10 +30,13 @@ export function attachAutosave(
|
||||
} catch (error) {
|
||||
if (error instanceof StaleRevisionError) {
|
||||
state.setSaveStatus('conflict');
|
||||
state.setNotice('This project was changed elsewhere. Reload to continue.');
|
||||
state.setNotice('This project was changed elsewhere. Reload to continue.', 'error');
|
||||
} else {
|
||||
state.setSaveStatus('error');
|
||||
state.setNotice(`Saving failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
state.setNotice(
|
||||
`Saving failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
saving = null;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Button, HStack, Label, Panel, ScrollContainer, type TextStyleToken, VStack } from '../../ui';
|
||||
import { consoleLog, type ConsoleLog, formatTime, type LogLevel } from './log';
|
||||
|
||||
const ROW_HEIGHT = 18;
|
||||
|
||||
/** A row is time plus message, not one string: a check that locates a line goes by its text alone. */
|
||||
interface Row {
|
||||
readonly box: HStack;
|
||||
readonly time: Label;
|
||||
readonly text: Label;
|
||||
}
|
||||
|
||||
const STYLE_OF: Record<LogLevel, TextStyleToken> = {
|
||||
info: 'mono',
|
||||
warn: 'monoWarn',
|
||||
error: 'monoError',
|
||||
};
|
||||
|
||||
/**
|
||||
* The tail of `ConsoleLog` under the centre tabs. Rebuilding runs from `refresh()` rather than
|
||||
* from the log event, so a burst of lines costs one rebuild per frame instead of one per line.
|
||||
*/
|
||||
export class ConsolePanel extends Panel {
|
||||
private readonly scroll = new ScrollContainer();
|
||||
private readonly rows = new VStack();
|
||||
private readonly views: Row[] = [];
|
||||
private stale = true;
|
||||
|
||||
constructor(private readonly log: ConsoleLog = consoleLog) {
|
||||
super({ title: 'Console', padding: 0, gap: 0 });
|
||||
this.header.add(new Button('Clear', { variant: 'ghost', onClick: () => this.log.clear() }));
|
||||
this.scroll.setContent(this.rows);
|
||||
this.content.add(this.scroll, 1);
|
||||
this.log.onChange(() => (this.stale = true));
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
if (!this.stale) return;
|
||||
this.stale = false;
|
||||
const lines = this.log.lines;
|
||||
// Following the tail is only wanted while the tail is what you are looking at.
|
||||
const atBottom = this.scroll.offset >= this.scroll.maxScroll - 1;
|
||||
while (this.views.length > lines.length) {
|
||||
const row = this.views.pop();
|
||||
if (row) this.rows.removeChild(row.box);
|
||||
}
|
||||
while (this.views.length < lines.length) {
|
||||
const time = new Label('', { style: 'monoMuted', padding: 6 });
|
||||
const text = new Label('', { style: 'mono' });
|
||||
const box = new HStack({ gap: 6 });
|
||||
box.minHeight = ROW_HEIGHT;
|
||||
time.minHeight = ROW_HEIGHT;
|
||||
text.minHeight = ROW_HEIGHT;
|
||||
box.add(time);
|
||||
box.add(text, 1);
|
||||
this.views.push({ box, time, text });
|
||||
this.rows.add(box);
|
||||
}
|
||||
lines.forEach((line, i) => {
|
||||
const row = this.views[i]!;
|
||||
row.time.setText(formatTime(line.time));
|
||||
row.text.setText(line.text);
|
||||
row.text.setStyle(STYLE_OF[line.level]);
|
||||
});
|
||||
this.scroll.relayoutContent();
|
||||
if (atBottom) this.scroll.scrollTo(this.scroll.maxScroll);
|
||||
}
|
||||
|
||||
/** What the check scripts read: the lines as drawn, newest last. */
|
||||
debugInfo(): { lines: { level: LogLevel; text: string; at: string }[]; height: number } {
|
||||
return {
|
||||
lines: this.log.lines.map((line) => ({
|
||||
level: line.level,
|
||||
text: line.text,
|
||||
at: formatTime(line.time),
|
||||
})),
|
||||
height: this.h,
|
||||
};
|
||||
}
|
||||
|
||||
/** `offset === max` is the state in which new lines follow the tail. */
|
||||
scrollInfo(): { offset: number; max: number } {
|
||||
return { offset: Math.round(this.scroll.offset), max: Math.round(this.scroll.maxScroll) };
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.log.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
export type LogLevel = 'info' | 'warn' | 'error';
|
||||
|
||||
export interface LogLine {
|
||||
readonly level: LogLevel;
|
||||
readonly text: string;
|
||||
/** Wall clock of the call, in ms; the panel draws it as `HH:MM:SS`. */
|
||||
readonly time: number;
|
||||
}
|
||||
|
||||
/** Local time of `ms` as `HH:MM:SS` – a console tail is read within the session, not across days. */
|
||||
export function formatTime(ms: number): string {
|
||||
const at = new Date(ms);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** Oldest lines are dropped past this; the panel is a tail, not an archive. */
|
||||
export const LOG_CAPACITY = 500;
|
||||
|
||||
const MAX_VALUE = 200;
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
// An Error prints its whole stack, which would bury the message under frames nobody reads here.
|
||||
if (value instanceof Error) return value.stack?.split('\n')[0] ?? `${value.name}: ${value.message}`;
|
||||
if (typeof value === 'function') return `[function ${value.name || 'anonymous'}]`;
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
let json: string;
|
||||
try {
|
||||
json = JSON.stringify(value);
|
||||
} catch {
|
||||
return '[unserialisable]';
|
||||
}
|
||||
return json.length > MAX_VALUE ? `${json.slice(0, MAX_VALUE)}…` : json;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** One line out of the arguments of a `console.*` call, the way the browser joins them. */
|
||||
export function formatArgs(args: readonly unknown[]): string {
|
||||
return args.map(formatValue).join(' ');
|
||||
}
|
||||
|
||||
export class ConsoleLog {
|
||||
private buffer: LogLine[] = [];
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
|
||||
/** Appends `text`; an embedded newline starts a new line, so a stack stays readable. */
|
||||
push(level: LogLevel, text: string): void {
|
||||
const time = this.now();
|
||||
for (const part of text.split('\n')) this.buffer.push({ level, text: part, time });
|
||||
if (this.buffer.length > LOG_CAPACITY) this.buffer = this.buffer.slice(-LOG_CAPACITY);
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
get lines(): readonly LogLine[] {
|
||||
return this.buffer;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.buffer.length === 0) return;
|
||||
this.buffer = [];
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
onChange(listener: () => void): void {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
}
|
||||
|
||||
export const consoleLog = new ConsoleLog();
|
||||
|
||||
const METHODS = ['log', 'info', 'debug', 'warn', 'error'] as const;
|
||||
type ConsoleMethod = (typeof METHODS)[number];
|
||||
|
||||
export type ConsoleLike = Record<ConsoleMethod, (...args: unknown[]) => void>;
|
||||
|
||||
const LEVEL_OF: Record<ConsoleMethod, LogLevel> = {
|
||||
log: 'info',
|
||||
info: 'info',
|
||||
debug: 'info',
|
||||
warn: 'warn',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
/**
|
||||
* Mirrors every `console.*` call into `log` and keeps calling the original, so devtools and the
|
||||
* check scripts still see it. Returns the undo, which only the tests use.
|
||||
*/
|
||||
export function captureConsole(target: ConsoleLike = console, log: ConsoleLog = consoleLog): () => void {
|
||||
const originals = METHODS.map((method) => [method, target[method]] as const);
|
||||
for (const [method, original] of originals) {
|
||||
target[method] = (...args: unknown[]) => {
|
||||
original.apply(target, args);
|
||||
log.push(LEVEL_OF[method], formatArgs(args));
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
for (const [method, original] of originals) target[method] = original;
|
||||
};
|
||||
}
|
||||
+40
-2
@@ -24,8 +24,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
const walk = (node: Container): void => {
|
||||
if (!node.visible) return;
|
||||
if (node instanceof Text && node.text === text) {
|
||||
const p = node.getGlobalPosition();
|
||||
hits.push({ x: p.x + node.width / 2, y: p.y + node.height / 2 });
|
||||
// Global bounds, not `width`/`height`: those are unscaled, and inside the graph canvas
|
||||
// (which is scaled by the zoom) that put the point right of the text it was meant to hit.
|
||||
const box = node.getBounds();
|
||||
hits.push({ x: box.x + box.width / 2, y: box.y + box.height / 2 });
|
||||
return;
|
||||
}
|
||||
for (const child of node.children) walk(child);
|
||||
@@ -180,6 +182,30 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
labelsDropped,
|
||||
shownLabel,
|
||||
viewportCenter: () => centreOf(viewport),
|
||||
/** Its drawn box – a step that wants an empty spot must stay inside what the console left over. */
|
||||
viewportRect: () => {
|
||||
const p = viewport.getGlobalPosition();
|
||||
return { x: p.x, y: p.y, w: viewport.w, h: viewport.h };
|
||||
},
|
||||
/**
|
||||
* A point in the viewport that no object covers – steps click there to deselect. A fixed
|
||||
* offset from the centre lands on an object as soon as the viewport changes size.
|
||||
*/
|
||||
emptySpot: () => {
|
||||
const p = viewport.getGlobalPosition();
|
||||
const scene = state.activeScene;
|
||||
const taken = scene
|
||||
? objectsOf(scene.id)
|
||||
.map((object) => viewport.objectRect(object.id))
|
||||
.filter((rect): rect is NonNullable<typeof rect> => rect !== null)
|
||||
: [];
|
||||
const clear = (x: number, y: number) =>
|
||||
!taken.some((r) => x >= r.x - 12 && x <= r.x + r.w + 12 && y >= r.y - 12 && y <= r.y + r.h + 12);
|
||||
for (let y = p.y + viewport.h - 24; y > p.y + 24; y -= 24) {
|
||||
for (let x = p.x + 24; x < p.x + viewport.w - 24; x += 24) if (clear(x, y)) return { x, y };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
viewportInfo: () => viewport.debugInfo(),
|
||||
/** Where a dragged asset would land in the Scene tab, and why not when it may not. */
|
||||
dropPreview: () => viewport.dropInfo(),
|
||||
@@ -216,6 +242,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
/** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */
|
||||
nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId),
|
||||
canvasRect: () => graphEditor.canvas.canvasRect(),
|
||||
/** Bare canvas below every node – where a step right-clicks to open the palette. */
|
||||
emptySpot: () => graphEditor.canvas.emptySpotGlobal(),
|
||||
selected: () => state.selectedNodeIds,
|
||||
dropTarget: () => graphEditor.canvas.pendingDropTarget(),
|
||||
danglingEdge: () => graphEditor.canvas.danglingEdgeVisible(),
|
||||
@@ -228,6 +256,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
pickerWidth: () => graphEditor.picker.w,
|
||||
pickerAt: () => centreOf(graphEditor.picker),
|
||||
snapping: () => graphEditor.canvas.snapEnabled,
|
||||
zoom: () => graphEditor.canvas.zoomLevel,
|
||||
crumbs: () => graphEditor.breadcrumb.crumbsDrawn(),
|
||||
},
|
||||
stateMachine: {
|
||||
@@ -240,6 +269,15 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
center: () => centreOf(editor.stateMachineEditor.canvas),
|
||||
snapping: () => editor.stateMachineEditor.canvas.snapEnabled,
|
||||
},
|
||||
/** The console panel: its lines as kept, its drawn height, and the divider above it. */
|
||||
console: {
|
||||
lines: () => editor.console.debugInfo().lines,
|
||||
height: () => editor.console.h,
|
||||
clear: () => editor.console.clear(),
|
||||
scroll: () => editor.console.scrollInfo(),
|
||||
centre: () => centreOf(editor.console),
|
||||
splitterAt: () => centreOf(editor.consoleSplitter),
|
||||
},
|
||||
/** The open image picker: what it offers, what is picked, and where a tile sits. */
|
||||
imagePicker: () => currentImagePicker()?.debugInfo() ?? null,
|
||||
imagePickerTile: (file: string) => currentImagePicker()?.tileGlobal(file) ?? null,
|
||||
|
||||
@@ -188,6 +188,11 @@ export class GraphCanvas extends Widget {
|
||||
return this.snapping;
|
||||
}
|
||||
|
||||
/** Screen pixels per graph unit; the fit picks it from the canvas size, so a check cannot assume 1. */
|
||||
get zoomLevel(): number {
|
||||
return this.zoom;
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||
}
|
||||
@@ -797,6 +802,25 @@ export class GraphCanvas extends Widget {
|
||||
return globalRect(this, this.w, this.h);
|
||||
}
|
||||
|
||||
/**
|
||||
* A drawn point no node covers, with room below and right for a node created there and for the
|
||||
* palette that creates it. Searched from the bottom up, so no edge between nodes crosses it.
|
||||
*/
|
||||
emptySpotGlobal(): { x: number; y: number } | null {
|
||||
const rect = this.canvasRect();
|
||||
const room = { below: NODE_HEIGHT_ESTIMATE * this.zoom + 40, right: NODE_WIDTH * this.zoom + 40 };
|
||||
const taken = [...this.views.keys()].map((id) => this.nodeRect(id)).filter((r): r is Rect => r !== null);
|
||||
const clear = (x: number, y: number) =>
|
||||
!taken.some(
|
||||
(r) => x >= r.x - 16 && x <= r.x + r.w + 16 && y >= r.y - 16 && y <= r.y + r.h + room.below + 16,
|
||||
);
|
||||
for (let y = rect.y + rect.h - room.below; y > rect.y + 40; y -= 20) {
|
||||
// Right to left: a node created here must stay inside the view a later marquee spans.
|
||||
for (let x = rect.x + rect.w - room.right; x > rect.x + 40; x -= 20) if (clear(x, y)) return { x, y };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A nested-behavior node takes its title from the behavior it runs. */
|
||||
nodeLabel(nodeId: string): string | null {
|
||||
return this.views.get(nodeId)?.def.label ?? null;
|
||||
|
||||
+10
-1
@@ -13,6 +13,7 @@ import {
|
||||
type Project,
|
||||
} from '../core/model/Project';
|
||||
import { deserializeProject, serializeProject } from '../core/store/serialize';
|
||||
import { consoleLog } from './console/log';
|
||||
|
||||
export type Selection =
|
||||
| { kind: 'asset'; assetId: string }
|
||||
@@ -90,6 +91,9 @@ export type EditorEvent =
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
/** How a notice reaches the console panel: as a line, as a red line, or not at all. */
|
||||
export type NoticeLevel = 'info' | 'error' | 'quiet';
|
||||
|
||||
/**
|
||||
* Single source of truth for the editor. Play mode swaps `project` for a deep clone (same ids)
|
||||
* with its own undo stack; panels and commands keep working on it, and `exitPlay()` discards it.
|
||||
@@ -392,8 +396,13 @@ export class EditorState {
|
||||
this.emit('save');
|
||||
}
|
||||
|
||||
setNotice(message: string): void {
|
||||
/**
|
||||
* Shows `message` in the toolbar and, unless `quiet`, keeps it in the console panel. Quiet is
|
||||
* for the play status lines, which state a mode rather than report an event.
|
||||
*/
|
||||
setNotice(message: string, level: NoticeLevel = 'info'): void {
|
||||
this.notice = message;
|
||||
if (message !== '' && level !== 'quiet') consoleLog.push(level, message);
|
||||
this.emit('notice');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ export function pickImageFiles(): Promise<File[]> {
|
||||
export function pickAndImport(deps: TextureImportDeps): void {
|
||||
void pickImageFiles()
|
||||
.then((files) => importImages(files, deps))
|
||||
.catch((error: unknown) => deps.state.setNotice(String(error)));
|
||||
.catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,7 +264,7 @@ export function installTextureImport(target: HTMLElement, deps: TextureImportDep
|
||||
}
|
||||
|
||||
function run(files: readonly File[], deps: TextureImportDeps): void {
|
||||
void importImages(files, deps).catch((error: unknown) => deps.state.setNotice(String(error)));
|
||||
void importImages(files, deps).catch((error: unknown) => deps.state.setNotice(String(error), 'error'));
|
||||
}
|
||||
|
||||
function imageFilesOf(data: DataTransfer | null): File[] {
|
||||
|
||||
Reference in new issue
Block a user