Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
StudioRotationSnapshot,
} from "./manualEdits";
import type { ManualOffsetDragMember } from "./manualOffsetDrag";
import type { CssRotationTarget } from "./rotationDraft";
import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
import type { SnapContext } from "./snapTargetCollection";
import type { SnapGuidesState } from "./SnapGuideOverlay";
Expand Down Expand Up @@ -63,8 +64,8 @@ export interface GestureState {
actualWidth: number;
actualHeight: number;
actualRotation: number;
/** Null when GSAP owns the rotate; else what the element's scale and transform turn, read at press. */
plainRotationShare: number | null;
/** Null when GSAP owns the rotate; else where its CSS turn is drawn and saved, read at press. */
plainRotation: CssRotationTarget | null;
editScaleX: number;
editScaleY: number;
// Rendered px per CSS px of the element at gesture start (> 1 under a GSAP scale()); the resize
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,11 @@ describe("a rotate on a page that loads GSAP", () => {

const handlers = createDomEditOverlayGestureHandlers(opts as never);
expect(handlers.startGesture("rotate", pointer(25, -20) as never)).toBe(true);
expect(opts.gestureRef.current?.plainRotationShare).toBe(0);
expect(opts.gestureRef.current?.plainRotation).toEqual({
property: "rotate",
prefix: "",
share: 0,
});
expect(opts.gestureRef.current?.actualRotation).toBeCloseTo(30);
handlers.onPointerMove(pointer(60, 20) as never);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
restoreManualOffsetDragMembers,
type ManualOffsetDragMember,
} from "./manualOffsetDrag";
import { readRotationBase } from "./rotationDraft";
import { readCssRotationTarget, readRotationBase } from "./rotationDraft";
import {
beginStudioManualEditGesture,
captureStudioBoxSize,
Expand All @@ -32,7 +32,6 @@ import {
} from "./domEditOverlayGestures";
import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
import { gsapWritesRotation } from "../../hooks/gsapRuntimeKeyframes";
import { readCssRotation } from "../../hooks/draggedGsapPosition";
import { logResize, resetResizeMoveLog } from "../../utils/resizeDebug";
import { logDrag, readDragPositions, resetDragMoveLog } from "../../utils/dragDebug";

Expand Down Expand Up @@ -234,8 +233,7 @@ export function startGesture(
// Rotation base: the angle the element shows. An element GSAP does not turn, or a plain-translate
// move, never asks GSAP: reading a property makes it bake the CSS into its transform.
const plain = !!pathOffsetMember?.plainTranslate || !gsapWritesRotation(sel.element);
const plainRotationShare =
plain && kind === "rotate" ? readCssRotation(sel.element, false) : null;
const plainRotation = plain && kind === "rotate" ? readCssRotationTarget(sel.element) : null;
const rotation = { angle: readRotationBase(sel.element, plain) };
const overlayBounds = overlayEl?.getBoundingClientRect();
const centerX = (overlayBounds?.left ?? 0) + rect.left + rect.width / 2;
Expand Down Expand Up @@ -284,7 +282,7 @@ export function startGesture(
actualWidth,
actualHeight,
actualRotation: rotation.angle,
plainRotationShare,
plainRotation,
editScaleX: rect.editScaleX,
editScaleY: rect.editScaleY,
contentScaleX,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export function captureStudioBoxSize(element: HTMLElement): StudioBoxSizeSnapsho
export function captureStudioRotation(element: HTMLElement): StudioRotationSnapshot {
return {
rotate: element.style.getPropertyValue("rotate"),
transform: element.style.getPropertyValue("transform"),
transformOrigin: element.style.getPropertyValue("transform-origin"),
studioRotation: element.style.getPropertyValue(STUDIO_ROTATION_PROP),
marker: element.getAttribute(STUDIO_ROTATION_ATTR),
Expand Down
1 change: 1 addition & 0 deletions packages/studio/src/components/editor/manualEditsTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export interface StudioBoxSizeSnapshot {

export interface StudioRotationSnapshot {
rotate: string;
transform: string;
transformOrigin: string;
studioRotation: string;
marker: string | null;
Expand Down
36 changes: 30 additions & 6 deletions packages/studio/src/components/editor/rotationDraft.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Window } from "happy-dom";
import { describe, expect, it, vi } from "vitest";
import { readCssRotation } from "../../hooks/draggedGsapPosition";
import { applyRotationDraft, readRotationBase } from "./rotationDraft";
import { applyRotationDraft, readCssRotationTarget, readRotationBase } from "./rotationDraft";

describe("rotate in a composition without GSAP", () => {
it("starts from the authored CSS rotation and drafts the absolute angle the commit writes", () => {
Expand All @@ -12,7 +11,7 @@ describe("rotate in a composition without GSAP", () => {
window.document.body.append(element);
const base = readRotationBase(element, true);
expect(base).toBeCloseTo(30);
applyRotationDraft(element, 55, readCssRotation(element, false));
applyRotationDraft(element, 55, readCssRotationTarget(element));
expect(element.style.getPropertyValue("rotate")).toBe("55deg");
});

Expand All @@ -33,7 +32,7 @@ describe("rotate in a composition without GSAP", () => {
css,
base: expect.closeTo(base),
});
applyRotationDraft(element, base + 25, readCssRotation(element, false));
applyRotationDraft(element, base + 25, readCssRotationTarget(element));
const rotate = Number.parseFloat(element.style.getPropertyValue("rotate"));
expect({ css, rotate }).toEqual({ css, rotate: expect.closeTo(drafted) });
}
Expand All @@ -48,9 +47,9 @@ describe("rotate in a composition without GSAP", () => {
element.id = "title";
window.document.body.append(element);
expect(readRotationBase(element, true)).toBeCloseTo(30);
const share = readCssRotation(element, false);
const target = readCssRotationTarget(element);
const styleReads = vi.spyOn(window, "getComputedStyle");
applyRotationDraft(element, 55, share);
applyRotationDraft(element, 55, target);
expect(styleReads).not.toHaveBeenCalled();
expect(element.style.getPropertyValue("rotate")).toBe("55deg");
expect(gsap.set).not.toHaveBeenCalled();
Expand All @@ -59,4 +58,29 @@ describe("rotate in a composition without GSAP", () => {
expect(element.style.getPropertyValue("rotate")).toBe("none");
expect(gsap.set).toHaveBeenCalledWith(element, { rotation: 55 });
});

it("turns a transform-centred element inside its transform, after the translate, so it stays put", () => {
const window = new Window();
window.document.head.innerHTML =
"<style>#title { rotate: 30deg; transform: translate(-120px, -80px); }</style>";
const element = window.document.createElement("h1");
element.id = "title";
window.document.body.append(element);
const target = readCssRotationTarget(element);
expect(target).toEqual({
property: "transform",
prefix: "translate(-120px, -80px)",
share: expect.closeTo(30),
});
applyRotationDraft(element, 55, target);
expect(element.style.getPropertyValue("transform")).toBe(
"translate(-120px, -80px) rotate(25deg)",
);
expect(element.style.getPropertyValue("rotate")).toBe("");
// The next rotate replaces its own trailing turn instead of stacking a second one.
applyRotationDraft(element, 70, readCssRotationTarget(element));
expect(element.style.getPropertyValue("transform")).toBe(
"translate(-120px, -80px) rotate(40deg)",
);
});
});
76 changes: 66 additions & 10 deletions packages/studio/src/components/editor/rotationDraft.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,85 @@
import { readCssRotation } from "../../hooks/draggedGsapPosition";
import { roundTo3 } from "../../utils/rounding";
import type { PatchOperation } from "../../utils/sourcePatcher";
import {
readStudioRotation,
restoreStudioRotation,
type StudioRotationSnapshot,
} from "./manualEdits";
import { getOffsetDragGsap } from "./manualOffsetDrag";

/** The element's own `rotate` that shows `angle`, less `share`, what its `scale` and `transform` turn. */
const TRAILING_TURN = /\s*rotate\(\s*-?[\d.]+(?:e[+-]?\d+)?deg\s*\)\s*$/;

// ponytail: the authored transform if it moves the box, from the last matching rule in sheet order;
// specificity, !important and @media are not weighed. Weigh them when a film's rule is missed.
function translatingTransform(element: HTMLElement): string {
const view = element.ownerDocument.defaultView;
const computed = view?.getComputedStyle(element).transform ?? "none";
const m = computed === "none" ? null : new view!.DOMMatrix(computed);
if (!m || (m.m41 === 0 && m.m42 === 0)) return "";
let value = element.style.getPropertyValue("transform");
for (const sheet of value ? [] : Array.from(element.ownerDocument.styleSheets)) {
let rules: CSSRule[];
try {
rules = Array.from(sheet.cssRules);
} catch {
continue; // a cross-origin sheet
}
for (const rule of rules as CSSStyleRule[]) {
const declared = rule.style?.getPropertyValue("transform");
if (declared && element.matches(rule.selectorText)) value = declared;
}
}
return value === "none" ? "" : value;
}

/** Where a plain rotate draws its turn, read once at press: the element's own `rotate`, or, when its
* transform translates it (often the translate(-50%, -50%) centring), a trailing rotate() in that
* transform, so the translate is not turned with the box. `share` is what the rest already turns. */
export interface CssRotationTarget {
property: "rotate" | "transform";
prefix: string;
share: number;
}

export function readCssRotationTarget(element: HTMLElement): CssRotationTarget {
const transform = translatingTransform(element);
if (!transform) return { property: "rotate", prefix: "", share: readCssRotation(element, false) };
const prefix = transform.replace(TRAILING_TURN, "");
const style = element.style;
const inline = [style.getPropertyValue("transform"), style.getPropertyPriority("transform")];
style.setProperty("transform", prefix || "none");
const share = readCssRotation(element);
style.setProperty("transform", inline[0] ?? "", inline[1] ?? "");
return { property: "transform", prefix, share };
}

/** Draws `angle` where `target` says, and returns the source patch that saves it as drawn. */
export function applyCssRotation(
element: HTMLElement,
angle: number,
share = readCssRotation(element, false),
): void {
element.style.setProperty("rotate", `${roundTo3(angle - share)}deg`);
target = readCssRotationTarget(element),
): PatchOperation & { value: string } {
const turn = `${roundTo3(angle - target.share)}deg`;
const value = target.property === "rotate" ? turn : `${target.prefix} rotate(${turn})`.trim();
element.style.setProperty(target.property, value);
return { type: "inline-style", property: target.property, value };
}

/** Back to the press: the rotation snapshot and, for a plain rotate, the inline transform it drew in. */
export function restorePlainRotation(element: HTMLElement, snapshot: StudioRotationSnapshot): void {
restoreStudioRotation(element, snapshot);
element.style.setProperty("transform", snapshot.transform);
}

// `plainShare` is decided once, at gesture start: a number when GSAP turns nothing on the element (the
// rotate draws its own CSS `rotate`), null for GSAP's rotation, as the commit writes it.
// `plain`, read at press: where a turn GSAP does not own draws; null for GSAP's rotation.
export function applyRotationDraft(
element: HTMLElement,
angle: number,
plainShare: number | null,
plain: CssRotationTarget | null,
): void {
const gsap = plainShare === null ? getOffsetDragGsap(element) : null;
if (!gsap) return applyCssRotation(element, angle, plainShare ?? undefined);
const gsap = plain ? null : getOffsetDragGsap(element);
if (!gsap) return void applyCssRotation(element, angle, plain ?? undefined);
element.style.setProperty("rotate", "none");
gsap.set(element, { rotation: angle });
}
Expand All @@ -39,7 +94,8 @@ export function restoreRotationDraft(
snapshot: StudioRotationSnapshot,
plain: boolean,
): void {
rotationGsap(element, plain)?.set(element, {
if (plain) return restorePlainRotation(element, snapshot);
getOffsetDragGsap(element)?.set(element, {
rotation: angle - (Number.parseFloat(snapshot.studioRotation) || 0),
});
restoreStudioRotation(element, snapshot);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
actualAngle: g.actualRotation,
snap: e.shiftKey,
});
applyRotationDraft(sel.element, rotated.angle, g.plainRotationShare);
applyRotationDraft(sel.element, rotated.angle, g.plainRotation);
return;
}

Expand Down Expand Up @@ -421,15 +421,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
sel.element,
g.actualRotation,
g.initialRotation,
g.plainRotationShare !== null,
g.plainRotation !== null,
);
if (!hasDomEditRotationChanged(g.actualRotation, finalRotation.angle)) {
restoreRotation();
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
return;
}
// Hold the final angle while the commit lands.
applyRotationDraft(sel.element, finalRotation.angle, g.plainRotationShare);
applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
void Promise.resolve(opts.onRotationCommitRef.current(sel, finalRotation))
.catch((error) => {
logGestureCommitFailure("rotate commit failed", error);
Expand Down Expand Up @@ -551,7 +551,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
sel.element,
g.actualRotation,
g.initialRotation,
g.plainRotationShare !== null,
g.plainRotation !== null,
);
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
}
Expand Down
17 changes: 17 additions & 0 deletions packages/studio/src/hooks/plainRotation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,21 @@ describe("savePlainRotation", () => {
expect(element.style.getPropertyValue("rotate")).toBe("");
expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
});

it("saves a transform-centred element's turn in its transform and puts it back on a failed save", async () => {
const element = document.createElement("div");
element.style.transform = "translate(-120px, -80px)";
const { saved, commitPositionPatchToHtml } = rotate(element, 25, {
save: () => Promise.reject(new Error("offline")),
});
expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([
{
type: "inline-style",
property: "transform",
value: "translate(-120px, -80px) rotate(25deg)",
},
]);
await expect(saved).rejects.toThrow("offline");
expect(element.style.getPropertyValue("transform")).toBe("translate(-120px, -80px)");
});
});
19 changes: 8 additions & 11 deletions packages/studio/src/hooks/plainRotation.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
import type { DomEditSelection } from "../components/editor/domEditing";
import {
captureStudioRotation,
clearStudioRotation,
restoreStudioRotation,
} from "../components/editor/manualEdits";
import { captureStudioRotation, clearStudioRotation } from "../components/editor/manualEdits";
import { buildClearRotationPatches } from "../components/editor/manualEditsDomPatches";
import { STUDIO_ROTATION_ATTR } from "../components/editor/manualEditsTypes";
import { applyCssRotation } from "../components/editor/rotationDraft";
import { applyCssRotation, restorePlainRotation } from "../components/editor/rotationDraft";
import type { PatchOperation } from "../utils/sourcePatcher";
import type { ElementOffsetStagerDeps } from "./elementOffsetStager";

let plainRotateCounter = 0;

/** GSAP does not turn the element: `next` is its whole angle, drawn and saved as its own `rotate`. */
/** GSAP does not turn the element: `next` is its whole angle, drawn and saved as its CSS turn. */
export function savePlainRotation(
{ commitPositionPatchToHtml, readOnlyPreview }: Omit<ElementOffsetStagerDeps, "showToast">,
selection: DomEditSelection,
Expand All @@ -26,15 +22,16 @@ export function savePlainRotation(
? buildClearRotationPatches(element)
: [];
if (patches.length) clearStudioRotation(element);
applyCssRotation(element, next.angle);
const value = element.style.getPropertyValue("rotate");
patches.push({ type: "inline-style", property: "rotate", value });
const drawn = applyCssRotation(element, next.angle);
patches.push(drawn);
return commitPositionPatchToHtml(selection, patches, {
label: "Rotate layer",
coalesceKey: `rotate:${++plainRotateCounter}`,
coalesceMs: Number.POSITIVE_INFINITY,
}).catch((error) => {
if (element.style.getPropertyValue("rotate") === value) restoreStudioRotation(element, before);
if (element.style.getPropertyValue(drawn.property) === drawn.value) {
restorePlainRotation(element, before);
}
throw error;
});
}
Loading