@hyperframes/studio 0.8.103 → 0.8.104

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 (49) hide show
  1. package/dist/assets/{hyperframes-player-DG795Hwu.js → hyperframes-player-DAoaajuM.js} +1 -1
  2. package/dist/assets/{index-BiEtDkae.js → index-BDK4rTMb.js} +1 -1
  3. package/dist/assets/{index-BPJRf2X9.js → index-De2aO4XM.js} +178 -178
  4. package/dist/assets/{index-DuCZ3pQw.js → index-WV9LroAD.js} +1 -1
  5. package/dist/index.d.ts +181 -168
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +428 -305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/editor/DomEditOverlay.tsx +5 -6
  11. package/src/components/editor/PropertyPanel.tsx +3 -3
  12. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +46 -6
  13. package/src/components/editor/domEditOverlayGestures.ts +13 -19
  14. package/src/components/editor/domEditOverlayStartGesture.test.ts +170 -13
  15. package/src/components/editor/domEditOverlayStartGesture.ts +15 -25
  16. package/src/components/editor/manualEdits.test.ts +3 -9
  17. package/src/components/editor/manualEdits.ts +0 -1
  18. package/src/components/editor/manualEditsDom.ts +0 -10
  19. package/src/components/editor/manualEditsDomPatches.test.ts +1 -45
  20. package/src/components/editor/manualEditsDomPatches.ts +1 -23
  21. package/src/components/editor/manualEditsSnapshot.ts +2 -0
  22. package/src/components/editor/manualEditsTypes.ts +2 -0
  23. package/src/components/editor/manualOffsetDrag.test.ts +41 -0
  24. package/src/components/editor/manualOffsetDrag.ts +6 -27
  25. package/src/components/editor/plainTranslate.ts +9 -4
  26. package/src/components/editor/propertyPanelTransformCommit.test.ts +29 -0
  27. package/src/components/editor/propertyPanelTransformCommit.ts +8 -5
  28. package/src/components/editor/propertyPanelTypes.ts +1 -0
  29. package/src/components/editor/rotateInlineSave.test.ts +111 -0
  30. package/src/components/editor/rotationDraft.test.ts +111 -0
  31. package/src/components/editor/rotationDraft.ts +145 -0
  32. package/src/components/editor/useDomEditNudge.test.tsx +33 -0
  33. package/src/components/editor/useDomEditNudge.ts +6 -2
  34. package/src/components/editor/useDomEditOverlayGestures.ts +22 -24
  35. package/src/hooks/draggedGsapPosition.ts +28 -0
  36. package/src/hooks/elementOffsetStager.ts +26 -6
  37. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  38. package/src/hooks/gsapRuntimeKeyframes.ts +36 -2
  39. package/src/hooks/plainRotation.test.ts +122 -0
  40. package/src/hooks/plainRotation.ts +42 -0
  41. package/src/hooks/useDomEditSession.ts +5 -5
  42. package/src/hooks/useDomGeometryCommit.test.tsx +75 -0
  43. package/src/hooks/useDomGeometryCommit.ts +17 -17
  44. package/src/hooks/useDomGeometryCommits.test.tsx +56 -1
  45. package/src/hooks/useDomGeometryCommits.ts +28 -36
  46. package/src/hooks/useGsapAwareEditing.test.tsx +194 -11
  47. package/src/hooks/useGsapAwareEditing.ts +43 -42
  48. package/src/hooks/useInlineTextEdit.test.tsx +33 -3
  49. package/src/hooks/useInlineTextEdit.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.103",
3
+ "version": "0.8.104",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/player": "0.8.103",
64
- "@hyperframes/shader-transitions": "0.8.103",
65
- "@hyperframes/core": "0.8.103",
66
- "@hyperframes/studio-server": "0.8.103",
67
- "@hyperframes/parsers": "0.8.103",
68
- "@hyperframes/sdk": "0.8.103"
63
+ "@hyperframes/core": "0.8.104",
64
+ "@hyperframes/parsers": "0.8.104",
65
+ "@hyperframes/player": "0.8.104",
66
+ "@hyperframes/sdk": "0.8.104",
67
+ "@hyperframes/shader-transitions": "0.8.104",
68
+ "@hyperframes/studio-server": "0.8.104"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.103"
84
+ "@hyperframes/producer": "0.8.104"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -1,3 +1,4 @@
1
+ import type { RotationCommit } from "./rotationDraft";
1
2
  import { memo, useEffect, useMemo, useRef, type RefObject } from "react";
2
3
  import { type DomEditSelection } from "./domEditing";
3
4
  import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
@@ -10,6 +11,7 @@ import {
10
11
  type BlockedMoveState,
11
12
  type DomEditGroupPathOffsetCommit,
12
13
  type FocusableDomEditOverlay,
14
+ type MoveCommitOptions,
13
15
  type GestureState,
14
16
  type GroupGestureState,
15
17
  focusDomEditOverlayElement,
@@ -45,7 +47,7 @@ export {
45
47
  hasDomEditRotationChanged,
46
48
  resolveDomEditRotationGesture,
47
49
  } from "./domEditOverlayGestures";
48
- export type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";
50
+ export type { DomEditGroupPathOffsetCommit, MoveCommitOptions } from "./domEditOverlayGestures";
49
51
 
50
52
  export interface DomEditOverlayProps {
51
53
  iframeRef: RefObject<HTMLIFrameElement | null>;
@@ -81,7 +83,7 @@ export interface DomEditOverlayProps {
81
83
  onPathOffsetCommit: (
82
84
  selection: DomEditSelection,
83
85
  next: { x: number; y: number },
84
- modifiers?: { altKey?: boolean },
86
+ modifiers?: MoveCommitOptions,
85
87
  ) => Promise<unknown> | void;
86
88
  onGroupPathOffsetCommit: (updates: DomEditGroupPathOffsetCommit[]) => Promise<unknown> | void;
87
89
  onBoxSizeCommit: (
@@ -90,10 +92,7 @@ export interface DomEditOverlayProps {
90
92
  offset?: { x: number; y: number },
91
93
  restore?: () => void,
92
94
  ) => Promise<unknown> | void;
93
- onRotationCommit: (
94
- selection: DomEditSelection,
95
- next: { angle: number },
96
- ) => Promise<unknown> | void;
95
+ onRotationCommit: (selection: DomEditSelection, next: RotationCommit) => Promise<unknown> | void;
97
96
  onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
98
97
  recordingState?: GestureRecordingState;
99
98
  onToggleRecording?: () => void;
@@ -3,8 +3,8 @@ import { memo, useMemo, useRef, useState } from "react";
3
3
  import { Move } from "../../icons/SystemIcons";
4
4
  import { InspectorHeaderActions } from "./InspectorHeaderActions";
5
5
  import { useStudioShellContext } from "../../contexts/StudioContext";
6
- import { readStudioBoxSize, readStudioRotation } from "./manualEdits";
7
- import { readMoveOffset } from "./plainTranslate";
6
+ import { readStudioBoxSize } from "./manualEdits";
7
+ import { readMoveOffset, readShownRotation } from "./plainTranslate";
8
8
  import {
9
9
  buildElementInfoText,
10
10
  EMPTY_STYLES,
@@ -210,7 +210,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
210
210
  manualSize.height > 0
211
211
  ? manualSize.height
212
212
  : (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
213
- const manualRotation = readStudioRotation(element.element);
213
+ const manualRotation = readShownRotation(element.element);
214
214
 
215
215
  const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
216
216
  const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0;
@@ -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()));
@@ -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
+ });
@@ -2,21 +2,20 @@
2
2
  * Gesture-begin functions: startGroupDrag and startGesture.
3
3
  * These are pure "start a new gesture" operations — no draft rect updates.
4
4
  */
5
- import { readElementGsapNumber } from "../../utils/elementGsap";
6
5
  import { type DomEditSelection } from "./domEditing";
7
6
  import {
7
+ applyManualOffsetDragDraft,
8
8
  createManualOffsetDragMember,
9
- readGsapRotation,
10
9
  restoreManualOffsetDragMembers,
11
10
  type ManualOffsetDragMember,
12
11
  } from "./manualOffsetDrag";
12
+ import { readCssRotationTarget, readRotationBase } from "./rotationDraft";
13
13
  import {
14
14
  beginStudioManualEditGesture,
15
15
  captureStudioBoxSize,
16
16
  captureStudioPathOffset,
17
17
  captureStudioRotation,
18
18
  readStudioBoxSize,
19
- readStudioRotation,
20
19
  } from "./manualEdits";
21
20
  import {
22
21
  type OverlayRect,
@@ -32,6 +31,7 @@ import {
32
31
  type UseDomEditOverlayGesturesOptions,
33
32
  } from "./domEditOverlayGestures";
34
33
  import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
34
+ import { gsapWritesRotation } from "../../hooks/gsapRuntimeKeyframes";
35
35
  import { logResize, resetResizeMoveLog } from "../../utils/resizeDebug";
36
36
  import { logDrag, readDragPositions, resetDragMoveLog } from "../../utils/dragDebug";
37
37
 
@@ -171,18 +171,6 @@ export function startGesture(
171
171
  Number.isFinite(rawContentScaleX) && rawContentScaleX > 0 ? rawContentScaleX : 1;
172
172
  const contentScaleY =
173
173
  Number.isFinite(rawContentScaleY) && rawContentScaleY > 0 ? rawContentScaleY : 1;
174
- let resizeAnchor: GestureState["resizeAnchor"];
175
- if (kind === "resize") {
176
- const startBcr = sel.element.getBoundingClientRect();
177
- resizeAnchor = {
178
- anchorX: startBcr.x,
179
- anchorY: startBcr.y,
180
- baseGsapX: readElementGsapNumber(sel.element, "x") ?? 0,
181
- baseGsapY: readElementGsapNumber(sel.element, "y") ?? 0,
182
- pinX: 0,
183
- pinY: 0,
184
- };
185
- }
186
174
  let initialPathOffset = captureStudioPathOffset(sel.element);
187
175
  let manualEditDragToken: string | undefined;
188
176
  let pathOffsetMember: ManualOffsetDragMember | undefined;
@@ -205,11 +193,9 @@ export function startGesture(
205
193
  initialPathOffset = result.member.initialPathOffset;
206
194
  manualEditDragToken = result.member.gestureToken;
207
195
  } else {
208
- // Center-anchored corner resize (CapCut model): the element scales about its
209
- // CENTER, which stays planted. All four corners behave identically, so EVERY
210
- // corner needs the manual-offset member that translates the element to re-pin
211
- // its center per frame (the memberless else-branch is only a defensive fallback
212
- // if member creation fails, e.g. the element can't take a manual offset).
196
+ // Center-anchored corner resize (CapCut model): the element scales about its planted CENTER,
197
+ // so every corner needs the member that re-pins the center per frame (the memberless
198
+ // branch is only a fallback for an element that can't take a manual offset).
213
199
  const needsAnchorOffset = kind === "resize" && sel.capabilities.canApplyManualOffset;
214
200
  if (needsAnchorOffset) {
215
201
  const result = createManualOffsetDragMember({
@@ -217,11 +203,14 @@ export function startGesture(
217
203
  selection: sel,
218
204
  element: sel.element,
219
205
  rect,
206
+ gesture: "resize",
220
207
  });
221
208
  if (result.ok) {
222
209
  pathOffsetMember = result.member;
223
210
  initialPathOffset = result.member.initialPathOffset;
224
211
  manualEditDragToken = result.member.gestureToken;
212
+ // Hold a % translate as the same px now, so a growing box can't drag it along mid-frame.
213
+ if (result.member.plainTranslate) applyManualOffsetDragDraft(result.member, 0, 0);
225
214
  } else {
226
215
  manualEditDragToken = beginStudioManualEditGesture(sel.element);
227
216
  }
@@ -230,10 +219,11 @@ export function startGesture(
230
219
  }
231
220
  }
232
221
 
233
- // Rotation base = GSAP's rotation plus the legacy `--hf-studio-rotation` var. A plain-translate
234
- // move never asks GSAP: reading a property makes it bake the CSS translate into its transform.
235
- const gsapRotation = pathOffsetMember?.plainTranslate ? 0 : readGsapRotation(sel.element);
236
- const rotation = { angle: gsapRotation + readStudioRotation(sel.element).angle };
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) };
237
227
  const overlayBounds = overlayEl?.getBoundingClientRect();
238
228
  const centerX = (overlayBounds?.left ?? 0) + rect.left + rect.width / 2;
239
229
  const centerY = (overlayBounds?.top ?? 0) + rect.top + rect.height / 2;
@@ -281,11 +271,11 @@ export function startGesture(
281
271
  actualWidth,
282
272
  actualHeight,
283
273
  actualRotation: rotation.angle,
274
+ plainRotation,
284
275
  editScaleX: rect.editScaleX,
285
276
  editScaleY: rect.editScaleY,
286
277
  contentScaleX,
287
278
  contentScaleY,
288
- resizeAnchor,
289
279
  manualEditDragToken,
290
280
  snapContext,
291
281
  resizeHandle: kind === "resize" ? (options?.resizeHandle ?? "se") : undefined,
@@ -10,7 +10,6 @@ import {
10
10
  applyStudioPathOffset,
11
11
  applyStudioPathOffsetDraft,
12
12
  applyStudioRotation,
13
- applyStudioRotationDraft,
14
13
  beginStudioManualEditGesture,
15
14
  captureStudioBoxSize,
16
15
  captureStudioRotation,
@@ -210,16 +209,11 @@ describe("studio manual edits", () => {
210
209
  expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
211
210
  expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
212
211
 
213
- applyStudioRotationDraft(card, { angle: -12.2604 });
214
- expect(readStudioRotation(card)).toEqual({ angle: -12.26 });
215
- expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.26deg)");
216
- expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
217
-
218
212
  const snapshot = captureStudioRotation(card);
219
- applyStudioRotationDraft(card, { angle: 45 });
213
+ applyStudioRotation(card, { angle: 45 });
220
214
  restoreStudioRotation(card, snapshot);
221
- expect(readStudioRotation(card)).toEqual({ angle: -12.26 });
222
- expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.26deg)");
215
+ expect(readStudioRotation(card)).toEqual({ angle: 24.247 });
216
+ expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.247deg");
223
217
  expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
224
218
  });
225
219
 
@@ -29,7 +29,6 @@ export {
29
29
  applyStudioBoxSize,
30
30
  applyStudioBoxSizeDraft,
31
31
  applyStudioRotation,
32
- applyStudioRotationDraft,
33
32
  } from "./manualEditsDom";
34
33
 
35
34
  export { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";
@@ -538,13 +538,3 @@ export function applyStudioRotation(element: HTMLElement, rotation: { angle: num
538
538
  composeStudioRotationValue(element, `var(${STUDIO_ROTATION_PROP}, 0deg)`),
539
539
  );
540
540
  }
541
-
542
- export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle: number }): void {
543
- promoteInlineForTransform(element);
544
- writeStudioRotationVars(element, rotation, { updateBase: false });
545
- element.setAttribute(STUDIO_ROTATION_DRAFT_ATTR, "true");
546
- element.style.setProperty(
547
- "rotate",
548
- composeStudioRotationValue(element, `${roundTo3(rotation.angle)}deg`),
549
- );
550
- }