@hyperframes/studio 0.8.97 → 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 (75) hide show
  1. package/dist/assets/{hyperframes-player-Biu5_W0l.js → hyperframes-player-Rml34FRW.js} +1 -1
  2. package/dist/assets/{index-BdV5T-QI.js → index-CVDArg3m.js} +188 -188
  3. package/dist/assets/{index-B5Z-1RW9.js → index-C_fi-jTe.js} +1 -1
  4. package/dist/assets/{index-CWuozokW.js → index-D9YzDuBx.js} +1 -1
  5. package/dist/index.d.ts +16 -4
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +758 -607
  8. package/dist/index.js.map +1 -1
  9. package/package.json +10 -9
  10. package/src/components/EditorShell.tsx +7 -0
  11. package/src/components/editor/DomEditCropHandles.test.tsx +34 -66
  12. package/src/components/editor/DomEditCropHandles.tsx +30 -62
  13. package/src/components/editor/DomEditOverlay.test.ts +2 -2
  14. package/src/components/editor/DomEditOverlay.tsx +0 -27
  15. package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -5
  16. package/src/components/editor/DomEditSelectionChrome.tsx +0 -6
  17. package/src/components/editor/clickAfterPreventedPress.test.tsx +122 -0
  18. package/src/components/editor/cropResize.test.ts +129 -0
  19. package/src/components/editor/cropResize.ts +143 -0
  20. package/src/components/editor/domEditOverlayCrop.ts +65 -15
  21. package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
  22. package/src/components/editor/domEditOverlayGestures.ts +2 -5
  23. package/src/components/editor/elementPositionOffset.ts +5 -4
  24. package/src/components/editor/manualEdits.test.ts +22 -8
  25. package/src/components/editor/manualEditsDom.ts +27 -11
  26. package/src/components/editor/manualEditsParsing.ts +0 -4
  27. package/src/components/editor/manualEditsSnapshot.ts +3 -0
  28. package/src/components/editor/manualOffsetDrag.test.ts +14 -0
  29. package/src/components/editor/propertyPanelFont.test.tsx +48 -5
  30. package/src/components/editor/propertyPanelFont.tsx +2 -1
  31. package/src/components/editor/shiftDragAxisLock.test.tsx +0 -2
  32. package/src/components/nle/TimelinePane.tsx +4 -0
  33. package/src/hooks/domSelectionTestHarness.ts +8 -0
  34. package/src/hooks/draggedGsapPosition.ts +4 -2
  35. package/src/hooks/gsapDragCommit.ts +6 -6
  36. package/src/hooks/gsapEditOutcome.ts +6 -0
  37. package/src/hooks/gsapResizeIntercept.ts +5 -5
  38. package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
  39. package/src/hooks/gsapRuntimeBridge.ts +2 -1
  40. package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
  41. package/src/hooks/gsapRuntimeKeyframes.ts +34 -21
  42. package/src/hooks/timelineTimingSync.ts +40 -4
  43. package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
  44. package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
  45. package/src/hooks/useCommitPreflightCapabilities.ts +3 -11
  46. package/src/hooks/useCropOverlay.ts +8 -2
  47. package/src/hooks/useDomEditCommits.test.tsx +6 -1
  48. package/src/hooks/useDomEditCommits.ts +1 -0
  49. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
  50. package/src/hooks/useDomEditSession.ts +2 -0
  51. package/src/hooks/useDomGeometryCommit.ts +1 -0
  52. package/src/hooks/useDomGeometryCommits.test.tsx +112 -0
  53. package/src/hooks/useDomGeometryCommits.ts +5 -0
  54. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
  55. package/src/hooks/useGsapAnimationFetchFallback.test.ts +44 -3
  56. package/src/hooks/useGsapAnimationFetchFallback.ts +11 -35
  57. package/src/hooks/useGsapAwareEditing.test.tsx +101 -5
  58. package/src/hooks/useGsapAwareEditing.ts +37 -23
  59. package/src/hooks/useGsapTweenCache.element.test.tsx +23 -1
  60. package/src/hooks/useGsapTweenCache.ts +0 -24
  61. package/src/hooks/useTimelineEditing.test.tsx +190 -135
  62. package/src/hooks/useTimelineEditing.ts +13 -13
  63. package/src/hooks/useTimelineGroupEditing.ts +13 -9
  64. package/src/index.ts +4 -1
  65. package/src/player/components/ClipContextMenu.test.tsx +182 -0
  66. package/src/player/components/ClipContextMenu.tsx +52 -10
  67. package/src/player/components/TimelineOverlays.test.ts +30 -7
  68. package/src/player/components/TimelineOverlays.tsx +7 -1
  69. package/src/player/components/TimelineProvider.tsx +1 -0
  70. package/src/player/components/TimelineTypes.ts +10 -0
  71. package/src/player/components/menuKeyboardNav.ts +12 -5
  72. package/src/player/components/useTimelineProviderState.tsx +2 -0
  73. package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
  74. package/src/utils/rounding.ts +5 -0
  75. package/src/utils/sdkEditTransaction.ts +10 -3
@@ -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();
@@ -2,13 +2,32 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { FontFamilyField } from "./propertyPanelFont";
7
+ import { sortFontOptions } from "./propertyPanelHelpers";
8
+
9
+ vi.mock("./propertyPanelHelpers", async (importOriginal) => {
10
+ const actual = await importOriginal<typeof import("./propertyPanelHelpers")>();
11
+ return { ...actual, sortFontOptions: vi.fn(actual.sortFontOptions) };
12
+ });
7
13
 
8
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
15
 
16
+ // The font APIs are stubbed and every family is off the Google lists, so no test reaches the network.
17
+ beforeEach(() => {
18
+ vi.stubGlobal(
19
+ "fetch",
20
+ vi.fn(async (url: string) => ({
21
+ ok: true,
22
+ json: async () => ({ fonts: url.includes("google") ? ["Roboto Slab"] : ["Arial"] }),
23
+ })),
24
+ );
25
+ });
26
+
10
27
  afterEach(() => {
11
28
  document.body.innerHTML = "";
29
+ document.head.innerHTML = "";
30
+ vi.unstubAllGlobals();
12
31
  });
13
32
 
14
33
  describe("FontFamilyField flat trigger", () => {
@@ -17,14 +36,38 @@ describe("FontFamilyField flat trigger", () => {
17
36
  document.body.append(host);
18
37
  const root = createRoot(host);
19
38
  act(() => {
20
- root.render(
21
- <FontFamilyField flat value="JetBrains Mono" importedFonts={[]} onCommit={vi.fn()} />,
22
- );
39
+ root.render(<FontFamilyField flat value="Georgia" importedFonts={[]} onCommit={vi.fn()} />);
23
40
  });
24
41
  const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
25
42
  expect(trigger).not.toBeNull();
26
43
  expect(trigger?.className).not.toContain("border-neutral-800");
27
- expect(host.textContent).toContain("JetBrains Mono");
44
+ expect(host.textContent).toContain("Georgia");
28
45
  act(() => root.unmount());
29
46
  });
30
47
  });
48
+
49
+ describe("FontFamilyField font list", () => {
50
+ it("builds the list only while the dropdown is open", async () => {
51
+ vi.mocked(sortFontOptions).mockClear();
52
+ const host = document.createElement("div");
53
+ document.body.append(host);
54
+ const root = createRoot(host);
55
+ const render = (value: string) =>
56
+ root.render(<FontFamilyField flat value={value} importedFonts={[]} onCommit={vi.fn()} />);
57
+ try {
58
+ await act(async () => render("Arial"));
59
+ // The font lists arrive and the value changes while the dropdown is closed, as during a drag.
60
+ await act(async () => render("Georgia"));
61
+ expect(sortFontOptions).not.toHaveBeenCalled();
62
+
63
+ const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
64
+ await act(async () => trigger?.click());
65
+ expect(sortFontOptions).toHaveBeenCalledTimes(1);
66
+ expect(host.textContent).toContain("Roboto Slab");
67
+ expect(document.head.querySelector('link[href*="fonts.googleapis.com"]')).toBeNull();
68
+ } finally {
69
+ act(() => root.unmount());
70
+ host.remove();
71
+ }
72
+ });
73
+ });
@@ -276,6 +276,7 @@ export function FontFamilyField({
276
276
  );
277
277
 
278
278
  const options = useMemo(() => {
279
+ if (!open) return [];
279
280
  const documentFonts = collectDocumentFontFamilies();
280
281
  const googleSet = new Set(googleFonts.map((f) => f.toLowerCase()));
281
282
  const taggedLocal = localFonts.map(
@@ -294,7 +295,7 @@ export function FontFamilyField({
294
295
  ...DEFAULT_FONT_FAMILIES.map((f): FontOption => ({ family: f, source: "System" })),
295
296
  ]),
296
297
  );
297
- }, [currentFamily, googleFonts, localFonts, projectFontAssets]);
298
+ }, [open, currentFamily, googleFonts, localFonts, projectFontAssets]);
298
299
 
299
300
  const filteredOptions = useMemo(() => {
300
301
  const matches = options.filter((o) => fontMatchesQuery(o.family, query));
@@ -193,7 +193,6 @@ describe("a shift press on a selected box starts the drag", () => {
193
193
  selectionKey="box"
194
194
  groupSelectionCount={0}
195
195
  gestures={spies as never}
196
- onBoxMouseDown={vi.fn()}
197
196
  onBoxClick={vi.fn()}
198
197
  />
199
198
  ));
@@ -207,7 +206,6 @@ describe("a shift press on a selected box starts the drag", () => {
207
206
  allowBodyDrag
208
207
  groupCanMove={groupCanMove}
209
208
  gestures={spies as never}
210
- onBoxMouseDown={vi.fn()}
211
209
  onBoxClick={vi.fn()}
212
210
  />
213
211
  ));
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { Timeline } from "../../player";
3
3
  import type { TimelineElement, TimelineTimeRange } from "../../player";
4
+ import type { TimelineProps } from "../../player/components/TimelineTypes";
4
5
  import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
5
6
  import { AudioMeterStrip } from "./AudioMeterStrip";
6
7
  import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
@@ -45,6 +46,7 @@ export interface TimelinePaneProps {
45
46
  onPasteClip?: () => Promise<void>;
46
47
  onDuplicateClip?: () => Promise<boolean>;
47
48
  canPasteClip?: () => boolean;
49
+ clipMenuItems?: TimelineProps["clipMenuItems"];
48
50
  }
49
51
 
50
52
  export function TimelinePane({
@@ -64,6 +66,7 @@ export function TimelinePane({
64
66
  onPasteClip,
65
67
  onDuplicateClip,
66
68
  canPasteClip,
69
+ clipMenuItems,
67
70
  }: TimelinePaneProps) {
68
71
  const {
69
72
  seek,
@@ -119,6 +122,7 @@ export function TimelinePane({
119
122
  onPasteClip={onPasteClip}
120
123
  onDuplicateClip={onDuplicateClip}
121
124
  canPasteClip={canPasteClip}
125
+ clipMenuItems={clipMenuItems}
122
126
  />
123
127
  </div>
124
128
  <AudioMeterStrip />
@@ -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
  });
@@ -200,7 +200,7 @@ function varsCarryChannel(vars: Record<string, unknown> | undefined, channels: s
200
200
  /**
201
201
  * Like `varsCarryChannel` but for a keyframe tween: the channels live inside the
202
202
  * keyframe steps (`vars.keyframes`), not as own props of `vars`. Handles the object
203
- * form (`{ "0%": {...} }`) and the array form (`[{...}, ...]`).
203
+ * form (`{ "0%": {...} }`), the array form (`[{...}, ...]`) and the object-of-arrays form.
204
204
  */
205
205
  function keyframeVarsCarryChannel(
206
206
  vars: Record<string, unknown> | undefined,
@@ -208,6 +208,8 @@ function keyframeVarsCarryChannel(
208
208
  ): boolean {
209
209
  const kf = vars?.keyframes;
210
210
  if (!kf || typeof kf !== "object") return false;
211
+ if (!Array.isArray(kf) && channels.some((ch) => Object.prototype.hasOwnProperty.call(kf, ch)))
212
+ return true;
211
213
  const steps = Array.isArray(kf) ? kf : Object.values(kf);
212
214
  return steps.some(
213
215
  (step) =>
@@ -375,8 +377,9 @@ export function readRuntimeKeyframes(
375
377
  }
376
378
 
377
379
  /**
378
- * Whether the live timeline has at least one NON-HOLD tween (non-zero duration,
379
- * not the studio position-hold `set`) targeting `selector`. Stricter than a
380
+ * Whether any live timeline has at least one NON-HOLD tween (non-zero duration,
381
+ * not the studio position-hold `set`) targeting `selector`. Every timeline is read:
382
+ * a soft reload re-adds the rebuilt composition's key last. Stricter than a
380
383
  * truthy `readRuntimeKeyframes`: that returns a flat read for any property-bearing
381
384
  * tween, so it can't distinguish a real animation from a leftover hold/marker.
382
385
  * The drag's stale-parse guard needs this exact distinction — after a delete-all
@@ -387,37 +390,47 @@ export function readRuntimeKeyframes(
387
390
  * rotation/scale tween doesn't make a static position hold enter the keyframe
388
391
  * branch.
389
392
  */
390
- // fallow-ignore-next-line complexity
391
393
  export function hasNonHoldTweenForElement(
392
394
  iframe: HTMLIFrameElement | null,
393
395
  selector: string,
394
396
  compositionId?: string,
395
397
  channels?: string[],
396
398
  ): boolean {
397
- const timelines = timelinesOf(iframe);
398
- if (!timelines) return false;
399
- const tlId =
400
- compositionId ||
401
- Object.keys(timelines).find((k) => typeof timelines[k]?.getChildren === "function");
402
- if (!tlId) return false;
403
- const timeline = timelines[tlId];
404
- if (!timeline?.getChildren) return false;
405
-
406
399
  let targetEl: Element | null = null;
407
400
  try {
408
401
  targetEl = iframe?.contentDocument?.querySelector(selector) ?? null;
409
402
  } catch {
410
403
  return false;
411
404
  }
412
- if (!targetEl) return false;
405
+ return !!targetEl && hasNonHoldTween(timelinesOf(iframe), targetEl, channels, compositionId);
406
+ }
413
407
 
414
- // fallow-ignore-next-line code-duplication
415
- for (const tween of timeline.getChildren(true)) {
416
- if (!tween.vars || !matchesElement(tween, targetEl)) continue;
417
- const dur = typeof tween.duration === "function" ? tween.duration() : 0;
418
- if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
419
- const read = readTween(tween.vars);
420
- if (read && (!channels || readCarriesChannel(read, channels))) return true;
408
+ /** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */
409
+ export function elementHasNonHoldTween(el: Element, channels?: string[]): boolean {
410
+ const win = el.ownerDocument.defaultView as {
411
+ __timelines?: Record<string, RuntimeTimeline>;
412
+ } | null;
413
+ return hasNonHoldTween(win?.__timelines ?? null, el, channels);
414
+ }
415
+
416
+ // fallow-ignore-next-line complexity
417
+ function hasNonHoldTween(
418
+ timelines: Record<string, RuntimeTimeline> | null,
419
+ targetEl: Element,
420
+ channels?: string[],
421
+ compositionId?: string,
422
+ ): boolean {
423
+ if (!timelines) return false;
424
+ for (const tlId of compositionId ? [compositionId] : Object.keys(timelines)) {
425
+ // fallow-ignore-next-line code-duplication
426
+ for (const tween of timelines[tlId]?.getChildren?.(true) ?? []) {
427
+ if (!tween.vars || !matchesElement(tween, targetEl)) continue;
428
+ const dur = typeof tween.duration === "function" ? tween.duration() : 0;
429
+ if (isZeroDurationSet(dur)) continue; // skip hold/set tweens (see isZeroDurationSet)
430
+ if (channels && keyframeVarsCarryChannel(tween.vars, channels)) return true;
431
+ const read = readTween(tween.vars);
432
+ if (read && (!channels || readCarriesChannel(read, channels))) return true;
433
+ }
421
434
  }
422
435
  return false;
423
436
  }