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,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer Menu Commands -- Merge Down, Move Up/Down, plus menu items
|
|
3
|
+
* pointing at the add/duplicate/delete commands registered by the
|
|
4
|
+
* layer-commands plugin in src/lib/layers/.
|
|
5
|
+
*
|
|
6
|
+
* This file is the "menu glue" for the Layer top-level menu; it does not
|
|
7
|
+
* own the core layer lifecycle commands.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PluginModule } from '../core/plugin-loader.js';
|
|
11
|
+
import * as layerTree from '../layers/layer-tree.svelte.js';
|
|
12
|
+
import type { Layer, LayerTreeState } from '../layers/layer-types.js';
|
|
13
|
+
import { composite } from '../layers/compositor.js';
|
|
14
|
+
import { PixelBuffer } from '../canvas/pixel-buffer.js';
|
|
15
|
+
import { canvasState } from '../canvas/canvas-state.svelte.js';
|
|
16
|
+
import { getFrames } from '../animation/frame-model.svelte.js';
|
|
17
|
+
import ArrowDownToLine from '~icons/lucide/arrow-down-to-line';
|
|
18
|
+
import Layers from '~icons/lucide/layers';
|
|
19
|
+
import ArrowUp from '~icons/lucide/arrow-up';
|
|
20
|
+
import ArrowDown from '~icons/lucide/arrow-down';
|
|
21
|
+
|
|
22
|
+
// Snapshot of per-frame pixel data for two layers (used for merge-down undo).
|
|
23
|
+
interface MergeDownPixelSnapshot {
|
|
24
|
+
topLayerId: string;
|
|
25
|
+
bottomLayerId: string;
|
|
26
|
+
// Per-frame: [frameId, topBuffer | undefined, bottomBuffer | undefined]
|
|
27
|
+
frameData: Array<{
|
|
28
|
+
frameId: string;
|
|
29
|
+
topBuffer: PixelBuffer | undefined;
|
|
30
|
+
bottomBuffer: PixelBuffer | undefined;
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface MergeDownSnapshot {
|
|
35
|
+
tree: LayerTreeState;
|
|
36
|
+
pixels: MergeDownPixelSnapshot;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Snapshot for flatten_group undo: tree structure + all children's pixel data
|
|
40
|
+
// across all frames, so we can fully restore both structure and pixels.
|
|
41
|
+
interface FlattenGroupSnapshot {
|
|
42
|
+
tree: LayerTreeState;
|
|
43
|
+
// Per-frame pixel data for every descendant pixel layer (and the new replacement).
|
|
44
|
+
// Map<frameId, Map<layerId, PixelBuffer>>
|
|
45
|
+
framePixelSnapshots: Array<{
|
|
46
|
+
frameId: string;
|
|
47
|
+
entries: Array<{ layerId: string; buffer: PixelBuffer }>;
|
|
48
|
+
}>;
|
|
49
|
+
// The replacement layer ID, so undo can remove its pixel data entries
|
|
50
|
+
replacementId: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Collect all descendant pixel layer IDs from a group (depth-first). */
|
|
54
|
+
function collectDescendantPixelIds(layer: Layer): string[] {
|
|
55
|
+
const ids: string[] = [];
|
|
56
|
+
if (layer.type === 'pixel') {
|
|
57
|
+
ids.push(layer.id);
|
|
58
|
+
} else if (layer.children) {
|
|
59
|
+
for (const child of layer.children) {
|
|
60
|
+
ids.push(...collectDescendantPixelIds(child));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return ids;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Composite a top layer's pixels onto a bottom buffer using the compositor's
|
|
68
|
+
* public API. Constructs a minimal two-layer tree so the existing blend-mode
|
|
69
|
+
* and opacity logic in `composite()` handles the math.
|
|
70
|
+
*/
|
|
71
|
+
function compositeTopOntoBottom(
|
|
72
|
+
bottomBuf: PixelBuffer,
|
|
73
|
+
topBuf: PixelBuffer,
|
|
74
|
+
topLayer: Layer,
|
|
75
|
+
): PixelBuffer {
|
|
76
|
+
// Build a fake two-layer tree: bottom (identity: 100% normal) then top
|
|
77
|
+
// (with its real opacity and blend mode). composite() walks bottom-to-top.
|
|
78
|
+
const fakeBottom: Layer = {
|
|
79
|
+
id: '__merge_bottom__',
|
|
80
|
+
name: '',
|
|
81
|
+
type: 'pixel',
|
|
82
|
+
visible: true,
|
|
83
|
+
opacity: 100,
|
|
84
|
+
blendMode: 'normal',
|
|
85
|
+
locked: false,
|
|
86
|
+
};
|
|
87
|
+
const fakeTop: Layer = {
|
|
88
|
+
id: '__merge_top__',
|
|
89
|
+
name: '',
|
|
90
|
+
type: 'pixel',
|
|
91
|
+
visible: true,
|
|
92
|
+
opacity: topLayer.opacity,
|
|
93
|
+
blendMode: topLayer.blendMode,
|
|
94
|
+
locked: false,
|
|
95
|
+
};
|
|
96
|
+
const fakePixelData = new Map<string, PixelBuffer>();
|
|
97
|
+
fakePixelData.set(fakeBottom.id, bottomBuf);
|
|
98
|
+
fakePixelData.set(fakeTop.id, topBuf);
|
|
99
|
+
return composite([fakeBottom, fakeTop], fakePixelData, bottomBuf.width, bottomBuf.height);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const layerMenuCommandsPlugin: PluginModule = {
|
|
103
|
+
name: 'ui/layer-menu-commands',
|
|
104
|
+
version: '1.0.0',
|
|
105
|
+
dependencies: [],
|
|
106
|
+
register(api) {
|
|
107
|
+
// add_layer, duplicate_layer, remove_layer are registered by layer-commands plugin;
|
|
108
|
+
// we only add menu items pointing to those existing commands.
|
|
109
|
+
|
|
110
|
+
api.addMenuItem('menu:layer:add', {
|
|
111
|
+
commandId: 'add_layer',
|
|
112
|
+
menuPath: 'layer',
|
|
113
|
+
group: 'create',
|
|
114
|
+
order: 10,
|
|
115
|
+
label: 'Add Layer',
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
api.addMenuItem('menu:layer:duplicate', {
|
|
119
|
+
commandId: 'duplicate_layer',
|
|
120
|
+
menuPath: 'layer',
|
|
121
|
+
group: 'create',
|
|
122
|
+
order: 20,
|
|
123
|
+
label: 'Duplicate Layer',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
api.addMenuItem('menu:layer:delete', {
|
|
127
|
+
commandId: 'remove_layer',
|
|
128
|
+
menuPath: 'layer',
|
|
129
|
+
group: 'create',
|
|
130
|
+
order: 30,
|
|
131
|
+
label: 'Delete Layer',
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
api.addCommand('merge_down', {
|
|
135
|
+
tier: 'project',
|
|
136
|
+
undoable: true,
|
|
137
|
+
execute(): MergeDownSnapshot | undefined {
|
|
138
|
+
const activeId = layerTree.getActiveLayerId();
|
|
139
|
+
if (!activeId) return;
|
|
140
|
+
|
|
141
|
+
// Find the top layer and the layer below it BEFORE any mutation.
|
|
142
|
+
const topLayer = layerTree.getLayer(activeId);
|
|
143
|
+
if (!topLayer || topLayer.type !== 'pixel') return;
|
|
144
|
+
|
|
145
|
+
// Locate the sibling directly below in the same parent array,
|
|
146
|
+
// mirroring the check in layerTree.mergeDown().
|
|
147
|
+
const parent = layerTree.getParent(activeId);
|
|
148
|
+
const siblings = parent?.children ?? layerTree.getLayers();
|
|
149
|
+
const sibIdx = siblings.findIndex((l) => l.id === activeId);
|
|
150
|
+
if (sibIdx <= 0) return;
|
|
151
|
+
const bottomLayer = siblings[sibIdx - 1];
|
|
152
|
+
if (!bottomLayer || bottomLayer.type !== 'pixel') return;
|
|
153
|
+
|
|
154
|
+
const { canvasWidth, canvasHeight } = canvasState;
|
|
155
|
+
const frames = getFrames();
|
|
156
|
+
|
|
157
|
+
// Snapshot tree and per-frame pixel data for both layers (for undo).
|
|
158
|
+
const pixelSnapshot: MergeDownPixelSnapshot = {
|
|
159
|
+
topLayerId: activeId,
|
|
160
|
+
bottomLayerId: bottomLayer.id,
|
|
161
|
+
frameData: frames.map((frame) => ({
|
|
162
|
+
frameId: frame.id,
|
|
163
|
+
topBuffer: frame.pixelData.get(activeId)?.clone(),
|
|
164
|
+
bottomBuffer: frame.pixelData.get(bottomLayer.id)?.clone(),
|
|
165
|
+
})),
|
|
166
|
+
};
|
|
167
|
+
const treeSnapshot = layerTree.serialize();
|
|
168
|
+
|
|
169
|
+
// Composite top layer pixels onto bottom layer for each frame.
|
|
170
|
+
for (const frame of frames) {
|
|
171
|
+
const topBuf = frame.pixelData.get(activeId);
|
|
172
|
+
if (!topBuf) {
|
|
173
|
+
// Top has no pixels in this frame -- nothing to merge, just clean up
|
|
174
|
+
frame.pixelData.delete(activeId);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const bottomBuf =
|
|
179
|
+
frame.pixelData.get(bottomLayer.id) ??
|
|
180
|
+
new PixelBuffer(canvasWidth, canvasHeight);
|
|
181
|
+
|
|
182
|
+
const merged = compositeTopOntoBottom(bottomBuf, topBuf, topLayer);
|
|
183
|
+
frame.pixelData.set(bottomLayer.id, merged);
|
|
184
|
+
frame.pixelData.delete(activeId);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Structural merge: remove top layer from the tree, activate bottom.
|
|
188
|
+
try {
|
|
189
|
+
layerTree.mergeDown(activeId);
|
|
190
|
+
} catch {
|
|
191
|
+
// Should not happen since we pre-validated, but guard anyway.
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { tree: treeSnapshot, pixels: pixelSnapshot };
|
|
196
|
+
},
|
|
197
|
+
undo(_params, _ctx, snapshot) {
|
|
198
|
+
const snap = snapshot as MergeDownSnapshot | undefined;
|
|
199
|
+
if (!snap) return;
|
|
200
|
+
|
|
201
|
+
// Restore the layer tree structure first.
|
|
202
|
+
layerTree.deserialize(snap.tree);
|
|
203
|
+
|
|
204
|
+
// Restore per-frame pixel data for both layers.
|
|
205
|
+
const { topLayerId, bottomLayerId, frameData } = snap.pixels;
|
|
206
|
+
const frames = getFrames();
|
|
207
|
+
for (const saved of frameData) {
|
|
208
|
+
const frame = frames.find((f) => f.id === saved.frameId);
|
|
209
|
+
if (!frame) continue;
|
|
210
|
+
|
|
211
|
+
if (saved.topBuffer) {
|
|
212
|
+
frame.pixelData.set(topLayerId, saved.topBuffer);
|
|
213
|
+
} else {
|
|
214
|
+
frame.pixelData.delete(topLayerId);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (saved.bottomBuffer) {
|
|
218
|
+
frame.pixelData.set(bottomLayerId, saved.bottomBuffer);
|
|
219
|
+
} else {
|
|
220
|
+
frame.pixelData.delete(bottomLayerId);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
describe() { return 'Merged layer down'; },
|
|
225
|
+
label: 'Merge Down',
|
|
226
|
+
category: 'Layer',
|
|
227
|
+
icon: ArrowDownToLine,
|
|
228
|
+
});
|
|
229
|
+
api.addMenuItem('menu:layer:merge-down', {
|
|
230
|
+
commandId: 'merge_down',
|
|
231
|
+
menuPath: 'layer',
|
|
232
|
+
group: 'merge',
|
|
233
|
+
order: 40,
|
|
234
|
+
label: 'Merge Down',
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
api.addCommand('flatten_group', {
|
|
238
|
+
tier: 'project',
|
|
239
|
+
undoable: true,
|
|
240
|
+
execute(params): FlattenGroupSnapshot | undefined {
|
|
241
|
+
const groupId = params["id"];
|
|
242
|
+
if (!groupId) return;
|
|
243
|
+
const group = layerTree.getLayer(groupId);
|
|
244
|
+
if (!group || group.type !== 'group') return;
|
|
245
|
+
|
|
246
|
+
// Snapshot tree structure before mutation
|
|
247
|
+
const treeSnapshot = layerTree.serialize();
|
|
248
|
+
|
|
249
|
+
// Collect all descendant pixel layer IDs whose data we need to
|
|
250
|
+
// snapshot (for undo) and remove (after compositing).
|
|
251
|
+
const descendantPixelIds = collectDescendantPixelIds(group);
|
|
252
|
+
|
|
253
|
+
const { canvasWidth, canvasHeight } = canvasState;
|
|
254
|
+
const frames = getFrames();
|
|
255
|
+
|
|
256
|
+
// Snapshot per-frame pixel data for all descendants, then composite
|
|
257
|
+
// and store the result under the replacement layer ID.
|
|
258
|
+
const framePixelSnapshots: FlattenGroupSnapshot['framePixelSnapshots'] = [];
|
|
259
|
+
|
|
260
|
+
// Perform the structural flatten first to get the replacement layer ID.
|
|
261
|
+
// We need the group's children (still in the tree) for compositing,
|
|
262
|
+
// so we snapshot the children list before flattenGroup removes them.
|
|
263
|
+
const childrenSnapshot = group.children ? [...group.children] : [];
|
|
264
|
+
const replacement = layerTree.flattenGroup(groupId);
|
|
265
|
+
|
|
266
|
+
// Now composite pixels per frame and manage pixelData maps
|
|
267
|
+
for (const frame of frames) {
|
|
268
|
+
// Snapshot all descendant pixel data entries for undo
|
|
269
|
+
const entries: FlattenGroupSnapshot['framePixelSnapshots'][number]['entries'] = [];
|
|
270
|
+
for (const layerId of descendantPixelIds) {
|
|
271
|
+
const buf = frame.pixelData.get(layerId);
|
|
272
|
+
if (buf) {
|
|
273
|
+
entries.push({ layerId, buffer: buf.clone() });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
framePixelSnapshots.push({ frameId: frame.id, entries });
|
|
277
|
+
|
|
278
|
+
// Composite the group's children into a single buffer.
|
|
279
|
+
// Use the snapshotted children list since flattenGroup already
|
|
280
|
+
// removed them from the tree.
|
|
281
|
+
const composited = composite(
|
|
282
|
+
childrenSnapshot,
|
|
283
|
+
frame.pixelData,
|
|
284
|
+
canvasWidth,
|
|
285
|
+
canvasHeight,
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
// Store composited result under the replacement layer's ID
|
|
289
|
+
frame.pixelData.set(replacement.id, composited);
|
|
290
|
+
|
|
291
|
+
// Remove all descendant pixel data entries (they're now merged)
|
|
292
|
+
for (const layerId of descendantPixelIds) {
|
|
293
|
+
frame.pixelData.delete(layerId);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Stash the group name on params so describe() can use it
|
|
298
|
+
params["_groupName"] = group.name;
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
tree: treeSnapshot,
|
|
302
|
+
framePixelSnapshots,
|
|
303
|
+
replacementId: replacement.id,
|
|
304
|
+
};
|
|
305
|
+
},
|
|
306
|
+
undo(_params, _ctx, snapshot) {
|
|
307
|
+
const snap = snapshot as FlattenGroupSnapshot | undefined;
|
|
308
|
+
if (!snap) return;
|
|
309
|
+
|
|
310
|
+
// Restore tree structure (brings back the group and its children)
|
|
311
|
+
layerTree.deserialize(snap.tree);
|
|
312
|
+
|
|
313
|
+
// Restore per-frame pixel data: remove replacement entries, restore
|
|
314
|
+
// descendant entries.
|
|
315
|
+
const frames = getFrames();
|
|
316
|
+
for (const framePxSnap of snap.framePixelSnapshots) {
|
|
317
|
+
const frame = frames.find((f) => f.id === framePxSnap.frameId);
|
|
318
|
+
if (!frame) continue;
|
|
319
|
+
|
|
320
|
+
// Remove the composited replacement entry
|
|
321
|
+
frame.pixelData.delete(snap.replacementId);
|
|
322
|
+
|
|
323
|
+
// Restore all descendant pixel data
|
|
324
|
+
for (const entry of framePxSnap.entries) {
|
|
325
|
+
frame.pixelData.set(entry.layerId, entry.buffer.clone());
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
describe(params) {
|
|
330
|
+
return `Flattened group "${String(params["_groupName"] ?? params["id"])}"`;
|
|
331
|
+
},
|
|
332
|
+
label: 'Flatten Group',
|
|
333
|
+
category: 'Layer',
|
|
334
|
+
icon: Layers,
|
|
335
|
+
});
|
|
336
|
+
api.addMenuItem('menu:layer:flatten-group', {
|
|
337
|
+
commandId: 'flatten_group',
|
|
338
|
+
menuPath: 'layer',
|
|
339
|
+
group: 'merge',
|
|
340
|
+
order: 45,
|
|
341
|
+
label: 'Flatten Group',
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
api.addCommand('move_layer_up', {
|
|
345
|
+
tier: 'project',
|
|
346
|
+
execute() {
|
|
347
|
+
const activeId = layerTree.getActiveLayerId();
|
|
348
|
+
if (!activeId) return;
|
|
349
|
+
const parent = layerTree.getParent(activeId);
|
|
350
|
+
const siblings = parent?.children ?? layerTree.getLayers();
|
|
351
|
+
const idx = siblings.findIndex((l) => l.id === activeId);
|
|
352
|
+
// "Up" in the visual stack means higher index (layers are bottom-first)
|
|
353
|
+
if (idx < 0 || idx >= siblings.length - 1) return;
|
|
354
|
+
layerTree.moveLayer(activeId, parent?.id ?? null, idx + 1);
|
|
355
|
+
},
|
|
356
|
+
undo() {},
|
|
357
|
+
describe() { return 'Moved layer up'; },
|
|
358
|
+
label: 'Move Layer Up',
|
|
359
|
+
category: 'Layer',
|
|
360
|
+
icon: ArrowUp,
|
|
361
|
+
});
|
|
362
|
+
api.addMenuItem('menu:layer:move-up', {
|
|
363
|
+
commandId: 'move_layer_up',
|
|
364
|
+
menuPath: 'layer',
|
|
365
|
+
group: 'order',
|
|
366
|
+
order: 50,
|
|
367
|
+
label: 'Move Layer Up',
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
api.addCommand('move_layer_down', {
|
|
371
|
+
tier: 'project',
|
|
372
|
+
execute() {
|
|
373
|
+
const activeId = layerTree.getActiveLayerId();
|
|
374
|
+
if (!activeId) return;
|
|
375
|
+
const parent = layerTree.getParent(activeId);
|
|
376
|
+
const siblings = parent?.children ?? layerTree.getLayers();
|
|
377
|
+
const idx = siblings.findIndex((l) => l.id === activeId);
|
|
378
|
+
// "Down" in the visual stack means lower index (layers are bottom-first)
|
|
379
|
+
if (idx <= 0) return;
|
|
380
|
+
layerTree.moveLayer(activeId, parent?.id ?? null, idx - 1);
|
|
381
|
+
},
|
|
382
|
+
undo() {},
|
|
383
|
+
describe() { return 'Moved layer down'; },
|
|
384
|
+
label: 'Move Layer Down',
|
|
385
|
+
category: 'Layer',
|
|
386
|
+
icon: ArrowDown,
|
|
387
|
+
});
|
|
388
|
+
api.addMenuItem('menu:layer:move-down', {
|
|
389
|
+
commandId: 'move_layer_down',
|
|
390
|
+
menuPath: 'layer',
|
|
391
|
+
group: 'order',
|
|
392
|
+
order: 60,
|
|
393
|
+
label: 'Move Layer Down',
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// --- Context menu: layer ---
|
|
397
|
+
|
|
398
|
+
api.addMenuItem('ctx:layer:add', {
|
|
399
|
+
commandId: 'add_layer',
|
|
400
|
+
menuPath: 'context/layer',
|
|
401
|
+
group: 'create',
|
|
402
|
+
order: 10,
|
|
403
|
+
label: 'Add Layer',
|
|
404
|
+
});
|
|
405
|
+
api.addMenuItem('ctx:layer:duplicate', {
|
|
406
|
+
commandId: 'duplicate_layer',
|
|
407
|
+
menuPath: 'context/layer',
|
|
408
|
+
group: 'create',
|
|
409
|
+
order: 20,
|
|
410
|
+
label: 'Duplicate Layer',
|
|
411
|
+
});
|
|
412
|
+
api.addMenuItem('ctx:layer:delete', {
|
|
413
|
+
commandId: 'remove_layer',
|
|
414
|
+
menuPath: 'context/layer',
|
|
415
|
+
group: 'manage',
|
|
416
|
+
order: 10,
|
|
417
|
+
label: 'Delete Layer',
|
|
418
|
+
});
|
|
419
|
+
},
|
|
420
|
+
};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu tree builder -- transforms flat MenuContribution entries from the
|
|
3
|
+
* menuRegistry into a hierarchical menu tree suitable for rendering.
|
|
4
|
+
*
|
|
5
|
+
* The menuRegistry stores one MenuContribution per item (keyed by name).
|
|
6
|
+
* This module groups them by menuPath, sorts by group/order, and builds
|
|
7
|
+
* nested submenus when a menuPath has more than one segment.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { menuRegistry, commandRegistry } from '../core/registries.svelte.js';
|
|
11
|
+
import { executeOrDispatch, getCommandForDispatch } from '../core/command-runner.js';
|
|
12
|
+
import type { MenuContribution } from '../core/plugin-types.js';
|
|
13
|
+
|
|
14
|
+
// --- Public types ---
|
|
15
|
+
|
|
16
|
+
export interface MenuItem {
|
|
17
|
+
id: string; // contribution name (registry key)
|
|
18
|
+
label: string; // display label
|
|
19
|
+
shortcut?: string; // keyboard shortcut display string
|
|
20
|
+
action?: () => void; // command execute handler
|
|
21
|
+
disabled?: boolean; // grayed out when true
|
|
22
|
+
separator?: boolean; // group separator (not a real item)
|
|
23
|
+
children?: MenuItem[]; // submenu items (present when this is a submenu parent)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MenuDefinition {
|
|
27
|
+
label: string; // top-level menu label (e.g. "File", "Edit")
|
|
28
|
+
items: MenuItem[]; // grouped and sorted items
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- Internal constants ---
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Well-known top-level menu order.
|
|
35
|
+
* Menus not in this list appear at the end in alphabetical order.
|
|
36
|
+
*/
|
|
37
|
+
const MENU_ORDER = ['file', 'edit', 'view', 'image', 'layer', 'animation', 'select', 'effects', 'context', 'help'];
|
|
38
|
+
|
|
39
|
+
// --- Helpers ---
|
|
40
|
+
|
|
41
|
+
/** Capitalize a menu path segment to a display label */
|
|
42
|
+
function segmentToLabel(segment: string): string {
|
|
43
|
+
return segment.charAt(0).toUpperCase() + segment.slice(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve the display label for a menu contribution */
|
|
47
|
+
function resolveLabel(contrib: MenuContribution, _name: string): string {
|
|
48
|
+
if (contrib.label) return contrib.label;
|
|
49
|
+
const cmd = commandRegistry.get(contrib.commandId);
|
|
50
|
+
if (cmd?.label) return cmd.label;
|
|
51
|
+
// Fallback: humanize the commandId
|
|
52
|
+
return contrib.commandId.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Build a single MenuItem from a contribution */
|
|
56
|
+
function buildMenuItem(contrib: MenuContribution, name: string): MenuItem {
|
|
57
|
+
const cmd = getCommandForDispatch(contrib.commandId);
|
|
58
|
+
const label = resolveLabel(contrib, name);
|
|
59
|
+
const disabled = contrib.enabled ? !contrib.enabled() : false;
|
|
60
|
+
|
|
61
|
+
// Omit shortcut/action when absent (exactOptionalPropertyTypes)
|
|
62
|
+
const item: MenuItem = {
|
|
63
|
+
id: name,
|
|
64
|
+
label,
|
|
65
|
+
disabled,
|
|
66
|
+
};
|
|
67
|
+
if (cmd?.defaultShortcut !== undefined) item.shortcut = cmd.defaultShortcut;
|
|
68
|
+
if (cmd) item.action = () => { executeOrDispatch(contrib.commandId, cmd); };
|
|
69
|
+
return item;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Internal entry type used during tree construction ---
|
|
73
|
+
|
|
74
|
+
interface MenuEntry {
|
|
75
|
+
path: string[];
|
|
76
|
+
contribution: MenuContribution;
|
|
77
|
+
name: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build menu items from a list of entries, handling submenus and grouping.
|
|
82
|
+
* Direct items (single remaining path segment) are grouped by `group` field
|
|
83
|
+
* with separators between groups. Submenu items (multiple segments) recurse.
|
|
84
|
+
*/
|
|
85
|
+
function buildMenuItems(entries: MenuEntry[]): MenuItem[] {
|
|
86
|
+
// Separate direct items (path length <= 1) from submenu items (path length > 1)
|
|
87
|
+
const directItems: { contribution: MenuContribution; name: string }[] = [];
|
|
88
|
+
const submenuMap = new Map<string, MenuEntry[]>();
|
|
89
|
+
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if (entry.path.length <= 1) {
|
|
92
|
+
directItems.push(entry);
|
|
93
|
+
} else {
|
|
94
|
+
const submenuKey = entry.path[1];
|
|
95
|
+
if (submenuKey === undefined) continue;
|
|
96
|
+
let bucket = submenuMap.get(submenuKey);
|
|
97
|
+
if (!bucket) {
|
|
98
|
+
bucket = [];
|
|
99
|
+
submenuMap.set(submenuKey, bucket);
|
|
100
|
+
}
|
|
101
|
+
// Shift the path down by one level for recursive processing
|
|
102
|
+
bucket.push({
|
|
103
|
+
...entry,
|
|
104
|
+
path: entry.path.slice(1),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Group direct items by their group field
|
|
110
|
+
const groups = new Map<string, { contribution: MenuContribution; name: string }[]>();
|
|
111
|
+
for (const item of directItems) {
|
|
112
|
+
const g = item.contribution.group;
|
|
113
|
+
let bucket = groups.get(g);
|
|
114
|
+
if (!bucket) {
|
|
115
|
+
bucket = [];
|
|
116
|
+
groups.set(g, bucket);
|
|
117
|
+
}
|
|
118
|
+
bucket.push(item);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Sort groups: "navigation" first, then alphabetically
|
|
122
|
+
const sortedGroups = [...groups.keys()].sort((a, b) => {
|
|
123
|
+
if (a === 'navigation') return -1;
|
|
124
|
+
if (b === 'navigation') return 1;
|
|
125
|
+
return a.localeCompare(b);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const result: MenuItem[] = [];
|
|
129
|
+
|
|
130
|
+
for (let gi = 0; gi < sortedGroups.length; gi++) {
|
|
131
|
+
const groupName = sortedGroups[gi];
|
|
132
|
+
if (groupName === undefined) continue;
|
|
133
|
+
const groupItems = groups.get(groupName);
|
|
134
|
+
if (!groupItems) continue;
|
|
135
|
+
|
|
136
|
+
// Sort items within group by order, then by label
|
|
137
|
+
groupItems.sort((a, b) => {
|
|
138
|
+
const orderDiff = a.contribution.order - b.contribution.order;
|
|
139
|
+
if (orderDiff !== 0) return orderDiff;
|
|
140
|
+
return resolveLabel(a.contribution, a.name).localeCompare(
|
|
141
|
+
resolveLabel(b.contribution, b.name),
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Separator between groups (not before the first)
|
|
146
|
+
if (gi > 0) {
|
|
147
|
+
result.push({ id: `sep-${groupName}`, label: '', separator: true });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const item of groupItems) {
|
|
151
|
+
result.push(buildMenuItem(item.contribution, item.name));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Append submenus after direct items
|
|
156
|
+
for (const [submenuKey, subEntries] of submenuMap) {
|
|
157
|
+
const last = result[result.length - 1];
|
|
158
|
+
if (result.length > 0 && last && !last.separator) {
|
|
159
|
+
result.push({ id: `sep-sub-${submenuKey}`, label: '', separator: true });
|
|
160
|
+
}
|
|
161
|
+
result.push({
|
|
162
|
+
id: `submenu-${submenuKey}`,
|
|
163
|
+
label: segmentToLabel(submenuKey),
|
|
164
|
+
children: buildMenuItems(subEntries),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- Public API ---
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Build the full menu bar from the menuRegistry.
|
|
175
|
+
* Returns an ordered list of top-level menus, each with grouped/sorted items.
|
|
176
|
+
*
|
|
177
|
+
* Because menuRegistry is backed by SvelteMap, calling this inside a $derived
|
|
178
|
+
* expression will automatically re-evaluate when contributions change.
|
|
179
|
+
*/
|
|
180
|
+
export function buildMenuBar(): MenuDefinition[] {
|
|
181
|
+
const contributions = [...menuRegistry.getAll().entries()];
|
|
182
|
+
|
|
183
|
+
// Group contributions by top-level menu (first segment of menuPath)
|
|
184
|
+
const menuMap = new Map<string, MenuEntry[]>();
|
|
185
|
+
|
|
186
|
+
for (const [name, contrib] of contributions) {
|
|
187
|
+
// Evaluate conditional visibility
|
|
188
|
+
if (contrib.when && !contrib.when()) continue;
|
|
189
|
+
|
|
190
|
+
const segments = contrib.menuPath.split('/');
|
|
191
|
+
const topLevel = segments[0];
|
|
192
|
+
if (topLevel === undefined) continue;
|
|
193
|
+
|
|
194
|
+
let bucket = menuMap.get(topLevel);
|
|
195
|
+
if (!bucket) {
|
|
196
|
+
bucket = [];
|
|
197
|
+
menuMap.set(topLevel, bucket);
|
|
198
|
+
}
|
|
199
|
+
bucket.push({ path: segments, contribution: contrib, name });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Sort top-level menus by MENU_ORDER, then alphabetically for unknowns
|
|
203
|
+
const sortedKeys = [...menuMap.keys()].sort((a, b) => {
|
|
204
|
+
const ai = MENU_ORDER.indexOf(a);
|
|
205
|
+
const bi = MENU_ORDER.indexOf(b);
|
|
206
|
+
if (ai !== -1 && bi !== -1) return ai - bi;
|
|
207
|
+
if (ai !== -1) return -1;
|
|
208
|
+
if (bi !== -1) return 1;
|
|
209
|
+
return a.localeCompare(b);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const menus: MenuDefinition[] = [];
|
|
213
|
+
|
|
214
|
+
for (const key of sortedKeys) {
|
|
215
|
+
const entries = menuMap.get(key);
|
|
216
|
+
if (!entries) continue;
|
|
217
|
+
menus.push({
|
|
218
|
+
label: segmentToLabel(key),
|
|
219
|
+
items: buildMenuItems(entries),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return menus;
|
|
224
|
+
}
|