@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.
Files changed (126) hide show
  1. package/dist/assets/{hyperframes-player-BbbN-wWN.js → hyperframes-player-CKCx0CFA.js} +1 -1
  2. package/dist/assets/{index-Cll7uTNy.js → index-CF31Pq1E.js} +1 -1
  3. package/dist/assets/{index-B8kU2xXl.js → index-ClLzyuwb.js} +172 -172
  4. package/dist/assets/{index-nvLF29ZH.js → index-CwS08gWW.js} +1 -1
  5. package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
  6. package/dist/chunk-7UQWVN52.js.map +1 -0
  7. package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
  8. package/dist/chunk-DRVWEPO5.js.map +1 -0
  9. package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
  10. package/dist/chunk-TDWGXBUT.js.map +1 -0
  11. package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
  12. package/dist/index.d.ts +8 -9
  13. package/dist/index.html +1 -1
  14. package/dist/index.js +2184 -1745
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/package.json +8 -8
  18. package/src/components/TimelineToolbar.tsx +6 -7
  19. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +42 -0
  20. package/src/components/editor/MotionPathOverlay.tsx +20 -26
  21. package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
  22. package/src/components/editor/domEditingLayers.test.ts +45 -18
  23. package/src/components/editor/domEditingLayers.ts +11 -9
  24. package/src/components/editor/domEditingTypes.ts +1 -0
  25. package/src/components/editor/keyframeRetime.ts +11 -4
  26. package/src/components/editor/motionPathCommit.nodeDrop.test.ts +212 -0
  27. package/src/components/editor/motionPathCommit.ts +58 -0
  28. package/src/components/editor/motionPathGeometry.test.ts +13 -0
  29. package/src/components/editor/motionPathGeometry.ts +4 -2
  30. package/src/components/editor/probeSourceElement.ts +17 -1
  31. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
  32. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
  33. package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
  34. package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
  35. package/src/hooks/gsapDragCommit.ts +41 -6
  36. package/src/hooks/gsapDragPositionCommit.ts +90 -17
  37. package/src/hooks/gsapEditOutcome.ts +27 -2
  38. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
  39. package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
  40. package/src/hooks/gsapMutationClient.ts +35 -7
  41. package/src/hooks/gsapOvershootEase.test.ts +67 -0
  42. package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
  43. package/src/hooks/gsapParsedTween.ts +27 -7
  44. package/src/hooks/gsapPositionDetection.ts +5 -2
  45. package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
  46. package/src/hooks/gsapRuntimeBridge.ts +7 -1
  47. package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
  48. package/src/hooks/gsapRuntimeKeyframes.ts +20 -60
  49. package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
  50. package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
  51. package/src/hooks/gsapShared.test.ts +49 -35
  52. package/src/hooks/gsapShared.ts +35 -33
  53. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +198 -6
  54. package/src/hooks/gsapValueAtPlayhead.test.ts +142 -27
  55. package/src/hooks/gsapValueAtPlayhead.ts +49 -10
  56. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
  57. package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
  58. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  59. package/src/hooks/timelineEditingHelpers.ts +40 -14
  60. package/src/hooks/timelineLinkEditPlan.ts +6 -4
  61. package/src/hooks/timelineMoveAdapter.ts +3 -5
  62. package/src/hooks/timelineTimingSync.test.ts +83 -125
  63. package/src/hooks/timelineTimingSync.ts +62 -42
  64. package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
  65. package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
  66. package/src/hooks/useCommitPreflightCapabilities.ts +44 -9
  67. package/src/hooks/useDomEditPreviewSync.ts +8 -2
  68. package/src/hooks/useDomSelection.test.ts +27 -0
  69. package/src/hooks/useDomSelection.ts +12 -13
  70. package/src/hooks/useDomSelectionTypes.ts +1 -0
  71. package/src/hooks/useEditHistoryActions.test.tsx +54 -3
  72. package/src/hooks/useEditHistoryActions.ts +9 -6
  73. package/src/hooks/useEnableKeyframes.test.ts +20 -15
  74. package/src/hooks/useEnableKeyframes.ts +54 -71
  75. package/src/hooks/useGestureCommit.ts +2 -1
  76. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
  77. package/src/hooks/useGsapTweenCache.ts +2 -17
  78. package/src/hooks/usePersistentEditHistory.test.ts +45 -0
  79. package/src/hooks/usePersistentEditHistory.ts +13 -2
  80. package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
  81. package/src/hooks/usePreviewPersistence.ts +26 -4
  82. package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
  83. package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
  84. package/src/hooks/useTimelineDeleteOps.ts +5 -13
  85. package/src/hooks/useTimelineEditing.test.tsx +63 -4
  86. package/src/hooks/useTimelineEditing.ts +21 -295
  87. package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
  88. package/src/hooks/useTimelineGroupEditing.ts +15 -38
  89. package/src/player/components/Timeline.test.ts +12 -4
  90. package/src/player/components/TimelineClip.tsx +3 -0
  91. package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
  92. package/src/player/components/TimelineLanes.test.tsx +61 -5
  93. package/src/player/components/TimelineLanes.tsx +20 -28
  94. package/src/player/components/TimelinePropertyLanes.tsx +10 -1
  95. package/src/player/components/timelineCallbacks.ts +1 -4
  96. package/src/player/components/timelineClipDragCommit.test.ts +31 -0
  97. package/src/player/components/timelineClipDragCommit.ts +39 -28
  98. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  99. package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
  100. package/src/player/components/timelineClipGestureHandlers.ts +2 -20
  101. package/src/player/components/timelineEditing.ts +1 -3
  102. package/src/player/components/timelineGroupResizeCommit.ts +10 -9
  103. package/src/player/components/timelineLinkSelection.ts +1 -35
  104. package/src/player/components/timelineOptimisticRevision.ts +7 -4
  105. package/src/player/components/trackHeaderLaneState.ts +9 -2
  106. package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
  107. package/src/player/components/trackHeaderLaneValues.ts +54 -13
  108. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
  109. package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
  110. package/src/player/components/useTimelineClipDrag.ts +0 -3
  111. package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
  112. package/src/player/store/batchElementUpdates.test.ts +43 -0
  113. package/src/player/store/batchElementUpdates.ts +9 -0
  114. package/src/player/store/elementPatchQueue.ts +35 -0
  115. package/src/player/store/playerStore.ts +5 -6
  116. package/src/utils/domEditHelpers.ts +5 -0
  117. package/src/utils/globalTimeCompiler.test.ts +0 -4
  118. package/src/utils/globalTimeCompiler.ts +6 -4
  119. package/src/utils/gsapKeyframeEases.ts +79 -0
  120. package/src/utils/sameData.ts +9 -0
  121. package/src/utils/studioFileHistory.ts +1 -0
  122. package/src/utils/studioPendingEdits.ts +15 -1
  123. package/dist/chunk-246VB2G3.js.map +0 -1
  124. package/dist/chunk-3F47EFCT.js.map +0 -1
  125. package/dist/chunk-5EKWJZLM.js.map +0 -1
  126. /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
package/dist/player.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  Player
3
- } from "./chunk-246VB2G3.js";
3
+ } from "./chunk-DRVWEPO5.js";
4
4
  import "./chunk-5VSXRGIX.js";
5
- import "./chunk-5EKWJZLM.js";
5
+ import "./chunk-TDWGXBUT.js";
6
6
  import "./chunk-PZ5AY32C.js";
7
7
  export {
8
8
  Player
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.126",
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/core": "0.8.126",
64
- "@hyperframes/sdk": "0.8.126",
65
- "@hyperframes/shader-transitions": "0.8.126",
66
- "@hyperframes/player": "0.8.126",
67
- "@hyperframes/parsers": "0.8.126",
68
- "@hyperframes/studio-server": "0.8.126"
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.126"
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, KEYFRAME_PCT_MATCH } from "../hooks/gsapShared";
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
- Math.abs((keyframes[0]?.percentage ?? -Infinity) - percentage) <= KEYFRAME_PCT_MATCH ||
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, session.domEditSelection)) {
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
- (keyframe) => Math.abs(keyframe.percentage - percentage) <= KEYFRAME_PCT_MATCH,
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
- commitNode,
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
- if (
380
- d.ref.type === "keyframe" &&
381
- anim &&
382
- selection &&
383
- !usePlayerStore.getState().autoKeyframeEnabled
384
- ) {
385
- const writes = observeGsapGesture((_sel, mutation, options) =>
386
- commitMutation(mutation, options),
387
- );
388
- void commitWholePropertyOffset(
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
- describe("collectDomEditLayerItems item budget", () => {
240
- function documentWith(count: number): HTMLElement {
241
- const root = document.createElement("div");
242
- root.setAttribute("data-composition-id", "index.html");
243
- for (let i = 0; i < count; i++) {
244
- const child = document.createElement("div");
245
- child.id = `el-${i}`;
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 = document.createElement("div");
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
- let existsInSource: boolean | undefined;
334
- if (!options.skipSourceProbe && options.projectId && (current.id || selector || hfId)) {
335
- const probeTarget: { id?: string; hfId?: string; selector?: string; selectorIndex?: number } =
336
- {};
337
- if (current.id) probeTarget.id = current.id;
338
- if (hfId) probeTarget.hfId = hfId;
339
- if (selector) probeTarget.selector = selector;
340
- if (selectorIndex != null) probeTarget.selectorIndex = selectorIndex;
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
 
@@ -92,6 +92,7 @@ export interface DomEditSelection extends PatchTarget {
92
92
  computedStyles: Record<string, string>;
93
93
  textFields: DomEditTextField[];
94
94
  capabilities: DomEditCapabilities;
95
+ existsInSource?: boolean;
95
96
  gsapAnimations?: GsapAnimation[];
96
97
  }
97
98
 
@@ -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 toTweenPct = round3(clamp(((dropAbsTime - tweenStart) / tweenDuration) * 100, 0, 100));
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 ? dropAbsTime : tweenStart + (kf.percentage / 100) * tweenDuration;
158
- return { from: kf.percentage, to: round3(((absTime - newStart) / newDuration) * 100) };
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 {