@hyperframes/studio 0.8.99 → 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 (28) hide show
  1. package/dist/assets/{hyperframes-player-Rml34FRW.js → hyperframes-player-fWt87gyh.js} +1 -1
  2. package/dist/assets/{index-C_fi-jTe.js → index-D34Oy5DN.js} +1 -1
  3. package/dist/assets/{index-CVDArg3m.js → index-D6TjRAvz.js} +176 -176
  4. package/dist/assets/{index-D9YzDuBx.js → index-DofdzdxJ.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +423 -312
  7. package/dist/index.js.map +1 -1
  8. package/package.json +8 -8
  9. package/src/components/editor/PropertyPanel.tsx +3 -3
  10. package/src/components/editor/domEditOverlayStartGesture.test.ts +83 -0
  11. package/src/components/editor/domEditOverlayStartGesture.ts +6 -4
  12. package/src/components/editor/manualOffsetDrag.test.ts +70 -0
  13. package/src/components/editor/manualOffsetDrag.ts +50 -40
  14. package/src/components/editor/plainTranslate.test.ts +62 -0
  15. package/src/components/editor/plainTranslate.ts +85 -0
  16. package/src/components/editor/propertyPanelTransformCommit.ts +3 -2
  17. package/src/components/editor/useDomEditNudge.ts +1 -1
  18. package/src/hooks/elementOffsetStager.ts +33 -2
  19. package/src/hooks/gsapRuntimeBridge.ts +1 -17
  20. package/src/hooks/gsapRuntimeKeyframes.ts +35 -0
  21. package/src/hooks/useDomEditCommits.test.tsx +12 -3
  22. package/src/hooks/useDomEditCommits.ts +2 -1
  23. package/src/hooks/useDomEditTextCommits.test.tsx +62 -5
  24. package/src/hooks/useDomEditTextCommits.ts +35 -35
  25. package/src/hooks/useDomGeometryCommit.test.tsx +62 -4
  26. package/src/hooks/useDomGeometryCommits.test.tsx +4 -2
  27. package/src/hooks/useGsapAwareEditing.test.tsx +46 -18
  28. package/src/hooks/useGsapAwareEditing.ts +11 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.99",
3
+ "version": "0.8.100",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/core": "0.8.99",
64
- "@hyperframes/parsers": "0.8.99",
65
- "@hyperframes/player": "0.8.99",
66
- "@hyperframes/sdk": "0.8.99",
67
- "@hyperframes/studio-server": "0.8.99",
68
- "@hyperframes/shader-transitions": "0.8.99"
63
+ "@hyperframes/core": "0.8.100",
64
+ "@hyperframes/sdk": "0.8.100",
65
+ "@hyperframes/parsers": "0.8.100",
66
+ "@hyperframes/player": "0.8.100",
67
+ "@hyperframes/shader-transitions": "0.8.100",
68
+ "@hyperframes/studio-server": "0.8.100"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.99"
84
+ "@hyperframes/producer": "0.8.100"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -3,7 +3,8 @@ import { memo, useMemo, useRef, useState } from "react";
3
3
  import { Move } from "../../icons/SystemIcons";
4
4
  import { InspectorHeaderActions } from "./InspectorHeaderActions";
5
5
  import { useStudioShellContext } from "../../contexts/StudioContext";
6
- import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
6
+ import { readStudioBoxSize, readStudioRotation } from "./manualEdits";
7
+ import { readMoveOffset } from "./plainTranslate";
7
8
  import {
8
9
  buildElementInfoText,
9
10
  EMPTY_STYLES,
@@ -199,7 +200,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
199
200
  // selection) so the Timing section shows for pure-GSAP elements with no data-start.
200
201
  const sections = resolveEditingSections(domEditSelectionToFacts(element, gsapAnimations.length));
201
202
  const showEditableSections = element.capabilities.canEditStyles && sections.style;
202
- const manualOffset = readStudioPathOffset(element.element);
203
+ const manualOffset = readMoveOffset(element.element);
203
204
  const manualSize = readStudioBoxSize(element.element);
204
205
  const resolvedWidth =
205
206
  manualSize.width > 0
@@ -209,7 +210,6 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
209
210
  manualSize.height > 0
210
211
  ? manualSize.height
211
212
  : (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
212
-
213
213
  const manualRotation = readStudioRotation(element.element);
214
214
 
215
215
  const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
@@ -0,0 +1,83 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import type { DomEditSelection } from "./domEditing";
4
+ import type { GestureState } from "./domEditOverlayGestures";
5
+ import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
6
+
7
+ afterEach(() => {
8
+ delete (window as { gsap?: unknown }).gsap;
9
+ delete (window as { __timelines?: unknown }).__timelines;
10
+ document.body.innerHTML = "";
11
+ });
12
+
13
+ const PRESS = {
14
+ clientX: 10,
15
+ clientY: 10,
16
+ pointerId: 1,
17
+ button: 0,
18
+ preventDefault() {},
19
+ stopPropagation() {},
20
+ currentTarget: { setPointerCapture() {} },
21
+ };
22
+
23
+ /** Presses a drag on the element and returns the gesture it started. */
24
+ function pressDrag(element: HTMLElement): GestureState | null {
25
+ const opts = pressOptions(element);
26
+ expect(
27
+ createDomEditOverlayGestureHandlers(opts as never).startGesture("drag", PRESS as never),
28
+ ).toBe(true);
29
+ return opts.gestureRef.current;
30
+ }
31
+
32
+ function pressOptions(element: HTMLElement) {
33
+ const ref = <T>(current: T) => ({ current });
34
+ const selection = { element, capabilities: { canApplyManualOffset: true } };
35
+ return {
36
+ selectionRef: ref(selection as unknown as DomEditSelection),
37
+ overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
38
+ boxRef: ref(null),
39
+ overlayRef: ref(null),
40
+ iframeRef: ref(null),
41
+ gestureRef: ref<GestureState | null>(null),
42
+ rafPausedRef: ref(false),
43
+ onManualDragStartRef: ref(vi.fn()),
44
+ onBlockedMoveRef: ref(vi.fn()),
45
+ };
46
+ }
47
+
48
+ describe("a drag press on a page that loads GSAP", () => {
49
+ it.each([
50
+ ["GSAP animates nothing", false],
51
+ ["GSAP animates only its parent", true],
52
+ ])(
53
+ "%s: the press never asks GSAP about the element, so its translate stays CSS",
54
+ (_, parentTween) => {
55
+ const getProperty = vi.fn(() => 0);
56
+ const set = vi.fn();
57
+ const parent = document.createElement("div");
58
+ const element = document.createElement("div");
59
+ element.style.setProperty("translate", "40px 30px");
60
+ parent.append(element);
61
+ document.body.append(parent);
62
+ const tween = { targets: () => [parent], vars: { x: 100 }, duration: () => 2 };
63
+ const timelines = { main: { getChildren: () => (parentTween ? [tween] : []) } };
64
+ Object.assign(window, { gsap: { getProperty, set }, __timelines: timelines });
65
+ expect(pressDrag(element)?.pathOffsetMember?.plainTranslate).toBe(true);
66
+ expect(getProperty).not.toHaveBeenCalled();
67
+ expect(set).not.toHaveBeenCalled();
68
+ expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
69
+ },
70
+ );
71
+ });
72
+
73
+ describe("a drag press on a centred element without GSAP", () => {
74
+ it("starts from its -50% translate in px and leaves its style as it was", () => {
75
+ const element = document.createElement("div");
76
+ element.style.cssText =
77
+ "position: absolute; left: 50%; top: 50%; width: 240px; height: 160px; translate: -50% -50%";
78
+ document.body.append(element);
79
+ const style = element.getAttribute("style");
80
+ expect(pressDrag(element)?.pathOffsetMember?.initialOffset).toEqual({ x: -120, y: -80 });
81
+ expect(element.getAttribute("style")).toBe(style);
82
+ });
83
+ });
@@ -70,6 +70,7 @@ export function startGroupDrag(
70
70
  selection: item.selection,
71
71
  element: item.element,
72
72
  rect: item.rect,
73
+ gesture: "drag",
73
74
  });
74
75
  if (!result.ok) {
75
76
  restoreManualOffsetDragMembers(members);
@@ -151,10 +152,6 @@ export function startGesture(
151
152
  return false;
152
153
 
153
154
  const size = readStudioBoxSize(sel.element);
154
- // Single-source rotation base = the live GSAP transform rotation plus any legacy
155
- // `--hf-studio-rotation` CSS var (old projects), so a rotate gesture starts from the
156
- // element's actual visual angle and commits an absolute angle to the timeline.
157
- const rotation = { angle: readGsapRotation(sel.element) + readStudioRotation(sel.element).angle };
158
155
  // The draft writes CSS width/height, so the resize base must be the CSS
159
156
  // layout size. offsetWidth/Height are transform-free; the overlay-rect
160
157
  // fallback (rect / editScale) includes the element's own GSAP scale and
@@ -198,6 +195,7 @@ export function startGesture(
198
195
  selection: sel,
199
196
  element: sel.element,
200
197
  rect,
198
+ gesture: "drag",
201
199
  });
202
200
  if (!result.ok) {
203
201
  opts.onBlockedMoveRef.current(result.selection);
@@ -232,6 +230,10 @@ export function startGesture(
232
230
  }
233
231
  }
234
232
 
233
+ // Rotation base = GSAP's rotation plus the legacy `--hf-studio-rotation` var. A plain-translate
234
+ // move never asks GSAP: reading a property makes it bake the CSS translate into its transform.
235
+ const gsapRotation = pathOffsetMember?.plainTranslate ? 0 : readGsapRotation(sel.element);
236
+ const rotation = { angle: gsapRotation + readStudioRotation(sel.element).angle };
235
237
  const overlayBounds = overlayEl?.getBoundingClientRect();
236
238
  const centerX = (overlayBounds?.left ?? 0) + rect.left + rect.width / 2;
237
239
  const centerY = (overlayBounds?.top ?? 0) + rect.top + rect.height / 2;
@@ -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,
@@ -566,3 +567,72 @@ describe("resumeGsapTimelines", () => {
566
567
  expect(() => resumeGsapTimelines(element)).not.toThrow();
567
568
  });
568
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;