@hyperframes/studio 0.8.98 → 0.8.100

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 (63) hide show
  1. package/dist/assets/{hyperframes-player-C6muFtfk.js → hyperframes-player-fWt87gyh.js} +1 -1
  2. package/dist/assets/{index-B6vU39NM.js → index-D34Oy5DN.js} +1 -1
  3. package/dist/assets/{index-itc1XziP.js → index-D6TjRAvz.js} +171 -171
  4. package/dist/assets/{index-BxnoLgxG.js → index-DofdzdxJ.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +718 -438
  7. package/dist/index.js.map +1 -1
  8. package/package.json +9 -9
  9. package/src/components/editor/DomEditCropHandles.test.tsx +34 -66
  10. package/src/components/editor/DomEditCropHandles.tsx +30 -62
  11. package/src/components/editor/DomEditOverlay.test.ts +2 -2
  12. package/src/components/editor/PropertyPanel.tsx +3 -3
  13. package/src/components/editor/cropResize.test.ts +129 -0
  14. package/src/components/editor/cropResize.ts +143 -0
  15. package/src/components/editor/domEditOverlayCrop.ts +65 -15
  16. package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
  17. package/src/components/editor/domEditOverlayGestures.ts +2 -5
  18. package/src/components/editor/domEditOverlayStartGesture.test.ts +83 -0
  19. package/src/components/editor/domEditOverlayStartGesture.ts +6 -4
  20. package/src/components/editor/elementPositionOffset.ts +5 -4
  21. package/src/components/editor/manualEdits.test.ts +22 -8
  22. package/src/components/editor/manualEditsDom.ts +27 -11
  23. package/src/components/editor/manualEditsParsing.ts +0 -4
  24. package/src/components/editor/manualEditsSnapshot.ts +3 -0
  25. package/src/components/editor/manualOffsetDrag.test.ts +84 -0
  26. package/src/components/editor/manualOffsetDrag.ts +50 -40
  27. package/src/components/editor/plainTranslate.test.ts +62 -0
  28. package/src/components/editor/plainTranslate.ts +85 -0
  29. package/src/components/editor/propertyPanelTransformCommit.ts +3 -2
  30. package/src/components/editor/useDomEditNudge.ts +1 -1
  31. package/src/hooks/domSelectionTestHarness.ts +8 -0
  32. package/src/hooks/draggedGsapPosition.ts +4 -2
  33. package/src/hooks/elementOffsetStager.ts +33 -2
  34. package/src/hooks/gsapDragCommit.ts +6 -6
  35. package/src/hooks/gsapEditOutcome.ts +6 -0
  36. package/src/hooks/gsapResizeIntercept.ts +5 -5
  37. package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
  38. package/src/hooks/gsapRuntimeBridge.ts +3 -18
  39. package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
  40. package/src/hooks/gsapRuntimeKeyframes.ts +69 -21
  41. package/src/hooks/timelineTimingSync.ts +40 -4
  42. package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
  43. package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
  44. package/src/hooks/useCropOverlay.ts +8 -2
  45. package/src/hooks/useDomEditCommits.test.tsx +18 -4
  46. package/src/hooks/useDomEditCommits.ts +3 -1
  47. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
  48. package/src/hooks/useDomEditSession.ts +2 -0
  49. package/src/hooks/useDomEditTextCommits.test.tsx +62 -5
  50. package/src/hooks/useDomEditTextCommits.ts +35 -35
  51. package/src/hooks/useDomGeometryCommit.test.tsx +62 -4
  52. package/src/hooks/useDomGeometryCommit.ts +1 -0
  53. package/src/hooks/useDomGeometryCommits.test.tsx +116 -2
  54. package/src/hooks/useDomGeometryCommits.ts +5 -0
  55. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
  56. package/src/hooks/useGsapAwareEditing.test.tsx +147 -23
  57. package/src/hooks/useGsapAwareEditing.ts +48 -34
  58. package/src/hooks/useTimelineEditing.test.tsx +190 -135
  59. package/src/hooks/useTimelineEditing.ts +13 -13
  60. package/src/hooks/useTimelineGroupEditing.ts +13 -9
  61. package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
  62. package/src/utils/rounding.ts +5 -0
  63. package/src/utils/sdkEditTransaction.ts +10 -3
@@ -0,0 +1,143 @@
1
+ import type { PatchOperation } from "../../utils/sourcePatcher";
2
+ import type { CommitMutation } from "../../hooks/gsapScriptCommitTypes";
3
+ import type { DomEditSelection } from "./domEditingTypes";
4
+ import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
5
+ import { elementHasNonHoldTween } from "../../hooks/gsapRuntimeKeyframes";
6
+ import { buildInsetClipPathSides, type ParsedInsetClipPathSides } from "./clipPathHelpers";
7
+ import { hasCropInsets, readElementCropInsets } from "./domEditOverlayCrop";
8
+ import { forgetStudioBoxSizeDraftBase, readStudioBoxSizeDraftBase } from "./manualEditsDom";
9
+
10
+ interface Box {
11
+ width: number;
12
+ height: number;
13
+ }
14
+
15
+ const boxOf = (element: HTMLElement): Box => ({
16
+ width: element.offsetWidth,
17
+ height: element.offsetHeight,
18
+ });
19
+
20
+ type Follows = Record<keyof Box, boolean>;
21
+
22
+ /** Per axis, whether a crop follows a resize: not when GSAP drives the clip or tweens that axis's size. */
23
+ function cropFollows(element: HTMLElement): Follows {
24
+ const clip = gsapAnimatesProperty(element, "clipPath");
25
+ return {
26
+ width: !clip && !elementHasNonHoldTween(element, ["width"]),
27
+ height: !clip && !elementHasNonHoldTween(element, ["height"]),
28
+ };
29
+ }
30
+
31
+ function scaleCrop(
32
+ crop: ParsedInsetClipPathSides,
33
+ from: Box,
34
+ to: Box,
35
+ follows: Follows,
36
+ ): ParsedInsetClipPathSides {
37
+ const sx = follows.width && from.width > 0 ? to.width / from.width : 1;
38
+ const sy = follows.height && from.height > 0 ? to.height / from.height : 1;
39
+ return {
40
+ top: crop.top * sy,
41
+ right: crop.right * sx,
42
+ bottom: crop.bottom * sy,
43
+ left: crop.left * sx,
44
+ radius: crop.radius,
45
+ };
46
+ }
47
+
48
+ /** The element's crop as the crop UI draws it: it follows the box while a resize draft is live. */
49
+ export function readCropFollowingResize(element: HTMLElement): ParsedInsetClipPathSides | null {
50
+ const crop = readElementCropInsets(element);
51
+ const base = readStudioBoxSizeDraftBase(element);
52
+ return crop && base ? scaleCrop(crop, base, boxOf(element), cropFollows(element)) : crop;
53
+ }
54
+
55
+ export interface CropResize {
56
+ patch: PatchOperation;
57
+ revert: () => void;
58
+ }
59
+
60
+ /** Call before a resize lands. Once the new size is live, the stage writes the crop that keeps the
61
+ * same part of the element in view and returns its patch, or null when nothing needs rescaling. */
62
+ export function prepareCropResize(element: HTMLElement): () => CropResize | null {
63
+ const from = readStudioBoxSizeDraftBase(element) ?? boxOf(element);
64
+ const before = element.style.getPropertyValue("clip-path");
65
+ const priority = element.style.getPropertyPriority("clip-path");
66
+ // Decided before the write lands: a W edit may add a width keyframe of its own.
67
+ const follows = cropFollows(element);
68
+ const crop = readElementCropInsets(element);
69
+ return () => {
70
+ forgetStudioBoxSizeDraftBase(element);
71
+ const to = boxOf(element);
72
+ // A crop edited while the size saved was drawn in the new box already.
73
+ const untouched = element.style.getPropertyValue("clip-path") === before;
74
+ if (!crop || !hasCropInsets(crop) || !untouched || !(to.width > 0 && to.height > 0))
75
+ return null;
76
+ const clip = buildInsetClipPathSides(scaleCrop(crop, from, to, follows), crop.radius);
77
+ // Unchanged when the box is (a resize saved as a `scale`) or no resized axis follows.
78
+ if (clip === buildInsetClipPathSides(crop, crop.radius)) return null;
79
+ element.style.setProperty("clip-path", clip, priority);
80
+ return {
81
+ patch: {
82
+ type: "inline-style",
83
+ property: "clip-path",
84
+ value: priority ? `${clip} !important` : clip,
85
+ },
86
+ revert: () => {
87
+ if (before) element.style.setProperty("clip-path", before, priority);
88
+ else element.style.removeProperty("clip-path");
89
+ },
90
+ };
91
+ };
92
+ }
93
+
94
+ type PatchCommit = (
95
+ selection: DomEditSelection,
96
+ patches: PatchOperation[],
97
+ options: { label: string; coalesceKey: string; coalesceMs?: number },
98
+ ) => Promise<void>;
99
+
100
+ /** Stage the crop and save it under the resize's undo key, taking it back off the element if that fails. */
101
+ export async function saveCropResize(
102
+ stage: () => CropResize | null,
103
+ selection: DomEditSelection,
104
+ commit: PatchCommit,
105
+ coalesceKey: string,
106
+ ): Promise<void> {
107
+ const crop = stage();
108
+ if (!crop) return;
109
+ try {
110
+ await commit(selection, [crop.patch], {
111
+ label: "Resize layer",
112
+ coalesceKey,
113
+ coalesceMs: Number.POSITIVE_INFINITY,
114
+ });
115
+ } catch (error) {
116
+ crop.revert();
117
+ throw error;
118
+ }
119
+ }
120
+
121
+ let sizeWriteCounter = 0;
122
+
123
+ /** A property write that may size the element (a W/H field on an animated element): when it
124
+ * does, its writes and the rescaled crop share one undo step. */
125
+ export async function writeSizeWithCrop(
126
+ selection: DomEditSelection,
127
+ properties: Record<string, unknown>,
128
+ mutation: CommitMutation | null,
129
+ commit: PatchCommit,
130
+ write: (keyed?: CommitMutation) => Promise<void>,
131
+ ): Promise<void> {
132
+ if (!mutation || !("width" in properties || "height" in properties)) return write();
133
+ const stage = prepareCropResize(selection.element);
134
+ const coalesceKey = `size-write:${++sizeWriteCounter}`;
135
+ const undoStep = { coalesceKey, coalesceMs: Number.POSITIVE_INFINITY };
136
+ const keyed: CommitMutation = (s, m, options) => mutation(s, m, { ...options, ...undoStep });
137
+ if (mutation.batch) {
138
+ const batch = mutation.batch;
139
+ keyed.batch = (calls, options) => batch(calls, { ...options, ...undoStep });
140
+ }
141
+ await write(keyed);
142
+ await saveCropResize(stage, selection, commit, coalesceKey);
143
+ }
@@ -1,5 +1,9 @@
1
1
  import { composeElementTransform, type PlanarTransformOps } from "./domEditOverlayTransform";
2
- import { parseInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
2
+ import {
3
+ parseInsetClipPathSides,
4
+ type ClipPathInsetSides,
5
+ type ParsedInsetClipPathSides,
6
+ } from "./clipPathHelpers";
3
7
 
4
8
  export type CropEdge = "top" | "right" | "bottom" | "left";
5
9
 
@@ -29,23 +33,69 @@ export function cropRectFromInsets(
29
33
  };
30
34
  }
31
35
 
32
- /**
33
- * Current inset crop of an element (inline first, computed fallback).
34
- * Zeros = no clip (croppable, nothing cropped yet). `null` = the element
35
- * carries a clip-path this tool cannot represent (circle/polygon/non-px
36
- * inset) — croppers must not lift, edit, or restore it, or the clip gets
37
- * silently replaced or destroyed on deselect.
38
- */
39
- export function readElementCropInsets(
40
- element: HTMLElement,
41
- ): (ClipPathInsetSides & { radius: number }) | null {
36
+ // A selected croppable element shows uncropped so the crop UI can dim what is cut away. A rule keyed on its
37
+ // identity does that, so the element's own clip-path stays the one record undo, redo and panel edits rewrite.
38
+ const cropLifts = new WeakMap<HTMLElement, { rule: HTMLStyleElement; sheetClip?: string }>();
39
+
40
+ function liftSelector(element: HTMLElement): string | null {
41
+ const quote = (value: string) => `"${value.replace(/["\\]/g, "\\$&")}"`;
42
+ const hfId = element.getAttribute("data-hf-id");
43
+ if (hfId) return `[data-hf-id=${quote(hfId)}]`;
44
+ return element.id ? `[id=${quote(element.id)}]` : null;
45
+ }
46
+
47
+ export function liftElementCrop(element: HTMLElement): void {
48
+ const selector = liftSelector(element);
49
+ if (!selector || cropLifts.has(element)) return;
50
+ const doc = element.ownerDocument;
51
+ const rule = doc.createElement("style");
52
+ rule.textContent = `${selector}{clip-path:none!important}`;
53
+ (doc.head ?? doc.documentElement).append(rule);
54
+ cropLifts.set(element, { rule });
55
+ }
56
+
57
+ export function dropElementCropLift(element: HTMLElement): void {
58
+ cropLifts.get(element)?.rule.remove();
59
+ cropLifts.delete(element);
60
+ }
61
+
62
+ export function isElementCropLifted(element: HTMLElement): boolean {
63
+ return cropLifts.has(element);
64
+ }
65
+
66
+ const computedClipPath = (element: HTMLElement) =>
67
+ element.ownerDocument.defaultView?.getComputedStyle(element).clipPath.trim() || "";
68
+
69
+ /** The element's clip-path, inline first, then the stylesheet's as it is without the lift. */
70
+ function readElementClipPath(element: HTMLElement): string {
42
71
  const inline = element.style.getPropertyValue("clip-path").trim();
43
- const value =
44
- inline || element.ownerDocument.defaultView?.getComputedStyle(element).clipPath.trim() || "";
72
+ const lift = cropLifts.get(element);
73
+ if (inline || !lift) return inline || computedClipPath(element);
74
+ // Only inline writes happen while selected, so the stylesheet's clip is read once per lift.
75
+ if (lift.sheetClip === undefined) {
76
+ const { parentNode, nextSibling } = lift.rule;
77
+ lift.rule.remove();
78
+ lift.sheetClip = computedClipPath(element);
79
+ parentNode?.insertBefore(lift.rule, nextSibling);
80
+ }
81
+ return lift.sheetClip;
82
+ }
83
+
84
+ /** Zeros = no clip yet. `null` = a clip this tool cannot represent (circle/polygon/non-px inset):
85
+ * croppers must not lift, edit, or restore it, or deselect silently replaces or destroys it. */
86
+ function parseCropClipPath(value: string): ParsedInsetClipPathSides | null {
45
87
  if (!value || value === "none") return { top: 0, right: 0, bottom: 0, left: 0, radius: 0 };
46
88
  return parseInsetClipPathSides(value);
47
89
  }
48
90
 
91
+ export function readElementCropInsets(element: HTMLElement): ParsedInsetClipPathSides | null {
92
+ return parseCropClipPath(readElementClipPath(element));
93
+ }
94
+
95
+ export function hasCropInsets(insets: ClipPathInsetSides): boolean {
96
+ return insets.top > 0 || insets.right > 0 || insets.bottom > 0 || insets.left > 0;
97
+ }
98
+
49
99
  export interface CropInsetDragInput {
50
100
  edge: CropEdge;
51
101
  startInsets: ClipPathInsetSides;
@@ -117,8 +167,8 @@ export function hugRectForElement(
117
167
  rect: CropScreenRect & { editScaleX: number; editScaleY: number },
118
168
  element: HTMLElement,
119
169
  ): CropScreenRect {
120
- const insets = readElementCropInsets(element);
121
- // Uneditable clip (null) can't be hugged — show the full element rect.
170
+ const insets = isElementCropLifted(element) ? null : readElementCropInsets(element);
171
+ // Uneditable or lifted clip — show the full element rect.
122
172
  if (!insets || (insets.top <= 0 && insets.right <= 0 && insets.bottom <= 0 && insets.left <= 0))
123
173
  return rect;
124
174
  return cropRectFromInsets(rect, insets, rect.editScaleX, rect.editScaleY);
@@ -0,0 +1,44 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import {
4
+ dropElementCropLift,
5
+ hugRectForElement,
6
+ liftElementCrop,
7
+ readElementCropInsets,
8
+ } from "./domEditOverlayCrop";
9
+
10
+ afterEach(() => {
11
+ document.head.innerHTML = "";
12
+ document.body.innerHTML = "";
13
+ });
14
+
15
+ function cropped(style: string): HTMLElement {
16
+ const el = document.createElement("div");
17
+ el.setAttribute("data-hf-id", "hf-a");
18
+ el.style.cssText = style;
19
+ document.body.append(el);
20
+ return el;
21
+ }
22
+
23
+ describe("crop lift", () => {
24
+ it("shows the element uncropped over its inline clip, and drop takes the rule away", () => {
25
+ const el = cropped("clip-path: inset(0px 60px 0px 0px)");
26
+ liftElementCrop(el);
27
+ expect(getComputedStyle(el).clipPath).toBe("none");
28
+ expect(el.style.getPropertyValue("clip-path")).toBe("inset(0px 60px 0px 0px)");
29
+ dropElementCropLift(el);
30
+ expect(document.head.querySelectorAll("style")).toHaveLength(0);
31
+ expect(getComputedStyle(el).clipPath).toBe("inset(0px 60px 0px 0px)");
32
+ });
33
+
34
+ it("still reads a stylesheet crop while lifted, and the hover hug shows the full box", () => {
35
+ const sheet = document.createElement("style");
36
+ sheet.textContent = '[data-hf-id="hf-a"] { clip-path: inset(0px 60px 0px 0px); }';
37
+ document.head.append(sheet);
38
+ const el = cropped("width: 300px");
39
+ liftElementCrop(el);
40
+ expect(readElementCropInsets(el)).toMatchObject({ right: 60 });
41
+ const rect = { left: 0, top: 0, width: 300, height: 200, editScaleX: 1, editScaleY: 1 };
42
+ expect(hugRectForElement(rect, el)).toEqual(rect);
43
+ });
44
+ });
@@ -11,6 +11,7 @@ import type { SnapContext } from "./snapTargetCollection";
11
11
  import type { SnapGuidesState } from "./SnapGuideOverlay";
12
12
  import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
13
13
  import { logSelect } from "../../utils/selectDebug";
14
+ import { roundTo3 } from "../../utils/rounding";
14
15
 
15
16
  export type GestureKind = "drag" | "resize" | "rotate";
16
17
 
@@ -197,10 +198,6 @@ function normalizeAngleDelta(delta: number): number {
197
198
  return ((((delta + 180) % 360) + 360) % 360) - 180;
198
199
  }
199
200
 
200
- function roundAngle(angle: number): number {
201
- return Math.round(angle * 10) / 10;
202
- }
203
-
204
201
  export function resolveDomEditRotationGesture(input: {
205
202
  centerX: number;
206
203
  centerY: number;
@@ -223,7 +220,7 @@ export function resolveDomEditRotationGesture(input: {
223
220
  return {
224
221
  angle: input.snap
225
222
  ? Math.round(angle / ROTATION_SNAP_DEGREES) * ROTATION_SNAP_DEGREES
226
- : roundAngle(angle),
223
+ : roundTo3(angle),
227
224
  };
228
225
  }
229
226
 
@@ -0,0 +1,83 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import type { DomEditSelection } from "./domEditing";
4
+ import type { GestureState } from "./domEditOverlayGestures";
5
+ import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
6
+
7
+ afterEach(() => {
8
+ delete (window as { gsap?: unknown }).gsap;
9
+ delete (window as { __timelines?: unknown }).__timelines;
10
+ document.body.innerHTML = "";
11
+ });
12
+
13
+ const PRESS = {
14
+ clientX: 10,
15
+ clientY: 10,
16
+ pointerId: 1,
17
+ button: 0,
18
+ preventDefault() {},
19
+ stopPropagation() {},
20
+ currentTarget: { setPointerCapture() {} },
21
+ };
22
+
23
+ /** Presses a drag on the element and returns the gesture it started. */
24
+ function pressDrag(element: HTMLElement): GestureState | null {
25
+ const opts = pressOptions(element);
26
+ expect(
27
+ createDomEditOverlayGestureHandlers(opts as never).startGesture("drag", PRESS as never),
28
+ ).toBe(true);
29
+ return opts.gestureRef.current;
30
+ }
31
+
32
+ function pressOptions(element: HTMLElement) {
33
+ const ref = <T>(current: T) => ({ current });
34
+ const selection = { element, capabilities: { canApplyManualOffset: true } };
35
+ return {
36
+ selectionRef: ref(selection as unknown as DomEditSelection),
37
+ overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
38
+ boxRef: ref(null),
39
+ overlayRef: ref(null),
40
+ iframeRef: ref(null),
41
+ gestureRef: ref<GestureState | null>(null),
42
+ rafPausedRef: ref(false),
43
+ onManualDragStartRef: ref(vi.fn()),
44
+ onBlockedMoveRef: ref(vi.fn()),
45
+ };
46
+ }
47
+
48
+ describe("a drag press on a page that loads GSAP", () => {
49
+ 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) => {
55
+ const getProperty = vi.fn(() => 0);
56
+ const set = vi.fn();
57
+ const parent = document.createElement("div");
58
+ const element = document.createElement("div");
59
+ element.style.setProperty("translate", "40px 30px");
60
+ parent.append(element);
61
+ document.body.append(parent);
62
+ const tween = { targets: () => [parent], vars: { x: 100 }, duration: () => 2 };
63
+ const timelines = { main: { getChildren: () => (parentTween ? [tween] : []) } };
64
+ Object.assign(window, { gsap: { getProperty, set }, __timelines: timelines });
65
+ expect(pressDrag(element)?.pathOffsetMember?.plainTranslate).toBe(true);
66
+ expect(getProperty).not.toHaveBeenCalled();
67
+ expect(set).not.toHaveBeenCalled();
68
+ expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
69
+ },
70
+ );
71
+ });
72
+
73
+ describe("a drag press on a centred element without GSAP", () => {
74
+ it("starts from its -50% translate in px and leaves its style as it was", () => {
75
+ const element = document.createElement("div");
76
+ element.style.cssText =
77
+ "position: absolute; left: 50%; top: 50%; width: 240px; height: 160px; translate: -50% -50%";
78
+ document.body.append(element);
79
+ const style = element.getAttribute("style");
80
+ expect(pressDrag(element)?.pathOffsetMember?.initialOffset).toEqual({ x: -120, y: -80 });
81
+ expect(element.getAttribute("style")).toBe(style);
82
+ });
83
+ });
@@ -70,6 +70,7 @@ export function startGroupDrag(
70
70
  selection: item.selection,
71
71
  element: item.element,
72
72
  rect: item.rect,
73
+ gesture: "drag",
73
74
  });
74
75
  if (!result.ok) {
75
76
  restoreManualOffsetDragMembers(members);
@@ -151,10 +152,6 @@ export function startGesture(
151
152
  return false;
152
153
 
153
154
  const size = readStudioBoxSize(sel.element);
154
- // Single-source rotation base = the live GSAP transform rotation plus any legacy
155
- // `--hf-studio-rotation` CSS var (old projects), so a rotate gesture starts from the
156
- // element's actual visual angle and commits an absolute angle to the timeline.
157
- const rotation = { angle: readGsapRotation(sel.element) + readStudioRotation(sel.element).angle };
158
155
  // The draft writes CSS width/height, so the resize base must be the CSS
159
156
  // layout size. offsetWidth/Height are transform-free; the overlay-rect
160
157
  // fallback (rect / editScale) includes the element's own GSAP scale and
@@ -198,6 +195,7 @@ export function startGesture(
198
195
  selection: sel,
199
196
  element: sel.element,
200
197
  rect,
198
+ gesture: "drag",
201
199
  });
202
200
  if (!result.ok) {
203
201
  opts.onBlockedMoveRef.current(result.selection);
@@ -232,6 +230,10 @@ export function startGesture(
232
230
  }
233
231
  }
234
232
 
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 };
235
237
  const overlayBounds = overlayEl?.getBoundingClientRect();
236
238
  const centerX = (overlayBounds?.left ?? 0) + rect.left + rect.width / 2;
237
239
  const centerY = (overlayBounds?.top ?? 0) + rect.top + rect.height / 2;
@@ -1,5 +1,6 @@
1
1
  import type { PatchOperation } from "../../utils/sourcePatcher";
2
2
  import { LAYER_REVEAL_PRIOR_POSITION_ATTR } from "../../player/lib/timelineElementHelpers";
3
+ import { roundTo3 } from "../../utils/rounding";
3
4
 
4
5
  interface LayoutBox {
5
6
  left: number;
@@ -30,8 +31,8 @@ export function applyElementPositionOffset(
30
31
  ): PatchOperation[] | ElementOffsetRefusal {
31
32
  const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
32
33
  if (!cs) return "anchored";
33
- const dx = Math.round(gestureOffset.x - readDragStart(el, "x"));
34
- const dy = Math.round(gestureOffset.y - readDragStart(el, "y"));
34
+ const dx = roundTo3(gestureOffset.x - readDragStart(el, "x"));
35
+ const dy = roundTo3(gestureOffset.y - readDragStart(el, "y"));
35
36
  // A Layers-panel pick lifts a static element to relative for display only.
36
37
  const lifted = el.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR) === "static";
37
38
  const isStatic = lifted || !/^(relative|absolute|fixed|sticky)$/.test(cs.position);
@@ -41,8 +42,8 @@ export function applyElementPositionOffset(
41
42
  const previous = { position: el.style.position, left: el.style.left, top: el.style.top };
42
43
  const before = layoutBox(el);
43
44
  if (isStatic) el.style.position = "relative";
44
- el.style.left = `${Math.round(baseLeft) + dx}px`;
45
- el.style.top = `${Math.round(baseTop) + dy}px`;
45
+ el.style.left = `${roundTo3(baseLeft + dx)}px`;
46
+ el.style.top = `${roundTo3(baseTop + dy)}px`;
46
47
  const after = layoutBox(el);
47
48
  const shiftedExactly =
48
49
  Math.abs(after.left - before.left - dx) <= 1 &&
@@ -146,6 +146,20 @@ describe("studio manual edits", () => {
146
146
  expect(card.style.getPropertyValue("rotate")).toBe(`var(${STUDIO_ROTATION_PROP}, 0deg)`);
147
147
  });
148
148
 
149
+ it("keeps sub-pixel offsets, and box sizes on whole px so GSAP reads a -50% centring exactly", () => {
150
+ const document = createDocument(`<div id="card" style="width: 160px; height: 90px"></div>`);
151
+ const card = document.getElementById("card") as HTMLElement;
152
+ mockBoundingRect(card, 160, 90);
153
+
154
+ applyStudioPathOffsetDraft(card, { x: 14.25, y: -8.5 });
155
+ applyStudioBoxSizeDraft(card, { width: 240.4, height: 135.6 });
156
+
157
+ expect(card.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("14.25px");
158
+ expect(card.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("-8.5px");
159
+ expect(card.style.getPropertyValue("width")).toBe("240px");
160
+ expect(card.style.getPropertyValue("height")).toBe("136px");
161
+ });
162
+
149
163
  it("applies box sizes through CSS dimensions and flex sizing overrides", () => {
150
164
  const document = createDocument(`
151
165
  <div style="display: flex; flex-direction: row">
@@ -188,24 +202,24 @@ describe("studio manual edits", () => {
188
202
  );
189
203
  const card = document.getElementById("card") as HTMLElement;
190
204
 
191
- applyStudioRotation(card, { angle: 24.24 });
205
+ applyStudioRotation(card, { angle: 24.2468 });
192
206
 
193
- expect(readStudioRotation(card)).toEqual({ angle: 24.2 });
194
- expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.2deg");
207
+ expect(readStudioRotation(card)).toEqual({ angle: 24.247 });
208
+ expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.247deg");
195
209
  expect(card.style.getPropertyValue("rotate")).toContain("8deg");
196
210
  expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
197
211
  expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
198
212
 
199
- applyStudioRotationDraft(card, { angle: -12.26 });
200
- expect(readStudioRotation(card)).toEqual({ angle: -12.3 });
201
- expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.3deg)");
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)");
202
216
  expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
203
217
 
204
218
  const snapshot = captureStudioRotation(card);
205
219
  applyStudioRotationDraft(card, { angle: 45 });
206
220
  restoreStudioRotation(card, snapshot);
207
- expect(readStudioRotation(card)).toEqual({ angle: -12.3 });
208
- expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.3deg)");
221
+ expect(readStudioRotation(card)).toEqual({ angle: -12.26 });
222
+ expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.26deg)");
209
223
  expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
210
224
  });
211
225
 
@@ -32,9 +32,9 @@ import {
32
32
  STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
33
33
  STUDIO_ROTATION_TRANSFORM_ORIGIN,
34
34
  } from "./manualEditsTypes";
35
- import { roundRotationAngle } from "./manualEditsParsing";
36
35
  import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
37
36
  import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
37
+ import { roundTo3, roundToLayoutPx } from "../../utils/rounding";
38
38
 
39
39
  /* ── Gesture tracking ─────────────────────────────────────────────── */
40
40
  let studioManualEditGestureId = 0;
@@ -203,8 +203,8 @@ function writeStudioPathOffsetVars(
203
203
  ): void {
204
204
  prepareStudioPathOffsetBase(element, options.updateBase ?? true);
205
205
  element.setAttribute(STUDIO_PATH_OFFSET_ATTR, "true");
206
- element.style.setProperty(STUDIO_OFFSET_X_PROP, `${Math.round(offset.x)}px`);
207
- element.style.setProperty(STUDIO_OFFSET_Y_PROP, `${Math.round(offset.y)}px`);
206
+ element.style.setProperty(STUDIO_OFFSET_X_PROP, `${roundTo3(offset.x)}px`);
207
+ element.style.setProperty(STUDIO_OFFSET_Y_PROP, `${roundTo3(offset.y)}px`);
208
208
  }
209
209
 
210
210
  /* ── Path offset apply ────────────────────────────────────────────── */
@@ -325,7 +325,7 @@ export function applyStudioPathOffsetDraft(
325
325
  // Non-GSAP elements: use CSS translate as before.
326
326
  element.style.setProperty(
327
327
  "translate",
328
- composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`),
328
+ composeTranslateValue(element, `${roundTo3(offset.x)}px`, `${roundTo3(offset.y)}px`),
329
329
  );
330
330
  stripGsapTranslateFromTransform(element);
331
331
  }
@@ -342,7 +342,7 @@ function readParentFlexBasisPixels(
342
342
  if (display !== "flex" && display !== "inline-flex") return null;
343
343
 
344
344
  const direction = readStyleOrComputed(parent, "flex-direction").trim();
345
- return Math.round(Math.max(1, direction.startsWith("column") ? size.height : size.width));
345
+ return roundToLayoutPx(Math.max(1, direction.startsWith("column") ? size.height : size.width));
346
346
  }
347
347
 
348
348
  function restoreStaleStudioScaleResize(element: HTMLElement): void {
@@ -417,8 +417,8 @@ function writeStudioBoxSizeVars(
417
417
  }
418
418
 
419
419
  element.setAttribute(STUDIO_BOX_SIZE_ATTR, "true");
420
- element.style.setProperty(STUDIO_WIDTH_PROP, `${Math.round(Math.max(1, size.width))}px`);
421
- element.style.setProperty(STUDIO_HEIGHT_PROP, `${Math.round(Math.max(1, size.height))}px`);
420
+ element.style.setProperty(STUDIO_WIDTH_PROP, `${roundToLayoutPx(Math.max(1, size.width))}px`);
421
+ element.style.setProperty(STUDIO_HEIGHT_PROP, `${roundToLayoutPx(Math.max(1, size.height))}px`);
422
422
  }
423
423
 
424
424
  function applyStudioBoxSizeDimensions(
@@ -428,8 +428,8 @@ function applyStudioBoxSizeDimensions(
428
428
  writeStudioBoxSizeVars(element, size);
429
429
  restoreStaleStudioScaleResize(element);
430
430
 
431
- const width = Math.round(Math.max(1, size.width));
432
- const height = Math.round(Math.max(1, size.height));
431
+ const width = roundToLayoutPx(Math.max(1, size.width));
432
+ const height = roundToLayoutPx(Math.max(1, size.height));
433
433
  element.style.setProperty("box-sizing", "border-box");
434
434
  element.style.setProperty("width", `${width}px`);
435
435
  element.style.setProperty("height", `${height}px`);
@@ -457,10 +457,26 @@ export function applyStudioBoxSize(
457
457
  applyStudioBoxSizeDimensions(element, size);
458
458
  }
459
459
 
460
+ // The element's box before a resize draft first changed it; gone once the resize saves or is restored.
461
+ const boxSizeDraftBases = new WeakMap<HTMLElement, { width: number; height: number }>();
462
+
463
+ export function readStudioBoxSizeDraftBase(
464
+ element: HTMLElement,
465
+ ): { width: number; height: number } | null {
466
+ return boxSizeDraftBases.get(element) ?? null;
467
+ }
468
+
469
+ export function forgetStudioBoxSizeDraftBase(element: HTMLElement): void {
470
+ boxSizeDraftBases.delete(element);
471
+ }
472
+
460
473
  export function applyStudioBoxSizeDraft(
461
474
  element: HTMLElement,
462
475
  size: { width: number; height: number },
463
476
  ): void {
477
+ if (!boxSizeDraftBases.has(element)) {
478
+ boxSizeDraftBases.set(element, { width: element.offsetWidth, height: element.offsetHeight });
479
+ }
464
480
  promoteInlineForTransform(element);
465
481
  applyStudioBoxSizeDimensions(element, size);
466
482
  }
@@ -509,7 +525,7 @@ function writeStudioRotationVars(
509
525
  ): void {
510
526
  prepareStudioRotationBase(element, options.updateBase ?? true);
511
527
  element.setAttribute(STUDIO_ROTATION_ATTR, "true");
512
- element.style.setProperty(STUDIO_ROTATION_PROP, `${roundRotationAngle(rotation.angle)}deg`);
528
+ element.style.setProperty(STUDIO_ROTATION_PROP, `${roundTo3(rotation.angle)}deg`);
513
529
  element.style.setProperty("transform-origin", STUDIO_ROTATION_TRANSFORM_ORIGIN);
514
530
  }
515
531
 
@@ -529,6 +545,6 @@ export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle
529
545
  element.setAttribute(STUDIO_ROTATION_DRAFT_ATTR, "true");
530
546
  element.style.setProperty(
531
547
  "rotate",
532
- composeStudioRotationValue(element, `${roundRotationAngle(rotation.angle)}deg`),
548
+ composeStudioRotationValue(element, `${roundTo3(rotation.angle)}deg`),
533
549
  );
534
550
  }
@@ -3,10 +3,6 @@ export function finiteNumber(value: unknown): number | null {
3
3
  return typeof value === "number" && Number.isFinite(value) ? value : null;
4
4
  }
5
5
 
6
- export function roundRotationAngle(angle: number): number {
7
- return Math.round(angle * 10) / 10;
8
- }
9
-
10
6
  /* ── File path utilities ──────────────────────────────────────────── */
11
7
  function normalizeStudioFileChangePath(path: string): string {
12
8
  return path
@@ -3,6 +3,7 @@ import {
3
3
  styleUsesStudioSize,
4
4
  styleUsesStudioRotation,
5
5
  restoreInlineDisplay,
6
+ forgetStudioBoxSizeDraftBase,
6
7
  } from "./manualEditsDom";
7
8
  import {
8
9
  STUDIO_OFFSET_X_PROP,
@@ -114,6 +115,7 @@ function restoreStyleProperty(element: HTMLElement, property: string, value: str
114
115
  }
115
116
 
116
117
  export function restoreStudioBoxSize(element: HTMLElement, previous: StudioBoxSizeSnapshot): void {
118
+ forgetStudioBoxSizeDraftBase(element);
117
119
  restoreStyleProperty(element, "width", previous.width);
118
120
  restoreStyleProperty(element, "height", previous.height);
119
121
  restoreStyleProperty(element, "min-width", previous.minWidth);
@@ -290,6 +292,7 @@ export function clearStudioRotation(element: HTMLElement): void {
290
292
  }
291
293
 
292
294
  export function clearStudioBoxSize(element: HTMLElement): void {
295
+ forgetStudioBoxSizeDraftBase(element);
293
296
  if (
294
297
  element.hasAttribute(STUDIO_BOX_SIZE_ATTR) ||
295
298
  styleUsesStudioSize(element.style.getPropertyValue("width")) ||