@hyperframes/studio 0.8.98 → 0.8.99

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 (51) hide show
  1. package/dist/assets/{hyperframes-player-C6muFtfk.js → hyperframes-player-Rml34FRW.js} +1 -1
  2. package/dist/assets/{index-itc1XziP.js → index-CVDArg3m.js} +178 -178
  3. package/dist/assets/{index-B6vU39NM.js → index-C_fi-jTe.js} +1 -1
  4. package/dist/assets/{index-BxnoLgxG.js → index-D9YzDuBx.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +542 -373
  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/cropResize.test.ts +129 -0
  13. package/src/components/editor/cropResize.ts +143 -0
  14. package/src/components/editor/domEditOverlayCrop.ts +65 -15
  15. package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
  16. package/src/components/editor/domEditOverlayGestures.ts +2 -5
  17. package/src/components/editor/elementPositionOffset.ts +5 -4
  18. package/src/components/editor/manualEdits.test.ts +22 -8
  19. package/src/components/editor/manualEditsDom.ts +27 -11
  20. package/src/components/editor/manualEditsParsing.ts +0 -4
  21. package/src/components/editor/manualEditsSnapshot.ts +3 -0
  22. package/src/components/editor/manualOffsetDrag.test.ts +14 -0
  23. package/src/hooks/domSelectionTestHarness.ts +8 -0
  24. package/src/hooks/draggedGsapPosition.ts +4 -2
  25. package/src/hooks/gsapDragCommit.ts +6 -6
  26. package/src/hooks/gsapEditOutcome.ts +6 -0
  27. package/src/hooks/gsapResizeIntercept.ts +5 -5
  28. package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
  29. package/src/hooks/gsapRuntimeBridge.ts +2 -1
  30. package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
  31. package/src/hooks/gsapRuntimeKeyframes.ts +34 -21
  32. package/src/hooks/timelineTimingSync.ts +40 -4
  33. package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
  34. package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
  35. package/src/hooks/useCropOverlay.ts +8 -2
  36. package/src/hooks/useDomEditCommits.test.tsx +6 -1
  37. package/src/hooks/useDomEditCommits.ts +1 -0
  38. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
  39. package/src/hooks/useDomEditSession.ts +2 -0
  40. package/src/hooks/useDomGeometryCommit.ts +1 -0
  41. package/src/hooks/useDomGeometryCommits.test.tsx +112 -0
  42. package/src/hooks/useDomGeometryCommits.ts +5 -0
  43. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
  44. package/src/hooks/useGsapAwareEditing.test.tsx +101 -5
  45. package/src/hooks/useGsapAwareEditing.ts +37 -23
  46. package/src/hooks/useTimelineEditing.test.tsx +190 -135
  47. package/src/hooks/useTimelineEditing.ts +13 -13
  48. package/src/hooks/useTimelineGroupEditing.ts +13 -9
  49. package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
  50. package/src/utils/rounding.ts +5 -0
  51. package/src/utils/sdkEditTransaction.ts +10 -3
@@ -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
 
@@ -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")) ||
@@ -13,6 +13,7 @@ import {
13
13
  type ManualOffsetDragMatrix,
14
14
  } from "./manualOffsetDrag";
15
15
  import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits";
16
+ import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
16
17
 
17
18
  function expectMatrixClose(actual: ManualOffsetDragMatrix, expected: ManualOffsetDragMatrix): void {
18
19
  expect(actual.a).toBeCloseTo(expected.a, 6);
@@ -449,6 +450,19 @@ describe("GSAP-element drag — dot-a flies regressions", () => {
449
450
  });
450
451
  });
451
452
 
453
+ describe("sub-pixel drags", () => {
454
+ it("draft and commit both land the exact fractional position", () => {
455
+ const { element, sets, member } = makeGsapDot();
456
+ const m = member();
457
+ applyManualOffsetDragDraft(m, 10.25, -3.5);
458
+ const draft = sets.at(-1)!;
459
+ expect(draft).toEqual({ x: m.baseGsap.x + 10.25, y: m.baseGsap.y - 3.5 });
460
+ const offset = applyManualOffsetDragCommit(m, 10.25, -3.5);
461
+ const { newX, newY } = computeDraggedGsapPosition(element, offset, m.baseGsap);
462
+ expect({ x: newX, y: newY }).toEqual(draft);
463
+ });
464
+ });
465
+
452
466
  describe("a stylesheet translate after a committed GSAP drag", () => {
453
467
  function draggedTitle(css: string, oldOffsetChannel = false, withGsap = true) {
454
468
  const window = new Window();
@@ -12,6 +12,14 @@ export function installReactActEnvironment(): void {
12
12
  });
13
13
  }
14
14
 
15
+ export function withInlineLayoutBox<T extends HTMLElement>(el: T): T {
16
+ Object.defineProperties(el, {
17
+ offsetWidth: { get: () => Number.parseFloat(el.style.width) || 0 },
18
+ offsetHeight: { get: () => Number.parseFloat(el.style.height) || 0 },
19
+ });
20
+ return el;
21
+ }
22
+
15
23
  /** Mount a React element into a fresh detached host and return its root. */
16
24
  export function mountReactHarness(node: React.ReactElement): Root {
17
25
  const host = document.createElement("div");
@@ -1,3 +1,5 @@
1
+ import { roundTo3 } from "../utils/rounding";
2
+
1
3
  /**
2
4
  * Drag → GSAP position math, shared by the commit path
3
5
  * (`gsapDragCommit.commitGsapPositionFromDrag` / `commitStaticGsapPosition`) and
@@ -39,8 +41,8 @@ export function computeDraggedGsapPosition(
39
41
  const baseGsapX = Number.isFinite(parsedBaseX) ? parsedBaseX : fallbackBase.x;
40
42
  const baseGsapY = Number.isFinite(parsedBaseY) ? parsedBaseY : fallbackBase.y;
41
43
  return {
42
- newX: Math.round(baseGsapX + adjX),
43
- newY: Math.round(baseGsapY + adjY),
44
+ newX: roundTo3(baseGsapX + adjX),
45
+ newY: roundTo3(baseGsapY + adjY),
44
46
  baseGsapX,
45
47
  baseGsapY,
46
48
  };
@@ -10,7 +10,7 @@ import {
10
10
  } from "../components/editor/manualEditsTypes";
11
11
  import { usePlayerStore } from "../player/store/playerStore";
12
12
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
13
- import { roundTo3 } from "../utils/rounding";
13
+ import { roundTo3, roundToLayoutPx } from "../utils/rounding";
14
14
  import { computeElementPercentage, writeTargetSelector } from "./gsapShared";
15
15
  import { computeDraggedGsapPosition } from "./draggedGsapPosition";
16
16
  import type { RuntimeTweenChange } from "./gsapRuntimePatch";
@@ -289,8 +289,8 @@ export async function commitStaticGsapSize(
289
289
  existingSet: GsapAnimation | null,
290
290
  callbacks: GsapDragCommitCallbacks,
291
291
  ): Promise<void> {
292
- const width = Math.round(size.width);
293
- const height = Math.round(size.height);
292
+ const width = roundToLayoutPx(size.width);
293
+ const height = roundToLayoutPx(size.height);
294
294
  if (existingSet) {
295
295
  await callbacks.commitMutation(
296
296
  selection,
@@ -320,7 +320,7 @@ export async function commitStaticGsapSize(
320
320
 
321
321
  /** Rounded `n` when it's a positive finite number, else `fallback`. */
322
322
  function positiveOr(n: number, fallback: number): number {
323
- return Number.isFinite(n) && n > 0 ? Math.round(n) : fallback;
323
+ return Number.isFinite(n) && n > 0 ? roundToLayoutPx(n) : fallback;
324
324
  }
325
325
 
326
326
  /**
@@ -366,8 +366,8 @@ export async function commitKeyframedSizeFromResize(
366
366
  const td = resolveTweenDuration(animatedTween);
367
367
  if (!(td > 0)) return false;
368
368
 
369
- const newW = Math.round(size.width);
370
- const newH = Math.round(size.height);
369
+ const newW = roundToLayoutPx(size.width);
370
+ const newH = roundToLayoutPx(size.height);
371
371
  const prior = resolvePriorSize(sizeSet, selection.element, newW, newH);
372
372
 
373
373
  const ct = usePlayerStore.getState().currentTime;
@@ -67,6 +67,12 @@ export function assertGsapEditPersisted(outcome: GsapEditOutcome): void {
67
67
  if (outcome.status === "blocked") throw new GsapEditBlockedError(outcome.reason, outcome.detail);
68
68
  }
69
69
 
70
+ /** A move only a shared tween positions is saved on the element itself; a blocked one throws. */
71
+ export async function saveMove(outcome: GsapEditOutcome, saveOnElement: () => Promise<void>) {
72
+ if (outcome.status === "element-offset") return saveOnElement();
73
+ assertGsapEditPersisted(outcome);
74
+ }
75
+
70
76
  function assertGsapAnimationDirectlyEditable(animation: GsapAnimation): void {
71
77
  const editability = editabilityForProvenance(animation.provenance);
72
78
  if (editability === "unroll") throw new GsapEditBlockedError("unroll-required");
@@ -31,7 +31,7 @@ import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
31
31
  import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
32
32
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
33
33
  import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
34
- import { roundTo3 } from "../utils/rounding";
34
+ import { roundTo3, roundToLayoutPx } from "../utils/rounding";
35
35
  import { resolveGroupTween } from "./gsapRuntimeBridge";
36
36
  import { logResize } from "../utils/resizeDebug";
37
37
  import { animationWritesAnyProperty, type GsapEditOutcome } from "./gsapEditOutcome";
@@ -287,8 +287,8 @@ export async function tryGsapResizeIntercept(
287
287
  }
288
288
  } else {
289
289
  resizeProps = {
290
- width: Math.round(size.width),
291
- height: Math.round(size.height),
290
+ width: roundToLayoutPx(size.width),
291
+ height: roundToLayoutPx(size.height),
292
292
  };
293
293
  }
294
294
  // Finalize a scale-route commit: tear down the gesture's inline width/height
@@ -358,8 +358,8 @@ export async function tryGsapResizeIntercept(
358
358
  // persisted file agree exactly (commitStaticGsapPosition composes the same
359
359
  // rounded value from this delta).
360
360
  const corrected = {
361
- x: Math.round(base.x + residual.x),
362
- y: Math.round(base.y + residual.y),
361
+ x: roundTo3(base.x + residual.x),
362
+ y: roundTo3(base.y + residual.y),
363
363
  };
364
364
  logResize("scale-finalize", {
365
365
  dropPoint: scaleDraftDropPoint,
@@ -319,6 +319,17 @@ describe("tryGsapRotationIntercept — instant holds", () => {
319
319
  expect(commitMutation).not.toHaveBeenCalled();
320
320
  });
321
321
 
322
+ it("commits the exact angle the draft showed", async () => {
323
+ const commitMutation = vi.fn();
324
+
325
+ await tryGsapRotationIntercept(selection, 75.25, [], null, commitMutation);
326
+ expect(commitMutation).toHaveBeenCalledWith(
327
+ selection,
328
+ expect.objectContaining({ type: "add", properties: { rotation: 75.25 } }),
329
+ expect.anything(),
330
+ );
331
+ });
332
+
322
333
  it("does not let an unrelated helper-authored skew tween block 2D rotation", async () => {
323
334
  const helperSkew = {
324
335
  id: "#puck-b-to-rotation",
@@ -27,6 +27,7 @@ import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
27
27
  import { isGestureTransactionCommit } from "./gestureTransaction";
28
28
  import { tweenReach, tweensForThisElement } from "./gsapTweenReach";
29
29
  import { resolveTweenDuration } from "../utils/globalTimeCompiler";
30
+ import { roundTo3 } from "../utils/rounding";
30
31
  import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
31
32
  import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
32
33
  import {
@@ -446,7 +447,7 @@ export async function tryGsapRotationIntercept(
446
447
  // `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
447
448
  // pointer sweep) or the inspector — so it IS the new rotation. No base re-add: the
448
449
  // gesture's live preview already gsap.set this value (single source of truth).
449
- const newRotation = Math.round(angle);
450
+ const newRotation = roundTo3(angle);
450
451
  // STATIC case (single source of truth = GSAP timeline): no rotation tween, so the
451
452
  // angle belongs in a `tl.set("#el",{rotation})`, not a keyframe conversion —
452
453
  // mirroring the static position set. Idempotent: re-rotate updates an existing
@@ -167,6 +167,13 @@ describe("hasNonHoldTweenForElement — strict live-tween existence (drag stale-
167
167
  expect(hasNonHoldTweenForElement(fakeIframe(el, [liveTween]), "#puck-b")).toBe(true);
168
168
  });
169
169
 
170
+ it("true when the tween sits in a later timeline (a soft reload re-adds main last)", () => {
171
+ const iframe = fakeIframe(el, []);
172
+ const win = iframe.contentWindow as unknown as { __timelines: Record<string, unknown> };
173
+ win.__timelines.main = { getChildren: () => [liveTween] };
174
+ expect(hasNonHoldTweenForElement(iframe, "#puck-b")).toBe(true);
175
+ });
176
+
170
177
  it("false when only a zero-duration hold/set remains (post delete-all)", () => {
171
178
  expect(hasNonHoldTweenForElement(fakeIframe(el, [holdSet]), "#puck-b")).toBe(false);
172
179
  });