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.
- package/.env.development +1 -0
- package/.github/workflows/ci.yml +22 -0
- package/.github/workflows/publish.yml +18 -0
- package/.prettierignore +5 -0
- package/.prettierrc +16 -0
- package/.python-version +1 -0
- package/.rlsbl/bases/.github/workflows/ci.yml +21 -0
- package/.rlsbl/bases/.github/workflows/publish.yml +18 -0
- package/.rlsbl/bases/.rlsbl/changes/unreleased.jsonl +0 -0
- package/.rlsbl/bases/.rlsbl/hooks/post-release.sh +8 -0
- package/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +5 -0
- package/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +8 -0
- package/.rlsbl/bases/.rlsbl/lint/go.toml +17 -0
- package/.rlsbl/bases/.rlsbl/lint/npm.toml +19 -0
- package/.rlsbl/bases/.rlsbl/lint/python.toml +25 -0
- package/.rlsbl/bases/CHANGELOG.md +5 -0
- package/.rlsbl/bases/LICENSE +21 -0
- package/.rlsbl/changes/.validated +1 -0
- package/.rlsbl/changes/0.1.0.jsonl +85 -0
- package/.rlsbl/changes/0.1.0.md +13 -0
- package/.rlsbl/changes/unreleased.jsonl +0 -0
- package/.rlsbl/config.json +6 -0
- package/.rlsbl/hashes.json +14 -0
- package/.rlsbl/hooks/post-release.sh +8 -0
- package/.rlsbl/hooks/pre-checks.sh +5 -0
- package/.rlsbl/hooks/pre-release.sh +8 -0
- package/.rlsbl/lint/go.toml +17 -0
- package/.rlsbl/lint/npm.toml +19 -0
- package/.rlsbl/lint/python.toml +25 -0
- package/.rlsbl/releases/unreleased.toml +0 -0
- package/.rlsbl/releases/v0.1.0.toml +3 -0
- package/.rlsbl/version +1 -0
- package/.selfdoc/hashes/hashes.json +146 -0
- package/.strictcli/schema.json +227 -0
- package/CHANGELOG.md +17 -0
- package/CLAUDE.md +100 -0
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/assets/icon.png +0 -0
- package/docs/_README.md +117 -0
- package/docs/cli-config.md +35 -0
- package/docs/cli-dev.md +21 -0
- package/docs/cli-diagnose.md +12 -0
- package/docs/cli-index.md +30 -0
- package/docs/cli-list.md +18 -0
- package/docs/cli-mcp.md +18 -0
- package/docs/cli-new.md +26 -0
- package/docs/cli-open.md +21 -0
- package/docs/cli-serve.md +21 -0
- package/docs/cli-stop.md +12 -0
- package/docs/gen-index.md +36 -0
- package/docs/index.md +13 -0
- package/docs/server-src-pixelweaver-__main__.md +12 -0
- package/docs/server-src-pixelweaver-autosave.md +12 -0
- package/docs/server-src-pixelweaver-bridge.md +12 -0
- package/docs/server-src-pixelweaver-cli.md +12 -0
- package/docs/server-src-pixelweaver-config.md +12 -0
- package/docs/server-src-pixelweaver-connections.md +12 -0
- package/docs/server-src-pixelweaver-main.md +12 -0
- package/docs/server-src-pixelweaver-mcp_bridge.md +12 -0
- package/docs/server-src-pixelweaver-mcp_drawing_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_export_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_frame_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_history_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_layer_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_lock.md +12 -0
- package/docs/server-src-pixelweaver-mcp_project_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_read_tools.md +12 -0
- package/docs/server-src-pixelweaver-mcp_registry.md +12 -0
- package/docs/server-src-pixelweaver-mcp_resources.md +12 -0
- package/docs/server-src-pixelweaver-mcp_server.md +12 -0
- package/docs/server-src-pixelweaver-protocol.md +12 -0
- package/docs/server-src-pixelweaver-state.md +12 -0
- package/docs/server-src-pixelweaver-storage.md +12 -0
- package/docs/server-src-pixelweaver-websocket.md +12 -0
- package/docs/server-src-pixelweaver.md +12 -0
- package/e2e/app-launch.test.ts +35 -0
- package/e2e/menus.test.ts +26 -0
- package/e2e/tools.test.ts +27 -0
- package/e2e/undo-redo.test.ts +11 -0
- package/eslint.config.js +62 -0
- package/index.html +13 -0
- package/package.json +48 -0
- package/playwright.config.ts +19 -0
- package/plugins/builtin/.gitkeep +0 -0
- package/plugins/builtin/advanced-fill-tool.ts +146 -0
- package/plugins/builtin/circle-tool.ts +186 -0
- package/plugins/builtin/diamond-tool.ts +182 -0
- package/plugins/builtin/dither-tool.ts +186 -0
- package/plugins/builtin/drawing-primitives-plugin.ts +362 -0
- package/plugins/builtin/drawing-utils.test.ts +495 -0
- package/plugins/builtin/drawing-utils.ts +431 -0
- package/plugins/builtin/effects/blur.ts +97 -0
- package/plugins/builtin/effects/color-effects.ts +278 -0
- package/plugins/builtin/effects/flip.ts +83 -0
- package/plugins/builtin/effects/glow.ts +118 -0
- package/plugins/builtin/effects/outline.ts +110 -0
- package/plugins/builtin/effects/rotate.ts +357 -0
- package/plugins/builtin/effects/scale.ts +258 -0
- package/plugins/builtin/effects/shadow.ts +111 -0
- package/plugins/builtin/effects/sharpen.ts +102 -0
- package/plugins/builtin/effects.test.ts +715 -0
- package/plugins/builtin/eraser-tool.ts +23 -0
- package/plugins/builtin/eyedropper-tool.ts +83 -0
- package/plugins/builtin/fill-tool.ts +93 -0
- package/plugins/builtin/gradient-tool.ts +204 -0
- package/plugins/builtin/gradient.test.ts +142 -0
- package/plugins/builtin/importers/aseprite-importer-plugin.ts +174 -0
- package/plugins/builtin/importers/aseprite-parser.ts +497 -0
- package/plugins/builtin/importers/piskel-importer-plugin.ts +222 -0
- package/plugins/builtin/importers/sky-spec-plugin.ts +409 -0
- package/plugins/builtin/line-tool.ts +267 -0
- package/plugins/builtin/make-stroke-tool.ts +271 -0
- package/plugins/builtin/noise-dither.test.ts +151 -0
- package/plugins/builtin/noise-tool.ts +131 -0
- package/plugins/builtin/pattern-stamp-tool.ts +162 -0
- package/plugins/builtin/pencil-tool.ts +25 -0
- package/plugins/builtin/rect-tool.ts +179 -0
- package/plugins/builtin/selection-tool.ts +388 -0
- package/plugins/builtin/selection.test.ts +195 -0
- package/plugins/builtin/tool-plugins.test.ts +529 -0
- package/public/favicon.svg +7 -0
- package/public/sw.js +91 -0
- package/pyproject.toml +49 -0
- package/scripts/eslint-wave-a-list.sh +24 -0
- package/scripts/eslint-wave-a-status.sh +25 -0
- package/scripts/fix-index-signature-access.py +127 -0
- package/scripts/fix-unchecked-index.py +128 -0
- package/scripts/fix-unchecked-vars.py +127 -0
- package/scripts/fix-wave-a-bangs.py +36 -0
- package/scripts/fix-wave-a-templates.py +108 -0
- package/scripts/fix-wave-b-void-expr.py +167 -0
- package/scripts/generate-command-params.py +540 -0
- package/scripts/migrate-single-frame-to-multi.py +171 -0
- package/scripts/smoke-test.sh +77 -0
- package/selfdoc.json +10 -0
- package/server/README.md +0 -0
- package/server/src/pixelweaver/__init__.py +1 -0
- package/server/src/pixelweaver/__main__.py +4 -0
- package/server/src/pixelweaver/autosave.py +114 -0
- package/server/src/pixelweaver/bridge.py +127 -0
- package/server/src/pixelweaver/cli.py +199 -0
- package/server/src/pixelweaver/config.py +24 -0
- package/server/src/pixelweaver/connections.py +54 -0
- package/server/src/pixelweaver/main.py +271 -0
- package/server/src/pixelweaver/mcp_bridge.py +189 -0
- package/server/src/pixelweaver/mcp_drawing_tools.py +178 -0
- package/server/src/pixelweaver/mcp_export_tools.py +291 -0
- package/server/src/pixelweaver/mcp_frame_tools.py +423 -0
- package/server/src/pixelweaver/mcp_history_tools.py +106 -0
- package/server/src/pixelweaver/mcp_layer_tools.py +64 -0
- package/server/src/pixelweaver/mcp_lock.py +37 -0
- package/server/src/pixelweaver/mcp_project_tools.py +302 -0
- package/server/src/pixelweaver/mcp_read_tools.py +163 -0
- package/server/src/pixelweaver/mcp_registry.py +247 -0
- package/server/src/pixelweaver/mcp_resources.py +312 -0
- package/server/src/pixelweaver/mcp_server.py +234 -0
- package/server/src/pixelweaver/protocol.py +219 -0
- package/server/src/pixelweaver/state.py +267 -0
- package/server/src/pixelweaver/storage.py +293 -0
- package/server/src/pixelweaver/websocket.py +282 -0
- package/server/tests/__init__.py +0 -0
- package/server/tests/conftest.py +17 -0
- package/server/tests/test_api.py +96 -0
- package/server/tests/test_bridge.py +161 -0
- package/server/tests/test_health.py +9 -0
- package/server/tests/test_integration.py +86 -0
- package/server/tests/test_mcp_bridge.py +293 -0
- package/server/tests/test_mcp_lock.py +34 -0
- package/server/tests/test_mcp_registry.py +679 -0
- package/server/tests/test_mcp_resources.py +648 -0
- package/server/tests/test_protocol.py +125 -0
- package/server/tests/test_state.py +87 -0
- package/server/tests/test_storage.py +306 -0
- package/server/tests/test_websocket.py +275 -0
- package/src/App.svelte +107 -0
- package/src/app.css +215 -0
- package/src/lib/animation/AnimationPreviewPanel.svelte +667 -0
- package/src/lib/animation/animation-commands.test.ts +228 -0
- package/src/lib/animation/animation-commands.ts +540 -0
- package/src/lib/animation/animation-preview-panel-plugin.ts +25 -0
- package/src/lib/animation/animation-preview.svelte.ts +151 -0
- package/src/lib/animation/clipboard.ts +134 -0
- package/src/lib/animation/frame-model.svelte.ts +437 -0
- package/src/lib/animation/frame-model.test.ts +314 -0
- package/src/lib/animation/frame-selection.svelte.ts +77 -0
- package/src/lib/animation/frame-tags.svelte.ts +238 -0
- package/src/lib/animation/frame-tags.test.ts +136 -0
- package/src/lib/animation/import.test.ts +141 -0
- package/src/lib/animation/import.ts +112 -0
- package/src/lib/animation/spritesheet-export.test.ts +239 -0
- package/src/lib/animation/spritesheet-export.ts +314 -0
- package/src/lib/canvas/CanvasViewport.svelte +216 -0
- package/src/lib/canvas/canvas-init-plugin.ts +178 -0
- package/src/lib/canvas/canvas-renderer.ts +408 -0
- package/src/lib/canvas/canvas-state.svelte.ts +232 -0
- package/src/lib/canvas/canvas-state.test.ts +139 -0
- package/src/lib/canvas/input-handler.ts +221 -0
- package/src/lib/canvas/input-plugin.ts +150 -0
- package/src/lib/canvas/onion-skin.ts +94 -0
- package/src/lib/canvas/pixel-buffer.test.ts +249 -0
- package/src/lib/canvas/pixel-buffer.ts +151 -0
- package/src/lib/canvas/render-state.svelte.ts +18 -0
- package/src/lib/canvas/shape-preview-state.svelte.ts +36 -0
- package/src/lib/canvas/tile-mode.test.ts +53 -0
- package/src/lib/canvas/tile-mode.ts +92 -0
- package/src/lib/canvas/viewport-utils.ts +24 -0
- package/src/lib/canvas/zoom-utils.ts +31 -0
- package/src/lib/color/.gitkeep +0 -0
- package/src/lib/color/color-commands.ts +87 -0
- package/src/lib/color/color-state.svelte.ts +98 -0
- package/src/lib/color/color-state.test.ts +91 -0
- package/src/lib/color/color-utils.test.ts +220 -0
- package/src/lib/color/color-utils.ts +243 -0
- package/src/lib/color/palette-state.svelte.ts +127 -0
- package/src/lib/color/palette-state.test.ts +154 -0
- package/src/lib/color/palette.ts +79 -0
- package/src/lib/core/bootstrap.ts +66 -0
- package/src/lib/core/command-params.generated.ts +1549 -0
- package/src/lib/core/command-params.ts +20 -0
- package/src/lib/core/command-runner.ts +79 -0
- package/src/lib/core/commands.ts +134 -0
- package/src/lib/core/dispatcher.test.ts +548 -0
- package/src/lib/core/dispatcher.ts +361 -0
- package/src/lib/core/index.test.ts +7 -0
- package/src/lib/core/notification-state.svelte.ts +119 -0
- package/src/lib/core/plugin-api.ts +210 -0
- package/src/lib/core/plugin-discovery.test.ts +53 -0
- package/src/lib/core/plugin-discovery.ts +65 -0
- package/src/lib/core/plugin-loader.test.ts +159 -0
- package/src/lib/core/plugin-loader.ts +240 -0
- package/src/lib/core/plugin-types.ts +286 -0
- package/src/lib/core/registries.svelte.ts +74 -0
- package/src/lib/core/tool-options-state.svelte.ts +61 -0
- package/src/lib/dock/DockLayout.svelte +375 -0
- package/src/lib/dock/TabAddMenu.svelte +90 -0
- package/src/lib/dock/dock-persistence.ts +46 -0
- package/src/lib/dock/dock-plugin.ts +49 -0
- package/src/lib/dock/dock-presets.ts +156 -0
- package/src/lib/dock/dock-state.svelte.ts +77 -0
- package/src/lib/dock/dock-types.ts +2 -0
- package/src/lib/dock/dockview-theme.css +226 -0
- package/src/lib/dock/dockview-theme.ts +17 -0
- package/src/lib/dock/header-action-renderer.ts +77 -0
- package/src/lib/edit/clipboard-state.ts +34 -0
- package/src/lib/export/download.ts +201 -0
- package/src/lib/export/png-metadata.ts +181 -0
- package/src/lib/history/ActionLogPanel.svelte +418 -0
- package/src/lib/history/action-log-panel-plugin.ts +24 -0
- package/src/lib/history/action-log.svelte.ts +172 -0
- package/src/lib/history/action-log.test.ts +168 -0
- package/src/lib/history/history-commands.ts +403 -0
- package/src/lib/history/macros.svelte.ts +320 -0
- package/src/lib/history/macros.test.ts +224 -0
- package/src/lib/history/replay-engine.test.ts +241 -0
- package/src/lib/history/replay-engine.ts +149 -0
- package/src/lib/history/spec-format.test.ts +250 -0
- package/src/lib/history/spec-format.ts +210 -0
- package/src/lib/iso/SeamCheckerPanel.svelte +251 -0
- package/src/lib/iso/iso-math.test.ts +77 -0
- package/src/lib/iso/iso-math.ts +77 -0
- package/src/lib/iso/seam-checker-panel-plugin.ts +25 -0
- package/src/lib/iso/seam-checker.test.ts +126 -0
- package/src/lib/iso/seam-checker.ts +93 -0
- package/src/lib/iso/tessellation.ts +67 -0
- package/src/lib/layers/compositor.test.ts +193 -0
- package/src/lib/layers/compositor.ts +175 -0
- package/src/lib/layers/layer-commands.test.ts +263 -0
- package/src/lib/layers/layer-commands.ts +429 -0
- package/src/lib/layers/layer-tree.svelte.ts +516 -0
- package/src/lib/layers/layer-tree.test.ts +383 -0
- package/src/lib/layers/layer-types.ts +56 -0
- package/src/lib/leveleditor/LevelEditorViewport.svelte +1808 -0
- package/src/lib/leveleditor/MapPropertiesPanel.svelte +266 -0
- package/src/lib/leveleditor/TilePickerPanel.svelte +324 -0
- package/src/lib/leveleditor/depth-sort.test.ts +70 -0
- package/src/lib/leveleditor/depth-sort.ts +39 -0
- package/src/lib/leveleditor/level-editor-commands.ts +353 -0
- package/src/lib/leveleditor/level-editor-viewport-plugin.ts +25 -0
- package/src/lib/leveleditor/map-properties-panel-plugin.ts +25 -0
- package/src/lib/leveleditor/map-state.svelte.ts +372 -0
- package/src/lib/leveleditor/map-state.test.ts +243 -0
- package/src/lib/leveleditor/tile-picker-panel-plugin.ts +25 -0
- package/src/lib/leveleditor/tile-source-registry.svelte.ts +91 -0
- package/src/lib/leveleditor/tiled-export.test.ts +144 -0
- package/src/lib/leveleditor/tiled-export.ts +374 -0
- package/src/lib/pywebview.d.ts +15 -0
- package/src/lib/recovery/.gitkeep +0 -0
- package/src/lib/recovery/idb-store.ts +118 -0
- package/src/lib/recovery/recovery-manager.test.ts +321 -0
- package/src/lib/recovery/recovery-manager.ts +115 -0
- package/src/lib/recovery/recovery-plugin.ts +55 -0
- package/src/lib/recovery/recovery-state.svelte.ts +21 -0
- package/src/lib/recovery/sw-plugin.ts +18 -0
- package/src/lib/recovery/sw-register.ts +17 -0
- package/src/lib/save/directory-format.ts +42 -0
- package/src/lib/save/project-snapshot.ts +139 -0
- package/src/lib/save/recent-projects.ts +56 -0
- package/src/lib/save/save-state.svelte.ts +35 -0
- package/src/lib/save/storage.ts +167 -0
- package/src/lib/save/zip-format.ts +45 -0
- package/src/lib/shortcuts/.gitkeep +0 -0
- package/src/lib/shortcuts/ShortcutEditorPanel.svelte +690 -0
- package/src/lib/shortcuts/default-bindings.ts +61 -0
- package/src/lib/shortcuts/shortcut-editor-panel-plugin.ts +25 -0
- package/src/lib/shortcuts/shortcut-init.ts +380 -0
- package/src/lib/shortcuts/shortcut-manager.test.ts +466 -0
- package/src/lib/shortcuts/shortcut-manager.ts +281 -0
- package/src/lib/shortcuts/shortcut-state.svelte.ts +78 -0
- package/src/lib/shortcuts/shortcuts-plugin.ts +17 -0
- package/src/lib/sync/patch-applicator.ts +300 -0
- package/src/lib/sync/patch-types.ts +65 -0
- package/src/lib/sync/project-manager.ts +108 -0
- package/src/lib/sync/sync-init.ts +152 -0
- package/src/lib/sync/sync-plugin.ts +19 -0
- package/src/lib/sync/sync-state.svelte.ts +56 -0
- package/src/lib/sync/ws-client.test.ts +604 -0
- package/src/lib/sync/ws-client.ts +574 -0
- package/src/lib/tools/.gitkeep +0 -0
- package/src/lib/ui/.gitkeep +0 -0
- package/src/lib/ui/AboutDialog.svelte +113 -0
- package/src/lib/ui/ColorPicker.svelte +761 -0
- package/src/lib/ui/ContextMenu.svelte +216 -0
- package/src/lib/ui/ExportDialog.svelte +747 -0
- package/src/lib/ui/FrameStrip.svelte +854 -0
- package/src/lib/ui/LayerPanel.svelte +810 -0
- package/src/lib/ui/MenuBar.svelte +590 -0
- package/src/lib/ui/NewProjectDialog.svelte +803 -0
- package/src/lib/ui/PluginManagerPanel.svelte +475 -0
- package/src/lib/ui/PromptDialog.svelte +252 -0
- package/src/lib/ui/RecoveryDialog.svelte +295 -0
- package/src/lib/ui/ResizeDialog.svelte +416 -0
- package/src/lib/ui/StatusBar.svelte +145 -0
- package/src/lib/ui/ToolbarPanel.svelte +488 -0
- package/src/lib/ui/animation-menu-commands.ts +194 -0
- package/src/lib/ui/command-palette/CommandPalette.svelte +232 -0
- package/src/lib/ui/command-palette/command-palette-plugin.ts +30 -0
- package/src/lib/ui/command-palette/command-palette-state.svelte.ts +190 -0
- package/src/lib/ui/command-palette/command-palette.test.ts +129 -0
- package/src/lib/ui/dialog-state.svelte.ts +70 -0
- package/src/lib/ui/edit-commands.ts +271 -0
- package/src/lib/ui/file-commands.ts +275 -0
- package/src/lib/ui/file-open.ts +99 -0
- package/src/lib/ui/help-commands.ts +93 -0
- package/src/lib/ui/image-commands.ts +181 -0
- package/src/lib/ui/layer-menu-commands.ts +420 -0
- package/src/lib/ui/menu-builder.ts +224 -0
- package/src/lib/ui/notifications/NotificationBanner.svelte +137 -0
- package/src/lib/ui/notifications/notification-plugin.ts +29 -0
- package/src/lib/ui/notifications/notification-state.svelte.ts +9 -0
- package/src/lib/ui/plugin-manager-panel-plugin.ts +26 -0
- package/src/lib/ui/plugin-state.svelte.ts +62 -0
- package/src/lib/ui/select-commands.ts +75 -0
- package/src/lib/ui/theme-plugin.ts +18 -0
- package/src/lib/ui/theme.svelte.ts +45 -0
- package/src/lib/ui/theme.test.ts +51 -0
- package/src/lib/ui/toolbar-config.ts +90 -0
- package/src/lib/ui/toolbar-plugin.ts +39 -0
- package/src/lib/ui/view-commands.ts +629 -0
- package/src/lib/variants/BisectionExportDialog.svelte +500 -0
- package/src/lib/variants/VariantPanel.svelte +822 -0
- package/src/lib/variants/bisection-export.test.ts +113 -0
- package/src/lib/variants/bisection-export.ts +148 -0
- package/src/lib/variants/palette-extraction.test.ts +111 -0
- package/src/lib/variants/palette-extraction.ts +84 -0
- package/src/lib/variants/palette-interpolation.test.ts +113 -0
- package/src/lib/variants/palette-interpolation.ts +87 -0
- package/src/lib/variants/palette-swap.test.ts +101 -0
- package/src/lib/variants/palette-swap.ts +114 -0
- package/src/lib/variants/variant-commands.ts +594 -0
- package/src/lib/variants/variant-panel-plugin.ts +27 -0
- package/src/lib/variants/variant-randomizer.ts +101 -0
- package/src/lib/variants/variant-state.svelte.ts +166 -0
- package/src/lib/variants/variant-state.test.ts +138 -0
- package/src/main.ts +14 -0
- package/src/vite-env.d.ts +3 -0
- package/svelte.config.js +2 -0
- package/todo/.done/audit-design-decisions.md +812 -0
- package/todo/.done/audit-implementation-plan.md +1235 -0
- package/todo/.done/happy-path-polish.md +177 -0
- package/todo/.done/pixelweaver-full-build.md +937 -0
- package/todo/.done/server-multi-frame-design.md +405 -0
- package/todo/.done/typed-dispatcher-design.md +435 -0
- package/todo/.done/unified-toolbar-and-action-system.md +323 -0
- package/todo/.obsolete/comprehensive-audit-obsolete-items.md +33 -0
- package/todo/.obsolete/tauri-desktop-bundle.md +424 -0
- package/todo/comprehensive-audit.md +1085 -0
- package/tsconfig.app.json +26 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +20 -0
- package/uv.lock +1167 -0
- 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
|
+
};
|