Compare commits
24 Commits
02df52c978
...
b5c7f87a20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5c7f87a20 | ||
|
|
c7730518ad | ||
|
|
9e1050e5f3 | ||
|
|
c23efeb397 | ||
|
|
ac2a156a1f | ||
|
|
b4f1a149c6 | ||
|
|
fb0c424745 | ||
|
|
3d32e8fbef | ||
|
|
f4ad35a7f8 | ||
|
|
ca9e265614 | ||
|
|
d77bdc22a9 | ||
|
|
af71a49b09 | ||
|
|
a460d6ea05 | ||
|
|
79c3748b0e | ||
|
|
812db180cd | ||
|
|
90897e3993 | ||
|
|
dd1b892034 | ||
|
|
91436086a8 | ||
|
|
bd60d64bc0 | ||
|
|
51027ce535 | ||
|
|
9422494458 | ||
|
|
d7406e803a | ||
|
|
a8dbd26474 | ||
|
|
697d12725d |
55
AGENTS.md
Normal file
55
AGENTS.md
Normal 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
7
app/AGENTS.md
Normal 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
27
app/app.ts
Normal 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
2
app/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { ImageStudioApp } from "./app";
|
||||
export { createImageStudioApp } from "./app";
|
||||
179
bun.lock
179
bun.lock
@@ -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
10
commands/AGENTS.md
Normal 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.
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
18
commands/dispatcher.test.ts
Normal file
18
commands/dispatcher.test.ts
Normal 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
30
commands/dispatcher.ts
Normal 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
22
commands/document.test.ts
Normal 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
34
commands/document.ts
Normal 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>[];
|
||||
@@ -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
26
commands/payloads.ts
Normal 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
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()],
|
||||
};
|
||||
}
|
||||
25
commands/selection.test.ts
Normal file
25
commands/selection.test.ts
Normal 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
63
commands/selection.ts
Normal 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
19
commands/tool.test.ts
Normal 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
64
commands/tool.ts
Normal 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
44
commands/viewport.test.ts
Normal 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
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>[];
|
||||
34
core/AGENTS.md
Normal file
34
core/AGENTS.md
Normal 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
8
editor/AGENTS.md
Normal 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
6
editor/index.ts
Normal 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
28
editor/initial-state.ts
Normal 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
27
editor/state.ts
Normal 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
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,
|
||||
};
|
||||
}
|
||||
15
editor/tools.ts
Normal file
15
editor/tools.ts
Normal 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
87
eslint.config.js
Normal 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
9
input/AGENTS.md
Normal 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
42
input/dom.ts
Normal 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
12
input/index.ts
Normal 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
49
input/keyboard.ts
Normal 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
24
input/pointer.ts
Normal 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;
|
||||
48
input/viewport-pan.test.ts
Normal file
48
input/viewport-pan.test.ts
Normal 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
48
input/viewport-pan.ts
Normal 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
68
input/viewport.test.ts
Normal 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
78
input/viewport.ts
Normal 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;
|
||||
}
|
||||
11
package.json
11
package.json
@@ -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
12
renderer/AGENTS.md
Normal 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
21
renderer/artboard.ts
Normal 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
26
renderer/checkerboard.ts
Normal 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
7
renderer/clear-rect.ts
Normal 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
3
renderer/index.ts
Normal 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
51
renderer/renderer.ts
Normal 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
13
renderer/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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
9
view/AGENTS.md
Normal 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/`.
|
||||
27
view/App.tsx
27
view/App.tsx
@@ -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
40
view/CanvasViewport.tsx
Normal 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
8
view/canvas/cursor.ts
Normal 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";
|
||||
}
|
||||
104
view/canvas/useCanvasInput.ts
Normal file
104
view/canvas/useCanvasInput.ts
Normal 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 };
|
||||
}
|
||||
22
view/canvas/useCanvasRenderer.ts
Normal file
22
view/canvas/useCanvasRenderer.ts
Normal 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]);
|
||||
}
|
||||
20
view/canvas/useCanvasResize.ts
Normal file
20
view/canvas/useCanvasResize.ts
Normal 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]);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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
7
view/useAppState.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user