@hyperframes/studio 0.8.98 → 0.8.100
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-fWt87gyh.js} +1 -1
- package/dist/assets/{index-B6vU39NM.js → index-D34Oy5DN.js} +1 -1
- package/dist/assets/{index-itc1XziP.js → index-D6TjRAvz.js} +171 -171
- package/dist/assets/{index-BxnoLgxG.js → index-DofdzdxJ.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +718 -438
- 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/PropertyPanel.tsx +3 -3
- 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/domEditOverlayStartGesture.test.ts +83 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +6 -4
- 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 +84 -0
- package/src/components/editor/manualOffsetDrag.ts +50 -40
- package/src/components/editor/plainTranslate.test.ts +62 -0
- package/src/components/editor/plainTranslate.ts +85 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +3 -2
- package/src/components/editor/useDomEditNudge.ts +1 -1
- package/src/hooks/domSelectionTestHarness.ts +8 -0
- package/src/hooks/draggedGsapPosition.ts +4 -2
- package/src/hooks/elementOffsetStager.ts +33 -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 +3 -18
- package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +69 -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 +18 -4
- package/src/hooks/useDomEditCommits.ts +3 -1
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomEditTextCommits.test.tsx +62 -5
- package/src/hooks/useDomEditTextCommits.ts +35 -35
- package/src/hooks/useDomGeometryCommit.test.tsx +62 -4
- package/src/hooks/useDomGeometryCommit.ts +1 -0
- package/src/hooks/useDomGeometryCommits.test.tsx +116 -2
- package/src/hooks/useDomGeometryCommits.ts +5 -0
- package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +147 -23
- package/src/hooks/useGsapAwareEditing.ts +48 -34
- 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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
2
|
+
import type { CommitMutation } from "../../hooks/gsapScriptCommitTypes";
|
|
3
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
4
|
+
import { gsapAnimatesProperty } from "./gsapAnimatesProperty";
|
|
5
|
+
import { elementHasNonHoldTween } from "../../hooks/gsapRuntimeKeyframes";
|
|
6
|
+
import { buildInsetClipPathSides, type ParsedInsetClipPathSides } from "./clipPathHelpers";
|
|
7
|
+
import { hasCropInsets, readElementCropInsets } from "./domEditOverlayCrop";
|
|
8
|
+
import { forgetStudioBoxSizeDraftBase, readStudioBoxSizeDraftBase } from "./manualEditsDom";
|
|
9
|
+
|
|
10
|
+
interface Box {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const boxOf = (element: HTMLElement): Box => ({
|
|
16
|
+
width: element.offsetWidth,
|
|
17
|
+
height: element.offsetHeight,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
type Follows = Record<keyof Box, boolean>;
|
|
21
|
+
|
|
22
|
+
/** Per axis, whether a crop follows a resize: not when GSAP drives the clip or tweens that axis's size. */
|
|
23
|
+
function cropFollows(element: HTMLElement): Follows {
|
|
24
|
+
const clip = gsapAnimatesProperty(element, "clipPath");
|
|
25
|
+
return {
|
|
26
|
+
width: !clip && !elementHasNonHoldTween(element, ["width"]),
|
|
27
|
+
height: !clip && !elementHasNonHoldTween(element, ["height"]),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function scaleCrop(
|
|
32
|
+
crop: ParsedInsetClipPathSides,
|
|
33
|
+
from: Box,
|
|
34
|
+
to: Box,
|
|
35
|
+
follows: Follows,
|
|
36
|
+
): ParsedInsetClipPathSides {
|
|
37
|
+
const sx = follows.width && from.width > 0 ? to.width / from.width : 1;
|
|
38
|
+
const sy = follows.height && from.height > 0 ? to.height / from.height : 1;
|
|
39
|
+
return {
|
|
40
|
+
top: crop.top * sy,
|
|
41
|
+
right: crop.right * sx,
|
|
42
|
+
bottom: crop.bottom * sy,
|
|
43
|
+
left: crop.left * sx,
|
|
44
|
+
radius: crop.radius,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The element's crop as the crop UI draws it: it follows the box while a resize draft is live. */
|
|
49
|
+
export function readCropFollowingResize(element: HTMLElement): ParsedInsetClipPathSides | null {
|
|
50
|
+
const crop = readElementCropInsets(element);
|
|
51
|
+
const base = readStudioBoxSizeDraftBase(element);
|
|
52
|
+
return crop && base ? scaleCrop(crop, base, boxOf(element), cropFollows(element)) : crop;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CropResize {
|
|
56
|
+
patch: PatchOperation;
|
|
57
|
+
revert: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Call before a resize lands. Once the new size is live, the stage writes the crop that keeps the
|
|
61
|
+
* same part of the element in view and returns its patch, or null when nothing needs rescaling. */
|
|
62
|
+
export function prepareCropResize(element: HTMLElement): () => CropResize | null {
|
|
63
|
+
const from = readStudioBoxSizeDraftBase(element) ?? boxOf(element);
|
|
64
|
+
const before = element.style.getPropertyValue("clip-path");
|
|
65
|
+
const priority = element.style.getPropertyPriority("clip-path");
|
|
66
|
+
// Decided before the write lands: a W edit may add a width keyframe of its own.
|
|
67
|
+
const follows = cropFollows(element);
|
|
68
|
+
const crop = readElementCropInsets(element);
|
|
69
|
+
return () => {
|
|
70
|
+
forgetStudioBoxSizeDraftBase(element);
|
|
71
|
+
const to = boxOf(element);
|
|
72
|
+
// A crop edited while the size saved was drawn in the new box already.
|
|
73
|
+
const untouched = element.style.getPropertyValue("clip-path") === before;
|
|
74
|
+
if (!crop || !hasCropInsets(crop) || !untouched || !(to.width > 0 && to.height > 0))
|
|
75
|
+
return null;
|
|
76
|
+
const clip = buildInsetClipPathSides(scaleCrop(crop, from, to, follows), crop.radius);
|
|
77
|
+
// Unchanged when the box is (a resize saved as a `scale`) or no resized axis follows.
|
|
78
|
+
if (clip === buildInsetClipPathSides(crop, crop.radius)) return null;
|
|
79
|
+
element.style.setProperty("clip-path", clip, priority);
|
|
80
|
+
return {
|
|
81
|
+
patch: {
|
|
82
|
+
type: "inline-style",
|
|
83
|
+
property: "clip-path",
|
|
84
|
+
value: priority ? `${clip} !important` : clip,
|
|
85
|
+
},
|
|
86
|
+
revert: () => {
|
|
87
|
+
if (before) element.style.setProperty("clip-path", before, priority);
|
|
88
|
+
else element.style.removeProperty("clip-path");
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type PatchCommit = (
|
|
95
|
+
selection: DomEditSelection,
|
|
96
|
+
patches: PatchOperation[],
|
|
97
|
+
options: { label: string; coalesceKey: string; coalesceMs?: number },
|
|
98
|
+
) => Promise<void>;
|
|
99
|
+
|
|
100
|
+
/** Stage the crop and save it under the resize's undo key, taking it back off the element if that fails. */
|
|
101
|
+
export async function saveCropResize(
|
|
102
|
+
stage: () => CropResize | null,
|
|
103
|
+
selection: DomEditSelection,
|
|
104
|
+
commit: PatchCommit,
|
|
105
|
+
coalesceKey: string,
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const crop = stage();
|
|
108
|
+
if (!crop) return;
|
|
109
|
+
try {
|
|
110
|
+
await commit(selection, [crop.patch], {
|
|
111
|
+
label: "Resize layer",
|
|
112
|
+
coalesceKey,
|
|
113
|
+
coalesceMs: Number.POSITIVE_INFINITY,
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
crop.revert();
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let sizeWriteCounter = 0;
|
|
122
|
+
|
|
123
|
+
/** A property write that may size the element (a W/H field on an animated element): when it
|
|
124
|
+
* does, its writes and the rescaled crop share one undo step. */
|
|
125
|
+
export async function writeSizeWithCrop(
|
|
126
|
+
selection: DomEditSelection,
|
|
127
|
+
properties: Record<string, unknown>,
|
|
128
|
+
mutation: CommitMutation | null,
|
|
129
|
+
commit: PatchCommit,
|
|
130
|
+
write: (keyed?: CommitMutation) => Promise<void>,
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
if (!mutation || !("width" in properties || "height" in properties)) return write();
|
|
133
|
+
const stage = prepareCropResize(selection.element);
|
|
134
|
+
const coalesceKey = `size-write:${++sizeWriteCounter}`;
|
|
135
|
+
const undoStep = { coalesceKey, coalesceMs: Number.POSITIVE_INFINITY };
|
|
136
|
+
const keyed: CommitMutation = (s, m, options) => mutation(s, m, { ...options, ...undoStep });
|
|
137
|
+
if (mutation.batch) {
|
|
138
|
+
const batch = mutation.batch;
|
|
139
|
+
keyed.batch = (calls, options) => batch(calls, { ...options, ...undoStep });
|
|
140
|
+
}
|
|
141
|
+
await write(keyed);
|
|
142
|
+
await saveCropResize(stage, selection, commit, coalesceKey);
|
|
143
|
+
}
|
|
@@ -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
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { DomEditSelection } from "./domEditing";
|
|
4
|
+
import type { GestureState } from "./domEditOverlayGestures";
|
|
5
|
+
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
delete (window as { gsap?: unknown }).gsap;
|
|
9
|
+
delete (window as { __timelines?: unknown }).__timelines;
|
|
10
|
+
document.body.innerHTML = "";
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const PRESS = {
|
|
14
|
+
clientX: 10,
|
|
15
|
+
clientY: 10,
|
|
16
|
+
pointerId: 1,
|
|
17
|
+
button: 0,
|
|
18
|
+
preventDefault() {},
|
|
19
|
+
stopPropagation() {},
|
|
20
|
+
currentTarget: { setPointerCapture() {} },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Presses a drag on the element and returns the gesture it started. */
|
|
24
|
+
function pressDrag(element: HTMLElement): GestureState | null {
|
|
25
|
+
const opts = pressOptions(element);
|
|
26
|
+
expect(
|
|
27
|
+
createDomEditOverlayGestureHandlers(opts as never).startGesture("drag", PRESS as never),
|
|
28
|
+
).toBe(true);
|
|
29
|
+
return opts.gestureRef.current;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function pressOptions(element: HTMLElement) {
|
|
33
|
+
const ref = <T>(current: T) => ({ current });
|
|
34
|
+
const selection = { element, capabilities: { canApplyManualOffset: true } };
|
|
35
|
+
return {
|
|
36
|
+
selectionRef: ref(selection as unknown as DomEditSelection),
|
|
37
|
+
overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
|
|
38
|
+
boxRef: ref(null),
|
|
39
|
+
overlayRef: ref(null),
|
|
40
|
+
iframeRef: ref(null),
|
|
41
|
+
gestureRef: ref<GestureState | null>(null),
|
|
42
|
+
rafPausedRef: ref(false),
|
|
43
|
+
onManualDragStartRef: ref(vi.fn()),
|
|
44
|
+
onBlockedMoveRef: ref(vi.fn()),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("a drag press on a page that loads GSAP", () => {
|
|
49
|
+
it.each([
|
|
50
|
+
["GSAP animates nothing", false],
|
|
51
|
+
["GSAP animates only its parent", true],
|
|
52
|
+
])(
|
|
53
|
+
"%s: the press never asks GSAP about the element, so its translate stays CSS",
|
|
54
|
+
(_, parentTween) => {
|
|
55
|
+
const getProperty = vi.fn(() => 0);
|
|
56
|
+
const set = vi.fn();
|
|
57
|
+
const parent = document.createElement("div");
|
|
58
|
+
const element = document.createElement("div");
|
|
59
|
+
element.style.setProperty("translate", "40px 30px");
|
|
60
|
+
parent.append(element);
|
|
61
|
+
document.body.append(parent);
|
|
62
|
+
const tween = { targets: () => [parent], vars: { x: 100 }, duration: () => 2 };
|
|
63
|
+
const timelines = { main: { getChildren: () => (parentTween ? [tween] : []) } };
|
|
64
|
+
Object.assign(window, { gsap: { getProperty, set }, __timelines: timelines });
|
|
65
|
+
expect(pressDrag(element)?.pathOffsetMember?.plainTranslate).toBe(true);
|
|
66
|
+
expect(getProperty).not.toHaveBeenCalled();
|
|
67
|
+
expect(set).not.toHaveBeenCalled();
|
|
68
|
+
expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
|
|
69
|
+
},
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("a drag press on a centred element without GSAP", () => {
|
|
74
|
+
it("starts from its -50% translate in px and leaves its style as it was", () => {
|
|
75
|
+
const element = document.createElement("div");
|
|
76
|
+
element.style.cssText =
|
|
77
|
+
"position: absolute; left: 50%; top: 50%; width: 240px; height: 160px; translate: -50% -50%";
|
|
78
|
+
document.body.append(element);
|
|
79
|
+
const style = element.getAttribute("style");
|
|
80
|
+
expect(pressDrag(element)?.pathOffsetMember?.initialOffset).toEqual({ x: -120, y: -80 });
|
|
81
|
+
expect(element.getAttribute("style")).toBe(style);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -70,6 +70,7 @@ export function startGroupDrag(
|
|
|
70
70
|
selection: item.selection,
|
|
71
71
|
element: item.element,
|
|
72
72
|
rect: item.rect,
|
|
73
|
+
gesture: "drag",
|
|
73
74
|
});
|
|
74
75
|
if (!result.ok) {
|
|
75
76
|
restoreManualOffsetDragMembers(members);
|
|
@@ -151,10 +152,6 @@ export function startGesture(
|
|
|
151
152
|
return false;
|
|
152
153
|
|
|
153
154
|
const size = readStudioBoxSize(sel.element);
|
|
154
|
-
// Single-source rotation base = the live GSAP transform rotation plus any legacy
|
|
155
|
-
// `--hf-studio-rotation` CSS var (old projects), so a rotate gesture starts from the
|
|
156
|
-
// element's actual visual angle and commits an absolute angle to the timeline.
|
|
157
|
-
const rotation = { angle: readGsapRotation(sel.element) + readStudioRotation(sel.element).angle };
|
|
158
155
|
// The draft writes CSS width/height, so the resize base must be the CSS
|
|
159
156
|
// layout size. offsetWidth/Height are transform-free; the overlay-rect
|
|
160
157
|
// fallback (rect / editScale) includes the element's own GSAP scale and
|
|
@@ -198,6 +195,7 @@ export function startGesture(
|
|
|
198
195
|
selection: sel,
|
|
199
196
|
element: sel.element,
|
|
200
197
|
rect,
|
|
198
|
+
gesture: "drag",
|
|
201
199
|
});
|
|
202
200
|
if (!result.ok) {
|
|
203
201
|
opts.onBlockedMoveRef.current(result.selection);
|
|
@@ -232,6 +230,10 @@ export function startGesture(
|
|
|
232
230
|
}
|
|
233
231
|
}
|
|
234
232
|
|
|
233
|
+
// Rotation base = GSAP's rotation plus the legacy `--hf-studio-rotation` var. A plain-translate
|
|
234
|
+
// move never asks GSAP: reading a property makes it bake the CSS translate into its transform.
|
|
235
|
+
const gsapRotation = pathOffsetMember?.plainTranslate ? 0 : readGsapRotation(sel.element);
|
|
236
|
+
const rotation = { angle: gsapRotation + readStudioRotation(sel.element).angle };
|
|
235
237
|
const overlayBounds = overlayEl?.getBoundingClientRect();
|
|
236
238
|
const centerX = (overlayBounds?.left ?? 0) + rect.left + rect.width / 2;
|
|
237
239
|
const centerY = (overlayBounds?.top ?? 0) + rect.top + rect.height / 2;
|
|
@@ -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")) ||
|