From a8dbd264749a73506c9457efc412ecbb95d3cc7f Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 3 Jul 2026 09:28:06 +0200 Subject: [PATCH] feat(commands): add app state dispatcher foundation --- AGENTS.md | 6 +- commands/dispatcher.ts | 29 +++++++++ commands/index.ts | 18 ++++++ commands/registry.ts | 22 +++++++ commands/viewport.ts | 138 ++++++++++++++++++++++++++++++++++++++++ editor/index.ts | 3 + editor/initial-state.ts | 26 ++++++++ editor/store.ts | 39 ++++++++++++ keybinds/index.ts | 2 + keybinds/keybind.ts | 48 ++++++++++++++ tsconfig.json | 3 +- 11 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 commands/dispatcher.ts create mode 100644 commands/registry.ts create mode 100644 commands/viewport.ts create mode 100644 editor/initial-state.ts create mode 100644 editor/store.ts create mode 100644 keybinds/index.ts create mode 100644 keybinds/keybind.ts diff --git a/AGENTS.md b/AGENTS.md index 5c8681d..1a20f40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,8 @@ Follow these rules for the whole repository. More specific `AGENTS.md` files ove ## Boundaries - `core/`: pure domain models only. See `core/AGENTS.md`. - `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. - `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 - `core/` imports nothing from app layers. - `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. - `view/` may import UI components and command dispatch interfaces; avoid importing renderer internals except through stable view-facing adapters. diff --git a/commands/dispatcher.ts b/commands/dispatcher.ts new file mode 100644 index 0000000..883e3a7 --- /dev/null +++ b/commands/dispatcher.ts @@ -0,0 +1,29 @@ +import type { AppState } from "@editor/state"; +import type { CommandContext } from "./command"; +import type { CommandRegistry } from "./registry"; + +export type Dispatch = (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; + }, + }; +} diff --git a/commands/index.ts b/commands/index.ts index 8eb46a4..6df89aa 100644 --- a/commands/index.ts +++ b/commands/index.ts @@ -1 +1,19 @@ 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"; diff --git a/commands/registry.ts b/commands/registry.ts new file mode 100644 index 0000000..5af32a9 --- /dev/null +++ b/commands/registry.ts @@ -0,0 +1,22 @@ +import type { Command } from "./command"; + +export type CommandRegistry = { + get(id: string): Command | undefined; + list(): Command[]; +}; + +export function createCommandRegistry(commands: Command[]): CommandRegistry { + const byId = new Map>(); + + 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()], + }; +} diff --git a/commands/viewport.ts b/commands/viewport.ts new file mode 100644 index 0000000..34fd0a2 --- /dev/null +++ b/commands/viewport.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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[]; diff --git a/editor/index.ts b/editor/index.ts index 7b77577..a516f04 100644 --- a/editor/index.ts +++ b/editor/index.ts @@ -1 +1,4 @@ export type { AppState, EditorState, SelectionState, ViewportState } from "./state"; +export { createInitialAppState, initialEditorState } from "./initial-state"; +export type { AppStore, StateListener } from "./store"; +export { createAppStore } from "./store"; diff --git a/editor/initial-state.ts b/editor/initial-state.ts new file mode 100644 index 0000000..d879e66 --- /dev/null +++ b/editor/initial-state.ts @@ -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, + }; +} diff --git a/editor/store.ts b/editor/store.ts new file mode 100644 index 0000000..0169398 --- /dev/null +++ b/editor/store.ts @@ -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(); + + 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, + }; +} diff --git a/keybinds/index.ts b/keybinds/index.ts new file mode 100644 index 0000000..6ab4c1d --- /dev/null +++ b/keybinds/index.ts @@ -0,0 +1,2 @@ +export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keybind"; +export { handleKeybind, keybindFromEvent } from "./keybind"; diff --git a/keybinds/keybind.ts b/keybinds/keybind.ts new file mode 100644 index 0000000..16ede28 --- /dev/null +++ b/keybinds/keybind.ts @@ -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; + +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; +} diff --git a/tsconfig.json b/tsconfig.json index 6088890..955f5ae 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,7 +28,8 @@ "@core/*": ["./core/*"], "@renderer/*": ["./renderer/*"], "@commands/*": ["./commands/*"], - "@editor/*": ["./editor/*"] + "@editor/*": ["./editor/*"], + "@keybinds/*": ["./keybinds/*"] }, // Some stricter flags (disabled by default)