@hyperframes/studio 0.8.101 → 0.8.103

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 (57) hide show
  1. package/dist/assets/{hyperframes-player-CFJLrHRF.js → hyperframes-player-DG795Hwu.js} +1 -1
  2. package/dist/assets/{index-BBNVnnyH.js → index-BPJRf2X9.js} +119 -119
  3. package/dist/assets/{index-CJOzbslX.js → index-BiEtDkae.js} +1 -1
  4. package/dist/assets/{index-xB5O0hPw.css → index-CQN3M56N.css} +1 -1
  5. package/dist/assets/{index-C33EDBL2.js → index-DuCZ3pQw.js} +1 -1
  6. package/dist/{chunk-MTAVNAGW.js → chunk-46XP7J3B.js} +2 -2
  7. package/dist/{chunk-S65AQH6M.js → chunk-DI4LCJHL.js} +3 -2
  8. package/dist/chunk-DI4LCJHL.js.map +1 -0
  9. package/dist/{chunk-7X4XRP3L.js → chunk-HIGHVWYD.js} +2 -2
  10. package/dist/{domEditingLayers-NU6Z5QWA.js → domEditingLayers-AQBDFNUC.js} +3 -3
  11. package/dist/index.d.ts +13 -3
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +932 -850
  14. package/dist/index.js.map +1 -1
  15. package/dist/player.js +2 -2
  16. package/dist/styles.css +1 -1
  17. package/package.json +8 -8
  18. package/src/components/editor/CanvasContextMenu.test.tsx +23 -2
  19. package/src/components/editor/CanvasContextMenu.tsx +7 -10
  20. package/src/components/editor/ConnectedDomEditOverlay.tsx +3 -128
  21. package/src/components/editor/DomEditOverlay.tsx +2 -1
  22. package/src/components/editor/PropertyPanel.tsx +2 -2
  23. package/src/components/editor/canvasContextMenuZOrder.ts +13 -0
  24. package/src/components/editor/domEditOverlayGestures.ts +1 -1
  25. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  26. package/src/components/editor/gsapAnimatesProperty.ts +0 -29
  27. package/src/components/editor/gsapLivePreview.test.ts +46 -1
  28. package/src/components/editor/manualOffsetDrag.test.ts +16 -0
  29. package/src/components/editor/manualOffsetDrag.ts +5 -5
  30. package/src/components/editor/plainTranslate.test.ts +38 -22
  31. package/src/components/editor/plainTranslate.ts +66 -36
  32. package/src/components/editor/previewReadOnly.overlay.test.tsx +30 -0
  33. package/src/components/editor/previewReadOnlyContext.tsx +3 -1
  34. package/src/components/editor/propertyPanelHelpers.ts +3 -0
  35. package/src/components/editor/propertyPanelTransformCommit.test.ts +51 -0
  36. package/src/components/editor/propertyPanelTransformCommit.ts +3 -1
  37. package/src/components/editor/useColorGradingController.test.ts +36 -1
  38. package/src/components/editor/useColorGradingController.ts +2 -2
  39. package/src/components/editor/useDomEditNudge.test.tsx +1 -0
  40. package/src/components/editor/useDomEditNudge.ts +6 -3
  41. package/src/components/editor/useDomEditZOrder.ts +185 -0
  42. package/src/components/nle/PreviewOverlaysTestMocks.ts +4 -0
  43. package/src/domEditExports.test.tsx +92 -0
  44. package/src/hooks/gsapRuntimeKeyframes.ts +6 -0
  45. package/src/hooks/useDomEditCommits.test.tsx +94 -90
  46. package/src/hooks/usePreviewInteraction.test.ts +32 -0
  47. package/src/hooks/usePreviewInteraction.ts +3 -2
  48. package/src/index.ts +3 -0
  49. package/src/player/components/TimelineClipFades.test.tsx +103 -3
  50. package/src/player/components/TimelineClipFades.tsx +53 -50
  51. package/src/styles/theme.css +1 -1
  52. package/src/utils/projectRouting.test.ts +9 -0
  53. package/src/utils/projectRouting.ts +3 -1
  54. package/dist/chunk-S65AQH6M.js.map +0 -1
  55. /package/dist/{chunk-MTAVNAGW.js.map → chunk-46XP7J3B.js.map} +0 -0
  56. /package/dist/{chunk-7X4XRP3L.js.map → chunk-HIGHVWYD.js.map} +0 -0
  57. /package/dist/{domEditingLayers-NU6Z5QWA.js.map → domEditingLayers-AQBDFNUC.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.101",
3
+ "version": "0.8.103",
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/parsers": "0.8.101",
64
- "@hyperframes/sdk": "0.8.101",
65
- "@hyperframes/shader-transitions": "0.8.101",
66
- "@hyperframes/studio-server": "0.8.101",
67
- "@hyperframes/player": "0.8.101",
68
- "@hyperframes/core": "0.8.101"
63
+ "@hyperframes/player": "0.8.103",
64
+ "@hyperframes/shader-transitions": "0.8.103",
65
+ "@hyperframes/core": "0.8.103",
66
+ "@hyperframes/studio-server": "0.8.103",
67
+ "@hyperframes/parsers": "0.8.103",
68
+ "@hyperframes/sdk": "0.8.103"
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.101"
84
+ "@hyperframes/producer": "0.8.103"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -3,7 +3,7 @@ import React, { act } from "react";
3
3
  import { createRoot, type Root } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
6
- import { resolveZIndexEntries } from "./ConnectedDomEditOverlay";
6
+ import { resolveZIndexEntries } from "./useDomEditZOrder";
7
7
  import { useElementLifecycleOps } from "../../hooks/useElementLifecycleOps";
8
8
  import { makeLifecycleOpsParams } from "../../hooks/elementLifecycleOpsTestUtils";
9
9
  import type { DomEditPatchBatch } from "../../hooks/domEditCommitTypes";
@@ -193,6 +193,27 @@ function renderCommitHook(captured: CapturedBatchCall[]) {
193
193
  return { commit: commit!, cleanup: () => act(() => hookRoot.unmount()) };
194
194
  }
195
195
 
196
+ describe("resolveZIndexEntries", () => {
197
+ it("keys a same-class sibling by its index among them, not as the first of its class", () => {
198
+ const parent = document.body.appendChild(document.createElement("div"));
199
+ const [first, second, selected] = ["card", "card", "picked"].map((name) => {
200
+ const child = parent.appendChild(document.createElement("div"));
201
+ child.className = name;
202
+ return child;
203
+ });
204
+ const { entries } = resolveZIndexEntries(
205
+ makeSelection("Picked", selected!),
206
+ [first!, second!].map((element, zIndex) => ({ element, zIndex })),
207
+ null,
208
+ );
209
+ expect(entries.map((entry) => [entry.selector, entry.selectorIndex])).toEqual([
210
+ [".card", 0],
211
+ [".card", 1],
212
+ ]);
213
+ parent.remove();
214
+ });
215
+ });
216
+
196
217
  describe("CanvasContextMenu — z-action commit path", () => {
197
218
  it("never mutates live styles itself and persists the position patch for a static element", async () => {
198
219
  const { target } = makeStaticFamily();
@@ -208,7 +229,7 @@ describe("CanvasContextMenu — z-action commit path", () => {
208
229
  selection,
209
230
  onApplyZIndex: (patches, action) => {
210
231
  stylesAtApply.push({ zIndex: target.style.zIndex, position: target.style.position });
211
- const { entries } = resolveZIndexEntries(selection, patches);
232
+ const { entries } = resolveZIndexEntries(selection, patches, null);
212
233
  void commit(entries, undefined, action);
213
234
  },
214
235
  });
@@ -16,7 +16,7 @@
16
16
  * flow, and captures the TRUE prior styles for its failure rollback.
17
17
  *
18
18
  * The prop MUST be wired at the call site to route through the full persist
19
- * path. ConnectedDomEditOverlay.tsx builds the per-patch PatchTargets (the selected
19
+ * path. useDomEditZOrder.ts builds the per-patch PatchTargets (the selected
20
20
  * element carries its full selection identity; sibling elements are iframe DOM
21
21
  * nodes, so their id / selector are derived from the node and they share the
22
22
  * selection's sourceFile) and forwards them to handleDomZIndexReorderCommit.
@@ -29,8 +29,7 @@ import type { DomEditSelection } from "./domEditing";
29
29
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
30
30
  import {
31
31
  isZOrderActionEnabled,
32
- resolveCrossedNeighbor,
33
- resolveZOrderChange,
32
+ resolveZOrderStep,
34
33
  type ZOrderAction,
35
34
  type ZOrderPatch,
36
35
  } from "./canvasContextMenuZOrder";
@@ -170,13 +169,11 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
170
169
 
171
170
  function handleZAction(action: ZAction) {
172
171
  if (!onApplyZIndex) return;
173
- const patches = resolveZOrderChange(el, action);
174
- if (patches === null) return;
175
- // Resolve the crossed neighbor BEFORE the commit path mutates live styles —
176
- // both resolvers must read the same pre-change render order. Always resolved
177
- // (not only for the flash): onApplyZIndex forwards it so the host can mirror
178
- // the z step into a timeline lane move.
179
- const crossed = resolveCrossedNeighbor(el, action);
172
+ // Resolved BEFORE the commit path mutates live styles. `crossed` is always
173
+ // resolved (not only for the flash): the host mirrors it into a lane move.
174
+ const step = resolveZOrderStep(el, action);
175
+ if (step === null) return;
176
+ const { patches, crossed } = step;
180
177
  // Do NOT pre-apply styles here: handleDomZIndexReorderCommit writes the
181
178
  // live z-index (and injects position:relative for static elements) in the
182
179
  // same synchronous flow, so feedback is still instant — and it must read
@@ -4,12 +4,7 @@ import {
4
4
  useDomEditActionsContext,
5
5
  useDomEditSelectionContext,
6
6
  } from "../../contexts/DomEditContext";
7
- import { readHfId, type DomEditSelection } from "./domEditing";
8
- import { buildStableSelector } from "./domEditingDom";
9
- import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
10
- import { zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps";
11
- import { useCanvasZOrderTimelineMirror } from "../nle/useCanvasZOrderTimelineMirror";
12
- import { runZLaneGesture } from "../nle/zLaneGesture";
7
+ import { useDomEditZOrder } from "./useDomEditZOrder";
13
8
 
14
9
  type HostInputProps = Pick<
15
10
  ComponentProps<typeof DomEditOverlay>,
@@ -25,94 +20,6 @@ export interface ConnectedDomEditOverlayProps extends HostInputProps {
25
20
  onTextEditingChange?: (editing: boolean) => void;
26
21
  }
27
22
 
28
- type ZIndexReorderEntry = {
29
- element: HTMLElement;
30
- zIndex: number;
31
- id?: string;
32
- selector?: string;
33
- selectorIndex?: number;
34
- sourceFile: string;
35
- /** Timeline store key — lets the commit update the store zIndex synchronously. */
36
- key?: string;
37
- };
38
-
39
- /** Can this element be robustly re-targeted for a persisted z change? */
40
- function canTargetZIndexElement(
41
- element: HTMLElement,
42
- id: string | undefined,
43
- selector: string | undefined,
44
- ): boolean {
45
- return Boolean(id || selector || readHfId(element));
46
- }
47
-
48
- /** The selected element carries its full selection identity. */
49
- function selectedZIndexEntry(sel: DomEditSelection, zIndex: number): ZIndexReorderEntry {
50
- return {
51
- element: sel.element,
52
- zIndex,
53
- id: sel.id ?? undefined,
54
- selector: sel.selector,
55
- selectorIndex: sel.selectorIndex,
56
- sourceFile: sel.sourceFile,
57
- key: deriveTimelineStoreKey({
58
- domId: sel.id ?? undefined,
59
- selector: sel.selector,
60
- selectorIndex: sel.selectorIndex,
61
- sourceFile: sel.sourceFile,
62
- }),
63
- };
64
- }
65
-
66
- /** A raw iframe sibling in the selection's file; null with no id or selector (z stays live). */
67
- function siblingZIndexEntry(
68
- element: HTMLElement,
69
- zIndex: number,
70
- sourceFile: string,
71
- ): ZIndexReorderEntry | null {
72
- const id = element.id || undefined;
73
- const selector = buildStableSelector(element);
74
- if (!canTargetZIndexElement(element, id, selector)) return null;
75
- return {
76
- element,
77
- zIndex,
78
- id,
79
- selector,
80
- selectorIndex: undefined,
81
- sourceFile,
82
- key: deriveTimelineStoreKey({ domId: id, selector, sourceFile }),
83
- };
84
- }
85
-
86
- /** Short human-readable label for a dropped sibling, for the console warning below. */
87
- function describeZIndexElement(element: HTMLElement): string {
88
- if (element.id) return `#${element.id}`;
89
- const firstClass = element.classList.item(0);
90
- return firstClass
91
- ? `${element.tagName.toLowerCase()}.${firstClass}`
92
- : element.tagName.toLowerCase();
93
- }
94
-
95
- // Resolve z-index patches into commit entries; a sibling with no stable
96
- // id/selector can't be written to source, so it is returned as `dropped` for
97
- // the revert-on-reload warning. Exported so tests can drive the menu → commit path.
98
- export function resolveZIndexEntries(
99
- sel: DomEditSelection,
100
- patches: ReadonlyArray<{ element: HTMLElement; zIndex: number }>,
101
- ): { entries: ZIndexReorderEntry[]; dropped: Array<{ element: HTMLElement; zIndex: number }> } {
102
- const entries: ZIndexReorderEntry[] = [];
103
- const dropped: Array<{ element: HTMLElement; zIndex: number }> = [];
104
- for (const patch of patches) {
105
- if (patch.element === sel.element) {
106
- entries.push(selectedZIndexEntry(sel, patch.zIndex));
107
- continue;
108
- }
109
- const entry = siblingZIndexEntry(patch.element, patch.zIndex, sel.sourceFile);
110
- if (entry) entries.push(entry);
111
- else dropped.push(patch);
112
- }
113
- return { entries, dropped };
114
- }
115
-
116
23
  // Studio's DomEditOverlay wired to the session in DomEditProvider, so a host
117
24
  // outside EditorShell mounts the same canvas editing without its own callbacks.
118
25
  export function ConnectedDomEditOverlay({
@@ -142,9 +49,8 @@ export function ConnectedDomEditOverlay({
142
49
  handleDomStyleCommit,
143
50
  applyMarqueeSelection,
144
51
  handleDomEditElementDelete,
145
- handleDomZIndexReorderCommit,
146
52
  } = useDomEditActionsContext();
147
- const mirrorZOrderToTimeline = useCanvasZOrderTimelineMirror();
53
+ const zOrder = useDomEditZOrder();
148
54
 
149
55
  return (
150
56
  <DomEditOverlay
@@ -170,38 +76,7 @@ export function ConnectedDomEditOverlay({
170
76
  onRotationCommit={handleDomRotationCommit}
171
77
  onStyleCommit={handleDomStyleCommit}
172
78
  onDeleteSelection={handleDomEditElementDelete}
173
- onApplyZIndex={(sel, patches, action, crossed) => {
174
- const { entries, dropped } = resolveZIndexEntries(sel, patches);
175
- if (dropped.length > 0) {
176
- // These siblings can't be written to source. Apply their live z
177
- // anyway so the resolved stacking order renders coherently — it
178
- // just reverts to the prior order on the next reload.
179
- for (const patch of dropped) patch.element.style.zIndex = String(patch.zIndex);
180
- console.warn(
181
- "[studio] z-index reorder: dropping sibling(s) with no stable id/selector " +
182
- "(will revert on reload):",
183
- dropped.map((patch) => describeZIndexElement(patch.element)).join(", "),
184
- );
185
- }
186
- if (entries.length === 0) return;
187
- // One coalesce key for the z persist AND the lane mirror folds both into one undo entry;
188
- // passed explicitly so the mirror shares it by construction, not by formula duplication.
189
- const coalesceKey = zReorderCoalesceKey(entries, action);
190
- // One serialized z→lane transaction: the mirror runs only AFTER a durable z commit and
191
- // no second gesture interleaves (see runZLaneGesture). A failed z commit has already
192
- // toasted and rolled back, so the catch only keeps its rejection from going unhandled.
193
- runZLaneGesture({
194
- commitZ: () => handleDomZIndexReorderCommit(entries, coalesceKey, action),
195
- mirror: () =>
196
- mirrorZOrderToTimeline({
197
- selectionKey: entries.find((e) => e.element === sel.element)?.key,
198
- action,
199
- crossed,
200
- sourceFile: sel.sourceFile,
201
- coalesceKey,
202
- }),
203
- }).catch(() => undefined);
204
- }}
79
+ onApplyZIndex={zOrder.commit}
205
80
  onMarqueeSelect={applyMarqueeSelection}
206
81
  />
207
82
  );
@@ -76,7 +76,7 @@ export interface DomEditOverlayProps {
76
76
  selection: DomEditSelection,
77
77
  options?: { revealPanel?: boolean; additive?: boolean },
78
78
  ) => void;
79
- onBlockedMove: (selection: DomEditSelection) => void;
79
+ onBlockedMove: (selection: DomEditSelection, reason?: string) => void;
80
80
  onManualDragStart?: () => void;
81
81
  onPathOffsetCommit: (
82
82
  selection: DomEditSelection,
@@ -296,6 +296,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
296
296
  gestureRef,
297
297
  groupGestureRef,
298
298
  blockedMoveRef,
299
+ onBlockedMoveRef,
299
300
  onManualDragStartRef,
300
301
  onPathOffsetCommitRef,
301
302
  onGroupPathOffsetCommitRef,
@@ -178,8 +178,9 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
178
178
  transformPerspective: 0,
179
179
  };
180
180
  // Unconditional like the hooks above: must not sit behind the `!element` return below.
181
+ const manualOffset = element ? readMoveOffset(element.element) : { x: 0, y: 0 };
181
182
  const { manualOffsetEditingDisabled, manualSizeEditingDisabled, manualRotationEditingDisabled } =
182
- useManualEditDisabledFlags(element?.capabilities);
183
+ useManualEditDisabledFlags(element?.capabilities, manualOffset);
183
184
 
184
185
  if (!element) {
185
186
  return (
@@ -200,7 +201,6 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
200
201
  // selection) so the Timing section shows for pure-GSAP elements with no data-start.
201
202
  const sections = resolveEditingSections(domEditSelectionToFacts(element, gsapAnimations.length));
202
203
  const showEditableSections = element.capabilities.canEditStyles && sections.style;
203
- const manualOffset = readMoveOffset(element.element);
204
204
  const manualSize = readStudioBoxSize(element.element);
205
205
  const resolvedWidth =
206
206
  manualSize.width > 0
@@ -511,3 +511,16 @@ export function isZOrderActionEnabled(
511
511
  ): boolean {
512
512
  return resolveZOrderChange(target, action, options) !== null;
513
513
  }
514
+
515
+ /**
516
+ * One z-order step: the patches plus the sibling it crosses, both read from the
517
+ * same pre-change render order. Null when the action is a no-op.
518
+ */
519
+ export function resolveZOrderStep(
520
+ target: HTMLElement,
521
+ action: ZOrderAction,
522
+ ): { patches: ZOrderPatch[]; crossed: HTMLElement | null } | null {
523
+ const patches = resolveZOrderChange(target, action);
524
+ if (patches === null) return null;
525
+ return { patches, crossed: resolveCrossedNeighbor(target, action) };
526
+ }
@@ -253,7 +253,7 @@ export type UseDomEditOverlayGesturesOptions = {
253
253
  suppressNextBoxClickRef: RefObject<boolean>;
254
254
  setOverlayRect: (next: OverlayRect | null) => void;
255
255
  setGroupOverlayItems: (next: GroupOverlayItem[]) => void;
256
- onBlockedMoveRef: RefObject<(selection: DomEditSelection) => void>;
256
+ onBlockedMoveRef: RefObject<(selection: DomEditSelection, reason?: string) => void>;
257
257
  onManualDragStartRef: RefObject<(() => void) | undefined>;
258
258
  onPathOffsetCommitRef: RefObject<
259
259
  (
@@ -76,7 +76,7 @@ export function startGroupDrag(
76
76
  restoreManualOffsetDragMembers(members);
77
77
  e.preventDefault();
78
78
  e.stopPropagation();
79
- opts.onBlockedMoveRef.current(result.selection);
79
+ opts.onBlockedMoveRef.current(result.selection, result.reason);
80
80
  return false;
81
81
  }
82
82
  members.push(result.member);
@@ -198,7 +198,7 @@ export function startGesture(
198
198
  gesture: "drag",
199
199
  });
200
200
  if (!result.ok) {
201
- opts.onBlockedMoveRef.current(result.selection);
201
+ opts.onBlockedMoveRef.current(result.selection, result.reason);
202
202
  return false;
203
203
  }
204
204
  pathOffsetMember = result.member;
@@ -1,32 +1,3 @@
1
- // GSAP's CSSPlugin takes ownership of the element's entire transform stack
2
- // when it tweens ANY of these — it bakes the CSS `translate` longhand into
3
- // style.transform at init and writes `translate: none` every tick. Position
4
- // reapply/strip logic must therefore stand down for all of them, not just x/y.
5
- const GSAP_TRANSFORM_PROPS = [
6
- "x",
7
- "y",
8
- "xPercent",
9
- "yPercent",
10
- "scale",
11
- "scaleX",
12
- "scaleY",
13
- "rotation",
14
- "rotate",
15
- "rotationX",
16
- "rotationY",
17
- "skewX",
18
- "skewY",
19
- "transform",
20
- ];
21
-
22
- /**
23
- * True when GSAP animates any transform-affecting property on the element,
24
- * meaning GSAP owns `style.transform` and has neutralized CSS `translate`.
25
- */
26
- export function gsapAnimatesTransform(el: HTMLElement): boolean {
27
- return gsapAnimatesProperty(el, ...GSAP_TRANSFORM_PROPS);
28
- }
29
-
30
1
  /**
31
2
  * Checks whether GSAP actively animates one or more CSS/GSAP properties on
32
3
  * the given element by inspecting all registered `__timelines`.
@@ -1,10 +1,15 @@
1
1
  // @vitest-environment happy-dom
2
- import { expect, it, vi } from "vitest";
2
+ import { afterEach, expect, it, vi } from "vitest";
3
3
  import { createGsapLivePreview } from "./gsapLivePreview";
4
4
  import type { DomEditSelection } from "./domEditingTypes";
5
5
  import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
6
6
  import { readGsapRuntimeValuesForPanel } from "./propertyPanelHelpers";
7
7
 
8
+ afterEach(() => {
9
+ delete (window as { gsap?: unknown }).gsap;
10
+ delete (window as { __timelines?: unknown }).__timelines;
11
+ });
12
+
8
13
  it("previews on the selected element, not an earlier same-id copy in a sub-composition", () => {
9
14
  document.body.innerHTML =
10
15
  '<div data-composition-id="strip"><div id="card" data-hf-id="hf-inner"></div></div>' +
@@ -61,3 +66,43 @@ it("the panel reads GSAP values off the node the live preview moves", () => {
61
66
  });
62
67
  expect(getProperty.mock.calls[0]?.[0]).toBe(root);
63
68
  });
69
+
70
+ it.each([
71
+ ["fades it", { opacity: 0 }, ["opacity"]],
72
+ ["fades it about its corner", { opacity: 0, transformOrigin: "0 0" }, ["opacity"]],
73
+ [
74
+ "moves it",
75
+ { x: 100 },
76
+ [
77
+ "x",
78
+ "y",
79
+ "rotation",
80
+ "rotationX",
81
+ "rotationY",
82
+ "rotationZ",
83
+ "z",
84
+ "scale",
85
+ "transformPerspective",
86
+ "opacity",
87
+ ],
88
+ ],
89
+ ])(
90
+ "the panel reads GSAP's transform only off an element GSAP positions: a tween that %s",
91
+ (_, vars, read) => {
92
+ document.body.innerHTML = '<div id="card"></div>';
93
+ const card = document.querySelector<HTMLElement>("#card");
94
+ const tween = { targets: () => [card], vars, duration: () => 1 };
95
+ const getProperty = vi.fn(() => 1);
96
+ const contentWindow = Object.assign(window, {
97
+ gsap: { getProperty },
98
+ __timelines: { main: { getChildren: () => [tween] } },
99
+ });
100
+ const selection = { id: "card", sourceFile: "index.html", element: card } as DomEditSelection;
101
+ const animations = [{ properties: vars }] as unknown as GsapAnimation[];
102
+ readGsapRuntimeValuesForPanel("anim", animations, selection, {
103
+ current: { contentWindow, contentDocument: document } as unknown as HTMLIFrameElement,
104
+ });
105
+ const props = getProperty.mock.calls.map((call) => (call as unknown[])[1]);
106
+ expect(props).toEqual(read);
107
+ },
108
+ );
@@ -13,6 +13,7 @@ import {
13
13
  resolveManualOffsetForPointerDelta,
14
14
  type ManualOffsetDragMatrix,
15
15
  } from "./manualOffsetDrag";
16
+ import { UNREADABLE_TRANSLATE } from "./plainTranslate";
16
17
  import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits";
17
18
  import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
18
19
 
@@ -596,6 +597,21 @@ describe("a move of an element GSAP does not position", () => {
596
597
  return { window, element, gsapCalls, member };
597
598
  }
598
599
 
600
+ it("refuses a translate it can't read instead of guessing, and leaves the element alone", () => {
601
+ const { element } = plainBox();
602
+ element.style.setProperty("translate", "abs(10% - 50px) 0px");
603
+ const result = createManualOffsetDragMember({
604
+ key: "box",
605
+ selection: { element } as never,
606
+ element,
607
+ rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
608
+ gesture: "drag",
609
+ });
610
+ expect(result.ok).toBe(false);
611
+ expect(result.ok ? "" : result.reason).toBe(UNREADABLE_TRANSLATE);
612
+ expect(element.style.getPropertyValue("translate")).toBe("abs(10% - 50px) 0px");
613
+ });
614
+
599
615
  it("drafts and drops the element's own translate in plain px, never touching GSAP", () => {
600
616
  const { element, gsapCalls, member } = plainBox({ gsapLoaded: true });
601
617
  const m = member();
@@ -12,7 +12,7 @@ import {
12
12
  } from "./manualEdits";
13
13
  import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
14
14
  import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
15
- import { readTranslatePx, writeTranslatePx } from "./plainTranslate";
15
+ import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
16
16
 
17
17
  interface OffsetDragGsap {
18
18
  set: (el: Element, vars: Record<string, number | string>) => void;
@@ -345,6 +345,8 @@ export function createManualOffsetDragMember(input: {
345
345
  const initialOffset = plainTranslate
346
346
  ? readTranslatePx(input.element)
347
347
  : readAppliedStudioPathOffset(input.element);
348
+ if (!finitePoint(initialOffset))
349
+ return { ok: false, reason: UNREADABLE_TRANSLATE, selection: input.selection };
348
350
  const win = input.element.ownerDocument.defaultView as
349
351
  | (Window & {
350
352
  gsap?: { getProperty?: (el: Element, prop: string) => number };
@@ -382,10 +384,8 @@ export function createManualOffsetDragMember(input: {
382
384
  plainTranslate,
383
385
  });
384
386
  if (!measured.ok) {
385
- // Fallback: when GSAP transforms interfere with probe measurement, use
386
- // the preview scale as an approximation. The commit path reads the actual
387
- // GSAP position from the iframe runtime, so visual imprecision during
388
- // drag is acceptable — the final committed position is always exact.
387
+ // GSAP transforms can defeat the probe: approximate with the preview scale. The commit
388
+ // reads GSAP's real position, so only the drag preview is imprecise.
389
389
  const scaleX = input.rect.editScaleX || 1;
390
390
  const scaleY = input.rect.editScaleY || 1;
391
391
  const w = readTransformWDivisor(input.element);
@@ -28,18 +28,31 @@ describe("readTranslatePx", () => {
28
28
  });
29
29
 
30
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);
31
+ ["min(10px, 5%) max(-20%, -40px)", { x: 10, y: -32 }],
32
+ ["clamp(0px, 10%, 30px) 5px", { x: 24, y: 5 }],
33
+ ["calc(min(50%, 100px) * 2 - (10px + 5%)) calc(-1 * clamp(10%, 1px, 20%))", { x: 178, y: -16 }],
34
+ ["calc(2 * 10px) calc(80px / 4)", { x: 20, y: 20 }],
35
+ ])("works out %s by arithmetic", (translate, want) => {
36
+ expect(readTranslatePx(box(`translate: ${translate}`))).toEqual(want);
41
37
  });
42
38
 
39
+ it.each(["calc(10px +) 0px", "abs(10% - 50px) 0px", "round(10%, 7px) 0px", "min(10px 5%) 0px"])(
40
+ "reads %s as NaN, never as a guess",
41
+ (translate) => {
42
+ expect(readTranslatePx(box(`translate: ${translate}`)).x).toBeNaN();
43
+ },
44
+ );
45
+
46
+ it.each(["content-box", "fill-box"])(
47
+ "resolves a percent against the content box under transform-box: %s",
48
+ (transformBox) => {
49
+ const element = box(
50
+ `translate: 50% 50%; padding: 10px; border: 5px solid; box-sizing: content-box; transform-box: ${transformBox}`,
51
+ );
52
+ expect(readTranslatePx(element)).toEqual({ x: 120, y: 80 });
53
+ },
54
+ );
55
+
43
56
  it("counts padding and border in the box a percent resolves against", () => {
44
57
  const element = box(
45
58
  "translate: 50% 50%; padding: 10px; border: 5px solid; box-sizing: content-box",
@@ -47,16 +60,19 @@ describe("readTranslatePx", () => {
47
60
  expect(readTranslatePx(element)).toEqual({ x: 135, y: 95 });
48
61
  });
49
62
 
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
- });
63
+ it.each(["-50% -50%", "min(10px, 5%) clamp(0px, 10%, 30px)"])(
64
+ "writes nothing to the element while it reads %s",
65
+ (translate) => {
66
+ const element = box(`translate: ${translate}; transform: rotate(5deg)`);
67
+ const before = element.getAttribute("style");
68
+ const writes: MutationRecord[] = [];
69
+ const observer = new MutationObserver((records) => writes.push(...records));
70
+ observer.observe(element, { attributes: true });
71
+ readTranslatePx(element);
72
+ writes.push(...observer.takeRecords());
73
+ observer.disconnect();
74
+ expect(writes).toEqual([]);
75
+ expect(element.getAttribute("style")).toBe(before);
76
+ },
77
+ );
62
78
  });