@hyperframes/studio 0.8.104 → 0.8.106

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 (225) hide show
  1. package/dist/assets/{hyperframes-player-DAoaajuM.js → hyperframes-player-dl6K6ABp.js} +1 -1
  2. package/dist/assets/index-CMiYVGv5.js +399 -0
  3. package/dist/assets/{index-WV9LroAD.js → index-DKT5NF1Y.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-BDK4rTMb.js → index-ooaZZhKf.js} +1 -1
  6. package/dist/{chunk-62DDWOSH.js → chunk-EEXSYY5F.js} +19 -18
  7. package/dist/chunk-EEXSYY5F.js.map +1 -0
  8. package/dist/{chunk-HIGHVWYD.js → chunk-ICTZJPQQ.js} +3 -2
  9. package/dist/chunk-ICTZJPQQ.js.map +1 -0
  10. package/dist/{chunk-6GAO75IU.js → chunk-NCUBAVGK.js} +10 -8
  11. package/dist/chunk-NCUBAVGK.js.map +1 -0
  12. package/dist/index.css +6 -1
  13. package/dist/index.css.map +1 -1
  14. package/dist/index.d.ts +15 -4
  15. package/dist/index.html +11 -2
  16. package/dist/index.js +10391 -9836
  17. package/dist/index.js.map +1 -1
  18. package/dist/player.js +2 -2
  19. package/dist/styles/tailwind-preset.js.map +1 -1
  20. package/dist/styles.css +1 -1
  21. package/dist/ui.js +2 -2
  22. package/package.json +9 -9
  23. package/src/captions/components/CaptionAnimationPanel.tsx +2 -2
  24. package/src/captions/components/CaptionPropertyPanel.tsx +2 -2
  25. package/src/captions/components/CaptionTimeline.tsx +3 -3
  26. package/src/captions/keyboard.test.ts +6 -0
  27. package/src/captions/keyboard.ts +2 -1
  28. package/src/components/AskAgentModal.tsx +1 -1
  29. package/src/components/LintModal.tsx +35 -35
  30. package/src/components/StudioErrorBoundary.tsx +1 -1
  31. package/src/components/StudioHeader.dom.test.tsx +35 -13
  32. package/src/components/StudioHeader.tsx +6 -2
  33. package/src/components/StudioToast.tsx +7 -16
  34. package/src/components/ThemeToggle.tsx +99 -0
  35. package/src/components/TimelineToolPicker.tsx +1 -1
  36. package/src/components/TimelineToolbar.tsx +11 -11
  37. package/src/components/dock/Dock.tsx +1 -1
  38. package/src/components/dock/dock.css +9 -1
  39. package/src/components/editor/AnimationCard.tsx +5 -5
  40. package/src/components/editor/AnimationCardParts.tsx +1 -1
  41. package/src/components/editor/BlockParamsPanel.tsx +3 -3
  42. package/src/components/editor/BorderRadiusEditor.tsx +8 -15
  43. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  44. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  45. package/src/components/editor/DomEditOverlay.tsx +37 -5
  46. package/src/components/editor/DomEditRotateHandle.tsx +1 -1
  47. package/src/components/editor/EaseCurveSection.tsx +14 -19
  48. package/src/components/editor/EaseModeControls.tsx +2 -2
  49. package/src/components/editor/EaseParamFields.tsx +8 -8
  50. package/src/components/editor/FileTree.tsx +1 -1
  51. package/src/components/editor/FileTreeIcons.tsx +47 -26
  52. package/src/components/editor/FileTreeNodes.tsx +11 -8
  53. package/src/components/editor/GestureRecordControl.tsx +3 -3
  54. package/src/components/editor/GsapAddAnimationControl.tsx +1 -1
  55. package/src/components/editor/GsapAnimationSection.tsx +2 -2
  56. package/src/components/editor/InlineTextToolbar.tsx +4 -4
  57. package/src/components/editor/InspectorHeaderActions.tsx +1 -1
  58. package/src/components/editor/KeyframeDiamond.tsx +6 -4
  59. package/src/components/editor/KeyframeNavigation.tsx +4 -4
  60. package/src/components/editor/LayersPanel.tsx +3 -3
  61. package/src/components/editor/PromotableControl.tsx +2 -2
  62. package/src/components/editor/PropertyPanelEmptyState.tsx +4 -4
  63. package/src/components/editor/PropertyPanelFlatFooter.tsx +2 -2
  64. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +1 -1
  65. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  66. package/src/components/editor/SnapToolbar.tsx +5 -5
  67. package/src/components/editor/SourceEditor.tsx +2 -6
  68. package/src/components/editor/TimelineFxPopover.tsx +4 -4
  69. package/src/components/editor/Transform3DCube.tsx +1 -1
  70. package/src/components/editor/anchoredResizeReleaseShift.test.ts +0 -84
  71. package/src/components/editor/domEditOverlayStartGesture.ts +127 -115
  72. package/src/components/editor/easeCurveSvg.tsx +1 -1
  73. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +275 -0
  74. package/src/components/editor/manualEditsDom.ts +36 -3
  75. package/src/components/editor/manualEditsDomPatches.test.ts +2 -86
  76. package/src/components/editor/manualEditsDomPatches.ts +0 -30
  77. package/src/components/editor/manualEditsSnapshot.ts +1 -0
  78. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  79. package/src/components/editor/marqueeCommit.ts +6 -1
  80. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  81. package/src/components/editor/overlayFrameLoop.ts +21 -0
  82. package/src/components/editor/persistSeam.integration.test.ts +0 -18
  83. package/src/components/editor/plainTranslate.ts +34 -3
  84. package/src/components/editor/propertyPanelColorCurveGraph.tsx +2 -2
  85. package/src/components/editor/propertyPanelColorGradingControls.tsx +1 -1
  86. package/src/components/editor/propertyPanelColorGradingSection.tsx +8 -8
  87. package/src/components/editor/propertyPanelColorGradingSlider.tsx +1 -1
  88. package/src/components/editor/propertyPanelColorSecondary.tsx +4 -4
  89. package/src/components/editor/propertyPanelFill.tsx +6 -6
  90. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  91. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +1 -1
  92. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +6 -9
  93. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  94. package/src/components/editor/propertyPanelFlatMediaSection.tsx +2 -2
  95. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -2
  96. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +2 -2
  97. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +4 -3
  98. package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
  99. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +1 -1
  100. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  101. package/src/components/editor/propertyPanelFont.tsx +71 -48
  102. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  103. package/src/components/editor/propertyPanelFxControls.tsx +2 -2
  104. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  105. package/src/components/editor/propertyPanelFxNodeRow.tsx +1 -1
  106. package/src/components/editor/propertyPanelFxPresetRun.tsx +1 -1
  107. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  108. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  109. package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
  110. package/src/components/editor/propertyPanelSections.tsx +2 -2
  111. package/src/components/editor/propertyPanelValueTier.test.ts +1 -1
  112. package/src/components/editor/propertyPanelValueTier.ts +1 -1
  113. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  114. package/src/components/editor/studioCodeTheme.ts +99 -0
  115. package/src/components/editor/useDomEditNudge.test.tsx +31 -0
  116. package/src/components/editor/useDomEditNudge.ts +2 -2
  117. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  118. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  119. package/src/components/editor/useMotionPathData.ts +5 -1
  120. package/src/components/feedback/StudioFeedbackCard.tsx +11 -23
  121. package/src/components/nle/AssetPreviewOverlay.tsx +1 -1
  122. package/src/components/nle/AudioMeterStrip.tsx +2 -2
  123. package/src/components/nle/NLEPreview.test.ts +8 -4
  124. package/src/components/nle/PreviewOverlays.tsx +1 -1
  125. package/src/components/nle/PreviewPane.tsx +3 -3
  126. package/src/components/nle/PreviewPoster.tsx +29 -1
  127. package/src/components/nle/PreviewZoomOverlay.tsx +1 -1
  128. package/src/components/panels/SlideshowPanel.tsx +4 -4
  129. package/src/components/panels/SlideshowSubPanels.tsx +19 -21
  130. package/src/components/panels/VariablesBindElement.tsx +1 -1
  131. package/src/components/panels/VariablesDeclarationForm.tsx +1 -1
  132. package/src/components/panels/VariablesPanel.tsx +4 -6
  133. package/src/components/panels/VariablesRowAction.tsx +1 -1
  134. package/src/components/renders/FfmpegRequiredNotice.tsx +4 -4
  135. package/src/components/renders/RenderQueue.tsx +2 -2
  136. package/src/components/renders/RenderQueueItem.tsx +3 -3
  137. package/src/components/sidebar/AssetCard.tsx +3 -3
  138. package/src/components/sidebar/AssetContextMenu.tsx +7 -7
  139. package/src/components/sidebar/AssetsTab.tsx +6 -6
  140. package/src/components/sidebar/AudioRow.tsx +5 -3
  141. package/src/components/sidebar/BlocksTab.tsx +6 -6
  142. package/src/components/sidebar/CompositionsTab.drag.test.tsx +3 -69
  143. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +4 -50
  144. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +256 -0
  145. package/src/components/sidebar/CompositionsTab.tsx +65 -12
  146. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  147. package/src/components/sidebar/SidebarLintButton.tsx +3 -3
  148. package/src/components/sidebar/compositionsTabTestUtils.tsx +39 -0
  149. package/src/components/timelineToolbarStyles.ts +3 -3
  150. package/src/components/ui/Button.tsx +4 -4
  151. package/src/components/ui/HyperframesLogo.tsx +19 -18
  152. package/src/components/ui/Input.tsx +1 -1
  153. package/src/components/ui/Menu.tsx +4 -2
  154. package/src/hooks/appHotkeysDispatch.ts +2 -1
  155. package/src/hooks/elementOffsetStager.ts +8 -7
  156. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  157. package/src/hooks/useAppHotkeys.test.ts +32 -0
  158. package/src/hooks/useAppHotkeys.ts +1 -2
  159. package/src/hooks/useClipboard.source.test.ts +141 -23
  160. package/src/hooks/useClipboard.ts +154 -123
  161. package/src/hooks/useDomEditCommits.ts +0 -3
  162. package/src/hooks/useDomEditSession.test.tsx +76 -72
  163. package/src/hooks/useDomEditSession.ts +7 -43
  164. package/src/hooks/useDomGeometryCommit.test.tsx +4 -1
  165. package/src/hooks/useDomGeometryCommit.ts +0 -1
  166. package/src/hooks/useDomGeometryCommits.test.tsx +177 -32
  167. package/src/hooks/useDomGeometryCommits.ts +4 -49
  168. package/src/hooks/useGsapTweenCache.ts +1 -2
  169. package/src/hooks/useInlineTextEdit.ts +16 -2
  170. package/src/hooks/useLivePreviewIframe.ts +18 -0
  171. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  172. package/src/hooks/usePreviewPersistence.ts +2 -5
  173. package/src/hooks/useRenderClipContent.ts +3 -3
  174. package/src/index.ts +2 -0
  175. package/src/livePreviewIframeExport.test.tsx +33 -0
  176. package/src/main.tsx +4 -1
  177. package/src/player/components/AudioWaveform.test.tsx +36 -15
  178. package/src/player/components/AudioWaveform.tsx +24 -7
  179. package/src/player/components/ClipContextMenu.tsx +1 -1
  180. package/src/player/components/CompositionThumbnail.tsx +23 -11
  181. package/src/player/components/EditModal.tsx +6 -6
  182. package/src/player/components/KeyframeDiamondContextMenu.tsx +2 -2
  183. package/src/player/components/PlayerControls.tsx +3 -3
  184. package/src/player/components/ShortcutsPanel.tsx +8 -20
  185. package/src/player/components/TimelineCanvas.tsx +1 -1
  186. package/src/player/components/TimelineClip.test.tsx +38 -0
  187. package/src/player/components/TimelineClip.tsx +16 -8
  188. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  189. package/src/player/components/TimelineClipFades.tsx +368 -137
  190. package/src/player/components/TimelineEmptyState.tsx +2 -2
  191. package/src/player/components/TimelineLanes.tsx +0 -1
  192. package/src/player/components/TrackClipCount.tsx +1 -1
  193. package/src/player/components/VolumeControl.tsx +1 -1
  194. package/src/player/components/timelineClipChildren.tsx +1 -1
  195. package/src/player/components/timelineMotionStyles.test.ts +6 -22
  196. package/src/player/components/trackHeaderLabelRows.tsx +16 -16
  197. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  198. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  199. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  200. package/src/player/lib/automationStoreSync.test.ts +18 -1
  201. package/src/player/lib/automationStoreSync.ts +29 -17
  202. package/src/player/lib/thumbnailScheduler.ts +4 -5
  203. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  204. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  205. package/src/player/sceneSwap.ts +18 -0
  206. package/src/styles/components.css +26 -0
  207. package/src/styles/hex-baseline.json +7 -19
  208. package/src/styles/studio.css +4 -7
  209. package/src/styles/tailwind-preset.shared.js +2 -1
  210. package/src/styles/theme.css +214 -147
  211. package/src/styles/theme.test.ts +13 -9
  212. package/src/utils/blockCategories.ts +41 -9
  213. package/src/utils/clipboardPayload.ts +12 -1
  214. package/src/utils/studioPendingEdits.ts +21 -0
  215. package/src/utils/studioTheme.test.tsx +141 -0
  216. package/src/utils/studioTheme.ts +34 -0
  217. package/src/utils/studioUiPreferences.ts +3 -0
  218. package/src/utils/typingTarget.test.ts +8 -1
  219. package/src/utils/typingTarget.ts +5 -0
  220. package/dist/assets/index-CQN3M56N.css +0 -1
  221. package/dist/assets/index-De2aO4XM.js +0 -399
  222. package/dist/chunk-62DDWOSH.js.map +0 -1
  223. package/dist/chunk-6GAO75IU.js.map +0 -1
  224. package/dist/chunk-HIGHVWYD.js.map +0 -1
  225. package/src/hooks/gsapTargetCache.ts +0 -62
@@ -12,6 +12,7 @@ import {
12
12
  import { readCssRotationTarget, readRotationBase } from "./rotationDraft";
13
13
  import {
14
14
  beginStudioManualEditGesture,
15
+ endStudioManualEditGesture,
15
16
  captureStudioBoxSize,
16
17
  captureStudioPathOffset,
17
18
  captureStudioRotation,
@@ -81,48 +82,54 @@ export function startGroupDrag(
81
82
  }
82
83
  members.push(result.member);
83
84
  }
84
- resetDragMoveLog();
85
- logDrag("group-start", {
86
- // A member whose mapping differs from its neighbours travels a different
87
- // distance for the same pointer delta, which is the group coming apart.
88
- members: Object.fromEntries(
89
- members.map((member) => [
90
- member.key,
91
- {
92
- map: `${member.screenToOffset.a.toFixed(3)},${member.screenToOffset.d.toFixed(3)}`,
93
- base: `${Math.round(member.baseGsap.x)},${Math.round(member.baseGsap.y)}`,
94
- offset: `${Math.round(member.initialOffset.x)},${Math.round(member.initialOffset.y)}`,
95
- },
96
- ]),
97
- ),
98
- at: readDragPositions(members),
99
- });
85
+ try {
86
+ resetDragMoveLog();
87
+ logDrag("group-start", {
88
+ // A member whose mapping differs from its neighbours travels a different
89
+ // distance for the same pointer delta, which is the group coming apart.
90
+ members: Object.fromEntries(
91
+ members.map((member) => [
92
+ member.key,
93
+ {
94
+ map: `${member.screenToOffset.a.toFixed(3)},${member.screenToOffset.d.toFixed(3)}`,
95
+ base: `${Math.round(member.baseGsap.x)},${Math.round(member.baseGsap.y)}`,
96
+ offset: `${Math.round(member.initialOffset.x)},${Math.round(member.initialOffset.y)}`,
97
+ },
98
+ ]),
99
+ ),
100
+ at: readDragPositions(members),
101
+ });
100
102
 
101
- const overlayEl = opts.overlayRef.current;
102
- const iframe = opts.iframeRef.current;
103
- const snapContext =
104
- overlayEl && iframe
105
- ? collectSnapContext({
106
- overlayEl,
107
- iframe,
108
- excludeElements: buildExcludeElements({
103
+ const overlayEl = opts.overlayRef.current;
104
+ const iframe = opts.iframeRef.current;
105
+ const snapContext =
106
+ overlayEl && iframe
107
+ ? collectSnapContext({
108
+ overlayEl,
109
109
  iframe,
110
- groupSelections: items.map((i) => i.selection),
111
- }),
112
- })
113
- : undefined;
110
+ excludeElements: buildExcludeElements({
111
+ iframe,
112
+ groupSelections: items.map((i) => i.selection),
113
+ }),
114
+ })
115
+ : undefined;
114
116
 
115
- e.preventDefault();
116
- e.stopPropagation();
117
- e.currentTarget.setPointerCapture(e.pointerId);
118
- opts.rafPausedRef.current = true;
119
- opts.groupGestureRef.current = {
120
- startX: e.clientX,
121
- startY: e.clientY,
122
- originItems: items,
123
- members,
124
- snapContext,
125
- };
117
+ e.preventDefault();
118
+ e.stopPropagation();
119
+ e.currentTarget.setPointerCapture(e.pointerId);
120
+ opts.rafPausedRef.current = true;
121
+ opts.groupGestureRef.current = {
122
+ startX: e.clientX,
123
+ startY: e.clientY,
124
+ originItems: items,
125
+ members,
126
+ snapContext,
127
+ };
128
+ } catch (error) {
129
+ opts.groupGestureRef.current = null;
130
+ restoreManualOffsetDragMembers(members);
131
+ throw error;
132
+ }
126
133
  return true;
127
134
  }
128
135
 
@@ -219,84 +226,89 @@ export function startGesture(
219
226
  }
220
227
  }
221
228
 
222
- // Rotation base: the angle the element shows. An element GSAP does not turn, or a plain-translate
223
- // move, never asks GSAP: reading a property makes it bake the CSS into its transform.
224
- const plain = !!pathOffsetMember?.plainTranslate || !gsapWritesRotation(sel.element);
225
- const plainRotation = plain && kind === "rotate" ? readCssRotationTarget(sel.element) : null;
226
- const rotation = { angle: readRotationBase(sel.element, plain) };
227
- const overlayBounds = overlayEl?.getBoundingClientRect();
228
- const centerX = (overlayBounds?.left ?? 0) + rect.left + rect.width / 2;
229
- const centerY = (overlayBounds?.top ?? 0) + rect.top + rect.height / 2;
229
+ // A throw before the gesture is armed would leave its mark, and the mark holds every reload.
230
+ try {
231
+ // Rotation base: the angle the element shows. An element GSAP does not turn, or a plain-translate
232
+ // move, never asks GSAP: reading a property makes it bake the CSS into its transform.
233
+ const plain = !!pathOffsetMember?.plainTranslate || !gsapWritesRotation(sel.element);
234
+ const plainRotation = plain && kind === "rotate" ? readCssRotationTarget(sel.element) : null;
235
+ const rotation = { angle: readRotationBase(sel.element, plain) };
236
+ const overlayBounds = overlayEl?.getBoundingClientRect();
237
+ const centerX = (overlayBounds?.left ?? 0) + rect.left + rect.width / 2;
238
+ const centerY = (overlayBounds?.top ?? 0) + rect.top + rect.height / 2;
230
239
 
231
- const iframe = opts.iframeRef.current;
240
+ const iframe = opts.iframeRef.current;
232
241
 
233
- // For a center-anchored corner resize, capture the element's rendered CENTER (the
234
- // centroid of its four real, rotation-aware corners) now, so per-frame anchoring
235
- // can pin that exact point instead of an axis-aligned width/height delta (which
236
- // only holds the center still when the element grows symmetrically from an
237
- // unrotated layout box). Present whenever an anchor member exists (all corners).
238
- let resizeFixedCenterStart: { x: number; y: number } | undefined;
239
- if (kind === "resize" && pathOffsetMember && overlayEl && iframe) {
240
- const corners = elementCornerOverlayPoints(overlayEl, iframe, sel.element);
241
- if (corners) resizeFixedCenterStart = overlayCornersCentroid(corners);
242
- }
243
- const snapContext =
244
- (kind === "drag" || kind === "resize") && overlayEl && iframe
245
- ? collectSnapContext({
246
- overlayEl,
247
- iframe,
248
- excludeElements: buildExcludeElements({ iframe, selection: sel }),
249
- })
250
- : undefined;
251
- e.preventDefault();
252
- e.stopPropagation();
253
- e.currentTarget.setPointerCapture(e.pointerId);
254
- opts.rafPausedRef.current = true;
255
- opts.gestureRef.current = {
256
- kind,
257
- mode,
258
- selection: sel,
259
- startX: e.clientX,
260
- startY: e.clientY,
261
- centerX,
262
- centerY,
263
- initialPathOffset,
264
- initialRotation: captureStudioRotation(sel.element),
265
- initialBoxSize: captureStudioBoxSize(sel.element),
266
- pathOffsetMember,
267
- originLeft: rect.left,
268
- originTop: rect.top,
269
- originWidth: rect.width,
270
- originHeight: rect.height,
271
- actualWidth,
272
- actualHeight,
273
- actualRotation: rotation.angle,
274
- plainRotation,
275
- editScaleX: rect.editScaleX,
276
- editScaleY: rect.editScaleY,
277
- contentScaleX,
278
- contentScaleY,
279
- manualEditDragToken,
280
- snapContext,
281
- resizeHandle: kind === "resize" ? (options?.resizeHandle ?? "se") : undefined,
282
- resizeFixedCenterStart,
283
- };
284
- if (kind === "resize") {
285
- resetResizeMoveLog();
286
- logResize("start", {
287
- handle: options?.resizeHandle ?? "se",
288
- pointer: { x: e.clientX, y: e.clientY },
289
- center: { x: centerX, y: centerY },
290
- origin: { left: rect.left, top: rect.top, w: rect.width, h: rect.height },
291
- actual: { w: actualWidth, h: actualHeight },
292
- editScale: { x: rect.editScaleX, y: rect.editScaleY },
293
- contentScale: { x: contentScaleX, y: contentScaleY },
294
- rotation: rotation.angle,
295
- hasOffsetMember: !!pathOffsetMember,
296
- fixedCenterStart: resizeFixedCenterStart ?? null,
297
- initialBoxSize: opts.gestureRef.current?.initialBoxSize ?? null,
298
- initialInlineStyle: sel.element.getAttribute("style"),
299
- });
242
+ // A corner resize pins the centroid of the four rotation-aware corners: an axis-aligned
243
+ // size delta only holds the centre still for a symmetric, unrotated growth.
244
+ let resizeFixedCenterStart: { x: number; y: number } | undefined;
245
+ if (kind === "resize" && pathOffsetMember && overlayEl && iframe) {
246
+ const corners = elementCornerOverlayPoints(overlayEl, iframe, sel.element);
247
+ if (corners) resizeFixedCenterStart = overlayCornersCentroid(corners);
248
+ }
249
+ const snapContext =
250
+ (kind === "drag" || kind === "resize") && overlayEl && iframe
251
+ ? collectSnapContext({
252
+ overlayEl,
253
+ iframe,
254
+ excludeElements: buildExcludeElements({ iframe, selection: sel }),
255
+ })
256
+ : undefined;
257
+ e.preventDefault();
258
+ e.stopPropagation();
259
+ e.currentTarget.setPointerCapture(e.pointerId);
260
+ opts.rafPausedRef.current = true;
261
+ opts.gestureRef.current = {
262
+ kind,
263
+ mode,
264
+ selection: sel,
265
+ startX: e.clientX,
266
+ startY: e.clientY,
267
+ centerX,
268
+ centerY,
269
+ initialPathOffset,
270
+ initialRotation: captureStudioRotation(sel.element),
271
+ initialBoxSize: captureStudioBoxSize(sel.element),
272
+ pathOffsetMember,
273
+ originLeft: rect.left,
274
+ originTop: rect.top,
275
+ originWidth: rect.width,
276
+ originHeight: rect.height,
277
+ actualWidth,
278
+ actualHeight,
279
+ actualRotation: rotation.angle,
280
+ plainRotation,
281
+ editScaleX: rect.editScaleX,
282
+ editScaleY: rect.editScaleY,
283
+ contentScaleX,
284
+ contentScaleY,
285
+ manualEditDragToken,
286
+ snapContext,
287
+ resizeHandle: kind === "resize" ? (options?.resizeHandle ?? "se") : undefined,
288
+ resizeFixedCenterStart,
289
+ };
290
+ if (kind === "resize") {
291
+ resetResizeMoveLog();
292
+ logResize("start", {
293
+ handle: options?.resizeHandle ?? "se",
294
+ pointer: { x: e.clientX, y: e.clientY },
295
+ center: { x: centerX, y: centerY },
296
+ origin: { left: rect.left, top: rect.top, w: rect.width, h: rect.height },
297
+ actual: { w: actualWidth, h: actualHeight },
298
+ editScale: { x: rect.editScaleX, y: rect.editScaleY },
299
+ contentScale: { x: contentScaleX, y: contentScaleY },
300
+ rotation: rotation.angle,
301
+ hasOffsetMember: !!pathOffsetMember,
302
+ fixedCenterStart: resizeFixedCenterStart ?? null,
303
+ initialBoxSize: opts.gestureRef.current?.initialBoxSize ?? null,
304
+ initialInlineStyle: sel.element.getAttribute("style"),
305
+ });
306
+ }
307
+ } catch (error) {
308
+ opts.gestureRef.current = null;
309
+ if (pathOffsetMember) restoreManualOffsetDragMembers([pathOffsetMember]);
310
+ else if (manualEditDragToken) endStudioManualEditGesture(sel.element, manualEditDragToken);
311
+ throw error;
300
312
  }
301
313
  return true;
302
314
  }
@@ -56,7 +56,7 @@ export function MiniCurveSvg({
56
56
  <path
57
57
  d={d}
58
58
  fill="none"
59
- stroke={active ? "#3CE6AC" : "#737373"}
59
+ className={active ? "stroke-accent-ink" : "stroke-text-muted"}
60
60
  strokeWidth="1.5"
61
61
  strokeLinecap="round"
62
62
  />
@@ -0,0 +1,275 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { makeSelection } from "../../hooks/domSelectionTestHarness";
7
+ import { useInlineTextEdit } from "../../hooks/useInlineTextEdit";
8
+ import {
9
+ makePreview,
10
+ mountPlayerWithPreview,
11
+ paintShadow,
12
+ resetPlayerStore,
13
+ type TimelinePlayerApi,
14
+ } from "../../player/hooks/timelinePlayerTestHarness";
15
+ import type { DomEditSelection } from "./domEditing";
16
+ import "./domEditOverlayTestMocks";
17
+ import { DomEditOverlay } from "./DomEditOverlay";
18
+ import { PreviewReadOnlyProvider } from "./previewReadOnlyContext";
19
+ import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "./manualEditsTypes";
20
+
21
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
22
+
23
+ const layout = vi.hoisted(() => ({
24
+ group: [] as unknown[],
25
+ rect: { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
26
+ }));
27
+ vi.mock("./useDomEditOverlayRects", () => ({
28
+ useDomEditOverlayRects: () => {
29
+ const { rect, group } = layout;
30
+ const groupOverlayItemsRef = { current: group };
31
+ const noop = () => undefined;
32
+ return {
33
+ overlayRect: rect,
34
+ overlayRectRef: { current: rect },
35
+ groupOverlayItems: group,
36
+ groupOverlayItemsRef,
37
+ hoverRect: null,
38
+ childRects: [],
39
+ setOverlayRect: noop,
40
+ setGroupOverlayItems: noop,
41
+ };
42
+ },
43
+ }));
44
+ vi.mock("../../utils/gsapSoftReload", async (importOriginal) => ({
45
+ ...(await importOriginal<typeof import("../../utils/gsapSoftReload")>()),
46
+ ensureMotionPathPluginLoaded: vi.fn(),
47
+ }));
48
+
49
+ const BOX = '[data-dom-edit-selection-box="true"]';
50
+ const RECT = layout.rect;
51
+ const TWO_LAYERS = '<h1 id="title">Title</h1><p id="sub">Sub</p>';
52
+
53
+ /** What the server serves: the file as the last commit left it. */
54
+ let file = { title: "", sub: "" };
55
+ const served = (query: string) =>
56
+ makePreview(
57
+ `<h1 id="title" style="${file.title}">Title</h1><p id="sub" style="${file.sub}">Sub</p>`,
58
+ query,
59
+ );
60
+
61
+ let overlayRoot: Root;
62
+ let player: ReturnType<typeof mountPlayerWithPreview>;
63
+
64
+ const marked = (doc: Document) => doc.querySelectorAll(`[${STUDIO_MANUAL_EDIT_GESTURE_ATTR}]`);
65
+ const byId = (iframe: HTMLIFrameElement | null, id: string) =>
66
+ iframe!.contentDocument!.getElementById(id) as HTMLElement;
67
+ const api = (): TimelinePlayerApi => player.getApi();
68
+
69
+ function pointer(target: Element, type: string, x: number, y: number) {
70
+ const init = { bubbles: true, cancelable: true, button: 0, pointerId: 1, clientX: x, clientY: y };
71
+ act(() => void target.dispatchEvent(new PointerEvent(type, init)));
72
+ }
73
+
74
+ /** The real player with a two-layer preview, and the real overlay editing it. */
75
+ function mountEditor(group: boolean) {
76
+ file = { title: "", sub: "" };
77
+ const live = makePreview(TWO_LAYERS);
78
+ player = mountPlayerWithPreview(live);
79
+ const selections = ["title", "sub"].map((id) => makeSelection(id, byId(live, id)));
80
+ layout.group = group
81
+ ? selections.map((s) => ({ key: s.id, selection: s, element: s.element, rect: RECT }))
82
+ : [];
83
+ const write = (sel: DomEditSelection, next: { x: number; y: number }) => {
84
+ file = { ...file, [sel.id!]: `translate: ${next.x}px ${next.y}px` };
85
+ return Promise.resolve();
86
+ };
87
+ const onPathOffsetCommit = vi.fn(write);
88
+ const onGroupPathOffsetCommit = vi.fn(
89
+ async (updates: { selection: DomEditSelection; next: { x: number; y: number } }[]) => {
90
+ for (const { selection, next } of updates) await write(selection, next);
91
+ },
92
+ );
93
+ const host = document.body.appendChild(document.createElement("div"));
94
+ overlayRoot = createRoot(host);
95
+ const render = (readOnly: boolean) =>
96
+ act(() =>
97
+ overlayRoot.render(
98
+ <PreviewReadOnlyProvider readOnly={readOnly}>
99
+ <DomEditOverlay
100
+ iframeRef={api().iframeRef}
101
+ activeCompositionPath={null}
102
+ selection={group ? null : selections[0]!}
103
+ groupSelections={group ? selections : []}
104
+ hoverSelection={null}
105
+ onCanvasMouseDown={() => undefined}
106
+ onCanvasPointerMove={() => Promise.resolve(null)}
107
+ onCanvasPointerLeave={() => undefined}
108
+ onSelectionChange={() => undefined}
109
+ onBlockedMove={() => undefined}
110
+ onPathOffsetCommit={onPathOffsetCommit}
111
+ onGroupPathOffsetCommit={onGroupPathOffsetCommit}
112
+ onBoxSizeCommit={() => undefined}
113
+ onRotationCommit={() => undefined}
114
+ />
115
+ </PreviewReadOnlyProvider>,
116
+ ),
117
+ );
118
+ render(false);
119
+ const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLElement;
120
+ return { live, overlay, box: overlay.querySelector(BOX)!, onPathOffsetCommit, render };
121
+ }
122
+
123
+ /** A reload whose shadow paints while the gesture is still live. */
124
+ async function reloadMidGesture() {
125
+ act(() => api().refreshPlayer());
126
+ const shadow = served("?_t=1");
127
+ const gen = await paintShadow(api, shadow);
128
+ return { shadow, gen };
129
+ }
130
+
131
+ async function settle() {
132
+ await act(async () => {
133
+ for (let tick = 0; tick < 10; tick++) await Promise.resolve();
134
+ });
135
+ }
136
+
137
+ beforeEach(() => {
138
+ HTMLElement.prototype.setPointerCapture = () => undefined;
139
+ HTMLElement.prototype.releasePointerCapture = () => undefined;
140
+ });
141
+
142
+ afterEach(() => {
143
+ act(() => overlayRoot?.unmount());
144
+ act(() => player?.root.unmount());
145
+ layout.group = [];
146
+ document.body.innerHTML = "";
147
+ resetPlayerStore();
148
+ });
149
+
150
+ describe("a reload during a drag", () => {
151
+ it.each([
152
+ ["one layer", false],
153
+ ["a group", true],
154
+ ])(
155
+ "that paints before the drop of %s waits, then shows the drop from a fresh load",
156
+ async (_, group) => {
157
+ const { live, overlay, box } = mountEditor(group);
158
+ pointer(box, "pointerdown", 150, 150);
159
+ pointer(overlay, "pointermove", 170, 160);
160
+ pointer(overlay, "pointermove", 190, 170);
161
+ const held = await reloadMidGesture();
162
+ expect(api().iframeRef.current, "promoted under the pointer").toBe(live);
163
+
164
+ pointer(overlay, "pointerup", 190, 170);
165
+ await settle();
166
+ expect(byId(live, "title").style.getPropertyValue("translate")).toBe("40px 20px");
167
+ expect(file.title).toBe("translate: 40px 20px");
168
+ if (group) expect(file.sub).toBe("translate: 40px 20px");
169
+ expect(marked(live.contentDocument!)).toHaveLength(0);
170
+ expect(api().iframeRef.current, "the shadow loaded before the drop").toBe(live);
171
+
172
+ const fresh = served("?_t=2");
173
+ expect(await paintShadow(api, fresh)).toBeGreaterThan(held.gen);
174
+ expect(api().iframeRef.current).toBe(fresh);
175
+ expect(byId(fresh, "title").style.getPropertyValue("translate")).toBe("40px 20px");
176
+ if (group) expect(byId(fresh, "sub").style.getPropertyValue("translate")).toBe("40px 20px");
177
+ },
178
+ );
179
+
180
+ it("that paints after the drop shows the drop from a fresh load, not the file before it", async () => {
181
+ const { live, overlay, box } = mountEditor(false);
182
+ pointer(box, "pointerdown", 150, 150);
183
+ pointer(overlay, "pointermove", 190, 170);
184
+ act(() => api().refreshPlayer());
185
+ const beforeDrop = served("?_t=1");
186
+ pointer(overlay, "pointerup", 190, 170);
187
+ await settle();
188
+ expect(file.title).toBe("translate: 40px 20px");
189
+
190
+ const requested = await paintShadow(api, beforeDrop);
191
+ expect(api().iframeRef.current, "the file before the drop").toBe(live);
192
+ const fresh = served("?_t=2");
193
+ expect(await paintShadow(api, fresh)).toBeGreaterThan(requested);
194
+ expect(byId(fresh, "title").style.getPropertyValue("translate")).toBe("40px 20px");
195
+ });
196
+ });
197
+
198
+ type Editor = ReturnType<typeof mountEditor>;
199
+
200
+ describe("every way a drag ends without a drop clears its mark and promotes the held reload", () => {
201
+ it.each([
202
+ ["pointercancel", (e: Editor) => pointer(e.overlay, "pointercancel", 0, 0)],
203
+ ["lostpointercapture", (e: Editor) => pointer(e.box, "lostpointercapture", 0, 0)],
204
+ ["window blur", () => act(() => void window.dispatchEvent(new Event("blur")))],
205
+ ["a switch to read-only", (e: Editor) => e.render(true)],
206
+ ["overlay unmount", () => act(() => overlayRoot.unmount())],
207
+ ])("%s", async (_, end) => {
208
+ const editor = mountEditor(false);
209
+ pointer(editor.box, "pointerdown", 150, 150);
210
+ pointer(editor.overlay, "pointermove", 190, 170);
211
+ expect(marked(editor.live.contentDocument!)).toHaveLength(1);
212
+ const held = await reloadMidGesture();
213
+ expect(api().iframeRef.current).toBe(editor.live);
214
+
215
+ end(editor);
216
+ await settle();
217
+ expect(marked(editor.live.contentDocument!)).toHaveLength(0);
218
+ expect(byId(editor.live, "title").style.getPropertyValue("translate")).not.toBe("40px 20px");
219
+ expect(editor.onPathOffsetCommit).not.toHaveBeenCalled();
220
+ expect(api().iframeRef.current, "nothing was saved, so the held copy is current").toBe(
221
+ held.shadow,
222
+ );
223
+ });
224
+
225
+ it("a press that throws before the drag is armed leaves no mark behind", async () => {
226
+ const editor = mountEditor(false);
227
+ HTMLElement.prototype.setPointerCapture = () => {
228
+ throw new Error("capture refused");
229
+ };
230
+ const swallow = (event: ErrorEvent) => event.preventDefault();
231
+ window.addEventListener("error", swallow);
232
+ try {
233
+ pointer(editor.box, "pointerdown", 150, 150);
234
+ } catch {
235
+ // React may rethrow the handler's error; either way the mark must be gone.
236
+ } finally {
237
+ window.removeEventListener("error", swallow);
238
+ }
239
+ expect(marked(editor.live.contentDocument!)).toHaveLength(0);
240
+ const { shadow } = await reloadMidGesture();
241
+ expect(api().iframeRef.current).toBe(shadow);
242
+ });
243
+ });
244
+
245
+ describe("an inline text edit", () => {
246
+ it("holds a reload while open, and the reload after it shows the saved text", async () => {
247
+ const live = makePreview(TWO_LAYERS);
248
+ player = mountPlayerWithPreview(live);
249
+ let land: () => void = () => {};
250
+ const onCommit = vi.fn(() => new Promise<void>((resolve) => (land = resolve)));
251
+ let controls: ReturnType<typeof useInlineTextEdit> | null = null;
252
+ function Editor() {
253
+ controls = useInlineTextEdit({ onCommit });
254
+ return null;
255
+ }
256
+ overlayRoot = createRoot(document.body.appendChild(document.createElement("div")));
257
+ act(() => overlayRoot.render(<Editor />));
258
+ act(() => void controls!.start(byId(live, "title")));
259
+ const held = await reloadMidGesture();
260
+ expect(api().iframeRef.current).toBe(live);
261
+
262
+ act(() => controls!.commit());
263
+ await settle();
264
+ expect(onCommit).toHaveBeenCalledTimes(1);
265
+ expect(marked(live.contentDocument!), "closed, though the save is still out").toHaveLength(0);
266
+ expect(api().iframeRef.current, "the held copy predates the text").toBe(live);
267
+ expect(api().previewSlots.find((slot) => slot.role === "shadow")?.gen).toBe(held.gen);
268
+
269
+ await act(async () => land());
270
+ await settle();
271
+ const afterSave = served("?_t=2");
272
+ expect(await paintShadow(api, afterSave)).toBeGreaterThan(held.gen);
273
+ expect(api().iframeRef.current).toBe(afterSave);
274
+ });
275
+ });
@@ -46,9 +46,42 @@ export function beginStudioManualEditGesture(element: HTMLElement): string {
46
46
  return token;
47
47
  }
48
48
 
49
+ const GESTURE_ENDED = "hf-manual-edit-gesture-ended";
50
+
49
51
  export function endStudioManualEditGesture(element: HTMLElement, token?: string): void {
50
52
  if (token && element.getAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR) !== token) return;
53
+ if (!element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return;
51
54
  element.removeAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR);
55
+ const doc = element.ownerDocument;
56
+ doc.dispatchEvent(new (doc.defaultView?.Event ?? Event)(GESTURE_ENDED));
57
+ }
58
+
59
+ export function isStudioManualEditGestureLiveIn(doc: Document): boolean {
60
+ return doc.querySelector(`[${STUDIO_MANUAL_EDIT_GESTURE_ATTR}]`) !== null;
61
+ }
62
+
63
+ /** Runs `run` once the last gesture in `doc` ends; the returned function stops waiting. */
64
+ export function afterStudioManualEditGestures(doc: Document, run: () => void): () => void {
65
+ const onEnded = () => {
66
+ if (isStudioManualEditGestureLiveIn(doc)) return;
67
+ stop();
68
+ run();
69
+ };
70
+ const stop = () => doc.removeEventListener(GESTURE_ENDED, onEnded);
71
+ doc.addEventListener(GESTURE_ENDED, onEnded);
72
+ return stop;
73
+ }
74
+
75
+ const gestureSaves = new WeakMap<Document, number>();
76
+
77
+ export function studioManualEditSavesIn(doc: Document): number {
78
+ return gestureSaves.get(doc) ?? 0;
79
+ }
80
+
81
+ /** A gesture's save in `element`'s document: a reload requested before it shows the file before it. */
82
+ export function noteStudioManualEditSave(element: HTMLElement): void {
83
+ const doc = element.ownerDocument;
84
+ gestureSaves.set(doc, studioManualEditSavesIn(doc) + 1);
52
85
  }
53
86
 
54
87
  function isStudioManualEditGestureActive(element: HTMLElement): boolean {
@@ -253,9 +286,9 @@ function stripGsapTranslateFromTransform(element: HTMLElement): void {
253
286
  // — as the non-GSAP path does — composes ON TOP of GSAP's transform, and the
254
287
  // subsequent strip/reapply math compounds into a runaway matrix that flings the
255
288
  // element off-canvas. So for GSAP-animated elements we keep `translate: none`
256
- // and push the offset straight into GSAP's x/y via gsap.set; the var() offset is
257
- // still persisted (buildPathOffsetPatches), and GSAP re-reads it at init on
258
- // reload. Returns true when handled as GSAP (caller must skip the CSS path).
289
+ // and push the offset straight into GSAP's x/y via gsap.set; the var() offset an
290
+ // older Studio saved stays in the file, and GSAP re-reads it at init on reload.
291
+ // Returns true when handled as GSAP (caller must skip the CSS path).
259
292
  // fallow-ignore-next-line complexity
260
293
  function applyStudioPathOffsetViaGsap(
261
294
  element: HTMLElement,