feat(commands): add app state dispatcher foundation

This commit is contained in:
syntaxbullet
2026-07-03 09:28:06 +02:00
parent 697d12725d
commit a8dbd26474
11 changed files with 331 additions and 3 deletions

View File

@@ -5,7 +5,8 @@ Follow these rules for the whole repository. More specific `AGENTS.md` files ove
## Boundaries ## Boundaries
- `core/`: pure domain models only. See `core/AGENTS.md`. - `core/`: pure domain models only. See `core/AGENTS.md`.
- `commands/`: the only write path for application state. - `commands/`: the only write path for application state.
- `editor/`: transient editor/app state types, e.g. viewport/camera and selection. - `editor/`: transient editor/app state types and app store, e.g. viewport/camera and selection.
- `keybinds/`: keybind resolution; global consumer first, command fallback second.
- `renderer/`: renders the current `ImageDocument` using the graphics backend, e.g. WebGL. - `renderer/`: renders the current `ImageDocument` using the graphics backend, e.g. WebGL.
- `view/`: React UI shell and controls only. - `view/`: React UI shell and controls only.
@@ -46,6 +47,7 @@ Follow these rules for the whole repository. More specific `AGENTS.md` files ove
## Imports ## Imports
- `core/` imports nothing from app layers. - `core/` imports nothing from app layers.
- `commands/` may import `core/` and `editor/`; avoid importing React or renderer backend APIs. - `commands/` may import `core/` and `editor/`; avoid importing React or renderer backend APIs.
- `editor/` may import `core/` types; it must not import React, commands, renderer, storage, or backend APIs. - `editor/` may import `core/` types and command dispatch infrastructure; it must not import React, renderer, storage, or backend APIs.
- `keybinds/` may import command dispatch types; it must not mutate state directly.
- `renderer/` may import `core/` and `editor/` types; it must not import React components. - `renderer/` may import `core/` and `editor/` types; it must not import React components.
- `view/` may import UI components and command dispatch interfaces; avoid importing renderer internals except through stable view-facing adapters. - `view/` may import UI components and command dispatch interfaces; avoid importing renderer internals except through stable view-facing adapters.

29
commands/dispatcher.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { AppState } from "@editor/state";
import type { CommandContext } from "./command";
import type { CommandRegistry } from "./registry";
export type Dispatch = <TPayload>(commandId: string, payload: TPayload) => AppState;
export type CommandDispatcher = {
dispatch: Dispatch;
};
export function createCommandDispatcher(options: {
registry: CommandRegistry;
getState: () => AppState;
setState: (state: AppState) => void;
}): CommandDispatcher {
return {
dispatch(commandId, payload) {
const command = options.registry.get(commandId);
if (!command) {
throw new Error(`Unknown command: ${commandId}`);
}
const context: CommandContext = { state: options.getState() };
const nextState = command.execute(context, payload);
options.setState(nextState);
return nextState;
},
};
}

View File

@@ -1 +1,19 @@
export type { Command, CommandContext } from "./command"; export type { Command, CommandContext } from "./command";
export type { CommandDispatcher, Dispatch } from "./dispatcher";
export { createCommandDispatcher } from "./dispatcher";
export type { CommandRegistry } from "./registry";
export { createCommandRegistry } from "./registry";
export {
viewportCommands,
viewportPanCommand,
viewportResetCommand,
viewportSetSizeCommand,
viewportSetZoomCommand,
viewportZoomAroundPointCommand,
} from "./viewport";
export type {
ViewportPanPayload,
ViewportSetSizePayload,
ViewportSetZoomPayload,
ViewportZoomAroundPointPayload,
} from "./viewport";

22
commands/registry.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { Command } from "./command";
export type CommandRegistry = {
get(id: string): Command<unknown> | undefined;
list(): Command<unknown>[];
};
export function createCommandRegistry(commands: Command<unknown>[]): CommandRegistry {
const byId = new Map<string, Command<unknown>>();
for (const command of commands) {
if (byId.has(command.id)) {
throw new Error(`Duplicate command id: ${command.id}`);
}
byId.set(command.id, command);
}
return {
get: (id) => byId.get(id),
list: () => [...byId.values()],
};
}

138
commands/viewport.ts Normal file
View File

@@ -0,0 +1,138 @@
import type { Vec2D } from "@core/geometry";
import type { Command } from "./command";
export type ViewportPanPayload = {
delta: Vec2D;
};
export type ViewportSetZoomPayload = {
zoom: number;
};
export type ViewportZoomAroundPointPayload = {
zoom: number;
point: Vec2D;
};
export type ViewportSetSizePayload = {
w: number;
h: number;
};
export const viewportPanCommand: Command<ViewportPanPayload> = {
id: "viewport.pan",
name: "Pan viewport",
execute({ state }, payload) {
return {
...state,
editor: {
...state.editor,
viewport: {
...state.editor.viewport,
center: {
x: state.editor.viewport.center.x + payload.delta.x,
y: state.editor.viewport.center.y + payload.delta.y,
},
},
},
};
},
};
export const viewportSetZoomCommand: Command<ViewportSetZoomPayload> = {
id: "viewport.setZoom",
name: "Set viewport zoom",
execute({ state }, payload) {
const zoom = Math.max(0.01, payload.zoom);
return {
...state,
editor: {
...state.editor,
viewport: {
...state.editor.viewport,
zoom,
},
},
};
},
};
export const viewportZoomAroundPointCommand: Command<ViewportZoomAroundPointPayload> = {
id: "viewport.zoomAroundPoint",
name: "Zoom viewport around point",
execute({ state }, payload) {
const viewport = state.editor.viewport;
const zoom = Math.max(0.01, payload.zoom);
const offset = {
x: payload.point.x - viewport.size.w / 2,
y: payload.point.y - viewport.size.h / 2,
};
const documentPoint = {
x: viewport.center.x + offset.x / viewport.zoom,
y: viewport.center.y + offset.y / viewport.zoom,
};
return {
...state,
editor: {
...state.editor,
viewport: {
...viewport,
zoom,
center: {
x: documentPoint.x - offset.x / zoom,
y: documentPoint.y - offset.y / zoom,
},
},
},
};
},
};
export const viewportSetSizeCommand: Command<ViewportSetSizePayload> = {
id: "viewport.setSize",
name: "Set viewport size",
execute({ state }, payload) {
return {
...state,
editor: {
...state.editor,
viewport: {
...state.editor.viewport,
size: {
w: Math.max(0, payload.w),
h: Math.max(0, payload.h),
},
},
},
};
},
};
export const viewportResetCommand: Command = {
id: "viewport.reset",
name: "Reset viewport",
execute({ state }) {
return {
...state,
editor: {
...state.editor,
viewport: {
center: { x: 0, y: 0 },
zoom: 1,
rotation: 0,
size: state.editor.viewport.size,
},
},
};
},
};
export const viewportCommands = [
viewportPanCommand,
viewportSetZoomCommand,
viewportZoomAroundPointCommand,
viewportSetSizeCommand,
viewportResetCommand,
] satisfies Command<unknown>[];

View File

@@ -1 +1,4 @@
export type { AppState, EditorState, SelectionState, ViewportState } from "./state"; export type { AppState, EditorState, SelectionState, ViewportState } from "./state";
export { createInitialAppState, initialEditorState } from "./initial-state";
export type { AppStore, StateListener } from "./store";
export { createAppStore } from "./store";

26
editor/initial-state.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { AppState, EditorState } from "./state";
export const initialEditorState: EditorState = {
viewport: {
center: { x: 0, y: 0 },
zoom: 1,
rotation: 0,
size: { w: 0, h: 0 },
},
selection: {
layerIds: [],
},
};
export function createInitialAppState(name = "Untitled"): AppState {
return {
document: {
id: crypto.randomUUID(),
name,
version: 1,
artboards: [],
assets: [],
},
editor: initialEditorState,
};
}

39
editor/store.ts Normal file
View File

@@ -0,0 +1,39 @@
import type { CommandDispatcher } from "@commands/dispatcher";
import { createCommandDispatcher } from "@commands/dispatcher";
import type { CommandRegistry } from "@commands/registry";
import type { AppState } from "./state";
export type StateListener = (state: AppState) => void;
export type AppStore = {
getState(): AppState;
subscribe(listener: StateListener): () => void;
dispatch: CommandDispatcher["dispatch"];
};
export function createAppStore(initialState: AppState, registry: CommandRegistry): AppStore {
let state = initialState;
const listeners = new Set<StateListener>();
const emit = () => {
for (const listener of listeners) listener(state);
};
const dispatcher = createCommandDispatcher({
registry,
getState: () => state,
setState: (nextState) => {
state = nextState;
emit();
},
});
return {
getState: () => state,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
dispatch: dispatcher.dispatch,
};
}

2
keybinds/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keybind";
export { handleKeybind, keybindFromEvent } from "./keybind";

48
keybinds/keybind.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { Dispatch } from "@commands/dispatcher";
export type Keybind = string;
export type KeybindEvent = {
key: string;
code: string;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
};
export type GlobalKeybindConsumer = (event: KeybindEvent) => boolean;
export type CommandKeybind = {
commandId: string;
payload?: unknown;
};
export type KeybindMap = ReadonlyMap<Keybind, CommandKeybind>;
export function keybindFromEvent(event: KeybindEvent): Keybind {
const parts = [
event.metaKey ? "Meta" : undefined,
event.ctrlKey ? "Ctrl" : undefined,
event.altKey ? "Alt" : undefined,
event.shiftKey ? "Shift" : undefined,
event.key,
].filter(Boolean);
return parts.join("+");
}
export function handleKeybind(options: {
event: KeybindEvent;
globalConsumer: GlobalKeybindConsumer;
keybindMap: KeybindMap;
dispatch: Dispatch;
}): boolean {
if (options.globalConsumer(options.event)) return true;
const command = options.keybindMap.get(keybindFromEvent(options.event));
if (!command) return false;
options.dispatch(command.commandId, command.payload);
return true;
}

View File

@@ -28,7 +28,8 @@
"@core/*": ["./core/*"], "@core/*": ["./core/*"],
"@renderer/*": ["./renderer/*"], "@renderer/*": ["./renderer/*"],
"@commands/*": ["./commands/*"], "@commands/*": ["./commands/*"],
"@editor/*": ["./editor/*"] "@editor/*": ["./editor/*"],
"@keybinds/*": ["./keybinds/*"]
}, },
// Some stricter flags (disabled by default) // Some stricter flags (disabled by default)