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
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { PixelBuffer } from '../canvas/pixel-buffer.js';
|
|
3
|
+
import * as fm from './frame-model.svelte.js';
|
|
4
|
+
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
fm._resetForTesting();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe('addFrame', () => {
|
|
10
|
+
it('should add a frame to an empty list', () => {
|
|
11
|
+
fm.addFrame();
|
|
12
|
+
expect(fm.getFrames()).toHaveLength(1);
|
|
13
|
+
expect(fm.getFrames()[0]?.index).toBe(0);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should insert after a specific index', () => {
|
|
17
|
+
fm.addFrame();
|
|
18
|
+
fm.addFrame();
|
|
19
|
+
fm.addFrame({ afterIndex: 0 });
|
|
20
|
+
expect(fm.getFrames()).toHaveLength(3);
|
|
21
|
+
// The new frame should be at index 1
|
|
22
|
+
expect(fm.getFrames()[1]?.id).not.toBe(fm.getFrames()[0]?.id);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should duplicate pixel data when duplicate option is set', () => {
|
|
26
|
+
const frame = fm.addFrame();
|
|
27
|
+
const buf = new PixelBuffer(2, 2);
|
|
28
|
+
buf.setPixel(0, 0, 255, 0, 0, 255);
|
|
29
|
+
frame.pixelData.set('layer1', buf);
|
|
30
|
+
|
|
31
|
+
fm.addFrame({ afterIndex: 0, duplicate: true });
|
|
32
|
+
const duped = fm.getFrames()[1];
|
|
33
|
+
if (!duped) throw new Error('expected duplicated frame');
|
|
34
|
+
const dupedBuf = duped.pixelData.get('layer1');
|
|
35
|
+
expect(dupedBuf).toBeDefined();
|
|
36
|
+
expect(dupedBuf?.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
|
|
37
|
+
// Verify it's a copy, not a reference
|
|
38
|
+
buf.setPixel(0, 0, 0, 255, 0, 255);
|
|
39
|
+
expect(dupedBuf?.getPixel(0, 0)).toEqual([255, 0, 0, 255]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('removeFrame', () => {
|
|
44
|
+
it('should remove a frame by index', () => {
|
|
45
|
+
fm.addFrame();
|
|
46
|
+
fm.addFrame();
|
|
47
|
+
fm.removeFrame(0);
|
|
48
|
+
expect(fm.getFrames()).toHaveLength(1);
|
|
49
|
+
expect(fm.getFrames()[0]?.index).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should throw when removing the last frame', () => {
|
|
53
|
+
fm.addFrame();
|
|
54
|
+
expect(() => { fm.removeFrame(0); }).toThrow('Cannot remove the last frame');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should adjust currentFrameIndex if needed', () => {
|
|
58
|
+
fm.addFrame();
|
|
59
|
+
fm.addFrame();
|
|
60
|
+
fm.setCurrentFrame(1);
|
|
61
|
+
fm.removeFrame(1);
|
|
62
|
+
expect(fm.getCurrentFrameIndex()).toBe(0);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('duplicateFrame', () => {
|
|
67
|
+
it('should create a copy after the source frame', () => {
|
|
68
|
+
const f0 = fm.addFrame();
|
|
69
|
+
const buf = new PixelBuffer(4, 4);
|
|
70
|
+
buf.fill(128, 128, 128, 255);
|
|
71
|
+
f0.pixelData.set('bg', buf);
|
|
72
|
+
|
|
73
|
+
fm.duplicateFrame(0);
|
|
74
|
+
expect(fm.getFrames()).toHaveLength(2);
|
|
75
|
+
const copy = fm.getFrames()[1];
|
|
76
|
+
if (!copy) throw new Error('expected duplicated copy');
|
|
77
|
+
const copyBuf = copy.pixelData.get('bg');
|
|
78
|
+
expect(copyBuf).toBeDefined();
|
|
79
|
+
expect(copyBuf?.getPixel(0, 0)).toEqual([128, 128, 128, 255]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('reorderFrame', () => {
|
|
84
|
+
it('should swap frame positions', () => {
|
|
85
|
+
const f0 = fm.addFrame();
|
|
86
|
+
const f1 = fm.addFrame();
|
|
87
|
+
const id0 = f0.id;
|
|
88
|
+
const id1 = f1.id;
|
|
89
|
+
|
|
90
|
+
fm.reorderFrame(0, 1);
|
|
91
|
+
expect(fm.getFrames()[0]?.id).toBe(id1);
|
|
92
|
+
expect(fm.getFrames()[1]?.id).toBe(id0);
|
|
93
|
+
// Indices should be reindexed
|
|
94
|
+
expect(fm.getFrames()[0]?.index).toBe(0);
|
|
95
|
+
expect(fm.getFrames()[1]?.index).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should track current frame through reorder', () => {
|
|
99
|
+
fm.addFrame();
|
|
100
|
+
fm.addFrame();
|
|
101
|
+
fm.addFrame();
|
|
102
|
+
fm.setCurrentFrame(0);
|
|
103
|
+
fm.reorderFrame(0, 2);
|
|
104
|
+
expect(fm.getCurrentFrameIndex()).toBe(2);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('reorderFrames', () => {
|
|
109
|
+
it('should move 2 adjacent frames forward', () => {
|
|
110
|
+
// Setup: 5 frames [A, B, C, D, E]
|
|
111
|
+
const fA = fm.addFrame();
|
|
112
|
+
const fB = fm.addFrame();
|
|
113
|
+
const fC = fm.addFrame();
|
|
114
|
+
const fD = fm.addFrame();
|
|
115
|
+
const fE = fm.addFrame();
|
|
116
|
+
const idA = fA.id, idB = fB.id, idC = fC.id, idD = fD.id, idE = fE.id;
|
|
117
|
+
|
|
118
|
+
// Move frames [0,1] (A,B) to position 4
|
|
119
|
+
// After extraction: [C, D, E], adjusted target = 4 - 2 = 2 (end)
|
|
120
|
+
// Result: [C, D, E, A, B] -- wait, let's recalculate:
|
|
121
|
+
// sorted=[0,1], extracted=[A,B], remaining=[C,D,E]
|
|
122
|
+
// adjustedTarget = 4 - 2 (both 0 and 1 < 4) = 2
|
|
123
|
+
// splice(2, 0, A, B) into [C,D,E] => [C,D,A,B,E]
|
|
124
|
+
fm.reorderFrames([0, 1], 4);
|
|
125
|
+
const ids = fm.getFrames().map((f) => f.id);
|
|
126
|
+
expect(ids).toEqual([idC, idD, idA, idB, idE]);
|
|
127
|
+
// Verify reindexing
|
|
128
|
+
fm.getFrames().forEach((f, i) => {
|
|
129
|
+
expect(f.index).toBe(i);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should move 2 adjacent frames backward', () => {
|
|
134
|
+
// Setup: 5 frames [A, B, C, D, E]
|
|
135
|
+
const fA = fm.addFrame();
|
|
136
|
+
const fB = fm.addFrame();
|
|
137
|
+
const fC = fm.addFrame();
|
|
138
|
+
const fD = fm.addFrame();
|
|
139
|
+
const fE = fm.addFrame();
|
|
140
|
+
const idA = fA.id, idB = fB.id, idC = fC.id, idD = fD.id, idE = fE.id;
|
|
141
|
+
|
|
142
|
+
// Move frames [3,4] (D,E) to position 1
|
|
143
|
+
// sorted=[3,4], extracted=[D,E], remaining=[A,B,C]
|
|
144
|
+
// adjustedTarget = 1 - 0 (neither 3 nor 4 < 1) = 1
|
|
145
|
+
// splice(1, 0, D, E) into [A,B,C] => [A,D,E,B,C]
|
|
146
|
+
fm.reorderFrames([3, 4], 1);
|
|
147
|
+
const ids = fm.getFrames().map((f) => f.id);
|
|
148
|
+
expect(ids).toEqual([idA, idD, idE, idB, idC]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should move non-contiguous frames', () => {
|
|
152
|
+
// Setup: 5 frames [A, B, C, D, E]
|
|
153
|
+
const fA = fm.addFrame();
|
|
154
|
+
const fB = fm.addFrame();
|
|
155
|
+
const fC = fm.addFrame();
|
|
156
|
+
const fD = fm.addFrame();
|
|
157
|
+
const fE = fm.addFrame();
|
|
158
|
+
const idA = fA.id, idB = fB.id, idC = fC.id, idD = fD.id, idE = fE.id;
|
|
159
|
+
|
|
160
|
+
// Move frames [0, 2] (A, C) to position 4
|
|
161
|
+
// sorted=[0,2], extracted=[A,C], remaining=[B,D,E]
|
|
162
|
+
// adjustedTarget = 4 - 2 (both 0 and 2 < 4) = 2
|
|
163
|
+
// splice(2, 0, A, C) into [B,D,E] => [B,D,A,C,E]
|
|
164
|
+
fm.reorderFrames([0, 2], 4);
|
|
165
|
+
const ids = fm.getFrames().map((f) => f.id);
|
|
166
|
+
expect(ids).toEqual([idB, idD, idA, idC, idE]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should be a no-op when moving all frames to position 0', () => {
|
|
170
|
+
const fA = fm.addFrame();
|
|
171
|
+
const fB = fm.addFrame();
|
|
172
|
+
const fC = fm.addFrame();
|
|
173
|
+
const idA = fA.id, idB = fB.id, idC = fC.id;
|
|
174
|
+
|
|
175
|
+
// Move all frames [0,1,2] to position 0
|
|
176
|
+
// sorted=[0,1,2], extracted=[A,B,C], remaining=[]
|
|
177
|
+
// adjustedTarget = 0 - 0 = 0
|
|
178
|
+
// splice(0, 0, A, B, C) into [] => [A, B, C]
|
|
179
|
+
fm.reorderFrames([0, 1, 2], 0);
|
|
180
|
+
const ids = fm.getFrames().map((f) => f.id);
|
|
181
|
+
expect(ids).toEqual([idA, idB, idC]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should work like reorderFrame for single-frame input', () => {
|
|
185
|
+
const fA = fm.addFrame();
|
|
186
|
+
const fB = fm.addFrame();
|
|
187
|
+
const fC = fm.addFrame();
|
|
188
|
+
const idA = fA.id, idB = fB.id, idC = fC.id;
|
|
189
|
+
|
|
190
|
+
// Move frame [0] (A) to position 2
|
|
191
|
+
// sorted=[0], extracted=[A], remaining=[B,C]
|
|
192
|
+
// adjustedTarget = 2 - 1 (0 < 2) = 1
|
|
193
|
+
// splice(1, 0, A) into [B,C] => [B,A,C]
|
|
194
|
+
fm.reorderFrames([0], 2);
|
|
195
|
+
const ids = fm.getFrames().map((f) => f.id);
|
|
196
|
+
expect(ids).toEqual([idB, idA, idC]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should handle target within the selected range', () => {
|
|
200
|
+
// Setup: 5 frames [A, B, C, D, E]
|
|
201
|
+
const fA = fm.addFrame();
|
|
202
|
+
const fB = fm.addFrame();
|
|
203
|
+
const fC = fm.addFrame();
|
|
204
|
+
const fD = fm.addFrame();
|
|
205
|
+
const fE = fm.addFrame();
|
|
206
|
+
const idA = fA.id, idB = fB.id, idC = fC.id, idD = fD.id, idE = fE.id;
|
|
207
|
+
|
|
208
|
+
// Move frames [1,2,3] (B,C,D) to position 2 (within the range)
|
|
209
|
+
// sorted=[1,2,3], extracted=[B,C,D], remaining=[A,E]
|
|
210
|
+
// adjustedTarget = 2 - 1 (only 1 < 2) = 1
|
|
211
|
+
// splice(1, 0, B, C, D) into [A,E] => [A,B,C,D,E]
|
|
212
|
+
fm.reorderFrames([1, 2, 3], 2);
|
|
213
|
+
const ids = fm.getFrames().map((f) => f.id);
|
|
214
|
+
expect(ids).toEqual([idA, idB, idC, idD, idE]);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('frame duration', () => {
|
|
219
|
+
it('should derive duration from global FPS when no override', () => {
|
|
220
|
+
fm.addFrame();
|
|
221
|
+
fm.setGlobalFps(10);
|
|
222
|
+
expect(fm.getFrameDuration(0)).toBe(100); // 1000 / 10
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should use per-frame override when set', () => {
|
|
226
|
+
fm.addFrame();
|
|
227
|
+
fm.setFrameDuration(0, 200);
|
|
228
|
+
expect(fm.getFrameDuration(0)).toBe(200);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should reset to global FPS when override cleared', () => {
|
|
232
|
+
fm.addFrame();
|
|
233
|
+
fm.setGlobalFps(12);
|
|
234
|
+
fm.setFrameDuration(0, 200);
|
|
235
|
+
fm.setFrameDuration(0, null);
|
|
236
|
+
expect(fm.getFrameDuration(0)).toBe(Math.round(1000 / 12));
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('current frame', () => {
|
|
241
|
+
it('should start at index 0', () => {
|
|
242
|
+
fm.addFrame();
|
|
243
|
+
expect(fm.getCurrentFrameIndex()).toBe(0);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should change via setCurrentFrame', () => {
|
|
247
|
+
fm.addFrame();
|
|
248
|
+
fm.addFrame();
|
|
249
|
+
fm.setCurrentFrame(1);
|
|
250
|
+
expect(fm.getCurrentFrameIndex()).toBe(1);
|
|
251
|
+
expect(fm.getCurrentFrame().index).toBe(1);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('should throw for out-of-range index', () => {
|
|
255
|
+
fm.addFrame();
|
|
256
|
+
expect(() => { fm.setCurrentFrame(5); }).toThrow();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('origin', () => {
|
|
261
|
+
it('should default to (0, 0)', () => {
|
|
262
|
+
expect(fm.getOriginX()).toBe(0);
|
|
263
|
+
expect(fm.getOriginY()).toBe(0);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should update via setOrigin', () => {
|
|
267
|
+
fm.setOrigin(16, 32);
|
|
268
|
+
expect(fm.getOriginX()).toBe(16);
|
|
269
|
+
expect(fm.getOriginY()).toBe(32);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('getPixelDataForLayer', () => {
|
|
274
|
+
it('should return pixel data for a valid frame and layer', () => {
|
|
275
|
+
const frame = fm.addFrame();
|
|
276
|
+
const buf = new PixelBuffer(8, 8);
|
|
277
|
+
frame.pixelData.set('myLayer', buf);
|
|
278
|
+
expect(fm.getPixelDataForLayer(0, 'myLayer')).toBe(buf);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should return undefined for missing layer', () => {
|
|
282
|
+
fm.addFrame();
|
|
283
|
+
expect(fm.getPixelDataForLayer(0, 'nonexistent')).toBeUndefined();
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe('serialize / deserialize', () => {
|
|
288
|
+
it('should roundtrip frame state', () => {
|
|
289
|
+
const f0 = fm.addFrame();
|
|
290
|
+
const buf = new PixelBuffer(4, 4);
|
|
291
|
+
buf.setPixel(1, 1, 42, 43, 44, 255);
|
|
292
|
+
f0.pixelData.set('layer1', buf);
|
|
293
|
+
fm.addFrame();
|
|
294
|
+
fm.setCurrentFrame(1);
|
|
295
|
+
fm.setGlobalFps(24);
|
|
296
|
+
fm.setOrigin(8, 16);
|
|
297
|
+
fm.setFrameDuration(0, 150);
|
|
298
|
+
|
|
299
|
+
const serialized = fm.serialize();
|
|
300
|
+
fm._resetForTesting();
|
|
301
|
+
fm.deserialize(serialized);
|
|
302
|
+
|
|
303
|
+
expect(fm.getFrames()).toHaveLength(2);
|
|
304
|
+
expect(fm.getCurrentFrameIndex()).toBe(1);
|
|
305
|
+
expect(fm.getGlobalFps()).toBe(24);
|
|
306
|
+
expect(fm.getOriginX()).toBe(8);
|
|
307
|
+
expect(fm.getOriginY()).toBe(16);
|
|
308
|
+
expect(fm.getFrames()[0]?.durationMs).toBe(150);
|
|
309
|
+
|
|
310
|
+
const restored = fm.getPixelDataForLayer(0, 'layer1');
|
|
311
|
+
expect(restored).toBeDefined();
|
|
312
|
+
expect(restored?.getPixel(1, 1)).toEqual([42, 43, 44, 255]);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame Selection State -- tracks which frames are selected for bulk operations.
|
|
3
|
+
*
|
|
4
|
+
* Supports three selection modes:
|
|
5
|
+
* - Click: single select (clear others, set anchor)
|
|
6
|
+
* - Ctrl+Click: toggle individual frame in/out of selection
|
|
7
|
+
* - Shift+Click: extend range from anchor to clicked frame
|
|
8
|
+
*
|
|
9
|
+
* The "anchor" is the last frame clicked without Shift, used as the
|
|
10
|
+
* range start for Shift+Click.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Use SvelteSet for reactivity
|
|
14
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
15
|
+
|
|
16
|
+
const selected = new SvelteSet<number>();
|
|
17
|
+
let anchor = $state(0);
|
|
18
|
+
|
|
19
|
+
export const frameSelection = {
|
|
20
|
+
/** The set of selected frame indices. */
|
|
21
|
+
get selected(): ReadonlySet<number> { return selected; },
|
|
22
|
+
|
|
23
|
+
/** The anchor index (start of shift-click range). */
|
|
24
|
+
get anchor() { return anchor; },
|
|
25
|
+
|
|
26
|
+
/** Whether a specific frame index is selected. */
|
|
27
|
+
isSelected(index: number): boolean { return selected.has(index); },
|
|
28
|
+
|
|
29
|
+
/** Whether multiple frames are selected. */
|
|
30
|
+
get hasMultiple(): boolean { return selected.size > 1; },
|
|
31
|
+
|
|
32
|
+
/** Number of selected frames. */
|
|
33
|
+
get count(): number { return selected.size; },
|
|
34
|
+
|
|
35
|
+
/** Get selected indices sorted ascending. */
|
|
36
|
+
get sortedIndices(): number[] { return [...selected].sort((a, b) => a - b); },
|
|
37
|
+
|
|
38
|
+
/** Single click: clear selection, select one, set anchor. */
|
|
39
|
+
selectOne(index: number): void {
|
|
40
|
+
selected.clear();
|
|
41
|
+
selected.add(index);
|
|
42
|
+
anchor = index;
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
/** Ctrl+Click: toggle one frame without affecting others. Update anchor. */
|
|
46
|
+
toggle(index: number): void {
|
|
47
|
+
if (selected.has(index)) {
|
|
48
|
+
selected.delete(index);
|
|
49
|
+
} else {
|
|
50
|
+
selected.add(index);
|
|
51
|
+
}
|
|
52
|
+
anchor = index;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
/** Shift+Click: select range from anchor to index (inclusive). */
|
|
56
|
+
extendTo(index: number): void {
|
|
57
|
+
selected.clear();
|
|
58
|
+
const from = Math.min(anchor, index);
|
|
59
|
+
const to = Math.max(anchor, index);
|
|
60
|
+
for (let i = from; i <= to; i++) {
|
|
61
|
+
selected.add(i);
|
|
62
|
+
}
|
|
63
|
+
// Don't update anchor -- shift-click preserves the anchor
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/** Clear all selection. */
|
|
67
|
+
clear(): void {
|
|
68
|
+
selected.clear();
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
/** Adjust indices after frames are added/removed. Call after any frame mutation. */
|
|
72
|
+
// For simplicity, just clear selection on structural changes
|
|
73
|
+
reset(): void {
|
|
74
|
+
selected.clear();
|
|
75
|
+
anchor = 0;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame Tags -- named ranges of frames for organizing animations.
|
|
3
|
+
*
|
|
4
|
+
* Tags can be nested (parentId) to represent sub-animations within
|
|
5
|
+
* larger sequences. Uses Svelte 5 $state runes.
|
|
6
|
+
*
|
|
7
|
+
* Module-level singleton -- one tag state per app instance.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// --- Types ---
|
|
11
|
+
|
|
12
|
+
export interface FrameTag {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
color: string;
|
|
16
|
+
startFrame: number; // inclusive
|
|
17
|
+
endFrame: number; // inclusive
|
|
18
|
+
parentId: string | null;
|
|
19
|
+
children: FrameTag[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SerializedTag {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
color: string;
|
|
26
|
+
startFrame: number;
|
|
27
|
+
endFrame: number;
|
|
28
|
+
parentId: string | null;
|
|
29
|
+
children: SerializedTag[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// --- Reactive state ---
|
|
33
|
+
|
|
34
|
+
let tags = $state<FrameTag[]>([]);
|
|
35
|
+
|
|
36
|
+
// --- Internal helpers ---
|
|
37
|
+
|
|
38
|
+
/** Default tag colors, cycled through on creation. */
|
|
39
|
+
const TAG_COLORS = [
|
|
40
|
+
'#4A90D9', '#D94A4A', '#4AD96B', '#D9C74A',
|
|
41
|
+
'#9B59B6', '#E67E22', '#1ABC9C', '#E74C3C',
|
|
42
|
+
];
|
|
43
|
+
let colorIndex = 0;
|
|
44
|
+
|
|
45
|
+
function nextColor(): string {
|
|
46
|
+
// TAG_COLORS is non-empty so the modulo lookup is always defined.
|
|
47
|
+
const color = TAG_COLORS[colorIndex % TAG_COLORS.length] ?? '#4A90D9';
|
|
48
|
+
colorIndex++;
|
|
49
|
+
return color;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Find a tag by ID in the entire tree. */
|
|
53
|
+
function findTag(id: string, searchIn: FrameTag[] = tags): FrameTag | undefined {
|
|
54
|
+
for (const tag of searchIn) {
|
|
55
|
+
if (tag.id === id) return tag;
|
|
56
|
+
const found = findTag(id, tag.children);
|
|
57
|
+
if (found) return found;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Find a tag and its parent array by ID. */
|
|
63
|
+
function findTagContext(
|
|
64
|
+
id: string,
|
|
65
|
+
searchIn: FrameTag[] = tags,
|
|
66
|
+
): { tag: FrameTag; siblings: FrameTag[]; index: number } | undefined {
|
|
67
|
+
for (let i = 0; i < searchIn.length; i++) {
|
|
68
|
+
const current = searchIn[i];
|
|
69
|
+
if (!current) continue;
|
|
70
|
+
if (current.id === id) {
|
|
71
|
+
return { tag: current, siblings: searchIn, index: i };
|
|
72
|
+
}
|
|
73
|
+
const found = findTagContext(id, current.children);
|
|
74
|
+
if (found) return found;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- Queries ---
|
|
80
|
+
|
|
81
|
+
export function getTags(): FrameTag[] {
|
|
82
|
+
return tags;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function getTag(id: string): FrameTag | undefined {
|
|
86
|
+
return findTag(id);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Get all tags that span a given frame index. */
|
|
90
|
+
export function getTagsForFrame(frameIndex: number): FrameTag[] {
|
|
91
|
+
const result: FrameTag[] = [];
|
|
92
|
+
|
|
93
|
+
function walk(searchIn: FrameTag[]): void {
|
|
94
|
+
for (const tag of searchIn) {
|
|
95
|
+
if (frameIndex >= tag.startFrame && frameIndex <= tag.endFrame) {
|
|
96
|
+
result.push(tag);
|
|
97
|
+
}
|
|
98
|
+
walk(tag.children);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
walk(tags);
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get the full frame range for a tag, including its nested children.
|
|
108
|
+
* Returns the min start and max end across the tag and all descendants.
|
|
109
|
+
*/
|
|
110
|
+
export function getFrameRange(tagId: string): { start: number; end: number } {
|
|
111
|
+
const tag = findTag(tagId);
|
|
112
|
+
if (!tag) {
|
|
113
|
+
throw new Error(`Tag "${tagId}" not found.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let start = tag.startFrame;
|
|
117
|
+
let end = tag.endFrame;
|
|
118
|
+
|
|
119
|
+
function expandRange(t: FrameTag): void {
|
|
120
|
+
start = Math.min(start, t.startFrame);
|
|
121
|
+
end = Math.max(end, t.endFrame);
|
|
122
|
+
for (const child of t.children) {
|
|
123
|
+
expandRange(child);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
expandRange(tag);
|
|
128
|
+
return { start, end };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- Mutations ---
|
|
132
|
+
|
|
133
|
+
export function addTag(
|
|
134
|
+
name: string,
|
|
135
|
+
startFrame: number,
|
|
136
|
+
endFrame: number,
|
|
137
|
+
options?: { color?: string; parentId?: string },
|
|
138
|
+
): FrameTag {
|
|
139
|
+
if (startFrame > endFrame) {
|
|
140
|
+
throw new Error(`startFrame (${String(startFrame)}) must be <= endFrame (${String(endFrame)}).`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const tag: FrameTag = {
|
|
144
|
+
id: crypto.randomUUID(),
|
|
145
|
+
name,
|
|
146
|
+
color: options?.color ?? nextColor(),
|
|
147
|
+
startFrame,
|
|
148
|
+
endFrame,
|
|
149
|
+
parentId: options?.parentId ?? null,
|
|
150
|
+
children: [],
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (options?.parentId) {
|
|
154
|
+
const parent = findTag(options.parentId);
|
|
155
|
+
if (!parent) {
|
|
156
|
+
throw new Error(`Parent tag "${options.parentId}" not found.`);
|
|
157
|
+
}
|
|
158
|
+
tag.parentId = parent.id;
|
|
159
|
+
parent.children.push(tag);
|
|
160
|
+
} else {
|
|
161
|
+
tags.push(tag);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Return the proxy version from the tree (just inserted above).
|
|
165
|
+
const inserted = findTag(tag.id);
|
|
166
|
+
if (!inserted) throw new Error(`addTag: inserted tag "${tag.id}" not found`);
|
|
167
|
+
return inserted;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function removeTag(id: string): void {
|
|
171
|
+
const ctx = findTagContext(id);
|
|
172
|
+
if (!ctx) {
|
|
173
|
+
throw new Error(`Tag "${id}" not found.`);
|
|
174
|
+
}
|
|
175
|
+
ctx.siblings.splice(ctx.index, 1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function updateTag(
|
|
179
|
+
id: string,
|
|
180
|
+
updates: Partial<{ name: string; color: string; startFrame: number; endFrame: number }>,
|
|
181
|
+
): void {
|
|
182
|
+
const tag = findTag(id);
|
|
183
|
+
if (!tag) {
|
|
184
|
+
throw new Error(`Tag "${id}" not found.`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (updates.name !== undefined) tag.name = updates.name;
|
|
188
|
+
if (updates.color !== undefined) tag.color = updates.color;
|
|
189
|
+
if (updates.startFrame !== undefined) tag.startFrame = updates.startFrame;
|
|
190
|
+
if (updates.endFrame !== undefined) tag.endFrame = updates.endFrame;
|
|
191
|
+
|
|
192
|
+
// Validate after update
|
|
193
|
+
if (tag.startFrame > tag.endFrame) {
|
|
194
|
+
throw new Error(`startFrame (${String(tag.startFrame)}) must be <= endFrame (${String(tag.endFrame)}).`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- Serialization ---
|
|
199
|
+
|
|
200
|
+
function serializeTag(tag: FrameTag): SerializedTag {
|
|
201
|
+
return {
|
|
202
|
+
id: tag.id,
|
|
203
|
+
name: tag.name,
|
|
204
|
+
color: tag.color,
|
|
205
|
+
startFrame: tag.startFrame,
|
|
206
|
+
endFrame: tag.endFrame,
|
|
207
|
+
parentId: tag.parentId,
|
|
208
|
+
children: tag.children.map(serializeTag),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function deserializeTag(data: SerializedTag): FrameTag {
|
|
213
|
+
return {
|
|
214
|
+
id: data.id,
|
|
215
|
+
name: data.name,
|
|
216
|
+
color: data.color,
|
|
217
|
+
startFrame: data.startFrame,
|
|
218
|
+
endFrame: data.endFrame,
|
|
219
|
+
parentId: data.parentId,
|
|
220
|
+
children: data.children.map(deserializeTag),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function serialize(): { tags: SerializedTag[] } {
|
|
225
|
+
return { tags: tags.map(serializeTag) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function deserialize(data: { tags: SerializedTag[] }): void {
|
|
229
|
+
tags = data.tags.map(deserializeTag);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Reset all tag state. Intended for tests only.
|
|
234
|
+
*/
|
|
235
|
+
export function _resetForTesting(): void {
|
|
236
|
+
tags = [];
|
|
237
|
+
colorIndex = 0;
|
|
238
|
+
}
|