@hyperframes/studio 0.8.132 → 0.8.134

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 (34) hide show
  1. package/dist/assets/{hyperframes-player-Cp8e_Zst.js → hyperframes-player-Bro1gj0Z.js} +1 -1
  2. package/dist/assets/{index-CASEBNy2.js → index-BwNDTmAD.js} +1 -1
  3. package/dist/assets/{index-DMTt9nTf.js → index-DnVGIbEC.js} +1 -1
  4. package/dist/assets/{index-oE1pOdAy.js → index-wnXyQjW-.js} +143 -143
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +717 -702
  7. package/dist/index.js.map +1 -1
  8. package/package.json +8 -8
  9. package/src/components/editor/manualEditsDom.ts +5 -1
  10. package/src/components/editor/plainTranslate.test.ts +21 -2
  11. package/src/components/editor/plainTranslate.ts +7 -0
  12. package/src/hooks/draggedGsapPosition.test.ts +34 -0
  13. package/src/hooks/draggedGsapPosition.ts +40 -15
  14. package/src/hooks/gsapDragCommit.ts +17 -10
  15. package/src/hooks/gsapDragPositionCommit.ts +8 -9
  16. package/src/hooks/gsapResizeIntercept.test.ts +72 -1
  17. package/src/hooks/gsapResizeIntercept.ts +84 -17
  18. package/src/hooks/gsapRuntimeBridge.ts +4 -1
  19. package/src/hooks/gsapTrailingKeyframes.realGsap.test.ts +44 -0
  20. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  21. package/src/hooks/useEditHistoryActions.ts +3 -0
  22. package/src/hooks/useEnableKeyframes.ts +32 -37
  23. package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
  24. package/src/hooks/useGsapAwareEditing.ts +10 -2
  25. package/src/player/components/timelineClipDragCommit.test.ts +22 -0
  26. package/src/player/components/timelineClipDragCommit.ts +7 -10
  27. package/src/player/components/timelineStackingSync.test.ts +101 -142
  28. package/src/player/components/timelineStackingSync.ts +51 -235
  29. package/src/player/components/useTimelineStackingSync.ts +1 -1
  30. package/src/timelineStackingSyncExport.test.tsx +3 -2
  31. package/src/utils/elementGsap.ts +6 -5
  32. package/src/utils/gsapUndoRestore.test.ts +22 -0
  33. package/src/utils/gsapUndoRestore.ts +4 -2
  34. package/src/utils/studioPendingEdits.ts +12 -2
@@ -0,0 +1,44 @@
1
+ import { trimTrailingKeyframeSpans } from "@hyperframes/parsers/gsap-writer-acorn";
2
+ import { gsap } from "gsap";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ const timeline = `const tl = gsap.timeline({ paused: true });\n`;
6
+
7
+ // What Studio saved for a plain element keyed at 1, 2 and 3 s, and for its size keyed at 3 s then 2 s.
8
+ const saved = {
9
+ position:
10
+ `tl.set("#target", { x: 299.893, data: "hf-hold" }, 0);\n` +
11
+ `tl.to("#target", { keyframes: { "0%": { x: 299.893 }, "33.333%": { x: 297.086 }, "66.667%": { x: 47.754 } }, duration: 3 }, 1);`,
12
+ size:
13
+ `tl.set("#target", { width: 440, data: "hf-hold" }, 0);\n` +
14
+ `tl.to("#target", { keyframes: { "0%": { width: 440 }, "50%": { width: 340, ease: "none" } }, duration: 2 }, 2);`,
15
+ };
16
+
17
+ function valueAt(script: string, prop: "x" | "width", seeks: number[]) {
18
+ const target: Record<string, number> = { x: 0, width: 240 };
19
+ const run = new Function(
20
+ "gsap",
21
+ "target",
22
+ `${script.replaceAll('"#target"', "target")}\nreturn tl;`,
23
+ );
24
+ const tl = run(gsap, target) as gsap.core.Timeline;
25
+ for (const time of seeks) tl.seek(time);
26
+ return target[prop];
27
+ }
28
+
29
+ describe.each([
30
+ ["position", "x", 47.754],
31
+ ["size", "width", 340],
32
+ ] as const)("a %s tween Studio keyed short of its end", (name, prop, atThree) => {
33
+ it("plays and seeks to the same value once its tail is trimmed", () => {
34
+ const script = trimTrailingKeyframeSpans(timeline, timeline + saved[name]);
35
+ expect(valueAt(script, prop, [3])).toBeCloseTo(atThree, 3);
36
+ expect(valueAt(script, prop, [1, 2, 3])).toBeCloseTo(atThree, 3);
37
+ });
38
+
39
+ it("is what GSAP 3.15 gets wrong on a direct seek without the trim", () => {
40
+ const script = timeline + saved[name];
41
+ expect(valueAt(script, prop, [1, 2, 3])).toBeCloseTo(atThree, 3);
42
+ expect(valueAt(script, prop, [3])).not.toBeCloseTo(atThree, 0);
43
+ });
44
+ });
@@ -7,6 +7,7 @@ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
7
7
  import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryActions";
8
8
  import {
9
9
  beginStudioPendingEdit,
10
+ isStudioEditSaving,
10
11
  setStudioPendingEditClaimClock,
11
12
  trackStudioPendingEdit,
12
13
  } from "../utils/studioPendingEdits";
@@ -97,6 +98,25 @@ describe("useEditHistoryActions", () => {
97
98
  });
98
99
  });
99
100
 
101
+ it("counts a shown step as saving until the server takes it, so a preview reload waits for its write", async () => {
102
+ const { deps, actions } = mount(
103
+ { ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES },
104
+ PREDICTED,
105
+ );
106
+ let step!: () => void;
107
+ const stepped = new Promise<void>((resolve) => (step = resolve));
108
+ const undo = deps.editHistory.undo.getMockImplementation()!;
109
+ deps.editHistory.undo.mockImplementation(async (cb) => (await stepped, undo(cb)));
110
+
111
+ const undone = actions.undo();
112
+ expect(deps.showHistoryRestoreNow).toHaveBeenCalledWith(PREDICTED.files);
113
+ expect(isStudioEditSaving()).toBe(true);
114
+ step();
115
+ await act(() => undone);
116
+ await Promise.resolve();
117
+ expect(isStudioEditSaving()).toBe(false);
118
+ });
119
+
100
120
  it("puts a shown step back and applies the server's own restore when it stepped another entry", async () => {
101
121
  const { deps, actions } = mount(
102
122
  {
@@ -4,6 +4,7 @@ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
4
4
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
5
5
  import type { RestoreFiles } from "../utils/gsapUndoRestore";
6
6
  import {
7
+ beginStudioShownRestore,
7
8
  hasStudioPendingEdits,
8
9
  paintBackNewestStudioPendingEdit,
9
10
  } from "../utils/studioPendingEdits";
@@ -82,6 +83,7 @@ export function useEditHistoryActions({
82
83
  const pendingEditShown = direction === "undo" ? paintBackNewestStudioPendingEdit() : null;
83
84
  const predicted = pendingEditShown ? null : (editHistory.predict?.(direction) ?? null);
84
85
  const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
86
+ const shownLanded = predictedShown ? beginStudioShownRestore() : null;
85
87
  const putBack = predictedShown ?? pendingEditShown?.showAgain;
86
88
  const claimedAfter =
87
89
  pendingEditShown?.claimsAtBegin ??
@@ -107,6 +109,7 @@ export function useEditHistoryActions({
107
109
  : stepped && editClaimed;
108
110
  } finally {
109
111
  if (putBack && !serverSteppedShown && !revertIsTheUndo) putBack();
112
+ shownLanded?.();
110
113
  }
111
114
  if (!result.ok && result.reason === "content-mismatch") {
112
115
  showToast(
@@ -31,6 +31,7 @@ import {
31
31
  import { POSITION_PROPS } from "./gsapRuntimeReaders";
32
32
  import { findParsedTween, withLiveTiming } from "./gsapParsedTween";
33
33
  import { roundTo3 } from "../utils/rounding";
34
+ import { readTranslatePxLeavingPercent } from "../components/editor/plainTranslate";
34
35
  import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
35
36
  import {
36
37
  buildExtendedKeyframes,
@@ -180,6 +181,32 @@ export function resolveNewTweenRange(
180
181
  return { start: clampedStart, duration: Math.max(0.5, roundTo3(end - clampedStart)) };
181
182
  }
182
183
 
184
+ export function singleKeyTweenMutation(
185
+ sel: DomEditSelection,
186
+ properties: Record<string, number>,
187
+ currentTime: number,
188
+ ): Record<string, unknown> | null {
189
+ // A brand-new tween: author it against the one element the user selected.
190
+ // The bare class selectorFromSelection hands back for an id-less element
191
+ // animates every sibling sharing the class (see writeTargetSelector).
192
+ const targetSelector = writeTargetSelector(sel);
193
+ if (!targetSelector) return null;
194
+ const { start, duration } = resolveNewTweenRange(
195
+ sel.dataAttributes?.start,
196
+ sel.dataAttributes?.duration,
197
+ currentTime,
198
+ );
199
+ // One keyframe at the playhead, a single diamond capturing the current value;
200
+ // motion comes from the keyframes the user adds or drags later.
201
+ return {
202
+ type: "add-with-keyframes",
203
+ targetSelector,
204
+ position: roundTo3(start),
205
+ duration: roundTo3(duration),
206
+ keyframes: [{ percentage: 0, properties }],
207
+ };
208
+ }
209
+
183
210
  // Authoritative parse of the current source for `sel`. Returns `null` when the
184
211
  // fetch can't run (no projectId / request failed) so callers can distinguish
185
212
  // "unavailable" from a genuine empty result (e.g. after a delete-all). An empty
@@ -514,43 +541,11 @@ export function useEnableKeyframes(
514
541
  }
515
542
  } else {
516
543
  const position = readElementPosition(iframe, sel, null);
517
- const { start: elStart, duration: elDuration } = resolveNewTweenRange(
518
- sel.dataAttributes?.start,
519
- sel.dataAttributes?.duration,
520
- t,
521
- );
522
- // A brand-new tween: author it against the one element the user selected.
523
- // The bare class selectorFromSelection hands back for an id-less element
524
- // animates every sibling sharing the class (see writeTargetSelector).
525
- const selector = writeTargetSelector(sel);
526
-
527
- if (!selector) {
528
- session.handleGsapAddAnimation("to");
529
- return;
530
- }
531
-
532
- if (Object.keys(position).length === 0) {
533
- position.x = 0;
534
- position.y = 0;
535
- }
536
-
537
- // One keyframe at the playhead — a single diamond capturing the current
538
- // value. Motion comes from the user adding/dragging more keyframes later;
539
- // creating 0%+100% up front showed two diamonds for a single "add keyframe".
540
- const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
541
- [{ percentage: 0, properties: { ...position } }];
542
-
543
- if (session.commitMutation) {
544
- await session.commitMutation(
545
- {
546
- type: "add-with-keyframes",
547
- targetSelector: selector,
548
- position: roundTo3(elStart),
549
- duration: roundTo3(elDuration),
550
- keyframes,
551
- },
552
- { label: "Enable keyframes", softReload: true },
553
- );
544
+ if (Object.keys(position).length === 0)
545
+ Object.assign(position, readTranslatePxLeavingPercent(sel.element));
546
+ const mutation = singleKeyTweenMutation(sel, position, t);
547
+ if (mutation && session.commitMutation) {
548
+ await session.commitMutation(mutation, { label: "Enable keyframes", softReload: true });
554
549
  } else {
555
550
  session.handleGsapAddAnimation("to");
556
551
  }
@@ -424,6 +424,7 @@ describe("useGsapAwareEditing anchored resize", () => {
424
424
  expect.any(Function),
425
425
  undefined,
426
426
  expect.any(Function),
427
+ expect.objectContaining({ frozen: true }),
427
428
  );
428
429
  act(() => h.root.unmount());
429
430
  });
@@ -16,7 +16,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
16
16
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
17
17
  import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
18
18
  import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
19
- import { computeDraggedGsapPosition } from "./draggedGsapPosition";
19
+ import { computeDraggedGsapPosition, freezeDragStamp } from "./draggedGsapPosition";
20
20
  import { readGsapPositionFromIframe } from "./gsapPositionDetection";
21
21
  import { selectorFromSelection } from "./gsapShared";
22
22
  import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
@@ -209,6 +209,7 @@ export function useGsapAwareEditing({
209
209
  restore: () => void = () => undefined,
210
210
  route?: { plainTranslate: boolean },
211
211
  ) => {
212
+ const stamp = freezeDragStamp(selection.element);
212
213
  const writes = observeGsapGesture(gsapCommitMutation);
213
214
  if (route?.plainTranslate ?? editsPlainCss(selection.element, "resize")) {
214
215
  const result = await handleDomBoxSizeCommit(selection, next, offset, restore);
@@ -256,6 +257,7 @@ export function useGsapAwareEditing({
256
257
  previewIframeRef.current,
257
258
  commitMutation,
258
259
  makeFetchFallback(selection),
260
+ { stamp },
259
261
  );
260
262
  // Saved after the size, under its undo key, so the two are one step.
261
263
  await saveMove(dragOutcome, async () => {
@@ -279,7 +281,12 @@ export function useGsapAwareEditing({
279
281
  x: 0,
280
282
  y: 0,
281
283
  };
282
- const { newX, newY } = computeDraggedGsapPosition(selection.element, offset, gsapPos);
284
+ const { newX, newY } = computeDraggedGsapPosition(
285
+ selection.element,
286
+ offset,
287
+ gsapPos,
288
+ stamp,
289
+ );
283
290
  logResize("sync-settle", { gsapPos, offset, newX, newY });
284
291
  setElementGsapPosition(selection.element, newX, newY);
285
292
  });
@@ -297,6 +304,7 @@ export function useGsapAwareEditing({
297
304
  makeFetchFallback(selection),
298
305
  offset,
299
306
  writes.drawKeepingUndone,
307
+ stamp,
300
308
  );
301
309
  assertGsapEditPersisted(outcome);
302
310
  // Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
@@ -588,6 +588,28 @@ describe("commitDraggedClipMove", () => {
588
588
  expectZLiftedToSix(onStackingPatches);
589
589
  });
590
590
 
591
+ it("a multi-selection lane move restacks only the dragged clip", async () => {
592
+ // a moves up from row 2 to row 1 over c; the selected scene keeps its row, so it keeps its z.
593
+ const elements = [
594
+ el("scene", 0, 0, 10),
595
+ el("b", 1, 5, 5),
596
+ el("a", 2, 0, 4),
597
+ el("c", 3, 0, 4),
598
+ ];
599
+ const z: Record<string, number> = { scene: 0, b: 2, a: 1, c: 3 };
600
+ const onStackingPatches = vi.fn();
601
+ runClipMove(drag(elements[2], { previewStart: 0, previewTrack: 1 }), {
602
+ elements,
603
+ trackOrder: [0, 1, 2, 3],
604
+ selectedKeys: new Set(["a", "scene"]),
605
+ readZIndex: (e) => z[e.key ?? e.id] ?? 0,
606
+ onStackingPatches,
607
+ });
608
+ await flushMicrotasks();
609
+ expect(onStackingPatches).toHaveBeenCalledTimes(1);
610
+ expect(onStackingPatches.mock.calls[0][0]).toEqual([{ key: "a", zIndex: 4 }]);
611
+ });
612
+
591
613
  it("partial z-sync deps (no readZIndex) → move persists but no stacking call", async () => {
592
614
  const elements = [el("a", 1, 0, 10), el("b", 0, 0, 10)];
593
615
  // onStackingPatches present but readZIndex absent → syncStackingForEdit needs
@@ -337,7 +337,6 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
337
337
  if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
338
338
  return e;
339
339
  });
340
- const multiKeys = multi ? multi.keys : null;
341
340
  if (!isVertical || !deps.readZIndex || !deps.onStackingPatches) {
342
341
  void refreshAfterDurableLaneMove(
343
342
  persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
@@ -354,7 +353,6 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
354
353
  dragKey,
355
354
  drag.element.track,
356
355
  drag.previewTrack,
357
- multiKeys,
358
356
  deps,
359
357
  coalesceKey,
360
358
  ),
@@ -472,7 +470,6 @@ function commitTrackInsert(
472
470
  dragKey,
473
471
  drag.element.track,
474
472
  drag.insertRow!,
475
- multi ? multi.keys : null,
476
473
  deps,
477
474
  coalesceKey,
478
475
  ),
@@ -532,8 +529,8 @@ export function commitZMirrorLaneMove(
532
529
  * vertical lane change. Projects the drop-intent element set (`candidate`: the
533
530
  * dragged clip at its new / fractional-insert lane, others at their current tracks)
534
531
  * onto StackingElement using the caller-supplied live z-index reader, then
535
- * delegates the minimal-z resolution to computeStackingPatches — a clip on the
536
- * upper lane paints above every clip it time-overlaps. No-op unless both z-sync
532
+ * delegates to computeStackingPatches — the moved clip alone rises (moved up) or
533
+ * sinks (moved down) past the clips it time-overlaps. No-op unless both z-sync
537
534
  * deps are present, and never when the gesture aimed at the clip's OWN current
538
535
  * lane (`aimedLane === currentLane` — not a relocation).
539
536
  */
@@ -542,7 +539,6 @@ function syncStackingForEdit(
542
539
  dragKey: string,
543
540
  currentLane: number,
544
541
  aimedLane: number,
545
- multiKeys: ReadonlySet<string> | null,
546
542
  deps: DragCommitDeps,
547
543
  coalesceKey?: string,
548
544
  ): Promise<void> {
@@ -567,10 +563,11 @@ function syncStackingForEdit(
567
563
  stackingContextId: el.stackingContextId ?? null,
568
564
  }));
569
565
 
570
- const editedKeys = [dragKey];
571
- if (multiKeys) for (const k of multiKeys) if (k !== dragKey) editedKeys.push(k);
572
-
573
- const patches = computeStackingPatches(stackingEls, editedKeys);
566
+ const patches = computeStackingPatches(
567
+ stackingEls,
568
+ [dragKey],
569
+ aimedLane < currentLane ? "up" : "down",
570
+ );
574
571
  if (patches.length === 0) return Promise.resolve();
575
572
  return Promise.resolve(onStackingPatches(patches, coalesceKey)).then(() => undefined);
576
573
  }