@hyperframes/studio 0.7.104 → 0.7.106

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.104",
3
+ "version": "0.7.106",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,17 +47,18 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/core": "0.7.104",
51
- "@hyperframes/parsers": "0.7.104",
52
- "@hyperframes/player": "0.7.104",
53
- "@hyperframes/studio-server": "0.7.104",
54
- "@hyperframes/sdk": "0.7.104"
50
+ "@hyperframes/parsers": "0.7.106",
51
+ "@hyperframes/studio-server": "0.7.106",
52
+ "@hyperframes/core": "0.7.106",
53
+ "@hyperframes/player": "0.7.106",
54
+ "@hyperframes/sdk": "0.7.106"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
58
58
  "@types/react-dom": "19",
59
59
  "@vitejs/plugin-react": "^4.0.0",
60
60
  "autoprefixer": "^10.4.0",
61
+ "chokidar": "^4.0.3",
61
62
  "fake-indexeddb": "^6.2.5",
62
63
  "postcss": "^8.4.0",
63
64
  "puppeteer-core": "^25.2.1",
@@ -67,7 +68,7 @@
67
68
  "vite": "^6.4.2",
68
69
  "vitest": "^3.2.4",
69
70
  "zustand": "^5.0.0",
70
- "@hyperframes/producer": "0.7.104"
71
+ "@hyperframes/producer": "0.7.106"
71
72
  },
72
73
  "peerDependencies": {
73
74
  "react": "19",
@@ -8,6 +8,29 @@ import { DomEditSelectionChrome } from "./DomEditSelectionChrome";
8
8
 
9
9
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
10
 
11
+ /** A selection whose capabilities are all on or all off, plus a host to render into. */
12
+ function selectionFixture(
13
+ element: HTMLElement,
14
+ selector: string,
15
+ enabled: boolean,
16
+ extra: Record<string, unknown> = {},
17
+ ) {
18
+ const selection = {
19
+ element,
20
+ selector,
21
+ ...extra,
22
+ capabilities: {
23
+ canCrop: enabled,
24
+ canApplyManualOffset: enabled,
25
+ canApplyManualSize: enabled,
26
+ canApplyManualRotation: enabled,
27
+ },
28
+ } as unknown as DomEditSelection;
29
+ const host = document.createElement("div");
30
+ document.body.append(host);
31
+ return { selection, host, root: createRoot(host) };
32
+ }
33
+
11
34
  describe("DomEditSelectionChrome crop composition", () => {
12
35
  it("renders overlay-only transparent chrome at headline geometry without changing composition bytes", () => {
13
36
  const composition = document.implementation.createHTMLDocument();
@@ -18,19 +41,7 @@ describe("DomEditSelectionChrome crop composition", () => {
18
41
  `;
19
42
  const headline = composition.querySelector<HTMLElement>(".hl-text")!;
20
43
  const before = composition.documentElement.outerHTML;
21
- const selection = {
22
- element: headline,
23
- selector: ".hl-text",
24
- capabilities: {
25
- canCrop: false,
26
- canApplyManualOffset: false,
27
- canApplyManualSize: false,
28
- canApplyManualRotation: false,
29
- },
30
- } as unknown as DomEditSelection;
31
- const host = document.createElement("div");
32
- document.body.append(host);
33
- const root = createRoot(host);
44
+ const { selection, host, root } = selectionFixture(headline, ".hl-text", false);
34
45
  act(() => {
35
46
  root.render(
36
47
  <DomEditSelectionChrome
@@ -69,24 +80,16 @@ describe("DomEditSelectionChrome crop composition", () => {
69
80
  offsetHeight: { value: 100 },
70
81
  });
71
82
  document.body.append(element);
72
- vi.spyOn(window, "getComputedStyle").mockReturnValue({
73
- clipPath: "inset(10px)",
74
- transform: "matrix(0.8660254, 0.5, -0.5, 0.8660254, 0, 0)",
75
- } as CSSStyleDeclaration);
76
- const selection = {
77
- element,
78
- id: "clip",
79
- selector: "#clip",
80
- capabilities: {
81
- canCrop: true,
82
- canApplyManualOffset: true,
83
- canApplyManualSize: true,
84
- canApplyManualRotation: true,
85
- },
86
- } as unknown as DomEditSelection;
87
- const host = document.createElement("div");
88
- document.body.append(host);
89
- const root = createRoot(host);
83
+ // Per element, not blanket: the crop frame composes the element's transform
84
+ // with its ancestors', so answering "rotated 30deg" for every node in the
85
+ // document would have the frame read the same turn several times over.
86
+ vi.spyOn(window, "getComputedStyle").mockImplementation(
87
+ ((node: Element) =>
88
+ (node === element
89
+ ? { clipPath: "inset(10px)", transform: "matrix(0.8660254, 0.5, -0.5, 0.8660254, 0, 0)" }
90
+ : { clipPath: "none", transform: "none" }) as CSSStyleDeclaration) as never,
91
+ );
92
+ const { selection, host, root } = selectionFixture(element, "#clip", true, { id: "clip" });
90
93
  act(() => {
91
94
  root.render(
92
95
  <DomEditSelectionChrome
@@ -8,6 +8,7 @@ import {
8
8
  resolveCropInsetFromMoveDrag,
9
9
  rotateDeltaIntoFrame,
10
10
  } from "./domEditOverlayCrop";
11
+ import { individualRotateDegrees } from "./domEditOverlayTransform";
11
12
 
12
13
  describe("resolveCropInsetFromEdgeDrag", () => {
13
14
  const startInsets = { top: 10, right: 20, bottom: 30, left: 40 };
@@ -160,10 +161,15 @@ describe("readElementCropInsets tri-state", () => {
160
161
  describe("readElementCropFrame", () => {
161
162
  const overlayRect = { left: 100, top: 50, width: 220, height: 130, editScaleX: 1, editScaleY: 1 };
162
163
 
164
+ // Models an element well enough for the ancestor walk: it reports its own
165
+ // transform, claims no composition-root attribute, and has no parent, so the
166
+ // walk composes exactly one node.
163
167
  const fakeEl = (transform: string, offsetWidth = 200, offsetHeight = 100) =>
164
168
  ({
165
169
  offsetWidth,
166
170
  offsetHeight,
171
+ parentElement: null,
172
+ hasAttribute: () => false,
167
173
  ownerDocument: { defaultView: { getComputedStyle: () => ({ transform }) } },
168
174
  }) as unknown as HTMLElement;
169
175
 
@@ -205,9 +211,43 @@ describe("readElementCropFrame", () => {
205
211
  expect(frame.height).toBeCloseTo(200, 3);
206
212
  });
207
213
 
208
- it("3D transform falls back to the axis-aligned frame", () => {
214
+ /**
215
+ * A 3D matrix is not automatically unmeasurable. GSAP writes one for an
216
+ * ordinary 2D move or spin (force3D), so refusing every `matrix3d` drew the
217
+ * crop outline square on elements the rest of the chrome drew rotated.
218
+ * The identity here is a 2D transform written the long way.
219
+ */
220
+ it("reads a planar matrix3d rather than giving up on it", () => {
221
+ // scale(1.5, 2) written the long way — planar, and not the identity.
209
222
  const frame = readElementCropFrame(
210
- fakeEl("matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1)"),
223
+ fakeEl("matrix3d(1.5,0,0,0,0,2,0,0,0,0,1,0,0,0,0,1)"),
224
+ overlayRect,
225
+ );
226
+ expect(frame.scaleX).toBeCloseTo(1.5, 3);
227
+ expect(frame.scaleY).toBeCloseTo(2, 3);
228
+ });
229
+
230
+ it("reads a 2D rotation written as matrix3d", () => {
231
+ const frame = readElementCropFrame(
232
+ fakeEl("matrix3d(0.866025,0.5,0,0,-0.5,0.866025,0,0,0,0,1,0,0,0,0,1)"),
233
+ overlayRect,
234
+ );
235
+ expect(frame.angleDeg).toBeCloseTo(30, 3);
236
+ });
237
+
238
+ it("reads a flipped element, which still has a real size", () => {
239
+ // Negative z scale — a composition that mirrors an element writes this.
240
+ const frame = readElementCropFrame(
241
+ fakeEl("matrix3d(-0.866025,-0.5,0,0,-0.5,0.866025,0,0,0,0,-1,0,0,0,0,1)"),
242
+ overlayRect,
243
+ );
244
+ expect(frame.width).toBeGreaterThan(0);
245
+ expect(frame.height).toBeGreaterThan(0);
246
+ });
247
+
248
+ it("still falls back on a perspective transform, which no single angle describes", () => {
249
+ const frame = readElementCropFrame(
250
+ fakeEl("matrix3d(1,0,0,0.002,0,1,0,0,0,0,1,0,0,0,0,1)"),
211
251
  overlayRect,
212
252
  );
213
253
  expect(frame).toEqual({
@@ -241,3 +281,97 @@ describe("rotateDeltaIntoFrame", () => {
241
281
  expect(back.deltaY).toBeCloseTo(-3, 6);
242
282
  });
243
283
  });
284
+
285
+ describe("individualRotateDegrees", () => {
286
+ /**
287
+ * Studio's rotate handle writes the CSS `rotate` property, not `transform`.
288
+ * Everything that measured an element's angle read `transform` alone, so a
289
+ * turned element reported upright and the selection box, crop outline and
290
+ * child outlines all drew square across it.
291
+ */
292
+ it("reads a plain angle", () => {
293
+ expect(individualRotateDegrees("-22deg")).toBeCloseTo(-22, 6);
294
+ expect(individualRotateDegrees("45deg")).toBeCloseTo(45, 6);
295
+ });
296
+
297
+ it("reads an explicit z-axis rotation, honouring the axis sign", () => {
298
+ expect(individualRotateDegrees("0 0 1 30deg")).toBeCloseTo(30, 6);
299
+ expect(individualRotateDegrees("0 0 -1 30deg")).toBeCloseTo(-30, 6);
300
+ });
301
+
302
+ it("reports nothing for a rotation that leaves the overlay's plane", () => {
303
+ // A 3D turn has no single in-plane angle. Reporting one would draw the
304
+ // chrome at a plausible-looking wrong angle instead of falling back square.
305
+ expect(individualRotateDegrees("1 0 0 45deg")).toBe(0);
306
+ expect(individualRotateDegrees("0 1 0 45deg")).toBe(0);
307
+ });
308
+
309
+ it("reports nothing when the property is absent or unparseable", () => {
310
+ expect(individualRotateDegrees("none")).toBe(0);
311
+ expect(individualRotateDegrees(undefined)).toBe(0);
312
+ expect(individualRotateDegrees("")).toBe(0);
313
+ expect(individualRotateDegrees("12")).toBe(0);
314
+ });
315
+ });
316
+
317
+ describe("readElementCropFrame — the composed walk", () => {
318
+ /**
319
+ * The case the crop outline got wrong: a text layer inside a rotated card.
320
+ * The layer carries its own spin and its parent turns it again, so the box
321
+ * belongs at the combination. Reading the element alone drew it across the
322
+ * text at roughly a right angle.
323
+ */
324
+ const nested = (childTransform: string, parentTransform: string) => {
325
+ const style = (transform: string) => ({ transform }) as CSSStyleDeclaration;
326
+ const parent = {
327
+ offsetWidth: 400,
328
+ offsetHeight: 300,
329
+ parentElement: null,
330
+ hasAttribute: (name: string) => name === "data-composition-id",
331
+ ownerDocument: { defaultView: { getComputedStyle: () => style(parentTransform) } },
332
+ };
333
+ return {
334
+ offsetWidth: 200,
335
+ offsetHeight: 100,
336
+ parentElement: parent,
337
+ hasAttribute: () => false,
338
+ ownerDocument: {
339
+ defaultView: {
340
+ getComputedStyle: (node: unknown) =>
341
+ node === parent ? style(parentTransform) : style(childTransform),
342
+ },
343
+ },
344
+ } as unknown as HTMLElement;
345
+ };
346
+
347
+ const overlayRect = { left: 100, top: 50, width: 220, height: 130, editScaleX: 1, editScaleY: 1 };
348
+
349
+ it("adds the parent's rotation to the child's", () => {
350
+ // child 30deg inside a parent turned 60deg → the layer paints at 90.
351
+ const frame = readElementCropFrame(
352
+ nested(
353
+ "matrix(0.8660254, 0.5, -0.5, 0.8660254, 0, 0)",
354
+ "matrix(0.5, 0.8660254, -0.8660254, 0.5, 0, 0)",
355
+ ),
356
+ overlayRect,
357
+ );
358
+ expect(frame.angleDeg).toBeCloseTo(90, 3);
359
+ });
360
+
361
+ it("takes the parent's rotation when the child has none of its own", () => {
362
+ const frame = readElementCropFrame(
363
+ nested("none", "matrix(0.8660254, 0.5, -0.5, 0.8660254, 0, 0)"),
364
+ overlayRect,
365
+ );
366
+ expect(frame.angleDeg).toBeCloseTo(30, 3);
367
+ });
368
+
369
+ it("stops at the composition root rather than walking the whole document", () => {
370
+ // The root itself is marked, so its own transform is the last one counted.
371
+ const frame = readElementCropFrame(
372
+ nested("none", "matrix(0.8660254, 0.5, -0.5, 0.8660254, 0, 0)"),
373
+ overlayRect,
374
+ );
375
+ expect(frame.angleDeg).toBeCloseTo(30, 3);
376
+ });
377
+ });
@@ -1,3 +1,4 @@
1
+ import { composeElementTransform, type PlanarTransformOps } from "./domEditOverlayTransform";
1
2
  import { parseInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
2
3
 
3
4
  export type CropEdge = "top" | "right" | "bottom" | "left";
@@ -147,6 +148,96 @@ export interface CropFrame {
147
148
  scaleY: number;
148
149
  }
149
150
 
151
+ /**
152
+ * The element's own 2D transform as matrix components, plus the `rotate`
153
+ * property's angle.
154
+ *
155
+ * `rotate` is a separate CSS property, not part of `transform`, and it is the
156
+ * one Studio's rotate handle writes — reading `transform` alone reported a
157
+ * turned element as upright, so the crop outline drew square across it.
158
+ *
159
+ * Null means there is nothing planar to draw against: no transform and no
160
+ * rotation, or a 3D/unparseable matrix. The caller falls back to the
161
+ * axis-aligned box rather than guessing an angle.
162
+ */
163
+ const IDENTITY = { a: 1, b: 0, c: 0, d: 1 };
164
+
165
+ /** Perspective terms this far from zero mean the mapping is not affine. */
166
+ const PERSPECTIVE_EPSILON = 1e-6;
167
+
168
+ type Planar2D = { a: number; b: number; c: number; d: number };
169
+
170
+ /**
171
+ * The 2D components of a computed transform, or null when it cannot be used.
172
+ *
173
+ * Accepts `matrix3d` as well as `matrix`, taking the same 2D projection the
174
+ * rest of the overlay reads through DOMMatrix. GSAP writes a 3D matrix for an
175
+ * ordinary 2D move or spin (force3D), and a composition that flips an element
176
+ * writes one with a negative z scale — treating either as unmeasurable left the
177
+ * crop outline square on an element every other piece of chrome drew rotated.
178
+ *
179
+ * Only a perspective term rules the matrix out, because that is where the
180
+ * mapping stops being affine and a single angle stops describing it.
181
+ */
182
+ function parseMatrixComponents(transform: string): Planar2D | null {
183
+ const flat = /^matrix\(([^)]+)\)$/.exec(transform);
184
+ if (flat) {
185
+ const [a, b, c, d] = flat[1]!.split(",").map((v) => Number.parseFloat(v));
186
+ return [a, b, c, d].every(Number.isFinite) ? { a: a!, b: b!, c: c!, d: d! } : null;
187
+ }
188
+ const spatial = /^matrix3d\(([^)]+)\)$/.exec(transform);
189
+ if (!spatial) return null;
190
+ const m = spatial[1]!.split(",").map((v) => Number.parseFloat(v));
191
+ if (m.length !== 16 || !m.every(Number.isFinite)) return null;
192
+ const affine = [m[3], m[7], m[11]].every((v) => Math.abs(v!) < PERSPECTIVE_EPSILON);
193
+ if (!affine) return null;
194
+ return { a: m[0]!, b: m[1]!, c: m[4]!, d: m[5]! };
195
+ }
196
+
197
+ /** The crop frame only needs an angle and a scale, so it composes plain 2D components. */
198
+ const PLANAR_2D_OPS: PlanarTransformOps<Planar2D> = {
199
+ identity: () => IDENTITY,
200
+ fromTransform: parseMatrixComponents,
201
+ fromRotate: (degrees) => {
202
+ const rad = (degrees * Math.PI) / 180;
203
+ return { a: Math.cos(rad), b: Math.sin(rad), c: -Math.sin(rad), d: Math.cos(rad) };
204
+ },
205
+ compose: (outer, inner) => ({
206
+ a: outer.a * inner.a + outer.c * inner.b,
207
+ b: outer.b * inner.a + outer.d * inner.b,
208
+ c: outer.a * inner.c + outer.c * inner.d,
209
+ d: outer.b * inner.c + outer.d * inner.d,
210
+ }),
211
+ };
212
+
213
+ /** Whether the matrix leaves the box exactly as it found it. */
214
+ function isIdentity(m: Planar2D): boolean {
215
+ return (
216
+ Math.abs(m.a - 1) < PERSPECTIVE_EPSILON &&
217
+ Math.abs(m.b) < PERSPECTIVE_EPSILON &&
218
+ Math.abs(m.c) < PERSPECTIVE_EPSILON &&
219
+ Math.abs(m.d - 1) < PERSPECTIVE_EPSILON
220
+ );
221
+ }
222
+
223
+ /**
224
+ * The transform the element paints under, in 2D components.
225
+ *
226
+ * Null when nothing up the chain transforms it: the caller's axis-aligned rect
227
+ * already describes it, and that comes from real layout rather than the
228
+ * element's untransformed box.
229
+ */
230
+ function readPlanarTransform(element: HTMLElement): Planar2D | null {
231
+ const acc = composeElementTransform(element, PLANAR_2D_OPS, (node) => {
232
+ try {
233
+ return node.ownerDocument.defaultView?.getComputedStyle(node) ?? null;
234
+ } catch {
235
+ return null;
236
+ }
237
+ });
238
+ return acc && !isIdentity(acc) ? acc : null;
239
+ }
240
+
150
241
  export function readElementCropFrame(
151
242
  element: HTMLElement,
152
243
  overlayRect: CropScreenRect & { editScaleX: number; editScaleY: number },
@@ -162,22 +253,15 @@ export function readElementCropFrame(
162
253
  scaleX: editX,
163
254
  scaleY: editY,
164
255
  };
165
- let transform = "";
166
- try {
167
- transform = element.ownerDocument.defaultView?.getComputedStyle(element).transform ?? "";
168
- } catch {
169
- return aabb;
170
- }
171
- if (!transform || transform === "none") return aabb;
172
- const m = /^matrix\(([^)]+)\)$/.exec(transform);
173
- if (!m) return aabb; // matrix3d or unparseable → axis-aligned fallback
174
- const [a, b, c, d] = m[1]!.split(",").map((v) => Number.parseFloat(v));
175
- if (![a, b, c, d].every(Number.isFinite)) return aabb;
176
- const elScaleX = Math.hypot(a!, b!);
177
- const det = a! * d! - b! * c!;
178
- const elScaleY = elScaleX !== 0 ? det / elScaleX : 1;
256
+ const planar = readPlanarTransform(element);
257
+ if (!planar) return aabb;
258
+ const { a, b, c, d } = planar;
259
+ const elScaleX = Math.hypot(a, b);
260
+ const det = a * d - b * c;
261
+ // |det| : a flipped element (negative determinant) still has a real size.
262
+ const elScaleY = elScaleX !== 0 ? Math.abs(det) / elScaleX : 1;
179
263
  if (elScaleX <= 0 || elScaleY <= 0) return aabb;
180
- const angleDeg = (Math.atan2(b!, a!) * 180) / Math.PI;
264
+ const angleDeg = (Math.atan2(b, a) * 180) / Math.PI;
181
265
  const scaleX = elScaleX * editX;
182
266
  const scaleY = elScaleY * editY;
183
267
  const width = element.offsetWidth * scaleX;
@@ -165,6 +165,19 @@ describe("orientedOverlayRect — rotation gate (perf fix, V15 18a/18b)", () =>
165
165
  * a text layer inside a rotated card got an upright dashed box sitting across
166
166
  * the rotated glyphs — the parent's chrome rotated and its children's did not.
167
167
  */
168
+ /**
169
+ * The selection box and the crop outline compose the same ancestor walk, so
170
+ * this asserts the geometry side of the case the crop test covers: a child
171
+ * inside a rotated parent reports the angle it paints at, not its own.
172
+ */
173
+ it("composes the parent's rotation into the child's angle", () => {
174
+ const { overlayEl, iframe, el } = buildHarness();
175
+ el.parentElement!.style.transform = ROTATE_30DEG_MATRIX;
176
+ const rect = orientedOverlayRect(overlayEl, iframe, el);
177
+ expect(rect).not.toBeNull();
178
+ expect(rect!.angle).toBeCloseTo(30, 3);
179
+ });
180
+
168
181
  it("child outlines carry the element's angle, so they can co-rotate with it", () => {
169
182
  const { overlayEl, iframe, el } = buildHarness();
170
183
  el.style.transform = ROTATE_30DEG_MATRIX;
@@ -1,6 +1,7 @@
1
1
  import { type DomEditSelection, findElementForSelection } from "./domEditing";
2
2
  import { isElementVisibleThroughAncestors } from "./domEditingDom";
3
3
  import { hugRectForElement } from "./domEditOverlayCrop";
4
+ import { composeElementTransform, type PlanarTransformOps } from "./domEditOverlayTransform";
4
5
 
5
6
  export interface OverlayRect {
6
7
  left: number;
@@ -146,17 +147,19 @@ function readElementTransformSnapshot(
146
147
  const DOMMatrixCtor = (win as Window & typeof globalThis).DOMMatrix;
147
148
  if (!DOMMatrixCtor) return null;
148
149
  const cs = win.getComputedStyle(element);
150
+ // The corner math transforms points, so this algebra keeps the full matrix,
151
+ // translation included, where the crop frame's keeps only 2D components.
152
+ const ops: PlanarTransformOps<DOMMatrix> = {
153
+ identity: () => new DOMMatrixCtor(),
154
+ fromTransform: (value) => new DOMMatrixCtor(value),
155
+ fromRotate: (degrees) => new DOMMatrixCtor().rotateSelf(degrees),
156
+ compose: (outer, inner) => outer.multiply(inner),
157
+ };
149
158
  try {
150
- let matrix = new DOMMatrixCtor();
151
- for (let node: HTMLElement | null = element; node; node = node.parentElement) {
152
- const transform = node === element ? cs.transform : win.getComputedStyle(node).transform;
153
- if (transform && transform !== "none") {
154
- // An ancestor applies outside, so it multiplies on the left.
155
- matrix = new DOMMatrixCtor(transform).multiply(matrix);
156
- }
157
- if (node.hasAttribute("data-composition-id")) break;
158
- }
159
- return { matrix, cs };
159
+ const matrix = composeElementTransform(element, ops, (node) =>
160
+ node === element ? cs : win.getComputedStyle(node),
161
+ );
162
+ return matrix ? { matrix, cs } : null;
160
163
  } catch {
161
164
  return null;
162
165
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * One walk from an element up to its composition root, composing the transform
3
+ * it actually paints under.
4
+ *
5
+ * The overlay measures an element's angle in two places — the selection box and
6
+ * the crop frame — and they need different arithmetic: one works in DOMMatrix
7
+ * because it goes on to transform corner points, the other in plain 2D
8
+ * components because it only needs an angle and a scale. What they must never
9
+ * differ on is *which* transforms count and in what order, because when they
10
+ * disagree the chrome disagrees with itself: the selection box drawn at one
11
+ * angle and the crop outline at another, on the same element.
12
+ *
13
+ * So the walk lives here once and takes the arithmetic as a parameter. Adding
14
+ * an individual property CSS grew later — `translate`, `scale` — means adding
15
+ * one step here and one method to each algebra, rather than finding both walks
16
+ * and hoping.
17
+ */
18
+
19
+ /** The composition's own root; the walk stops there rather than at the document. */
20
+ const COMPOSITION_ROOT_ATTR = "data-composition-id";
21
+
22
+ /**
23
+ * The arithmetic the walk needs, whatever the caller's matrix type is.
24
+ *
25
+ * `fromTransform` returns null for a transform the caller cannot use — a
26
+ * perspective matrix, say — which aborts the walk rather than composing a
27
+ * matrix that describes something other than what is painted.
28
+ */
29
+ export interface PlanarTransformOps<M> {
30
+ identity(): M;
31
+ fromTransform(value: string): M | null;
32
+ fromRotate(degrees: number): M;
33
+ /** `outer` applied around `inner`, as an ancestor composes over a child. */
34
+ compose(outer: M, inner: M): M;
35
+ }
36
+
37
+ /**
38
+ * The planar rotation in the CSS `rotate` property, in degrees.
39
+ *
40
+ * Computes to `none`, an angle (`-22deg`), or an axis plus an angle
41
+ * (`0 0 1 -22deg`). Only a rotation about z stays in the overlay's plane; any
42
+ * other axis is 3D and reports 0, which leaves the caller on its axis-aligned
43
+ * fallback rather than drawing a box at a plausible-looking wrong angle.
44
+ */
45
+ export function individualRotateDegrees(value: string | undefined): number {
46
+ if (!value || value === "none") return 0;
47
+ const parts = value.trim().split(/\s+/);
48
+ const angle = parts.at(-1);
49
+ if (!angle?.endsWith("deg")) return 0;
50
+ if (parts.length === 4) {
51
+ const [x, y, z] = parts;
52
+ if (Number(x) !== 0 || Number(y) !== 0 || Math.abs(Number(z)) !== 1) return 0;
53
+ const deg = Number.parseFloat(angle);
54
+ return Number.isFinite(deg) ? deg * Math.sign(Number(z)) : 0;
55
+ }
56
+ if (parts.length !== 1) return 0;
57
+ const deg = Number.parseFloat(angle);
58
+ return Number.isFinite(deg) ? deg : 0;
59
+ }
60
+
61
+ /**
62
+ * The element's transform composed with every ancestor's, up to the composition
63
+ * root.
64
+ *
65
+ * Within a node, CSS applies the individual properties before `transform`, so
66
+ * `rotate` composes on the left of it. Between nodes, an ancestor applies
67
+ * outside its child. Null means some node's transform was unusable and the
68
+ * caller should fall back rather than guess.
69
+ */
70
+ export function composeElementTransform<M>(
71
+ element: HTMLElement,
72
+ ops: PlanarTransformOps<M>,
73
+ getStyle: (node: HTMLElement) => CSSStyleDeclaration | null,
74
+ ): M | null {
75
+ let acc = ops.identity();
76
+ for (let node: HTMLElement | null = element; node; node = node.parentElement) {
77
+ const style = getStyle(node);
78
+ if (!style) return null;
79
+ const transform = style.transform;
80
+ let own = transform && transform !== "none" ? ops.fromTransform(transform) : ops.identity();
81
+ if (!own) return null;
82
+ const spin = individualRotateDegrees(style.rotate);
83
+ if (spin !== 0) own = ops.compose(ops.fromRotate(spin), own);
84
+ acc = ops.compose(own, acc);
85
+ if (node.hasAttribute(COMPOSITION_ROOT_ATTR)) break;
86
+ }
87
+ return acc;
88
+ }