Compare commits

...

24 Commits

Author SHA1 Message Date
syntaxbullet
b5c7f87a20 refactor(view): extract canvas viewport hooks 2026-07-03 10:16:34 +02:00
syntaxbullet
c7730518ad feat(input): route temporary pan through tool state 2026-07-03 10:14:43 +02:00
syntaxbullet
9e1050e5f3 feat(commands): add tool mode commands 2026-07-03 10:12:28 +02:00
syntaxbullet
c23efeb397 feat(editor): add tool interaction state 2026-07-03 10:10:50 +02:00
syntaxbullet
ac2a156a1f style(view): overlay compact top bar 2026-07-03 10:02:22 +02:00
syntaxbullet
b4f1a149c6 feat(view): show default artboard canvas 2026-07-03 10:01:02 +02:00
syntaxbullet
fb0c424745 refactor(renderer): extract artboard checkerboard drawing 2026-07-03 09:56:03 +02:00
syntaxbullet
3d32e8fbef feat(renderer): draw transparent artboard checkerboard 2026-07-03 09:52:24 +02:00
syntaxbullet
f4ad35a7f8 feat(commands): add transparent artboard creation 2026-07-03 09:52:00 +02:00
syntaxbullet
ca9e265614 feat(commands): add selection commands 2026-07-03 09:51:26 +02:00
syntaxbullet
d77bdc22a9 feat(commands): type command payload dispatch 2026-07-03 09:50:39 +02:00
syntaxbullet
af71a49b09 test(commands): cover viewport command flow 2026-07-03 09:50:03 +02:00
syntaxbullet
a460d6ea05 feat(view): wire app instance into frontend 2026-07-03 09:47:05 +02:00
syntaxbullet
79c3748b0e feat(input): add viewport wheel zoom and panning 2026-07-03 09:47:05 +02:00
syntaxbullet
812db180cd feat(view): wire renderer into canvas viewport 2026-07-03 09:41:37 +02:00
syntaxbullet
90897e3993 fix(renderer): derive backing size from viewport state 2026-07-03 09:41:12 +02:00
syntaxbullet
dd1b892034 feat(input): add DOM event command adapters 2026-07-03 09:37:25 +02:00
syntaxbullet
91436086a8 feat(renderer): add WebGL renderer skeleton 2026-07-03 09:36:43 +02:00
syntaxbullet
bd60d64bc0 feat(view): add canvas viewport shell 2026-07-03 09:36:25 +02:00
syntaxbullet
51027ce535 feat(view): add read-only app state hook 2026-07-03 09:36:13 +02:00
syntaxbullet
9422494458 feat(app): add composition root 2026-07-03 09:36:04 +02:00
syntaxbullet
d7406e803a chore(architecture): enforce layer boundaries 2026-07-03 09:34:21 +02:00
syntaxbullet
a8dbd26474 feat(commands): add app state dispatcher foundation 2026-07-03 09:28:06 +02:00
syntaxbullet
697d12725d feat(editor): add transient app state foundation 2026-07-03 09:24:08 +02:00
56 changed files with 1818 additions and 18 deletions

55
AGENTS.md Normal file
View File

@@ -0,0 +1,55 @@
# Architecture Rules
Follow these rules for the whole repository. More specific `AGENTS.md` files override/add rules for their folders.
## Boundaries
- `core/`: pure domain models only. See `core/AGENTS.md`.
- `app/`: composition root for wiring stores, command registries, input, renderer, and view adapters.
- `commands/`: the only write path for application state.
- `editor/`: transient editor/app state types and app store, e.g. viewport/camera and selection.
- `input/`: keyboard, pointer, mouse, touch, pen, and wheel 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.
## State Ownership
- All persistent document state is represented by `ImageDocument` and related `core/` models.
- Transient editor state is application state too: viewport/camera, selection, tools, active artboard/layer, drag state, etc.
- Commands are the only code allowed to create, replace, or mutate persistent or transient state.
- React must not own, manage, derive authoritative, or alter document/editor state.
- React may hold only local ephemeral UI details with no app meaning, e.g. open popover, hovered button, uncontrolled input draft before command submit.
- Do not update state directly from React event handlers, renderer callbacks, effects, stores, services, or input handlers. Dispatch a command instead.
## Commands
- Every state change must be modeled as a command with an explicit id, payload, and context.
- Commands must be deterministic and testable; avoid DOM, React, WebGL, timers, network, and filesystem access inside command execution.
- Commands return/apply the next state; they should preserve domain invariants and validate payloads before changing state.
- UI actions, menus, toolbar buttons, keyboard shortcuts, pointer/wheel gestures, and renderer interactions all request changes by dispatching commands.
## React / View
- React displays current state and exposes user intent.
- React components receive state snapshots/selectors and command dispatch functions; they do not contain business rules.
- Do not put rendering engine logic, document mutation logic, or editor workflow ownership in React components.
- Effects are for UI integration/subscription setup only, not for deriving or correcting application state.
## Renderer
- The renderer draws the current `ImageDocument` plus read-only editor state overlays.
- Renderer code must not mutate document/editor state directly.
- Renderer interactions may emit intents/events that are translated into commands.
- Keep rendering backend details isolated behind renderer APIs; do not leak WebGL objects into `core/`, `commands/`, or React state.
## Input
- Input handling is ordered and explicit for keyboard, pointer, mouse, touch, pen, and wheel events.
- First check the global input consumer/map.
- If the global consumer handles the input, stop.
- If not consumed globally, input may be consumed by dispatching a command.
- Input handlers must not mutate state directly.
- Avoid ad-hoc component-local shortcuts/gestures unless they are purely local UI behavior and cannot affect app/editor/document state.
## Imports
- `app/` may import from all layers; lower layers must not import from `app/`.
- `core/` imports nothing from app layers.
- `commands/` may import `core/` and `editor/`; avoid importing React or renderer backend APIs.
- `editor/` may import `core/` types and command dispatch infrastructure; it must not import React, renderer, storage, or backend APIs.
- `input/` may import command dispatch types and shared geometry 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.

7
app/AGENTS.md Normal file
View File

@@ -0,0 +1,7 @@
# App Composition Rules
- `app/` is the composition root for runtime wiring.
- Create registries, stores, command sets, input adapters, and renderer/view adapters here.
- Do not implement domain rules, command behavior, rendering backend logic, or React UI here.
- App composition may import from all layers, but lower layers must not import from `app/`.
- Keep factories testable; avoid module-level mutable singleton state unless explicitly required by the runtime entrypoint.

27
app/app.ts Normal file
View File

@@ -0,0 +1,27 @@
import { documentCommands } from "@commands/document";
import { createCommandRegistry } from "@commands/registry";
import { selectionCommands } from "@commands/selection";
import { toolCommands } from "@commands/tool";
import { viewportCommands } from "@commands/viewport";
import { createInitialAppState } from "@editor/initial-state";
import { createAppStore } from "@editor/store";
export type ImageStudioApp = ReturnType<typeof createImageStudioApp>;
export function createImageStudioApp(options?: { documentName?: string; createDefaultArtboard?: boolean }) {
const registry = createCommandRegistry([...viewportCommands, ...selectionCommands, ...documentCommands, ...toolCommands]);
const store = createAppStore(createInitialAppState(options?.documentName), registry);
if (options?.createDefaultArtboard !== false) {
store.dispatch("document.addArtboard", {
id: crypto.randomUUID(),
name: "Artboard 1",
bounds: { x: -400, y: -300, w: 800, h: 600 },
});
}
return {
registry,
store,
};
}

2
app/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export type { ImageStudioApp } from "./app";
export { createImageStudioApp } from "./app";

179
bun.lock
View File

@@ -15,14 +15,43 @@
"tw-animate-css": "^1.4.0",
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/bun": "latest",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^10.6.0",
"tailwindcss": "^4.1.11",
"typescript-eslint": "^8.62.1",
},
},
},
"packages": {
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="],
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="],
@@ -61,12 +90,48 @@
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@26.1.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw=="],
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.62.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/type-utils": "8.62.1", "@typescript-eslint/utils": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.62.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.62.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.62.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.62.1", "@typescript-eslint/types": "^8.62.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.62.1", "", { "dependencies": { "@typescript-eslint/types": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1" } }, "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.62.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.62.1", "", { "dependencies": { "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/utils": "8.62.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.62.1", "", {}, "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.62.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.62.1", "@typescript-eslint/tsconfig-utils": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.62.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.62.1", "@typescript-eslint/types": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.62.1", "", { "dependencies": { "@typescript-eslint/types": "8.62.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g=="],
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
"bun": ["bun@1.3.14", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="],
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
@@ -77,20 +142,134 @@
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.6.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg=="],
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
"tailwindcss": ["tailwindcss@4.3.2", "", {}, "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"typescript-eslint": ["typescript-eslint@8.62.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.62.1", "@typescript-eslint/parser": "8.62.1", "@typescript-eslint/typescript-estree": "8.62.1", "@typescript-eslint/utils": "8.62.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
}
}

10
commands/AGENTS.md Normal file
View File

@@ -0,0 +1,10 @@
# Commands Rules
- `commands/` is the only place state changes are implemented.
- Commands may change persistent `ImageDocument` state and transient `EditorState`.
- Commands must be deterministic, testable, and side-effect free.
- Do not import React, DOM UI, renderer/WebGL internals, storage, network, timers, or filesystem APIs.
- Validate payloads before applying changes.
- Preserve all `core/` and `editor/` invariants.
- Return the next `AppState`; do not mutate existing state objects in place.
- Every command needs a stable id, clear payload type, and focused responsibility.

View File

@@ -1,11 +1,11 @@
import type { ImageDocument } from "@core/document";
import type { AppState } from "@editor/state";
export type CommandContext = {
document: ImageDocument;
state: AppState;
};
export type Command<TPayload = void> = {
id: string;
name: string;
execute(context: CommandContext, payload: TPayload): ImageDocument;
execute(context: CommandContext, payload: TPayload): AppState;
};

View File

@@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { createAppStore } from "@editor/store";
import { createCommandRegistry } from "./registry";
import { viewportPanCommand } from "./viewport";
describe("command dispatcher", () => {
test("throws for unknown commands", () => {
const store = createAppStore(createInitialAppState("Test"), createCommandRegistry([]));
expect(() => store.dispatch("missing" as never, undefined as never)).toThrow("Unknown command: missing");
});
test("dispatch applies command result to store", () => {
const store = createAppStore(createInitialAppState("Test"), createCommandRegistry([viewportPanCommand]));
store.dispatch("viewport.pan", { delta: { x: 3, y: 7 } });
expect(store.getState().editor.viewport.center).toEqual({ x: 3, y: 7 });
});
});

30
commands/dispatcher.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { AppState } from "@editor/state";
import type { CommandContext } from "./command";
import type { CommandId, CommandPayloads } from "./payloads";
import type { CommandRegistry } from "./registry";
export type Dispatch = <TCommandId extends CommandId>(commandId: TCommandId, payload: CommandPayloads[TCommandId]) => 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;
},
};
}

22
commands/document.test.ts Normal file
View File

@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { documentAddArtboardCommand } from "./document";
describe("document commands", () => {
test("adds transparent artboard", () => {
const next = documentAddArtboardCommand.execute(
{ state: createInitialAppState("Test") },
{ id: "a1", name: "Artboard 1", bounds: { x: 0, y: 0, w: 320, h: 240 } },
);
expect(next.document.artboards).toEqual([
{
id: "a1",
name: "Artboard 1",
bounds: { x: 0, y: 0, w: 320, h: 240 },
backgroundColor: "transparent",
layers: [],
},
]);
});
});

34
commands/document.ts Normal file
View File

@@ -0,0 +1,34 @@
import type { Rect } from "@core/geometry";
import type { ArtboardId } from "@core/id";
import type { Command } from "./command";
export type DocumentAddArtboardPayload = {
id: ArtboardId;
name: string;
bounds: Rect;
};
export const documentAddArtboardCommand: Command<DocumentAddArtboardPayload> = {
id: "document.addArtboard",
name: "Add artboard",
execute({ state }, payload) {
return {
...state,
document: {
...state.document,
artboards: [
...state.document.artboards,
{
id: payload.id,
name: payload.name,
bounds: payload.bounds,
backgroundColor: "transparent",
layers: [],
},
],
},
};
},
};
export const documentCommands = [documentAddArtboardCommand] satisfies Command<unknown>[];

View File

@@ -1 +1,26 @@
export type { Command, CommandContext } from "./command";
export { documentAddArtboardCommand, documentCommands } from "./document";
export type { DocumentAddArtboardPayload } from "./document";
export type { CommandDispatcher, Dispatch } from "./dispatcher";
export type { CommandId, CommandPayloads } from "./payloads";
export { createCommandDispatcher } from "./dispatcher";
export type { CommandRegistry } from "./registry";
export { createCommandRegistry } from "./registry";
export { selectionAddLayerCommand, selectionClearCommand, selectionCommands, selectionSetCommand } from "./selection";
export type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
export { toolCommands, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand } from "./tool";
export type { ToolSetActivePayload } from "./tool";
export {
viewportCommands,
viewportPanCommand,
viewportResetCommand,
viewportSetSizeCommand,
viewportSetZoomCommand,
viewportZoomAroundPointCommand,
} from "./viewport";
export type {
ViewportPanPayload,
ViewportSetSizePayload,
ViewportSetZoomPayload,
ViewportZoomAroundPointPayload,
} from "./viewport";

26
commands/payloads.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { DocumentAddArtboardPayload } from "./document";
import type { SelectionAddLayerPayload, SelectionSetPayload } from "./selection";
import type { ToolSetActivePayload } from "./tool";
import type {
ViewportPanPayload,
ViewportSetSizePayload,
ViewportSetZoomPayload,
ViewportZoomAroundPointPayload,
} from "./viewport";
export type CommandPayloads = {
"document.addArtboard": DocumentAddArtboardPayload;
"selection.set": SelectionSetPayload;
"selection.clear": void;
"selection.addLayer": SelectionAddLayerPayload;
"tool.setActive": ToolSetActivePayload;
"tool.enterTemporaryPan": void;
"tool.exitTemporaryPan": void;
"viewport.pan": ViewportPanPayload;
"viewport.setZoom": ViewportSetZoomPayload;
"viewport.zoomAroundPoint": ViewportZoomAroundPointPayload;
"viewport.setSize": ViewportSetSizePayload;
"viewport.reset": void;
};
export type CommandId = keyof CommandPayloads;

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()],
};
}

View File

@@ -0,0 +1,25 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { selectionAddLayerCommand, selectionClearCommand, selectionSetCommand } from "./selection";
describe("selection commands", () => {
test("sets selection", () => {
const next = selectionSetCommand.execute({ state: createInitialAppState("Test") }, { artboardId: "a1", layerIds: ["l1"] });
expect(next.editor.selection).toEqual({ artboardId: "a1", layerIds: ["l1"] });
});
test("adds unique layer selection", () => {
const selected = selectionSetCommand.execute({ state: createInitialAppState("Test") }, { layerIds: ["l1"] });
const next = selectionAddLayerCommand.execute({ state: selected }, { layerId: "l2" });
const same = selectionAddLayerCommand.execute({ state: next }, { layerId: "l2" });
expect(next.editor.selection.layerIds).toEqual(["l1", "l2"]);
expect(same).toBe(next);
});
test("clears selection", () => {
const selected = selectionSetCommand.execute({ state: createInitialAppState("Test") }, { artboardId: "a1", layerIds: ["l1"] });
const next = selectionClearCommand.execute({ state: selected }, undefined);
expect(next.editor.selection).toEqual({ layerIds: [] });
});
});

63
commands/selection.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { ArtboardId, LayerId } from "@core/id";
import type { Command } from "./command";
export type SelectionSetPayload = {
artboardId?: ArtboardId;
layerIds: LayerId[];
};
export type SelectionAddLayerPayload = {
layerId: LayerId;
};
export const selectionSetCommand: Command<SelectionSetPayload> = {
id: "selection.set",
name: "Set selection",
execute({ state }, payload) {
return {
...state,
editor: {
...state.editor,
selection: {
artboardId: payload.artboardId,
layerIds: [...payload.layerIds],
},
},
};
},
};
export const selectionClearCommand: Command = {
id: "selection.clear",
name: "Clear selection",
execute({ state }) {
return {
...state,
editor: {
...state.editor,
selection: { layerIds: [] },
},
};
},
};
export const selectionAddLayerCommand: Command<SelectionAddLayerPayload> = {
id: "selection.addLayer",
name: "Add layer to selection",
execute({ state }, payload) {
if (state.editor.selection.layerIds.includes(payload.layerId)) return state;
return {
...state,
editor: {
...state.editor,
selection: {
...state.editor.selection,
layerIds: [...state.editor.selection.layerIds, payload.layerId],
},
},
};
},
};
export const selectionCommands = [selectionSetCommand, selectionClearCommand, selectionAddLayerCommand] satisfies Command<unknown>[];

19
commands/tool.test.ts Normal file
View File

@@ -0,0 +1,19 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import { toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand, toolSetActiveCommand } from "./tool";
describe("tool commands", () => {
test("sets active tool", () => {
const next = toolSetActiveCommand.execute({ state: createInitialAppState("Test") }, { tool: "pan" });
expect(next.editor.tools).toEqual({ activeTool: "pan", interactionMode: { type: "tool", tool: "pan" } });
});
test("enters and exits temporary pan", () => {
const initial = createInitialAppState("Test");
const panning = toolEnterTemporaryPanCommand.execute({ state: initial }, undefined);
const restored = toolExitTemporaryPanCommand.execute({ state: panning }, undefined);
expect(panning.editor.tools).toEqual({ activeTool: "select", interactionMode: { type: "temporary-pan", previousTool: "select" } });
expect(restored.editor.tools).toEqual(initial.editor.tools);
});
});

64
commands/tool.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { ToolId } from "@editor/tools";
import type { Command } from "./command";
export type ToolSetActivePayload = {
tool: ToolId;
};
export const toolSetActiveCommand: Command<ToolSetActivePayload> = {
id: "tool.setActive",
name: "Set active tool",
execute({ state }, payload) {
return {
...state,
editor: {
...state.editor,
tools: {
activeTool: payload.tool,
interactionMode: { type: "tool", tool: payload.tool },
},
},
};
},
};
export const toolEnterTemporaryPanCommand: Command = {
id: "tool.enterTemporaryPan",
name: "Enter temporary pan",
execute({ state }) {
if (state.editor.tools.interactionMode.type === "temporary-pan") return state;
return {
...state,
editor: {
...state.editor,
tools: {
...state.editor.tools,
interactionMode: { type: "temporary-pan", previousTool: state.editor.tools.activeTool },
},
},
};
},
};
export const toolExitTemporaryPanCommand: Command = {
id: "tool.exitTemporaryPan",
name: "Exit temporary pan",
execute({ state }) {
const mode = state.editor.tools.interactionMode;
if (mode.type !== "temporary-pan") return state;
return {
...state,
editor: {
...state.editor,
tools: {
activeTool: mode.previousTool,
interactionMode: { type: "tool", tool: mode.previousTool },
},
},
};
},
};
export const toolCommands = [toolSetActiveCommand, toolEnterTemporaryPanCommand, toolExitTemporaryPanCommand] satisfies Command<unknown>[];

44
commands/viewport.test.ts Normal file
View File

@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test";
import { createInitialAppState } from "@editor/initial-state";
import {
viewportPanCommand,
viewportResetCommand,
viewportSetSizeCommand,
viewportSetZoomCommand,
viewportZoomAroundPointCommand,
} from "./viewport";
const context = () => ({ state: createInitialAppState("Test") });
describe("viewport commands", () => {
test("pans viewport center", () => {
const next = viewportPanCommand.execute(context(), { delta: { x: 10, y: -5 } });
expect(next.editor.viewport.center).toEqual({ x: 10, y: -5 });
});
test("sets zoom with minimum clamp", () => {
const next = viewportSetZoomCommand.execute(context(), { zoom: -1 });
expect(next.editor.viewport.zoom).toBe(0.01);
});
test("sets non-negative viewport size", () => {
const next = viewportSetSizeCommand.execute(context(), { w: 800, h: -1 });
expect(next.editor.viewport.size).toEqual({ w: 800, h: 0 });
});
test("zooms around a viewport point", () => {
const state = viewportSetSizeCommand.execute(context(), { w: 100, h: 100 });
const next = viewportZoomAroundPointCommand.execute({ state }, { zoom: 2, point: { x: 100, y: 50 } });
expect(next.editor.viewport.zoom).toBe(2);
expect(next.editor.viewport.center).toEqual({ x: 25, y: 0 });
});
test("resets viewport but preserves size", () => {
const sized = viewportSetSizeCommand.execute(context(), { w: 640, h: 480 });
const panned = viewportPanCommand.execute({ state: sized }, { delta: { x: 4, y: 8 } });
const next = viewportResetCommand.execute({ state: panned }, undefined);
expect(next.editor.viewport).toEqual({ center: { x: 0, y: 0 }, zoom: 1, rotation: 0, size: { w: 640, h: 480 } });
});
});

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>[];

34
core/AGENTS.md Normal file
View File

@@ -0,0 +1,34 @@
# Core Domain Model Rules
Follow these rules for all changes in `core/`.
## Purpose
- `core/` contains pure domain types for the image editor: documents, artboards, layers, assets, ids, and geometry.
- Keep it framework-free, runtime-light, and reusable by renderer, commands, persistence, and tests.
## Hard Rules
- Do not import React, DOM APIs, UI components, storage, networking, filesystem, command handlers, or renderer code.
- Do not add side effects, global state, caches, singletons, or environment-dependent behavior.
- Prefer exported TypeScript `type`s. Add runtime code only when it is pure, deterministic, and domain-generic.
- Keep domain files small and focused. One concept per file; re-export public types from `core/index.ts`.
- Use `import type` / `export type` for type-only dependencies.
- Preserve discriminated unions. Every `Layer` variant must have a stable `type` string.
- Do not weaken domain types with `any`, broad `string | number` unions, optional fields, or nullable values unless the domain truly allows absence.
- IDs are opaque aliases from `id.ts`; do not inline plain `string` ID fields in models.
- Do not duplicate geometry shapes outside `geometry.ts`. Use `Vec2D`, `Size`, `Rect`, `Bounds`, `Transform`, `Mat2D`, and `CoordinateSpace`.
- Avoid app/workflow concerns in names and fields. Domain models describe image-editing state, not UI state.
## Model Invariants
- `ImageDocument` owns `artboards` and shared `assets`.
- `Artboard` owns top-level `layers` and has document-space `bounds`.
- `LayerGroup.children` owns nested layers; only groups have children.
- `ImageLayer.assetId` must reference an `Asset.id` in the same document.
- `BaseLayer.opacity` is normalized `0..1`; `visible` and `locked` are explicit booleans.
- `Transform` stores position, scale, and rotation only; derived matrices/bounds should not be persisted on models.
- `clippingMask.maskLayerId` references another layer by `LayerId`; do not embed mask layer objects.
## Changing Models
- Before adding a field, decide whether it is core persisted state or derived/UI state. Derived/UI state does not belong here.
- When adding a new domain type, create a focused file and export it from `index.ts`.
- When adding a new layer kind, update the union in `layer.ts`, add a discriminant, and document its required relationships.
- Keep names stable and serialization-friendly; assume these types may be saved, loaded, diffed, and migrated.

8
editor/AGENTS.md Normal file
View File

@@ -0,0 +1,8 @@
# Editor State Rules
- `editor/` owns transient app/editor state types and the app store.
- Transient state includes viewport/camera, selection, tools, active entities, and drag/session state.
- Do not put persisted document fields here; use `core/` for saved image document state.
- Do not import React, renderer/WebGL, storage, network, or filesystem APIs.
- The store must expose read/subscribe and command dispatch only; no public direct setters.
- State changes must flow through commands, including viewport/camera changes.

6
editor/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export type { AppState, EditorState, SelectionState, ViewportState } from "./state";
export type { InteractionMode, ToolId, ToolState } from "./tools";
export { initialToolState } from "./tools";
export { createInitialAppState, initialEditorState } from "./initial-state";
export type { AppStore, StateListener } from "./store";
export { createAppStore } from "./store";

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

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

27
editor/state.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { ImageDocument } from "@core/document";
import type { Angle, Size, Vec2D } from "@core/geometry";
import type { ArtboardId, LayerId } from "@core/id";
import type { ToolState } from "./tools";
export type ViewportState = {
center: Vec2D;
zoom: number;
rotation: Angle;
size: Size;
};
export type SelectionState = {
artboardId?: ArtboardId;
layerIds: LayerId[];
};
export type EditorState = {
viewport: ViewportState;
selection: SelectionState;
tools: ToolState;
};
export type AppState = {
document: ImageDocument;
editor: EditorState;
};

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,
};
}

15
editor/tools.ts Normal file
View File

@@ -0,0 +1,15 @@
export type ToolId = "select" | "pan";
export type InteractionMode =
| { type: "tool"; tool: ToolId }
| { type: "temporary-pan"; previousTool: ToolId };
export type ToolState = {
activeTool: ToolId;
interactionMode: InteractionMode;
};
export const initialToolState: ToolState = {
activeTool: "select",
interactionMode: { type: "tool", tool: "select" },
};

87
eslint.config.js Normal file
View File

@@ -0,0 +1,87 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
const appLayerImports = [
"@app/*",
"@view/*",
"@renderer/*",
"@commands/*",
"@editor/*",
"@input/*",
"../app/*",
"../view/*",
"../renderer/*",
"../commands/*",
"../editor/*",
"../input/*",
];
export default tseslint.config(
{
ignores: ["dist/**", "node_modules/**", "eslint.config.js"],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }],
},
},
{
files: ["core/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": ["error", { patterns: appLayerImports }],
},
},
{
files: ["commands/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{ patterns: ["@app/*", "@view/*", "@renderer/*", "../app/*", "../view/*", "../renderer/*", "react", "react-dom"] },
],
},
},
{
files: ["editor/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{ patterns: ["@app/*", "@view/*", "@renderer/*", "../app/*", "../view/*", "../renderer/*", "react", "react-dom"] },
],
},
},
{
files: ["renderer/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{ patterns: ["@app/*", "@view/*", "../app/*", "../view/*", "react", "react-dom"] },
],
},
},
{
files: ["input/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
"@app/*",
"@view/*",
"@renderer/*",
"@editor/*",
"../app/*",
"../view/*",
"../renderer/*",
"../editor/*",
"react",
"react-dom",
],
},
],
},
},
);

9
input/AGENTS.md Normal file
View File

@@ -0,0 +1,9 @@
# Input Rules
- `input/` resolves keyboard, pointer, mouse, touch, pen, and wheel input only.
- Resolution order is strict: global consumer first, command fallback second.
- If the global consumer handles input, stop and do not dispatch a command.
- Input handlers must never mutate state directly.
- Input may dispatch commands by id with payloads.
- Keep this layer independent from React, renderer, editor store internals, and persisted core domain models.
- DOM/browser events should be normalized into small input event types before command mapping.

42
input/dom.ts Normal file
View File

@@ -0,0 +1,42 @@
import type { KeybindEvent } from "./keyboard";
import type { PointerInputEvent, WheelInputEvent } from "./pointer";
export function keybindEventFromKeyboardEvent(event: KeyboardEvent): KeybindEvent {
return {
key: event.key,
code: event.code,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
};
}
function normalizePointerType(pointerType: string): PointerInputEvent["pointerType"] {
if (pointerType === "pen" || pointerType === "touch") return pointerType;
return "mouse";
}
export function pointerInputEventFromPointerEvent(event: PointerEvent): PointerInputEvent {
return {
pointerId: event.pointerId,
pointerType: normalizePointerType(event.pointerType),
position: { x: event.offsetX, y: event.offsetY },
buttons: event.buttons,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
};
}
export function wheelInputEventFromWheelEvent(event: WheelEvent): WheelInputEvent {
return {
position: { x: event.offsetX, y: event.offsetY },
delta: { x: event.deltaX, y: event.deltaY },
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
};
}

12
input/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export {
keybindEventFromKeyboardEvent,
pointerInputEventFromPointerEvent,
wheelInputEventFromWheelEvent,
} from "./dom";
export type { CommandKeybind, GlobalKeybindConsumer, Keybind, KeybindEvent, KeybindMap } from "./keyboard";
export { handleKeybind, keybindFromEvent } from "./keyboard";
export type { ViewportPanInputController } from "./viewport-pan";
export { createViewportPanInputController } from "./viewport-pan";
export type { GlobalPointerConsumer, GlobalWheelConsumer, PointerInputEvent, WheelInputEvent } from "./pointer";
export type { ViewportPointerPanHandler } from "./viewport";
export { createViewportPointerPanHandler, handleViewportWheel } from "./viewport";

49
input/keyboard.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { Dispatch } from "@commands/dispatcher";
import type { CommandId, CommandPayloads } from "@commands/payloads";
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<TCommandId extends CommandId = CommandId> = {
commandId: TCommandId;
payload: CommandPayloads[TCommandId];
};
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;
}

24
input/pointer.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { Vec2D } from "@core/geometry";
export type PointerInputEvent = {
pointerId: number;
pointerType: "mouse" | "pen" | "touch";
position: Vec2D;
buttons: number;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
};
export type WheelInputEvent = {
position: Vec2D;
delta: Vec2D;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
};
export type GlobalPointerConsumer = (event: PointerInputEvent) => boolean;
export type GlobalWheelConsumer = (event: WheelInputEvent) => boolean;

View File

@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test";
import { createViewportPanInputController } from "./viewport-pan";
const ignoredState = undefined as never;
describe("viewport pan input controller", () => {
test("space enters and exits temporary pan through commands", () => {
const dispatched: unknown[] = [];
const controller = createViewportPanInputController({
globalKeyConsumer: () => false,
globalPointerConsumer: () => false,
getCurrentZoom: () => 1,
isPanMode: () => false,
dispatch: (commandId, payload) => {
dispatched.push({ commandId, payload });
return ignoredState;
},
});
expect(controller.keyDown(keyEvent("Space"))).toBe(true);
expect(controller.keyUp(keyEvent("Space"))).toBe(true);
expect(dispatched).toEqual([
{ commandId: "tool.enterTemporaryPan", payload: undefined },
{ commandId: "tool.exitTemporaryPan", payload: undefined },
]);
});
test("global key consumer prevents temporary pan command", () => {
let dispatched = false;
const controller = createViewportPanInputController({
globalKeyConsumer: () => true,
globalPointerConsumer: () => false,
getCurrentZoom: () => 1,
isPanMode: () => false,
dispatch: () => {
dispatched = true;
return ignoredState;
},
});
expect(controller.keyDown(keyEvent("Space"))).toBe(true);
expect(dispatched).toBe(false);
});
});
function keyEvent(code: string) {
return { key: code === "Space" ? " " : code, code, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false };
}

48
input/viewport-pan.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { Dispatch } from "@commands/dispatcher";
import type { GlobalKeybindConsumer, KeybindEvent } from "./keyboard";
import type { GlobalPointerConsumer, PointerInputEvent } from "./pointer";
import { createViewportPointerPanHandler } from "./viewport";
export type ViewportPanInputController = {
keyDown(event: KeybindEvent): boolean;
keyUp(event: KeybindEvent): boolean;
pointerDown(event: PointerInputEvent): boolean;
pointerMove(event: PointerInputEvent): boolean;
pointerUp(event: PointerInputEvent): boolean;
isPanMode(): boolean;
};
export function createViewportPanInputController(options: {
globalKeyConsumer: GlobalKeybindConsumer;
globalPointerConsumer: GlobalPointerConsumer;
dispatch: Dispatch;
getCurrentZoom: () => number;
isPanMode: () => boolean;
}): ViewportPanInputController {
const pointerPan = createViewportPointerPanHandler({
globalConsumer: options.globalPointerConsumer,
dispatch: options.dispatch,
getCurrentZoom: options.getCurrentZoom,
canPan: (event) => event.pointerType !== "mouse" || (event.buttons & 4) === 4 || (options.isPanMode() && (event.buttons & 1) === 1),
});
return {
keyDown(event) {
if (options.globalKeyConsumer(event)) return true;
if (event.code !== "Space") return false;
options.dispatch("tool.enterTemporaryPan", undefined);
return true;
},
keyUp(event) {
if (event.code !== "Space") return false;
options.dispatch("tool.exitTemporaryPan", undefined);
return true;
},
pointerDown: pointerPan.pointerDown,
pointerMove: pointerPan.pointerMove,
pointerUp: pointerPan.pointerUp,
isPanMode: options.isPanMode,
};
}

68
input/viewport.test.ts Normal file
View File

@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test";
import { createViewportPointerPanHandler, handleViewportWheel } from "./viewport";
const ignoredState = undefined as never;
describe("viewport input", () => {
test("wheel dispatches zoom around point", () => {
const dispatched: unknown[] = [];
const consumed = handleViewportWheel({
event: { position: { x: 10, y: 20 }, delta: { x: 0, y: -100 }, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false },
globalConsumer: () => false,
currentZoom: 1,
dispatch: (commandId, payload) => {
dispatched.push({ commandId, payload });
return ignoredState;
},
});
expect(consumed).toBe(true);
expect(dispatched[0]).toEqual({ commandId: "viewport.zoomAroundPoint", payload: { zoom: Math.exp(0.1), point: { x: 10, y: 20 } } });
});
test("global wheel consumer prevents command dispatch", () => {
let called = false;
const consumed = handleViewportWheel({
event: { position: { x: 0, y: 0 }, delta: { x: 0, y: 1 }, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false },
globalConsumer: () => true,
currentZoom: 1,
dispatch: () => {
called = true;
return ignoredState;
},
});
expect(consumed).toBe(true);
expect(called).toBe(false);
});
test("middle mouse drag dispatches viewport pan", () => {
const dispatched: unknown[] = [];
const pan = createViewportPointerPanHandler({
globalConsumer: () => false,
getCurrentZoom: () => 2,
dispatch: (commandId, payload) => {
dispatched.push({ commandId, payload });
return ignoredState;
},
});
expect(pan.pointerDown(basePointer({ buttons: 4, position: { x: 10, y: 10 } }))).toBe(true);
expect(pan.pointerMove(basePointer({ buttons: 4, position: { x: 14, y: 6 } }))).toBe(true);
expect(dispatched[0]).toEqual({ commandId: "viewport.pan", payload: { delta: { x: -2, y: 2 } } });
});
});
function basePointer(overrides: Partial<Parameters<ReturnType<typeof createViewportPointerPanHandler>["pointerDown"]>[0]> = {}) {
return {
pointerId: 1,
pointerType: "mouse" as const,
position: { x: 0, y: 0 },
buttons: 0,
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
...overrides,
};
}

78
input/viewport.ts Normal file
View File

@@ -0,0 +1,78 @@
import type { Dispatch } from "@commands/dispatcher";
import type { GlobalPointerConsumer, GlobalWheelConsumer, PointerInputEvent, WheelInputEvent } from "./pointer";
export type ViewportPointerPanHandler = {
pointerDown(event: PointerInputEvent): boolean;
pointerMove(event: PointerInputEvent): boolean;
pointerUp(event: PointerInputEvent): boolean;
};
export function createViewportPointerPanHandler(options: {
globalConsumer: GlobalPointerConsumer;
dispatch: Dispatch;
getCurrentZoom: () => number;
canPan?: (event: PointerInputEvent) => boolean;
}): ViewportPointerPanHandler {
let activePointerId: number | undefined;
let lastPosition: PointerInputEvent["position"] | undefined;
const canPan = options.canPan ?? defaultCanPan;
return {
pointerDown(event) {
if (options.globalConsumer(event)) return true;
if (!canPan(event)) return false;
activePointerId = event.pointerId;
lastPosition = event.position;
return true;
},
pointerMove(event) {
if (activePointerId !== event.pointerId || !lastPosition) return false;
const zoom = options.getCurrentZoom();
const screenDelta = {
x: event.position.x - lastPosition.x,
y: event.position.y - lastPosition.y,
};
lastPosition = event.position;
options.dispatch("viewport.pan", {
delta: {
x: -screenDelta.x / zoom,
y: -screenDelta.y / zoom,
},
});
return true;
},
pointerUp(event) {
if (activePointerId !== event.pointerId) return false;
activePointerId = undefined;
lastPosition = undefined;
return true;
},
};
}
function defaultCanPan(event: PointerInputEvent) {
return event.pointerType !== "mouse" || (event.buttons & 4) === 4;
}
export function handleViewportWheel(options: {
event: WheelInputEvent;
globalConsumer: GlobalWheelConsumer;
dispatch: Dispatch;
currentZoom: number;
}): boolean {
if (options.globalConsumer(options.event)) return true;
const zoomFactor = Math.exp(-options.event.delta.y * 0.001);
options.dispatch("viewport.zoomAroundPoint", {
zoom: options.currentZoom * zoomFactor,
point: options.event.position,
});
return true;
}

View File

@@ -6,7 +6,9 @@
"scripts": {
"dev": "bun --hot index.ts",
"start": "NODE_ENV=production bun index.ts",
"build": "bun run build.ts"
"build": "bun run build.ts",
"lint": "eslint .",
"test": "bun test"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.3",
@@ -19,9 +21,12 @@
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/bun": "latest",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/bun": "latest",
"tailwindcss": "^4.1.11"
"eslint": "^10.6.0",
"tailwindcss": "^4.1.11",
"typescript-eslint": "^8.62.1"
}
}

12
renderer/AGENTS.md Normal file
View File

@@ -0,0 +1,12 @@
# Renderer Rules
- `renderer/` draws the current `ImageDocument` plus read-only editor overlays.
- `renderer.ts` is the renderer entrypoint/orchestrator only; keep it small.
- Extract feature rendering into focused files such as `artboard.ts`, `checkerboard.ts`, `layers.ts`, or backend helpers.
- Do not place shape drawing, shader setup, hit testing, cache management, or feature-specific rendering loops directly in `renderer.ts`.
- Rendering backend details, e.g. WebGL objects, stay isolated here.
- Do not mutate document or editor state directly.
- Renderer interactions may emit intents/events only; callers translate them into commands.
- Do not import React components or view code.
- Do not leak backend objects into `core/`, `commands/`, `editor/`, or React state.
- Cache only derived rendering data; authoritative state lives in `core/` and `editor/`.

21
renderer/artboard.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { Artboard } from "@core/artboard";
import type { ViewportState } from "@editor/state";
import { renderCheckerboard } from "./checkerboard";
import type { ScreenRect, WebGlRendererContext } from "./types";
export function renderArtboard(context: WebGlRendererContext, artboard: Artboard, viewport: ViewportState) {
const rect = artboardScreenRect(context.canvas, artboard, viewport);
if (artboard.backgroundColor === "transparent") {
renderCheckerboard(context, rect, Math.max(4, Math.round(12 * viewport.zoom)));
}
}
function artboardScreenRect(canvas: HTMLCanvasElement, artboard: Artboard, viewport: ViewportState): ScreenRect {
return {
x: Math.round(canvas.width / 2 + (artboard.bounds.x - viewport.center.x) * viewport.zoom),
y: Math.round(canvas.height / 2 + (artboard.bounds.y - viewport.center.y) * viewport.zoom),
w: Math.max(0, Math.round(artboard.bounds.w * viewport.zoom)),
h: Math.max(0, Math.round(artboard.bounds.h * viewport.zoom)),
};
}

26
renderer/checkerboard.ts Normal file
View File

@@ -0,0 +1,26 @@
import { clearScreenRect } from "./clear-rect";
import type { ScreenRect, WebGlRendererContext } from "./types";
const darkChecker = [0.82, 0.82, 0.86, 1] as const;
const lightChecker = [0.94, 0.94, 0.97, 1] as const;
export function renderCheckerboard(context: WebGlRendererContext, rect: ScreenRect, squareSize: number) {
const clampedSquareSize = Math.max(1, squareSize);
const canvasWidth = context.canvas.width;
const canvasHeight = context.canvas.height;
for (let py = rect.y; py < rect.y + rect.h; py += clampedSquareSize) {
for (let px = rect.x; px < rect.x + rect.w; px += clampedSquareSize) {
const x = Math.max(0, px);
const y = Math.max(0, py);
const w = Math.min(px + clampedSquareSize, rect.x + rect.w, canvasWidth) - x;
const h = Math.min(py + clampedSquareSize, rect.y + rect.h, canvasHeight) - y;
if (w <= 0 || h <= 0) continue;
const checker =
(Math.floor((px - rect.x) / clampedSquareSize) + Math.floor((py - rect.y) / clampedSquareSize)) % 2 === 0;
clearScreenRect(context, { x, y, w, h }, checker ? darkChecker : lightChecker);
}
}
}

7
renderer/clear-rect.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";
export function clearScreenRect(context: WebGlRendererContext, rect: ScreenRect, color: RgbaColor) {
context.gl.scissor(rect.x, context.canvas.height - rect.y - rect.h, rect.w, rect.h);
context.gl.clearColor(...color);
context.gl.clear(context.gl.COLOR_BUFFER_BIT);
}

3
renderer/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export type { ImageRenderer, RenderFrame, RendererBackend } from "./renderer";
export { createRenderer } from "./renderer";
export type { RgbaColor, ScreenRect, WebGlRendererContext } from "./types";

51
renderer/renderer.ts Normal file
View File

@@ -0,0 +1,51 @@
import type { ImageDocument } from "@core/document";
import type { EditorState } from "@editor/state";
import { renderArtboard } from "./artboard";
import type { WebGlRendererContext } from "./types";
export type RenderFrame = {
document: ImageDocument;
editor: EditorState;
};
export type ImageRenderer = {
render(frame: RenderFrame): void;
dispose(): void;
};
export type RendererBackend = "webgl";
export function createRenderer(canvas: HTMLCanvasElement, backend: RendererBackend = "webgl"): ImageRenderer {
if (backend !== "webgl") {
throw new Error(`Unsupported renderer backend: ${backend}`);
}
const context = canvas.getContext("webgl2");
if (!context) {
throw new Error("WebGL2 is not available");
}
const rendererContext: WebGlRendererContext = { gl: context, canvas };
return {
render(frame) {
const { w, h } = frame.editor.viewport.size;
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
context.viewport(0, 0, w, h);
context.disable(context.SCISSOR_TEST);
context.clearColor(0.18, 0.18, 0.2, 1);
context.clear(context.COLOR_BUFFER_BIT);
context.enable(context.SCISSOR_TEST);
for (const artboard of frame.document.artboards) {
renderArtboard(rendererContext, artboard, frame.editor.viewport);
}
context.disable(context.SCISSOR_TEST);
},
dispose() {},
};
}

13
renderer/types.ts Normal file
View File

@@ -0,0 +1,13 @@
export type RgbaColor = readonly [number, number, number, number];
export type ScreenRect = {
x: number;
y: number;
w: number;
h: number;
};
export type WebGlRendererContext = {
gl: WebGL2RenderingContext;
canvas: HTMLCanvasElement;
};

View File

@@ -27,7 +27,10 @@
"@view/*": ["./view/*"],
"@core/*": ["./core/*"],
"@renderer/*": ["./renderer/*"],
"@commands/*": ["./commands/*"]
"@commands/*": ["./commands/*"],
"@editor/*": ["./editor/*"],
"@input/*": ["./input/*"],
"@app/*": ["./app/*"]
},
// Some stricter flags (disabled by default)

9
view/AGENTS.md Normal file
View File

@@ -0,0 +1,9 @@
# View / React Rules
- React is display-only UI and user-intent capture.
- React must not own, manage, derive authoritative, or mutate document/editor state.
- React may keep only local UI state with no app meaning, e.g. popover open state or unsent input draft.
- Event handlers, toolbar buttons, menus, and effects must request state changes by dispatching commands.
- Do not implement business rules, document mutation, renderer backend logic, or editor workflow ownership in components.
- Effects are for subscriptions and UI integration only.
- Keep canvas components small. Extract renderer lifecycle, resize observation, input wiring, cursor logic, and other DOM integration into focused hooks/files under `view/canvas/`.

View File

@@ -1,16 +1,25 @@
import { Button } from "@/components/ui/button";
import type { ImageStudioApp } from "@app/app";
import { CanvasViewport } from "./CanvasViewport";
import { useAppState } from "./useAppState";
import "./index.css";
export function App() {
export type AppProps = {
app: ImageStudioApp;
};
export function App({ app }: AppProps) {
const state = useAppState(app.store);
const zoomPercent = Math.round(state.editor.viewport.zoom * 100);
return (
<main className="min-h-screen bg-background text-foreground">
<div className="container mx-auto flex min-h-screen flex-col items-center justify-center gap-6 p-8 text-center">
<div className="space-y-2">
<h1 className="text-4xl font-bold tracking-tight">Image Studio</h1>
<p className="text-muted-foreground">A minimal Bun, React, Tailwind CSS, and shadcn/ui starter.</p>
<main className="relative h-full bg-background text-foreground">
<header className="pointer-events-none absolute inset-x-0 top-0 z-10 flex h-8 items-center justify-between px-3 text-white">
<h1 className="text-sm font-medium">Image Studio</h1>
<div className="text-xs">
{state.document.name} · {zoomPercent}% · {state.editor.viewport.size.w}×{state.editor.viewport.size.h}
</div>
<Button>Get started</Button>
</div>
</header>
<CanvasViewport store={app.store} />
</main>
);
}

40
view/CanvasViewport.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { useMemo, useRef } from "react";
import type { AppStore } from "@editor/store";
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
import { canvasCursorClass } from "./canvas/cursor";
import { useCanvasInput } from "./canvas/useCanvasInput";
import { useCanvasRenderer } from "./canvas/useCanvasRenderer";
import { useCanvasResize } from "./canvas/useCanvasResize";
import { useAppState } from "./useAppState";
const ignoreGlobalKeybind: GlobalKeybindConsumer = () => false;
const ignoreGlobalPointer: GlobalPointerConsumer = () => false;
const ignoreGlobalWheel: GlobalWheelConsumer = () => false;
export type CanvasViewportProps = {
store: AppStore;
globalKeybindConsumer?: GlobalKeybindConsumer;
globalPointerConsumer?: GlobalPointerConsumer;
globalWheelConsumer?: GlobalWheelConsumer;
};
export function CanvasViewport({
store,
globalKeybindConsumer = ignoreGlobalKeybind,
globalPointerConsumer = ignoreGlobalPointer,
globalWheelConsumer = ignoreGlobalWheel,
}: CanvasViewportProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const state = useAppState(store);
const inputOptions = useMemo(
() => ({ globalKeybindConsumer, globalPointerConsumer, globalWheelConsumer }),
[globalKeybindConsumer, globalPointerConsumer, globalWheelConsumer],
);
useCanvasRenderer(canvasRef, store);
useCanvasResize(canvasRef, store.dispatch);
const input = useCanvasInput(canvasRef, store, inputOptions);
const cursorClass = canvasCursorClass(state.editor.tools.interactionMode, input);
return <canvas ref={canvasRef} className={`h-full w-full ${cursorClass}`} />;
}

8
view/canvas/cursor.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { InteractionMode } from "@editor/tools";
import type { CanvasInputState } from "./useCanvasInput";
export function canvasCursorClass(interactionMode: InteractionMode, input: CanvasInputState) {
if (input.isPanning) return "cursor-grabbing";
if (interactionMode.type === "temporary-pan") return "cursor-grab";
return "cursor-default";
}

View File

@@ -0,0 +1,104 @@
import { useEffect, useState, type RefObject } from "react";
import type { AppStore } from "@editor/store";
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
import {
createViewportPanInputController,
handleViewportWheel,
keybindEventFromKeyboardEvent,
pointerInputEventFromPointerEvent,
wheelInputEventFromWheelEvent,
} from "@input/index";
export type CanvasInputOptions = {
globalKeybindConsumer: GlobalKeybindConsumer;
globalPointerConsumer: GlobalPointerConsumer;
globalWheelConsumer: GlobalWheelConsumer;
};
export type CanvasInputState = {
isPanning: boolean;
};
export function useCanvasInput(
canvasRef: RefObject<HTMLCanvasElement | null>,
store: AppStore,
options: CanvasInputOptions,
): CanvasInputState {
const [isPanning, setIsPanning] = useState(false);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const panHandler = createViewportPanInputController({
globalKeyConsumer: options.globalKeybindConsumer,
globalPointerConsumer: options.globalPointerConsumer,
dispatch: store.dispatch,
getCurrentZoom: () => store.getState().editor.viewport.zoom,
isPanMode: () => store.getState().editor.tools.interactionMode.type === "temporary-pan",
});
const handleKeyDown = (event: KeyboardEvent) => {
const consumed = panHandler.keyDown(keybindEventFromKeyboardEvent(event));
if (consumed) event.preventDefault();
};
const handleKeyUp = (event: KeyboardEvent) => {
const consumed = panHandler.keyUp(keybindEventFromKeyboardEvent(event));
if (consumed) event.preventDefault();
};
const handlePointerDown = (event: PointerEvent) => {
const consumed = panHandler.pointerDown(pointerInputEventFromPointerEvent(event));
if (!consumed) return;
canvas.setPointerCapture(event.pointerId);
setIsPanning(true);
event.preventDefault();
};
const handlePointerMove = (event: PointerEvent) => {
const consumed = panHandler.pointerMove(pointerInputEventFromPointerEvent(event));
if (consumed) event.preventDefault();
};
const handlePointerUp = (event: PointerEvent) => {
const consumed = panHandler.pointerUp(pointerInputEventFromPointerEvent(event));
if (!consumed) return;
setIsPanning(false);
event.preventDefault();
};
const handleWheel = (event: WheelEvent) => {
const consumed = handleViewportWheel({
event: wheelInputEventFromWheelEvent(event),
globalConsumer: options.globalWheelConsumer,
dispatch: store.dispatch,
currentZoom: store.getState().editor.viewport.zoom,
});
if (consumed) event.preventDefault();
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
canvas.addEventListener("pointerdown", handlePointerDown);
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerup", handlePointerUp);
canvas.addEventListener("pointercancel", handlePointerUp);
canvas.addEventListener("wheel", handleWheel, { passive: false });
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
canvas.removeEventListener("pointerdown", handlePointerDown);
canvas.removeEventListener("pointermove", handlePointerMove);
canvas.removeEventListener("pointerup", handlePointerUp);
canvas.removeEventListener("pointercancel", handlePointerUp);
canvas.removeEventListener("wheel", handleWheel);
};
}, [canvasRef, options, store]);
return { isPanning };
}

View File

@@ -0,0 +1,22 @@
import { useEffect, type RefObject } from "react";
import type { AppStore } from "@editor/store";
import { createRenderer } from "@renderer/index";
export function useCanvasRenderer(canvasRef: RefObject<HTMLCanvasElement | null>, store: AppStore) {
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const renderer = createRenderer(canvas);
renderer.render(store.getState());
const unsubscribe = store.subscribe((state) => {
renderer.render(state);
});
return () => {
unsubscribe();
renderer.dispose();
};
}, [canvasRef, store]);
}

View File

@@ -0,0 +1,20 @@
import { useEffect, type RefObject } from "react";
import type { Dispatch } from "@commands/dispatcher";
export function useCanvasResize(canvasRef: RefObject<HTMLCanvasElement | null>, dispatch: Dispatch) {
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const resizeObserver = new ResizeObserver(([entry]) => {
if (!entry) return;
const width = Math.floor(entry.contentRect.width);
const height = Math.floor(entry.contentRect.height);
dispatch("viewport.setSize", { w: width, h: height });
});
resizeObserver.observe(canvas);
return () => resizeObserver.disconnect();
}, [canvasRef, dispatch]);
}

View File

@@ -5,14 +5,16 @@
* It is included in `view/index.html`.
*/
import { createImageStudioApp } from "@app/app";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
const elem = document.getElementById("root")!;
const imageStudioApp = createImageStudioApp();
const app = (
<StrictMode>
<App />
<App app={imageStudioApp} />
</StrictMode>
);

View File

@@ -5,7 +5,13 @@
@apply font-sans;
}
html,
body,
#root {
@apply h-full;
}
body {
@apply m-0 min-w-[320px] min-h-screen bg-background text-foreground;
@apply m-0 min-w-[320px] bg-background text-foreground;
}
}

7
view/useAppState.ts Normal file
View File

@@ -0,0 +1,7 @@
import { useSyncExternalStore } from "react";
import type { AppStore } from "@editor/store";
import type { AppState } from "@editor/state";
export function useAppState(store: AppStore): AppState {
return useSyncExternalStore(store.subscribe, store.getState, store.getState);
}