@agent-native/core 0.90.1 → 0.90.2
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +14 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/AgentPanel.tsx +1 -1
- package/corpus/core/src/client/rich-markdown-editor/useCollabReconcile.ts +11 -6
- package/corpus/core/src/client/use-action.ts +39 -2
- package/corpus/core/src/client/use-db-sync.ts +44 -18
- package/corpus/core/src/db/create-get-db.ts +32 -2
- package/corpus/core/src/server/framework-request-handler.ts +52 -4
- package/corpus/templates/assets/.agents/skills/asset-generation/SKILL.md +5 -0
- package/corpus/templates/assets/.agents/skills/logo-composite/SKILL.md +34 -6
- package/corpus/templates/assets/AGENTS.md +23 -4
- package/corpus/templates/assets/actions/_generation-preset-settings.ts +48 -0
- package/corpus/templates/assets/actions/_helpers.ts +8 -1
- package/corpus/templates/assets/actions/_preset-skeleton-validation.ts +64 -0
- package/corpus/templates/assets/actions/create-generation-preset.ts +11 -3
- package/corpus/templates/assets/actions/generate-image-batch.ts +7 -2
- package/corpus/templates/assets/actions/generate-image.ts +342 -25
- package/corpus/templates/assets/actions/get-library.ts +4 -3
- package/corpus/templates/assets/actions/navigate.ts +2 -1
- package/corpus/templates/assets/actions/refresh-generation-run.ts +5 -1
- package/corpus/templates/assets/actions/update-asset.ts +1 -0
- package/corpus/templates/assets/actions/update-generation-preset.ts +8 -1
- package/corpus/templates/assets/actions/view-screen.ts +8 -1
- package/corpus/templates/assets/app/components/generation/GenerationResults.tsx +9 -1
- package/corpus/templates/assets/app/hooks/use-image-model-menu.ts +1 -0
- package/corpus/templates/assets/app/hooks/use-navigation-state.ts +11 -0
- package/corpus/templates/assets/app/i18n/zh-TW.ts +64 -0
- package/corpus/templates/assets/app/i18n-data.ts +650 -0
- package/corpus/templates/assets/app/routes/asset.$id.tsx +2 -1
- package/corpus/templates/assets/app/routes/brand-kits.$id.tsx +44 -33
- package/corpus/templates/assets/app/routes/brand-kits.$id_.presets.$presetId.tsx +1474 -0
- package/corpus/templates/assets/app/routes/library.tsx +43 -1
- package/corpus/templates/assets/server/lib/generation.ts +366 -44
- package/corpus/templates/assets/server/lib/image-processing.ts +252 -0
- package/corpus/templates/assets/server/lib/preset-skeleton.ts +137 -0
- package/corpus/templates/assets/shared/api.ts +47 -0
- package/corpus/templates/clips/app/components/library/library-layout.tsx +7 -2
- package/corpus/templates/clips/app/components/library/sort-menu.tsx +1 -1
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +114 -33
- package/corpus/templates/clips/app/routes/_app.library._index.tsx +27 -1
- package/corpus/templates/clips/app/routes/record.tsx +157 -68
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +5 -5
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +20 -3
- package/corpus/templates/clips/shared/finalize-recovery.ts +23 -3
- package/corpus/templates/content/AGENTS.md +52 -52
- package/corpus/templates/content/actions/_builder-cms-read-client.ts +188 -8
- package/corpus/templates/content/actions/_builder-docs-client.ts +41 -3
- package/corpus/templates/content/actions/_database-source-utils.ts +998 -160
- package/corpus/templates/content/actions/_database-utils.ts +44 -4
- package/corpus/templates/content/actions/_notion-action-utils.ts +6 -2
- package/corpus/templates/content/actions/add-content-database-source-field-property.ts +23 -1
- package/corpus/templates/content/actions/attach-content-database-source.ts +85 -12
- package/corpus/templates/content/actions/change-content-database-source-role.ts +9 -1
- package/corpus/templates/content/actions/delete-content-database.ts +1 -0
- package/corpus/templates/content/actions/get-document.ts +11 -2
- package/corpus/templates/content/actions/list-content-databases.ts +57 -9
- package/corpus/templates/content/actions/prepare-builder-source-review.ts +23 -6
- package/corpus/templates/content/actions/refresh-content-database-source.ts +26 -3
- package/corpus/templates/content/actions/restore-content-database.ts +1 -0
- package/corpus/templates/content/actions/update-document.ts +43 -0
- package/corpus/templates/content/app/components/editor/BuilderBodySyncingNotice.tsx +21 -0
- package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +0 -1
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +70 -28
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +69 -17
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +4 -4
- package/corpus/templates/content/app/components/editor/body-hydration.ts +112 -0
- package/corpus/templates/content/app/components/editor/builder-body-hydration-pump.ts +52 -0
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +9076 -2876
- package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +41 -1
- package/corpus/templates/content/app/components/editor/extensions/ImageBlock.tsx +36 -11
- package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +21 -1
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +11 -1
- package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +11 -8
- package/corpus/templates/content/app/global.css +41 -0
- package/corpus/templates/content/app/hooks/use-content-database.ts +346 -42
- package/corpus/templates/content/app/hooks/use-db-sync.ts +9 -0
- package/corpus/templates/content/app/hooks/use-documents.ts +47 -0
- package/corpus/templates/content/app/hooks/use-notion.ts +12 -10
- package/corpus/templates/content/app/i18n/zh-TW.ts +35 -0
- package/corpus/templates/content/app/i18n-data.ts +355 -0
- package/corpus/templates/content/app/root.tsx +22 -0
- package/corpus/templates/content/changelog/2026-07-01-builder-database-sources-keep-loading-rows-in-the-background.md +6 -0
- package/corpus/templates/content/changelog/2026-07-01-builder-source-attach-starts-with-usable-rows.md +6 -0
- package/corpus/templates/content/changelog/2026-07-01-builder-source-attachment-now-stays-connected-after-large.md +6 -0
- package/corpus/templates/content/changelog/2026-07-01-builder-source-refreshes-now-show-progress-for-large-docs-blog.md +6 -0
- package/corpus/templates/content/changelog/2026-07-01-database-pages-now-delete-to-trash-without-route-errors.md +6 -0
- package/corpus/templates/content/changelog/2026-07-02-builder-source-refreshes-recover-from-stalled-background-row.md +6 -0
- package/corpus/templates/content/changelog/2026-07-02-builder-sourced-rows-open-with-sync-protection.md +6 -0
- package/corpus/templates/content/changelog/2026-07-02-database-rows-and-pages-open-faster-from-recently-loaded-dat.md +6 -0
- package/corpus/templates/content/changelog/2026-07-02-preview-panels-close-reliably-narrow-sidebar-row-actions-sta.md +6 -0
- package/corpus/templates/content/changelog/2026-07-06-builder-sync-progress-no-longer-shows-continue.md +6 -0
- package/corpus/templates/content/shared/api.ts +13 -0
- package/corpus/templates/content/shared/builder-mdx.ts +16 -3
- package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +23 -13
- package/corpus/templates/design/.agents/skills/responsive-breakpoints/SKILL.md +98 -0
- package/corpus/templates/design/.agents/skills/shader-fills/SKILL.md +162 -0
- package/corpus/templates/design/AGENTS.md +9 -2
- package/corpus/templates/design/actions/add-breakpoint.ts +6 -3
- package/corpus/templates/design/actions/apply-a11y-fix.ts +57 -37
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +41 -20
- package/corpus/templates/design/actions/apply-motion-edit.ts +251 -38
- package/corpus/templates/design/actions/apply-shader-fill.ts +90 -66
- package/corpus/templates/design/actions/apply-visual-edit.ts +282 -55
- package/corpus/templates/design/actions/create-component.ts +29 -60
- package/corpus/templates/design/actions/create-file.ts +9 -2
- package/corpus/templates/design/actions/duplicate-design.ts +31 -2
- package/corpus/templates/design/actions/edit-design.ts +36 -38
- package/corpus/templates/design/actions/generate-design-title.ts +87 -0
- package/corpus/templates/design/actions/generate-design.ts +53 -18
- package/corpus/templates/design/actions/generate-screens.ts +108 -4
- package/corpus/templates/design/actions/get-motion-timeline.ts +35 -7
- package/corpus/templates/design/actions/insert-asset.ts +34 -29
- package/corpus/templates/design/actions/insert-design-native-asset.ts +129 -32
- package/corpus/templates/design/actions/insert-figma-library-asset.ts +35 -30
- package/corpus/templates/design/actions/present-design-variants.ts +7 -2
- package/corpus/templates/design/actions/remove-motion-timeline.ts +31 -36
- package/corpus/templates/design/actions/update-file.ts +190 -75
- package/corpus/templates/design/app/components/design/BreakpointBar.tsx +403 -0
- package/corpus/templates/design/app/components/design/CanvasContextMenu.tsx +469 -222
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +708 -53
- package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +83 -3
- package/corpus/templates/design/app/components/design/EditPanel.tsx +1091 -6948
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +419 -85
- package/corpus/templates/design/app/components/design/MotionDock.tsx +1124 -103
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +2610 -1398
- package/corpus/templates/design/app/components/design/ReadOnlyDesignBanner.tsx +22 -0
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +2182 -235
- package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +187 -11
- package/corpus/templates/design/app/components/design/bridge/shader-fill-preview.bridge.ts +376 -1
- package/corpus/templates/design/app/components/design/bridge/shader-runtime.bridge.ts +746 -0
- package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +17 -0
- package/corpus/templates/design/app/components/design/code-workbench/SideBar.tsx +1 -1
- package/corpus/templates/design/app/components/design/code-workbench/explorer/FileTree.tsx +1 -3
- package/corpus/templates/design/app/components/design/code-workbench/quickinput/QuickInput.tsx +1 -1
- package/corpus/templates/design/app/components/design/edit-panel/appearance-properties.tsx +518 -0
- package/corpus/templates/design/app/components/design/edit-panel/code-inspect-helpers.tsx +562 -0
- package/corpus/templates/design/app/components/design/edit-panel/component-section.tsx +763 -0
- package/corpus/templates/design/app/components/design/edit-panel/document-colors.ts +94 -0
- package/corpus/templates/design/app/components/design/edit-panel/effects-properties.tsx +780 -0
- package/corpus/templates/design/app/components/design/edit-panel/element-classification.ts +446 -0
- package/corpus/templates/design/app/components/design/edit-panel/element-identity.ts +79 -0
- package/corpus/templates/design/app/components/design/edit-panel/field-primitives.tsx +315 -0
- package/corpus/templates/design/app/components/design/edit-panel/fill-gradient-helpers.ts +344 -0
- package/corpus/templates/design/app/components/design/edit-panel/fill-properties.tsx +594 -0
- package/corpus/templates/design/app/components/design/edit-panel/frame-presets-panel.tsx +119 -0
- package/corpus/templates/design/app/components/design/edit-panel/inspector-controls.tsx +248 -0
- package/corpus/templates/design/app/components/design/edit-panel/interaction-state-helpers.ts +45 -0
- package/corpus/templates/design/app/components/design/edit-panel/layout-properties.tsx +693 -0
- package/corpus/templates/design/app/components/design/edit-panel/panel-primitives.tsx +724 -0
- package/corpus/templates/design/app/components/design/edit-panel/position-helpers.ts +207 -0
- package/corpus/templates/design/app/components/design/edit-panel/position-layout-properties.tsx +628 -0
- package/corpus/templates/design/app/components/design/edit-panel/selection-helpers.ts +91 -0
- package/corpus/templates/design/app/components/design/edit-panel/stroke-properties.tsx +649 -0
- package/corpus/templates/design/app/components/design/edit-panel/style-change-types.ts +118 -0
- package/corpus/templates/design/app/components/design/edit-panel/style-options.ts +88 -0
- package/corpus/templates/design/app/components/design/edit-panel/transform-helpers.ts +100 -0
- package/corpus/templates/design/app/components/design/edit-panel/typography-helpers.ts +117 -0
- package/corpus/templates/design/app/components/design/edit-panel/typography-properties.tsx +501 -0
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +91 -40
- package/corpus/templates/design/app/components/design/inspector/BreakpointOverrideIndicator.tsx +82 -0
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +189 -87
- package/corpus/templates/design/app/components/design/inspector/GlslShaderPanel.tsx +1097 -0
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +44 -0
- package/corpus/templates/design/app/components/design/inspector/InteractionStatePanel.tsx +258 -0
- package/corpus/templates/design/app/components/design/inspector/MotionKeyframeDiamond.tsx +144 -0
- package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +99 -9
- package/corpus/templates/design/app/components/design/inspector/frame-size-presets.ts +116 -0
- package/corpus/templates/design/app/components/design/inspector/index.ts +26 -0
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +29 -0
- package/corpus/templates/design/app/components/design/multi-screen/alt-hover-measurement.ts +171 -0
- package/corpus/templates/design/app/components/design/multi-screen/board-surface-html.ts +220 -0
- package/corpus/templates/design/app/components/design/multi-screen/chrome-transitions.ts +35 -0
- package/corpus/templates/design/app/components/design/multi-screen/cross-screen-drop.ts +134 -0
- package/corpus/templates/design/app/components/design/multi-screen/culling.ts +185 -0
- package/corpus/templates/design/app/components/design/multi-screen/gradient-overlay-geometry.ts +120 -0
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +892 -0
- package/corpus/templates/design/app/components/design/multi-screen/vector-edit-geometry.ts +21 -0
- package/corpus/templates/design/app/components/design/types.ts +15 -0
- package/corpus/templates/design/app/components/editor/PromptDialog.tsx +109 -5
- package/corpus/templates/design/app/global.css +8 -8
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +259 -2
- package/corpus/templates/design/app/i18n/zh-TW.ts +163 -9
- package/corpus/templates/design/app/i18n-breakpoints.ts +205 -0
- package/corpus/templates/design/app/i18n-data.ts +1664 -152
- package/corpus/templates/design/app/i18n-motion.ts +563 -0
- package/corpus/templates/design/app/lib/design-import.ts +73 -0
- package/corpus/templates/design/app/pages/Index.tsx +41 -22
- package/corpus/templates/design/changelog/2026-06-29-design-inspector-controls-now-match-figmas-softer-dark-chrome.md +1 -1
- package/corpus/templates/design/changelog/2026-06-30-design-now-has-a-figma-style-left-rail-for-file-agent-assets.md +1 -1
- package/corpus/templates/design/changelog/2026-06-30-design-tools-now-use-a-quieter-figma-style-list-layout.md +1 -1
- package/corpus/templates/design/changelog/2026-06-30-multi-select-outlines-now-stay-consistent-across-all-screens.md +1 -1
- package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +1 -1
- package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +1 -1
- package/corpus/templates/design/changelog/2026-07-05-designs-get-ai-generated-names-from-your-prompt.md +6 -0
- package/corpus/templates/design/changelog/2026-07-05-local-code-screens-keep-their-connection-when-duplicated-and.md +6 -0
- package/corpus/templates/design/changelog/2026-07-05-press-a-to-draw-screens-on-the-canvas-inside-a-screen-it-dr.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-3d-rotation-and-perspective-controls-on-every-element.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-ai-generated-and-duplicated-screens-now-get-their-editor-sel.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-applying-shaders-and-fills-in-quick-succession-can-no-longer.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-breakpoint-frames-in-the-overview-now-support-click-to-selec.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-breakpoint-segments-in-the-inspector-now-show-a-device-icon-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-breakpoint-targeting-now-lives-in-one-compact-control-in-the.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-canvas-edits-can-no-longer-be-overwritten-by-stale-collabora.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-canvas-edits-inside-screens-save-reliably-again.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-cleaner-inspector-removed-the-ask-ai-box-from-the-design-tab.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-clicking-empty-canvas-space-now-deselects-an-element-inside-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-dragging-an-element-onto-a-rectangle-now-nests-it-inside-wit.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-dragging-screens-and-objects-on-the-canvas-is-now-much-smoot.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-drawing-a-shape-or-text-on-the-canvas-no-longer-flashes-away.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-dropping-an-element-between-two-others-inside-a-screen-now-s.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-duplicating-a-screen-now-places-the-copy-exactly-where-you-d.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-edit-gradients-directly-on-elements-inside-screens-scale-str.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-edits-from-collaborators-and-the-agent-now-update-the-canvas.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-fixed-a-bug-where-most-style-position-and-text-edits-briefly.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-fixed-a-confusing-raw-error-message-when-a-file-upload-fails.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-fixed-a-rare-case-where-an-edit-could-be-silently-overwritte.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-fixed-inserted-assets-sometimes-disappearing-reappearing-or-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-fixed-inspector-value-scrubbing-producing-erratic-numbers-an.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-fixed-padding-gap-and-min-max-size-scrubbing-not-updating-th.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-fixed-text-stroke-color-turning-black-and-glyphs-disappearin.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-fixed-the-collapsed-effects-and-stroke-inspector-sections-sh.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-fixed-the-new-design-dialog-closing-unexpectedly-when-using-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-fixed-the-typography-section-missing-when-selecting-text-nes.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-full-view-no-longer-flashes-back-to-the-overview-on-entry-no.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-keyframe-animation-timeline-per-property-keyframes-easing-cu.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-moving-restyling-and-scrubbing-values-on-elements-in-ai-gene.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-padding-and-corner-radius-value-scrubbing-now-snaps-to-whole.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-padding-handles-now-only-resize-on-the-handle-line-itself-sh.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-preview-and-edit-hover-focus-and-pressed-states-with-real-cs.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-promoting-an-element-to-a-component-now-works-correctly-for-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-reduced-url-history-churn-and-selection-overlay-flicker-whil.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-removed-the-decorative-dot-before-screen-names-in-the-overvi.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-responsive-breakpoints-edit-at-any-width-with-cascading-over.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-screen-labels-no-longer-turn-blue-when-you-hover-inside-the-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-scrolling-a-screen-with-an-element-selected-stays-smooth-on-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-selecting-multiple-elements-and-dragging-now-moves-the-whole.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-selection-borders-handles-and-value-readouts-now-stay-the-sa.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-selection-handles-and-editing-chrome-now-stay-a-constant-siz.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-shader-fills-and-effects-ai-authored-or-preset-glsl-shaders-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-text-drawn-on-the-canvas-now-defaults-to-white-and-inter-in-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-text-dropped-into-a-shape-now-stays-readable-instead-of-disa.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-the-add-tweak-controls-prompt-no-longer-jumps-to-the-corner-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-the-padding-value-readout-now-stays-visible-while-you-hover-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-the-selected-element-attachment-now-clears-from-the-chat-com.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-the-zoom-menu-is-more-compact-with-tight-rows-and-right-alig.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-undo-and-redo-no-longer-flash-the-screen-changes-now-apply-i.md +6 -0
- package/corpus/templates/design/changelog/2026-07-06-zoom-presets-like-zoom-to-50-now-stay-on-the-focused-screen-.md +6 -0
- package/corpus/templates/design/server/lib/import-design-files.ts +11 -2
- package/corpus/templates/design/server/source-workspace.ts +139 -59
- package/corpus/templates/design/shared/board-file.ts +11 -2
- package/corpus/templates/design/shared/breakpoint-media.ts +543 -0
- package/corpus/templates/design/shared/canvas-math.ts +354 -35
- package/corpus/templates/design/shared/code-layer.ts +277 -8
- package/corpus/templates/design/shared/design-state.ts +11 -4
- package/corpus/templates/design/shared/interaction-states.ts +548 -0
- package/corpus/templates/design/shared/motion-compiler.ts +186 -23
- package/corpus/templates/design/shared/motion-easing.ts +294 -0
- package/corpus/templates/design/shared/motion-timeline.ts +400 -34
- package/corpus/templates/design/shared/prompt-title.ts +95 -0
- package/corpus/templates/design/shared/responsive-classes.ts +448 -10
- package/corpus/templates/design/shared/screen-annotation.ts +46 -0
- package/corpus/templates/design/shared/shader-fills.ts +1009 -0
- package/corpus/templates/design/shared/shader-presets.ts +852 -4
- package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +15 -2
- package/corpus/templates/slides/app/components/editor/ImportButton.tsx +10 -1
- package/corpus/templates/slides/app/lib/upload-response.ts +71 -0
- package/dist/client/AgentPanel.js +1 -1
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/rich-markdown-editor/useCollabReconcile.d.ts.map +1 -1
- package/dist/client/rich-markdown-editor/useCollabReconcile.js +11 -6
- package/dist/client/rich-markdown-editor/useCollabReconcile.js.map +1 -1
- package/dist/client/use-action.d.ts +3 -0
- package/dist/client/use-action.d.ts.map +1 -1
- package/dist/client/use-action.js +34 -3
- package/dist/client/use-action.js.map +1 -1
- package/dist/client/use-db-sync.d.ts +4 -0
- package/dist/client/use-db-sync.d.ts.map +1 -1
- package/dist/client/use-db-sync.js +37 -18
- package/dist/client/use-db-sync.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/db/create-get-db.d.ts +25 -0
- package/dist/db/create-get-db.d.ts.map +1 -1
- package/dist/db/create-get-db.js +25 -2
- package/dist/db/create-get-db.js.map +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/framework-request-handler.d.ts.map +1 -1
- package/dist/server/framework-request-handler.js +37 -1
- package/dist/server/framework-request-handler.js.map +1 -1
- package/package.json +1 -1
- package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +0 -153
- package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +0 -133
- package/corpus/templates/design/app/pages/DesignEditor.tsx +0 -22924
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// i18n-raw-literal-disable-file — new Design Studio panel; UI strings are localized when this feature is finalized in the follow-up PR.
|
|
2
1
|
/**
|
|
3
2
|
* MotionDock — bottom motion timeline dock for the Design Studio (§6.3).
|
|
4
3
|
*
|
|
@@ -17,29 +16,48 @@
|
|
|
17
16
|
* All times are normalised to [0, 1] internally; the ruler maps them to px.
|
|
18
17
|
*/
|
|
19
18
|
|
|
19
|
+
import { useT } from "@agent-native/core/client";
|
|
20
|
+
import {
|
|
21
|
+
MOTION_CURVE_PRESETS,
|
|
22
|
+
MOTION_SPRING_DEFAULT_BOUNCE,
|
|
23
|
+
MOTION_SPRING_PRESETS,
|
|
24
|
+
parseSpringToken,
|
|
25
|
+
sampleSpring,
|
|
26
|
+
springToken,
|
|
27
|
+
} from "@shared/motion-easing";
|
|
20
28
|
import type {
|
|
21
29
|
MotionTrack,
|
|
22
30
|
MotionKeyframe,
|
|
23
31
|
MotionEase,
|
|
32
|
+
MotionPlaybackMode,
|
|
24
33
|
MotionPropertyPreset,
|
|
25
34
|
} from "@shared/motion-timeline";
|
|
26
35
|
import {
|
|
36
|
+
MOTION_DEFAULT_PLAYBACK_MODE,
|
|
27
37
|
MOTION_PROPERTY_PRESETS,
|
|
28
38
|
createMotionTrackFromPreset,
|
|
39
|
+
getMotionTrackTiming,
|
|
29
40
|
hasTrackFor,
|
|
41
|
+
readTimelinePlaybackMode,
|
|
30
42
|
sampleMotionKeyframesAt,
|
|
31
43
|
sortMotionKeyframes,
|
|
44
|
+
timelineTimeToTrackTime,
|
|
32
45
|
upsertMotionKeyframeAtTime,
|
|
46
|
+
withTimelinePlaybackMode,
|
|
33
47
|
} from "@shared/motion-timeline";
|
|
34
48
|
import {
|
|
35
49
|
IconPlayerPlay,
|
|
36
50
|
IconPlayerPause,
|
|
37
51
|
IconPlayerStop,
|
|
52
|
+
IconArrowsLeftRight,
|
|
38
53
|
IconCheck,
|
|
39
54
|
IconDiamond,
|
|
55
|
+
IconDiamondFilled,
|
|
40
56
|
IconChevronDown,
|
|
41
57
|
IconChevronRight,
|
|
42
58
|
IconPlus,
|
|
59
|
+
IconRepeat,
|
|
60
|
+
IconRepeatOnce,
|
|
43
61
|
IconTrash,
|
|
44
62
|
IconRefresh,
|
|
45
63
|
IconBolt,
|
|
@@ -50,6 +68,7 @@ import {
|
|
|
50
68
|
useEffect,
|
|
51
69
|
useRef,
|
|
52
70
|
useState,
|
|
71
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
53
72
|
type PointerEvent as ReactPointerEvent,
|
|
54
73
|
type TransitionEvent as ReactTransitionEvent,
|
|
55
74
|
} from "react";
|
|
@@ -62,9 +81,18 @@ import {
|
|
|
62
81
|
DropdownMenuItem,
|
|
63
82
|
DropdownMenuLabel,
|
|
64
83
|
DropdownMenuSeparator,
|
|
84
|
+
DropdownMenuSub,
|
|
85
|
+
DropdownMenuSubContent,
|
|
86
|
+
DropdownMenuSubTrigger,
|
|
65
87
|
DropdownMenuTrigger,
|
|
66
88
|
} from "@/components/ui/dropdown-menu";
|
|
67
89
|
import { Input } from "@/components/ui/input";
|
|
90
|
+
import {
|
|
91
|
+
Popover,
|
|
92
|
+
PopoverContent,
|
|
93
|
+
PopoverTrigger,
|
|
94
|
+
} from "@/components/ui/popover";
|
|
95
|
+
import { Slider } from "@/components/ui/slider";
|
|
68
96
|
import {
|
|
69
97
|
Tooltip,
|
|
70
98
|
TooltipContent,
|
|
@@ -81,16 +109,57 @@ const PLAYHEAD_WIDTH = 2; // px
|
|
|
81
109
|
const MIN_DOCK_HEIGHT = 160; // px
|
|
82
110
|
const DEFAULT_DOCK_HEIGHT = 280; // px
|
|
83
111
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Playback-mode cycling order + chrome, matching Figma Motion's cycling
|
|
114
|
+
* toolbar button (Loop / Once / Ping-pong).
|
|
115
|
+
*/
|
|
116
|
+
const PLAYBACK_MODES: {
|
|
117
|
+
mode: MotionPlaybackMode;
|
|
118
|
+
labelKey: "loop" | "once" | "pingPong";
|
|
119
|
+
Icon: typeof IconRepeat;
|
|
120
|
+
}[] = [
|
|
121
|
+
{ mode: "loop", labelKey: "loop", Icon: IconRepeat },
|
|
122
|
+
{ mode: "once", labelKey: "once", Icon: IconRepeatOnce },
|
|
123
|
+
{ mode: "ping-pong", labelKey: "pingPong", Icon: IconArrowsLeftRight },
|
|
92
124
|
];
|
|
93
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Row-identity key for a track. Uses the unit-separator delimiter (same
|
|
128
|
+
* convention as apply-motion-edit's motionTrackKey) so distinct
|
|
129
|
+
* (nodeId, property) pairs can never collide.
|
|
130
|
+
*/
|
|
131
|
+
function trackKey(track: MotionTrack): string {
|
|
132
|
+
return `${track.targetNodeId}\u001f${track.property}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Compact ruler tick label: "250ms" under a second, "1.5s" above. */
|
|
136
|
+
function formatMsTick(ms: number): string {
|
|
137
|
+
if (ms < 1000) return `${ms}ms`;
|
|
138
|
+
const s = (ms / 1000).toFixed(2).replace(/\.?0+$/, "");
|
|
139
|
+
return `${s}s`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Human label for an ease value: preset name when recognised, else raw. */
|
|
143
|
+
function easeLabel(
|
|
144
|
+
ease: MotionEase | undefined,
|
|
145
|
+
t: (key: string) => string,
|
|
146
|
+
): string {
|
|
147
|
+
if (ease === undefined) return t("designEditor.motion.defaultEase");
|
|
148
|
+
const curve = MOTION_CURVE_PRESETS.find((preset) => preset.value === ease);
|
|
149
|
+
if (curve) return curve.label;
|
|
150
|
+
const springPreset = MOTION_SPRING_PRESETS.find(
|
|
151
|
+
(preset) => preset.value === ease,
|
|
152
|
+
);
|
|
153
|
+
if (springPreset) return springPreset.label;
|
|
154
|
+
if (parseSpringToken(String(ease))) {
|
|
155
|
+
return t("designEditor.motion.customSpring");
|
|
156
|
+
}
|
|
157
|
+
if (/^cubic-bezier\(/.test(String(ease))) {
|
|
158
|
+
return t("designEditor.motion.customBezier");
|
|
159
|
+
}
|
|
160
|
+
return String(ease);
|
|
161
|
+
}
|
|
162
|
+
|
|
94
163
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
95
164
|
|
|
96
165
|
export interface MotionDockTrack extends MotionTrack {
|
|
@@ -115,6 +184,15 @@ export interface MotionDockProps {
|
|
|
115
184
|
onTracksChange?: (tracks: MotionDockTrack[]) => void;
|
|
116
185
|
/** Called when durationMs is edited. */
|
|
117
186
|
onDurationChange?: (ms: number) => void;
|
|
187
|
+
/**
|
|
188
|
+
* Controlled playback mode (Loop / Once / Ping-pong). When omitted, the
|
|
189
|
+
* dock reads the mode stamped in `tracks` (timelinePlaybackMode on the
|
|
190
|
+
* first track) and persists changes by re-stamping the tracks through
|
|
191
|
+
* `onTracksChange` — no extra parent wiring needed.
|
|
192
|
+
*/
|
|
193
|
+
playbackMode?: MotionPlaybackMode;
|
|
194
|
+
/** Called when the playback-mode cycling button changes the mode. */
|
|
195
|
+
onPlaybackModeChange?: (mode: MotionPlaybackMode) => void;
|
|
118
196
|
/**
|
|
119
197
|
* Reference to the canvas iframe element. Used to send preview postMessages.
|
|
120
198
|
* If not provided, preview messages are skipped (no crash).
|
|
@@ -165,6 +243,8 @@ export function MotionDock({
|
|
|
165
243
|
onExitComplete,
|
|
166
244
|
onTracksChange,
|
|
167
245
|
onDurationChange,
|
|
246
|
+
playbackMode: playbackModeProp,
|
|
247
|
+
onPlaybackModeChange,
|
|
168
248
|
canvasIframeRef,
|
|
169
249
|
applying = false,
|
|
170
250
|
autoKeyframe: autoKeyframeProp,
|
|
@@ -174,6 +254,8 @@ export function MotionDock({
|
|
|
174
254
|
livePlayheadRef,
|
|
175
255
|
selectedTarget = null,
|
|
176
256
|
}: MotionDockProps) {
|
|
257
|
+
const t = useT();
|
|
258
|
+
|
|
177
259
|
// Controlled / uncontrolled open state.
|
|
178
260
|
const [openInternal, setOpenInternal] = useState(false);
|
|
179
261
|
const isOpen = openProp !== undefined ? openProp : openInternal;
|
|
@@ -218,6 +300,36 @@ export function MotionDock({
|
|
|
218
300
|
},
|
|
219
301
|
[autoKeyframe, onAutoKeyframeChange],
|
|
220
302
|
);
|
|
303
|
+
|
|
304
|
+
// Playback mode (Loop / Once / Ping-pong). Controlled by the parent when
|
|
305
|
+
// provided; otherwise read from the stamp persisted in the tracks JSON.
|
|
306
|
+
// Cycling the button re-stamps the tracks through onTracksChange, so the
|
|
307
|
+
// mode persists via the parent's existing autosave without extra wiring.
|
|
308
|
+
const [playbackModeInternal, setPlaybackModeInternal] =
|
|
309
|
+
useState<MotionPlaybackMode | null>(null);
|
|
310
|
+
const playbackMode: MotionPlaybackMode =
|
|
311
|
+
playbackModeProp ??
|
|
312
|
+
readTimelinePlaybackMode(tracks) ??
|
|
313
|
+
playbackModeInternal ??
|
|
314
|
+
MOTION_DEFAULT_PLAYBACK_MODE;
|
|
315
|
+
const cyclePlaybackMode = useCallback(() => {
|
|
316
|
+
const index = PLAYBACK_MODES.findIndex((m) => m.mode === playbackMode);
|
|
317
|
+
const next = PLAYBACK_MODES[(index + 1) % PLAYBACK_MODES.length].mode;
|
|
318
|
+
setPlaybackModeInternal(next);
|
|
319
|
+
onPlaybackModeChange?.(next);
|
|
320
|
+
if (onTracksChange && tracks.length > 0) {
|
|
321
|
+
onTracksChange(withTimelinePlaybackMode(tracks, next));
|
|
322
|
+
}
|
|
323
|
+
}, [onPlaybackModeChange, onTracksChange, playbackMode, tracks]);
|
|
324
|
+
|
|
325
|
+
// Selected property row — the target of the toolbar's add-keyframe ◆
|
|
326
|
+
// button (Figma keys the selected track at the playhead).
|
|
327
|
+
const [selectedTrackKey, setSelectedTrackKey] = useState<string | null>(null);
|
|
328
|
+
const selectedTrack =
|
|
329
|
+
tracks.find((track) => trackKey(track) === selectedTrackKey) ?? null;
|
|
330
|
+
|
|
331
|
+
// Current-time field draft (null = displaying the live playhead).
|
|
332
|
+
const [timeDraft, setTimeDraft] = useState<string | null>(null);
|
|
221
333
|
/** Internal high-frequency playhead update — does NOT notify the parent. */
|
|
222
334
|
const setPlayheadLocal = useCallback(
|
|
223
335
|
(next: number) => {
|
|
@@ -309,20 +421,40 @@ export function MotionDock({
|
|
|
309
421
|
playStartRef.current = { wallMs: performance.now(), startT };
|
|
310
422
|
setPlaying(true);
|
|
311
423
|
|
|
424
|
+
// Loop and ping-pong run until explicitly paused; once stops at the end
|
|
425
|
+
// (matching the compiled CSS's animation-iteration-count / -direction).
|
|
312
426
|
const tick = (now: number) => {
|
|
313
427
|
if (!playStartRef.current) return;
|
|
314
428
|
const elapsed = now - playStartRef.current.wallMs;
|
|
315
|
-
const
|
|
429
|
+
const progressed = playStartRef.current.startT + elapsed / durationMs;
|
|
430
|
+
let t: number;
|
|
431
|
+
let done = false;
|
|
432
|
+
if (playbackMode === "loop") {
|
|
433
|
+
t = progressed % 1;
|
|
434
|
+
} else if (playbackMode === "ping-pong") {
|
|
435
|
+
const phase = progressed % 2;
|
|
436
|
+
t = phase <= 1 ? phase : 2 - phase;
|
|
437
|
+
} else {
|
|
438
|
+
t = Math.min(1, progressed);
|
|
439
|
+
done = progressed >= 1;
|
|
440
|
+
}
|
|
316
441
|
setPlayheadLocal(t);
|
|
317
442
|
sendPreview(t);
|
|
318
|
-
if (
|
|
443
|
+
if (!done) {
|
|
319
444
|
playRafRef.current = requestAnimationFrame(tick);
|
|
320
445
|
} else {
|
|
321
446
|
stopPlayback();
|
|
322
447
|
}
|
|
323
448
|
};
|
|
324
449
|
playRafRef.current = requestAnimationFrame(tick);
|
|
325
|
-
}, [
|
|
450
|
+
}, [
|
|
451
|
+
durationMs,
|
|
452
|
+
playbackMode,
|
|
453
|
+
playhead,
|
|
454
|
+
sendPreview,
|
|
455
|
+
setPlayheadLocal,
|
|
456
|
+
stopPlayback,
|
|
457
|
+
]);
|
|
326
458
|
|
|
327
459
|
useEffect(() => {
|
|
328
460
|
return () => {
|
|
@@ -414,16 +546,23 @@ export function MotionDock({
|
|
|
414
546
|
|
|
415
547
|
const addKeyframe = useCallback(
|
|
416
548
|
(track: MotionDockTrack) => {
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
549
|
+
// Map the timeline playhead into the track's own span (tracks may be
|
|
550
|
+
// offset/scaled via delayMs/durationMs), then seed the new keyframe
|
|
551
|
+
// with the track's interpolated value at that local time (what the
|
|
552
|
+
// preview currently shows) — a hardcoded "0" is invalid CSS for
|
|
553
|
+
// transform/filter/color tracks and snaps the preview.
|
|
554
|
+
const localT = timelineTimeToTrackTime(
|
|
555
|
+
track,
|
|
556
|
+
playhead * durationMs,
|
|
557
|
+
durationMs,
|
|
558
|
+
);
|
|
420
559
|
const sampled = sampleMotionKeyframesAt(
|
|
421
560
|
track.keyframes,
|
|
422
|
-
|
|
561
|
+
localT,
|
|
423
562
|
defaultEase,
|
|
424
563
|
);
|
|
425
564
|
const newKf: MotionKeyframe = {
|
|
426
|
-
t:
|
|
565
|
+
t: localT,
|
|
427
566
|
value: sampled || "0",
|
|
428
567
|
ease: defaultEase,
|
|
429
568
|
};
|
|
@@ -435,7 +574,7 @@ export function MotionDock({
|
|
|
435
574
|
keyframes: upsertMotionKeyframeAtTime(tr.keyframes, newKf),
|
|
436
575
|
}));
|
|
437
576
|
},
|
|
438
|
-
[defaultEase, playhead, updateTrack],
|
|
577
|
+
[defaultEase, durationMs, playhead, updateTrack],
|
|
439
578
|
);
|
|
440
579
|
|
|
441
580
|
// ── Create a brand-new track (the "first track" path) ──────────────────────
|
|
@@ -462,14 +601,24 @@ export function MotionDock({
|
|
|
462
601
|
// presets can target the same property, e.g. slide + scale are both
|
|
463
602
|
// "transform").
|
|
464
603
|
toast.info(
|
|
465
|
-
|
|
604
|
+
t("designEditor.motion.trackExists", {
|
|
605
|
+
property: preset.property,
|
|
606
|
+
label,
|
|
607
|
+
}),
|
|
466
608
|
);
|
|
467
609
|
return;
|
|
468
610
|
}
|
|
469
611
|
|
|
470
612
|
const seeded = createMotionTrackFromPreset(nodeId, preset, defaultEase);
|
|
471
613
|
const newTrack: MotionDockTrack = { ...seeded, label };
|
|
472
|
-
|
|
614
|
+
// Figma parity: brand-new timelines default to Loop. Existing
|
|
615
|
+
// timelines keep whatever mode is already stamped/resolved.
|
|
616
|
+
const nextTracks =
|
|
617
|
+
tracks.length === 0
|
|
618
|
+
? withTimelinePlaybackMode([newTrack], "loop")
|
|
619
|
+
: [...tracks, newTrack];
|
|
620
|
+
onTracksChange(nextTracks);
|
|
621
|
+
setSelectedTrackKey(trackKey(newTrack));
|
|
473
622
|
},
|
|
474
623
|
[defaultEase, onTracksChange, selectedTarget, tracks],
|
|
475
624
|
);
|
|
@@ -537,15 +686,105 @@ export function MotionDock({
|
|
|
537
686
|
[updateTrack],
|
|
538
687
|
);
|
|
539
688
|
|
|
540
|
-
// ──
|
|
689
|
+
// ── Layer span-bar gestures (drag to offset, edge-drag to scale) ─────────
|
|
690
|
+
// Given the layer's NEW span, remap every track of that layer
|
|
691
|
+
// proportionally from the OLD span so per-track relative offsets survive.
|
|
692
|
+
const updateLayerSpan = useCallback(
|
|
693
|
+
(nodeId: string, nextSpan: { startMs: number; durationMs: number }) => {
|
|
694
|
+
if (!onTracksChange) return;
|
|
695
|
+
const timingsByKey = new Map<
|
|
696
|
+
string,
|
|
697
|
+
{ startMs: number; endMs: number; durationMs: number }
|
|
698
|
+
>();
|
|
699
|
+
for (const tr of tracks) {
|
|
700
|
+
if (tr.targetNodeId === nodeId) {
|
|
701
|
+
timingsByKey.set(trackKey(tr), getMotionTrackTiming(tr, durationMs));
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (timingsByKey.size === 0) return;
|
|
705
|
+
const timings = [...timingsByKey.values()];
|
|
706
|
+
const spanStart = Math.min(...timings.map((t) => t.startMs));
|
|
707
|
+
const spanEnd = Math.max(...timings.map((t) => t.endMs));
|
|
708
|
+
const spanDur = Math.max(1, spanEnd - spanStart);
|
|
709
|
+
const factor = Math.max(0.01, nextSpan.durationMs / spanDur);
|
|
710
|
+
onTracksChange(
|
|
711
|
+
tracks.map((tr) => {
|
|
712
|
+
const timing = timingsByKey.get(trackKey(tr));
|
|
713
|
+
if (!timing) return tr;
|
|
714
|
+
const startMs = Math.max(
|
|
715
|
+
0,
|
|
716
|
+
Math.round(
|
|
717
|
+
nextSpan.startMs + (timing.startMs - spanStart) * factor,
|
|
718
|
+
),
|
|
719
|
+
);
|
|
720
|
+
const trackDur = Math.max(20, Math.round(timing.durationMs * factor));
|
|
721
|
+
const out: MotionDockTrack = { ...tr };
|
|
722
|
+
if (startMs > 0) out.delayMs = startMs;
|
|
723
|
+
else delete out.delayMs;
|
|
724
|
+
if (trackDur !== durationMs) out.durationMs = trackDur;
|
|
725
|
+
else delete out.durationMs;
|
|
726
|
+
return out;
|
|
727
|
+
}),
|
|
728
|
+
);
|
|
729
|
+
},
|
|
730
|
+
[durationMs, onTracksChange, tracks],
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
// ── Current-time field ────────────────────────────────────────────────────
|
|
734
|
+
const commitTimeDraft = useCallback(() => {
|
|
735
|
+
if (timeDraft === null) return;
|
|
736
|
+
const ms = parseInt(timeDraft, 10);
|
|
737
|
+
setTimeDraft(null);
|
|
738
|
+
if (isNaN(ms)) return;
|
|
739
|
+
const t = Math.max(0, Math.min(1, ms / durationMs));
|
|
740
|
+
stopPlayback();
|
|
741
|
+
setPlayheadLocal(t);
|
|
742
|
+
commitPlayhead();
|
|
743
|
+
sendPreview(t);
|
|
744
|
+
}, [
|
|
745
|
+
commitPlayhead,
|
|
746
|
+
durationMs,
|
|
747
|
+
sendPreview,
|
|
748
|
+
setPlayheadLocal,
|
|
749
|
+
stopPlayback,
|
|
750
|
+
timeDraft,
|
|
751
|
+
]);
|
|
752
|
+
|
|
753
|
+
// ── Space plays/pauses while the dock has focus (Figma parity) ───────────
|
|
754
|
+
const handleDockKeyDown = useCallback(
|
|
755
|
+
(e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
756
|
+
if (e.code !== "Space") return;
|
|
757
|
+
const target = e.target as HTMLElement | null;
|
|
758
|
+
if (target?.closest("input, textarea, select, [contenteditable]")) {
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
e.preventDefault();
|
|
762
|
+
if (playing) stopPlayback();
|
|
763
|
+
else startPlayback();
|
|
764
|
+
},
|
|
765
|
+
[playing, startPlayback, stopPlayback],
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
// ── Ruler tick marks (ms, nice steps — Figma-style ms ruler) ─────────────
|
|
541
769
|
function rulerTicks(): { t: number; label: string }[] {
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
770
|
+
const NICE_STEPS = [
|
|
771
|
+
10, 25, 50, 100, 200, 250, 500, 1000, 2000, 2500, 5000, 10000,
|
|
772
|
+
];
|
|
773
|
+
// Plain loop (rather than Array#find with an inline arrow function) so a
|
|
774
|
+
// `<=` comparison never sits directly after a `>` character — the i18n
|
|
775
|
+
// raw-literal guard's regex heuristic treats `>...<` runs as JSX text.
|
|
776
|
+
let step = Math.ceil(durationMs / 10);
|
|
777
|
+
for (const candidate of NICE_STEPS) {
|
|
778
|
+
if (durationMs / candidate <= 10) {
|
|
779
|
+
step = candidate;
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
const ticks: { t: number; label: string }[] = [];
|
|
784
|
+
for (let ms = 0; ms <= durationMs; ms += step) {
|
|
785
|
+
ticks.push({ t: ms / durationMs, label: formatMsTick(ms) });
|
|
786
|
+
}
|
|
787
|
+
return ticks;
|
|
549
788
|
}
|
|
550
789
|
|
|
551
790
|
// ── Group tracks by layer (nodeId) ────────────────────────────────────────
|
|
@@ -579,10 +818,14 @@ export function MotionDock({
|
|
|
579
818
|
style={{ height: isOpen ? dockHeight : 0 }}
|
|
580
819
|
>
|
|
581
820
|
<div
|
|
582
|
-
aria-label="
|
|
821
|
+
aria-label={t("designEditor.motion.dockLabel")}
|
|
583
822
|
aria-hidden={!isOpen ? true : undefined}
|
|
823
|
+
// Focusable so Space can play/pause while the dock is focused
|
|
824
|
+
// (Figma parity); -1 keeps it out of the tab order.
|
|
825
|
+
tabIndex={-1}
|
|
826
|
+
onKeyDown={handleDockKeyDown}
|
|
584
827
|
className={cn(
|
|
585
|
-
"design-motion-dock absolute inset-x-0 bottom-0 z-40 flex min-h-0 transform-gpu flex-col overflow-hidden border-t bg-background select-none",
|
|
828
|
+
"design-motion-dock absolute inset-x-0 bottom-0 z-40 flex min-h-0 transform-gpu flex-col overflow-hidden border-t bg-background outline-none select-none",
|
|
586
829
|
isOpen
|
|
587
830
|
? "translate-y-0 border-border opacity-100"
|
|
588
831
|
: "translate-y-full border-transparent pointer-events-none",
|
|
@@ -605,7 +848,7 @@ export function MotionDock({
|
|
|
605
848
|
type="button"
|
|
606
849
|
className="-ml-1 flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]"
|
|
607
850
|
onClick={() => setOpen(false)}
|
|
608
|
-
aria-label="
|
|
851
|
+
aria-label={t("designEditor.motion.collapseDock")}
|
|
609
852
|
>
|
|
610
853
|
<IconChevronDown className="size-3.5" />
|
|
611
854
|
</button>
|
|
@@ -620,7 +863,11 @@ export function MotionDock({
|
|
|
620
863
|
size="icon"
|
|
621
864
|
className="size-6 shrink-0"
|
|
622
865
|
onClick={playing ? stopPlayback : startPlayback}
|
|
623
|
-
aria-label={
|
|
866
|
+
aria-label={
|
|
867
|
+
playing
|
|
868
|
+
? t("designEditor.motion.pause")
|
|
869
|
+
: t("designEditor.motion.play")
|
|
870
|
+
}
|
|
624
871
|
>
|
|
625
872
|
{playing ? (
|
|
626
873
|
<IconPlayerPause className="size-3.5" />
|
|
@@ -630,7 +877,9 @@ export function MotionDock({
|
|
|
630
877
|
</Button>
|
|
631
878
|
</TooltipTrigger>
|
|
632
879
|
<TooltipContent side="top">
|
|
633
|
-
{playing
|
|
880
|
+
{playing
|
|
881
|
+
? t("designEditor.motion.pause")
|
|
882
|
+
: t("designEditor.motion.play")}
|
|
634
883
|
</TooltipContent>
|
|
635
884
|
</Tooltip>
|
|
636
885
|
|
|
@@ -648,18 +897,74 @@ export function MotionDock({
|
|
|
648
897
|
commitPlayhead();
|
|
649
898
|
sendPreview(0);
|
|
650
899
|
}}
|
|
651
|
-
aria-label="
|
|
900
|
+
aria-label={t("designEditor.motion.resetPlayhead")}
|
|
652
901
|
>
|
|
653
902
|
<IconPlayerStop className="size-3.5" />
|
|
654
903
|
</Button>
|
|
655
904
|
</TooltipTrigger>
|
|
656
|
-
<TooltipContent side="top">
|
|
905
|
+
<TooltipContent side="top">
|
|
906
|
+
{t("designEditor.motion.reset")}
|
|
907
|
+
</TooltipContent>
|
|
657
908
|
</Tooltip>
|
|
658
909
|
|
|
910
|
+
{/* Add keyframe at playhead (selected property row) */}
|
|
911
|
+
<Tooltip>
|
|
912
|
+
<TooltipTrigger asChild>
|
|
913
|
+
<span className="inline-flex">
|
|
914
|
+
<Button
|
|
915
|
+
type="button"
|
|
916
|
+
variant="ghost"
|
|
917
|
+
size="icon"
|
|
918
|
+
className="size-6 shrink-0"
|
|
919
|
+
disabled={!selectedTrack}
|
|
920
|
+
onClick={() => {
|
|
921
|
+
if (selectedTrack) addKeyframe(selectedTrack);
|
|
922
|
+
}}
|
|
923
|
+
aria-label={t("designEditor.motion.addKeyframeAtPlayhead")}
|
|
924
|
+
>
|
|
925
|
+
<IconDiamondFilled className="size-3" />
|
|
926
|
+
</Button>
|
|
927
|
+
</span>
|
|
928
|
+
</TooltipTrigger>
|
|
929
|
+
<TooltipContent side="top">
|
|
930
|
+
{selectedTrack
|
|
931
|
+
? t("designEditor.motion.addKeyframeAtPlayheadForProperty", {
|
|
932
|
+
property: selectedTrack.property,
|
|
933
|
+
})
|
|
934
|
+
: t("designEditor.motion.selectPropertyRowFirst")}
|
|
935
|
+
</TooltipContent>
|
|
936
|
+
</Tooltip>
|
|
937
|
+
|
|
938
|
+
{/* Current time (ms) — click to jump the playhead */}
|
|
939
|
+
<div className="flex items-center gap-1 ml-1">
|
|
940
|
+
<Input
|
|
941
|
+
type="number"
|
|
942
|
+
min={0}
|
|
943
|
+
max={durationMs}
|
|
944
|
+
step={10}
|
|
945
|
+
value={timeDraft ?? String(Math.round(playhead * durationMs))}
|
|
946
|
+
onFocus={() =>
|
|
947
|
+
setTimeDraft(String(Math.round(playhead * durationMs)))
|
|
948
|
+
}
|
|
949
|
+
onChange={(e) => setTimeDraft(e.target.value)}
|
|
950
|
+
onBlur={commitTimeDraft}
|
|
951
|
+
onKeyDown={(e) => {
|
|
952
|
+
if (e.key === "Enter") {
|
|
953
|
+
e.preventDefault();
|
|
954
|
+
commitTimeDraft();
|
|
955
|
+
(e.target as HTMLInputElement).blur();
|
|
956
|
+
}
|
|
957
|
+
}}
|
|
958
|
+
className="h-5 w-14 px-1 !text-[11px] md:!text-[11px]"
|
|
959
|
+
aria-label={t("designEditor.motion.currentTimeMs")}
|
|
960
|
+
/>
|
|
961
|
+
<span className="text-[10px] text-muted-foreground">/</span>
|
|
962
|
+
</div>
|
|
963
|
+
|
|
659
964
|
{/* Duration */}
|
|
660
965
|
<div className="flex items-center gap-1 ml-1">
|
|
661
966
|
<span className="text-[10px] text-muted-foreground">
|
|
662
|
-
|
|
967
|
+
{t("designEditor.motion.duration")}
|
|
663
968
|
</span>
|
|
664
969
|
<Input
|
|
665
970
|
type="number"
|
|
@@ -676,12 +981,42 @@ export function MotionDock({
|
|
|
676
981
|
}
|
|
677
982
|
}}
|
|
678
983
|
className="h-5 w-16 px-1 !text-[11px] md:!text-[11px]"
|
|
679
|
-
aria-label="
|
|
984
|
+
aria-label={t("designEditor.motion.durationMs")}
|
|
680
985
|
/>
|
|
681
986
|
<span className="text-[10px] text-muted-foreground">ms</span>
|
|
682
987
|
</div>
|
|
683
988
|
|
|
684
|
-
{/*
|
|
989
|
+
{/* Playback mode — cycling button (Loop / Once / Ping-pong) */}
|
|
990
|
+
{(() => {
|
|
991
|
+
const current =
|
|
992
|
+
PLAYBACK_MODES.find((m) => m.mode === playbackMode) ??
|
|
993
|
+
PLAYBACK_MODES[0];
|
|
994
|
+
const ModeIcon = current.Icon;
|
|
995
|
+
const modeLabel = t(`designEditor.motion.${current.labelKey}`);
|
|
996
|
+
return (
|
|
997
|
+
<Tooltip>
|
|
998
|
+
<TooltipTrigger asChild>
|
|
999
|
+
<Button
|
|
1000
|
+
type="button"
|
|
1001
|
+
variant="ghost"
|
|
1002
|
+
size="icon"
|
|
1003
|
+
className="size-6 shrink-0"
|
|
1004
|
+
onClick={cyclePlaybackMode}
|
|
1005
|
+
aria-label={t("designEditor.motion.playbackMode", {
|
|
1006
|
+
mode: modeLabel,
|
|
1007
|
+
})}
|
|
1008
|
+
>
|
|
1009
|
+
<ModeIcon className="size-3.5" />
|
|
1010
|
+
</Button>
|
|
1011
|
+
</TooltipTrigger>
|
|
1012
|
+
<TooltipContent side="top">
|
|
1013
|
+
{t("designEditor.motion.playback", { mode: modeLabel })}
|
|
1014
|
+
</TooltipContent>
|
|
1015
|
+
</Tooltip>
|
|
1016
|
+
);
|
|
1017
|
+
})()}
|
|
1018
|
+
|
|
1019
|
+
{/* Add motion — the "create first track" entry point. */}
|
|
685
1020
|
<div className="ml-2">
|
|
686
1021
|
<AddTrackMenu
|
|
687
1022
|
selectedTarget={selectedTarget}
|
|
@@ -702,13 +1037,15 @@ export function MotionDock({
|
|
|
702
1037
|
autoKeyframe && "text-primary",
|
|
703
1038
|
)}
|
|
704
1039
|
onClick={() => setAutoKeyframe((v) => !v)}
|
|
705
|
-
aria-label="
|
|
1040
|
+
aria-label={t("designEditor.motion.toggleAutoKeyframe")}
|
|
706
1041
|
aria-pressed={autoKeyframe}
|
|
707
1042
|
>
|
|
708
1043
|
<IconBolt className="size-3.5" />
|
|
709
1044
|
</Button>
|
|
710
1045
|
</TooltipTrigger>
|
|
711
|
-
<TooltipContent side="top">
|
|
1046
|
+
<TooltipContent side="top">
|
|
1047
|
+
{t("designEditor.motion.autoKeyframe")}
|
|
1048
|
+
</TooltipContent>
|
|
712
1049
|
</Tooltip>
|
|
713
1050
|
|
|
714
1051
|
{applying ? (
|
|
@@ -716,13 +1053,15 @@ export function MotionDock({
|
|
|
716
1053
|
<TooltipTrigger asChild>
|
|
717
1054
|
<span
|
|
718
1055
|
role="status"
|
|
719
|
-
aria-label="
|
|
1056
|
+
aria-label={t("designEditor.motion.savingMotion")}
|
|
720
1057
|
className="flex size-6 items-center justify-center rounded text-muted-foreground"
|
|
721
1058
|
>
|
|
722
1059
|
<IconRefresh className="size-3 animate-spin" />
|
|
723
1060
|
</span>
|
|
724
1061
|
</TooltipTrigger>
|
|
725
|
-
<TooltipContent side="top">
|
|
1062
|
+
<TooltipContent side="top">
|
|
1063
|
+
{t("designEditor.motion.savingMotion")}
|
|
1064
|
+
</TooltipContent>
|
|
726
1065
|
</Tooltip>
|
|
727
1066
|
) : null}
|
|
728
1067
|
</div>
|
|
@@ -748,11 +1087,11 @@ export function MotionDock({
|
|
|
748
1087
|
{selectedTarget ? (
|
|
749
1088
|
<>
|
|
750
1089
|
<p className="!text-[11px] text-muted-foreground/70 leading-snug">
|
|
751
|
-
|
|
1090
|
+
{t("designEditor.motion.emptyStateAnimate")}{" "}
|
|
752
1091
|
<span className="font-medium text-foreground/80">
|
|
753
1092
|
{selectedTarget.label}
|
|
754
1093
|
</span>
|
|
755
|
-
.
|
|
1094
|
+
{t("designEditor.motion.emptyStatePickProperty")}
|
|
756
1095
|
</p>
|
|
757
1096
|
<AddTrackMenu
|
|
758
1097
|
selectedTarget={selectedTarget}
|
|
@@ -762,8 +1101,7 @@ export function MotionDock({
|
|
|
762
1101
|
</>
|
|
763
1102
|
) : (
|
|
764
1103
|
<p className="!text-[11px] text-muted-foreground/70 leading-snug">
|
|
765
|
-
|
|
766
|
-
it.
|
|
1104
|
+
{t("designEditor.motion.emptyStateNoSelection")}
|
|
767
1105
|
</p>
|
|
768
1106
|
)}
|
|
769
1107
|
</div>
|
|
@@ -782,6 +1120,10 @@ export function MotionDock({
|
|
|
782
1120
|
})
|
|
783
1121
|
}
|
|
784
1122
|
onAddKeyframe={(track) => addKeyframe(track)}
|
|
1123
|
+
selectedTrackKey={selectedTrackKey}
|
|
1124
|
+
onSelectTrack={(track) =>
|
|
1125
|
+
setSelectedTrackKey(trackKey(track))
|
|
1126
|
+
}
|
|
785
1127
|
/>
|
|
786
1128
|
))
|
|
787
1129
|
)}
|
|
@@ -824,10 +1166,17 @@ export function MotionDock({
|
|
|
824
1166
|
layer={layer}
|
|
825
1167
|
expanded={expandedNodeIds.has(layer.nodeId)}
|
|
826
1168
|
playhead={playhead}
|
|
1169
|
+
timelineDurationMs={durationMs}
|
|
1170
|
+
defaultEase={defaultEase}
|
|
1171
|
+
selectedTrackKey={selectedTrackKey}
|
|
1172
|
+
onSelectTrack={(track) =>
|
|
1173
|
+
setSelectedTrackKey(trackKey(track))
|
|
1174
|
+
}
|
|
827
1175
|
onDeleteKeyframe={deleteKeyframe}
|
|
828
1176
|
onMoveKeyframe={moveKeyframe}
|
|
829
1177
|
onMoveKeyframeEnd={moveKeyframeEnd}
|
|
830
1178
|
onEaseKeyframe={easeKeyframe}
|
|
1179
|
+
onLayerSpanChange={updateLayerSpan}
|
|
831
1180
|
/>
|
|
832
1181
|
))}
|
|
833
1182
|
|
|
@@ -862,16 +1211,21 @@ interface AddTrackMenuProps {
|
|
|
862
1211
|
}
|
|
863
1212
|
|
|
864
1213
|
/**
|
|
865
|
-
*
|
|
866
|
-
* element.
|
|
867
|
-
*
|
|
1214
|
+
* "Add motion" dropdown — creates a new motion track for the selected
|
|
1215
|
+
* element. Matches Figma Motion's submenu verbatim: Position / Scale /
|
|
1216
|
+
* Rotation / Opacity directly, with the remaining keyframeable properties
|
|
1217
|
+
* under a "More" submenu (Corner radius, Fill, Stroke paint, Stroke weight,
|
|
1218
|
+
* Drop shadow). Disabled (with a hint) when nothing is selected, which is
|
|
1219
|
+
* the only state where a first track cannot be created.
|
|
868
1220
|
*/
|
|
869
1221
|
function AddTrackMenu({
|
|
870
1222
|
selectedTarget,
|
|
871
1223
|
onCreateTrack,
|
|
872
1224
|
variant = "toolbar",
|
|
873
1225
|
}: AddTrackMenuProps) {
|
|
1226
|
+
const t = useT();
|
|
874
1227
|
const disabled = !selectedTarget;
|
|
1228
|
+
const addMotionLabel = t("designEditor.motion.addMotion");
|
|
875
1229
|
const trigger =
|
|
876
1230
|
variant === "cta" ? (
|
|
877
1231
|
<Button
|
|
@@ -882,7 +1236,7 @@ function AddTrackMenu({
|
|
|
882
1236
|
disabled={disabled}
|
|
883
1237
|
>
|
|
884
1238
|
<IconPlus className="size-3.5" />
|
|
885
|
-
|
|
1239
|
+
{addMotionLabel}
|
|
886
1240
|
</Button>
|
|
887
1241
|
) : (
|
|
888
1242
|
<Button
|
|
@@ -893,7 +1247,7 @@ function AddTrackMenu({
|
|
|
893
1247
|
disabled={disabled}
|
|
894
1248
|
>
|
|
895
1249
|
<IconPlus className="size-3.5" />
|
|
896
|
-
|
|
1250
|
+
{addMotionLabel}
|
|
897
1251
|
</Button>
|
|
898
1252
|
);
|
|
899
1253
|
|
|
@@ -906,21 +1260,30 @@ function AddTrackMenu({
|
|
|
906
1260
|
<span className="inline-flex">{trigger}</span>
|
|
907
1261
|
</TooltipTrigger>
|
|
908
1262
|
<TooltipContent side="top">
|
|
909
|
-
|
|
1263
|
+
{t("designEditor.motion.selectElementFirst")}
|
|
910
1264
|
</TooltipContent>
|
|
911
1265
|
</Tooltip>
|
|
912
1266
|
);
|
|
913
1267
|
}
|
|
914
1268
|
|
|
1269
|
+
const primaryPresets = MOTION_PROPERTY_PRESETS.filter(
|
|
1270
|
+
(preset) => preset.group === "primary",
|
|
1271
|
+
);
|
|
1272
|
+
const morePresets = MOTION_PROPERTY_PRESETS.filter(
|
|
1273
|
+
(preset) => preset.group === "more",
|
|
1274
|
+
);
|
|
1275
|
+
|
|
915
1276
|
return (
|
|
916
1277
|
<DropdownMenu>
|
|
917
1278
|
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
|
918
1279
|
<DropdownMenuContent align="start" className="w-48 p-1">
|
|
919
1280
|
<DropdownMenuLabel className="truncate px-2 py-1 text-[10px] font-medium leading-none text-muted-foreground">
|
|
920
|
-
|
|
1281
|
+
{t("designEditor.motion.animateLayer", {
|
|
1282
|
+
label: selectedTarget.label,
|
|
1283
|
+
})}
|
|
921
1284
|
</DropdownMenuLabel>
|
|
922
1285
|
<DropdownMenuSeparator className="my-1" />
|
|
923
|
-
{
|
|
1286
|
+
{primaryPresets.map((preset) => (
|
|
924
1287
|
<DropdownMenuItem
|
|
925
1288
|
key={`${preset.property}-${preset.label}`}
|
|
926
1289
|
className="h-7 px-2 text-[12px] leading-none"
|
|
@@ -929,6 +1292,22 @@ function AddTrackMenu({
|
|
|
929
1292
|
{preset.label}
|
|
930
1293
|
</DropdownMenuItem>
|
|
931
1294
|
))}
|
|
1295
|
+
<DropdownMenuSub>
|
|
1296
|
+
<DropdownMenuSubTrigger className="h-7 px-2 text-[12px] leading-none">
|
|
1297
|
+
{t("designEditor.motion.more")}
|
|
1298
|
+
</DropdownMenuSubTrigger>
|
|
1299
|
+
<DropdownMenuSubContent className="w-44 p-1">
|
|
1300
|
+
{morePresets.map((preset) => (
|
|
1301
|
+
<DropdownMenuItem
|
|
1302
|
+
key={`${preset.property}-${preset.label}`}
|
|
1303
|
+
className="h-7 px-2 text-[12px] leading-none"
|
|
1304
|
+
onSelect={() => onCreateTrack(preset)}
|
|
1305
|
+
>
|
|
1306
|
+
{preset.label}
|
|
1307
|
+
</DropdownMenuItem>
|
|
1308
|
+
))}
|
|
1309
|
+
</DropdownMenuSubContent>
|
|
1310
|
+
</DropdownMenuSub>
|
|
932
1311
|
</DropdownMenuContent>
|
|
933
1312
|
</DropdownMenu>
|
|
934
1313
|
);
|
|
@@ -966,6 +1345,9 @@ interface LayerGroupProps {
|
|
|
966
1345
|
expanded: boolean;
|
|
967
1346
|
onToggleExpand: () => void;
|
|
968
1347
|
onAddKeyframe: (track: MotionDockTrack) => void;
|
|
1348
|
+
/** Key of the selected property row (toolbar ◆ target). */
|
|
1349
|
+
selectedTrackKey: string | null;
|
|
1350
|
+
onSelectTrack: (track: MotionDockTrack) => void;
|
|
969
1351
|
}
|
|
970
1352
|
|
|
971
1353
|
function LayerGroup({
|
|
@@ -973,7 +1355,10 @@ function LayerGroup({
|
|
|
973
1355
|
expanded,
|
|
974
1356
|
onToggleExpand,
|
|
975
1357
|
onAddKeyframe,
|
|
1358
|
+
selectedTrackKey,
|
|
1359
|
+
onSelectTrack,
|
|
976
1360
|
}: LayerGroupProps) {
|
|
1361
|
+
const t = useT();
|
|
977
1362
|
return (
|
|
978
1363
|
<>
|
|
979
1364
|
{/* Layer header row */}
|
|
@@ -1003,13 +1388,23 @@ function LayerGroup({
|
|
|
1003
1388
|
</span>
|
|
1004
1389
|
</div>
|
|
1005
1390
|
|
|
1006
|
-
{/* Property sub-rows */}
|
|
1391
|
+
{/* Property sub-rows — click to select (toolbar ◆ keys the selection) */}
|
|
1007
1392
|
{expanded &&
|
|
1008
1393
|
layer.tracks.map((track) => (
|
|
1009
1394
|
<div
|
|
1010
1395
|
key={`${track.targetNodeId}-${track.property}`}
|
|
1011
|
-
className=
|
|
1396
|
+
className={cn(
|
|
1397
|
+
"group flex items-center gap-1 pl-5 pr-2 cursor-pointer hover:bg-accent/20",
|
|
1398
|
+
trackKey(track) === selectedTrackKey && "bg-accent/40",
|
|
1399
|
+
)}
|
|
1012
1400
|
style={{ height: ROW_HEIGHT }}
|
|
1401
|
+
role="button"
|
|
1402
|
+
tabIndex={0}
|
|
1403
|
+
onClick={() => onSelectTrack(track)}
|
|
1404
|
+
onKeyDown={(e) => {
|
|
1405
|
+
if (e.key === "Enter") onSelectTrack(track);
|
|
1406
|
+
}}
|
|
1407
|
+
aria-pressed={trackKey(track) === selectedTrackKey}
|
|
1013
1408
|
>
|
|
1014
1409
|
<IconDiamond className="size-2.5 shrink-0 text-primary/70" />
|
|
1015
1410
|
<span className="flex-1 truncate text-[10px] text-muted-foreground">
|
|
@@ -1026,13 +1421,13 @@ function LayerGroup({
|
|
|
1026
1421
|
e.stopPropagation();
|
|
1027
1422
|
onAddKeyframe(track);
|
|
1028
1423
|
}}
|
|
1029
|
-
aria-label="
|
|
1424
|
+
aria-label={t("designEditor.motion.addKeyframe")}
|
|
1030
1425
|
>
|
|
1031
1426
|
<IconPlus className="size-3" />
|
|
1032
1427
|
</Button>
|
|
1033
1428
|
</TooltipTrigger>
|
|
1034
1429
|
<TooltipContent side="right">
|
|
1035
|
-
|
|
1430
|
+
{t("designEditor.motion.addKeyframeAtPlayhead")}
|
|
1036
1431
|
</TooltipContent>
|
|
1037
1432
|
</Tooltip>
|
|
1038
1433
|
</div>
|
|
@@ -1045,6 +1440,10 @@ interface LayerTrackRowsProps {
|
|
|
1045
1440
|
layer: { nodeId: string; label: string; tracks: MotionDockTrack[] };
|
|
1046
1441
|
expanded: boolean;
|
|
1047
1442
|
playhead: number;
|
|
1443
|
+
timelineDurationMs: number;
|
|
1444
|
+
defaultEase: MotionEase;
|
|
1445
|
+
selectedTrackKey: string | null;
|
|
1446
|
+
onSelectTrack: (track: MotionDockTrack) => void;
|
|
1048
1447
|
onDeleteKeyframe: (track: MotionDockTrack, index: number) => void;
|
|
1049
1448
|
onMoveKeyframe: (track: MotionDockTrack, index: number, newT: number) => void;
|
|
1050
1449
|
onMoveKeyframeEnd: (track: MotionDockTrack) => void;
|
|
@@ -1053,24 +1452,56 @@ interface LayerTrackRowsProps {
|
|
|
1053
1452
|
index: number,
|
|
1054
1453
|
ease: MotionEase,
|
|
1055
1454
|
) => void;
|
|
1455
|
+
/** Layer span-bar gesture commit: drag to offset, edge-drag to scale. */
|
|
1456
|
+
onLayerSpanChange: (
|
|
1457
|
+
nodeId: string,
|
|
1458
|
+
span: { startMs: number; durationMs: number },
|
|
1459
|
+
) => void;
|
|
1056
1460
|
}
|
|
1057
1461
|
|
|
1058
1462
|
function LayerTrackRows({
|
|
1059
1463
|
layer,
|
|
1060
1464
|
expanded,
|
|
1061
1465
|
playhead: _playhead,
|
|
1466
|
+
timelineDurationMs,
|
|
1467
|
+
defaultEase,
|
|
1468
|
+
selectedTrackKey,
|
|
1469
|
+
onSelectTrack,
|
|
1062
1470
|
onDeleteKeyframe,
|
|
1063
1471
|
onMoveKeyframe,
|
|
1064
1472
|
onMoveKeyframeEnd,
|
|
1065
1473
|
onEaseKeyframe,
|
|
1474
|
+
onLayerSpanChange,
|
|
1066
1475
|
}: LayerTrackRowsProps) {
|
|
1476
|
+
// Layer span = earliest track start … latest track end (Figma's parent
|
|
1477
|
+
// layer bar). Dragging it offsets every track; edge handles scale them.
|
|
1478
|
+
const timings = layer.tracks.map((track) =>
|
|
1479
|
+
getMotionTrackTiming(track, timelineDurationMs),
|
|
1480
|
+
);
|
|
1481
|
+
const spanStartMs =
|
|
1482
|
+
timings.length > 0 ? Math.min(...timings.map((t) => t.startMs)) : 0;
|
|
1483
|
+
const spanEndMs =
|
|
1484
|
+
timings.length > 0
|
|
1485
|
+
? Math.max(...timings.map((t) => t.endMs))
|
|
1486
|
+
: timelineDurationMs;
|
|
1487
|
+
|
|
1067
1488
|
return (
|
|
1068
1489
|
<>
|
|
1069
|
-
{/* Layer header
|
|
1490
|
+
{/* Layer header row: the draggable span bar */}
|
|
1070
1491
|
<div
|
|
1071
1492
|
className="relative border-b border-border/40"
|
|
1072
1493
|
style={{ height: ROW_HEIGHT }}
|
|
1073
|
-
|
|
1494
|
+
>
|
|
1495
|
+
{layer.tracks.length > 0 && (
|
|
1496
|
+
<LayerSpanBar
|
|
1497
|
+
spanStartMs={spanStartMs}
|
|
1498
|
+
spanEndMs={spanEndMs}
|
|
1499
|
+
timelineDurationMs={timelineDurationMs}
|
|
1500
|
+
label={layer.label}
|
|
1501
|
+
onSpanChange={(span) => onLayerSpanChange(layer.nodeId, span)}
|
|
1502
|
+
/>
|
|
1503
|
+
)}
|
|
1504
|
+
</div>
|
|
1074
1505
|
|
|
1075
1506
|
{/* Property track rows */}
|
|
1076
1507
|
{expanded &&
|
|
@@ -1078,6 +1509,10 @@ function LayerTrackRows({
|
|
|
1078
1509
|
<TrackRow
|
|
1079
1510
|
key={`${track.targetNodeId}-${track.property}`}
|
|
1080
1511
|
track={track}
|
|
1512
|
+
timelineDurationMs={timelineDurationMs}
|
|
1513
|
+
defaultEase={defaultEase}
|
|
1514
|
+
selected={trackKey(track) === selectedTrackKey}
|
|
1515
|
+
onSelect={() => onSelectTrack(track)}
|
|
1081
1516
|
onDeleteKeyframe={(index) => onDeleteKeyframe(track, index)}
|
|
1082
1517
|
onMoveKeyframe={(index, newT) => onMoveKeyframe(track, index, newT)}
|
|
1083
1518
|
onMoveKeyframeEnd={() => onMoveKeyframeEnd(track)}
|
|
@@ -1088,8 +1523,122 @@ function LayerTrackRows({
|
|
|
1088
1523
|
);
|
|
1089
1524
|
}
|
|
1090
1525
|
|
|
1526
|
+
// ─── Layer span bar (drag = offset, edge-drag = scale) ────────────────────────
|
|
1527
|
+
|
|
1528
|
+
interface LayerSpanBarProps {
|
|
1529
|
+
spanStartMs: number;
|
|
1530
|
+
spanEndMs: number;
|
|
1531
|
+
timelineDurationMs: number;
|
|
1532
|
+
label: string;
|
|
1533
|
+
onSpanChange: (span: { startMs: number; durationMs: number }) => void;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function LayerSpanBar({
|
|
1537
|
+
spanStartMs,
|
|
1538
|
+
spanEndMs,
|
|
1539
|
+
timelineDurationMs,
|
|
1540
|
+
label,
|
|
1541
|
+
onSpanChange,
|
|
1542
|
+
}: LayerSpanBarProps) {
|
|
1543
|
+
const t = useT();
|
|
1544
|
+
const barRef = useRef<HTMLDivElement>(null);
|
|
1545
|
+
const gesture = useRef<{
|
|
1546
|
+
kind: "move" | "left" | "right";
|
|
1547
|
+
startX: number;
|
|
1548
|
+
startMs: number;
|
|
1549
|
+
durMs: number;
|
|
1550
|
+
pxPerMs: number;
|
|
1551
|
+
} | null>(null);
|
|
1552
|
+
|
|
1553
|
+
const beginGesture = useCallback(
|
|
1554
|
+
(e: ReactPointerEvent<HTMLElement>, kind: "move" | "left" | "right") => {
|
|
1555
|
+
const row = barRef.current?.parentElement;
|
|
1556
|
+
if (!row) return;
|
|
1557
|
+
e.stopPropagation();
|
|
1558
|
+
const rect = row.getBoundingClientRect();
|
|
1559
|
+
if (rect.width <= 0) return;
|
|
1560
|
+
gesture.current = {
|
|
1561
|
+
kind,
|
|
1562
|
+
startX: e.clientX,
|
|
1563
|
+
startMs: spanStartMs,
|
|
1564
|
+
durMs: Math.max(1, spanEndMs - spanStartMs),
|
|
1565
|
+
pxPerMs: rect.width / timelineDurationMs,
|
|
1566
|
+
};
|
|
1567
|
+
(e.target as Element).setPointerCapture(e.pointerId);
|
|
1568
|
+
},
|
|
1569
|
+
[spanEndMs, spanStartMs, timelineDurationMs],
|
|
1570
|
+
);
|
|
1571
|
+
|
|
1572
|
+
const handlePointerMove = useCallback(
|
|
1573
|
+
(e: ReactPointerEvent<HTMLElement>) => {
|
|
1574
|
+
const g = gesture.current;
|
|
1575
|
+
if (!g) return;
|
|
1576
|
+
const deltaMs = (e.clientX - g.startX) / g.pxPerMs;
|
|
1577
|
+
if (g.kind === "move") {
|
|
1578
|
+
const maxStart = Math.max(0, timelineDurationMs - g.durMs);
|
|
1579
|
+
const startMs = Math.min(Math.max(0, g.startMs + deltaMs), maxStart);
|
|
1580
|
+
onSpanChange({ startMs, durationMs: g.durMs });
|
|
1581
|
+
} else if (g.kind === "right") {
|
|
1582
|
+
const durationMs = Math.max(
|
|
1583
|
+
50,
|
|
1584
|
+
Math.min(g.durMs + deltaMs, timelineDurationMs - g.startMs),
|
|
1585
|
+
);
|
|
1586
|
+
onSpanChange({ startMs: g.startMs, durationMs });
|
|
1587
|
+
} else {
|
|
1588
|
+
const endMs = g.startMs + g.durMs;
|
|
1589
|
+
const startMs = Math.max(0, Math.min(g.startMs + deltaMs, endMs - 50));
|
|
1590
|
+
onSpanChange({ startMs, durationMs: endMs - startMs });
|
|
1591
|
+
}
|
|
1592
|
+
},
|
|
1593
|
+
[onSpanChange, timelineDurationMs],
|
|
1594
|
+
);
|
|
1595
|
+
|
|
1596
|
+
const endGesture = useCallback(() => {
|
|
1597
|
+
gesture.current = null;
|
|
1598
|
+
}, []);
|
|
1599
|
+
|
|
1600
|
+
const leftPct = (spanStartMs / timelineDurationMs) * 100;
|
|
1601
|
+
const widthPct = Math.max(
|
|
1602
|
+
0.5,
|
|
1603
|
+
((spanEndMs - spanStartMs) / timelineDurationMs) * 100,
|
|
1604
|
+
);
|
|
1605
|
+
|
|
1606
|
+
return (
|
|
1607
|
+
<div
|
|
1608
|
+
ref={barRef}
|
|
1609
|
+
className="group absolute top-1/2 h-4 -translate-y-1/2 cursor-grab active:cursor-grabbing rounded-sm bg-primary/20 hover:bg-primary/30 border border-primary/40"
|
|
1610
|
+
style={{ left: `${leftPct}%`, width: `${widthPct}%` }}
|
|
1611
|
+
onPointerDown={(e) => beginGesture(e, "move")}
|
|
1612
|
+
onPointerMove={handlePointerMove}
|
|
1613
|
+
onPointerUp={endGesture}
|
|
1614
|
+
onPointerCancel={endGesture}
|
|
1615
|
+
role="slider"
|
|
1616
|
+
aria-label={t("designEditor.motion.layerAnimationSpan", { label })}
|
|
1617
|
+
aria-valuemin={0}
|
|
1618
|
+
aria-valuemax={timelineDurationMs}
|
|
1619
|
+
aria-valuenow={Math.round(spanStartMs)}
|
|
1620
|
+
aria-valuetext={`${Math.round(spanStartMs)}ms – ${Math.round(spanEndMs)}ms`}
|
|
1621
|
+
tabIndex={-1}
|
|
1622
|
+
>
|
|
1623
|
+
{/* Edge handles: scale the layer's animations */}
|
|
1624
|
+
<div
|
|
1625
|
+
className="absolute left-0 top-0 h-full w-1.5 cursor-ew-resize rounded-l-sm bg-primary/50 opacity-0 group-hover:opacity-100"
|
|
1626
|
+
onPointerDown={(e) => beginGesture(e, "left")}
|
|
1627
|
+
/>
|
|
1628
|
+
<div
|
|
1629
|
+
className="absolute right-0 top-0 h-full w-1.5 cursor-ew-resize rounded-r-sm bg-primary/50 opacity-0 group-hover:opacity-100"
|
|
1630
|
+
onPointerDown={(e) => beginGesture(e, "right")}
|
|
1631
|
+
/>
|
|
1632
|
+
</div>
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1091
1636
|
interface TrackRowProps {
|
|
1092
1637
|
track: MotionDockTrack;
|
|
1638
|
+
timelineDurationMs: number;
|
|
1639
|
+
defaultEase: MotionEase;
|
|
1640
|
+
selected: boolean;
|
|
1641
|
+
onSelect: () => void;
|
|
1093
1642
|
onDeleteKeyframe: (index: number) => void;
|
|
1094
1643
|
onMoveKeyframe: (index: number, newT: number) => void;
|
|
1095
1644
|
onMoveKeyframeEnd: () => void;
|
|
@@ -1098,12 +1647,23 @@ interface TrackRowProps {
|
|
|
1098
1647
|
|
|
1099
1648
|
function TrackRow({
|
|
1100
1649
|
track,
|
|
1650
|
+
timelineDurationMs,
|
|
1651
|
+
defaultEase,
|
|
1652
|
+
selected,
|
|
1653
|
+
onSelect,
|
|
1101
1654
|
onDeleteKeyframe,
|
|
1102
1655
|
onMoveKeyframe,
|
|
1103
1656
|
onMoveKeyframeEnd,
|
|
1104
1657
|
onEaseKeyframe,
|
|
1105
1658
|
}: TrackRowProps) {
|
|
1106
1659
|
const rowRef = useRef<HTMLDivElement>(null);
|
|
1660
|
+
// The track's own span within the timeline (delayMs/durationMs offsets).
|
|
1661
|
+
// Keyframe t is normalised to THIS span; positions on the ruler are
|
|
1662
|
+
// (startMs + t * span) / timelineDurationMs.
|
|
1663
|
+
const timing = getMotionTrackTiming(track, timelineDurationMs);
|
|
1664
|
+
const fractionFor = (kfT: number) =>
|
|
1665
|
+
(timing.startMs + kfT * timing.durationMs) / timelineDurationMs;
|
|
1666
|
+
|
|
1107
1667
|
// Identify the dragged keyframe by its INDEX in track.keyframes, not object
|
|
1108
1668
|
// identity: the parent replaces keyframe objects on every move (immutable
|
|
1109
1669
|
// update), so a captured object reference goes stale after the first move
|
|
@@ -1122,6 +1682,7 @@ function TrackRow({
|
|
|
1122
1682
|
kf: MotionKeyframe,
|
|
1123
1683
|
) => {
|
|
1124
1684
|
e.stopPropagation();
|
|
1685
|
+
onSelect();
|
|
1125
1686
|
dragging.current = {
|
|
1126
1687
|
index,
|
|
1127
1688
|
startX: e.clientX,
|
|
@@ -1130,7 +1691,7 @@ function TrackRow({
|
|
|
1130
1691
|
};
|
|
1131
1692
|
(e.target as Element).setPointerCapture(e.pointerId);
|
|
1132
1693
|
},
|
|
1133
|
-
[],
|
|
1694
|
+
[onSelect],
|
|
1134
1695
|
);
|
|
1135
1696
|
|
|
1136
1697
|
const handlePointerMove = useCallback(
|
|
@@ -1138,12 +1699,13 @@ function TrackRow({
|
|
|
1138
1699
|
if (!dragging.current || !rowRef.current) return;
|
|
1139
1700
|
const rect = rowRef.current.getBoundingClientRect();
|
|
1140
1701
|
const dx = e.clientX - dragging.current.startX;
|
|
1141
|
-
|
|
1702
|
+
// Pixel delta → timeline fraction → track-local time delta.
|
|
1703
|
+
const dt = (dx / rect.width) * (timelineDurationMs / timing.durationMs);
|
|
1142
1704
|
const newT = Math.max(0, Math.min(1, dragging.current.startT + dt));
|
|
1143
1705
|
dragging.current.moved = true;
|
|
1144
1706
|
onMoveKeyframe(dragging.current.index, newT);
|
|
1145
1707
|
},
|
|
1146
|
-
[onMoveKeyframe],
|
|
1708
|
+
[onMoveKeyframe, timelineDurationMs, timing.durationMs],
|
|
1147
1709
|
);
|
|
1148
1710
|
|
|
1149
1711
|
const handlePointerUp = useCallback(() => {
|
|
@@ -1154,11 +1716,22 @@ function TrackRow({
|
|
|
1154
1716
|
if (wasMoved) onMoveKeyframeEnd();
|
|
1155
1717
|
}, [onMoveKeyframeEnd]);
|
|
1156
1718
|
|
|
1719
|
+
// Segments connect consecutive keyframes; clicking one opens the easing
|
|
1720
|
+
// panel for the transition (stored on the LEAVING keyframe, CSS-style).
|
|
1721
|
+
const orderedIndices = track.keyframes
|
|
1722
|
+
.map((kf, index) => ({ t: kf.t, index }))
|
|
1723
|
+
.sort((a, b) => a.t - b.t)
|
|
1724
|
+
.map((entry) => entry.index);
|
|
1725
|
+
|
|
1157
1726
|
return (
|
|
1158
1727
|
<div
|
|
1159
1728
|
ref={rowRef}
|
|
1160
|
-
className=
|
|
1729
|
+
className={cn(
|
|
1730
|
+
"relative border-b border-border/30",
|
|
1731
|
+
selected && "bg-accent/20",
|
|
1732
|
+
)}
|
|
1161
1733
|
style={{ height: ROW_HEIGHT }}
|
|
1734
|
+
onClick={onSelect}
|
|
1162
1735
|
onPointerMove={handlePointerMove}
|
|
1163
1736
|
onPointerUp={handlePointerUp}
|
|
1164
1737
|
onPointerCancel={handlePointerUp}
|
|
@@ -1166,14 +1739,34 @@ function TrackRow({
|
|
|
1166
1739
|
{/* Track baseline */}
|
|
1167
1740
|
<div className="absolute inset-y-1/2 left-0 right-0 h-px bg-border/50" />
|
|
1168
1741
|
|
|
1742
|
+
{/* Easing segments between consecutive keyframes */}
|
|
1743
|
+
{orderedIndices.slice(0, -1).map((kfIndex, order) => {
|
|
1744
|
+
const from = track.keyframes[kfIndex];
|
|
1745
|
+
const to = track.keyframes[orderedIndices[order + 1]];
|
|
1746
|
+
if (!from || !to) return null;
|
|
1747
|
+
const left = fractionFor(from.t);
|
|
1748
|
+
const width = Math.max(0, fractionFor(to.t) - left);
|
|
1749
|
+
return (
|
|
1750
|
+
<EasingSegment
|
|
1751
|
+
key={`segment-${kfIndex}`}
|
|
1752
|
+
left={left}
|
|
1753
|
+
width={width}
|
|
1754
|
+
ease={from.ease}
|
|
1755
|
+
defaultEase={defaultEase}
|
|
1756
|
+
onEaseChange={(ease) => onEaseKeyframe(kfIndex, ease)}
|
|
1757
|
+
/>
|
|
1758
|
+
);
|
|
1759
|
+
})}
|
|
1760
|
+
|
|
1169
1761
|
{/* Keyframe diamonds */}
|
|
1170
1762
|
{track.keyframes.map((kf, i) => (
|
|
1171
1763
|
<KeyframeDiamond
|
|
1172
1764
|
key={i}
|
|
1173
1765
|
kf={kf}
|
|
1766
|
+
leftFraction={fractionFor(kf.t)}
|
|
1767
|
+
timeMs={Math.round(timing.startMs + kf.t * timing.durationMs)}
|
|
1174
1768
|
onPointerDown={(e) => handleDiamondPointerDown(e, i, kf)}
|
|
1175
1769
|
onDelete={() => onDeleteKeyframe(i)}
|
|
1176
|
-
onEaseChange={(ease) => onEaseKeyframe(i, ease)}
|
|
1177
1770
|
/>
|
|
1178
1771
|
))}
|
|
1179
1772
|
</div>
|
|
@@ -1182,27 +1775,32 @@ function TrackRow({
|
|
|
1182
1775
|
|
|
1183
1776
|
interface KeyframeDiamondProps {
|
|
1184
1777
|
kf: MotionKeyframe;
|
|
1778
|
+
/** Horizontal position as a fraction of the whole timeline (offset-aware). */
|
|
1779
|
+
leftFraction: number;
|
|
1780
|
+
/** Absolute keyframe time on the timeline, ms (for the tooltip). */
|
|
1781
|
+
timeMs: number;
|
|
1185
1782
|
onPointerDown: (e: ReactPointerEvent<SVGSVGElement>) => void;
|
|
1186
1783
|
onDelete: () => void;
|
|
1187
|
-
onEaseChange: (ease: MotionEase) => void;
|
|
1188
1784
|
}
|
|
1189
1785
|
|
|
1786
|
+
/**
|
|
1787
|
+
* Draggable keyframe diamond. Easing is edited on the SEGMENT between two
|
|
1788
|
+
* diamonds (click the connecting bar), matching Figma Motion.
|
|
1789
|
+
*/
|
|
1190
1790
|
function KeyframeDiamond({
|
|
1191
1791
|
kf,
|
|
1792
|
+
leftFraction,
|
|
1793
|
+
timeMs,
|
|
1192
1794
|
onPointerDown,
|
|
1193
1795
|
onDelete,
|
|
1194
|
-
onEaseChange,
|
|
1195
1796
|
}: KeyframeDiamondProps) {
|
|
1797
|
+
const t = useT();
|
|
1196
1798
|
const [hovered, setHovered] = useState(false);
|
|
1197
|
-
const currentEase = kf.ease ?? "ease";
|
|
1198
|
-
const currentPreset = EASE_PRESETS.find(
|
|
1199
|
-
(preset) => preset.value === currentEase,
|
|
1200
|
-
);
|
|
1201
1799
|
|
|
1202
1800
|
return (
|
|
1203
1801
|
<div
|
|
1204
1802
|
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 group"
|
|
1205
|
-
style={{ left: `${
|
|
1803
|
+
style={{ left: `${leftFraction * 100}%` }}
|
|
1206
1804
|
>
|
|
1207
1805
|
<Tooltip>
|
|
1208
1806
|
<TooltipTrigger asChild>
|
|
@@ -1214,7 +1812,7 @@ function KeyframeDiamond({
|
|
|
1214
1812
|
onPointerDown={onPointerDown}
|
|
1215
1813
|
onPointerEnter={() => setHovered(true)}
|
|
1216
1814
|
onPointerLeave={() => setHovered(false)}
|
|
1217
|
-
aria-label={
|
|
1815
|
+
aria-label={t("designEditor.motion.keyframeAt", { ms: timeMs })}
|
|
1218
1816
|
>
|
|
1219
1817
|
<rect
|
|
1220
1818
|
x={1}
|
|
@@ -1238,53 +1836,476 @@ function KeyframeDiamond({
|
|
|
1238
1836
|
className="flex items-center gap-1 text-[10px]"
|
|
1239
1837
|
>
|
|
1240
1838
|
<span>
|
|
1241
|
-
{
|
|
1242
|
-
{currentPreset?.label ?? currentEase}
|
|
1839
|
+
{timeMs}ms — {kf.value}
|
|
1243
1840
|
</span>
|
|
1244
1841
|
<button
|
|
1245
1842
|
type="button"
|
|
1246
1843
|
className="ml-1 text-destructive hover:text-destructive/80"
|
|
1247
1844
|
onClick={onDelete}
|
|
1248
|
-
aria-label="
|
|
1845
|
+
aria-label={t("designEditor.motion.deleteKeyframe")}
|
|
1249
1846
|
>
|
|
1250
1847
|
<IconTrash className="size-3" />
|
|
1251
1848
|
</button>
|
|
1252
1849
|
</TooltipContent>
|
|
1253
1850
|
</Tooltip>
|
|
1851
|
+
</div>
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
// ─── Segment easing (click the connector between two keyframes) ──────────────
|
|
1856
|
+
|
|
1857
|
+
interface EasingSegmentProps {
|
|
1858
|
+
/** Left edge as a fraction of the timeline. */
|
|
1859
|
+
left: number;
|
|
1860
|
+
/** Width as a fraction of the timeline. */
|
|
1861
|
+
width: number;
|
|
1862
|
+
/** The LEAVING keyframe's ease (undefined = timeline default). */
|
|
1863
|
+
ease: MotionEase | undefined;
|
|
1864
|
+
defaultEase: MotionEase;
|
|
1865
|
+
onEaseChange: (ease: MotionEase) => void;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* The clickable bar between two keyframe diamonds. Opens the easing panel
|
|
1870
|
+
* (Curve / Spring tabs) for the transition into the next keyframe — matching
|
|
1871
|
+
* Figma Motion's "click the connecting line" flow.
|
|
1872
|
+
*/
|
|
1873
|
+
function EasingSegment({
|
|
1874
|
+
left,
|
|
1875
|
+
width,
|
|
1876
|
+
ease,
|
|
1877
|
+
defaultEase,
|
|
1878
|
+
onEaseChange,
|
|
1879
|
+
}: EasingSegmentProps) {
|
|
1880
|
+
const t = useT();
|
|
1881
|
+
const effective = ease ?? defaultEase;
|
|
1882
|
+
const effectiveLabel = easeLabel(effective, t);
|
|
1883
|
+
return (
|
|
1884
|
+
<Popover>
|
|
1885
|
+
<PopoverTrigger asChild>
|
|
1886
|
+
<button
|
|
1887
|
+
type="button"
|
|
1888
|
+
className="absolute top-1/2 h-1.5 -translate-y-1/2 cursor-pointer rounded-full bg-primary/25 hover:bg-primary/50 data-[state=open]:bg-primary/60"
|
|
1889
|
+
style={{
|
|
1890
|
+
left: `${left * 100}%`,
|
|
1891
|
+
width: `${width * 100}%`,
|
|
1892
|
+
minWidth: 8,
|
|
1893
|
+
}}
|
|
1894
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
1895
|
+
aria-label={t("designEditor.motion.segmentEasing", {
|
|
1896
|
+
ease: effectiveLabel,
|
|
1897
|
+
})}
|
|
1898
|
+
title={effectiveLabel}
|
|
1899
|
+
/>
|
|
1900
|
+
</PopoverTrigger>
|
|
1901
|
+
<PopoverContent
|
|
1902
|
+
side="top"
|
|
1903
|
+
align="center"
|
|
1904
|
+
className="w-60 p-2"
|
|
1905
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
1906
|
+
>
|
|
1907
|
+
<EasingPanel ease={effective} onChange={onEaseChange} />
|
|
1908
|
+
</PopoverContent>
|
|
1909
|
+
</Popover>
|
|
1910
|
+
);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// ─── Easing panel (Curve / Spring tabs) ───────────────────────────────────────
|
|
1914
|
+
|
|
1915
|
+
interface EasingPanelProps {
|
|
1916
|
+
ease: MotionEase;
|
|
1917
|
+
onChange: (ease: MotionEase) => void;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
/**
|
|
1921
|
+
* Figma-Motion-parity easing editor:
|
|
1922
|
+
* - Curve tab: Hold, Linear, Ease in/out variants, back curves, and Custom
|
|
1923
|
+
* bezier with editable x1,y1,x2,y2 + a draggable curve editor.
|
|
1924
|
+
* - Spring tab: Gentle / Quick / Bouncy / Slow presets and Custom spring
|
|
1925
|
+
* with a single Bounce control (0–1, default 0.25).
|
|
1926
|
+
*/
|
|
1927
|
+
function EasingPanel({ ease, onChange }: EasingPanelProps) {
|
|
1928
|
+
const t = useT();
|
|
1929
|
+
const easeStr = String(ease);
|
|
1930
|
+
const spring = parseSpringToken(easeStr);
|
|
1931
|
+
const [tab, setTab] = useState<"curve" | "spring">(
|
|
1932
|
+
spring ? "spring" : "curve",
|
|
1933
|
+
);
|
|
1934
|
+
|
|
1935
|
+
const isCurvePreset = MOTION_CURVE_PRESETS.some(
|
|
1936
|
+
(preset) => preset.value === easeStr,
|
|
1937
|
+
);
|
|
1938
|
+
const bezierMatch = /^cubic-bezier\(([^)]+)\)$/.exec(easeStr);
|
|
1939
|
+
const bezierPoints: [number, number, number, number] = (() => {
|
|
1940
|
+
if (bezierMatch) {
|
|
1941
|
+
const parts = bezierMatch[1].split(",").map((part) => parseFloat(part));
|
|
1942
|
+
if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) {
|
|
1943
|
+
return [parts[0], parts[1], parts[2], parts[3]];
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
return [0.42, 0, 0.58, 1];
|
|
1947
|
+
})();
|
|
1948
|
+
const isCustomBezier = bezierMatch !== null && !isCurvePreset;
|
|
1949
|
+
|
|
1950
|
+
const springPreset = MOTION_SPRING_PRESETS.find(
|
|
1951
|
+
(preset) => preset.value === easeStr,
|
|
1952
|
+
);
|
|
1953
|
+
const isCustomSpring = spring !== null && !springPreset;
|
|
1954
|
+
const bounce = spring?.bounce ?? MOTION_SPRING_DEFAULT_BOUNCE;
|
|
1955
|
+
const settle = spring?.settle ?? 1;
|
|
1956
|
+
|
|
1957
|
+
const itemClass = (active: boolean) =>
|
|
1958
|
+
cn(
|
|
1959
|
+
"flex h-6 w-full cursor-pointer items-center gap-1 rounded px-2 text-left text-[11px] leading-none hover:bg-accent",
|
|
1960
|
+
active && "font-medium",
|
|
1961
|
+
);
|
|
1962
|
+
const checkClass = (active: boolean) =>
|
|
1963
|
+
cn("size-3 shrink-0", active ? "opacity-100" : "opacity-0");
|
|
1254
1964
|
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1965
|
+
return (
|
|
1966
|
+
<div className="flex flex-col gap-2">
|
|
1967
|
+
{/* Pill tab switch: Curve | Spring */}
|
|
1968
|
+
<div className="flex gap-0.5 rounded-md bg-muted p-0.5">
|
|
1969
|
+
{(
|
|
1970
|
+
[
|
|
1971
|
+
["curve", t("designEditor.motion.curveTab")],
|
|
1972
|
+
["spring", t("designEditor.motion.springTab")],
|
|
1973
|
+
] as const
|
|
1974
|
+
).map(([key, label]) => (
|
|
1258
1975
|
<button
|
|
1976
|
+
key={key}
|
|
1259
1977
|
type="button"
|
|
1260
|
-
className=
|
|
1261
|
-
|
|
1978
|
+
className={cn(
|
|
1979
|
+
"flex-1 cursor-pointer rounded px-2 py-1 text-[11px] leading-none",
|
|
1980
|
+
tab === key
|
|
1981
|
+
? "bg-background font-medium shadow-sm"
|
|
1982
|
+
: "text-muted-foreground hover:text-foreground",
|
|
1983
|
+
)}
|
|
1984
|
+
onClick={() => setTab(key)}
|
|
1985
|
+
aria-pressed={tab === key}
|
|
1262
1986
|
>
|
|
1263
|
-
|
|
1987
|
+
{label}
|
|
1264
1988
|
</button>
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
<
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1989
|
+
))}
|
|
1990
|
+
</div>
|
|
1991
|
+
|
|
1992
|
+
{tab === "curve" ? (
|
|
1993
|
+
<div className="flex flex-col gap-2">
|
|
1994
|
+
<div className="flex flex-col">
|
|
1995
|
+
{MOTION_CURVE_PRESETS.map((preset) => (
|
|
1996
|
+
<button
|
|
1997
|
+
key={preset.label}
|
|
1998
|
+
type="button"
|
|
1999
|
+
className={itemClass(preset.value === easeStr)}
|
|
2000
|
+
onClick={() => onChange(preset.value)}
|
|
2001
|
+
>
|
|
2002
|
+
<IconCheck className={checkClass(preset.value === easeStr)} />
|
|
2003
|
+
{preset.label}
|
|
2004
|
+
</button>
|
|
2005
|
+
))}
|
|
2006
|
+
<button
|
|
2007
|
+
type="button"
|
|
2008
|
+
className={itemClass(isCustomBezier)}
|
|
2009
|
+
onClick={() =>
|
|
2010
|
+
onChange(
|
|
2011
|
+
`cubic-bezier(${bezierPoints
|
|
2012
|
+
.map((n) => Math.round(n * 100) / 100)
|
|
2013
|
+
.join(", ")})`,
|
|
2014
|
+
)
|
|
2015
|
+
}
|
|
1276
2016
|
>
|
|
1277
|
-
<IconCheck
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
2017
|
+
<IconCheck className={checkClass(isCustomBezier)} />
|
|
2018
|
+
{t("designEditor.motion.customBezier")}
|
|
2019
|
+
</button>
|
|
2020
|
+
</div>
|
|
2021
|
+
{isCustomBezier && (
|
|
2022
|
+
<CurveEditor
|
|
2023
|
+
value={bezierPoints}
|
|
2024
|
+
onChange={(points) =>
|
|
2025
|
+
onChange(
|
|
2026
|
+
`cubic-bezier(${points
|
|
2027
|
+
.map((n) => Math.round(n * 100) / 100)
|
|
2028
|
+
.join(", ")})`,
|
|
2029
|
+
)
|
|
2030
|
+
}
|
|
2031
|
+
/>
|
|
2032
|
+
)}
|
|
2033
|
+
</div>
|
|
2034
|
+
) : (
|
|
2035
|
+
<div className="flex flex-col gap-2">
|
|
2036
|
+
<div className="flex flex-col">
|
|
2037
|
+
{MOTION_SPRING_PRESETS.map((preset) => (
|
|
2038
|
+
<button
|
|
2039
|
+
key={preset.label}
|
|
2040
|
+
type="button"
|
|
2041
|
+
className={itemClass(preset.value === easeStr)}
|
|
2042
|
+
onClick={() => onChange(preset.value)}
|
|
2043
|
+
>
|
|
2044
|
+
<IconCheck className={checkClass(preset.value === easeStr)} />
|
|
2045
|
+
{preset.label}
|
|
2046
|
+
</button>
|
|
2047
|
+
))}
|
|
2048
|
+
<button
|
|
2049
|
+
type="button"
|
|
2050
|
+
className={itemClass(isCustomSpring)}
|
|
2051
|
+
onClick={() =>
|
|
2052
|
+
onChange(
|
|
2053
|
+
springToken({
|
|
2054
|
+
bounce: MOTION_SPRING_DEFAULT_BOUNCE,
|
|
2055
|
+
settle: 1,
|
|
2056
|
+
}),
|
|
2057
|
+
)
|
|
2058
|
+
}
|
|
2059
|
+
>
|
|
2060
|
+
<IconCheck className={checkClass(isCustomSpring)} />
|
|
2061
|
+
{t("designEditor.motion.customSpring")}
|
|
2062
|
+
</button>
|
|
2063
|
+
</div>
|
|
2064
|
+
{isCustomSpring && (
|
|
2065
|
+
<div className="flex flex-col gap-1 px-1">
|
|
2066
|
+
<div className="flex items-center justify-between">
|
|
2067
|
+
<span className="text-[10px] text-muted-foreground">
|
|
2068
|
+
{t("designEditor.motion.bounce")}
|
|
2069
|
+
</span>
|
|
2070
|
+
<span className="text-[10px] tabular-nums">
|
|
2071
|
+
{bounce.toFixed(2)}
|
|
2072
|
+
</span>
|
|
2073
|
+
</div>
|
|
2074
|
+
<Slider
|
|
2075
|
+
value={[bounce]}
|
|
2076
|
+
min={0}
|
|
2077
|
+
max={1}
|
|
2078
|
+
step={0.01}
|
|
2079
|
+
onValueChange={(values) =>
|
|
2080
|
+
onChange(springToken({ bounce: values[0] ?? bounce, settle }))
|
|
2081
|
+
}
|
|
2082
|
+
aria-label={t("designEditor.motion.bounce")}
|
|
1282
2083
|
/>
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
2084
|
+
</div>
|
|
2085
|
+
)}
|
|
2086
|
+
{spring !== null && (
|
|
2087
|
+
<SpringCurvePreview bounce={bounce} settle={settle} />
|
|
2088
|
+
)}
|
|
2089
|
+
</div>
|
|
2090
|
+
)}
|
|
1288
2091
|
</div>
|
|
1289
2092
|
);
|
|
1290
2093
|
}
|
|
2094
|
+
|
|
2095
|
+
// ─── Custom bezier curve editor (draggable control points) ────────────────────
|
|
2096
|
+
|
|
2097
|
+
const CURVE_W = 216;
|
|
2098
|
+
const CURVE_H = 132;
|
|
2099
|
+
const CURVE_PAD = 10;
|
|
2100
|
+
// Vertical view range: y ∈ [-0.25, 1.25] so overshoot handles stay visible.
|
|
2101
|
+
const CURVE_Y_MIN = -0.25;
|
|
2102
|
+
const CURVE_Y_MAX = 1.25;
|
|
2103
|
+
|
|
2104
|
+
interface CurveEditorProps {
|
|
2105
|
+
value: [number, number, number, number];
|
|
2106
|
+
onChange: (value: [number, number, number, number]) => void;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
function CurveEditor({ value, onChange }: CurveEditorProps) {
|
|
2110
|
+
const t = useT();
|
|
2111
|
+
const [x1, y1, x2, y2] = value;
|
|
2112
|
+
const svgRef = useRef<SVGSVGElement>(null);
|
|
2113
|
+
const draggingHandle = useRef<1 | 2 | null>(null);
|
|
2114
|
+
|
|
2115
|
+
const xPx = (x: number) => CURVE_PAD + x * (CURVE_W - 2 * CURVE_PAD);
|
|
2116
|
+
const yPx = (y: number) =>
|
|
2117
|
+
CURVE_PAD +
|
|
2118
|
+
((CURVE_Y_MAX - y) / (CURVE_Y_MAX - CURVE_Y_MIN)) *
|
|
2119
|
+
(CURVE_H - 2 * CURVE_PAD);
|
|
2120
|
+
|
|
2121
|
+
const fromEvent = (e: ReactPointerEvent<SVGSVGElement>) => {
|
|
2122
|
+
const rect = svgRef.current?.getBoundingClientRect();
|
|
2123
|
+
if (!rect) return null;
|
|
2124
|
+
const px = ((e.clientX - rect.left) / rect.width) * CURVE_W;
|
|
2125
|
+
const py = ((e.clientY - rect.top) / rect.height) * CURVE_H;
|
|
2126
|
+
// CSS requires x control points in [0, 1]; y may overshoot.
|
|
2127
|
+
const x = Math.max(
|
|
2128
|
+
0,
|
|
2129
|
+
Math.min(1, (px - CURVE_PAD) / (CURVE_W - 2 * CURVE_PAD)),
|
|
2130
|
+
);
|
|
2131
|
+
const y =
|
|
2132
|
+
CURVE_Y_MAX -
|
|
2133
|
+
((py - CURVE_PAD) / (CURVE_H - 2 * CURVE_PAD)) *
|
|
2134
|
+
(CURVE_Y_MAX - CURVE_Y_MIN);
|
|
2135
|
+
return [x, Math.max(-1, Math.min(2, y))] as const;
|
|
2136
|
+
};
|
|
2137
|
+
|
|
2138
|
+
const handleMove = (e: ReactPointerEvent<SVGSVGElement>) => {
|
|
2139
|
+
if (!draggingHandle.current) return;
|
|
2140
|
+
const point = fromEvent(e);
|
|
2141
|
+
if (!point) return;
|
|
2142
|
+
onChange(
|
|
2143
|
+
draggingHandle.current === 1
|
|
2144
|
+
? [point[0], point[1], x2, y2]
|
|
2145
|
+
: [x1, y1, point[0], point[1]],
|
|
2146
|
+
);
|
|
2147
|
+
};
|
|
2148
|
+
|
|
2149
|
+
const numberField = (
|
|
2150
|
+
label: string,
|
|
2151
|
+
fieldValue: number,
|
|
2152
|
+
apply: (n: number) => [number, number, number, number],
|
|
2153
|
+
) => (
|
|
2154
|
+
<label className="flex flex-col items-stretch gap-0.5">
|
|
2155
|
+
<span className="text-center text-[9px] text-muted-foreground">
|
|
2156
|
+
{label}
|
|
2157
|
+
</span>
|
|
2158
|
+
<Input
|
|
2159
|
+
type="number"
|
|
2160
|
+
step={0.01}
|
|
2161
|
+
value={String(Math.round(fieldValue * 100) / 100)}
|
|
2162
|
+
onChange={(e) => {
|
|
2163
|
+
const n = parseFloat(e.target.value);
|
|
2164
|
+
if (Number.isFinite(n)) onChange(apply(n));
|
|
2165
|
+
}}
|
|
2166
|
+
className="h-5 px-1 !text-[10px] md:!text-[10px]"
|
|
2167
|
+
aria-label={label}
|
|
2168
|
+
/>
|
|
2169
|
+
</label>
|
|
2170
|
+
);
|
|
2171
|
+
|
|
2172
|
+
return (
|
|
2173
|
+
<div className="flex flex-col gap-1">
|
|
2174
|
+
<svg
|
|
2175
|
+
ref={svgRef}
|
|
2176
|
+
width="100%"
|
|
2177
|
+
viewBox={`0 0 ${CURVE_W} ${CURVE_H}`}
|
|
2178
|
+
className="rounded border border-border bg-muted/40 touch-none"
|
|
2179
|
+
onPointerMove={handleMove}
|
|
2180
|
+
onPointerUp={() => (draggingHandle.current = null)}
|
|
2181
|
+
onPointerCancel={() => (draggingHandle.current = null)}
|
|
2182
|
+
role="application"
|
|
2183
|
+
aria-label={t("designEditor.motion.bezierCurveEditor")}
|
|
2184
|
+
>
|
|
2185
|
+
{/* Unit box (0..1) */}
|
|
2186
|
+
<rect
|
|
2187
|
+
x={xPx(0)}
|
|
2188
|
+
y={yPx(1)}
|
|
2189
|
+
width={xPx(1) - xPx(0)}
|
|
2190
|
+
height={yPx(0) - yPx(1)}
|
|
2191
|
+
className="fill-transparent stroke-border"
|
|
2192
|
+
strokeDasharray="3 3"
|
|
2193
|
+
/>
|
|
2194
|
+
{/* Handle arms */}
|
|
2195
|
+
<line
|
|
2196
|
+
x1={xPx(0)}
|
|
2197
|
+
y1={yPx(0)}
|
|
2198
|
+
x2={xPx(Math.max(0, Math.min(1, x1)))}
|
|
2199
|
+
y2={yPx(y1)}
|
|
2200
|
+
className="stroke-primary/50"
|
|
2201
|
+
/>
|
|
2202
|
+
<line
|
|
2203
|
+
x1={xPx(1)}
|
|
2204
|
+
y1={yPx(1)}
|
|
2205
|
+
x2={xPx(Math.max(0, Math.min(1, x2)))}
|
|
2206
|
+
y2={yPx(y2)}
|
|
2207
|
+
className="stroke-primary/50"
|
|
2208
|
+
/>
|
|
2209
|
+
{/* The curve itself */}
|
|
2210
|
+
<path
|
|
2211
|
+
d={`M ${xPx(0)} ${yPx(0)} C ${xPx(Math.max(0, Math.min(1, x1)))} ${yPx(y1)}, ${xPx(Math.max(0, Math.min(1, x2)))} ${yPx(y2)}, ${xPx(1)} ${yPx(1)}`}
|
|
2212
|
+
className="fill-none stroke-primary"
|
|
2213
|
+
strokeWidth={1.5}
|
|
2214
|
+
/>
|
|
2215
|
+
{/* Draggable control points */}
|
|
2216
|
+
<circle
|
|
2217
|
+
cx={xPx(Math.max(0, Math.min(1, x1)))}
|
|
2218
|
+
cy={yPx(y1)}
|
|
2219
|
+
r={5}
|
|
2220
|
+
className="cursor-grab fill-primary stroke-background active:cursor-grabbing"
|
|
2221
|
+
onPointerDown={(e) => {
|
|
2222
|
+
draggingHandle.current = 1;
|
|
2223
|
+
(e.target as Element).setPointerCapture(e.pointerId);
|
|
2224
|
+
}}
|
|
2225
|
+
/>
|
|
2226
|
+
<circle
|
|
2227
|
+
cx={xPx(Math.max(0, Math.min(1, x2)))}
|
|
2228
|
+
cy={yPx(y2)}
|
|
2229
|
+
r={5}
|
|
2230
|
+
className="cursor-grab fill-primary stroke-background active:cursor-grabbing"
|
|
2231
|
+
onPointerDown={(e) => {
|
|
2232
|
+
draggingHandle.current = 2;
|
|
2233
|
+
(e.target as Element).setPointerCapture(e.pointerId);
|
|
2234
|
+
}}
|
|
2235
|
+
/>
|
|
2236
|
+
</svg>
|
|
2237
|
+
<div className="grid grid-cols-4 gap-1">
|
|
2238
|
+
{numberField("x1", x1, (n) => [
|
|
2239
|
+
Math.max(0, Math.min(1, n)),
|
|
2240
|
+
y1,
|
|
2241
|
+
x2,
|
|
2242
|
+
y2,
|
|
2243
|
+
])}
|
|
2244
|
+
{numberField("y1", y1, (n) => [x1, n, x2, y2])}
|
|
2245
|
+
{numberField("x2", x2, (n) => [
|
|
2246
|
+
x1,
|
|
2247
|
+
y1,
|
|
2248
|
+
Math.max(0, Math.min(1, n)),
|
|
2249
|
+
y2,
|
|
2250
|
+
])}
|
|
2251
|
+
{numberField("y2", y2, (n) => [x1, y1, x2, n])}
|
|
2252
|
+
</div>
|
|
2253
|
+
</div>
|
|
2254
|
+
);
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
// ─── Spring curve preview ─────────────────────────────────────────────────────
|
|
2258
|
+
|
|
2259
|
+
function SpringCurvePreview({
|
|
2260
|
+
bounce,
|
|
2261
|
+
settle,
|
|
2262
|
+
}: {
|
|
2263
|
+
bounce: number;
|
|
2264
|
+
settle: number;
|
|
2265
|
+
}) {
|
|
2266
|
+
const t = useT();
|
|
2267
|
+
const points: string[] = [];
|
|
2268
|
+
const samples = 72;
|
|
2269
|
+
for (let i = 0; i <= samples; i++) {
|
|
2270
|
+
const x = i / samples;
|
|
2271
|
+
const y = sampleSpring({ bounce, settle }, x);
|
|
2272
|
+
points.push(
|
|
2273
|
+
`${(CURVE_PAD + x * (CURVE_W - 2 * CURVE_PAD)).toFixed(1)},${(
|
|
2274
|
+
CURVE_PAD +
|
|
2275
|
+
((CURVE_Y_MAX - y) / (CURVE_Y_MAX - CURVE_Y_MIN)) *
|
|
2276
|
+
(CURVE_H - 2 * CURVE_PAD)
|
|
2277
|
+
).toFixed(1)}`,
|
|
2278
|
+
);
|
|
2279
|
+
}
|
|
2280
|
+
return (
|
|
2281
|
+
<svg
|
|
2282
|
+
width="100%"
|
|
2283
|
+
viewBox={`0 0 ${CURVE_W} ${CURVE_H}`}
|
|
2284
|
+
className="rounded border border-border bg-muted/40"
|
|
2285
|
+
aria-label={t("designEditor.motion.springCurvePreview")}
|
|
2286
|
+
role="img"
|
|
2287
|
+
>
|
|
2288
|
+
<line
|
|
2289
|
+
x1={CURVE_PAD}
|
|
2290
|
+
y1={
|
|
2291
|
+
CURVE_PAD +
|
|
2292
|
+
((CURVE_Y_MAX - 1) / (CURVE_Y_MAX - CURVE_Y_MIN)) *
|
|
2293
|
+
(CURVE_H - 2 * CURVE_PAD)
|
|
2294
|
+
}
|
|
2295
|
+
x2={CURVE_W - CURVE_PAD}
|
|
2296
|
+
y2={
|
|
2297
|
+
CURVE_PAD +
|
|
2298
|
+
((CURVE_Y_MAX - 1) / (CURVE_Y_MAX - CURVE_Y_MIN)) *
|
|
2299
|
+
(CURVE_H - 2 * CURVE_PAD)
|
|
2300
|
+
}
|
|
2301
|
+
className="stroke-border"
|
|
2302
|
+
strokeDasharray="3 3"
|
|
2303
|
+
/>
|
|
2304
|
+
<polyline
|
|
2305
|
+
points={points.join(" ")}
|
|
2306
|
+
className="fill-none stroke-primary"
|
|
2307
|
+
strokeWidth={1.5}
|
|
2308
|
+
/>
|
|
2309
|
+
</svg>
|
|
2310
|
+
);
|
|
2311
|
+
}
|