fix(studio): read the rotate property when measuring an element's angle (#3163)

* fix(studio): read the rotate property when measuring an element's angle

Turning an element with Studio's rotate handle left every piece of overlay
chrome square across it: the selection box, the crop outline and the child
outlines all drew upright while the element underneath was clearly rotated.

The handle writes the CSS `rotate` property. `rotate` is an individual
transform property, not part of `transform`, so `getComputedStyle(el).transform`
reports nothing for it and both places that measure an element's angle — the
overlay geometry and the crop frame — read the element as upright.

Both now read `rotate` alongside `transform` and compose them the way CSS
does, individual properties first. A rotation about any axis but z has no
single in-plane angle, so it reports nothing and the caller keeps its
axis-aligned fallback rather than drawing chrome at a plausible wrong angle.

* fix(studio): stop the crop outline refusing the transforms GSAP writes

The crop outline still drew square on a rotated element after the rotate-
property fix, because it refused the transform outright: it accepted only
`matrix(...)`, and GSAP writes `matrix3d(...)` for an ordinary 2D move or spin
(force3D). A composition that mirrors an element writes one with a negative z
scale, and the negative determinant that follows was refused too.

Both are ordinary planar transforms. The outline now reads the same 2D
projection the rest of the chrome takes through DOMMatrix, and sizes a
flipped element from the magnitude of its determinant. Only a perspective
term still falls back, because that is where the mapping stops being affine
and no single angle describes it.

The test that asserted "a 3D matrix means give up" asserted the bug: its
fixture was the identity written as matrix3d, which is as planar as a
transform gets. It now checks the behaviour that replaced it, alongside the
perspective case, which still falls back.

* fix(studio): draw the crop outline at the angle the element paints under

Selecting a text layer inside a rotated card drew its crop outline across the
text at roughly a right angle. The outline read the element's own transform,
but what the user sees is that composed with every ancestor's — the layer
carries its own spin and its parent turns it again.

It now walks to the composition root and composes each level, the element's
`rotate` property before its `transform` and an ancestor outside its child,
which is the order CSS applies them in. Nothing transformed anywhere still
falls back to the caller's axis-aligned rect, since that comes from real
layout and describes the element exactly.

The chrome test stubbed getComputedStyle to answer "rotated 30deg" for every
node in the document, so composing read the same turn once per ancestor. The
stub now answers per element, which is what it always meant.

* fix(studio): stop the dev server reloading the page on every canvas edit

A composition lives under this package's root, so Vite's HMR saw a write to
one as an html page dependency changing and full-reloaded the browser. That
reload is the flash after every edit in the canvas: the whole app remounts,
taking the preview iframe with it.

The decision was never Vite's to make. Studio already knows whether a write
was its own — that is what the write receipt is for — and refreshes the
preview itself when it needs to. Vite's watcher now ignores the project data,
and the dev plugin watches it on a watcher of its own, announcing changes as
hf:file-change exactly as before.

Measured on a drag: Vite full reloads went from one per edit to none, and the
receipt now reports 'suppressed: own write token' where it previously never
saw a matching path.

* refactor(studio): compose an element's transform in one walk, not two

Review: the crop frame hand-composed ancestor matrices while the geometry
file did the same walk through DOMMatrix. Both were right, but the next
individual transform property CSS grows — `translate`, `scale` — would have to
land in both, and a miss puts the crop outline back at the wrong angle while
the selection box draws the right one.

The walk now lives in one place and takes the arithmetic as a parameter. The
geometry file keeps DOMMatrix, because it goes on to transform corner points
and needs the translation; the crop frame keeps plain 2D components, because
it only needs an angle and a scale. Which transforms count, and in what order,
is stated once.

Also from review: the nested case was verified by hand only, so the composed
walk is now covered on both sides — a child inside a rotated parent reports
the angle it paints at, the parent's rotation alone when the child has none,
and the walk stopping at the composition root. And `hasAttribute?.` was dead
on a narrowed HTMLElement; it only survived because the crop test's fake
element was not one. The fake now models an element and the guard is gone.

* style(studio): format the shared transform module
This commit is contained in:
Miguel Ángel
2026-08-10 17:41:34 -04:00
committed by GitHub
parent 945988d61f
commit 3e5be0e8c3
9 changed files with 436 additions and 93 deletions
+14 -13
View File
@@ -24,7 +24,7 @@
},
"packages/aws-lambda": {
"name": "@hyperframes/aws-lambda",
"version": "0.7.78",
"version": "0.7.105",
"dependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/client-sfn": "^3.700.0",
@@ -57,7 +57,7 @@
},
"packages/cli": {
"name": "@hyperframes/cli",
"version": "0.7.78",
"version": "0.7.105",
"bin": {
"hyperframes": "./bin/hyperframes.mjs",
},
@@ -107,7 +107,7 @@
},
"packages/core": {
"name": "@hyperframes/core",
"version": "0.7.78",
"version": "0.7.105",
"dependencies": {
"@chenglou/pretext": "^0.0.5",
"@hyperframes/lint": "workspace:*",
@@ -132,7 +132,7 @@
},
"packages/engine": {
"name": "@hyperframes/engine",
"version": "0.7.78",
"version": "0.7.105",
"dependencies": {
"@hono/node-server": "^2.0.5",
"@hyperframes/core": "workspace:^",
@@ -151,7 +151,7 @@
},
"packages/gcp-cloud-run": {
"name": "@hyperframes/gcp-cloud-run",
"version": "0.7.78",
"version": "0.7.105",
"dependencies": {
"@google-cloud/storage": "^7.14.0",
"@google-cloud/workflows": "^4.2.0",
@@ -172,7 +172,7 @@
},
"packages/lint": {
"name": "@hyperframes/lint",
"version": "0.7.78",
"version": "0.7.105",
"dependencies": {
"@hyperframes/parsers": "workspace:*",
"htmlparser2": "^10.1.0",
@@ -190,7 +190,7 @@
},
"packages/parsers": {
"name": "@hyperframes/parsers",
"version": "0.7.78",
"version": "0.7.105",
"dependencies": {
"@babel/parser": "^7.27.0",
"acorn": "^8.17.0",
@@ -210,7 +210,7 @@
},
"packages/player": {
"name": "@hyperframes/player",
"version": "0.7.78",
"version": "0.7.105",
"dependencies": {
"@hyperframes/core": "workspace:*",
},
@@ -225,7 +225,7 @@
},
"packages/producer": {
"name": "@hyperframes/producer",
"version": "0.7.78",
"version": "0.7.105",
"dependencies": {
"@fontsource/archivo-black": "^5.2.8",
"@fontsource/eb-garamond": "^5.2.7",
@@ -270,7 +270,7 @@
},
"packages/sdk": {
"name": "@hyperframes/sdk",
"version": "0.7.78",
"version": "0.7.105",
"dependencies": {
"@hyperframes/core": "workspace:*",
"@hyperframes/parsers": "workspace:*",
@@ -300,7 +300,7 @@
},
"packages/shader-transitions": {
"name": "@hyperframes/shader-transitions",
"version": "0.7.78",
"version": "0.7.105",
"dependencies": {
"html2canvas": "^1.4.1",
},
@@ -312,7 +312,7 @@
},
"packages/studio": {
"name": "@hyperframes/studio",
"version": "0.7.78",
"version": "0.7.105",
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
@@ -344,6 +344,7 @@
"@types/react-dom": "19",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.0",
"chokidar": "^4.0.3",
"fake-indexeddb": "^6.2.5",
"postcss": "^8.4.0",
"puppeteer-core": "^25.2.1",
@@ -362,7 +363,7 @@
},
"packages/studio-server": {
"name": "@hyperframes/studio-server",
"version": "0.7.78",
"version": "0.7.105",
"dependencies": {
"@hyperframes/core": "workspace:*",
"@hyperframes/parsers": "workspace:*",
+1
View File
@@ -85,6 +85,7 @@
"@types/react-dom": "19",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.0",
"chokidar": "^4.0.3",
"fake-indexeddb": "^6.2.5",
"postcss": "^8.4.0",
"puppeteer-core": "^25.2.1",
@@ -8,6 +8,29 @@ import { DomEditSelectionChrome } from "./DomEditSelectionChrome";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/** A selection whose capabilities are all on or all off, plus a host to render into. */
function selectionFixture(
element: HTMLElement,
selector: string,
enabled: boolean,
extra: Record<string, unknown> = {},
) {
const selection = {
element,
selector,
...extra,
capabilities: {
canCrop: enabled,
canApplyManualOffset: enabled,
canApplyManualSize: enabled,
canApplyManualRotation: enabled,
},
} as unknown as DomEditSelection;
const host = document.createElement("div");
document.body.append(host);
return { selection, host, root: createRoot(host) };
}
describe("DomEditSelectionChrome crop composition", () => {
it("renders overlay-only transparent chrome at headline geometry without changing composition bytes", () => {
const composition = document.implementation.createHTMLDocument();
@@ -18,19 +41,7 @@ describe("DomEditSelectionChrome crop composition", () => {
`;
const headline = composition.querySelector<HTMLElement>(".hl-text")!;
const before = composition.documentElement.outerHTML;
const selection = {
element: headline,
selector: ".hl-text",
capabilities: {
canCrop: false,
canApplyManualOffset: false,
canApplyManualSize: false,
canApplyManualRotation: false,
},
} as unknown as DomEditSelection;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const { selection, host, root } = selectionFixture(headline, ".hl-text", false);
act(() => {
root.render(
<DomEditSelectionChrome
@@ -69,24 +80,16 @@ describe("DomEditSelectionChrome crop composition", () => {
offsetHeight: { value: 100 },
});
document.body.append(element);
vi.spyOn(window, "getComputedStyle").mockReturnValue({
clipPath: "inset(10px)",
transform: "matrix(0.8660254, 0.5, -0.5, 0.8660254, 0, 0)",
} as CSSStyleDeclaration);
const selection = {
element,
id: "clip",
selector: "#clip",
capabilities: {
canCrop: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
},
} as unknown as DomEditSelection;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
// Per element, not blanket: the crop frame composes the element's transform
// with its ancestors', so answering "rotated 30deg" for every node in the
// document would have the frame read the same turn several times over.
vi.spyOn(window, "getComputedStyle").mockImplementation(
((node: Element) =>
(node === element
? { clipPath: "inset(10px)", transform: "matrix(0.8660254, 0.5, -0.5, 0.8660254, 0, 0)" }
: { clipPath: "none", transform: "none" }) as CSSStyleDeclaration) as never,
);
const { selection, host, root } = selectionFixture(element, "#clip", true, { id: "clip" });
act(() => {
root.render(
<DomEditSelectionChrome
@@ -8,6 +8,7 @@ import {
resolveCropInsetFromMoveDrag,
rotateDeltaIntoFrame,
} from "./domEditOverlayCrop";
import { individualRotateDegrees } from "./domEditOverlayTransform";
describe("resolveCropInsetFromEdgeDrag", () => {
const startInsets = { top: 10, right: 20, bottom: 30, left: 40 };
@@ -160,10 +161,15 @@ describe("readElementCropInsets tri-state", () => {
describe("readElementCropFrame", () => {
const overlayRect = { left: 100, top: 50, width: 220, height: 130, editScaleX: 1, editScaleY: 1 };
// Models an element well enough for the ancestor walk: it reports its own
// transform, claims no composition-root attribute, and has no parent, so the
// walk composes exactly one node.
const fakeEl = (transform: string, offsetWidth = 200, offsetHeight = 100) =>
({
offsetWidth,
offsetHeight,
parentElement: null,
hasAttribute: () => false,
ownerDocument: { defaultView: { getComputedStyle: () => ({ transform }) } },
}) as unknown as HTMLElement;
@@ -205,9 +211,43 @@ describe("readElementCropFrame", () => {
expect(frame.height).toBeCloseTo(200, 3);
});
it("3D transform falls back to the axis-aligned frame", () => {
/**
* A 3D matrix is not automatically unmeasurable. GSAP writes one for an
* ordinary 2D move or spin (force3D), so refusing every `matrix3d` drew the
* crop outline square on elements the rest of the chrome drew rotated.
* The identity here is a 2D transform written the long way.
*/
it("reads a planar matrix3d rather than giving up on it", () => {
// scale(1.5, 2) written the long way — planar, and not the identity.
const frame = readElementCropFrame(
fakeEl("matrix3d(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1)"),
fakeEl("matrix3d(1.5,0,0,0,0,2,0,0,0,0,1,0,0,0,0,1)"),
overlayRect,
);
expect(frame.scaleX).toBeCloseTo(1.5, 3);
expect(frame.scaleY).toBeCloseTo(2, 3);
});
it("reads a 2D rotation written as matrix3d", () => {
const frame = readElementCropFrame(
fakeEl("matrix3d(0.866025,0.5,0,0,-0.5,0.866025,0,0,0,0,1,0,0,0,0,1)"),
overlayRect,
);
expect(frame.angleDeg).toBeCloseTo(30, 3);
});
it("reads a flipped element, which still has a real size", () => {
// Negative z scale — a composition that mirrors an element writes this.
const frame = readElementCropFrame(
fakeEl("matrix3d(-0.866025,-0.5,0,0,-0.5,0.866025,0,0,0,0,-1,0,0,0,0,1)"),
overlayRect,
);
expect(frame.width).toBeGreaterThan(0);
expect(frame.height).toBeGreaterThan(0);
});
it("still falls back on a perspective transform, which no single angle describes", () => {
const frame = readElementCropFrame(
fakeEl("matrix3d(1,0,0,0.002,0,1,0,0,0,0,1,0,0,0,0,1)"),
overlayRect,
);
expect(frame).toEqual({
@@ -241,3 +281,97 @@ describe("rotateDeltaIntoFrame", () => {
expect(back.deltaY).toBeCloseTo(-3, 6);
});
});
describe("individualRotateDegrees", () => {
/**
* Studio's rotate handle writes the CSS `rotate` property, not `transform`.
* Everything that measured an element's angle read `transform` alone, so a
* turned element reported upright and the selection box, crop outline and
* child outlines all drew square across it.
*/
it("reads a plain angle", () => {
expect(individualRotateDegrees("-22deg")).toBeCloseTo(-22, 6);
expect(individualRotateDegrees("45deg")).toBeCloseTo(45, 6);
});
it("reads an explicit z-axis rotation, honouring the axis sign", () => {
expect(individualRotateDegrees("0 0 1 30deg")).toBeCloseTo(30, 6);
expect(individualRotateDegrees("0 0 -1 30deg")).toBeCloseTo(-30, 6);
});
it("reports nothing for a rotation that leaves the overlay's plane", () => {
// A 3D turn has no single in-plane angle. Reporting one would draw the
// chrome at a plausible-looking wrong angle instead of falling back square.
expect(individualRotateDegrees("1 0 0 45deg")).toBe(0);
expect(individualRotateDegrees("0 1 0 45deg")).toBe(0);
});
it("reports nothing when the property is absent or unparseable", () => {
expect(individualRotateDegrees("none")).toBe(0);
expect(individualRotateDegrees(undefined)).toBe(0);
expect(individualRotateDegrees("")).toBe(0);
expect(individualRotateDegrees("12")).toBe(0);
});
});
describe("readElementCropFrame — the composed walk", () => {
/**
* The case the crop outline got wrong: a text layer inside a rotated card.
* The layer carries its own spin and its parent turns it again, so the box
* belongs at the combination. Reading the element alone drew it across the
* text at roughly a right angle.
*/
const nested = (childTransform: string, parentTransform: string) => {
const style = (transform: string) => ({ transform }) as CSSStyleDeclaration;
const parent = {
offsetWidth: 400,
offsetHeight: 300,
parentElement: null,
hasAttribute: (name: string) => name === "data-composition-id",
ownerDocument: { defaultView: { getComputedStyle: () => style(parentTransform) } },
};
return {
offsetWidth: 200,
offsetHeight: 100,
parentElement: parent,
hasAttribute: () => false,
ownerDocument: {
defaultView: {
getComputedStyle: (node: unknown) =>
node === parent ? style(parentTransform) : style(childTransform),
},
},
} as unknown as HTMLElement;
};
const overlayRect = { left: 100, top: 50, width: 220, height: 130, editScaleX: 1, editScaleY: 1 };
it("adds the parent's rotation to the child's", () => {
// child 30deg inside a parent turned 60deg → the layer paints at 90.
const frame = readElementCropFrame(
nested(
"matrix(0.8660254, 0.5, -0.5, 0.8660254, 0, 0)",
"matrix(0.5, 0.8660254, -0.8660254, 0.5, 0, 0)",
),
overlayRect,
);
expect(frame.angleDeg).toBeCloseTo(90, 3);
});
it("takes the parent's rotation when the child has none of its own", () => {
const frame = readElementCropFrame(
nested("none", "matrix(0.8660254, 0.5, -0.5, 0.8660254, 0, 0)"),
overlayRect,
);
expect(frame.angleDeg).toBeCloseTo(30, 3);
});
it("stops at the composition root rather than walking the whole document", () => {
// The root itself is marked, so its own transform is the last one counted.
const frame = readElementCropFrame(
nested("none", "matrix(0.8660254, 0.5, -0.5, 0.8660254, 0, 0)"),
overlayRect,
);
expect(frame.angleDeg).toBeCloseTo(30, 3);
});
});
@@ -1,3 +1,4 @@
import { composeElementTransform, type PlanarTransformOps } from "./domEditOverlayTransform";
import { parseInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers";
export type CropEdge = "top" | "right" | "bottom" | "left";
@@ -147,6 +148,96 @@ export interface CropFrame {
scaleY: number;
}
/**
* The element's own 2D transform as matrix components, plus the `rotate`
* property's angle.
*
* `rotate` is a separate CSS property, not part of `transform`, and it is the
* one Studio's rotate handle writes — reading `transform` alone reported a
* turned element as upright, so the crop outline drew square across it.
*
* Null means there is nothing planar to draw against: no transform and no
* rotation, or a 3D/unparseable matrix. The caller falls back to the
* axis-aligned box rather than guessing an angle.
*/
const IDENTITY = { a: 1, b: 0, c: 0, d: 1 };
/** Perspective terms this far from zero mean the mapping is not affine. */
const PERSPECTIVE_EPSILON = 1e-6;
type Planar2D = { a: number; b: number; c: number; d: number };
/**
* The 2D components of a computed transform, or null when it cannot be used.
*
* Accepts `matrix3d` as well as `matrix`, taking the same 2D projection the
* rest of the overlay reads through DOMMatrix. GSAP writes a 3D matrix for an
* ordinary 2D move or spin (force3D), and a composition that flips an element
* writes one with a negative z scale — treating either as unmeasurable left the
* crop outline square on an element every other piece of chrome drew rotated.
*
* Only a perspective term rules the matrix out, because that is where the
* mapping stops being affine and a single angle stops describing it.
*/
function parseMatrixComponents(transform: string): Planar2D | null {
const flat = /^matrix\(([^)]+)\)$/.exec(transform);
if (flat) {
const [a, b, c, d] = flat[1]!.split(",").map((v) => Number.parseFloat(v));
return [a, b, c, d].every(Number.isFinite) ? { a: a!, b: b!, c: c!, d: d! } : null;
}
const spatial = /^matrix3d\(([^)]+)\)$/.exec(transform);
if (!spatial) return null;
const m = spatial[1]!.split(",").map((v) => Number.parseFloat(v));
if (m.length !== 16 || !m.every(Number.isFinite)) return null;
const affine = [m[3], m[7], m[11]].every((v) => Math.abs(v!) < PERSPECTIVE_EPSILON);
if (!affine) return null;
return { a: m[0]!, b: m[1]!, c: m[4]!, d: m[5]! };
}
/** The crop frame only needs an angle and a scale, so it composes plain 2D components. */
const PLANAR_2D_OPS: PlanarTransformOps<Planar2D> = {
identity: () => IDENTITY,
fromTransform: parseMatrixComponents,
fromRotate: (degrees) => {
const rad = (degrees * Math.PI) / 180;
return { a: Math.cos(rad), b: Math.sin(rad), c: -Math.sin(rad), d: Math.cos(rad) };
},
compose: (outer, inner) => ({
a: outer.a * inner.a + outer.c * inner.b,
b: outer.b * inner.a + outer.d * inner.b,
c: outer.a * inner.c + outer.c * inner.d,
d: outer.b * inner.c + outer.d * inner.d,
}),
};
/** Whether the matrix leaves the box exactly as it found it. */
function isIdentity(m: Planar2D): boolean {
return (
Math.abs(m.a - 1) < PERSPECTIVE_EPSILON &&
Math.abs(m.b) < PERSPECTIVE_EPSILON &&
Math.abs(m.c) < PERSPECTIVE_EPSILON &&
Math.abs(m.d - 1) < PERSPECTIVE_EPSILON
);
}
/**
* The transform the element paints under, in 2D components.
*
* Null when nothing up the chain transforms it: the caller's axis-aligned rect
* already describes it, and that comes from real layout rather than the
* element's untransformed box.
*/
function readPlanarTransform(element: HTMLElement): Planar2D | null {
const acc = composeElementTransform(element, PLANAR_2D_OPS, (node) => {
try {
return node.ownerDocument.defaultView?.getComputedStyle(node) ?? null;
} catch {
return null;
}
});
return acc && !isIdentity(acc) ? acc : null;
}
export function readElementCropFrame(
element: HTMLElement,
overlayRect: CropScreenRect & { editScaleX: number; editScaleY: number },
@@ -162,22 +253,15 @@ export function readElementCropFrame(
scaleX: editX,
scaleY: editY,
};
let transform = "";
try {
transform = element.ownerDocument.defaultView?.getComputedStyle(element).transform ?? "";
} catch {
return aabb;
}
if (!transform || transform === "none") return aabb;
const m = /^matrix\(([^)]+)\)$/.exec(transform);
if (!m) return aabb; // matrix3d or unparseable → axis-aligned fallback
const [a, b, c, d] = m[1]!.split(",").map((v) => Number.parseFloat(v));
if (![a, b, c, d].every(Number.isFinite)) return aabb;
const elScaleX = Math.hypot(a!, b!);
const det = a! * d! - b! * c!;
const elScaleY = elScaleX !== 0 ? det / elScaleX : 1;
const planar = readPlanarTransform(element);
if (!planar) return aabb;
const { a, b, c, d } = planar;
const elScaleX = Math.hypot(a, b);
const det = a * d - b * c;
// |det| : a flipped element (negative determinant) still has a real size.
const elScaleY = elScaleX !== 0 ? Math.abs(det) / elScaleX : 1;
if (elScaleX <= 0 || elScaleY <= 0) return aabb;
const angleDeg = (Math.atan2(b!, a!) * 180) / Math.PI;
const angleDeg = (Math.atan2(b, a) * 180) / Math.PI;
const scaleX = elScaleX * editX;
const scaleY = elScaleY * editY;
const width = element.offsetWidth * scaleX;
@@ -165,6 +165,19 @@ describe("orientedOverlayRect — rotation gate (perf fix, V15 18a/18b)", () =>
* a text layer inside a rotated card got an upright dashed box sitting across
* the rotated glyphs the parent's chrome rotated and its children's did not.
*/
/**
* The selection box and the crop outline compose the same ancestor walk, so
* this asserts the geometry side of the case the crop test covers: a child
* inside a rotated parent reports the angle it paints at, not its own.
*/
it("composes the parent's rotation into the child's angle", () => {
const { overlayEl, iframe, el } = buildHarness();
el.parentElement!.style.transform = ROTATE_30DEG_MATRIX;
const rect = orientedOverlayRect(overlayEl, iframe, el);
expect(rect).not.toBeNull();
expect(rect!.angle).toBeCloseTo(30, 3);
});
it("child outlines carry the element's angle, so they can co-rotate with it", () => {
const { overlayEl, iframe, el } = buildHarness();
el.style.transform = ROTATE_30DEG_MATRIX;
@@ -1,6 +1,7 @@
import { type DomEditSelection, findElementForSelection } from "./domEditing";
import { isElementVisibleThroughAncestors } from "./domEditingDom";
import { hugRectForElement } from "./domEditOverlayCrop";
import { composeElementTransform, type PlanarTransformOps } from "./domEditOverlayTransform";
export interface OverlayRect {
left: number;
@@ -146,17 +147,19 @@ function readElementTransformSnapshot(
const DOMMatrixCtor = (win as Window & typeof globalThis).DOMMatrix;
if (!DOMMatrixCtor) return null;
const cs = win.getComputedStyle(element);
// The corner math transforms points, so this algebra keeps the full matrix,
// translation included, where the crop frame's keeps only 2D components.
const ops: PlanarTransformOps<DOMMatrix> = {
identity: () => new DOMMatrixCtor(),
fromTransform: (value) => new DOMMatrixCtor(value),
fromRotate: (degrees) => new DOMMatrixCtor().rotateSelf(degrees),
compose: (outer, inner) => outer.multiply(inner),
};
try {
let matrix = new DOMMatrixCtor();
for (let node: HTMLElement | null = element; node; node = node.parentElement) {
const transform = node === element ? cs.transform : win.getComputedStyle(node).transform;
if (transform && transform !== "none") {
// An ancestor applies outside, so it multiplies on the left.
matrix = new DOMMatrixCtor(transform).multiply(matrix);
}
if (node.hasAttribute("data-composition-id")) break;
}
return { matrix, cs };
const matrix = composeElementTransform(element, ops, (node) =>
node === element ? cs : win.getComputedStyle(node),
);
return matrix ? { matrix, cs } : null;
} catch {
return null;
}
@@ -0,0 +1,88 @@
/**
* One walk from an element up to its composition root, composing the transform
* it actually paints under.
*
* The overlay measures an element's angle in two places the selection box and
* the crop frame and they need different arithmetic: one works in DOMMatrix
* because it goes on to transform corner points, the other in plain 2D
* components because it only needs an angle and a scale. What they must never
* differ on is *which* transforms count and in what order, because when they
* disagree the chrome disagrees with itself: the selection box drawn at one
* angle and the crop outline at another, on the same element.
*
* So the walk lives here once and takes the arithmetic as a parameter. Adding
* an individual property CSS grew later `translate`, `scale` means adding
* one step here and one method to each algebra, rather than finding both walks
* and hoping.
*/
/** The composition's own root; the walk stops there rather than at the document. */
const COMPOSITION_ROOT_ATTR = "data-composition-id";
/**
* The arithmetic the walk needs, whatever the caller's matrix type is.
*
* `fromTransform` returns null for a transform the caller cannot use a
* perspective matrix, say which aborts the walk rather than composing a
* matrix that describes something other than what is painted.
*/
export interface PlanarTransformOps<M> {
identity(): M;
fromTransform(value: string): M | null;
fromRotate(degrees: number): M;
/** `outer` applied around `inner`, as an ancestor composes over a child. */
compose(outer: M, inner: M): M;
}
/**
* The planar rotation in the CSS `rotate` property, in degrees.
*
* Computes to `none`, an angle (`-22deg`), or an axis plus an angle
* (`0 0 1 -22deg`). Only a rotation about z stays in the overlay's plane; any
* other axis is 3D and reports 0, which leaves the caller on its axis-aligned
* fallback rather than drawing a box at a plausible-looking wrong angle.
*/
export function individualRotateDegrees(value: string | undefined): number {
if (!value || value === "none") return 0;
const parts = value.trim().split(/\s+/);
const angle = parts.at(-1);
if (!angle?.endsWith("deg")) return 0;
if (parts.length === 4) {
const [x, y, z] = parts;
if (Number(x) !== 0 || Number(y) !== 0 || Math.abs(Number(z)) !== 1) return 0;
const deg = Number.parseFloat(angle);
return Number.isFinite(deg) ? deg * Math.sign(Number(z)) : 0;
}
if (parts.length !== 1) return 0;
const deg = Number.parseFloat(angle);
return Number.isFinite(deg) ? deg : 0;
}
/**
* The element's transform composed with every ancestor's, up to the composition
* root.
*
* Within a node, CSS applies the individual properties before `transform`, so
* `rotate` composes on the left of it. Between nodes, an ancestor applies
* outside its child. Null means some node's transform was unusable and the
* caller should fall back rather than guess.
*/
export function composeElementTransform<M>(
element: HTMLElement,
ops: PlanarTransformOps<M>,
getStyle: (node: HTMLElement) => CSSStyleDeclaration | null,
): M | null {
let acc = ops.identity();
for (let node: HTMLElement | null = element; node; node = node.parentElement) {
const style = getStyle(node);
if (!style) return null;
const transform = style.transform;
let own = transform && transform !== "none" ? ops.fromTransform(transform) : ops.identity();
if (!own) return null;
const spin = individualRotateDegrees(style.rotate);
if (spin !== 0) own = ops.compose(ops.fromRotate(spin), own);
acc = ops.compose(own, acc);
if (node.hasAttribute(COMPOSITION_ROOT_ATTR)) break;
}
return acc;
}
+38 -22
View File
@@ -3,7 +3,8 @@ import react from "@vitejs/plugin-react";
import { readFileSync, readdirSync, existsSync, lstatSync, realpathSync } from "node:fs";
import { join, resolve } from "node:path";
import { readNodeRequestBody } from "./vite.request-body.js";
import { createViteAdapter, isPathWithin } from "./vite.adapter";
import { watch } from "chokidar";
import { createViteAdapter } from "./vite.adapter";
async function loadRuntimeSourceForDev(
server: import("vite").ViteDevServer,
@@ -143,15 +144,16 @@ function devProjectApi(): Plugin {
}
});
// Watch project directories for file changes → HMR
// Watch project directories on a watcher of our own. Vite's is told to
// ignore them (see `server.watch.ignored`), because it answers an html
// change with a full page reload; this one only announces the change and
// lets Studio decide what to do with it.
const realProjectPaths: string[] = [];
try {
for (const entry of readdirSync(dataDir, { withFileTypes: true })) {
const full = join(dataDir, entry.name);
try {
const real = lstatSync(full).isSymbolicLink() ? realpathSync(full) : full;
realProjectPaths.push(real);
server.watcher.add(real);
realProjectPaths.push(lstatSync(full).isSymbolicLink() ? realpathSync(full) : full);
} catch {
/* skip broken symlinks */
}
@@ -160,24 +162,29 @@ function devProjectApi(): Plugin {
/* dataDir doesn't exist yet */
}
server.watcher.on("change", (filePath: string) => {
const isProjectFile = realProjectPaths.some((p) => isPathWithin(p, filePath));
if (
isProjectFile &&
(filePath.endsWith(".html") ||
filePath.endsWith(".css") ||
filePath.endsWith(".js") ||
filePath.endsWith(".json"))
) {
console.log(`[Studio] File changed: ${filePath}`);
const receipt = _studioServerModule?.consumeFileWriteReceipt?.(filePath) ?? null;
server.ws.send({
type: "custom",
event: "hf:file-change",
data: receipt ?? { path: filePath },
});
}
const projectWatcher = watch(realProjectPaths, {
ignoreInitial: true,
// A project write is a whole-file replace; wait for it to settle so a
// half-written composition is never announced.
awaitWriteFinish: { stabilityThreshold: 40, pollInterval: 10 },
});
projectWatcher.on("change", (filePath: string) => {
if (
!filePath.endsWith(".html") &&
!filePath.endsWith(".css") &&
!filePath.endsWith(".js") &&
!filePath.endsWith(".json")
)
return;
console.log(`[Studio] File changed: ${filePath}`);
const receipt = _studioServerModule?.consumeFileWriteReceipt?.(filePath) ?? null;
server.ws.send({
type: "custom",
event: "hf:file-change",
data: receipt ?? { path: filePath },
});
});
server.httpServer?.on("close", () => void projectWatcher.close());
},
};
}
@@ -205,6 +212,15 @@ export default defineConfig({
},
server: {
port: 5190,
watch: {
// A composition lives under this package's root, so Vite's HMR sees a
// write to one as an html page dependency changing and full-reloads the
// browser. That reload is the flash after every edit in the canvas, and
// it is not Studio's to make: the app already decides whether a write of
// its own needs the preview refreshed, and the plugin below announces
// project writes as `hf:file-change` off its own watcher.
ignored: ["**/data/projects/**"],
},
},
ssr: {
// recast / @babel/parser are CommonJS and call `require("fs")`. They are