@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
@@ -5,6 +5,7 @@ import {
5
5
  resumeGsapTimelines,
6
6
  applyManualOffsetDragDraft,
7
7
  applyManualOffsetDragMatrix,
8
+ applyManualOffsetNudgeDraft,
8
9
  createManualOffsetDragMember,
9
10
  endManualOffsetDragMembers,
10
11
  invertManualOffsetDragMatrix,
@@ -13,6 +14,7 @@ import {
13
14
  type ManualOffsetDragMatrix,
14
15
  } from "./manualOffsetDrag";
15
16
  import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits";
17
+ import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
16
18
 
17
19
  function expectMatrixClose(actual: ManualOffsetDragMatrix, expected: ManualOffsetDragMatrix): void {
18
20
  expect(actual.a).toBeCloseTo(expected.a, 6);
@@ -449,6 +451,19 @@ describe("GSAP-element drag — dot-a flies regressions", () => {
449
451
  });
450
452
  });
451
453
 
454
+ describe("sub-pixel drags", () => {
455
+ it("draft and commit both land the exact fractional position", () => {
456
+ const { element, sets, member } = makeGsapDot();
457
+ const m = member();
458
+ applyManualOffsetDragDraft(m, 10.25, -3.5);
459
+ const draft = sets.at(-1)!;
460
+ expect(draft).toEqual({ x: m.baseGsap.x + 10.25, y: m.baseGsap.y - 3.5 });
461
+ const offset = applyManualOffsetDragCommit(m, 10.25, -3.5);
462
+ const { newX, newY } = computeDraggedGsapPosition(element, offset, m.baseGsap);
463
+ expect({ x: newX, y: newY }).toEqual(draft);
464
+ });
465
+ });
466
+
452
467
  describe("a stylesheet translate after a committed GSAP drag", () => {
453
468
  function draggedTitle(css: string, oldOffsetChannel = false, withGsap = true) {
454
469
  const window = new Window();
@@ -552,3 +567,72 @@ describe("resumeGsapTimelines", () => {
552
567
  expect(() => resumeGsapTimelines(element)).not.toThrow();
553
568
  });
554
569
  });
570
+
571
+ describe("a move of an element GSAP does not position", () => {
572
+ function plainBox(options: { gsapLoaded?: boolean } = {}) {
573
+ const window = new Window();
574
+ const element = window.document.createElement("div");
575
+ window.document.body.append(element);
576
+ element.style.setProperty("translate", "40px 30px");
577
+ // A rect that never moves: the probe falls back to the 1:1 preview-scale mapping.
578
+ element.getBoundingClientRect = () => new window.DOMRect(10, 20, 100, 50);
579
+ const gsapCalls: string[] = [];
580
+ if (options.gsapLoaded) {
581
+ Object.assign(window, {
582
+ gsap: { set: () => gsapCalls.push("set"), getProperty: () => gsapCalls.push("read") },
583
+ });
584
+ }
585
+ const member = (gesture: "drag" | "nudge" = "drag") => {
586
+ const result = createManualOffsetDragMember({
587
+ key: "box",
588
+ selection: { element } as never,
589
+ element,
590
+ rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
591
+ gesture,
592
+ });
593
+ if (!result.ok) throw new Error(result.reason);
594
+ return result.member;
595
+ };
596
+ return { window, element, gsapCalls, member };
597
+ }
598
+
599
+ it("drafts and drops the element's own translate in plain px, never touching GSAP", () => {
600
+ const { element, gsapCalls, member } = plainBox({ gsapLoaded: true });
601
+ const m = member();
602
+ expect(m.plainTranslate).toBe(true);
603
+ applyManualOffsetDragDraft(m, 90.25, 60);
604
+ expect(element.style.getPropertyValue("translate")).toBe("130.25px 90px");
605
+ expect(applyManualOffsetDragCommit(m, 90.25, 60)).toEqual({ x: 130.25, y: 90 });
606
+ endManualOffsetDragMembers([m]);
607
+ expect(element.style.getPropertyValue("translate")).toBe("130.25px 90px");
608
+ expect(gsapCalls).toEqual([]);
609
+ expect(element.hasAttribute("data-hf-drag-gsap-base-x")).toBe(false);
610
+ });
611
+
612
+ it("starts a second move where the first dropped, while the first save is still in flight", () => {
613
+ const { member } = plainBox();
614
+ const first = member();
615
+ applyManualOffsetDragCommit(first, 90, 60);
616
+ const second = member();
617
+ expect(second.initialOffset).toEqual({ x: 130, y: 90 });
618
+ expect(applyManualOffsetDragCommit(second, 40, 0)).toEqual({ x: 170, y: 90 });
619
+ });
620
+
621
+ it("nudges from the element's translate", () => {
622
+ const { member } = plainBox();
623
+ expect(applyManualOffsetNudgeDraft(member("nudge"), { x: 5, y: -1 })).toEqual({ x: 45, y: 29 });
624
+ });
625
+
626
+ it("keeps the GSAP writer once GSAP renders the element's transform", () => {
627
+ const { element, member } = plainBox({ gsapLoaded: true });
628
+ Object.assign(element, { _gsap: { renderTransform: () => {} } });
629
+ expect(member().plainTranslate).toBe(false);
630
+ });
631
+
632
+ it("keeps the GSAP writer for an element a timeline hold positions", () => {
633
+ const { window, element, member } = plainBox();
634
+ const hold = { targets: () => [element], vars: { x: 40 }, duration: () => 0 };
635
+ Object.assign(window, { __timelines: { main: { getChildren: () => [hold] } } });
636
+ expect(member().plainTranslate).toBe(false);
637
+ });
638
+ });
@@ -11,6 +11,8 @@ import {
11
11
  type StudioPathOffsetSnapshot,
12
12
  } from "./manualEdits";
13
13
  import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
14
+ import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
15
+ import { readTranslatePx, writeTranslatePx } from "./plainTranslate";
14
16
 
15
17
  interface OffsetDragGsap {
16
18
  set: (el: Element, vars: Record<string, number | string>) => void;
@@ -97,6 +99,8 @@ export interface ManualOffsetDragMember {
97
99
  selection: DomEditSelection;
98
100
  element: HTMLElement;
99
101
  initialOffset: { x: number; y: number };
102
+ /** The move writes the element's own inline `translate`; offsets are then absolute translate px. */
103
+ plainTranslate: boolean;
100
104
  /**
101
105
  * The element's GSAP x/y at gesture start, captured in JS so a mid-drag
102
106
  * re-render (which reverts inline style + wipes the `data-hf-drag-gsap-base-*`
@@ -242,22 +246,23 @@ function readTransformWDivisor(element: HTMLElement): number {
242
246
  export function measureManualOffsetDragScreenToOffsetMatrix(
243
247
  element: HTMLElement,
244
248
  initialOffset: { x: number; y: number },
245
- options: { probeSize?: number; scaleX?: number; scaleY?: number } = {},
249
+ options: { probeSize?: number; scaleX?: number; scaleY?: number; plainTranslate?: boolean } = {},
246
250
  ): { ok: true; matrix: ManualOffsetDragMatrix } | { ok: false; reason: string } {
247
251
  const probeSize = options.probeSize ?? DEFAULT_OFFSET_PROBE_PX;
248
252
  if (!Number.isFinite(probeSize) || probeSize <= 0) {
249
253
  return { ok: false, reason: "Invalid movement probe size." };
250
254
  }
251
255
 
256
+ const draw = options.plainTranslate ? writeTranslatePx : applyStudioPathOffsetDraft;
252
257
  const snapshot = captureStudioPathOffset(element);
253
258
  try {
254
- applyStudioPathOffsetDraft(element, initialOffset);
259
+ draw(element, initialOffset);
255
260
  const origin = getRectCenter(element);
256
261
  if (!origin) {
257
262
  return { ok: false, reason: "Element has no measurable box." };
258
263
  }
259
264
 
260
- applyStudioPathOffsetDraft(element, {
265
+ draw(element, {
261
266
  x: initialOffset.x + probeSize,
262
267
  y: initialOffset.y,
263
268
  });
@@ -266,7 +271,7 @@ export function measureManualOffsetDragScreenToOffsetMatrix(
266
271
  return { ok: false, reason: "Element X movement could not be measured." };
267
272
  }
268
273
 
269
- applyStudioPathOffsetDraft(element, {
274
+ draw(element, {
270
275
  x: initialOffset.x,
271
276
  y: initialOffset.y + probeSize,
272
277
  });
@@ -332,24 +337,25 @@ export function createManualOffsetDragMember(input: {
332
337
  selection: DomEditSelection;
333
338
  element: HTMLElement;
334
339
  rect: ManualOffsetDragRect;
340
+ gesture?: "drag" | "nudge"; // a resize's anchor member keeps the legacy offset channel
335
341
  }): ManualOffsetDragMemberResult {
336
- // Base the drag on the offset ACTUALLY applied, never the raw (possibly dormant)
337
- // var — see readAppliedStudioPathOffset. This keeps the commit purely relative
338
- // (applied + delta) so a stale offset can't fling the element off-screen.
339
- const initialOffset = readAppliedStudioPathOffset(input.element);
340
- input.element.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
341
- input.element.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y));
342
-
342
+ const plainTranslate = !!input.gesture && !gsapWritesPosition(input.element);
343
+ // Base the drag on the offset ACTUALLY applied, never a dormant var (readAppliedStudioPathOffset),
344
+ // so a stale offset can't fling the element off-screen.
345
+ const initialOffset = plainTranslate
346
+ ? readTranslatePx(input.element)
347
+ : readAppliedStudioPathOffset(input.element);
343
348
  const win = input.element.ownerDocument.defaultView as
344
349
  | (Window & {
345
350
  gsap?: { getProperty?: (el: Element, prop: string) => number };
346
351
  __timelines?: Record<string, { pause?: () => void; paused?: () => boolean }>;
347
352
  })
348
353
  | null;
349
- const gsapX = win?.gsap?.getProperty?.(input.element, "x") || 0;
350
- const gsapY = win?.gsap?.getProperty?.(input.element, "y") || 0;
351
- input.element.setAttribute("data-hf-drag-gsap-base-x", String(gsapX));
352
- input.element.setAttribute("data-hf-drag-gsap-base-y", String(gsapY));
354
+ // getProperty makes GSAP bake the CSS translate into its transform, so a plain move never reads it.
355
+ const gsapX = (!plainTranslate && win?.gsap?.getProperty?.(input.element, "x")) || 0;
356
+ const gsapY = (!plainTranslate && win?.gsap?.getProperty?.(input.element, "y")) || 0;
357
+ const baseGsap = { x: gsapX, y: gsapY };
358
+ if (!plainTranslate) stampGestureBase(input.element, initialOffset, baseGsap);
353
359
 
354
360
  if (win?.__timelines) {
355
361
  const paused: string[] = [];
@@ -373,8 +379,8 @@ export function createManualOffsetDragMember(input: {
373
379
  const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset, {
374
380
  scaleX: input.rect.editScaleX,
375
381
  scaleY: input.rect.editScaleY,
382
+ plainTranslate,
376
383
  });
377
- const baseGsap = { x: gsapX, y: gsapY };
378
384
  if (!measured.ok) {
379
385
  // Fallback: when GSAP transforms interfere with probe measurement, use
380
386
  // the preview scale as an approximation. The commit path reads the actual
@@ -390,6 +396,7 @@ export function createManualOffsetDragMember(input: {
390
396
  selection: input.selection,
391
397
  element: input.element,
392
398
  initialOffset,
399
+ plainTranslate,
393
400
  baseGsap,
394
401
  initialPathOffset,
395
402
  gestureToken,
@@ -406,6 +413,7 @@ export function createManualOffsetDragMember(input: {
406
413
  selection: input.selection,
407
414
  element: input.element,
408
415
  initialOffset,
416
+ plainTranslate,
409
417
  baseGsap,
410
418
  initialPathOffset,
411
419
  gestureToken,
@@ -428,18 +436,25 @@ function resolveManualOffsetDragMemberOffset(
428
436
  });
429
437
  }
430
438
 
439
+ /** Draws the offset where the commit writes it: the inline translate, else GSAP, else the legacy var. */
440
+ function drawMemberOffset(
441
+ member: ManualOffsetDragMember,
442
+ offset: { x: number; y: number },
443
+ commit: boolean,
444
+ ): void {
445
+ if (member.plainTranslate) writeTranslatePx(member.element, offset);
446
+ else if (applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) return;
447
+ else if (commit) applyStudioPathOffset(member.element, offset);
448
+ else applyStudioPathOffsetDraft(member.element, offset);
449
+ }
450
+
431
451
  export function applyManualOffsetDragDraft(
432
452
  member: ManualOffsetDragMember,
433
453
  dx: number,
434
454
  dy: number,
435
455
  ): { x: number; y: number } {
436
456
  const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
437
- // Position is single-sourced on the GSAP timeline; preview through gsap.set so
438
- // the live draft matches the committed `tl.set`/keyframe. CSS draft only when
439
- // gsap is unavailable (no preview iframe runtime).
440
- if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
441
- applyStudioPathOffsetDraft(member.element, offset);
442
- }
457
+ drawMemberOffset(member, offset, false);
443
458
  return offset;
444
459
  }
445
460
 
@@ -451,25 +466,20 @@ export function applyManualOffsetDragDraft(
451
466
  * the element flies off-screen the instant you drop it. The member holds the
452
467
  * true gesture-start values in JS, immune to the re-render.
453
468
  */
454
- function restampManualOffsetDragGestureBase(member: ManualOffsetDragMember): void {
455
- member.element.setAttribute("data-hf-drag-gsap-base-x", String(member.baseGsap.x));
456
- member.element.setAttribute("data-hf-drag-gsap-base-y", String(member.baseGsap.y));
457
- member.element.setAttribute("data-hf-drag-initial-offset-x", String(member.initialOffset.x));
458
- member.element.setAttribute("data-hf-drag-initial-offset-y", String(member.initialOffset.y));
469
+ function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
470
+ el.setAttribute("data-hf-drag-gsap-base-x", String(baseGsap.x));
471
+ el.setAttribute("data-hf-drag-gsap-base-y", String(baseGsap.y));
472
+ el.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
473
+ el.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y));
459
474
  }
460
475
 
461
476
  function applyManualOffsetCommitValue(
462
477
  member: ManualOffsetDragMember,
463
478
  offset: { x: number; y: number },
464
479
  ): { x: number; y: number } {
465
- restampManualOffsetDragGestureBase(member);
466
- // Optimistic visual through the GSAP channel (same as the live draft and the
467
- // committed `tl.set`), so the element holds its dropped position until the
468
- // source mutation soft-reloads — no transient CSS `--hf-studio-offset` write.
469
- // CSS apply only when gsap is unavailable.
470
- if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
471
- applyStudioPathOffset(member.element, offset);
472
- }
480
+ if (!member.plainTranslate)
481
+ stampGestureBase(member.element, member.initialOffset, member.baseGsap);
482
+ drawMemberOffset(member, offset, true);
473
483
  return offset;
474
484
  }
475
485
 
@@ -496,9 +506,7 @@ export function applyManualOffsetNudgeDraft(
496
506
  x: member.initialOffset.x + delta.x,
497
507
  y: member.initialOffset.y + delta.y,
498
508
  };
499
- if (!applyOffsetDragDraftViaGsap(member.element, offset, member.baseGsap)) {
500
- applyStudioPathOffsetDraft(member.element, offset);
501
- }
509
+ drawMemberOffset(member, offset, false);
502
510
  return offset;
503
511
  }
504
512
 
@@ -514,7 +522,9 @@ export function applyManualOffsetNudgeCommit(
514
522
 
515
523
  function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
516
524
  restoreStudioPathOffset(member.element, member.initialPathOffset);
517
- getOffsetDragGsap(member.element)?.set(member.element, { ...member.baseGsap });
525
+ if (!member.plainTranslate) {
526
+ getOffsetDragGsap(member.element)?.set(member.element, { ...member.baseGsap });
527
+ }
518
528
  endStudioManualEditGesture(member.element, member.gestureToken);
519
529
  }
520
530
 
@@ -548,7 +558,7 @@ export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): v
548
558
  member.element.removeAttribute("data-hf-drag-gsap-base-y");
549
559
  // Do NOT clearProps:"transform" — that nukes the committed GSAP position
550
560
  // and causes a visual snap-back before the soft reload re-applies it.
551
- const gsapOwnsPosition = Boolean(getOffsetDragGsap(member.element));
561
+ const gsapOwnsPosition = !member.plainTranslate && Boolean(getOffsetDragGsap(member.element));
552
562
  // Migration: when GSAP owns the position (the committed value lives in the
553
563
  // GSAP transform), the legacy `--hf-studio-offset` CSS channel is obsolete.
554
564
  // Clear it on the LIVE element — otherwise the leftover `translate:
@@ -0,0 +1,62 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from "vitest";
3
+ import { readTranslatePx } from "./plainTranslate";
4
+
5
+ function box(style: string): HTMLElement {
6
+ const element = document.createElement("div");
7
+ element.style.cssText = `position: absolute; width: 240px; height: 160px; ${style}`;
8
+ document.body.append(element);
9
+ return element;
10
+ }
11
+
12
+ describe("readTranslatePx", () => {
13
+ it.each([
14
+ ["40px 30px", { x: 40, y: 30 }],
15
+ ["-50% -50%", { x: -120, y: -80 }],
16
+ ["25% 25%", { x: 60, y: 40 }],
17
+ ["calc(-50% + 12px) calc(10% - 6px)", { x: -108, y: 10 }],
18
+ ["30px", { x: 30, y: 0 }],
19
+ ])("reads %s against the border box, in px", (translate, want) => {
20
+ expect(readTranslatePx(box(`translate: ${translate}`))).toEqual(want);
21
+ });
22
+
23
+ it.each([
24
+ ["calc(50% - 20px) 0px", { x: 100, y: 0 }],
25
+ ["calc(50% + 20px) calc(50% - 20px)", { x: 140, y: 60 }],
26
+ ])("adds and subtracts the terms of %s", (translate, want) => {
27
+ expect(readTranslatePx(box(`translate: ${translate}`))).toEqual(want);
28
+ });
29
+
30
+ it.each([
31
+ "min(10px, 5%) 0px",
32
+ "calc(2 * 10px) 0px",
33
+ "calc(10px +) 0px",
34
+ "clamp(0px, 10%, 30px) 5px",
35
+ ])("hands %s to Chrome's own resolution and puts the element's style back", (translate) => {
36
+ const element = box(`translate: ${translate}; transform: rotate(5deg)`);
37
+ const before = element.getAttribute("style");
38
+ const read = readTranslatePx(element);
39
+ expect(Number.isFinite(read.x) && Number.isFinite(read.y)).toBe(true);
40
+ expect(element.getAttribute("style")).toBe(before);
41
+ });
42
+
43
+ it("counts padding and border in the box a percent resolves against", () => {
44
+ const element = box(
45
+ "translate: 50% 50%; padding: 10px; border: 5px solid; box-sizing: content-box",
46
+ );
47
+ expect(readTranslatePx(element)).toEqual({ x: 135, y: 95 });
48
+ });
49
+
50
+ it("writes nothing to the element it reads", () => {
51
+ const element = box("translate: -50% -50%");
52
+ const before = element.getAttribute("style");
53
+ const writes: MutationRecord[] = [];
54
+ const observer = new MutationObserver((records) => writes.push(...records));
55
+ observer.observe(element, { attributes: true });
56
+ readTranslatePx(element);
57
+ writes.push(...observer.takeRecords());
58
+ observer.disconnect();
59
+ expect(writes).toEqual([]);
60
+ expect(element.getAttribute("style")).toBe(before);
61
+ });
62
+ });
@@ -0,0 +1,85 @@
1
+ import { roundTo3 } from "../../utils/rounding";
2
+ import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
3
+ import { readStudioPathOffset } from "./manualEditsDom";
4
+ import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
5
+ import type { PatchOperation } from "../../utils/sourcePatcher";
6
+
7
+ type Point = { x: number; y: number };
8
+
9
+ const TERM = /^(-?\d*\.?\d+(?:e[-+]?\d+)?)(px|%)$/i;
10
+
11
+ const SIGN: Record<string, number> = { "+": 1, "-": -1 };
12
+
13
+ function termPx(term: string | undefined, side: number): number | null {
14
+ const match = TERM.exec(term ?? "");
15
+ return match ? Number(match[1]) * (match[2] === "%" ? side / 100 : 1) : null;
16
+ }
17
+
18
+ /** px, %, or Chrome's computed `calc(P% + Lpx)`, against the border-box side; null for min()/max()/clamp(). */
19
+ function resolveLength(value: string, side: number): number | null {
20
+ const parts = (/^calc\((.*)\)$/.exec(value)?.[1] ?? value).split(" ");
21
+ if (parts.length % 2 === 0) return null;
22
+ let total = termPx(parts[0], side);
23
+ for (let i = 1; i < parts.length && total !== null; i += 2) {
24
+ const term = termPx(parts[i + 1], side);
25
+ const sign = SIGN[parts[i] ?? ""];
26
+ total = term === null || !sign ? null : total + sign * term;
27
+ }
28
+ return total;
29
+ }
30
+
31
+ function borderBox(
32
+ cs: CSSStyleDeclaration,
33
+ size: "width" | "height",
34
+ a: string,
35
+ b: string,
36
+ ): number {
37
+ const n = (prop: string) => Number.parseFloat(cs.getPropertyValue(prop)) || 0;
38
+ const pad =
39
+ n(`padding-${a}`) + n(`padding-${b}`) + n(`border-${a}-width`) + n(`border-${b}-width`);
40
+ return n(size) + (cs.boxSizing === "border-box" ? 0 : pad);
41
+ }
42
+
43
+ // Chrome resolves min()/max()/clamp() inside a transform function to a matrix; an unrendered box has none.
44
+ function resolveInTransform(el: HTMLElement, x: string, y: string): Point {
45
+ const style = el.style;
46
+ const saved = [style.getPropertyValue("transform"), style.getPropertyPriority("transform")];
47
+ style.setProperty("transform", `translate(${x}, ${y})`, "important");
48
+ try {
49
+ const resolved = el.ownerDocument.defaultView?.getComputedStyle(el).transform ?? "";
50
+ const matrix = new DOMMatrixReadOnly(resolved.startsWith("matrix") ? resolved : undefined);
51
+ return { x: matrix.m41, y: matrix.m42 };
52
+ } finally {
53
+ style.setProperty("transform", saved[0] ?? "", saved[1] ?? "");
54
+ }
55
+ }
56
+
57
+ /** The element's CSS `translate` in px, as it renders now. */
58
+ // ponytail: a 3-value translate loses its z on the next move; keep z when a fixture needs it.
59
+ export function readTranslatePx(el: HTMLElement): Point {
60
+ const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
61
+ const value = el.style.getPropertyValue("translate") || cs?.translate || "none";
62
+ if (value === "none") return { x: 0, y: 0 };
63
+ const [x = "0px", y = "0px"] = splitTopLevelWhitespace(value);
64
+ const px = cs && resolveLength(x, borderBox(cs, "width", "left", "right"));
65
+ const py = cs && resolveLength(y, borderBox(cs, "height", "top", "bottom"));
66
+ return px != null && py != null ? { x: px, y: py } : resolveInTransform(el, x, y);
67
+ }
68
+
69
+ /** Plain px only: GSAP's CSSPlugin splits `translate` on spaces and drops a calc(). */
70
+ function formatTranslatePx(p: Point): string {
71
+ return `${roundTo3(p.x)}px ${roundTo3(p.y)}px`;
72
+ }
73
+
74
+ export function translatePatch(p: Point): PatchOperation & { value: string } {
75
+ return { type: "inline-style", property: "translate", value: formatTranslatePx(p) };
76
+ }
77
+
78
+ export function writeTranslatePx(el: HTMLElement, p: Point): void {
79
+ el.style.setProperty("translate", formatTranslatePx(p));
80
+ }
81
+
82
+ /** The position the panel shows and edits: the translate a move writes, unless GSAP positions it. */
83
+ export function readMoveOffset(el: HTMLElement): Point {
84
+ return gsapWritesPosition(el) ? readStudioPathOffset(el) : readTranslatePx(el);
85
+ }
@@ -1,5 +1,6 @@
1
1
  import type { DomEditSelection } from "./domEditingTypes";
2
- import { readStudioBoxSize, readStudioPathOffset } from "./manualEdits";
2
+ import { readStudioBoxSize } from "./manualEdits";
3
+ import { readMoveOffset } from "./plainTranslate";
3
4
  import { parsePxMetricValue, type PropertyPanelProps } from "./propertyPanelHelpers";
4
5
 
5
6
  interface TransformCommitDeps {
@@ -73,7 +74,7 @@ export function createTransformCommitHandlers({
73
74
  )
74
75
  )
75
76
  return;
76
- const current = readStudioPathOffset(element.element);
77
+ const current = readMoveOffset(element.element);
77
78
  await Promise.resolve(
78
79
  onSetManualOffset(element, {
79
80
  x: axis === "x" ? parsed : current.x,
@@ -189,7 +189,7 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
189
189
  if (!targets) return null;
190
190
  const members: ManualOffsetDragMember[] = [];
191
191
  for (const target of targets) {
192
- const result = createManualOffsetDragMember(target);
192
+ const result = createManualOffsetDragMember({ ...target, gesture: "nudge" });
193
193
  if (!result.ok) {
194
194
  restoreManualOffsetDragMembers(members);
195
195
  return null;
@@ -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
  };
@@ -5,6 +5,8 @@ import {
5
5
  } from "../components/editor/elementPositionOffset";
6
6
  import { LAYER_REVEAL_PRIOR_POSITION_ATTR } from "../player/lib/timelineElementHelpers";
7
7
  import type { PatchOperation } from "../utils/sourcePatcher";
8
+ import { translatePatch } from "../components/editor/plainTranslate";
9
+ import { gsapWritesPosition } from "./gsapRuntimeKeyframes";
8
10
 
9
11
  const ELEMENT_OFFSET_REFUSED: Record<ElementOffsetRefusal, string> = {
10
12
  anchored: "This layer is anchored from its right or bottom edge. Move it in the Code tab.",
@@ -30,8 +32,34 @@ function settleGsapDraftAtGestureStart(el: HTMLElement): void {
30
32
  if (gsap && Number.isFinite(x) && Number.isFinite(y)) gsap.set(el, { x, y });
31
33
  }
32
34
 
33
- /** Applies a shared-tween element's move live now; `save` persists it as left/top on that
34
- * element, `rollback` takes the live move back. Throws, after a toast, when it cannot. */
35
+ let plainMoveCounter = 0;
36
+
37
+ /** GSAP does not position the element: `next` is its whole translate, live now and saved as drawn. */
38
+ function stagePlainTranslate(
39
+ commitPositionPatchToHtml: ElementOffsetStagerDeps["commitPositionPatchToHtml"],
40
+ selection: DomEditSelection,
41
+ next: { x: number; y: number },
42
+ coalesceKey?: string,
43
+ ): { save: () => Promise<void>; rollback: () => void } {
44
+ const style = selection.element.style;
45
+ const before = style.getPropertyValue("translate");
46
+ const patch = translatePatch(next);
47
+ style.setProperty("translate", patch.value);
48
+ const rollback = () => {
49
+ if (style.getPropertyValue("translate") === patch.value) style.setProperty("translate", before);
50
+ };
51
+ const key = coalesceKey ?? `move:${++plainMoveCounter}`;
52
+ const options = { label: "Move layer", coalesceKey: key, coalesceMs: Number.POSITIVE_INFINITY };
53
+ const save = () =>
54
+ commitPositionPatchToHtml(selection, [patch], options).catch((error) => {
55
+ rollback();
56
+ throw error;
57
+ });
58
+ return { save, rollback };
59
+ }
60
+
61
+ /** Applies a move on the element itself live now: its translate when GSAP does not position it,
62
+ * else left/top for a shared-tween element. Throws, after a toast, when it cannot. */
35
63
  export function stageElementOffset(
36
64
  { commitPositionPatchToHtml, showToast, readOnlyPreview }: ElementOffsetStagerDeps,
37
65
  selection: DomEditSelection,
@@ -40,6 +68,9 @@ export function stageElementOffset(
40
68
  ): { save: () => Promise<void>; rollback: () => void } {
41
69
  const el = selection.element;
42
70
  if (readOnlyPreview) return { save: () => Promise.resolve(), rollback: () => undefined };
71
+ if (!gsapWritesPosition(el)) {
72
+ return stagePlainTranslate(commitPositionPatchToHtml, selection, next, coalesceKey);
73
+ }
43
74
  const previous = { position: el.style.position, left: el.style.left, top: el.style.top };
44
75
  const liftMarker = el.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR);
45
76
  const result = applyElementPositionOffset(el, next);
@@ -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");