@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.
- package/dist/assets/{hyperframes-player-DG795Hwu.js → hyperframes-player-DAoaajuM.js} +1 -1
- package/dist/assets/{index-BiEtDkae.js → index-BDK4rTMb.js} +1 -1
- package/dist/assets/{index-BPJRf2X9.js → index-De2aO4XM.js} +178 -178
- package/dist/assets/{index-DuCZ3pQw.js → index-WV9LroAD.js} +1 -1
- package/dist/index.d.ts +181 -168
- package/dist/index.html +1 -1
- package/dist/index.js +428 -305
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/editor/DomEditOverlay.tsx +5 -6
- package/src/components/editor/PropertyPanel.tsx +3 -3
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +46 -6
- package/src/components/editor/domEditOverlayGestures.ts +13 -19
- package/src/components/editor/domEditOverlayStartGesture.test.ts +170 -13
- package/src/components/editor/domEditOverlayStartGesture.ts +15 -25
- package/src/components/editor/manualEdits.test.ts +3 -9
- package/src/components/editor/manualEdits.ts +0 -1
- package/src/components/editor/manualEditsDom.ts +0 -10
- package/src/components/editor/manualEditsDomPatches.test.ts +1 -45
- package/src/components/editor/manualEditsDomPatches.ts +1 -23
- package/src/components/editor/manualEditsSnapshot.ts +2 -0
- package/src/components/editor/manualEditsTypes.ts +2 -0
- package/src/components/editor/manualOffsetDrag.test.ts +41 -0
- package/src/components/editor/manualOffsetDrag.ts +6 -27
- package/src/components/editor/plainTranslate.ts +9 -4
- package/src/components/editor/propertyPanelTransformCommit.test.ts +29 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +8 -5
- package/src/components/editor/propertyPanelTypes.ts +1 -0
- package/src/components/editor/rotateInlineSave.test.ts +111 -0
- package/src/components/editor/rotationDraft.test.ts +111 -0
- package/src/components/editor/rotationDraft.ts +145 -0
- package/src/components/editor/useDomEditNudge.test.tsx +33 -0
- package/src/components/editor/useDomEditNudge.ts +6 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +22 -24
- package/src/hooks/draggedGsapPosition.ts +28 -0
- package/src/hooks/elementOffsetStager.ts +26 -6
- package/src/hooks/gsapRuntimeBridge.ts +5 -3
- package/src/hooks/gsapRuntimeKeyframes.ts +36 -2
- package/src/hooks/plainRotation.test.ts +122 -0
- package/src/hooks/plainRotation.ts +42 -0
- package/src/hooks/useDomEditSession.ts +5 -5
- package/src/hooks/useDomGeometryCommit.test.tsx +75 -0
- package/src/hooks/useDomGeometryCommit.ts +17 -17
- package/src/hooks/useDomGeometryCommits.test.tsx +56 -1
- package/src/hooks/useDomGeometryCommits.ts +28 -36
- package/src/hooks/useGsapAwareEditing.test.tsx +194 -11
- package/src/hooks/useGsapAwareEditing.ts +43 -42
- package/src/hooks/useInlineTextEdit.test.tsx +33 -3
- package/src/hooks/useInlineTextEdit.ts +2 -2
|
@@ -46,7 +46,6 @@ import {
|
|
|
46
46
|
buildClearPathOffsetPatches,
|
|
47
47
|
buildBoxSizePatches,
|
|
48
48
|
buildClearBoxSizePatches,
|
|
49
|
-
buildRotationPatches,
|
|
50
49
|
buildClearRotationPatches,
|
|
51
50
|
buildMotionPatches,
|
|
52
51
|
buildClearMotionPatches,
|
|
@@ -335,45 +334,7 @@ describe("anchored-corner combined patch: [...buildBoxSizePatches, ...buildPathO
|
|
|
335
334
|
|
|
336
335
|
/* ── Rotation ────────────────────────────────────────────────────────────── */
|
|
337
336
|
|
|
338
|
-
describe("
|
|
339
|
-
function populatedRotEl(): HTMLElement {
|
|
340
|
-
const e = div();
|
|
341
|
-
e.style.setProperty(STUDIO_ROTATION_PROP, "45");
|
|
342
|
-
e.style.setProperty("rotate", "45deg");
|
|
343
|
-
e.style.setProperty("transform-origin", "left center");
|
|
344
|
-
e.style.setProperty("display", "block");
|
|
345
|
-
e.setAttribute(STUDIO_ORIGINAL_ROTATE_ATTR, "0deg");
|
|
346
|
-
e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "0deg");
|
|
347
|
-
e.setAttribute(STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR, "center center");
|
|
348
|
-
e.setAttribute(STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, "flex");
|
|
349
|
-
return e;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
it("populated: captures rotation styles, attrs, and transform-display marker in declaration order", () => {
|
|
353
|
-
const ops = buildRotationPatches(populatedRotEl());
|
|
354
|
-
expect(ops).toEqual([
|
|
355
|
-
{ type: "inline-style", property: STUDIO_ROTATION_PROP, value: "45" },
|
|
356
|
-
{ type: "inline-style", property: "rotate", value: "45deg" },
|
|
357
|
-
{ type: "inline-style", property: "transform-origin", value: "left center" },
|
|
358
|
-
{ type: "inline-style", property: "display", value: "block" },
|
|
359
|
-
{ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" },
|
|
360
|
-
{ type: "attribute", property: STUDIO_ORIGINAL_ROTATE_ATTR, value: "0deg" },
|
|
361
|
-
{ type: "attribute", property: STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, value: "0deg" },
|
|
362
|
-
{
|
|
363
|
-
type: "attribute",
|
|
364
|
-
property: STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
|
|
365
|
-
value: "center center",
|
|
366
|
-
},
|
|
367
|
-
{ type: "attribute", property: STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR, value: "flex" },
|
|
368
|
-
]);
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
it("empty: bare element yields only the rotation marker", () => {
|
|
372
|
-
expect(buildRotationPatches(div())).toEqual([
|
|
373
|
-
{ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" },
|
|
374
|
-
]);
|
|
375
|
-
});
|
|
376
|
-
|
|
337
|
+
describe("buildClearRotationPatches", () => {
|
|
377
338
|
it("clear: restores rotate and transform-origin from orig attrs, nulls draft attr", () => {
|
|
378
339
|
const e = div();
|
|
379
340
|
e.setAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR, "30deg");
|
|
@@ -405,11 +366,6 @@ describe("buildRotationPatches / buildClearRotationPatches", () => {
|
|
|
405
366
|
const ops = buildClearRotationPatches(e);
|
|
406
367
|
expect(ops.find((o) => o.property === "rotate")?.value).toBeNull();
|
|
407
368
|
});
|
|
408
|
-
|
|
409
|
-
it("build/clear symmetry: clear addresses every {type,property} key that build emits", () => {
|
|
410
|
-
const e = populatedRotEl();
|
|
411
|
-
assertClearCoversKeys(buildRotationPatches(e), buildClearRotationPatches(e));
|
|
412
|
-
});
|
|
413
369
|
});
|
|
414
370
|
|
|
415
371
|
/* ── Motion ──────────────────────────────────────────────────────────────── */
|
|
@@ -118,7 +118,7 @@ export function buildClearPathOffsetPatches(element: HTMLElement): PatchOperatio
|
|
|
118
118
|
|
|
119
119
|
/* ── Box size patches ────────────────────────────────────────────── */
|
|
120
120
|
|
|
121
|
-
const BOX_SIZE_STYLE_PROPS = [
|
|
121
|
+
export const BOX_SIZE_STYLE_PROPS = [
|
|
122
122
|
"width",
|
|
123
123
|
"height",
|
|
124
124
|
"min-width",
|
|
@@ -185,28 +185,6 @@ export function buildClearBoxSizePatches(element: HTMLElement): PatchOperation[]
|
|
|
185
185
|
|
|
186
186
|
/* ── Rotation patches ────────────────────────────────────────────── */
|
|
187
187
|
|
|
188
|
-
const ROTATION_STYLE_PROPS = [
|
|
189
|
-
STUDIO_ROTATION_PROP,
|
|
190
|
-
"rotate",
|
|
191
|
-
"transform-origin",
|
|
192
|
-
"display",
|
|
193
|
-
] as const;
|
|
194
|
-
|
|
195
|
-
const ROTATION_ORIG_ATTRS = [
|
|
196
|
-
STUDIO_ORIGINAL_ROTATE_ATTR,
|
|
197
|
-
STUDIO_ORIGINAL_INLINE_ROTATE_ATTR,
|
|
198
|
-
STUDIO_ORIGINAL_ROTATION_TRANSFORM_ORIGIN_ATTR,
|
|
199
|
-
STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
|
|
200
|
-
] as const;
|
|
201
|
-
|
|
202
|
-
export function buildRotationPatches(element: HTMLElement): PatchOperation[] {
|
|
203
|
-
const ops: PatchOperation[] = [];
|
|
204
|
-
collectInlineStyleOps(element, ROTATION_STYLE_PROPS, ops);
|
|
205
|
-
ops.push({ type: "attribute", property: STUDIO_ROTATION_ATTR, value: "true" });
|
|
206
|
-
collectAttributeOps(element, ROTATION_ORIG_ATTRS, ops);
|
|
207
|
-
return ops;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
188
|
export function buildClearRotationPatches(element: HTMLElement): PatchOperation[] {
|
|
211
189
|
const origInlineRotate = element.getAttribute(STUDIO_ORIGINAL_INLINE_ROTATE_ATTR);
|
|
212
190
|
const origRotationTransformOrigin = element.getAttribute(
|
|
@@ -82,6 +82,8 @@ export function captureStudioBoxSize(element: HTMLElement): StudioBoxSizeSnapsho
|
|
|
82
82
|
export function captureStudioRotation(element: HTMLElement): StudioRotationSnapshot {
|
|
83
83
|
return {
|
|
84
84
|
rotate: element.style.getPropertyValue("rotate"),
|
|
85
|
+
transform: element.style.getPropertyValue("transform"),
|
|
86
|
+
display: element.style.getPropertyValue("display"),
|
|
85
87
|
transformOrigin: element.style.getPropertyValue("transform-origin"),
|
|
86
88
|
studioRotation: element.style.getPropertyValue(STUDIO_ROTATION_PROP),
|
|
87
89
|
marker: element.getAttribute(STUDIO_ROTATION_ATTR),
|
|
@@ -652,3 +652,44 @@ describe("a move of an element GSAP does not position", () => {
|
|
|
652
652
|
expect(member().plainTranslate).toBe(false);
|
|
653
653
|
});
|
|
654
654
|
});
|
|
655
|
+
|
|
656
|
+
describe("a resize's anchor member", () => {
|
|
657
|
+
function anchorMember(tweenVars?: Record<string, number>) {
|
|
658
|
+
const window = new Window();
|
|
659
|
+
const element = window.document.createElement("div");
|
|
660
|
+
element.id = "box";
|
|
661
|
+
window.document.body.append(element);
|
|
662
|
+
const tweens = tweenVars ? [{ vars: tweenVars, targets: () => [element] }] : [];
|
|
663
|
+
Object.assign(window, { __timelines: { main: { getChildren: () => tweens } } });
|
|
664
|
+
element.getBoundingClientRect = () => {
|
|
665
|
+
const [x = 0, y = 0] = element.style
|
|
666
|
+
.getPropertyValue("translate")
|
|
667
|
+
.split(" ")
|
|
668
|
+
.map((v) => Number.parseFloat(v));
|
|
669
|
+
return new window.DOMRect(10 + (x || 0), 20 + (y || 0), 100, 50);
|
|
670
|
+
};
|
|
671
|
+
const result = createManualOffsetDragMember({
|
|
672
|
+
key: "box",
|
|
673
|
+
selection: { element } as never,
|
|
674
|
+
element,
|
|
675
|
+
rect: { left: 10, top: 20, width: 100, height: 50, editScaleX: 1, editScaleY: 1 },
|
|
676
|
+
gesture: "resize",
|
|
677
|
+
});
|
|
678
|
+
if (!result.ok) throw new Error("member");
|
|
679
|
+
return result.member;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
it("keeps the centre with the element's own plain translate when GSAP does not own the box", () => {
|
|
683
|
+
const member = anchorMember();
|
|
684
|
+
expect(member.plainTranslate).toBe(true);
|
|
685
|
+
applyManualOffsetDragDraft(member, -10.25, 3.5);
|
|
686
|
+
expect(member.element.style.getPropertyValue("translate")).toBe("-10.25px 3.5px");
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it.each(["width", "maxWidth", "minHeight", "flexBasis", "scale", "scaleX"])(
|
|
690
|
+
"leaves a box whose %s GSAP tweens, which the CSS box writer also sets, to the GSAP writer",
|
|
691
|
+
(channel) => {
|
|
692
|
+
expect(anchorMember({ [channel]: 300 }).plainTranslate).toBe(false);
|
|
693
|
+
},
|
|
694
|
+
);
|
|
695
|
+
});
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
type StudioPathOffsetSnapshot,
|
|
12
12
|
} from "./manualEdits";
|
|
13
13
|
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
|
|
14
|
-
import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
|
|
14
|
+
import { gsapWritesBox, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
|
|
15
15
|
import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
|
|
16
16
|
|
|
17
17
|
interface OffsetDragGsap {
|
|
@@ -19,7 +19,7 @@ interface OffsetDragGsap {
|
|
|
19
19
|
getProperty: (el: Element, prop: string) => number;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null {
|
|
22
|
+
export function getOffsetDragGsap(element: HTMLElement): OffsetDragGsap | null {
|
|
23
23
|
const win = element.ownerDocument.defaultView as
|
|
24
24
|
| (Window & { gsap?: Partial<OffsetDragGsap> })
|
|
25
25
|
| null;
|
|
@@ -53,27 +53,6 @@ function applyOffsetDragDraftViaGsap(
|
|
|
53
53
|
return true;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/**
|
|
57
|
-
* Live rotation preview through the GSAP channel — the SAME channel the commit
|
|
58
|
-
* lands in (a `tl.set`/keyframe rotation), mirroring `applyOffsetDragDraftViaGsap`.
|
|
59
|
-
* GSAP owns the transform rotation, so neutralize the CSS `rotate` longhand to keep
|
|
60
|
-
* the two channels from composing. `angle` is the absolute target rotation. Returns
|
|
61
|
-
* false when gsap is unavailable (caller falls back to the CSS draft).
|
|
62
|
-
*/
|
|
63
|
-
export function applyRotationDraftViaGsap(element: HTMLElement, angle: number): boolean {
|
|
64
|
-
const gsap = getOffsetDragGsap(element);
|
|
65
|
-
if (!gsap) return false;
|
|
66
|
-
element.style.setProperty("rotate", "none");
|
|
67
|
-
gsap.set(element, { rotation: angle });
|
|
68
|
-
return true;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Current GSAP transform rotation — the single-source rotation base. 0 if gsap is unavailable. */
|
|
72
|
-
export function readGsapRotation(element: HTMLElement): number {
|
|
73
|
-
const gsap = getOffsetDragGsap(element);
|
|
74
|
-
return gsap ? Number(gsap.getProperty(element, "rotation")) || 0 : 0;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
56
|
const DEFAULT_OFFSET_PROBE_PX = 100;
|
|
78
57
|
const MIN_PROBE_VECTOR_LENGTH_PX = 0.01;
|
|
79
58
|
const MIN_MATRIX_DETERMINANT = 0.000001;
|
|
@@ -337,11 +316,11 @@ export function createManualOffsetDragMember(input: {
|
|
|
337
316
|
selection: DomEditSelection;
|
|
338
317
|
element: HTMLElement;
|
|
339
318
|
rect: ManualOffsetDragRect;
|
|
340
|
-
gesture?: "drag" | "nudge"; //
|
|
319
|
+
gesture?: "drag" | "nudge" | "resize"; // resize: the anchor that keeps its centre planted
|
|
341
320
|
}): ManualOffsetDragMemberResult {
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
// so a stale
|
|
321
|
+
const gsapOwns = input.gesture === "resize" ? gsapWritesBox : gsapWritesPosition;
|
|
322
|
+
const plainTranslate = !!input.gesture && !gsapOwns(input.element);
|
|
323
|
+
// The APPLIED offset, never a dormant var, so a stale one can't fling the element off-screen.
|
|
345
324
|
const initialOffset = plainTranslate
|
|
346
325
|
? readTranslatePx(input.element)
|
|
347
326
|
: readAppliedStudioPathOffset(input.element);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { roundTo3 } from "../../utils/rounding";
|
|
2
|
-
import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
|
|
3
|
-
import {
|
|
2
|
+
import { gsapWritesPosition, gsapWritesRotation } from "../../hooks/gsapRuntimeKeyframes";
|
|
3
|
+
import { readCssRotation } from "../../hooks/draggedGsapPosition";
|
|
4
|
+
import { readStudioPathOffset, readStudioRotation } from "./manualEditsDom";
|
|
4
5
|
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
|
5
6
|
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
6
7
|
|
|
@@ -110,6 +111,10 @@ export function writeTranslatePx(el: HTMLElement, p: Point): void {
|
|
|
110
111
|
}
|
|
111
112
|
|
|
112
113
|
/** The position the panel shows and edits: the translate a move writes, unless GSAP positions it. */
|
|
113
|
-
export function readMoveOffset(el: HTMLElement): Point {
|
|
114
|
-
return
|
|
114
|
+
export function readMoveOffset(el: HTMLElement, plainTranslate = !gsapWritesPosition(el)): Point {
|
|
115
|
+
return plainTranslate ? readTranslatePx(el) : readStudioPathOffset(el);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function readShownRotation(el: HTMLElement): { angle: number } {
|
|
119
|
+
return gsapWritesRotation(el) ? readStudioRotation(el) : { angle: roundTo3(readCssRotation(el)) };
|
|
115
120
|
}
|
|
@@ -106,3 +106,32 @@ describe("the panel's position fields on a translate Studio can't read", () => {
|
|
|
106
106
|
expect(showToast).toHaveBeenCalledWith(UNREADABLE_TRANSLATE);
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
|
+
|
|
110
|
+
describe("the panel's position fields", () => {
|
|
111
|
+
it("read and move an element without GSAP by its CSS translate, on the route they chose", async () => {
|
|
112
|
+
const onSetManualOffset = vi.fn();
|
|
113
|
+
const box = document.createElement("div");
|
|
114
|
+
box.style.setProperty("translate", "40px 30px");
|
|
115
|
+
const element = { id: "box", selector: "#box", element: box } as unknown as DomEditSelection;
|
|
116
|
+
const handlers = createTransformCommitHandlers({
|
|
117
|
+
element,
|
|
118
|
+
styles: {},
|
|
119
|
+
hasGsapAnimation: false,
|
|
120
|
+
gsapAnimId: null,
|
|
121
|
+
gsapKeyframes: null,
|
|
122
|
+
currentPct: 0,
|
|
123
|
+
onCommitAnimatedProperty: undefined,
|
|
124
|
+
onAddKeyframe: undefined,
|
|
125
|
+
onSetManualOffset,
|
|
126
|
+
onSetManualSize: vi.fn(),
|
|
127
|
+
onSetManualRotation: vi.fn(),
|
|
128
|
+
showToast: vi.fn(),
|
|
129
|
+
});
|
|
130
|
+
await handlers.commitManualOffset("x", "100px");
|
|
131
|
+
expect(onSetManualOffset).toHaveBeenCalledWith(
|
|
132
|
+
element,
|
|
133
|
+
{ x: 100, y: 30 },
|
|
134
|
+
{ plainTranslate: true },
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
|
|
1
2
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
2
3
|
import { readStudioBoxSize } from "./manualEdits";
|
|
3
4
|
import { readMoveOffset, UNREADABLE_TRANSLATE } from "./plainTranslate";
|
|
@@ -74,14 +75,16 @@ export function createTransformCommitHandlers({
|
|
|
74
75
|
)
|
|
75
76
|
)
|
|
76
77
|
return;
|
|
77
|
-
const
|
|
78
|
+
const plainTranslate = !gsapWritesPosition(element.element);
|
|
79
|
+
const current = readMoveOffset(element.element, plainTranslate);
|
|
78
80
|
if (!Number.isFinite(current.x) || !Number.isFinite(current.y))
|
|
79
81
|
return void showToast?.(UNREADABLE_TRANSLATE);
|
|
80
82
|
await Promise.resolve(
|
|
81
|
-
onSetManualOffset(
|
|
82
|
-
|
|
83
|
-
y: axis === "y" ? parsed : current.y,
|
|
84
|
-
|
|
83
|
+
onSetManualOffset(
|
|
84
|
+
element,
|
|
85
|
+
{ x: axis === "x" ? parsed : current.x, y: axis === "y" ? parsed : current.y },
|
|
86
|
+
{ plainTranslate },
|
|
87
|
+
),
|
|
85
88
|
);
|
|
86
89
|
};
|
|
87
90
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
4
|
+
import { savePlainRotation } from "../../hooks/plainRotation";
|
|
5
|
+
import { applyStudioRotation } from "./manualEdits";
|
|
6
|
+
import type { ElementOffsetStagerDeps } from "../../hooks/elementOffsetStager";
|
|
7
|
+
import type { DomEditSelection } from "./domEditing";
|
|
8
|
+
import type { GestureState, UseDomEditOverlayGesturesOptions } from "./domEditOverlayGestures";
|
|
9
|
+
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
10
|
+
|
|
11
|
+
const ref = <T>(current: T) => ({ current });
|
|
12
|
+
|
|
13
|
+
function evt(clientX: number, clientY: number) {
|
|
14
|
+
return {
|
|
15
|
+
clientX,
|
|
16
|
+
clientY,
|
|
17
|
+
pointerId: 1,
|
|
18
|
+
button: 0,
|
|
19
|
+
altKey: false,
|
|
20
|
+
shiftKey: false,
|
|
21
|
+
preventDefault() {},
|
|
22
|
+
stopPropagation() {},
|
|
23
|
+
currentTarget: { setPointerCapture() {} },
|
|
24
|
+
} as unknown as React.PointerEvent<HTMLDivElement>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
document.head.innerHTML = "";
|
|
29
|
+
document.body.innerHTML = "";
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
async function rotateWord(before: (word: HTMLElement) => void = () => {}) {
|
|
33
|
+
const sheet = document.head.appendChild(document.createElement("style"));
|
|
34
|
+
sheet.textContent = "#word { display: inline; }";
|
|
35
|
+
const element = document.body.appendChild(document.createElement("span"));
|
|
36
|
+
element.id = "word";
|
|
37
|
+
element.textContent = "Word";
|
|
38
|
+
const selection = {
|
|
39
|
+
element,
|
|
40
|
+
id: "word",
|
|
41
|
+
selector: "#word",
|
|
42
|
+
selectorIndex: 0,
|
|
43
|
+
sourceFile: "index.html",
|
|
44
|
+
tagName: "span",
|
|
45
|
+
label: "Word",
|
|
46
|
+
textContent: "Word",
|
|
47
|
+
textFields: [],
|
|
48
|
+
capabilities: { canMove: true, canApplyManualRotation: true },
|
|
49
|
+
} as unknown as DomEditSelection;
|
|
50
|
+
const commitPositionPatchToHtml = vi.fn<ElementOffsetStagerDeps["commitPositionPatchToHtml"]>(
|
|
51
|
+
() => Promise.resolve(),
|
|
52
|
+
);
|
|
53
|
+
const saves: Promise<void>[] = [];
|
|
54
|
+
const opts: UseDomEditOverlayGesturesOptions = {
|
|
55
|
+
overlayRef: ref<HTMLDivElement | null>(document.createElement("div")),
|
|
56
|
+
iframeRef: ref<HTMLIFrameElement | null>(document.createElement("iframe")),
|
|
57
|
+
boxRef: ref<HTMLDivElement | null>(document.createElement("div")),
|
|
58
|
+
selectionRef: ref<DomEditSelection | null>(selection),
|
|
59
|
+
hoverSelectionRef: ref<DomEditSelection | null>(null),
|
|
60
|
+
overlayRectRef: ref({ left: 0, top: 0, width: 200, height: 100, editScaleX: 1, editScaleY: 1 }),
|
|
61
|
+
groupOverlayItemsRef: ref([]),
|
|
62
|
+
gestureRef: ref<GestureState | null>(null),
|
|
63
|
+
groupGestureRef: ref(null),
|
|
64
|
+
blockedMoveRef: ref(null),
|
|
65
|
+
rafPausedRef: ref(false),
|
|
66
|
+
suppressNextBoxClickRef: ref(false),
|
|
67
|
+
setOverlayRect: () => {},
|
|
68
|
+
setGroupOverlayItems: () => {},
|
|
69
|
+
onBlockedMoveRef: ref(() => {}),
|
|
70
|
+
onManualDragStartRef: ref(() => {}),
|
|
71
|
+
onPathOffsetCommitRef: ref(() => {}),
|
|
72
|
+
onGroupPathOffsetCommitRef: ref(() => {}),
|
|
73
|
+
onBoxSizeCommitRef: ref(() => {}),
|
|
74
|
+
onRotationCommitRef: ref((s, next) => {
|
|
75
|
+
const saved = savePlainRotation({ commitPositionPatchToHtml }, s, next);
|
|
76
|
+
saves.push(saved);
|
|
77
|
+
return saved;
|
|
78
|
+
}),
|
|
79
|
+
onCanvasPointerMoveRef: ref(() => Promise.resolve(null)),
|
|
80
|
+
onCanvasMouseDown: () => {},
|
|
81
|
+
snapGuidesRef: ref(null),
|
|
82
|
+
} as unknown as UseDomEditOverlayGesturesOptions;
|
|
83
|
+
before(element);
|
|
84
|
+
const handlers = createDomEditOverlayGestureHandlers(opts);
|
|
85
|
+
|
|
86
|
+
handlers.startGesture("rotate", evt(200, 50));
|
|
87
|
+
handlers.onPointerMove(evt(100, 150));
|
|
88
|
+
handlers.onPointerUp(evt(100, 150));
|
|
89
|
+
await Promise.all(saves);
|
|
90
|
+
|
|
91
|
+
expect(saves).toHaveLength(1);
|
|
92
|
+
return commitPositionPatchToHtml.mock.calls[0]![1];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const INLINE_BLOCK = { type: "inline-style", property: "display", value: "inline-block" };
|
|
96
|
+
const turnOf = (patches: Awaited<ReturnType<typeof rotateWord>>) =>
|
|
97
|
+
patches.findLast((patch) => patch.type === "inline-style" && patch.property === "rotate");
|
|
98
|
+
|
|
99
|
+
it("saves an inline span's turn with the inline-block it was drawn with, after the draft and the hold", async () => {
|
|
100
|
+
expect(await rotateWord()).toContainEqual(INLINE_BLOCK);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("saves inline-block and the whole turn for a word an older Studio already turned 15 deg", async () => {
|
|
104
|
+
const plain = await rotateWord();
|
|
105
|
+
document.head.innerHTML = "";
|
|
106
|
+
document.body.innerHTML = "";
|
|
107
|
+
const legacy = await rotateWord((word) => applyStudioRotation(word, { angle: 15 }));
|
|
108
|
+
expect(legacy).toContainEqual(INLINE_BLOCK);
|
|
109
|
+
const degrees = (patches: typeof plain) => Number.parseFloat(String(turnOf(patches)?.value));
|
|
110
|
+
expect(degrees(legacy)).toBe(degrees(plain) + 15);
|
|
111
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Window } from "happy-dom";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { applyRotationDraft, readCssRotationTarget, readRotationBase } from "./rotationDraft";
|
|
4
|
+
|
|
5
|
+
describe("rotate in a composition without GSAP", () => {
|
|
6
|
+
it("starts from the authored CSS rotation and drafts the absolute angle the commit writes", () => {
|
|
7
|
+
const window = new Window();
|
|
8
|
+
window.document.head.innerHTML = "<style>#title { rotate: 30deg; }</style>";
|
|
9
|
+
const element = window.document.createElement("h1");
|
|
10
|
+
element.id = "title";
|
|
11
|
+
window.document.body.append(element);
|
|
12
|
+
const base = readRotationBase(element, true);
|
|
13
|
+
expect(base).toBeCloseTo(30);
|
|
14
|
+
applyRotationDraft(element, 55, readCssRotationTarget(element));
|
|
15
|
+
expect(element.style.getPropertyValue("rotate")).toBe("55deg");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("drafts only the part of the angle the CSS rotate owns, leaving transform and scale theirs", () => {
|
|
19
|
+
const cases: Array<[string, number, number]> = [
|
|
20
|
+
["transform: rotate(30deg);", 30, 25],
|
|
21
|
+
["rotate: 20deg; transform: rotate(10deg);", 30, 45],
|
|
22
|
+
["scale: -1 1;", 180, 25],
|
|
23
|
+
["transform: scaleX(-1);", 180, 25],
|
|
24
|
+
];
|
|
25
|
+
for (const [css, base, drafted] of cases) {
|
|
26
|
+
const window = new Window();
|
|
27
|
+
window.document.head.innerHTML = `<style>#title { ${css} }</style>`;
|
|
28
|
+
const element = window.document.createElement("h1");
|
|
29
|
+
element.id = "title";
|
|
30
|
+
window.document.body.append(element);
|
|
31
|
+
expect({ css, base: readRotationBase(element, true) }).toEqual({
|
|
32
|
+
css,
|
|
33
|
+
base: expect.closeTo(base),
|
|
34
|
+
});
|
|
35
|
+
applyRotationDraft(element, base + 25, readCssRotationTarget(element));
|
|
36
|
+
const rotate = Number.parseFloat(element.style.getPropertyValue("rotate"));
|
|
37
|
+
expect({ css, rotate }).toEqual({ css, rotate: expect.closeTo(drafted) });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("never asks GSAP, nor restyles per frame, when GSAP turns nothing on the element", () => {
|
|
42
|
+
const window = new Window();
|
|
43
|
+
const gsap = { set: vi.fn(), getProperty: vi.fn(() => 0) };
|
|
44
|
+
Object.assign(window, { gsap });
|
|
45
|
+
window.document.head.innerHTML = "<style>#title { rotate: 30deg; }</style>";
|
|
46
|
+
const element = window.document.createElement("h1");
|
|
47
|
+
element.id = "title";
|
|
48
|
+
window.document.body.append(element);
|
|
49
|
+
expect(readRotationBase(element, true)).toBeCloseTo(30);
|
|
50
|
+
const target = readCssRotationTarget(element);
|
|
51
|
+
const styleReads = vi.spyOn(window, "getComputedStyle");
|
|
52
|
+
applyRotationDraft(element, 55, target);
|
|
53
|
+
expect(styleReads).not.toHaveBeenCalled();
|
|
54
|
+
expect(element.style.getPropertyValue("rotate")).toBe("55deg");
|
|
55
|
+
expect(gsap.set).not.toHaveBeenCalled();
|
|
56
|
+
expect(gsap.getProperty).not.toHaveBeenCalled();
|
|
57
|
+
applyRotationDraft(element, 55, null);
|
|
58
|
+
expect(element.style.getPropertyValue("rotate")).toBe("none");
|
|
59
|
+
expect(gsap.set).toHaveBeenCalledWith(element, { rotation: 55 });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("turns a transform-centred element inside its transform, after the translate, so it stays put", () => {
|
|
63
|
+
const window = new Window();
|
|
64
|
+
window.document.head.innerHTML =
|
|
65
|
+
"<style>#title { rotate: 30deg; transform: translate(-120px, -80px); }</style>";
|
|
66
|
+
const element = window.document.createElement("h1");
|
|
67
|
+
element.id = "title";
|
|
68
|
+
window.document.body.append(element);
|
|
69
|
+
const target = readCssRotationTarget(element);
|
|
70
|
+
expect(target).toEqual({
|
|
71
|
+
property: "transform",
|
|
72
|
+
before: "translate(-120px, -80px)",
|
|
73
|
+
after: "",
|
|
74
|
+
share: expect.closeTo(30),
|
|
75
|
+
sign: 1,
|
|
76
|
+
inline: false,
|
|
77
|
+
});
|
|
78
|
+
applyRotationDraft(element, 55, target);
|
|
79
|
+
expect(element.style.getPropertyValue("transform")).toBe(
|
|
80
|
+
"translate(-120px, -80px) rotate(25deg)",
|
|
81
|
+
);
|
|
82
|
+
expect(element.style.getPropertyValue("rotate")).toBe("");
|
|
83
|
+
// The next rotate replaces its own turn instead of stacking a second one.
|
|
84
|
+
applyRotationDraft(element, 70, readCssRotationTarget(element));
|
|
85
|
+
expect(element.style.getPropertyValue("transform")).toBe(
|
|
86
|
+
"translate(-120px, -80px) rotate(40deg)",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Each case turns 25 deg with the cursor: the fold GSAP and the outline read goes up by 25, unsheared.
|
|
91
|
+
it.each([
|
|
92
|
+
["a mirrored transform", "transform: translate(-120px, -80px) scaleX(-1);", 180],
|
|
93
|
+
["a stretched transform", "transform: translate(-120px, -80px) scale(2, 1);", 0],
|
|
94
|
+
["a mirroring scale property", "scale: -1 1; transform: translate(-120px, -80px);", 180],
|
|
95
|
+
])("turns %s centred element with the cursor, in place", (_, css, base) => {
|
|
96
|
+
const window = new Window();
|
|
97
|
+
window.document.head.innerHTML = `<style>#title { ${css} }</style>`;
|
|
98
|
+
const element = window.document.createElement("h1");
|
|
99
|
+
element.id = "title";
|
|
100
|
+
window.document.body.append(element);
|
|
101
|
+
const turned = (angle: number) => (((angle % 360) + 360) % 360) % 360;
|
|
102
|
+
expect(turned(readRotationBase(element, true))).toBeCloseTo(base);
|
|
103
|
+
applyRotationDraft(element, base + 25, readCssRotationTarget(element));
|
|
104
|
+
const transform = element.style.getPropertyValue("transform");
|
|
105
|
+
expect(transform.startsWith("translate(-120px, -80px) rotate(")).toBe(true);
|
|
106
|
+
expect(turned(readRotationBase(element, true))).toBeCloseTo(base + 25);
|
|
107
|
+
const m = new window.DOMMatrix(window.getComputedStyle(element).transform);
|
|
108
|
+
expect(m.a * m.c + m.b * m.d).toBeCloseTo(0);
|
|
109
|
+
expect([m.e, m.f]).toEqual([-120, -80]);
|
|
110
|
+
});
|
|
111
|
+
});
|