artcraft: move engine events + EngineStoreBridge into ui-pagescene

The Chromium-style EventListenerMap bus and the per-event-type bridge
both move into the lib. Class identity is single-sourced from
@storyteller/ui-pagescene so emit/subscribe lookups share the same
constructor key whether they're called from inside the lib or from
the artcraft host (e.g. PageEditor's grid-toggle GridVisibleChangedEvent).

The two apps/PageScene/engine files left in place become tiny re-export
shims pointing at the lib so the existing ~50+ importers (engine
internals, action dispatchers, PageEditor) keep working unchanged
during the staged migration -- they'll be deleted in a future commit
once their consumers move into the lib too.

Workspace plumbing:
- frontend/tsconfig.json: nx sync added pagescene to the root project
  references list.
- pagescene/tsconfig.{json,lib.json}: nx sync trimmed the references
  to only the libs the new code actually uses
  (api-enums, model-list, common).
- node_modules/@storyteller/ui-pagescene needs to be a Windows
  directory junction (mklink /J) for the workspace to resolve the
  package; the prior bash `ln -s` produced a copy on Windows that
  didn't reflect the lib's live state and caused misleading
  "no exported member" errors.

Drive-by: nx sync also added a missing `gravatar` reference under
lightbox-modal -- unrelated cleanup riding along.

Verified: artcraft typecheck error count back to 124 baseline (no
new errors from this move).
This commit is contained in:
Tanish Manku committed 2026-05-08 13:38:25 -07:00
1 parent bd18c3ec64
commit cdb16ff9f4
12 files changed
+461 -488

No files matched your search

@@ -1,157 +1,8 @@
// EngineStoreBridge — single boundary between the 3D engine and the
// Zustand store. It is the ONLY file inside engine/ that imports
// usePageSceneStore. Engine subsystems emit typed EngineEvents on the
// bus; this bridge subscribes once per event class at construction and
// translates each event into a one-line store mutation.
//
// One handler per event class — no instanceof chains. The bus routes
// each emit to the matching handler set in O(1) (Map lookup), so the
// hot per-frame events (CameraUpdatedEvent, etc.) avoid the linear
// type-guard scan that the old switch-style had.
import { usePageSceneStore } from "../PageSceneStore";
import { EngineEventBus } from "./events/EngineEventBus";
import {
AssetModalVisibilityChangedEvent,
CameraAspectRatioChangedEvent,
CameraUpdatedEvent,
CamerasReplacedEvent,
EditorLoaderEvent,
EditorStateChangedEvent,
EngineInitializedEvent,
GridVisibleChangedEvent,
InspectorPanelChangedEvent,
ObjectAddedEvent,
ObjectRemovedEvent,
OutlinerItemLockToggledEvent,
OutlinerItemVisibilityToggledEvent,
OutlinerRefreshedEvent,
OutlinerSelectedItemChangedEvent,
PoseControlsVisibilityChangedEvent,
PoseModeChangedEvent,
SceneLoadedEvent,
SceneResetEvent,
SelectedCameraChangedEvent,
SelectedModeChangedEvent,
SelectionChangedEvent,
TransformModeChangedEvent,
TransformSpaceChangedEvent,
} from "./events/EngineEvent";
export type EngineStoreBridgeDeps = {
bus: EngineEventBus;
};
export class EngineStoreBridge {
private unsubscribeAll: () => void;
constructor(deps: EngineStoreBridgeDeps) {
const { bus } = deps;
const unsubs: Array<() => void> = [
// selection / inspector
bus.subscribe(SelectionChangedEvent, (e) =>
usePageSceneStore.getState().setSelectedObject(e.selection),
),
bus.subscribe(InspectorPanelChangedEvent, (e) => {
const store = usePageSceneStore.getState();
if (e.panel) {
store.updateObjectPanel(e.panel);
store.showObjectPanel(e.panel);
} else {
store.hideObjectPanel();
}
}),
// outliner
bus.subscribe(OutlinerRefreshedEvent, (e) =>
usePageSceneStore.getState().setOutlinerItems(e.items),
),
bus.subscribe(OutlinerSelectedItemChangedEvent, (e) =>
usePageSceneStore.getState().setOutlinerSelectedItem(e.item),
),
bus.subscribe(OutlinerItemLockToggledEvent, (e) =>
usePageSceneStore.getState().toggleOutlinerLock(e.uuid),
),
bus.subscribe(OutlinerItemVisibilityToggledEvent, (e) =>
usePageSceneStore.getState().toggleOutlinerVisibility(e.uuid),
),
// scene tree
bus.subscribe(ObjectAddedEvent, (e) => {
const store = usePageSceneStore.getState();
if (e.object.kind === "character") store.addCharacter(e.object);
else if (e.object.kind === "shape") store.addShape(e.object);
else store.addObject(e.object);
}),
bus.subscribe(ObjectRemovedEvent, (e) =>
usePageSceneStore.getState().removeSceneObject(e.uuid),
),
bus.subscribe(SceneResetEvent, () =>
usePageSceneStore.getState().resetScene(),
),
// mode / overlays
bus.subscribe(TransformModeChangedEvent, (e) =>
usePageSceneStore.getState().setTransformMode(e.mode),
),
bus.subscribe(TransformSpaceChangedEvent, (e) =>
usePageSceneStore.getState().setTransformSpace(e.space),
),
bus.subscribe(SelectedModeChangedEvent, (e) =>
usePageSceneStore.getState().setSelectedMode(e.mode),
),
bus.subscribe(EditorStateChangedEvent, (e) =>
usePageSceneStore.getState().setEditorState(e.state),
),
bus.subscribe(EditorLoaderEvent, (e) => {
const store = usePageSceneStore.getState();
if (e.visible) store.showEditorLoader(e.message);
else store.hideEditorLoader();
}),
bus.subscribe(PoseControlsVisibilityChangedEvent, (e) =>
usePageSceneStore.getState().setShowPoseControls(e.visible),
),
bus.subscribe(PoseModeChangedEvent, (e) =>
usePageSceneStore.getState().setPoseMode(e.mode),
),
bus.subscribe(AssetModalVisibilityChangedEvent, (e) => {
const store = usePageSceneStore.getState();
store.setAssetModalVisible(e.visible);
store.setAssetModalVisibleDuringDrag(e.visibleDuringDrag);
}),
bus.subscribe(GridVisibleChangedEvent, (e) =>
usePageSceneStore.getState().setGridVisible(e.visible),
),
// camera
bus.subscribe(CameraAspectRatioChangedEvent, (e) =>
usePageSceneStore.getState().setCameraAspectRatio(e.ratio),
),
bus.subscribe(CamerasReplacedEvent, (e) =>
usePageSceneStore.setState({ cameras: e.cameras }),
),
bus.subscribe(SelectedCameraChangedEvent, (e) =>
usePageSceneStore.getState().setSelectedCameraId(e.cameraId),
),
bus.subscribe(CameraUpdatedEvent, (e) =>
usePageSceneStore.getState().updateCamera(e.cameraId, e.updates),
),
// lifecycle
bus.subscribe(EngineInitializedEvent, (e) =>
usePageSceneStore.getState().setIs3DEditorInitialized(e.initialized),
),
bus.subscribe(SceneLoadedEvent, (e) =>
usePageSceneStore.getState().setIs3DSceneLoaded(e.loaded),
),
];
this.unsubscribeAll = () => {
for (const u of unsubs) u();
};
}
dispose(): void {
this.unsubscribeAll();
}
}
// Re-export shim. Single source of truth lives in
// @storyteller/ui-pagescene.
export {
EngineStoreBridge,
} from "@storyteller/ui-pagescene";
export type {
EngineStoreBridgeDeps,
} from "@storyteller/ui-pagescene";
@@ -1,183 +1,4 @@
// Typed event protocol for the 3D engine.
//
// Architecture (Command/polymorphism — same shape as engine/editor/
// actions/UndoableAction): one class per event kind. Engine sites emit
// `bus.emit(new SelectionChangedEvent(...))`; EngineStoreBridge is the
// single subscriber that translates events to Zustand store mutations.
// Adding a new event kind = one new class file here + one new branch
// in EngineStoreBridge.handle. No other files change.
//
// Engine code never imports the Zustand store; emitting events is the
// only way for the engine to push state changes outward.
import type { CameraAspectRatio, EditorStates } from "../../enums";
import type {
ObjectPanelObject,
OutlinerItem,
PoseMode,
SceneObject,
SelectedSceneObject,
TransformMode,
TransformSpace,
} from "../../PageSceneStore";
import type { Camera } from "@storyteller/common";
export abstract class EngineEvent {
readonly timestamp: number = performance.now();
}
// ─── lifecycle ────────────────────────────────────────────────────────
export class EngineInitializedEvent extends EngineEvent {
constructor(readonly initialized: boolean) {
super();
}
}
export class SceneLoadedEvent extends EngineEvent {
constructor(readonly loaded: boolean) {
super();
}
}
export class SceneResetEvent extends EngineEvent {}
// ─── selection / inspector ─────────────────────────────────────────────
export class SelectionChangedEvent extends EngineEvent {
constructor(readonly selection: SelectedSceneObject | null) {
super();
}
}
export class InspectorPanelChangedEvent extends EngineEvent {
constructor(readonly panel: ObjectPanelObject | null) {
super();
}
}
// ─── scene tree ────────────────────────────────────────────────────────
export class ObjectAddedEvent extends EngineEvent {
constructor(readonly object: SceneObject) {
super();
}
}
export class ObjectRemovedEvent extends EngineEvent {
constructor(readonly uuid: string) {
super();
}
}
export class OutlinerRefreshedEvent extends EngineEvent {
constructor(readonly items: OutlinerItem[]) {
super();
}
}
export class OutlinerSelectedItemChangedEvent extends EngineEvent {
constructor(readonly item: OutlinerItem | null) {
super();
}
}
export class OutlinerItemLockToggledEvent extends EngineEvent {
constructor(readonly uuid: string) {
super();
}
}
export class OutlinerItemVisibilityToggledEvent extends EngineEvent {
constructor(readonly uuid: string) {
super();
}
}
// ─── mode / transform / overlays ──────────────────────────────────────
export class TransformModeChangedEvent extends EngineEvent {
constructor(readonly mode: TransformMode) {
super();
}
}
export class TransformSpaceChangedEvent extends EngineEvent {
constructor(readonly space: TransformSpace) {
super();
}
}
export class SelectedModeChangedEvent extends EngineEvent {
constructor(readonly mode: string) {
super();
}
}
export class EditorStateChangedEvent extends EngineEvent {
constructor(readonly state: EditorStates) {
super();
}
}
export class EditorLoaderEvent extends EngineEvent {
constructor(readonly visible: boolean, readonly message?: string) {
super();
}
}
export class PoseControlsVisibilityChangedEvent extends EngineEvent {
constructor(readonly visible: boolean) {
super();
}
}
export class PoseModeChangedEvent extends EngineEvent {
constructor(readonly mode: PoseMode) {
super();
}
}
export class AssetModalVisibilityChangedEvent extends EngineEvent {
constructor(
readonly visible: boolean,
readonly visibleDuringDrag: boolean,
) {
super();
}
}
export class GridVisibleChangedEvent extends EngineEvent {
constructor(readonly visible: boolean) {
super();
}
}
// ─── camera ────────────────────────────────────────────────────────────
export class CameraAspectRatioChangedEvent extends EngineEvent {
constructor(readonly ratio: CameraAspectRatio) {
super();
}
}
export class CamerasReplacedEvent extends EngineEvent {
constructor(readonly cameras: Camera[]) {
super();
}
}
export class SelectedCameraChangedEvent extends EngineEvent {
constructor(readonly cameraId: string) {
super();
}
}
export class CameraUpdatedEvent extends EngineEvent {
constructor(
readonly cameraId: string,
readonly updates: Partial<Camera>,
) {
super();
}
}
// Re-export shim. Single source of truth lives in
// @storyteller/ui-pagescene; class identity is single-sourced so
// emit/subscribe lookups share the same constructor key.
export * from "@storyteller/ui-pagescene";
@@ -1,50 +1,9 @@
// Per-event-type pub/sub for EngineEvent. Mirrors Chromium's
// EventListenerMap: each event class has its own listener set, and
// emit() walks only that set — no global fan-out, no instanceof
// chains in subscribers. We key by constructor reference instead of
// the DOM's string `type` because identity is faster than string
// hashing and we have no user-visible type strings to intern.
//
// emit() iterates the Set directly. ES Set iteration is well-defined
// under concurrent add/delete (deleted entries skipped, added entries
// visited later in the same iteration) — fine for our engine, since
// no current subscriber self-modifies the listener set mid-dispatch.
import type { EngineEvent } from "./EngineEvent";
export type EngineEventCtor<T extends EngineEvent = EngineEvent> = new (
...args: never[]
) => T;
export type EngineEventListener<T extends EngineEvent = EngineEvent> = (
event: T,
) => void;
export class EngineEventBus {
private byCtor = new Map<
EngineEventCtor,
Set<EngineEventListener<EngineEvent>>
>();
subscribe<T extends EngineEvent>(
ctor: EngineEventCtor<T>,
listener: EngineEventListener<T>,
): () => void {
let set = this.byCtor.get(ctor);
if (!set) {
set = new Set();
this.byCtor.set(ctor, set);
}
const erased = listener as EngineEventListener<EngineEvent>;
set.add(erased);
return () => {
set!.delete(erased);
};
}
emit<T extends EngineEvent>(event: T): void {
const set = this.byCtor.get(event.constructor as EngineEventCtor);
if (!set) return;
for (const fn of set) fn(event);
}
}
// Re-export shim. Single source of truth lives in
// @storyteller/ui-pagescene.
export {
EngineEventBus,
} from "@storyteller/ui-pagescene";
export type {
EngineEventCtor,
EngineEventListener,
} from "@storyteller/ui-pagescene";
@@ -14,6 +14,9 @@
{
"path": "../toaster"
},
{
"path": "../gravatar"
},
{
"path": "../../api"
},
@@ -55,6 +55,9 @@
{
"path": "../toaster/tsconfig.lib.json"
},
{
"path": "../gravatar/tsconfig.lib.json"
},
{
"path": "../../api/tsconfig.lib.json"
},
@@ -41,3 +41,42 @@ export type { XYZ, Simple3DVector } from "./lib/datastructures/common";
// Models (lib-internal types — only what's needed at the public boundary)
export type { MediaItem, AudioMediaItem } from "./lib/models/assets";
// Engine event bus + event classes (host wrappers can emit
// GridVisibleChangedEvent etc. through editor.bus to keep the
// one-way write flow). Class identity is single-sourced from the
// lib so emit/subscribe lookups share the same constructor key.
export { EngineEventBus } from "./lib/engine/events/EngineEventBus";
export { EngineStoreBridge } from "./lib/engine/EngineStoreBridge";
export type { EngineStoreBridgeDeps } from "./lib/engine/EngineStoreBridge";
export type {
EngineEventCtor,
EngineEventListener,
} from "./lib/engine/events/EngineEventBus";
export {
EngineEvent,
EngineInitializedEvent,
SceneLoadedEvent,
SceneResetEvent,
SelectionChangedEvent,
InspectorPanelChangedEvent,
ObjectAddedEvent,
ObjectRemovedEvent,
OutlinerRefreshedEvent,
OutlinerSelectedItemChangedEvent,
OutlinerItemLockToggledEvent,
OutlinerItemVisibilityToggledEvent,
TransformModeChangedEvent,
TransformSpaceChangedEvent,
SelectedModeChangedEvent,
EditorStateChangedEvent,
EditorLoaderEvent,
PoseControlsVisibilityChangedEvent,
PoseModeChangedEvent,
AssetModalVisibilityChangedEvent,
GridVisibleChangedEvent,
CameraAspectRatioChangedEvent,
CamerasReplacedEvent,
SelectedCameraChangedEvent,
CameraUpdatedEvent,
} from "./lib/engine/events/EngineEvent";
@@ -0,0 +1,157 @@
// EngineStoreBridge — single boundary between the 3D engine and the
// Zustand store. It is the ONLY file inside engine/ that imports
// usePageSceneStore. Engine subsystems emit typed EngineEvents on the
// bus; this bridge subscribes once per event class at construction and
// translates each event into a one-line store mutation.
//
// One handler per event class — no instanceof chains. The bus routes
// each emit to the matching handler set in O(1) (Map lookup), so the
// hot per-frame events (CameraUpdatedEvent, etc.) avoid the linear
// type-guard scan that the old switch-style had.
import { usePageSceneStore } from "../PageSceneStore";
import { EngineEventBus } from "./events/EngineEventBus";
import {
AssetModalVisibilityChangedEvent,
CameraAspectRatioChangedEvent,
CameraUpdatedEvent,
CamerasReplacedEvent,
EditorLoaderEvent,
EditorStateChangedEvent,
EngineInitializedEvent,
GridVisibleChangedEvent,
InspectorPanelChangedEvent,
ObjectAddedEvent,
ObjectRemovedEvent,
OutlinerItemLockToggledEvent,
OutlinerItemVisibilityToggledEvent,
OutlinerRefreshedEvent,
OutlinerSelectedItemChangedEvent,
PoseControlsVisibilityChangedEvent,
PoseModeChangedEvent,
SceneLoadedEvent,
SceneResetEvent,
SelectedCameraChangedEvent,
SelectedModeChangedEvent,
SelectionChangedEvent,
TransformModeChangedEvent,
TransformSpaceChangedEvent,
} from "./events/EngineEvent";
export type EngineStoreBridgeDeps = {
bus: EngineEventBus;
};
export class EngineStoreBridge {
private unsubscribeAll: () => void;
constructor(deps: EngineStoreBridgeDeps) {
const { bus } = deps;
const unsubs: Array<() => void> = [
// selection / inspector
bus.subscribe(SelectionChangedEvent, (e) =>
usePageSceneStore.getState().setSelectedObject(e.selection),
),
bus.subscribe(InspectorPanelChangedEvent, (e) => {
const store = usePageSceneStore.getState();
if (e.panel) {
store.updateObjectPanel(e.panel);
store.showObjectPanel(e.panel);
} else {
store.hideObjectPanel();
}
}),
// outliner
bus.subscribe(OutlinerRefreshedEvent, (e) =>
usePageSceneStore.getState().setOutlinerItems(e.items),
),
bus.subscribe(OutlinerSelectedItemChangedEvent, (e) =>
usePageSceneStore.getState().setOutlinerSelectedItem(e.item),
),
bus.subscribe(OutlinerItemLockToggledEvent, (e) =>
usePageSceneStore.getState().toggleOutlinerLock(e.uuid),
),
bus.subscribe(OutlinerItemVisibilityToggledEvent, (e) =>
usePageSceneStore.getState().toggleOutlinerVisibility(e.uuid),
),
// scene tree
bus.subscribe(ObjectAddedEvent, (e) => {
const store = usePageSceneStore.getState();
if (e.object.kind === "character") store.addCharacter(e.object);
else if (e.object.kind === "shape") store.addShape(e.object);
else store.addObject(e.object);
}),
bus.subscribe(ObjectRemovedEvent, (e) =>
usePageSceneStore.getState().removeSceneObject(e.uuid),
),
bus.subscribe(SceneResetEvent, () =>
usePageSceneStore.getState().resetScene(),
),
// mode / overlays
bus.subscribe(TransformModeChangedEvent, (e) =>
usePageSceneStore.getState().setTransformMode(e.mode),
),
bus.subscribe(TransformSpaceChangedEvent, (e) =>
usePageSceneStore.getState().setTransformSpace(e.space),
),
bus.subscribe(SelectedModeChangedEvent, (e) =>
usePageSceneStore.getState().setSelectedMode(e.mode),
),
bus.subscribe(EditorStateChangedEvent, (e) =>
usePageSceneStore.getState().setEditorState(e.state),
),
bus.subscribe(EditorLoaderEvent, (e) => {
const store = usePageSceneStore.getState();
if (e.visible) store.showEditorLoader(e.message);
else store.hideEditorLoader();
}),
bus.subscribe(PoseControlsVisibilityChangedEvent, (e) =>
usePageSceneStore.getState().setShowPoseControls(e.visible),
),
bus.subscribe(PoseModeChangedEvent, (e) =>
usePageSceneStore.getState().setPoseMode(e.mode),
),
bus.subscribe(AssetModalVisibilityChangedEvent, (e) => {
const store = usePageSceneStore.getState();
store.setAssetModalVisible(e.visible);
store.setAssetModalVisibleDuringDrag(e.visibleDuringDrag);
}),
bus.subscribe(GridVisibleChangedEvent, (e) =>
usePageSceneStore.getState().setGridVisible(e.visible),
),
// camera
bus.subscribe(CameraAspectRatioChangedEvent, (e) =>
usePageSceneStore.getState().setCameraAspectRatio(e.ratio),
),
bus.subscribe(CamerasReplacedEvent, (e) =>
usePageSceneStore.setState({ cameras: e.cameras }),
),
bus.subscribe(SelectedCameraChangedEvent, (e) =>
usePageSceneStore.getState().setSelectedCameraId(e.cameraId),
),
bus.subscribe(CameraUpdatedEvent, (e) =>
usePageSceneStore.getState().updateCamera(e.cameraId, e.updates),
),
// lifecycle
bus.subscribe(EngineInitializedEvent, (e) =>
usePageSceneStore.getState().setIs3DEditorInitialized(e.initialized),
),
bus.subscribe(SceneLoadedEvent, (e) =>
usePageSceneStore.getState().setIs3DSceneLoaded(e.loaded),
),
];
this.unsubscribeAll = () => {
for (const u of unsubs) u();
};
}
dispose(): void {
this.unsubscribeAll();
}
}
@@ -0,0 +1,183 @@
// Typed event protocol for the 3D engine.
//
// Architecture (Command/polymorphism — same shape as engine/editor/
// actions/UndoableAction): one class per event kind. Engine sites emit
// `bus.emit(new SelectionChangedEvent(...))`; EngineStoreBridge is the
// single subscriber that translates events to Zustand store mutations.
// Adding a new event kind = one new class file here + one new branch
// in EngineStoreBridge.handle. No other files change.
//
// Engine code never imports the Zustand store; emitting events is the
// only way for the engine to push state changes outward.
import type { CameraAspectRatio, EditorStates } from "../../enums";
import type {
ObjectPanelObject,
OutlinerItem,
PoseMode,
SceneObject,
SelectedSceneObject,
TransformMode,
TransformSpace,
} from "../../PageSceneStore";
import type { Camera } from "@storyteller/common";
export abstract class EngineEvent {
readonly timestamp: number = performance.now();
}
// ─── lifecycle ────────────────────────────────────────────────────────
export class EngineInitializedEvent extends EngineEvent {
constructor(readonly initialized: boolean) {
super();
}
}
export class SceneLoadedEvent extends EngineEvent {
constructor(readonly loaded: boolean) {
super();
}
}
export class SceneResetEvent extends EngineEvent {}
// ─── selection / inspector ─────────────────────────────────────────────
export class SelectionChangedEvent extends EngineEvent {
constructor(readonly selection: SelectedSceneObject | null) {
super();
}
}
export class InspectorPanelChangedEvent extends EngineEvent {
constructor(readonly panel: ObjectPanelObject | null) {
super();
}
}
// ─── scene tree ────────────────────────────────────────────────────────
export class ObjectAddedEvent extends EngineEvent {
constructor(readonly object: SceneObject) {
super();
}
}
export class ObjectRemovedEvent extends EngineEvent {
constructor(readonly uuid: string) {
super();
}
}
export class OutlinerRefreshedEvent extends EngineEvent {
constructor(readonly items: OutlinerItem[]) {
super();
}
}
export class OutlinerSelectedItemChangedEvent extends EngineEvent {
constructor(readonly item: OutlinerItem | null) {
super();
}
}
export class OutlinerItemLockToggledEvent extends EngineEvent {
constructor(readonly uuid: string) {
super();
}
}
export class OutlinerItemVisibilityToggledEvent extends EngineEvent {
constructor(readonly uuid: string) {
super();
}
}
// ─── mode / transform / overlays ──────────────────────────────────────
export class TransformModeChangedEvent extends EngineEvent {
constructor(readonly mode: TransformMode) {
super();
}
}
export class TransformSpaceChangedEvent extends EngineEvent {
constructor(readonly space: TransformSpace) {
super();
}
}
export class SelectedModeChangedEvent extends EngineEvent {
constructor(readonly mode: string) {
super();
}
}
export class EditorStateChangedEvent extends EngineEvent {
constructor(readonly state: EditorStates) {
super();
}
}
export class EditorLoaderEvent extends EngineEvent {
constructor(readonly visible: boolean, readonly message?: string) {
super();
}
}
export class PoseControlsVisibilityChangedEvent extends EngineEvent {
constructor(readonly visible: boolean) {
super();
}
}
export class PoseModeChangedEvent extends EngineEvent {
constructor(readonly mode: PoseMode) {
super();
}
}
export class AssetModalVisibilityChangedEvent extends EngineEvent {
constructor(
readonly visible: boolean,
readonly visibleDuringDrag: boolean,
) {
super();
}
}
export class GridVisibleChangedEvent extends EngineEvent {
constructor(readonly visible: boolean) {
super();
}
}
// ─── camera ────────────────────────────────────────────────────────────
export class CameraAspectRatioChangedEvent extends EngineEvent {
constructor(readonly ratio: CameraAspectRatio) {
super();
}
}
export class CamerasReplacedEvent extends EngineEvent {
constructor(readonly cameras: Camera[]) {
super();
}
}
export class SelectedCameraChangedEvent extends EngineEvent {
constructor(readonly cameraId: string) {
super();
}
}
export class CameraUpdatedEvent extends EngineEvent {
constructor(
readonly cameraId: string,
readonly updates: Partial<Camera>,
) {
super();
}
}
@@ -0,0 +1,50 @@
// Per-event-type pub/sub for EngineEvent. Mirrors Chromium's
// EventListenerMap: each event class has its own listener set, and
// emit() walks only that set — no global fan-out, no instanceof
// chains in subscribers. We key by constructor reference instead of
// the DOM's string `type` because identity is faster than string
// hashing and we have no user-visible type strings to intern.
//
// emit() iterates the Set directly. ES Set iteration is well-defined
// under concurrent add/delete (deleted entries skipped, added entries
// visited later in the same iteration) — fine for our engine, since
// no current subscriber self-modifies the listener set mid-dispatch.
import type { EngineEvent } from "./EngineEvent";
export type EngineEventCtor<T extends EngineEvent = EngineEvent> = new (
...args: never[]
) => T;
export type EngineEventListener<T extends EngineEvent = EngineEvent> = (
event: T,
) => void;
export class EngineEventBus {
private byCtor = new Map<
EngineEventCtor,
Set<EngineEventListener<EngineEvent>>
>();
subscribe<T extends EngineEvent>(
ctor: EngineEventCtor<T>,
listener: EngineEventListener<T>,
): () => void {
let set = this.byCtor.get(ctor);
if (!set) {
set = new Set();
this.byCtor.set(ctor, set);
}
const erased = listener as EngineEventListener<EngineEvent>;
set.add(erased);
return () => {
set!.delete(erased);
};
}
emit<T extends EngineEvent>(event: T): void {
const set = this.byCtor.get(event.constructor as EngineEventCtor);
if (!set) return;
for (const fn of set) fn(event);
}
}
@@ -2,12 +2,6 @@
"files": [],
"include": [],
"references": [
{
"path": "../../common"
},
{
"path": "../../api"
},
{
"path": "../../api-enums"
},
@@ -15,49 +9,7 @@
"path": "../../model-list"
},
{
"path": "../../soundboard"
},
{
"path": "../promptbox"
},
{
"path": "../popover"
},
{
"path": "../loading"
},
{
"path": "../toaster"
},
{
"path": "../gallery-modal"
},
{
"path": "../model-selector"
},
{
"path": "../help-menu"
},
{
"path": "../login-modal"
},
{
"path": "../settings-modal"
},
{
"path": "../modal"
},
{
"path": "../tooltip"
},
{
"path": "../button"
},
{
"path": "../input"
},
{
"path": "../slider-v2"
"path": "../../common"
},
{
"path": "./tsconfig.lib.json"
@@ -44,12 +44,6 @@
"src/**/*.tsx"
],
"references": [
{
"path": "../../common/tsconfig.lib.json"
},
{
"path": "../../api/tsconfig.lib.json"
},
{
"path": "../../api-enums/tsconfig.lib.json"
},
@@ -57,49 +51,7 @@
"path": "../../model-list/tsconfig.lib.json"
},
{
"path": "../../soundboard/tsconfig.lib.json"
},
{
"path": "../promptbox/tsconfig.lib.json"
},
{
"path": "../popover/tsconfig.lib.json"
},
{
"path": "../loading/tsconfig.lib.json"
},
{
"path": "../toaster/tsconfig.lib.json"
},
{
"path": "../gallery-modal/tsconfig.lib.json"
},
{
"path": "../model-selector/tsconfig.lib.json"
},
{
"path": "../help-menu/tsconfig.lib.json"
},
{
"path": "../login-modal/tsconfig.lib.json"
},
{
"path": "../settings-modal/tsconfig.lib.json"
},
{
"path": "../modal/tsconfig.lib.json"
},
{
"path": "../tooltip/tsconfig.lib.json"
},
{
"path": "../button/tsconfig.lib.json"
},
{
"path": "../input/tsconfig.lib.json"
},
{
"path": "../slider-v2/tsconfig.lib.json"
"path": "../../common/tsconfig.lib.json"
}
]
}
+3
View File
@@ -188,6 +188,9 @@
},
{
"path": "./libs/components/checkbox"
},
{
"path": "./libs/components/pagescene"
}
]
}