@hyperframes/studio 0.7.102 → 0.7.104

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 (71) hide show
  1. package/dist/assets/{hyperframes-player-BTnkvhf3.js → hyperframes-player-Bj2HAymq.js} +1 -1
  2. package/dist/assets/{index-CnrSn9Ua.js → index-8UzO6PkK.js} +1 -1
  3. package/dist/assets/{index-DN614u1l.js → index-DzfDa1ci.js} +212 -212
  4. package/dist/assets/{index-B4MocVKM.js → index-TDfRaO1Y.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +779 -294
  7. package/dist/index.js.map +1 -1
  8. package/package.json +7 -7
  9. package/src/App.tsx +6 -2
  10. package/src/captions/hooks/useCaptionSync.ts +2 -1
  11. package/src/components/editor/DomEditOverlay.test.ts +89 -2
  12. package/src/components/editor/DomEditOverlay.tsx +40 -23
  13. package/src/components/editor/domEditOverlayGeometry.test.ts +94 -0
  14. package/src/components/editor/domEditOverlayGeometry.ts +59 -2
  15. package/src/components/editor/domEditOverlayGestures.ts +42 -0
  16. package/src/components/editor/domEditOverlayStartGesture.ts +17 -0
  17. package/src/components/editor/groupDragMove.ts +110 -0
  18. package/src/components/editor/groupDropKeepsSelection.test.ts +70 -0
  19. package/src/components/editor/manualEditsDom.ts +15 -13
  20. package/src/components/editor/manualOffsetDrag.test.ts +92 -5
  21. package/src/components/editor/manualOffsetDrag.ts +22 -17
  22. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +64 -0
  23. package/src/components/editor/snapEngageTravel.test.ts +65 -0
  24. package/src/components/editor/snapEngine.ts +21 -1
  25. package/src/components/editor/useDomEditOverlayGestures.ts +40 -53
  26. package/src/components/editor/useDomEditOverlayRects.ts +4 -2
  27. package/src/hooks/domSelectionTimelineMirror.ts +73 -0
  28. package/src/hooks/gestureTransaction.test.ts +7 -12
  29. package/src/hooks/gestureTransaction.ts +7 -20
  30. package/src/hooks/gsapRuntimePatch.test.ts +43 -0
  31. package/src/hooks/gsapRuntimePatch.ts +11 -1
  32. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  33. package/src/hooks/gsapScriptCommitTypes.ts +20 -0
  34. package/src/hooks/keyframeCacheAstLoad.test.ts +108 -0
  35. package/src/hooks/keyframeCacheAstLoad.ts +29 -1
  36. package/src/hooks/timelineTimingSync.ts +4 -1
  37. package/src/hooks/useDomEditCommits.ts +2 -1
  38. package/src/hooks/useDomEditCommitsHelpers.ts +2 -1
  39. package/src/hooks/useDomEditPreviewSync.ts +23 -0
  40. package/src/hooks/useDomEditSession.test.tsx +43 -62
  41. package/src/hooks/useDomEditSession.ts +10 -2
  42. package/src/hooks/useDomEditWiring.ts +6 -0
  43. package/src/hooks/useDomSelection.test.ts +133 -3
  44. package/src/hooks/useDomSelection.ts +67 -40
  45. package/src/hooks/useDomSelectionSelectionGuards.test.ts +6 -2
  46. package/src/hooks/useElementLifecycleOps.ts +2 -1
  47. package/src/hooks/useExternalFileChangeCoordinator.ts +16 -3
  48. package/src/hooks/useFileManager.ts +2 -8
  49. package/src/hooks/useGroupCommits.ts +2 -1
  50. package/src/hooks/useGsapAnimationFetchFallback.ts +6 -1
  51. package/src/hooks/useGsapAwareEditing.test.tsx +36 -0
  52. package/src/hooks/useGsapAwareEditing.ts +88 -15
  53. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -5
  54. package/src/hooks/useGsapScriptCommits.test.tsx +193 -7
  55. package/src/hooks/useGsapScriptCommits.ts +52 -15
  56. package/src/hooks/useStudioUrlState.ts +130 -7
  57. package/src/hooks/useTimelineEditing.ts +2 -1
  58. package/src/hooks/useTimelineSelectionPreviewSync.ts +13 -0
  59. package/src/player/hooks/useTimelinePlayer.ts +2 -0
  60. package/src/utils/dragDebug.ts +98 -0
  61. package/src/utils/razorSplitTransaction.test.ts +15 -3
  62. package/src/utils/razorSplitTransaction.ts +2 -0
  63. package/src/utils/reloadDebug.ts +10 -0
  64. package/src/utils/resizeDebug.ts +19 -29
  65. package/src/utils/selectDebug.ts +9 -0
  66. package/src/utils/studioDebug.test.ts +32 -0
  67. package/src/utils/studioDebug.ts +30 -0
  68. package/src/utils/studioFileVersion.ts +16 -1
  69. package/src/utils/studioUrlState.test.ts +163 -1
  70. package/src/utils/studioUrlState.ts +60 -3
  71. package/src/utils/timelineCompositionInsert.ts +2 -1
@@ -73,14 +73,159 @@ describe("applyPreviewSync", () => {
73
73
 
74
74
  syncDragPreview(result(), reloadPreview);
75
75
 
76
- expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(FAKE_IFRAME, "#a", {
77
- kind: "set",
78
- props: { x: 10 },
79
- });
76
+ expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(
77
+ FAKE_IFRAME,
78
+ "#a",
79
+ {
80
+ kind: "set",
81
+ props: { x: 10 },
82
+ },
83
+ undefined,
84
+ false,
85
+ );
80
86
  expect(applySoftReload).not.toHaveBeenCalled();
81
87
  expect(reloadPreview).not.toHaveBeenCalled();
82
88
  });
83
89
 
90
+ it("instantPatches: patches every element the batch wrote, rendering once at the end", () => {
91
+ patchRuntimeTweenInPlace.mockReturnValue(true);
92
+ const reloadPreview = vi.fn();
93
+
94
+ applyPreviewSync(
95
+ FAKE_IFRAME,
96
+ result(),
97
+ {
98
+ label: "Move animated layer (group)",
99
+ softReload: true,
100
+ instantPatches: [
101
+ { selector: "#a", change: { kind: "set" as const, props: { x: 1 } } },
102
+ { selector: "#b", change: { kind: "set" as const, props: { x: 2 } } },
103
+ { selector: "#c", change: { kind: "set" as const, props: { x: 3 } } },
104
+ ],
105
+ },
106
+ reloadPreview,
107
+ );
108
+
109
+ // Only the last patch re-renders — the earlier two defer their seek, so the
110
+ // group repaints once instead of once per member.
111
+ expect(patchRuntimeTweenInPlace.mock.calls.map((call) => [call[1], call[4]])).toEqual([
112
+ ["#a", true],
113
+ ["#b", true],
114
+ ["#c", false],
115
+ ]);
116
+ expect(applySoftReload).not.toHaveBeenCalled();
117
+ expect(reloadPreview).not.toHaveBeenCalled();
118
+ });
119
+
120
+ it("applies both plural and singular patches when a caller supplies both", () => {
121
+ patchRuntimeTweenInPlace.mockReturnValue(true);
122
+
123
+ applyPreviewSync(
124
+ FAKE_IFRAME,
125
+ result(),
126
+ {
127
+ label: "mixed patch contract",
128
+ instantPatches: [
129
+ { selector: "#group-a", change: { kind: "set" as const, props: { x: 1 } } },
130
+ ],
131
+ instantPatch: {
132
+ selector: "#single-b",
133
+ change: { kind: "set" as const, props: { x: 2 } },
134
+ },
135
+ },
136
+ vi.fn(),
137
+ );
138
+
139
+ expect(patchRuntimeTweenInPlace.mock.calls.map((call) => [call[1], call[4]])).toEqual([
140
+ ["#group-a", true],
141
+ ["#single-b", false],
142
+ ]);
143
+ });
144
+
145
+ it("instantPatches: one patch that misses falls the whole batch back to the reload", () => {
146
+ patchRuntimeTweenInPlace.mockImplementation((_iframe, selector) => selector !== "#b");
147
+ applySoftReload.mockReturnValue("applied");
148
+ const reloadPreview = vi.fn();
149
+
150
+ applyPreviewSync(
151
+ FAKE_IFRAME,
152
+ result({ scriptText: "SCRIPT" }),
153
+ {
154
+ label: "Move animated layer (group)",
155
+ softReload: true,
156
+ instantPatches: [
157
+ { selector: "#a", change: { kind: "set" as const, props: { x: 1 } } },
158
+ { selector: "#b", change: { kind: "set" as const, props: { x: 2 } } },
159
+ ],
160
+ },
161
+ reloadPreview,
162
+ );
163
+
164
+ // A half-patched preview is worse than a reloaded one: "#a" landed, "#b" did
165
+ // not, so the reload repaints both from the written source.
166
+ expect(applySoftReload).toHaveBeenCalled();
167
+ expect(trackStudioEvent).toHaveBeenCalledWith("gsap_instant_patch_fallback", {
168
+ selector: "#b",
169
+ });
170
+ });
171
+
172
+ it("carries a deferred patch miss into the final batch render", () => {
173
+ const previewFallbackLatch = { pending: false };
174
+ applySoftReload.mockReturnValue("applied");
175
+ const reloadPreview = vi.fn();
176
+ patchRuntimeTweenInPlace.mockReturnValueOnce(false).mockReturnValueOnce(true);
177
+
178
+ applyPreviewSync(
179
+ FAKE_IFRAME,
180
+ result({ scriptText: "SCRIPT" }),
181
+ {
182
+ label: "Move animated layer (group)",
183
+ softReload: true,
184
+ deferPreviewSync: true,
185
+ previewFallbackLatch,
186
+ instantPatch: { selector: "#missed", change: { kind: "set", props: { x: 1 } } },
187
+ },
188
+ reloadPreview,
189
+ );
190
+
191
+ expect(previewFallbackLatch.pending).toBe(true);
192
+ expect(applySoftReload).not.toHaveBeenCalled();
193
+
194
+ applyPreviewSync(
195
+ FAKE_IFRAME,
196
+ result({ scriptText: "SCRIPT" }),
197
+ {
198
+ label: "Move animated layer (group)",
199
+ softReload: true,
200
+ previewFallbackLatch,
201
+ instantPatch: { selector: "#final", change: { kind: "set", props: { x: 2 } } },
202
+ },
203
+ reloadPreview,
204
+ );
205
+
206
+ expect(previewFallbackLatch.pending).toBe(false);
207
+ expect(applySoftReload).toHaveBeenCalledTimes(1);
208
+ });
209
+
210
+ it("falls back immediately when a deferred patch miss has no final-render latch", () => {
211
+ patchRuntimeTweenInPlace.mockReturnValue(false);
212
+ applySoftReload.mockReturnValue("applied");
213
+
214
+ applyPreviewSync(
215
+ FAKE_IFRAME,
216
+ result({ scriptText: "SCRIPT" }),
217
+ {
218
+ label: "Deferred standalone write",
219
+ softReload: true,
220
+ deferPreviewSync: true,
221
+ instantPatch: { selector: "#missed", change: { kind: "set", props: { x: 1 } } },
222
+ },
223
+ vi.fn(),
224
+ );
225
+
226
+ expect(applySoftReload).toHaveBeenCalledTimes(1);
227
+ });
228
+
84
229
  it("instantPatch + patch fails: falls back to the soft reload, passing onAsyncFailure", () => {
85
230
  patchRuntimeTweenInPlace.mockReturnValue(false);
86
231
  applySoftReload.mockReturnValue("applied");
@@ -338,10 +483,51 @@ describe("runCommit — instantPatch wiring", () => {
338
483
 
339
484
  // The file already matched (changed:false) but the runtime patch deferred
340
485
  // from the paired first commit must still land.
341
- expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(FAKE_IFRAME, "#a", {
342
- kind: "set",
343
- props: { x: 485, y: 311 },
486
+ expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(
487
+ FAKE_IFRAME,
488
+ "#a",
489
+ {
490
+ kind: "set",
491
+ props: { x: 485, y: 311 },
492
+ },
493
+ undefined,
494
+ false,
495
+ );
496
+ expect(deps.reloadPreview).not.toHaveBeenCalled();
497
+ });
498
+
499
+ it("no-op batch still applies every plural instant patch", async () => {
500
+ patchRuntimeTweenInPlace.mockReturnValue(true);
501
+ mockFetchResult({ changed: false });
502
+ const deps = renderCommitHook();
503
+ const batch = deps.api.commitMutation.batch;
504
+ if (!batch) throw new Error("batch capability missing");
505
+
506
+ await act(async () => {
507
+ await batch(
508
+ [
509
+ {
510
+ selection,
511
+ mutation: { type: "update-property", property: "x", value: 10 },
512
+ options: {
513
+ label: "Move layer",
514
+ instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
515
+ },
516
+ },
517
+ {
518
+ selection: { ...selection, id: "b", selector: "#b" },
519
+ mutation: { type: "update-property", property: "x", value: 20 },
520
+ options: {
521
+ label: "Move layer",
522
+ instantPatch: { selector: "#b", change: { kind: "set", props: { x: 20 } } },
523
+ },
524
+ },
525
+ ],
526
+ { label: "Move animated layer (group)" },
527
+ );
344
528
  });
529
+
530
+ expect(patchRuntimeTweenInPlace.mock.calls.map((call) => call[1])).toEqual(["#a", "#b"]);
345
531
  expect(deps.reloadPreview).not.toHaveBeenCalled();
346
532
  });
347
533
 
@@ -36,6 +36,7 @@ import {
36
36
  useGsapSaveFailureTelemetry,
37
37
  useSafeGsapCommitMutation,
38
38
  } from "./useSafeGsapCommitMutation";
39
+ import { studioWriteHeaders } from "../utils/studioFileVersion";
39
40
 
40
41
  async function mutateGsapScript(
41
42
  projectId: string,
@@ -46,7 +47,7 @@ async function mutateGsapScript(
46
47
  `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`,
47
48
  {
48
49
  method: "POST",
49
- headers: { "Content-Type": "application/json" },
50
+ headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
50
51
  body: JSON.stringify(mutation),
51
52
  },
52
53
  );
@@ -65,7 +66,7 @@ async function mutateGsapScriptBatch(
65
66
  `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations-batch/${encodeURIComponent(sourceFile)}`,
66
67
  {
67
68
  method: "POST",
68
- headers: { "Content-Type": "application/json" },
69
+ headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
69
70
  body: JSON.stringify({ mutations }),
70
71
  },
71
72
  );
@@ -125,7 +126,10 @@ function finishUnchangedMutation(
125
126
  reloadPreview: () => void,
126
127
  ): boolean {
127
128
  if (result.changed !== false) return false;
128
- if (!options.skipReload && options.instantPatch) {
129
+ if (
130
+ !options.skipReload &&
131
+ (instantPatchesFor(options).length > 0 || options.previewFallbackLatch?.pending)
132
+ ) {
129
133
  applyPreviewSync(iframe, result, options, reloadPreview);
130
134
  }
131
135
  return true;
@@ -248,19 +252,37 @@ export function applyPreviewSync(
248
252
  options: CommitMutationOptions,
249
253
  reloadPreview: () => void,
250
254
  ): void {
251
- if (options.instantPatch) {
252
- const patched = patchRuntimeTweenInPlace(
253
- iframe,
254
- options.instantPatch.selector,
255
- options.instantPatch.change,
255
+ const patches = instantPatchesFor(options);
256
+ let needsFallback = options.previewFallbackLatch?.pending === true;
257
+ if (patches.length > 0) {
258
+ const deferSeek = options.deferPreviewSync === true;
259
+ const missed = patches.find(
260
+ (patch, index) =>
261
+ !patchRuntimeTweenInPlace(
262
+ iframe,
263
+ patch.selector,
264
+ patch.change,
265
+ undefined,
266
+ deferSeek || index < patches.length - 1,
267
+ ),
256
268
  );
257
- // Patched in place — element is already correct on screen; no reload needed.
258
- if (patched) return;
259
- // The instant path couldn't patch in place record the fallback so we can
260
- // track how often the fast path misses before the soft/full reload below.
261
- trackStudioEvent("gsap_instant_patch_fallback", { selector: options.instantPatch.selector });
262
- // Fall through to the soft/full reload path below.
269
+ if (missed) {
270
+ // The instant path couldn't patch in place — record the fallback so we can
271
+ // track how often the fast path misses before the soft/full reload below.
272
+ trackStudioEvent("gsap_instant_patch_fallback", { selector: missed.selector });
273
+ needsFallback = true;
274
+ }
275
+ // Patched in place — elements are already correct on screen; no reload needed
276
+ // unless an earlier deferred batch left one member unpatched.
277
+ if (!needsFallback) return;
278
+ }
279
+ // Written, but the caller has more writes to make and will render after the last.
280
+ if (options.deferPreviewSync && options.previewFallbackLatch) {
281
+ options.previewFallbackLatch.pending = needsFallback;
282
+ return;
263
283
  }
284
+ if (options.deferPreviewSync && !needsFallback) return;
285
+ if (options.previewFallbackLatch) options.previewFallbackLatch.pending = false;
264
286
  if (options.softReload && result.scriptText) {
265
287
  // A soft-reloadable edit escalates to a full iframe remount ONLY on the
266
288
  // PERMANENT "cannot-soft-reload" result (the preview is genuinely stale/
@@ -280,6 +302,15 @@ export function applyPreviewSync(
280
302
  }
281
303
  }
282
304
 
305
+ function instantPatchesFor(
306
+ options: CommitMutationOptions,
307
+ ): NonNullable<CommitMutationOptions["instantPatches"]> {
308
+ return [
309
+ ...(options.instantPatches ?? []),
310
+ ...(options.instantPatch ? [options.instantPatch] : []),
311
+ ];
312
+ }
313
+
283
314
  // oxfmt-ignore
284
315
  // fallow-ignore-next-line complexity
285
316
  export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, publishSdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) {
@@ -355,7 +386,13 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
355
386
  );
356
387
  if (!result) return;
357
388
  options.onResult?.(result);
358
- await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, options);
389
+ // Each call brings its own fast-path patch; the batch wrote them all, so the
390
+ // preview sync applies them all rather than just the last call's.
391
+ const instantPatches = calls
392
+ .map(({ options: callOptions }) => callOptions.instantPatch)
393
+ .filter((patch) => patch !== undefined);
394
+ const { instantPatch: _instantPatch, ...batchOptions } = options;
395
+ await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, instantPatches.length > 0 ? { ...batchOptions, instantPatches } : batchOptions);
359
396
  }, [showToast, finalizeSuccessfulMutation]);
360
397
 
361
398
  // Every GSAP-script commit is a read-modify-write of one file. Overlapping
@@ -7,6 +7,7 @@ import {
7
7
  buildStudioHash,
8
8
  parseStudioUrlStateFromHash,
9
9
  type StudioUrlSelectionState,
10
+ type StudioUrlSelectionTarget,
10
11
  type StudioUrlState,
11
12
  } from "../utils/studioUrlState";
12
13
 
@@ -22,6 +23,8 @@ interface UseStudioUrlStateParams {
22
23
  rightCollapsed: boolean;
23
24
  activeCompPathHydrated: boolean;
24
25
  domEditSelection: DomEditSelection | null;
26
+ domEditGroupSelections: DomEditSelection[];
27
+ applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
25
28
  buildDomSelectionFromTarget: (
26
29
  target: HTMLElement,
27
30
  options?: { preferClipAncestor?: boolean },
@@ -38,8 +41,7 @@ interface UseStudioUrlStateParams {
38
41
  initialState: StudioUrlState;
39
42
  }
40
43
 
41
- function toPersistedSelection(selection: DomEditSelection | null): StudioUrlSelectionState | null {
42
- if (!selection) return null;
44
+ function toPersistedTarget(selection: DomEditSelection): StudioUrlSelectionTarget | null {
43
45
  if (!selection.id && !selection.selector) return null;
44
46
  return {
45
47
  sourceFile: selection.sourceFile || undefined,
@@ -49,12 +51,109 @@ function toPersistedSelection(selection: DomEditSelection | null): StudioUrlSele
49
51
  };
50
52
  }
51
53
 
54
+ function selectionTargetKey(selection: StudioUrlSelectionTarget): string {
55
+ return [
56
+ selection.sourceFile ?? "",
57
+ selection.id ?? "",
58
+ selection.selector ?? "",
59
+ selection.selectorIndex ?? "",
60
+ ].join("|");
61
+ }
62
+
63
+ function toPersistedSelection(
64
+ selection: DomEditSelection | null,
65
+ // Optional: a caller that only ever has one selection has nothing to add, and
66
+ // the URL must still carry that one rather than throwing on the way out.
67
+ group: DomEditSelection[] = [],
68
+ ): StudioUrlSelectionState | null {
69
+ if (!selection) return null;
70
+ const primary = toPersistedTarget(selection);
71
+ if (!primary) return null;
72
+ // The primary is already carried by the top-level fields; the rest ride along so the link
73
+ // reopens the same multi-selection instead of a single element.
74
+ const primaryKey = selectionTargetKey(primary);
75
+ const members = new Map<string, StudioUrlSelectionTarget>();
76
+ for (const member of group) {
77
+ const target = toPersistedTarget(member);
78
+ if (!target) continue;
79
+ const key = selectionTargetKey(target);
80
+ if (key !== primaryKey) members.set(key, target);
81
+ }
82
+ return {
83
+ ...primary,
84
+ group: members.size > 0 ? [...members.values()] : undefined,
85
+ };
86
+ }
87
+
52
88
  function replaceHash(nextHash: string) {
53
89
  if (typeof window === "undefined") return;
54
90
  if (window.location.hash === nextHash) return;
55
91
  window.history.replaceState(null, "", nextHash);
56
92
  }
57
93
 
94
+ interface ResolveUrlSelectionsParams {
95
+ doc: Document;
96
+ primaryElement: HTMLElement;
97
+ selection: StudioUrlSelectionState;
98
+ group: StudioUrlSelectionTarget[];
99
+ activeCompPath: string | null;
100
+ isCurrent: () => boolean;
101
+ buildDomSelection: UseStudioUrlStateParams["buildDomSelectionFromTarget"];
102
+ }
103
+
104
+ function findUrlSelectionElement(
105
+ doc: Document,
106
+ target: StudioUrlSelectionTarget,
107
+ fallbackSourceFile: string,
108
+ activeCompPath: string | null,
109
+ ): HTMLElement | null {
110
+ return findElementForSelection(
111
+ doc,
112
+ {
113
+ sourceFile: target.sourceFile ?? fallbackSourceFile,
114
+ id: target.id,
115
+ selector: target.selector,
116
+ selectorIndex: target.selectorIndex,
117
+ },
118
+ activeCompPath,
119
+ );
120
+ }
121
+
122
+ async function buildOptionalDomSelection(
123
+ element: HTMLElement | null,
124
+ buildDomSelection: UseStudioUrlStateParams["buildDomSelectionFromTarget"],
125
+ ): Promise<DomEditSelection | null> {
126
+ if (!element) return null;
127
+ return buildDomSelection(element, { preferClipAncestor: false });
128
+ }
129
+
130
+ async function resolveUrlSelections({
131
+ doc,
132
+ primaryElement,
133
+ selection,
134
+ group,
135
+ activeCompPath,
136
+ isCurrent,
137
+ buildDomSelection,
138
+ }: ResolveUrlSelectionsParams): Promise<DomEditSelection[] | null> {
139
+ const primary = await buildDomSelection(primaryElement, { preferClipAncestor: false });
140
+ if (!isCurrent()) return null;
141
+ if (!primary) return [];
142
+ const members = [primary];
143
+ for (const member of group) {
144
+ const element = findUrlSelectionElement(
145
+ doc,
146
+ member,
147
+ selection.sourceFile ?? "",
148
+ activeCompPath,
149
+ );
150
+ const resolved = await buildOptionalDomSelection(element, buildDomSelection);
151
+ if (!isCurrent()) return null;
152
+ if (resolved) members.push(resolved);
153
+ }
154
+ return members;
155
+ }
156
+
58
157
  export function useStudioUrlState({
59
158
  projectId,
60
159
  activeCompPath,
@@ -67,6 +166,8 @@ export function useStudioUrlState({
67
166
  rightCollapsed,
68
167
  activeCompPathHydrated,
69
168
  domEditSelection,
169
+ domEditGroupSelections,
170
+ applyMarqueeSelection,
70
171
  buildDomSelectionFromTarget,
71
172
  applyDomSelection,
72
173
  setRightPanelTab,
@@ -82,6 +183,7 @@ export function useStudioUrlState({
82
183
  const [selectionHydrated, setSelectionHydrated] = useState(initialState.selection == null);
83
184
  const pendingSelectionRef = useRef(initialState.selection);
84
185
  const stableTimeRef = useRef<number | null>(initialState.currentTime);
186
+ const selectionApplySeqRef = useRef(0);
85
187
 
86
188
  const buildUrlState = useCallback(
87
189
  (): StudioUrlState => ({
@@ -91,10 +193,10 @@ export function useStudioUrlState({
91
193
  rightCollapsed,
92
194
  timelineVisible: null,
93
195
  selection: hydratedSelectionRef.current
94
- ? toPersistedSelection(domEditSelection)
196
+ ? toPersistedSelection(domEditSelection, domEditGroupSelections)
95
197
  : pendingSelectionRef.current,
96
198
  }),
97
- [activeCompPath, domEditSelection, rightCollapsed, rightPanelTab],
199
+ [activeCompPath, domEditGroupSelections, domEditSelection, rightCollapsed, rightPanelTab],
98
200
  );
99
201
 
100
202
  // Resolve a URL selection to a live element and apply it. Shared by the initial
@@ -103,6 +205,7 @@ export function useStudioUrlState({
103
205
  // a missing element or null selection clears the selection and returns true.
104
206
  const applyUrlSelection = useCallback(
105
207
  (selection: StudioUrlSelectionState | null): boolean => {
208
+ const applySeq = ++selectionApplySeqRef.current;
106
209
  if (!selection) {
107
210
  applyDomSelection(null, { revealPanel: false });
108
211
  return true;
@@ -128,12 +231,32 @@ export function useStudioUrlState({
128
231
  applyDomSelection(null, { revealPanel: false });
129
232
  return true;
130
233
  }
131
- void buildDomSelectionFromTarget(element, { preferClipAncestor: false }).then((resolved) => {
132
- applyDomSelection(resolved, { revealPanel: false });
234
+ const group = selection.group ?? [];
235
+ void resolveUrlSelections({
236
+ doc,
237
+ primaryElement: element,
238
+ selection,
239
+ group,
240
+ activeCompPath,
241
+ isCurrent: () => applySeq === selectionApplySeqRef.current,
242
+ buildDomSelection: buildDomSelectionFromTarget,
243
+ }).then((members) => {
244
+ if (!members) return;
245
+ const primary = members[0];
246
+ if (!primary) return applyDomSelection(null, { revealPanel: false });
247
+ if (group.length === 0) return applyDomSelection(primary, { revealPanel: false });
248
+ // Missing group members are dropped without failing the rest.
249
+ applyMarqueeSelection(members, false);
133
250
  });
134
251
  return true;
135
252
  },
136
- [activeCompPath, applyDomSelection, buildDomSelectionFromTarget, previewIframeRef],
253
+ [
254
+ activeCompPath,
255
+ applyDomSelection,
256
+ applyMarqueeSelection,
257
+ buildDomSelectionFromTarget,
258
+ previewIframeRef,
259
+ ],
137
260
  );
138
261
 
139
262
  useEffect(() => {
@@ -36,6 +36,7 @@ import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
36
36
  import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
37
37
  import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
38
38
  import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
39
+ import { studioWriteHeaders } from "../utils/studioFileVersion";
39
40
 
40
41
  type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
41
42
  stackingReorder?: TimelineStackingReorderIntent | null;
@@ -412,7 +413,7 @@ export function useTimelineEditing({
412
413
  `/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
413
414
  {
414
415
  method: "POST",
415
- headers: { "Content-Type": "application/json" },
416
+ headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
416
417
  body: JSON.stringify({ target: patchTarget }),
417
418
  },
418
419
  );
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
2
2
  import type { TimelineElement } from "../player";
3
3
  import type { DomEditSelection } from "../components/editor/domEditing";
4
4
  import { resolveTimelineIdForSelection } from "../utils/studioHelpers";
5
+ import { logSelect } from "../utils/selectDebug";
5
6
 
6
7
  interface UseTimelineSelectionPreviewSyncParams {
7
8
  selectedElementId: string | null;
@@ -93,6 +94,13 @@ export function useTimelineSelectionPreviewSync({
93
94
 
94
95
  if (selectedIds.length === 0) {
95
96
  missingSelectionKeyRef.current = "";
97
+ // The timeline holds nothing, so the canvas is about to hold nothing either.
98
+ // This is the path that silently drops a selection the user can still see.
99
+ logSelect("timeline-empty", {
100
+ had: currentIds.length,
101
+ previousKey: previousSelectedKey.length > 0,
102
+ clearing: previousSelectedKey.length > 0 && currentIds.length > 0,
103
+ });
96
104
  if (previousSelectedKey.length > 0 && currentIds.length > 0) {
97
105
  applyDomSelection(null, { revealPanel: false });
98
106
  }
@@ -127,6 +135,11 @@ export function useTimelineSelectionPreviewSync({
127
135
  return;
128
136
  }
129
137
  missingSelectionKeyRef.current = "";
138
+ logSelect("timeline-sync", {
139
+ wanted: selectedIds.length,
140
+ had: currentIds.length,
141
+ resolved: selections.length,
142
+ });
130
143
  if (selections.length === 0) {
131
144
  applyDomSelection(null, { revealPanel: false });
132
145
  } else if (selections.length === 1) {
@@ -4,6 +4,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
4
4
  import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
5
5
  import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
6
6
  import { useTimelinePlayerLoop } from "./useTimelinePlayerLoop";
7
+ import { logReload } from "../../utils/reloadDebug";
7
8
 
8
9
  export type { ClipManifestClip } from "../lib/playbackTypes";
9
10
  export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
@@ -445,6 +446,7 @@ export function useTimelinePlayer() {
445
446
  const refreshPlayer = useCallback(() => {
446
447
  const iframe = iframeRef.current;
447
448
  if (!iframe) return;
449
+ logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack }));
448
450
  saveSeekPosition();
449
451
  // Hide the iframe across the full reload so the user never sees the reloading
450
452
  // document's RAW DOM (every clip stacked and visible) in the window between the