@hyperframes/studio 0.7.54 → 0.7.56

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 (262) hide show
  1. package/dist/assets/{index-uBY329wb.js → index-BWnOxAiH.js} +1 -1
  2. package/dist/assets/{index-CMHYjEZ5.js → index-C4csZims.js} +1 -1
  3. package/dist/assets/index-CmVCjZjp.js +423 -0
  4. package/dist/assets/index-D-GyYi2d.css +1 -0
  5. package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
  6. package/dist/chunk-5QSIMBEJ.js.map +1 -0
  7. package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
  8. package/dist/index.d.ts +146 -144
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +40582 -37803
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -7
  13. package/src/App.tsx +106 -96
  14. package/src/components/EditorShell.tsx +253 -0
  15. package/src/components/StudioGlobalDragOverlay.tsx +1 -3
  16. package/src/components/StudioLeftSidebar.tsx +13 -3
  17. package/src/components/StudioRightPanel.tsx +8 -3
  18. package/src/components/TimelineToolbar.test.tsx +2 -2
  19. package/src/components/TimelineToolbar.tsx +179 -121
  20. package/src/components/editor/CanvasContextMenu.test.tsx +115 -0
  21. package/src/components/editor/CanvasContextMenu.tsx +198 -0
  22. package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
  23. package/src/components/editor/DomEditCropHandles.tsx +64 -29
  24. package/src/components/editor/DomEditOverlay.test.ts +245 -279
  25. package/src/components/editor/DomEditOverlay.tsx +158 -126
  26. package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
  27. package/src/components/editor/DomEditRotateHandle.tsx +38 -18
  28. package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
  29. package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
  30. package/src/components/editor/LayersPanel.test.ts +66 -2
  31. package/src/components/editor/LayersPanel.tsx +43 -1
  32. package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
  33. package/src/components/editor/OffCanvasIndicators.tsx +49 -10
  34. package/src/components/editor/SnapGuideOverlay.tsx +28 -23
  35. package/src/components/editor/SnapToolbar.test.tsx +0 -1
  36. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
  37. package/src/components/editor/anchoredResizeReleaseShift.test.ts +208 -0
  38. package/src/components/editor/canvasContextMenuZOrder.test.ts +435 -0
  39. package/src/components/editor/canvasContextMenuZOrder.ts +352 -0
  40. package/src/components/editor/domEditNudge.test.ts +80 -0
  41. package/src/components/editor/domEditNudge.ts +44 -0
  42. package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
  43. package/src/components/editor/domEditOverlayGeometry.ts +280 -20
  44. package/src/components/editor/domEditOverlayGestures.ts +44 -41
  45. package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
  46. package/src/components/editor/domEditResizeLocal.test.ts +131 -0
  47. package/src/components/editor/domEditResizeLocal.ts +125 -0
  48. package/src/components/editor/domEditingDom.ts +1 -1
  49. package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
  50. package/src/components/editor/manualOffsetDrag.ts +55 -12
  51. package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
  52. package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
  53. package/src/components/editor/resizeDraft.ts +108 -0
  54. package/src/components/editor/snapEngine.test.ts +12 -78
  55. package/src/components/editor/snapEngine.ts +13 -53
  56. package/src/components/editor/useDomEditNudge.test.tsx +228 -0
  57. package/src/components/editor/useDomEditNudge.ts +254 -0
  58. package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
  59. package/src/components/editor/useDomEditOverlayRects.ts +9 -2
  60. package/src/components/nle/AssetPreviewOverlay.tsx +147 -0
  61. package/src/components/nle/NLEContext.test.ts +144 -0
  62. package/src/components/nle/NLEContext.tsx +328 -0
  63. package/src/components/nle/NLEPreview.tsx +1 -1
  64. package/src/components/nle/PreviewOverlays.tsx +240 -0
  65. package/src/components/nle/PreviewPane.tsx +163 -0
  66. package/src/components/nle/TimelinePane.test.ts +60 -0
  67. package/src/components/nle/TimelinePane.tsx +293 -0
  68. package/src/components/nle/TimelineResizeDivider.tsx +8 -2
  69. package/src/components/nle/useCompositionStack.test.tsx +44 -0
  70. package/src/components/nle/useCompositionStack.ts +17 -9
  71. package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
  72. package/src/components/sidebar/AssetCard.tsx +326 -0
  73. package/src/components/sidebar/AssetContextMenu.tsx +15 -30
  74. package/src/components/sidebar/AssetsTab.test.ts +96 -0
  75. package/src/components/sidebar/AssetsTab.tsx +71 -206
  76. package/src/components/sidebar/AudioRow.tsx +39 -2
  77. package/src/components/sidebar/BlocksTab.tsx +8 -1
  78. package/src/components/sidebar/LeftSidebar.tsx +4 -1
  79. package/src/components/sidebar/assetHelpers.ts +29 -0
  80. package/src/contexts/DomEditContext.tsx +9 -0
  81. package/src/contexts/StudioContext.tsx +9 -8
  82. package/src/contexts/TimelineEditContext.tsx +3 -7
  83. package/src/hooks/deleteSelectedKeyframes.ts +35 -0
  84. package/src/hooks/domEditCommitTypes.ts +12 -1
  85. package/src/hooks/domSelectionTestHarness.ts +14 -0
  86. package/src/hooks/gestureTransaction.test.ts +311 -0
  87. package/src/hooks/gestureTransaction.ts +199 -0
  88. package/src/hooks/gsapDragCommit.test.ts +141 -41
  89. package/src/hooks/gsapDragCommit.ts +64 -74
  90. package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
  91. package/src/hooks/gsapResizeIntercept.test.ts +69 -0
  92. package/src/hooks/gsapResizeIntercept.ts +38 -10
  93. package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
  94. package/src/hooks/gsapRuntimeBridge.ts +4 -4
  95. package/src/hooks/gsapScriptCommitTypes.ts +16 -5
  96. package/src/hooks/gsapShared.test.ts +19 -1
  97. package/src/hooks/gsapShared.ts +14 -0
  98. package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
  99. package/src/hooks/timelineEditingGsap.ts +14 -0
  100. package/src/hooks/timelineEditingHelpers.test.ts +34 -1
  101. package/src/hooks/timelineEditingHelpers.ts +22 -48
  102. package/src/hooks/timelineMoveAdapter.test.ts +77 -0
  103. package/src/hooks/timelineMoveAdapter.ts +37 -0
  104. package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
  105. package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
  106. package/src/hooks/useAppHotkeys.ts +16 -25
  107. package/src/hooks/useBlockHandlers.ts +55 -27
  108. package/src/hooks/useContextMenuDismiss.ts +31 -7
  109. package/src/hooks/useDomEditCommits.test.tsx +154 -1
  110. package/src/hooks/useDomEditCommits.ts +100 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  112. package/src/hooks/useDomEditSession.ts +2 -2
  113. package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
  114. package/src/hooks/useDomEditTextCommits.ts +8 -3
  115. package/src/hooks/useDomEditWiring.ts +7 -9
  116. package/src/hooks/useDomGeometryCommits.ts +17 -2
  117. package/src/hooks/useDomSelection.test.ts +2 -36
  118. package/src/hooks/useDomSelection.ts +81 -51
  119. package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
  120. package/src/hooks/useElementLifecycleOps.test.tsx +258 -0
  121. package/src/hooks/useElementLifecycleOps.ts +40 -71
  122. package/src/hooks/useEnableKeyframes.test.ts +142 -1
  123. package/src/hooks/useEnableKeyframes.ts +73 -29
  124. package/src/hooks/useGestureCommit.test.tsx +130 -0
  125. package/src/hooks/useGestureCommit.ts +32 -8
  126. package/src/hooks/useGroupCommits.ts +7 -28
  127. package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
  128. package/src/hooks/useGsapAwareEditing.ts +115 -20
  129. package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
  130. package/src/hooks/useGsapKeyframeOps.ts +38 -10
  131. package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
  132. package/src/hooks/useGsapScriptCommits.ts +134 -50
  133. package/src/hooks/useGsapSelectionHandlers.ts +49 -16
  134. package/src/hooks/useMusicBeatAnalysis.ts +72 -36
  135. package/src/hooks/usePersistentEditHistory.test.ts +33 -0
  136. package/src/hooks/usePersistentEditHistory.ts +92 -58
  137. package/src/hooks/usePreviewPersistence.ts +25 -7
  138. package/src/hooks/useRazorSplit.history.test.tsx +303 -0
  139. package/src/hooks/useRazorSplit.test.ts +269 -0
  140. package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
  141. package/src/hooks/useRazorSplit.ts +107 -36
  142. package/src/hooks/useRenderClipContent.ts +24 -6
  143. package/src/hooks/useStudioContextValue.ts +15 -5
  144. package/src/hooks/useStudioUrlState.ts +2 -4
  145. package/src/hooks/useTimelineEditing.test.tsx +20 -29
  146. package/src/hooks/useTimelineEditingTypes.ts +6 -0
  147. package/src/hooks/useTimelineGroupEditing.ts +18 -4
  148. package/src/index.ts +1 -1
  149. package/src/player/components/ImageThumbnail.test.tsx +173 -0
  150. package/src/player/components/ImageThumbnail.tsx +160 -0
  151. package/src/player/components/PlayheadIndicator.tsx +50 -5
  152. package/src/player/components/Timeline.test.ts +102 -19
  153. package/src/player/components/Timeline.tsx +194 -194
  154. package/src/player/components/TimelineCanvas.tsx +207 -521
  155. package/src/player/components/TimelineClip.tsx +4 -1
  156. package/src/player/components/TimelineClipDiamonds.tsx +9 -40
  157. package/src/player/components/TimelineEmptyState.tsx +3 -1
  158. package/src/player/components/TimelineLanes.tsx +487 -0
  159. package/src/player/components/TimelineOverlays.tsx +122 -0
  160. package/src/player/components/VideoThumbnail.test.tsx +152 -0
  161. package/src/player/components/VideoThumbnail.tsx +28 -7
  162. package/src/player/components/thumbnailUtils.test.ts +127 -0
  163. package/src/player/components/thumbnailUtils.ts +54 -0
  164. package/src/player/components/timelineCallbacks.ts +18 -18
  165. package/src/player/components/timelineClipChildren.tsx +39 -0
  166. package/src/player/components/timelineClipDragCommit.test.ts +943 -0
  167. package/src/player/components/timelineClipDragCommit.ts +401 -0
  168. package/src/player/components/timelineClipDragPreview.test.ts +144 -0
  169. package/src/player/components/timelineClipDragPreview.ts +323 -38
  170. package/src/player/components/timelineClipDragTypes.ts +65 -0
  171. package/src/player/components/timelineCollision.test.ts +477 -0
  172. package/src/player/components/timelineCollision.ts +263 -0
  173. package/src/player/components/timelineDragDrop.ts +69 -60
  174. package/src/player/components/timelineEditCapabilities.ts +52 -0
  175. package/src/player/components/timelineEditing.test.ts +1 -1
  176. package/src/player/components/timelineEditing.ts +76 -59
  177. package/src/player/components/timelineGroupEditing.test.ts +126 -0
  178. package/src/player/components/timelineGroupEditing.ts +133 -0
  179. package/src/player/components/timelineGroupResizeCommit.ts +54 -0
  180. package/src/player/components/timelineLayout.test.ts +95 -23
  181. package/src/player/components/timelineLayout.ts +120 -61
  182. package/src/player/components/timelineMarquee.test.ts +197 -0
  183. package/src/player/components/timelineMarquee.ts +112 -0
  184. package/src/player/components/timelineMultiDragPreview.test.ts +127 -0
  185. package/src/player/components/timelineMultiDragPreview.ts +106 -0
  186. package/src/player/components/timelineOptimisticRevision.ts +42 -0
  187. package/src/player/components/timelineSnapping.test.ts +134 -0
  188. package/src/player/components/timelineSnapping.ts +110 -0
  189. package/src/player/components/timelineStackingSync.test.ts +244 -0
  190. package/src/player/components/timelineStackingSync.ts +331 -0
  191. package/src/player/components/timelineTheme.ts +6 -1
  192. package/src/player/components/timelineZones.test.ts +425 -0
  193. package/src/player/components/timelineZones.ts +198 -0
  194. package/src/player/components/timelineZoom.test.ts +67 -0
  195. package/src/player/components/timelineZoom.ts +51 -0
  196. package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
  197. package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
  198. package/src/player/components/useTimelineClipDrag.ts +380 -386
  199. package/src/player/components/useTimelineEditPinning.ts +121 -0
  200. package/src/player/components/useTimelineGeometry.ts +129 -0
  201. package/src/player/components/useTimelinePlayhead.ts +4 -16
  202. package/src/player/components/useTimelineRangeSelection.ts +341 -50
  203. package/src/player/components/useTimelineStackingSync.test.tsx +67 -0
  204. package/src/player/components/useTimelineStackingSync.ts +83 -0
  205. package/src/player/hooks/useExpandedTimelineElements.ts +0 -3
  206. package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
  207. package/src/player/hooks/useTimelinePlayer.ts +48 -53
  208. package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
  209. package/src/player/hooks/useTimelineSyncCallbacks.test.ts +219 -0
  210. package/src/player/hooks/useTimelineSyncCallbacks.ts +104 -4
  211. package/src/player/index.ts +3 -2
  212. package/src/player/lib/time.test.ts +19 -19
  213. package/src/player/lib/time.ts +6 -2
  214. package/src/player/lib/timelineDOM.test.ts +25 -98
  215. package/src/player/lib/timelineDOM.ts +12 -81
  216. package/src/player/lib/timelineElementHelpers.ts +54 -5
  217. package/src/player/lib/timelineIframeHelpers.ts +2 -0
  218. package/src/player/store/playerStore.ts +62 -3
  219. package/src/styles/studio.css +27 -0
  220. package/src/utils/assetClickBehavior.test.ts +104 -0
  221. package/src/utils/assetClickBehavior.ts +75 -0
  222. package/src/utils/assetPreviewStore.ts +33 -0
  223. package/src/utils/blockInstaller.ts +10 -2
  224. package/src/utils/canvasNudgeGate.test.ts +31 -0
  225. package/src/utils/canvasNudgeGate.ts +32 -0
  226. package/src/utils/editHistory.test.ts +35 -0
  227. package/src/utils/gsapSoftReload.test.ts +119 -1
  228. package/src/utils/gsapSoftReload.ts +153 -0
  229. package/src/utils/mediaTypes.ts +3 -2
  230. package/src/utils/resizeDebug.ts +55 -0
  231. package/src/utils/rootDuration.test.ts +90 -1
  232. package/src/utils/rootDuration.ts +76 -13
  233. package/src/utils/sdkCutover.ts +4 -0
  234. package/src/utils/studioFileHistory.ts +37 -7
  235. package/src/utils/studioHelpers.ts +62 -0
  236. package/src/utils/studioPreviewHelpers.test.ts +65 -8
  237. package/src/utils/studioPreviewHelpers.ts +10 -0
  238. package/src/utils/studioTelemetry.ts +4 -1
  239. package/src/utils/studioUiPreferences.test.ts +38 -0
  240. package/src/utils/studioUiPreferences.ts +27 -0
  241. package/src/utils/studioUrlState.test.ts +0 -1
  242. package/src/utils/timelineAssetDrop.ts +62 -0
  243. package/src/utils/timelineDiscovery.ts +0 -17
  244. package/src/utils/timelineInspector.test.ts +121 -0
  245. package/src/utils/timelineInspector.ts +32 -1
  246. package/dist/assets/index-Dq7FEg0K.css +0 -1
  247. package/dist/assets/index-pRhCpGPz.js +0 -423
  248. package/dist/chunk-SOTCF4DF.js.map +0 -1
  249. package/src/components/StudioPreviewArea.tsx +0 -500
  250. package/src/components/nle/NLELayout.test.ts +0 -12
  251. package/src/components/nle/NLELayout.tsx +0 -591
  252. package/src/player/components/TimelineLayerGutter.tsx +0 -61
  253. package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
  254. package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
  255. package/src/player/components/timelineSnapTargets.test.ts +0 -144
  256. package/src/player/components/timelineSnapTargets.ts +0 -164
  257. package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
  258. package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
  259. package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
  260. package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
  261. package/src/utils/timelineDiscovery.test.ts +0 -90
  262. /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
@@ -0,0 +1,135 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { DomEditSelection, DomEditTextField } from "../components/editor/domEditing";
5
+ import { mountReactHarness } from "./domSelectionTestHarness";
6
+ import { useDomEditTextCommits, type UseDomEditTextCommitsParams } from "./useDomEditTextCommits";
7
+
8
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
9
+
10
+ interface Deferred<T> {
11
+ promise: Promise<T>;
12
+ resolve: (value: T | PromiseLike<T>) => void;
13
+ reject: (reason?: unknown) => void;
14
+ }
15
+
16
+ function createDeferred<T>(): Deferred<T> {
17
+ let resolve: Deferred<T>["resolve"] | undefined;
18
+ let reject: Deferred<T>["reject"] | undefined;
19
+ const promise = new Promise<T>((resolvePromise, rejectPromise) => {
20
+ resolve = resolvePromise;
21
+ reject = rejectPromise;
22
+ });
23
+ if (!resolve || !reject) throw new Error("deferred callbacks were not initialized");
24
+ return { promise, resolve, reject };
25
+ }
26
+
27
+ function textField(value: string): DomEditTextField {
28
+ return {
29
+ key: "self",
30
+ label: "Text",
31
+ value,
32
+ tagName: "div",
33
+ attributes: [],
34
+ inlineStyles: {},
35
+ computedStyles: {},
36
+ source: "self",
37
+ };
38
+ }
39
+
40
+ function selectionFor(element: HTMLElement): DomEditSelection {
41
+ return {
42
+ id: element.id,
43
+ element,
44
+ label: "Card",
45
+ tagName: "div",
46
+ sourceFile: "index.html",
47
+ compositionPath: "index.html",
48
+ isCompositionHost: false,
49
+ isInsideLockedComposition: false,
50
+ boundingBox: { x: 0, y: 0, width: 100, height: 100 },
51
+ textContent: element.textContent,
52
+ dataAttributes: {},
53
+ inlineStyles: {},
54
+ computedStyles: {},
55
+ textFields: [textField(element.textContent ?? "")],
56
+ capabilities: {
57
+ canSelect: true,
58
+ canEditStyles: true,
59
+ canCrop: true,
60
+ canMove: true,
61
+ canResize: true,
62
+ canApplyManualOffset: true,
63
+ canApplyManualSize: true,
64
+ canApplyManualRotation: true,
65
+ },
66
+ };
67
+ }
68
+
69
+ let cleanup: (() => void) | null = null;
70
+
71
+ function renderTextCommitHook(params: UseDomEditTextCommitsParams) {
72
+ const captured: { hook: ReturnType<typeof useDomEditTextCommits> | null } = { hook: null };
73
+ function TextCommitProbe() {
74
+ captured.hook = useDomEditTextCommits(params);
75
+ return null;
76
+ }
77
+ const root = mountReactHarness(<TextCommitProbe />);
78
+ cleanup = () => act(() => root.unmount());
79
+ if (!captured.hook) throw new Error("hook did not initialize");
80
+ return captured.hook;
81
+ }
82
+
83
+ afterEach(() => {
84
+ cleanup?.();
85
+ cleanup = null;
86
+ document.body.replaceChildren();
87
+ vi.restoreAllMocks();
88
+ });
89
+
90
+ describe("useDomEditTextCommits", () => {
91
+ it("does not let a stale failed fields commit revert newer text", async () => {
92
+ const iframe = document.createElement("iframe");
93
+ document.body.append(iframe);
94
+ const doc = iframe.contentDocument;
95
+ if (!doc) throw new Error("expected iframe document");
96
+ doc.body.innerHTML = '<div id="card">Original</div>';
97
+ const element = doc.getElementById("card");
98
+ const HTMLElementCtor = doc.defaultView?.HTMLElement;
99
+ if (!HTMLElementCtor || !(element instanceof HTMLElementCtor)) {
100
+ throw new Error("expected preview element");
101
+ }
102
+ vi.spyOn(console, "warn").mockImplementation(() => {});
103
+ const selection = selectionFor(element);
104
+ const stalePersist = createDeferred<void>();
105
+ const persistDomEditOperations = vi
106
+ .fn()
107
+ .mockImplementationOnce(() => stalePersist.promise)
108
+ .mockResolvedValueOnce(undefined);
109
+ const hook = renderTextCommitHook({
110
+ activeCompPath: "index.html",
111
+ previewIframeRef: { current: iframe },
112
+ showToast: vi.fn(),
113
+ domEditSelection: selection,
114
+ applyDomSelection: vi.fn(),
115
+ refreshDomEditSelectionFromPreview: vi.fn(),
116
+ buildDomSelectionFromTarget: vi.fn(async () => null),
117
+ persistDomEditOperations,
118
+ resolveImportedFontAsset: () => null,
119
+ });
120
+
121
+ let staleCommit: Promise<void> | undefined;
122
+ act(() => {
123
+ staleCommit = hook.commitDomTextFields(selection, [textField("Stale")]);
124
+ });
125
+ await act(async () => {
126
+ await hook.commitDomTextFields(selection, [textField("Newest")]);
127
+ });
128
+ stalePersist.reject(new Error("stale request failed"));
129
+ await act(async () => {
130
+ await staleCommit;
131
+ });
132
+
133
+ expect(element.innerHTML).toBe("Newest");
134
+ });
135
+ });
@@ -174,7 +174,11 @@ export function useDomEditTextCommits({
174
174
  let editedElement: HTMLElement | null = null;
175
175
  let previousInlineValue: string | null = null;
176
176
  const operations = buildDomStyleCommitOperations(property, value, isImageBackgroundCommit);
177
- const skipRefresh = property !== "z-index";
177
+ // Inline-style commits never full-reload the preview (that blanks the iframe
178
+ // until it re-renders): the live element was already mutated optimistically in
179
+ // apply(). z-index is no exception — setting `element.style.zIndex` restacks the
180
+ // element in-browser immediately, so a reload would only cost a black blink.
181
+ const skipRefresh = true;
178
182
 
179
183
  await runDomEditCommit({
180
184
  capture: () => {
@@ -305,6 +309,7 @@ export function useDomEditTextCommits({
305
309
  nextTextFields: DomEditTextField[],
306
310
  options?: { importedFont?: ImportedFontAsset | null },
307
311
  ) => {
312
+ const isLatestTextCommit = bumpDomEditCommitVersion(domTextCommitVersionRef);
308
313
  const textCommit = planDomTextCommit(
309
314
  selection.textFields,
310
315
  nextTextFields,
@@ -344,14 +349,14 @@ export function useDomEditTextCommits({
344
349
  : undefined,
345
350
  });
346
351
  },
347
- shouldRevert: () => true,
352
+ shouldRevert: () => isLatestTextCommit(),
348
353
  revert: () => {
349
354
  if (!editedElement || previousInnerHtml === null) return;
350
355
  editedElement.innerHTML = previousInnerHtml;
351
356
  },
352
357
  onError: (error) =>
353
358
  reportDomEditPersistFailure(selection, textCommit.operations, error, showToast),
354
- shouldResync: () => true,
359
+ shouldResync: isLatestTextCommit,
355
360
  resync: () =>
356
361
  resyncDomTextSelectionFromPreview(
357
362
  doc,
@@ -11,7 +11,6 @@ import { useCallback, useEffect, useRef } from "react";
11
11
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
12
12
  import { STUDIO_GSAP_PANEL_ENABLED } from "../components/editor/manualEditingAvailability";
13
13
  import { usePlayerStore } from "../player";
14
- import { resolveTimelineIdForSelection } from "../utils/studioHelpers";
15
14
  import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
16
15
  import { useGsapAnimationsForElement, usePopulateKeyframeCacheForFile } from "./useGsapTweenCache";
17
16
  import { useGsapAnimationFetchFallback } from "./useGsapAnimationFetchFallback";
@@ -171,14 +170,13 @@ export function useDomEditWiring({
171
170
 
172
171
  useEffect(() => {
173
172
  if (!domEditSelection?.id) return;
174
- const { selectedElementId, elements, setSelectionAnchor } = usePlayerStore.getState();
175
- // Resolve through the canonical resolver (source-file + ancestor + active-comp
176
- // fallback) rather than a narrow domId/id match, so a sub-composition selection
177
- // maps to the same clip the rest of the selection pipeline picks. Use the
178
- // anchor-only setter: this is a DOM->store echo and must not collapse a group.
179
- const key = resolveTimelineIdForSelection(domEditSelection, elements, activeCompPath);
180
- if (key && key !== selectedElementId) setSelectionAnchor(key);
181
- }, [domEditSelection, activeCompPath]);
173
+ const { selectedElementId, elements, setSelectedElementId } = usePlayerStore.getState();
174
+ const matchKey = elements.find(
175
+ (el) => el.domId === domEditSelection.id || el.id === domEditSelection.id,
176
+ );
177
+ const key = matchKey ? (matchKey.key ?? matchKey.id) : null;
178
+ if (key && key !== selectedElementId) setSelectedElementId(key);
179
+ }, [domEditSelection?.id]);
182
180
 
183
181
  // ── GSAP cache sync ──
184
182
 
@@ -61,14 +61,29 @@ export function useDomGeometryCommits({
61
61
  );
62
62
 
63
63
  const handleDomBoxSizeCommit = useCallback(
64
- (selection: DomEditSelection, next: { width: number; height: number }) => {
64
+ (
65
+ selection: DomEditSelection,
66
+ next: { width: number; height: number },
67
+ offset?: { x: number; y: number },
68
+ ) => {
65
69
  if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
66
70
  const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
67
71
  showToast(error.message, "error");
68
72
  return Promise.reject(error);
69
73
  }
70
74
  applyStudioBoxSize(selection.element, next);
71
- return commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), {
75
+ // Anchored-corner resize (NW/NE/SW) also moves the element to keep the
76
+ // opposite corner fixed. Apply the offset and emit BOTH patch sets in a
77
+ // SINGLE commit: one persist = one undo entry, and there is no
78
+ // intermediate re-stamp where the new size is in source but the anchor
79
+ // offset is not (that frame was the release "jump"). Both builders read
80
+ // the already-mutated live element, so concatenation is safe.
81
+ const patches = buildBoxSizePatches(selection.element);
82
+ if (offset) {
83
+ applyStudioPathOffset(selection.element, offset);
84
+ patches.push(...buildPathOffsetPatches(selection.element));
85
+ }
86
+ return commitPositionPatchToHtml(selection, patches, {
72
87
  label: "Resize layer box",
73
88
  coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
74
89
  });
@@ -2,9 +2,7 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
6
- import type { TimelineElement } from "../player";
7
- import { usePlayerStore } from "../player/store/playerStore";
5
+ import { describe, expect, it, vi } from "vitest";
8
6
  import { installReactActEnvironment, makeSelection } from "./domSelectionTestHarness";
9
7
  import { useDomSelection } from "./useDomSelection";
10
8
 
@@ -14,7 +12,6 @@ interface HarnessProps {
14
12
  activeCompPath: string | null;
15
13
  projectId: string | null;
16
14
  refreshKey: number;
17
- timelineElements?: TimelineElement[];
18
15
  }
19
16
 
20
17
  function renderHarness(initialProps: HarnessProps): {
@@ -35,7 +32,7 @@ function renderHarness(initialProps: HarnessProps): {
35
32
  compIdToSrc: new Map(),
36
33
  captionEditMode: false,
37
34
  previewIframeRef: { current: null },
38
- timelineElements: props.timelineElements ?? [],
35
+ timelineElements: [],
39
36
  setSelectedTimelineElementId: vi.fn(),
40
37
  setRightCollapsed: vi.fn(),
41
38
  setRightPanelTab: vi.fn(),
@@ -67,10 +64,6 @@ function renderHarness(initialProps: HarnessProps): {
67
64
  };
68
65
  }
69
66
 
70
- afterEach(() => {
71
- usePlayerStore.getState().reset();
72
- });
73
-
74
67
  function setupSelectedHarness() {
75
68
  const element = document.createElement("div");
76
69
  element.id = "headline";
@@ -138,31 +131,4 @@ describe("useDomSelection", () => {
138
131
  expect(harness.current().domEditSelection).toBe(selection);
139
132
  harness.cleanup();
140
133
  });
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
- });
168
134
  });
@@ -1,10 +1,14 @@
1
1
  import { useState, useCallback, useRef, useEffect } from "react";
2
- import type { TimelineElement } from "../player";
2
+ import type { SelectElementOptions, TimelineElement } from "../player";
3
3
  import {
4
4
  getAllPreviewTargetsFromPointer,
5
5
  getPreviewTargetFromPointer,
6
6
  } from "../utils/studioPreviewHelpers";
7
- import { resolveTimelineIdForSelection, type RightPanelTab } from "../utils/studioHelpers";
7
+ import {
8
+ findMatchingTimelineElementId,
9
+ findTimelineIdByAncestor,
10
+ type RightPanelTab,
11
+ } from "../utils/studioHelpers";
8
12
  import {
9
13
  domEditSelectionsTargetSame,
10
14
  domEditSelectionInGroup,
@@ -20,7 +24,6 @@ import {
20
24
  type DomEditSelection,
21
25
  } from "../components/editor/domEditing";
22
26
  import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
23
- import { usePlayerStore } from "../player/store/playerStore";
24
27
 
25
28
  // ── Types ──
26
29
 
@@ -44,7 +47,7 @@ export interface UseDomSelectionParams {
44
47
  captionEditMode: boolean;
45
48
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
46
49
  timelineElements: TimelineElement[];
47
- setSelectedTimelineElementId: (id: string | null) => void;
50
+ setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void;
48
51
  setRightCollapsed: (collapsed: boolean) => void;
49
52
  setRightPanelTab: (tab: RightPanelTab) => void;
50
53
  previewIframe: HTMLIFrameElement | null;
@@ -92,6 +95,7 @@ export interface UseDomSelectionReturn {
92
95
  ) => Promise<DomEditSelection | null>;
93
96
  handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
94
97
  refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
98
+ refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
95
99
  applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
96
100
  }
97
101
 
@@ -206,42 +210,27 @@ export function useDomSelection({
206
210
  if (nextSelection) {
207
211
  if (options?.revealPanel !== false) {
208
212
  setRightCollapsed(false);
209
- // Keep the Variables tab in place — selecting elements is part of
210
- // the bind flow there; yanking to Design would lose the context.
213
+ // Keep the Variables tab in place — selecting elements is part of the bind
214
+ // flow there; yanking to Design would lose the context.
211
215
  if (rightPanelTabRef.current !== "variables") {
212
216
  setRightPanelTab("design");
213
217
  }
214
218
  }
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);
232
- }
219
+ const nextSelectedTimelineId =
220
+ findMatchingTimelineElementId(nextSelection, timelineElements) ??
221
+ findTimelineIdByAncestor(
222
+ nextSelection.element,
223
+ timelineElements,
224
+ nextSelection.sourceFile || "index.html",
225
+ );
226
+ // Late marquee notify: a primary already in the live set must not collapse it.
227
+ setSelectedTimelineElementId(nextSelectedTimelineId, { preserveSet: true });
233
228
  return;
234
229
  }
235
230
 
236
231
  setSelectedTimelineElementId(null);
237
232
  },
238
- [
239
- setSelectedTimelineElementId,
240
- timelineElements,
241
- setRightCollapsed,
242
- setRightPanelTab,
243
- activeCompPath,
244
- ],
233
+ [setSelectedTimelineElementId, timelineElements, setRightCollapsed, setRightPanelTab],
245
234
  );
246
235
 
247
236
  const clearDomSelection = useCallback(() => {
@@ -424,6 +413,55 @@ export function useDomSelection({
424
413
  [activeCompPath, applyDomSelection, buildDomSelectionFromTarget, previewIframeRef],
425
414
  );
426
415
 
416
+ const refreshDomEditGroupSelectionsFromPreview = useCallback(
417
+ // fallow-ignore-next-line complexity
418
+ async (selections: DomEditSelection[]) => {
419
+ const iframe = previewIframeRef.current;
420
+ let doc: Document | null = null;
421
+ try {
422
+ doc = iframe?.contentDocument ?? null;
423
+ } catch {
424
+ return;
425
+ }
426
+ if (!doc) return;
427
+
428
+ const nextGroup: DomEditSelection[] = [];
429
+ for (const selection of selections) {
430
+ const element = findElementForSelection(doc, selection, activeCompPath);
431
+ if (!element) continue;
432
+ const nextSelection = await buildDomSelectionFromTarget(element);
433
+ if (nextSelection) nextGroup.push(nextSelection);
434
+ }
435
+ if (nextGroup.length === 0) return;
436
+
437
+ const currentSelection = domEditSelectionRef.current;
438
+ const nextSelection =
439
+ nextGroup.find((selection) => domEditSelectionsTargetSame(selection, currentSelection)) ??
440
+ nextGroup[0] ??
441
+ null;
442
+
443
+ domEditSelectionRef.current = nextSelection;
444
+ domEditGroupSelectionsRef.current = nextGroup;
445
+ setDomEditSelection(nextSelection);
446
+ setDomEditGroupSelections(nextGroup);
447
+
448
+ if (nextSelection) {
449
+ setSelectedTimelineElementId(
450
+ findMatchingTimelineElementId(nextSelection, timelineElements),
451
+ );
452
+ } else {
453
+ setSelectedTimelineElementId(null);
454
+ }
455
+ },
456
+ [
457
+ activeCompPath,
458
+ buildDomSelectionFromTarget,
459
+ setSelectedTimelineElementId,
460
+ timelineElements,
461
+ previewIframeRef,
462
+ ],
463
+ );
464
+
427
465
  // ── Effects ──
428
466
 
429
467
  // Clear hover unconditionally on composition/project/preview change
@@ -471,8 +509,7 @@ export function useDomSelection({
471
509
  const applyMarqueeSelection = useCallback(
472
510
  // fallow-ignore-next-line complexity
473
511
  (selections: DomEditSelection[], additive: boolean) => {
474
- // Honor the inspector-panels kill switch like applyDomSelection does, so
475
- // marquee can't land selections while the inspector UI is suppressed.
512
+ // Honor the inspector-panels kill switch like applyDomSelection does.
476
513
  if (!STUDIO_INSPECTOR_PANELS_ENABLED) {
477
514
  domEditSelectionRef.current = null;
478
515
  domEditGroupSelectionsRef.current = [];
@@ -493,8 +530,7 @@ export function useDomSelection({
493
530
  if (!domEditSelectionInGroup(nextGroup, s)) nextGroup = [...nextGroup, s];
494
531
  }
495
532
  } else {
496
- // Dedupe by target: under select-as-unit several marquee'd members collapse
497
- // to the same group, which must count as one selection, not many duplicates.
533
+ // Dedupe by target: select-as-unit collapses marquee'd members to one group.
498
534
  nextGroup = [];
499
535
  for (const s of selections) {
500
536
  if (!domEditSelectionInGroup(nextGroup, s)) nextGroup.push(s);
@@ -505,23 +541,16 @@ export function useDomSelection({
505
541
  domEditGroupSelectionsRef.current = nextGroup;
506
542
  setDomEditSelection(nextSelection);
507
543
  setDomEditGroupSelections(nextGroup);
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
- }
544
+ const nextTimelineId =
545
+ findMatchingTimelineElementId(nextSelection, timelineElements) ??
546
+ findTimelineIdByAncestor(
547
+ nextSelection.element,
548
+ timelineElements,
549
+ nextSelection.sourceFile || "index.html",
550
+ );
551
+ setSelectedTimelineElementId(nextTimelineId);
523
552
  },
524
- [applyDomSelection, timelineElements, setSelectedTimelineElementId, activeCompPath],
553
+ [applyDomSelection, timelineElements, setSelectedTimelineElementId],
525
554
  );
526
555
 
527
556
  // Disabled inspector effect
@@ -558,6 +587,7 @@ export function useDomSelection({
558
587
  buildDomSelectionForTimelineElement,
559
588
  handleTimelineElementSelect,
560
589
  refreshDomEditSelectionFromPreview,
590
+ refreshDomEditGroupSelectionsFromPreview,
561
591
  applyMarqueeSelection,
562
592
  };
563
593
  }