@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
@@ -0,0 +1,157 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
+ import { tryGsapDragIntercept } from "./gsapRuntimeBridge";
6
+
7
+ const WORDS = ["How", "we", "build", "videos", "at", "scale", "every", "day"];
8
+
9
+ function mountPhrase(): HTMLElement[] {
10
+ document.body.innerHTML = `<h1>${WORDS.map(
11
+ (word, i) => `<span class="w" data-hf-id="hf-w${i}">${word}</span>`,
12
+ ).join("")}</h1>`;
13
+ return [...document.querySelectorAll<HTMLElement>(".w")];
14
+ }
15
+
16
+ function wordSelection(element: HTMLElement, index: number): DomEditSelection {
17
+ return {
18
+ selector: ".w",
19
+ selectorIndex: index,
20
+ hfId: element.getAttribute("data-hf-id") ?? undefined,
21
+ element,
22
+ } as unknown as DomEditSelection;
23
+ }
24
+
25
+ // One staggered entrance on the shared class, the way generated titles are written.
26
+ const staggerIn = {
27
+ id: ".w-from-200",
28
+ targetSelector: ".w",
29
+ method: "from",
30
+ properties: { y: 60, opacity: 0 },
31
+ position: 0.2,
32
+ resolvedStart: 0.2,
33
+ duration: 0.6,
34
+ stagger: 0.08,
35
+ } as unknown as GsapAnimation;
36
+
37
+ afterEach(() => {
38
+ document.body.innerHTML = "";
39
+ vi.restoreAllMocks();
40
+ });
41
+
42
+ describe("tryGsapDragIntercept: one word of a staggered phrase", () => {
43
+ it("leaves the shared tween alone and hands the move to that word's own offset", async () => {
44
+ const words = mountPhrase();
45
+ const commitMutation = vi.fn();
46
+ const outcome = await tryGsapDragIntercept(
47
+ wordSelection(words[0]!, 0),
48
+ { x: -40, y: 0 },
49
+ [staggerIn],
50
+ null,
51
+ commitMutation,
52
+ async () => [staggerIn],
53
+ );
54
+ expect(outcome).toEqual({ status: "element-offset" });
55
+ expect(commitMutation).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it("still edits a tween that targets only the dragged word", async () => {
59
+ const words = mountPhrase();
60
+ const own = { ...staggerIn, id: "own", targetSelector: '[data-hf-id="hf-w0"]' };
61
+ const commitMutation = vi.fn();
62
+ const outcome = await tryGsapDragIntercept(
63
+ wordSelection(words[0]!, 0),
64
+ { x: -40, y: 0 },
65
+ [own as GsapAnimation],
66
+ null,
67
+ commitMutation,
68
+ async () => [own as GsapAnimation],
69
+ );
70
+ expect(outcome.status).toBe("persisted");
71
+ expect(commitMutation).toHaveBeenCalled();
72
+ });
73
+ });
74
+
75
+ describe("tryGsapDragIntercept: which tweens a word's drag may use", () => {
76
+ it("edits the word's own position tween, not the shared one, when it has both", async () => {
77
+ const words = mountPhrase();
78
+ const own = {
79
+ ...staggerIn,
80
+ id: "own",
81
+ method: "set",
82
+ targetSelector: '[data-hf-id="hf-w0"]',
83
+ properties: { x: 10, y: 0 },
84
+ duration: 0,
85
+ propertyGroup: "position",
86
+ } as unknown as GsapAnimation;
87
+ const commitMutation = vi.fn();
88
+ const outcome = await tryGsapDragIntercept(
89
+ wordSelection(words[0]!, 0),
90
+ { x: -40, y: 0 },
91
+ [staggerIn, own],
92
+ null,
93
+ commitMutation,
94
+ async () => [staggerIn, own],
95
+ );
96
+ expect(outcome.status).toBe("persisted");
97
+ const touched = commitMutation.mock.calls.map((call) => JSON.stringify(call[1]));
98
+ expect(touched.length).toBeGreaterThan(0);
99
+ expect(touched.every((m) => !m.includes(staggerIn.id))).toBe(true);
100
+ expect(touched.some((m) => m.includes('"own"'))).toBe(true);
101
+ });
102
+
103
+ it("ignores a tween whose target never reaches the word", async () => {
104
+ document.body.innerHTML = `<div id="title"><span class="w">A</span><span class="w">B</span></div>
105
+ <div id="subtitle"><span class="w" data-hf-id="hf-s0">C</span></div>`;
106
+ const word = document.querySelector<HTMLElement>('[data-hf-id="hf-s0"]')!;
107
+ const titleOnly = { ...staggerIn, id: "title-only", targetSelector: "#title .w" };
108
+ const commitMutation = vi.fn();
109
+ const outcome = await tryGsapDragIntercept(
110
+ wordSelection(word, 2),
111
+ { x: -40, y: 0 },
112
+ [titleOnly as GsapAnimation],
113
+ null,
114
+ commitMutation,
115
+ async () => [titleOnly as GsapAnimation],
116
+ );
117
+ expect(outcome.status).toBe("persisted");
118
+ const touched = commitMutation.mock.calls.map((call) => JSON.stringify(call[1]));
119
+ expect(touched.every((m) => !m.includes("title-only"))).toBe(true);
120
+ });
121
+ });
122
+
123
+ describe("tryGsapDragIntercept: one drag is one undo step", () => {
124
+ it("records the split and the move under one gesture key", async () => {
125
+ document.body.innerHTML = `<div id="hero"></div>`;
126
+ const hero = document.getElementById("hero")!;
127
+ const mixed = {
128
+ id: "#hero-fromTo-0",
129
+ targetSelector: "#hero",
130
+ method: "fromTo",
131
+ fromProperties: { x: 0, opacity: 0 },
132
+ properties: { x: 100, opacity: 1 },
133
+ position: 0,
134
+ resolvedStart: 0,
135
+ duration: 1,
136
+ } as unknown as GsapAnimation;
137
+ const split = [
138
+ { ...mixed, id: "#hero-fromTo-0-position", propertyGroup: "position" },
139
+ { ...mixed, id: "#hero-fromTo-0-opacity", propertyGroup: "opacity" },
140
+ ] as GsapAnimation[];
141
+ let fetches = 0;
142
+ const commitMutation = vi.fn();
143
+ await tryGsapDragIntercept(
144
+ { id: "hero", selector: "#hero", element: hero } as unknown as DomEditSelection,
145
+ { x: 20, y: 0 },
146
+ [mixed],
147
+ null,
148
+ commitMutation,
149
+ async () => (fetches++ === 0 ? [mixed] : split),
150
+ );
151
+ const options = commitMutation.mock.calls.map((call) => call[2]);
152
+ expect(options.length).toBeGreaterThan(1);
153
+ expect(new Set(options.map((o) => o.coalesceKey)).size).toBe(1);
154
+ expect(options[0].coalesceKey).toMatch(/^gsap:drag:/);
155
+ expect(options.every((o) => o.coalesceMs === Number.POSITIVE_INFINITY)).toBe(true);
156
+ });
157
+ });
@@ -24,6 +24,8 @@ import {
24
24
  materializeIfDynamic,
25
25
  } from "./gsapDragCommit";
26
26
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
27
+ import { isGestureTransactionCommit } from "./gestureTransaction";
28
+ import { tweenReach, tweensForThisElement } from "./gsapTweenReach";
27
29
  import { resolveTweenDuration } from "../utils/globalTimeCompiler";
28
30
  import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
29
31
  import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
@@ -128,12 +130,18 @@ export async function resolveGroupTween(
128
130
 
129
131
  export type { GsapDragCommitCallbacks };
130
132
 
131
- /**
132
- * Attempt to handle a drag commit via the GSAP script mutation path.
133
- *
134
- * Returns an explicit persisted/blocked outcome. Callers must reject blocked
135
- * outcomes so the gesture layer restores its runtime and overlay drafts.
136
- */
133
+ const writesPosition = (a: GsapAnimation) => animationWritesAnyProperty(a, POSITION_CHANNEL_SET);
134
+
135
+ /** Only a tween shared with siblings positions this element (a stagger on `.w`). */
136
+ function positionedOnlyBySharedTween(selection: DomEditSelection, animations: GsapAnimation[]) {
137
+ const positioning = animations.filter(writesPosition);
138
+ return (
139
+ positioning.some((a) => tweenReach(a, selection.element) === "shared") &&
140
+ tweensForThisElement(selection, positioning).length === 0
141
+ );
142
+ }
143
+
144
+ // fallow-ignore-next-line complexity
137
145
  async function preflightGsapDragIntercept(
138
146
  selection: DomEditSelection,
139
147
  animations: GsapAnimation[],
@@ -161,9 +169,18 @@ export function dragEditOutcome(
161
169
  // set as well: ignoring it would let a transient fetch failure bypass helper /
162
170
  // runtime-source ownership and reach a destructive split or property write.
163
171
  const allKnownAnimations = [...animations, ...fetchedAnimations];
164
- const editability = directEditOutcomeForProperties(allKnownAnimations, POSITION_CHANNEL_SET);
172
+ const reaching = allKnownAnimations.filter(
173
+ (a) => tweenReach(a, selection.element) !== "elsewhere",
174
+ );
175
+ const editability = directEditOutcomeForProperties(reaching, POSITION_CHANNEL_SET);
165
176
  if (editability.status === "blocked") return editability;
166
- const sourceAnimations = fetchedAnimations.length > 0 ? fetchedAnimations : animations;
177
+ if (positionedOnlyBySharedTween(selection, allKnownAnimations)) {
178
+ return { status: "element-offset" };
179
+ }
180
+ const sourceAnimations = tweensForThisElement(
181
+ selection,
182
+ fetchedAnimations.length > 0 ? fetchedAnimations : animations,
183
+ );
167
184
  // In a group only a tween naming this member by id or by its own selector counts,
168
185
  // so a class tween shared with id-selected members cannot take one member's write.
169
186
  const positionSources = group
@@ -187,13 +204,28 @@ export function dragEditOutcome(
187
204
  return { status: "persisted" };
188
205
  }
189
206
 
207
+ let dragGestureCounter = 0;
208
+
209
+ /** Every write one drag makes (a split, then the move) records as ONE undo step. A
210
+ * transaction or group commit already owns its key. */
211
+ function oneUndoStep(
212
+ commit: GsapDragCommitCallbacks["commitMutation"],
213
+ ): GsapDragCommitCallbacks["commitMutation"] {
214
+ if (isGestureTransactionCommit(commit)) return commit;
215
+ const coalesceKey = `gsap:drag:${++dragGestureCounter}`;
216
+ return (selection, mutation, options) =>
217
+ commit(selection, mutation, { ...options, coalesceKey, coalesceMs: Number.POSITIVE_INFINITY });
218
+ }
219
+
220
+ /** Commits a drag through the GSAP script. Callers reject `blocked` (the gesture layer
221
+ * restores its drafts) and save `element-offset` on the element itself. */
190
222
  export async function tryGsapDragIntercept(
191
223
  selection: DomEditSelection,
192
224
  offset: { x: number; y: number },
193
- animations: GsapAnimation[],
225
+ allAnimations: GsapAnimation[],
194
226
  iframe: HTMLIFrameElement | null,
195
- commitMutation: GsapDragCommitCallbacks["commitMutation"],
196
- fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
227
+ gestureCommit: GsapDragCommitCallbacks["commitMutation"],
228
+ fetchAllAnimations?: () => Promise<GsapAnimation[]>,
197
229
  options?: {
198
230
  altKey?: boolean;
199
231
  preflightOnly?: boolean;
@@ -204,16 +236,20 @@ export async function tryGsapDragIntercept(
204
236
  if (!options?.preflightPassed) {
205
237
  const preflight = await preflightGsapDragIntercept(
206
238
  selection,
207
- animations,
239
+ allAnimations,
208
240
  iframe,
209
- fetchFallbackAnimations,
241
+ fetchAllAnimations,
210
242
  options?.group,
211
243
  );
212
- if (preflight.status === "blocked" || options?.preflightOnly) return preflight;
244
+ if (preflight.status !== "persisted" || options?.preflightOnly) return preflight;
213
245
  }
246
+ const animations = tweensForThisElement(selection, allAnimations);
247
+ const fetchFallbackAnimations =
248
+ fetchAllAnimations && (async () => tweensForThisElement(selection, await fetchAllAnimations()));
214
249
  const selector = selectorFromSelection(selection);
215
250
  // The preflight above proves this; retain a defensive result for DOM churn.
216
251
  if (!selector) return { status: "blocked", reason: "no-selector" };
252
+ const commitMutation = oneUndoStep(gestureCommit);
217
253
 
218
254
  // Self-heal: enforce a single position write BEFORE committing. A corrupted
219
255
  // file can carry 2+ conflicting position writes for one selector (e.g. a
@@ -195,7 +195,7 @@ describe("resolveClipTimingBasis", () => {
195
195
  it("rebases an expanded sub-comp child by its host mount", () => {
196
196
  // Expanded children carry host-ABSOLUTE display starts; the tweens they own are
197
197
  // still composition-local, so the basis is the child's local start.
198
- const pill = { id: "pill", domId: "pill", start: 8, duration: 4, expandedParentStart: 6 };
198
+ const pill = { id: "pill", domId: "pill", start: 8, duration: 4, parentCompositionStart: 6 };
199
199
  expect(resolveClipTimingBasis("pill", "scene.html", [pill], [])).toEqual({
200
200
  elStart: 2,
201
201
  elDuration: 4,
@@ -10,6 +10,7 @@ import {
10
10
  resolveTweenStart,
11
11
  resolveTweenDuration,
12
12
  } from "../utils/globalTimeCompiler";
13
+ import { toCompositionTime } from "../player/store/timelineElement";
13
14
 
14
15
  // ── Types ─────────────────────────────────────────────────────────────────────
15
16
 
@@ -165,14 +166,16 @@ export function resolveSelectorElementIds(
165
166
  }
166
167
 
167
168
  /**
168
- * The clip start in the frame the element's OWN tweens are measured in. An
169
- * expanded sub-composition child sits on the master timeline at a host-absolute
170
- * `start`, but its tweens are parsed from its own source file and are local to
171
- * it, so the two must be brought into one frame before any clip-% math — or
172
- * every keyframe rebases to a percentage far outside the clip.
169
+ * The clip start in the frame the element's OWN tweens are measured in. A nested
170
+ * row sits on the master timeline at a master `start`, but its tweens are local
171
+ * to its composition, so the two must be brought into one frame before any clip-%
172
+ * math — or every keyframe rebases to a percentage far outside the clip.
173
173
  */
174
- export function clipTimingStart(element: { start: number; expandedParentStart?: number }): number {
175
- return element.start - (element.expandedParentStart ?? 0);
174
+ export function clipTimingStart(element: {
175
+ start: number;
176
+ parentCompositionStart?: number;
177
+ }): number {
178
+ return toCompositionTime(element, element.start);
176
179
  }
177
180
 
178
181
  export function selectorFromSelection(selection: DomEditSelection): string | null {
@@ -476,8 +479,8 @@ export function toAbsoluteTime(tweenPos: number, tweenDur: number, percentage: n
476
479
  * timeline element's start is main-timeline absolute, so passing the raw element
477
480
  * start subtracted two different frames from each other: a host mounted at 1.5s
478
481
  * cached its 0s tween at -12%, and a clip-relative percentage can never be
479
- * negative. The composition's mount is `expandedParentStart` for an expanded
480
- * child, the parent composition clip's start otherwise, and 0 for a
482
+ * negative. The composition's mount is the row's `parentCompositionStart` when
483
+ * set, the parent composition clip's start otherwise, and 0 for a
481
484
  * root-composition element, whose start already IS the tween frame.
482
485
  */
483
486
  export function resolveClipTimingBasis(
@@ -489,7 +492,7 @@ export function resolveClipTimingBasis(
489
492
  id: string;
490
493
  start: number;
491
494
  duration: number;
492
- expandedParentStart?: number;
495
+ parentCompositionStart?: number;
493
496
  parentCompositionId?: string | null;
494
497
  }>,
495
498
  domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
@@ -502,8 +505,9 @@ export function resolveClipTimingBasis(
502
505
  const parent = parentId
503
506
  ? elements.find((el) => el.domId === parentId || el.id === parentId)
504
507
  : undefined;
505
- const mount = direct.expandedParentStart ?? parent?.start;
506
- if (mount !== undefined) return { elStart: direct.start - mount, elDuration: direct.duration };
508
+ if (direct.parentCompositionStart !== undefined)
509
+ return { elStart: clipTimingStart(direct), elDuration: direct.duration };
510
+ if (parent) return { elStart: direct.start - parent.start, elDuration: direct.duration };
507
511
  // No parent composition named, so this IS a main-timeline clip and its own
508
512
  // start is already the basis.
509
513
  if (!parentId) return { elStart: direct.start, elDuration: direct.duration };
@@ -0,0 +1,30 @@
1
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
+
4
+ type TweenReach = "own" | "shared" | "elsewhere" | "unknown";
5
+
6
+ /** Which elements a tween's target resolves to in the live preview, relative to `el`. */
7
+ export function tweenReach(animation: GsapAnimation, el: Element | null | undefined): TweenReach {
8
+ const doc = el?.ownerDocument;
9
+ if (!el?.isConnected || !doc || animation.hasUnresolvedSelector) return "unknown";
10
+ let hits: Element[];
11
+ try {
12
+ hits = [...doc.querySelectorAll(animation.targetSelector)];
13
+ } catch {
14
+ return "unknown";
15
+ }
16
+ if (!hits.includes(el)) return "elsewhere";
17
+ return hits.length === 1 ? "own" : "shared";
18
+ }
19
+
20
+ /** The tweens a one-element edit may rewrite: not one shared with siblings, nor one
21
+ * that never reaches the element. */
22
+ export function tweensForThisElement(
23
+ selection: DomEditSelection,
24
+ animations: GsapAnimation[],
25
+ ): GsapAnimation[] {
26
+ return animations.filter((a) => {
27
+ const reach = tweenReach(a, selection.element);
28
+ return reach === "own" || reach === "unknown";
29
+ });
30
+ }
@@ -1,4 +1,5 @@
1
1
  import { type TimelineElement, usePlayerStore } from "../player/store/playerStore";
2
+ import { toAuthoredStart } from "../player/store/timelineElement";
2
3
  import {
3
4
  applyPatchByTarget,
4
5
  findTagByTarget,
@@ -274,7 +275,7 @@ export function buildTimelineResizeTimingPatch(
274
275
  let patched = applyPatchByTarget(original, target, {
275
276
  type: "attribute",
276
277
  property: "start",
277
- value: formatTimelineAttributeNumber(updates.start),
278
+ value: formatTimelineAttributeNumber(toAuthoredStart(element, updates.start)),
278
279
  });
279
280
  patched = applyPatchByTarget(patched, target, {
280
281
  type: "attribute",
@@ -336,6 +336,64 @@ describe("copy order", () => {
336
336
  });
337
337
  });
338
338
 
339
+ describe("duplicate of a clip inside a sub-composition", () => {
340
+ it("writes the copy at local time right after the original", async () => {
341
+ // Host at 2 s, so the clip's local 1-3 s shows as a 3-5 s master row.
342
+ const sub: TimelineElement = {
343
+ id: SUB_HF_ID,
344
+ hfId: SUB_HF_ID,
345
+ tag: "h2",
346
+ start: 3,
347
+ duration: 2,
348
+ track: 0,
349
+ authoredTrack: 0,
350
+ sourceFile: "compositions/sub.html",
351
+ parentCompositionStart: 2,
352
+ };
353
+ usePlayerStore.setState({
354
+ elements: [TITLE, sub],
355
+ selectedElementId: sub.id,
356
+ selectedElementIds: new Set([sub.id]),
357
+ });
358
+ const { clipboard, writes } = mountClipboard(null, SUB, (host) =>
359
+ host.setAttribute("data-start", "2"),
360
+ );
361
+ await clipboard().handleDuplicate();
362
+ const starts = [...(writes[0] ?? "").matchAll(/<h2[^>]*data-start="([^"]+)"/g)].map(
363
+ (m) => m[1],
364
+ );
365
+ expect(starts.sort()).toEqual(["1", "3"]);
366
+ });
367
+
368
+ it("moves the copy off a lane its own file already fills right after the original", async () => {
369
+ // Host at 10 s: the clip is local 1-3 (master 11-13); its neighbour is local 3-5,
370
+ // exactly where the copy lands, so the lane check must compare in local time.
371
+ const sub: TimelineElement = {
372
+ id: SUB_HF_ID,
373
+ hfId: SUB_HF_ID,
374
+ tag: "h2",
375
+ start: 11,
376
+ duration: 2,
377
+ track: 0,
378
+ authoredTrack: 0,
379
+ sourceFile: "compositions/sub.html",
380
+ parentCompositionStart: 10,
381
+ };
382
+ const neighbour: TimelineElement = { ...sub, id: "next", hfId: "next", start: 13 };
383
+ usePlayerStore.setState({
384
+ elements: [TITLE, sub, neighbour],
385
+ selectedElementId: sub.id,
386
+ selectedElementIds: new Set([sub.id]),
387
+ });
388
+ const { clipboard, writes } = mountClipboard(null, SUB, (host) =>
389
+ host.setAttribute("data-start", "10"),
390
+ );
391
+ await clipboard().handleDuplicate();
392
+ const copy = /<h2[^>]*data-start="3"[^>]*>/.exec(writes[0] ?? "")?.[0] ?? "";
393
+ expect(copy).toContain('data-track-index="1"');
394
+ });
395
+ });
396
+
339
397
  describe("a copy that fails", () => {
340
398
  it("leaves the previous copy on the clipboard", async () => {
341
399
  selectTitle();
@@ -2,6 +2,7 @@ import { useCallback, useRef } from "react";
2
2
  import { EXCLUDED_TAGS, mintHfId, walkCompositionDescendants } from "@hyperframes/parsers/hf-ids";
3
3
  import type { TimelineElement } from "../player";
4
4
  import { usePlayerStore } from "../player";
5
+ import { toAuthoredStart } from "../player/store/timelineElement";
5
6
  import type { DomEditSelection } from "../components/editor/domEditing";
6
7
  import {
7
8
  type ClipboardPayload,
@@ -416,14 +417,22 @@ export function useClipboard({
416
417
  if (!pid) return false;
417
418
 
418
419
  const { elements } = targets;
419
- const targetPath = elements[0]?.sourceFile || activeCompPath || "index.html";
420
- const anchorTime = Math.max(...elements.map((el) => el.start + el.duration));
420
+ const pathOf = (el: TimelineElement) => el.sourceFile || activeCompPath || "index.html";
421
+ const targetPath = pathOf(elements[0]!);
422
+ // The copy lands in targetPath's own clock, so anchor and lane check are local to it.
423
+ const anchorTime = toAuthoredStart(
424
+ elements[0]!,
425
+ Math.max(...elements.map((el) => el.start + el.duration)),
426
+ );
421
427
 
422
428
  try {
423
429
  const clips = await readClips(targets);
424
430
  let ids: string[] = [];
425
431
  const duplicate = (originalContent: string) => {
426
- const liveElements = usePlayerStore.getState().elements;
432
+ const liveElements = usePlayerStore
433
+ .getState()
434
+ .elements.filter((el) => pathOf(el) === targetPath)
435
+ .map((el) => ({ ...el, start: toAuthoredStart(el, el.start) }));
427
436
  const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
428
437
  ids = pasted.ids;
429
438
  return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
@@ -51,8 +51,8 @@ const MANUAL_FLAGS = [
51
51
  /** Null when the commit would go through; "" while the check is still running. */
52
52
  function refusal(preflight: CommitPreflight | null, check: keyof CommitPreflight): string | null {
53
53
  const outcome = preflight?.[check];
54
- if (outcome?.status === "persisted") return null;
55
- return outcome ? GSAP_EDIT_BLOCK_COPY[outcome.reason] : "";
54
+ if (!outcome) return "";
55
+ return outcome.status === "blocked" ? GSAP_EDIT_BLOCK_COPY[outcome.reason] : null;
56
56
  }
57
57
 
58
58
  /** Closes each manual flag whose commit Studio would refuse, and says why. */
@@ -269,6 +269,7 @@ export function useDomEditCommits({
269
269
  // ── Geometry commits (path offset, box size, rotation) ──
270
270
 
271
271
  const {
272
+ stageElementPositionOffset,
272
273
  handleDomPathOffsetCommit,
273
274
  handleDomBoxSizeCommit,
274
275
  handleDomRotationCommit,
@@ -312,6 +313,7 @@ export function useDomEditCommits({
312
313
  handleDomTextFieldStyleCommit,
313
314
  handleDomAddTextField,
314
315
  handleDomRemoveTextField,
316
+ stageElementPositionOffset,
315
317
  handleDomPathOffsetCommit,
316
318
  handleDomBoxSizeCommit,
317
319
  handleDomRotationCommit,
@@ -4,6 +4,7 @@ import type { PatchOperation } from "../utils/sourcePatcher";
4
4
  import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
5
5
  import { DomEditSaveQueueOpenError } from "../utils/domEditSaveQueue";
6
6
  import type { PersistDomEditOperations } from "./domEditCommitTypes";
7
+ import { wasAlreadyToasted } from "./domEditPersistFailure";
7
8
 
8
9
  interface UseDomEditPositionPatchCommitParams {
9
10
  activeCompPath: string | null;
@@ -39,7 +40,9 @@ export function useDomEditPositionPatchCommit({
39
40
  // resolved the commit, which skipped the caller's revert, so the element
40
41
  // stayed where the drag put it while nothing reached the file.
41
42
  if (error instanceof DomEditSaveQueueOpenError) throw error;
42
- showToast(error instanceof Error ? error.message : "Failed to save position");
43
+ if (!wasAlreadyToasted(error)) {
44
+ showToast(error instanceof Error ? error.message : "Failed to save position");
45
+ }
43
46
  trackStudioSaveFailure({
44
47
  source: "dom_edit",
45
48
  error,
@@ -229,6 +229,7 @@ export function useDomEditSession({
229
229
  handleDomTextFieldStyleCommit,
230
230
  handleDomAddTextField,
231
231
  handleDomRemoveTextField,
232
+ stageElementPositionOffset,
232
233
  handleDomBoxSizeCommit,
233
234
  handleDomManualEditsReset,
234
235
  handleDomEditElementsDelete,
@@ -487,6 +488,7 @@ export function useDomEditSession({
487
488
  bumpGsapCache,
488
489
  makeFetchFallback,
489
490
  trackGsapInteractionFailure,
491
+ stageElementPositionOffset,
490
492
  handleDomBoxSizeCommit,
491
493
  addGsapAnimation,
492
494
  convertToKeyframes,
@@ -224,3 +224,105 @@ describe("useDomGeometryCommit, from the package entry", () => {
224
224
  unmount();
225
225
  });
226
226
  });
227
+
228
+ describe("useDomGeometryCommit, one word of a staggered phrase", () => {
229
+ const WORDS = ["How", "we", "build", "videos", "at", "scale", "every", "day"];
230
+ const PHRASE = WORDS.map((w, i) => `<span class="w" data-hf-id="hf-w${i}">${w}</span>`).join("");
231
+
232
+ function stubPhraseServer(patchStatuses: number[] = []) {
233
+ const calls = { patches: [] as unknown[], gsapMutations: [] as unknown[] };
234
+ vi.stubGlobal(
235
+ "fetch",
236
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
237
+ const url = String(input instanceof Request ? input.url : input);
238
+ const json = (body: unknown) =>
239
+ new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } });
240
+ if (url.includes("/api/projects/p1/gsap-animations/")) {
241
+ return json({
242
+ animations: [
243
+ {
244
+ id: ".w-from-200",
245
+ targetSelector: ".w",
246
+ method: "from",
247
+ position: 0.2,
248
+ duration: 0.6,
249
+ properties: { y: 60, opacity: 0 },
250
+ },
251
+ ],
252
+ });
253
+ }
254
+ if (url.includes("/api/projects/p1/files/")) return json({ content: PHRASE });
255
+ if (url.includes("/api/projects/p1/file-mutations/patch-element/")) {
256
+ calls.patches.push(JSON.parse(String(init?.body)));
257
+ const status = patchStatuses.shift();
258
+ if (status) {
259
+ return new Response(JSON.stringify({ error: "the file changed on disk" }), {
260
+ status,
261
+ headers: { "content-type": "application/json" },
262
+ });
263
+ }
264
+ return json({ ok: true, changed: true, matched: true, content: "AFTER", version: "v2" });
265
+ }
266
+ if (url.includes("/api/projects/p1/gsap-mutations/")) {
267
+ calls.gsapMutations.push(JSON.parse(String(init?.body)));
268
+ return json({ ok: true, changed: true, before: "B", after: "A" });
269
+ }
270
+ throw new Error(`Unexpected fetch: ${url}`);
271
+ }),
272
+ );
273
+ return calls;
274
+ }
275
+
276
+ function mountWord(element: HTMLElement) {
277
+ element.ownerDocument.body.innerHTML = PHRASE;
278
+ const word = element.ownerDocument.querySelector<HTMLElement>('[data-hf-id="hf-w0"]')!;
279
+ Object.defineProperties(word, {
280
+ offsetLeft: { get: () => 100 + (Number.parseFloat(word.style.left) || 0) },
281
+ offsetTop: { get: () => 200 + (Number.parseFloat(word.style.top) || 0) },
282
+ });
283
+ return { ...makeSelection("How", word), id: undefined, selector: ".w", hfId: "hf-w0" };
284
+ }
285
+
286
+ it("saves left/top on the dragged word only and never rewrites the shared tween", async () => {
287
+ const calls = stubPhraseServer();
288
+ const { element, recordEdit, hook, unmount } = renderHost();
289
+ const selection = mountWord(element);
290
+
291
+ await expect(hook().commitPathOffset(selection, { x: 40, y: 20 })).resolves.toEqual({
292
+ ok: true,
293
+ });
294
+
295
+ expect(calls.gsapMutations).toHaveLength(0);
296
+ expect(calls.patches).toEqual([
297
+ expect.objectContaining({
298
+ target: expect.objectContaining({ hfId: "hf-w0" }),
299
+ operations: [
300
+ { type: "inline-style", property: "position", value: "relative" },
301
+ { type: "inline-style", property: "left", value: "40px" },
302
+ { type: "inline-style", property: "top", value: "20px" },
303
+ ],
304
+ }),
305
+ ]);
306
+ expect(recordEdit).toHaveBeenCalledTimes(1);
307
+ unmount();
308
+ });
309
+
310
+ it("saves a later move after a conflict refused the first, and says what happened", async () => {
311
+ const calls = stubPhraseServer([409]);
312
+ const showToast = vi.fn();
313
+ const { element, recordEdit, hook, unmount } = renderHost({ showToast });
314
+ const selection = mountWord(element);
315
+
316
+ await expect(hook().commitPathOffset(selection, { x: 40, y: 20 })).rejects.toThrow();
317
+ expect(showToast.mock.calls.map((call) => call[0])).toEqual([
318
+ "Couldn't save edit: the file changed on disk",
319
+ ]);
320
+ await expect(hook().commitPathOffset(selection, { x: 40, y: 20 })).resolves.toEqual({
321
+ ok: true,
322
+ });
323
+
324
+ expect(calls.patches).toHaveLength(2);
325
+ expect(recordEdit).toHaveBeenCalledTimes(1);
326
+ unmount();
327
+ });
328
+ });