feat(commands): add app state dispatcher foundation
This commit is contained in:
@@ -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
29
commands/dispatcher.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
22
commands/registry.ts
Normal 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
138
commands/viewport.ts
Normal 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>[];
|
||||||
@@ -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
26
editor/initial-state.ts
Normal 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
39
editor/store.ts
Normal 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
2
keybinds/index.ts
Normal 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
48
keybinds/keybind.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user