@hyperframes/studio 0.8.103 → 0.8.105

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 (200) hide show
  1. package/dist/assets/{hyperframes-player-DG795Hwu.js → hyperframes-player-tnfCiVdR.js} +1 -1
  2. package/dist/assets/{index-BiEtDkae.js → index-Ca6LZlVb.js} +1 -1
  3. package/dist/assets/index-Cre9X4RS.css +1 -0
  4. package/dist/assets/{index-BPJRf2X9.js → index-DEDhPmPD.js} +215 -215
  5. package/dist/assets/{index-DuCZ3pQw.js → index-zpe332hb.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-6GAO75IU.js → chunk-NCUBAVGK.js} +10 -8
  9. package/dist/chunk-NCUBAVGK.js.map +1 -0
  10. package/dist/{chunk-HIGHVWYD.js → chunk-QADHJFDF.js} +2 -2
  11. package/dist/index.css +6 -1
  12. package/dist/index.css.map +1 -1
  13. package/dist/index.d.ts +181 -168
  14. package/dist/index.html +2 -2
  15. package/dist/index.js +1018 -919
  16. package/dist/index.js.map +1 -1
  17. package/dist/player.js +2 -2
  18. package/dist/styles/tailwind-preset.js.map +1 -1
  19. package/dist/styles.css +1 -1
  20. package/dist/ui.js +2 -2
  21. package/package.json +8 -8
  22. package/src/captions/components/CaptionAnimationPanel.tsx +2 -2
  23. package/src/captions/components/CaptionPropertyPanel.tsx +2 -2
  24. package/src/captions/components/CaptionTimeline.tsx +3 -3
  25. package/src/components/AskAgentModal.tsx +1 -1
  26. package/src/components/LintModal.tsx +35 -35
  27. package/src/components/StudioErrorBoundary.tsx +1 -1
  28. package/src/components/StudioHeader.dom.test.tsx +4 -4
  29. package/src/components/StudioHeader.tsx +2 -1
  30. package/src/components/StudioToast.tsx +7 -16
  31. package/src/components/TimelineToolPicker.tsx +1 -1
  32. package/src/components/TimelineToolbar.tsx +11 -11
  33. package/src/components/dock/Dock.tsx +1 -1
  34. package/src/components/dock/dock.css +9 -1
  35. package/src/components/editor/AnimationCard.tsx +5 -5
  36. package/src/components/editor/AnimationCardParts.tsx +1 -1
  37. package/src/components/editor/BlockParamsPanel.tsx +3 -3
  38. package/src/components/editor/BorderRadiusEditor.tsx +8 -15
  39. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  40. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  41. package/src/components/editor/DomEditOverlay.tsx +5 -6
  42. package/src/components/editor/DomEditRotateHandle.tsx +1 -1
  43. package/src/components/editor/EaseCurveSection.tsx +14 -19
  44. package/src/components/editor/EaseModeControls.tsx +2 -2
  45. package/src/components/editor/EaseParamFields.tsx +8 -8
  46. package/src/components/editor/FileTree.tsx +1 -1
  47. package/src/components/editor/FileTreeIcons.tsx +47 -26
  48. package/src/components/editor/FileTreeNodes.tsx +11 -8
  49. package/src/components/editor/GestureRecordControl.tsx +3 -3
  50. package/src/components/editor/GsapAddAnimationControl.tsx +1 -1
  51. package/src/components/editor/GsapAnimationSection.tsx +2 -2
  52. package/src/components/editor/InlineTextToolbar.tsx +4 -4
  53. package/src/components/editor/InspectorHeaderActions.tsx +1 -1
  54. package/src/components/editor/KeyframeDiamond.tsx +6 -4
  55. package/src/components/editor/KeyframeNavigation.tsx +4 -4
  56. package/src/components/editor/LayersPanel.tsx +3 -3
  57. package/src/components/editor/PromotableControl.tsx +2 -2
  58. package/src/components/editor/PropertyPanel.tsx +3 -3
  59. package/src/components/editor/PropertyPanelEmptyState.tsx +4 -4
  60. package/src/components/editor/PropertyPanelFlatFooter.tsx +2 -2
  61. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +1 -1
  62. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  63. package/src/components/editor/SnapToolbar.tsx +5 -5
  64. package/src/components/editor/TimelineFxPopover.tsx +4 -4
  65. package/src/components/editor/Transform3DCube.tsx +1 -1
  66. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +46 -6
  67. package/src/components/editor/anchoredResizeReleaseShift.test.ts +0 -84
  68. package/src/components/editor/domEditOverlayGestures.ts +13 -19
  69. package/src/components/editor/domEditOverlayStartGesture.test.ts +170 -13
  70. package/src/components/editor/domEditOverlayStartGesture.ts +15 -25
  71. package/src/components/editor/easeCurveSvg.tsx +1 -1
  72. package/src/components/editor/manualEdits.test.ts +3 -9
  73. package/src/components/editor/manualEdits.ts +0 -1
  74. package/src/components/editor/manualEditsDom.ts +3 -13
  75. package/src/components/editor/manualEditsDomPatches.test.ts +3 -131
  76. package/src/components/editor/manualEditsDomPatches.ts +1 -53
  77. package/src/components/editor/manualEditsSnapshot.ts +3 -0
  78. package/src/components/editor/manualEditsTypes.ts +2 -0
  79. package/src/components/editor/manualOffsetDrag.test.ts +41 -0
  80. package/src/components/editor/manualOffsetDrag.ts +6 -27
  81. package/src/components/editor/persistSeam.integration.test.ts +0 -18
  82. package/src/components/editor/plainTranslate.ts +42 -6
  83. package/src/components/editor/propertyPanelColorCurveGraph.tsx +2 -2
  84. package/src/components/editor/propertyPanelColorGradingControls.tsx +1 -1
  85. package/src/components/editor/propertyPanelColorGradingSection.tsx +8 -8
  86. package/src/components/editor/propertyPanelColorGradingSlider.tsx +1 -1
  87. package/src/components/editor/propertyPanelColorSecondary.tsx +4 -4
  88. package/src/components/editor/propertyPanelFill.tsx +6 -6
  89. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  90. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +1 -1
  91. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +6 -9
  92. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  93. package/src/components/editor/propertyPanelFlatMediaSection.tsx +2 -2
  94. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -2
  95. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +2 -2
  96. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +4 -3
  97. package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
  98. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +1 -1
  99. package/src/components/editor/propertyPanelFont.tsx +2 -2
  100. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  101. package/src/components/editor/propertyPanelFxControls.tsx +2 -2
  102. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  103. package/src/components/editor/propertyPanelFxNodeRow.tsx +1 -1
  104. package/src/components/editor/propertyPanelFxPresetRun.tsx +1 -1
  105. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  106. package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
  107. package/src/components/editor/propertyPanelSections.tsx +2 -2
  108. package/src/components/editor/propertyPanelTransformCommit.test.ts +29 -0
  109. package/src/components/editor/propertyPanelTransformCommit.ts +8 -5
  110. package/src/components/editor/propertyPanelTypes.ts +1 -0
  111. package/src/components/editor/propertyPanelValueTier.test.ts +1 -1
  112. package/src/components/editor/propertyPanelValueTier.ts +1 -1
  113. package/src/components/editor/rotateInlineSave.test.ts +111 -0
  114. package/src/components/editor/rotationDraft.test.ts +111 -0
  115. package/src/components/editor/rotationDraft.ts +145 -0
  116. package/src/components/editor/useDomEditNudge.test.tsx +33 -0
  117. package/src/components/editor/useDomEditNudge.ts +6 -2
  118. package/src/components/editor/useDomEditOverlayGestures.ts +22 -24
  119. package/src/components/feedback/StudioFeedbackCard.tsx +11 -23
  120. package/src/components/nle/AssetPreviewOverlay.tsx +1 -1
  121. package/src/components/nle/AudioMeterStrip.tsx +2 -2
  122. package/src/components/nle/NLEPreview.test.ts +8 -4
  123. package/src/components/nle/PreviewOverlays.tsx +1 -1
  124. package/src/components/nle/PreviewPane.tsx +3 -3
  125. package/src/components/nle/PreviewPoster.tsx +29 -1
  126. package/src/components/nle/PreviewZoomOverlay.tsx +1 -1
  127. package/src/components/panels/SlideshowPanel.tsx +4 -4
  128. package/src/components/panels/SlideshowSubPanels.tsx +19 -21
  129. package/src/components/panels/VariablesBindElement.tsx +1 -1
  130. package/src/components/panels/VariablesDeclarationForm.tsx +1 -1
  131. package/src/components/panels/VariablesPanel.tsx +4 -6
  132. package/src/components/panels/VariablesRowAction.tsx +1 -1
  133. package/src/components/renders/FfmpegRequiredNotice.tsx +4 -4
  134. package/src/components/renders/RenderQueue.tsx +2 -2
  135. package/src/components/renders/RenderQueueItem.tsx +3 -3
  136. package/src/components/sidebar/AssetCard.tsx +3 -3
  137. package/src/components/sidebar/AssetContextMenu.tsx +7 -7
  138. package/src/components/sidebar/AssetsTab.tsx +6 -6
  139. package/src/components/sidebar/AudioRow.tsx +5 -3
  140. package/src/components/sidebar/BlocksTab.tsx +6 -6
  141. package/src/components/sidebar/CompositionsTab.drag.test.tsx +3 -69
  142. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +4 -50
  143. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +256 -0
  144. package/src/components/sidebar/CompositionsTab.tsx +65 -12
  145. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  146. package/src/components/sidebar/SidebarLintButton.tsx +3 -3
  147. package/src/components/sidebar/compositionsTabTestUtils.tsx +39 -0
  148. package/src/components/timelineToolbarStyles.ts +3 -3
  149. package/src/components/ui/Button.tsx +4 -4
  150. package/src/components/ui/HyperframesLogo.tsx +19 -18
  151. package/src/components/ui/Input.tsx +1 -1
  152. package/src/components/ui/Menu.tsx +4 -2
  153. package/src/hooks/draggedGsapPosition.ts +28 -0
  154. package/src/hooks/elementOffsetStager.ts +34 -13
  155. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  156. package/src/hooks/gsapRuntimeKeyframes.ts +36 -2
  157. package/src/hooks/plainRotation.test.ts +122 -0
  158. package/src/hooks/plainRotation.ts +42 -0
  159. package/src/hooks/useClipboard.source.test.ts +42 -4
  160. package/src/hooks/useClipboard.ts +13 -3
  161. package/src/hooks/useDomEditCommits.ts +0 -3
  162. package/src/hooks/useDomEditSession.ts +5 -5
  163. package/src/hooks/useDomGeometryCommit.test.tsx +79 -1
  164. package/src/hooks/useDomGeometryCommit.ts +16 -17
  165. package/src/hooks/useDomGeometryCommits.test.tsx +233 -33
  166. package/src/hooks/useDomGeometryCommits.ts +27 -80
  167. package/src/hooks/useGsapAwareEditing.test.tsx +194 -11
  168. package/src/hooks/useGsapAwareEditing.ts +43 -42
  169. package/src/hooks/useInlineTextEdit.test.tsx +33 -3
  170. package/src/hooks/useInlineTextEdit.ts +2 -2
  171. package/src/player/components/ClipContextMenu.tsx +1 -1
  172. package/src/player/components/CompositionThumbnail.tsx +23 -11
  173. package/src/player/components/EditModal.tsx +6 -6
  174. package/src/player/components/KeyframeDiamondContextMenu.tsx +2 -2
  175. package/src/player/components/PlayerControls.tsx +3 -3
  176. package/src/player/components/ShortcutsPanel.tsx +8 -20
  177. package/src/player/components/TimelineCanvas.tsx +1 -1
  178. package/src/player/components/TimelineClip.tsx +2 -2
  179. package/src/player/components/TimelineEmptyState.tsx +2 -2
  180. package/src/player/components/TimelineLanes.tsx +0 -1
  181. package/src/player/components/TrackClipCount.tsx +1 -1
  182. package/src/player/components/VolumeControl.tsx +1 -1
  183. package/src/player/components/timelineClipChildren.tsx +1 -1
  184. package/src/player/components/timelineMotionStyles.test.ts +6 -22
  185. package/src/player/components/trackHeaderLabelRows.tsx +16 -16
  186. package/src/player/lib/thumbnailScheduler.ts +4 -5
  187. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  188. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  189. package/src/styles/hex-baseline.json +7 -19
  190. package/src/styles/studio.css +0 -9
  191. package/src/styles/tailwind-preset.shared.js +2 -1
  192. package/src/styles/theme.css +204 -147
  193. package/src/styles/theme.test.ts +13 -9
  194. package/src/utils/blockCategories.ts +41 -9
  195. package/src/utils/clipboardPayload.ts +2 -0
  196. package/dist/assets/index-CQN3M56N.css +0 -1
  197. package/dist/chunk-62DDWOSH.js.map +0 -1
  198. package/dist/chunk-6GAO75IU.js.map +0 -1
  199. package/src/hooks/gsapTargetCache.ts +0 -62
  200. /package/dist/{chunk-HIGHVWYD.js.map → chunk-QADHJFDF.js.map} +0 -0
@@ -38,7 +38,7 @@ describe("PropertyPanelFlatHeader", () => {
38
38
  expect(host.textContent).toContain("Mono Label");
39
39
  expect(host.textContent).toContain(".mono-label · div");
40
40
  const icon = host.querySelector('[data-flat-header-icon="true"]');
41
- expect(icon?.className).toContain("text-panel-accent");
41
+ expect(icon?.className).toContain("text-accent-ink");
42
42
  act(() => root.unmount());
43
43
  });
44
44
 
@@ -4,7 +4,7 @@ import { ClipboardList, Film, Square, Type, X } from "../../icons/SystemIcons";
4
4
 
5
5
  const ICON_BY_KIND = { text: Type, media: Film, other: Square } as const;
6
6
  const ICON_COLOR_BY_KIND = {
7
- text: "text-panel-accent",
7
+ text: "text-accent-ink",
8
8
  media: "text-panel-media",
9
9
  other: "text-panel-container",
10
10
  } as const;
@@ -92,7 +92,7 @@ export function PropertyPanelFlatHeader({
92
92
  track("button", "Copy element info");
93
93
  onCopy();
94
94
  }}
95
- className={copied ? "text-panel-accent" : undefined}
95
+ className={copied ? "text-accent-ink" : undefined}
96
96
  >
97
97
  <ClipboardList size={13} />
98
98
  </button>
@@ -73,7 +73,7 @@ export const SnapToolbar = memo(function SnapToolbar() {
73
73
  type="button"
74
74
  className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
75
75
  motionPathArmed
76
- ? "bg-studio-accent/20 text-studio-accent"
76
+ ? "bg-studio-accent/20 text-accent-ink"
77
77
  : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
78
78
  }`}
79
79
  onClick={() => setMotionPathArmed(!motionPathArmed)}
@@ -99,7 +99,7 @@ export const SnapToolbar = memo(function SnapToolbar() {
99
99
  type="button"
100
100
  className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
101
101
  visible
102
- ? "bg-studio-accent/20 text-studio-accent"
102
+ ? "bg-studio-accent/20 text-accent-ink"
103
103
  : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
104
104
  }`}
105
105
  onClick={toggle}
@@ -115,7 +115,7 @@ export const SnapToolbar = memo(function SnapToolbar() {
115
115
  type="button"
116
116
  className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
117
117
  prefs.snapEnabled
118
- ? "bg-studio-accent/20 text-studio-accent"
118
+ ? "bg-studio-accent/20 text-accent-ink"
119
119
  : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
120
120
  }`}
121
121
  onClick={toggleSnap}
@@ -131,7 +131,7 @@ export const SnapToolbar = memo(function SnapToolbar() {
131
131
  type="button"
132
132
  className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
133
133
  prefs.gridVisible
134
- ? "bg-studio-accent/20 text-studio-accent"
134
+ ? "bg-studio-accent/20 text-accent-ink"
135
135
  : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
136
136
  }`}
137
137
  onClick={toggleGrid}
@@ -164,7 +164,7 @@ export const SnapToolbar = memo(function SnapToolbar() {
164
164
  {gridPopoverOpen && (
165
165
  <div
166
166
  ref={popoverRef}
167
- className="absolute right-0 top-full mt-1 rounded-lg bg-neutral-800 border border-neutral-700 p-3 shadow-xl min-w-[180px]"
167
+ className="absolute right-0 top-full mt-1 rounded-lg bg-raised border border-border p-3 shadow-xl min-w-[180px]"
168
168
  >
169
169
  <label className="flex items-center justify-between text-xs text-white/80 mb-2">
170
170
  <span>Grid spacing</span>
@@ -139,7 +139,7 @@ export function TimelineFxPopover({
139
139
  ref={rootRef}
140
140
  role="dialog"
141
141
  aria-label="Effects"
142
- className="z-200 flex flex-col overflow-hidden rounded-md border border-white/10 bg-[#1b1b1f] p-2 shadow-xl"
142
+ className="z-200 flex flex-col overflow-hidden rounded-md border border-border bg-raised p-2 shadow-xl"
143
143
  style={clampedStyle(anchorRect)}
144
144
  onKeyDown={onKeyDown}
145
145
  onPointerDown={(event) => event.stopPropagation()}
@@ -165,10 +165,10 @@ export function TimelineFxPopover({
165
165
  onAuditionTracked={(id) => trackPresetAuditioned(id, { trackKind })}
166
166
  />
167
167
  </div>
168
- <div className="mt-2 flex shrink-0 items-center justify-between border-t border-white/10 pt-2 text-[10px] text-white/55">
168
+ <div className="mt-2 flex shrink-0 items-center justify-between border-t border-border pt-2 text-[10px] text-text-muted">
169
169
  <button
170
170
  type="button"
171
- className="hover:text-white"
171
+ className="hover:text-text-0"
172
172
  onClick={() => {
173
173
  onClose();
174
174
  onOpenRack();
@@ -178,7 +178,7 @@ export function TimelineFxPopover({
178
178
  </button>
179
179
  <button
180
180
  type="button"
181
- className="hover:text-white"
181
+ className="hover:text-text-0"
182
182
  onClick={() => {
183
183
  onClose();
184
184
  onOpenRack();
@@ -314,7 +314,7 @@ export function Transform3DCube({
314
314
  aria-label="Keyframe 3D transform"
315
315
  aria-pressed={keyframed}
316
316
  className={`absolute left-1.5 top-1.5 rounded p-0.5 hover:bg-neutral-800 ${
317
- keyframed ? "text-[#5ff0bf]" : "text-neutral-500 hover:text-neutral-200"
317
+ keyframed ? "text-accent-ink" : "text-neutral-500 hover:text-neutral-200"
318
318
  }`}
319
319
  >
320
320
  <svg
@@ -16,23 +16,49 @@ const ORIGIN_CENTER = {
16
16
 
17
17
  // Consistent geometry stub: model the physical truth the real DOM would report.
18
18
  // A CSS width/height change grows the box from its top-left, so the rendered
19
- // center drifts by half the size delta; the manual offset the gesture applies
20
- // (read back from the element's studio vars) pulls it back. `elementCornerOverlayPoints`
19
+ // center drifts by half the size delta; the element's own translate, which the gesture
20
+ // writes in plain px, pulls it back. `elementCornerOverlayPoints`
21
21
  // returns the four corners of that drifted box; `overlayCornersCentroid` (kept
22
22
  // real) averages them so the anchor loop can measure the true center each frame.
23
+ // An authored `translate: 25% 25%` follows the box size until the gesture writes px over it.
24
+ const authored = vi.hoisted(() => ({ percent: 0 }));
25
+ function inlineTranslate(element: HTMLElement): { x: number; y: number } | null {
26
+ const value = element.style.getPropertyValue("translate");
27
+ if (!value) return null;
28
+ const [x = 0, y = 0] = value.split(" ").map((v) => Number.parseFloat(v) || 0);
29
+ return { x, y };
30
+ }
31
+ function renderedTranslate(element: HTMLElement, width: number, height: number) {
32
+ return inlineTranslate(element) ?? { x: authored.percent * width, y: authored.percent * height };
33
+ }
34
+
35
+ vi.mock("./plainTranslate", async () => {
36
+ const actual = await vi.importActual<typeof import("./plainTranslate")>("./plainTranslate");
37
+ return {
38
+ ...actual,
39
+ readTranslatePx: (element: HTMLElement) =>
40
+ renderedTranslate(
41
+ element,
42
+ element.offsetWidth || ORIGIN.width,
43
+ element.offsetHeight || ORIGIN.height,
44
+ ),
45
+ };
46
+ });
47
+
23
48
  vi.mock("./domEditOverlayGeometry", async () => {
24
49
  const actual = await vi.importActual<typeof import("./domEditOverlayGeometry")>(
25
50
  "./domEditOverlayGeometry",
26
51
  );
27
- const { readStudioBoxSize, readStudioPathOffset } = await import("./manualEditsDom");
52
+ const { readStudioBoxSize } = await import("./manualEditsDom");
28
53
  const physicalCenter = (element: HTMLElement) => {
29
54
  const size = readStudioBoxSize(element);
30
55
  const width = size.width > 0 ? size.width : ORIGIN.width;
31
56
  const height = size.height > 0 ? size.height : ORIGIN.height;
32
- const offset = readStudioPathOffset(element);
57
+ const offset = renderedTranslate(element, width, height);
58
+ const start = { x: authored.percent * ORIGIN.width, y: authored.percent * ORIGIN.height };
33
59
  return {
34
- x: ORIGIN_CENTER.x + (width - ORIGIN.width) / 2 + offset.x,
35
- y: ORIGIN_CENTER.y + (height - ORIGIN.height) / 2 + offset.y,
60
+ x: ORIGIN_CENTER.x + (width - ORIGIN.width) / 2 + offset.x - start.x,
61
+ y: ORIGIN_CENTER.y + (height - ORIGIN.height) / 2 + offset.y - start.y,
36
62
  width,
37
63
  height,
38
64
  };
@@ -189,6 +215,7 @@ async function finishResize(handlers: ReturnType<typeof createDomEditOverlayGest
189
215
 
190
216
  afterEach(() => {
191
217
  document.body.innerHTML = "";
218
+ authored.percent = 0;
192
219
  });
193
220
 
194
221
  describe("anchored corner resize — the release commit feeds the center-pin offset", () => {
@@ -227,6 +254,19 @@ describe("anchored corner resize — the release commit feeds the center-pin off
227
254
  expect(offset.y).toBeCloseTo(-(size.height - ORIGIN.height) / 2, 0);
228
255
  });
229
256
 
257
+ it("keeps the centre on the first frame when the authored translate is a percent", async () => {
258
+ const { orientedOverlayRect } = await import("./domEditOverlayGeometry");
259
+ authored.percent = 0.25;
260
+ const { handlers, selection } = buildHarness();
261
+ handlers.startGesture("resize", evt(ORIGIN_CENTER.x + 100, ORIGIN_CENTER.y), {
262
+ resizeHandle: "se",
263
+ });
264
+ handlers.onPointerMove(evt(ORIGIN_CENTER.x + 150, ORIGIN_CENTER.y));
265
+ const rect = orientedOverlayRect(null as never, null as never, selection.element)!;
266
+ expect(rect.left + rect.width / 2).toBeCloseTo(ORIGIN_CENTER.x, 1);
267
+ expect(rect.top + rect.height / 2).toBeCloseTo(ORIGIN_CENTER.y, 1);
268
+ });
269
+
230
270
  it("does not log a paused save queue as an ordinary resize failure", async () => {
231
271
  const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
232
272
  const { handlers } = buildHarness(() => Promise.reject(new DomEditSaveQueueOpenError()));
@@ -1,12 +1,6 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, describe, expect, it } from "vitest";
3
- import { applyStudioBoxSize, applyStudioPathOffset, readStudioBoxSize } from "./manualEditsDom";
4
- import { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";
5
- import { buildBoxSizePatches, buildPathOffsetPatches } from "./manualEditsDomPatches";
6
- import { createManualOffsetDragMember, applyManualOffsetDragCommit } from "./manualOffsetDrag";
7
3
  import { computeNextResizeAnchor } from "./domEditResizeLocal";
8
- import type { PatchOperation } from "../../utils/sourcePatcher";
9
- import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
10
4
 
11
5
  /**
12
6
  * Center-anchored corner resize (CapCut model): the element scales about its
@@ -38,40 +32,6 @@ afterEach(() => {
38
32
  document.body.innerHTML = "";
39
33
  });
40
34
 
41
- /** Apply a built PatchOperation[] to a live element, mirroring sourcePatcher's
42
- * inline-style / attribute application — i.e. what the persisted source carries
43
- * when it is re-parsed into the DOM on the next preview load. */
44
- function applyPatchesToElement(el: HTMLElement, ops: PatchOperation[]): void {
45
- for (const op of ops) {
46
- if (op.type === "inline-style") {
47
- if (op.value === null) el.style.removeProperty(op.property);
48
- else el.style.setProperty(op.property, op.value);
49
- } else if (op.type === "attribute") {
50
- if (op.value === null) el.removeAttribute(op.property);
51
- else el.setAttribute(op.property, op.value);
52
- }
53
- }
54
- }
55
-
56
- /** Net translate applied to an element, resolving the studio offset var()
57
- * expression to its px value so we compare the actually-rendered translation. */
58
- function resolvedTranslatePx(el: HTMLElement): { x: number; y: number } {
59
- const raw = el.style.getPropertyValue("translate").trim();
60
- if (!raw || raw === "none") return { x: 0, y: 0 };
61
- const vx = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-x")) || 0;
62
- const vy = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-y")) || 0;
63
- const parts = splitTopLevelWhitespace(raw);
64
- const parseAxis = (part: string, varVal: number): number => {
65
- if (part && part.includes("--hf-studio-offset")) return varVal;
66
- const n = Number.parseFloat(part);
67
- return Number.isFinite(n) ? n : 0;
68
- };
69
- return {
70
- x: parseAxis(parts[0] ?? "", vx),
71
- y: parseAxis(parts[1] ?? "", vy),
72
- };
73
- }
74
-
75
35
  describe("center-anchored corner resize — no shift after release", () => {
76
36
  it("the per-frame center anchor converges (does NOT oscillate) — the release-shift root cause", () => {
77
37
  // Model the pointermove anchor loop that pins the element's CENTER. The physical
@@ -157,48 +117,4 @@ describe("center-anchored corner resize — no shift after release", () => {
157
117
  }
158
118
  }
159
119
  });
160
-
161
- it("net translate after persist+reload equals the committed anchor offset (non-GSAP)", () => {
162
- // The committed offset flows through the real apply → persist → reload chain
163
- // unchanged (this hop was proved clean; the shift is upstream in the anchor
164
- // loop above, not in persistence).
165
- const el = document.createElement("div");
166
- el.style.setProperty("width", "200px");
167
- el.style.setProperty("height", "100px");
168
- document.body.appendChild(el);
169
-
170
- const anchorDx = -30;
171
- const anchorDy = -18;
172
- const finalSize = { width: 240, height: 130 };
173
-
174
- applyStudioBoxSize(el, finalSize);
175
- const memberResult = createManualOffsetDragMember({
176
- key: "k",
177
- selection: { element: el } as never,
178
- element: el,
179
- rect: { left: 0, top: 0, width: 240, height: 130, editScaleX: 1, editScaleY: 1 },
180
- });
181
- expect(memberResult.ok).toBe(true);
182
- if (!memberResult.ok) return;
183
-
184
- const finalOffset = applyManualOffsetDragCommit(memberResult.member, anchorDx, anchorDy);
185
-
186
- applyStudioBoxSize(el, finalSize);
187
- const patches = buildBoxSizePatches(el);
188
- applyStudioPathOffset(el, finalOffset);
189
- patches.push(...buildPathOffsetPatches(el));
190
-
191
- expect(resolvedTranslatePx(el)).toEqual({ x: anchorDx, y: anchorDy });
192
-
193
- // Persist → fresh element re-parsed from source → reload re-stamp.
194
- const reloaded = document.createElement("div");
195
- reloaded.style.setProperty("width", "200px");
196
- reloaded.style.setProperty("height", "100px");
197
- document.body.appendChild(reloaded);
198
- applyPatchesToElement(reloaded, patches);
199
- reapplyPositionEditsAfterSeek(reloaded.ownerDocument);
200
-
201
- expect(resolvedTranslatePx(reloaded)).toEqual({ x: anchorDx, y: anchorDy });
202
- expect(readStudioBoxSize(reloaded)).toEqual(finalSize);
203
- });
204
120
  });
@@ -6,6 +6,7 @@ import type {
6
6
  StudioRotationSnapshot,
7
7
  } from "./manualEdits";
8
8
  import type { ManualOffsetDragMember } from "./manualOffsetDrag";
9
+ import type { CssRotationTarget, RotationCommit } from "./rotationDraft";
9
10
  import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
10
11
  import type { SnapContext } from "./snapTargetCollection";
11
12
  import type { SnapGuidesState } from "./SnapGuideOverlay";
@@ -63,27 +64,14 @@ export interface GestureState {
63
64
  actualWidth: number;
64
65
  actualHeight: number;
65
66
  actualRotation: number;
67
+ /** Null when GSAP owns the rotate; else where its CSS turn is drawn and saved, read at press. */
68
+ plainRotation: CssRotationTarget | null;
66
69
  editScaleX: number;
67
70
  editScaleY: number;
68
- // Rendered-per-CSS-pixel factor of the element itself at gesture start (a GSAP
69
- // scale() transform makes this > 1) — the resize draft divides by it so the box
70
- // follows the cursor instead of overshooting by the live scale.
71
+ // Rendered px per CSS px of the element at gesture start (> 1 under a GSAP scale()); the resize
72
+ // draft divides by it so the box follows the cursor instead of overshooting by the live scale.
71
73
  contentScaleX: number;
72
74
  contentScaleY: number;
73
- // Resize anchor pinning: with a live scale transform, growing the CSS box
74
- // shifts the rendered box (scaling happens around the element center), so the
75
- // un-dragged corner creeps during the draft. The move handler measures the
76
- // gesture-start top-left drift each frame and counters it through the GSAP
77
- // position channel; the pin accumulates so the correction converges.
78
- // Present only on resize gestures.
79
- resizeAnchor?: {
80
- anchorX: number;
81
- anchorY: number;
82
- baseGsapX: number;
83
- baseGsapY: number;
84
- pinX: number;
85
- pinY: number;
86
- };
87
75
  manualEditDragToken?: string;
88
76
  snapContext?: SnapContext;
89
77
  lastSnappedDx?: number;
@@ -232,9 +220,15 @@ export function hasDomEditRotationChanged(initialAngle: number, nextAngle: numbe
232
220
  // These live here (rather than in DomEditOverlay.tsx or useDomEditOverlayGestures.ts)
233
221
  // to break circular imports between those files.
234
222
 
223
+ export interface MoveCommitOptions {
224
+ altKey?: boolean;
225
+ plainTranslate?: boolean;
226
+ }
227
+
235
228
  export interface DomEditGroupPathOffsetCommit {
236
229
  selection: DomEditSelection;
237
230
  next: { x: number; y: number };
231
+ plainTranslate?: boolean;
238
232
  }
239
233
 
240
234
  // Refs are stable across renders; values are read via .current.
@@ -259,7 +253,7 @@ export type UseDomEditOverlayGesturesOptions = {
259
253
  (
260
254
  s: DomEditSelection,
261
255
  n: { x: number; y: number },
262
- m?: { altKey?: boolean },
256
+ m?: MoveCommitOptions,
263
257
  ) => Promise<unknown> | void
264
258
  >;
265
259
  onGroupPathOffsetCommitRef: RefObject<
@@ -274,7 +268,7 @@ export type UseDomEditOverlayGesturesOptions = {
274
268
  ) => Promise<unknown> | void
275
269
  >;
276
270
  onRotationCommitRef: RefObject<
277
- (s: DomEditSelection, n: { angle: number }) => Promise<unknown> | void
271
+ (s: DomEditSelection, n: RotationCommit) => Promise<unknown> | void
278
272
  >;
279
273
  onCanvasPointerMoveRef: RefObject<
280
274
  (
@@ -20,38 +20,48 @@ const PRESS = {
20
20
  currentTarget: { setPointerCapture() {} },
21
21
  };
22
22
 
23
- /** Presses a drag on the element and returns the gesture it started. */
24
- function pressDrag(element: HTMLElement): GestureState | null {
23
+ /** Presses a drag or resize on the element and returns the gesture it started. */
24
+ function pressGesture(element: HTMLElement, kind: "drag" | "resize" = "drag"): GestureState | null {
25
25
  const opts = pressOptions(element);
26
26
  expect(
27
- createDomEditOverlayGestureHandlers(opts as never).startGesture("drag", PRESS as never),
27
+ createDomEditOverlayGestureHandlers(opts as never).startGesture(kind, PRESS as never),
28
28
  ).toBe(true);
29
29
  return opts.gestureRef.current;
30
30
  }
31
31
 
32
32
  function pressOptions(element: HTMLElement) {
33
33
  const ref = <T>(current: T) => ({ current });
34
- const selection = { element, capabilities: { canApplyManualOffset: true } };
34
+ const capabilities = { canApplyManualOffset: true, canApplyManualSize: true };
35
+ const selection = { element, capabilities };
35
36
  return {
36
37
  selectionRef: ref(selection as unknown as DomEditSelection),
37
38
  overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
38
- boxRef: ref(null),
39
+ boxRef: ref(document.createElement("div")),
39
40
  overlayRef: ref(null),
40
41
  iframeRef: ref(null),
41
42
  gestureRef: ref<GestureState | null>(null),
42
43
  rafPausedRef: ref(false),
43
44
  onManualDragStartRef: ref(vi.fn()),
44
45
  onBlockedMoveRef: ref(vi.fn()),
46
+ onPathOffsetCommitRef: ref(vi.fn()),
47
+ snapGuidesRef: ref(null),
48
+ groupGestureRef: ref(null),
49
+ blockedMoveRef: ref(null),
50
+ setOverlayRect: vi.fn(),
51
+ suppressNextBoxClickRef: ref(false),
52
+ hoverSelectionRef: ref(null),
53
+ onCanvasMouseDown: vi.fn(),
45
54
  };
46
55
  }
47
56
 
48
- describe("a drag press on a page that loads GSAP", () => {
57
+ describe("a drag or resize press on a page that loads GSAP", () => {
49
58
  it.each([
50
- ["GSAP animates nothing", false],
51
- ["GSAP animates only its parent", true],
52
- ])(
53
- "%s: the press never asks GSAP about the element, so its translate stays CSS",
54
- (_, parentTween) => {
59
+ ["drag", "GSAP animates nothing", false],
60
+ ["drag", "GSAP animates only its parent", true],
61
+ ["resize", "GSAP animates nothing", false],
62
+ ] as const)(
63
+ "%s, %s: the press never asks GSAP about the element, so its translate stays CSS",
64
+ (kind, _, parentTween) => {
55
65
  const getProperty = vi.fn(() => 0);
56
66
  const set = vi.fn();
57
67
  const parent = document.createElement("div");
@@ -62,7 +72,7 @@ describe("a drag press on a page that loads GSAP", () => {
62
72
  const tween = { targets: () => [parent], vars: { x: 100 }, duration: () => 2 };
63
73
  const timelines = { main: { getChildren: () => (parentTween ? [tween] : []) } };
64
74
  Object.assign(window, { gsap: { getProperty, set }, __timelines: timelines });
65
- expect(pressDrag(element)?.pathOffsetMember?.plainTranslate).toBe(true);
75
+ expect(pressGesture(element, kind)?.pathOffsetMember?.plainTranslate).toBe(true);
66
76
  expect(getProperty).not.toHaveBeenCalled();
67
77
  expect(set).not.toHaveBeenCalled();
68
78
  expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
@@ -77,7 +87,154 @@ describe("a drag press on a centred element without GSAP", () => {
77
87
  "position: absolute; left: 50%; top: 50%; width: 240px; height: 160px; translate: -50% -50%";
78
88
  document.body.append(element);
79
89
  const style = element.getAttribute("style");
80
- expect(pressDrag(element)?.pathOffsetMember?.initialOffset).toEqual({ x: -120, y: -80 });
90
+ expect(pressGesture(element)?.pathOffsetMember?.initialOffset).toEqual({ x: -120, y: -80 });
81
91
  expect(element.getAttribute("style")).toBe(style);
82
92
  });
83
93
  });
94
+
95
+ describe("a drag on an element without GSAP", () => {
96
+ it("drops on the route it chose at press, even if GSAP takes the element over mid-drag", () => {
97
+ const element = document.createElement("div");
98
+ element.style.setProperty("translate", "40px 30px");
99
+ document.body.append(element);
100
+ const opts = pressOptions(element);
101
+ const handlers = createDomEditOverlayGestureHandlers(opts as never);
102
+ expect(handlers.startGesture("drag", PRESS as never)).toBe(true);
103
+ Object.assign(element, { _gsap: { renderTransform: () => {} } });
104
+ const release = {
105
+ ...PRESS,
106
+ clientX: 110,
107
+ clientY: 70,
108
+ currentTarget: { releasePointerCapture() {} },
109
+ };
110
+ handlers.onPointerUp(release as never);
111
+ expect(opts.onPathOffsetCommitRef.current).toHaveBeenCalledWith(
112
+ expect.anything(),
113
+ expect.anything(),
114
+ expect.objectContaining({ plainTranslate: true }),
115
+ );
116
+ });
117
+ });
118
+
119
+ describe("a group drag of elements without GSAP", () => {
120
+ it("drops every member on the route it chose at press", () => {
121
+ const items = [0, 1].map((i) => {
122
+ const element = document.createElement("div");
123
+ element.style.setProperty("translate", `${i * 10}px 0px`);
124
+ document.body.append(element);
125
+ const selection = { element, capabilities: { canApplyManualOffset: true } };
126
+ const rect = { left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 };
127
+ return { key: `m${i}`, selection, element, rect };
128
+ });
129
+ const onGroupPathOffsetCommit = vi.fn();
130
+ const opts = {
131
+ ...pressOptions(items[0]!.element),
132
+ groupOverlayItemsRef: { current: items },
133
+ onGroupPathOffsetCommitRef: { current: onGroupPathOffsetCommit },
134
+ setGroupOverlayItems: vi.fn(),
135
+ };
136
+ const handlers = createDomEditOverlayGestureHandlers(opts as never);
137
+ expect(handlers.startGroupDrag(PRESS as never)).toBe(true);
138
+ const release = {
139
+ ...PRESS,
140
+ clientX: 110,
141
+ clientY: 70,
142
+ currentTarget: { releasePointerCapture() {} },
143
+ };
144
+ handlers.onPointerUp(release as never);
145
+ const updates = onGroupPathOffsetCommit.mock.calls[0]?.[0] as { plainTranslate?: boolean }[];
146
+ expect(updates.map((update) => update.plainTranslate)).toEqual([true, true]);
147
+ });
148
+ });
149
+
150
+ describe("a rotate on a page that loads GSAP", () => {
151
+ it("never asks GSAP about an element it does not turn, and draws the turn as its CSS rotate", () => {
152
+ const getProperty = vi.fn(() => 0);
153
+ const set = vi.fn();
154
+ const element = document.createElement("div");
155
+ element.style.setProperty("rotate", "30deg");
156
+ document.body.append(element);
157
+ Object.assign(window, {
158
+ gsap: { getProperty, set },
159
+ __timelines: { main: { getChildren: () => [] } },
160
+ });
161
+ const selection = { element, capabilities: { canApplyManualRotation: true } };
162
+ const ref = <T>(current: T) => ({ current });
163
+ const opts = {
164
+ selectionRef: ref(selection as unknown as DomEditSelection),
165
+ overlayRectRef: ref({ left: 0, top: 0, width: 50, height: 40, editScaleX: 1, editScaleY: 1 }),
166
+ boxRef: ref(document.createElement("div")),
167
+ overlayRef: ref(null),
168
+ iframeRef: ref(null),
169
+ gestureRef: ref<GestureState | null>(null),
170
+ groupGestureRef: ref(null),
171
+ blockedMoveRef: ref(null),
172
+ rafPausedRef: ref(false),
173
+ onCanvasPointerMoveRef: ref(vi.fn()),
174
+ };
175
+ const pointer = (clientX: number, clientY: number) => ({
176
+ clientX,
177
+ clientY,
178
+ pointerId: 1,
179
+ button: 0,
180
+ shiftKey: false,
181
+ preventDefault() {},
182
+ stopPropagation() {},
183
+ currentTarget: { setPointerCapture() {} },
184
+ });
185
+
186
+ const handlers = createDomEditOverlayGestureHandlers(opts as never);
187
+ expect(handlers.startGesture("rotate", pointer(25, -20) as never)).toBe(true);
188
+ expect(opts.gestureRef.current?.plainRotation).toEqual({
189
+ property: "rotate",
190
+ before: "",
191
+ after: "",
192
+ share: 0,
193
+ sign: 1,
194
+ inline: false,
195
+ });
196
+ expect(opts.gestureRef.current?.actualRotation).toBeCloseTo(30);
197
+ handlers.onPointerMove(pointer(60, 20) as never);
198
+
199
+ expect(element.style.getPropertyValue("rotate")).toMatch(/deg$/);
200
+ expect(element.style.getPropertyValue("rotate")).not.toBe("30deg");
201
+ expect(getProperty).not.toHaveBeenCalled();
202
+ expect(set).not.toHaveBeenCalled();
203
+ });
204
+ });
205
+
206
+ describe("a rotate press on an element a GSAP tween turns", () => {
207
+ it.each(["rotation", "rotate", "rotateZ"])("reads its base from GSAP (%s)", (channel) => {
208
+ const getProperty = vi.fn(() => 40);
209
+ const element = document.createElement("div");
210
+ document.body.append(element);
211
+ const tween = { targets: () => [element], vars: { [channel]: 40 }, duration: () => 2 };
212
+ const timelines = { main: { getChildren: () => [tween] } };
213
+ Object.assign(window, { gsap: { getProperty, set: vi.fn() }, __timelines: timelines });
214
+ const ref = <T>(current: T) => ({ current });
215
+ const selection = { element, capabilities: { canApplyManualRotation: true } };
216
+ const opts = {
217
+ selectionRef: ref(selection as unknown as DomEditSelection),
218
+ overlayRectRef: ref({ left: 0, top: 0, width: 50, height: 40, editScaleX: 1, editScaleY: 1 }),
219
+ boxRef: ref(document.createElement("div")),
220
+ overlayRef: ref(null),
221
+ iframeRef: ref(null),
222
+ gestureRef: ref<GestureState | null>(null),
223
+ rafPausedRef: ref(false),
224
+ };
225
+ const press = {
226
+ clientX: 25,
227
+ clientY: -20,
228
+ pointerId: 1,
229
+ button: 0,
230
+ preventDefault() {},
231
+ stopPropagation() {},
232
+ currentTarget: { setPointerCapture() {} },
233
+ };
234
+ const handlers = createDomEditOverlayGestureHandlers(opts as never);
235
+ expect(handlers.startGesture("rotate", press as never)).toBe(true);
236
+ expect(opts.gestureRef.current?.plainRotation).toBeNull();
237
+ expect(opts.gestureRef.current?.actualRotation).toBe(40);
238
+ expect(getProperty).toHaveBeenCalledWith(element, "rotation");
239
+ });
240
+ });