@hyperframes/studio 0.7.75 → 0.7.77
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/dist/assets/{hyperframes-player-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
- package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
- package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
- package/dist/assets/index-Bp4jAYZG.js +428 -0
- package/dist/assets/index-CBAfprvx.css +1 -0
- package/dist/index.d.ts +64 -17
- package/dist/index.html +2 -2
- package/dist/index.js +18040 -16293
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +16 -17
- package/src/components/EditorShell.tsx +3 -0
- package/src/components/TimelineToolbar.test.tsx +48 -3
- package/src/components/TimelineToolbar.tsx +89 -36
- package/src/components/editor/AnimationCard.test.tsx +18 -75
- package/src/components/editor/AnimationCard.tsx +26 -2
- package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
- package/src/components/editor/GsapAnimationSection.tsx +18 -157
- package/src/components/editor/KeyframeEaseList.tsx +5 -1
- package/src/components/editor/KeyframeNavigation.tsx +46 -14
- package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
- package/src/components/editor/MotionPathOverlay.tsx +32 -10
- package/src/components/editor/PropertyPanel.tsx +15 -5
- package/src/components/editor/PropertyPanelFlat.tsx +48 -23
- package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
- package/src/components/editor/keyframeRetime.test.ts +25 -22
- package/src/components/editor/keyframeRetime.ts +1 -2
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
- package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
- package/src/components/nle/PreviewOverlays.tsx +3 -1
- package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
- package/src/contexts/TimelineEditContext.tsx +1 -1
- package/src/hooks/deleteSelectedKeyframes.ts +24 -8
- package/src/hooks/gsapDragCommit.ts +2 -2
- package/src/hooks/gsapDragPositionCommit.ts +84 -5
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
- package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
- package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
- package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/gsapShared.test.ts +109 -1
- package/src/hooks/gsapShared.ts +142 -17
- package/src/hooks/gsapTweenSynth.test.ts +30 -1
- package/src/hooks/gsapTweenSynth.ts +61 -14
- package/src/hooks/keyframeCacheAstLoad.ts +181 -0
- package/src/hooks/timelineEditingHelpers.test.ts +80 -0
- package/src/hooks/timelineMoveAdapter.ts +9 -2
- package/src/hooks/useDomEditWiring.ts +2 -2
- package/src/hooks/useDomSelection.ts +4 -0
- package/src/hooks/useEnableKeyframes.test.ts +122 -0
- package/src/hooks/useEnableKeyframes.ts +116 -56
- package/src/hooks/useGestureCommit.ts +2 -2
- package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
- package/src/hooks/useGsapKeyframeOps.ts +30 -13
- package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
- package/src/hooks/useGsapScriptCommits.ts +2 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
- package/src/hooks/useGsapSelectionHandlers.ts +90 -65
- package/src/hooks/useGsapTweenCache.test.ts +16 -0
- package/src/hooks/useGsapTweenCache.ts +64 -178
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
- package/src/hooks/useStudioContextValue.test.ts +93 -0
- package/src/hooks/useStudioContextValue.ts +17 -3
- package/src/hooks/useStudioTestHooks.ts +63 -0
- package/src/hooks/useTimelineEditing.test.tsx +187 -22
- package/src/hooks/useTimelineEditing.ts +43 -32
- package/src/hooks/useTimelineEditingTypes.ts +2 -0
- package/src/hooks/useTimelineGroupEditing.ts +106 -82
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
- package/src/player/components/LayerDisclosureRow.tsx +76 -0
- package/src/player/components/Timeline.test.ts +420 -90
- package/src/player/components/Timeline.tsx +80 -78
- package/src/player/components/TimelineCanvas.tsx +33 -21
- package/src/player/components/TimelineClip.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
- package/src/player/components/TimelineClipDiamonds.tsx +320 -117
- package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
- package/src/player/components/TimelineLanes.tsx +153 -79
- package/src/player/components/TimelineOverlays.tsx +4 -18
- package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
- package/src/player/components/TimelinePropertyLanes.tsx +191 -0
- package/src/player/components/TimelineRazorInteraction.tsx +60 -0
- package/src/player/components/TimelineRuler.tsx +11 -21
- package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
- package/src/player/components/TimelineTrackHeader.tsx +363 -0
- package/src/player/components/TrackClipCount.tsx +17 -0
- package/src/player/components/timelineCallbacks.ts +21 -6
- package/src/player/components/timelineClipDragPreview.test.ts +50 -3
- package/src/player/components/timelineClipDragPreview.ts +33 -15
- package/src/player/components/timelineCollision.ts +4 -13
- package/src/player/components/timelineDiamondTypes.ts +120 -0
- package/src/player/components/timelineDragDrop.ts +8 -3
- package/src/player/components/timelineEditing.test.ts +19 -27
- package/src/player/components/timelineEditing.ts +7 -8
- package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
- package/src/player/components/timelineKeyframeIdentity.ts +66 -0
- package/src/player/components/timelineLayout.test.ts +162 -9
- package/src/player/components/timelineLayout.ts +198 -38
- package/src/player/components/timelineMarquee.test.ts +76 -21
- package/src/player/components/timelineMarquee.ts +17 -14
- package/src/player/components/trackHeaderLaneState.ts +121 -0
- package/src/player/components/trackHeaderLaneValues.ts +139 -0
- package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
- package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
- package/src/player/components/useTimelineClipDrag.ts +4 -1
- package/src/player/components/useTimelineGeometry.ts +4 -1
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
- package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
- package/src/player/components/useTimelinePlayhead.ts +21 -12
- package/src/player/components/useTimelineRangeSelection.ts +35 -14
- package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
- package/src/player/components/useTimelineTrackLayout.ts +162 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
- package/src/player/lib/timelineElementHelpers.ts +26 -11
- package/src/player/store/keyframeSlice.ts +122 -0
- package/src/player/store/playerStore.test.ts +25 -0
- package/src/player/store/playerStore.ts +7 -43
- package/src/telemetry/events.test.ts +9 -3
- package/src/telemetry/events.ts +16 -7
- package/src/utils/globalTimeCompiler.test.ts +10 -0
- package/src/utils/globalTimeCompiler.ts +3 -3
- package/dist/assets/index-B37rWXo4.css +0 -1
- package/dist/assets/index-DzDajONI.js +0 -428
- package/src/player/components/TimelineDragGhost.tsx +0 -59
- package/src/utils/keyframeSelection.test.ts +0 -45
- package/src/utils/keyframeSelection.ts +0 -29
|
@@ -1,72 +1,40 @@
|
|
|
1
|
-
import { memo, useRef, useState } from "react";
|
|
1
|
+
import { memo, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { BEAT_BAND_H } from "./BeatStrip";
|
|
3
3
|
import {
|
|
4
4
|
KEYFRAME_DRAG_THRESHOLD_PX,
|
|
5
5
|
previewClipPct,
|
|
6
6
|
resolveKeyframeDrag,
|
|
7
7
|
} from "../../components/editor/keyframeDrag";
|
|
8
|
+
import { TimelineDiamondConnectors } from "./TimelineDiamondConnectors";
|
|
9
|
+
import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation";
|
|
10
|
+
import { LANE_H } from "./timelineLayout";
|
|
11
|
+
import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
|
|
12
|
+
import {
|
|
13
|
+
DIAMOND_RATIO,
|
|
14
|
+
KF_MAX_PCT,
|
|
15
|
+
KF_MIN_PCT,
|
|
16
|
+
keyframeTarget,
|
|
17
|
+
type DragState,
|
|
18
|
+
type TimelineClipDiamondsProps,
|
|
19
|
+
type TimelineDiamondKeyframe,
|
|
20
|
+
type TimelineDiamondLaneProps,
|
|
21
|
+
} from "./timelineDiamondTypes";
|
|
8
22
|
|
|
9
|
-
|
|
10
|
-
percentage: number;
|
|
11
|
-
/** Tween-relative percentage (the retime mutation keys on this, not clip %). */
|
|
12
|
-
tweenPercentage?: number;
|
|
13
|
-
properties: Record<string, number | string>;
|
|
14
|
-
ease?: string;
|
|
15
|
-
}
|
|
23
|
+
export type { TimelineDiamondKeyframe } from "./timelineDiamondTypes";
|
|
16
24
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
// Floor for a diamond's clickable width. The visual size still narrows to the
|
|
26
|
+
// neighbour gap so packed diamonds stay individually readable, but the hit box
|
|
27
|
+
// stops there: at the zoom floor the gap alone left a ~7px target, which is
|
|
28
|
+
// neither hittable nor selectable with any accuracy. Boxes may overlap slightly
|
|
29
|
+
// below this width; each diamond still owns the half-gap around its own centre.
|
|
30
|
+
const KF_MIN_HIT_W = 12;
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
clipHeightPx: number;
|
|
28
|
-
/** Beat-dot strip is shown on this track → shrink diamonds + drop them into
|
|
29
|
-
* the bottom half so they clear the strip at the top. */
|
|
30
|
-
beatsActive?: boolean;
|
|
31
|
-
accentColor: string;
|
|
32
|
-
isSelected: boolean;
|
|
33
|
-
currentPercentage: number;
|
|
34
|
-
elementId: string;
|
|
35
|
-
selectedKeyframes: Set<string>;
|
|
36
|
-
onClickKeyframe?: (percentage: number) => void;
|
|
37
|
-
onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
|
|
38
|
-
onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
|
|
39
|
-
/** Drag-to-retime: move a keyframe to a new time, preserving its value + ease.
|
|
40
|
-
* Both percentages are clip-relative: `fromClipPercentage` identifies the
|
|
41
|
-
* dragged keyframe, `toClipPercentage` is the neighbour-clamped drop position.
|
|
42
|
-
* The handler decides move (within the tween) vs resize (past its boundary). */
|
|
43
|
-
onMoveKeyframe?: (
|
|
44
|
-
elementId: string,
|
|
45
|
-
fromClipPercentage: number,
|
|
46
|
-
toClipPercentage: number,
|
|
47
|
-
) => void;
|
|
48
|
-
/** Set while resolving a diamond press so the ancestor clip's onClick (which
|
|
49
|
-
* toggles selection off when already selected) ignores the native "click"
|
|
50
|
-
* the browser auto-synthesizes after this button's pointerdown+pointerup. */
|
|
51
|
-
suppressClickRef?: React.RefObject<boolean>;
|
|
32
|
+
/** A clip-% is a float division, so a raw tooltip reads `25.032499999999995%`. */
|
|
33
|
+
function roundPct(percentage: number): number {
|
|
34
|
+
return Math.round(percentage * 1000) / 1000;
|
|
52
35
|
}
|
|
53
36
|
|
|
54
|
-
const
|
|
55
|
-
// Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
|
|
56
|
-
// slightly outside [0, 100] (from rounding or stale cache during the async
|
|
57
|
-
// persist → reload cycle) are still rendered (the clip is overflow-visible) at
|
|
58
|
-
// their true position rather than hidden.
|
|
59
|
-
const KF_MIN_PCT = -5;
|
|
60
|
-
const KF_MAX_PCT = 105;
|
|
61
|
-
|
|
62
|
-
type DragState = {
|
|
63
|
-
kfKey: string;
|
|
64
|
-
startX: number;
|
|
65
|
-
fromClipPct: number;
|
|
66
|
-
moved: boolean;
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
37
|
+
export const TimelineDiamondLane = memo(function TimelineDiamondLane({
|
|
70
38
|
keyframesData,
|
|
71
39
|
clipWidthPx,
|
|
72
40
|
clipHeightPx,
|
|
@@ -80,14 +48,72 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
80
48
|
onShiftClickKeyframe,
|
|
81
49
|
onContextMenuKeyframe,
|
|
82
50
|
onMoveKeyframe,
|
|
51
|
+
onSelectSegment,
|
|
83
52
|
suppressClickRef,
|
|
84
|
-
|
|
53
|
+
groupAware = false,
|
|
54
|
+
globalEase = "none",
|
|
55
|
+
}: TimelineDiamondLaneProps) {
|
|
85
56
|
// Hooks must run before the early return below.
|
|
86
57
|
const dragRef = useRef<DragState | null>(null);
|
|
58
|
+
// Pending retime destination (clip + tween %) per keyframe key, so a rapid
|
|
59
|
+
// second drag composes from where the first move left the keyframe (whose
|
|
60
|
+
// cache entry has not rebuilt yet) instead of the stale rendered value.
|
|
61
|
+
const pendingRetimeRef = useRef<Map<string, { clipPct: number; tweenPct: number }> | null>(null);
|
|
62
|
+
// Lazy: `useRef(new Map())` allocates a Map on every render and throws all but
|
|
63
|
+
// the first away, once per mounted lane.
|
|
64
|
+
pendingRetimeRef.current ??= new Map();
|
|
65
|
+
const pendingRetimes = pendingRetimeRef.current;
|
|
66
|
+
// The most recent retime dispatched from this lane, whichever diamond it came
|
|
67
|
+
// from. Selection is lane-wide, so "is my revert still relevant" is a lane-wide
|
|
68
|
+
// question, not a per-keyframe one.
|
|
69
|
+
const latestRetimeRef = useRef<{ clipPct: number; tweenPct: number } | null>(null);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
// Clear a pending entry once the authoritative cache reflects THAT keyframe
|
|
72
|
+
// at ~its destination. Match by tolerance, not equality: cache writers round
|
|
73
|
+
// clip %s, so an exact check would leak an entry after every successful
|
|
74
|
+
// retime. Match by identity too: a bare "some keyframe is near that %" test
|
|
75
|
+
// cleared the entry whenever an unrelated sibling happened to sit there,
|
|
76
|
+
// which is easy to hit on an evenly spaced row.
|
|
77
|
+
const pendingEntries = pendingRetimeRef.current;
|
|
78
|
+
if (!pendingEntries) return;
|
|
79
|
+
for (const [key, pending] of pendingEntries) {
|
|
80
|
+
const settled = keyframesData.keyframes.some(
|
|
81
|
+
(k) =>
|
|
82
|
+
timelineKeyframeSelectionKey(elementId, keyframeTarget(k)) === key &&
|
|
83
|
+
Math.abs(k.percentage - pending.clipPct) < 0.2,
|
|
84
|
+
);
|
|
85
|
+
if (settled) pendingEntries.delete(key);
|
|
86
|
+
}
|
|
87
|
+
}, [keyframesData.keyframes, elementId]);
|
|
87
88
|
// Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold
|
|
88
89
|
// (that optimistic hold was the #1763 flake). The atomic move-keyframe commit
|
|
89
90
|
// on drop re-keys the diamond from source.
|
|
90
91
|
const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null);
|
|
92
|
+
// One preview render per frame: a 120Hz trackpad fires pointermove far faster
|
|
93
|
+
// than the lane can repaint, and every diamond in the row re-evaluates its
|
|
94
|
+
// memo on each of those renders.
|
|
95
|
+
const previewFrameRef = useRef<number | null>(null);
|
|
96
|
+
const cancelPreviewFrame = () => {
|
|
97
|
+
if (previewFrameRef.current === null) return;
|
|
98
|
+
cancelAnimationFrame(previewFrameRef.current);
|
|
99
|
+
previewFrameRef.current = null;
|
|
100
|
+
};
|
|
101
|
+
// Escape backs out of an in-flight retime, the way clip and element drags
|
|
102
|
+
// already do. Nothing was written yet (the commit happens on pointerup), so
|
|
103
|
+
// dropping the preview is the whole undo.
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
106
|
+
if (event.key !== "Escape" || !dragRef.current || dragRef.current.cancelled) return;
|
|
107
|
+
dragRef.current.cancelled = true;
|
|
108
|
+
cancelPreviewFrame();
|
|
109
|
+
setPreview(null);
|
|
110
|
+
};
|
|
111
|
+
document.addEventListener("keydown", onKeyDown);
|
|
112
|
+
return () => {
|
|
113
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
114
|
+
cancelPreviewFrame();
|
|
115
|
+
};
|
|
116
|
+
}, []);
|
|
91
117
|
// The button element can re-render (reposition/unmount) synchronously from
|
|
92
118
|
// the state updates onClickKeyframe/onMoveKeyframe trigger, before the
|
|
93
119
|
// browser gets to auto-synthesize the "click" event that normally follows
|
|
@@ -108,15 +134,67 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
108
134
|
|
|
109
135
|
// When the beat strip occupies the top band, shrink the diamonds and center
|
|
110
136
|
// them in the remaining bottom region so they don't collide with it.
|
|
111
|
-
|
|
112
|
-
|
|
137
|
+
// One consistent keyframe-diamond size everywhere (clip bars + property lanes),
|
|
138
|
+
// matching the property-lane size (LANE_H · ratio). Beat-strip tracks still
|
|
139
|
+
// shrink to fit under the strip.
|
|
140
|
+
const diamondSize = beatsActive
|
|
141
|
+
? Math.round(clipHeightPx * 0.45)
|
|
142
|
+
: Math.round(LANE_H * DIAMOND_RATIO);
|
|
113
143
|
const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2;
|
|
114
144
|
const sorted = keyframesData.keyframes
|
|
115
145
|
.filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
|
|
116
146
|
.sort((a, b) => a.percentage - b.percentage);
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
147
|
+
// The neighbour clamp bounds a dragged diamond between its immediate siblings
|
|
148
|
+
// so a retime can't reorder the tween. Siblings means "keyframes of the SAME
|
|
149
|
+
// tween": a merged row interleaves several animations, and two of them
|
|
150
|
+
// colliding at one percentage would otherwise pin each other's diamonds in
|
|
151
|
+
// place — the drag clamped back onto its own position and resolved to a click.
|
|
152
|
+
const siblingRowOf = (keyframe: TimelineDiamondKeyframe) =>
|
|
153
|
+
keyframe.animationId === undefined
|
|
154
|
+
? sorted
|
|
155
|
+
: sorted.filter((k) => k.animationId === keyframe.animationId);
|
|
156
|
+
// Compose each sibling's pending destination in first: clamping against
|
|
157
|
+
// cached positions while the dragged keyframe reads its pending one let a
|
|
158
|
+
// second drag cross a neighbour that had already moved past it. Built once
|
|
159
|
+
// per render, keyed by tween: every diamond of a row needs the same row, and
|
|
160
|
+
// rebuilding + re-sorting it inside the marker loop below made this
|
|
161
|
+
// O(keyframes squared) allocations on every playhead tick.
|
|
162
|
+
const pendingClipPctOf = (keyframe: TimelineDiamondKeyframe) =>
|
|
163
|
+
pendingRetimes.get(timelineKeyframeSelectionKey(elementId, keyframeTarget(keyframe)))
|
|
164
|
+
?.clipPct ?? keyframe.percentage;
|
|
165
|
+
const siblingRows = new Map<
|
|
166
|
+
string | undefined,
|
|
167
|
+
{ keyframes: TimelineDiamondKeyframe[]; clipPcts: number[] }
|
|
168
|
+
>();
|
|
169
|
+
for (const keyframe of sorted) {
|
|
170
|
+
if (siblingRows.has(keyframe.animationId)) continue;
|
|
171
|
+
const row = siblingRowOf(keyframe)
|
|
172
|
+
.map((k) => ({ keyframe: k, clipPct: pendingClipPctOf(k) }))
|
|
173
|
+
.sort((a, b) => a.clipPct - b.clipPct);
|
|
174
|
+
siblingRows.set(keyframe.animationId, {
|
|
175
|
+
keyframes: row.map((s) => s.keyframe),
|
|
176
|
+
clipPcts: row.map((s) => s.clipPct),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
const centerXOf = (percentage: number) =>
|
|
180
|
+
Math.max(0, Math.min(clipWidthPx, (percentage / 100) * clipWidthPx));
|
|
181
|
+
// One record per diamond, carrying its own geometry, so the connector and
|
|
182
|
+
// button passes below read neighbours as values instead of index lookups.
|
|
183
|
+
const markers = sorted.map((keyframe, index) => {
|
|
184
|
+
const centerX = centerXOf(keyframe.percentage);
|
|
185
|
+
const previous = sorted[index - 1];
|
|
186
|
+
const next = sorted[index + 1];
|
|
187
|
+
const previousGap = previous ? centerX - centerXOf(previous.percentage) : Infinity;
|
|
188
|
+
const nextGap = next ? centerXOf(next.percentage) - centerX : Infinity;
|
|
189
|
+
const nearestGap = Math.max(1, Math.min(previousGap, nextGap));
|
|
190
|
+
const gapWidth = Math.min(diamondSize, nearestGap);
|
|
191
|
+
return {
|
|
192
|
+
keyframe,
|
|
193
|
+
centerX,
|
|
194
|
+
hitWidth: Math.max(KF_MIN_HIT_W, gapWidth),
|
|
195
|
+
visualSize: gapWidth === diamondSize ? diamondSize : Math.max(2, gapWidth - 2),
|
|
196
|
+
};
|
|
197
|
+
});
|
|
120
198
|
const baseColor = isSelected ? accentColor : "#a3a3a3";
|
|
121
199
|
const baseOpacity = isSelected ? 0.4 : 0.25;
|
|
122
200
|
const canDrag = isSelected && !!onMoveKeyframe;
|
|
@@ -135,39 +213,33 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
135
213
|
pointerEvents: "none",
|
|
136
214
|
}}
|
|
137
215
|
>
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
style={{
|
|
149
|
-
left: x1,
|
|
150
|
-
top: centerY,
|
|
151
|
-
width: x2 - x1,
|
|
152
|
-
height: 2,
|
|
153
|
-
transform: "translateY(-1px)",
|
|
154
|
-
background: baseColor,
|
|
155
|
-
opacity: baseOpacity,
|
|
156
|
-
borderRadius: 1,
|
|
157
|
-
}}
|
|
158
|
-
/>
|
|
159
|
-
);
|
|
160
|
-
})}
|
|
216
|
+
<TimelineDiamondConnectors
|
|
217
|
+
markers={markers}
|
|
218
|
+
centerY={centerY}
|
|
219
|
+
baseColor={baseColor}
|
|
220
|
+
baseOpacity={baseOpacity}
|
|
221
|
+
groupAware={groupAware}
|
|
222
|
+
globalEase={globalEase}
|
|
223
|
+
keyframeTarget={keyframeTarget}
|
|
224
|
+
onSelectSegment={onSelectSegment}
|
|
225
|
+
/>
|
|
161
226
|
|
|
162
|
-
{
|
|
163
|
-
const
|
|
227
|
+
{markers.map((marker, i) => {
|
|
228
|
+
const kf = marker.keyframe;
|
|
229
|
+
const target = keyframeTarget(kf);
|
|
230
|
+
const kfKey = timelineKeyframeSelectionKey(elementId, target);
|
|
231
|
+
// Clamp against this keyframe's own tween, not the whole merged row.
|
|
232
|
+
const siblingRow = siblingRows.get(kf.animationId);
|
|
233
|
+
const siblingClipPcts = siblingRow?.clipPcts ?? [];
|
|
234
|
+
const siblingIndex = siblingRow?.keyframes.indexOf(kf) ?? -1;
|
|
164
235
|
// While dragging this diamond, render it at the live preview clip-%.
|
|
165
236
|
const renderPct = preview?.kfKey === kfKey ? preview.clipPct : kf.percentage;
|
|
166
|
-
// Center the
|
|
167
|
-
// diamond's midpoint sits exactly
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
237
|
+
// Center the marker's non-overlapping hit region ON its keyframe %, so
|
|
238
|
+
// the diamond's midpoint sits exactly on the playhead/ruler x for that time.
|
|
239
|
+
// The 0% diamond's left half lands in the reserved left gutter (the
|
|
240
|
+
// content origin is inset past the label column, Figma-style) so it stays
|
|
241
|
+
// fully visible instead of being clipped by the sticky label column.
|
|
242
|
+
const leftPx = (renderPct / 100) * clipWidthPx - marker.hitWidth / 2;
|
|
171
243
|
const isKfSelected = selectedKeyframes.has(kfKey);
|
|
172
244
|
const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
|
|
173
245
|
const isHighlighted = isKfSelected || atPlayhead;
|
|
@@ -181,53 +253,72 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
181
253
|
dragRef.current = {
|
|
182
254
|
kfKey,
|
|
183
255
|
startX: e.clientX,
|
|
184
|
-
|
|
256
|
+
lastX: e.clientX,
|
|
257
|
+
index: siblingIndex,
|
|
258
|
+
fromClipPct: pendingRetimes.get(kfKey)?.clipPct ?? kf.percentage,
|
|
185
259
|
moved: false,
|
|
186
260
|
};
|
|
187
261
|
}
|
|
188
262
|
};
|
|
189
263
|
const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
190
264
|
const d = dragRef.current;
|
|
191
|
-
if (!d || d.kfKey !== kfKey) return;
|
|
265
|
+
if (!d || d.kfKey !== kfKey || d.cancelled) return;
|
|
266
|
+
d.lastX = e.clientX;
|
|
192
267
|
if (!d.moved && Math.abs(e.clientX - d.startX) >= KEYFRAME_DRAG_THRESHOLD_PX) {
|
|
193
268
|
d.moved = true;
|
|
194
269
|
}
|
|
195
|
-
if (d.moved)
|
|
270
|
+
if (!d.moved || previewFrameRef.current !== null) return;
|
|
271
|
+
previewFrameRef.current = requestAnimationFrame(() => {
|
|
272
|
+
previewFrameRef.current = null;
|
|
273
|
+
const live = dragRef.current;
|
|
274
|
+
if (!live || live.kfKey !== kfKey || live.cancelled) return;
|
|
196
275
|
setPreview({
|
|
197
276
|
kfKey,
|
|
198
277
|
clipPct: previewClipPct({
|
|
199
|
-
pointerDownX:
|
|
200
|
-
pointerMoveX:
|
|
278
|
+
pointerDownX: live.startX,
|
|
279
|
+
pointerMoveX: live.lastX,
|
|
201
280
|
clipWidthPx,
|
|
202
|
-
draggedClipPct:
|
|
203
|
-
draggedIndex:
|
|
204
|
-
sortedClipPcts,
|
|
281
|
+
draggedClipPct: live.fromClipPct,
|
|
282
|
+
draggedIndex: live.index,
|
|
283
|
+
sortedClipPcts: siblingClipPcts,
|
|
205
284
|
}),
|
|
206
285
|
});
|
|
207
|
-
}
|
|
286
|
+
});
|
|
208
287
|
};
|
|
209
288
|
const onPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
210
289
|
const d = dragRef.current;
|
|
290
|
+
if (d?.kfKey === kfKey && d.cancelled) {
|
|
291
|
+
// Escape already ended this drag; the release is not a click.
|
|
292
|
+
dragRef.current = null;
|
|
293
|
+
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
294
|
+
suppressNextClick();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
211
297
|
// No drag armed (canDrag false / non-primary press) → treat as a click.
|
|
212
298
|
if (!d || d.kfKey !== kfKey) {
|
|
213
299
|
if (e.button !== 0) return;
|
|
214
300
|
suppressNextClick();
|
|
215
|
-
if (e.shiftKey) onShiftClickKeyframe?.(
|
|
216
|
-
else onClickKeyframe?.(
|
|
301
|
+
if (e.shiftKey) onShiftClickKeyframe?.(target);
|
|
302
|
+
else onClickKeyframe?.(target);
|
|
217
303
|
return;
|
|
218
304
|
}
|
|
219
305
|
e.stopPropagation();
|
|
220
306
|
dragRef.current = null;
|
|
307
|
+
cancelPreviewFrame();
|
|
221
308
|
setPreview(null);
|
|
222
309
|
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
223
310
|
suppressNextClick();
|
|
311
|
+
// Single-diamond retime by design: a multi-select drag would have to
|
|
312
|
+
// move every selected keyframe as one mutation, which the script ops
|
|
313
|
+
// do not express yet. Selecting several and dragging one moves only
|
|
314
|
+
// the dragged one.
|
|
224
315
|
const res = resolveKeyframeDrag({
|
|
225
316
|
pointerDownX: d.startX,
|
|
226
317
|
pointerUpX: e.clientX,
|
|
227
318
|
clipWidthPx,
|
|
228
319
|
draggedClipPct: d.fromClipPct,
|
|
229
|
-
draggedIndex:
|
|
230
|
-
sortedClipPcts,
|
|
320
|
+
draggedIndex: siblingIndex,
|
|
321
|
+
sortedClipPcts: siblingClipPcts,
|
|
231
322
|
});
|
|
232
323
|
if (res.kind === "click" || res.kind === "noop") {
|
|
233
324
|
// "noop" is a press with enough pointer jitter to arm a drag (canDrag
|
|
@@ -235,27 +326,92 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
235
326
|
// back onto ~the same position — no real retime, so treat it as the
|
|
236
327
|
// click it was. Otherwise a normal click with a few px of mouse/
|
|
237
328
|
// trackpad drift silently does nothing: no selection, no move.
|
|
238
|
-
if (e.shiftKey) onShiftClickKeyframe?.(
|
|
239
|
-
else onClickKeyframe?.(
|
|
329
|
+
if (e.shiftKey) onShiftClickKeyframe?.(target);
|
|
330
|
+
else onClickKeyframe?.(target);
|
|
240
331
|
} else if (res.kind === "move" && res.toClipPct != null) {
|
|
241
|
-
|
|
332
|
+
const animKfs =
|
|
333
|
+
target.animationId === undefined
|
|
334
|
+
? keyframesData.keyframes
|
|
335
|
+
: keyframesData.keyframes.filter((k) => k.animationId === target.animationId);
|
|
336
|
+
// Clamp to the mapped tween range: clipToTweenPercentage extrapolates
|
|
337
|
+
// linearly, so a boundary drag past the range would otherwise reselect
|
|
338
|
+
// an out-of-range tween % (e.g. 150%) even though the mutation clamps
|
|
339
|
+
// the moved endpoint back to the boundary.
|
|
340
|
+
const tweenPcts = animKfs
|
|
341
|
+
.map((k) => k.tweenPercentage)
|
|
342
|
+
.filter((v): v is number => typeof v === "number");
|
|
343
|
+
const clampTween = (v: number) =>
|
|
344
|
+
tweenPcts.length
|
|
345
|
+
? Math.max(Math.min(...tweenPcts), Math.min(Math.max(...tweenPcts), v))
|
|
346
|
+
: v;
|
|
347
|
+
const newTweenPct = clampTween(clipToTweenPercentage(animKfs, res.toClipPct));
|
|
348
|
+
// For a rapid second retime the diamond still renders the stale cache
|
|
349
|
+
// position, so identify the FROM keyframe by the pending (already-moved)
|
|
350
|
+
// position; the mutation locates the source keyframe by this identity.
|
|
351
|
+
const pendingBefore = pendingRetimes.get(kfKey);
|
|
352
|
+
const fromTarget = pendingBefore
|
|
353
|
+
? {
|
|
354
|
+
...target,
|
|
355
|
+
percentage: pendingBefore.clipPct,
|
|
356
|
+
tweenPercentage: pendingBefore.tweenPct,
|
|
357
|
+
}
|
|
358
|
+
: target;
|
|
359
|
+
const pending = { clipPct: res.toClipPct, tweenPct: newTweenPct };
|
|
360
|
+
pendingRetimes.set(kfKey, pending);
|
|
361
|
+
latestRetimeRef.current = pending;
|
|
362
|
+
const clearPending = () => {
|
|
363
|
+
if (pendingRetimes.get(kfKey) === pending) {
|
|
364
|
+
pendingRetimes.delete(kfKey);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
// A rejected drop (the destination time is already occupied) snaps
|
|
368
|
+
// the diamond back to its source position, so the pending entry AND
|
|
369
|
+
// the selection have to revert with it — parking on the ghost drop
|
|
370
|
+
// position strands the playhead + selection on a keyframe that does
|
|
371
|
+
// not exist there.
|
|
372
|
+
const revertRetime = () => {
|
|
373
|
+
// Only the newest gesture owns the selection. A rejected first drag
|
|
374
|
+
// whose commit settles after a second one started would otherwise
|
|
375
|
+
// park the selection back on ITS source keyframe, undoing a retime
|
|
376
|
+
// the user has already made and moving the playhead with it.
|
|
377
|
+
const isLatest = latestRetimeRef.current === pending;
|
|
378
|
+
clearPending();
|
|
379
|
+
if (isLatest) onClickKeyframe?.(fromTarget);
|
|
380
|
+
};
|
|
381
|
+
void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => {
|
|
382
|
+
if (!committed) revertRetime();
|
|
383
|
+
}, revertRetime);
|
|
242
384
|
// A retime still targeted this exact diamond — park/select it at its
|
|
243
385
|
// new position, same as a plain click, or a drag that actually moved
|
|
244
|
-
// something looks identical to one that silently did nothing.
|
|
245
|
-
|
|
386
|
+
// something looks identical to one that silently did nothing. Done
|
|
387
|
+
// optimistically so the gesture stays responsive; revertRetime puts
|
|
388
|
+
// it back if the move is rejected.
|
|
389
|
+
onClickKeyframe?.({
|
|
390
|
+
...target,
|
|
391
|
+
percentage: res.toClipPct,
|
|
392
|
+
tweenPercentage: newTweenPct,
|
|
393
|
+
});
|
|
246
394
|
}
|
|
247
395
|
};
|
|
248
396
|
|
|
249
397
|
return (
|
|
250
398
|
<button
|
|
251
|
-
|
|
399
|
+
// Key on the authored identity (tween-%), not the rendered clip-% or
|
|
400
|
+
// the row index: a clip resize, a neighbour's retime, or a re-sort
|
|
401
|
+
// changes both of those without changing WHICH keyframe this is, and
|
|
402
|
+
// a key change remounts the button mid-drag (losing pointer capture).
|
|
403
|
+
key={`${kf.animationId ?? i}:${kf.propertyGroup ?? ""}:${kf.tweenPercentage ?? kf.percentage}`}
|
|
252
404
|
type="button"
|
|
253
405
|
className="absolute"
|
|
406
|
+
data-keyframe-group={groupAware ? kf.propertyGroup : undefined}
|
|
407
|
+
data-keyframe-percentage={
|
|
408
|
+
groupAware ? (kf.tweenPercentage ?? kf.percentage) : undefined
|
|
409
|
+
}
|
|
254
410
|
style={{
|
|
255
411
|
left: leftPx,
|
|
256
412
|
top: centerY,
|
|
257
413
|
transform: "translateY(-50%)",
|
|
258
|
-
width:
|
|
414
|
+
width: marker.hitWidth,
|
|
259
415
|
height: diamondSize,
|
|
260
416
|
zIndex: isHighlighted ? 2 : 1,
|
|
261
417
|
pointerEvents: "auto",
|
|
@@ -264,18 +420,37 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
264
420
|
cursor: canDrag ? "ew-resize" : "pointer",
|
|
265
421
|
padding: 0,
|
|
266
422
|
touchAction: "none",
|
|
423
|
+
display: "flex",
|
|
424
|
+
alignItems: "center",
|
|
425
|
+
justifyContent: "center",
|
|
426
|
+
overflow: "visible",
|
|
267
427
|
}}
|
|
268
428
|
onPointerDown={onPointerDown}
|
|
269
429
|
onPointerMove={onPointerMove}
|
|
270
430
|
onPointerUp={onPointerUp}
|
|
431
|
+
onPointerCancel={(e) => {
|
|
432
|
+
// Browser/OS cancellation (or lost capture) ends the drag without a
|
|
433
|
+
// pointerup, so clear the armed drag and preview or a ghost diamond
|
|
434
|
+
// stays stuck at the last previewed position.
|
|
435
|
+
if (dragRef.current?.kfKey !== kfKey) return;
|
|
436
|
+
dragRef.current = null;
|
|
437
|
+
cancelPreviewFrame();
|
|
438
|
+
setPreview(null);
|
|
439
|
+
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
|
440
|
+
}}
|
|
271
441
|
onContextMenu={(e) => {
|
|
272
442
|
e.preventDefault();
|
|
273
443
|
e.stopPropagation();
|
|
274
|
-
onContextMenuKeyframe?.(e,
|
|
444
|
+
onContextMenuKeyframe?.(e, target);
|
|
275
445
|
}}
|
|
276
|
-
title={`${kf.percentage}%`}
|
|
446
|
+
title={`${roundPct(kf.percentage)}%`}
|
|
277
447
|
>
|
|
278
|
-
<svg
|
|
448
|
+
<svg
|
|
449
|
+
width={marker.visualSize}
|
|
450
|
+
height={marker.visualSize}
|
|
451
|
+
viewBox="0 0 10 10"
|
|
452
|
+
style={{ flexShrink: 0, pointerEvents: "none" }}
|
|
453
|
+
>
|
|
279
454
|
{isKfSelected && (
|
|
280
455
|
<path
|
|
281
456
|
d="M5 0L10 5L5 10L0 5Z"
|
|
@@ -297,3 +472,31 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
|
|
|
297
472
|
</div>
|
|
298
473
|
);
|
|
299
474
|
});
|
|
475
|
+
|
|
476
|
+
export const TimelineClipDiamonds = memo(function TimelineClipDiamonds(
|
|
477
|
+
props: TimelineClipDiamondsProps,
|
|
478
|
+
) {
|
|
479
|
+
return (
|
|
480
|
+
<TimelineDiamondLane
|
|
481
|
+
{...props}
|
|
482
|
+
globalEase={props.keyframesData.ease}
|
|
483
|
+
onClickKeyframe={(target) => props.onClickKeyframe?.(props.elementId, target)}
|
|
484
|
+
onShiftClickKeyframe={(target) => props.onShiftClickKeyframe?.(props.elementId, target)}
|
|
485
|
+
onContextMenuKeyframe={(e, target) =>
|
|
486
|
+
props.onContextMenuKeyframe?.(e, props.elementId, target)
|
|
487
|
+
}
|
|
488
|
+
onMoveKeyframe={
|
|
489
|
+
props.onMoveKeyframe
|
|
490
|
+
? (target, toClipPercentage) =>
|
|
491
|
+
props.onMoveKeyframe?.(props.elementId, target, toClipPercentage) ??
|
|
492
|
+
Promise.resolve(false)
|
|
493
|
+
: undefined
|
|
494
|
+
}
|
|
495
|
+
onSelectSegment={
|
|
496
|
+
props.onSelectSegment
|
|
497
|
+
? (target) => props.onSelectSegment?.(props.elementId, target)
|
|
498
|
+
: undefined
|
|
499
|
+
}
|
|
500
|
+
/>
|
|
501
|
+
);
|
|
502
|
+
});
|