@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,214 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { DomEditSelection } from "./domEditing";
5
+ import type { GestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
6
+
7
+ // Origin box (overlay px). The gesture-start center is its centroid; the
8
+ // center-anchored resize must keep THIS point planted, which means the release
9
+ // must commit a nonzero offset equal to minus half the size growth.
10
+ const ORIGIN = { left: 0, top: 0, width: 200, height: 100 };
11
+ const ORIGIN_CENTER = {
12
+ x: ORIGIN.left + ORIGIN.width / 2,
13
+ y: ORIGIN.top + ORIGIN.height / 2,
14
+ };
15
+
16
+ // Consistent geometry stub: model the physical truth the real DOM would report.
17
+ // A CSS width/height change grows the box from its top-left, so the rendered
18
+ // center drifts by half the size delta; the manual offset the gesture applies
19
+ // (read back from the element's studio vars) pulls it back. `elementCornerOverlayPoints`
20
+ // returns the four corners of that drifted box; `overlayCornersCentroid` (kept
21
+ // real) averages them so the anchor loop can measure the true center each frame.
22
+ vi.mock("./domEditOverlayGeometry", async () => {
23
+ const actual = await vi.importActual<typeof import("./domEditOverlayGeometry")>(
24
+ "./domEditOverlayGeometry",
25
+ );
26
+ const { readStudioBoxSize, readStudioPathOffset } = await import("./manualEditsDom");
27
+ const physicalCenter = (element: HTMLElement) => {
28
+ const size = readStudioBoxSize(element);
29
+ const width = size.width > 0 ? size.width : ORIGIN.width;
30
+ const height = size.height > 0 ? size.height : ORIGIN.height;
31
+ const offset = readStudioPathOffset(element);
32
+ return {
33
+ x: ORIGIN_CENTER.x + (width - ORIGIN.width) / 2 + offset.x,
34
+ y: ORIGIN_CENTER.y + (height - ORIGIN.height) / 2 + offset.y,
35
+ width,
36
+ height,
37
+ };
38
+ };
39
+ return {
40
+ ...actual,
41
+ elementCornerOverlayPoints: (_o: unknown, _i: unknown, element: HTMLElement) => {
42
+ const c = physicalCenter(element);
43
+ const hw = c.width / 2;
44
+ const hh = c.height / 2;
45
+ return {
46
+ nw: { x: c.x - hw, y: c.y - hh },
47
+ ne: { x: c.x + hw, y: c.y - hh },
48
+ sw: { x: c.x - hw, y: c.y + hh },
49
+ se: { x: c.x + hw, y: c.y + hh },
50
+ };
51
+ },
52
+ orientedOverlayRect: (_o: unknown, _i: unknown, element: HTMLElement) => {
53
+ const c = physicalCenter(element);
54
+ return {
55
+ left: c.x - c.width / 2,
56
+ top: c.y - c.height / 2,
57
+ width: c.width,
58
+ height: c.height,
59
+ editScaleX: 1,
60
+ editScaleY: 1,
61
+ angle: 0,
62
+ };
63
+ },
64
+ };
65
+ });
66
+
67
+ const { createDomEditOverlayGestureHandlers } = await import("./useDomEditOverlayGestures");
68
+
69
+ function ref<T>(current: T) {
70
+ return { current };
71
+ }
72
+
73
+ interface CommitCall {
74
+ size: { width: number; height: number };
75
+ offset: { x: number; y: number } | undefined;
76
+ }
77
+
78
+ function buildHarness() {
79
+ const element = document.createElement("div");
80
+ document.body.append(element);
81
+
82
+ const selection = {
83
+ element,
84
+ id: "box",
85
+ selector: "#box",
86
+ selectorIndex: 0,
87
+ sourceFile: "index.html",
88
+ tagName: "div",
89
+ label: "Box",
90
+ textContent: "",
91
+ textFields: [],
92
+ capabilities: {
93
+ canEditText: false,
94
+ canEditLayout: true,
95
+ canMove: true,
96
+ canApplyManualOffset: true,
97
+ canApplyManualSize: true,
98
+ canApplyManualRotation: false,
99
+ canAdjustOpacity: true,
100
+ canAdjustFill: true,
101
+ canAdjustBorderRadius: true,
102
+ canAdjustStroke: true,
103
+ canAdjustShadow: true,
104
+ canAdjustZIndex: true,
105
+ },
106
+ computedStyle: { display: "block", position: "absolute" },
107
+ } as unknown as DomEditSelection;
108
+
109
+ const commits: CommitCall[] = [];
110
+ const overlayEl = document.createElement("div");
111
+ const iframe = document.createElement("iframe");
112
+
113
+ const opts: UseDomEditOverlayGesturesOptions = {
114
+ overlayRef: ref<HTMLDivElement | null>(overlayEl),
115
+ iframeRef: ref<HTMLIFrameElement | null>(iframe),
116
+ boxRef: ref<HTMLDivElement | null>(document.createElement("div")),
117
+ selectionRef: ref<DomEditSelection | null>(selection),
118
+ hoverSelectionRef: ref<DomEditSelection | null>(null),
119
+ overlayRectRef: ref<OverlayRectLike | null>({
120
+ left: ORIGIN.left,
121
+ top: ORIGIN.top,
122
+ width: ORIGIN.width,
123
+ height: ORIGIN.height,
124
+ editScaleX: 1,
125
+ editScaleY: 1,
126
+ }) as never,
127
+ groupOverlayItemsRef: ref([]),
128
+ gestureRef: ref<GestureState | null>(null),
129
+ groupGestureRef: ref(null),
130
+ blockedMoveRef: ref(null),
131
+ rafPausedRef: ref(false),
132
+ suppressNextBoxClickRef: ref(false),
133
+ setOverlayRect: () => {},
134
+ setGroupOverlayItems: () => {},
135
+ onBlockedMoveRef: ref(() => {}),
136
+ onManualDragStartRef: ref(() => {}),
137
+ onPathOffsetCommitRef: ref(() => {}),
138
+ onGroupPathOffsetCommitRef: ref(() => {}),
139
+ onBoxSizeCommitRef: ref((_s, size, offset) => {
140
+ commits.push({ size, offset });
141
+ }),
142
+ onRotationCommitRef: ref(() => {}),
143
+ onCanvasPointerMoveRef: ref(() => Promise.resolve(null)),
144
+ onCanvasMouseDown: () => {},
145
+ snapGuidesRef: ref(null),
146
+ };
147
+
148
+ const handlers = createDomEditOverlayGestureHandlers(opts);
149
+ return { handlers, commits, selection };
150
+ }
151
+
152
+ type OverlayRectLike = {
153
+ left: number;
154
+ top: number;
155
+ width: number;
156
+ height: number;
157
+ editScaleX: number;
158
+ editScaleY: number;
159
+ };
160
+
161
+ function evt(clientX: number, clientY: number) {
162
+ return {
163
+ clientX,
164
+ clientY,
165
+ pointerId: 1,
166
+ button: 0,
167
+ altKey: false,
168
+ shiftKey: false,
169
+ preventDefault() {},
170
+ stopPropagation() {},
171
+ currentTarget: { setPointerCapture() {} },
172
+ } as unknown as React.PointerEvent<HTMLDivElement>;
173
+ }
174
+
175
+ afterEach(() => {
176
+ document.body.innerHTML = "";
177
+ });
178
+
179
+ describe("anchored corner resize — the release commit feeds the center-pin offset", () => {
180
+ it("onPointerUp passes a nonzero offset that keeps the center planted", () => {
181
+ const { handlers, commits } = buildHarness();
182
+
183
+ // Start an SE corner resize. Pointer starts 100px right of the center.
184
+ handlers.startGesture("resize", evt(ORIGIN_CENTER.x + 100, ORIGIN_CENTER.y), {
185
+ resizeHandle: "se",
186
+ });
187
+
188
+ // Drag outward to radial scale 1.5 (dist 150 / 100). Several frames so the
189
+ // per-frame center-pin anchor accumulates and converges into g.lastResizeAnchor.
190
+ for (let i = 0; i < 5; i++) {
191
+ handlers.onPointerMove(evt(ORIGIN_CENTER.x + 150, ORIGIN_CENTER.y));
192
+ }
193
+
194
+ handlers.onPointerUp(evt(ORIGIN_CENTER.x + 150, ORIGIN_CENTER.y));
195
+
196
+ expect(commits).toHaveLength(1);
197
+ const { size, offset } = commits[0]!;
198
+
199
+ // Proportional 1.5x growth of the 200x100 base.
200
+ expect(size.width).toBeCloseTo(300, 0);
201
+ expect(size.height).toBeCloseTo(150, 0);
202
+
203
+ // The committed offset must be present and nonzero — the open question.
204
+ expect(offset).toBeDefined();
205
+ if (!offset) return;
206
+ expect(offset.x).not.toBe(0);
207
+ expect(offset.y).not.toBe(0);
208
+
209
+ // And it must equal minus half the size growth, i.e. it re-pins the center to
210
+ // exactly the gesture-start center (offset = -(finalSize - origin)/2).
211
+ expect(offset.x).toBeCloseTo(-(size.width - ORIGIN.width) / 2, 0);
212
+ expect(offset.y).toBeCloseTo(-(size.height - ORIGIN.height) / 2, 0);
213
+ });
214
+ });
@@ -0,0 +1,208 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import {
4
+ applyStudioBoxSize,
5
+ applyStudioPathOffset,
6
+ readStudioBoxSize,
7
+ reapplyPositionEditsAfterSeek,
8
+ } from "./manualEditsDom";
9
+ import { buildBoxSizePatches, buildPathOffsetPatches } from "./manualEditsDomPatches";
10
+ import { createManualOffsetDragMember, applyManualOffsetDragCommit } from "./manualOffsetDrag";
11
+ import { computeNextResizeAnchor } from "./domEditResizeLocal";
12
+ import type { PatchOperation } from "../../utils/sourcePatcher";
13
+ import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
14
+
15
+ /**
16
+ * Center-anchored corner resize (CapCut model): the element scales about its
17
+ * CENTER, which must stay planted across the whole gesture — including after
18
+ * release, on every corner and at any rotation.
19
+ *
20
+ * Root cause of the original release "shift" (proved with a real-layout Chromium
21
+ * replay, see the anchor-loop test below): during a resize drag the per-frame
22
+ * anchor is derived from the element's LIVE measured center — which already carries
23
+ * the offset applied on the PREVIOUS frame — while `applyManualOffsetDragDraft`
24
+ * treats that anchor as the ABSOLUTE offset. So `fixedStart - centerNow` is really
25
+ * only the RESIDUAL correction, and using it as the absolute value makes the anchor
26
+ * OSCILLATE between the correct value and zero every frame:
27
+ * frame 0: offset 0 → center shifted by the resize → anchor = full amount → apply
28
+ * frame 1: offset applied → center back at fixedStart → anchor = 0 → apply 0 (un-pin!)
29
+ * frame 2: offset 0 again → anchor = full amount → ...
30
+ * Release commits `g.lastResizeAnchor` from whichever parity the last pointermove
31
+ * landed on, so the element lands EITHER pinned OR un-pinned — an unpredictable
32
+ * post-release "shift".
33
+ *
34
+ * Fix (useDomEditOverlayGestures pointermove, resize branch, fa4f39168): accumulate
35
+ * the residual onto the previously-applied anchor instead of using it as the
36
+ * absolute offset, so the loop converges to a stable value on every frame. The
37
+ * per-frame accumulation is the exported `computeNextResizeAnchor` helper (the one
38
+ * call site in the pointermove resize branch); tests 1 & 2 drive it directly.
39
+ */
40
+
41
+ afterEach(() => {
42
+ document.body.innerHTML = "";
43
+ });
44
+
45
+ /** Apply a built PatchOperation[] to a live element, mirroring sourcePatcher's
46
+ * inline-style / attribute application — i.e. what the persisted source carries
47
+ * when it is re-parsed into the DOM on the next preview load. */
48
+ function applyPatchesToElement(el: HTMLElement, ops: PatchOperation[]): void {
49
+ for (const op of ops) {
50
+ if (op.type === "inline-style") {
51
+ if (op.value === null) el.style.removeProperty(op.property);
52
+ else el.style.setProperty(op.property, op.value);
53
+ } else if (op.type === "attribute") {
54
+ if (op.value === null) el.removeAttribute(op.property);
55
+ else el.setAttribute(op.property, op.value);
56
+ }
57
+ }
58
+ }
59
+
60
+ /** Net translate applied to an element, resolving the studio offset var()
61
+ * expression to its px value so we compare the actually-rendered translation. */
62
+ function resolvedTranslatePx(el: HTMLElement): { x: number; y: number } {
63
+ const raw = el.style.getPropertyValue("translate").trim();
64
+ if (!raw || raw === "none") return { x: 0, y: 0 };
65
+ const vx = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-x")) || 0;
66
+ const vy = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-y")) || 0;
67
+ const parts = splitTopLevelWhitespace(raw);
68
+ const parseAxis = (part: string, varVal: number): number => {
69
+ if (part && part.includes("--hf-studio-offset")) return varVal;
70
+ const n = Number.parseFloat(part);
71
+ return Number.isFinite(n) ? n : 0;
72
+ };
73
+ return {
74
+ x: parseAxis(parts[0] ?? "", vx),
75
+ y: parseAxis(parts[1] ?? "", vy),
76
+ };
77
+ }
78
+
79
+ describe("center-anchored corner resize — no shift after release", () => {
80
+ it("the per-frame center anchor converges (does NOT oscillate) — the release-shift root cause", () => {
81
+ // Model the pointermove anchor loop that pins the element's CENTER. The physical
82
+ // truth (confirmed in a real browser): the measured center sits at
83
+ // `fixedStart - appliedOffset` shifted by the resize — i.e. applying the offset
84
+ // moves the center back toward its gesture-start position. Here scale=1 so
85
+ // screen px == offset px. `trueAnchor` is the compensating offset that pins it.
86
+ const trueAnchor = { dx: -60, dy: -27 };
87
+ const fixedStart = { x: 500, y: 270 };
88
+
89
+ // `appliedOffset` mirrors what applyManualOffsetDragDraft set last frame.
90
+ let appliedOffset = { x: 0, y: 0 };
91
+ // `lastResizeAnchor` accumulator, exactly as g.lastResizeAnchor in the fix.
92
+ let lastResizeAnchor: { dx: number; dy: number } | undefined;
93
+
94
+ const anchorsSeen: Array<{ dx: number; dy: number }> = [];
95
+ for (let frame = 0; frame < 8; frame++) {
96
+ // Live measured center: the resize would put it at fixedStart + trueAnchor
97
+ // (un-anchored), and the currently-applied offset pulls it back by that
98
+ // offset. So centerNow = fixedStart - trueAnchor + appliedOffset.
99
+ const centerNow = {
100
+ x: fixedStart.x - trueAnchor.dx + appliedOffset.x,
101
+ y: fixedStart.y - trueAnchor.dy + appliedOffset.y,
102
+ };
103
+ // ── The fixed logic (accumulate residual onto the previous anchor) ──
104
+ const anchor = computeNextResizeAnchor(lastResizeAnchor, fixedStart, centerNow);
105
+ lastResizeAnchor = anchor;
106
+ anchorsSeen.push(anchor);
107
+ // applyManualOffsetDragDraft sets the absolute offset (scale 1) = anchor.
108
+ appliedOffset = { x: anchor.dx, y: anchor.dy };
109
+ }
110
+
111
+ // Every frame must report the same, correct anchor — no oscillation, so the
112
+ // committed value is parity-independent.
113
+ for (const a of anchorsSeen) {
114
+ expect(a).toEqual(trueAnchor);
115
+ }
116
+ // Guard against the OLD absolute formula regressing: with `anchor =
117
+ // fixedStart - centerNow` (no accumulation) the sequence would be
118
+ // [trueAnchor, 0, trueAnchor, 0, ...]; assert the last two frames agree.
119
+ expect(anchorsSeen.at(-1)).toEqual(anchorsSeen.at(-2));
120
+ });
121
+
122
+ it("the center stays fixed for every corner at any rotation (loop converges)", () => {
123
+ // The pin loop is handle- and rotation-independent: it always measures the
124
+ // element CENTER and drives it back to fixedStart. Simulate the loop for all
125
+ // four corners across unrotated + rotated gestures; the resize's raw center
126
+ // shift varies with corner/rotation (modelled as `rawShift`), but the loop must
127
+ // converge the measured center onto fixedStart every time.
128
+ const fixedStart = { x: 640, y: 360 };
129
+ const HANDLES = ["nw", "ne", "sw", "se"] as const;
130
+ const DEGS = [0, 30, 90, 137];
131
+ for (const handle of HANDLES) {
132
+ for (const deg of DEGS) {
133
+ const t = (deg * Math.PI) / 180;
134
+ // Raw (un-pinned) center shift the size write would cause this frame —
135
+ // a corner/rotation-dependent vector. Its exact value is irrelevant; the
136
+ // loop only needs to cancel it.
137
+ const seed = (HANDLES.indexOf(handle) + 1) * 11;
138
+ const rawShift = {
139
+ dx: Math.cos(t) * seed - Math.sin(t) * (seed / 2),
140
+ dy: Math.sin(t) * seed + Math.cos(t) * (seed / 2),
141
+ };
142
+ let appliedOffset = { x: 0, y: 0 };
143
+ let lastResizeAnchor: { dx: number; dy: number } | undefined;
144
+ let centerNow = { x: fixedStart.x, y: fixedStart.y };
145
+ for (let frame = 0; frame < 6; frame++) {
146
+ centerNow = {
147
+ x: fixedStart.x + rawShift.dx + appliedOffset.x,
148
+ y: fixedStart.y + rawShift.dy + appliedOffset.y,
149
+ };
150
+ const anchor = computeNextResizeAnchor(lastResizeAnchor, fixedStart, centerNow);
151
+ lastResizeAnchor = anchor;
152
+ appliedOffset = { x: anchor.dx, y: anchor.dy };
153
+ }
154
+ // After convergence the pinned center equals the gesture-start center.
155
+ const pinnedCenter = {
156
+ x: fixedStart.x + rawShift.dx + appliedOffset.x,
157
+ y: fixedStart.y + rawShift.dy + appliedOffset.y,
158
+ };
159
+ expect(pinnedCenter.x).toBeCloseTo(fixedStart.x, 9);
160
+ expect(pinnedCenter.y).toBeCloseTo(fixedStart.y, 9);
161
+ }
162
+ }
163
+ });
164
+
165
+ it("net translate after persist+reload equals the committed anchor offset (non-GSAP)", () => {
166
+ // The committed offset flows through the real apply → persist → reload chain
167
+ // unchanged (this hop was proved clean; the shift is upstream in the anchor
168
+ // loop above, not in persistence).
169
+ const el = document.createElement("div");
170
+ el.style.setProperty("width", "200px");
171
+ el.style.setProperty("height", "100px");
172
+ document.body.appendChild(el);
173
+
174
+ const anchorDx = -30;
175
+ const anchorDy = -18;
176
+ const finalSize = { width: 240, height: 130 };
177
+
178
+ applyStudioBoxSize(el, finalSize);
179
+ const memberResult = createManualOffsetDragMember({
180
+ key: "k",
181
+ selection: { element: el } as never,
182
+ element: el,
183
+ rect: { left: 0, top: 0, width: 240, height: 130, editScaleX: 1, editScaleY: 1 },
184
+ });
185
+ expect(memberResult.ok).toBe(true);
186
+ if (!memberResult.ok) return;
187
+
188
+ const finalOffset = applyManualOffsetDragCommit(memberResult.member, anchorDx, anchorDy);
189
+
190
+ applyStudioBoxSize(el, finalSize);
191
+ const patches = buildBoxSizePatches(el);
192
+ applyStudioPathOffset(el, finalOffset);
193
+ patches.push(...buildPathOffsetPatches(el));
194
+
195
+ expect(resolvedTranslatePx(el)).toEqual({ x: anchorDx, y: anchorDy });
196
+
197
+ // Persist → fresh element re-parsed from source → reload re-stamp.
198
+ const reloaded = document.createElement("div");
199
+ reloaded.style.setProperty("width", "200px");
200
+ reloaded.style.setProperty("height", "100px");
201
+ document.body.appendChild(reloaded);
202
+ applyPatchesToElement(reloaded, patches);
203
+ reapplyPositionEditsAfterSeek(reloaded.ownerDocument);
204
+
205
+ expect(resolvedTranslatePx(reloaded)).toEqual({ x: anchorDx, y: anchorDy });
206
+ expect(readStudioBoxSize(reloaded)).toEqual(finalSize);
207
+ });
208
+ });