@hyperframes/studio 0.8.103 → 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 (49) hide show
  1. package/dist/assets/{hyperframes-player-DG795Hwu.js → hyperframes-player-DAoaajuM.js} +1 -1
  2. package/dist/assets/{index-BiEtDkae.js → index-BDK4rTMb.js} +1 -1
  3. package/dist/assets/{index-BPJRf2X9.js → index-De2aO4XM.js} +178 -178
  4. package/dist/assets/{index-DuCZ3pQw.js → index-WV9LroAD.js} +1 -1
  5. package/dist/index.d.ts +181 -168
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +428 -305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/editor/DomEditOverlay.tsx +5 -6
  11. package/src/components/editor/PropertyPanel.tsx +3 -3
  12. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +46 -6
  13. package/src/components/editor/domEditOverlayGestures.ts +13 -19
  14. package/src/components/editor/domEditOverlayStartGesture.test.ts +170 -13
  15. package/src/components/editor/domEditOverlayStartGesture.ts +15 -25
  16. package/src/components/editor/manualEdits.test.ts +3 -9
  17. package/src/components/editor/manualEdits.ts +0 -1
  18. package/src/components/editor/manualEditsDom.ts +0 -10
  19. package/src/components/editor/manualEditsDomPatches.test.ts +1 -45
  20. package/src/components/editor/manualEditsDomPatches.ts +1 -23
  21. package/src/components/editor/manualEditsSnapshot.ts +2 -0
  22. package/src/components/editor/manualEditsTypes.ts +2 -0
  23. package/src/components/editor/manualOffsetDrag.test.ts +41 -0
  24. package/src/components/editor/manualOffsetDrag.ts +6 -27
  25. package/src/components/editor/plainTranslate.ts +9 -4
  26. package/src/components/editor/propertyPanelTransformCommit.test.ts +29 -0
  27. package/src/components/editor/propertyPanelTransformCommit.ts +8 -5
  28. package/src/components/editor/propertyPanelTypes.ts +1 -0
  29. package/src/components/editor/rotateInlineSave.test.ts +111 -0
  30. package/src/components/editor/rotationDraft.test.ts +111 -0
  31. package/src/components/editor/rotationDraft.ts +145 -0
  32. package/src/components/editor/useDomEditNudge.test.tsx +33 -0
  33. package/src/components/editor/useDomEditNudge.ts +6 -2
  34. package/src/components/editor/useDomEditOverlayGestures.ts +22 -24
  35. package/src/hooks/draggedGsapPosition.ts +28 -0
  36. package/src/hooks/elementOffsetStager.ts +26 -6
  37. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  38. package/src/hooks/gsapRuntimeKeyframes.ts +36 -2
  39. package/src/hooks/plainRotation.test.ts +122 -0
  40. package/src/hooks/plainRotation.ts +42 -0
  41. package/src/hooks/useDomEditSession.ts +5 -5
  42. package/src/hooks/useDomGeometryCommit.test.tsx +75 -0
  43. package/src/hooks/useDomGeometryCommit.ts +17 -17
  44. package/src/hooks/useDomGeometryCommits.test.tsx +56 -1
  45. package/src/hooks/useDomGeometryCommits.ts +28 -36
  46. package/src/hooks/useGsapAwareEditing.test.tsx +194 -11
  47. package/src/hooks/useGsapAwareEditing.ts +43 -42
  48. package/src/hooks/useInlineTextEdit.test.tsx +33 -3
  49. package/src/hooks/useInlineTextEdit.ts +2 -2
@@ -0,0 +1,145 @@
1
+ import { readCssRotation } from "../../hooks/draggedGsapPosition";
2
+ import { roundTo3 } from "../../utils/rounding";
3
+ import type { PatchOperation } from "../../utils/sourcePatcher";
4
+ import {
5
+ readStudioRotation,
6
+ restoreStudioRotation,
7
+ type StudioRotationSnapshot,
8
+ } from "./manualEdits";
9
+ import { getOffsetDragGsap } from "./manualOffsetDrag";
10
+ import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
11
+
12
+ function lastRuleTransform(element: HTMLElement): string {
13
+ let value = "";
14
+ for (const sheet of Array.from(element.ownerDocument.styleSheets)) {
15
+ let rules: CSSStyleRule[] = [];
16
+ try {
17
+ rules = Array.from(sheet.cssRules) as CSSStyleRule[];
18
+ } catch {
19
+ // a cross-origin sheet
20
+ }
21
+ for (const rule of rules) {
22
+ const declared = rule.style?.getPropertyValue("transform");
23
+ if (declared && element.matches(rule.selectorText)) value = declared;
24
+ }
25
+ }
26
+ return value;
27
+ }
28
+
29
+ // ponytail: the authored transform if it moves the box, from the last matching rule in sheet order;
30
+ // specificity, !important and @media are not weighed. Weigh them when a film's rule is missed.
31
+ function translatingTransform(element: HTMLElement): string {
32
+ const view = element.ownerDocument.defaultView;
33
+ const computed = view?.getComputedStyle(element).transform ?? "none";
34
+ const m = computed === "none" ? null : new view!.DOMMatrix(computed);
35
+ if (!m || (m.m41 === 0 && m.m42 === 0)) return "";
36
+ const value = element.style.getPropertyValue("transform") || lastRuleTransform(element);
37
+ return value === "none" ? "" : value;
38
+ }
39
+
40
+ /** Where a plain rotate draws its turn, read once at press: the element's own `rotate`, or, when its
41
+ * transform translates it (often the translate(-50%, -50%) centring), a rotate() right after that
42
+ * translate, so the box turns in place in screen space. `share` is what the rest already turns. */
43
+ export interface CssRotationTarget {
44
+ property: "rotate" | "transform";
45
+ before: string;
46
+ after: string;
47
+ share: number;
48
+ /** -1 when a mirroring `scale` property flips a turn made inside the transform. */
49
+ sign: number;
50
+ /** An inline box does not transform, so the turn makes it inline-block. */
51
+ inline: boolean;
52
+ }
53
+
54
+ export type RotationCommit = { angle: number; plain?: CssRotationTarget };
55
+
56
+ const TRANSLATE = /^translate(?:3d|X|Y|Z)?\(/i;
57
+ const OWN_TURN = /^rotate\(\s*-?[\d.]+(?:e[+-]?\d+)?deg\s*\)$/i;
58
+
59
+ export function readCssRotationTarget(element: HTMLElement): CssRotationTarget {
60
+ const style = element.ownerDocument.defaultView?.getComputedStyle(element);
61
+ const inline = style?.display === "inline";
62
+ const transform = translatingTransform(element);
63
+ if (!transform) {
64
+ const share = readCssRotation(element, false);
65
+ return { property: "rotate", before: "", after: "", share, sign: 1, inline };
66
+ }
67
+ const parts = splitTopLevelWhitespace(transform);
68
+ const lead = parts.findIndex((part) => !TRANSLATE.test(part));
69
+ const split = lead < 0 ? parts.length : lead;
70
+ const rest = parts.slice(split);
71
+ if (OWN_TURN.test(rest[0] ?? "")) rest.shift();
72
+ const [before, after] = [parts.slice(0, split).join(" "), rest.join(" ")];
73
+ const [sx = 1, sy = sx] = splitTopLevelWhitespace(style?.getPropertyValue("scale") ?? "").map(
74
+ Number.parseFloat,
75
+ );
76
+ const inlineStyle = element.style;
77
+ const saved = [
78
+ inlineStyle.getPropertyValue("transform"),
79
+ inlineStyle.getPropertyPriority("transform"),
80
+ ];
81
+ inlineStyle.setProperty("transform", `${before} ${after}`.trim() || "none");
82
+ const share = readCssRotation(element);
83
+ inlineStyle.setProperty("transform", saved[0] ?? "", saved[1] ?? "");
84
+ return { property: "transform", before, after, share, sign: sx * sy < 0 ? -1 : 1, inline };
85
+ }
86
+
87
+ /** Draws `angle` where `target` says, and returns the source patches that save it as drawn. */
88
+ export function applyCssRotation(
89
+ element: HTMLElement,
90
+ angle: number,
91
+ target = readCssRotationTarget(element),
92
+ ): Array<PatchOperation & { value: string }> {
93
+ const turn = `${roundTo3(target.sign * (angle - target.share))}deg`;
94
+ const value =
95
+ target.property === "rotate"
96
+ ? turn
97
+ : [target.before, `rotate(${turn})`, target.after].filter(Boolean).join(" ");
98
+ const patches: Array<PatchOperation & { value: string }> = [
99
+ { type: "inline-style", property: target.property, value },
100
+ ];
101
+ if (target.inline) {
102
+ patches.unshift({ type: "inline-style", property: "display", value: "inline-block" });
103
+ }
104
+ for (const patch of patches) element.style.setProperty(patch.property, patch.value);
105
+ return patches;
106
+ }
107
+
108
+ /** Back to the press: the rotation snapshot and, for a plain rotate, the inline transform it drew in. */
109
+ export function restorePlainRotation(element: HTMLElement, snapshot: StudioRotationSnapshot): void {
110
+ restoreStudioRotation(element, snapshot);
111
+ element.style.setProperty("transform", snapshot.transform);
112
+ element.style.setProperty("display", snapshot.display);
113
+ }
114
+
115
+ // `plain`, read at press: where a turn GSAP does not own draws; null for GSAP's rotation.
116
+ export function applyRotationDraft(
117
+ element: HTMLElement,
118
+ angle: number,
119
+ plain: CssRotationTarget | null,
120
+ ): void {
121
+ if (plain) return void applyCssRotation(element, angle, plain);
122
+ element.style.setProperty("rotate", "none");
123
+ getOffsetDragGsap(element)?.set(element, { rotation: angle });
124
+ }
125
+
126
+ /** Back to the gesture start: the CSS snapshot, and GSAP's rotation without the legacy var. */
127
+ export function restoreRotationDraft(
128
+ element: HTMLElement,
129
+ angle: number,
130
+ snapshot: StudioRotationSnapshot,
131
+ plain: boolean,
132
+ ): void {
133
+ if (plain) return restorePlainRotation(element, snapshot);
134
+ getOffsetDragGsap(element)?.set(element, {
135
+ rotation: angle - (Number.parseFloat(snapshot.studioRotation) || 0),
136
+ });
137
+ restoreStudioRotation(element, snapshot);
138
+ }
139
+
140
+ /** The angle a rotate gesture starts from, as the element shows it. */
141
+ export function readRotationBase(element: HTMLElement, plain: boolean): number {
142
+ if (plain) return readCssRotation(element);
143
+ const gsapRotation = Number(getOffsetDragGsap(element)?.getProperty(element, "rotation") ?? 0);
144
+ return gsapRotation + readStudioRotation(element).angle;
145
+ }
@@ -227,3 +227,36 @@ describe("useDomEditNudge — selection cleanup keyed on stable identity", () =>
227
227
  elementB.remove();
228
228
  });
229
229
  });
230
+
231
+ describe("useDomEditNudge carries the route its press chose", () => {
232
+ beforeEach(() => {
233
+ vi.useFakeTimers();
234
+ __resetForTests();
235
+ });
236
+ afterEach(() => vi.useRealTimers());
237
+
238
+ it("commits an element without GSAP on the CSS route", () => {
239
+ const host = document.createElement("div");
240
+ const root = createRoot(host);
241
+ const element = document.createElement("div");
242
+ document.body.append(element);
243
+ const commit = vi.fn();
244
+ act(() => {
245
+ root.render(
246
+ React.createElement(Harness, {
247
+ selection: makeSelection("Dot", element),
248
+ onPathOffsetCommit: commit,
249
+ }),
250
+ );
251
+ });
252
+ act(() => dispatchArrowRight());
253
+ act(() => vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10));
254
+ expect(commit).toHaveBeenCalledWith(
255
+ expect.anything(),
256
+ expect.anything(),
257
+ expect.objectContaining({ plainTranslate: true }),
258
+ );
259
+ act(() => root.unmount());
260
+ element.remove();
261
+ });
262
+ });
@@ -18,6 +18,7 @@ import {
18
18
  import type {
19
19
  BlockedMoveState,
20
20
  DomEditGroupPathOffsetCommit,
21
+ MoveCommitOptions,
21
22
  GestureState,
22
23
  GroupGestureState,
23
24
  } from "./domEditOverlayGestures";
@@ -60,7 +61,7 @@ export interface UseDomEditNudgeParams {
60
61
  (
61
62
  s: DomEditSelection,
62
63
  n: { x: number; y: number },
63
- m?: { altKey?: boolean },
64
+ m?: MoveCommitOptions,
64
65
  ) => Promise<unknown> | void
65
66
  >;
66
67
  onGroupPathOffsetCommitRef: RefObject<
@@ -150,11 +151,14 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
150
151
  const updates: DomEditGroupPathOffsetCommit[] = session.members.map((member) => ({
151
152
  selection: member.selection,
152
153
  next: applyManualOffsetNudgeCommit(member, session.accum),
154
+ plainTranslate: member.plainTranslate,
153
155
  }));
154
156
  const p = paramsRef.current;
155
157
  const commit = session.isGroup
156
158
  ? p.onGroupPathOffsetCommitRef.current(updates)
157
- : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next);
159
+ : p.onPathOffsetCommitRef.current(updates[0].selection, updates[0].next, {
160
+ plainTranslate: updates[0].plainTranslate,
161
+ });
158
162
  void Promise.resolve(commit)
159
163
  .catch(() => {
160
164
  for (const member of session.members) {
@@ -9,21 +9,18 @@ import { type DomEditSelection } from "./domEditing";
9
9
  import {
10
10
  applyManualOffsetDragCommit,
11
11
  applyManualOffsetDragDraft,
12
- applyRotationDraftViaGsap,
13
12
  endManualOffsetDragMembers,
14
13
  restoreManualOffsetDragMembers,
15
14
  } from "./manualOffsetDrag";
15
+ import { applyRotationDraft, restoreRotationDraft } from "./rotationDraft";
16
16
  import {
17
17
  applyStudioBoxSize,
18
18
  applyStudioBoxSizeDraft,
19
- applyStudioRotation,
20
- applyStudioRotationDraft,
21
19
  endStudioManualEditGesture,
22
20
  isStudioManualEditGestureCurrent,
23
21
  readStudioBoxSize,
24
22
  restoreStudioBoxSize,
25
23
  restoreStudioPathOffset,
26
- restoreStudioRotation,
27
24
  } from "./manualEdits";
28
25
  import {
29
26
  type GroupOverlayItem,
@@ -166,9 +163,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
166
163
  actualAngle: g.actualRotation,
167
164
  snap: e.shiftKey,
168
165
  });
169
- if (!applyRotationDraftViaGsap(sel.element, rotated.angle)) {
170
- applyStudioRotationDraft(sel.element, rotated);
171
- }
166
+ applyRotationDraft(sel.element, rotated.angle, g.plainRotation);
172
167
  return;
173
168
  }
174
169
 
@@ -336,6 +331,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
336
331
  const updates = groupG.members.map((member) => ({
337
332
  selection: member.selection,
338
333
  next: applyManualOffsetDragCommit(member, dx, dy),
334
+ plainTranslate: member.plainTranslate,
339
335
  }));
340
336
  logDrag("drop", {
341
337
  pointer: `${Math.round(rawDx)},${Math.round(rawDy)}`,
@@ -421,24 +417,22 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
421
417
  actualAngle: g.actualRotation,
422
418
  snap: e.shiftKey,
423
419
  });
424
- const restoreRotation = () => {
425
- // Single source of truth: snap the GSAP rotation back to the gesture's base
426
- // angle; fall back to the legacy CSS-var restore when gsap is unavailable.
427
- if (!applyRotationDraftViaGsap(sel.element, g.actualRotation)) {
428
- restoreStudioRotation(sel.element, g.initialRotation);
429
- }
430
- };
420
+ const restoreRotation = () =>
421
+ restoreRotationDraft(
422
+ sel.element,
423
+ g.actualRotation,
424
+ g.initialRotation,
425
+ g.plainRotation !== null,
426
+ );
431
427
  if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
432
428
  restoreRotation();
433
429
  endStudioManualEditGesture(sel.element, g.manualEditDragToken);
434
430
  return;
435
431
  }
436
- // Keep the preview at the final angle through the GSAP channel (NOT the CSS var)
437
- // while the commit lands a `tl.set`/keyframe rotation on the timeline.
438
- if (!applyRotationDraftViaGsap(sel.element, finalRotation.angle)) {
439
- applyStudioRotation(sel.element, finalRotation);
440
- }
441
- void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
432
+ // Hold the final angle while the commit lands.
433
+ applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
434
+ const commit = g.plainRotation ? { ...finalRotation, plain: g.plainRotation } : finalRotation;
435
+ void Promise.resolve(opts.onRotationCommitRef.current(sel, commit))
442
436
  .catch((error) => {
443
437
  logGestureCommitFailure("rotate commit failed", error);
444
438
  if (
@@ -476,7 +470,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
476
470
  box.style.top = `${nextBoxTop}px`;
477
471
  }
478
472
  void Promise.resolve(
479
- opts.onPathOffsetCommitRef.current(sel, finalOffset, { altKey: e.altKey }),
473
+ opts.onPathOffsetCommitRef.current(sel, finalOffset, {
474
+ altKey: e.altKey,
475
+ plainTranslate: g.pathOffsetMember.plainTranslate,
476
+ }),
480
477
  )
481
478
  .catch(() => {
482
479
  if (
@@ -555,11 +552,12 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
555
552
  restoreGestureOverlayRect(g);
556
553
  }
557
554
  if (g?.mode === "rotation" && sel) {
558
- applyRotationDraftViaGsap(
555
+ restoreRotationDraft(
559
556
  sel.element,
560
- g.actualRotation - (Number.parseFloat(g.initialRotation.studioRotation) || 0),
557
+ g.actualRotation,
558
+ g.initialRotation,
559
+ g.plainRotation !== null,
561
560
  );
562
- restoreStudioRotation(sel.element, g.initialRotation);
563
561
  endStudioManualEditGesture(sel.element, g.manualEditDragToken);
564
562
  }
565
563
  opts.blockedMoveRef.current = null;
@@ -8,6 +8,34 @@ import { roundTo3 } from "../utils/rounding";
8
8
  * it without pulling the GSAP commit graph into its module scope.
9
9
  */
10
10
 
11
+ const cssValue = (style: CSSStyleDeclaration, prop: string) => {
12
+ const value = style.getPropertyValue(prop).trim();
13
+ return value === "none" ? "" : value;
14
+ };
15
+
16
+ // Without GSAP, the rotation GSAP will parse from the CSS `rotate`, `scale` and `transform`; with
17
+ // `withRotate` false, only the part `scale` and `transform` draw. GSAP folds them into one transform,
18
+ // and a list the browser rejects (e.g. `rotate: x 30deg`) leaves it only the plain transform.
19
+ export function readCssRotation(element: HTMLElement, withRotate = true): number {
20
+ const view = element.ownerDocument.defaultView;
21
+ if (!view) return 0;
22
+ const style = view.getComputedStyle(element);
23
+ const rotate = withRotate ? cssValue(style, "rotate") : "";
24
+ const scale = cssValue(style, "scale");
25
+ const transform = cssValue(style, "transform");
26
+ const angle = (list: string) => {
27
+ if (!list) return 0;
28
+ const m = new view.DOMMatrix(list);
29
+ return (Math.atan2(m.b, m.a) * 180) / Math.PI;
30
+ };
31
+ const folded = [rotate && `rotate(${rotate})`, scale && `scale(${scale.split(/\s+/).join(",")})`];
32
+ try {
33
+ return angle([...folded, transform].join(" ").trim());
34
+ } catch {
35
+ return angle(transform);
36
+ }
37
+ }
38
+
11
39
  /**
12
40
  * Translate a studio drag offset into absolute GSAP x/y, accounting for the
13
41
  * element's rotation and its drag-start base pose. Reads the drag-start
@@ -6,7 +6,11 @@ import {
6
6
  import { LAYER_REVEAL_PRIOR_POSITION_ATTR } from "../player/lib/timelineElementHelpers";
7
7
  import type { PatchOperation } from "../utils/sourcePatcher";
8
8
  import { translatePatch } from "../components/editor/plainTranslate";
9
- import { gsapWritesPosition } from "./gsapRuntimeKeyframes";
9
+ import { gsapHoldsTranslate } from "./gsapRuntimeKeyframes";
10
+ import { markStudioSaveErrorAlreadyToasted } from "../utils/studioSaveDiagnostics";
11
+
12
+ const GSAP_TOOK_OVER =
13
+ "The animation took over this layer's position during the move, so it was not saved.";
10
14
 
11
15
  const ELEMENT_OFFSET_REFUSED: Record<ElementOffsetRefusal, string> = {
12
16
  anchored: "This layer is anchored from its right or bottom edge. Move it in the Code tab.",
@@ -23,15 +27,29 @@ export interface ElementOffsetStagerDeps {
23
27
  readOnlyPreview?: boolean;
24
28
  }
25
29
 
30
+ function gsapOf(el: HTMLElement): { set: (t: Element, v: object) => void } | undefined {
31
+ return (el.ownerDocument.defaultView as { gsap?: { set: (t: Element, v: object) => void } })
32
+ ?.gsap;
33
+ }
34
+
26
35
  /** The drag draft moved GSAP's x/y; left/top carries the move now, so put them back. */
27
36
  function settleGsapDraftAtGestureStart(el: HTMLElement): void {
28
- const gsap = (el.ownerDocument.defaultView as { gsap?: { set: (t: Element, v: object) => void } })
29
- ?.gsap;
37
+ const gsap = gsapOf(el);
30
38
  const x = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "");
31
39
  const y = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "");
32
40
  if (gsap && Number.isFinite(x) && Number.isFinite(y)) gsap.set(el, { x, y });
33
41
  }
34
42
 
43
+ export function refuseGsapTakeover(
44
+ el: HTMLElement,
45
+ showToast: ElementOffsetStagerDeps["showToast"],
46
+ ) {
47
+ if (!gsapHoldsTranslate(el)) return;
48
+ gsapOf(el)?.set(el, { x: 0, y: 0, xPercent: 0, yPercent: 0 });
49
+ showToast(GSAP_TOOK_OVER, "error");
50
+ throw markStudioSaveErrorAlreadyToasted(new Error(GSAP_TOOK_OVER));
51
+ }
52
+
35
53
  let plainMoveCounter = 0;
36
54
 
37
55
  /** GSAP does not position the element: `next` is its whole translate, live now and saved as drawn. */
@@ -58,17 +76,19 @@ function stagePlainTranslate(
58
76
  return { save, rollback };
59
77
  }
60
78
 
61
- /** Applies a move on the element itself live now: its translate when GSAP does not position it,
62
- * else left/top for a shared-tween element. Throws, after a toast, when it cannot. */
79
+ /** Applies a move on the element itself live now: its translate on the plain route, else
80
+ * left/top for a shared-tween element. Throws, after a toast, when it cannot. */
63
81
  export function stageElementOffset(
64
82
  { commitPositionPatchToHtml, showToast, readOnlyPreview }: ElementOffsetStagerDeps,
65
83
  selection: DomEditSelection,
66
84
  next: { x: number; y: number },
85
+ plainTranslate: boolean,
67
86
  coalesceKey?: string,
68
87
  ): { save: () => Promise<void>; rollback: () => void } {
69
88
  const el = selection.element;
70
89
  if (readOnlyPreview) return { save: () => Promise.resolve(), rollback: () => undefined };
71
- if (!gsapWritesPosition(el)) {
90
+ if (plainTranslate) {
91
+ refuseGsapTakeover(el, showToast);
72
92
  return stagePlainTranslate(commitPositionPatchToHtml, selection, next, coalesceKey);
73
93
  }
74
94
  const previous = { position: el.style.position, left: el.style.left, top: el.style.top };
@@ -35,7 +35,11 @@ import {
35
35
  pickClosestToPlayhead,
36
36
  readGsapPositionFromIframe,
37
37
  } from "./gsapPositionDetection";
38
- import { hasNonHoldTweenForElement, POSITION_CHANNELS } from "./gsapRuntimeKeyframes";
38
+ import {
39
+ hasNonHoldTweenForElement,
40
+ POSITION_CHANNELS,
41
+ ROTATION_CHANNELS,
42
+ } from "./gsapRuntimeKeyframes";
39
43
  import { getAnimationsForElement } from "./gsapElementMatch";
40
44
  import {
41
45
  animationWritesAnyProperty,
@@ -44,8 +48,6 @@ import {
44
48
  } from "./gsapEditOutcome";
45
49
 
46
50
  const POSITION_CHANNEL_SET = new Set<string>(POSITION_CHANNELS);
47
-
48
- const ROTATION_CHANNELS: string[] = ["rotation", "rotationX", "rotationY", "rotationZ"];
49
51
  const ROTATION_CHANNEL_SET = new Set<string>(ROTATION_CHANNELS);
50
52
 
51
53
  // ── Property-group tween resolution ───────────────────────────────────────
@@ -11,6 +11,7 @@
11
11
  import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
12
12
  import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
13
13
  import { roundTo3 } from "../utils/rounding";
14
+ import { BOX_SIZE_STYLE_PROPS } from "../components/editor/manualEditsDomPatches";
14
15
 
15
16
  /**
16
17
  * A GSAP tween's `vars` object — intentionally open: it mixes channel values
@@ -439,11 +440,44 @@ function gsapWritesChannels(el: Element, channels: string[]): boolean {
439
440
  );
440
441
  }
441
442
 
443
+ // GSAP's CSSPlugin also takes rotate, rotateX/Y/Z for rotation.
444
+ export const ROTATION_CHANNELS: string[] = [
445
+ ...["rotation", "rotationX", "rotationY", "rotationZ"],
446
+ ...["rotate", "rotateX", "rotateY", "rotateZ"],
447
+ ];
448
+
449
+ const gsapRendersTransform = (el: Element) =>
450
+ !!(el as { _gsap?: { renderTransform?: unknown } })._gsap?.renderTransform;
451
+
442
452
  /** GSAP owns this element's position: a tween or hold writes it, or GSAP already renders its
443
453
  * transform (a CSS translate would then apply twice). Everything else moves by plain CSS. */
444
454
  export function gsapWritesPosition(el: Element): boolean {
445
- const cache = (el as { _gsap?: { renderTransform?: unknown } })._gsap;
446
- return !!cache?.renderTransform || gsapWritesChannels(el, MOVE_CHANNELS);
455
+ return gsapRendersTransform(el) || gsapWritesChannels(el, MOVE_CHANNELS);
456
+ }
457
+
458
+ /** `gsapWritesPosition` for a rotate: everything else turns by its own CSS `rotate`. */
459
+ export function gsapWritesRotation(el: Element): boolean {
460
+ return gsapRendersTransform(el) || gsapWritesChannels(el, ROTATION_CHANNELS);
461
+ }
462
+
463
+ export function gsapHoldsTranslate(el: Element): boolean {
464
+ const cache = (el as { _gsap?: Record<string, unknown> })._gsap;
465
+ return ["x", "y", "xPercent", "yPercent"].some(
466
+ (key) => !!Number.parseFloat(String(cache?.[key])),
467
+ );
468
+ }
469
+
470
+ const BOX_CHANNELS = [
471
+ ...BOX_SIZE_STYLE_PROPS.map((prop) =>
472
+ prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()),
473
+ ),
474
+ "scaleX",
475
+ "scaleY",
476
+ ];
477
+
478
+ /** GSAP owns this element's box: its position, or any property the CSS box writer sets. Else a resize writes CSS. */
479
+ export function gsapWritesBox(el: Element): boolean {
480
+ return gsapWritesPosition(el) || gsapWritesChannels(el, BOX_CHANNELS);
447
481
  }
448
482
 
449
483
  /** `hasNonHoldTweenForElement` for an element in hand, read from its own window's timelines. */
@@ -0,0 +1,122 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import type { DomEditSelection } from "../components/editor/domEditing";
5
+ import {
6
+ applyStudioRotation,
7
+ reapplyPositionEditsAfterSeek,
8
+ } from "../components/editor/manualEdits";
9
+ import { STUDIO_ROTATION_ATTR } from "../components/editor/manualEditsTypes";
10
+ import type { ElementOffsetStagerDeps } from "./elementOffsetStager";
11
+ import { savePlainRotation } from "./plainRotation";
12
+ import { readShownRotation } from "../components/editor/plainTranslate";
13
+
14
+ function rotate(
15
+ element: HTMLElement,
16
+ angle: number,
17
+ options: { save?: () => Promise<void>; readOnlyPreview?: boolean } = {},
18
+ ) {
19
+ element.id = "box";
20
+ document.body.append(element);
21
+ const commitPositionPatchToHtml = vi.fn<ElementOffsetStagerDeps["commitPositionPatchToHtml"]>(
22
+ options.save ?? (() => Promise.resolve()),
23
+ );
24
+ const selection = { element, id: "box", selector: "#box" } as unknown as DomEditSelection;
25
+ const saved = savePlainRotation(
26
+ { commitPositionPatchToHtml, readOnlyPreview: options.readOnlyPreview },
27
+ selection,
28
+ { angle },
29
+ );
30
+ return { saved, commitPositionPatchToHtml, selection };
31
+ }
32
+
33
+ describe("savePlainRotation", () => {
34
+ it("saves the element's own rotate, less what its transform turns, with no Studio marks", async () => {
35
+ const element = document.createElement("div");
36
+ element.style.transform = "rotate(10deg)";
37
+ const { saved, commitPositionPatchToHtml, selection } = rotate(element, 55);
38
+ await saved;
39
+ expect(element.style.getPropertyValue("rotate")).toBe("45deg");
40
+ expect(commitPositionPatchToHtml).toHaveBeenCalledWith(
41
+ selection,
42
+ [{ type: "inline-style", property: "rotate", value: "45deg" }],
43
+ expect.objectContaining({ label: "Rotate layer" }),
44
+ );
45
+ expect(element.hasAttribute(STUDIO_ROTATION_ATTR)).toBe(false);
46
+ });
47
+
48
+ it("drops a legacy Studio rotation in the same write, so a seek does not put its angle back", async () => {
49
+ const element = document.createElement("div");
50
+ applyStudioRotation(element, { angle: 15 });
51
+ const { saved, commitPositionPatchToHtml } = rotate(element, 40);
52
+ await saved;
53
+ const patches = commitPositionPatchToHtml.mock.calls[0]![1];
54
+ expect(patches).toContainEqual({
55
+ type: "attribute",
56
+ property: STUDIO_ROTATION_ATTR,
57
+ value: null,
58
+ });
59
+ expect(patches.at(-1)).toEqual({ type: "inline-style", property: "rotate", value: "40deg" });
60
+ reapplyPositionEditsAfterSeek(document);
61
+ expect(element.style.getPropertyValue("rotate")).toBe("40deg");
62
+ });
63
+
64
+ it("puts the live rotate back when the save fails", async () => {
65
+ const element = document.createElement("div");
66
+ element.style.setProperty("rotate", "12deg");
67
+ const { saved } = rotate(element, 30, { save: () => Promise.reject(new Error("offline")) });
68
+ await expect(saved).rejects.toThrow("offline");
69
+ expect(element.style.getPropertyValue("rotate")).toBe("12deg");
70
+ });
71
+
72
+ it("writes nothing in a read-only preview", async () => {
73
+ const element = document.createElement("div");
74
+ const { saved, commitPositionPatchToHtml } = rotate(element, 30, { readOnlyPreview: true });
75
+ await saved;
76
+ expect(element.style.getPropertyValue("rotate")).toBe("");
77
+ expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it("saves a transform-centred element's turn in its transform and puts it back on a failed save", async () => {
81
+ const element = document.createElement("div");
82
+ element.style.transform = "translate(-120px, -80px)";
83
+ const { saved, commitPositionPatchToHtml } = rotate(element, 25, {
84
+ save: () => Promise.reject(new Error("offline")),
85
+ });
86
+ expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([
87
+ {
88
+ type: "inline-style",
89
+ property: "transform",
90
+ value: "translate(-120px, -80px) rotate(25deg)",
91
+ },
92
+ ]);
93
+ await expect(saved).rejects.toThrow("offline");
94
+ expect(element.style.getPropertyValue("transform")).toBe("translate(-120px, -80px)");
95
+ });
96
+
97
+ it("makes an inline element inline-block, as a turn needs, and puts it back on a failed save", async () => {
98
+ const element = document.createElement("span");
99
+ element.style.setProperty("display", "inline");
100
+ const { saved, commitPositionPatchToHtml } = rotate(element, 20, {
101
+ save: () => Promise.reject(new Error("offline")),
102
+ });
103
+ expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([
104
+ { type: "inline-style", property: "display", value: "inline-block" },
105
+ { type: "inline-style", property: "rotate", value: "20deg" },
106
+ ]);
107
+ await expect(saved).rejects.toThrow("offline");
108
+ expect(element.style.getPropertyValue("display")).toBe("inline");
109
+ });
110
+ });
111
+
112
+ describe("readShownRotation", () => {
113
+ it("shows an element's own CSS turn, and GSAP's legacy reading once GSAP turns it", () => {
114
+ const element = document.createElement("div");
115
+ document.body.append(element);
116
+ element.style.setProperty("rotate", "25deg");
117
+ expect(readShownRotation(element)).toEqual({ angle: 25 });
118
+ Object.assign(element, { _gsap: { renderTransform: () => {} } });
119
+ applyStudioRotation(element, { angle: 15 });
120
+ expect(readShownRotation(element)).toEqual({ angle: 15 });
121
+ });
122
+ });
@@ -0,0 +1,42 @@
1
+ import type { DomEditSelection } from "../components/editor/domEditing";
2
+ import { captureStudioRotation, clearStudioRotation } from "../components/editor/manualEdits";
3
+ import { buildClearRotationPatches } from "../components/editor/manualEditsDomPatches";
4
+ import { STUDIO_ROTATION_ATTR } from "../components/editor/manualEditsTypes";
5
+ import {
6
+ applyCssRotation,
7
+ restorePlainRotation,
8
+ type RotationCommit,
9
+ } from "../components/editor/rotationDraft";
10
+ import type { PatchOperation } from "../utils/sourcePatcher";
11
+ import type { ElementOffsetStagerDeps } from "./elementOffsetStager";
12
+
13
+ let plainRotateCounter = 0;
14
+
15
+ /** GSAP does not turn the element: `next` is its whole angle, drawn and saved as its CSS turn. */
16
+ export function savePlainRotation(
17
+ { commitPositionPatchToHtml, readOnlyPreview }: Omit<ElementOffsetStagerDeps, "showToast">,
18
+ selection: DomEditSelection,
19
+ next: RotationCommit,
20
+ ): Promise<void> {
21
+ if (readOnlyPreview) return Promise.resolve();
22
+ const { element } = selection;
23
+ const before = captureStudioRotation(element);
24
+ // Legacy marks go in the same write, or a seek puts their angle back; clearing them voids the press read.
25
+ const patches: PatchOperation[] = element.hasAttribute(STUDIO_ROTATION_ATTR)
26
+ ? buildClearRotationPatches(element)
27
+ : [];
28
+ if (patches.length) clearStudioRotation(element);
29
+ const drawn = applyCssRotation(element, next.angle, patches.length ? undefined : next.plain);
30
+ patches.push(...drawn);
31
+ const turn = drawn.at(-1)!;
32
+ return commitPositionPatchToHtml(selection, patches, {
33
+ label: "Rotate layer",
34
+ coalesceKey: `rotate:${++plainRotateCounter}`,
35
+ coalesceMs: Number.POSITIVE_INFINITY,
36
+ }).catch((error) => {
37
+ if (element.style.getPropertyValue(turn.property) === turn.value) {
38
+ restorePlainRotation(element, before);
39
+ }
40
+ throw error;
41
+ });
42
+ }