@agent-native/core 0.85.5 → 0.85.7
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 +12 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/AssistantChat.tsx +1 -1
- package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
- package/corpus/core/src/collab/presence.ts +63 -3
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
- package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
- package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
- package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
- package/corpus/templates/design/AGENTS.md +40 -4
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
- package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
- package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
- package/corpus/templates/design/actions/connect-localhost.ts +25 -18
- package/corpus/templates/design/actions/edit-design.ts +5 -3
- package/corpus/templates/design/actions/export-pdf.ts +8 -1
- package/corpus/templates/design/actions/export-zip.ts +10 -1
- package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
- package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
- package/corpus/templates/design/actions/insert-asset.ts +190 -7
- package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
- package/corpus/templates/design/actions/present-design-variants.ts +12 -8
- package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
- package/corpus/templates/design/actions/run-design-audit.ts +7 -4
- package/corpus/templates/design/actions/write-local-file.ts +47 -22
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
- package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
- package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1520 -410
- package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
- package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
- package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
- package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
- package/corpus/templates/design/app/components/design/types.ts +16 -0
- package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
- package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
- package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/design/app/i18n-data.ts +18 -0
- package/corpus/templates/design/app/lib/design-import.ts +95 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +4015 -840
- package/corpus/templates/design/changelog/2026-07-02-creating-two-frames-in-a-row-no-longer-freezes-and-reloads-t.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-design-retries-stalled-screen-edits-with-a-smaller-safer-fol.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
- package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
- package/corpus/templates/design/server/lib/design-export.ts +43 -1
- package/corpus/templates/design/server/plugins/agent-chat.ts +1 -1
- package/corpus/templates/design/shared/board-file.ts +25 -5
- package/corpus/templates/design/shared/canvas-math.ts +754 -9
- package/corpus/templates/design/shared/code-layer.ts +304 -26
- package/corpus/templates/design/shared/color-utils.ts +84 -0
- package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
- package/corpus/templates/design/shared/motion-compiler.ts +75 -31
- package/corpus/templates/design/shared/motion-timeline.ts +335 -0
- package/corpus/templates/design/shared/pen-path.ts +200 -23
- package/dist/client/AssistantChat.js +1 -1
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/use-pinch-zoom.d.ts.map +1 -1
- package/dist/client/use-pinch-zoom.js +76 -11
- package/dist/client/use-pinch-zoom.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/presence.d.ts.map +1 -1
- package/dist/collab/presence.js +54 -3
- package/dist/collab/presence.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/observability/routes.d.ts +2 -2
- package/dist/secrets/routes.d.ts +3 -3
- package/package.json +1 -1
|
@@ -27,11 +27,15 @@ import {
|
|
|
27
27
|
MOTION_PROPERTY_PRESETS,
|
|
28
28
|
createMotionTrackFromPreset,
|
|
29
29
|
hasTrackFor,
|
|
30
|
+
sampleMotionKeyframesAt,
|
|
31
|
+
sortMotionKeyframes,
|
|
32
|
+
upsertMotionKeyframeAtTime,
|
|
30
33
|
} from "@shared/motion-timeline";
|
|
31
34
|
import {
|
|
32
35
|
IconPlayerPlay,
|
|
33
36
|
IconPlayerPause,
|
|
34
37
|
IconPlayerStop,
|
|
38
|
+
IconCheck,
|
|
35
39
|
IconDiamond,
|
|
36
40
|
IconChevronDown,
|
|
37
41
|
IconChevronRight,
|
|
@@ -49,6 +53,7 @@ import {
|
|
|
49
53
|
type PointerEvent as ReactPointerEvent,
|
|
50
54
|
type TransitionEvent as ReactTransitionEvent,
|
|
51
55
|
} from "react";
|
|
56
|
+
import { toast } from "sonner";
|
|
52
57
|
|
|
53
58
|
import { Button } from "@/components/ui/button";
|
|
54
59
|
import {
|
|
@@ -123,8 +128,23 @@ export interface MotionDockProps {
|
|
|
123
128
|
onAutoKeyframeChange?: (enabled: boolean) => void;
|
|
124
129
|
/** Controlled playhead position, normalized to [0, 1]. */
|
|
125
130
|
playhead?: number;
|
|
126
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Called when the playhead position COMMITS (pause/stop, scrub end, reset)
|
|
133
|
+
* — deliberately not on every rAF tick/scrub frame, so the parent is never
|
|
134
|
+
* re-rendered at 60fps. Continuous preview stays inside the dock.
|
|
135
|
+
*/
|
|
127
136
|
onPlayheadChange?: (t: number) => void;
|
|
137
|
+
/**
|
|
138
|
+
* Parent-owned mirror of the LIVE playhead position, updated on every rAF
|
|
139
|
+
* tick and scrub frame (not just at commit points). The dock only notifies
|
|
140
|
+
* the parent's state at commit points to avoid 60fps re-renders, but
|
|
141
|
+
* auto-keyframe needs the true current position — an inspector edit made
|
|
142
|
+
* mid-playback must key at where the playhead actually is, not at the last
|
|
143
|
+
* committed time. Writing to a ref keeps that value fresh without
|
|
144
|
+
* re-rendering the editor. Cleared back to the committed value on
|
|
145
|
+
* pause/stop/scrub-end so a later read outside playback isn't stale.
|
|
146
|
+
*/
|
|
147
|
+
livePlayheadRef?: React.MutableRefObject<number | null>;
|
|
128
148
|
/**
|
|
129
149
|
* The currently-selected canvas element, if any. Required to create the FIRST
|
|
130
150
|
* track for a layer: the picker animates this node's
|
|
@@ -151,6 +171,7 @@ export function MotionDock({
|
|
|
151
171
|
onAutoKeyframeChange,
|
|
152
172
|
playhead: playheadProp,
|
|
153
173
|
onPlayheadChange,
|
|
174
|
+
livePlayheadRef,
|
|
154
175
|
selectedTarget = null,
|
|
155
176
|
}: MotionDockProps) {
|
|
156
177
|
// Controlled / uncontrolled open state.
|
|
@@ -164,15 +185,24 @@ export function MotionDock({
|
|
|
164
185
|
[onOpenChange],
|
|
165
186
|
);
|
|
166
187
|
|
|
167
|
-
// Playhead position: normalised [0, 1].
|
|
188
|
+
// Playhead position: normalised [0, 1]. High-frequency updates (rAF playback
|
|
189
|
+
// ticks, ruler scrubbing) stay INTERNAL to the dock; the parent is only
|
|
190
|
+
// notified at commit points (pause/stop, scrub end, reset) so a 60fps
|
|
191
|
+
// playhead never re-renders the whole editor page.
|
|
168
192
|
const [playhead, setPlayhead] = useState(playheadProp ?? 0);
|
|
193
|
+
const playheadRef = useRef(playheadProp ?? 0);
|
|
169
194
|
const [playing, setPlaying] = useState(false);
|
|
170
195
|
const playRafRef = useRef<number | null>(null);
|
|
171
196
|
const playStartRef = useRef<{ wallMs: number; startT: number } | null>(null);
|
|
172
197
|
useEffect(() => {
|
|
173
198
|
if (playheadProp === undefined) return;
|
|
174
|
-
|
|
175
|
-
|
|
199
|
+
const clamped = Math.max(0, Math.min(1, playheadProp));
|
|
200
|
+
playheadRef.current = clamped;
|
|
201
|
+
// Keep the live-playhead mirror seeded with the committed position so a
|
|
202
|
+
// read outside playback/scrub returns the current time, not a stale one.
|
|
203
|
+
if (livePlayheadRef) livePlayheadRef.current = clamped;
|
|
204
|
+
setPlayhead(clamped);
|
|
205
|
+
}, [livePlayheadRef, playheadProp]);
|
|
176
206
|
|
|
177
207
|
// Auto-keyframe mode: inspector/style edits create keyframes at the playhead.
|
|
178
208
|
const [autoKeyframeInternal, setAutoKeyframeInternal] = useState(false);
|
|
@@ -188,13 +218,24 @@ export function MotionDock({
|
|
|
188
218
|
},
|
|
189
219
|
[autoKeyframe, onAutoKeyframeChange],
|
|
190
220
|
);
|
|
191
|
-
|
|
221
|
+
/** Internal high-frequency playhead update — does NOT notify the parent. */
|
|
222
|
+
const setPlayheadLocal = useCallback(
|
|
192
223
|
(next: number) => {
|
|
224
|
+
playheadRef.current = next;
|
|
225
|
+
// Mirror the LIVE position into the parent-owned ref so auto-keyframe
|
|
226
|
+
// reads the true current playhead during playback/scrub, not the last
|
|
227
|
+
// committed time. Updating a ref never re-renders, so this stays cheap
|
|
228
|
+
// at 60fps.
|
|
229
|
+
if (livePlayheadRef) livePlayheadRef.current = next;
|
|
193
230
|
setPlayhead(next);
|
|
194
|
-
onPlayheadChange?.(next);
|
|
195
231
|
},
|
|
196
|
-
[
|
|
232
|
+
[livePlayheadRef],
|
|
197
233
|
);
|
|
234
|
+
/** Commit the current playhead to the parent (pause, scrub end, reset). */
|
|
235
|
+
const commitPlayhead = useCallback(() => {
|
|
236
|
+
if (livePlayheadRef) livePlayheadRef.current = playheadRef.current;
|
|
237
|
+
onPlayheadChange?.(playheadRef.current);
|
|
238
|
+
}, [livePlayheadRef, onPlayheadChange]);
|
|
198
239
|
|
|
199
240
|
// Dock height (resizable via the top drag handle).
|
|
200
241
|
const [dockHeight, setDockHeight] = useState(DEFAULT_DOCK_HEIGHT);
|
|
@@ -258,7 +299,9 @@ export function MotionDock({
|
|
|
258
299
|
}
|
|
259
300
|
playStartRef.current = null;
|
|
260
301
|
setPlaying(false);
|
|
261
|
-
|
|
302
|
+
// Pause/stop is a commit point: hand the final position to the parent.
|
|
303
|
+
commitPlayhead();
|
|
304
|
+
}, [commitPlayhead]);
|
|
262
305
|
|
|
263
306
|
const startPlayback = useCallback(() => {
|
|
264
307
|
stopPlayback();
|
|
@@ -270,7 +313,7 @@ export function MotionDock({
|
|
|
270
313
|
if (!playStartRef.current) return;
|
|
271
314
|
const elapsed = now - playStartRef.current.wallMs;
|
|
272
315
|
const t = Math.min(1, playStartRef.current.startT + elapsed / durationMs);
|
|
273
|
-
|
|
316
|
+
setPlayheadLocal(t);
|
|
274
317
|
sendPreview(t);
|
|
275
318
|
if (t < 1) {
|
|
276
319
|
playRafRef.current = requestAnimationFrame(tick);
|
|
@@ -279,7 +322,7 @@ export function MotionDock({
|
|
|
279
322
|
}
|
|
280
323
|
};
|
|
281
324
|
playRafRef.current = requestAnimationFrame(tick);
|
|
282
|
-
}, [durationMs, playhead, sendPreview,
|
|
325
|
+
}, [durationMs, playhead, sendPreview, setPlayheadLocal, stopPlayback]);
|
|
283
326
|
|
|
284
327
|
useEffect(() => {
|
|
285
328
|
return () => {
|
|
@@ -298,10 +341,10 @@ export function MotionDock({
|
|
|
298
341
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
299
342
|
const rect = trackAreaRef.current.getBoundingClientRect();
|
|
300
343
|
const t = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
301
|
-
|
|
344
|
+
setPlayheadLocal(t);
|
|
302
345
|
sendPreview(t);
|
|
303
346
|
},
|
|
304
|
-
[sendPreview,
|
|
347
|
+
[sendPreview, setPlayheadLocal, stopPlayback],
|
|
305
348
|
);
|
|
306
349
|
|
|
307
350
|
const handleRulerPointerMove = useCallback(
|
|
@@ -309,15 +352,18 @@ export function MotionDock({
|
|
|
309
352
|
if (!isDraggingPlayhead.current || !trackAreaRef.current) return;
|
|
310
353
|
const rect = trackAreaRef.current.getBoundingClientRect();
|
|
311
354
|
const t = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
312
|
-
|
|
355
|
+
setPlayheadLocal(t);
|
|
313
356
|
sendPreview(t);
|
|
314
357
|
},
|
|
315
|
-
[sendPreview,
|
|
358
|
+
[sendPreview, setPlayheadLocal],
|
|
316
359
|
);
|
|
317
360
|
|
|
318
361
|
const handleRulerPointerUp = useCallback(() => {
|
|
362
|
+
if (!isDraggingPlayhead.current) return;
|
|
319
363
|
isDraggingPlayhead.current = false;
|
|
320
|
-
|
|
364
|
+
// Scrub end is a commit point.
|
|
365
|
+
commitPlayhead();
|
|
366
|
+
}, [commitPlayhead]);
|
|
321
367
|
|
|
322
368
|
// ── Dock resize drag ─────────────────────────────────────────────────────
|
|
323
369
|
const handleResizePointerDown = useCallback(
|
|
@@ -368,14 +414,25 @@ export function MotionDock({
|
|
|
368
414
|
|
|
369
415
|
const addKeyframe = useCallback(
|
|
370
416
|
(track: MotionDockTrack) => {
|
|
417
|
+
// Seed the new keyframe with the track's interpolated value at the
|
|
418
|
+
// playhead (what the preview currently shows) — a hardcoded "0" is
|
|
419
|
+
// invalid CSS for transform/filter/color tracks and snaps the preview.
|
|
420
|
+
const sampled = sampleMotionKeyframesAt(
|
|
421
|
+
track.keyframes,
|
|
422
|
+
playhead,
|
|
423
|
+
defaultEase,
|
|
424
|
+
);
|
|
371
425
|
const newKf: MotionKeyframe = {
|
|
372
426
|
t: playhead,
|
|
373
|
-
value: "0",
|
|
427
|
+
value: sampled || "0",
|
|
374
428
|
ease: defaultEase,
|
|
375
429
|
};
|
|
376
430
|
updateTrack(track.targetNodeId, track.property, (tr) => ({
|
|
377
431
|
...tr,
|
|
378
|
-
|
|
432
|
+
// Epsilon replace-at-time: adding at (nearly) the same playhead
|
|
433
|
+
// position replaces the existing stop instead of stacking an
|
|
434
|
+
// invisible duplicate diamond.
|
|
435
|
+
keyframes: upsertMotionKeyframeAtTime(tr.keyframes, newKf),
|
|
379
436
|
}));
|
|
380
437
|
},
|
|
381
438
|
[defaultEase, playhead, updateTrack],
|
|
@@ -400,7 +457,13 @@ export function MotionDock({
|
|
|
400
457
|
});
|
|
401
458
|
|
|
402
459
|
if (hasTrackFor(tracks, nodeId, preset.property)) {
|
|
403
|
-
// Track already exists — do not duplicate; the expand above surfaces
|
|
460
|
+
// Track already exists — do not duplicate; the expand above surfaces
|
|
461
|
+
// it, and an explicit notice explains why nothing new appeared (two
|
|
462
|
+
// presets can target the same property, e.g. slide + scale are both
|
|
463
|
+
// "transform").
|
|
464
|
+
toast.info(
|
|
465
|
+
`A "${preset.property}" track already exists for ${label}. Edit its keyframes in the timeline instead.`,
|
|
466
|
+
);
|
|
404
467
|
return;
|
|
405
468
|
}
|
|
406
469
|
|
|
@@ -412,10 +475,63 @@ export function MotionDock({
|
|
|
412
475
|
);
|
|
413
476
|
|
|
414
477
|
const deleteKeyframe = useCallback(
|
|
415
|
-
(track: MotionDockTrack,
|
|
478
|
+
(track: MotionDockTrack, index: number) => {
|
|
479
|
+
if (!onTracksChange) return;
|
|
480
|
+
if (track.keyframes.length <= 1) {
|
|
481
|
+
// Deleting the last keyframe removes the whole track: a 0-keyframe
|
|
482
|
+
// track cannot compile and is rejected by apply-motion-edit, which
|
|
483
|
+
// would brick every subsequent autosave with no UI to recover.
|
|
484
|
+
onTracksChange(
|
|
485
|
+
tracks.filter(
|
|
486
|
+
(tr) =>
|
|
487
|
+
!(
|
|
488
|
+
tr.targetNodeId === track.targetNodeId &&
|
|
489
|
+
tr.property === track.property
|
|
490
|
+
),
|
|
491
|
+
),
|
|
492
|
+
);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
updateTrack(track.targetNodeId, track.property, (tr) => ({
|
|
496
|
+
...tr,
|
|
497
|
+
keyframes: tr.keyframes.filter((_, i) => i !== index),
|
|
498
|
+
}));
|
|
499
|
+
},
|
|
500
|
+
[onTracksChange, tracks, updateTrack],
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
const moveKeyframe = useCallback(
|
|
504
|
+
(track: MotionDockTrack, index: number, newT: number) => {
|
|
416
505
|
updateTrack(track.targetNodeId, track.property, (tr) => ({
|
|
417
506
|
...tr,
|
|
418
|
-
keyframes: tr.keyframes.
|
|
507
|
+
keyframes: tr.keyframes.map((kf, i) =>
|
|
508
|
+
i === index ? { ...kf, t: newT } : kf,
|
|
509
|
+
),
|
|
510
|
+
}));
|
|
511
|
+
},
|
|
512
|
+
[updateTrack],
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
// Re-sort a track's keyframes once a drag finishes. Sorting DURING the drag
|
|
516
|
+
// would reshuffle the dragged keyframe's index mid-gesture; the preview
|
|
517
|
+
// bridge and compiler sort defensively, so drag-time order is safe.
|
|
518
|
+
const moveKeyframeEnd = useCallback(
|
|
519
|
+
(track: MotionDockTrack) => {
|
|
520
|
+
updateTrack(track.targetNodeId, track.property, (tr) => ({
|
|
521
|
+
...tr,
|
|
522
|
+
keyframes: sortMotionKeyframes(tr.keyframes),
|
|
523
|
+
}));
|
|
524
|
+
},
|
|
525
|
+
[updateTrack],
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
const easeKeyframe = useCallback(
|
|
529
|
+
(track: MotionDockTrack, index: number, ease: MotionEase) => {
|
|
530
|
+
updateTrack(track.targetNodeId, track.property, (tr) => ({
|
|
531
|
+
...tr,
|
|
532
|
+
keyframes: tr.keyframes.map((kf, i) =>
|
|
533
|
+
i === index ? { ...kf, ease } : kf,
|
|
534
|
+
),
|
|
419
535
|
}));
|
|
420
536
|
},
|
|
421
537
|
[updateTrack],
|
|
@@ -528,7 +644,8 @@ export function MotionDock({
|
|
|
528
644
|
className="size-6 shrink-0"
|
|
529
645
|
onClick={() => {
|
|
530
646
|
stopPlayback();
|
|
531
|
-
|
|
647
|
+
setPlayheadLocal(0);
|
|
648
|
+
commitPlayhead();
|
|
532
649
|
sendPreview(0);
|
|
533
650
|
}}
|
|
534
651
|
aria-label="Reset playhead"
|
|
@@ -708,14 +825,9 @@ export function MotionDock({
|
|
|
708
825
|
expanded={expandedNodeIds.has(layer.nodeId)}
|
|
709
826
|
playhead={playhead}
|
|
710
827
|
onDeleteKeyframe={deleteKeyframe}
|
|
711
|
-
onMoveKeyframe={
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
keyframes: tr.keyframes.map((k) =>
|
|
715
|
-
k === kf ? { ...k, t: newT } : k,
|
|
716
|
-
),
|
|
717
|
-
}));
|
|
718
|
-
}}
|
|
828
|
+
onMoveKeyframe={moveKeyframe}
|
|
829
|
+
onMoveKeyframeEnd={moveKeyframeEnd}
|
|
830
|
+
onEaseKeyframe={easeKeyframe}
|
|
719
831
|
/>
|
|
720
832
|
))}
|
|
721
833
|
|
|
@@ -933,11 +1045,13 @@ interface LayerTrackRowsProps {
|
|
|
933
1045
|
layer: { nodeId: string; label: string; tracks: MotionDockTrack[] };
|
|
934
1046
|
expanded: boolean;
|
|
935
1047
|
playhead: number;
|
|
936
|
-
onDeleteKeyframe: (track: MotionDockTrack,
|
|
937
|
-
onMoveKeyframe: (
|
|
1048
|
+
onDeleteKeyframe: (track: MotionDockTrack, index: number) => void;
|
|
1049
|
+
onMoveKeyframe: (track: MotionDockTrack, index: number, newT: number) => void;
|
|
1050
|
+
onMoveKeyframeEnd: (track: MotionDockTrack) => void;
|
|
1051
|
+
onEaseKeyframe: (
|
|
938
1052
|
track: MotionDockTrack,
|
|
939
|
-
|
|
940
|
-
|
|
1053
|
+
index: number,
|
|
1054
|
+
ease: MotionEase,
|
|
941
1055
|
) => void;
|
|
942
1056
|
}
|
|
943
1057
|
|
|
@@ -947,6 +1061,8 @@ function LayerTrackRows({
|
|
|
947
1061
|
playhead: _playhead,
|
|
948
1062
|
onDeleteKeyframe,
|
|
949
1063
|
onMoveKeyframe,
|
|
1064
|
+
onMoveKeyframeEnd,
|
|
1065
|
+
onEaseKeyframe,
|
|
950
1066
|
}: LayerTrackRowsProps) {
|
|
951
1067
|
return (
|
|
952
1068
|
<>
|
|
@@ -962,8 +1078,10 @@ function LayerTrackRows({
|
|
|
962
1078
|
<TrackRow
|
|
963
1079
|
key={`${track.targetNodeId}-${track.property}`}
|
|
964
1080
|
track={track}
|
|
965
|
-
onDeleteKeyframe={(
|
|
966
|
-
onMoveKeyframe={(
|
|
1081
|
+
onDeleteKeyframe={(index) => onDeleteKeyframe(track, index)}
|
|
1082
|
+
onMoveKeyframe={(index, newT) => onMoveKeyframe(track, index, newT)}
|
|
1083
|
+
onMoveKeyframeEnd={() => onMoveKeyframeEnd(track)}
|
|
1084
|
+
onEaseKeyframe={(index, ease) => onEaseKeyframe(track, index, ease)}
|
|
967
1085
|
/>
|
|
968
1086
|
))}
|
|
969
1087
|
</>
|
|
@@ -972,22 +1090,44 @@ function LayerTrackRows({
|
|
|
972
1090
|
|
|
973
1091
|
interface TrackRowProps {
|
|
974
1092
|
track: MotionDockTrack;
|
|
975
|
-
onDeleteKeyframe: (
|
|
976
|
-
onMoveKeyframe: (
|
|
1093
|
+
onDeleteKeyframe: (index: number) => void;
|
|
1094
|
+
onMoveKeyframe: (index: number, newT: number) => void;
|
|
1095
|
+
onMoveKeyframeEnd: () => void;
|
|
1096
|
+
onEaseKeyframe: (index: number, ease: MotionEase) => void;
|
|
977
1097
|
}
|
|
978
1098
|
|
|
979
|
-
function TrackRow({
|
|
1099
|
+
function TrackRow({
|
|
1100
|
+
track,
|
|
1101
|
+
onDeleteKeyframe,
|
|
1102
|
+
onMoveKeyframe,
|
|
1103
|
+
onMoveKeyframeEnd,
|
|
1104
|
+
onEaseKeyframe,
|
|
1105
|
+
}: TrackRowProps) {
|
|
980
1106
|
const rowRef = useRef<HTMLDivElement>(null);
|
|
1107
|
+
// Identify the dragged keyframe by its INDEX in track.keyframes, not object
|
|
1108
|
+
// identity: the parent replaces keyframe objects on every move (immutable
|
|
1109
|
+
// update), so a captured object reference goes stale after the first move
|
|
1110
|
+
// and every subsequent pointermove would match nothing (frozen keyframe).
|
|
981
1111
|
const dragging = useRef<{
|
|
982
|
-
|
|
1112
|
+
index: number;
|
|
983
1113
|
startX: number;
|
|
984
1114
|
startT: number;
|
|
1115
|
+
moved: boolean;
|
|
985
1116
|
} | null>(null);
|
|
986
1117
|
|
|
987
1118
|
const handleDiamondPointerDown = useCallback(
|
|
988
|
-
(
|
|
1119
|
+
(
|
|
1120
|
+
e: ReactPointerEvent<SVGSVGElement>,
|
|
1121
|
+
index: number,
|
|
1122
|
+
kf: MotionKeyframe,
|
|
1123
|
+
) => {
|
|
989
1124
|
e.stopPropagation();
|
|
990
|
-
dragging.current = {
|
|
1125
|
+
dragging.current = {
|
|
1126
|
+
index,
|
|
1127
|
+
startX: e.clientX,
|
|
1128
|
+
startT: kf.t,
|
|
1129
|
+
moved: false,
|
|
1130
|
+
};
|
|
991
1131
|
(e.target as Element).setPointerCapture(e.pointerId);
|
|
992
1132
|
},
|
|
993
1133
|
[],
|
|
@@ -1000,14 +1140,19 @@ function TrackRow({ track, onDeleteKeyframe, onMoveKeyframe }: TrackRowProps) {
|
|
|
1000
1140
|
const dx = e.clientX - dragging.current.startX;
|
|
1001
1141
|
const dt = dx / rect.width;
|
|
1002
1142
|
const newT = Math.max(0, Math.min(1, dragging.current.startT + dt));
|
|
1003
|
-
|
|
1143
|
+
dragging.current.moved = true;
|
|
1144
|
+
onMoveKeyframe(dragging.current.index, newT);
|
|
1004
1145
|
},
|
|
1005
1146
|
[onMoveKeyframe],
|
|
1006
1147
|
);
|
|
1007
1148
|
|
|
1008
1149
|
const handlePointerUp = useCallback(() => {
|
|
1150
|
+
const wasMoved = dragging.current?.moved === true;
|
|
1009
1151
|
dragging.current = null;
|
|
1010
|
-
|
|
1152
|
+
// Only re-sort (and re-mark dirty) when the keyframe actually moved — a
|
|
1153
|
+
// plain click on a diamond must not trigger an autosave.
|
|
1154
|
+
if (wasMoved) onMoveKeyframeEnd();
|
|
1155
|
+
}, [onMoveKeyframeEnd]);
|
|
1011
1156
|
|
|
1012
1157
|
return (
|
|
1013
1158
|
<div
|
|
@@ -1016,6 +1161,7 @@ function TrackRow({ track, onDeleteKeyframe, onMoveKeyframe }: TrackRowProps) {
|
|
|
1016
1161
|
style={{ height: ROW_HEIGHT }}
|
|
1017
1162
|
onPointerMove={handlePointerMove}
|
|
1018
1163
|
onPointerUp={handlePointerUp}
|
|
1164
|
+
onPointerCancel={handlePointerUp}
|
|
1019
1165
|
>
|
|
1020
1166
|
{/* Track baseline */}
|
|
1021
1167
|
<div className="absolute inset-y-1/2 left-0 right-0 h-px bg-border/50" />
|
|
@@ -1025,8 +1171,9 @@ function TrackRow({ track, onDeleteKeyframe, onMoveKeyframe }: TrackRowProps) {
|
|
|
1025
1171
|
<KeyframeDiamond
|
|
1026
1172
|
key={i}
|
|
1027
1173
|
kf={kf}
|
|
1028
|
-
onPointerDown={(e) => handleDiamondPointerDown(e, kf)}
|
|
1029
|
-
onDelete={() => onDeleteKeyframe(
|
|
1174
|
+
onPointerDown={(e) => handleDiamondPointerDown(e, i, kf)}
|
|
1175
|
+
onDelete={() => onDeleteKeyframe(i)}
|
|
1176
|
+
onEaseChange={(ease) => onEaseKeyframe(i, ease)}
|
|
1030
1177
|
/>
|
|
1031
1178
|
))}
|
|
1032
1179
|
</div>
|
|
@@ -1037,14 +1184,20 @@ interface KeyframeDiamondProps {
|
|
|
1037
1184
|
kf: MotionKeyframe;
|
|
1038
1185
|
onPointerDown: (e: ReactPointerEvent<SVGSVGElement>) => void;
|
|
1039
1186
|
onDelete: () => void;
|
|
1187
|
+
onEaseChange: (ease: MotionEase) => void;
|
|
1040
1188
|
}
|
|
1041
1189
|
|
|
1042
1190
|
function KeyframeDiamond({
|
|
1043
1191
|
kf,
|
|
1044
1192
|
onPointerDown,
|
|
1045
1193
|
onDelete,
|
|
1194
|
+
onEaseChange,
|
|
1046
1195
|
}: KeyframeDiamondProps) {
|
|
1047
1196
|
const [hovered, setHovered] = useState(false);
|
|
1197
|
+
const currentEase = kf.ease ?? "ease";
|
|
1198
|
+
const currentPreset = EASE_PRESETS.find(
|
|
1199
|
+
(preset) => preset.value === currentEase,
|
|
1200
|
+
);
|
|
1048
1201
|
|
|
1049
1202
|
return (
|
|
1050
1203
|
<div
|
|
@@ -1085,7 +1238,8 @@ function KeyframeDiamond({
|
|
|
1085
1238
|
className="flex items-center gap-1 text-[10px]"
|
|
1086
1239
|
>
|
|
1087
1240
|
<span>
|
|
1088
|
-
{Math.round(kf.t * 100)}% — {kf.value}
|
|
1241
|
+
{Math.round(kf.t * 100)}% — {kf.value} —{" "}
|
|
1242
|
+
{currentPreset?.label ?? currentEase}
|
|
1089
1243
|
</span>
|
|
1090
1244
|
<button
|
|
1091
1245
|
type="button"
|
|
@@ -1097,6 +1251,40 @@ function KeyframeDiamond({
|
|
|
1097
1251
|
</button>
|
|
1098
1252
|
</TooltipContent>
|
|
1099
1253
|
</Tooltip>
|
|
1254
|
+
|
|
1255
|
+
{/* Easing picker — appears under the diamond on hover. */}
|
|
1256
|
+
<DropdownMenu>
|
|
1257
|
+
<DropdownMenuTrigger asChild>
|
|
1258
|
+
<button
|
|
1259
|
+
type="button"
|
|
1260
|
+
className="absolute left-1/2 top-full flex size-3 -translate-x-1/2 cursor-pointer items-center justify-center rounded-sm text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 data-[state=open]:opacity-100"
|
|
1261
|
+
aria-label={`Change easing (currently ${currentPreset?.label ?? currentEase})`}
|
|
1262
|
+
>
|
|
1263
|
+
<IconChevronDown className="size-2.5" />
|
|
1264
|
+
</button>
|
|
1265
|
+
</DropdownMenuTrigger>
|
|
1266
|
+
<DropdownMenuContent align="start" className="w-40 p-1">
|
|
1267
|
+
<DropdownMenuLabel className="px-2 py-1 text-[10px] font-medium leading-none text-muted-foreground">
|
|
1268
|
+
Easing
|
|
1269
|
+
</DropdownMenuLabel>
|
|
1270
|
+
<DropdownMenuSeparator className="my-1" />
|
|
1271
|
+
{EASE_PRESETS.map((preset) => (
|
|
1272
|
+
<DropdownMenuItem
|
|
1273
|
+
key={preset.value}
|
|
1274
|
+
className="h-7 px-2 text-[12px] leading-none"
|
|
1275
|
+
onSelect={() => onEaseChange(preset.value)}
|
|
1276
|
+
>
|
|
1277
|
+
<IconCheck
|
|
1278
|
+
className={cn(
|
|
1279
|
+
"mr-1 size-3",
|
|
1280
|
+
preset.value === currentEase ? "opacity-100" : "opacity-0",
|
|
1281
|
+
)}
|
|
1282
|
+
/>
|
|
1283
|
+
{preset.label}
|
|
1284
|
+
</DropdownMenuItem>
|
|
1285
|
+
))}
|
|
1286
|
+
</DropdownMenuContent>
|
|
1287
|
+
</DropdownMenu>
|
|
1100
1288
|
</div>
|
|
1101
1289
|
);
|
|
1102
1290
|
}
|