@hyperframes/studio 0.8.90 → 0.8.92

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 (102) hide show
  1. package/dist/assets/{hyperframes-player-DDjER_ii.js → hyperframes-player-DKJPDHUp.js} +1 -1
  2. package/dist/assets/{index-BKE3_N4v.js → index-C5_j5-3u.js} +178 -178
  3. package/dist/assets/{index-5SFeUXP8.js → index-CHZ4kFWm.js} +1 -1
  4. package/dist/assets/{index-CVsCxnlz.js → index-DpzpqJ_j.js} +1 -1
  5. package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
  6. package/dist/chunk-QPSOTK6M.js.map +1 -0
  7. package/dist/index.d.ts +18 -14
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +9190 -8916
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/components/TimelineToolbar.test.tsx +12 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +5 -1
  15. package/src/components/editor/commitPreflightHandles.test.tsx +7 -20
  16. package/src/components/editor/elementPositionOffset.test.ts +62 -0
  17. package/src/components/editor/elementPositionOffset.ts +64 -0
  18. package/src/components/editor/motionPathCommit.test.ts +25 -5
  19. package/src/components/editor/motionPathSelection.ts +10 -3
  20. package/src/components/editor/useLayerRevealOverride.test.ts +8 -0
  21. package/src/components/editor/useLayerRevealOverride.ts +2 -1
  22. package/src/components/nle/NLEContext.tsx +1 -1
  23. package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
  24. package/src/hooks/domEditPersistFailure.ts +8 -6
  25. package/src/hooks/elementOffsetStager.ts +65 -0
  26. package/src/hooks/gestureTransaction.ts +4 -2
  27. package/src/hooks/gsapDragCommit.ts +1 -0
  28. package/src/hooks/gsapEditOutcome.ts +2 -1
  29. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
  30. package/src/hooks/gsapRuntimeBridge.ts +50 -14
  31. package/src/hooks/gsapShared.test.ts +1 -1
  32. package/src/hooks/gsapShared.ts +16 -12
  33. package/src/hooks/gsapTweenReach.ts +30 -0
  34. package/src/hooks/timelineEditingHelpers.ts +2 -1
  35. package/src/hooks/useClipboard.source.test.ts +58 -0
  36. package/src/hooks/useClipboard.ts +12 -3
  37. package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
  38. package/src/hooks/useDomEditCommits.ts +2 -0
  39. package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
  40. package/src/hooks/useDomEditSession.ts +2 -0
  41. package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
  42. package/src/hooks/useDomGeometryCommit.ts +42 -1
  43. package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
  44. package/src/hooks/useDomGeometryCommits.ts +14 -1
  45. package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
  46. package/src/hooks/useGsapAwareEditing.ts +44 -7
  47. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
  48. package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
  49. package/src/hooks/useRazorSplit.test.ts +5 -5
  50. package/src/hooks/useTimelineAssetDropOps.test.tsx +138 -0
  51. package/src/hooks/useTimelineAssetDropOps.ts +40 -18
  52. package/src/hooks/useTimelineEditing.test.tsx +384 -0
  53. package/src/hooks/useTimelineEditing.ts +12 -12
  54. package/src/hooks/useTimelineGroupEditing.ts +16 -7
  55. package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
  56. package/src/player/components/Timeline.showKeyframes.test.tsx +28 -2
  57. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  58. package/src/player/components/TimelineProvider.tsx +1 -0
  59. package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
  60. package/src/player/components/TimelineTypes.ts +1 -1
  61. package/src/player/components/timelineClipDragCommit.test.ts +32 -66
  62. package/src/player/components/timelineClipDragCommit.ts +19 -37
  63. package/src/player/components/timelineClipDragPreview.test.ts +283 -24
  64. package/src/player/components/timelineClipDragPreview.ts +51 -83
  65. package/src/player/components/timelineClipDragTypes.ts +3 -8
  66. package/src/player/components/timelineCollision.test.ts +100 -302
  67. package/src/player/components/timelineCollision.ts +73 -160
  68. package/src/player/components/timelineDragDrop.test.tsx +19 -5
  69. package/src/player/components/timelineDragDrop.ts +7 -9
  70. package/src/player/components/timelineDragLanding.ts +0 -49
  71. package/src/player/components/timelineEditing.ts +2 -1
  72. package/src/player/components/timelineGroupEditing.ts +7 -2
  73. package/src/player/components/timelineLayout.test.ts +0 -25
  74. package/src/player/components/timelineLayout.ts +0 -16
  75. package/src/player/components/timelineMultiDragPreview.test.ts +34 -23
  76. package/src/player/components/timelineMultiDragPreview.ts +26 -25
  77. package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
  78. package/src/player/components/timelineProviderStateBuilders.ts +14 -6
  79. package/src/player/components/timelineShellMeta.tsx +99 -0
  80. package/src/player/components/timelineZMirror.test.ts +2 -2
  81. package/src/player/components/timelineZMirror.ts +1 -1
  82. package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
  83. package/src/player/components/useTimelineClipDrag.ts +3 -2
  84. package/src/player/components/useTimelineDragPreviewState.ts +34 -0
  85. package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
  86. package/src/player/components/useTimelinePlayhead.ts +56 -27
  87. package/src/player/components/useTimelineProviderState.tsx +29 -66
  88. package/src/player/components/useTimelineZoom.ts +9 -1
  89. package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -4
  91. package/src/player/lib/timelineDOM.test.ts +79 -0
  92. package/src/player/lib/timelineDOM.ts +16 -7
  93. package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
  94. package/src/player/lib/timelineIframeHelpers.ts +12 -3
  95. package/src/player/store/liveTime.ts +6 -0
  96. package/src/player/store/playerStore.ts +2 -0
  97. package/src/player/store/timelineElement.ts +28 -3
  98. package/src/utils/razorSplitTransaction.test.ts +1 -1
  99. package/src/utils/razorSplitTransaction.ts +3 -3
  100. package/src/utils/timelineDropTrackInsert.test.ts +60 -1
  101. package/src/utils/timelineDropTrackInsert.ts +36 -5
  102. package/dist/chunk-KU4F7QZ3.js.map +0 -1
package/dist/player.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  Player
3
- } from "./chunk-KU4F7QZ3.js";
3
+ } from "./chunk-QPSOTK6M.js";
4
4
  import "./chunk-QZ6HNNFG.js";
5
5
  import "./chunk-S65AQH6M.js";
6
6
  import "./chunk-PZ5AY32C.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.90",
3
+ "version": "0.8.92",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.90",
64
- "@hyperframes/parsers": "0.8.90",
65
- "@hyperframes/player": "0.8.90",
66
- "@hyperframes/sdk": "0.8.90",
67
- "@hyperframes/shader-transitions": "0.8.90",
68
- "@hyperframes/studio-server": "0.8.90"
63
+ "@hyperframes/core": "0.8.92",
64
+ "@hyperframes/sdk": "0.8.92",
65
+ "@hyperframes/parsers": "0.8.92",
66
+ "@hyperframes/shader-transitions": "0.8.92",
67
+ "@hyperframes/player": "0.8.92",
68
+ "@hyperframes/studio-server": "0.8.92"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.90"
84
+ "@hyperframes/producer": "0.8.92"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -226,6 +226,18 @@ describe("TimelineToolbar Fit", () => {
226
226
  expect(fit()?.getAttribute("aria-pressed")).toBe("false");
227
227
  act(() => root.unmount());
228
228
  });
229
+
230
+ it("counts a zoom-in click as a person's zoom, so the timeline anchors it on the playhead", () => {
231
+ const { host, root } = renderToolbar();
232
+ const before = usePlayerStore.getState().userZoomCount;
233
+ act(() =>
234
+ host
235
+ .querySelector('button[aria-label="Zoom in"]')
236
+ ?.dispatchEvent(new MouseEvent("click", { bubbles: true })),
237
+ );
238
+ expect(usePlayerStore.getState().userZoomCount).toBe(before + 1);
239
+ act(() => root.unmount());
240
+ });
229
241
  });
230
242
 
231
243
  describe("TimelineToolbar audio meters", () => {
@@ -127,7 +127,11 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
127
127
  // just in render, after the early returns) so the park-timer cleanup can key on
128
128
  // it: a pending park seek belongs to the OLD animation, so firing it after the
129
129
  // active animation changed would jump the playhead onto a stale keyframe.
130
- const animId = editableAnimationId(selectedGsapAnimations ?? [], geometry?.kind ?? "linear");
130
+ const animId = editableAnimationId(
131
+ selectedGsapAnimations ?? [],
132
+ geometry?.kind ?? "linear",
133
+ selection,
134
+ );
131
135
  // Clear the debounced park timer on unmount AND whenever the active animation id
132
136
  // changes — not unmount-only, or a queued seek from the previous selection still
133
137
  // fires against the new one.
@@ -377,45 +377,32 @@ describe("handles follow what Studio would commit", () => {
377
377
  expect(flags(seen.selection)[0]).toBe(true);
378
378
  });
379
379
 
380
- it("refuses a group move carried by one shared tween, while a single member stays movable", async () => {
380
+ // The per-element left/top channel supersedes the old refusal: each member saves its own move.
381
+ it("lets a group move carried by one shared tween through as each member's own offset", async () => {
381
382
  const dots = [element("dot-1", "dot"), element("dot-2", "dot")];
382
383
  const preview = livePreview(dots, { y: 40 });
383
384
  const stagger = tween({ y: 40 }, { targetSelector: ".dot", method: "from" } as never);
384
385
  const [a, b] = dots.map(resolved);
385
- const { seen, overlay, spies, render } = await select([stagger], {
386
+ const { seen, overlay, spies } = await select([stagger], {
386
387
  selection: a!,
387
388
  groups: [a!, b!],
388
389
  preview,
389
390
  });
390
391
 
391
- expect(seen.groups.map((s) => flags(s)[0])).toEqual([false, false]);
392
- expect(seen.groups[0]?.capabilities.reasonIfDisabled).toBe(
393
- GSAP_EDIT_BLOCK_COPY["source-uneditable"],
394
- );
392
+ expect(seen.groups.map((s) => flags(s)[0])).toEqual([true, true]);
395
393
  fire(overlay().querySelector(BOX)!, "pointerdown", { clientX: 150, clientY: 150 });
396
- expect(spies.onBlockedMove).toHaveBeenCalledTimes(1);
397
- const commitList = getAnimationsForElement(
398
- [stagger],
399
- { id: "dot-1", selector: "#dot-1" },
400
- dots[0],
401
- );
394
+ expect(spies.onBlockedMove).not.toHaveBeenCalled();
402
395
  const groupPreflight = { preflightOnly: true, group: true };
403
396
  const commit = tryGsapDragIntercept(
404
397
  a!,
405
398
  { x: 0, y: 0 },
406
- commitList,
399
+ [stagger],
407
400
  preview,
408
401
  vi.fn(),
409
402
  undefined,
410
403
  groupPreflight,
411
404
  );
412
- expect(await commit).toMatchObject({
413
- status: "blocked",
414
- detail: "live-position-no-source-tween",
415
- });
416
-
417
- render({ selection: a!, preview });
418
- expect(flags(seen.selection)[0]).toBe(true);
405
+ expect(await commit).toEqual({ status: "element-offset" });
419
406
  });
420
407
 
421
408
  it("toasts once on the primary press of a blocked element, before any travel", async () => {
@@ -0,0 +1,62 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from "vitest";
3
+ import { applyElementPositionOffset } from "./elementPositionOffset";
4
+
5
+ // jsdom has no layout: offsets follow left/top unless the box is anchored elsewhere.
6
+ function word(style: string, anchoredRight = false): HTMLElement {
7
+ const el = document.createElement("span");
8
+ el.setAttribute("style", style);
9
+ const px = (v: string) => Number.parseFloat(v) || 0;
10
+ Object.defineProperties(el, {
11
+ offsetLeft: { get: () => (anchoredRight ? 500 : 100 + px(el.style.left)) },
12
+ offsetTop: { get: () => 200 + px(el.style.top) },
13
+ offsetWidth: { get: () => 80 },
14
+ offsetHeight: { get: () => 40 },
15
+ });
16
+ el.setAttribute("data-hf-drag-initial-offset-x", "0");
17
+ el.setAttribute("data-hf-drag-initial-offset-y", "0");
18
+ document.body.append(el);
19
+ return el;
20
+ }
21
+
22
+ describe("applyElementPositionOffset", () => {
23
+ it("persists position for a static word the Layers panel lifted to relative", () => {
24
+ const el = word("position: relative");
25
+ el.setAttribute("data-hf-reveal-prior-pos", "static");
26
+ expect(applyElementPositionOffset(el, { x: 40, y: 20 })).toEqual([
27
+ { type: "inline-style", property: "position", value: "relative" },
28
+ { type: "inline-style", property: "left", value: "40px" },
29
+ { type: "inline-style", property: "top", value: "20px" },
30
+ ]);
31
+ expect(el.hasAttribute("data-hf-reveal-prior-pos")).toBe(false);
32
+ });
33
+
34
+ it("names percent positioning as the reason it refuses", () => {
35
+ const el = word("position: relative; left: 10%; top: 0px");
36
+ expect(applyElementPositionOffset(el, { x: 40, y: 20 })).toBe("percent");
37
+ expect(el.style.left).toBe("10%");
38
+ });
39
+
40
+ it("moves a static element by relative left/top", () => {
41
+ const el = word("");
42
+ expect(applyElementPositionOffset(el, { x: 40, y: 20 })).toEqual([
43
+ { type: "inline-style", property: "position", value: "relative" },
44
+ { type: "inline-style", property: "left", value: "40px" },
45
+ { type: "inline-style", property: "top", value: "20px" },
46
+ ]);
47
+ });
48
+
49
+ it("adds to left/top the element already has, so a second drag accumulates", () => {
50
+ const el = word("position: relative; left: 40px; top: 20px");
51
+ expect(applyElementPositionOffset(el, { x: 40, y: 20 })).toEqual([
52
+ { type: "inline-style", property: "left", value: "80px" },
53
+ { type: "inline-style", property: "top", value: "40px" },
54
+ ]);
55
+ });
56
+
57
+ it("refuses, leaving the element untouched, when left does not move the box", () => {
58
+ const el = word("position: absolute; right: 30px", true);
59
+ expect(applyElementPositionOffset(el, { x: 40, y: 20 })).toBe("anchored");
60
+ expect([el.style.left, el.style.top, el.style.right]).toEqual(["", "", "30px"]);
61
+ });
62
+ });
@@ -0,0 +1,64 @@
1
+ import type { PatchOperation } from "../../utils/sourcePatcher";
2
+ import { LAYER_REVEAL_PRIOR_POSITION_ATTR } from "../../player/lib/timelineElementHelpers";
3
+
4
+ interface LayoutBox {
5
+ left: number;
6
+ top: number;
7
+ width: number;
8
+ height: number;
9
+ }
10
+
11
+ const layoutBox = (el: HTMLElement): LayoutBox => ({
12
+ left: el.offsetLeft,
13
+ top: el.offsetTop,
14
+ width: el.offsetWidth,
15
+ height: el.offsetHeight,
16
+ });
17
+
18
+ function readDragStart(el: HTMLElement, axis: "x" | "y"): number {
19
+ const value = Number.parseFloat(el.getAttribute(`data-hf-drag-initial-offset-${axis}`) ?? "");
20
+ return Number.isFinite(value) ? value : 0;
21
+ }
22
+
23
+ export type ElementOffsetRefusal = "percent" | "anchored";
24
+
25
+ /** Moves one element by adding to its own `left`/`top` (GSAP never parses them), or
26
+ * names why not, leaving the element untouched. */
27
+ export function applyElementPositionOffset(
28
+ el: HTMLElement,
29
+ gestureOffset: { x: number; y: number },
30
+ ): PatchOperation[] | ElementOffsetRefusal {
31
+ const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
32
+ if (!cs) return "anchored";
33
+ const dx = Math.round(gestureOffset.x - readDragStart(el, "x"));
34
+ const dy = Math.round(gestureOffset.y - readDragStart(el, "y"));
35
+ // A Layers-panel pick lifts a static element to relative for display only.
36
+ const lifted = el.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR) === "static";
37
+ const isStatic = lifted || !/^(relative|absolute|fixed|sticky)$/.test(cs.position);
38
+ if (!isStatic && (cs.left.endsWith("%") || cs.top.endsWith("%"))) return "percent";
39
+ const baseLeft = isStatic ? 0 : Number.parseFloat(cs.left) || 0;
40
+ const baseTop = isStatic ? 0 : Number.parseFloat(cs.top) || 0;
41
+ const previous = { position: el.style.position, left: el.style.left, top: el.style.top };
42
+ const before = layoutBox(el);
43
+ if (isStatic) el.style.position = "relative";
44
+ el.style.left = `${Math.round(baseLeft) + dx}px`;
45
+ el.style.top = `${Math.round(baseTop) + dy}px`;
46
+ const after = layoutBox(el);
47
+ const shiftedExactly =
48
+ Math.abs(after.left - before.left - dx) <= 1 &&
49
+ Math.abs(after.top - before.top - dy) <= 1 &&
50
+ Math.abs(after.width - before.width) <= 1 &&
51
+ Math.abs(after.height - before.height) <= 1;
52
+ if (!shiftedExactly) {
53
+ Object.assign(el.style, previous);
54
+ return "anchored";
55
+ }
56
+ if (lifted) el.removeAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
57
+ return [
58
+ ...(isStatic
59
+ ? [{ type: "inline-style", property: "position", value: "relative" } as const]
60
+ : []),
61
+ { type: "inline-style", property: "left", value: el.style.left },
62
+ { type: "inline-style", property: "top", value: el.style.top },
63
+ ];
64
+ }
@@ -1,3 +1,4 @@
1
+ // @vitest-environment jsdom
1
2
  import { describe, it, expect, vi } from "vitest";
2
3
  import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
3
4
  import { editableAnimationId } from "./motionPathSelection";
@@ -19,10 +20,13 @@ const anim = (over: Partial<GsapAnimation>): GsapAnimation =>
19
20
  ...over,
20
21
  }) as GsapAnimation;
21
22
 
23
+ // The element the default `#el` tweens target.
24
+ const elSel = { id: "el" } as never;
25
+
22
26
  describe("editableAnimationId", () => {
23
27
  it("picks the arc animation for an arc path", () => {
24
28
  const arc = anim({ id: "arc1", arcPath: { enabled: true, autoRotate: false, segments: [] } });
25
- expect(editableAnimationId([anim({ id: "other" }), arc], "arc")).toBe("arc1");
29
+ expect(editableAnimationId([anim({ id: "other" }), arc], "arc", elSel)).toBe("arc1");
26
30
  });
27
31
 
28
32
  it("picks a position-keyframe animation for a linear path", () => {
@@ -34,7 +38,7 @@ describe("editableAnimationId", () => {
34
38
  keyframes: [{ percentage: 0, properties: { x: 0, y: 0 } }],
35
39
  } as never,
36
40
  });
37
- expect(editableAnimationId([kf], "linear")).toBe("kf1");
41
+ expect(editableAnimationId([kf], "linear", elSel)).toBe("kf1");
38
42
  });
39
43
 
40
44
  it("returns null for dynamic (unresolved) tweens — read-only", () => {
@@ -43,7 +47,7 @@ describe("editableAnimationId", () => {
43
47
  arcPath: { enabled: true, autoRotate: false, segments: [] },
44
48
  hasUnresolvedKeyframes: true,
45
49
  });
46
- expect(editableAnimationId([dyn], "arc")).toBeNull();
50
+ expect(editableAnimationId([dyn], "arc", elSel)).toBeNull();
47
51
  });
48
52
 
49
53
  it("returns null for non-literal (helper) provenance — read-only", () => {
@@ -52,11 +56,27 @@ describe("editableAnimationId", () => {
52
56
  arcPath: { enabled: true, autoRotate: false, segments: [] },
53
57
  provenance: { kind: "helper" } as never,
54
58
  });
55
- expect(editableAnimationId([helper], "arc")).toBeNull();
59
+ expect(editableAnimationId([helper], "arc", elSel)).toBeNull();
60
+ });
61
+
62
+ it("leaves a tween that also moves the element's siblings read-only", () => {
63
+ document.body.innerHTML = `<span class="w" data-hf-id="w0">How</span><span class="w" data-hf-id="w1">we</span>`;
64
+ const word = document.querySelector<HTMLElement>('[data-hf-id="w0"]')!;
65
+ const stagger = anim({
66
+ id: "stagger",
67
+ targetSelector: ".w",
68
+ propertyGroup: "position",
69
+ keyframes: {
70
+ format: "percentage",
71
+ keyframes: [{ percentage: 0, properties: { x: 0, y: 0 } }],
72
+ } as never,
73
+ });
74
+ const wordSel = { selector: ".w", selectorIndex: 0, hfId: "w0", element: word } as never;
75
+ expect(editableAnimationId([stagger], "linear", wordSel)).toBeNull();
56
76
  });
57
77
 
58
78
  it("returns null when nothing matches", () => {
59
- expect(editableAnimationId([anim({ id: "x" })], "linear")).toBeNull();
79
+ expect(editableAnimationId([anim({ id: "x" })], "linear", elSel)).toBeNull();
60
80
  });
61
81
  });
62
82
 
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
7
7
  import type { DomEditSelection } from "./domEditing";
8
- import { writeTargetSelector } from "../../hooks/gsapShared";
8
+ import { tweenTargetsElement, writeTargetSelector } from "../../hooks/gsapShared";
9
9
 
10
10
  /**
11
11
  * The selector the overlay both MEASURES the element by and authors a new
@@ -24,13 +24,20 @@ export function selectorFor(sel: DomEditSelection | null): string | null {
24
24
 
25
25
  /** The animation whose path is editable on-canvas: literal, statically resolved,
26
26
  * and matching the rendered geometry kind. Returns null when the path can only
27
- * be displayed (dynamic/helper tweens) — those nodes stay read-only. */
27
+ * be displayed (dynamic/helper tweens, or a tween that also moves other
28
+ * elements) — those nodes stay read-only and a press drags the element itself. */
28
29
  export function editableAnimationId(
29
30
  animations: GsapAnimation[],
30
31
  kind: "linear" | "arc",
32
+ sel: DomEditSelection | null,
31
33
  ): string | null {
34
+ const own = selectorFor(sel);
32
35
  const ok = (a: GsapAnimation) =>
33
- !a.hasUnresolvedKeyframes && !a.hasUnresolvedSelector && !a.provenance;
36
+ !a.hasUnresolvedKeyframes &&
37
+ !a.hasUnresolvedSelector &&
38
+ !a.provenance &&
39
+ !!own &&
40
+ tweenTargetsElement(a.targetSelector, own, sel?.element);
34
41
  if (kind === "arc") return animations.find((a) => a.arcPath?.enabled && ok(a))?.id ?? null;
35
42
  const a = animations.find(
36
43
  (anim) =>
@@ -56,6 +56,14 @@ describe("liftElementToTop / restoreLiftedElement", () => {
56
56
  expect(el.style.zIndex).toBe("");
57
57
  });
58
58
 
59
+ it("keeps a position a move authored while the element was lifted", () => {
60
+ const el = makeEl();
61
+ const lift = liftElementToTop(el)!;
62
+ el.removeAttribute("data-hf-reveal-prior-pos");
63
+ restoreLiftedElement(el, lift);
64
+ expect(el.style.position).toBe("relative");
65
+ });
66
+
59
67
  it("a z-reorder commit consumes the lift: restore becomes a no-op", () => {
60
68
  const el = makeEl("3", "absolute");
61
69
  const lift = liftElementToTop(el)!;
@@ -271,12 +271,13 @@ export function restoreLiftedElement(element: HTMLElement, lift: RevealLift): vo
271
271
  }
272
272
  return;
273
273
  }
274
+ const positionStillLifted = element.hasAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
274
275
  element.removeAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
275
276
  element.removeAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
276
277
  if (element.style.zIndex === LAYER_REVEAL_LIFT_Z) {
277
278
  restoreInline(element, "z-index", lift.priors.zIndex);
278
279
  }
279
- if (lift.positionLifted && element.style.position === "relative") {
280
+ if (lift.positionLifted && positionStillLifted && element.style.position === "relative") {
280
281
  restoreInline(element, "position", lift.priors.position);
281
282
  }
282
283
  }
@@ -21,7 +21,7 @@ export interface NLEContextValue {
21
21
  play: () => void;
22
22
  pause: () => void;
23
23
  togglePlay: () => void;
24
- seek: (time: number, options?: { keepPlaying?: boolean }) => boolean;
24
+ seek: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => boolean;
25
25
  refreshPlayer: () => void;
26
26
  onIframeLoad: () => void;
27
27
  // The hidden reload iframe NLEPreview renders next to the live one during a full reload.
@@ -149,6 +149,59 @@ function arrangeClickedCircle(): {
149
149
  return { circle, selection: selectionForElement(circle) };
150
150
  }
151
151
 
152
+ // main 0 > intro 2 > logo 3 > badge 1: rows are master time, tweens are local.
153
+ function nestedKeyframedRow(id: string, start: number, duration: number, host: number) {
154
+ const row: TimelineElement = {
155
+ id,
156
+ key: `compositions/${id}-parent.html#${id}`,
157
+ domId: id,
158
+ tag: "div",
159
+ start,
160
+ duration,
161
+ track: 0,
162
+ sourceFile: `compositions/${id}-parent.html`,
163
+ parentCompositionStart: host,
164
+ };
165
+ const anim: GsapAnimation = {
166
+ ...authoredInteriorAnimation(),
167
+ id: `${id}-to-position`,
168
+ targetSelector: `#${id}`,
169
+ position: start - host,
170
+ resolvedStart: start - host,
171
+ duration,
172
+ };
173
+ usePlayerStore.setState({
174
+ elements: [row],
175
+ gsapAnimations: new Map([[row.key!, [anim]]]),
176
+ });
177
+ return { row, anim };
178
+ }
179
+
180
+ describe("keyframe drags on nested rows", () => {
181
+ it.each([
182
+ ["logo", 5, 5, 2, 60],
183
+ ["badge", 6, 2, 5, 75],
184
+ ])("re-keys %s's middle keyframe inside its local tween", async (id, start, dur, host, pct) => {
185
+ const { row, anim } = nestedKeyframedRow(id, start, dur, host);
186
+ const view = renderCallbacks();
187
+ await act(async () => {
188
+ await view.callbacks.onMoveKeyframe?.(
189
+ row.key!,
190
+ { percentage: 50, propertyGroup: "position", tweenPercentage: 50, animationId: anim.id },
191
+ pct,
192
+ );
193
+ });
194
+ expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
195
+ anim.id,
196
+ 50,
197
+ pct,
198
+ selectionForElement(row),
199
+ );
200
+ expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
201
+ view.unmount();
202
+ });
203
+ });
204
+
152
205
  beforeEach(() => {
153
206
  vi.clearAllMocks();
154
207
  mocks.animations = [flatAnimation];
@@ -46,6 +46,13 @@ function getSelectionLabel(selection: DomEditPersistFailureSelection): string {
46
46
  return selection.label || selection.selector || selection.id || "this element";
47
47
  }
48
48
 
49
+ export function wasAlreadyToasted(error: unknown): boolean {
50
+ return (
51
+ (error instanceof DomEditPersistUnsafeValueError || error instanceof StudioSaveHttpError) &&
52
+ error.alreadyToasted
53
+ );
54
+ }
55
+
49
56
  export function reportDomEditPersistFailure(
50
57
  selection: DomEditPersistFailureSelection,
51
58
  operations: PatchOperation[],
@@ -59,12 +66,7 @@ export function reportDomEditPersistFailure(
59
66
  error: detail,
60
67
  });
61
68
 
62
- const wasAlreadyToasted =
63
- (error instanceof DomEditPersistUnsafeValueError || error instanceof StudioSaveHttpError) &&
64
- error.alreadyToasted;
65
- if (wasAlreadyToasted) {
66
- return;
67
- }
69
+ if (wasAlreadyToasted(error)) return;
68
70
 
69
71
  showToast(`Couldn't save "${getSelectionLabel(selection)}": ${detail}`, "error");
70
72
  }
@@ -0,0 +1,65 @@
1
+ import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
2
+ import {
3
+ applyElementPositionOffset,
4
+ type ElementOffsetRefusal,
5
+ } from "../components/editor/elementPositionOffset";
6
+ import { LAYER_REVEAL_PRIOR_POSITION_ATTR } from "../player/lib/timelineElementHelpers";
7
+ import type { PatchOperation } from "../utils/sourcePatcher";
8
+
9
+ const ELEMENT_OFFSET_REFUSED: Record<ElementOffsetRefusal, string> = {
10
+ anchored: "This layer is anchored from its right or bottom edge. Move it in the Code tab.",
11
+ percent: "This layer's position is set in percent. Move it in the Code tab.",
12
+ };
13
+
14
+ export interface ElementOffsetStagerDeps {
15
+ commitPositionPatchToHtml: (
16
+ selection: DomEditSelection,
17
+ patches: PatchOperation[],
18
+ options: { label: string; coalesceKey: string; coalesceMs?: number },
19
+ ) => Promise<void>;
20
+ showToast: (message: string, tone?: "error" | "info") => void;
21
+ readOnlyPreview?: boolean;
22
+ }
23
+
24
+ /** The drag draft moved GSAP's x/y; left/top carries the move now, so put them back. */
25
+ function settleGsapDraftAtGestureStart(el: HTMLElement): void {
26
+ const gsap = (el.ownerDocument.defaultView as { gsap?: { set: (t: Element, v: object) => void } })
27
+ ?.gsap;
28
+ const x = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "");
29
+ const y = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "");
30
+ if (gsap && Number.isFinite(x) && Number.isFinite(y)) gsap.set(el, { x, y });
31
+ }
32
+
33
+ /** Applies a shared-tween element's move live now; `save` persists it as left/top on that
34
+ * element, `rollback` takes the live move back. Throws, after a toast, when it cannot. */
35
+ export function stageElementOffset(
36
+ { commitPositionPatchToHtml, showToast, readOnlyPreview }: ElementOffsetStagerDeps,
37
+ selection: DomEditSelection,
38
+ next: { x: number; y: number },
39
+ coalesceKey?: string,
40
+ ): { save: () => Promise<void>; rollback: () => void } {
41
+ const el = selection.element;
42
+ if (readOnlyPreview) return { save: () => Promise.resolve(), rollback: () => undefined };
43
+ const previous = { position: el.style.position, left: el.style.left, top: el.style.top };
44
+ const liftMarker = el.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
45
+ const result = applyElementPositionOffset(el, next);
46
+ if (!Array.isArray(result)) {
47
+ showToast(ELEMENT_OFFSET_REFUSED[result], "error");
48
+ throw new Error(ELEMENT_OFFSET_REFUSED[result]);
49
+ }
50
+ settleGsapDraftAtGestureStart(el);
51
+ const rollback = () => {
52
+ Object.assign(el.style, previous);
53
+ if (liftMarker !== null) el.setAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR, liftMarker);
54
+ };
55
+ const save = () =>
56
+ commitPositionPatchToHtml(selection, result, {
57
+ label: "Move layer",
58
+ coalesceKey: coalesceKey ?? `element-offset:${getDomEditTargetKey(selection)}`,
59
+ ...(coalesceKey && { coalesceMs: Number.POSITIVE_INFINITY }),
60
+ }).catch((error) => {
61
+ rollback();
62
+ throw error;
63
+ });
64
+ return { save, rollback };
65
+ }
@@ -14,7 +14,8 @@ export interface GestureTransaction {
14
14
  element: HTMLElement;
15
15
  label: string;
16
16
  settle(): void;
17
- persist(commit: TxCommit): Promise<void>;
17
+ persist(commit: TxCommit, coalesceKey: string): Promise<void>;
18
+ afterBufferedCommitsSaved?(): Promise<void>;
18
19
  restore(): void;
19
20
  skipPixelAssert?: boolean;
20
21
  }
@@ -142,9 +143,10 @@ export function runGestureTransaction(tx: GestureTransaction): Promise<void> {
142
143
  };
143
144
 
144
145
  return tx
145
- .persist(commit)
146
+ .persist(commit, coalesceKey)
146
147
  .then(async () => {
147
148
  reloadCount = await dispatchBufferedCommits(bufferedCommits);
149
+ await tx.afterBufferedCommitsSaved?.();
148
150
  const durationMs = Math.round(performance.now() - startedAt);
149
151
  logCommit("persisted", { label: tx.label, coalesceKey });
150
152
  if (before) {
@@ -29,6 +29,7 @@ export interface GsapDragCommitCallbacks {
29
29
  options: {
30
30
  label: string;
31
31
  coalesceKey?: string;
32
+ coalesceMs?: number;
32
33
  softReload?: boolean;
33
34
  skipReload?: boolean;
34
35
  beforeReload?: () => void;
@@ -43,7 +43,8 @@ export type GsapEditOutcome =
43
43
  */
44
44
  ownsDragOffset?: boolean;
45
45
  }
46
- | { status: "blocked"; reason: GsapEditBlockReason; detail?: GsapEditBlockDetail };
46
+ | { status: "blocked"; reason: GsapEditBlockReason; detail?: GsapEditBlockDetail }
47
+ | { status: "element-offset" };
47
48
 
48
49
  export const GSAP_EDIT_BLOCK_COPY: Record<GsapEditBlockReason, string> = {
49
50
  "no-selector": "This layer needs a stable selector before Studio can save the edit.",