@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
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Puts Studio's committed edits back after a timeline seek has rendered over them.
|
|
2
|
+
import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
|
|
2
3
|
import {
|
|
3
4
|
STUDIO_BOX_SIZE_ATTR,
|
|
4
5
|
STUDIO_HEIGHT_PROP,
|
|
@@ -15,15 +16,11 @@ import { STUDIO_MOTION_ATTR, STUDIO_MOTION_TIMELINE_ID } from "./studioMotionTyp
|
|
|
15
16
|
import { gsapWritesChannels } from "../../hooks/gsapRuntimeKeyframes";
|
|
16
17
|
|
|
17
18
|
function queryStudioElements(doc: Document, attr: string): HTMLElement[] {
|
|
18
|
-
const
|
|
19
|
-
if (!ctor) return [];
|
|
20
|
-
const elements = Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(
|
|
21
|
-
(el): el is HTMLElement => el instanceof ctor,
|
|
22
|
-
);
|
|
19
|
+
const elements = Array.from(doc.querySelectorAll(`[${attr}="true"]`)).filter(isHtmlElement);
|
|
23
20
|
// Handle legacy HTML files where attributes were persisted with a double data- prefix
|
|
24
21
|
const legacyAttr = `data-${attr}`;
|
|
25
22
|
for (const el of doc.querySelectorAll(`[${legacyAttr}="true"]`)) {
|
|
26
|
-
if (el
|
|
23
|
+
if (isHtmlElement(el) && !el.hasAttribute(attr)) {
|
|
27
24
|
el.setAttribute(attr, "true");
|
|
28
25
|
el.removeAttribute(legacyAttr);
|
|
29
26
|
elements.push(el);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isHtmlElement } from "@hyperframes/core/runtime/dom-realm";
|
|
1
2
|
import type { DomEditSelection } from "./domEditing";
|
|
2
3
|
import {
|
|
3
4
|
applyStudioPathOffset,
|
|
@@ -13,7 +14,6 @@ import {
|
|
|
13
14
|
import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
|
|
14
15
|
import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
|
|
15
16
|
import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
|
|
16
|
-
import type { StudioEditRevert } from "../../utils/studioPendingEdits";
|
|
17
17
|
|
|
18
18
|
interface OffsetDragGsap {
|
|
19
19
|
set: (el: Element, vars: Record<string, number | string>) => void;
|
|
@@ -132,9 +132,7 @@ function getFrameElement(win: Window): HTMLElement | null {
|
|
|
132
132
|
try {
|
|
133
133
|
const frameElement = win.frameElement;
|
|
134
134
|
if (!frameElement) return null;
|
|
135
|
-
|
|
136
|
-
const htmlElement = ownerWin?.HTMLElement;
|
|
137
|
-
return htmlElement && frameElement instanceof htmlElement ? frameElement : null;
|
|
135
|
+
return isHtmlElement(frameElement) ? frameElement : null;
|
|
138
136
|
} catch {
|
|
139
137
|
return null;
|
|
140
138
|
}
|
|
@@ -448,7 +446,7 @@ export function applyManualOffsetDragDraft(
|
|
|
448
446
|
* the element flies off-screen the instant you drop it. The member holds the
|
|
449
447
|
* true gesture-start values in JS, immune to the re-render.
|
|
450
448
|
*/
|
|
451
|
-
function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
|
|
449
|
+
export function stampGestureBase(el: HTMLElement, initialOffset: Point, baseGsap: Point): void {
|
|
452
450
|
el.setAttribute("data-hf-drag-gsap-base-x", String(baseGsap.x));
|
|
453
451
|
el.setAttribute("data-hf-drag-gsap-base-y", String(baseGsap.y));
|
|
454
452
|
el.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x));
|
|
@@ -510,16 +508,6 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
|
|
|
510
508
|
endStudioManualEditGesture(member.element, member.gestureToken);
|
|
511
509
|
}
|
|
512
510
|
|
|
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
511
|
/** Roll back a FAILED drag to the exact gesture-start state. */
|
|
524
512
|
export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
|
|
525
513
|
for (const member of members) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { buildMotionPathGeometry, nearestPointOnPath } from "./motionPathGeometry";
|
|
2
|
+
import { buildMotionPathGeometry, nearestPointOnPath, nodeCentre } from "./motionPathGeometry";
|
|
3
3
|
import type { ReadTween } from "../../hooks/gsapRuntimeKeyframes";
|
|
4
4
|
|
|
5
5
|
const kf = (percentage: number, x: number, y: number) => ({ percentage, properties: { x, y } });
|
|
@@ -135,3 +135,50 @@ describe("nearestPointOnPath", () => {
|
|
|
135
135
|
expect(nearestPointOnPath(0, 0, [{ x: 0, y: 0 }])).toBeNull();
|
|
136
136
|
});
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
describe("nodeCentre", () => {
|
|
140
|
+
// The edit bench's `keys` layer at 1 s: left 560, 260 wide now, GSAP x 50; it reaches x 60 and
|
|
141
|
+
// width 280 at 2 s, x 120 and width 320 at 3 s, so its centre is then at 760 and 840.
|
|
142
|
+
const home = { x: 690, y: 380, w: 260, h: 160, ax: 0.5, ay: 0.5 };
|
|
143
|
+
const read: ReadTween = {
|
|
144
|
+
keyframes: [
|
|
145
|
+
{ percentage: 66.667, properties: { x: 60, width: 280 } },
|
|
146
|
+
{ percentage: 100, properties: { x: 120, width: 320 } },
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
it("draws a keyframe that also sets the size where the layer's centre is at that keyframe", () => {
|
|
151
|
+
const geo = buildMotionPathGeometry(read, { x: 50, y: 30 })!;
|
|
152
|
+
expect(geo.nodes.map((n) => nodeCentre(n, home, 1))).toEqual([
|
|
153
|
+
{ x: 760, y: 410 },
|
|
154
|
+
{ x: 840, y: 410 },
|
|
155
|
+
]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("keeps a layer centred by xPercent/yPercent -50 on its centre as a keyframe resizes it", () => {
|
|
159
|
+
const geo = buildMotionPathGeometry(read, { x: 50, y: 30 })!;
|
|
160
|
+
const centred = { ...home, ax: 0, ay: 0 };
|
|
161
|
+
expect(geo.nodes.map((n) => nodeCentre(n, centred, 1).x)).toEqual([750, 810]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("keeps a keyframe that sets no size at home plus its offset", () => {
|
|
165
|
+
const geo = buildMotionPathGeometry({ keyframes: [kf(0, 10, 20), kf(100, 200, 80)] })!;
|
|
166
|
+
expect(nodeCentre(geo.nodes[1]!, home, 1)).toEqual({ x: 890, y: 460 });
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("start", () => {
|
|
171
|
+
it("keeps where GSAP started a tween whose first keyframe comes later, with the size it had", () => {
|
|
172
|
+
const read: ReadTween = {
|
|
173
|
+
keyframes: [kf(66.667, 60, 30), kf(100, 120, 30)],
|
|
174
|
+
start: { x: 40, y: 30, width: 260 },
|
|
175
|
+
};
|
|
176
|
+
expect(buildMotionPathGeometry(read)?.start).toEqual({ x: 40, y: 30, w: 260 });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("draws no start when GSAP has not read one", () => {
|
|
180
|
+
expect(buildMotionPathGeometry({ keyframes: [kf(50, 60, 30), kf(100, 120, 30)] })?.start).toBe(
|
|
181
|
+
undefined,
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -11,18 +11,40 @@ export type MotionNodeRef =
|
|
|
11
11
|
| { type: "keyframe"; pct: number } // x/y position keyframe at this tween-relative %
|
|
12
12
|
| { type: "waypoint"; index: number }; // motionPath waypoint (anchor) at this index
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
/** An offset on the path and the layout width/height set there, when one is. */
|
|
15
|
+
export interface MotionPathPoint {
|
|
15
16
|
x: number;
|
|
16
17
|
y: number;
|
|
18
|
+
w?: number;
|
|
19
|
+
h?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MotionPathNode extends MotionPathPoint {
|
|
17
23
|
ref: MotionNodeRef;
|
|
18
24
|
}
|
|
19
25
|
|
|
26
|
+
/** The live layer's centre without its x/y offset, its layout size now, and the share of a size
|
|
27
|
+
* change that moves the centre (0.5 from the left edge; 0 when xPercent -50 centres it). */
|
|
28
|
+
export type MotionPathHome = { x: number; y: number; w: number; h: number; ax: number; ay: number };
|
|
29
|
+
|
|
30
|
+
/** Where the layer's centre is at a node, with the size that keyframe sets. */
|
|
31
|
+
export function nodeCentre(n: MotionPathPoint, home: MotionPathHome, pScale: number) {
|
|
32
|
+
const grow = (to: number | undefined, now: number, share: number) =>
|
|
33
|
+
to === undefined ? 0 : (to - now) * share;
|
|
34
|
+
return {
|
|
35
|
+
x: home.x + (n.x + grow(n.w, home.w, home.ax)) * pScale,
|
|
36
|
+
y: home.y + (n.y + grow(n.h, home.h, home.ay)) * pScale,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
20
40
|
export interface MotionPathGeometry {
|
|
21
41
|
/** "linear" = x/y keyframes; "arc" = motionPath tween. */
|
|
22
42
|
kind: "linear" | "arc";
|
|
23
43
|
/** SVG polyline points: "x,y x,y ...". */
|
|
24
44
|
points: string;
|
|
25
45
|
nodes: MotionPathNode[];
|
|
46
|
+
/** Where GSAP started the tween, when its first keyframe comes later: drawn, not draggable. */
|
|
47
|
+
start?: MotionPathPoint;
|
|
26
48
|
}
|
|
27
49
|
|
|
28
50
|
/**
|
|
@@ -78,6 +100,16 @@ export function nearestPointOnPath(
|
|
|
78
100
|
return best;
|
|
79
101
|
}
|
|
80
102
|
|
|
103
|
+
const finiteNumber = (v: unknown): v is number => typeof v === "number" && isFinite(v);
|
|
104
|
+
|
|
105
|
+
/** The layout width/height a keyframe sets, when it sets one. */
|
|
106
|
+
function sizeAt(p: Record<string, unknown>): { w?: number; h?: number } {
|
|
107
|
+
return {
|
|
108
|
+
...(finiteNumber(p.width) && { w: p.width }),
|
|
109
|
+
...(finiteNumber(p.height) && { h: p.height }),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
81
113
|
export function buildMotionPathGeometry(
|
|
82
114
|
read: ReadTween | null,
|
|
83
115
|
base: { x: number; y: number } = { x: 0, y: 0 },
|
|
@@ -94,26 +126,32 @@ export function buildMotionPathGeometry(
|
|
|
94
126
|
// translate it folded in, else 0), so we default it and still draw a path. But if the tween
|
|
95
127
|
// DOES animate an axis and a given keyframe omits it, that value is interpolated
|
|
96
128
|
// (not 0) and can't be placed here → skip that node (the prior behavior).
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
const tweenHasY = read.keyframes.some((kf) => finite(kf.properties.y));
|
|
129
|
+
const tweenHasX = read.keyframes.some((kf) => finiteNumber(kf.properties.x));
|
|
130
|
+
const tweenHasY = read.keyframes.some((kf) => finiteNumber(kf.properties.y));
|
|
100
131
|
if (!tweenHasX && !tweenHasY) return null; // no positional motion (opacity/scale only)
|
|
101
132
|
|
|
133
|
+
const pointAt = (p: Record<string, unknown>): MotionPathPoint | null => {
|
|
134
|
+
if ((tweenHasX && !finiteNumber(p.x)) || (tweenHasY && !finiteNumber(p.y))) return null;
|
|
135
|
+
const x = tweenHasX ? (p.x as number) : base.x;
|
|
136
|
+
const y = tweenHasY ? (p.y as number) : base.y;
|
|
137
|
+
return { x, y, ...sizeAt(p) };
|
|
138
|
+
};
|
|
102
139
|
read.keyframes.forEach((kf, i) => {
|
|
103
|
-
|
|
104
|
-
if (
|
|
140
|
+
const at = pointAt(kf.properties);
|
|
141
|
+
if (!at) return;
|
|
105
142
|
nodes.push({
|
|
106
|
-
|
|
107
|
-
y: tweenHasY ? (kf.properties.y as number) : base.y,
|
|
143
|
+
...at,
|
|
108
144
|
ref: isArc ? { type: "waypoint", index: i } : { type: "keyframe", pct: kf.percentage },
|
|
109
145
|
});
|
|
110
146
|
});
|
|
111
147
|
|
|
112
148
|
if (nodes.length < 2) return null;
|
|
149
|
+
const start = !isArc && read.start ? pointAt(read.start) : null;
|
|
113
150
|
|
|
114
151
|
return {
|
|
115
152
|
kind: isArc ? "arc" : "linear",
|
|
116
153
|
points: nodes.map((n) => `${n.x},${n.y}`).join(" "),
|
|
117
154
|
nodes,
|
|
155
|
+
...(start && { start }),
|
|
118
156
|
};
|
|
119
157
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { MotionPathHome } from "./motionPathGeometry";
|
|
2
|
+
|
|
3
|
+
// An element's computed transform translate: a group wrapper GSAP moved carries its offset here.
|
|
4
|
+
function transformTranslate(el: HTMLElement): { x: number; y: number } {
|
|
5
|
+
const t = el.ownerDocument?.defaultView?.getComputedStyle(el).transform;
|
|
6
|
+
if (!t || t === "none") return { x: 0, y: 0 };
|
|
7
|
+
const m3 = t.match(/matrix3d\(([^)]+)\)/);
|
|
8
|
+
if (m3) {
|
|
9
|
+
const v = m3[1].split(",").map(Number);
|
|
10
|
+
return { x: v[12] || 0, y: v[13] || 0 };
|
|
11
|
+
}
|
|
12
|
+
const m = t.match(/matrix\(([^)]+)\)/);
|
|
13
|
+
if (m) {
|
|
14
|
+
const v = m[1].split(",").map(Number);
|
|
15
|
+
return { x: v[4] || 0, y: v[5] || 0 };
|
|
16
|
+
}
|
|
17
|
+
return { x: 0, y: 0 };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function cssTranslate(el: HTMLElement): [number, number] {
|
|
21
|
+
const raw = el.ownerDocument?.defaultView?.getComputedStyle(el).translate ?? "none";
|
|
22
|
+
const [x = "0", y = "0"] = raw === "none" ? [] : raw.split(" ");
|
|
23
|
+
const px = (v: string, size: number) =>
|
|
24
|
+
v.endsWith("%") ? (Number.parseFloat(v) / 100) * size : Number.parseFloat(v) || 0;
|
|
25
|
+
return [px(x, el.offsetWidth), px(y, el.offsetHeight)];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Centre shift per px grown: 0.5 from a set left/top, -0.5 from a set right/bottom, plus the percent.
|
|
29
|
+
* ponytail: in-flow layers get 0 (layout may centre them); read their anchor if one draws off. */
|
|
30
|
+
function growShares(el: HTMLElement, px: number, py: number): { ax: number; ay: number } {
|
|
31
|
+
const style = el.ownerDocument?.defaultView?.getComputedStyle(el);
|
|
32
|
+
if (!style || !/^(absolute|fixed)$/.test(style.position)) return { ax: 0, ay: 0 };
|
|
33
|
+
const map = el.computedStyleMap?.();
|
|
34
|
+
const auto = (side: string) => String(map?.get(side) ?? "") === "auto";
|
|
35
|
+
const share = (start: string, end: string) => (auto(start) && !auto(end) ? -0.5 : 0.5);
|
|
36
|
+
return { ax: share("left", "right") + px, ay: share("top", "bottom") + py };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function elementHome(el: HTMLElement): MotionPathHome {
|
|
40
|
+
let left = 0;
|
|
41
|
+
let top = 0;
|
|
42
|
+
let node: HTMLElement | null = el;
|
|
43
|
+
while (node) {
|
|
44
|
+
left += node.offsetLeft;
|
|
45
|
+
top += node.offsetTop;
|
|
46
|
+
// Ancestor transforms (a group wrapper GSAP moved) shift where the layer renders; its own
|
|
47
|
+
// transform is the animated offset the path itself draws, so it is left out.
|
|
48
|
+
if (node !== el) {
|
|
49
|
+
const t = transformTranslate(node);
|
|
50
|
+
left += t.x;
|
|
51
|
+
top += t.y;
|
|
52
|
+
}
|
|
53
|
+
const parent = node.offsetParent as HTMLElement | null;
|
|
54
|
+
if (!parent || parent.hasAttribute("data-composition-id")) break;
|
|
55
|
+
node = parent;
|
|
56
|
+
}
|
|
57
|
+
// A translate composes once GSAP has parsed the layer; before, its parse sums translate and transform,
|
|
58
|
+
// makes minus half the size xPercent -50 and folds the rest into x, which a path sets (_parseTransform).
|
|
59
|
+
const cache = (
|
|
60
|
+
el as { _gsap?: { x?: unknown; uncache?: unknown; xPercent?: unknown; yPercent?: unknown } }
|
|
61
|
+
)._gsap;
|
|
62
|
+
const parsed = cache !== undefined && "x" in cache && !cache.uncache;
|
|
63
|
+
const [tx, ty] = cssTranslate(el);
|
|
64
|
+
const share = (cached: unknown, t: number, size: number) =>
|
|
65
|
+
parsed ? (Number(cached) || 0) / 100 : t && Math.round(size / 2) === Math.round(-t) ? -0.5 : 0;
|
|
66
|
+
const own = parsed ? { x: 0, y: 0 } : transformTranslate(el);
|
|
67
|
+
const [px, py] = [
|
|
68
|
+
share(cache?.xPercent, tx + own.x, el.offsetWidth),
|
|
69
|
+
share(cache?.yPercent, ty + own.y, el.offsetHeight),
|
|
70
|
+
];
|
|
71
|
+
const x = left + el.offsetWidth * (0.5 + px) + (parsed ? tx : 0);
|
|
72
|
+
const y = top + el.offsetHeight * (0.5 + py) + (parsed ? ty : 0);
|
|
73
|
+
const { ax, ay } = growShares(el, px, py);
|
|
74
|
+
return { x, y, w: el.offsetWidth, h: el.offsetHeight, ax, ay };
|
|
75
|
+
}
|