@hyperframes/studio 0.7.46 → 0.7.48

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 (110) hide show
  1. package/dist/assets/{index-DO2MAFSG.js → index-CWHON2kh.js} +1 -1
  2. package/dist/assets/index-DX02Q1Yl.js +423 -0
  3. package/dist/assets/index-Dq7FEg0K.css +1 -0
  4. package/dist/assets/{index-BGVoOXdg.js → index-RTqYPUN7.js} +1 -1
  5. package/dist/index.d.ts +96 -29
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +8443 -4305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +26 -32
  11. package/src/components/DesignPanelPromoteProvider.tsx +45 -0
  12. package/src/components/PanelTabButton.tsx +31 -0
  13. package/src/components/StudioPreviewArea.tsx +75 -3
  14. package/src/components/StudioRightPanel.tsx +111 -113
  15. package/src/components/editor/LayersPanel.tsx +2 -22
  16. package/src/components/editor/PromotableControl.tsx +105 -0
  17. package/src/components/editor/propertyPanelSections.tsx +42 -21
  18. package/src/components/panels/VariablesBindElement.tsx +266 -0
  19. package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
  20. package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
  21. package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
  22. package/src/components/panels/VariablesPanel.tsx +554 -0
  23. package/src/components/panels/VariablesRowAction.tsx +25 -0
  24. package/src/components/panels/VariablesValueControls.tsx +215 -0
  25. package/src/components/renders/useRenderQueue.ts +12 -0
  26. package/src/contexts/TimelineEditContext.tsx +7 -2
  27. package/src/contexts/VariablePromoteContext.tsx +138 -0
  28. package/src/contexts/variablePromoteHelpers.test.ts +124 -0
  29. package/src/contexts/variablePromoteHelpers.ts +53 -0
  30. package/src/contexts/variablePromoteIntegration.test.ts +80 -0
  31. package/src/hooks/previewVariablesStore.ts +34 -0
  32. package/src/hooks/timelineEditingHelpers.test.ts +104 -0
  33. package/src/hooks/timelineEditingHelpers.ts +386 -10
  34. package/src/hooks/useDomEditWiring.ts +9 -7
  35. package/src/hooks/useDomSelection.test.ts +36 -2
  36. package/src/hooks/useDomSelection.ts +56 -75
  37. package/src/hooks/useElementLifecycleOps.ts +57 -17
  38. package/src/hooks/usePreviewDocumentVersion.ts +27 -0
  39. package/src/hooks/useProjectCompositionVariables.ts +131 -0
  40. package/src/hooks/useStudioContextValue.ts +6 -1
  41. package/src/hooks/useStudioSdkSessions.ts +37 -0
  42. package/src/hooks/useTimelineEditing.test.tsx +963 -0
  43. package/src/hooks/useTimelineEditing.ts +122 -100
  44. package/src/hooks/useTimelineEditingTypes.ts +15 -0
  45. package/src/hooks/useTimelineGroupEditing.ts +370 -0
  46. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
  47. package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
  48. package/src/hooks/useVariablesPersist.ts +61 -0
  49. package/src/player/components/BeatStrip.tsx +19 -3
  50. package/src/player/components/Player.tsx +7 -1
  51. package/src/player/components/Timeline.test.ts +38 -0
  52. package/src/player/components/Timeline.tsx +102 -80
  53. package/src/player/components/TimelineCanvas.tsx +340 -296
  54. package/src/player/components/TimelineClip.test.tsx +11 -0
  55. package/src/player/components/TimelineClipDiamonds.tsx +40 -9
  56. package/src/player/components/TimelineDragGhost.tsx +59 -0
  57. package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
  58. package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
  59. package/src/player/components/TimelineLayerGutter.tsx +61 -0
  60. package/src/player/components/TimelineRuler.tsx +4 -2
  61. package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
  62. package/src/player/components/timelineCallbacks.ts +20 -2
  63. package/src/player/components/timelineClipDragPreview.ts +52 -0
  64. package/src/player/components/timelineDragDrop.ts +19 -3
  65. package/src/player/components/timelineDropIndicator.test.ts +48 -0
  66. package/src/player/components/timelineDropIndicator.ts +33 -0
  67. package/src/player/components/timelineEditing.test.ts +251 -1
  68. package/src/player/components/timelineEditing.ts +178 -19
  69. package/src/player/components/timelineGroupEditing.ts +159 -0
  70. package/src/player/components/timelineLayerDrag.test.ts +200 -0
  71. package/src/player/components/timelineLayerDrag.ts +383 -0
  72. package/src/player/components/timelineLayout.test.ts +34 -0
  73. package/src/player/components/timelineLayout.ts +47 -0
  74. package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
  75. package/src/player/components/timelineSnapTargets.test.ts +144 -0
  76. package/src/player/components/timelineSnapTargets.ts +164 -0
  77. package/src/player/components/timelineStacking.ts +62 -0
  78. package/src/player/components/timelineTrackOrder.test.ts +144 -0
  79. package/src/player/components/timelineTrackOrder.ts +173 -0
  80. package/src/player/components/useTimelineActiveClips.test.ts +20 -0
  81. package/src/player/components/useTimelineActiveClips.ts +15 -4
  82. package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
  83. package/src/player/components/useTimelineClipDrag.ts +172 -126
  84. package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
  85. package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
  86. package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
  87. package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
  88. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  89. package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -0
  91. package/src/player/lib/layerOrdering.test.ts +77 -0
  92. package/src/player/lib/layerOrdering.ts +98 -0
  93. package/src/player/lib/playbackTypes.ts +3 -0
  94. package/src/player/lib/timelineDOM.test.ts +103 -0
  95. package/src/player/lib/timelineDOM.ts +80 -0
  96. package/src/player/lib/timelineElementHelpers.ts +1 -1
  97. package/src/player/store/playerStore.test.ts +92 -0
  98. package/src/player/store/playerStore.ts +69 -9
  99. package/src/utils/blockInstaller.ts +2 -14
  100. package/src/utils/editHistory.test.ts +61 -0
  101. package/src/utils/editHistory.ts +7 -1
  102. package/src/utils/rootDuration.test.ts +34 -0
  103. package/src/utils/rootDuration.ts +17 -0
  104. package/src/utils/sdkCutover.ts +45 -0
  105. package/src/utils/studioHelpers.test.ts +31 -0
  106. package/src/utils/studioHelpers.ts +29 -1
  107. package/src/utils/studioUrlState.test.ts +29 -0
  108. package/src/utils/studioUrlState.ts +16 -1
  109. package/dist/assets/index-BsLQ2qGn.js +0 -420
  110. package/dist/assets/index-DoVLXke0.css +0 -1
@@ -2,7 +2,9 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { describe, expect, it, vi } from "vitest";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import type { TimelineElement } from "../player";
7
+ import { usePlayerStore } from "../player/store/playerStore";
6
8
  import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness";
7
9
  import { useDomSelection } from "./useDomSelection";
8
10
 
@@ -12,6 +14,7 @@ interface HarnessProps {
12
14
  activeCompPath: string | null;
13
15
  projectId: string | null;
14
16
  refreshKey: number;
17
+ timelineElements?: TimelineElement[];
15
18
  }
16
19
 
17
20
  function renderHarness(initialProps: HarnessProps): {
@@ -32,7 +35,7 @@ function renderHarness(initialProps: HarnessProps): {
32
35
  compIdToSrc: new Map(),
33
36
  captionEditMode: false,
34
37
  previewIframeRef: { current: null },
35
- timelineElements: [],
38
+ timelineElements: props.timelineElements ?? [],
36
39
  setSelectedTimelineElementId: vi.fn(),
37
40
  setRightCollapsed: vi.fn(),
38
41
  setRightPanelTab: vi.fn(),
@@ -64,6 +67,10 @@ function renderHarness(initialProps: HarnessProps): {
64
67
  };
65
68
  }
66
69
 
70
+ afterEach(() => {
71
+ usePlayerStore.getState().reset();
72
+ });
73
+
67
74
  function setupSelectedHarness() {
68
75
  const element = document.createElement("div");
69
76
  element.id = "headline";
@@ -131,4 +138,31 @@ describe("useDomSelection", () => {
131
138
  expect(harness.current().domEditSelection).toBe(selection);
132
139
  harness.cleanup();
133
140
  });
141
+
142
+ it("keeps preview marquee selections mirrored to the full timeline selection set", () => {
143
+ const first = document.createElement("div");
144
+ first.id = "clip-1";
145
+ const second = document.createElement("div");
146
+ second.id = "clip-2";
147
+ const firstSelection = makeSelection("First", first);
148
+ const secondSelection = makeSelection("Second", second);
149
+ const harness = renderHarness({
150
+ activeCompPath: "intro.html",
151
+ projectId: "project-1",
152
+ refreshKey: 0,
153
+ timelineElements: [
154
+ { id: "clip-1", domId: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
155
+ { id: "clip-2", domId: "clip-2", tag: "div", start: 1, duration: 1, track: 1 },
156
+ ],
157
+ });
158
+
159
+ act(() => harness.current().applyMarqueeSelection([secondSelection, firstSelection], false));
160
+
161
+ const state = usePlayerStore.getState();
162
+ expect([...state.selectedElementIds]).toEqual(["clip-2", "clip-1"]);
163
+ expect(state.selectedElementId).toBe("clip-2");
164
+ expect(harness.current().domEditGroupSelections).toHaveLength(2);
165
+ expect(harness.current().domEditSelection).toBe(secondSelection);
166
+ harness.cleanup();
167
+ });
134
168
  });
@@ -4,11 +4,7 @@ import {
4
4
  getAllPreviewTargetsFromPointer,
5
5
  getPreviewTargetFromPointer,
6
6
  } from "../utils/studioPreviewHelpers";
7
- import {
8
- findMatchingTimelineElementId,
9
- findTimelineIdByAncestor,
10
- type RightPanelTab,
11
- } from "../utils/studioHelpers";
7
+ import { resolveTimelineIdForSelection, type RightPanelTab } from "../utils/studioHelpers";
12
8
  import {
13
9
  domEditSelectionsTargetSame,
14
10
  domEditSelectionInGroup,
@@ -24,6 +20,7 @@ import {
24
20
  type DomEditSelection,
25
21
  } from "../components/editor/domEditing";
26
22
  import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
23
+ import { usePlayerStore } from "../player/store/playerStore";
27
24
 
28
25
  // ── Types ──
29
26
 
@@ -95,7 +92,6 @@ export interface UseDomSelectionReturn {
95
92
  ) => Promise<DomEditSelection | null>;
96
93
  handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
97
94
  refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
98
- refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
99
95
  applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
100
96
  }
101
97
 
@@ -126,11 +122,16 @@ export function useDomSelection({
126
122
 
127
123
  // ── Refs ──
128
124
 
125
+ const rightPanelTabRef = useRef(rightPanelTab);
126
+ rightPanelTabRef.current = rightPanelTab;
129
127
  const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
130
128
  const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
131
129
  const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
132
130
  const activeGroupElementRef = useRef<HTMLElement | null>(activeGroupElement);
133
131
  const compositionIdentityRef = useRef({ activeCompPath, projectId });
132
+ // Monotonic token so a rapid A->B timeline-clip select can't let A's slower async
133
+ // resolution land after B and restore the wrong selection.
134
+ const timelineSelectSeqRef = useRef(0);
134
135
 
135
136
  // Keep refs in sync with state
136
137
  domEditSelectionRef.current = domEditSelection;
@@ -205,22 +206,42 @@ export function useDomSelection({
205
206
  if (nextSelection) {
206
207
  if (options?.revealPanel !== false) {
207
208
  setRightCollapsed(false);
208
- setRightPanelTab("design");
209
+ // Keep the Variables tab in place — selecting elements is part of
210
+ // the bind flow there; yanking to Design would lose the context.
211
+ if (rightPanelTabRef.current !== "variables") {
212
+ setRightPanelTab("design");
213
+ }
214
+ }
215
+ // Mirror the whole DOM group to the store so it stays the single source of
216
+ // truth: a single selection collapses to one id; a preserved group (echo
217
+ // during a gesture) keeps every member instead of shrinking to the anchor.
218
+ const anchorId = resolveTimelineIdForSelection(
219
+ nextSelection,
220
+ timelineElements,
221
+ activeCompPath,
222
+ );
223
+ const groupIds = nextGroup
224
+ .map((selection) =>
225
+ resolveTimelineIdForSelection(selection, timelineElements, activeCompPath),
226
+ )
227
+ .filter((id): id is string => Boolean(id));
228
+ if (groupIds.length > 0) {
229
+ usePlayerStore.getState().setSelection(groupIds, anchorId);
230
+ } else {
231
+ setSelectedTimelineElementId(anchorId);
209
232
  }
210
- const nextSelectedTimelineId =
211
- findMatchingTimelineElementId(nextSelection, timelineElements) ??
212
- findTimelineIdByAncestor(
213
- nextSelection.element,
214
- timelineElements,
215
- nextSelection.sourceFile || "index.html",
216
- );
217
- setSelectedTimelineElementId(nextSelectedTimelineId);
218
233
  return;
219
234
  }
220
235
 
221
236
  setSelectedTimelineElementId(null);
222
237
  },
223
- [setSelectedTimelineElementId, timelineElements, setRightCollapsed, setRightPanelTab],
238
+ [
239
+ setSelectedTimelineElementId,
240
+ timelineElements,
241
+ setRightCollapsed,
242
+ setRightPanelTab,
243
+ activeCompPath,
244
+ ],
224
245
  );
225
246
 
226
247
  const clearDomSelection = useCallback(() => {
@@ -360,12 +381,15 @@ export function useDomSelection({
360
381
  const handleTimelineElementSelect = useCallback(
361
382
  async (element: TimelineElement | null) => {
362
383
  if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
384
+ const seq = ++timelineSelectSeqRef.current;
363
385
  if (!element) {
364
386
  applyDomSelection(null, { revealPanel: false });
365
387
  return;
366
388
  }
367
389
 
368
390
  const selection = await buildDomSelectionForTimelineElement(element);
391
+ // A newer selection superseded this one while we were resolving — drop the stale result.
392
+ if (seq !== timelineSelectSeqRef.current) return;
369
393
  if (selection) applyDomSelection(selection);
370
394
  },
371
395
  [applyDomSelection, buildDomSelectionForTimelineElement],
@@ -400,55 +424,6 @@ export function useDomSelection({
400
424
  [activeCompPath, applyDomSelection, buildDomSelectionFromTarget, previewIframeRef],
401
425
  );
402
426
 
403
- const refreshDomEditGroupSelectionsFromPreview = useCallback(
404
- // fallow-ignore-next-line complexity
405
- async (selections: DomEditSelection[]) => {
406
- const iframe = previewIframeRef.current;
407
- let doc: Document | null = null;
408
- try {
409
- doc = iframe?.contentDocument ?? null;
410
- } catch {
411
- return;
412
- }
413
- if (!doc) return;
414
-
415
- const nextGroup: DomEditSelection[] = [];
416
- for (const selection of selections) {
417
- const element = findElementForSelection(doc, selection, activeCompPath);
418
- if (!element) continue;
419
- const nextSelection = await buildDomSelectionFromTarget(element);
420
- if (nextSelection) nextGroup.push(nextSelection);
421
- }
422
- if (nextGroup.length === 0) return;
423
-
424
- const currentSelection = domEditSelectionRef.current;
425
- const nextSelection =
426
- nextGroup.find((selection) => domEditSelectionsTargetSame(selection, currentSelection)) ??
427
- nextGroup[0] ??
428
- null;
429
-
430
- domEditSelectionRef.current = nextSelection;
431
- domEditGroupSelectionsRef.current = nextGroup;
432
- setDomEditSelection(nextSelection);
433
- setDomEditGroupSelections(nextGroup);
434
-
435
- if (nextSelection) {
436
- setSelectedTimelineElementId(
437
- findMatchingTimelineElementId(nextSelection, timelineElements),
438
- );
439
- } else {
440
- setSelectedTimelineElementId(null);
441
- }
442
- },
443
- [
444
- activeCompPath,
445
- buildDomSelectionFromTarget,
446
- setSelectedTimelineElementId,
447
- timelineElements,
448
- previewIframeRef,
449
- ],
450
- );
451
-
452
427
  // ── Effects ──
453
428
 
454
429
  // Clear hover unconditionally on composition/project/preview change
@@ -530,16 +505,23 @@ export function useDomSelection({
530
505
  domEditGroupSelectionsRef.current = nextGroup;
531
506
  setDomEditSelection(nextSelection);
532
507
  setDomEditGroupSelections(nextGroup);
533
- const nextTimelineId =
534
- findMatchingTimelineElementId(nextSelection, timelineElements) ??
535
- findTimelineIdByAncestor(
536
- nextSelection.element,
537
- timelineElements,
538
- nextSelection.sourceFile || "index.html",
539
- );
540
- setSelectedTimelineElementId(nextTimelineId);
508
+ const nextTimelineId = resolveTimelineIdForSelection(
509
+ nextSelection,
510
+ timelineElements,
511
+ activeCompPath,
512
+ );
513
+ const nextTimelineIds = nextGroup
514
+ .map((selection) =>
515
+ resolveTimelineIdForSelection(selection, timelineElements, activeCompPath),
516
+ )
517
+ .filter((id): id is string => Boolean(id));
518
+ if (nextTimelineIds.length > 0) {
519
+ usePlayerStore.getState().setSelection(nextTimelineIds, nextTimelineId);
520
+ } else {
521
+ setSelectedTimelineElementId(null);
522
+ }
541
523
  },
542
- [applyDomSelection, timelineElements, setSelectedTimelineElementId],
524
+ [applyDomSelection, timelineElements, setSelectedTimelineElementId, activeCompPath],
543
525
  );
544
526
 
545
527
  // Disabled inspector effect
@@ -576,7 +558,6 @@ export function useDomSelection({
576
558
  buildDomSelectionForTimelineElement,
577
559
  handleTimelineElementSelect,
578
560
  refreshDomEditSelectionFromPreview,
579
- refreshDomEditGroupSelectionsFromPreview,
580
561
  applyMarqueeSelection,
581
562
  };
582
563
  }
@@ -160,6 +160,7 @@ export function useElementLifecycleOps({
160
160
  // persistDomEditOperations → onTrySdkPersist, so it is already SDK-cut-over as setStyle.
161
161
  // No SDK reorder/reparent op exists; DOM sibling order stays server-authoritative if ever needed.
162
162
  const handleDomZIndexReorderCommit = useCallback(
163
+ // fallow-ignore-next-line complexity
163
164
  (
164
165
  entries: Array<{
165
166
  element: HTMLElement;
@@ -168,17 +169,26 @@ export function useElementLifecycleOps({
168
169
  selector?: string;
169
170
  selectorIndex?: number;
170
171
  sourceFile: string;
172
+ key?: string;
171
173
  }>,
172
174
  ) => {
173
- if (entries.length === 0) return;
175
+ if (entries.length === 0) return Promise.resolve();
174
176
  // Resolver shadow (telemetry-only, decoupled from cutover): record whether
175
177
  // the SDK resolves each reordered element — the reorderElements op's targets.
176
178
  onReorderShadow?.(
177
179
  entries.map((e) => readHfId(e.element)).filter((id): id is string => id != null),
178
180
  );
179
181
  const coalesceKey = `z-reorder:${entries.map((e) => e.id ?? e.selector ?? e.element.getAttribute("data-hf-id") ?? "el").join(":")}`;
182
+ const saves: Array<Promise<void>> = [];
183
+ const rollbacks: Array<() => void> = [];
180
184
  for (let i = 0; i < entries.length; i++) {
181
185
  const entry = entries[i];
186
+ const priorZIndex = entry.element.style.zIndex;
187
+ const priorPosition = entry.element.style.position;
188
+ const priorStoreEntry = entry.key
189
+ ? usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === entry.key)
190
+ : undefined;
191
+ let positionChanged = false;
182
192
  entry.element.style.zIndex = String(entry.zIndex);
183
193
  const patches: Array<{ type: "inline-style"; property: string; value: string }> = [
184
194
  { type: "inline-style", property: "z-index", value: String(entry.zIndex) },
@@ -187,28 +197,58 @@ export function useElementLifecycleOps({
187
197
  const win = entry.element.ownerDocument?.defaultView;
188
198
  if (win && win.getComputedStyle(entry.element).position === "static") {
189
199
  entry.element.style.position = "relative";
200
+ positionChanged = true;
190
201
  patches.push({ type: "inline-style", property: "position", value: "relative" });
191
202
  }
192
203
  } catch {
193
204
  /* cross-origin or detached — skip */
194
205
  }
195
- void commitPositionPatchToHtml(
196
- {
197
- element: entry.element,
198
- id: entry.id ?? null,
199
- hfId: readHfId(entry.element),
200
- selector: entry.selector,
201
- selectorIndex: entry.selectorIndex,
202
- sourceFile: entry.sourceFile,
203
- } as unknown as DomEditSelection,
204
- patches,
205
- {
206
- label: "Reorder layers",
207
- coalesceKey,
208
- skipRefresh: i < entries.length - 1,
209
- },
210
- ).catch(() => undefined);
206
+ if (entry.key) {
207
+ usePlayerStore
208
+ .getState()
209
+ .updateElement(entry.key, { zIndex: entry.zIndex, hasExplicitZIndex: true });
210
+ }
211
+ rollbacks.push(() => {
212
+ entry.element.style.zIndex = priorZIndex;
213
+ if (positionChanged) entry.element.style.position = priorPosition;
214
+ if (entry.key && priorStoreEntry) {
215
+ usePlayerStore.getState().updateElement(entry.key, {
216
+ zIndex: priorStoreEntry.zIndex,
217
+ hasExplicitZIndex: priorStoreEntry.hasExplicitZIndex,
218
+ });
219
+ }
220
+ });
221
+ saves.push(
222
+ commitPositionPatchToHtml(
223
+ {
224
+ element: entry.element,
225
+ id: entry.id ?? null,
226
+ hfId: readHfId(entry.element),
227
+ selector: entry.selector,
228
+ selectorIndex: entry.selectorIndex,
229
+ sourceFile: entry.sourceFile,
230
+ } as unknown as DomEditSelection,
231
+ patches,
232
+ {
233
+ label: "Reorder layers",
234
+ coalesceKey,
235
+ skipRefresh: i < entries.length - 1,
236
+ },
237
+ ),
238
+ );
211
239
  }
240
+ // Resolves once every z-index patch is persisted so a same-file timing write
241
+ // can be ordered after it (see applyTimelineStackingReorder callers).
242
+ return Promise.allSettled(saves).then((settled) => {
243
+ const rejected = settled.find(
244
+ (result): result is PromiseRejectedResult => result.status === "rejected",
245
+ );
246
+ if (rejected) {
247
+ for (const rollback of rollbacks) rollback();
248
+ return Promise.reject(rejected.reason);
249
+ }
250
+ return undefined;
251
+ });
212
252
  },
213
253
  [commitPositionPatchToHtml, onReorderShadow],
214
254
  );
@@ -0,0 +1,27 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ /**
4
+ * Version counter for the preview DOM. `refresh` bumps immediately and again
5
+ * at 80ms / 300ms so consumers re-scan after the iframe settles; pending
6
+ * timers are collapsed by each new refresh and cleared on unmount.
7
+ */
8
+ export function usePreviewDocumentVersion(): [number, () => void] {
9
+ const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
10
+ const refreshTimersRef = useRef<number[]>([]);
11
+ const refresh = useCallback(() => {
12
+ for (const id of refreshTimersRef.current) clearTimeout(id);
13
+ refreshTimersRef.current = [];
14
+ setPreviewDocumentVersion((v) => v + 1);
15
+ refreshTimersRef.current.push(
16
+ window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 80),
17
+ window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 300),
18
+ );
19
+ }, []);
20
+ useEffect(
21
+ () => () => {
22
+ for (const id of refreshTimersRef.current) clearTimeout(id);
23
+ },
24
+ [],
25
+ );
26
+ return [previewDocumentVersion, refresh];
27
+ }
@@ -0,0 +1,131 @@
1
+ import { useCallback, useEffect, useState, type MutableRefObject } from "react";
2
+ import { openComposition, type Composition, type CompositionVariable } from "@hyperframes/sdk";
3
+ import { persistSdkSerialize } from "../utils/sdkCutover";
4
+ import type { EditHistoryKind } from "../utils/editHistory";
5
+
6
+ /** Records an edit into the studio's undo history (label + kind + per-file before/after). */
7
+ export type RecordEditFn = (entry: {
8
+ label: string;
9
+ kind: EditHistoryKind;
10
+ files: Record<string, { before: string; after: string }>;
11
+ }) => Promise<void>;
12
+
13
+ export interface CompositionVariableGroup {
14
+ /** Project-relative file path, e.g. "compositions/frames/02-problem.html". */
15
+ path: string;
16
+ /** The composition's variable declarations (empty groups are dropped by the hook). */
17
+ variables: CompositionVariable[];
18
+ }
19
+
20
+ /** Read one composition file's declarations, or null to skip (unreadable / none / unparseable). */
21
+ // fallow-ignore-next-line complexity
22
+ async function readGroup(
23
+ path: string,
24
+ readProjectFile: (path: string) => Promise<string>,
25
+ ): Promise<CompositionVariableGroup | null> {
26
+ let content: string;
27
+ try {
28
+ content = await readProjectFile(path);
29
+ } catch {
30
+ return null;
31
+ }
32
+ if (!content.includes("data-composition-variables")) return null;
33
+ try {
34
+ const comp = await openComposition(content, { history: false });
35
+ try {
36
+ const variables = comp.getVariableDeclarations();
37
+ return variables.length > 0 ? { path, variables } : null;
38
+ } finally {
39
+ comp.dispose();
40
+ }
41
+ } catch {
42
+ return null; // Unparseable composition — skip rather than break the whole panel.
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Read variable declarations from every composition file in the project except
48
+ * `excludePath` (the active composition, which the panel renders with its full
49
+ * preview/add controls). Powers the Variables tab's "other compositions"
50
+ * sections so a variable promoted into a sub-comp file is visible alongside the
51
+ * host's own. Re-reads whenever `refreshKey` changes (after an edit or preview
52
+ * reload). A cheap substring guard skips files with no declarations before the
53
+ * full parse, so large projects don't pay N openComposition calls.
54
+ */
55
+ export function useProjectCompositionVariables(
56
+ fileTree: string[],
57
+ excludePath: string | null,
58
+ readProjectFile: (path: string) => Promise<string>,
59
+ refreshKey: unknown,
60
+ ): CompositionVariableGroup[] {
61
+ const [groups, setGroups] = useState<CompositionVariableGroup[]>([]);
62
+
63
+ useEffect(() => {
64
+ let cancelled = false;
65
+ const htmlFiles = fileTree.filter((p) => p.endsWith(".html") && p !== excludePath);
66
+
67
+ void (async () => {
68
+ const out: CompositionVariableGroup[] = [];
69
+ for (const path of htmlFiles) {
70
+ const group = await readGroup(path, readProjectFile);
71
+ if (group) out.push(group);
72
+ }
73
+ if (!cancelled) setGroups(out);
74
+ })();
75
+
76
+ return () => {
77
+ cancelled = true;
78
+ };
79
+ // eslint-disable-next-line react-hooks/exhaustive-deps
80
+ }, [fileTree, excludePath, readProjectFile, refreshKey]);
81
+
82
+ return groups;
83
+ }
84
+
85
+ interface EditVariablesDeps {
86
+ readProjectFile: (path: string) => Promise<string>;
87
+ writeProjectFile: (path: string, content: string) => Promise<void>;
88
+ recordEdit: RecordEditFn;
89
+ reloadPreview: () => void;
90
+ domEditSaveTimestampRef: MutableRefObject<number>;
91
+ }
92
+
93
+ /**
94
+ * Apply a variable-schema mutation to an arbitrary composition file (a sub-comp
95
+ * that isn't the active SDK session) and persist it through the standard
96
+ * single-writer path. Opens a throwaway session on the file, runs `mutate`,
97
+ * and writes the serialized result — the same contract as useVariablesPersist,
98
+ * but keyed on `path` rather than a live session.
99
+ */
100
+ export function useEditVariablesInFile(deps: EditVariablesDeps) {
101
+ const { readProjectFile, writeProjectFile, recordEdit, reloadPreview, domEditSaveTimestampRef } =
102
+ deps;
103
+ return useCallback(
104
+ async (path: string, label: string, mutate: (session: Composition) => void): Promise<void> => {
105
+ const originalContent = await readProjectFile(path);
106
+ const comp = await openComposition(originalContent, { history: false });
107
+ let after: string;
108
+ try {
109
+ mutate(comp);
110
+ after = comp.serialize();
111
+ } finally {
112
+ comp.dispose();
113
+ }
114
+ if (after === originalContent) return;
115
+ await persistSdkSerialize(
116
+ after,
117
+ path,
118
+ originalContent,
119
+ {
120
+ editHistory: { recordEdit },
121
+ writeProjectFile,
122
+ reloadPreview,
123
+ domEditSaveTimestampRef,
124
+ compositionPath: path,
125
+ },
126
+ { label },
127
+ );
128
+ },
129
+ [readProjectFile, writeProjectFile, recordEdit, reloadPreview, domEditSaveTimestampRef],
130
+ );
131
+ }
@@ -97,7 +97,12 @@ export function useInspectorState(
97
97
  STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
98
98
  // Keep the selection box + motion path drawn even when the Inspector is
99
99
  // collapsed — closing the panel shouldn't visually deselect the element.
100
- shouldShowSelectedDomBounds: inspectorPanelActive && !isPlaying && !isGestureRecording,
100
+ // The Variables tab also works against the canvas selection (bind card),
101
+ // so the selection outline stays visible there too.
102
+ shouldShowSelectedDomBounds:
103
+ (inspectorPanelActive || rightPanelTab === "variables") &&
104
+ !isPlaying &&
105
+ !isGestureRecording,
101
106
  };
102
107
  }, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]);
103
108
  }
@@ -0,0 +1,37 @@
1
+ import { useEffect, type MutableRefObject } from "react";
2
+ import { useSdkSession } from "./useSdkSession";
3
+ import { usePreviewVariablesStore } from "./previewVariablesStore";
4
+
5
+ /**
6
+ * Open the studio's SDK session with master-view semantics.
7
+ *
8
+ * The master view has no explicit comp path, but the session must still model
9
+ * the project's main composition so schema-level panels (Variables, Slideshow)
10
+ * work there. Edit-flow consumers keep the legacy "no session on master view"
11
+ * gating via `editFlowSdkSession` so cutover behavior is unchanged.
12
+ *
13
+ * Also clears preview variable overrides whenever the composition or project
14
+ * changes — overrides are per-composition and must never leak into another
15
+ * composition's preview or render.
16
+ */
17
+ export function useStudioSdkSessions(
18
+ projectId: string | null,
19
+ activeCompPath: string | null,
20
+ domEditSaveTimestampRef: MutableRefObject<number>,
21
+ masterCompPath: string | null,
22
+ ) {
23
+ // On the master view (no explicit comp) the schema panels target the project's
24
+ // resolved main composition — the first `.html` in the tree, not a hardcoded
25
+ // "index.html" that may not exist. `null` when the project has no composition
26
+ // yet, which correctly leaves the session (and the panels) empty.
27
+ const sdkHandle = useSdkSession(
28
+ projectId,
29
+ activeCompPath ?? masterCompPath,
30
+ domEditSaveTimestampRef,
31
+ );
32
+ const editFlowSdkSession = activeCompPath ? sdkHandle.session : null;
33
+ useEffect(() => {
34
+ usePreviewVariablesStore.getState().setValues(null);
35
+ }, [projectId, activeCompPath]);
36
+ return { sdkHandle, editFlowSdkSession };
37
+ }