@hyperframes/studio 0.8.75 → 0.8.77

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 (130) hide show
  1. package/dist/assets/hyperframes-player-BSDTJ9EF.js +464 -0
  2. package/dist/assets/{index-BlUYw1MB.js → index-2GgHjQq-.js} +163 -163
  3. package/dist/assets/{index-0w6iIlwZ.css → index-BWJqxeaH.css} +1 -1
  4. package/dist/assets/{index-BiFKcNjs.js → index-CsFCRdMo.js} +1 -1
  5. package/dist/assets/{index-HjexPsny.js → index-DcxWQ7Za.js} +1 -1
  6. package/dist/{chunk-7BP32G62.js → chunk-5VANA2TG.js} +12 -1
  7. package/dist/chunk-5VANA2TG.js.map +1 -0
  8. package/dist/{domEditingLayers-UKMVYXT6.js → domEditingLayers-IGPLTI5E.js} +4 -2
  9. package/dist/index.d.ts +34 -69
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +5287 -5277
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +7 -7
  15. package/src/components/StudioRightPanels.types.ts +0 -2
  16. package/src/components/editor/DomEditOverlay.test.ts +5 -0
  17. package/src/components/editor/LayersPanel.tsx +3 -14
  18. package/src/components/editor/PreviewOverlayProvider.test.tsx +6 -1
  19. package/src/components/editor/domEditing.ts +1 -0
  20. package/src/components/editor/domEditingElement.ts +13 -0
  21. package/src/components/editor/domEditingLayers.test.ts +27 -0
  22. package/src/components/editor/domEditingLayers.ts +9 -0
  23. package/src/components/editor/gsapLivePreview.test.ts +63 -0
  24. package/src/components/editor/gsapLivePreview.ts +2 -14
  25. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +57 -35
  26. package/src/components/editor/overlayFrameLoop.test.ts +16 -0
  27. package/src/components/editor/overlayFrameLoop.ts +11 -0
  28. package/src/components/editor/propertyPanelHelpers.ts +4 -6
  29. package/src/components/editor/usePreviewCompositionRect.test.tsx +6 -1
  30. package/src/components/nle/NLEPreview.test.ts +202 -22
  31. package/src/components/nle/NLEPreview.tsx +65 -46
  32. package/src/components/nle/PreviewPoster.tsx +99 -0
  33. package/src/components/nle/PreviewZoomOverlay.tsx +111 -0
  34. package/src/components/nle/previewZoom.test.ts +42 -0
  35. package/src/components/nle/previewZoom.ts +33 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +30 -49
  37. package/src/components/panels/VariablesPanel.tsx +0 -2
  38. package/src/components/sidebar/CompositionsTab.drag.test.tsx +5 -1
  39. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +14 -0
  40. package/src/components/sidebar/CompositionsTab.test.ts +4 -6
  41. package/src/components/sidebar/CompositionsTab.tsx +28 -21
  42. package/src/components/studioColorGradingScope.ts +0 -3
  43. package/src/hooks/domEditDeleteMembers.ts +0 -2
  44. package/src/hooks/gsapScriptCommitTypes.ts +0 -2
  45. package/src/hooks/keyframeCacheAstLoad.test.ts +31 -1
  46. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  47. package/src/hooks/timelineAudioGroupCreate.ts +0 -1
  48. package/src/hooks/timelineEditingHelpers.ts +0 -3
  49. package/src/hooks/timelineTimingSync.ts +0 -1
  50. package/src/hooks/timelineTrackVisibility.ts +0 -1
  51. package/src/hooks/useBlockHandlers.ts +0 -2
  52. package/src/hooks/useClipboard.test.ts +69 -10
  53. package/src/hooks/useClipboard.ts +28 -19
  54. package/src/hooks/useDomEditCommits.test.tsx +0 -1
  55. package/src/hooks/useDomEditCommits.ts +0 -4
  56. package/src/hooks/useDomEditWiring.ts +1 -3
  57. package/src/hooks/useEditHistoryActions.test.tsx +31 -14
  58. package/src/hooks/useEditHistoryActions.ts +12 -7
  59. package/src/hooks/useEditorSave.ts +0 -3
  60. package/src/hooks/useElementLifecycleOps.ts +0 -1
  61. package/src/hooks/useEnableKeyframes.test.ts +9 -1
  62. package/src/hooks/useFileManager.ts +0 -2
  63. package/src/hooks/useGestureRecording.test.tsx +1 -1
  64. package/src/hooks/useGestureRecording.ts +6 -10
  65. package/src/hooks/useGroupCommits.ts +0 -1
  66. package/src/hooks/useGsapScriptCommits.ts +0 -1
  67. package/src/hooks/useGsapTweenCache.element.test.tsx +39 -0
  68. package/src/hooks/useGsapTweenCache.ts +5 -13
  69. package/src/hooks/useLintModal.test.tsx +34 -1
  70. package/src/hooks/useLintModal.ts +32 -30
  71. package/src/hooks/usePersistentEditHistory.test.ts +221 -315
  72. package/src/hooks/usePersistentEditHistory.ts +155 -342
  73. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +2 -0
  74. package/src/hooks/usePreviewPersistence.ts +0 -2
  75. package/src/hooks/useProjectCompositionVariables.ts +1 -3
  76. package/src/hooks/useRazorSplit.history.test.tsx +46 -67
  77. package/src/hooks/useRazorSplit.test.ts +17 -47
  78. package/src/hooks/useRenderClipContent.test.ts +29 -0
  79. package/src/hooks/useRenderClipContent.ts +6 -3
  80. package/src/hooks/useSdkSession.lifecycle.test.tsx +34 -4
  81. package/src/hooks/useSdkSession.ts +8 -2
  82. package/src/hooks/useSlideshowPersist.ts +0 -2
  83. package/src/hooks/useStudioContextValue.ts +6 -1
  84. package/src/hooks/useTimelineAssetDropOps.ts +0 -1
  85. package/src/hooks/useTimelineDeleteOps.ts +0 -1
  86. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  87. package/src/hooks/useTimelineEditingTypes.ts +0 -2
  88. package/src/hostMount.test.tsx +53 -22
  89. package/src/main.tsx +3 -0
  90. package/src/player/components/CompositionThumbnail.tsx +14 -0
  91. package/src/player/components/Player.test.ts +49 -0
  92. package/src/player/components/Player.tsx +21 -7
  93. package/src/player/components/timelineClipDragCommit.test.ts +7 -39
  94. package/src/player/hooks/timelineDomBinding.test.ts +50 -0
  95. package/src/player/hooks/timelineSyncHydration.test.ts +25 -1
  96. package/src/player/hooks/timelineSyncHydration.ts +3 -3
  97. package/src/player/hooks/useShadowPreviewReload.ts +1 -0
  98. package/src/player/hooks/useTimelinePlayer.play.test.ts +120 -0
  99. package/src/player/hooks/useTimelinePlayer.ts +1 -0
  100. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  101. package/src/player/lib/automationStoreSync.test.ts +24 -0
  102. package/src/player/lib/automationStoreSync.ts +3 -9
  103. package/src/player/lib/playbackScrub.ts +1 -1
  104. package/src/player/lib/timelineDOM.test.ts +28 -0
  105. package/src/player/lib/timelineDOM.ts +4 -2
  106. package/src/player/lib/timelineElementHelpers.ts +78 -9
  107. package/src/player/lib/timelineIframeHelpers.test.ts +49 -5
  108. package/src/player/lib/timelineIframeHelpers.ts +13 -12
  109. package/src/player/store/playerStore.test.ts +35 -1
  110. package/src/player/store/playerStore.ts +29 -0
  111. package/src/player/store/readinessSlice.ts +34 -6
  112. package/src/utils/blockInstaller.ts +0 -3
  113. package/src/utils/previewPrefetch.test.ts +22 -0
  114. package/src/utils/previewPrefetch.ts +14 -0
  115. package/src/utils/razorSplitTransaction.test.ts +0 -1
  116. package/src/utils/razorSplitTransaction.ts +1 -1
  117. package/src/utils/sdkEditTransaction.ts +0 -3
  118. package/src/utils/studioFileHistory.test.ts +0 -9
  119. package/src/utils/studioFileHistory.ts +1 -5
  120. package/src/utils/studioUiPreferences.test.ts +9 -3
  121. package/src/utils/studioUiPreferences.ts +0 -20
  122. package/src/utils/timelineCompositionInsert.ts +0 -1
  123. package/dist/assets/hyperframes-player-WbBVbWuO.js +0 -464
  124. package/dist/chunk-7BP32G62.js.map +0 -1
  125. package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +0 -60
  126. package/src/utils/editHistory.test.ts +0 -534
  127. package/src/utils/editHistory.ts +0 -236
  128. package/src/utils/editHistoryStorage.test.ts +0 -37
  129. package/src/utils/editHistoryStorage.ts +0 -99
  130. /package/dist/{domEditingLayers-UKMVYXT6.js.map → domEditingLayers-IGPLTI5E.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.75",
3
+ "version": "0.8.77",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -52,11 +52,11 @@
52
52
  "dockview-react": "8.3.1",
53
53
  "gsap": "^3.13.0",
54
54
  "mediabunny": "^1.45.3",
55
- "@hyperframes/core": "0.8.75",
56
- "@hyperframes/player": "0.8.75",
57
- "@hyperframes/parsers": "0.8.75",
58
- "@hyperframes/sdk": "0.8.75",
59
- "@hyperframes/studio-server": "0.8.75"
55
+ "@hyperframes/core": "0.8.77",
56
+ "@hyperframes/parsers": "0.8.77",
57
+ "@hyperframes/studio-server": "0.8.77",
58
+ "@hyperframes/player": "0.8.77",
59
+ "@hyperframes/sdk": "0.8.77"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@tailwindcss/vite": "^4.3.3",
@@ -72,7 +72,7 @@
72
72
  "vite": "^6.4.2",
73
73
  "vitest": "^4.1.11",
74
74
  "zustand": "^5.0.0",
75
- "@hyperframes/producer": "0.8.75"
75
+ "@hyperframes/producer": "0.8.77"
76
76
  },
77
77
  "peerDependencies": {
78
78
  "react": "19",
@@ -9,7 +9,6 @@
9
9
  import type { StudioEditPersistenceProps } from "./panels/VariablesPanel";
10
10
  import type { BlockParam } from "@hyperframes/core/registry";
11
11
  import type { Composition } from "@hyperframes/sdk";
12
- import type { EditHistoryKind } from "../utils/editHistory";
13
12
  import type { UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
14
13
  import type { AddMediaOverlayHandler } from "./editor/propertyPanelTypes";
15
14
  import type { ToggleHiddenHandler } from "../utils/studioHelpers";
@@ -46,7 +45,6 @@ export interface StudioRightPanelsProps extends StudioEditPersistenceProps {
46
45
  reloadPreview: () => void;
47
46
  recordEdit: (entry: {
48
47
  label: string;
49
- kind: EditHistoryKind;
50
48
  files: Record<string, { before: string; after: string }>;
51
49
  }) => Promise<void>;
52
50
  onToggleElementHidden?: ToggleHiddenHandler;
@@ -18,6 +18,7 @@ import {
18
18
  hoverCacheDescribesPoint,
19
19
  resolveResizeCenterAnchorOffset,
20
20
  } from "./domEditOverlayGestures";
21
+ import { usePlayerStore } from "../../player/store/playerStore";
21
22
 
22
23
  // React 19 warns unless the test environment opts into act().
23
24
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -242,6 +243,10 @@ function dispatchOverlayPointerDown(target: Element, clientX = 120, clientY = 80
242
243
  });
243
244
  }
244
245
 
246
+ beforeEach(() => {
247
+ usePlayerStore.setState({ previewBooted: true });
248
+ });
249
+
245
250
  describe("focusDomEditOverlayElement", () => {
246
251
  it("focuses the canvas overlay without scrolling", () => {
247
252
  const calls: Array<FocusOptions | undefined> = [];
@@ -3,6 +3,7 @@ import { memo, useState, useCallback, useEffect, useRef } from "react";
3
3
  import {
4
4
  collectDomEditLayerItems,
5
5
  getDomEditLayerKey,
6
+ liveLayerElement,
6
7
  resolveDomEditSelection,
7
8
  type DomEditLayerItem,
8
9
  } from "./domEditing";
@@ -190,20 +191,8 @@ export const LayersPanel = memo(function LayersPanel() {
190
191
 
191
192
  const resolveSelection = useCallback(
192
193
  (layer: DomEditLayerItem) => {
193
- // Re-find the element from the live DOM — layer.element may be stale
194
- // after soft reload (which replaces scripts without reloading the iframe).
195
- let el = layer.element;
196
- if (!el.isConnected) {
197
- const iframe = previewIframeRef.current;
198
- const doc = iframe?.contentDocument;
199
- if (doc) {
200
- const found =
201
- (layer.id ? doc.getElementById(layer.id) : null) ??
202
- (layer.hfId ? doc.querySelector(`[data-hf-id="${CSS.escape(layer.hfId)}"]`) : null) ??
203
- doc.getElementById(layer.key);
204
- if (found instanceof HTMLElement) el = found;
205
- }
206
- }
194
+ const doc = previewIframeRef.current?.contentDocument;
195
+ const el = liveLayerElement(layer, doc, activeCompPath);
207
196
  return resolveDomEditSelection(el, {
208
197
  activeCompositionPath: activeCompPath,
209
198
  isMasterView,
@@ -2,10 +2,11 @@
2
2
 
3
3
  import { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
6
  import { GridOverlay } from "./GridOverlay";
7
7
  import { PreviewOverlayProvider } from "./PreviewOverlayProvider";
8
8
  import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
9
+ import { usePlayerStore } from "../../player/store/playerStore";
9
10
 
10
11
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
11
12
 
@@ -27,6 +28,10 @@ function compositionFrame() {
27
28
  return iframe;
28
29
  }
29
30
 
31
+ beforeEach(() => {
32
+ usePlayerStore.setState({ previewBooted: true });
33
+ });
34
+
30
35
  describe("PreviewOverlayProvider", () => {
31
36
  it("derives grid geometry after the iframe prop becomes available", async () => {
32
37
  window.localStorage.setItem(
@@ -37,6 +37,7 @@ export {
37
37
  getDomEditNonEditableReason,
38
38
  getDomEditTargetKey,
39
39
  isTextEditableSelection,
40
+ liveLayerElement,
40
41
  readHfId,
41
42
  refreshDomEditSelection,
42
43
  resolveDomEditCapabilities,
@@ -314,6 +314,19 @@ export function findElementForSelection(
314
314
  return findAll(selection.selector)[selection.selectorIndex ?? 0] ?? null;
315
315
  }
316
316
 
317
+ // The selected node while mounted, else its copy in the selection's own file (ids repeat across files), else any copy.
318
+ export function findPreviewNode(
319
+ doc: Document | null | undefined,
320
+ el: DomEditSelection,
321
+ ): Element | null {
322
+ if (!doc) return null;
323
+ if (el.element?.isConnected && el.element.ownerDocument === doc) return el.element;
324
+ return (
325
+ findElementForSelection(doc, el) ??
326
+ findElementForSelection(doc, { ...el, sourceFile: undefined })
327
+ );
328
+ }
329
+
317
330
  // fallow-ignore-next-line complexity
318
331
  export function findElementForTimelineElement(
319
332
  doc: Document,
@@ -6,6 +6,7 @@ import {
6
6
  buildDomEditPatchTarget,
7
7
  buildTextFieldChildLocator,
8
8
  readHfId,
9
+ liveLayerElement,
9
10
  } from "./domEditingLayers";
10
11
  import type { DomEditTextField } from "./domEditingTypes";
11
12
 
@@ -305,3 +306,29 @@ describe("collectDomEditLayerItems selector-index cost", () => {
305
306
  expect(classSelectorQueries(12)).toBe(1);
306
307
  });
307
308
  });
309
+
310
+ describe("liveLayerElement", () => {
311
+ it("finds a replaced layer again in its own file when a sub-composition repeats its id", () => {
312
+ document.body.innerHTML =
313
+ '<div data-composition-id="main">' +
314
+ '<div data-composition-id="strip" data-composition-src="compositions/strip.html">' +
315
+ '<div id="card-1">strip</div></div><div id="card-1">root</div></div>';
316
+ const stale = document.createElement("div");
317
+ const layer = {
318
+ key: "index.html:card-1:0",
319
+ element: stale,
320
+ label: "card-1",
321
+ tagName: "div",
322
+ depth: 0,
323
+ childCount: 0,
324
+ id: "card-1",
325
+ sourceFile: "index.html",
326
+ };
327
+
328
+ expect(liveLayerElement(layer, document, "index.html").textContent).toBe("root");
329
+ expect(
330
+ liveLayerElement({ ...layer, sourceFile: "compositions/strip.html" }, document, "index.html")
331
+ .textContent,
332
+ ).toBe("strip");
333
+ });
334
+ });
@@ -491,6 +491,15 @@ export function collectDomEditLayerItems(
491
491
  return items;
492
492
  }
493
493
 
494
+ export function liveLayerElement(
495
+ layer: DomEditLayerItem,
496
+ doc: Document | null | undefined,
497
+ activeCompositionPath: string | null,
498
+ ): HTMLElement {
499
+ if (layer.element.isConnected || !doc) return layer.element;
500
+ return findElementForSelection(doc, layer, activeCompositionPath) ?? layer.element;
501
+ }
502
+
494
503
  // ─── Patch operations ────────────────────────────────────────────────────────
495
504
 
496
505
  export function buildDomEditStylePatchOperation(
@@ -0,0 +1,63 @@
1
+ // @vitest-environment happy-dom
2
+ import { expect, it, vi } from "vitest";
3
+ import { createGsapLivePreview } from "./gsapLivePreview";
4
+ import type { DomEditSelection } from "./domEditingTypes";
5
+ import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
6
+ import { readGsapRuntimeValuesForPanel } from "./propertyPanelHelpers";
7
+
8
+ it("previews on the selected element, not an earlier same-id copy in a sub-composition", () => {
9
+ document.body.innerHTML =
10
+ '<div data-composition-id="strip"><div id="card" data-hf-id="hf-inner"></div></div>' +
11
+ '<div id="card" data-hf-id="hf-root"></div>';
12
+ const set = vi.fn();
13
+ const iframe = { contentWindow: { gsap: { set } }, contentDocument: document };
14
+ const preview = createGsapLivePreview({ current: iframe as unknown as HTMLIFrameElement });
15
+ preview({ id: "card", hfId: "hf-root" } as DomEditSelection, { x: 10 });
16
+ expect(set.mock.calls[0]?.[0]).toBe(document.querySelector('[data-hf-id="hf-root"]'));
17
+ });
18
+
19
+ it("previews on the copy in the selection's own file when a sub-composition repeats its hf-id", () => {
20
+ document.body.innerHTML =
21
+ '<div data-composition-id="strip" data-composition-src="compositions/strip.html">' +
22
+ '<div id="card" data-hf-id="hf-card"></div></div><div id="card" data-hf-id="hf-card" class="root"></div>';
23
+ const set = vi.fn();
24
+ const iframe = { contentWindow: { gsap: { set } }, contentDocument: document };
25
+ const preview = createGsapLivePreview({ current: iframe as unknown as HTMLIFrameElement });
26
+ preview({ id: "card", hfId: "hf-card", sourceFile: "index.html" } as DomEditSelection, { x: 10 });
27
+ expect(set.mock.calls[0]?.[0]).toBe(document.querySelector(".root"));
28
+ });
29
+
30
+ it("previews on the selected node while it is mounted, even in a second mount of the same sub-composition", () => {
31
+ document.body.innerHTML =
32
+ '<div data-composition-id="strip" data-composition-src="compositions/strip.html"><div id="card" data-hf-id="hf-card"></div></div>' +
33
+ '<div data-composition-id="strip" data-composition-src="compositions/strip.html"><div id="card" data-hf-id="hf-card"></div></div>';
34
+ const second = document.querySelectorAll<HTMLElement>("#card")[1];
35
+ const selection = {
36
+ id: "card",
37
+ hfId: "hf-card",
38
+ sourceFile: "compositions/strip.html",
39
+ element: second,
40
+ };
41
+ const set = vi.fn();
42
+ const iframe = { contentWindow: { gsap: { set } }, contentDocument: document };
43
+ createGsapLivePreview({ current: iframe as unknown as HTMLIFrameElement })(
44
+ selection as DomEditSelection,
45
+ { x: 10 },
46
+ );
47
+ expect(set.mock.calls[0]?.[0]).toBe(second);
48
+ });
49
+
50
+ it("the panel reads GSAP values off the node the live preview moves", () => {
51
+ document.body.innerHTML =
52
+ '<div data-composition-id="strip" data-composition-src="compositions/strip.html"><div id="card"></div></div>' +
53
+ '<div id="card" class="root"></div>';
54
+ const root = document.querySelector<HTMLElement>(".root");
55
+ const getProperty = vi.fn(() => 5);
56
+ const iframe = { contentWindow: { gsap: { getProperty } }, contentDocument: document };
57
+ const selection = { id: "card", sourceFile: "index.html", element: root } as DomEditSelection;
58
+ const animations = [{ properties: { x: 5 } }] as unknown as GsapAnimation[];
59
+ readGsapRuntimeValuesForPanel("anim", animations, selection, {
60
+ current: iframe as unknown as HTMLIFrameElement,
61
+ });
62
+ expect(getProperty.mock.calls[0]?.[0]).toBe(root);
63
+ });
@@ -1,4 +1,5 @@
1
1
  import type { DomEditSelection } from "./domEditingTypes";
2
+ import { findPreviewNode } from "./domEditingElement";
2
3
 
3
4
  /**
4
5
  * Build the "live preview" callback the 3D-transform sub-view fires while a
@@ -8,19 +9,6 @@ import type { DomEditSelection } from "./domEditingTypes";
8
9
  * Extracted so the identical closure exists once — shared by the legacy
9
10
  * PropertyPanel Layout section and the flat Layout group (PropertyPanelFlat).
10
11
  */
11
- // Resolve by id when unique, otherwise by selector + selectorIndex — a bare
12
- // querySelector(selector) always hits the FIRST match, so dragging on the
13
- // second of two same-selector siblings would animate the wrong element.
14
- function resolvePreviewNode(
15
- doc: Document | null | undefined,
16
- el: DomEditSelection,
17
- ): Element | null {
18
- if (!doc) return null;
19
- if (el.id) return doc.querySelector(`#${el.id}`);
20
- if (!el.selector) return null;
21
- return doc.querySelectorAll(el.selector)[el.selectorIndex ?? 0] ?? null;
22
- }
23
-
24
12
  export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameElement | null }) {
25
13
  return (el: DomEditSelection, props: Record<string, number>) => {
26
14
  const iframe = iframeRef.current;
@@ -28,7 +16,7 @@ export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameE
28
16
  | { gsap?: { set: (t: Element, v: Record<string, number>) => void } }
29
17
  | null
30
18
  | undefined;
31
- const node = resolvePreviewNode(iframe?.contentDocument, el);
19
+ const node = findPreviewNode(iframe?.contentDocument, el);
32
20
  if (win?.gsap && node) win.gsap.set(node, props);
33
21
  };
34
22
  }
@@ -2,9 +2,10 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
- import { afterAll, beforeAll, describe, expect, it } from "vitest";
5
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { DomEditOverlay } from "./DomEditOverlay";
7
7
  import { RECOMPUTE_INTERVAL_MS } from "./offCanvasIndicatorRefresh";
8
+ import { usePlayerStore } from "../../player/store/playerStore";
8
9
 
9
10
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
10
11
 
@@ -59,6 +60,30 @@ async function flushAnimationFrames(): Promise<void> {
59
60
  });
60
61
  }
61
62
 
63
+ // The refresh waits on MutationObserver delivery and then on its own throttle, so no fixed
64
+ // number of frames is guaranteed to be enough on a loaded runner. Flush until the indicator
65
+ // reaches the state under test.
66
+ async function untilIndicator(host: HTMLElement, check: (indicator: Element | null) => void) {
67
+ await vi.waitFor(
68
+ async () => {
69
+ await act(async () => {
70
+ await flushAnimationFrames();
71
+ });
72
+ check(host.querySelector(INDICATOR));
73
+ },
74
+ { timeout: 4000, interval: 0 },
75
+ );
76
+ }
77
+
78
+ // Delivers each batch of mutation records `ms` late, as a busy runner can.
79
+ function lateObserver(Base: typeof MutationObserver, ms: number): typeof MutationObserver {
80
+ return class extends Base {
81
+ constructor(callback: MutationCallback) {
82
+ super((records, observer) => setTimeout(() => callback(records, observer), ms));
83
+ }
84
+ };
85
+ }
86
+
62
87
  interface OverlayHarness {
63
88
  host: HTMLElement;
64
89
  movedElement: HTMLElement;
@@ -68,7 +93,7 @@ interface OverlayHarness {
68
93
  // Mount DomEditOverlay over an iframe whose #headline sits at `initialLeft`, with a
69
94
  // getBoundingClientRect stub that reads the element's live inline geometry (so a
70
95
  // style mutation moves it) and reports the composition/overlay as 800x450 at origin.
71
- function mountOverlayWithHeadline(initialLeft: number): OverlayHarness {
96
+ function mountOverlayWithHeadline(initialLeft: number, observerDelayMs = 0): OverlayHarness {
72
97
  const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect;
73
98
  const host = document.createElement("div");
74
99
  document.body.append(host);
@@ -85,6 +110,9 @@ function mountOverlayWithHeadline(initialLeft: number): OverlayHarness {
85
110
  `;
86
111
  const movedElement = doc.getElementById("headline");
87
112
  if (!movedElement) throw new Error("Expected test element");
113
+ const view = doc.defaultView?.MutationObserver ? doc.defaultView : globalThis;
114
+ const RealObserver = view.MutationObserver;
115
+ if (observerDelayMs > 0) view.MutationObserver = lateObserver(RealObserver, observerDelayMs);
88
116
 
89
117
  Element.prototype.getBoundingClientRect = function (): DOMRect {
90
118
  if (this === movedElement) {
@@ -125,58 +153,52 @@ function mountOverlayWithHeadline(initialLeft: number): OverlayHarness {
125
153
  cleanup: () => {
126
154
  act(() => root.unmount());
127
155
  Element.prototype.getBoundingClientRect = originalGetBoundingClientRect;
156
+ view.MutationObserver = RealObserver;
128
157
  iframe.remove();
129
158
  host.remove();
130
159
  },
131
160
  };
132
161
  }
133
162
 
134
- describe("off-canvas indicator refresh", () => {
135
- it("removes the indicator when an off-canvas element moves in-canvas (off->on)", async () => {
136
- const h = mountOverlayWithHeadline(760);
137
- try {
138
- await act(async () => {
139
- await flushAnimationFrames();
140
- });
141
- expect(h.host.querySelector(INDICATOR)).toBeTruthy();
142
-
143
- act(() => {
144
- h.movedElement.style.left = "120px";
145
- });
146
- await act(async () => {
147
- await Promise.resolve();
148
- await flushAnimationFrames();
149
- });
163
+ beforeEach(() => {
164
+ usePlayerStore.setState({ previewBooted: true });
165
+ });
150
166
 
151
- expect(h.host.querySelector(INDICATOR)).toBeNull();
152
- } finally {
153
- h.cleanup();
154
- }
155
- });
167
+ describe("off-canvas indicator refresh", () => {
168
+ for (const [label, delayMs] of [
169
+ ["", 0],
170
+ [" when mutation delivery lags past a fixed wait", 300],
171
+ ] as const) {
172
+ it(`removes the indicator when an off-canvas element moves in-canvas (off->on)${label}`, async () => {
173
+ const h = mountOverlayWithHeadline(760, delayMs);
174
+ try {
175
+ await untilIndicator(h.host, (indicator) => expect(indicator).toBeTruthy());
176
+
177
+ act(() => {
178
+ h.movedElement.style.left = "120px";
179
+ });
180
+ await untilIndicator(h.host, (indicator) => expect(indicator).toBeNull());
181
+ } finally {
182
+ h.cleanup();
183
+ }
184
+ });
185
+ }
156
186
 
157
187
  it("tracks the indicator to the new position when it stays off-canvas (off->off)", async () => {
158
188
  const h = mountOverlayWithHeadline(760);
159
189
  try {
160
- await act(async () => {
161
- await flushAnimationFrames();
162
- });
190
+ await untilIndicator(h.host, (indicator) => expect(indicator).toBeTruthy());
163
191
  const before = h.host.querySelector(INDICATOR);
164
- expect(before).toBeTruthy();
165
192
  const leftBefore = (before!.parentElement as HTMLElement).style.left;
166
193
 
167
194
  // Move further off-canvas (still outside the 800px-wide composition).
168
195
  act(() => {
169
196
  h.movedElement.style.left = "1200px";
170
197
  });
171
- await act(async () => {
172
- await Promise.resolve();
173
- await flushAnimationFrames();
198
+ await untilIndicator(h.host, (indicator) => {
199
+ expect(indicator).toBeTruthy();
200
+ expect((indicator!.parentElement as HTMLElement).style.left).not.toEqual(leftBefore);
174
201
  });
175
-
176
- const after = h.host.querySelector(INDICATOR);
177
- expect(after).toBeTruthy();
178
- const leftAfter = (after!.parentElement as HTMLElement).style.left;
179
- expect(leftAfter).not.toEqual(leftBefore);
180
202
  } finally {
181
203
  h.cleanup();
182
204
  }
@@ -7,6 +7,7 @@ import {
7
7
  resetOverlayFrameLoopForTests,
8
8
  subscribeOverlayFrame,
9
9
  } from "./overlayFrameLoop";
10
+ import { usePlayerStore } from "../../player/store/playerStore";
10
11
 
11
12
  /**
12
13
  * The editor's overlay polls run on one shared, parkable frame loop. The two
@@ -37,6 +38,7 @@ describe("overlay frame loop", () => {
37
38
  };
38
39
 
39
40
  beforeEach(() => {
41
+ usePlayerStore.setState({ previewBooted: true });
40
42
  vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance", "Date"] });
41
43
  queued = [];
42
44
  window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
@@ -55,6 +57,20 @@ describe("overlay frame loop", () => {
55
57
  window.cancelAnimationFrame = originalCancelRaf;
56
58
  });
57
59
 
60
+ it("does not poll the preview before it boots, and wakes when it does", () => {
61
+ usePlayerStore.setState({ previewBooted: false });
62
+ let runs = 0;
63
+ subscribeOverlayFrame(() => {
64
+ runs += 1;
65
+ });
66
+ framesOver(1000);
67
+ expect(runs).toBe(0);
68
+
69
+ usePlayerStore.getState().markPreviewBooted();
70
+ framesOver(32);
71
+ expect(runs).toBeGreaterThan(0);
72
+ });
73
+
58
74
  it("runs every subscriber on one frame, not one frame each", () => {
59
75
  const calls: string[] = [];
60
76
  subscribeOverlayFrame(() => calls.push("a"));
@@ -25,6 +25,8 @@
25
25
  * the next couple of hundred milliseconds, and no further event is dispatched
26
26
  * while it does.
27
27
  */
28
+ import { usePlayerStore } from "../../player/store/playerStore";
29
+
28
30
  const AWAKE_MS = 400;
29
31
  /** How often a parked loop runs one frame anyway, in case a wake was missed. */
30
32
  export const IDLE_POLL_MS = 250;
@@ -35,6 +37,7 @@ let frameId: number | null = null;
35
37
  let idleTimerId: ReturnType<typeof setTimeout> | null = null;
36
38
  let awakeUntil = 0;
37
39
  let listenersAttached = false;
40
+ let stopBootWake: (() => void) | null = null;
38
41
 
39
42
  /** Wake sources. All passive reads; none of them can be cancelled by us. */
40
43
  const WINDOW_EVENTS = [
@@ -111,6 +114,9 @@ function runFrame(): void {
111
114
  // it — and a shared loop makes that failure four overlays wide plus the
112
115
  // idle-poll safety net, permanently, rather than one overlay's own problem.
113
116
  schedule();
117
+ // Nothing to track until the live preview boots, and a poll before then forces layout
118
+ // on a document that is still loading, on the thread the boot runs on.
119
+ if (!usePlayerStore.getState().previewBooted) return;
114
120
  for (const subscriber of subscribers) {
115
121
  try {
116
122
  subscriber();
@@ -154,6 +160,9 @@ function attachListeners(): void {
154
160
  window.addEventListener(type, requestOverlayFrames, { capture: true, passive: true });
155
161
  }
156
162
  window.addEventListener("message", onPreviewMessage, { capture: true, passive: true });
163
+ stopBootWake = usePlayerStore.subscribe((state, prev) => {
164
+ if (state.previewBooted && !prev.previewBooted) requestOverlayFrames();
165
+ });
157
166
  }
158
167
 
159
168
  function detachListeners(): void {
@@ -163,6 +172,8 @@ function detachListeners(): void {
163
172
  window.removeEventListener(type, requestOverlayFrames, { capture: true });
164
173
  }
165
174
  window.removeEventListener("message", onPreviewMessage, { capture: true });
175
+ stopBootWake?.();
176
+ stopBootWake = null;
166
177
  }
167
178
 
168
179
  /**
@@ -4,6 +4,7 @@ import type { DomEditSelection } from "./domEditing";
4
4
  import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
5
5
  import type { TimelineElement } from "../../player";
6
6
  import { roundToCenti } from "../../utils/rounding";
7
+ import { findPreviewNode } from "./domEditingElement";
7
8
 
8
9
  export type {
9
10
  BackgroundRemovalProgress,
@@ -470,8 +471,6 @@ export function readGsapRuntimeValuesForPanel(
470
471
  if (!gsapAnimId || gsapAnimations.length === 0) return null;
471
472
  const iframe = previewIframeRef?.current;
472
473
  if (!iframe?.contentWindow) return null;
473
- const selector = element.id ? `#${element.id}` : element.selector;
474
- if (!selector) return null;
475
474
  try {
476
475
  const gsap = (
477
476
  iframe.contentWindow as unknown as {
@@ -479,7 +478,7 @@ export function readGsapRuntimeValuesForPanel(
479
478
  }
480
479
  ).gsap;
481
480
  if (!gsap?.getProperty) return null;
482
- const el = iframe.contentDocument?.querySelector(selector);
481
+ const el = findPreviewNode(iframe.contentDocument, element);
483
482
  if (!el) return null;
484
483
  const propKeys = collectPanelPropKeys(gsapAnimations);
485
484
  const result: Record<string, number> = {};
@@ -508,10 +507,9 @@ export function readGsapBorderRadiusForPanel(
508
507
  if (!hasBRProp) return null;
509
508
  }
510
509
  const iframe = previewIframeRef?.current;
511
- const selector = element.id ? `#${element.id}` : element.selector;
512
- if (!iframe?.contentDocument || !selector) return null;
510
+ if (!iframe?.contentDocument) return null;
513
511
  try {
514
- const el = iframe.contentDocument.querySelector(selector);
512
+ const el = findPreviewNode(iframe.contentDocument, element);
515
513
  if (!el || !iframe.contentWindow) return null;
516
514
  const cs = iframe.contentWindow.getComputedStyle(el);
517
515
  const parse = (v: string) => Number.parseFloat(v) || 0;
@@ -2,12 +2,13 @@
2
2
 
3
3
  import { act, useRef, useState } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
6
  import { resetOverlayFrameLoopForTests } from "./overlayFrameLoop";
7
7
  import {
8
8
  usePreviewCompositionRect,
9
9
  type PreviewCompositionRect,
10
10
  } from "./usePreviewCompositionRect";
11
+ import { usePlayerStore } from "../../player/store/playerStore";
11
12
 
12
13
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
14
 
@@ -59,6 +60,10 @@ function compositionFrame(box: [number, number, number, number], width: number,
59
60
 
60
61
  const settle = () => new Promise((r) => setTimeout(r, 400));
61
62
 
63
+ beforeEach(() => {
64
+ usePlayerStore.setState({ previewBooted: true });
65
+ });
66
+
62
67
  describe("usePreviewCompositionRect", () => {
63
68
  it("reports the live preview iframe's box relative to the overlay, scaled to the composition", async () => {
64
69
  const probe = mountProbe();