@hyperframes/studio 0.7.38 → 0.7.40
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/{index-ClUipc8i.js → index-89kPtC4s.js} +1 -1
- package/dist/assets/{index-BLRTwY5l.js → index-B2Utv-2b.js} +200 -200
- package/dist/assets/index-BpM6cnfP.css +1 -0
- package/dist/assets/{index-Ykq7ihge.js → index-CjX8Ljc8.js} +1 -1
- package/dist/{chunk-JND3XUJL.js → chunk-BA66NM4L.js} +1 -1
- package/dist/chunk-BA66NM4L.js.map +1 -0
- package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-H7LDFIJ7.js} +2 -2
- package/dist/index.d.ts +47 -29
- package/dist/index.html +2 -2
- package/dist/index.js +4432 -3045
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +9 -7
- package/src/components/StudioPreviewArea.tsx +14 -0
- package/src/components/StudioRightPanel.tsx +9 -0
- package/src/components/editor/DomEditCropHandles.tsx +238 -0
- package/src/components/editor/DomEditOverlay.tsx +140 -151
- package/src/components/editor/DomEditRotateHandle.tsx +41 -0
- package/src/components/editor/PropertyPanel.test.ts +73 -0
- package/src/components/editor/PropertyPanel.tsx +42 -37
- package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
- package/src/components/editor/SnapToolbar.tsx +20 -1
- package/src/components/editor/clipPathHelpers.ts +113 -0
- package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
- package/src/components/editor/domEditOverlayCrop.ts +115 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayShape.ts +39 -0
- package/src/components/editor/domEditing.test.ts +2 -0
- package/src/components/editor/domEditingElement.ts +8 -1
- package/src/components/editor/domEditingTypes.ts +3 -0
- package/src/components/editor/manualEditsDom.ts +1 -18
- package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
- package/src/components/editor/marqueeCommit.ts +3 -2
- package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
- package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
- package/src/components/editor/propertyPanelHelpers.ts +21 -34
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +3 -0
- package/src/components/editor/snapTargetCollection.ts +2 -3
- package/src/components/editor/useDomEditCompositionRect.ts +68 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
- package/src/components/editor/useDomEditOverlayRects.ts +5 -3
- package/src/components/sidebar/AssetsTab.test.ts +87 -0
- package/src/components/sidebar/AssetsTab.tsx +113 -15
- package/src/components/sidebar/BlocksTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/hooks/domSelectionTestHarness.ts +1 -0
- package/src/hooks/gsapDragCommit.test.ts +56 -0
- package/src/hooks/gsapDragCommit.ts +41 -109
- package/src/hooks/gsapDragPositionCommit.ts +3 -0
- package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
- package/src/hooks/gsapRuntimeBridge.ts +23 -2
- package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
- package/src/hooks/timelineEditingHelpers.ts +17 -5
- package/src/hooks/timelineTrackVisibility.test.ts +202 -0
- package/src/hooks/timelineTrackVisibility.ts +371 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -0
- package/src/hooks/useRenderClipContent.test.ts +68 -2
- package/src/hooks/useRenderClipContent.ts +5 -6
- package/src/hooks/useTimelineEditing.ts +35 -30
- package/src/hooks/useTimelineEditingTypes.ts +30 -0
- package/src/icons/SystemIcons.tsx +12 -4
- package/src/player/components/AudioWaveform.tsx +11 -8
- package/src/player/components/CompositionThumbnail.tsx +14 -11
- package/src/player/components/PlayheadIndicator.tsx +21 -11
- package/src/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/components/Timeline.test.ts +105 -5
- package/src/player/components/Timeline.tsx +37 -47
- package/src/player/components/TimelineCanvas.tsx +275 -256
- package/src/player/components/TimelineClip.test.tsx +105 -0
- package/src/player/components/TimelineClip.tsx +44 -57
- package/src/player/components/TimelineRuler.tsx +17 -23
- package/src/player/components/TimelineShortcutHint.tsx +26 -0
- package/src/player/components/TimelineTypes.ts +18 -0
- package/src/player/components/VideoThumbnail.tsx +16 -13
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineIcons.tsx +2 -44
- package/src/player/components/timelineLayout.ts +18 -22
- package/src/player/components/timelineMotionStyles.test.ts +101 -0
- package/src/player/components/timelineTheme.test.ts +21 -4
- package/src/player/components/timelineTheme.ts +15 -15
- package/src/player/components/useTimelineActiveClips.test.ts +93 -0
- package/src/player/components/useTimelineActiveClips.ts +125 -0
- package/src/player/components/useTimelinePlayhead.ts +6 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
- package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
- package/src/player/lib/time.test.ts +1 -0
- package/src/player/lib/timelineDOM.test.ts +48 -1
- package/src/player/lib/timelineDOM.ts +7 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +19 -1
- package/src/styles/studio.css +108 -0
- package/src/utils/sourcePatcher.test.ts +11 -0
- package/src/utils/sourcePatcher.ts +6 -4
- package/dist/assets/index-DJaiR8T2.css +0 -1
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
|
@@ -14,7 +14,16 @@ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeComp
|
|
|
14
14
|
import { roundTo3 } from "../utils/rounding";
|
|
15
15
|
import { computeElementPercentage } from "./gsapShared";
|
|
16
16
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
17
|
-
import type { RuntimeTweenChange
|
|
17
|
+
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
|
18
|
+
import {
|
|
19
|
+
setPatchFromUpdateProperties,
|
|
20
|
+
setPatchFromUpdateProperty,
|
|
21
|
+
} from "./gsapDragStaticSetHelpers";
|
|
22
|
+
export {
|
|
23
|
+
findExistingPositionWrite,
|
|
24
|
+
findRotationSetAnimation,
|
|
25
|
+
findSizeSetAnimation,
|
|
26
|
+
} from "./gsapDragStaticSetHelpers";
|
|
18
27
|
export interface GsapDragCommitCallbacks {
|
|
19
28
|
commitMutation: (
|
|
20
29
|
selection: DomEditSelection,
|
|
@@ -103,86 +112,6 @@ export async function materializeIfDynamic(
|
|
|
103
112
|
|
|
104
113
|
// ── Drag → GSAP position math ──────────────────────────────────────────────
|
|
105
114
|
|
|
106
|
-
/** The shape of an `update-property` mutation a static-set nudge POSTs. */
|
|
107
|
-
interface UpdatePropertyMutation {
|
|
108
|
-
type: "update-property";
|
|
109
|
-
animationId: string;
|
|
110
|
-
property: string;
|
|
111
|
-
value: number;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Build the `instantPatch` for a value-only `tl.set` from the SAME
|
|
116
|
-
* `update-property` mutation(s) that are POSTed — so the patch can never carry a
|
|
117
|
-
* value the source write didn't (one source of truth). Each mutation contributes
|
|
118
|
-
* its `{property: value}` channel to the patch's props.
|
|
119
|
-
*/
|
|
120
|
-
function setPatchFromUpdateProperties(
|
|
121
|
-
selector: string,
|
|
122
|
-
mutations: UpdatePropertyMutation[],
|
|
123
|
-
global = false,
|
|
124
|
-
): { selector: string; change: RuntimeTweenChange } {
|
|
125
|
-
const props: SetPatchProps = {};
|
|
126
|
-
for (const m of mutations) props[m.property as keyof SetPatchProps] = m.value;
|
|
127
|
-
// An off-timeline `gsap.set` has no runtime tween to patch — apply it to the
|
|
128
|
-
// element directly. An on-timeline `tl.set` mutates its tween (so a re-seek keeps it).
|
|
129
|
-
return { selector, change: { kind: global ? "global-set" : "set", props } };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Single-mutation convenience over {@link setPatchFromUpdateProperties}. */
|
|
133
|
-
function setPatchFromUpdateProperty(
|
|
134
|
-
selector: string,
|
|
135
|
-
mutation: UpdatePropertyMutation,
|
|
136
|
-
global = false,
|
|
137
|
-
): { selector: string; change: RuntimeTweenChange } {
|
|
138
|
-
return setPatchFromUpdateProperties(selector, [mutation], global);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Find the studio position-hold `set` for a selector — a `tl.set("#el",{x,y})`
|
|
143
|
-
* with no duration. This is what a static-element nudge writes/updates.
|
|
144
|
-
*/
|
|
145
|
-
function findPositionSetAnimation(
|
|
146
|
-
animations: GsapAnimation[],
|
|
147
|
-
selector: string,
|
|
148
|
-
): GsapAnimation | null {
|
|
149
|
-
return (
|
|
150
|
-
animations.find(
|
|
151
|
-
(a) =>
|
|
152
|
-
a.method === "set" &&
|
|
153
|
-
a.targetSelector === selector &&
|
|
154
|
-
("x" in a.properties || "y" in a.properties),
|
|
155
|
-
) ?? null
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Find the EXISTING static position HOLD to update for a static-hold drag. Not
|
|
161
|
-
* just a `set`: a degenerate `tl.to("#el",{duration:0,x,y})` (what
|
|
162
|
-
* remove-all-keyframes leaves behind) is a held position too, and the next drag
|
|
163
|
-
* must UPDATE it in place rather than append a second `gsap.set` that fights it
|
|
164
|
-
* (the duplicate-position-write bug). Only zero-duration holds qualify — a
|
|
165
|
-
* live-duration `to`/`from` is NOT a static hold (and in the static path it's a
|
|
166
|
-
* stale/phantom parse: re-committing it would resurrect a just-deleted tween).
|
|
167
|
-
* Prefers a `set` (the canonical static channel) when both forms exist.
|
|
168
|
-
*/
|
|
169
|
-
function findExistingPositionWrite(
|
|
170
|
-
animations: GsapAnimation[],
|
|
171
|
-
selector: string,
|
|
172
|
-
): GsapAnimation | null {
|
|
173
|
-
const set = findPositionSetAnimation(animations, selector);
|
|
174
|
-
if (set) return set;
|
|
175
|
-
return (
|
|
176
|
-
animations.find(
|
|
177
|
-
(a) =>
|
|
178
|
-
a.targetSelector === selector &&
|
|
179
|
-
a.propertyGroup === "position" &&
|
|
180
|
-
!a.keyframes &&
|
|
181
|
-
(a.duration ?? 0) === 0,
|
|
182
|
-
) ?? null
|
|
183
|
-
);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
115
|
/**
|
|
187
116
|
* Commit a STATIC element drag as a `tl.set("#el",{x,y})` — the single-source
|
|
188
117
|
* position channel for elements with no position animation. Idempotent: a
|
|
@@ -201,6 +130,34 @@ export async function commitStaticGsapPosition(
|
|
|
201
130
|
): Promise<void> {
|
|
202
131
|
const { newX, newY } = computeDraggedGsapPosition(selection.element, studioOffset, gsapPos);
|
|
203
132
|
if (existingSet) {
|
|
133
|
+
if (existingSet.keyframes) {
|
|
134
|
+
// Keyframed zero-duration hold (drag-path corruption): can't update-property
|
|
135
|
+
// into keyframes — delete it and write a clean static set instead.
|
|
136
|
+
const coalesceKey = `gsap:heal-static:${existingSet.id}`;
|
|
137
|
+
await callbacks.commitMutation(
|
|
138
|
+
selection,
|
|
139
|
+
{ type: "delete", animationId: existingSet.id },
|
|
140
|
+
{ label: "Move layer", skipReload: true, coalesceKey },
|
|
141
|
+
);
|
|
142
|
+
await callbacks.commitMutation(
|
|
143
|
+
selection,
|
|
144
|
+
{
|
|
145
|
+
type: "add",
|
|
146
|
+
targetSelector: selector,
|
|
147
|
+
method: "set",
|
|
148
|
+
position: 0,
|
|
149
|
+
properties: { x: newX, y: newY },
|
|
150
|
+
global: true,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
label: "Move layer",
|
|
154
|
+
softReload: true,
|
|
155
|
+
coalesceKey,
|
|
156
|
+
instantPatch: { selector, change: { kind: "global-set", props: { x: newX, y: newY } } },
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
204
161
|
// Update in place — two single-property mutations (the API updates one prop
|
|
205
162
|
// per call). Coalesce them and reload only after the second lands.
|
|
206
163
|
const coalesceKey = `gsap:set-nudge:${existingSet.id}`;
|
|
@@ -262,19 +219,6 @@ export async function commitStaticGsapPosition(
|
|
|
262
219
|
);
|
|
263
220
|
}
|
|
264
221
|
|
|
265
|
-
export { findExistingPositionWrite };
|
|
266
|
-
|
|
267
|
-
function findRotationSetAnimation(
|
|
268
|
-
animations: GsapAnimation[],
|
|
269
|
-
selector: string,
|
|
270
|
-
): GsapAnimation | null {
|
|
271
|
-
return (
|
|
272
|
-
animations.find(
|
|
273
|
-
(a) => a.method === "set" && a.targetSelector === selector && "rotation" in a.properties,
|
|
274
|
-
) ?? null
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
222
|
/**
|
|
279
223
|
* Commit a STATIC element rotation as a `tl.set("#el",{rotation})` — the single-
|
|
280
224
|
* source rotation channel for elements with no rotation animation (mirrors
|
|
@@ -328,19 +272,6 @@ export async function commitStaticGsapRotation(
|
|
|
328
272
|
);
|
|
329
273
|
}
|
|
330
274
|
|
|
331
|
-
export { findRotationSetAnimation };
|
|
332
|
-
|
|
333
|
-
function findSizeSetAnimation(animations: GsapAnimation[], selector: string): GsapAnimation | null {
|
|
334
|
-
return (
|
|
335
|
-
animations.find(
|
|
336
|
-
(a) =>
|
|
337
|
-
a.method === "set" &&
|
|
338
|
-
a.targetSelector === selector &&
|
|
339
|
-
("width" in a.properties || "height" in a.properties),
|
|
340
|
-
) ?? null
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
275
|
/**
|
|
345
276
|
* Commit a STATIC element resize as a `tl.set("#el",{width,height})` — the
|
|
346
277
|
* single-source size channel for elements with no size animation (mirrors
|
|
@@ -489,8 +420,6 @@ export async function commitKeyframedSizeFromResize(
|
|
|
489
420
|
return true;
|
|
490
421
|
}
|
|
491
422
|
|
|
492
|
-
export { findSizeSetAnimation };
|
|
493
|
-
|
|
494
423
|
// ── Whole-path offset (plain drag on animated element) ──────────────────
|
|
495
424
|
|
|
496
425
|
/**
|
|
@@ -498,6 +427,7 @@ export { findSizeSetAnimation };
|
|
|
498
427
|
* shifts together so the animation shape is preserved and the element can't
|
|
499
428
|
* dart off-screen. For flat tweens (no keyframes), convert first then shift.
|
|
500
429
|
*/
|
|
430
|
+
// fallow-ignore-next-line code-duplication
|
|
501
431
|
// fallow-ignore-next-line complexity
|
|
502
432
|
export async function commitWholePathOffset(
|
|
503
433
|
selection: DomEditSelection,
|
|
@@ -515,6 +445,7 @@ export async function commitWholePathOffset(
|
|
|
515
445
|
gsapPos,
|
|
516
446
|
);
|
|
517
447
|
const deltaX = newX - baseGsapX;
|
|
448
|
+
// fallow-ignore-next-line code-duplication
|
|
518
449
|
const deltaY = newY - baseGsapY;
|
|
519
450
|
const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
|
|
520
451
|
const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
|
|
@@ -525,6 +456,7 @@ export async function commitWholePathOffset(
|
|
|
525
456
|
el.removeAttribute("data-hf-drag-initial-offset-y");
|
|
526
457
|
};
|
|
527
458
|
|
|
459
|
+
// fallow-ignore-next-line code-duplication
|
|
528
460
|
let effectiveAnim = anim;
|
|
529
461
|
if (anim.keyframes) {
|
|
530
462
|
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
|
@@ -202,6 +202,7 @@ async function commitFlatViaKeyframes(
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
const coalesceKey = `gsap:convert-drag:${anim.id}`;
|
|
205
|
+
// fallow-ignore-next-line code-duplication
|
|
205
206
|
await callbacks.commitMutation(
|
|
206
207
|
selection,
|
|
207
208
|
{
|
|
@@ -229,6 +230,7 @@ async function commitFlatViaKeyframes(
|
|
|
229
230
|
if (editedSelected) parkPlayheadOnKeyframe(anim, pct);
|
|
230
231
|
}
|
|
231
232
|
|
|
233
|
+
// fallow-ignore-next-line code-duplication
|
|
232
234
|
// fallow-ignore-next-line complexity
|
|
233
235
|
export async function commitGsapPositionFromDrag(
|
|
234
236
|
selection: DomEditSelection,
|
|
@@ -240,6 +242,7 @@ export async function commitGsapPositionFromDrag(
|
|
|
240
242
|
callbacks: GsapDragCommitCallbacks,
|
|
241
243
|
): Promise<void> {
|
|
242
244
|
const el = selection.element;
|
|
245
|
+
// fallow-ignore-next-line code-duplication
|
|
243
246
|
const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
|
|
244
247
|
el,
|
|
245
248
|
studioOffset,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
3
|
+
import type { RuntimeTweenChange, SetPatchProps } from "./gsapRuntimePatch";
|
|
4
|
+
|
|
5
|
+
/** The shape of an `update-property` mutation a static-set nudge POSTs. */
|
|
6
|
+
interface UpdatePropertyMutation {
|
|
7
|
+
type: "update-property";
|
|
8
|
+
animationId: string;
|
|
9
|
+
property: string;
|
|
10
|
+
value: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build the `instantPatch` for a value-only `tl.set` from the SAME
|
|
15
|
+
* `update-property` mutation(s) that are POSTed — so the patch can never carry a
|
|
16
|
+
* value the source write didn't (one source of truth). Each mutation contributes
|
|
17
|
+
* its `{property: value}` channel to the patch's props.
|
|
18
|
+
*/
|
|
19
|
+
export function setPatchFromUpdateProperties(
|
|
20
|
+
selector: string,
|
|
21
|
+
mutations: UpdatePropertyMutation[],
|
|
22
|
+
global = false,
|
|
23
|
+
): { selector: string; change: RuntimeTweenChange } {
|
|
24
|
+
const props: SetPatchProps = {};
|
|
25
|
+
for (const m of mutations) props[m.property as keyof SetPatchProps] = m.value;
|
|
26
|
+
// An off-timeline `gsap.set` has no runtime tween to patch — apply it to the
|
|
27
|
+
// element directly. An on-timeline `tl.set` mutates its tween (so a re-seek keeps it).
|
|
28
|
+
return { selector, change: { kind: global ? "global-set" : "set", props } };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Single-mutation convenience over {@link setPatchFromUpdateProperties}. */
|
|
32
|
+
export function setPatchFromUpdateProperty(
|
|
33
|
+
selector: string,
|
|
34
|
+
mutation: UpdatePropertyMutation,
|
|
35
|
+
global = false,
|
|
36
|
+
): { selector: string; change: RuntimeTweenChange } {
|
|
37
|
+
return setPatchFromUpdateProperties(selector, [mutation], global);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Find the studio position-hold `set` for a selector — a `tl.set("#el",{x,y})`
|
|
42
|
+
* with no duration. This is what a static-element nudge writes/updates.
|
|
43
|
+
*/
|
|
44
|
+
function findPositionSetAnimation(
|
|
45
|
+
animations: GsapAnimation[],
|
|
46
|
+
selector: string,
|
|
47
|
+
): GsapAnimation | null {
|
|
48
|
+
return (
|
|
49
|
+
animations.find(
|
|
50
|
+
(a) =>
|
|
51
|
+
a.method === "set" &&
|
|
52
|
+
a.targetSelector === selector &&
|
|
53
|
+
("x" in a.properties || "y" in a.properties),
|
|
54
|
+
) ?? null
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Find the EXISTING static position HOLD to update for a static-hold drag. Not
|
|
60
|
+
* just a `set`: a degenerate `tl.to("#el",{duration:0,x,y})` (what
|
|
61
|
+
* remove-all-keyframes leaves behind) is a held position too, and the next drag
|
|
62
|
+
* must UPDATE it in place rather than append a second `gsap.set` that fights it
|
|
63
|
+
* (the duplicate-position-write bug). Only zero-duration holds qualify — a
|
|
64
|
+
* live-duration `to`/`from` is NOT a static hold (and in the static path it's a
|
|
65
|
+
* stale/phantom parse: re-committing it would resurrect a just-deleted tween).
|
|
66
|
+
* A keyframed zero-duration `to` is ALSO a static hold (a drag-path corruption
|
|
67
|
+
* artifact) and must be recognized so the static commit normalizes it.
|
|
68
|
+
* Prefers a `set` (the canonical static channel) when both forms exist.
|
|
69
|
+
*/
|
|
70
|
+
export function findExistingPositionWrite(
|
|
71
|
+
animations: GsapAnimation[],
|
|
72
|
+
selector: string,
|
|
73
|
+
): GsapAnimation | null {
|
|
74
|
+
const set = findPositionSetAnimation(animations, selector);
|
|
75
|
+
if (set) return set;
|
|
76
|
+
return (
|
|
77
|
+
animations.find(
|
|
78
|
+
(a) =>
|
|
79
|
+
a.targetSelector === selector &&
|
|
80
|
+
a.propertyGroup === "position" &&
|
|
81
|
+
resolveTweenDuration(a) === 0,
|
|
82
|
+
) ?? null
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function findRotationSetAnimation(
|
|
87
|
+
animations: GsapAnimation[],
|
|
88
|
+
selector: string,
|
|
89
|
+
): GsapAnimation | null {
|
|
90
|
+
return (
|
|
91
|
+
animations.find(
|
|
92
|
+
(a) => a.method === "set" && a.targetSelector === selector && "rotation" in a.properties,
|
|
93
|
+
) ?? null
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function findSizeSetAnimation(
|
|
98
|
+
animations: GsapAnimation[],
|
|
99
|
+
selector: string,
|
|
100
|
+
): GsapAnimation | null {
|
|
101
|
+
return (
|
|
102
|
+
animations.find(
|
|
103
|
+
(a) =>
|
|
104
|
+
a.method === "set" &&
|
|
105
|
+
a.targetSelector === selector &&
|
|
106
|
+
("width" in a.properties || "height" in a.properties),
|
|
107
|
+
) ?? null
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -38,6 +38,23 @@ import {
|
|
|
38
38
|
import { hasNonHoldTweenForElement } from "./gsapRuntimeKeyframes";
|
|
39
39
|
import { roundTo3 } from "../utils/rounding";
|
|
40
40
|
|
|
41
|
+
// Position channels — used to scope the "has a live position tween?" check so a
|
|
42
|
+
// sibling rotation/scale animation never forces a static position hold into the
|
|
43
|
+
// keyframe branch (which corrupts it into a frozen duration-0 keyframed tween).
|
|
44
|
+
const POSITION_CHANNELS = [
|
|
45
|
+
"x",
|
|
46
|
+
"y",
|
|
47
|
+
"xPercent",
|
|
48
|
+
"yPercent",
|
|
49
|
+
"left",
|
|
50
|
+
"top",
|
|
51
|
+
// GSAP normalizes translateX/Y to x/y at play time, but readTween reads the
|
|
52
|
+
// AUTHORED shape — include them so a hand-authored translateX/Y position tween
|
|
53
|
+
// still counts as a live position tween.
|
|
54
|
+
"translateX",
|
|
55
|
+
"translateY",
|
|
56
|
+
];
|
|
57
|
+
|
|
41
58
|
// ── Property-group tween resolution ───────────────────────────────────────
|
|
42
59
|
|
|
43
60
|
/**
|
|
@@ -185,7 +202,7 @@ export async function tryGsapDragIntercept(
|
|
|
185
202
|
// `tl.set("#el",{x,y})`, not a keyframe conversion: re-nudge an existing set in
|
|
186
203
|
// place (idempotent), else add a new one. This also covers the stale-cache
|
|
187
204
|
// phantom — committing a set is correct because the element genuinely has no live motion.
|
|
188
|
-
const hasNonHold = hasNonHoldTweenForElement(iframe, selector);
|
|
205
|
+
const hasNonHold = hasNonHoldTweenForElement(iframe, selector, undefined, POSITION_CHANNELS);
|
|
189
206
|
// A KEYFRAMED position tween — even one that's currently a flat constant ("hold",
|
|
190
207
|
// e.g. 0% and 100% identical) — is still an animation the user is building, so a
|
|
191
208
|
// drag must add/update a keyframe, NOT fall back to a static `set`. Without this,
|
|
@@ -193,7 +210,9 @@ export async function tryGsapDragIntercept(
|
|
|
193
210
|
// fights the tween (the "drag didn't create a keyframe / didn't persist" bug). The
|
|
194
211
|
// static path is only for elements with NO keyframed position tween (truly static,
|
|
195
212
|
// or just a leftover position-hold `set`).
|
|
196
|
-
|
|
213
|
+
// A zero-duration keyframed tween is a static HOLD, not a live animation —
|
|
214
|
+
// treat it as static so the drag heals it instead of feeding it more keyframes.
|
|
215
|
+
const hasKeyframedPosTween = !!posAnim?.keyframes && resolveTweenDuration(posAnim) > 0;
|
|
197
216
|
if (!hasNonHold && !hasKeyframedPosTween) {
|
|
198
217
|
const existingSet =
|
|
199
218
|
posAnim && posAnim.method === "set" && posAnim.targetSelector === selector
|
|
@@ -362,6 +381,7 @@ export async function tryGsapResizeIntercept(
|
|
|
362
381
|
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes.
|
|
363
382
|
// Outside-range uses the extend path which handles everything atomically.
|
|
364
383
|
if (!outsideRange) {
|
|
384
|
+
// fallow-ignore-next-line code-duplication
|
|
365
385
|
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
|
366
386
|
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
|
367
387
|
if (newId) anim = { ...anim, id: newId };
|
|
@@ -530,6 +550,7 @@ export async function tryGsapRotationIntercept(
|
|
|
530
550
|
return true;
|
|
531
551
|
}
|
|
532
552
|
|
|
553
|
+
// fallow-ignore-next-line code-duplication
|
|
533
554
|
if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
|
|
534
555
|
const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
|
|
535
556
|
if (newId) anim = { ...anim, id: newId };
|
|
@@ -176,6 +176,40 @@ describe("hasNonHoldTweenForElement — strict live-tween existence (drag stale-
|
|
|
176
176
|
});
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
+
describe("hasNonHoldTweenForElement — channel-scoped (position vs sibling rotation)", () => {
|
|
180
|
+
const el = { id: "puck-c" };
|
|
181
|
+
const rotationTween = {
|
|
182
|
+
targets: () => [el],
|
|
183
|
+
vars: { keyframes: { "0%": { rotation: 0 }, "100%": { rotation: 90 } }, duration: 1 },
|
|
184
|
+
duration: () => 1,
|
|
185
|
+
startTime: () => 0,
|
|
186
|
+
};
|
|
187
|
+
const positionTween = {
|
|
188
|
+
targets: () => [el],
|
|
189
|
+
vars: { keyframes: { "0%": { x: 0, y: 0 }, "100%": { x: 100, y: 40 } }, duration: 1 },
|
|
190
|
+
duration: () => 1,
|
|
191
|
+
startTime: () => 0,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
it("unfiltered: a rotation-only keyframed tween counts as non-hold", () => {
|
|
195
|
+
expect(hasNonHoldTweenForElement(fakeIframe(el, [rotationTween]), "#puck-c")).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("position-scoped: a sibling rotation-only tween is NOT a live position tween", () => {
|
|
199
|
+
// The primary fix: a keyframed rotation must not route a static position hold
|
|
200
|
+
// into the keyframe branch. Scoping to position channels returns false here.
|
|
201
|
+
expect(
|
|
202
|
+
hasNonHoldTweenForElement(fakeIframe(el, [rotationTween]), "#puck-c", undefined, ["x", "y"]),
|
|
203
|
+
).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("position-scoped: a real keyframed position tween still counts", () => {
|
|
207
|
+
expect(
|
|
208
|
+
hasNonHoldTweenForElement(fakeIframe(el, [positionTween]), "#puck-c", undefined, ["x", "y"]),
|
|
209
|
+
).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
179
213
|
describe("arcPathFromMotionPathValue", () => {
|
|
180
214
|
it("builds arc config from object form { path, curviness }", () => {
|
|
181
215
|
const arc = arcPathFromMotionPathValue({
|
|
@@ -237,6 +237,7 @@ export function resolveRuntimeTween(
|
|
|
237
237
|
kind: "keyframe" | "set",
|
|
238
238
|
compositionId?: string,
|
|
239
239
|
channels?: string[],
|
|
240
|
+
// fallow-ignore-next-line code-duplication
|
|
240
241
|
): ResolvedRuntimeTween | null {
|
|
241
242
|
const timelines = timelinesOf(iframe);
|
|
242
243
|
if (!timelines) return null;
|
|
@@ -261,6 +262,7 @@ export function resolveRuntimeTween(
|
|
|
261
262
|
const wantChannels = channels && channels.length > 0 ? channels : null;
|
|
262
263
|
|
|
263
264
|
let first: ResolvedRuntimeTween | null = null;
|
|
265
|
+
// fallow-ignore-next-line code-duplication
|
|
264
266
|
let channelMatch: ResolvedRuntimeTween | null = null;
|
|
265
267
|
for (const tlId of tlIds) {
|
|
266
268
|
const timeline = timelines[tlId];
|
|
@@ -316,6 +318,7 @@ export function readRuntimeKeyframes(
|
|
|
316
318
|
selector: string,
|
|
317
319
|
compositionId?: string,
|
|
318
320
|
requireChannels?: string[],
|
|
321
|
+
// fallow-ignore-next-line code-duplication
|
|
319
322
|
): ReadTween | null {
|
|
320
323
|
const timelines = timelinesOf(iframe);
|
|
321
324
|
if (!timelines) return null;
|
|
@@ -344,11 +347,13 @@ export function readRuntimeKeyframes(
|
|
|
344
347
|
// (e.g. two non-overlapping gesture recordings → two separate `to()`s). The
|
|
345
348
|
// overlay must draw the segment under the PLAYHEAD, not blindly the first one
|
|
346
349
|
// — otherwise recording a second gesture leaves the path stuck on the first.
|
|
350
|
+
// fallow-ignore-next-line code-duplication
|
|
347
351
|
let firstRead: ReadTween | null = null;
|
|
348
352
|
for (const tlId of tlIds) {
|
|
349
353
|
const timeline = timelines[tlId];
|
|
350
354
|
if (!timeline?.getChildren) continue;
|
|
351
355
|
const now = typeof timeline.time === "function" ? timeline.time() : null;
|
|
356
|
+
// fallow-ignore-next-line code-duplication
|
|
352
357
|
for (const tween of timeline.getChildren(true)) {
|
|
353
358
|
if (!tween.vars || !matchesElement(tween, targetEl)) continue;
|
|
354
359
|
const dur = typeof tween.duration === "function" ? tween.duration() : 0;
|
|
@@ -377,12 +382,17 @@ export function readRuntimeKeyframes(
|
|
|
377
382
|
* The drag's stale-parse guard needs this exact distinction — after a delete-all
|
|
378
383
|
* only a hold may remain, and resurrecting the deleted tween from the stale parse
|
|
379
384
|
* must be avoided.
|
|
385
|
+
* When `channels` is provided, only tweens carrying one of those keyframe
|
|
386
|
+
* properties count as non-hold motion (e.g. position channels), so a sibling
|
|
387
|
+
* rotation/scale tween doesn't make a static position hold enter the keyframe
|
|
388
|
+
* branch.
|
|
380
389
|
*/
|
|
381
390
|
// fallow-ignore-next-line complexity
|
|
382
391
|
export function hasNonHoldTweenForElement(
|
|
383
392
|
iframe: HTMLIFrameElement | null,
|
|
384
393
|
selector: string,
|
|
385
394
|
compositionId?: string,
|
|
395
|
+
channels?: string[],
|
|
386
396
|
): boolean {
|
|
387
397
|
const timelines = timelinesOf(iframe);
|
|
388
398
|
if (!timelines) return false;
|
|
@@ -401,11 +411,13 @@ export function hasNonHoldTweenForElement(
|
|
|
401
411
|
}
|
|
402
412
|
if (!targetEl) return false;
|
|
403
413
|
|
|
414
|
+
// fallow-ignore-next-line code-duplication
|
|
404
415
|
for (const tween of timeline.getChildren(true)) {
|
|
405
416
|
if (!tween.vars || !matchesElement(tween, targetEl)) continue;
|
|
406
417
|
const dur = typeof tween.duration === "function" ? tween.duration() : 0;
|
|
407
418
|
if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
|
|
408
|
-
|
|
419
|
+
const read = readTween(tween.vars);
|
|
420
|
+
if (read && (!channels || readCarriesChannel(read, channels))) return true;
|
|
409
421
|
}
|
|
410
422
|
return false;
|
|
411
423
|
}
|
|
@@ -29,6 +29,7 @@ export async function commitWholePropertyOffset(
|
|
|
29
29
|
callbacks: GsapDragCommitCallbacks,
|
|
30
30
|
label: string,
|
|
31
31
|
): Promise<void> {
|
|
32
|
+
// fallow-ignore-next-line code-duplication
|
|
32
33
|
let effectiveAnim = anim;
|
|
33
34
|
if (anim.keyframes) {
|
|
34
35
|
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
|
@@ -41,19 +41,30 @@ export type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
|
|
|
41
41
|
// The runtime re-reads data-start/data-duration from the DOM on each sync tick
|
|
42
42
|
// (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are
|
|
43
43
|
// picked up automatically on the next frame without a rebind call.
|
|
44
|
-
export function
|
|
44
|
+
export function findTimelineElementInIframe(
|
|
45
45
|
iframe: HTMLIFrameElement | null,
|
|
46
46
|
element: TimelineElement,
|
|
47
|
-
|
|
48
|
-
): void {
|
|
47
|
+
): Element | null {
|
|
49
48
|
try {
|
|
50
49
|
const doc = iframe?.contentDocument;
|
|
51
|
-
if (!doc) return;
|
|
52
|
-
|
|
50
|
+
if (!doc) return null;
|
|
51
|
+
return element.domId
|
|
53
52
|
? doc.getElementById(element.domId)
|
|
54
53
|
: element.selector
|
|
55
54
|
? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null)
|
|
56
55
|
: null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function patchIframeDomTiming(
|
|
62
|
+
iframe: HTMLIFrameElement | null,
|
|
63
|
+
element: TimelineElement,
|
|
64
|
+
attrs: Array<[string, string]>,
|
|
65
|
+
): void {
|
|
66
|
+
try {
|
|
67
|
+
const el = findTimelineElementInIframe(iframe, element);
|
|
57
68
|
if (!el) return;
|
|
58
69
|
for (const [name, value] of attrs) el.setAttribute(name, value);
|
|
59
70
|
} catch {
|
|
@@ -61,6 +72,7 @@ export function patchIframeDomTiming(
|
|
|
61
72
|
}
|
|
62
73
|
}
|
|
63
74
|
|
|
75
|
+
// fallow-ignore-next-line complexity
|
|
64
76
|
export function resolveResizePlaybackStart(
|
|
65
77
|
original: string,
|
|
66
78
|
target: PatchTarget,
|