@hyperframes/studio 0.8.124 → 0.8.125
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-ColOZ_78.js → hyperframes-player-CmIh-wq9.js} +1 -1
- package/dist/assets/{index-jQEqFKMk.js → index-4LRR6o2F.js} +1 -1
- package/dist/assets/{index-BuSTrK-Z.js → index-CrYoEZ5p.js} +139 -139
- package/dist/assets/{index-CVuUfURE.js → index-yCK8osBg.js} +1 -1
- package/dist/index.d.ts +43 -35
- package/dist/index.html +1 -1
- package/dist/index.js +213 -94
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/editor/domEditOverlayGestures.ts +2 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +6 -0
- package/src/components/editor/dragUndoPaint.test.ts +170 -2
- package/src/components/editor/gestureUndoRevert.ts +104 -0
- package/src/components/editor/manualOffsetDrag.ts +1 -12
- package/src/components/editor/useDomEditNudge.ts +1 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +9 -5
- package/src/hooks/gestureTransaction.test.ts +60 -0
- package/src/hooks/gestureTransaction.ts +5 -2
- package/src/hooks/gsapGestureOutcome.ts +9 -2
- package/src/hooks/gsapResizeDropPoint.test.ts +39 -1
- package/src/hooks/gsapResizeElementSize.test.tsx +27 -0
- package/src/hooks/gsapResizeIntercept.ts +55 -68
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +29 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
- package/src/hooks/useGsapAwareEditing.ts +21 -25
- package/src/hooks/useGsapAwareGroupMove.ts +4 -6
- package/src/hooks/useGsapScriptCommits.test.tsx +42 -1
- package/src/hooks/useGsapScriptCommits.ts +12 -3
- package/src/utils/studioPendingEdits.test.ts +52 -0
- package/src/utils/studioPendingEdits.ts +55 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.125",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/
|
|
64
|
-
"@hyperframes/player": "0.8.
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/shader-transitions": "0.8.
|
|
68
|
-
"@hyperframes/studio-server": "0.8.
|
|
63
|
+
"@hyperframes/parsers": "0.8.125",
|
|
64
|
+
"@hyperframes/player": "0.8.125",
|
|
65
|
+
"@hyperframes/core": "0.8.125",
|
|
66
|
+
"@hyperframes/sdk": "0.8.125",
|
|
67
|
+
"@hyperframes/shader-transitions": "0.8.125",
|
|
68
|
+
"@hyperframes/studio-server": "0.8.125"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.125"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
StudioRotationSnapshot,
|
|
7
7
|
} from "./manualEdits";
|
|
8
8
|
import type { ManualOffsetDragMember } from "./manualOffsetDrag";
|
|
9
|
+
import type { StudioElementLook } from "./gestureUndoRevert";
|
|
9
10
|
import type { CssRotationTarget, RotationCommit } from "./rotationDraft";
|
|
10
11
|
import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry";
|
|
11
12
|
import type { SnapContext } from "./snapTargetCollection";
|
|
@@ -57,6 +58,7 @@ export interface GestureState {
|
|
|
57
58
|
initialPathOffset: StudioPathOffsetSnapshot;
|
|
58
59
|
initialRotation: StudioRotationSnapshot;
|
|
59
60
|
initialBoxSize: StudioBoxSizeSnapshot;
|
|
61
|
+
initialLook: StudioElementLook;
|
|
60
62
|
pathOffsetMember?: ManualOffsetDragMember;
|
|
61
63
|
originLeft: number;
|
|
62
64
|
originTop: number;
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
restoreManualOffsetDragMembers,
|
|
10
10
|
type ManualOffsetDragMember,
|
|
11
11
|
} from "./manualOffsetDrag";
|
|
12
|
+
import { readElementLook } from "./gestureUndoRevert";
|
|
12
13
|
import { readCssRotationTarget, readRotationBase } from "./rotationDraft";
|
|
13
14
|
import {
|
|
14
15
|
beginStudioManualEditGesture,
|
|
@@ -179,6 +180,10 @@ export function startGesture(
|
|
|
179
180
|
Number.isFinite(rawContentScaleX) && rawContentScaleX > 0 ? rawContentScaleX : 1;
|
|
180
181
|
const contentScaleY =
|
|
181
182
|
Number.isFinite(rawContentScaleY) && rawContentScaleY > 0 ? rawContentScaleY : 1;
|
|
183
|
+
const initialLook = readElementLook(
|
|
184
|
+
sel.element,
|
|
185
|
+
kind !== "drag" && !editsPlainCss(sel.element, kind === "rotate" ? "rotate" : "resize"),
|
|
186
|
+
);
|
|
182
187
|
let initialPathOffset = captureStudioPathOffset(sel.element);
|
|
183
188
|
let manualEditDragToken: string | undefined;
|
|
184
189
|
let pathOffsetMember: ManualOffsetDragMember | undefined;
|
|
@@ -274,6 +279,7 @@ export function startGesture(
|
|
|
274
279
|
initialPathOffset,
|
|
275
280
|
initialRotation: captureStudioRotation(sel.element),
|
|
276
281
|
initialBoxSize: captureStudioBoxSize(sel.element),
|
|
282
|
+
initialLook,
|
|
277
283
|
pathOffsetMember,
|
|
278
284
|
originLeft: rect.left,
|
|
279
285
|
originTop: rect.top,
|
|
@@ -1,15 +1,40 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { afterEach, expect, it, vi } from "vitest";
|
|
3
|
+
import { savePlainRotation } from "../../hooks/plainRotation";
|
|
3
4
|
import type { DomEditSelection } from "./domEditing";
|
|
4
5
|
import type { GestureState } from "./domEditOverlayGestures";
|
|
5
6
|
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
6
7
|
import {
|
|
8
|
+
adoptingStudioPendingEdit,
|
|
7
9
|
hasStudioPendingEdits,
|
|
8
10
|
paintBackNewestStudioPendingEdit,
|
|
11
|
+
type StudioEditInFlight,
|
|
9
12
|
} from "../../utils/studioPendingEdits";
|
|
10
13
|
|
|
14
|
+
const gsapOwns = vi.hoisted(() => ({ on: false }));
|
|
15
|
+
vi.mock("../../hooks/gsapRuntimeKeyframes", async (importOriginal) => ({
|
|
16
|
+
...(await importOriginal<typeof import("../../hooks/gsapRuntimeKeyframes")>()),
|
|
17
|
+
editsPlainCss: () => !gsapOwns.on,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
/** GSAP's own transform values per element, as `gsap.set` / `gsap.getProperty` see them. */
|
|
21
|
+
function fakeGsap(start: Record<string, number>) {
|
|
22
|
+
const values = new Map<Element, Record<string, number>>();
|
|
23
|
+
const of = (el: Element) => values.get(el) ?? values.set(el, { ...start }).get(el)!;
|
|
24
|
+
gsapOwns.on = true;
|
|
25
|
+
Object.assign(window, {
|
|
26
|
+
gsap: {
|
|
27
|
+
set: (el: Element, props: Record<string, number>) => Object.assign(of(el), props),
|
|
28
|
+
getProperty: (el: Element, prop: string) => of(el)[prop] ?? 0,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
return of;
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
afterEach(() => {
|
|
12
35
|
document.body.innerHTML = "";
|
|
36
|
+
gsapOwns.on = false;
|
|
37
|
+
Reflect.deleteProperty(window, "gsap");
|
|
13
38
|
});
|
|
14
39
|
|
|
15
40
|
const ref = <T>(current: T) => ({ current });
|
|
@@ -24,7 +49,7 @@ const pointer = (x: number, y: number) => ({
|
|
|
24
49
|
});
|
|
25
50
|
|
|
26
51
|
/** Drags a box without GSAP 100 px right and 60 px down; its save waits for `save`. */
|
|
27
|
-
function dragWithSaveRunning(save: Promise<void>) {
|
|
52
|
+
function dragWithSaveRunning(save: Promise<void> | (() => Promise<void>)) {
|
|
28
53
|
const element = document.createElement("div");
|
|
29
54
|
element.style.setProperty("translate", "40px 30px");
|
|
30
55
|
document.body.append(element);
|
|
@@ -39,7 +64,7 @@ function dragWithSaveRunning(save: Promise<void>) {
|
|
|
39
64
|
rafPausedRef: ref(false),
|
|
40
65
|
onManualDragStartRef: ref(vi.fn()),
|
|
41
66
|
onBlockedMoveRef: ref(vi.fn()),
|
|
42
|
-
onPathOffsetCommitRef: ref(vi.fn(() => save)),
|
|
67
|
+
onPathOffsetCommitRef: ref(vi.fn(typeof save === "function" ? save : () => save)),
|
|
43
68
|
snapGuidesRef: ref(null),
|
|
44
69
|
groupGestureRef: ref(null),
|
|
45
70
|
blockedMoveRef: ref(null),
|
|
@@ -69,3 +94,146 @@ it("a drag whose save is still running can be painted back at once, and shown ag
|
|
|
69
94
|
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
70
95
|
expect(paintBackNewestStudioPendingEdit()).toBeNull();
|
|
71
96
|
});
|
|
97
|
+
|
|
98
|
+
/** Resizes or rotates a plain 240x160 box; the commit draws what it saves at once and its save waits for `save`. */
|
|
99
|
+
function gestureWithSaveRunning(kind: "resize" | "rotate", save: Promise<void>) {
|
|
100
|
+
const element = document.createElement("div");
|
|
101
|
+
element.setAttribute(
|
|
102
|
+
"style",
|
|
103
|
+
"position: absolute; width: 240px; height: 160px; clip-path: inset(10px)",
|
|
104
|
+
);
|
|
105
|
+
document.body.append(element);
|
|
106
|
+
const selection = {
|
|
107
|
+
element,
|
|
108
|
+
capabilities: { canApplyManualSize: true, canApplyManualRotation: true },
|
|
109
|
+
};
|
|
110
|
+
const handlers = createDomEditOverlayGestureHandlers({
|
|
111
|
+
selectionRef: ref(selection as unknown as DomEditSelection),
|
|
112
|
+
overlayRectRef: ref({ left: 0, top: 0, width: 240, height: 160, editScaleX: 1, editScaleY: 1 }),
|
|
113
|
+
boxRef: ref(document.createElement("div")),
|
|
114
|
+
overlayRef: ref(null),
|
|
115
|
+
iframeRef: ref(null),
|
|
116
|
+
gestureRef: ref<GestureState | null>(null),
|
|
117
|
+
rafPausedRef: ref(false),
|
|
118
|
+
onBoxSizeCommitRef: ref(
|
|
119
|
+
vi.fn(() => {
|
|
120
|
+
element.style.setProperty("clip-path", "inset(15px)");
|
|
121
|
+
return save;
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
onRotationCommitRef: ref((sel: DomEditSelection, next: never) =>
|
|
125
|
+
savePlainRotation({ commitPositionPatchToHtml: () => save.then(() => undefined) }, sel, next),
|
|
126
|
+
),
|
|
127
|
+
snapGuidesRef: ref(null),
|
|
128
|
+
groupGestureRef: ref(null),
|
|
129
|
+
blockedMoveRef: ref(null),
|
|
130
|
+
setOverlayRect: vi.fn(),
|
|
131
|
+
suppressNextBoxClickRef: ref(false),
|
|
132
|
+
hoverSelectionRef: ref(null),
|
|
133
|
+
onCanvasMouseDown: vi.fn(),
|
|
134
|
+
} as never);
|
|
135
|
+
const start = kind === "resize" ? pointer(240, 160) : pointer(240, 80);
|
|
136
|
+
expect(handlers.startGesture(kind, start as never, { resizeHandle: "se" })).toBe(true);
|
|
137
|
+
const end = kind === "resize" ? pointer(300, 200) : pointer(120, 200);
|
|
138
|
+
handlers.onPointerMove(end as never);
|
|
139
|
+
handlers.onPointerUp(end as never);
|
|
140
|
+
return element;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
it.each(["resize", "rotate"] as const)(
|
|
144
|
+
"a %s whose save is still running can be painted back at once, and shown again",
|
|
145
|
+
async (kind) => {
|
|
146
|
+
let saved!: () => void;
|
|
147
|
+
const element = gestureWithSaveRunning(kind, new Promise<void>((resolve) => (saved = resolve)));
|
|
148
|
+
const before = "position: absolute; width: 240px; height: 160px; clip-path: inset(10px)";
|
|
149
|
+
const edited = element.getAttribute("style");
|
|
150
|
+
expect(edited).not.toBe(before);
|
|
151
|
+
|
|
152
|
+
const shown = paintBackNewestStudioPendingEdit();
|
|
153
|
+
expect(element.getAttribute("style")).toBe(before);
|
|
154
|
+
expect(paintBackNewestStudioPendingEdit()).toBeNull();
|
|
155
|
+
shown!.showAgain();
|
|
156
|
+
expect(element.getAttribute("style")).toBe(edited);
|
|
157
|
+
|
|
158
|
+
saved();
|
|
159
|
+
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
it("a drag of a box GSAP positions is painted back to where GSAP had it, and shown again", async () => {
|
|
164
|
+
const gsapOf = fakeGsap({ x: 5, y: 7 });
|
|
165
|
+
let saved!: () => void;
|
|
166
|
+
const element = dragWithSaveRunning(new Promise<void>((resolve) => (saved = resolve)));
|
|
167
|
+
const moved = { ...gsapOf(element) };
|
|
168
|
+
expect(moved).not.toMatchObject({ x: 5, y: 7 });
|
|
169
|
+
|
|
170
|
+
const shown = paintBackNewestStudioPendingEdit();
|
|
171
|
+
expect(gsapOf(element)).toMatchObject({ x: 5, y: 7 });
|
|
172
|
+
shown!.showAgain();
|
|
173
|
+
expect(gsapOf(element)).toMatchObject({ x: moved.x, y: moved.y });
|
|
174
|
+
|
|
175
|
+
saved();
|
|
176
|
+
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("a rotate of a box GSAP turns is painted back to GSAP's angle at press, and shown again", async () => {
|
|
180
|
+
const gsapOf = fakeGsap({ rotation: 10 });
|
|
181
|
+
let saved!: () => void;
|
|
182
|
+
const element = gestureWithSaveRunning(
|
|
183
|
+
"rotate",
|
|
184
|
+
new Promise<void>((resolve) => (saved = resolve)),
|
|
185
|
+
);
|
|
186
|
+
const turned = gsapOf(element).rotation;
|
|
187
|
+
expect(turned).not.toBe(10);
|
|
188
|
+
|
|
189
|
+
const shown = paintBackNewestStudioPendingEdit();
|
|
190
|
+
expect(gsapOf(element).rotation).toBe(10);
|
|
191
|
+
shown!.showAgain();
|
|
192
|
+
expect(gsapOf(element).rotation).toBe(turned);
|
|
193
|
+
|
|
194
|
+
saved();
|
|
195
|
+
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("a GSAP drag saved as a left/top offset stays painted back while that offset is drawn", async () => {
|
|
199
|
+
fakeGsap({ x: 5, y: 7 });
|
|
200
|
+
let saved!: () => void;
|
|
201
|
+
let drawn!: Promise<void>;
|
|
202
|
+
const element = dragWithSaveRunning(() => {
|
|
203
|
+
const edit = adoptingStudioPendingEdit()!;
|
|
204
|
+
drawn = new Promise<void>((resolve) => (saved = resolve)).then(() =>
|
|
205
|
+
edit.drawKeepingUndone(() => void element.style.setProperty("left", "99px")),
|
|
206
|
+
);
|
|
207
|
+
return drawn;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const shown = paintBackNewestStudioPendingEdit();
|
|
211
|
+
saved();
|
|
212
|
+
await drawn;
|
|
213
|
+
expect(element.style.getPropertyValue("left")).toBe("");
|
|
214
|
+
shown!.showAgain();
|
|
215
|
+
expect(element.style.getPropertyValue("left")).toBe("99px");
|
|
216
|
+
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("keeps a GSAP drag's base through every repaint of its undo, so its save adds the drag once", async () => {
|
|
220
|
+
const gsapOf = fakeGsap({ x: 5, y: 7 });
|
|
221
|
+
let edit!: StudioEditInFlight;
|
|
222
|
+
let release!: () => void;
|
|
223
|
+
const element = dragWithSaveRunning(() => {
|
|
224
|
+
edit = adoptingStudioPendingEdit()!;
|
|
225
|
+
return new Promise<void>((resolve) => (release = resolve));
|
|
226
|
+
});
|
|
227
|
+
const moved = { ...gsapOf(element) };
|
|
228
|
+
|
|
229
|
+
const shown = paintBackNewestStudioPendingEdit();
|
|
230
|
+
edit.drawKeepingUndone(() => undefined);
|
|
231
|
+
expect(gsapOf(element)).toMatchObject({ x: 5, y: 7 });
|
|
232
|
+
expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe("5");
|
|
233
|
+
shown!.showAgain();
|
|
234
|
+
expect(gsapOf(element)).toMatchObject({ x: moved.x, y: moved.y });
|
|
235
|
+
expect(element.getAttribute("data-hf-drag-gsap-base-x")).toBe("5");
|
|
236
|
+
|
|
237
|
+
release();
|
|
238
|
+
await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
|
|
239
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {
|
|
2
|
+
captureStudioPathOffset,
|
|
3
|
+
restoreStudioPathOffset,
|
|
4
|
+
type StudioPathOffsetSnapshot,
|
|
5
|
+
} from "./manualEdits";
|
|
6
|
+
import {
|
|
7
|
+
getOffsetDragGsap,
|
|
8
|
+
stampGestureBase,
|
|
9
|
+
type ManualOffsetDragMember,
|
|
10
|
+
} from "./manualOffsetDrag";
|
|
11
|
+
import type { StudioEditRevert } from "../../utils/studioPendingEdits";
|
|
12
|
+
|
|
13
|
+
interface MemberPosition {
|
|
14
|
+
offset: StudioPathOffsetSnapshot;
|
|
15
|
+
gsap: { x: number; y: number } | null;
|
|
16
|
+
placement: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Where an element-offset move draws the element when no transform channel can take it.
|
|
20
|
+
const PLACEMENT = ["position", "left", "top"];
|
|
21
|
+
const placementOf = (element: HTMLElement) =>
|
|
22
|
+
PLACEMENT.map((prop) => element.style.getPropertyValue(prop));
|
|
23
|
+
|
|
24
|
+
function showPlacement(element: HTMLElement, placement: string[]): void {
|
|
25
|
+
PLACEMENT.forEach((prop, i) => {
|
|
26
|
+
if (placement[i]) element.style.setProperty(prop, placement[i]!);
|
|
27
|
+
else element.style.removeProperty(prop);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readMemberPosition(member: ManualOffsetDragMember): MemberPosition {
|
|
32
|
+
const gsap = member.plainTranslate ? null : getOffsetDragGsap(member.element);
|
|
33
|
+
return {
|
|
34
|
+
offset: captureStudioPathOffset(member.element),
|
|
35
|
+
gsap: gsap && {
|
|
36
|
+
x: Number(gsap.getProperty(member.element, "x")),
|
|
37
|
+
y: Number(gsap.getProperty(member.element, "y")),
|
|
38
|
+
},
|
|
39
|
+
placement: placementOf(member.element),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function showMemberPosition(member: ManualOffsetDragMember, position: MemberPosition): void {
|
|
44
|
+
restoreStudioPathOffset(member.element, position.offset);
|
|
45
|
+
showPlacement(member.element, position.placement);
|
|
46
|
+
if (position.gsap) getOffsetDragGsap(member.element)?.set(member.element, { ...position.gsap });
|
|
47
|
+
// The restore drops the gesture's base stamps; its save still reads them.
|
|
48
|
+
if (!member.plainTranslate)
|
|
49
|
+
stampGestureBase(member.element, member.initialOffset, member.baseGsap);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Undo's live revert of a move: its members at gesture start. */
|
|
53
|
+
export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert {
|
|
54
|
+
const startPlacement = members.map((member) => placementOf(member.element));
|
|
55
|
+
return () => {
|
|
56
|
+
const shown = members.map(readMemberPosition);
|
|
57
|
+
members.forEach((member, i) =>
|
|
58
|
+
showMemberPosition(member, {
|
|
59
|
+
offset: member.initialPathOffset,
|
|
60
|
+
gsap: member.plainTranslate ? null : member.baseGsap,
|
|
61
|
+
placement: startPlacement[i]!,
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
return () => members.forEach((member, i) => showMemberPosition(member, shown[i]!));
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface StudioElementLook {
|
|
69
|
+
style: string | null;
|
|
70
|
+
gsap: Record<string, number> | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const GSAP_LOOK_PROPS = ["x", "y", "rotation", "scaleX", "scaleY"];
|
|
74
|
+
|
|
75
|
+
/** Reads GSAP only for an element it owns: reading a plain one makes GSAP bake its CSS into a transform. */
|
|
76
|
+
export function readElementLook(element: HTMLElement, gsapOwned: boolean): StudioElementLook {
|
|
77
|
+
const gsap = gsapOwned ? getOffsetDragGsap(element) : null;
|
|
78
|
+
return {
|
|
79
|
+
style: element.getAttribute("style"),
|
|
80
|
+
gsap:
|
|
81
|
+
gsap &&
|
|
82
|
+
Object.fromEntries(
|
|
83
|
+
GSAP_LOOK_PROPS.map((prop) => [prop, Number(gsap.getProperty(element, prop))]),
|
|
84
|
+
),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function showElementLook(element: HTMLElement, look: StudioElementLook): void {
|
|
89
|
+
if (look.style === null) element.removeAttribute("style");
|
|
90
|
+
else element.setAttribute("style", look.style);
|
|
91
|
+
if (look.gsap) getOffsetDragGsap(element)?.set(element, { ...look.gsap });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Undo's live revert of a resize or rotate: the element as it looked at press. */
|
|
95
|
+
export function elementLookRevert(
|
|
96
|
+
element: HTMLElement,
|
|
97
|
+
start: StudioElementLook,
|
|
98
|
+
): StudioEditRevert {
|
|
99
|
+
return () => {
|
|
100
|
+
const shown = readElementLook(element, start.gsap !== null);
|
|
101
|
+
showElementLook(element, start);
|
|
102
|
+
return () => showElementLook(element, shown);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
|
|
14
14
|
import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
|
|
15
15
|
import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
|
|
16
|
-
import type { StudioEditRevert } from "../../utils/studioPendingEdits";
|
|
17
16
|
|
|
18
17
|
interface OffsetDragGsap {
|
|
19
18
|
set: (el: Element, vars: Record<string, number | string>) => void;
|
|
@@ -448,7 +447,7 @@ export function applyManualOffsetDragDraft(
|
|
|
448
447
|
* the element flies off-screen the instant you drop it. The member holds the
|
|
449
448
|
* true gesture-start values in JS, immune to the re-render.
|
|
450
449
|
*/
|
|
451
|
-
function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
|
|
450
|
+
export function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
|
|
452
451
|
el.setAttribute("data-hf-drag-gsap-base-x", String(baseGsap.x));
|
|
453
452
|
el.setAttribute("data-hf-drag-gsap-base-y", String(baseGsap.y));
|
|
454
453
|
el.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
|
|
@@ -510,16 +509,6 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
|
|
|
510
509
|
endStudioManualEditGesture(member.element, member.gestureToken);
|
|
511
510
|
}
|
|
512
511
|
|
|
513
|
-
/** Undo's live revert of a move: its members at gesture start. Null off the plain route, which GSAP draws. */
|
|
514
|
-
export function manualOffsetMoveRevert(members: ManualOffsetDragMember[]): StudioEditRevert | null {
|
|
515
|
-
if (!members.every((member) => member.plainTranslate)) return null;
|
|
516
|
-
return () => {
|
|
517
|
-
const shown = members.map((member) => captureStudioPathOffset(member.element));
|
|
518
|
-
for (const member of members) restoreStudioPathOffset(member.element, member.initialPathOffset);
|
|
519
|
-
return () => members.forEach((member, i) => restoreStudioPathOffset(member.element, shown[i]!));
|
|
520
|
-
};
|
|
521
|
-
}
|
|
522
|
-
|
|
523
512
|
/** Roll back a FAILED drag to the exact gesture-start state. */
|
|
524
513
|
export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
|
|
525
514
|
for (const member of members) {
|
|
@@ -32,10 +32,10 @@ import {
|
|
|
32
32
|
applyManualOffsetNudgeDraft,
|
|
33
33
|
createManualOffsetDragMember,
|
|
34
34
|
endManualOffsetDragMembers,
|
|
35
|
-
manualOffsetMoveRevert,
|
|
36
35
|
restoreManualOffsetDragMembers,
|
|
37
36
|
type ManualOffsetDragMember,
|
|
38
37
|
} from "./manualOffsetDrag";
|
|
38
|
+
import { manualOffsetMoveRevert } from "./gestureUndoRevert";
|
|
39
39
|
import { isStudioManualEditGestureCurrent, restoreStudioPathOffset } from "./manualEdits";
|
|
40
40
|
import {
|
|
41
41
|
CANVAS_NUDGE_COMMIT_DEBOUNCE_MS,
|
|
@@ -12,8 +12,8 @@ import {
|
|
|
12
12
|
applyManualOffsetDragDraft,
|
|
13
13
|
endManualOffsetDragMembers,
|
|
14
14
|
restoreManualOffsetDragMembers,
|
|
15
|
-
manualOffsetMoveRevert,
|
|
16
15
|
} from "./manualOffsetDrag";
|
|
16
|
+
import { manualOffsetMoveRevert, elementLookRevert } from "./gestureUndoRevert";
|
|
17
17
|
import { applyRotationDraft, restoreRotationDraft } from "./rotationDraft";
|
|
18
18
|
import {
|
|
19
19
|
applyStudioBoxSize,
|
|
@@ -443,7 +443,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
443
443
|
// Hold the final angle while the commit lands.
|
|
444
444
|
applyRotationDraft(sel.element, finalRotation.angle, g.plainRotation);
|
|
445
445
|
const commit = { ...finalRotation, plain: g.plainRotation };
|
|
446
|
-
|
|
446
|
+
const edit = beginStudioPendingEdit(elementLookRevert(sel.element, g.initialLook));
|
|
447
|
+
const saved = Promise.resolve(edit.adopt(() => opts.onRotationCommitRef.current(sel, commit)))
|
|
447
448
|
.then((result) => trackPreviewEditResult("rotate", "drag", result))
|
|
448
449
|
.catch((error) => {
|
|
449
450
|
logGestureCommitFailure("rotate commit failed", error);
|
|
@@ -454,6 +455,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
454
455
|
restoreRotation();
|
|
455
456
|
})
|
|
456
457
|
.finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
|
|
458
|
+
edit.settle(saved);
|
|
457
459
|
} else if (g.kind === "drag") {
|
|
458
460
|
// A moved drag (taps returned earlier) must not let the release click
|
|
459
461
|
// re-select whatever now sits under the pointer — dropping over a
|
|
@@ -535,9 +537,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
535
537
|
restoreStudioBoxSize(sel.element, g.initialBoxSize);
|
|
536
538
|
if (finalOffset) restoreStudioPathOffset(sel.element, g.initialPathOffset);
|
|
537
539
|
};
|
|
538
|
-
|
|
539
|
-
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member)
|
|
540
|
-
)
|
|
540
|
+
const commitSize = () =>
|
|
541
|
+
opts.onBoxSizeCommitRef.current(sel, finalSize, finalOffset ?? undefined, restore, member);
|
|
542
|
+
const edit = beginStudioPendingEdit(elementLookRevert(sel.element, g.initialLook));
|
|
543
|
+
const saved = Promise.resolve(edit.adopt(commitSize))
|
|
541
544
|
.then((result) => trackPreviewEditResult("resize", "drag", result))
|
|
542
545
|
.catch((error) => {
|
|
543
546
|
logGestureCommitFailure("resize commit failed", error);
|
|
@@ -546,6 +549,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
546
549
|
if (member) endManualOffsetDragMembers([member]);
|
|
547
550
|
else endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
|
548
551
|
});
|
|
552
|
+
edit.settle(saved);
|
|
549
553
|
logResizeSettle(sel.element, "post-release");
|
|
550
554
|
}
|
|
551
555
|
};
|
|
@@ -281,6 +281,66 @@ describe("runGestureTransaction", () => {
|
|
|
281
281
|
log.mockRestore();
|
|
282
282
|
});
|
|
283
283
|
|
|
284
|
+
it("measures a box undo painted back mid-save as the edit drew it, so it reports no change", async () => {
|
|
285
|
+
const element = document.createElement("div");
|
|
286
|
+
let undone = false;
|
|
287
|
+
vi.spyOn(element, "getBoundingClientRect").mockImplementation(() =>
|
|
288
|
+
undone ? rect(0, 0, 50, 40) : rect(10, 20, 100, 80),
|
|
289
|
+
);
|
|
290
|
+
const draw = <T>(run: () => T): T => {
|
|
291
|
+
undone = false;
|
|
292
|
+
try {
|
|
293
|
+
return run();
|
|
294
|
+
} finally {
|
|
295
|
+
undone = true;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
await runGestureTransaction({
|
|
300
|
+
element,
|
|
301
|
+
label: "Resize layer",
|
|
302
|
+
settle: vi.fn(),
|
|
303
|
+
persist: async () => void (undone = true),
|
|
304
|
+
restore: vi.fn(),
|
|
305
|
+
draw,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(trackStudioEventMock).not.toHaveBeenCalledWith(
|
|
309
|
+
"commit_invariant_violation",
|
|
310
|
+
expect.anything(),
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("measures as the edit drew it when undo painted the box back before the transaction began", async () => {
|
|
315
|
+
const element = document.createElement("div");
|
|
316
|
+
let undone = true;
|
|
317
|
+
vi.spyOn(element, "getBoundingClientRect").mockImplementation(() =>
|
|
318
|
+
undone ? rect(0, 0, 50, 40) : rect(10, 20, 100, 80),
|
|
319
|
+
);
|
|
320
|
+
const draw = <T>(run: () => T): T => {
|
|
321
|
+
undone = false;
|
|
322
|
+
try {
|
|
323
|
+
return run();
|
|
324
|
+
} finally {
|
|
325
|
+
undone = true;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
await runGestureTransaction({
|
|
330
|
+
element,
|
|
331
|
+
label: "Resize layer",
|
|
332
|
+
settle: vi.fn(),
|
|
333
|
+
persist: async () => undefined,
|
|
334
|
+
restore: vi.fn(),
|
|
335
|
+
draw,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
expect(trackStudioEventMock).not.toHaveBeenCalledWith(
|
|
339
|
+
"commit_invariant_violation",
|
|
340
|
+
expect.anything(),
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
284
344
|
it("skips the pixel assertion for live position tweens", async () => {
|
|
285
345
|
const element = document.createElement("div");
|
|
286
346
|
const getRect = vi.spyOn(element, "getBoundingClientRect");
|
|
@@ -18,6 +18,7 @@ export interface GestureTransaction {
|
|
|
18
18
|
afterBufferedCommitsSaved?(): Promise<void>;
|
|
19
19
|
restore(): void;
|
|
20
20
|
skipPixelAssert?: boolean;
|
|
21
|
+
draw?<T>(run: () => T): T;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
let transactionCounter = 0;
|
|
@@ -126,7 +127,9 @@ export function runGestureTransaction(tx: GestureTransaction): Promise<void> {
|
|
|
126
127
|
tx.settle();
|
|
127
128
|
logCommit("settled", { label: tx.label, coalesceKey });
|
|
128
129
|
|
|
129
|
-
const
|
|
130
|
+
const measure = () =>
|
|
131
|
+
tx.draw ? tx.draw(() => readPixelRect(tx.element)) : readPixelRect(tx.element);
|
|
132
|
+
const before = !tx.skipPixelAssert ? measure() : null;
|
|
130
133
|
const commit: TxCommit = (commitMutation) => {
|
|
131
134
|
const wrapped: CommitMutation = (selection, mutation, options) => {
|
|
132
135
|
mutationCount += 1;
|
|
@@ -150,7 +153,7 @@ export function runGestureTransaction(tx: GestureTransaction): Promise<void> {
|
|
|
150
153
|
const durationMs = Math.round(performance.now() - startedAt);
|
|
151
154
|
logCommit("persisted", { label: tx.label, coalesceKey });
|
|
152
155
|
if (before) {
|
|
153
|
-
const after =
|
|
156
|
+
const after = measure();
|
|
154
157
|
const delta = pixelDelta(before, after);
|
|
155
158
|
if (exceedsPixelTolerance(delta)) {
|
|
156
159
|
logCommit("persist-changed-pixels", { label: tx.label, before, after, delta });
|
|
@@ -12,8 +12,11 @@ import {
|
|
|
12
12
|
type KeyframeUsageAction,
|
|
13
13
|
} from "../utils/keyframeUsage";
|
|
14
14
|
import type { GeometryCommitResult } from "../utils/previewFeatureUsage";
|
|
15
|
+
import { adoptingStudioPendingEdit } from "../utils/studioPendingEdits";
|
|
15
16
|
|
|
16
17
|
export function observeGsapGesture(writer: CommitMutation | null) {
|
|
18
|
+
const edit = adoptingStudioPendingEdit();
|
|
19
|
+
const join = <T>(run: () => T): T => (edit ? edit.within(run) : run());
|
|
17
20
|
let changed = false;
|
|
18
21
|
let pendingResults = 0;
|
|
19
22
|
const actions = new Set<KeyframeUsageAction>();
|
|
@@ -21,12 +24,14 @@ export function observeGsapGesture(writer: CommitMutation | null) {
|
|
|
21
24
|
pendingResults += 1;
|
|
22
25
|
return {
|
|
23
26
|
...options,
|
|
27
|
+
...(edit && { pendingEdit: edit }),
|
|
24
28
|
keyframeTelemetry: false,
|
|
25
29
|
onResult: (result: MutationResult) => {
|
|
26
30
|
pendingResults -= 1;
|
|
27
31
|
options.onResult?.(result);
|
|
28
32
|
if (!result.ok || result.changed !== true) return;
|
|
29
33
|
changed = true;
|
|
34
|
+
edit?.markSaved();
|
|
30
35
|
const members = changedMutationIndices(
|
|
31
36
|
result,
|
|
32
37
|
calls.length,
|
|
@@ -43,16 +48,18 @@ export function observeGsapGesture(writer: CommitMutation | null) {
|
|
|
43
48
|
let commit: CommitMutation | null = null;
|
|
44
49
|
if (writer) {
|
|
45
50
|
commit = (selection, mutation, options) =>
|
|
46
|
-
writer(selection, mutation, observe([{ selection, mutation, options }], options));
|
|
51
|
+
join(() => writer(selection, mutation, observe([{ selection, mutation, options }], options)));
|
|
47
52
|
if (writer.batch) {
|
|
48
53
|
const batch = writer.batch;
|
|
49
|
-
commit.batch = (calls, options) => batch(calls, observe(calls, options));
|
|
54
|
+
commit.batch = (calls, options) => join(() => batch(calls, observe(calls, options)));
|
|
50
55
|
}
|
|
51
56
|
}
|
|
52
57
|
return {
|
|
53
58
|
commit,
|
|
59
|
+
drawKeepingUndone: <T>(draw: () => T): T => (edit ? edit.drawKeepingUndone(draw) : draw()),
|
|
54
60
|
recordDomResult: (result: { changed: boolean } | undefined) => {
|
|
55
61
|
changed ||= result?.changed === true;
|
|
62
|
+
if (result?.changed) edit?.markSaved();
|
|
56
63
|
},
|
|
57
64
|
finish: (domChanged = false): GeometryCommitResult => {
|
|
58
65
|
if (pendingResults !== 0) return { ok: true, changed: false };
|