@hyperframes/studio 0.8.127 → 0.8.128

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 (114) hide show
  1. package/dist/assets/{hyperframes-player-uqzDaxVL.js → hyperframes-player-CKCx0CFA.js} +1 -1
  2. package/dist/assets/{index-DXSYlqmV.js → index-CF31Pq1E.js} +1 -1
  3. package/dist/assets/{index-COhbL1xq.js → index-ClLzyuwb.js} +171 -171
  4. package/dist/assets/{index-CLBNYlDY.js → index-CwS08gWW.js} +1 -1
  5. package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
  6. package/dist/chunk-7UQWVN52.js.map +1 -0
  7. package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
  8. package/dist/chunk-DRVWEPO5.js.map +1 -0
  9. package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
  10. package/dist/chunk-TDWGXBUT.js.map +1 -0
  11. package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
  12. package/dist/index.d.ts +8 -9
  13. package/dist/index.html +1 -1
  14. package/dist/index.js +1193 -869
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/package.json +8 -8
  18. package/src/components/TimelineToolbar.tsx +6 -7
  19. package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
  20. package/src/components/editor/domEditingLayers.test.ts +45 -18
  21. package/src/components/editor/domEditingLayers.ts +11 -9
  22. package/src/components/editor/domEditingTypes.ts +1 -0
  23. package/src/components/editor/keyframeRetime.ts +11 -4
  24. package/src/components/editor/probeSourceElement.ts +17 -1
  25. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
  26. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
  27. package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
  28. package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
  29. package/src/hooks/gsapDragCommit.ts +8 -5
  30. package/src/hooks/gsapDragPositionCommit.ts +90 -17
  31. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
  32. package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
  33. package/src/hooks/gsapMutationClient.ts +35 -7
  34. package/src/hooks/gsapOvershootEase.test.ts +67 -0
  35. package/src/hooks/gsapParsedTween.ts +5 -6
  36. package/src/hooks/gsapPositionDetection.ts +5 -2
  37. package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
  38. package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
  39. package/src/hooks/gsapRuntimeKeyframes.ts +19 -59
  40. package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
  41. package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
  42. package/src/hooks/gsapShared.test.ts +48 -35
  43. package/src/hooks/gsapShared.ts +30 -32
  44. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +93 -3
  45. package/src/hooks/gsapValueAtPlayhead.test.ts +13 -0
  46. package/src/hooks/gsapValueAtPlayhead.ts +3 -4
  47. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  48. package/src/hooks/timelineEditingHelpers.ts +40 -14
  49. package/src/hooks/timelineLinkEditPlan.ts +6 -4
  50. package/src/hooks/timelineMoveAdapter.ts +3 -5
  51. package/src/hooks/timelineTimingSync.test.ts +83 -125
  52. package/src/hooks/timelineTimingSync.ts +62 -42
  53. package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
  54. package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
  55. package/src/hooks/useCommitPreflightCapabilities.ts +42 -7
  56. package/src/hooks/useDomEditPreviewSync.ts +8 -2
  57. package/src/hooks/useDomSelection.test.ts +27 -0
  58. package/src/hooks/useDomSelection.ts +12 -13
  59. package/src/hooks/useDomSelectionTypes.ts +1 -0
  60. package/src/hooks/useEditHistoryActions.test.tsx +54 -3
  61. package/src/hooks/useEditHistoryActions.ts +9 -6
  62. package/src/hooks/useEnableKeyframes.test.ts +20 -15
  63. package/src/hooks/useEnableKeyframes.ts +54 -71
  64. package/src/hooks/useGestureCommit.ts +2 -1
  65. package/src/hooks/useGsapTweenCache.ts +2 -17
  66. package/src/hooks/usePersistentEditHistory.test.ts +45 -0
  67. package/src/hooks/usePersistentEditHistory.ts +13 -2
  68. package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
  69. package/src/hooks/usePreviewPersistence.ts +26 -4
  70. package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
  71. package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
  72. package/src/hooks/useTimelineDeleteOps.ts +5 -13
  73. package/src/hooks/useTimelineEditing.test.tsx +63 -4
  74. package/src/hooks/useTimelineEditing.ts +21 -295
  75. package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
  76. package/src/hooks/useTimelineGroupEditing.ts +15 -38
  77. package/src/player/components/Timeline.test.ts +12 -4
  78. package/src/player/components/TimelineClip.tsx +3 -0
  79. package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
  80. package/src/player/components/TimelineLanes.test.tsx +61 -5
  81. package/src/player/components/TimelineLanes.tsx +20 -28
  82. package/src/player/components/TimelinePropertyLanes.tsx +10 -1
  83. package/src/player/components/timelineCallbacks.ts +1 -4
  84. package/src/player/components/timelineClipDragCommit.test.ts +31 -0
  85. package/src/player/components/timelineClipDragCommit.ts +39 -28
  86. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  87. package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
  88. package/src/player/components/timelineClipGestureHandlers.ts +2 -20
  89. package/src/player/components/timelineEditing.ts +1 -3
  90. package/src/player/components/timelineGroupResizeCommit.ts +10 -9
  91. package/src/player/components/timelineLinkSelection.ts +1 -35
  92. package/src/player/components/timelineOptimisticRevision.ts +7 -4
  93. package/src/player/components/trackHeaderLaneState.ts +9 -2
  94. package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
  95. package/src/player/components/trackHeaderLaneValues.ts +54 -13
  96. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
  97. package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
  98. package/src/player/components/useTimelineClipDrag.ts +0 -3
  99. package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
  100. package/src/player/store/batchElementUpdates.test.ts +43 -0
  101. package/src/player/store/batchElementUpdates.ts +9 -0
  102. package/src/player/store/elementPatchQueue.ts +35 -0
  103. package/src/player/store/playerStore.ts +5 -6
  104. package/src/utils/domEditHelpers.ts +5 -0
  105. package/src/utils/globalTimeCompiler.test.ts +0 -4
  106. package/src/utils/globalTimeCompiler.ts +6 -4
  107. package/src/utils/gsapKeyframeEases.ts +79 -0
  108. package/src/utils/sameData.ts +9 -0
  109. package/src/utils/studioFileHistory.ts +1 -0
  110. package/src/utils/studioPendingEdits.ts +15 -1
  111. package/dist/chunk-246VB2G3.js.map +0 -1
  112. package/dist/chunk-3F47EFCT.js.map +0 -1
  113. package/dist/chunk-5EKWJZLM.js.map +0 -1
  114. /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
@@ -7,13 +7,13 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
7
  import {
8
8
  applyArcKeyframeAtPlayhead,
9
9
  animatedProps,
10
- buildExtendedKeyframes,
11
10
  isPlayheadWithinTween,
12
11
  promoteSetToKeyframes,
13
12
  resolveNewTweenRange,
14
13
  useEnableKeyframes,
15
14
  type EnableKeyframesSession,
16
15
  } from "./useEnableKeyframes";
16
+ import { buildExtendedKeyframes } from "./gsapDragPositionCommit";
17
17
  import { usePlayerStore } from "../player/store/playerStore";
18
18
 
19
19
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -109,17 +109,11 @@ describe("isPlayheadWithinTween", () => {
109
109
  expect(isPlayheadWithinTween(anim({ position: "+=1" }), 99)).toBe(true);
110
110
  });
111
111
 
112
- // The toolbar's "extends animation" tooltip has to agree with what the edit
113
- // paths do. Those span a duration-less tween across its clip, so answering
114
- // from GSAP's 0.5s default reported the playhead outside a window the click
115
- // then treated as clip-wide.
116
- it("spans the clip for a duration-less tween when given the selection", () => {
112
+ it("uses the window GSAP plays a duration-less tween in, not its clip", () => {
117
113
  const durationless = anim({ position: 0 });
118
- const selection = { dataAttributes: { duration: "16" } } as unknown as DomEditSelection;
119
114
 
115
+ expect(isPlayheadWithinTween(durationless, 0.4)).toBe(true);
120
116
  expect(isPlayheadWithinTween(durationless, 5)).toBe(false);
121
- expect(isPlayheadWithinTween(durationless, 5, selection)).toBe(true);
122
- expect(isPlayheadWithinTween(durationless, 20, selection)).toBe(false);
123
117
  });
124
118
  });
125
119
 
@@ -238,6 +232,7 @@ describe("applyArcKeyframeAtPlayhead", () => {
238
232
  id: "#el-to-0-position",
239
233
  position: 0,
240
234
  duration: 10,
235
+ ease: "none",
241
236
  keyframes: {
242
237
  format: "object-array",
243
238
  keyframes: [
@@ -318,8 +313,8 @@ describe("applyArcKeyframeAtPlayhead", () => {
318
313
  );
319
314
  });
320
315
 
321
- it("uses the owning clip duration when an arc omits its outer duration", async () => {
322
- const fixture = arcFixture(25, 25);
316
+ it("extends a duration-less arc to a later playhead, keeping the other nodes' times", async () => {
317
+ const fixture = arcFixture(100, 0);
323
318
  const durationlessArc = { ...arcAnim, duration: undefined };
324
319
 
325
320
  await applyArcKeyframeAtPlayhead(
@@ -330,12 +325,22 @@ describe("applyArcKeyframeAtPlayhead", () => {
330
325
  fixture.iframe,
331
326
  );
332
327
 
328
+ // GSAP plays it 0-0.5 s, so the nodes stay at 0, 0.25 and 0.5 s.
333
329
  expect(fixture.commitMutation).toHaveBeenCalledWith(
334
- expect.objectContaining({
330
+ {
335
331
  type: "replace-with-keyframes",
336
- duration: 10,
337
- keyframes: expect.arrayContaining([{ percentage: 25, properties: { x: 25, y: 25 } }]),
338
- }),
332
+ animationId: arcAnim.id,
333
+ targetSelector: "#el",
334
+ position: 0,
335
+ duration: 2.5,
336
+ keyframes: [
337
+ { percentage: 0, properties: { x: 0, y: 0 } },
338
+ { percentage: 10, properties: { x: 50, y: 50 } },
339
+ { percentage: 20, properties: { x: 100, y: 0 } },
340
+ { percentage: 100, properties: { x: 100, y: 0 } },
341
+ ],
342
+ ease: "none",
343
+ },
339
344
  { label: "Add keyframe", softReload: true, keyframeAction: "add" },
340
345
  );
341
346
  });
@@ -8,7 +8,7 @@
8
8
  * Reads GSAP runtime values only (no CSS offset — it applies separately via translate).
9
9
  */
10
10
  import { useCallback } from "react";
11
- import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
11
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
12
12
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
13
  import { usePlayerStore } from "../player/store/playerStore";
14
14
  import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
@@ -18,19 +18,25 @@ import {
18
18
  keyframeEases,
19
19
  KEYFRAME_PCT_MATCH,
20
20
  isInstantHold,
21
- resolveEditableTweenDuration,
21
+ playsNear,
22
22
  writeTargetSelector,
23
23
  } from "./gsapShared";
24
24
  import {
25
25
  absoluteToPercentage,
26
+ absoluteToPercentageForAnimation,
26
27
  resolveTweenStart,
27
28
  resolveTweenDuration,
28
29
  isTimeWithinTween,
29
30
  } from "../utils/globalTimeCompiler";
30
31
  import { POSITION_PROPS } from "./gsapRuntimeReaders";
32
+ import { findParsedTween, withLiveTiming } from "./gsapParsedTween";
31
33
  import { roundTo3 } from "../utils/rounding";
32
34
  import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
33
- import { buildTemporalArcKeyframes } from "./gsapDragPositionCommit";
35
+ import {
36
+ buildExtendedKeyframes,
37
+ buildTemporalArcKeyframes,
38
+ linearArcKeyframes,
39
+ } from "./gsapDragPositionCommit";
34
40
 
35
41
  let enableKeyframesTransactionCounter = 0;
36
42
 
@@ -80,58 +86,11 @@ export function animatedProps(anim: GsapAnimation | null): string[] {
80
86
  * Whether the playhead sits inside an animation's tween range. When the tween's
81
87
  * start can't be resolved we don't block (the percentage falls back to clip range,
82
88
  * preserving prior behavior for elements without explicit timing).
83
- *
84
- * Pass the selection whenever the caller has one: a duration-less tween spans
85
- * its clip, and answering from GSAP's 0.5s default reports the playhead outside
86
- * a window the edit paths treat as clip-wide.
87
89
  */
88
- export function isPlayheadWithinTween(
89
- anim: GsapAnimation,
90
- currentTime: number,
91
- selection?: DomEditSelection | null,
92
- ): boolean {
90
+ export function isPlayheadWithinTween(anim: GsapAnimation, currentTime: number): boolean {
93
91
  const start = resolveTweenStart(anim);
94
92
  if (start === null) return true;
95
- const duration = selection
96
- ? resolveEditableTweenDuration(anim, selection)
97
- : resolveTweenDuration(anim);
98
- return isTimeWithinTween(currentTime, start, duration);
99
- }
100
-
101
- /**
102
- * Grow a keyframe tween's range to reach a playhead that sits outside it, and add a
103
- * keyframe there. Existing keyframes keep their *absolute* timing (percentages
104
- * rescale into the new range), so the current motion is preserved — the playhead
105
- * just becomes a new hold at the start or end. Used when "add keyframe at playhead"
106
- * fires beyond the tween instead of disabling the action.
107
- */
108
- export function buildExtendedKeyframes(
109
- anim: GsapAnimation,
110
- currentTime: number,
111
- position: Record<string, number>,
112
- sourceDuration = resolveTweenDuration(anim),
113
- ): { position: number; duration: number; keyframes: GsapPercentageKeyframe[] } {
114
- const oldStart = resolveTweenStart(anim) ?? 0;
115
- const oldDuration = sourceDuration;
116
- const newStart = Math.min(oldStart, currentTime);
117
- const newEnd = Math.max(oldStart + oldDuration, currentTime);
118
- const newDuration = roundTo3(newEnd - newStart);
119
- const toPct = (absoluteTime: number) =>
120
- newDuration > 0
121
- ? Math.max(
122
- 0,
123
- Math.min(100, Math.round(((absoluteTime - newStart) / newDuration) * 1000) / 10),
124
- )
125
- : 0;
126
- const stops = anim.keyframes?.keyframes ?? [];
127
- const rescaled: GsapPercentageKeyframe[] = stops.map((stop) => ({
128
- percentage: toPct(oldStart + (stop.percentage / 100) * oldDuration),
129
- properties: stop.properties,
130
- ...(stop.ease ? { ease: stop.ease } : {}),
131
- }));
132
- const added: GsapPercentageKeyframe = { percentage: toPct(currentTime), properties: position };
133
- const keyframes = [...rescaled, added].sort((a, b) => a.percentage - b.percentage);
134
- return { position: roundTo3(newStart), duration: newDuration, keyframes };
93
+ return isTimeWithinTween(currentTime, start, resolveTweenDuration(anim));
135
94
  }
136
95
 
137
96
  async function replaceSetWithSingleKeyframe(
@@ -285,17 +244,24 @@ async function extendKeyframedTweenToPlayhead(
285
244
  * buildExtendedKeyframes). Shared by native keyframe tweens and flat tweens that
286
245
  * were just converted, so both behave identically.
287
246
  */
288
- async function applyKeyframeAtPlayhead(
247
+ export async function applyKeyframeAtPlayhead(
289
248
  session: EnableKeyframesSession,
290
249
  sel: DomEditSelection,
291
250
  kfAnim: GsapAnimation,
292
251
  t: number,
293
252
  iframe: HTMLIFrameElement | null,
294
253
  commitOverrides?: Partial<CommitMutationOptions>,
254
+ liveTween?: ReturnType<typeof findParsedTween>,
295
255
  ): Promise<void> {
296
- const duration = resolveEditableTweenDuration(kfAnim, sel);
256
+ const keepAuthoredDuration = kfAnim.durationUnresolved === true;
257
+ if (keepAuthoredDuration) {
258
+ kfAnim = withLiveTiming(kfAnim, liveTween ?? findParsedTween(iframe, sel.element, kfAnim));
259
+ }
260
+ if (keepAuthoredDuration && kfAnim.duration == null) return;
261
+ const duration = resolveTweenDuration(kfAnim);
297
262
  const start = resolveTweenStart(kfAnim);
298
263
  if (start !== null && !isTimeWithinTween(t, start, duration)) {
264
+ if (keepAuthoredDuration) return;
299
265
  await extendKeyframedTweenToPlayhead(
300
266
  session,
301
267
  sel,
@@ -308,10 +274,10 @@ async function applyKeyframeAtPlayhead(
308
274
  return;
309
275
  }
310
276
  const pct =
311
- start === null ? computeElementPercentage(t, sel) : absoluteToPercentage(t, start, duration);
312
- const existing = kfAnim.keyframes?.keyframes.find(
313
- (k) => Math.abs(k.percentage - pct) <= KEYFRAME_PCT_MATCH,
314
- );
277
+ start === null
278
+ ? computeElementPercentage(t, sel)
279
+ : (absoluteToPercentageForAnimation(t, kfAnim) ?? 0);
280
+ const existing = kfAnim.keyframes?.keyframes.find((k) => playsNear(kfAnim, k.percentage, pct));
315
281
  if (existing) {
316
282
  session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
317
283
  return;
@@ -404,21 +370,27 @@ export async function applyArcKeyframeAtPlayhead(
404
370
  const targetSelector = existingTweenTargetSelector(arcAnim, sel);
405
371
  if (!targetSelector) return;
406
372
  const start = resolveTweenStart(arcAnim) ?? 0;
407
- const duration = resolveEditableTweenDuration(arcAnim, sel);
373
+ const duration = resolveTweenDuration(arcAnim);
408
374
  if (!isTimeWithinTween(t, start, duration)) {
409
- if (t > start) {
410
- await session.commitMutation(
411
- {
412
- type: "update-meta",
413
- animationId: arcAnim.id,
414
- updates: { duration: roundTo3(t - start) },
415
- },
416
- { label: "Extend motion path", softReload: true },
417
- );
418
- }
375
+ const held = readElementPosition(iframe, sel, arcAnim);
376
+ if (t <= start || typeof held.x !== "number" || typeof held.y !== "number") return;
377
+ const extended = buildExtendedKeyframes(arcAnim, t, { x: held.x, y: held.y }, duration, "none");
378
+ await session.commitMutation(
379
+ {
380
+ type: "replace-with-keyframes",
381
+ animationId: arcAnim.id,
382
+ targetSelector,
383
+ position: extended.position,
384
+ duration: extended.duration,
385
+ keyframes: extended.keyframes,
386
+ ease: "none",
387
+ },
388
+ { label: "Add keyframe", keyframeAction: "add", softReload: true },
389
+ );
419
390
  return;
420
391
  }
421
- const nodes = arcAnim.keyframes?.keyframes ?? [];
392
+ // The arc is rewritten linear: compare and write at the times GSAP plays each node.
393
+ const nodes = linearArcKeyframes(arcAnim);
422
394
  const playheadPercentage = absoluteToPercentage(t, start, duration);
423
395
  const timedNodeIndex = nodes.findIndex(
424
396
  (node) => Math.abs(node.percentage - playheadPercentage) <= KEYFRAME_PCT_MATCH,
@@ -502,6 +474,9 @@ export function useEnableKeyframes(
502
474
  // resolvedFromValues, so the 0%/100% stops keep the real start→end motion
503
475
  // (passing the playhead value would flatten it). Then apply uniformly so an
504
476
  // out-of-range playhead extends the range just like a keyframe tween.
477
+ const flatLive = flatAnim.durationUnresolved
478
+ ? findParsedTween(iframe, sel.element, flatAnim)
479
+ : undefined;
505
480
  enableKeyframesTransactionCounter += 1;
506
481
  const coalesceKey = `enable-keyframes:${flatAnim.id}:${enableKeyframesTransactionCounter}`;
507
482
  const convertCommitOverrides: Partial<CommitMutationOptions> = {
@@ -527,7 +502,15 @@ export function useEnableKeyframes(
527
502
  // conversion splits into two undo entries under real latency.
528
503
  coalesceMs: Number.POSITIVE_INFINITY,
529
504
  };
530
- await applyKeyframeAtPlayhead(session, sel, converted, t, iframe, applyCommitOverrides);
505
+ await applyKeyframeAtPlayhead(
506
+ session,
507
+ sel,
508
+ converted,
509
+ t,
510
+ iframe,
511
+ applyCommitOverrides,
512
+ flatLive,
513
+ );
531
514
  }
532
515
  } else {
533
516
  const position = readElementPosition(iframe, sel, null);
@@ -8,6 +8,7 @@ import { simplifyGestureSamples } from "../utils/rdpSimplify";
8
8
  import { fitEasesFromVelocity } from "../utils/velocityEaseFitter";
9
9
  import { smoothGestureKeyframes } from "../utils/gestureSmoother";
10
10
  import { usePlayerStore } from "../player";
11
+ import { resolveTweenDuration } from "../utils/globalTimeCompiler";
11
12
  import type { DomEditSelection } from "../components/editor/domEditing";
12
13
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
13
14
  import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
@@ -258,7 +259,7 @@ export function useGestureCommit({
258
259
  );
259
260
  } else {
260
261
  const tweenStart = existingPositionTween.resolvedStart ?? 0;
261
- const tweenDur = existingPositionTween.duration ?? duration;
262
+ const tweenDur = resolveTweenDuration(existingPositionTween);
262
263
  const tweenEnd = tweenStart + tweenDur;
263
264
  const recEnd = recStart + duration;
264
265
 
@@ -11,7 +11,7 @@ import {
11
11
  publishKeyframeCache,
12
12
  writeGsapAnimationsForElement,
13
13
  } from "./gsapKeyframeCacheHelpers";
14
- import { resolveClipTimingBasis, toAbsoluteTime, toClipPercentage } from "./gsapShared";
14
+ import { resolveClipTimingBasis, toClipKeyframes } from "./gsapShared";
15
15
  import {
16
16
  deduplicateKeyframes,
17
17
  isStaticPositionHold,
@@ -199,22 +199,7 @@ export function useGsapAnimationsForElement(
199
199
  if (isStaticPositionHold(anim)) continue;
200
200
  const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
201
201
  if (!kf) continue;
202
- // Convert tween-relative percentages to clip-relative so diamonds
203
- // render at the correct position within the timeline clip.
204
- const tweenPos =
205
- anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
206
- const tweenDur = anim.duration ?? elDuration;
207
- for (const k of kf.keyframes) {
208
- const absTime = toAbsoluteTime(tweenPos, tweenDur, k.percentage);
209
- const clipPct = toClipPercentage(absTime, elStart, elDuration, k.percentage);
210
- allKeyframes.push({
211
- ...k,
212
- percentage: clipPct,
213
- tweenPercentage: k.percentage,
214
- propertyGroup: anim.propertyGroup,
215
- animationId: anim.id,
216
- });
217
- }
202
+ allKeyframes.push(...toClipKeyframes(kf.keyframes, anim, elStart, elDuration));
218
203
  format = kf.format;
219
204
  if (kf.ease) ease = kf.ease;
220
205
  if (kf.easeEach) easeEach = kf.easeEach;
@@ -14,6 +14,10 @@ import {
14
14
  type StudioApiAdapter,
15
15
  } from "@hyperframes/studio-server";
16
16
  import { consumeStudioWriteToken } from "../utils/studioFileVersion";
17
+ import {
18
+ beginStudioPendingEdit,
19
+ paintBackNewestStudioPendingEdit,
20
+ } from "../utils/studioPendingEdits";
17
21
  import { usePersistentEditHistory } from "./usePersistentEditHistory";
18
22
 
19
23
  const cleanup: Array<() => unknown> = [];
@@ -88,6 +92,22 @@ it("an edit Studio saved is undone and redone by the project's history, with the
88
92
  expect(file()).toBe("B");
89
93
  });
90
94
 
95
+ it("gives an edit that begins the history's claim count, so undo can tell its claims from older ones", async () => {
96
+ const { hook, save } = await studio();
97
+ save("B");
98
+ await act(() =>
99
+ hook().recordEdit({
100
+ label: "Moved Title",
101
+ files: { "index.html": { before: "A", after: "B" } },
102
+ }),
103
+ );
104
+ const saving = beginStudioPendingEdit(() => () => {});
105
+
106
+ expect(paintBackNewestStudioPendingEdit()?.claimsAtBegin).toBe(hook().claims());
107
+ expect(hook().claims()).toBe(1);
108
+ saving.settle();
109
+ });
110
+
91
111
  it("undoes the edit claimed since the key, not a later edit the server took in first", async () => {
92
112
  const { dir, hook, file, save, readFile } = await studio();
93
113
  const atKey = hook().claims();
@@ -366,6 +386,31 @@ it("a drag's edits under one key undo as one step, even before the drag goes idl
366
386
  expect(file()).toBe("A");
367
387
  });
368
388
 
389
+ it("a key one page holds does not join another page's edit under the same key", async () => {
390
+ const { hook, file, save, readFile } = await studio();
391
+ let reloaded!: ReturnType<typeof usePersistentEditHistory>;
392
+ function Reloaded() {
393
+ reloaded = usePersistentEditHistory({ projectId: "demo" });
394
+ return null;
395
+ }
396
+ const root = createRoot(document.createElement("div"));
397
+ await act(async () => root.render(createElement(Reloaded)));
398
+ cleanup.push(() => act(() => root.unmount()));
399
+ const held = { label: "Moved Title", coalesceKey: "timeline-move:1", coalesceMs: Infinity };
400
+ save("B");
401
+ await act(() =>
402
+ hook().recordEdit({ ...held, files: { "index.html": { before: "A", after: "B" } } }),
403
+ );
404
+ save("C");
405
+ await act(() =>
406
+ reloaded.recordEdit({ ...held, files: { "index.html": { before: "B", after: "C" } } }),
407
+ );
408
+
409
+ await act(() => reloaded.undo({ readFile }));
410
+
411
+ expect(file()).toBe("B");
412
+ });
413
+
369
414
  it("an undo before the history view shows a drag's held claim still waits on the files it wrote", async () => {
370
415
  const { hook, save, readFile } = await studio();
371
416
  const server = globalThis.fetch;
@@ -3,7 +3,9 @@ import type { HistoryListItem, HistoryResult } from "@hyperframes/studio-server"
3
3
  import { studioFileContentVersion, studioWriteHeaders } from "../utils/studioFileVersion";
4
4
  import type { RestoreFiles } from "../utils/gsapUndoRestore";
5
5
  import { studioApiFetch } from "../utils/studioApiFetch";
6
+ import { setStudioPendingEditClaimClock } from "../utils/studioPendingEdits";
6
7
  import type { RecordEditInput } from "../utils/studioFileHistory";
8
+ import { generateId } from "../utils/generateId";
7
9
 
8
10
  interface ApplyCallbacks {
9
11
  readFile: (path: string) => Promise<string>;
@@ -206,6 +208,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
206
208
  const projectIdRef = useRef(projectId);
207
209
  projectIdRef.current = projectId;
208
210
  const [own] = useState(createOwnHistory);
211
+ const [pageKeyScope] = useState(generateId);
209
212
 
210
213
  const refresh = useCallback(async () => {
211
214
  if (!projectId) return;
@@ -228,6 +231,11 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
228
231
  void refresh().finally(() => setLoaded(true));
229
232
  }, [refresh, own]);
230
233
 
234
+ useEffect(() => {
235
+ setStudioPendingEditClaimClock(own.claimCount);
236
+ return () => setStudioPendingEditClaimClock(null);
237
+ }, [own]);
238
+
231
239
  const recordEdit = useCallback(
232
240
  async ({ label, coalesceKey, coalesceMs, files, created = [] }: RecordEditInput) => {
233
241
  if (!projectId) return;
@@ -237,7 +245,10 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
237
245
  label,
238
246
  paths,
239
247
  overwrote: await overwroteVersions(files),
240
- ...(coalesceKey && { coalesceKey, idleMs: coalesceMs ?? DEFAULT_COALESCE_MS }),
248
+ ...(coalesceKey && {
249
+ coalesceKey: `${pageKeyScope}:${coalesceKey}`,
250
+ idleMs: coalesceMs ?? DEFAULT_COALESCE_MS,
251
+ }),
241
252
  });
242
253
  const claimed = claimHeld(reply, label);
243
254
  if (claimed) {
@@ -247,7 +258,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
247
258
  heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null;
248
259
  await refresh();
249
260
  },
250
- [projectId, refresh, own],
261
+ [projectId, refresh, own, pageKeyScope],
251
262
  );
252
263
 
253
264
  const step = useCallback(
@@ -4,6 +4,10 @@ import React, { act } from "react";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import { usePlayerStore } from "../player/store/playerStore";
6
6
  import type { TimelineElement } from "../player/store/timelineElement";
7
+ import {
8
+ beginStudioManualEditGesture,
9
+ endStudioManualEditGesture,
10
+ } from "../components/editor/manualEdits";
7
11
  import { mountReactHarness } from "./domSelectionTestHarness";
8
12
  import { usePreviewPersistence } from "./usePreviewPersistence";
9
13
 
@@ -132,3 +136,82 @@ describe("undo that re-runs the top-level script over an element of a nested com
132
136
  expect(nwid.getAttribute("style")).toBe("left: 40px; width: 337px");
133
137
  });
134
138
  });
139
+
140
+ describe("undo that lands while a newer gesture holds an element", () => {
141
+ const page = (left: number) =>
142
+ `<html><body><div id="root" data-composition-id="root"><div id="box" data-hf-id="hf-b" style="left: ${left}px"></div></div></body></html>`;
143
+ const files = { "index.html": { previous: page(0), restored: page(200) } };
144
+
145
+ function heldPreview(held = true) {
146
+ const doc = document.implementation.createHTMLDocument("");
147
+ doc.head.innerHTML = `<meta name="hf-scene-parts" content='{"shared":"s1","scenes":{}}'>`;
148
+ doc.body.innerHTML = `<div id="root" data-composition-id="root"><div id="box" data-hf-id="hf-b" style="left: 90px"></div></div>`;
149
+ const box = doc.getElementById("box")!;
150
+ if (held) beginStudioManualEditGesture(box, "move");
151
+ const contentWindow = {
152
+ __hfForceTimelineRebind: vi.fn(),
153
+ __timelines: {},
154
+ __player: { getTime: () => 1, seek: vi.fn() },
155
+ __hfStudioManualEditsApply: vi.fn(),
156
+ };
157
+ const iframe = { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement;
158
+ const reloadPreview = vi.fn();
159
+ let hook: ReturnType<typeof usePreviewPersistence> | null = null;
160
+ function Harness() {
161
+ hook = usePreviewPersistence({
162
+ showToast: () => {},
163
+ readOptionalProjectFile: async () => "",
164
+ writeProjectFile: async () => {},
165
+ recordEdit: async () => {},
166
+ previewIframeRef: { current: iframe },
167
+ activeCompPathRef: { current: "index.html" },
168
+ reloadPreview,
169
+ });
170
+ return null;
171
+ }
172
+ mountReactHarness(<Harness />);
173
+ usePlayerStore.getState().setSelectedElementId("box");
174
+ const sharedPart = () =>
175
+ JSON.parse(doc.querySelector<HTMLMetaElement>('meta[name="hf-scene-parts"]')!.content).shared;
176
+ return { box, win: contentWindow, reloadPreview, sharedPart, hook: () => hook! };
177
+ }
178
+
179
+ it("leaves the held element where the gesture drew it and reloads the preview once it ends", async () => {
180
+ const { box, win, reloadPreview, sharedPart, hook } = heldPreview();
181
+
182
+ await act(async () => hook().syncHistoryPreviewAfterApply({ paths: ["index.html"], files }));
183
+
184
+ expect(box.style.left).toBe("90px");
185
+ expect(win.__player.seek).not.toHaveBeenCalled();
186
+ expect(win.__hfStudioManualEditsApply).not.toHaveBeenCalled();
187
+ expect(reloadPreview).not.toHaveBeenCalled();
188
+ endStudioManualEditGesture(box);
189
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
190
+ // The scene swap must load the restored file again, not keep the part it showed before the undo.
191
+ expect(sharedPart()).toBe("");
192
+ expect(usePlayerStore.getState().selectedElementId).toBe("box");
193
+ });
194
+
195
+ it("does not paint a predicted undo under the gesture", () => {
196
+ const { box, win, hook } = heldPreview();
197
+
198
+ expect(hook().showHistoryRestoreNow(files)).toBeNull();
199
+ expect(box.style.left).toBe("90px");
200
+ expect(win.__player.seek).not.toHaveBeenCalled();
201
+ });
202
+
203
+ it("puts a refused predicted undo back through the preview reload once a gesture took the element", async () => {
204
+ const { box, win, reloadPreview, hook } = heldPreview(false);
205
+ const putBack = hook().showHistoryRestoreNow(files)!;
206
+ beginStudioManualEditGesture(box, "move");
207
+ win.__player.seek.mockClear();
208
+
209
+ putBack();
210
+ await act(async () => {});
211
+ expect(reloadPreview).not.toHaveBeenCalled();
212
+ endStudioManualEditGesture(box);
213
+
214
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
215
+ expect(win.__player.seek).not.toHaveBeenCalled();
216
+ });
217
+ });
@@ -4,7 +4,12 @@ import {
4
4
  installStudioManualEditSeekReapply,
5
5
  reapplyPositionEditsAfterSeek,
6
6
  } from "../components/editor/manualEdits";
7
+ import {
8
+ afterStudioManualEditGestures,
9
+ isStudioManualEditGestureLiveIn,
10
+ } from "../components/editor/manualEditsDom";
7
11
  import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
12
+ import { markScenesStale } from "../player/sceneSwap";
8
13
  import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue";
9
14
  import {
10
15
  flushStudioPendingEdits,
@@ -199,6 +204,12 @@ export function usePreviewPersistence({
199
204
 
200
205
  // ── Sync preview after undo/redo ──
201
206
 
207
+ // Undo never repaints under a live gesture; the preview reloads once the last one ends.
208
+ const heldPreviewDoc = useCallback(() => {
209
+ const doc = previewIframeRef.current?.contentDocument;
210
+ return doc && isStudioManualEditGestureLiveIn(doc) ? doc : null;
211
+ }, [previewIframeRef]);
212
+
202
213
  const syncHistoryPreviewAfterApply = useCallback(
203
214
  async (restore: HistoryPreviewRestore) => {
204
215
  // Prefer an in-place soft reload for a soft-reloadable restore (the change
@@ -220,6 +231,15 @@ export function usePreviewPersistence({
220
231
  : Promise.reject(new Error("No project is open to read nested files from.")),
221
232
  ),
222
233
  );
234
+ const held = heldPreviewDoc();
235
+ if (held) {
236
+ const paths = restore.paths ?? Object.keys(restore.files ?? {});
237
+ afterStudioManualEditGestures(held, () => {
238
+ markScenesStale(previewIframeRef.current, paths);
239
+ reloadPreview();
240
+ });
241
+ return;
242
+ }
223
243
  const strategy = applyUndoRestoreToPreview(
224
244
  previewIframeRef.current,
225
245
  activeCompPathRef.current,
@@ -238,24 +258,26 @@ export function usePreviewPersistence({
238
258
  // reload. The full path above waits for the reloaded preview to report instead.
239
259
  syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
240
260
  },
241
- [previewIframeRef, activeCompPathRef, reloadPreview],
261
+ [previewIframeRef, activeCompPathRef, reloadPreview, heldPreviewDoc],
242
262
  );
243
263
 
244
264
  // A restore the server has not confirmed yet: in place now, or not at all. A GSAP script re-run is not
245
265
  // synchronous, and a pending save would land under it.
246
266
  const showHistoryRestoreNow = useCallback(
247
267
  (files: RestoreFiles): (() => void) | null => {
248
- if (!domEditSaveQueueRef.current?.isIdle() || hasStudioPendingEdits()) return null;
268
+ if (!domEditSaveQueueRef.current?.isIdle() || hasStudioPendingEdits() || heldPreviewDoc())
269
+ return null;
249
270
  const iframe = previewIframeRef.current;
250
271
  const now = () => usePlayerStore.getState().currentTime;
251
272
  const putBack = showRestoreInPlace(iframe, activeCompPathRef.current, files, now());
252
273
  if (!putBack) return null;
253
274
  return () => {
254
- if (putBack(now())) syncStoredAutomationFromPreview(iframe?.contentDocument ?? null);
275
+ if (!heldPreviewDoc() && putBack(now()))
276
+ syncStoredAutomationFromPreview(iframe?.contentDocument ?? null);
255
277
  else void syncHistoryPreviewAfterApply({ paths: Object.keys(files) });
256
278
  };
257
279
  },
258
- [previewIframeRef, activeCompPathRef, syncHistoryPreviewAfterApply],
280
+ [previewIframeRef, activeCompPathRef, syncHistoryPreviewAfterApply, heldPreviewDoc],
259
281
  );
260
282
 
261
283
  // ── Migrate legacy studio-motion.json ──