pixelweaver 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (392) hide show
  1. package/.env.development +1 -0
  2. package/.github/workflows/ci.yml +22 -0
  3. package/.github/workflows/publish.yml +18 -0
  4. package/.prettierignore +5 -0
  5. package/.prettierrc +16 -0
  6. package/.python-version +1 -0
  7. package/.rlsbl/bases/.github/workflows/ci.yml +21 -0
  8. package/.rlsbl/bases/.github/workflows/publish.yml +18 -0
  9. package/.rlsbl/bases/.rlsbl/changes/unreleased.jsonl +0 -0
  10. package/.rlsbl/bases/.rlsbl/hooks/post-release.sh +8 -0
  11. package/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +5 -0
  12. package/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +8 -0
  13. package/.rlsbl/bases/.rlsbl/lint/go.toml +17 -0
  14. package/.rlsbl/bases/.rlsbl/lint/npm.toml +19 -0
  15. package/.rlsbl/bases/.rlsbl/lint/python.toml +25 -0
  16. package/.rlsbl/bases/CHANGELOG.md +5 -0
  17. package/.rlsbl/bases/LICENSE +21 -0
  18. package/.rlsbl/changes/.validated +1 -0
  19. package/.rlsbl/changes/0.1.0.jsonl +85 -0
  20. package/.rlsbl/changes/0.1.0.md +13 -0
  21. package/.rlsbl/changes/unreleased.jsonl +0 -0
  22. package/.rlsbl/config.json +6 -0
  23. package/.rlsbl/hashes.json +14 -0
  24. package/.rlsbl/hooks/post-release.sh +8 -0
  25. package/.rlsbl/hooks/pre-checks.sh +5 -0
  26. package/.rlsbl/hooks/pre-release.sh +8 -0
  27. package/.rlsbl/lint/go.toml +17 -0
  28. package/.rlsbl/lint/npm.toml +19 -0
  29. package/.rlsbl/lint/python.toml +25 -0
  30. package/.rlsbl/releases/unreleased.toml +0 -0
  31. package/.rlsbl/releases/v0.1.0.toml +3 -0
  32. package/.rlsbl/version +1 -0
  33. package/.selfdoc/hashes/hashes.json +146 -0
  34. package/.strictcli/schema.json +227 -0
  35. package/CHANGELOG.md +17 -0
  36. package/CLAUDE.md +100 -0
  37. package/LICENSE +21 -0
  38. package/README.md +116 -0
  39. package/assets/icon.png +0 -0
  40. package/docs/_README.md +117 -0
  41. package/docs/cli-config.md +35 -0
  42. package/docs/cli-dev.md +21 -0
  43. package/docs/cli-diagnose.md +12 -0
  44. package/docs/cli-index.md +30 -0
  45. package/docs/cli-list.md +18 -0
  46. package/docs/cli-mcp.md +18 -0
  47. package/docs/cli-new.md +26 -0
  48. package/docs/cli-open.md +21 -0
  49. package/docs/cli-serve.md +21 -0
  50. package/docs/cli-stop.md +12 -0
  51. package/docs/gen-index.md +36 -0
  52. package/docs/index.md +13 -0
  53. package/docs/server-src-pixelweaver-__main__.md +12 -0
  54. package/docs/server-src-pixelweaver-autosave.md +12 -0
  55. package/docs/server-src-pixelweaver-bridge.md +12 -0
  56. package/docs/server-src-pixelweaver-cli.md +12 -0
  57. package/docs/server-src-pixelweaver-config.md +12 -0
  58. package/docs/server-src-pixelweaver-connections.md +12 -0
  59. package/docs/server-src-pixelweaver-main.md +12 -0
  60. package/docs/server-src-pixelweaver-mcp_bridge.md +12 -0
  61. package/docs/server-src-pixelweaver-mcp_drawing_tools.md +12 -0
  62. package/docs/server-src-pixelweaver-mcp_export_tools.md +12 -0
  63. package/docs/server-src-pixelweaver-mcp_frame_tools.md +12 -0
  64. package/docs/server-src-pixelweaver-mcp_history_tools.md +12 -0
  65. package/docs/server-src-pixelweaver-mcp_layer_tools.md +12 -0
  66. package/docs/server-src-pixelweaver-mcp_lock.md +12 -0
  67. package/docs/server-src-pixelweaver-mcp_project_tools.md +12 -0
  68. package/docs/server-src-pixelweaver-mcp_read_tools.md +12 -0
  69. package/docs/server-src-pixelweaver-mcp_registry.md +12 -0
  70. package/docs/server-src-pixelweaver-mcp_resources.md +12 -0
  71. package/docs/server-src-pixelweaver-mcp_server.md +12 -0
  72. package/docs/server-src-pixelweaver-protocol.md +12 -0
  73. package/docs/server-src-pixelweaver-state.md +12 -0
  74. package/docs/server-src-pixelweaver-storage.md +12 -0
  75. package/docs/server-src-pixelweaver-websocket.md +12 -0
  76. package/docs/server-src-pixelweaver.md +12 -0
  77. package/e2e/app-launch.test.ts +35 -0
  78. package/e2e/menus.test.ts +26 -0
  79. package/e2e/tools.test.ts +27 -0
  80. package/e2e/undo-redo.test.ts +11 -0
  81. package/eslint.config.js +62 -0
  82. package/index.html +13 -0
  83. package/package.json +48 -0
  84. package/playwright.config.ts +19 -0
  85. package/plugins/builtin/.gitkeep +0 -0
  86. package/plugins/builtin/advanced-fill-tool.ts +146 -0
  87. package/plugins/builtin/circle-tool.ts +186 -0
  88. package/plugins/builtin/diamond-tool.ts +182 -0
  89. package/plugins/builtin/dither-tool.ts +186 -0
  90. package/plugins/builtin/drawing-primitives-plugin.ts +362 -0
  91. package/plugins/builtin/drawing-utils.test.ts +495 -0
  92. package/plugins/builtin/drawing-utils.ts +431 -0
  93. package/plugins/builtin/effects/blur.ts +97 -0
  94. package/plugins/builtin/effects/color-effects.ts +278 -0
  95. package/plugins/builtin/effects/flip.ts +83 -0
  96. package/plugins/builtin/effects/glow.ts +118 -0
  97. package/plugins/builtin/effects/outline.ts +110 -0
  98. package/plugins/builtin/effects/rotate.ts +357 -0
  99. package/plugins/builtin/effects/scale.ts +258 -0
  100. package/plugins/builtin/effects/shadow.ts +111 -0
  101. package/plugins/builtin/effects/sharpen.ts +102 -0
  102. package/plugins/builtin/effects.test.ts +715 -0
  103. package/plugins/builtin/eraser-tool.ts +23 -0
  104. package/plugins/builtin/eyedropper-tool.ts +83 -0
  105. package/plugins/builtin/fill-tool.ts +93 -0
  106. package/plugins/builtin/gradient-tool.ts +204 -0
  107. package/plugins/builtin/gradient.test.ts +142 -0
  108. package/plugins/builtin/importers/aseprite-importer-plugin.ts +174 -0
  109. package/plugins/builtin/importers/aseprite-parser.ts +497 -0
  110. package/plugins/builtin/importers/piskel-importer-plugin.ts +222 -0
  111. package/plugins/builtin/importers/sky-spec-plugin.ts +409 -0
  112. package/plugins/builtin/line-tool.ts +267 -0
  113. package/plugins/builtin/make-stroke-tool.ts +271 -0
  114. package/plugins/builtin/noise-dither.test.ts +151 -0
  115. package/plugins/builtin/noise-tool.ts +131 -0
  116. package/plugins/builtin/pattern-stamp-tool.ts +162 -0
  117. package/plugins/builtin/pencil-tool.ts +25 -0
  118. package/plugins/builtin/rect-tool.ts +179 -0
  119. package/plugins/builtin/selection-tool.ts +388 -0
  120. package/plugins/builtin/selection.test.ts +195 -0
  121. package/plugins/builtin/tool-plugins.test.ts +529 -0
  122. package/public/favicon.svg +7 -0
  123. package/public/sw.js +91 -0
  124. package/pyproject.toml +49 -0
  125. package/scripts/eslint-wave-a-list.sh +24 -0
  126. package/scripts/eslint-wave-a-status.sh +25 -0
  127. package/scripts/fix-index-signature-access.py +127 -0
  128. package/scripts/fix-unchecked-index.py +128 -0
  129. package/scripts/fix-unchecked-vars.py +127 -0
  130. package/scripts/fix-wave-a-bangs.py +36 -0
  131. package/scripts/fix-wave-a-templates.py +108 -0
  132. package/scripts/fix-wave-b-void-expr.py +167 -0
  133. package/scripts/generate-command-params.py +540 -0
  134. package/scripts/migrate-single-frame-to-multi.py +171 -0
  135. package/scripts/smoke-test.sh +77 -0
  136. package/selfdoc.json +10 -0
  137. package/server/README.md +0 -0
  138. package/server/src/pixelweaver/__init__.py +1 -0
  139. package/server/src/pixelweaver/__main__.py +4 -0
  140. package/server/src/pixelweaver/autosave.py +114 -0
  141. package/server/src/pixelweaver/bridge.py +127 -0
  142. package/server/src/pixelweaver/cli.py +199 -0
  143. package/server/src/pixelweaver/config.py +24 -0
  144. package/server/src/pixelweaver/connections.py +54 -0
  145. package/server/src/pixelweaver/main.py +271 -0
  146. package/server/src/pixelweaver/mcp_bridge.py +189 -0
  147. package/server/src/pixelweaver/mcp_drawing_tools.py +178 -0
  148. package/server/src/pixelweaver/mcp_export_tools.py +291 -0
  149. package/server/src/pixelweaver/mcp_frame_tools.py +423 -0
  150. package/server/src/pixelweaver/mcp_history_tools.py +106 -0
  151. package/server/src/pixelweaver/mcp_layer_tools.py +64 -0
  152. package/server/src/pixelweaver/mcp_lock.py +37 -0
  153. package/server/src/pixelweaver/mcp_project_tools.py +302 -0
  154. package/server/src/pixelweaver/mcp_read_tools.py +163 -0
  155. package/server/src/pixelweaver/mcp_registry.py +247 -0
  156. package/server/src/pixelweaver/mcp_resources.py +312 -0
  157. package/server/src/pixelweaver/mcp_server.py +234 -0
  158. package/server/src/pixelweaver/protocol.py +219 -0
  159. package/server/src/pixelweaver/state.py +267 -0
  160. package/server/src/pixelweaver/storage.py +293 -0
  161. package/server/src/pixelweaver/websocket.py +282 -0
  162. package/server/tests/__init__.py +0 -0
  163. package/server/tests/conftest.py +17 -0
  164. package/server/tests/test_api.py +96 -0
  165. package/server/tests/test_bridge.py +161 -0
  166. package/server/tests/test_health.py +9 -0
  167. package/server/tests/test_integration.py +86 -0
  168. package/server/tests/test_mcp_bridge.py +293 -0
  169. package/server/tests/test_mcp_lock.py +34 -0
  170. package/server/tests/test_mcp_registry.py +679 -0
  171. package/server/tests/test_mcp_resources.py +648 -0
  172. package/server/tests/test_protocol.py +125 -0
  173. package/server/tests/test_state.py +87 -0
  174. package/server/tests/test_storage.py +306 -0
  175. package/server/tests/test_websocket.py +275 -0
  176. package/src/App.svelte +107 -0
  177. package/src/app.css +215 -0
  178. package/src/lib/animation/AnimationPreviewPanel.svelte +667 -0
  179. package/src/lib/animation/animation-commands.test.ts +228 -0
  180. package/src/lib/animation/animation-commands.ts +540 -0
  181. package/src/lib/animation/animation-preview-panel-plugin.ts +25 -0
  182. package/src/lib/animation/animation-preview.svelte.ts +151 -0
  183. package/src/lib/animation/clipboard.ts +134 -0
  184. package/src/lib/animation/frame-model.svelte.ts +437 -0
  185. package/src/lib/animation/frame-model.test.ts +314 -0
  186. package/src/lib/animation/frame-selection.svelte.ts +77 -0
  187. package/src/lib/animation/frame-tags.svelte.ts +238 -0
  188. package/src/lib/animation/frame-tags.test.ts +136 -0
  189. package/src/lib/animation/import.test.ts +141 -0
  190. package/src/lib/animation/import.ts +112 -0
  191. package/src/lib/animation/spritesheet-export.test.ts +239 -0
  192. package/src/lib/animation/spritesheet-export.ts +314 -0
  193. package/src/lib/canvas/CanvasViewport.svelte +216 -0
  194. package/src/lib/canvas/canvas-init-plugin.ts +178 -0
  195. package/src/lib/canvas/canvas-renderer.ts +408 -0
  196. package/src/lib/canvas/canvas-state.svelte.ts +232 -0
  197. package/src/lib/canvas/canvas-state.test.ts +139 -0
  198. package/src/lib/canvas/input-handler.ts +221 -0
  199. package/src/lib/canvas/input-plugin.ts +150 -0
  200. package/src/lib/canvas/onion-skin.ts +94 -0
  201. package/src/lib/canvas/pixel-buffer.test.ts +249 -0
  202. package/src/lib/canvas/pixel-buffer.ts +151 -0
  203. package/src/lib/canvas/render-state.svelte.ts +18 -0
  204. package/src/lib/canvas/shape-preview-state.svelte.ts +36 -0
  205. package/src/lib/canvas/tile-mode.test.ts +53 -0
  206. package/src/lib/canvas/tile-mode.ts +92 -0
  207. package/src/lib/canvas/viewport-utils.ts +24 -0
  208. package/src/lib/canvas/zoom-utils.ts +31 -0
  209. package/src/lib/color/.gitkeep +0 -0
  210. package/src/lib/color/color-commands.ts +87 -0
  211. package/src/lib/color/color-state.svelte.ts +98 -0
  212. package/src/lib/color/color-state.test.ts +91 -0
  213. package/src/lib/color/color-utils.test.ts +220 -0
  214. package/src/lib/color/color-utils.ts +243 -0
  215. package/src/lib/color/palette-state.svelte.ts +127 -0
  216. package/src/lib/color/palette-state.test.ts +154 -0
  217. package/src/lib/color/palette.ts +79 -0
  218. package/src/lib/core/bootstrap.ts +66 -0
  219. package/src/lib/core/command-params.generated.ts +1549 -0
  220. package/src/lib/core/command-params.ts +20 -0
  221. package/src/lib/core/command-runner.ts +79 -0
  222. package/src/lib/core/commands.ts +134 -0
  223. package/src/lib/core/dispatcher.test.ts +548 -0
  224. package/src/lib/core/dispatcher.ts +361 -0
  225. package/src/lib/core/index.test.ts +7 -0
  226. package/src/lib/core/notification-state.svelte.ts +119 -0
  227. package/src/lib/core/plugin-api.ts +210 -0
  228. package/src/lib/core/plugin-discovery.test.ts +53 -0
  229. package/src/lib/core/plugin-discovery.ts +65 -0
  230. package/src/lib/core/plugin-loader.test.ts +159 -0
  231. package/src/lib/core/plugin-loader.ts +240 -0
  232. package/src/lib/core/plugin-types.ts +286 -0
  233. package/src/lib/core/registries.svelte.ts +74 -0
  234. package/src/lib/core/tool-options-state.svelte.ts +61 -0
  235. package/src/lib/dock/DockLayout.svelte +375 -0
  236. package/src/lib/dock/TabAddMenu.svelte +90 -0
  237. package/src/lib/dock/dock-persistence.ts +46 -0
  238. package/src/lib/dock/dock-plugin.ts +49 -0
  239. package/src/lib/dock/dock-presets.ts +156 -0
  240. package/src/lib/dock/dock-state.svelte.ts +77 -0
  241. package/src/lib/dock/dock-types.ts +2 -0
  242. package/src/lib/dock/dockview-theme.css +226 -0
  243. package/src/lib/dock/dockview-theme.ts +17 -0
  244. package/src/lib/dock/header-action-renderer.ts +77 -0
  245. package/src/lib/edit/clipboard-state.ts +34 -0
  246. package/src/lib/export/download.ts +201 -0
  247. package/src/lib/export/png-metadata.ts +181 -0
  248. package/src/lib/history/ActionLogPanel.svelte +418 -0
  249. package/src/lib/history/action-log-panel-plugin.ts +24 -0
  250. package/src/lib/history/action-log.svelte.ts +172 -0
  251. package/src/lib/history/action-log.test.ts +168 -0
  252. package/src/lib/history/history-commands.ts +403 -0
  253. package/src/lib/history/macros.svelte.ts +320 -0
  254. package/src/lib/history/macros.test.ts +224 -0
  255. package/src/lib/history/replay-engine.test.ts +241 -0
  256. package/src/lib/history/replay-engine.ts +149 -0
  257. package/src/lib/history/spec-format.test.ts +250 -0
  258. package/src/lib/history/spec-format.ts +210 -0
  259. package/src/lib/iso/SeamCheckerPanel.svelte +251 -0
  260. package/src/lib/iso/iso-math.test.ts +77 -0
  261. package/src/lib/iso/iso-math.ts +77 -0
  262. package/src/lib/iso/seam-checker-panel-plugin.ts +25 -0
  263. package/src/lib/iso/seam-checker.test.ts +126 -0
  264. package/src/lib/iso/seam-checker.ts +93 -0
  265. package/src/lib/iso/tessellation.ts +67 -0
  266. package/src/lib/layers/compositor.test.ts +193 -0
  267. package/src/lib/layers/compositor.ts +175 -0
  268. package/src/lib/layers/layer-commands.test.ts +263 -0
  269. package/src/lib/layers/layer-commands.ts +429 -0
  270. package/src/lib/layers/layer-tree.svelte.ts +516 -0
  271. package/src/lib/layers/layer-tree.test.ts +383 -0
  272. package/src/lib/layers/layer-types.ts +56 -0
  273. package/src/lib/leveleditor/LevelEditorViewport.svelte +1808 -0
  274. package/src/lib/leveleditor/MapPropertiesPanel.svelte +266 -0
  275. package/src/lib/leveleditor/TilePickerPanel.svelte +324 -0
  276. package/src/lib/leveleditor/depth-sort.test.ts +70 -0
  277. package/src/lib/leveleditor/depth-sort.ts +39 -0
  278. package/src/lib/leveleditor/level-editor-commands.ts +353 -0
  279. package/src/lib/leveleditor/level-editor-viewport-plugin.ts +25 -0
  280. package/src/lib/leveleditor/map-properties-panel-plugin.ts +25 -0
  281. package/src/lib/leveleditor/map-state.svelte.ts +372 -0
  282. package/src/lib/leveleditor/map-state.test.ts +243 -0
  283. package/src/lib/leveleditor/tile-picker-panel-plugin.ts +25 -0
  284. package/src/lib/leveleditor/tile-source-registry.svelte.ts +91 -0
  285. package/src/lib/leveleditor/tiled-export.test.ts +144 -0
  286. package/src/lib/leveleditor/tiled-export.ts +374 -0
  287. package/src/lib/pywebview.d.ts +15 -0
  288. package/src/lib/recovery/.gitkeep +0 -0
  289. package/src/lib/recovery/idb-store.ts +118 -0
  290. package/src/lib/recovery/recovery-manager.test.ts +321 -0
  291. package/src/lib/recovery/recovery-manager.ts +115 -0
  292. package/src/lib/recovery/recovery-plugin.ts +55 -0
  293. package/src/lib/recovery/recovery-state.svelte.ts +21 -0
  294. package/src/lib/recovery/sw-plugin.ts +18 -0
  295. package/src/lib/recovery/sw-register.ts +17 -0
  296. package/src/lib/save/directory-format.ts +42 -0
  297. package/src/lib/save/project-snapshot.ts +139 -0
  298. package/src/lib/save/recent-projects.ts +56 -0
  299. package/src/lib/save/save-state.svelte.ts +35 -0
  300. package/src/lib/save/storage.ts +167 -0
  301. package/src/lib/save/zip-format.ts +45 -0
  302. package/src/lib/shortcuts/.gitkeep +0 -0
  303. package/src/lib/shortcuts/ShortcutEditorPanel.svelte +690 -0
  304. package/src/lib/shortcuts/default-bindings.ts +61 -0
  305. package/src/lib/shortcuts/shortcut-editor-panel-plugin.ts +25 -0
  306. package/src/lib/shortcuts/shortcut-init.ts +380 -0
  307. package/src/lib/shortcuts/shortcut-manager.test.ts +466 -0
  308. package/src/lib/shortcuts/shortcut-manager.ts +281 -0
  309. package/src/lib/shortcuts/shortcut-state.svelte.ts +78 -0
  310. package/src/lib/shortcuts/shortcuts-plugin.ts +17 -0
  311. package/src/lib/sync/patch-applicator.ts +300 -0
  312. package/src/lib/sync/patch-types.ts +65 -0
  313. package/src/lib/sync/project-manager.ts +108 -0
  314. package/src/lib/sync/sync-init.ts +152 -0
  315. package/src/lib/sync/sync-plugin.ts +19 -0
  316. package/src/lib/sync/sync-state.svelte.ts +56 -0
  317. package/src/lib/sync/ws-client.test.ts +604 -0
  318. package/src/lib/sync/ws-client.ts +574 -0
  319. package/src/lib/tools/.gitkeep +0 -0
  320. package/src/lib/ui/.gitkeep +0 -0
  321. package/src/lib/ui/AboutDialog.svelte +113 -0
  322. package/src/lib/ui/ColorPicker.svelte +761 -0
  323. package/src/lib/ui/ContextMenu.svelte +216 -0
  324. package/src/lib/ui/ExportDialog.svelte +747 -0
  325. package/src/lib/ui/FrameStrip.svelte +854 -0
  326. package/src/lib/ui/LayerPanel.svelte +810 -0
  327. package/src/lib/ui/MenuBar.svelte +590 -0
  328. package/src/lib/ui/NewProjectDialog.svelte +803 -0
  329. package/src/lib/ui/PluginManagerPanel.svelte +475 -0
  330. package/src/lib/ui/PromptDialog.svelte +252 -0
  331. package/src/lib/ui/RecoveryDialog.svelte +295 -0
  332. package/src/lib/ui/ResizeDialog.svelte +416 -0
  333. package/src/lib/ui/StatusBar.svelte +145 -0
  334. package/src/lib/ui/ToolbarPanel.svelte +488 -0
  335. package/src/lib/ui/animation-menu-commands.ts +194 -0
  336. package/src/lib/ui/command-palette/CommandPalette.svelte +232 -0
  337. package/src/lib/ui/command-palette/command-palette-plugin.ts +30 -0
  338. package/src/lib/ui/command-palette/command-palette-state.svelte.ts +190 -0
  339. package/src/lib/ui/command-palette/command-palette.test.ts +129 -0
  340. package/src/lib/ui/dialog-state.svelte.ts +70 -0
  341. package/src/lib/ui/edit-commands.ts +271 -0
  342. package/src/lib/ui/file-commands.ts +275 -0
  343. package/src/lib/ui/file-open.ts +99 -0
  344. package/src/lib/ui/help-commands.ts +93 -0
  345. package/src/lib/ui/image-commands.ts +181 -0
  346. package/src/lib/ui/layer-menu-commands.ts +420 -0
  347. package/src/lib/ui/menu-builder.ts +224 -0
  348. package/src/lib/ui/notifications/NotificationBanner.svelte +137 -0
  349. package/src/lib/ui/notifications/notification-plugin.ts +29 -0
  350. package/src/lib/ui/notifications/notification-state.svelte.ts +9 -0
  351. package/src/lib/ui/plugin-manager-panel-plugin.ts +26 -0
  352. package/src/lib/ui/plugin-state.svelte.ts +62 -0
  353. package/src/lib/ui/select-commands.ts +75 -0
  354. package/src/lib/ui/theme-plugin.ts +18 -0
  355. package/src/lib/ui/theme.svelte.ts +45 -0
  356. package/src/lib/ui/theme.test.ts +51 -0
  357. package/src/lib/ui/toolbar-config.ts +90 -0
  358. package/src/lib/ui/toolbar-plugin.ts +39 -0
  359. package/src/lib/ui/view-commands.ts +629 -0
  360. package/src/lib/variants/BisectionExportDialog.svelte +500 -0
  361. package/src/lib/variants/VariantPanel.svelte +822 -0
  362. package/src/lib/variants/bisection-export.test.ts +113 -0
  363. package/src/lib/variants/bisection-export.ts +148 -0
  364. package/src/lib/variants/palette-extraction.test.ts +111 -0
  365. package/src/lib/variants/palette-extraction.ts +84 -0
  366. package/src/lib/variants/palette-interpolation.test.ts +113 -0
  367. package/src/lib/variants/palette-interpolation.ts +87 -0
  368. package/src/lib/variants/palette-swap.test.ts +101 -0
  369. package/src/lib/variants/palette-swap.ts +114 -0
  370. package/src/lib/variants/variant-commands.ts +594 -0
  371. package/src/lib/variants/variant-panel-plugin.ts +27 -0
  372. package/src/lib/variants/variant-randomizer.ts +101 -0
  373. package/src/lib/variants/variant-state.svelte.ts +166 -0
  374. package/src/lib/variants/variant-state.test.ts +138 -0
  375. package/src/main.ts +14 -0
  376. package/src/vite-env.d.ts +3 -0
  377. package/svelte.config.js +2 -0
  378. package/todo/.done/audit-design-decisions.md +812 -0
  379. package/todo/.done/audit-implementation-plan.md +1235 -0
  380. package/todo/.done/happy-path-polish.md +177 -0
  381. package/todo/.done/pixelweaver-full-build.md +937 -0
  382. package/todo/.done/server-multi-frame-design.md +405 -0
  383. package/todo/.done/typed-dispatcher-design.md +435 -0
  384. package/todo/.done/unified-toolbar-and-action-system.md +323 -0
  385. package/todo/.obsolete/comprehensive-audit-obsolete-items.md +33 -0
  386. package/todo/.obsolete/tauri-desktop-bundle.md +424 -0
  387. package/todo/comprehensive-audit.md +1085 -0
  388. package/tsconfig.app.json +26 -0
  389. package/tsconfig.json +7 -0
  390. package/tsconfig.node.json +20 -0
  391. package/uv.lock +1167 -0
  392. package/vite.config.ts +32 -0
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Spritesheet Export -- arranges frames into a spritesheet image with metadata.
3
+ *
4
+ * Supports horizontal strip, grid, and atlas layouts. Generates metadata
5
+ * in PixelWeaver, TexturePacker, Aseprite, and CSS sprite formats.
6
+ */
7
+
8
+ import { PixelBuffer } from '../canvas/pixel-buffer.js';
9
+ import { composite } from '../layers/compositor.js';
10
+ import type { Frame } from './frame-model.svelte.js';
11
+ import type { Layer } from '../layers/layer-types.js';
12
+
13
+ // --- Types ---
14
+
15
+ export type LayoutType = 'horizontal' | 'grid' | 'atlas';
16
+ export type MetadataFormat = 'pixelweaver' | 'texturepacker' | 'aseprite' | 'css';
17
+
18
+ export interface ExportOptions {
19
+ layout: LayoutType;
20
+ columns?: number;
21
+ padding: number;
22
+ tagId?: string;
23
+ metadataFormat: MetadataFormat;
24
+ }
25
+
26
+ export interface LayoutResult {
27
+ width: number;
28
+ height: number;
29
+ positions: { x: number; y: number }[];
30
+ }
31
+
32
+ // --- Layout calculators ---
33
+
34
+ export function calculateHorizontalLayout(
35
+ frameCount: number,
36
+ frameWidth: number,
37
+ frameHeight: number,
38
+ padding: number,
39
+ ): LayoutResult {
40
+ const positions: { x: number; y: number }[] = [];
41
+ for (let i = 0; i < frameCount; i++) {
42
+ positions.push({
43
+ x: i * (frameWidth + padding),
44
+ y: 0,
45
+ });
46
+ }
47
+ return {
48
+ width: frameCount * frameWidth + Math.max(0, frameCount - 1) * padding,
49
+ height: frameHeight,
50
+ positions,
51
+ };
52
+ }
53
+
54
+ export function calculateGridLayout(
55
+ frameCount: number,
56
+ frameWidth: number,
57
+ frameHeight: number,
58
+ columns: number,
59
+ padding: number,
60
+ ): LayoutResult {
61
+ const cols = Math.max(1, columns);
62
+ const rows = Math.ceil(frameCount / cols);
63
+ const positions: { x: number; y: number }[] = [];
64
+
65
+ for (let i = 0; i < frameCount; i++) {
66
+ const col = i % cols;
67
+ const row = Math.floor(i / cols);
68
+ positions.push({
69
+ x: col * (frameWidth + padding),
70
+ y: row * (frameHeight + padding),
71
+ });
72
+ }
73
+
74
+ return {
75
+ width: cols * frameWidth + Math.max(0, cols - 1) * padding,
76
+ height: rows * frameHeight + Math.max(0, rows - 1) * padding,
77
+ positions,
78
+ };
79
+ }
80
+
81
+ // --- Metadata generators ---
82
+
83
+ export function generatePixelweaverMeta(
84
+ layout: LayoutResult,
85
+ frames: Frame[],
86
+ options: ExportOptions,
87
+ ): string {
88
+ return JSON.stringify(
89
+ {
90
+ format: 'pixelweaver',
91
+ version: '1.0.0',
92
+ spritesheet: {
93
+ width: layout.width,
94
+ height: layout.height,
95
+ layout: options.layout,
96
+ padding: options.padding,
97
+ },
98
+ frames: frames.map((frame, i) => ({
99
+ index: i,
100
+ x: layout.positions[i]?.x ?? 0,
101
+ y: layout.positions[i]?.y ?? 0,
102
+ durationMs: frame.durationMs,
103
+ })),
104
+ },
105
+ null,
106
+ 2,
107
+ );
108
+ }
109
+
110
+ export function generateTexturePackerMeta(
111
+ layout: LayoutResult,
112
+ frames: Frame[],
113
+ canvasWidth: number,
114
+ canvasHeight: number,
115
+ ): string {
116
+ // TexturePacker JSON Hash format
117
+ const frameEntries: Record<string, object> = {};
118
+ for (let i = 0; i < frames.length; i++) {
119
+ const pos = layout.positions[i];
120
+ const fr = frames[i];
121
+ if (!pos || !fr) continue;
122
+ frameEntries[`frame_${String(i)}`] = {
123
+ frame: { x: pos.x, y: pos.y, w: canvasWidth, h: canvasHeight },
124
+ rotated: false,
125
+ trimmed: false,
126
+ spriteSourceSize: { x: 0, y: 0, w: canvasWidth, h: canvasHeight },
127
+ sourceSize: { w: canvasWidth, h: canvasHeight },
128
+ duration: fr.durationMs ?? 100,
129
+ };
130
+ }
131
+
132
+ return JSON.stringify(
133
+ {
134
+ frames: frameEntries,
135
+ meta: {
136
+ app: 'PixelWeaver',
137
+ version: '1.0.0',
138
+ format: 'RGBA8888',
139
+ size: { w: layout.width, h: layout.height },
140
+ scale: 1,
141
+ },
142
+ },
143
+ null,
144
+ 2,
145
+ );
146
+ }
147
+
148
+ export function generateAsepriteMeta(
149
+ layout: LayoutResult,
150
+ frames: Frame[],
151
+ canvasWidth: number,
152
+ canvasHeight: number,
153
+ ): string {
154
+ // Aseprite JSON format
155
+ const frameEntries: Record<string, object> = {};
156
+ for (let i = 0; i < frames.length; i++) {
157
+ const pos = layout.positions[i];
158
+ const fr = frames[i];
159
+ if (!pos || !fr) continue;
160
+ frameEntries[`sprite ${String(i)}.ase`] = {
161
+ frame: { x: pos.x, y: pos.y, w: canvasWidth, h: canvasHeight },
162
+ rotated: false,
163
+ trimmed: false,
164
+ spriteSourceSize: { x: 0, y: 0, w: canvasWidth, h: canvasHeight },
165
+ sourceSize: { w: canvasWidth, h: canvasHeight },
166
+ duration: fr.durationMs ?? 100,
167
+ };
168
+ }
169
+
170
+ return JSON.stringify(
171
+ {
172
+ frames: frameEntries,
173
+ meta: {
174
+ app: 'http://www.aseprite.org/',
175
+ version: '1.3',
176
+ image: 'spritesheet.png',
177
+ format: 'RGBA8888',
178
+ size: { w: layout.width, h: layout.height },
179
+ scale: '1',
180
+ frameTags: [],
181
+ layers: [],
182
+ slices: [],
183
+ },
184
+ },
185
+ null,
186
+ 2,
187
+ );
188
+ }
189
+
190
+ export function generateCssMeta(
191
+ layout: LayoutResult,
192
+ frames: Frame[],
193
+ canvasWidth: number,
194
+ canvasHeight: number,
195
+ ): string {
196
+ const lines: string[] = [];
197
+
198
+ // Base class
199
+ lines.push(`.sprite {`);
200
+ lines.push(` width: ${String(canvasWidth)}px;`);
201
+ lines.push(` height: ${String(canvasHeight)}px;`);
202
+ lines.push(` background-image: url('spritesheet.png');`);
203
+ lines.push(` background-repeat: no-repeat;`);
204
+ lines.push(`}`);
205
+ lines.push('');
206
+
207
+ // Per-frame classes
208
+ for (let i = 0; i < frames.length; i++) {
209
+ const pos = layout.positions[i];
210
+ if (!pos) continue;
211
+ lines.push(`.sprite-frame-${String(i)} {`);
212
+ lines.push(` background-position: -${String(pos.x)}px -${String(pos.y)}px;`);
213
+ lines.push(`}`);
214
+ if (i < frames.length - 1) lines.push('');
215
+ }
216
+
217
+ // CSS animation keyframes
218
+ lines.push('');
219
+ lines.push(`@keyframes sprite-animation {`);
220
+ const totalDuration = frames.reduce(
221
+ (sum, f) => sum + (f.durationMs ?? 100),
222
+ 0,
223
+ );
224
+ let cumulativeTime = 0;
225
+ for (let i = 0; i < frames.length; i++) {
226
+ const pct = Math.round((cumulativeTime / totalDuration) * 100);
227
+ const pos = layout.positions[i];
228
+ const fr = frames[i];
229
+ if (!pos || !fr) continue;
230
+ lines.push(` ${String(pct)}% { background-position: -${String(pos.x)}px -${String(pos.y)}px; }`);
231
+ cumulativeTime += fr.durationMs ?? 100;
232
+ }
233
+ lines.push(`}`);
234
+
235
+ return lines.join('\n');
236
+ }
237
+
238
+ // --- Main export function ---
239
+
240
+ /**
241
+ * Export frames as a spritesheet with metadata.
242
+ *
243
+ * Composites each frame's visible layers into a single buffer,
244
+ * then arranges them according to the layout options.
245
+ */
246
+ export function exportSpritesheet(
247
+ frames: Frame[],
248
+ layerTree: Layer[],
249
+ options: ExportOptions,
250
+ ): { image: PixelBuffer; metadata: string } {
251
+ if (frames.length === 0) {
252
+ throw new Error('No frames to export.');
253
+ }
254
+
255
+ // Determine frame dimensions from the first frame's first pixel buffer
256
+ let canvasWidth = 0;
257
+ let canvasHeight = 0;
258
+ for (const frame of frames) {
259
+ for (const [, buffer] of frame.pixelData) {
260
+ canvasWidth = buffer.width;
261
+ canvasHeight = buffer.height;
262
+ break;
263
+ }
264
+ if (canvasWidth > 0) break;
265
+ }
266
+
267
+ // Fallback: if no pixel data, use 1x1
268
+ if (canvasWidth === 0) canvasWidth = 1;
269
+ if (canvasHeight === 0) canvasHeight = 1;
270
+
271
+ // Calculate layout
272
+ let layout: LayoutResult;
273
+ if (options.layout === 'horizontal') {
274
+ layout = calculateHorizontalLayout(frames.length, canvasWidth, canvasHeight, options.padding);
275
+ } else if (options.layout === 'atlas') {
276
+ // Auto-square grid: compute columns as ceil(sqrt(n)) so the sheet is roughly square
277
+ const cols = Math.ceil(Math.sqrt(frames.length));
278
+ layout = calculateGridLayout(frames.length, canvasWidth, canvasHeight, cols, options.padding);
279
+ } else {
280
+ const cols = options.columns ?? Math.ceil(Math.sqrt(frames.length));
281
+ layout = calculateGridLayout(frames.length, canvasWidth, canvasHeight, cols, options.padding);
282
+ }
283
+
284
+ // Create output buffer
285
+ const output = new PixelBuffer(layout.width, layout.height);
286
+
287
+ // Composite each frame and paste into the output
288
+ for (let i = 0; i < frames.length; i++) {
289
+ const frame = frames[i];
290
+ const pos = layout.positions[i];
291
+ if (!frame || !pos) continue;
292
+ const composited = composite(layerTree, frame.pixelData, canvasWidth, canvasHeight);
293
+ output.paste(composited, pos.x, pos.y);
294
+ }
295
+
296
+ // Generate metadata
297
+ let metadata: string;
298
+ switch (options.metadataFormat) {
299
+ case 'pixelweaver':
300
+ metadata = generatePixelweaverMeta(layout, frames, options);
301
+ break;
302
+ case 'texturepacker':
303
+ metadata = generateTexturePackerMeta(layout, frames, canvasWidth, canvasHeight);
304
+ break;
305
+ case 'aseprite':
306
+ metadata = generateAsepriteMeta(layout, frames, canvasWidth, canvasHeight);
307
+ break;
308
+ case 'css':
309
+ metadata = generateCssMeta(layout, frames, canvasWidth, canvasHeight);
310
+ break;
311
+ }
312
+
313
+ return { image: output, metadata };
314
+ }
@@ -0,0 +1,216 @@
1
+ <!--
2
+ CanvasViewport -- the main rendering surface for pixel editing.
3
+
4
+ Creates an HTML <canvas> that fills its container, connects the input
5
+ handler for zoom/pan/cursor tracking, and runs a requestAnimationFrame
6
+ loop to keep the display in sync with state.
7
+ -->
8
+ <script lang="ts">
9
+ import { onMount } from 'svelte';
10
+ import { canvasState } from './canvas-state.svelte.js';
11
+ import { renderCanvas, renderGrid, renderCursor, renderShapePreview, renderIsoGuide } from './canvas-renderer.js';
12
+ import { getShapePreview } from './shape-preview-state.svelte.js';
13
+ import { renderTilePreview } from './tile-mode.js';
14
+ import { renderOnionSkin } from './onion-skin.js';
15
+ import { bindInputHandler } from './input-handler.js';
16
+ import { getRenderBuffer } from './render-state.svelte.js';
17
+ import { ZOOM_STEPS } from './zoom-utils.js';
18
+ import { getFrames, getCurrentFrameIndex } from '../animation/frame-model.svelte.js';
19
+ import { getLayers } from '../layers/layer-tree.svelte.js';
20
+ import { composite } from '../layers/compositor.js';
21
+ import ContextMenu from '../ui/ContextMenu.svelte';
22
+
23
+ // --- Canvas element ref and rendering ---
24
+ let canvasEl: HTMLCanvasElement;
25
+ let animFrameId: number;
26
+
27
+ // --- Context menu state ---
28
+ let contextMenu = $state<{ x: number; y: number } | null>(null);
29
+
30
+ function handleContextMenu(e: MouseEvent) {
31
+ e.preventDefault();
32
+ contextMenu = { x: e.clientX, y: e.clientY };
33
+ }
34
+
35
+ /** Resize the canvas element to match its CSS size (retina-aware). */
36
+ function resizeCanvas(): void {
37
+ const rect = canvasEl.getBoundingClientRect();
38
+ const dpr = window.devicePixelRatio || 1;
39
+ const w = Math.round(rect.width * dpr);
40
+ const h = Math.round(rect.height * dpr);
41
+
42
+ if (canvasEl.width !== w || canvasEl.height !== h) {
43
+ canvasEl.width = w;
44
+ canvasEl.height = h;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Fit the canvas to the viewport and center it (called once on mount).
50
+ * Picks the largest zoom step that keeps the canvas within 90% of the
51
+ * viewport, then offsets so the canvas is centered.
52
+ */
53
+ function fitAndCenterCanvas(): void {
54
+ const rect = canvasEl.getBoundingClientRect();
55
+ if (rect.width === 0 || rect.height === 0) return;
56
+
57
+ const padding = 0.9; // use 90% of viewport
58
+ const maxW = rect.width * padding;
59
+ const maxH = rect.height * padding;
60
+
61
+ // Pick the largest zoom step where the canvas still fits
62
+ let bestZoom: number = ZOOM_STEPS[0];
63
+ for (const step of ZOOM_STEPS) {
64
+ if (canvasState.canvasWidth * step <= maxW && canvasState.canvasHeight * step <= maxH) {
65
+ bestZoom = step;
66
+ }
67
+ }
68
+ canvasState.zoom = bestZoom;
69
+
70
+ // Center at the chosen zoom
71
+ const canvasPixelW = canvasState.canvasWidth * bestZoom;
72
+ const canvasPixelH = canvasState.canvasHeight * bestZoom;
73
+ canvasState.panX = Math.round((rect.width - canvasPixelW) / 2);
74
+ canvasState.panY = Math.round((rect.height - canvasPixelH) / 2);
75
+ }
76
+
77
+ /** The render loop: clears, draws pixels, grid, and cursor each frame. */
78
+ function renderLoop(): void {
79
+ resizeCanvas();
80
+
81
+ const ctx = canvasEl.getContext('2d');
82
+ if (!ctx) return;
83
+
84
+ // Account for device pixel ratio in transforms
85
+ const dpr = window.devicePixelRatio || 1;
86
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
87
+
88
+ // Main render pass -- use the shared composited buffer from render-state
89
+ const buffer = getRenderBuffer();
90
+ if (buffer) {
91
+ renderCanvas(ctx, buffer, canvasState.zoom, canvasState.panX, canvasState.panY);
92
+ } else {
93
+ // No buffer yet (plugin not loaded); clear to workspace background
94
+ const { width: cw, height: ch } = ctx.canvas;
95
+ const style = getComputedStyle(document.documentElement);
96
+ ctx.fillStyle = style.getPropertyValue('--bg-canvas').trim() || '#121212';
97
+ ctx.fillRect(0, 0, cw, ch);
98
+ }
99
+
100
+ // Onion skin: draw ghosts of adjacent frames behind the current frame
101
+ if (canvasState.onionSkin) {
102
+ const frames = getFrames();
103
+ const curIdx = getCurrentFrameIndex();
104
+ if (frames.length > 1) {
105
+ const layers = getLayers();
106
+ const w = canvasState.canvasWidth;
107
+ const h = canvasState.canvasHeight;
108
+ const prevFrame = curIdx > 0 ? frames[curIdx - 1] : undefined;
109
+ const nextFrame = curIdx < frames.length - 1 ? frames[curIdx + 1] : undefined;
110
+ const prevBuffer = prevFrame ? composite(layers, prevFrame.pixelData, w, h) : null;
111
+ const nextBuffer = nextFrame ? composite(layers, nextFrame.pixelData, w, h) : null;
112
+ renderOnionSkin(ctx, prevBuffer, nextBuffer, canvasState.zoom, canvasState.panX, canvasState.panY);
113
+ }
114
+ }
115
+
116
+ // Tile mode: draw 3x3 ghost grid around the canvas so the user can check seam continuity
117
+ if (canvasState.tileMode && buffer) {
118
+ renderTilePreview(ctx, buffer, canvasState.zoom, canvasState.panX, canvasState.panY);
119
+ }
120
+
121
+ // Shape preview overlay (rubber-band during shape tool drags)
122
+ const shapePreview = getShapePreview();
123
+ if (shapePreview) {
124
+ renderShapePreview(ctx, shapePreview, canvasState.zoom, canvasState.panX, canvasState.panY);
125
+ }
126
+
127
+ // Isometric diamond guide overlay
128
+ if (canvasState.isoGuide) {
129
+ const isoW = buffer?.width ?? canvasState.canvasWidth;
130
+ const isoH = buffer?.height ?? canvasState.canvasHeight;
131
+ renderIsoGuide(ctx, isoW, isoH, canvasState.zoom, canvasState.panX, canvasState.panY);
132
+ }
133
+
134
+ // Grid overlay (only at high zoom, when enabled); use canvas dimensions when buffer is absent
135
+ if (canvasState.showGrid) {
136
+ const gridW = buffer?.width ?? canvasState.canvasWidth;
137
+ const gridH = buffer?.height ?? canvasState.canvasHeight;
138
+ renderGrid(ctx, gridW, gridH, canvasState.zoom, canvasState.panX, canvasState.panY);
139
+ }
140
+
141
+ // Cursor overlay (only when cursor is over the canvas)
142
+ if (canvasState.cursorInBounds) {
143
+ renderCursor(
144
+ ctx,
145
+ canvasState.cursorX,
146
+ canvasState.cursorY,
147
+ canvasState.zoom,
148
+ canvasState.panX,
149
+ canvasState.panY,
150
+ );
151
+ }
152
+
153
+ animFrameId = requestAnimationFrame(renderLoop);
154
+ }
155
+
156
+ onMount(() => {
157
+ const unbindInput = bindInputHandler(canvasEl);
158
+
159
+ // Defer fitAndCenterCanvas until dockview has finished layout.
160
+ // ResizeObserver fires early when the element first gets size but before
161
+ // dockview distributes space, so we wait 300ms after first non-zero size.
162
+ let fitTimer: ReturnType<typeof setTimeout> | undefined;
163
+ const resizeObs = new ResizeObserver((entries) => {
164
+ const entry = entries[0];
165
+ if (!entry) return;
166
+ const { width, height } = entry.contentRect;
167
+ if (width > 0 && height > 0) {
168
+ clearTimeout(fitTimer);
169
+ fitTimer = setTimeout(() => {
170
+ fitAndCenterCanvas();
171
+ resizeObs.disconnect();
172
+ }, 300);
173
+ }
174
+ });
175
+ resizeObs.observe(canvasEl);
176
+
177
+ animFrameId = requestAnimationFrame(renderLoop);
178
+
179
+ return () => {
180
+ cancelAnimationFrame(animFrameId);
181
+ resizeObs.disconnect();
182
+ unbindInput();
183
+ };
184
+ });
185
+ </script>
186
+
187
+ <canvas
188
+ bind:this={canvasEl}
189
+ class="canvas-viewport"
190
+ tabindex="0"
191
+ role="application"
192
+ aria-label={`Pixel canvas, ${String(canvasState.canvasWidth)} by ${String(canvasState.canvasHeight)}`}
193
+ oncontextmenu={handleContextMenu}
194
+ ></canvas>
195
+
196
+ {#if contextMenu}
197
+ <ContextMenu
198
+ menuPath="context/canvas"
199
+ x={contextMenu.x}
200
+ y={contextMenu.y}
201
+ onClose={() => contextMenu = null}
202
+ />
203
+ {/if}
204
+
205
+ <style>
206
+ .canvas-viewport {
207
+ display: block;
208
+ width: 100%;
209
+ height: 100%;
210
+ /* Prevent the canvas from being selectable or showing text cursor */
211
+ user-select: none;
212
+ touch-action: none;
213
+ outline: none;
214
+ cursor: crosshair;
215
+ }
216
+ </style>
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Canvas Init Plugin -- bootstraps the layer tree, frame model, compositor,
3
+ * and dispatcher context so the canvas can render real pixel data.
4
+ *
5
+ * Sets up:
6
+ * - A default pixel layer ("Layer 1")
7
+ * - A default frame (frame 0) with a PixelBuffer for that layer
8
+ * - A reactive getActiveBuffer() in the dispatcher context
9
+ * - A recomposite hook that fires after every command execution
10
+ */
11
+
12
+ import type { PluginModule } from '../core/plugin-loader.js';
13
+ import {
14
+ addLayer,
15
+ getLayers,
16
+ getActiveLayerId,
17
+ getAllPixelLayers,
18
+ } from '../layers/layer-tree.svelte.js';
19
+ import {
20
+ addFrame,
21
+ getCurrentFrame,
22
+ getCurrentFrameIndex,
23
+ getFrames,
24
+ } from '../animation/frame-model.svelte.js';
25
+ import { composite } from '../layers/compositor.js';
26
+ import { canvasState, MAX_CANVAS_SIZE, MIN_CANVAS_SIZE } from './canvas-state.svelte.js';
27
+ import { PixelBuffer } from './pixel-buffer.js';
28
+ import { setContext, onUndo } from '../core/dispatcher.js';
29
+ import { setRenderBuffer } from './render-state.svelte.js';
30
+
31
+ export const canvasInitPlugin: PluginModule = {
32
+ name: 'builtin/canvas-init',
33
+ version: '1.0.0',
34
+ description: 'Initializes layers, frames, compositor, and dispatcher context',
35
+
36
+ register(api) {
37
+ // 1. Create a default pixel layer
38
+ const defaultLayer = addLayer('Layer 1');
39
+
40
+ // 2. Ensure at least one frame exists
41
+ if (getFrames().length === 0) {
42
+ addFrame();
43
+ }
44
+
45
+ // 3. Ensure the current frame has a PixelBuffer for the default layer
46
+ const frame = getCurrentFrame();
47
+ if (!frame.pixelData.has(defaultLayer.id)) {
48
+ frame.pixelData.set(
49
+ defaultLayer.id,
50
+ new PixelBuffer(canvasState.canvasWidth, canvasState.canvasHeight),
51
+ );
52
+ }
53
+
54
+ // 4. Provide a reactive getter for the active layer's buffer to the dispatcher context.
55
+ // Commands use this to know which buffer to draw into.
56
+ // setActiveBuffer lets effects that change buffer dimensions (rotate
57
+ // 90/270 on non-square canvases, scale) replace the active layer's
58
+ // pixel data with a new buffer. It updates the canvas size to match
59
+ // the new buffer and writes the buffer into the current frame under
60
+ // the active layer id. Effects must snapshot the old dimensions and
61
+ // pixel data themselves so undo can restore them via setActiveBuffer.
62
+ setContext({
63
+ getActiveBuffer: () => {
64
+ const currentFrame = getCurrentFrame();
65
+ const layerId = getActiveLayerId();
66
+ let buf = currentFrame.pixelData.get(layerId);
67
+ if (!buf) {
68
+ buf = new PixelBuffer(canvasState.canvasWidth, canvasState.canvasHeight);
69
+ currentFrame.pixelData.set(layerId, buf);
70
+ }
71
+ return buf;
72
+ },
73
+ setActiveBuffer: (buffer: PixelBuffer) => {
74
+ const currentFrame = getCurrentFrame();
75
+ const layerId = getActiveLayerId();
76
+ assertBufferSize(buffer);
77
+ // Update canvas dimensions to match the new buffer.
78
+ canvasState.canvasWidth = buffer.width;
79
+ canvasState.canvasHeight = buffer.height;
80
+ // Swap in the new buffer for the active layer in the current frame.
81
+ currentFrame.pixelData.set(layerId, buffer);
82
+ },
83
+ getActiveLayerId: () => {
84
+ // getCurrentFrame() throws when there are no frames, so reaching
85
+ // this line guarantees a frame exists. When there's a frame but
86
+ // no layer, getActiveLayerId() returns the empty string in
87
+ // layer-tree, which we preserve for effects that want to
88
+ // diagnose "no active layer" vs "no frame" (the latter surfaces
89
+ // as a thrown exception from getCurrentFrame above).
90
+ getCurrentFrame();
91
+ return getActiveLayerId();
92
+ },
93
+ getActiveFrameIndex: () => getCurrentFrameIndex(),
94
+ setBufferForLayer: (frameIndex: number, layerId: string, buffer: PixelBuffer) => {
95
+ // Write a buffer into an explicit (frameIndex, layerId) pair --
96
+ // used by effects on undo, and by whole-canvas ops that iterate
97
+ // all pairs. Resizes the canvas to the buffer dims; callers doing
98
+ // multi-pair updates must make sure the final write reflects the
99
+ // intended canvas size (or update canvas dims themselves after).
100
+ const frames = getFrames();
101
+ if (frameIndex < 0 || frameIndex >= frames.length) return;
102
+ const frame = frames[frameIndex];
103
+ if (!frame) return;
104
+ assertBufferSize(buffer);
105
+ canvasState.canvasWidth = buffer.width;
106
+ canvasState.canvasHeight = buffer.height;
107
+ frame.pixelData.set(layerId, buffer);
108
+ },
109
+ getAllFrameLayerBuffers: () => {
110
+ // Used by whole-canvas effects to iterate every (frame, pixel-layer)
111
+ // pair. Only yields pairs where the frame actually has a buffer for
112
+ // that layer -- group layers and layers without buffers are skipped.
113
+ const result: Array<{ frameIndex: number; layerId: string; buffer: PixelBuffer }> = [];
114
+ const allFrames = getFrames();
115
+ const pixelLayers = getAllPixelLayers();
116
+ for (let frameIndex = 0; frameIndex < allFrames.length; frameIndex++) {
117
+ const frame = allFrames[frameIndex];
118
+ if (!frame) continue;
119
+ for (const layer of pixelLayers) {
120
+ const buf = frame.pixelData.get(layer.id);
121
+ if (buf) {
122
+ result.push({ frameIndex, layerId: layer.id, buffer: buf });
123
+ }
124
+ }
125
+ }
126
+ return result;
127
+ },
128
+ setCanvasSize: (width: number, height: number) => {
129
+ // Let whole-canvas effects update canvas dims once after writing
130
+ // all per-layer buffers. Goes through canvasState's setter which
131
+ // clamps to [MIN_CANVAS_SIZE, MAX_CANVAS_SIZE]; effects must have
132
+ // already ensured buffers fit (via assertBufferSize).
133
+ canvasState.canvasWidth = width;
134
+ canvasState.canvasHeight = height;
135
+ },
136
+ });
137
+
138
+ /**
139
+ * Throw if a buffer falls outside the allowed canvas size range. Extracted
140
+ * so setActiveBuffer and setBufferForLayer share the same guard -- the
141
+ * silent-clamp bug this replaces affected any path that writes into
142
+ * frame.pixelData without going through canvasState first.
143
+ */
144
+ function assertBufferSize(buffer: PixelBuffer): void {
145
+ if (
146
+ buffer.width < MIN_CANVAS_SIZE ||
147
+ buffer.width > MAX_CANVAS_SIZE ||
148
+ buffer.height < MIN_CANVAS_SIZE ||
149
+ buffer.height > MAX_CANVAS_SIZE
150
+ ) {
151
+ throw new Error(
152
+ `setActiveBuffer: buffer dimensions ${String(buffer.width)}x${String(buffer.height)} ` +
153
+ `are outside the allowed range [${String(MIN_CANVAS_SIZE)}, ${String(MAX_CANVAS_SIZE)}]`,
154
+ );
155
+ }
156
+ }
157
+
158
+ // 5. Recomposite: flatten all visible layers into the shared render buffer
159
+ function recomposite(): void {
160
+ const currentFrame = getCurrentFrame();
161
+ const layers = getLayers();
162
+ const result = composite(
163
+ layers,
164
+ currentFrame.pixelData,
165
+ canvasState.canvasWidth,
166
+ canvasState.canvasHeight,
167
+ );
168
+ setRenderBuffer(result);
169
+ }
170
+
171
+ // 6. Recomposite after every command execution and after undo
172
+ api.onCommand(() => { recomposite(); });
173
+ onUndo(() => { recomposite(); });
174
+
175
+ // 7. Initial composite so the canvas has something to render immediately
176
+ recomposite();
177
+ },
178
+ };