@hyperframes/studio 0.8.132 → 0.8.134
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-Cp8e_Zst.js → hyperframes-player-Bro1gj0Z.js} +1 -1
- package/dist/assets/{index-CASEBNy2.js → index-BwNDTmAD.js} +1 -1
- package/dist/assets/{index-DMTt9nTf.js → index-DnVGIbEC.js} +1 -1
- package/dist/assets/{index-oE1pOdAy.js → index-wnXyQjW-.js} +143 -143
- package/dist/index.html +1 -1
- package/dist/index.js +717 -702
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/editor/manualEditsDom.ts +5 -1
- package/src/components/editor/plainTranslate.test.ts +21 -2
- package/src/components/editor/plainTranslate.ts +7 -0
- package/src/hooks/draggedGsapPosition.test.ts +34 -0
- package/src/hooks/draggedGsapPosition.ts +40 -15
- package/src/hooks/gsapDragCommit.ts +17 -10
- package/src/hooks/gsapDragPositionCommit.ts +8 -9
- package/src/hooks/gsapResizeIntercept.test.ts +72 -1
- package/src/hooks/gsapResizeIntercept.ts +84 -17
- package/src/hooks/gsapRuntimeBridge.ts +4 -1
- package/src/hooks/gsapTrailingKeyframes.realGsap.test.ts +44 -0
- package/src/hooks/useEditHistoryActions.test.tsx +20 -0
- package/src/hooks/useEditHistoryActions.ts +3 -0
- package/src/hooks/useEnableKeyframes.ts +32 -37
- package/src/hooks/useGsapAwareEditing.test.tsx +1 -0
- package/src/hooks/useGsapAwareEditing.ts +10 -2
- package/src/player/components/timelineClipDragCommit.test.ts +22 -0
- package/src/player/components/timelineClipDragCommit.ts +7 -10
- package/src/player/components/timelineStackingSync.test.ts +101 -142
- package/src/player/components/timelineStackingSync.ts +51 -235
- package/src/player/components/useTimelineStackingSync.ts +1 -1
- package/src/timelineStackingSyncExport.test.tsx +3 -2
- package/src/utils/elementGsap.ts +6 -5
- package/src/utils/gsapUndoRestore.test.ts +22 -0
- package/src/utils/gsapUndoRestore.ts +4 -2
- package/src/utils/studioPendingEdits.ts +12 -2
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { trimTrailingKeyframeSpans } from "@hyperframes/parsers/gsap-writer-acorn";
|
|
2
|
+
import { gsap } from "gsap";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
const timeline = `const tl = gsap.timeline({ paused: true });\n`;
|
|
6
|
+
|
|
7
|
+
// What Studio saved for a plain element keyed at 1, 2 and 3 s, and for its size keyed at 3 s then 2 s.
|
|
8
|
+
const saved = {
|
|
9
|
+
position:
|
|
10
|
+
`tl.set("#target", { x: 299.893, data: "hf-hold" }, 0);\n` +
|
|
11
|
+
`tl.to("#target", { keyframes: { "0%": { x: 299.893 }, "33.333%": { x: 297.086 }, "66.667%": { x: 47.754 } }, duration: 3 }, 1);`,
|
|
12
|
+
size:
|
|
13
|
+
`tl.set("#target", { width: 440, data: "hf-hold" }, 0);\n` +
|
|
14
|
+
`tl.to("#target", { keyframes: { "0%": { width: 440 }, "50%": { width: 340, ease: "none" } }, duration: 2 }, 2);`,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function valueAt(script: string, prop: "x" | "width", seeks: number[]) {
|
|
18
|
+
const target: Record<string, number> = { x: 0, width: 240 };
|
|
19
|
+
const run = new Function(
|
|
20
|
+
"gsap",
|
|
21
|
+
"target",
|
|
22
|
+
`${script.replaceAll('"#target"', "target")}\nreturn tl;`,
|
|
23
|
+
);
|
|
24
|
+
const tl = run(gsap, target) as gsap.core.Timeline;
|
|
25
|
+
for (const time of seeks) tl.seek(time);
|
|
26
|
+
return target[prop];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe.each([
|
|
30
|
+
["position", "x", 47.754],
|
|
31
|
+
["size", "width", 340],
|
|
32
|
+
] as const)("a %s tween Studio keyed short of its end", (name, prop, atThree) => {
|
|
33
|
+
it("plays and seeks to the same value once its tail is trimmed", () => {
|
|
34
|
+
const script = trimTrailingKeyframeSpans(timeline, timeline + saved[name]);
|
|
35
|
+
expect(valueAt(script, prop, [3])).toBeCloseTo(atThree, 3);
|
|
36
|
+
expect(valueAt(script, prop, [1, 2, 3])).toBeCloseTo(atThree, 3);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("is what GSAP 3.15 gets wrong on a direct seek without the trim", () => {
|
|
40
|
+
const script = timeline + saved[name];
|
|
41
|
+
expect(valueAt(script, prop, [1, 2, 3])).toBeCloseTo(atThree, 3);
|
|
42
|
+
expect(valueAt(script, prop, [3])).not.toBeCloseTo(atThree, 0);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -7,6 +7,7 @@ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
|
7
7
|
import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryActions";
|
|
8
8
|
import {
|
|
9
9
|
beginStudioPendingEdit,
|
|
10
|
+
isStudioEditSaving,
|
|
10
11
|
setStudioPendingEditClaimClock,
|
|
11
12
|
trackStudioPendingEdit,
|
|
12
13
|
} from "../utils/studioPendingEdits";
|
|
@@ -97,6 +98,25 @@ describe("useEditHistoryActions", () => {
|
|
|
97
98
|
});
|
|
98
99
|
});
|
|
99
100
|
|
|
101
|
+
it("counts a shown step as saving until the server takes it, so a preview reload waits for its write", async () => {
|
|
102
|
+
const { deps, actions } = mount(
|
|
103
|
+
{ ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e1", files: SERVER_FILES },
|
|
104
|
+
PREDICTED,
|
|
105
|
+
);
|
|
106
|
+
let step!: () => void;
|
|
107
|
+
const stepped = new Promise<void>((resolve) => (step = resolve));
|
|
108
|
+
const undo = deps.editHistory.undo.getMockImplementation()!;
|
|
109
|
+
deps.editHistory.undo.mockImplementation(async (cb) => (await stepped, undo(cb)));
|
|
110
|
+
|
|
111
|
+
const undone = actions.undo();
|
|
112
|
+
expect(deps.showHistoryRestoreNow).toHaveBeenCalledWith(PREDICTED.files);
|
|
113
|
+
expect(isStudioEditSaving()).toBe(true);
|
|
114
|
+
step();
|
|
115
|
+
await act(() => undone);
|
|
116
|
+
await Promise.resolve();
|
|
117
|
+
expect(isStudioEditSaving()).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
100
120
|
it("puts a shown step back and applies the server's own restore when it stepped another entry", async () => {
|
|
101
121
|
const { deps, actions } = mount(
|
|
102
122
|
{
|
|
@@ -4,6 +4,7 @@ import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
|
4
4
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
5
5
|
import type { RestoreFiles } from "../utils/gsapUndoRestore";
|
|
6
6
|
import {
|
|
7
|
+
beginStudioShownRestore,
|
|
7
8
|
hasStudioPendingEdits,
|
|
8
9
|
paintBackNewestStudioPendingEdit,
|
|
9
10
|
} from "../utils/studioPendingEdits";
|
|
@@ -82,6 +83,7 @@ export function useEditHistoryActions({
|
|
|
82
83
|
const pendingEditShown = direction === "undo" ? paintBackNewestStudioPendingEdit() : null;
|
|
83
84
|
const predicted = pendingEditShown ? null : (editHistory.predict?.(direction) ?? null);
|
|
84
85
|
const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
|
|
86
|
+
const shownLanded = predictedShown ? beginStudioShownRestore() : null;
|
|
85
87
|
const putBack = predictedShown ?? pendingEditShown?.showAgain;
|
|
86
88
|
const claimedAfter =
|
|
87
89
|
pendingEditShown?.claimsAtBegin ??
|
|
@@ -107,6 +109,7 @@ export function useEditHistoryActions({
|
|
|
107
109
|
: stepped && editClaimed;
|
|
108
110
|
} finally {
|
|
109
111
|
if (putBack && !serverSteppedShown && !revertIsTheUndo) putBack();
|
|
112
|
+
shownLanded?.();
|
|
110
113
|
}
|
|
111
114
|
if (!result.ok && result.reason === "content-mismatch") {
|
|
112
115
|
showToast(
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { POSITION_PROPS } from "./gsapRuntimeReaders";
|
|
32
32
|
import { findParsedTween, withLiveTiming } from "./gsapParsedTween";
|
|
33
33
|
import { roundTo3 } from "../utils/rounding";
|
|
34
|
+
import { readTranslatePxLeavingPercent } from "../components/editor/plainTranslate";
|
|
34
35
|
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
35
36
|
import {
|
|
36
37
|
buildExtendedKeyframes,
|
|
@@ -180,6 +181,32 @@ export function resolveNewTweenRange(
|
|
|
180
181
|
return { start: clampedStart, duration: Math.max(0.5, roundTo3(end - clampedStart)) };
|
|
181
182
|
}
|
|
182
183
|
|
|
184
|
+
export function singleKeyTweenMutation(
|
|
185
|
+
sel: DomEditSelection,
|
|
186
|
+
properties: Record<string, number>,
|
|
187
|
+
currentTime: number,
|
|
188
|
+
): Record<string, unknown> | null {
|
|
189
|
+
// A brand-new tween: author it against the one element the user selected.
|
|
190
|
+
// The bare class selectorFromSelection hands back for an id-less element
|
|
191
|
+
// animates every sibling sharing the class (see writeTargetSelector).
|
|
192
|
+
const targetSelector = writeTargetSelector(sel);
|
|
193
|
+
if (!targetSelector) return null;
|
|
194
|
+
const { start, duration } = resolveNewTweenRange(
|
|
195
|
+
sel.dataAttributes?.start,
|
|
196
|
+
sel.dataAttributes?.duration,
|
|
197
|
+
currentTime,
|
|
198
|
+
);
|
|
199
|
+
// One keyframe at the playhead, a single diamond capturing the current value;
|
|
200
|
+
// motion comes from the keyframes the user adds or drags later.
|
|
201
|
+
return {
|
|
202
|
+
type: "add-with-keyframes",
|
|
203
|
+
targetSelector,
|
|
204
|
+
position: roundTo3(start),
|
|
205
|
+
duration: roundTo3(duration),
|
|
206
|
+
keyframes: [{ percentage: 0, properties }],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
183
210
|
// Authoritative parse of the current source for `sel`. Returns `null` when the
|
|
184
211
|
// fetch can't run (no projectId / request failed) so callers can distinguish
|
|
185
212
|
// "unavailable" from a genuine empty result (e.g. after a delete-all). An empty
|
|
@@ -514,43 +541,11 @@ export function useEnableKeyframes(
|
|
|
514
541
|
}
|
|
515
542
|
} else {
|
|
516
543
|
const position = readElementPosition(iframe, sel, null);
|
|
517
|
-
|
|
518
|
-
sel.
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
// A brand-new tween: author it against the one element the user selected.
|
|
523
|
-
// The bare class selectorFromSelection hands back for an id-less element
|
|
524
|
-
// animates every sibling sharing the class (see writeTargetSelector).
|
|
525
|
-
const selector = writeTargetSelector(sel);
|
|
526
|
-
|
|
527
|
-
if (!selector) {
|
|
528
|
-
session.handleGsapAddAnimation("to");
|
|
529
|
-
return;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
if (Object.keys(position).length === 0) {
|
|
533
|
-
position.x = 0;
|
|
534
|
-
position.y = 0;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// One keyframe at the playhead — a single diamond capturing the current
|
|
538
|
-
// value. Motion comes from the user adding/dragging more keyframes later;
|
|
539
|
-
// creating 0%+100% up front showed two diamonds for a single "add keyframe".
|
|
540
|
-
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
|
541
|
-
[{ percentage: 0, properties: { ...position } }];
|
|
542
|
-
|
|
543
|
-
if (session.commitMutation) {
|
|
544
|
-
await session.commitMutation(
|
|
545
|
-
{
|
|
546
|
-
type: "add-with-keyframes",
|
|
547
|
-
targetSelector: selector,
|
|
548
|
-
position: roundTo3(elStart),
|
|
549
|
-
duration: roundTo3(elDuration),
|
|
550
|
-
keyframes,
|
|
551
|
-
},
|
|
552
|
-
{ label: "Enable keyframes", softReload: true },
|
|
553
|
-
);
|
|
544
|
+
if (Object.keys(position).length === 0)
|
|
545
|
+
Object.assign(position, readTranslatePxLeavingPercent(sel.element));
|
|
546
|
+
const mutation = singleKeyTweenMutation(sel, position, t);
|
|
547
|
+
if (mutation && session.commitMutation) {
|
|
548
|
+
await session.commitMutation(mutation, { label: "Enable keyframes", softReload: true });
|
|
554
549
|
} else {
|
|
555
550
|
session.handleGsapAddAnimation("to");
|
|
556
551
|
}
|
|
@@ -16,7 +16,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
16
16
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
17
17
|
import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
|
|
18
18
|
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
|
|
19
|
-
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
19
|
+
import { computeDraggedGsapPosition, freezeDragStamp } from "./draggedGsapPosition";
|
|
20
20
|
import { readGsapPositionFromIframe } from "./gsapPositionDetection";
|
|
21
21
|
import { selectorFromSelection } from "./gsapShared";
|
|
22
22
|
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
|
|
@@ -209,6 +209,7 @@ export function useGsapAwareEditing({
|
|
|
209
209
|
restore: () => void = () => undefined,
|
|
210
210
|
route?: { plainTranslate: boolean },
|
|
211
211
|
) => {
|
|
212
|
+
const stamp = freezeDragStamp(selection.element);
|
|
212
213
|
const writes = observeGsapGesture(gsapCommitMutation);
|
|
213
214
|
if (route?.plainTranslate ?? editsPlainCss(selection.element, "resize")) {
|
|
214
215
|
const result = await handleDomBoxSizeCommit(selection, next, offset, restore);
|
|
@@ -256,6 +257,7 @@ export function useGsapAwareEditing({
|
|
|
256
257
|
previewIframeRef.current,
|
|
257
258
|
commitMutation,
|
|
258
259
|
makeFetchFallback(selection),
|
|
260
|
+
{ stamp },
|
|
259
261
|
);
|
|
260
262
|
// Saved after the size, under its undo key, so the two are one step.
|
|
261
263
|
await saveMove(dragOutcome, async () => {
|
|
@@ -279,7 +281,12 @@ export function useGsapAwareEditing({
|
|
|
279
281
|
x: 0,
|
|
280
282
|
y: 0,
|
|
281
283
|
};
|
|
282
|
-
const { newX, newY } = computeDraggedGsapPosition(
|
|
284
|
+
const { newX, newY } = computeDraggedGsapPosition(
|
|
285
|
+
selection.element,
|
|
286
|
+
offset,
|
|
287
|
+
gsapPos,
|
|
288
|
+
stamp,
|
|
289
|
+
);
|
|
283
290
|
logResize("sync-settle", { gsapPos, offset, newX, newY });
|
|
284
291
|
setElementGsapPosition(selection.element, newX, newY);
|
|
285
292
|
});
|
|
@@ -297,6 +304,7 @@ export function useGsapAwareEditing({
|
|
|
297
304
|
makeFetchFallback(selection),
|
|
298
305
|
offset,
|
|
299
306
|
writes.drawKeepingUndone,
|
|
307
|
+
stamp,
|
|
300
308
|
);
|
|
301
309
|
assertGsapEditPersisted(outcome);
|
|
302
310
|
// Saved before the buffered GSAP writes, so their reload stays the gesture's last render.
|
|
@@ -588,6 +588,28 @@ describe("commitDraggedClipMove", () => {
|
|
|
588
588
|
expectZLiftedToSix(onStackingPatches);
|
|
589
589
|
});
|
|
590
590
|
|
|
591
|
+
it("a multi-selection lane move restacks only the dragged clip", async () => {
|
|
592
|
+
// a moves up from row 2 to row 1 over c; the selected scene keeps its row, so it keeps its z.
|
|
593
|
+
const elements = [
|
|
594
|
+
el("scene", 0, 0, 10),
|
|
595
|
+
el("b", 1, 5, 5),
|
|
596
|
+
el("a", 2, 0, 4),
|
|
597
|
+
el("c", 3, 0, 4),
|
|
598
|
+
];
|
|
599
|
+
const z: Record<string, number> = { scene: 0, b: 2, a: 1, c: 3 };
|
|
600
|
+
const onStackingPatches = vi.fn();
|
|
601
|
+
runClipMove(drag(elements[2], { previewStart: 0, previewTrack: 1 }), {
|
|
602
|
+
elements,
|
|
603
|
+
trackOrder: [0, 1, 2, 3],
|
|
604
|
+
selectedKeys: new Set(["a", "scene"]),
|
|
605
|
+
readZIndex: (e) => z[e.key ?? e.id] ?? 0,
|
|
606
|
+
onStackingPatches,
|
|
607
|
+
});
|
|
608
|
+
await flushMicrotasks();
|
|
609
|
+
expect(onStackingPatches).toHaveBeenCalledTimes(1);
|
|
610
|
+
expect(onStackingPatches.mock.calls[0][0]).toEqual([{ key: "a", zIndex: 4 }]);
|
|
611
|
+
});
|
|
612
|
+
|
|
591
613
|
it("partial z-sync deps (no readZIndex) → move persists but no stacking call", async () => {
|
|
592
614
|
const elements = [el("a", 1, 0, 10), el("b", 0, 0, 10)];
|
|
593
615
|
// onStackingPatches present but readZIndex absent → syncStackingForEdit needs
|
|
@@ -337,7 +337,6 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
|
|
|
337
337
|
if (multi?.keys.has(keyOf(e))) return { ...e, start: multi.movedStart(e) };
|
|
338
338
|
return e;
|
|
339
339
|
});
|
|
340
|
-
const multiKeys = multi ? multi.keys : null;
|
|
341
340
|
if (!isVertical || !deps.readZIndex || !deps.onStackingPatches) {
|
|
342
341
|
void refreshAfterDurableLaneMove(
|
|
343
342
|
persistMoveEdits(edits, deps, coalesceKey, "lane-reorder"),
|
|
@@ -354,7 +353,6 @@ export function commitDraggedClipMove(drag: DraggedClipState, deps: DragCommitDe
|
|
|
354
353
|
dragKey,
|
|
355
354
|
drag.element.track,
|
|
356
355
|
drag.previewTrack,
|
|
357
|
-
multiKeys,
|
|
358
356
|
deps,
|
|
359
357
|
coalesceKey,
|
|
360
358
|
),
|
|
@@ -472,7 +470,6 @@ function commitTrackInsert(
|
|
|
472
470
|
dragKey,
|
|
473
471
|
drag.element.track,
|
|
474
472
|
drag.insertRow!,
|
|
475
|
-
multi ? multi.keys : null,
|
|
476
473
|
deps,
|
|
477
474
|
coalesceKey,
|
|
478
475
|
),
|
|
@@ -532,8 +529,8 @@ export function commitZMirrorLaneMove(
|
|
|
532
529
|
* vertical lane change. Projects the drop-intent element set (`candidate`: the
|
|
533
530
|
* dragged clip at its new / fractional-insert lane, others at their current tracks)
|
|
534
531
|
* onto StackingElement using the caller-supplied live z-index reader, then
|
|
535
|
-
* delegates
|
|
536
|
-
*
|
|
532
|
+
* delegates to computeStackingPatches — the moved clip alone rises (moved up) or
|
|
533
|
+
* sinks (moved down) past the clips it time-overlaps. No-op unless both z-sync
|
|
537
534
|
* deps are present, and never when the gesture aimed at the clip's OWN current
|
|
538
535
|
* lane (`aimedLane === currentLane` — not a relocation).
|
|
539
536
|
*/
|
|
@@ -542,7 +539,6 @@ function syncStackingForEdit(
|
|
|
542
539
|
dragKey: string,
|
|
543
540
|
currentLane: number,
|
|
544
541
|
aimedLane: number,
|
|
545
|
-
multiKeys: ReadonlySet<string> | null,
|
|
546
542
|
deps: DragCommitDeps,
|
|
547
543
|
coalesceKey?: string,
|
|
548
544
|
): Promise<void> {
|
|
@@ -567,10 +563,11 @@ function syncStackingForEdit(
|
|
|
567
563
|
stackingContextId: el.stackingContextId ?? null,
|
|
568
564
|
}));
|
|
569
565
|
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
566
|
+
const patches = computeStackingPatches(
|
|
567
|
+
stackingEls,
|
|
568
|
+
[dragKey],
|
|
569
|
+
aimedLane < currentLane ? "up" : "down",
|
|
570
|
+
);
|
|
574
571
|
if (patches.length === 0) return Promise.resolve();
|
|
575
572
|
return Promise.resolve(onStackingPatches(patches, coalesceKey)).then(() => undefined);
|
|
576
573
|
}
|