@hyperframes/studio 0.8.102 → 0.8.104

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 (73) hide show
  1. package/dist/assets/{hyperframes-player-BuRiFtrF.js → hyperframes-player-DAoaajuM.js} +1 -1
  2. package/dist/assets/{index-BT3sauee.js → index-BDK4rTMb.js} +1 -1
  3. package/dist/assets/{index-xB5O0hPw.css → index-CQN3M56N.css} +1 -1
  4. package/dist/assets/{index-C2utDZt-.js → index-De2aO4XM.js} +179 -179
  5. package/dist/assets/{index-BM35fjFf.js → index-WV9LroAD.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 +183 -170
  12. package/dist/index.html +2 -2
  13. package/dist/index.js +550 -389
  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/DomEditOverlay.tsx +7 -7
  19. package/src/components/editor/PropertyPanel.tsx +5 -5
  20. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +46 -6
  21. package/src/components/editor/domEditOverlayGestures.ts +14 -20
  22. package/src/components/editor/domEditOverlayStartGesture.test.ts +170 -13
  23. package/src/components/editor/domEditOverlayStartGesture.ts +17 -27
  24. package/src/components/editor/manualEdits.test.ts +3 -9
  25. package/src/components/editor/manualEdits.ts +0 -1
  26. package/src/components/editor/manualEditsDom.ts +0 -10
  27. package/src/components/editor/manualEditsDomPatches.test.ts +1 -45
  28. package/src/components/editor/manualEditsDomPatches.ts +1 -23
  29. package/src/components/editor/manualEditsSnapshot.ts +2 -0
  30. package/src/components/editor/manualEditsTypes.ts +2 -0
  31. package/src/components/editor/manualOffsetDrag.test.ts +57 -0
  32. package/src/components/editor/manualOffsetDrag.ts +11 -32
  33. package/src/components/editor/plainTranslate.test.ts +38 -22
  34. package/src/components/editor/plainTranslate.ts +75 -40
  35. package/src/components/editor/previewReadOnly.overlay.test.tsx +30 -0
  36. package/src/components/editor/previewReadOnlyContext.tsx +3 -1
  37. package/src/components/editor/propertyPanelTransformCommit.test.ts +80 -0
  38. package/src/components/editor/propertyPanelTransformCommit.ts +11 -6
  39. package/src/components/editor/propertyPanelTypes.ts +1 -0
  40. package/src/components/editor/rotateInlineSave.test.ts +111 -0
  41. package/src/components/editor/rotationDraft.test.ts +111 -0
  42. package/src/components/editor/rotationDraft.ts +145 -0
  43. package/src/components/editor/useColorGradingController.test.ts +36 -1
  44. package/src/components/editor/useColorGradingController.ts +2 -2
  45. package/src/components/editor/useDomEditNudge.test.tsx +34 -0
  46. package/src/components/editor/useDomEditNudge.ts +12 -5
  47. package/src/components/editor/useDomEditOverlayGestures.ts +22 -24
  48. package/src/hooks/draggedGsapPosition.ts +28 -0
  49. package/src/hooks/elementOffsetStager.ts +26 -6
  50. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  51. package/src/hooks/gsapRuntimeKeyframes.ts +36 -2
  52. package/src/hooks/plainRotation.test.ts +122 -0
  53. package/src/hooks/plainRotation.ts +42 -0
  54. package/src/hooks/useDomEditSession.ts +5 -5
  55. package/src/hooks/useDomGeometryCommit.test.tsx +75 -0
  56. package/src/hooks/useDomGeometryCommit.ts +17 -17
  57. package/src/hooks/useDomGeometryCommits.test.tsx +56 -1
  58. package/src/hooks/useDomGeometryCommits.ts +28 -36
  59. package/src/hooks/useGsapAwareEditing.test.tsx +194 -11
  60. package/src/hooks/useGsapAwareEditing.ts +43 -42
  61. package/src/hooks/useInlineTextEdit.test.tsx +33 -3
  62. package/src/hooks/useInlineTextEdit.ts +2 -2
  63. package/src/hooks/usePreviewInteraction.test.ts +32 -0
  64. package/src/hooks/usePreviewInteraction.ts +3 -2
  65. package/src/player/components/TimelineClipFades.test.tsx +103 -3
  66. package/src/player/components/TimelineClipFades.tsx +53 -50
  67. package/src/styles/theme.css +1 -1
  68. package/src/utils/projectRouting.test.ts +9 -0
  69. package/src/utils/projectRouting.ts +3 -1
  70. package/dist/chunk-S65AQH6M.js.map +0 -1
  71. /package/dist/{chunk-MTAVNAGW.js.map → chunk-46XP7J3B.js.map} +0 -0
  72. /package/dist/{chunk-7X4XRP3L.js.map → chunk-HIGHVWYD.js.map} +0 -0
  73. /package/dist/{domEditingLayers-NU6Z5QWA.js.map → domEditingLayers-AQBDFNUC.js.map} +0 -0
@@ -10,7 +10,6 @@ import {
10
10
  applyStudioPathOffset,
11
11
  applyStudioPathOffsetDraft,
12
12
  applyStudioRotation,
13
- applyStudioRotationDraft,
14
13
  beginStudioManualEditGesture,
15
14
  captureStudioBoxSize,
16
15
  captureStudioRotation,
@@ -210,16 +209,11 @@ describe("studio manual edits", () => {
210
209
  expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
211
210
  expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
212
211
 
213
- applyStudioRotationDraft(card, { angle: -12.2604 });
214
- expect(readStudioRotation(card)).toEqual({ angle: -12.26 });
215
- expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.26deg)");
216
- expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
217
-
218
212
  const snapshot = captureStudioRotation(card);
219
- applyStudioRotationDraft(card, { angle: 45 });
213
+ applyStudioRotation(card, { angle: 45 });
220
214
  restoreStudioRotation(card, snapshot);
221
- expect(readStudioRotation(card)).toEqual({ angle: -12.26 });
222
- expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.26deg)");
215
+ expect(readStudioRotation(card)).toEqual({ angle: 24.247 });
216
+ expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.247deg");
223
217
  expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
224
218
  });
225
219
 
@@ -29,7 +29,6 @@ export {
29
29
  applyStudioBoxSize,
30
30
  applyStudioBoxSizeDraft,
31
31
  applyStudioRotation,
32
- applyStudioRotationDraft,
33
32
  } from "./manualEditsDom";
34
33
 
35
34
  export { reapplyPositionEditsAfterSeek } from "./manualEditsSeekReapply";
@@ -538,13 +538,3 @@ export function applyStudioRotation(element: HTMLElement, rotation: { angle: num
538
538
  composeStudioRotationValue(element, `var(${STUDIO_ROTATION_PROP}, 0deg)`),
539
539
  );
540
540
  }
541
-
542
- export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle: number }): void {
543
- promoteInlineForTransform(element);
544
- writeStudioRotationVars(element, rotation, { updateBase: false });
545
- element.setAttribute(STUDIO_ROTATION_DRAFT_ATTR, "true");
546
- element.style.setProperty(
547
- "rotate",
548
- composeStudioRotationValue(element, `${roundTo3(rotation.angle)}deg`),
549
- );
550
- }
@@ -46,7 +46,6 @@ import {
46
46
  buildClearPathOffsetPatches,
47
47
  buildBoxSizePatches,
48
48
  buildClearBoxSizePatches,
49
- buildRotationPatches,
50
49
  buildClearRotationPatches,
51
50
  buildMotionPatches,
52
51
  buildClearMotionPatches,
@@ -335,45 +334,7 @@ describe("anchored-corner combined patch: [...buildBoxSizePatches, ...buildPathO
335
334
 
336
335
  /* ── Rotation ────────────────────────────────────────────────────────────── */
337
336
 
338
- describe("buildRotationPatches / buildClearRotationPatches", () => {
339
- function populatedRotEl(): HTMLElement {
340
- const e = div();
341
- e.style.setProperty(STUDIO_ROTATION_PROP, "45");
342
- e.style.setProperty("rotate", "45deg");
343
- e.style.setProperty("transform-origin", "left center");
344
- e.style.setProperty("display", "block");
345
- e.setAttribute(STUDIO_ORIGINAL_ROTATE_ATTR, "0deg");
346
- e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "0deg");
347
- e.setAttribute(STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, "center center");
348
- e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "flex");
349
- return e;
350
- }
351
-
352
- it("populated: captures rotation styles, attrs, and transform-display marker in declaration order", () => {
353
- const ops = buildRotationPatches(populatedRotEl());
354
- expect(ops).toEqual([
355
- { type: "inline-style", property: STUDIO_ROTATION_PROP, value: "45" },
356
- { type: "inline-style", property: "rotate", value: "45deg" },
357
- { type: "inline-style", property: "transform-origin", value: "left center" },
358
- { type: "inline-style", property: "display", value: "block" },
359
- { type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" },
360
- { type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: "0deg" },
361
- { type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: "0deg" },
362
- {
363
- type: "attribute",
364
- property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
365
- value: "center center",
366
- },
367
- { type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: "flex" },
368
- ]);
369
- });
370
-
371
- it("empty: bare element yields only the rotation marker", () => {
372
- expect(buildRotationPatches(div())).toEqual([
373
- { type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" },
374
- ]);
375
- });
376
-
337
+ describe("buildClearRotationPatches", () => {
377
338
  it("clear: restores rotate and transform-origin from orig attrs, nulls draft attr", () => {
378
339
  const e = div();
379
340
  e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "30deg");
@@ -405,11 +366,6 @@ describe("buildRotationPatches / buildClearRotationPatches", () => {
405
366
  const ops = buildClearRotationPatches(e);
406
367
  expect(ops.find((o) => o.property === "rotate")?.value).toBeNull();
407
368
  });
408
-
409
- it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => {
410
- const e = populatedRotEl();
411
- assertClearCoversKeys(buildRotationPatches(e), buildClearRotationPatches(e));
412
- });
413
369
  });
414
370
 
415
371
  /* ── Motion ──────────────────────────────────────────────────────────────── */
@@ -118,7 +118,7 @@ export function buildClearPathOffsetPatches(element: HTMLElement): PatchOperatio
118
118
 
119
119
  /* ── Box size patches ────────────────────────────────────────────── */
120
120
 
121
- const BOX_SIZE_STYLE_PROPS = [
121
+ export const BOX_SIZE_STYLE_PROPS = [
122
122
  "width",
123
123
  "height",
124
124
  "min-width",
@@ -185,28 +185,6 @@ export function buildClearBoxSizePatches(element: HTMLElement): PatchOperation[]
185
185
 
186
186
  /* ── Rotation patches ────────────────────────────────────────────── */
187
187
 
188
- const ROTATION_STYLE_PROPS = [
189
- STUDIO_ROTATION_PROP,
190
- "rotate",
191
- "transform-origin",
192
- "display",
193
- ] as const;
194
-
195
- const ROTATION_ORIG_ATTRS = [
196
- STUDIO_ORIGINAL_ROTATE_ATTR,
197
- STUDIO_ORIGINAL_INLINE_ROTATE_ATTR,
198
- STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
199
- STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
200
- ] as const;
201
-
202
- export function buildRotationPatches(element: HTMLElement): PatchOperation[] {
203
- const ops: PatchOperation[] = [];
204
- collectInlineStyleOps(element, ROTATION_STYLE_PROPS, ops);
205
- ops.push({ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" });
206
- collectAttributeOps(element, ROTATION_ORIG_ATTRS, ops);
207
- return ops;
208
- }
209
-
210
188
  export function buildClearRotationPatches(element: HTMLElement): PatchOperation[] {
211
189
  const origInlineRotate = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR);
212
190
  const origRotationTransformOrigin = element.getAttribute(
@@ -82,6 +82,8 @@ export function captureStudioBoxSize(element: HTMLElement): StudioBoxSizeSnapsho
82
82
  export function captureStudioRotation(element: HTMLElement): StudioRotationSnapshot {
83
83
  return {
84
84
  rotate: element.style.getPropertyValue("rotate"),
85
+ transform: element.style.getPropertyValue("transform"),
86
+ display: element.style.getPropertyValue("display"),
85
87
  transformOrigin: element.style.getPropertyValue("transform-origin"),
86
88
  studioRotation: element.style.getPropertyValue(STUDIO_ROTATION_PROP),
87
89
  marker: element.getAttribute(STUDIO_ROTATION_ATTR),
@@ -99,6 +99,8 @@ export interface StudioBoxSizeSnapshot {
99
99
 
100
100
  export interface StudioRotationSnapshot {
101
101
  rotate: string;
102
+ transform: string;
103
+ display: string;
102
104
  transformOrigin: string;
103
105
  studioRotation: string;
104
106
  marker: string | null;
@@ -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();
@@ -636,3 +652,44 @@ describe("a move of an element GSAP does not position", () => {
636
652
  expect(member().plainTranslate).toBe(false);
637
653
  });
638
654
  });
655
+
656
+ describe("a resize's anchor member", () => {
657
+ function anchorMember(tweenVars?: Record<string, number>) {
658
+ const window = new Window();
659
+ const element = window.document.createElement("div");
660
+ element.id = "box";
661
+ window.document.body.append(element);
662
+ const tweens = tweenVars ? [{ vars: tweenVars, targets: () => [element] }] : [];
663
+ Object.assign(window, { __timelines: { main: { getChildren: () => tweens } } });
664
+ element.getBoundingClientRect = () => {
665
+ const [x = 0, y = 0] = element.style
666
+ .getPropertyValue("translate")
667
+ .split(" ")
668
+ .map((v) => Number.parseFloat(v));
669
+ return new window.DOMRect(10 + (x || 0), 20 + (y || 0), 100, 50);
670
+ };
671
+ const result = createManualOffsetDragMember({
672
+ key: "box",
673
+ selection: { element } as never,
674
+ element,
675
+ rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
676
+ gesture: "resize",
677
+ });
678
+ if (!result.ok) throw new Error("member");
679
+ return result.member;
680
+ }
681
+
682
+ it("keeps the centre with the element's own plain translate when GSAP does not own the box", () => {
683
+ const member = anchorMember();
684
+ expect(member.plainTranslate).toBe(true);
685
+ applyManualOffsetDragDraft(member, -10.25, 3.5);
686
+ expect(member.element.style.getPropertyValue("translate")).toBe("-10.25px 3.5px");
687
+ });
688
+
689
+ it.each(["width", "maxWidth", "minHeight", "flexBasis", "scale", "scaleX"])(
690
+ "leaves a box whose %s GSAP tweens, which the CSS box writer also sets, to the GSAP writer",
691
+ (channel) => {
692
+ expect(anchorMember({ [channel]: 300 }).plainTranslate).toBe(false);
693
+ },
694
+ );
695
+ });
@@ -11,15 +11,15 @@ 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
+ import { gsapWritesBox, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
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;
19
19
  getProperty: (el: Element, prop: string) => number;
20
20
  }
21
21
 
22
- function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null {
22
+ export function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null {
23
23
  const win = element.ownerDocument.defaultView as
24
24
  | (Window & { gsap?: Partial<OffsetDragGsap> })
25
25
  | null;
@@ -53,27 +53,6 @@ function applyOffsetDragDraftViaGsap(
53
53
  return true;
54
54
  }
55
55
 
56
- /**
57
- * Live rotation preview through the GSAP channel — the SAME channel the commit
58
- * lands in (a `tl.set`/keyframe rotation), mirroring `applyOffsetDragDraftViaGsap`.
59
- * GSAP owns the transform rotation, so neutralize the CSS `rotate` longhand to keep
60
- * the two channels from composing. `angle` is the absolute target rotation. Returns
61
- * false when gsap is unavailable (caller falls back to the CSS draft).
62
- */
63
- export function applyRotationDraftViaGsap(element: HTMLElement, angle: number): boolean {
64
- const gsap = getOffsetDragGsap(element);
65
- if (!gsap) return false;
66
- element.style.setProperty("rotate", "none");
67
- gsap.set(element, { rotation: angle });
68
- return true;
69
- }
70
-
71
- /** Current GSAP transform rotation — the single-source rotation base. 0 if gsap is unavailable. */
72
- export function readGsapRotation(element: HTMLElement): number {
73
- const gsap = getOffsetDragGsap(element);
74
- return gsap ? Number(gsap.getProperty(element, "rotation")) || 0 : 0;
75
- }
76
-
77
56
  const DEFAULT_OFFSET_PROBE_PX = 100;
78
57
  const MIN_PROBE_VECTOR_LENGTH_PX = 0.01;
79
58
  const MIN_MATRIX_DETERMINANT = 0.000001;
@@ -337,14 +316,16 @@ export function createManualOffsetDragMember(input: {
337
316
  selection: DomEditSelection;
338
317
  element: HTMLElement;
339
318
  rect: ManualOffsetDragRect;
340
- gesture?: "drag" | "nudge"; // a resize's anchor member keeps the legacy offset channel
319
+ gesture?: "drag" | "nudge" | "resize"; // resize: the anchor that keeps its centre planted
341
320
  }): ManualOffsetDragMemberResult {
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.
321
+ const gsapOwns = input.gesture === "resize" ? gsapWritesBox : gsapWritesPosition;
322
+ const plainTranslate = !!input.gesture && !gsapOwns(input.element);
323
+ // The APPLIED offset, never a dormant var, so a stale one can't fling the element off-screen.
345
324
  const initialOffset = plainTranslate
346
325
  ? readTranslatePx(input.element)
347
326
  : readAppliedStudioPathOffset(input.element);
327
+ if (!finitePoint(initialOffset))
328
+ return { ok: false, reason: UNREADABLE_TRANSLATE, selection: input.selection };
348
329
  const win = input.element.ownerDocument.defaultView as
349
330
  | (Window & {
350
331
  gsap?: { getProperty?: (el: Element, prop: string) => number };
@@ -382,10 +363,8 @@ export function createManualOffsetDragMember(input: {
382
363
  plainTranslate,
383
364
  });
384
365
  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.
366
+ // GSAP transforms can defeat the probe: approximate with the preview scale. The commit
367
+ // reads GSAP's real position, so only the drag preview is imprecise.
389
368
  const scaleX = input.rect.editScaleX || 1;
390
369
  const scaleY = input.rect.editScaleY || 1;
391
370
  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
  });
@@ -1,34 +1,71 @@
1
1
  import { roundTo3 } from "../../utils/rounding";
2
- import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
3
- import { readStudioPathOffset } from "./manualEditsDom";
2
+ import { gsapWritesPosition, gsapWritesRotation } from "../../hooks/gsapRuntimeKeyframes";
3
+ import { readCssRotation } from "../../hooks/draggedGsapPosition";
4
+ import { readStudioPathOffset, readStudioRotation } from "./manualEditsDom";
4
5
  import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
5
6
  import type { PatchOperation } from "../../utils/sourcePatcher";
6
7
 
7
8
  type Point = { x: number; y: number };
8
9
 
9
- const TERM = /^(-?\d*\.?\d+(?:e[-+]?\d+)?)(px|%)$/i;
10
+ const TOKEN = /\s*(?:(\d*\.?\d+(?:e[-+]?\d+)?)(px|%)?|([a-z]+)\(|([-+*/(),]))/giy;
11
+ const FUNCTIONS: Record<string, (args: number[]) => number> = {
12
+ "(": ([a = Number.NaN]) => a,
13
+ "calc(": ([a = Number.NaN]) => a,
14
+ "min(": (args) => Math.min(...args),
15
+ "max(": (args) => Math.max(...args),
16
+ "clamp(": ([lo = Number.NaN, v = Number.NaN, hi = Number.NaN]) => Math.max(lo, Math.min(v, hi)),
17
+ };
10
18
 
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;
19
+ /** Lengths become px against `side`; functions keep their "(" so they can't pass for an operator. */
20
+ function tokenize(value: string, side: number): (number | string)[] {
21
+ const tokens: (number | string)[] = [];
22
+ const end = value.trimEnd().length;
23
+ TOKEN.lastIndex = 0;
24
+ for (let m = TOKEN.exec(value); m; m = TOKEN.lastIndex < end ? TOKEN.exec(value) : null) {
25
+ if (m[1]) tokens.push(Number(m[1]) * (m[2] === "%" ? side / 100 : 1));
26
+ else tokens.push(m[3] ? `${m[3].toLowerCase()}(` : (m[4] ?? ""));
27
+ }
28
+ return TOKEN.lastIndex < end ? [] : tokens;
16
29
  }
17
30
 
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;
31
+ /** Chrome's computed length (px, %, calc/min/max/clamp, + - * /) in px against `side`; NaN otherwise. */
32
+ function evaluateLength(value: string, side: number): number {
33
+ const tokens = tokenize(value, side);
34
+ let i = 0;
35
+ const next = () => tokens[i++];
36
+ const take = (...ops: string[]) => ops.includes(tokens[i] as string) && next();
37
+ function sum(): number {
38
+ let total = product();
39
+ for (let op = take("+", "-"); op; op = take("+", "-"))
40
+ total += (op === "+" ? 1 : -1) * product();
41
+ return total;
42
+ }
43
+ function product(): number {
44
+ let total = unary();
45
+ for (let op = take("*", "/"); op; op = take("*", "/"))
46
+ total = op === "*" ? total * unary() : total / unary();
47
+ return total;
27
48
  }
28
- return total;
49
+ function unary(): number {
50
+ return take("-") ? -unary() : atom();
51
+ }
52
+ function atom(): number {
53
+ const token = next();
54
+ if (typeof token === "number") return token;
55
+ const fn = FUNCTIONS[token ?? ""];
56
+ if (!fn) return Number.NaN;
57
+ const args = [sum()];
58
+ while (take(",")) args.push(sum());
59
+ return take(")") ? fn(args) : Number.NaN;
60
+ }
61
+ const total = tokens.length ? sum() : Number.NaN;
62
+ return i === tokens.length ? total : Number.NaN;
29
63
  }
30
64
 
31
- function borderBox(
65
+ const CONTENT_BOXES = new Set(["content-box", "fill-box"]);
66
+
67
+ /** The side % resolves against: the box `transform-box` names (fill-box is the content box on HTML). */
68
+ function referenceBox(
32
69
  cs: CSSStyleDeclaration,
33
70
  size: "width" | "height",
34
71
  a: string,
@@ -37,35 +74,29 @@ function borderBox(
37
74
  const n = (prop: string) => Number.parseFloat(cs.getPropertyValue(prop)) || 0;
38
75
  const pad =
39
76
  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
- }
77
+ const content = n(size) - (cs.boxSizing === "border-box" ? pad : 0);
78
+ return CONTENT_BOXES.has(cs.getPropertyValue("transform-box")) ? content : content + pad;
55
79
  }
56
80
 
57
81
  /** The element's CSS `translate` in px, as it renders now. */
58
82
  // ponytail: a 3-value translate loses its z on the next move; keep z when a fixture needs it.
59
83
  export function readTranslatePx(el: HTMLElement): Point {
60
84
  const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
61
- const value = el.style.getPropertyValue("translate") || cs?.translate || "none";
85
+ // Computed first: Chrome resolves em, vw and var() there and leaves only % to work out.
86
+ const value = cs?.translate || el.style.getPropertyValue("translate") || "none";
62
87
  if (value === "none") return { x: 0, y: 0 };
63
88
  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);
89
+ const side = (size: "width" | "height", a: string, b: string) =>
90
+ cs ? referenceBox(cs, size, a, b) : 0;
91
+ return {
92
+ x: evaluateLength(x, side("width", "left", "right")),
93
+ y: evaluateLength(y, side("height", "top", "bottom")),
94
+ };
67
95
  }
68
96
 
97
+ export const UNREADABLE_TRANSLATE =
98
+ "Studio can't read this layer's translate. Move it in the Code tab.";
99
+
69
100
  /** Plain px only: GSAP's CSSPlugin splits `translate` on spaces and drops a calc(). */
70
101
  function formatTranslatePx(p: Point): string {
71
102
  return `${roundTo3(p.x)}px ${roundTo3(p.y)}px`;
@@ -80,6 +111,10 @@ export function writeTranslatePx(el: HTMLElement, p: Point): void {
80
111
  }
81
112
 
82
113
  /** 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);
114
+ export function readMoveOffset(el: HTMLElement, plainTranslate = !gsapWritesPosition(el)): Point {
115
+ return plainTranslate ? readTranslatePx(el) : readStudioPathOffset(el);
116
+ }
117
+
118
+ export function readShownRotation(el: HTMLElement): { angle: number } {
119
+ return gsapWritesRotation(el) ? readStudioRotation(el) : { angle: roundTo3(readCssRotation(el)) };
85
120
  }
@@ -9,6 +9,7 @@ import { __resetForTests } from "../../utils/canvasNudgeGate";
9
9
  import { PreviewReadOnlyProvider } from "./previewReadOnlyContext";
10
10
  import "./domEditOverlayTestMocks";
11
11
  import { DomEditOverlay } from "./DomEditOverlay";
12
+ import { UNREADABLE_TRANSLATE } from "./plainTranslate";
12
13
 
13
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
15
 
@@ -475,3 +476,32 @@ describe("DomEditOverlay onTextEditingChange", () => {
475
476
  expect(spies.onTextEditingChange.mock.calls).toEqual([[true], [false]]);
476
477
  });
477
478
  });
479
+
480
+ describe("DomEditOverlay on a layer whose translate Studio can't read", () => {
481
+ it("refuses a drag and an arrow nudge out loud, and commits nothing", () => {
482
+ const { spies, overlay, selection } = fixture();
483
+ selection.element.style.setProperty("translate", "abs(10% - 50px) 0px");
484
+ fire(overlay.querySelector(BOX)!, "pointerdown");
485
+ const presses = [false, true, true].map(
486
+ (repeat) =>
487
+ new KeyboardEvent("keydown", {
488
+ key: "ArrowRight",
489
+ bubbles: true,
490
+ cancelable: true,
491
+ repeat,
492
+ }),
493
+ );
494
+ act(() => {
495
+ for (const press of presses) window.dispatchEvent(press);
496
+ vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
497
+ });
498
+ // One toast for the drag and one for the whole held arrow, whose every press is swallowed.
499
+ expect(spies.onBlockedMove.mock.calls).toEqual([
500
+ [selection, UNREADABLE_TRANSLATE],
501
+ [selection, UNREADABLE_TRANSLATE],
502
+ ]);
503
+ expect(presses.map((press) => press.defaultPrevented)).toEqual([true, true, true]);
504
+ expect(spies.onPathOffsetCommit).not.toHaveBeenCalled();
505
+ expect(selection.element.style.getPropertyValue("translate")).toBe("abs(10% - 50px) 0px");
506
+ });
507
+ });
@@ -49,10 +49,12 @@ interface ManualEditCapabilities {
49
49
  */
50
50
  export function useManualEditDisabledFlags(
51
51
  capabilities: ManualEditCapabilities | null | undefined,
52
+ offset = { x: 0, y: 0 },
52
53
  ) {
53
54
  const { readOnly } = useContext(PreviewReadOnlyContext);
55
+ const unreadable = !Number.isFinite(offset.x) || !Number.isFinite(offset.y);
54
56
  return {
55
- manualOffsetEditingDisabled: !capabilities?.canApplyManualOffset || readOnly,
57
+ manualOffsetEditingDisabled: !capabilities?.canApplyManualOffset || readOnly || unreadable,
56
58
  manualSizeEditingDisabled: !capabilities?.canApplyManualSize || readOnly,
57
59
  manualRotationEditingDisabled: !capabilities?.canApplyManualRotation || readOnly,
58
60
  };