@hyperframes/studio 0.8.127 → 0.8.129
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-uqzDaxVL.js → hyperframes-player-DfEcBl9r.js} +1 -1
- package/dist/assets/{index-DXSYlqmV.js → index-9ZaZFCMZ.js} +1 -1
- package/dist/assets/{index-CLBNYlDY.js → index-Bmy-KVib.js} +1 -1
- package/dist/assets/{index-COhbL1xq.js → index-C9YkEyN4.js} +189 -189
- package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
- package/dist/chunk-7UQWVN52.js.map +1 -0
- package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
- package/dist/chunk-DRVWEPO5.js.map +1 -0
- package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
- package/dist/chunk-TDWGXBUT.js.map +1 -0
- package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
- package/dist/index.d.ts +8 -9
- package/dist/index.html +1 -1
- package/dist/index.js +1396 -917
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/components/TimelineToolbar.tsx +6 -7
- package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
- package/src/components/editor/domEditingLayers.test.ts +45 -18
- package/src/components/editor/domEditingLayers.ts +11 -9
- package/src/components/editor/domEditingTypes.ts +1 -0
- package/src/components/editor/keyframeRetime.ts +11 -4
- package/src/components/editor/probeSourceElement.ts +17 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
- package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
- package/src/hooks/gsapDragCommit.ts +8 -5
- package/src/hooks/gsapDragPositionCommit.ts +90 -17
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
- package/src/hooks/gsapMutationClient.ts +35 -7
- package/src/hooks/gsapOvershootEase.test.ts +67 -0
- package/src/hooks/gsapParsedTween.ts +5 -6
- package/src/hooks/gsapPositionDetection.ts +5 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
- package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
- package/src/hooks/gsapRuntimeKeyframes.ts +19 -59
- package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
- package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
- package/src/hooks/gsapShared.test.ts +48 -35
- package/src/hooks/gsapShared.ts +30 -32
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +93 -3
- package/src/hooks/gsapValueAtPlayhead.test.ts +13 -0
- package/src/hooks/gsapValueAtPlayhead.ts +3 -4
- package/src/hooks/timelineEditingHelpers.test.ts +21 -0
- package/src/hooks/timelineEditingHelpers.ts +40 -14
- package/src/hooks/timelineLinkEditPlan.ts +6 -4
- package/src/hooks/timelineMoveAdapter.ts +3 -5
- package/src/hooks/timelineTimingSync.test.ts +83 -125
- package/src/hooks/timelineTimingSync.ts +82 -63
- package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
- package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
- package/src/hooks/useCommitPreflightCapabilities.ts +42 -7
- package/src/hooks/useDomEditPreviewSync.ts +8 -2
- package/src/hooks/useDomSelection.test.ts +27 -0
- package/src/hooks/useDomSelection.ts +12 -13
- package/src/hooks/useDomSelectionTypes.ts +1 -0
- package/src/hooks/useEditHistoryActions.test.tsx +54 -3
- package/src/hooks/useEditHistoryActions.ts +9 -6
- package/src/hooks/useEnableKeyframes.test.ts +20 -15
- package/src/hooks/useEnableKeyframes.ts +54 -71
- package/src/hooks/useGestureCommit.ts +2 -1
- package/src/hooks/useGsapTweenCache.ts +2 -17
- package/src/hooks/usePersistentEditHistory.test.ts +45 -0
- package/src/hooks/usePersistentEditHistory.ts +13 -2
- package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
- package/src/hooks/usePreviewPersistence.ts +26 -4
- package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
- package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
- package/src/hooks/useTimelineDeleteOps.ts +5 -13
- package/src/hooks/useTimelineEditing.test.tsx +63 -4
- package/src/hooks/useTimelineEditing.ts +21 -295
- package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
- package/src/hooks/useTimelineGroupEditing.ts +15 -38
- package/src/player/components/Timeline.test.ts +12 -4
- package/src/player/components/TimelineClip.tsx +3 -0
- package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
- package/src/player/components/TimelineLanes.test.tsx +61 -5
- package/src/player/components/TimelineLanes.tsx +20 -28
- package/src/player/components/TimelinePropertyLanes.tsx +10 -1
- package/src/player/components/timelineCallbacks.ts +1 -4
- package/src/player/components/timelineClipDragCommit.test.ts +31 -0
- package/src/player/components/timelineClipDragCommit.ts +39 -28
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
- package/src/player/components/timelineClipGestureHandlers.ts +2 -20
- package/src/player/components/timelineEditing.ts +1 -3
- package/src/player/components/timelineGroupResizeCommit.ts +10 -9
- package/src/player/components/timelineLinkSelection.ts +1 -35
- package/src/player/components/timelineOptimisticRevision.ts +7 -4
- package/src/player/components/trackHeaderLaneState.ts +9 -2
- package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
- package/src/player/components/trackHeaderLaneValues.ts +54 -13
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
- package/src/player/components/useTimelineClipDrag.ts +0 -3
- package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
- package/src/player/store/batchElementUpdates.test.ts +43 -0
- package/src/player/store/batchElementUpdates.ts +9 -0
- package/src/player/store/elementPatchQueue.ts +35 -0
- package/src/player/store/playerStore.ts +5 -6
- package/src/utils/domEditHelpers.ts +5 -0
- package/src/utils/globalTimeCompiler.test.ts +0 -4
- package/src/utils/globalTimeCompiler.ts +6 -4
- package/src/utils/gsapKeyframeEases.ts +79 -0
- package/src/utils/gsapLiveRetime.realGsap.test.ts +544 -0
- package/src/utils/gsapLiveRetime.ts +286 -0
- package/src/utils/gsapSoftReload.ts +16 -7
- package/src/utils/gsapUndoRestore.test.ts +43 -0
- package/src/utils/gsapUndoRestore.ts +6 -3
- package/src/utils/sameData.ts +9 -0
- package/src/utils/studioFileHistory.ts +1 -0
- package/src/utils/studioPendingEdits.ts +15 -1
- package/dist/chunk-246VB2G3.js.map +0 -1
- package/dist/chunk-3F47EFCT.js.map +0 -1
- package/dist/chunk-5EKWJZLM.js.map +0 -1
- /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
package/dist/player.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.129",
|
|
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/
|
|
65
|
-
"@hyperframes/player": "0.8.
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.129",
|
|
64
|
+
"@hyperframes/parsers": "0.8.129",
|
|
65
|
+
"@hyperframes/player": "0.8.129",
|
|
66
|
+
"@hyperframes/shader-transitions": "0.8.129",
|
|
67
|
+
"@hyperframes/studio-server": "0.8.129",
|
|
68
|
+
"@hyperframes/sdk": "0.8.129"
|
|
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.129"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
isPlayheadWithinTween,
|
|
12
12
|
type EnableKeyframesSession,
|
|
13
13
|
} from "../hooks/useEnableKeyframes";
|
|
14
|
-
import { computeElementPercentage,
|
|
14
|
+
import { computeElementPercentage, playsNear } from "../hooks/gsapShared";
|
|
15
15
|
import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard";
|
|
16
16
|
import {
|
|
17
17
|
getNextTimelineZoomPercent,
|
|
@@ -73,9 +73,8 @@ const NO_KEYFRAME_TOGGLE: KeyframeToggleState = {
|
|
|
73
73
|
function isMotionPathEndpoint(animation: GsapAnimation | undefined, percentage: number): boolean {
|
|
74
74
|
if (!animation?.keyframes) return false;
|
|
75
75
|
const keyframes = animation.keyframes.keyframes;
|
|
76
|
-
return (
|
|
77
|
-
|
|
78
|
-
Math.abs((keyframes.at(-1)?.percentage ?? Infinity) - percentage) <= KEYFRAME_PCT_MATCH
|
|
76
|
+
return [keyframes[0], keyframes.at(-1)].some(
|
|
77
|
+
(keyframe) => keyframe && playsNear(animation, keyframe.percentage, percentage),
|
|
79
78
|
);
|
|
80
79
|
}
|
|
81
80
|
|
|
@@ -93,14 +92,14 @@ function resolveKeyframeToggleState(
|
|
|
93
92
|
if (!animation?.keyframes) return NO_KEYFRAME_TOGGLE;
|
|
94
93
|
|
|
95
94
|
const isMotionPath = Boolean(arcAnimation);
|
|
96
|
-
if (!isPlayheadWithinTween(animation, currentTime
|
|
95
|
+
if (!isPlayheadWithinTween(animation, currentTime)) {
|
|
97
96
|
return { state: "inactive", isMotionPath, pathEndpoint: false, willExtend: true };
|
|
98
97
|
}
|
|
99
98
|
|
|
100
99
|
const percentage = computeElementPercentage(currentTime, session.domEditSelection, animation);
|
|
101
100
|
const pathEndpoint = isMotionPathEndpoint(arcAnimation, percentage);
|
|
102
|
-
const active = animation.keyframes.keyframes.some(
|
|
103
|
-
(
|
|
101
|
+
const active = animation.keyframes.keyframes.some((keyframe) =>
|
|
102
|
+
playsNear(animation, keyframe.percentage, percentage),
|
|
104
103
|
);
|
|
105
104
|
return {
|
|
106
105
|
state: pathEndpoint ? "none" : active ? "active" : "inactive",
|
|
@@ -207,6 +207,13 @@ async function select(animations: GsapAnimation[] | null, props: Partial<EditorP
|
|
|
207
207
|
return view;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
async function selectLoopedPair() {
|
|
211
|
+
const a = resolved(element("a"));
|
|
212
|
+
const b = resolved(element("b"));
|
|
213
|
+
const view = await select([loop({ x: 120 }, "#a")], { selection: a, groups: [a, b] });
|
|
214
|
+
return { a, b, ...view };
|
|
215
|
+
}
|
|
216
|
+
|
|
210
217
|
describe("handles follow what Studio would commit", () => {
|
|
211
218
|
beforeEach(() => {
|
|
212
219
|
HTMLElement.prototype.setPointerCapture = () => undefined;
|
|
@@ -442,6 +449,31 @@ describe("handles follow what Studio would commit", () => {
|
|
|
442
449
|
expect(spies.onManualDragStart).not.toHaveBeenCalled();
|
|
443
450
|
});
|
|
444
451
|
|
|
452
|
+
it("re-checks only the members that changed when the group is handed back anew", async () => {
|
|
453
|
+
const { a, b, seen, render } = await selectLoopedPair();
|
|
454
|
+
const [narrowedA, narrowedB] = seen.groups;
|
|
455
|
+
|
|
456
|
+
render({ selection: a, groups: [a, b] });
|
|
457
|
+
expect(seen.groups[0]).toBe(narrowedA);
|
|
458
|
+
expect(seen.groups[1]).toBe(narrowedB);
|
|
459
|
+
const movedA = resolved(a.element);
|
|
460
|
+
render({ selection: movedA, groups: [movedA, b] });
|
|
461
|
+
expect(seen.groups[0]).not.toBe(narrowedA);
|
|
462
|
+
expect(flags(seen.groups[0]!)[0]).toBe(false);
|
|
463
|
+
expect(seen.groups[1]).toBe(narrowedB);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("checks again on a new cache version even when that re-read fails", async () => {
|
|
467
|
+
const { a, b, seen, render } = await selectLoopedPair();
|
|
468
|
+
const [narrowedA] = seen.groups;
|
|
469
|
+
|
|
470
|
+
parses.fetch.mockResolvedValueOnce(null);
|
|
471
|
+
render({ selection: a, groups: [a, b], version: 1 });
|
|
472
|
+
await settle();
|
|
473
|
+
expect(seen.groups[0]).not.toBe(narrowedA);
|
|
474
|
+
expect(flags(seen.groups[0]!)[0]).toBe(false);
|
|
475
|
+
});
|
|
476
|
+
|
|
445
477
|
it.each([
|
|
446
478
|
["rotation", loop({ rotation: 90 }), [true, true, false], { dots: 4, rotate: false }],
|
|
447
479
|
["resize", loop({ width: 320, height: 90 }), [true, false, true], { dots: 0, rotate: true }],
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import {
|
|
4
4
|
collectDomEditLayerItems,
|
|
5
5
|
resolveDomEditSelection,
|
|
@@ -100,6 +100,37 @@ describe("resolveDomEditSelection — hfId from data-hf-id", () => {
|
|
|
100
100
|
});
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
describe("resolveDomEditSelection — source probe on re-resolve", () => {
|
|
104
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
105
|
+
const probing = { activeCompositionPath: "index.html", isMasterView: true, projectId: "p" };
|
|
106
|
+
|
|
107
|
+
it("asks the server once per node and target; re-resolving the same pair keeps the answer", async () => {
|
|
108
|
+
const fetchMock = vi.fn(() =>
|
|
109
|
+
Promise.resolve({ ok: true, json: () => Promise.resolve({ exists: [false] }) }),
|
|
110
|
+
);
|
|
111
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
112
|
+
const el = document.createElement("div");
|
|
113
|
+
el.id = "generated";
|
|
114
|
+
document.body.appendChild(el);
|
|
115
|
+
|
|
116
|
+
const first = await resolveDomEditSelection(el, probing);
|
|
117
|
+
const again = await resolveDomEditSelection(el, { ...probing, previous: first });
|
|
118
|
+
el.id = "renamed";
|
|
119
|
+
await resolveDomEditSelection(el, { ...probing, previous: again });
|
|
120
|
+
el.id = "generated";
|
|
121
|
+
const replaced = document.createElement("div");
|
|
122
|
+
replaced.id = "generated";
|
|
123
|
+
el.replaceWith(replaced);
|
|
124
|
+
await resolveDomEditSelection(replaced, { ...probing, previous: again });
|
|
125
|
+
replaced.remove();
|
|
126
|
+
|
|
127
|
+
expect(first?.existsInSource).toBe(false);
|
|
128
|
+
expect(again?.existsInSource).toBe(false);
|
|
129
|
+
expect(again?.capabilities.canMove).toBe(false);
|
|
130
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
103
134
|
describe("resolveDomEditSelection — data-hf-group capture", () => {
|
|
104
135
|
// <div id="parent"><div data-hf-group="Group 1"><div data-hf-group="Group 2">
|
|
105
136
|
// <span id="child"/></div></div></div>
|
|
@@ -236,17 +267,19 @@ describe("buildTextFieldChildLocator", () => {
|
|
|
236
267
|
});
|
|
237
268
|
});
|
|
238
269
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
root.append(child);
|
|
247
|
-
}
|
|
248
|
-
return root;
|
|
270
|
+
function compositionWith(count: number, child: (el: HTMLElement, i: number) => void): HTMLElement {
|
|
271
|
+
const root = document.createElement("div");
|
|
272
|
+
root.setAttribute("data-composition-id", "index.html");
|
|
273
|
+
for (let i = 0; i < count; i++) {
|
|
274
|
+
const el = document.createElement("div");
|
|
275
|
+
child(el, i);
|
|
276
|
+
root.append(el);
|
|
249
277
|
}
|
|
278
|
+
return root;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
describe("collectDomEditLayerItems item budget", () => {
|
|
282
|
+
const documentWith = (count: number) => compositionWith(count, (el, i) => (el.id = `el-${i}`));
|
|
250
283
|
|
|
251
284
|
it("returns the whole document by default", () => {
|
|
252
285
|
// A default cap here silently truncated the marquee's candidate list: a drag
|
|
@@ -264,13 +297,7 @@ describe("collectDomEditLayerItems selector-index cost", () => {
|
|
|
264
297
|
// Attached, unlike the fixture above: a detached subtree is invisible to
|
|
265
298
|
// document.querySelectorAll, so the occurrence lookup would find nothing.
|
|
266
299
|
function attachedRootWithSharedClass(count: number): HTMLElement {
|
|
267
|
-
const root =
|
|
268
|
-
root.setAttribute("data-composition-id", "index.html");
|
|
269
|
-
for (let i = 0; i < count; i++) {
|
|
270
|
-
const child = document.createElement("div");
|
|
271
|
-
child.className = "box";
|
|
272
|
-
root.append(child);
|
|
273
|
-
}
|
|
300
|
+
const root = compositionWith(count, (el) => (el.className = "box"));
|
|
274
301
|
document.body.append(root);
|
|
275
302
|
return root;
|
|
276
303
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { probeSourceElement } from "./probeSourceElement";
|
|
1
|
+
import { knownSourceAnswer, probeSourceElement, type ProbeTarget } from "./probeSourceElement";
|
|
2
2
|
import { isAudibleVideoNode } from "../../player/lib/timelineElementHelpers";
|
|
3
3
|
import type { PatchOperation } from "../../utils/sourcePatcher";
|
|
4
4
|
import {
|
|
@@ -288,6 +288,7 @@ export async function resolveDomEditSelection(
|
|
|
288
288
|
projectId?: string | null;
|
|
289
289
|
skipSourceProbe?: boolean;
|
|
290
290
|
exactTarget?: boolean;
|
|
291
|
+
previous?: DomEditSelection | null;
|
|
291
292
|
},
|
|
292
293
|
): Promise<DomEditSelection | null> {
|
|
293
294
|
if (!startEl) return null;
|
|
@@ -330,14 +331,14 @@ export async function resolveDomEditSelection(
|
|
|
330
331
|
isCompositionRootLayer(current, doc, computedStyles);
|
|
331
332
|
const textFields = collectDomEditTextFields(current);
|
|
332
333
|
const isInsideLocked = Boolean(findClosestByAttribute(current, ["data-timeline-locked"]));
|
|
333
|
-
|
|
334
|
-
if (
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
334
|
+
const probeTarget: ProbeTarget = {};
|
|
335
|
+
if (current.id) probeTarget.id = current.id;
|
|
336
|
+
if (hfId) probeTarget.hfId = hfId;
|
|
337
|
+
if (selector) probeTarget.selector = selector;
|
|
338
|
+
if (selectorIndex != null) probeTarget.selectorIndex = selectorIndex;
|
|
339
|
+
let existsInSource = knownSourceAnswer(options.previous, current, sourceFile, probeTarget);
|
|
340
|
+
const probe = existsInSource === undefined && !options.skipSourceProbe;
|
|
341
|
+
if (probe && options.projectId && (current.id || selector || hfId)) {
|
|
341
342
|
existsInSource = await probeSourceElement(options.projectId, sourceFile, probeTarget);
|
|
342
343
|
}
|
|
343
344
|
const capabilities = resolveEditingAffordances(
|
|
@@ -380,6 +381,7 @@ export async function resolveDomEditSelection(
|
|
|
380
381
|
computedStyles,
|
|
381
382
|
textFields,
|
|
382
383
|
capabilities,
|
|
384
|
+
existsInSource,
|
|
383
385
|
};
|
|
384
386
|
}
|
|
385
387
|
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* Kept pure (no React/store/GSAP) so the trickiest math is unit-testable. The
|
|
15
15
|
* caller supplies the resolved tween window + the drop's absolute time.
|
|
16
16
|
*/
|
|
17
|
+
import { progressAtTime, timeAtProgress } from "../../utils/gsapKeyframeEases";
|
|
17
18
|
|
|
18
19
|
export interface RetimeKeyframe {
|
|
19
20
|
/** Tween-relative percentage (the writer/runtime key on this). */
|
|
@@ -92,6 +93,7 @@ function resolveFlatTweenBoundaryRetime(opts: {
|
|
|
92
93
|
* - `draggedTweenPct`: identifies which keyframe is being dragged (closest match).
|
|
93
94
|
* - `tweenStart` / `tweenDuration`: the tween's resolved absolute window.
|
|
94
95
|
* - `dropAbsTime`: the drop's absolute time (handler converts clip-% → seconds).
|
|
96
|
+
* - `runEase`: the tween's run ease, which decides when each keyframe plays.
|
|
95
97
|
*/
|
|
96
98
|
export function resolveKeyframeRetime(opts: {
|
|
97
99
|
keyframes: ReadonlyArray<RetimeKeyframe>;
|
|
@@ -99,8 +101,9 @@ export function resolveKeyframeRetime(opts: {
|
|
|
99
101
|
tweenStart: number;
|
|
100
102
|
tweenDuration: number;
|
|
101
103
|
dropAbsTime: number;
|
|
104
|
+
runEase?: string;
|
|
102
105
|
}): KeyframeRetimeResult {
|
|
103
|
-
const { keyframes, draggedTweenPct, tweenStart, tweenDuration, dropAbsTime } = opts;
|
|
106
|
+
const { keyframes, draggedTweenPct, tweenStart, tweenDuration, dropAbsTime, runEase } = opts;
|
|
104
107
|
if (tweenDuration <= 0) return { kind: "noop" };
|
|
105
108
|
const tweenEnd = tweenStart + tweenDuration;
|
|
106
109
|
|
|
@@ -124,7 +127,8 @@ export function resolveKeyframeRetime(opts: {
|
|
|
124
127
|
// Round here, not at the return: the no-op test below and the value written
|
|
125
128
|
// to source must be the same number. The resize branch already rounds, so
|
|
126
129
|
// this keeps both write paths at the authored 3dp precision.
|
|
127
|
-
const
|
|
130
|
+
const dropTimePct = clamp(((dropAbsTime - tweenStart) / tweenDuration) * 100, 0, 100);
|
|
131
|
+
const toTweenPct = round3(progressAtTime(runEase, dropTimePct));
|
|
128
132
|
if (Math.abs(toTweenPct - draggedTweenPct) < NOOP_EPSILON_PCT) return { kind: "noop" };
|
|
129
133
|
return { kind: "move", toTweenPct };
|
|
130
134
|
}
|
|
@@ -154,8 +158,11 @@ export function resolveKeyframeRetime(opts: {
|
|
|
154
158
|
// survive verbatim (no rebuilt keyframes array).
|
|
155
159
|
const pctRemap: KeyframePctRemap[] = keyframes.map((kf, i) => {
|
|
156
160
|
const absTime =
|
|
157
|
-
i === draggedIdx
|
|
158
|
-
|
|
161
|
+
i === draggedIdx
|
|
162
|
+
? dropAbsTime
|
|
163
|
+
: tweenStart + (timeAtProgress(runEase, kf.percentage) / 100) * tweenDuration;
|
|
164
|
+
const newTimePct = ((absTime - newStart) / newDuration) * 100;
|
|
165
|
+
return { from: kf.percentage, to: round3(progressAtTime(runEase, newTimePct)) };
|
|
159
166
|
});
|
|
160
167
|
|
|
161
168
|
return {
|
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
2
2
|
import { studioApiFetch } from "../../utils/studioApiFetch";
|
|
3
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
3
4
|
|
|
4
|
-
type ProbeTarget = { id?: string; hfId?: string; selector?: string; selectorIndex?: number };
|
|
5
|
+
export type ProbeTarget = { id?: string; hfId?: string; selector?: string; selectorIndex?: number };
|
|
6
|
+
|
|
7
|
+
export function knownSourceAnswer(
|
|
8
|
+
previous: DomEditSelection | null | undefined,
|
|
9
|
+
element: HTMLElement,
|
|
10
|
+
sourceFile: string,
|
|
11
|
+
target: ProbeTarget,
|
|
12
|
+
): boolean | undefined {
|
|
13
|
+
if (!previous || previous.element !== element || previous.sourceFile !== sourceFile) return;
|
|
14
|
+
const same =
|
|
15
|
+
previous.id === target.id &&
|
|
16
|
+
previous.hfId === target.hfId &&
|
|
17
|
+
previous.selector === target.selector &&
|
|
18
|
+
previous.selectorIndex === target.selectorIndex;
|
|
19
|
+
return same ? previous.existsInSource : undefined;
|
|
20
|
+
}
|
|
5
21
|
|
|
6
22
|
interface PendingProbe {
|
|
7
23
|
target: ProbeTarget;
|
|
@@ -79,24 +79,14 @@ function mountMirrorHarness(history: {
|
|
|
79
79
|
moveCoalesceKeys: string[];
|
|
80
80
|
records: RecordedWrite[];
|
|
81
81
|
}) {
|
|
82
|
-
const record = (
|
|
83
|
-
label: string,
|
|
84
|
-
coalesceKey: string,
|
|
85
|
-
_coalesceMs: number | undefined,
|
|
86
|
-
after: string,
|
|
87
|
-
) => {
|
|
82
|
+
const record = (label: string, coalesceKey: string, after: string) => {
|
|
88
83
|
history.records.push({ label, coalesceKey, after });
|
|
89
84
|
history.fileContent.current = after;
|
|
90
85
|
};
|
|
91
86
|
|
|
92
|
-
const onMoveElements: TimelineEditCallbacks["onMoveElements"] = (
|
|
93
|
-
_edits,
|
|
94
|
-
coalesceKey,
|
|
95
|
-
_operation,
|
|
96
|
-
coalesceMs,
|
|
97
|
-
) => {
|
|
87
|
+
const onMoveElements: TimelineEditCallbacks["onMoveElements"] = (_edits, coalesceKey) => {
|
|
98
88
|
history.moveCoalesceKeys.push(coalesceKey ?? "<none>");
|
|
99
|
-
record("Move timeline clips", coalesceKey ?? "<none>",
|
|
89
|
+
record("Move timeline clips", coalesceKey ?? "<none>", "C-move");
|
|
100
90
|
};
|
|
101
91
|
|
|
102
92
|
const api: Partial<HarnessApi> = {};
|
|
@@ -104,7 +94,7 @@ function mountMirrorHarness(history: {
|
|
|
104
94
|
const { handleDomZIndexReorderCommit } = useElementLifecycleOps(
|
|
105
95
|
makeLifecycleOpsParams({
|
|
106
96
|
commitDomEditPatchBatches: async (_batches, options) => {
|
|
107
|
-
record(options.label, options.coalesceKey,
|
|
97
|
+
record(options.label, options.coalesceKey, "B-z");
|
|
108
98
|
return { durable: true, allMatched: true, changed: true };
|
|
109
99
|
},
|
|
110
100
|
}),
|
|
@@ -163,13 +163,6 @@ function useMirrorLaneMoveCommit(): (
|
|
|
163
163
|
// stacking sync must not re-trigger and fight the just-set z values.
|
|
164
164
|
},
|
|
165
165
|
coalesceKey,
|
|
166
|
-
// Unbounded fold window: this record lands only AFTER the z persist's
|
|
167
|
-
// server round-trip resolved, so the gap between the gesture's two
|
|
168
|
-
// records exceeds editHistory's 300ms default under real latency and
|
|
169
|
-
// the fold would silently split into two undo entries. The shared key
|
|
170
|
-
// is unique per gesture (zReorderCoalesceKey's gesture seq), so the
|
|
171
|
-
// unbounded window can never merge two distinct user actions.
|
|
172
|
-
Number.POSITIVE_INFINITY,
|
|
173
166
|
);
|
|
174
167
|
},
|
|
175
168
|
[onMoveElements],
|
|
@@ -679,6 +679,33 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
|
|
679
679
|
view.unmount();
|
|
680
680
|
});
|
|
681
681
|
|
|
682
|
+
it("retimes a duration-less tween's keyframe in the window GSAP plays it, not the clip", async () => {
|
|
683
|
+
const durationless = { ...authoredInteriorAnimation(), duration: undefined };
|
|
684
|
+
mocks.animations = [durationless];
|
|
685
|
+
usePlayerStore.setState({ gsapAnimations: new Map([["box", [durationless]]]) });
|
|
686
|
+
const view = renderCallbacks();
|
|
687
|
+
|
|
688
|
+
// GSAP plays it 0-0.5 s of the 1 s clip, so a drop at 0.4 s is 80% of the tween.
|
|
689
|
+
await view.callbacks.onMoveKeyframe?.(
|
|
690
|
+
"box",
|
|
691
|
+
{
|
|
692
|
+
percentage: 25,
|
|
693
|
+
propertyGroup: "position",
|
|
694
|
+
tweenPercentage: 50,
|
|
695
|
+
animationId: durationless.id,
|
|
696
|
+
},
|
|
697
|
+
40,
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
|
|
701
|
+
durationless.id,
|
|
702
|
+
50,
|
|
703
|
+
80,
|
|
704
|
+
mocks.selection,
|
|
705
|
+
);
|
|
706
|
+
view.unmount();
|
|
707
|
+
});
|
|
708
|
+
|
|
682
709
|
// A drag starts on whatever diamond the pointer is over, which need not be the
|
|
683
710
|
// selected element. Resolving against the selection would retime the selected
|
|
684
711
|
// element's tween and commit it through the selected element's file.
|
|
@@ -777,46 +804,4 @@ describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => {
|
|
|
777
804
|
expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled();
|
|
778
805
|
view.unmount();
|
|
779
806
|
});
|
|
780
|
-
|
|
781
|
-
it("uses the clip timing basis when retiming a duration-less tween", async () => {
|
|
782
|
-
const durationless = {
|
|
783
|
-
...authoredInteriorAnimation(),
|
|
784
|
-
position: 3.2,
|
|
785
|
-
resolvedStart: 3.2,
|
|
786
|
-
duration: undefined,
|
|
787
|
-
};
|
|
788
|
-
const wideElement = { ...element, start: 10.94, duration: 16.26 };
|
|
789
|
-
mocks.animations = [durationless];
|
|
790
|
-
usePlayerStore.setState({
|
|
791
|
-
elements: [wideElement],
|
|
792
|
-
gsapAnimations: new Map([["box", [durationless]]]),
|
|
793
|
-
});
|
|
794
|
-
const view = renderCallbacks();
|
|
795
|
-
|
|
796
|
-
await expect(
|
|
797
|
-
view.callbacks.onMoveKeyframe?.(
|
|
798
|
-
"box",
|
|
799
|
-
{
|
|
800
|
-
percentage: 19.1,
|
|
801
|
-
propertyGroup: "position",
|
|
802
|
-
tweenPercentage: 50,
|
|
803
|
-
animationId: durationless.id,
|
|
804
|
-
},
|
|
805
|
-
40,
|
|
806
|
-
),
|
|
807
|
-
).resolves.toBe(true);
|
|
808
|
-
|
|
809
|
-
// The whole point of the clip basis: the drop lands at 10.94 + 0.40 * 16.26 =
|
|
810
|
-
// 17.444s, and the duration-less tween borrows the clip's 16.26s window from
|
|
811
|
-
// its 3.2s start, so 17.444 - 3.2 over 16.26 is 87.601%. Any other basis (a
|
|
812
|
-
// zero-length tween, or the clip's own 0-100 %) produces a different number.
|
|
813
|
-
expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(
|
|
814
|
-
durationless.id,
|
|
815
|
-
50,
|
|
816
|
-
expect.closeTo(87.601, 3),
|
|
817
|
-
mocks.selection,
|
|
818
|
-
);
|
|
819
|
-
expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled();
|
|
820
|
-
view.unmount();
|
|
821
|
-
});
|
|
822
807
|
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
useDomEditSelectionContext,
|
|
13
13
|
} from "../../contexts/DomEditContext";
|
|
14
14
|
import { resolveTweenStart, resolveTweenDuration } from "../../utils/globalTimeCompiler";
|
|
15
|
+
import { runEaseOf } from "../../utils/gsapKeyframeEases";
|
|
15
16
|
import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache";
|
|
16
17
|
import { elementCacheKeys } from "../../hooks/gsapKeyframeCacheHelpers";
|
|
17
18
|
import { resolveKeyframeRetime } from "../editor/keyframeRetime";
|
|
@@ -32,7 +33,6 @@ export interface TimelineEditCallbackDeps {
|
|
|
32
33
|
edits: Array<{ element: TimelineElement; updates: Pick<TimelineElement, "start" | "track"> }>,
|
|
33
34
|
coalesceKey?: string,
|
|
34
35
|
operation?: TimelineMoveOperation,
|
|
35
|
-
coalesceMs?: number,
|
|
36
36
|
) => Promise<void> | void;
|
|
37
37
|
handleTimelineElementResize: (
|
|
38
38
|
element: TimelineElement,
|
|
@@ -332,7 +332,7 @@ export function useTimelineEditCallbacks({
|
|
|
332
332
|
elements,
|
|
333
333
|
domClipChildren,
|
|
334
334
|
);
|
|
335
|
-
const tweenDuration = resolveTweenDuration(anim
|
|
335
|
+
const tweenDuration = resolveTweenDuration(anim);
|
|
336
336
|
const dropAbsTime = elStart + (toClipPct / 100) * elDuration;
|
|
337
337
|
const decision = resolveKeyframeRetime({
|
|
338
338
|
keyframes: anim.keyframes?.keyframes ?? [],
|
|
@@ -340,6 +340,7 @@ export function useTimelineEditCallbacks({
|
|
|
340
340
|
tweenStart,
|
|
341
341
|
tweenDuration,
|
|
342
342
|
dropAbsTime,
|
|
343
|
+
runEase: runEaseOf(anim),
|
|
343
344
|
});
|
|
344
345
|
if (decision.kind === "move" && decision.toTweenPct != null) {
|
|
345
346
|
return handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct, sel);
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
6
6
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
7
7
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
percentageToAbsoluteForAnimation,
|
|
10
|
+
resolveTweenStart,
|
|
11
|
+
resolveTweenDuration,
|
|
12
|
+
} from "../utils/globalTimeCompiler";
|
|
9
13
|
import { roundTo3, roundToLayoutPx } from "../utils/rounding";
|
|
10
14
|
import { computeElementPercentage, keyframeEases, writeTargetSelector } from "./gsapShared";
|
|
11
15
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
@@ -54,10 +58,9 @@ export function computeCurrentPercentage(
|
|
|
54
58
|
// 1.2 start), so the post-commit reseek renders the element's base pose and the edit
|
|
55
59
|
// looks like it snapped away. Keeping the playhead on the edited keyframe avoids that.
|
|
56
60
|
export function parkPlayheadOnKeyframe(anim: GsapAnimation, pct: number): void {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
usePlayerStore.getState().requestSeek(roundTo3(ts + (pct / 100) * td));
|
|
61
|
+
const time = percentageToAbsoluteForAnimation(pct, anim);
|
|
62
|
+
if (time === null || resolveTweenDuration(anim) <= 0) return;
|
|
63
|
+
usePlayerStore.getState().requestSeek(roundTo3(time));
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
async function replaceKeyframedPositionHold(
|