@hyperframes/studio 0.8.126 → 0.8.128
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-BbbN-wWN.js → hyperframes-player-CKCx0CFA.js} +1 -1
- package/dist/assets/{index-Cll7uTNy.js → index-CF31Pq1E.js} +1 -1
- package/dist/assets/{index-B8kU2xXl.js → index-ClLzyuwb.js} +172 -172
- package/dist/assets/{index-nvLF29ZH.js → index-CwS08gWW.js} +1 -1
- 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 +2184 -1745
- 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/MotionPathOverlay.layerPress.test.tsx +42 -0
- package/src/components/editor/MotionPathOverlay.tsx +20 -26
- 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/motionPathCommit.nodeDrop.test.ts +212 -0
- package/src/components/editor/motionPathCommit.ts +58 -0
- package/src/components/editor/motionPathGeometry.test.ts +13 -0
- package/src/components/editor/motionPathGeometry.ts +4 -2
- 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 +41 -6
- package/src/hooks/gsapDragPositionCommit.ts +90 -17
- package/src/hooks/gsapEditOutcome.ts +27 -2
- 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.test-helpers.ts +28 -1
- package/src/hooks/gsapParsedTween.ts +27 -7
- package/src/hooks/gsapPositionDetection.ts +5 -2
- package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
- package/src/hooks/gsapRuntimeBridge.ts +7 -1
- package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
- package/src/hooks/gsapRuntimeKeyframes.ts +20 -60
- package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
- package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
- package/src/hooks/gsapShared.test.ts +49 -35
- package/src/hooks/gsapShared.ts +35 -33
- package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +198 -6
- package/src/hooks/gsapValueAtPlayhead.test.ts +142 -27
- package/src/hooks/gsapValueAtPlayhead.ts +49 -10
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
- 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 +62 -42
- package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
- package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
- package/src/hooks/useCommitPreflightCapabilities.ts +44 -9
- 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/useGsapAwareEditing.groupPlan.test.tsx +25 -0
- 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/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.128",
|
|
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/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/player": "0.8.128",
|
|
64
|
+
"@hyperframes/parsers": "0.8.128",
|
|
65
|
+
"@hyperframes/sdk": "0.8.128",
|
|
66
|
+
"@hyperframes/shader-transitions": "0.8.128",
|
|
67
|
+
"@hyperframes/studio-server": "0.8.128",
|
|
68
|
+
"@hyperframes/core": "0.8.128"
|
|
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.128"
|
|
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",
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
import { act } from "react";
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { afterEach, expect, it, vi } from "vitest";
|
|
5
|
+
import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
|
|
5
6
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
6
7
|
import { MotionPathOverlay } from "./MotionPathOverlay";
|
|
8
|
+
import { commitNodeDrop } from "./motionPathCommit";
|
|
7
9
|
import type { DomEditSelection } from "./domEditing";
|
|
8
10
|
|
|
9
11
|
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
@@ -14,6 +16,14 @@ const { commitMutation } = vi.hoisted(() => ({
|
|
|
14
16
|
vi.mock("../../contexts/DomEditContext", () => ({
|
|
15
17
|
useDomEditContext: () => ({ selectedGsapAnimations: [], commitMutation }),
|
|
16
18
|
}));
|
|
19
|
+
const showToast = vi.hoisted(() => vi.fn());
|
|
20
|
+
vi.mock("../../contexts/StudioContext", () => ({
|
|
21
|
+
useStudioShellContextOptional: () => ({ showToast }),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("./motionPathCommit", async (importOriginal) => ({
|
|
24
|
+
...(await importOriginal<typeof import("./motionPathCommit")>()),
|
|
25
|
+
commitNodeDrop: vi.fn(),
|
|
26
|
+
}));
|
|
17
27
|
vi.mock("./motionPathSelection", () => ({
|
|
18
28
|
selectorFor: () => "#box",
|
|
19
29
|
editableAnimationId: () => "a1",
|
|
@@ -189,3 +199,35 @@ it("draws where GSAP started the tween before the first keyframe, as a mark that
|
|
|
189
199
|
const drawn = host.querySelector("polyline:not(.pointer-events-auto)")!;
|
|
190
200
|
expect(drawn.getAttribute("points")).toBe("50,30 60,30 140,30");
|
|
191
201
|
});
|
|
202
|
+
|
|
203
|
+
it("a node drop the writer refuses says why", async () => {
|
|
204
|
+
const refusal = new GsapEditBlockedError("keyframes-uneditable");
|
|
205
|
+
vi.mocked(commitNodeDrop).mockRejectedValue(refusal);
|
|
206
|
+
vi.spyOn(Element.prototype, "setPointerCapture").mockImplementation(() => {});
|
|
207
|
+
const host = document.body.appendChild(document.createElement("div"));
|
|
208
|
+
const root = createRoot(host);
|
|
209
|
+
const selection = { element: document.createElement("div") } as unknown as DomEditSelection;
|
|
210
|
+
try {
|
|
211
|
+
act(() =>
|
|
212
|
+
root.render(
|
|
213
|
+
<MotionPathOverlay
|
|
214
|
+
iframeRef={{ current: null }}
|
|
215
|
+
selection={selection}
|
|
216
|
+
compositionSize={{ width: 1920, height: 1080 }}
|
|
217
|
+
isPlaying={false}
|
|
218
|
+
/>,
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
const node = host.querySelector('circle.pointer-events-auto[cx="140"]')!;
|
|
222
|
+
document.elementsFromPoint = () => [node];
|
|
223
|
+
const at = (clientX: number) => ({ bubbles: true, button: 0, clientX, clientY: 30 });
|
|
224
|
+
act(() => void node.dispatchEvent(new PointerEvent("pointerdown", at(140))));
|
|
225
|
+
act(() => void node.dispatchEvent(new PointerEvent("pointermove", at(180))));
|
|
226
|
+
await act(async () => void node.dispatchEvent(new PointerEvent("pointerup", at(180))));
|
|
227
|
+
expect(commitNodeDrop).toHaveBeenCalledTimes(1);
|
|
228
|
+
expect(showToast).toHaveBeenCalledWith(refusal.message, "error");
|
|
229
|
+
} finally {
|
|
230
|
+
act(() => root.unmount());
|
|
231
|
+
host.remove();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import { observeGsapGesture } from "../../hooks/gsapGestureOutcome";
|
|
2
|
-
import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
|
|
3
1
|
import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
|
|
4
2
|
import { memo, useEffect, useRef, useState, type RefObject } from "react";
|
|
5
3
|
import type { DomEditSelection } from "./domEditing";
|
|
6
4
|
import { useDomEditContext } from "../../contexts/DomEditContext";
|
|
5
|
+
import { useStudioShellContextOptional } from "../../contexts/StudioContext";
|
|
6
|
+
import { useGsapInteractionFailureTelemetry } from "../../hooks/useGsapInteractionFailureTelemetry";
|
|
7
7
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
8
8
|
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
|
|
9
9
|
import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
|
|
10
|
-
import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
|
|
11
10
|
import { nearestPointOnPath, nodeCentre, type MotionNodeRef } from "./motionPathGeometry";
|
|
12
11
|
import { editableAnimationId, selectorFor } from "./motionPathSelection";
|
|
13
12
|
import { controlForNode, controlUnder, pressControl } from "./motionPathLayerNode";
|
|
@@ -23,8 +22,9 @@ import {
|
|
|
23
22
|
commitAddKeyframe,
|
|
24
23
|
commitAddWaypoint,
|
|
25
24
|
commitCreatePath,
|
|
26
|
-
|
|
25
|
+
commitNodeDrop,
|
|
27
26
|
commitRemoveWaypoint,
|
|
27
|
+
nodeDropLabel,
|
|
28
28
|
} from "./motionPathCommit";
|
|
29
29
|
import { elementHome } from "./motionPathHome";
|
|
30
30
|
import { hasMotionPathPlugin, transformWDivisor, useMotionPathData } from "./useMotionPathData";
|
|
@@ -66,6 +66,7 @@ const NODE_PX = 6; // node radius in screen pixels (kept constant across zoom)
|
|
|
66
66
|
// (select the keyframe); at or above it the gesture commits a move. Screen-space
|
|
67
67
|
// (not composition px) so it behaves identically at any zoom.
|
|
68
68
|
const DRAG_THRESHOLD_PX = 3;
|
|
69
|
+
const noToast = () => {};
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* Draws the selected element's GSAP motion path over the canvas — a dashed
|
|
@@ -91,6 +92,11 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
91
92
|
handleGsapRemoveAllKeyframes,
|
|
92
93
|
handleGsapMoveKeyframeToPlayhead,
|
|
93
94
|
} = useDomEditContext();
|
|
95
|
+
const shell = useStudioShellContextOptional();
|
|
96
|
+
const reportFailure = useGsapInteractionFailureTelemetry(
|
|
97
|
+
shell?.activeCompPath ?? null,
|
|
98
|
+
shell?.showToast ?? noToast,
|
|
99
|
+
);
|
|
94
100
|
const { rect, geometry, geometryResolved, visibleInPreview, home, pScale } = useMotionPathData(
|
|
95
101
|
iframeRef,
|
|
96
102
|
selectorFor(selection),
|
|
@@ -373,30 +379,18 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
373
379
|
// high zoom) would commit an identical value — a no-op undo entry. Skip the
|
|
374
380
|
// commit, but don't treat it as a click either (the user did drag).
|
|
375
381
|
if (x === Math.round(d.initX) && y === Math.round(d.initY)) return;
|
|
376
|
-
// Auto-keyframe off (#1808): dragging a keyframe's node shifts the whole path.
|
|
377
382
|
const anim =
|
|
378
383
|
d.ref.type === "keyframe" ? selectedGsapAnimations?.find((a) => a.id === animId) : undefined;
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
selection,
|
|
390
|
-
anim,
|
|
391
|
-
{ x, y },
|
|
392
|
-
d.ref.pct,
|
|
393
|
-
iframeRef.current,
|
|
394
|
-
{ commitMutation: writes.commit! },
|
|
395
|
-
"Move animation path",
|
|
396
|
-
).then(() => trackPreviewEditResult("motion_path", "drag", writes.finish()));
|
|
397
|
-
} else {
|
|
398
|
-
void commitNode(d.ref, x, y, animId, commitMutation);
|
|
399
|
-
}
|
|
384
|
+
const label = nodeDropLabel(d.ref);
|
|
385
|
+
commitNodeDrop({
|
|
386
|
+
ref: d.ref,
|
|
387
|
+
at: { x, y },
|
|
388
|
+
animId,
|
|
389
|
+
anim,
|
|
390
|
+
selection,
|
|
391
|
+
iframe: iframeRef.current,
|
|
392
|
+
commitMutation,
|
|
393
|
+
}).catch((error: unknown) => reportFailure(error, selection, "drag", label));
|
|
400
394
|
// Park on the edited keyframe, or a playhead before the tween hides the edit.
|
|
401
395
|
if (d.ref.type === "keyframe" && anim) {
|
|
402
396
|
parkPlayheadOnKeyframe(anim, d.ref.pct);
|
|
@@ -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 {
|