@hyperframes/studio 0.8.98 → 0.8.99
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-C6muFtfk.js → hyperframes-player-Rml34FRW.js} +1 -1
- package/dist/assets/{index-itc1XziP.js → index-CVDArg3m.js} +178 -178
- package/dist/assets/{index-B6vU39NM.js → index-C_fi-jTe.js} +1 -1
- package/dist/assets/{index-BxnoLgxG.js → index-D9YzDuBx.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +542 -373
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/components/editor/DomEditCropHandles.test.tsx +34 -66
- package/src/components/editor/DomEditCropHandles.tsx +30 -62
- package/src/components/editor/DomEditOverlay.test.ts +2 -2
- package/src/components/editor/cropResize.test.ts +129 -0
- package/src/components/editor/cropResize.ts +143 -0
- package/src/components/editor/domEditOverlayCrop.ts +65 -15
- package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
- package/src/components/editor/domEditOverlayGestures.ts +2 -5
- package/src/components/editor/elementPositionOffset.ts +5 -4
- package/src/components/editor/manualEdits.test.ts +22 -8
- package/src/components/editor/manualEditsDom.ts +27 -11
- package/src/components/editor/manualEditsParsing.ts +0 -4
- package/src/components/editor/manualEditsSnapshot.ts +3 -0
- package/src/components/editor/manualOffsetDrag.test.ts +14 -0
- package/src/hooks/domSelectionTestHarness.ts +8 -0
- package/src/hooks/draggedGsapPosition.ts +4 -2
- package/src/hooks/gsapDragCommit.ts +6 -6
- package/src/hooks/gsapEditOutcome.ts +6 -0
- package/src/hooks/gsapResizeIntercept.ts +5 -5
- package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
- package/src/hooks/gsapRuntimeBridge.ts +2 -1
- package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +34 -21
- package/src/hooks/timelineTimingSync.ts +40 -4
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
- package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
- package/src/hooks/useCropOverlay.ts +8 -2
- package/src/hooks/useDomEditCommits.test.tsx +6 -1
- package/src/hooks/useDomEditCommits.ts +1 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommit.ts +1 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +112 -0
- package/src/hooks/useDomGeometryCommits.ts +5 -0
- package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +101 -5
- package/src/hooks/useGsapAwareEditing.ts +37 -23
- package/src/hooks/useTimelineEditing.test.tsx +190 -135
- package/src/hooks/useTimelineEditing.ts +13 -13
- package/src/hooks/useTimelineGroupEditing.ts +13 -9
- package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
- package/src/utils/rounding.ts +5 -0
- package/src/utils/sdkEditTransaction.ts +10 -3
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { composeElementTransform, type PlanarTransformOps } from "./domEditOverlayTransform";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
parseInsetClipPathSides,
|
|
4
|
+
type ClipPathInsetSides,
|
|
5
|
+
type ParsedInsetClipPathSides,
|
|
6
|
+
} from "./clipPathHelpers";
|
|
3
7
|
|
|
4
8
|
export type CropEdge = "top" | "right" | "bottom" | "left";
|
|
5
9
|
|
|
@@ -29,23 +33,69 @@ export function cropRectFromInsets(
|
|
|
29
33
|
};
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
element:
|
|
41
|
-
|
|
36
|
+
// A selected croppable element shows uncropped so the crop UI can dim what is cut away. A rule keyed on its
|
|
37
|
+
// identity does that, so the element's own clip-path stays the one record undo, redo and panel edits rewrite.
|
|
38
|
+
const cropLifts = new WeakMap<HTMLElement, { rule: HTMLStyleElement; sheetClip?: string }>();
|
|
39
|
+
|
|
40
|
+
function liftSelector(element: HTMLElement): string | null {
|
|
41
|
+
const quote = (value: string) => `"${value.replace(/["\\]/g, "\\$&")}"`;
|
|
42
|
+
const hfId = element.getAttribute("data-hf-id");
|
|
43
|
+
if (hfId) return `[data-hf-id=${quote(hfId)}]`;
|
|
44
|
+
return element.id ? `[id=${quote(element.id)}]` : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function liftElementCrop(element: HTMLElement): void {
|
|
48
|
+
const selector = liftSelector(element);
|
|
49
|
+
if (!selector || cropLifts.has(element)) return;
|
|
50
|
+
const doc = element.ownerDocument;
|
|
51
|
+
const rule = doc.createElement("style");
|
|
52
|
+
rule.textContent = `${selector}{clip-path:none!important}`;
|
|
53
|
+
(doc.head ?? doc.documentElement).append(rule);
|
|
54
|
+
cropLifts.set(element, { rule });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function dropElementCropLift(element: HTMLElement): void {
|
|
58
|
+
cropLifts.get(element)?.rule.remove();
|
|
59
|
+
cropLifts.delete(element);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isElementCropLifted(element: HTMLElement): boolean {
|
|
63
|
+
return cropLifts.has(element);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const computedClipPath = (element: HTMLElement) =>
|
|
67
|
+
element.ownerDocument.defaultView?.getComputedStyle(element).clipPath.trim() || "";
|
|
68
|
+
|
|
69
|
+
/** The element's clip-path, inline first, then the stylesheet's as it is without the lift. */
|
|
70
|
+
function readElementClipPath(element: HTMLElement): string {
|
|
42
71
|
const inline = element.style.getPropertyValue("clip-path").trim();
|
|
43
|
-
const
|
|
44
|
-
|
|
72
|
+
const lift = cropLifts.get(element);
|
|
73
|
+
if (inline || !lift) return inline || computedClipPath(element);
|
|
74
|
+
// Only inline writes happen while selected, so the stylesheet's clip is read once per lift.
|
|
75
|
+
if (lift.sheetClip === undefined) {
|
|
76
|
+
const { parentNode, nextSibling } = lift.rule;
|
|
77
|
+
lift.rule.remove();
|
|
78
|
+
lift.sheetClip = computedClipPath(element);
|
|
79
|
+
parentNode?.insertBefore(lift.rule, nextSibling);
|
|
80
|
+
}
|
|
81
|
+
return lift.sheetClip;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Zeros = no clip yet. `null` = a clip this tool cannot represent (circle/polygon/non-px inset):
|
|
85
|
+
* croppers must not lift, edit, or restore it, or deselect silently replaces or destroys it. */
|
|
86
|
+
function parseCropClipPath(value: string): ParsedInsetClipPathSides | null {
|
|
45
87
|
if (!value || value === "none") return { top: 0, right: 0, bottom: 0, left: 0, radius: 0 };
|
|
46
88
|
return parseInsetClipPathSides(value);
|
|
47
89
|
}
|
|
48
90
|
|
|
91
|
+
export function readElementCropInsets(element: HTMLElement): ParsedInsetClipPathSides | null {
|
|
92
|
+
return parseCropClipPath(readElementClipPath(element));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function hasCropInsets(insets: ClipPathInsetSides): boolean {
|
|
96
|
+
return insets.top > 0 || insets.right > 0 || insets.bottom > 0 || insets.left > 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
49
99
|
export interface CropInsetDragInput {
|
|
50
100
|
edge: CropEdge;
|
|
51
101
|
startInsets: ClipPathInsetSides;
|
|
@@ -117,8 +167,8 @@ export function hugRectForElement(
|
|
|
117
167
|
rect: CropScreenRect & { editScaleX: number; editScaleY: number },
|
|
118
168
|
element: HTMLElement,
|
|
119
169
|
): CropScreenRect {
|
|
120
|
-
const insets = readElementCropInsets(element);
|
|
121
|
-
// Uneditable
|
|
170
|
+
const insets = isElementCropLifted(element) ? null : readElementCropInsets(element);
|
|
171
|
+
// Uneditable or lifted clip — show the full element rect.
|
|
122
172
|
if (!insets || (insets.top <= 0 && insets.right <= 0 && insets.bottom <= 0 && insets.left <= 0))
|
|
123
173
|
return rect;
|
|
124
174
|
return cropRectFromInsets(rect, insets, rect.editScaleX, rect.editScaleY);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
dropElementCropLift,
|
|
5
|
+
hugRectForElement,
|
|
6
|
+
liftElementCrop,
|
|
7
|
+
readElementCropInsets,
|
|
8
|
+
} from "./domEditOverlayCrop";
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
document.head.innerHTML = "";
|
|
12
|
+
document.body.innerHTML = "";
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function cropped(style: string): HTMLElement {
|
|
16
|
+
const el = document.createElement("div");
|
|
17
|
+
el.setAttribute("data-hf-id", "hf-a");
|
|
18
|
+
el.style.cssText = style;
|
|
19
|
+
document.body.append(el);
|
|
20
|
+
return el;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("crop lift", () => {
|
|
24
|
+
it("shows the element uncropped over its inline clip, and drop takes the rule away", () => {
|
|
25
|
+
const el = cropped("clip-path: inset(0px 60px 0px 0px)");
|
|
26
|
+
liftElementCrop(el);
|
|
27
|
+
expect(getComputedStyle(el).clipPath).toBe("none");
|
|
28
|
+
expect(el.style.getPropertyValue("clip-path")).toBe("inset(0px 60px 0px 0px)");
|
|
29
|
+
dropElementCropLift(el);
|
|
30
|
+
expect(document.head.querySelectorAll("style")).toHaveLength(0);
|
|
31
|
+
expect(getComputedStyle(el).clipPath).toBe("inset(0px 60px 0px 0px)");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("still reads a stylesheet crop while lifted, and the hover hug shows the full box", () => {
|
|
35
|
+
const sheet = document.createElement("style");
|
|
36
|
+
sheet.textContent = '[data-hf-id="hf-a"] { clip-path: inset(0px 60px 0px 0px); }';
|
|
37
|
+
document.head.append(sheet);
|
|
38
|
+
const el = cropped("width: 300px");
|
|
39
|
+
liftElementCrop(el);
|
|
40
|
+
expect(readElementCropInsets(el)).toMatchObject({ right: 60 });
|
|
41
|
+
const rect = { left: 0, top: 0, width: 300, height: 200, editScaleX: 1, editScaleY: 1 };
|
|
42
|
+
expect(hugRectForElement(rect, el)).toEqual(rect);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -11,6 +11,7 @@ import type { SnapContext } from "./snapTargetCollection";
|
|
|
11
11
|
import type { SnapGuidesState } from "./SnapGuideOverlay";
|
|
12
12
|
import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction";
|
|
13
13
|
import { logSelect } from "../../utils/selectDebug";
|
|
14
|
+
import { roundTo3 } from "../../utils/rounding";
|
|
14
15
|
|
|
15
16
|
export type GestureKind = "drag" | "resize" | "rotate";
|
|
16
17
|
|
|
@@ -197,10 +198,6 @@ function normalizeAngleDelta(delta: number): number {
|
|
|
197
198
|
return ((((delta + 180) % 360) + 360) % 360) - 180;
|
|
198
199
|
}
|
|
199
200
|
|
|
200
|
-
function roundAngle(angle: number): number {
|
|
201
|
-
return Math.round(angle * 10) / 10;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
201
|
export function resolveDomEditRotationGesture(input: {
|
|
205
202
|
centerX: number;
|
|
206
203
|
centerY: number;
|
|
@@ -223,7 +220,7 @@ export function resolveDomEditRotationGesture(input: {
|
|
|
223
220
|
return {
|
|
224
221
|
angle: input.snap
|
|
225
222
|
? Math.round(angle / ROTATION_SNAP_DEGREES) * ROTATION_SNAP_DEGREES
|
|
226
|
-
:
|
|
223
|
+
: roundTo3(angle),
|
|
227
224
|
};
|
|
228
225
|
}
|
|
229
226
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
2
2
|
import { LAYER_REVEAL_PRIOR_POSITION_ATTR } from "../../player/lib/timelineElementHelpers";
|
|
3
|
+
import { roundTo3 } from "../../utils/rounding";
|
|
3
4
|
|
|
4
5
|
interface LayoutBox {
|
|
5
6
|
left: number;
|
|
@@ -30,8 +31,8 @@ export function applyElementPositionOffset(
|
|
|
30
31
|
): PatchOperation[] | ElementOffsetRefusal {
|
|
31
32
|
const cs = el.ownerDocument.defaultView?.getComputedStyle(el);
|
|
32
33
|
if (!cs) return "anchored";
|
|
33
|
-
const dx =
|
|
34
|
-
const dy =
|
|
34
|
+
const dx = roundTo3(gestureOffset.x - readDragStart(el, "x"));
|
|
35
|
+
const dy = roundTo3(gestureOffset.y - readDragStart(el, "y"));
|
|
35
36
|
// A Layers-panel pick lifts a static element to relative for display only.
|
|
36
37
|
const lifted = el.getAttribute(LAYER_REVEAL_PRIOR_POSITION_ATTR) === "static";
|
|
37
38
|
const isStatic = lifted || !/^(relative|absolute|fixed|sticky)$/.test(cs.position);
|
|
@@ -41,8 +42,8 @@ export function applyElementPositionOffset(
|
|
|
41
42
|
const previous = { position: el.style.position, left: el.style.left, top: el.style.top };
|
|
42
43
|
const before = layoutBox(el);
|
|
43
44
|
if (isStatic) el.style.position = "relative";
|
|
44
|
-
el.style.left = `${
|
|
45
|
-
el.style.top = `${
|
|
45
|
+
el.style.left = `${roundTo3(baseLeft + dx)}px`;
|
|
46
|
+
el.style.top = `${roundTo3(baseTop + dy)}px`;
|
|
46
47
|
const after = layoutBox(el);
|
|
47
48
|
const shiftedExactly =
|
|
48
49
|
Math.abs(after.left - before.left - dx) <= 1 &&
|
|
@@ -146,6 +146,20 @@ describe("studio manual edits", () => {
|
|
|
146
146
|
expect(card.style.getPropertyValue("rotate")).toBe(`var(${STUDIO_ROTATION_PROP}, 0deg)`);
|
|
147
147
|
});
|
|
148
148
|
|
|
149
|
+
it("keeps sub-pixel offsets, and box sizes on whole px so GSAP reads a -50% centring exactly", () => {
|
|
150
|
+
const document = createDocument(`<div id="card" style="width: 160px; height: 90px"></div>`);
|
|
151
|
+
const card = document.getElementById("card") as HTMLElement;
|
|
152
|
+
mockBoundingRect(card, 160, 90);
|
|
153
|
+
|
|
154
|
+
applyStudioPathOffsetDraft(card, { x: 14.25, y: -8.5 });
|
|
155
|
+
applyStudioBoxSizeDraft(card, { width: 240.4, height: 135.6 });
|
|
156
|
+
|
|
157
|
+
expect(card.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("14.25px");
|
|
158
|
+
expect(card.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("-8.5px");
|
|
159
|
+
expect(card.style.getPropertyValue("width")).toBe("240px");
|
|
160
|
+
expect(card.style.getPropertyValue("height")).toBe("136px");
|
|
161
|
+
});
|
|
162
|
+
|
|
149
163
|
it("applies box sizes through CSS dimensions and flex sizing overrides", () => {
|
|
150
164
|
const document = createDocument(`
|
|
151
165
|
<div style="display: flex; flex-direction: row">
|
|
@@ -188,24 +202,24 @@ describe("studio manual edits", () => {
|
|
|
188
202
|
);
|
|
189
203
|
const card = document.getElementById("card") as HTMLElement;
|
|
190
204
|
|
|
191
|
-
applyStudioRotation(card, { angle: 24.
|
|
205
|
+
applyStudioRotation(card, { angle: 24.2468 });
|
|
192
206
|
|
|
193
|
-
expect(readStudioRotation(card)).toEqual({ angle: 24.
|
|
194
|
-
expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.
|
|
207
|
+
expect(readStudioRotation(card)).toEqual({ angle: 24.247 });
|
|
208
|
+
expect(card.style.getPropertyValue(STUDIO_ROTATION_PROP)).toBe("24.247deg");
|
|
195
209
|
expect(card.style.getPropertyValue("rotate")).toContain("8deg");
|
|
196
210
|
expect(card.style.getPropertyValue("rotate")).toContain(STUDIO_ROTATION_PROP);
|
|
197
211
|
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
|
198
212
|
|
|
199
|
-
applyStudioRotationDraft(card, { angle: -12.
|
|
200
|
-
expect(readStudioRotation(card)).toEqual({ angle: -12.
|
|
201
|
-
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.
|
|
213
|
+
applyStudioRotationDraft(card, { angle: -12.2604 });
|
|
214
|
+
expect(readStudioRotation(card)).toEqual({ angle: -12.26 });
|
|
215
|
+
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.26deg)");
|
|
202
216
|
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
|
203
217
|
|
|
204
218
|
const snapshot = captureStudioRotation(card);
|
|
205
219
|
applyStudioRotationDraft(card, { angle: 45 });
|
|
206
220
|
restoreStudioRotation(card, snapshot);
|
|
207
|
-
expect(readStudioRotation(card)).toEqual({ angle: -12.
|
|
208
|
-
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.
|
|
221
|
+
expect(readStudioRotation(card)).toEqual({ angle: -12.26 });
|
|
222
|
+
expect(card.style.getPropertyValue("rotate")).toBe("calc(8deg + -12.26deg)");
|
|
209
223
|
expect(card.style.getPropertyValue("transform-origin")).toBe("center center");
|
|
210
224
|
});
|
|
211
225
|
|
|
@@ -32,9 +32,9 @@ import {
|
|
|
32
32
|
STUDIO_ORIGINAL_TRANSFORM_DISPLAY_ATTR,
|
|
33
33
|
STUDIO_ROTATION_TRANSFORM_ORIGIN,
|
|
34
34
|
} from "./manualEditsTypes";
|
|
35
|
-
import { roundRotationAngle } from "./manualEditsParsing";
|
|
36
35
|
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
|
|
37
36
|
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
|
|
37
|
+
import { roundTo3, roundToLayoutPx } from "../../utils/rounding";
|
|
38
38
|
|
|
39
39
|
/* ── Gesture tracking ─────────────────────────────────────────────── */
|
|
40
40
|
let studioManualEditGestureId = 0;
|
|
@@ -203,8 +203,8 @@ function writeStudioPathOffsetVars(
|
|
|
203
203
|
): void {
|
|
204
204
|
prepareStudioPathOffsetBase(element, options.updateBase ?? true);
|
|
205
205
|
element.setAttribute(STUDIO_PATH_OFFSET_ATTR, "true");
|
|
206
|
-
element.style.setProperty(STUDIO_OFFSET_X_PROP, `${
|
|
207
|
-
element.style.setProperty(STUDIO_OFFSET_Y_PROP, `${
|
|
206
|
+
element.style.setProperty(STUDIO_OFFSET_X_PROP, `${roundTo3(offset.x)}px`);
|
|
207
|
+
element.style.setProperty(STUDIO_OFFSET_Y_PROP, `${roundTo3(offset.y)}px`);
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
/* ── Path offset apply ────────────────────────────────────────────── */
|
|
@@ -325,7 +325,7 @@ export function applyStudioPathOffsetDraft(
|
|
|
325
325
|
// Non-GSAP elements: use CSS translate as before.
|
|
326
326
|
element.style.setProperty(
|
|
327
327
|
"translate",
|
|
328
|
-
composeTranslateValue(element, `${
|
|
328
|
+
composeTranslateValue(element, `${roundTo3(offset.x)}px`, `${roundTo3(offset.y)}px`),
|
|
329
329
|
);
|
|
330
330
|
stripGsapTranslateFromTransform(element);
|
|
331
331
|
}
|
|
@@ -342,7 +342,7 @@ function readParentFlexBasisPixels(
|
|
|
342
342
|
if (display !== "flex" && display !== "inline-flex") return null;
|
|
343
343
|
|
|
344
344
|
const direction = readStyleOrComputed(parent, "flex-direction").trim();
|
|
345
|
-
return
|
|
345
|
+
return roundToLayoutPx(Math.max(1, direction.startsWith("column") ? size.height : size.width));
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
function restoreStaleStudioScaleResize(element: HTMLElement): void {
|
|
@@ -417,8 +417,8 @@ function writeStudioBoxSizeVars(
|
|
|
417
417
|
}
|
|
418
418
|
|
|
419
419
|
element.setAttribute(STUDIO_BOX_SIZE_ATTR, "true");
|
|
420
|
-
element.style.setProperty(STUDIO_WIDTH_PROP, `${
|
|
421
|
-
element.style.setProperty(STUDIO_HEIGHT_PROP, `${
|
|
420
|
+
element.style.setProperty(STUDIO_WIDTH_PROP, `${roundToLayoutPx(Math.max(1, size.width))}px`);
|
|
421
|
+
element.style.setProperty(STUDIO_HEIGHT_PROP, `${roundToLayoutPx(Math.max(1, size.height))}px`);
|
|
422
422
|
}
|
|
423
423
|
|
|
424
424
|
function applyStudioBoxSizeDimensions(
|
|
@@ -428,8 +428,8 @@ function applyStudioBoxSizeDimensions(
|
|
|
428
428
|
writeStudioBoxSizeVars(element, size);
|
|
429
429
|
restoreStaleStudioScaleResize(element);
|
|
430
430
|
|
|
431
|
-
const width =
|
|
432
|
-
const height =
|
|
431
|
+
const width = roundToLayoutPx(Math.max(1, size.width));
|
|
432
|
+
const height = roundToLayoutPx(Math.max(1, size.height));
|
|
433
433
|
element.style.setProperty("box-sizing", "border-box");
|
|
434
434
|
element.style.setProperty("width", `${width}px`);
|
|
435
435
|
element.style.setProperty("height", `${height}px`);
|
|
@@ -457,10 +457,26 @@ export function applyStudioBoxSize(
|
|
|
457
457
|
applyStudioBoxSizeDimensions(element, size);
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
+
// The element's box before a resize draft first changed it; gone once the resize saves or is restored.
|
|
461
|
+
const boxSizeDraftBases = new WeakMap<HTMLElement, { width: number; height: number }>();
|
|
462
|
+
|
|
463
|
+
export function readStudioBoxSizeDraftBase(
|
|
464
|
+
element: HTMLElement,
|
|
465
|
+
): { width: number; height: number } | null {
|
|
466
|
+
return boxSizeDraftBases.get(element) ?? null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function forgetStudioBoxSizeDraftBase(element: HTMLElement): void {
|
|
470
|
+
boxSizeDraftBases.delete(element);
|
|
471
|
+
}
|
|
472
|
+
|
|
460
473
|
export function applyStudioBoxSizeDraft(
|
|
461
474
|
element: HTMLElement,
|
|
462
475
|
size: { width: number; height: number },
|
|
463
476
|
): void {
|
|
477
|
+
if (!boxSizeDraftBases.has(element)) {
|
|
478
|
+
boxSizeDraftBases.set(element, { width: element.offsetWidth, height: element.offsetHeight });
|
|
479
|
+
}
|
|
464
480
|
promoteInlineForTransform(element);
|
|
465
481
|
applyStudioBoxSizeDimensions(element, size);
|
|
466
482
|
}
|
|
@@ -509,7 +525,7 @@ function writeStudioRotationVars(
|
|
|
509
525
|
): void {
|
|
510
526
|
prepareStudioRotationBase(element, options.updateBase ?? true);
|
|
511
527
|
element.setAttribute(STUDIO_ROTATION_ATTR, "true");
|
|
512
|
-
element.style.setProperty(STUDIO_ROTATION_PROP, `${
|
|
528
|
+
element.style.setProperty(STUDIO_ROTATION_PROP, `${roundTo3(rotation.angle)}deg`);
|
|
513
529
|
element.style.setProperty("transform-origin", STUDIO_ROTATION_TRANSFORM_ORIGIN);
|
|
514
530
|
}
|
|
515
531
|
|
|
@@ -529,6 +545,6 @@ export function applyStudioRotationDraft(element: HTMLElement, rotation: { angle
|
|
|
529
545
|
element.setAttribute(STUDIO_ROTATION_DRAFT_ATTR, "true");
|
|
530
546
|
element.style.setProperty(
|
|
531
547
|
"rotate",
|
|
532
|
-
composeStudioRotationValue(element, `${
|
|
548
|
+
composeStudioRotationValue(element, `${roundTo3(rotation.angle)}deg`),
|
|
533
549
|
);
|
|
534
550
|
}
|
|
@@ -3,10 +3,6 @@ export function finiteNumber(value: unknown): number | null {
|
|
|
3
3
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
export function roundRotationAngle(angle: number): number {
|
|
7
|
-
return Math.round(angle * 10) / 10;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
6
|
/* ── File path utilities ──────────────────────────────────────────── */
|
|
11
7
|
function normalizeStudioFileChangePath(path: string): string {
|
|
12
8
|
return path
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
styleUsesStudioSize,
|
|
4
4
|
styleUsesStudioRotation,
|
|
5
5
|
restoreInlineDisplay,
|
|
6
|
+
forgetStudioBoxSizeDraftBase,
|
|
6
7
|
} from "./manualEditsDom";
|
|
7
8
|
import {
|
|
8
9
|
STUDIO_OFFSET_X_PROP,
|
|
@@ -114,6 +115,7 @@ function restoreStyleProperty(element: HTMLElement, property: string, value: str
|
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
export function restoreStudioBoxSize(element: HTMLElement, previous: StudioBoxSizeSnapshot): void {
|
|
118
|
+
forgetStudioBoxSizeDraftBase(element);
|
|
117
119
|
restoreStyleProperty(element, "width", previous.width);
|
|
118
120
|
restoreStyleProperty(element, "height", previous.height);
|
|
119
121
|
restoreStyleProperty(element, "min-width", previous.minWidth);
|
|
@@ -290,6 +292,7 @@ export function clearStudioRotation(element: HTMLElement): void {
|
|
|
290
292
|
}
|
|
291
293
|
|
|
292
294
|
export function clearStudioBoxSize(element: HTMLElement): void {
|
|
295
|
+
forgetStudioBoxSizeDraftBase(element);
|
|
293
296
|
if (
|
|
294
297
|
element.hasAttribute(STUDIO_BOX_SIZE_ATTR) ||
|
|
295
298
|
styleUsesStudioSize(element.style.getPropertyValue("width")) ||
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ManualOffsetDragMatrix,
|
|
14
14
|
} from "./manualOffsetDrag";
|
|
15
15
|
import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits";
|
|
16
|
+
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
|
|
16
17
|
|
|
17
18
|
function expectMatrixClose(actual: ManualOffsetDragMatrix, expected: ManualOffsetDragMatrix): void {
|
|
18
19
|
expect(actual.a).toBeCloseTo(expected.a, 6);
|
|
@@ -449,6 +450,19 @@ describe("GSAP-element drag — dot-a flies regressions", () => {
|
|
|
449
450
|
});
|
|
450
451
|
});
|
|
451
452
|
|
|
453
|
+
describe("sub-pixel drags", () => {
|
|
454
|
+
it("draft and commit both land the exact fractional position", () => {
|
|
455
|
+
const { element, sets, member } = makeGsapDot();
|
|
456
|
+
const m = member();
|
|
457
|
+
applyManualOffsetDragDraft(m, 10.25, -3.5);
|
|
458
|
+
const draft = sets.at(-1)!;
|
|
459
|
+
expect(draft).toEqual({ x: m.baseGsap.x + 10.25, y: m.baseGsap.y - 3.5 });
|
|
460
|
+
const offset = applyManualOffsetDragCommit(m, 10.25, -3.5);
|
|
461
|
+
const { newX, newY } = computeDraggedGsapPosition(element, offset, m.baseGsap);
|
|
462
|
+
expect({ x: newX, y: newY }).toEqual(draft);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
452
466
|
describe("a stylesheet translate after a committed GSAP drag", () => {
|
|
453
467
|
function draggedTitle(css: string, oldOffsetChannel = false, withGsap = true) {
|
|
454
468
|
const window = new Window();
|
|
@@ -12,6 +12,14 @@ export function installReactActEnvironment(): void {
|
|
|
12
12
|
});
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export function withInlineLayoutBox<T extends HTMLElement>(el: T): T {
|
|
16
|
+
Object.defineProperties(el, {
|
|
17
|
+
offsetWidth: { get: () => Number.parseFloat(el.style.width) || 0 },
|
|
18
|
+
offsetHeight: { get: () => Number.parseFloat(el.style.height) || 0 },
|
|
19
|
+
});
|
|
20
|
+
return el;
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
/** Mount a React element into a fresh detached host and return its root. */
|
|
16
24
|
export function mountReactHarness(node: React.ReactElement): Root {
|
|
17
25
|
const host = document.createElement("div");
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { roundTo3 } from "../utils/rounding";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Drag → GSAP position math, shared by the commit path
|
|
3
5
|
* (`gsapDragCommit.commitGsapPositionFromDrag` / `commitStaticGsapPosition`) and
|
|
@@ -39,8 +41,8 @@ export function computeDraggedGsapPosition(
|
|
|
39
41
|
const baseGsapX = Number.isFinite(parsedBaseX) ? parsedBaseX : fallbackBase.x;
|
|
40
42
|
const baseGsapY = Number.isFinite(parsedBaseY) ? parsedBaseY : fallbackBase.y;
|
|
41
43
|
return {
|
|
42
|
-
newX:
|
|
43
|
-
newY:
|
|
44
|
+
newX: roundTo3(baseGsapX + adjX),
|
|
45
|
+
newY: roundTo3(baseGsapY + adjY),
|
|
44
46
|
baseGsapX,
|
|
45
47
|
baseGsapY,
|
|
46
48
|
};
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "../components/editor/manualEditsTypes";
|
|
11
11
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
12
12
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
13
|
-
import { roundTo3 } from "../utils/rounding";
|
|
13
|
+
import { roundTo3, roundToLayoutPx } from "../utils/rounding";
|
|
14
14
|
import { computeElementPercentage, writeTargetSelector } from "./gsapShared";
|
|
15
15
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
16
16
|
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
|
@@ -289,8 +289,8 @@ export async function commitStaticGsapSize(
|
|
|
289
289
|
existingSet: GsapAnimation | null,
|
|
290
290
|
callbacks: GsapDragCommitCallbacks,
|
|
291
291
|
): Promise<void> {
|
|
292
|
-
const width =
|
|
293
|
-
const height =
|
|
292
|
+
const width = roundToLayoutPx(size.width);
|
|
293
|
+
const height = roundToLayoutPx(size.height);
|
|
294
294
|
if (existingSet) {
|
|
295
295
|
await callbacks.commitMutation(
|
|
296
296
|
selection,
|
|
@@ -320,7 +320,7 @@ export async function commitStaticGsapSize(
|
|
|
320
320
|
|
|
321
321
|
/** Rounded `n` when it's a positive finite number, else `fallback`. */
|
|
322
322
|
function positiveOr(n: number, fallback: number): number {
|
|
323
|
-
return Number.isFinite(n) && n > 0 ?
|
|
323
|
+
return Number.isFinite(n) && n > 0 ? roundToLayoutPx(n) : fallback;
|
|
324
324
|
}
|
|
325
325
|
|
|
326
326
|
/**
|
|
@@ -366,8 +366,8 @@ export async function commitKeyframedSizeFromResize(
|
|
|
366
366
|
const td = resolveTweenDuration(animatedTween);
|
|
367
367
|
if (!(td > 0)) return false;
|
|
368
368
|
|
|
369
|
-
const newW =
|
|
370
|
-
const newH =
|
|
369
|
+
const newW = roundToLayoutPx(size.width);
|
|
370
|
+
const newH = roundToLayoutPx(size.height);
|
|
371
371
|
const prior = resolvePriorSize(sizeSet, selection.element, newW, newH);
|
|
372
372
|
|
|
373
373
|
const ct = usePlayerStore.getState().currentTime;
|
|
@@ -67,6 +67,12 @@ export function assertGsapEditPersisted(outcome: GsapEditOutcome): void {
|
|
|
67
67
|
if (outcome.status === "blocked") throw new GsapEditBlockedError(outcome.reason, outcome.detail);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** A move only a shared tween positions is saved on the element itself; a blocked one throws. */
|
|
71
|
+
export async function saveMove(outcome: GsapEditOutcome, saveOnElement: () => Promise<void>) {
|
|
72
|
+
if (outcome.status === "element-offset") return saveOnElement();
|
|
73
|
+
assertGsapEditPersisted(outcome);
|
|
74
|
+
}
|
|
75
|
+
|
|
70
76
|
function assertGsapAnimationDirectlyEditable(animation: GsapAnimation): void {
|
|
71
77
|
const editability = editabilityForProvenance(animation.provenance);
|
|
72
78
|
if (editability === "unroll") throw new GsapEditBlockedError("unroll-required");
|
|
@@ -31,7 +31,7 @@ import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
|
31
31
|
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
|
|
32
32
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
33
33
|
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
|
34
|
-
import { roundTo3 } from "../utils/rounding";
|
|
34
|
+
import { roundTo3, roundToLayoutPx } from "../utils/rounding";
|
|
35
35
|
import { resolveGroupTween } from "./gsapRuntimeBridge";
|
|
36
36
|
import { logResize } from "../utils/resizeDebug";
|
|
37
37
|
import { animationWritesAnyProperty, type GsapEditOutcome } from "./gsapEditOutcome";
|
|
@@ -287,8 +287,8 @@ export async function tryGsapResizeIntercept(
|
|
|
287
287
|
}
|
|
288
288
|
} else {
|
|
289
289
|
resizeProps = {
|
|
290
|
-
width:
|
|
291
|
-
height:
|
|
290
|
+
width: roundToLayoutPx(size.width),
|
|
291
|
+
height: roundToLayoutPx(size.height),
|
|
292
292
|
};
|
|
293
293
|
}
|
|
294
294
|
// Finalize a scale-route commit: tear down the gesture's inline width/height
|
|
@@ -358,8 +358,8 @@ export async function tryGsapResizeIntercept(
|
|
|
358
358
|
// persisted file agree exactly (commitStaticGsapPosition composes the same
|
|
359
359
|
// rounded value from this delta).
|
|
360
360
|
const corrected = {
|
|
361
|
-
x:
|
|
362
|
-
y:
|
|
361
|
+
x: roundTo3(base.x + residual.x),
|
|
362
|
+
y: roundTo3(base.y + residual.y),
|
|
363
363
|
};
|
|
364
364
|
logResize("scale-finalize", {
|
|
365
365
|
dropPoint: scaleDraftDropPoint,
|
|
@@ -319,6 +319,17 @@ describe("tryGsapRotationIntercept — instant holds", () => {
|
|
|
319
319
|
expect(commitMutation).not.toHaveBeenCalled();
|
|
320
320
|
});
|
|
321
321
|
|
|
322
|
+
it("commits the exact angle the draft showed", async () => {
|
|
323
|
+
const commitMutation = vi.fn();
|
|
324
|
+
|
|
325
|
+
await tryGsapRotationIntercept(selection, 75.25, [], null, commitMutation);
|
|
326
|
+
expect(commitMutation).toHaveBeenCalledWith(
|
|
327
|
+
selection,
|
|
328
|
+
expect.objectContaining({ type: "add", properties: { rotation: 75.25 } }),
|
|
329
|
+
expect.anything(),
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
322
333
|
it("does not let an unrelated helper-authored skew tween block 2D rotation", async () => {
|
|
323
334
|
const helperSkew = {
|
|
324
335
|
id: "#puck-b-to-rotation",
|
|
@@ -27,6 +27,7 @@ import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
|
27
27
|
import { isGestureTransactionCommit } from "./gestureTransaction";
|
|
28
28
|
import { tweenReach, tweensForThisElement } from "./gsapTweenReach";
|
|
29
29
|
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
30
|
+
import { roundTo3 } from "../utils/rounding";
|
|
30
31
|
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
31
32
|
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
|
32
33
|
import {
|
|
@@ -446,7 +447,7 @@ export async function tryGsapRotationIntercept(
|
|
|
446
447
|
// `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base +
|
|
447
448
|
// pointer sweep) or the inspector — so it IS the new rotation. No base re-add: the
|
|
448
449
|
// gesture's live preview already gsap.set this value (single source of truth).
|
|
449
|
-
const newRotation =
|
|
450
|
+
const newRotation = roundTo3(angle);
|
|
450
451
|
// STATIC case (single source of truth = GSAP timeline): no rotation tween, so the
|
|
451
452
|
// angle belongs in a `tl.set("#el",{rotation})`, not a keyframe conversion —
|
|
452
453
|
// mirroring the static position set. Idempotent: re-rotate updates an existing
|
|
@@ -167,6 +167,13 @@ describe("hasNonHoldTweenForElement — strict live-tween existence (drag stale-
|
|
|
167
167
|
expect(hasNonHoldTweenForElement(fakeIframe(el, [liveTween]), "#puck-b")).toBe(true);
|
|
168
168
|
});
|
|
169
169
|
|
|
170
|
+
it("true when the tween sits in a later timeline (a soft reload re-adds main last)", () => {
|
|
171
|
+
const iframe = fakeIframe(el, []);
|
|
172
|
+
const win = iframe.contentWindow as unknown as { __timelines: Record<string, unknown> };
|
|
173
|
+
win.__timelines.main = { getChildren: () => [liveTween] };
|
|
174
|
+
expect(hasNonHoldTweenForElement(iframe, "#puck-b")).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
170
177
|
it("false when only a zero-duration hold/set remains (post delete-all)", () => {
|
|
171
178
|
expect(hasNonHoldTweenForElement(fakeIframe(el, [holdSet]), "#puck-b")).toBe(false);
|
|
172
179
|
});
|