@hyperframes/studio 0.7.39 → 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.
Files changed (76) hide show
  1. package/dist/assets/{index-312a3Ceu.js → index-89kPtC4s.js} +1 -1
  2. package/dist/assets/{index-B9YvRJz1.js → index-B2Utv-2b.js} +195 -195
  3. package/dist/assets/index-BpM6cnfP.css +1 -0
  4. package/dist/assets/{index-CQHiecE7.js → index-CjX8Ljc8.js} +1 -1
  5. package/dist/{chunk-RCLGSZ7C.js → chunk-BA66NM4L.js} +1 -1
  6. package/dist/{chunk-RCLGSZ7C.js.map → chunk-BA66NM4L.js.map} +1 -1
  7. package/dist/{domEditingLayers-S6YOLVRG.js → domEditingLayers-H7LDFIJ7.js} +2 -2
  8. package/dist/index.d.ts +34 -29
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +2749 -2075
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +2 -1
  14. package/src/components/StudioPreviewArea.tsx +6 -0
  15. package/src/components/StudioRightPanel.tsx +3 -0
  16. package/src/components/editor/DomEditOverlay.tsx +33 -47
  17. package/src/components/editor/PropertyPanel.test.ts +25 -0
  18. package/src/components/editor/PropertyPanel.tsx +33 -7
  19. package/src/components/editor/domEditingElement.ts +8 -1
  20. package/src/components/editor/manualEditsDom.ts +1 -18
  21. package/src/components/editor/manualEditsStyleHelpers.ts +18 -0
  22. package/src/components/editor/offCanvasIndicatorGeometry.ts +74 -0
  23. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +154 -0
  24. package/src/components/editor/offCanvasIndicatorRefresh.ts +99 -0
  25. package/src/components/editor/propertyPanelHelpers.ts +11 -0
  26. package/src/components/editor/propertyPanelTypes.ts +1 -0
  27. package/src/contexts/TimelineEditContext.tsx +1 -0
  28. package/src/hooks/gsapDragCommit.test.ts +56 -0
  29. package/src/hooks/gsapDragCommit.ts +41 -109
  30. package/src/hooks/gsapDragPositionCommit.ts +3 -0
  31. package/src/hooks/gsapDragStaticSetHelpers.ts +109 -0
  32. package/src/hooks/gsapRuntimeBridge.ts +23 -2
  33. package/src/hooks/gsapRuntimeKeyframes.test.ts +34 -0
  34. package/src/hooks/gsapRuntimeKeyframes.ts +13 -1
  35. package/src/hooks/gsapWholePropertyOffsetCommit.ts +1 -0
  36. package/src/hooks/timelineEditingHelpers.ts +17 -5
  37. package/src/hooks/timelineTrackVisibility.test.ts +202 -0
  38. package/src/hooks/timelineTrackVisibility.ts +371 -0
  39. package/src/hooks/useRenderClipContent.test.ts +68 -2
  40. package/src/hooks/useRenderClipContent.ts +5 -6
  41. package/src/hooks/useTimelineEditing.ts +35 -30
  42. package/src/hooks/useTimelineEditingTypes.ts +30 -0
  43. package/src/icons/SystemIcons.tsx +12 -4
  44. package/src/player/components/AudioWaveform.tsx +11 -8
  45. package/src/player/components/CompositionThumbnail.tsx +14 -11
  46. package/src/player/components/PlayheadIndicator.tsx +21 -11
  47. package/src/player/components/Timeline.test.ts +105 -5
  48. package/src/player/components/Timeline.tsx +37 -47
  49. package/src/player/components/TimelineCanvas.tsx +275 -256
  50. package/src/player/components/TimelineClip.test.tsx +105 -0
  51. package/src/player/components/TimelineClip.tsx +44 -57
  52. package/src/player/components/TimelineRuler.tsx +17 -23
  53. package/src/player/components/TimelineShortcutHint.tsx +26 -0
  54. package/src/player/components/TimelineTypes.ts +18 -0
  55. package/src/player/components/VideoThumbnail.tsx +16 -13
  56. package/src/player/components/timelineCallbacks.ts +1 -0
  57. package/src/player/components/timelineIcons.tsx +2 -44
  58. package/src/player/components/timelineLayout.ts +18 -22
  59. package/src/player/components/timelineMotionStyles.test.ts +101 -0
  60. package/src/player/components/timelineTheme.test.ts +21 -4
  61. package/src/player/components/timelineTheme.ts +15 -15
  62. package/src/player/components/useTimelineActiveClips.test.ts +93 -0
  63. package/src/player/components/useTimelineActiveClips.ts +125 -0
  64. package/src/player/components/useTimelinePlayhead.ts +6 -0
  65. package/src/player/hooks/useExpandedTimelineElements.test.ts +110 -3
  66. package/src/player/hooks/useExpandedTimelineElements.ts +106 -19
  67. package/src/player/lib/time.test.ts +1 -0
  68. package/src/player/lib/timelineDOM.test.ts +48 -1
  69. package/src/player/lib/timelineDOM.ts +7 -0
  70. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  71. package/src/player/store/playerStore.ts +5 -1
  72. package/src/styles/studio.css +108 -0
  73. package/src/utils/sourcePatcher.test.ts +11 -0
  74. package/src/utils/sourcePatcher.ts +6 -4
  75. package/dist/assets/index-0P10SwC_.css +0 -1
  76. /package/dist/{domEditingLayers-S6YOLVRG.js.map → domEditingLayers-H7LDFIJ7.js.map} +0 -0
@@ -0,0 +1,99 @@
1
+ import type React from "react";
2
+ import type { OffCanvasRect } from "./OffCanvasIndicators";
3
+ import { recomputeOffCanvasIndicators } from "./offCanvasIndicatorGeometry";
4
+
5
+ interface OffCanvasIndicatorRefreshOptions {
6
+ iframeRef: React.RefObject<HTMLIFrameElement | null>;
7
+ overlayRef: React.RefObject<HTMLDivElement | null>;
8
+ compRectRef: React.MutableRefObject<{ left: number; top: number; width: number; height: number }>;
9
+ activeCompositionPathRef: React.MutableRefObject<string | null>;
10
+ dirtyRef: React.MutableRefObject<boolean>;
11
+ sigRef: React.MutableRefObject<string>;
12
+ observerRef: React.MutableRefObject<MutationObserver | null>;
13
+ observedDocRef: React.MutableRefObject<Document | null>;
14
+ elementsRef: React.MutableRefObject<Map<string, HTMLElement>>;
15
+ setRects: (rects: OffCanvasRect[]) => void;
16
+ }
17
+
18
+ function compSignature(comp: { left: number; top: number; width: number; height: number }): string {
19
+ return `${Math.round(comp.left)}:${Math.round(comp.top)}:${Math.round(comp.width)}:${Math.round(comp.height)}`;
20
+ }
21
+
22
+ function clearIndicators(options: OffCanvasIndicatorRefreshOptions): void {
23
+ options.dirtyRef.current = false;
24
+ options.sigRef.current = "";
25
+ options.elementsRef.current = new Map();
26
+ options.setRects([]);
27
+ }
28
+
29
+ function observeDoc(doc: Document, markDirty: () => void): MutationObserver | null {
30
+ const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver;
31
+ if (!Observer) return null;
32
+ const observer = new Observer(markDirty);
33
+ observer.observe(doc.documentElement, {
34
+ attributes: true,
35
+ // data-hidden is included explicitly: hiding an element writes data-hidden, and
36
+ // although the runtime honoring also writes display:"none" (a style mutation we'd
37
+ // catch anyway), keying on the attribute directly makes the coupling robust to any
38
+ // future throttling of that runtime sync.
39
+ attributeFilter: ["style", "class", "transform", "width", "height", "data-hidden"],
40
+ childList: true,
41
+ subtree: true,
42
+ });
43
+ return observer;
44
+ }
45
+
46
+ export function startOffCanvasIndicatorRefresh(
47
+ options: OffCanvasIndicatorRefreshOptions,
48
+ ): () => void {
49
+ let frame = 0;
50
+ let lastCompSig = "";
51
+ const markDirty = () => {
52
+ options.dirtyRef.current = true;
53
+ };
54
+ const attachObserver = (doc: Document | null) => {
55
+ options.observerRef.current?.disconnect();
56
+ options.observerRef.current = doc?.documentElement ? observeDoc(doc, markDirty) : null;
57
+ options.observedDocRef.current = doc;
58
+ options.sigRef.current = "";
59
+ };
60
+ const update = () => {
61
+ frame = requestAnimationFrame(update);
62
+ const iframe = options.iframeRef.current;
63
+ const overlayEl = options.overlayRef.current;
64
+ const doc = iframe?.contentDocument ?? null;
65
+ if (doc !== options.observedDocRef.current) {
66
+ attachObserver(doc);
67
+ markDirty();
68
+ }
69
+ const comp = options.compRectRef.current;
70
+ const nextCompSig = compSignature(comp);
71
+ if (nextCompSig !== lastCompSig) {
72
+ lastCompSig = nextCompSig;
73
+ markDirty();
74
+ }
75
+ if (!iframe || !overlayEl) {
76
+ if (options.dirtyRef.current) clearIndicators(options);
77
+ return;
78
+ }
79
+ if (!options.dirtyRef.current) return;
80
+ options.dirtyRef.current = false;
81
+ recomputeOffCanvasIndicators(
82
+ iframe,
83
+ overlayEl,
84
+ doc,
85
+ comp,
86
+ options.activeCompositionPathRef.current,
87
+ options.sigRef,
88
+ options.elementsRef,
89
+ options.setRects,
90
+ );
91
+ };
92
+ frame = requestAnimationFrame(update);
93
+ return () => {
94
+ cancelAnimationFrame(frame);
95
+ options.observerRef.current?.disconnect();
96
+ options.observerRef.current = null;
97
+ options.observedDocRef.current = null;
98
+ };
99
+ }
@@ -2,6 +2,7 @@ import { parseCssColor, type ParsedColor } from "./colorValue";
2
2
  import { COMMON_LOCAL_FONT_FAMILIES } from "./fontCatalog";
3
3
  import type { DomEditSelection } from "./domEditing";
4
4
  import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
5
+ import type { TimelineElement } from "../../player";
5
6
  import { roundToCenti } from "../../utils/rounding";
6
7
 
7
8
  export type {
@@ -18,6 +19,16 @@ export function stripQueryAndHash(value: string): string {
18
19
  return value.slice(0, Math.min(queryIndex, hashIndex));
19
20
  }
20
21
 
22
+ export function isSelectedElementHidden(
23
+ elements: readonly TimelineElement[],
24
+ selectedElementId: string | null,
25
+ ): boolean {
26
+ if (!selectedElementId) return false;
27
+ return (
28
+ elements.find((element) => (element.key ?? element.id) === selectedElementId)?.hidden === true
29
+ );
30
+ }
31
+
21
32
  /* ------------------------------------------------------------------ */
22
33
  /* Font types & constants (shared by font and section modules) */
23
34
  /* ------------------------------------------------------------------ */
@@ -52,6 +52,7 @@ export interface PropertyPanelProps {
52
52
  onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
53
53
  onRemoveTextField: (fieldKey: string) => void;
54
54
  onAskAgent: () => void;
55
+ onToggleElementHidden?: (elementKey: string, hidden: boolean) => void | Promise<void>;
55
56
  onImportAssets?: (files: FileList, dir?: string) => Promise<string[]>;
56
57
  fontAssets?: ImportedFontAsset[];
57
58
  onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
@@ -32,6 +32,7 @@ export function TimelineEditProvider({
32
32
  [
33
33
  value.onMoveElement,
34
34
  value.onResizeElement,
35
+ value.onToggleTrackHidden,
35
36
  value.onBlockedEditAttempt,
36
37
  value.onSplitElement,
37
38
  value.onRazorSplit,
@@ -5,6 +5,7 @@ import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
5
5
  import {
6
6
  commitStaticGsapPosition,
7
7
  commitStaticGsapRotation,
8
+ findExistingPositionWrite,
8
9
  parkPlayheadOnKeyframe,
9
10
  type GsapDragCommitCallbacks,
10
11
  } from "./gsapDragCommit";
@@ -75,6 +76,7 @@ describe("commitGsapPositionFromDrag — flat tween", () => {
75
76
  });
76
77
 
77
78
  it("extends the existing tween (never spawns a parallel one) when dragged OUTSIDE its range", async () => {
79
+ // fallow-ignore-next-line code-duplication
78
80
  usePlayerStore.setState({ currentTime: 6 }); // outside [1.2, 3.4]
79
81
  const { types, callbacks } = recordingCallbacks();
80
82
 
@@ -94,7 +96,9 @@ describe("commitGsapPositionFromDrag — flat tween", () => {
94
96
  });
95
97
 
96
98
  it("adds a keyframe at the playhead when dragged INSIDE its range", async () => {
99
+ // fallow-ignore-next-line code-duplication
97
100
  usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4]
101
+ // fallow-ignore-next-line code-duplication
98
102
  const { types, callbacks } = recordingCallbacks();
99
103
 
100
104
  await commitGsapPositionFromDrag(
@@ -115,6 +119,7 @@ describe("commitGsapPositionFromDrag — flat tween", () => {
115
119
  // User clicked the 100% diamond (activeKeyframePct=100), playhead drifted past
116
120
  // the end. Expect: convert + add-keyframe AT 100% — not replace-with-keyframes.
117
121
  usePlayerStore.setState({ currentTime: 6, activeKeyframePct: 100 }); // outside [1.2, 3.4]
122
+ // fallow-ignore-next-line code-duplication
118
123
  const { types, mutations, callbacks } = recordingCallbacks();
119
124
 
120
125
  await commitGsapPositionFromDrag(
@@ -338,6 +343,7 @@ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
338
343
  await commitStaticGsapPosition(
339
344
  selection(),
340
345
  { x: -50, y: 30 },
346
+ // fallow-ignore-next-line code-duplication
341
347
  { x: 0, y: 0 },
342
348
  "#puck-a",
343
349
  null, // no existing set → `add` a new base gsap.set
@@ -352,6 +358,55 @@ describe("commitStaticGsapPosition — instantPatch (value-only set)", () => {
352
358
  });
353
359
  });
354
360
 
361
+ // A degenerate `tl.to("#el",{keyframes:{...},duration:0})` — what a pre-fix drag
362
+ // left behind when it routed a STATIC position hold (sitting beside a keyframed
363
+ // rotation) into the keyframe branch. A duration-0 keyframed tween renders its
364
+ // final keyframe at every playhead, so the element froze and "couldn't move".
365
+ const keyframedZeroDurationHold = (): GsapAnimation =>
366
+ ({
367
+ id: "#puck-a-frozen",
368
+ targetSelector: "#puck-a",
369
+ method: "to",
370
+ propertyGroup: "position",
371
+ duration: 0,
372
+ keyframes: {
373
+ keyframes: [
374
+ { percentage: 0, properties: { x: 100, y: 50 } },
375
+ { percentage: 100, properties: { x: -260, y: -70 } },
376
+ ],
377
+ },
378
+ properties: {},
379
+ }) as unknown as GsapAnimation;
380
+
381
+ describe("static position hold recognition + heal (frozen duration-0 keyframed tween)", () => {
382
+ beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
383
+
384
+ it("findExistingPositionWrite recognizes a keyframed zero-duration position hold", () => {
385
+ const found = findExistingPositionWrite([keyframedZeroDurationHold()], "#puck-a");
386
+ expect(found?.id).toBe("#puck-a-frozen");
387
+ });
388
+
389
+ it("commitStaticGsapPosition heals a keyframed hold by delete + clean add-set (never update-property)", async () => {
390
+ const { commits, callbacks } = optionRecordingCallbacks();
391
+
392
+ await commitStaticGsapPosition(
393
+ selection(),
394
+ { x: -50, y: 30 },
395
+ { x: 0, y: 0 },
396
+ "#puck-a",
397
+ keyframedZeroDurationHold(),
398
+ callbacks,
399
+ );
400
+
401
+ const types = commits.map((c) => c.mutation.type);
402
+ // Can't update-property into keyframes — must delete the frozen tween and
403
+ // write a clean static set, so the element becomes freely movable.
404
+ expect(types).toEqual(["delete", "add"]);
405
+ expect(types).not.toContain("update-property");
406
+ expect((commits[1].mutation as { method?: string }).method).toBe("set");
407
+ });
408
+ });
409
+
355
410
  describe("commitStaticGsapRotation — instantPatch (value-only set)", () => {
356
411
  beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }));
357
412
 
@@ -377,6 +432,7 @@ describe("commitStaticGsapRotation — instantPatch (value-only set)", () => {
377
432
  it("ADDS a global gsap.set with a global-set instantPatch (off-timeline, no flash)", async () => {
378
433
  const { commits, callbacks } = optionRecordingCallbacks();
379
434
 
435
+ // fallow-ignore-next-line code-duplication
380
436
  await commitStaticGsapRotation(selection(), 42, "#puck-a", null, callbacks);
381
437
 
382
438
  expect(commits).toHaveLength(1);
@@ -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, SetPatchProps } from "./gsapRuntimePatch";
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
- const hasKeyframedPosTween = !!posAnim?.keyframes;
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 };