@hyperframes/studio 0.8.124 → 0.8.126
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-BbbN-wWN.js} +1 -1
- package/dist/assets/{index-BuSTrK-Z.js → index-B8kU2xXl.js} +177 -177
- package/dist/assets/{index-CVuUfURE.js → index-Cll7uTNy.js} +1 -1
- package/dist/assets/{index-jQEqFKMk.js → index-nvLF29ZH.js} +1 -1
- package/dist/index.d.ts +43 -35
- package/dist/index.html +1 -1
- package/dist/index.js +4458 -4072
- package/dist/index.js.map +1 -1
- package/package.json +9 -8
- package/src/components/editor/DomEditSelectionChrome.test.tsx +5 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +1 -0
- package/src/components/editor/MotionPathNode.tsx +56 -0
- package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +140 -48
- package/src/components/editor/MotionPathOverlay.test.tsx +45 -0
- package/src/components/editor/MotionPathOverlay.tsx +30 -61
- package/src/components/editor/canvasContextMenuZOrder.ts +1 -10
- 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/manualEditsSeekReapply.ts +3 -6
- package/src/components/editor/manualOffsetDrag.ts +3 -15
- package/src/components/editor/motionPathGeometry.test.ts +48 -1
- package/src/components/editor/motionPathGeometry.ts +46 -8
- package/src/components/editor/motionPathHome.ts +75 -0
- package/src/components/editor/motionPathLayerNode.test.ts +43 -35
- package/src/components/editor/motionPathLayerNode.ts +20 -16
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +20 -0
- package/src/components/editor/studioMotion.ts +7 -19
- package/src/components/editor/useDomEditNudge.ts +1 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +9 -5
- package/src/components/editor/useMotionPathData.test.tsx +236 -0
- package/src/components/editor/useMotionPathData.ts +23 -61
- package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +1 -0
- 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/gsapParsedTween.ts +16 -0
- 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/gsapRuntimeKeyframes.realGsap.test.ts +48 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +10 -3
- package/src/hooks/gsapScriptCommitTypes.ts +2 -0
- package/src/hooks/gsapShared.ts +2 -1
- package/src/hooks/gsapShared.writeTarget.test.ts +10 -0
- package/src/hooks/intersectionObserverTestUtils.ts +16 -0
- package/src/hooks/resizeObserverTestUtils.ts +9 -2
- package/src/hooks/timelineEditingHelpers.ts +2 -9
- 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/hooks/useThumbnailStripSize.test.tsx +236 -1
- package/src/hooks/useThumbnailStripSize.ts +206 -17
- package/src/noWindowBoundDomChecks.test.ts +31 -0
- package/src/player/components/CompositionThumbnail.tsx +10 -6
- package/src/player/components/ImageThumbnail.tsx +11 -5
- package/src/player/components/ThumbnailTiles.test.tsx +30 -0
- package/src/player/components/ThumbnailTiles.tsx +41 -0
- package/src/player/components/VideoThumbnail.test.tsx +116 -9
- package/src/player/components/VideoThumbnail.tsx +11 -5
- package/src/player/lib/timelineGroupInfo.realm.test.ts +20 -0
- package/src/player/lib/timelineGroupInfo.ts +2 -1
- package/src/utils/studioPendingEdits.test.ts +52 -0
- package/src/utils/studioPendingEdits.ts +55 -10
- package/src/webmcp/webmcpTestUtils.ts +2 -2
|
@@ -1,31 +1,19 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type RefObject } from "react";
|
|
2
|
+
import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
|
|
2
3
|
import { readRuntimeKeyframes } from "../../hooks/gsapRuntimeKeyframes";
|
|
3
4
|
import { readGsapPositionFromIframe } from "../../hooks/gsapPositionDetection";
|
|
4
5
|
import { isElementVisibleForOverlay } from "./domEditOverlayGeometry";
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
buildMotionPathGeometry,
|
|
8
|
+
type MotionPathGeometry,
|
|
9
|
+
type MotionPathHome,
|
|
10
|
+
} from "./motionPathGeometry";
|
|
11
|
+
import { elementHome } from "./motionPathHome";
|
|
6
12
|
import { subscribeOverlayFrame } from "./overlayFrameLoop";
|
|
7
13
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
8
14
|
|
|
9
15
|
type Rect = { left: number; top: number; width: number; height: number };
|
|
10
16
|
|
|
11
|
-
// The translate (e/f) components of an element's computed transform, in comp px.
|
|
12
|
-
// A group wrapper dragged via GSAP carries its offset here, not in offsetLeft/Top.
|
|
13
|
-
function transformTranslate(el: HTMLElement): { x: number; y: number } {
|
|
14
|
-
const t = el.ownerDocument?.defaultView?.getComputedStyle(el).transform;
|
|
15
|
-
if (!t || t === "none") return { x: 0, y: 0 };
|
|
16
|
-
const m3 = t.match(/matrix3d\(([^)]+)\)/);
|
|
17
|
-
if (m3) {
|
|
18
|
-
const v = m3[1].split(",").map(Number);
|
|
19
|
-
return { x: v[12] || 0, y: v[13] || 0 };
|
|
20
|
-
}
|
|
21
|
-
const m = t.match(/matrix\(([^)]+)\)/);
|
|
22
|
-
if (m) {
|
|
23
|
-
const v = m[1].split(",").map(Number);
|
|
24
|
-
return { x: v[4] || 0, y: v[5] || 0 };
|
|
25
|
-
}
|
|
26
|
-
return { x: 0, y: 0 };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
17
|
// Perspective foreshortening of the element's OWN transform (matrix3d m44). A
|
|
30
18
|
// depth element (translateZ toward the viewer) renders 1/m44× larger, so its
|
|
31
19
|
// animated x/y offsets travel 1/m44× further on screen than the flat preview
|
|
@@ -40,43 +28,6 @@ export function transformWDivisor(el: HTMLElement): number {
|
|
|
40
28
|
return Number.isFinite(w) && w > 0 ? w : 1;
|
|
41
29
|
}
|
|
42
30
|
|
|
43
|
-
export function elementHome(el: HTMLElement): { x: number; y: number } {
|
|
44
|
-
let left = 0;
|
|
45
|
-
let top = 0;
|
|
46
|
-
let node: HTMLElement | null = el;
|
|
47
|
-
while (node) {
|
|
48
|
-
left += node.offsetLeft;
|
|
49
|
-
top += node.offsetTop;
|
|
50
|
-
// Ancestor transforms (e.g. a group wrapper moved via GSAP) shift where the
|
|
51
|
-
// element actually renders, so the path must anchor on top of them. The element's
|
|
52
|
-
// OWN transform is excluded — that's the animated offset the path itself draws.
|
|
53
|
-
if (node !== el) {
|
|
54
|
-
const t = transformTranslate(node);
|
|
55
|
-
left += t.x;
|
|
56
|
-
top += t.y;
|
|
57
|
-
}
|
|
58
|
-
const parent = node.offsetParent as HTMLElement | null;
|
|
59
|
-
if (!parent || parent.hasAttribute("data-composition-id")) break;
|
|
60
|
-
node = parent;
|
|
61
|
-
}
|
|
62
|
-
let x = left + el.offsetWidth / 2;
|
|
63
|
-
let y = top + el.offsetHeight / 2;
|
|
64
|
-
if ((el.style.translate ?? "").includes("var(")) {
|
|
65
|
-
x += Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-x")) || 0;
|
|
66
|
-
y += Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-y")) || 0;
|
|
67
|
-
}
|
|
68
|
-
return { x, y };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function isPreviewHtmlElement(
|
|
72
|
-
node: Element | null | undefined,
|
|
73
|
-
iframe: HTMLIFrameElement | null,
|
|
74
|
-
): node is HTMLElement {
|
|
75
|
-
const Ctor = (iframe?.contentWindow as unknown as { HTMLElement?: typeof HTMLElement } | null)
|
|
76
|
-
?.HTMLElement;
|
|
77
|
-
return Boolean(node && Ctor && node instanceof Ctor);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
31
|
function rectsClose(a: Rect, b: Rect): boolean {
|
|
81
32
|
return (
|
|
82
33
|
Math.abs(a.left - b.left) < 0.5 &&
|
|
@@ -104,7 +55,7 @@ export function useMotionPathData(
|
|
|
104
55
|
geometry: MotionPathGeometry | null;
|
|
105
56
|
geometryResolved: boolean;
|
|
106
57
|
visibleInPreview: boolean;
|
|
107
|
-
home:
|
|
58
|
+
home: MotionPathHome | null;
|
|
108
59
|
pScale: number;
|
|
109
60
|
} {
|
|
110
61
|
const [rect, setRect] = useState<Rect | null>(null);
|
|
@@ -112,7 +63,7 @@ export function useMotionPathData(
|
|
|
112
63
|
const resolvedForRef = useRef<string | null>(null);
|
|
113
64
|
const geometryResolved = resolvedForRef.current === selector;
|
|
114
65
|
const [visibleInPreview, setVisibleInPreview] = useState(true);
|
|
115
|
-
const [home, setHome] = useState<
|
|
66
|
+
const [home, setHome] = useState<MotionPathHome | null>(null);
|
|
116
67
|
// Perspective magnification (1/m44) of the selected element — applied to the
|
|
117
68
|
// path's offset points so depth (translateZ) elements' paths track on screen.
|
|
118
69
|
const [pScale, setPScale] = useState(1);
|
|
@@ -146,13 +97,21 @@ export function useMotionPathData(
|
|
|
146
97
|
} catch {
|
|
147
98
|
/* cross-origin guard */
|
|
148
99
|
}
|
|
149
|
-
const live =
|
|
100
|
+
const live = isHtmlElement(target) ? target : null;
|
|
150
101
|
const vis = live ? isElementVisibleForOverlay(live) : true;
|
|
151
102
|
setVisibleInPreview((prev) => (prev === vis ? prev : vis));
|
|
152
103
|
if (live) {
|
|
153
104
|
const h = elementHome(live);
|
|
154
105
|
setHome((prev) =>
|
|
155
|
-
prev &&
|
|
106
|
+
prev &&
|
|
107
|
+
Math.abs(prev.x - h.x) < 0.5 &&
|
|
108
|
+
Math.abs(prev.y - h.y) < 0.5 &&
|
|
109
|
+
prev.w === h.w &&
|
|
110
|
+
prev.h === h.h &&
|
|
111
|
+
prev.ax === h.ax &&
|
|
112
|
+
prev.ay === h.ay
|
|
113
|
+
? prev
|
|
114
|
+
: h,
|
|
156
115
|
);
|
|
157
116
|
const ps = 1 / transformWDivisor(live);
|
|
158
117
|
setPScale((p) => (Math.abs(p - ps) < 0.001 ? p : ps));
|
|
@@ -173,7 +132,10 @@ export function useMotionPathData(
|
|
|
173
132
|
const base = read ? readGsapPositionFromIframe(iframeRef.current, selector) : null;
|
|
174
133
|
const next = buildMotionPathGeometry(read, base ?? undefined);
|
|
175
134
|
setGeometry((prev) =>
|
|
176
|
-
prev?.
|
|
135
|
+
prev?.kind === next?.kind &&
|
|
136
|
+
JSON.stringify([prev?.nodes, prev?.start]) === JSON.stringify([next?.nodes, next?.start])
|
|
137
|
+
? prev
|
|
138
|
+
: next,
|
|
177
139
|
);
|
|
178
140
|
resolvedForRef.current = selector;
|
|
179
141
|
};
|
|
@@ -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 };
|
|
@@ -2,6 +2,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
2
2
|
import { elementTargets } from "../utils/elementGsap";
|
|
3
3
|
import { resolveTweenStart } from "../utils/globalTimeCompiler";
|
|
4
4
|
import type { ImplicitEndValue } from "./gsapValueAtPlayhead";
|
|
5
|
+
import type { ReadTween } from "./gsapRuntimeKeyframes";
|
|
5
6
|
|
|
6
7
|
// GSAP 3 internals: a property tween in a tween's `_pt` chain; CSSPlugin keeps its own under `d._pt`.
|
|
7
8
|
interface PropTween {
|
|
@@ -57,6 +58,21 @@ function endsIn(tween: ParsedTween, prop: string): [number, number] | null {
|
|
|
57
58
|
return tween._from ? [pair[1], pair[0]] : pair;
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/** `read` plus GSAP's start for a step list or a first key past 0%, read per channel. */
|
|
62
|
+
export function withParsedStart(read: ReadTween, live: unknown): ReadTween {
|
|
63
|
+
const tween = live as ParsedTween;
|
|
64
|
+
const first = read.keyframes[0];
|
|
65
|
+
if (!Array.isArray(tween.vars?.keyframes) && !((first?.percentage ?? 0) > 0)) return read;
|
|
66
|
+
const children = tween.timeline?.getChildren?.() ?? [tween];
|
|
67
|
+
const start: Record<string, number> = {};
|
|
68
|
+
for (const prop of ["x", "y", "width", "height"]) {
|
|
69
|
+
const ends = children.map((child) => endsIn(child, prop)).find(Boolean);
|
|
70
|
+
if (ends) start[prop] = ends[0];
|
|
71
|
+
}
|
|
72
|
+
const moved = Object.entries(start).some(([prop, value]) => value !== first?.properties[prop]);
|
|
73
|
+
return moved ? { ...read, start } : read;
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
/** The live tween GSAP built from `anim`: same element, start and channels, parsed. */
|
|
61
77
|
// fallow-ignore-next-line complexity
|
|
62
78
|
export function findParsedTween(
|
|
@@ -75,6 +75,8 @@ interface ResizeCase {
|
|
|
75
75
|
longhandTween?: boolean;
|
|
76
76
|
/** What already writes this element's position. */
|
|
77
77
|
positionWrite?: "static-set" | "keyframed-tween" | "none";
|
|
78
|
+
/** Undo paints the box back to its press look while the animations load. */
|
|
79
|
+
paintedBackWhileLoading?: boolean;
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
function createResizeElement(testCase: ResizeCase): HTMLElement {
|
|
@@ -241,6 +243,30 @@ async function runCase(testCase: ResizeCase) {
|
|
|
241
243
|
} as unknown as HTMLIFrameElement;
|
|
242
244
|
|
|
243
245
|
const dropPoint = el.getBoundingClientRect();
|
|
246
|
+
// A painted-back edit: undone on screen, shown again only inside a draw (as `drawKeepingUndone` does).
|
|
247
|
+
const look = () => ({
|
|
248
|
+
w: el.style.width,
|
|
249
|
+
h: el.style.height,
|
|
250
|
+
pos: { ...live.pos },
|
|
251
|
+
scale: { ...live.scale },
|
|
252
|
+
});
|
|
253
|
+
const show = (shown: ReturnType<typeof look>) => {
|
|
254
|
+
el.style.width = shown.w;
|
|
255
|
+
el.style.height = shown.h;
|
|
256
|
+
Object.assign(live, { pos: { ...shown.pos }, scale: { ...shown.scale } });
|
|
257
|
+
};
|
|
258
|
+
let shownEdit: ReturnType<typeof look> | null = null;
|
|
259
|
+
const paintBack = () => {
|
|
260
|
+
shownEdit = look();
|
|
261
|
+
show({ w: "", h: "", pos: { ...testCase.base }, scale: { ...testCase.liveScale } });
|
|
262
|
+
};
|
|
263
|
+
const draw = <T>(run: () => T): T => {
|
|
264
|
+
if (!shownEdit) return run();
|
|
265
|
+
show(shownEdit);
|
|
266
|
+
const drawn = run();
|
|
267
|
+
paintBack();
|
|
268
|
+
return drawn;
|
|
269
|
+
};
|
|
244
270
|
const position = positionAnimation(testCase.positionWrite ?? "static-set", testCase.base);
|
|
245
271
|
const animations = [scaleTween(!!testCase.longhandTween), ...(position ? [position] : [])];
|
|
246
272
|
const commitMutation = vi.fn();
|
|
@@ -252,7 +278,12 @@ async function runCase(testCase: ResizeCase) {
|
|
|
252
278
|
animations,
|
|
253
279
|
iframe,
|
|
254
280
|
commitMutation,
|
|
255
|
-
async () =>
|
|
281
|
+
async () => {
|
|
282
|
+
if (testCase.paintedBackWhileLoading) paintBack();
|
|
283
|
+
return animations;
|
|
284
|
+
},
|
|
285
|
+
undefined,
|
|
286
|
+
draw,
|
|
256
287
|
);
|
|
257
288
|
|
|
258
289
|
// Re-render what the file now says: the committed scale, the persisted
|
|
@@ -393,3 +424,10 @@ it("holds the drop point across a second drag", async () => {
|
|
|
393
424
|
expect(second.settled.y).toBeCloseTo(second.dropPoint.y, 0);
|
|
394
425
|
expect(second.settled.w).toBeCloseTo(second.dropPoint.w, 0);
|
|
395
426
|
});
|
|
427
|
+
|
|
428
|
+
it("lands on the drop point when undo paints the box back while its animations load", async () => {
|
|
429
|
+
const { dropPoint, settled } = await runCase({ ...CASES[0]!, paintedBackWhileLoading: true });
|
|
430
|
+
expect(settled.x).toBeCloseTo(dropPoint.x, 0);
|
|
431
|
+
expect(settled.y).toBeCloseTo(dropPoint.y, 0);
|
|
432
|
+
expect(settled.w).toBeCloseTo(dropPoint.w, 0);
|
|
433
|
+
});
|
|
@@ -5,6 +5,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
5
5
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
6
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
7
7
|
import { applyStudioBoxSizeDraft } from "../components/editor/manualEdits";
|
|
8
|
+
import {
|
|
9
|
+
beginStudioPendingEdit,
|
|
10
|
+
paintBackNewestStudioPendingEdit,
|
|
11
|
+
} from "../utils/studioPendingEdits";
|
|
8
12
|
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
9
13
|
import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
|
10
14
|
import { useGsapAwareEditing } from "./useGsapAwareEditing";
|
|
@@ -134,4 +138,27 @@ describe("resizing an element GSAP positions", () => {
|
|
|
134
138
|
expect(cssSize).toEqual([]);
|
|
135
139
|
act(() => h.root.unmount());
|
|
136
140
|
});
|
|
141
|
+
|
|
142
|
+
it("keeps a resize undo painted back drawn undone while its save lands, and draws it again on show again", async () => {
|
|
143
|
+
const h = mount([positionHold]);
|
|
144
|
+
const element = h.selection.element;
|
|
145
|
+
const edit = beginStudioPendingEdit(() => {
|
|
146
|
+
const shown = element.getAttribute("style");
|
|
147
|
+
element.setAttribute("style", "scale: none");
|
|
148
|
+
return () => element.setAttribute("style", shown ?? "");
|
|
149
|
+
});
|
|
150
|
+
const saved = edit.adopt(() => h.resize(h.selection, h.size, { x: -50, y: -33.5 }));
|
|
151
|
+
edit.settle(saved);
|
|
152
|
+
const shown = paintBackNewestStudioPendingEdit();
|
|
153
|
+
|
|
154
|
+
await act(() => saved);
|
|
155
|
+
expect(h.commitPatch.mock.calls.at(-1)![1]).toEqual(
|
|
156
|
+
expect.arrayContaining([{ type: "inline-style", property: "width", value: "340px" }]),
|
|
157
|
+
);
|
|
158
|
+
expect(element.getAttribute("style")).toBe("scale: none");
|
|
159
|
+
|
|
160
|
+
shown!.showAgain();
|
|
161
|
+
expect(element.getAttribute("style")).toContain("--hf-studio-width: 340px");
|
|
162
|
+
act(() => h.root.unmount());
|
|
163
|
+
});
|
|
137
164
|
});
|
|
@@ -135,6 +135,7 @@ export async function tryGsapResizeIntercept(
|
|
|
135
135
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
136
136
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
137
137
|
dragOffset?: { x: number; y: number },
|
|
138
|
+
draw: <T>(run: () => T) => T = (run) => run(),
|
|
138
139
|
): Promise<GsapEditOutcome> {
|
|
139
140
|
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
|
|
140
141
|
const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations);
|
|
@@ -315,7 +316,7 @@ export async function tryGsapResizeIntercept(
|
|
|
315
316
|
// difference and compensates, so release matches the drop pixel-for-pixel
|
|
316
317
|
// regardless of live scale or repeat resizes.
|
|
317
318
|
if (el) {
|
|
318
|
-
const dropRect = el.getBoundingClientRect();
|
|
319
|
+
const dropRect = draw(() => el.getBoundingClientRect());
|
|
319
320
|
scaleDraftDropPoint = { x: dropRect.x, y: dropRect.y };
|
|
320
321
|
}
|
|
321
322
|
} else {
|
|
@@ -342,75 +343,61 @@ export async function tryGsapResizeIntercept(
|
|
|
342
343
|
// resize took the size route and never moved anything: the drop point is
|
|
343
344
|
// the drag's to settle, not ours.
|
|
344
345
|
if (!scaleDraftEl) return false;
|
|
345
|
-
|
|
346
|
-
if (!scaleDraftDropPoint || !selector)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
// This step reads where the commit lands the box and shifts the position
|
|
350
|
-
// hold by the difference. That only works if the commit has actually
|
|
351
|
-
// rendered, and whether it had was luck: on the FIRST resize of an element
|
|
352
|
-
// the timeline had not re-seeked yet, so this measured the element at its
|
|
353
|
-
// natural size, still sitting on the drop point, computed a residual of
|
|
354
|
-
// zero, and skipped the correction entirely. The scale then landed, GSAP
|
|
355
|
-
// rendered it around the element's centre, and the element jumped by the
|
|
356
|
-
// whole drag distance. Elements that had been resized before got a
|
|
357
|
-
// correction only because their PREVIOUS scale made the residual non-zero.
|
|
358
|
-
//
|
|
359
|
-
// Setting it here costs nothing when the commit has already rendered (same
|
|
360
|
-
// value) and makes the measurement below mean what it says either way.
|
|
361
|
-
if (committedScale) {
|
|
362
|
-
setElementGsapScale(scaleDraftEl, committedScale.x, committedScale.y);
|
|
363
|
-
}
|
|
364
|
-
// Measure from the pre-gesture position, not the draft one.
|
|
365
|
-
//
|
|
366
|
-
// The resize draft translates the element to keep the dragged corner under
|
|
367
|
-
// the cursor, but the scale route never persists that translation — the
|
|
368
|
-
// element renders back at its pre-gesture position as soon as the commit
|
|
369
|
-
// lands. Measuring while the draft translation was still applied made the
|
|
370
|
-
// residual carry the whole drag distance, and the position commit then
|
|
371
|
-
// composed that residual onto the pre-gesture base (it reads the gesture's
|
|
372
|
-
// own base attributes, not the live value), so the element landed a full
|
|
373
|
-
// drag away from the drop point on every scale resize.
|
|
374
|
-
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
|
|
375
|
-
const { baseGsapX, baseGsapY } = computeDraggedGsapPosition(
|
|
376
|
-
selection.element,
|
|
377
|
-
{ x: 0, y: 0 },
|
|
378
|
-
gsapPos,
|
|
379
|
-
);
|
|
380
|
-
const base = { x: baseGsapX, y: baseGsapY };
|
|
381
|
-
setElementGsapPosition(scaleDraftEl, base.x, base.y);
|
|
382
|
-
const post = scaleDraftEl.getBoundingClientRect();
|
|
383
|
-
const residual = { x: scaleDraftDropPoint.x - post.x, y: scaleDraftDropPoint.y - post.y };
|
|
384
|
-
if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return false;
|
|
385
|
-
if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
|
|
386
|
-
logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
|
|
387
|
-
// Settled, with nothing to write. Still ours: forwarding the drag offset
|
|
388
|
-
// on top would move the box off the point it is already sitting on.
|
|
389
|
-
return true;
|
|
346
|
+
const draftEl = scaleDraftEl;
|
|
347
|
+
if (!scaleDraftDropPoint || !selector) {
|
|
348
|
+
draw(() => clearStudioBoxSize(draftEl));
|
|
349
|
+
return false;
|
|
390
350
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
351
|
+
const dropPoint = scaleDraftDropPoint;
|
|
352
|
+
const measured = draw(() => {
|
|
353
|
+
clearStudioBoxSize(draftEl);
|
|
354
|
+
// Draw the committed scale before measuring: on a first resize the timeline has not re-seeked yet,
|
|
355
|
+
// so the box would read at its natural size and the correction would be skipped.
|
|
356
|
+
if (committedScale) {
|
|
357
|
+
setElementGsapScale(draftEl, committedScale.x, committedScale.y);
|
|
358
|
+
}
|
|
359
|
+
// Measure from the pre-gesture position: the scale route never saves the draft's translation, and
|
|
360
|
+
// the position write composes the residual onto that same base.
|
|
361
|
+
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
|
|
362
|
+
const { baseGsapX, baseGsapY } = computeDraggedGsapPosition(
|
|
363
|
+
selection.element,
|
|
364
|
+
{ x: 0, y: 0 },
|
|
365
|
+
gsapPos,
|
|
366
|
+
);
|
|
367
|
+
const base = { x: baseGsapX, y: baseGsapY };
|
|
368
|
+
setElementGsapPosition(draftEl, base.x, base.y);
|
|
369
|
+
const post = draftEl.getBoundingClientRect();
|
|
370
|
+
const residual = { x: dropPoint.x - post.x, y: dropPoint.y - post.y };
|
|
371
|
+
if (!Number.isFinite(residual.x) || !Number.isFinite(residual.y)) return null;
|
|
372
|
+
if (Math.abs(residual.x) < 0.5 && Math.abs(residual.y) < 0.5) {
|
|
373
|
+
logResize("scale-finalize", { skipped: "already-on-drop-point", residual, base });
|
|
374
|
+
// Settled, with nothing to write. Still ours: forwarding the drag offset
|
|
375
|
+
// on top would move the box off the point it is already sitting on.
|
|
376
|
+
return "settled" as const;
|
|
377
|
+
}
|
|
378
|
+
// The ONE corrected position — rounded once so the live runtime and the
|
|
379
|
+
// persisted file agree exactly (commitStaticGsapPosition composes the same
|
|
380
|
+
// rounded value from this delta).
|
|
381
|
+
const corrected = {
|
|
382
|
+
x: roundTo3(base.x + residual.x),
|
|
383
|
+
y: roundTo3(base.y + residual.y),
|
|
384
|
+
};
|
|
385
|
+
logResize("scale-finalize", {
|
|
386
|
+
dropPoint,
|
|
387
|
+
post: { x: post.x, y: post.y },
|
|
388
|
+
residual,
|
|
389
|
+
gsapPos,
|
|
390
|
+
base,
|
|
391
|
+
corrected,
|
|
392
|
+
});
|
|
393
|
+
// Correct the live box in the same task as the measurement, so no frame shows it off the drop
|
|
394
|
+
// point while the position write is in flight.
|
|
395
|
+
setElementGsapPosition(draftEl, corrected.x, corrected.y);
|
|
396
|
+
return { base, corrected };
|
|
405
397
|
});
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
// that reload, so no frame has painted the uncorrected position yet. The
|
|
410
|
-
// server persist below costs network round-trips; without this set, the
|
|
411
|
-
// element visibly sits at the wrong spot for those frames (the drop
|
|
412
|
-
// "jump"). The persisted commit re-applies the same values (idempotent).
|
|
413
|
-
setElementGsapPosition(scaleDraftEl, corrected.x, corrected.y);
|
|
398
|
+
if (measured === null) return false;
|
|
399
|
+
if (measured === "settled") return true;
|
|
400
|
+
const { base, corrected } = measured;
|
|
414
401
|
// Re-fetch: the scale commit above just rewrote the script, so the caller's
|
|
415
402
|
// animation list (and its ids) may be stale for the position lookup.
|
|
416
403
|
const currentAnimations = fetchFallbackAnimations
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { gsap } from "gsap";
|
|
3
|
+
import { expect, it } from "vitest";
|
|
4
|
+
import { readRuntimeKeyframes } from "./gsapRuntimeKeyframes";
|
|
5
|
+
|
|
6
|
+
/** The edit bench's `keys` layer: GSAP starts it at x 40, 260 wide, and plays it to 1 s. */
|
|
7
|
+
function read(keyframes: string) {
|
|
8
|
+
const box = Object.assign(document.body.appendChild(document.createElement("div")), { id: "x" });
|
|
9
|
+
box.style.width = "260px";
|
|
10
|
+
gsap.set(box, { x: 40 });
|
|
11
|
+
const win = { __timelines: {} as Record<string, gsap.core.Timeline> };
|
|
12
|
+
new Function(
|
|
13
|
+
"gsap",
|
|
14
|
+
"window",
|
|
15
|
+
`var tl = gsap.timeline({ paused: true });
|
|
16
|
+
tl.to("#x", { keyframes: ${keyframes} }, 0);
|
|
17
|
+
window.__timelines["t"] = tl;`,
|
|
18
|
+
)(gsap, win);
|
|
19
|
+
const timeline = win.__timelines.t!;
|
|
20
|
+
timeline.progress(0.0001, true).seek(1);
|
|
21
|
+
const iframe = { contentWindow: { ...win, gsap }, contentDocument: document };
|
|
22
|
+
const got = readRuntimeKeyframes(iframe as unknown as HTMLIFrameElement, "#x");
|
|
23
|
+
timeline.kill();
|
|
24
|
+
box.remove();
|
|
25
|
+
return got;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
it("reads where GSAP started a step list whose first keyframe comes later", () => {
|
|
29
|
+
const got = read(
|
|
30
|
+
`[{ x: 60, width: 280, duration: 2, ease: "none" }, { x: 120, width: 320, duration: 1, ease: "none" }]`,
|
|
31
|
+
);
|
|
32
|
+
expect(got?.start).toEqual({ x: 40, width: 260 });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it.each([
|
|
36
|
+
[`{ "50%": { x: 60, y: 20 }, "100%": { x: 120, y: 40 } }, duration: 2`, { x: 40, y: 0 }],
|
|
37
|
+
[
|
|
38
|
+
`{ "50%": { x: 60, width: 280 }, "100%": { x: 120, width: 320 } }, duration: 2`,
|
|
39
|
+
{ x: 40, width: 260 },
|
|
40
|
+
],
|
|
41
|
+
])("reads every channel's start for a percentage list past 0%: %s", (keyframes, start) => {
|
|
42
|
+
expect(read(keyframes)?.start).toEqual(start);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("reads no start for keyframes that begin at 0%, or a step list that begins where GSAP started", () => {
|
|
46
|
+
expect(read(`{ "0%": { x: 10 }, "100%": { x: 120 } }, duration: 3`)?.start).toBeUndefined();
|
|
47
|
+
expect(read(`[{ x: 40, duration: 0 }, { x: 120, duration: 1 }]`)?.start).toBeUndefined();
|
|
48
|
+
});
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { buildArcPath, type ArcPathConfig } from "@hyperframes/core/gsap-parser-acorn";
|
|
12
12
|
import { parsePercentageKeyframes, toAbsoluteTime } from "./gsapShared";
|
|
13
13
|
import { roundTo3 } from "../utils/rounding";
|
|
14
|
+
import { withParsedStart } from "./gsapParsedTween";
|
|
14
15
|
import { BOX_SIZE_STYLE_PROPS } from "../components/editor/manualEditsDomPatches";
|
|
15
16
|
import { gsapRendersTransform } from "../components/editor/gsapAnimatesProperty";
|
|
16
17
|
|
|
@@ -43,7 +44,13 @@ export interface RuntimeTimeline {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
type Pct = { percentage: number; properties: Record<string, number | string> };
|
|
46
|
-
export type ReadTween = {
|
|
47
|
+
export type ReadTween = {
|
|
48
|
+
keyframes: Pct[];
|
|
49
|
+
easeEach?: string;
|
|
50
|
+
arcPath?: ArcPathConfig;
|
|
51
|
+
/** GSAP's start values when the first keyframe is after 0%. */
|
|
52
|
+
start?: Record<string, number>;
|
|
53
|
+
};
|
|
47
54
|
|
|
48
55
|
export interface RuntimeKeyframeEntry {
|
|
49
56
|
keyframes: Pct[];
|
|
@@ -365,11 +372,11 @@ export function readRuntimeKeyframes(
|
|
|
365
372
|
const read = readTween(tween.vars);
|
|
366
373
|
if (!read) continue;
|
|
367
374
|
if (requireChannels && !readCarriesChannel(read, requireChannels)) continue;
|
|
368
|
-
if (firstRead === null) firstRead = read;
|
|
375
|
+
if (firstRead === null) firstRead = withParsedStart(read, tween);
|
|
369
376
|
// Prefer the tween whose [start, start+dur] contains the playhead.
|
|
370
377
|
if (now != null) {
|
|
371
378
|
const start = typeof tween.startTime === "function" ? tween.startTime() : 0;
|
|
372
|
-
if (now >= start - 1e-3 && now <= start + dur + 1e-3) return read;
|
|
379
|
+
if (now >= start - 1e-3 && now <= start + dur + 1e-3) return withParsedStart(read, tween);
|
|
373
380
|
}
|
|
374
381
|
}
|
|
375
382
|
}
|