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,854 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* FrameStrip -- horizontal animation timeline below the canvas.
|
|
4
|
+
*
|
|
5
|
+
* Shows one thumbnail per frame, frame tags as colored bars, action
|
|
6
|
+
* buttons for frame management, and a global FPS control. Supports
|
|
7
|
+
* drag-to-reorder via pointer events.
|
|
8
|
+
*
|
|
9
|
+
* Thumbnails are composited from visible layers using the compositor
|
|
10
|
+
* and cached in a Map keyed by frame ID + a generation counter.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { CommandType, ParamsOf } from '../core/command-params.js';
|
|
14
|
+
import { dispatch } from '../core/dispatcher.js';
|
|
15
|
+
import ContextMenu from './ContextMenu.svelte';
|
|
16
|
+
import {
|
|
17
|
+
getFrames,
|
|
18
|
+
getCurrentFrameIndex,
|
|
19
|
+
setCurrentFrame,
|
|
20
|
+
getGlobalFps,
|
|
21
|
+
getFrameDuration,
|
|
22
|
+
} from '../animation/frame-model.svelte.js';
|
|
23
|
+
import { getTags } from '../animation/frame-tags.svelte.js';
|
|
24
|
+
import { getLayers } from '../layers/layer-tree.svelte.js';
|
|
25
|
+
import { composite } from '../layers/compositor.js';
|
|
26
|
+
import { canvasState } from '../canvas/canvas-state.svelte.js';
|
|
27
|
+
import { frameSelection } from '../animation/frame-selection.svelte.js';
|
|
28
|
+
import type { FrameTag } from '../animation/frame-tags.svelte.js';
|
|
29
|
+
import PlusIcon from '~icons/lucide/plus';
|
|
30
|
+
import TrashIcon from '~icons/lucide/trash-2';
|
|
31
|
+
|
|
32
|
+
// --- Constants ---
|
|
33
|
+
|
|
34
|
+
/** Maximum thumbnail dimension in pixels. */
|
|
35
|
+
const THUMB_SIZE = 48;
|
|
36
|
+
|
|
37
|
+
// --- Command dispatch helper ---
|
|
38
|
+
|
|
39
|
+
// Typed overload: compile-time param validation for known commands
|
|
40
|
+
function dispatchCmd<T extends CommandType>(type: T, params: ParamsOf<T>): void;
|
|
41
|
+
// String fallback: for dynamic dispatch where command type is a variable
|
|
42
|
+
function dispatchCmd(type: string, params?: Record<string, unknown>): void;
|
|
43
|
+
function dispatchCmd(type: string, params: Record<string, unknown> = {}) {
|
|
44
|
+
dispatch({
|
|
45
|
+
type,
|
|
46
|
+
plugin: 'ui/animation',
|
|
47
|
+
version: '1.0.0',
|
|
48
|
+
params,
|
|
49
|
+
id: crypto.randomUUID(),
|
|
50
|
+
timestamp: Date.now(),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- Thumbnail cache ---
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Cached thumbnail canvases keyed by frame ID. Each entry stores an
|
|
58
|
+
* offscreen canvas with the composited preview already drawn on it.
|
|
59
|
+
* A generation counter tracks staleness -- when the frame's pixel data
|
|
60
|
+
* changes, the generation in the frame model will differ from the cached
|
|
61
|
+
* one, triggering a re-composite.
|
|
62
|
+
*/
|
|
63
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- render cache, not reactive
|
|
64
|
+
let thumbCache = new Map<string, { canvas: OffscreenCanvas; gen: number }>();
|
|
65
|
+
|
|
66
|
+
/** Global generation counter; bumped whenever we detect state changes. */
|
|
67
|
+
let generation = $state(0);
|
|
68
|
+
|
|
69
|
+
// --- Thumbnail dimensions (proportionally scaled to fit THUMB_SIZE) ---
|
|
70
|
+
|
|
71
|
+
let thumbWidth = $derived((() => {
|
|
72
|
+
const w = canvasState.canvasWidth;
|
|
73
|
+
const h = canvasState.canvasHeight;
|
|
74
|
+
if (w >= h) return THUMB_SIZE;
|
|
75
|
+
return Math.max(1, Math.round((w / h) * THUMB_SIZE));
|
|
76
|
+
})());
|
|
77
|
+
|
|
78
|
+
let thumbHeight = $derived((() => {
|
|
79
|
+
const w = canvasState.canvasWidth;
|
|
80
|
+
const h = canvasState.canvasHeight;
|
|
81
|
+
if (h >= w) return THUMB_SIZE;
|
|
82
|
+
return Math.max(1, Math.round((h / w) * THUMB_SIZE));
|
|
83
|
+
})());
|
|
84
|
+
|
|
85
|
+
// --- Derived state ---
|
|
86
|
+
|
|
87
|
+
let frames = $derived(getFrames());
|
|
88
|
+
let currentIndex = $derived(getCurrentFrameIndex());
|
|
89
|
+
let globalFps = $derived(getGlobalFps());
|
|
90
|
+
let tags = $derived(getTags());
|
|
91
|
+
|
|
92
|
+
// --- Canvas element refs for each frame thumbnail ---
|
|
93
|
+
|
|
94
|
+
let canvasEls: (HTMLCanvasElement | null)[] = $state([]);
|
|
95
|
+
|
|
96
|
+
// --- Drag-to-reorder state ---
|
|
97
|
+
|
|
98
|
+
let dragFromIndex = $state<number | null>(null);
|
|
99
|
+
let dragOverIndex = $state<number | null>(null);
|
|
100
|
+
let dragInsertSide = $state<'left' | 'right' | null>(null);
|
|
101
|
+
|
|
102
|
+
// --- Inline duration editing state ---
|
|
103
|
+
|
|
104
|
+
/** Index of the frame currently being edited for duration, or null if none. */
|
|
105
|
+
let editingDurationIndex = $state<number | null>(null);
|
|
106
|
+
/** Current value in the duration input field during editing. */
|
|
107
|
+
let editDurationValue = $state('');
|
|
108
|
+
|
|
109
|
+
// --- FPS input state ---
|
|
110
|
+
|
|
111
|
+
// Writable-but-derived: user typing diverges from globalFps until commit.
|
|
112
|
+
// eslint-disable-next-line svelte/prefer-writable-derived -- diverges during user input
|
|
113
|
+
let fpsInputValue = $state(String(getGlobalFps()));
|
|
114
|
+
|
|
115
|
+
// Keep fpsInputValue in sync when globalFps changes externally
|
|
116
|
+
$effect(() => {
|
|
117
|
+
fpsInputValue = String(globalFps);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// --- Tag helpers ---
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build a list of top-level tag bars to display above the frame strip.
|
|
124
|
+
* Each bar spans from tag.startFrame to tag.endFrame.
|
|
125
|
+
*/
|
|
126
|
+
function getVisibleTags(): FrameTag[] {
|
|
127
|
+
// Flatten the tag tree to get all tags (including nested)
|
|
128
|
+
const result: FrameTag[] = [];
|
|
129
|
+
function walk(list: FrameTag[]) {
|
|
130
|
+
for (const tag of list) {
|
|
131
|
+
result.push(tag);
|
|
132
|
+
if (tag.children.length > 0) walk(tag.children);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
walk(tags);
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let visibleTags = $derived(getVisibleTags());
|
|
140
|
+
|
|
141
|
+
// --- Thumbnail rendering ---
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Render a single frame's thumbnail onto its canvas element.
|
|
145
|
+
* Composites all visible layers for that frame, scales the result
|
|
146
|
+
* down to thumbnail size, and draws it.
|
|
147
|
+
*/
|
|
148
|
+
function renderThumbnail(frameIndex: number, canvasEl: HTMLCanvasElement) {
|
|
149
|
+
const frame = frames[frameIndex];
|
|
150
|
+
if (!frame) return;
|
|
151
|
+
|
|
152
|
+
const w = canvasState.canvasWidth;
|
|
153
|
+
const h = canvasState.canvasHeight;
|
|
154
|
+
const layerTree = getLayers();
|
|
155
|
+
|
|
156
|
+
// Composite all visible layers for this frame
|
|
157
|
+
const composited = composite(layerTree, frame.pixelData, w, h);
|
|
158
|
+
|
|
159
|
+
// Create ImageData from the composited buffer
|
|
160
|
+
const imgData = new ImageData(
|
|
161
|
+
new Uint8ClampedArray(composited.data),
|
|
162
|
+
w,
|
|
163
|
+
h,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Use an offscreen canvas to scale the composite down to thumbnail size
|
|
167
|
+
const offscreen = new OffscreenCanvas(w, h);
|
|
168
|
+
const offCtx = offscreen.getContext('2d');
|
|
169
|
+
if (!offCtx) return;
|
|
170
|
+
offCtx.putImageData(imgData, 0, 0);
|
|
171
|
+
|
|
172
|
+
// Draw scaled onto the visible canvas
|
|
173
|
+
canvasEl.width = thumbWidth;
|
|
174
|
+
canvasEl.height = thumbHeight;
|
|
175
|
+
const ctx = canvasEl.getContext('2d');
|
|
176
|
+
if (!ctx) return;
|
|
177
|
+
ctx.imageSmoothingEnabled = false;
|
|
178
|
+
ctx.clearRect(0, 0, thumbWidth, thumbHeight);
|
|
179
|
+
ctx.drawImage(offscreen, 0, 0, thumbWidth, thumbHeight);
|
|
180
|
+
|
|
181
|
+
// Update cache
|
|
182
|
+
thumbCache.set(frame.id, {
|
|
183
|
+
canvas: offscreen,
|
|
184
|
+
gen: generation,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Re-render all thumbnails. Called from $effect when frames, layers,
|
|
190
|
+
* or canvas dimensions change.
|
|
191
|
+
*/
|
|
192
|
+
$effect(() => {
|
|
193
|
+
// Read reactive dependencies to trigger re-runs:
|
|
194
|
+
// frame list, current index, layer tree, canvas size, and generation
|
|
195
|
+
const _frames = frames;
|
|
196
|
+
const _currentIdx = currentIndex;
|
|
197
|
+
const _layers = getLayers();
|
|
198
|
+
const _w = canvasState.canvasWidth;
|
|
199
|
+
const _h = canvasState.canvasHeight;
|
|
200
|
+
const _gen = generation;
|
|
201
|
+
|
|
202
|
+
// Render each frame thumbnail
|
|
203
|
+
for (let i = 0; i < _frames.length; i++) {
|
|
204
|
+
const el = canvasEls[i];
|
|
205
|
+
if (el) {
|
|
206
|
+
renderThumbnail(i, el);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// --- Sync selection with current frame ---
|
|
212
|
+
|
|
213
|
+
// If nothing is selected (e.g. on init or after reset), select current frame
|
|
214
|
+
$effect(() => {
|
|
215
|
+
if (frameSelection.count === 0) {
|
|
216
|
+
frameSelection.selectOne(getCurrentFrameIndex());
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// --- Actions ---
|
|
221
|
+
|
|
222
|
+
function handleSelectFrame(index: number, e: PointerEvent | MouseEvent) {
|
|
223
|
+
if (e.shiftKey) {
|
|
224
|
+
frameSelection.extendTo(index);
|
|
225
|
+
} else if (e.ctrlKey || e.metaKey) {
|
|
226
|
+
frameSelection.toggle(index);
|
|
227
|
+
} else {
|
|
228
|
+
frameSelection.selectOne(index);
|
|
229
|
+
}
|
|
230
|
+
setCurrentFrame(index);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function handleAddFrame() {
|
|
234
|
+
dispatchCmd('add_frame', { afterIndex: currentIndex });
|
|
235
|
+
frameSelection.reset();
|
|
236
|
+
generation++;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function handleDuplicateFrame() {
|
|
240
|
+
dispatchCmd('duplicate_frame', { index: currentIndex });
|
|
241
|
+
frameSelection.reset();
|
|
242
|
+
generation++;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function handleDeleteFrame() {
|
|
246
|
+
if (frames.length <= 1) return;
|
|
247
|
+
dispatchCmd('delete_selected_frames', {});
|
|
248
|
+
generation++;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function handleFpsChange() {
|
|
252
|
+
const value = parseInt(fpsInputValue, 10);
|
|
253
|
+
if (!isNaN(value) && value >= 1 && value <= 120) {
|
|
254
|
+
dispatchCmd('set_global_fps', { fps: value });
|
|
255
|
+
} else {
|
|
256
|
+
// Reset to current value if invalid
|
|
257
|
+
fpsInputValue = String(globalFps);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function handleFpsKeydown(e: KeyboardEvent) {
|
|
262
|
+
if (e.key === 'Enter') {
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
handleFpsChange();
|
|
265
|
+
(e.target as HTMLInputElement).blur();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// --- Drag-to-reorder handlers ---
|
|
270
|
+
|
|
271
|
+
function handleDragPointerDown(e: PointerEvent, index: number) {
|
|
272
|
+
if (e.button !== 0) return;
|
|
273
|
+
dragFromIndex = index;
|
|
274
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function handleDragPointerMove(e: PointerEvent, index: number) {
|
|
278
|
+
if (dragFromIndex === null) return;
|
|
279
|
+
if (dragFromIndex === index) {
|
|
280
|
+
// Moving within the same frame -- check if cursor left the element
|
|
281
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
282
|
+
if (e.clientX < rect.left || e.clientX > rect.right) {
|
|
283
|
+
dragOverIndex = index;
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
288
|
+
const midX = rect.left + rect.width / 2;
|
|
289
|
+
dragOverIndex = index;
|
|
290
|
+
dragInsertSide = e.clientX < midX ? 'left' : 'right';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function handleDragPointerUp() {
|
|
294
|
+
if (dragFromIndex !== null && dragOverIndex !== null && dragInsertSide !== null) {
|
|
295
|
+
let toIndex = dragOverIndex;
|
|
296
|
+
if (dragInsertSide === 'right') {
|
|
297
|
+
toIndex = dragOverIndex + 1;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Bulk move: when multiple frames are selected and the dragged frame
|
|
301
|
+
// is part of that selection, move the entire selection as a group.
|
|
302
|
+
if (frameSelection.hasMultiple && frameSelection.isSelected(dragFromIndex)) {
|
|
303
|
+
const fromIndices = frameSelection.sortedIndices;
|
|
304
|
+
// Skip if the drop target is already within the selected range
|
|
305
|
+
// and wouldn't result in any movement
|
|
306
|
+
const minSel = fromIndices[0]!;
|
|
307
|
+
const maxSel = fromIndices[fromIndices.length - 1]!;
|
|
308
|
+
if (toIndex < minSel || toIndex > maxSel + 1) {
|
|
309
|
+
dispatchCmd('reorder_frames', {
|
|
310
|
+
fromIndices,
|
|
311
|
+
toIndex,
|
|
312
|
+
});
|
|
313
|
+
frameSelection.reset();
|
|
314
|
+
generation++;
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
// Single frame reorder (original behavior)
|
|
318
|
+
// Adjust for removal shift: if dragging from before the target,
|
|
319
|
+
// the removal shifts target indices down
|
|
320
|
+
if (dragFromIndex < toIndex) {
|
|
321
|
+
toIndex -= 1;
|
|
322
|
+
}
|
|
323
|
+
if (dragFromIndex !== toIndex && toIndex >= 0 && toIndex < frames.length) {
|
|
324
|
+
dispatchCmd('reorder_frame', {
|
|
325
|
+
fromIndex: dragFromIndex,
|
|
326
|
+
toIndex,
|
|
327
|
+
});
|
|
328
|
+
frameSelection.reset();
|
|
329
|
+
generation++;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
dragFromIndex = null;
|
|
334
|
+
dragOverIndex = null;
|
|
335
|
+
dragInsertSide = null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Check whether a frame has a custom (per-frame) duration override.
|
|
340
|
+
*/
|
|
341
|
+
function hasCustomDuration(index: number): boolean {
|
|
342
|
+
const frame = frames[index];
|
|
343
|
+
return frame != null && frame.durationMs !== null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Get the display duration string for a frame (always shown).
|
|
348
|
+
* Uses per-frame override if set, otherwise derives from global FPS.
|
|
349
|
+
*/
|
|
350
|
+
function displayDuration(index: number): string {
|
|
351
|
+
return `${String(getFrameDuration(index))}ms`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// --- Inline duration editing handlers ---
|
|
355
|
+
|
|
356
|
+
function startDurationEdit(index: number) {
|
|
357
|
+
editingDurationIndex = index;
|
|
358
|
+
editDurationValue = String(getFrameDuration(index));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function commitDurationEdit(index: number) {
|
|
362
|
+
editingDurationIndex = null;
|
|
363
|
+
const value = parseInt(editDurationValue, 10);
|
|
364
|
+
const globalDefault = Math.round(1000 / globalFps);
|
|
365
|
+
|
|
366
|
+
if (isNaN(value) || value <= 0) {
|
|
367
|
+
// Invalid input -- discard, no change
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (value === globalDefault) {
|
|
372
|
+
// Matches global default -- reset to null (remove per-frame override)
|
|
373
|
+
dispatchCmd('set_frame_duration', { index, durationMs: null });
|
|
374
|
+
} else {
|
|
375
|
+
dispatchCmd('set_frame_duration', { index, durationMs: value });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function cancelDurationEdit() {
|
|
380
|
+
editingDurationIndex = null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function handleDurationKeydown(e: KeyboardEvent, index: number) {
|
|
384
|
+
if (e.key === 'Enter') {
|
|
385
|
+
e.preventDefault();
|
|
386
|
+
commitDurationEdit(index);
|
|
387
|
+
} else if (e.key === 'Escape') {
|
|
388
|
+
e.preventDefault();
|
|
389
|
+
cancelDurationEdit();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// --- Svelte action: auto-focus and select input contents on mount ---
|
|
394
|
+
|
|
395
|
+
function autoFocusSelect(node: HTMLInputElement) {
|
|
396
|
+
// Use a microtask to ensure the DOM is settled before focusing
|
|
397
|
+
queueMicrotask(() => {
|
|
398
|
+
node.focus();
|
|
399
|
+
node.select();
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// --- Context menu state ---
|
|
404
|
+
let contextMenu = $state<{ x: number; y: number } | null>(null);
|
|
405
|
+
|
|
406
|
+
function handleContextMenu(e: MouseEvent) {
|
|
407
|
+
e.preventDefault();
|
|
408
|
+
contextMenu = { x: e.clientX, y: e.clientY };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// --- Keyboard shortcuts (active when frame strip has focus) ---
|
|
412
|
+
|
|
413
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
414
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
415
|
+
|
|
416
|
+
if (mod && e.key === 'c') {
|
|
417
|
+
e.preventDefault();
|
|
418
|
+
dispatchCmd('copy_frame', {});
|
|
419
|
+
} else if (mod && e.key === 'v') {
|
|
420
|
+
e.preventDefault();
|
|
421
|
+
dispatchCmd('paste_frame', {});
|
|
422
|
+
} else if (e.key === 'Delete') {
|
|
423
|
+
e.preventDefault();
|
|
424
|
+
if (frames.length > 1) {
|
|
425
|
+
dispatchCmd('delete_selected_frames', {});
|
|
426
|
+
generation++;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
</script>
|
|
431
|
+
|
|
432
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
433
|
+
<div
|
|
434
|
+
class="frame-strip"
|
|
435
|
+
tabindex="0"
|
|
436
|
+
oncontextmenu={handleContextMenu}
|
|
437
|
+
onkeydown={handleKeyDown}
|
|
438
|
+
>
|
|
439
|
+
<!-- Tag bars row -->
|
|
440
|
+
{#if visibleTags.length > 0}
|
|
441
|
+
<div class="tag-row">
|
|
442
|
+
{#each visibleTags as tag (tag.id)}
|
|
443
|
+
{@const startPx = tag.startFrame * (thumbWidth + 4)}
|
|
444
|
+
{@const spanPx = (tag.endFrame - tag.startFrame + 1) * (thumbWidth + 4) - 4}
|
|
445
|
+
<div
|
|
446
|
+
class="tag-bar"
|
|
447
|
+
style="left: {startPx}px; width: {Math.max(spanPx, 4)}px; background: {tag.color};"
|
|
448
|
+
title="{tag.name} (frames {tag.startFrame + 1}-{tag.endFrame + 1})"
|
|
449
|
+
>
|
|
450
|
+
<span class="tag-label">{tag.name}</span>
|
|
451
|
+
</div>
|
|
452
|
+
{/each}
|
|
453
|
+
</div>
|
|
454
|
+
{/if}
|
|
455
|
+
|
|
456
|
+
<!-- Main strip: scrollable frames + fixed controls -->
|
|
457
|
+
<div class="strip-body">
|
|
458
|
+
<!-- Scrollable frame list -->
|
|
459
|
+
<div class="frame-scroll" role="listbox" aria-label="Animation frames">
|
|
460
|
+
{#each frames as frame, i (frame.id)}
|
|
461
|
+
<div
|
|
462
|
+
class="frame-cell"
|
|
463
|
+
class:frame-cell--active={i === currentIndex}
|
|
464
|
+
class:frame-cell--selected={frameSelection.isSelected(i)}
|
|
465
|
+
class:frame-cell--drag-left={dragOverIndex === i && dragInsertSide === 'left'}
|
|
466
|
+
class:frame-cell--drag-right={dragOverIndex === i && dragInsertSide === 'right'}
|
|
467
|
+
class:frame-cell--dragging={dragFromIndex === i}
|
|
468
|
+
role="option"
|
|
469
|
+
aria-selected={i === currentIndex}
|
|
470
|
+
tabindex="-1"
|
|
471
|
+
onclick={(e) => { handleSelectFrame(i, e); }}
|
|
472
|
+
onpointerdown={(e) => { handleDragPointerDown(e, i); }}
|
|
473
|
+
onpointermove={(e) => { handleDragPointerMove(e, i); }}
|
|
474
|
+
onpointerup={handleDragPointerUp}
|
|
475
|
+
>
|
|
476
|
+
<canvas
|
|
477
|
+
bind:this={canvasEls[i]}
|
|
478
|
+
class="frame-thumb"
|
|
479
|
+
width={thumbWidth}
|
|
480
|
+
height={thumbHeight}
|
|
481
|
+
></canvas>
|
|
482
|
+
<span class="frame-number">{i + 1}</span>
|
|
483
|
+
{#if editingDurationIndex === i}
|
|
484
|
+
<!-- Inline duration editor: input + "ms" suffix -->
|
|
485
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
486
|
+
<span
|
|
487
|
+
class="duration-editor"
|
|
488
|
+
onclick={(e) => { e.stopPropagation(); }}
|
|
489
|
+
onpointerdown={(e) => { e.stopPropagation(); }}
|
|
490
|
+
>
|
|
491
|
+
<input
|
|
492
|
+
class="duration-input"
|
|
493
|
+
type="number"
|
|
494
|
+
min="1"
|
|
495
|
+
bind:value={editDurationValue}
|
|
496
|
+
onblur={() => { commitDurationEdit(i); }}
|
|
497
|
+
onkeydown={(e) => { handleDurationKeydown(e, i); }}
|
|
498
|
+
use:autoFocusSelect
|
|
499
|
+
/>
|
|
500
|
+
<span class="duration-suffix">ms</span>
|
|
501
|
+
</span>
|
|
502
|
+
{:else}
|
|
503
|
+
<!-- Duration display: always shown, styled differently for custom overrides -->
|
|
504
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
505
|
+
<span
|
|
506
|
+
class="frame-duration"
|
|
507
|
+
class:frame-duration--custom={hasCustomDuration(i)}
|
|
508
|
+
ondblclick={(e) => { e.stopPropagation(); startDurationEdit(i); }}
|
|
509
|
+
>{displayDuration(i)}</span>
|
|
510
|
+
{/if}
|
|
511
|
+
</div>
|
|
512
|
+
{/each}
|
|
513
|
+
</div>
|
|
514
|
+
|
|
515
|
+
<!-- Fixed controls -->
|
|
516
|
+
<div class="strip-controls">
|
|
517
|
+
<div class="frame-actions">
|
|
518
|
+
<button
|
|
519
|
+
class="action-btn"
|
|
520
|
+
title="Add Frame"
|
|
521
|
+
aria-label="Add Frame"
|
|
522
|
+
onclick={handleAddFrame}
|
|
523
|
+
><PlusIcon /></button>
|
|
524
|
+
<button
|
|
525
|
+
class="action-btn"
|
|
526
|
+
title="Duplicate Frame"
|
|
527
|
+
aria-label="Duplicate Frame"
|
|
528
|
+
onclick={handleDuplicateFrame}
|
|
529
|
+
>
|
|
530
|
+
<svg class="icon" viewBox="0 0 16 16" width="14" height="14">
|
|
531
|
+
<rect x="1" y="3" width="10" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/>
|
|
532
|
+
<rect x="5" y="1" width="10" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.2"/>
|
|
533
|
+
</svg>
|
|
534
|
+
</button>
|
|
535
|
+
<button
|
|
536
|
+
class="action-btn"
|
|
537
|
+
title="Delete Frame"
|
|
538
|
+
aria-label="Delete Frame"
|
|
539
|
+
onclick={handleDeleteFrame}
|
|
540
|
+
disabled={frames.length <= 1}
|
|
541
|
+
><TrashIcon /></button>
|
|
542
|
+
</div>
|
|
543
|
+
<div class="fps-control">
|
|
544
|
+
<label class="fps-label" for="fps-input">FPS</label>
|
|
545
|
+
<input
|
|
546
|
+
id="fps-input"
|
|
547
|
+
class="fps-input"
|
|
548
|
+
type="number"
|
|
549
|
+
min="1"
|
|
550
|
+
max="120"
|
|
551
|
+
bind:value={fpsInputValue}
|
|
552
|
+
onblur={handleFpsChange}
|
|
553
|
+
onkeydown={handleFpsKeydown}
|
|
554
|
+
/>
|
|
555
|
+
</div>
|
|
556
|
+
</div>
|
|
557
|
+
</div>
|
|
558
|
+
</div>
|
|
559
|
+
|
|
560
|
+
{#if contextMenu}
|
|
561
|
+
<ContextMenu
|
|
562
|
+
menuPath="context/frame"
|
|
563
|
+
x={contextMenu.x}
|
|
564
|
+
y={contextMenu.y}
|
|
565
|
+
onClose={() => contextMenu = null}
|
|
566
|
+
/>
|
|
567
|
+
{/if}
|
|
568
|
+
|
|
569
|
+
<style>
|
|
570
|
+
.frame-strip {
|
|
571
|
+
display: flex;
|
|
572
|
+
flex-direction: column;
|
|
573
|
+
width: 100%;
|
|
574
|
+
background: var(--bg-toolbar);
|
|
575
|
+
border-top: 1px solid var(--border);
|
|
576
|
+
user-select: none;
|
|
577
|
+
font-size: var(--text-sm);
|
|
578
|
+
color: var(--text-primary);
|
|
579
|
+
outline: none;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/* --- Tag row --- */
|
|
583
|
+
|
|
584
|
+
.tag-row {
|
|
585
|
+
position: relative;
|
|
586
|
+
height: 14px;
|
|
587
|
+
flex-shrink: 0;
|
|
588
|
+
overflow: hidden;
|
|
589
|
+
margin: var(--space-1) 0 0 0;
|
|
590
|
+
padding-left: var(--space-2);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
.tag-bar {
|
|
594
|
+
position: absolute;
|
|
595
|
+
top: 0;
|
|
596
|
+
height: 12px;
|
|
597
|
+
border-radius: var(--radius-sm);
|
|
598
|
+
display: flex;
|
|
599
|
+
align-items: center;
|
|
600
|
+
overflow: hidden;
|
|
601
|
+
opacity: 0.75;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
.tag-label {
|
|
605
|
+
font-size: 9px;
|
|
606
|
+
font-weight: 600;
|
|
607
|
+
color: #fff;
|
|
608
|
+
padding: 0 var(--space-2);
|
|
609
|
+
white-space: nowrap;
|
|
610
|
+
overflow: hidden;
|
|
611
|
+
text-overflow: ellipsis;
|
|
612
|
+
text-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/* --- Main body: scrollable frames + fixed controls --- */
|
|
616
|
+
|
|
617
|
+
.strip-body {
|
|
618
|
+
display: flex;
|
|
619
|
+
align-items: stretch;
|
|
620
|
+
height: 82px;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
.frame-scroll {
|
|
624
|
+
display: flex;
|
|
625
|
+
align-items: center;
|
|
626
|
+
gap: var(--space-2);
|
|
627
|
+
padding: var(--space-2);
|
|
628
|
+
overflow-x: auto;
|
|
629
|
+
overflow-y: hidden;
|
|
630
|
+
flex: 1;
|
|
631
|
+
min-width: 0;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/* --- Individual frame cell --- */
|
|
635
|
+
|
|
636
|
+
.frame-cell {
|
|
637
|
+
display: flex;
|
|
638
|
+
flex-direction: column;
|
|
639
|
+
align-items: center;
|
|
640
|
+
justify-content: center;
|
|
641
|
+
gap: var(--space-1);
|
|
642
|
+
padding: 3px;
|
|
643
|
+
border: 2px solid transparent;
|
|
644
|
+
border-radius: var(--radius-md);
|
|
645
|
+
cursor: pointer;
|
|
646
|
+
flex-shrink: 0;
|
|
647
|
+
position: relative;
|
|
648
|
+
transition: border-color var(--transition-fast);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
.frame-cell:hover {
|
|
652
|
+
background: rgba(255, 255, 255, 0.04);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
:global([data-theme="light"]) .frame-cell:hover {
|
|
656
|
+
background: rgba(0, 0, 0, 0.04);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
.frame-cell--active {
|
|
660
|
+
border-color: var(--accent);
|
|
661
|
+
background: rgba(74, 158, 255, 0.1);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
.frame-cell--selected:not(.frame-cell--active) {
|
|
665
|
+
outline: 2px solid var(--accent);
|
|
666
|
+
outline-offset: -2px;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
.frame-cell--dragging {
|
|
670
|
+
opacity: 0.35;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/* Insertion indicator: a vertical accent-colored line shown between
|
|
674
|
+
frames during drag-to-reorder. Uses pseudo-elements positioned in
|
|
675
|
+
the gap between frame cells (offset by half the gap width). */
|
|
676
|
+
|
|
677
|
+
.frame-cell--drag-left::before,
|
|
678
|
+
.frame-cell--drag-right::after {
|
|
679
|
+
content: '';
|
|
680
|
+
position: absolute;
|
|
681
|
+
top: 0;
|
|
682
|
+
bottom: 0;
|
|
683
|
+
width: 3px;
|
|
684
|
+
background: var(--accent);
|
|
685
|
+
border-radius: 1px;
|
|
686
|
+
pointer-events: none;
|
|
687
|
+
z-index: 1;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
.frame-cell--drag-left::before {
|
|
691
|
+
left: -5px;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
.frame-cell--drag-right::after {
|
|
695
|
+
right: -5px;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
.frame-thumb {
|
|
699
|
+
image-rendering: pixelated;
|
|
700
|
+
background: var(--bg-canvas);
|
|
701
|
+
border-radius: var(--radius-sm);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
.frame-number {
|
|
705
|
+
font-size: 9px;
|
|
706
|
+
color: var(--text-secondary);
|
|
707
|
+
line-height: 1;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
.frame-cell--active .frame-number {
|
|
711
|
+
color: var(--accent);
|
|
712
|
+
font-weight: 600;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
.frame-duration {
|
|
716
|
+
font-size: 8px;
|
|
717
|
+
color: var(--text-secondary);
|
|
718
|
+
line-height: 1;
|
|
719
|
+
cursor: default;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
.frame-duration--custom {
|
|
723
|
+
color: var(--accent);
|
|
724
|
+
font-weight: 700;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/* --- Inline duration editor --- */
|
|
728
|
+
|
|
729
|
+
.duration-editor {
|
|
730
|
+
display: flex;
|
|
731
|
+
align-items: center;
|
|
732
|
+
gap: 1px;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
.duration-input {
|
|
736
|
+
width: 40px;
|
|
737
|
+
height: 16px;
|
|
738
|
+
background: var(--bg-primary);
|
|
739
|
+
border: 1px solid var(--accent);
|
|
740
|
+
border-radius: var(--radius-sm);
|
|
741
|
+
color: var(--text-primary);
|
|
742
|
+
font-size: 8px;
|
|
743
|
+
text-align: center;
|
|
744
|
+
padding: 0 2px;
|
|
745
|
+
/* Hide number input spinner arrows */
|
|
746
|
+
-moz-appearance: textfield;
|
|
747
|
+
appearance: textfield;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
.duration-input::-webkit-inner-spin-button,
|
|
751
|
+
.duration-input::-webkit-outer-spin-button {
|
|
752
|
+
-webkit-appearance: none;
|
|
753
|
+
margin: 0;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
.duration-suffix {
|
|
757
|
+
font-size: 8px;
|
|
758
|
+
color: var(--text-secondary);
|
|
759
|
+
line-height: 1;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/* --- Fixed controls on the right --- */
|
|
763
|
+
|
|
764
|
+
.strip-controls {
|
|
765
|
+
display: flex;
|
|
766
|
+
align-items: center;
|
|
767
|
+
gap: var(--space-3);
|
|
768
|
+
padding: var(--space-2) var(--space-3);
|
|
769
|
+
flex-shrink: 0;
|
|
770
|
+
border-left: 1px solid var(--border);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
.frame-actions {
|
|
774
|
+
display: flex;
|
|
775
|
+
gap: var(--space-1);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
.action-btn {
|
|
779
|
+
background: none;
|
|
780
|
+
border: 1px solid transparent;
|
|
781
|
+
border-radius: var(--radius-sm);
|
|
782
|
+
color: var(--text-secondary);
|
|
783
|
+
cursor: pointer;
|
|
784
|
+
width: 24px;
|
|
785
|
+
height: 24px;
|
|
786
|
+
display: flex;
|
|
787
|
+
align-items: center;
|
|
788
|
+
justify-content: center;
|
|
789
|
+
font-size: var(--text-xl);
|
|
790
|
+
padding: 0;
|
|
791
|
+
line-height: 1;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
.action-btn:hover:not(:disabled) {
|
|
795
|
+
background: var(--bg-primary);
|
|
796
|
+
color: var(--text-primary);
|
|
797
|
+
border-color: var(--border);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
.action-btn:disabled {
|
|
801
|
+
opacity: 0.3;
|
|
802
|
+
cursor: default;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
.icon {
|
|
806
|
+
display: block;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
.action-btn :global(svg) {
|
|
810
|
+
width: 14px;
|
|
811
|
+
height: 14px;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/* --- FPS control --- */
|
|
815
|
+
|
|
816
|
+
.fps-control {
|
|
817
|
+
display: flex;
|
|
818
|
+
align-items: center;
|
|
819
|
+
gap: var(--space-2);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
.fps-label {
|
|
823
|
+
font-size: var(--text-xs);
|
|
824
|
+
font-weight: 600;
|
|
825
|
+
color: var(--text-secondary);
|
|
826
|
+
text-transform: uppercase;
|
|
827
|
+
letter-spacing: 0.3px;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
.fps-input {
|
|
831
|
+
width: 40px;
|
|
832
|
+
height: 22px;
|
|
833
|
+
background: var(--bg-primary);
|
|
834
|
+
border: 1px solid var(--border);
|
|
835
|
+
border-radius: var(--radius-sm);
|
|
836
|
+
color: var(--text-primary);
|
|
837
|
+
font-size: var(--text-sm);
|
|
838
|
+
text-align: center;
|
|
839
|
+
padding: 0 var(--space-1);
|
|
840
|
+
/* Hide number input spinner arrows */
|
|
841
|
+
-moz-appearance: textfield;
|
|
842
|
+
appearance: textfield;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
.fps-input::-webkit-inner-spin-button,
|
|
846
|
+
.fps-input::-webkit-outer-spin-button {
|
|
847
|
+
-webkit-appearance: none;
|
|
848
|
+
margin: 0;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
.fps-input:focus-visible {
|
|
852
|
+
border-color: var(--accent);
|
|
853
|
+
}
|
|
854
|
+
</style>
|