@hyperframes/studio 0.8.133 → 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.
@@ -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.
@@ -8,7 +8,6 @@
8
8
  type ElementGsapWindow = Window & {
9
9
  gsap?: {
10
10
  set?: (target: Element, vars: Record<string, number>) => void;
11
- getProperty?: (target: Element, prop: string) => unknown;
12
11
  };
13
12
  };
14
13
 
@@ -38,10 +37,12 @@ export function setElementGsapScale(element: HTMLElement, x: number, y: number):
38
37
  return true;
39
38
  }
40
39
 
41
- /** The element's GSAP numeric property, or null when unreadable. */
42
- export function readElementGsapNumber(element: HTMLElement, prop: string): number | null {
43
- const value = Number(gsapOf(element)?.getProperty?.(element, prop));
44
- return Number.isFinite(value) ? value : null;
40
+ /** Set the element's GSAP width/height. Returns false when no runtime is reachable. */
41
+ export function setElementGsapSize(element: HTMLElement, width: number, height: number): boolean {
42
+ const gsap = gsapOf(element);
43
+ if (!gsap?.set) return false;
44
+ gsap.set(element, { width, height });
45
+ return true;
45
46
  }
46
47
 
47
48
  /** The targets CSSPlugin styles: not plain objects (the runtime's filler) or XML-namespace elements. */
@@ -21,6 +21,7 @@ import {
21
21
  } from "./gsapUndoRestore";
22
22
  import { applyPatch } from "./sourcePatcher";
23
23
  import { beginStudioManualEditGesture } from "../components/editor/manualEdits";
24
+ import { studioManualEditSavesIn } from "../components/editor/manualEditsDom";
24
25
  import { writePlainMove, writeTranslatePx } from "../components/editor/plainTranslate";
25
26
 
26
27
  // ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
@@ -281,6 +282,16 @@ describe("applyUndoRestoreToPreview", () => {
281
282
  expect(doc.getElementById("a")!.getAttribute("style")).toBe("z-index: 8");
282
283
  });
283
284
 
285
+ it("counts a script-only restore applied in place, so a reload requested before it loads again", () => {
286
+ const script = (x: number) => `window.__timelines["root"]=gsap.timeline().to("#a",{x:${x}});`;
287
+ const page = (x: number) => `<div id="a">t</div><script>${script(x)}</script>`;
288
+ const { iframe, doc } = buildLiveIframe(page(2));
289
+ const before = studioManualEditSavesIn(doc);
290
+ const files = { [ROOT]: { previous: wrap(page(2)), restored: wrap(page(1)) } };
291
+ applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
292
+ expect(studioManualEditSavesIn(doc)).toBeGreaterThan(before);
293
+ });
294
+
284
295
  it("does NOT re-run an UNCHANGED GSAP script for an attribute-only restore", () => {
285
296
  // The live doc holds a script element; a re-run would mutate/remove it
286
297
  // (applySoftReload removes stale script elements before re-running).
@@ -563,6 +574,17 @@ describe("an undo that lands while the layer is being dragged", () => {
563
574
  expect(el.style.getPropertyValue("width")).toBe("100px");
564
575
  });
565
576
 
577
+ it("counts an undo shown or applied in place, so a reload requested before it loads again", () => {
578
+ const { iframe, doc } = buildLiveIframe(undone);
579
+ const before = studioManualEditSavesIn(doc);
580
+
581
+ showRestoreInPlace(iframe, ROOT, files, 3);
582
+ expect(studioManualEditSavesIn(doc)).toBeGreaterThan(before);
583
+ const shown = studioManualEditSavesIn(doc);
584
+ applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn());
585
+ expect(studioManualEditSavesIn(doc)).toBeGreaterThan(shown);
586
+ });
587
+
566
588
  it("keeps a drag that started after the undo was shown when the undo is put back", () => {
567
589
  const { iframe, doc } = buildLiveIframe(undone);
568
590
  const el = doc.getElementById("a")!;
@@ -14,7 +14,7 @@ import { findAuthoredElement, parseSavedSource } from "./authoredSource";
14
14
  import { STUDIO_EDIT_ATTRS } from "../components/editor/manualEditsSeekReapply";
15
15
  import { markScenesStale } from "../player/sceneSwap";
16
16
  import { STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR } from "../components/editor/manualEditsTypes";
17
- import { studioGestureDraws } from "../components/editor/manualEditsDom";
17
+ import { countStudioPreviewChange, studioGestureDraws } from "../components/editor/manualEditsDom";
18
18
 
19
19
  type PreviewWindow = Window & {
20
20
  __player?: { seek?: (t: number) => void };
@@ -312,7 +312,8 @@ export function showRestoreInPlace(
312
312
  const doc = iframe?.contentDocument;
313
313
  const win = iframe?.contentWindow as PreviewWindow | null;
314
314
  const plan = doc && win ? planRestoreTargets(doc, activeCompPath ?? "index.html", files) : null;
315
- if (!iframe || !win || !plan || plan.scripted) return null;
315
+ if (!iframe || !doc || !win || !plan || plan.scripted) return null;
316
+ countStudioPreviewChange(doc);
316
317
  const before = plan.targets.map(
317
318
  ({ live, restored }) => [live, live.cloneNode(false) as Element, restored] as const,
318
319
  );
@@ -393,6 +394,7 @@ export function applyUndoRestoreToPreview(
393
394
  reloadPreview();
394
395
  return "full";
395
396
  }
397
+ countStudioPreviewChange(doc);
396
398
  // Sync each changed element's attributes onto the live DOM from the restored
397
399
  // markup, so the runtime's seek-reapply reads the reverted values.
398
400
  for (const target of plan.targets)
@@ -66,8 +66,18 @@ export function hasStudioPendingEdits(): boolean {
66
66
  return pendingEdits.size > 0 || focusedField() !== null;
67
67
  }
68
68
 
69
+ const shownRestores = new Set<Promise<void>>();
70
+
71
+ export function beginStudioShownRestore(): () => void {
72
+ let end = () => {};
73
+ const landed = new Promise<void>((resolve) => (end = resolve));
74
+ shownRestores.add(landed);
75
+ void landed.then(() => shownRestores.delete(landed));
76
+ return end;
77
+ }
78
+
69
79
  export function isStudioEditSaving(): boolean {
70
- return pendingEdits.size > 0;
80
+ return pendingEdits.size > 0 || shownRestores.size > 0;
71
81
  }
72
82
 
73
83
  export function afterStudioPendingEdits(run: () => void): () => void {
@@ -75,7 +85,7 @@ export function afterStudioPendingEdits(run: () => void): () => void {
75
85
  const check = () => {
76
86
  if (!waiting) return;
77
87
  if (isStudioEditSaving()) {
78
- void Promise.allSettled([...pendingEdits.keys()]).then(check);
88
+ void Promise.allSettled([...pendingEdits.keys(), ...shownRestores]).then(check);
79
89
  return;
80
90
  }
81
91
  waiting = false;