@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
|
@@ -7,13 +7,13 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
|
7
7
|
import {
|
|
8
8
|
applyArcKeyframeAtPlayhead,
|
|
9
9
|
animatedProps,
|
|
10
|
-
buildExtendedKeyframes,
|
|
11
10
|
isPlayheadWithinTween,
|
|
12
11
|
promoteSetToKeyframes,
|
|
13
12
|
resolveNewTweenRange,
|
|
14
13
|
useEnableKeyframes,
|
|
15
14
|
type EnableKeyframesSession,
|
|
16
15
|
} from "./useEnableKeyframes";
|
|
16
|
+
import { buildExtendedKeyframes } from "./gsapDragPositionCommit";
|
|
17
17
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
18
18
|
|
|
19
19
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -109,17 +109,11 @@ describe("isPlayheadWithinTween", () => {
|
|
|
109
109
|
expect(isPlayheadWithinTween(anim({ position: "+=1" }), 99)).toBe(true);
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
// paths do. Those span a duration-less tween across its clip, so answering
|
|
114
|
-
// from GSAP's 0.5s default reported the playhead outside a window the click
|
|
115
|
-
// then treated as clip-wide.
|
|
116
|
-
it("spans the clip for a duration-less tween when given the selection", () => {
|
|
112
|
+
it("uses the window GSAP plays a duration-less tween in, not its clip", () => {
|
|
117
113
|
const durationless = anim({ position: 0 });
|
|
118
|
-
const selection = { dataAttributes: { duration: "16" } } as unknown as DomEditSelection;
|
|
119
114
|
|
|
115
|
+
expect(isPlayheadWithinTween(durationless, 0.4)).toBe(true);
|
|
120
116
|
expect(isPlayheadWithinTween(durationless, 5)).toBe(false);
|
|
121
|
-
expect(isPlayheadWithinTween(durationless, 5, selection)).toBe(true);
|
|
122
|
-
expect(isPlayheadWithinTween(durationless, 20, selection)).toBe(false);
|
|
123
117
|
});
|
|
124
118
|
});
|
|
125
119
|
|
|
@@ -238,6 +232,7 @@ describe("applyArcKeyframeAtPlayhead", () => {
|
|
|
238
232
|
id: "#el-to-0-position",
|
|
239
233
|
position: 0,
|
|
240
234
|
duration: 10,
|
|
235
|
+
ease: "none",
|
|
241
236
|
keyframes: {
|
|
242
237
|
format: "object-array",
|
|
243
238
|
keyframes: [
|
|
@@ -318,8 +313,8 @@ describe("applyArcKeyframeAtPlayhead", () => {
|
|
|
318
313
|
);
|
|
319
314
|
});
|
|
320
315
|
|
|
321
|
-
it("
|
|
322
|
-
const fixture = arcFixture(
|
|
316
|
+
it("extends a duration-less arc to a later playhead, keeping the other nodes' times", async () => {
|
|
317
|
+
const fixture = arcFixture(100, 0);
|
|
323
318
|
const durationlessArc = { ...arcAnim, duration: undefined };
|
|
324
319
|
|
|
325
320
|
await applyArcKeyframeAtPlayhead(
|
|
@@ -330,12 +325,22 @@ describe("applyArcKeyframeAtPlayhead", () => {
|
|
|
330
325
|
fixture.iframe,
|
|
331
326
|
);
|
|
332
327
|
|
|
328
|
+
// GSAP plays it 0-0.5 s, so the nodes stay at 0, 0.25 and 0.5 s.
|
|
333
329
|
expect(fixture.commitMutation).toHaveBeenCalledWith(
|
|
334
|
-
|
|
330
|
+
{
|
|
335
331
|
type: "replace-with-keyframes",
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
332
|
+
animationId: arcAnim.id,
|
|
333
|
+
targetSelector: "#el",
|
|
334
|
+
position: 0,
|
|
335
|
+
duration: 2.5,
|
|
336
|
+
keyframes: [
|
|
337
|
+
{ percentage: 0, properties: { x: 0, y: 0 } },
|
|
338
|
+
{ percentage: 10, properties: { x: 50, y: 50 } },
|
|
339
|
+
{ percentage: 20, properties: { x: 100, y: 0 } },
|
|
340
|
+
{ percentage: 100, properties: { x: 100, y: 0 } },
|
|
341
|
+
],
|
|
342
|
+
ease: "none",
|
|
343
|
+
},
|
|
339
344
|
{ label: "Add keyframe", softReload: true, keyframeAction: "add" },
|
|
340
345
|
);
|
|
341
346
|
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Reads GSAP runtime values only (no CSS offset — it applies separately via translate).
|
|
9
9
|
*/
|
|
10
10
|
import { useCallback } from "react";
|
|
11
|
-
import type { GsapAnimation
|
|
11
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
12
12
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
13
13
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
14
14
|
import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
|
|
@@ -18,19 +18,25 @@ import {
|
|
|
18
18
|
keyframeEases,
|
|
19
19
|
KEYFRAME_PCT_MATCH,
|
|
20
20
|
isInstantHold,
|
|
21
|
-
|
|
21
|
+
playsNear,
|
|
22
22
|
writeTargetSelector,
|
|
23
23
|
} from "./gsapShared";
|
|
24
24
|
import {
|
|
25
25
|
absoluteToPercentage,
|
|
26
|
+
absoluteToPercentageForAnimation,
|
|
26
27
|
resolveTweenStart,
|
|
27
28
|
resolveTweenDuration,
|
|
28
29
|
isTimeWithinTween,
|
|
29
30
|
} from "../utils/globalTimeCompiler";
|
|
30
31
|
import { POSITION_PROPS } from "./gsapRuntimeReaders";
|
|
32
|
+
import { findParsedTween, withLiveTiming } from "./gsapParsedTween";
|
|
31
33
|
import { roundTo3 } from "../utils/rounding";
|
|
32
34
|
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
33
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
buildExtendedKeyframes,
|
|
37
|
+
buildTemporalArcKeyframes,
|
|
38
|
+
linearArcKeyframes,
|
|
39
|
+
} from "./gsapDragPositionCommit";
|
|
34
40
|
|
|
35
41
|
let enableKeyframesTransactionCounter = 0;
|
|
36
42
|
|
|
@@ -80,58 +86,11 @@ export function animatedProps(anim: GsapAnimation | null): string[] {
|
|
|
80
86
|
* Whether the playhead sits inside an animation's tween range. When the tween's
|
|
81
87
|
* start can't be resolved we don't block (the percentage falls back to clip range,
|
|
82
88
|
* preserving prior behavior for elements without explicit timing).
|
|
83
|
-
*
|
|
84
|
-
* Pass the selection whenever the caller has one: a duration-less tween spans
|
|
85
|
-
* its clip, and answering from GSAP's 0.5s default reports the playhead outside
|
|
86
|
-
* a window the edit paths treat as clip-wide.
|
|
87
89
|
*/
|
|
88
|
-
export function isPlayheadWithinTween(
|
|
89
|
-
anim: GsapAnimation,
|
|
90
|
-
currentTime: number,
|
|
91
|
-
selection?: DomEditSelection | null,
|
|
92
|
-
): boolean {
|
|
90
|
+
export function isPlayheadWithinTween(anim: GsapAnimation, currentTime: number): boolean {
|
|
93
91
|
const start = resolveTweenStart(anim);
|
|
94
92
|
if (start === null) return true;
|
|
95
|
-
|
|
96
|
-
? resolveEditableTweenDuration(anim, selection)
|
|
97
|
-
: resolveTweenDuration(anim);
|
|
98
|
-
return isTimeWithinTween(currentTime, start, duration);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Grow a keyframe tween's range to reach a playhead that sits outside it, and add a
|
|
103
|
-
* keyframe there. Existing keyframes keep their *absolute* timing (percentages
|
|
104
|
-
* rescale into the new range), so the current motion is preserved — the playhead
|
|
105
|
-
* just becomes a new hold at the start or end. Used when "add keyframe at playhead"
|
|
106
|
-
* fires beyond the tween instead of disabling the action.
|
|
107
|
-
*/
|
|
108
|
-
export function buildExtendedKeyframes(
|
|
109
|
-
anim: GsapAnimation,
|
|
110
|
-
currentTime: number,
|
|
111
|
-
position: Record<string, number>,
|
|
112
|
-
sourceDuration = resolveTweenDuration(anim),
|
|
113
|
-
): { position: number; duration: number; keyframes: GsapPercentageKeyframe[] } {
|
|
114
|
-
const oldStart = resolveTweenStart(anim) ?? 0;
|
|
115
|
-
const oldDuration = sourceDuration;
|
|
116
|
-
const newStart = Math.min(oldStart, currentTime);
|
|
117
|
-
const newEnd = Math.max(oldStart + oldDuration, currentTime);
|
|
118
|
-
const newDuration = roundTo3(newEnd - newStart);
|
|
119
|
-
const toPct = (absoluteTime: number) =>
|
|
120
|
-
newDuration > 0
|
|
121
|
-
? Math.max(
|
|
122
|
-
0,
|
|
123
|
-
Math.min(100, Math.round(((absoluteTime - newStart) / newDuration) * 1000) / 10),
|
|
124
|
-
)
|
|
125
|
-
: 0;
|
|
126
|
-
const stops = anim.keyframes?.keyframes ?? [];
|
|
127
|
-
const rescaled: GsapPercentageKeyframe[] = stops.map((stop) => ({
|
|
128
|
-
percentage: toPct(oldStart + (stop.percentage / 100) * oldDuration),
|
|
129
|
-
properties: stop.properties,
|
|
130
|
-
...(stop.ease ? { ease: stop.ease } : {}),
|
|
131
|
-
}));
|
|
132
|
-
const added: GsapPercentageKeyframe = { percentage: toPct(currentTime), properties: position };
|
|
133
|
-
const keyframes = [...rescaled, added].sort((a, b) => a.percentage - b.percentage);
|
|
134
|
-
return { position: roundTo3(newStart), duration: newDuration, keyframes };
|
|
93
|
+
return isTimeWithinTween(currentTime, start, resolveTweenDuration(anim));
|
|
135
94
|
}
|
|
136
95
|
|
|
137
96
|
async function replaceSetWithSingleKeyframe(
|
|
@@ -285,17 +244,24 @@ async function extendKeyframedTweenToPlayhead(
|
|
|
285
244
|
* buildExtendedKeyframes). Shared by native keyframe tweens and flat tweens that
|
|
286
245
|
* were just converted, so both behave identically.
|
|
287
246
|
*/
|
|
288
|
-
async function applyKeyframeAtPlayhead(
|
|
247
|
+
export async function applyKeyframeAtPlayhead(
|
|
289
248
|
session: EnableKeyframesSession,
|
|
290
249
|
sel: DomEditSelection,
|
|
291
250
|
kfAnim: GsapAnimation,
|
|
292
251
|
t: number,
|
|
293
252
|
iframe: HTMLIFrameElement | null,
|
|
294
253
|
commitOverrides?: Partial<CommitMutationOptions>,
|
|
254
|
+
liveTween?: ReturnType<typeof findParsedTween>,
|
|
295
255
|
): Promise<void> {
|
|
296
|
-
const
|
|
256
|
+
const keepAuthoredDuration = kfAnim.durationUnresolved === true;
|
|
257
|
+
if (keepAuthoredDuration) {
|
|
258
|
+
kfAnim = withLiveTiming(kfAnim, liveTween ?? findParsedTween(iframe, sel.element, kfAnim));
|
|
259
|
+
}
|
|
260
|
+
if (keepAuthoredDuration && kfAnim.duration == null) return;
|
|
261
|
+
const duration = resolveTweenDuration(kfAnim);
|
|
297
262
|
const start = resolveTweenStart(kfAnim);
|
|
298
263
|
if (start !== null && !isTimeWithinTween(t, start, duration)) {
|
|
264
|
+
if (keepAuthoredDuration) return;
|
|
299
265
|
await extendKeyframedTweenToPlayhead(
|
|
300
266
|
session,
|
|
301
267
|
sel,
|
|
@@ -308,10 +274,10 @@ async function applyKeyframeAtPlayhead(
|
|
|
308
274
|
return;
|
|
309
275
|
}
|
|
310
276
|
const pct =
|
|
311
|
-
start === null
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
);
|
|
277
|
+
start === null
|
|
278
|
+
? computeElementPercentage(t, sel)
|
|
279
|
+
: (absoluteToPercentageForAnimation(t, kfAnim) ?? 0);
|
|
280
|
+
const existing = kfAnim.keyframes?.keyframes.find((k) => playsNear(kfAnim, k.percentage, pct));
|
|
315
281
|
if (existing) {
|
|
316
282
|
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
|
|
317
283
|
return;
|
|
@@ -404,21 +370,27 @@ export async function applyArcKeyframeAtPlayhead(
|
|
|
404
370
|
const targetSelector = existingTweenTargetSelector(arcAnim, sel);
|
|
405
371
|
if (!targetSelector) return;
|
|
406
372
|
const start = resolveTweenStart(arcAnim) ?? 0;
|
|
407
|
-
const duration =
|
|
373
|
+
const duration = resolveTweenDuration(arcAnim);
|
|
408
374
|
if (!isTimeWithinTween(t, start, duration)) {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
375
|
+
const held = readElementPosition(iframe, sel, arcAnim);
|
|
376
|
+
if (t <= start || typeof held.x !== "number" || typeof held.y !== "number") return;
|
|
377
|
+
const extended = buildExtendedKeyframes(arcAnim, t, { x: held.x, y: held.y }, duration, "none");
|
|
378
|
+
await session.commitMutation(
|
|
379
|
+
{
|
|
380
|
+
type: "replace-with-keyframes",
|
|
381
|
+
animationId: arcAnim.id,
|
|
382
|
+
targetSelector,
|
|
383
|
+
position: extended.position,
|
|
384
|
+
duration: extended.duration,
|
|
385
|
+
keyframes: extended.keyframes,
|
|
386
|
+
ease: "none",
|
|
387
|
+
},
|
|
388
|
+
{ label: "Add keyframe", keyframeAction: "add", softReload: true },
|
|
389
|
+
);
|
|
419
390
|
return;
|
|
420
391
|
}
|
|
421
|
-
|
|
392
|
+
// The arc is rewritten linear: compare and write at the times GSAP plays each node.
|
|
393
|
+
const nodes = linearArcKeyframes(arcAnim);
|
|
422
394
|
const playheadPercentage = absoluteToPercentage(t, start, duration);
|
|
423
395
|
const timedNodeIndex = nodes.findIndex(
|
|
424
396
|
(node) => Math.abs(node.percentage - playheadPercentage) <= KEYFRAME_PCT_MATCH,
|
|
@@ -502,6 +474,9 @@ export function useEnableKeyframes(
|
|
|
502
474
|
// resolvedFromValues, so the 0%/100% stops keep the real start→end motion
|
|
503
475
|
// (passing the playhead value would flatten it). Then apply uniformly so an
|
|
504
476
|
// out-of-range playhead extends the range just like a keyframe tween.
|
|
477
|
+
const flatLive = flatAnim.durationUnresolved
|
|
478
|
+
? findParsedTween(iframe, sel.element, flatAnim)
|
|
479
|
+
: undefined;
|
|
505
480
|
enableKeyframesTransactionCounter += 1;
|
|
506
481
|
const coalesceKey = `enable-keyframes:${flatAnim.id}:${enableKeyframesTransactionCounter}`;
|
|
507
482
|
const convertCommitOverrides: Partial<CommitMutationOptions> = {
|
|
@@ -527,7 +502,15 @@ export function useEnableKeyframes(
|
|
|
527
502
|
// conversion splits into two undo entries under real latency.
|
|
528
503
|
coalesceMs: Number.POSITIVE_INFINITY,
|
|
529
504
|
};
|
|
530
|
-
await applyKeyframeAtPlayhead(
|
|
505
|
+
await applyKeyframeAtPlayhead(
|
|
506
|
+
session,
|
|
507
|
+
sel,
|
|
508
|
+
converted,
|
|
509
|
+
t,
|
|
510
|
+
iframe,
|
|
511
|
+
applyCommitOverrides,
|
|
512
|
+
flatLive,
|
|
513
|
+
);
|
|
531
514
|
}
|
|
532
515
|
} else {
|
|
533
516
|
const position = readElementPosition(iframe, sel, null);
|
|
@@ -8,6 +8,7 @@ import { simplifyGestureSamples } from "../utils/rdpSimplify";
|
|
|
8
8
|
import { fitEasesFromVelocity } from "../utils/velocityEaseFitter";
|
|
9
9
|
import { smoothGestureKeyframes } from "../utils/gestureSmoother";
|
|
10
10
|
import { usePlayerStore } from "../player";
|
|
11
|
+
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
11
12
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
12
13
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
13
14
|
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
|
@@ -258,7 +259,7 @@ export function useGestureCommit({
|
|
|
258
259
|
);
|
|
259
260
|
} else {
|
|
260
261
|
const tweenStart = existingPositionTween.resolvedStart ?? 0;
|
|
261
|
-
const tweenDur = existingPositionTween
|
|
262
|
+
const tweenDur = resolveTweenDuration(existingPositionTween);
|
|
262
263
|
const tweenEnd = tweenStart + tweenDur;
|
|
263
264
|
const recEnd = recStart + duration;
|
|
264
265
|
|
|
@@ -127,6 +127,31 @@ describe("a group drag plans every member before its first write", () => {
|
|
|
127
127
|
act(() => h.root.unmount());
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
it("with auto-keyframe off, refuses the whole group when one member's step list holds a step delay", async () => {
|
|
131
|
+
usePlayerStore.setState({ autoKeyframeEnabled: false });
|
|
132
|
+
const steps = {
|
|
133
|
+
format: "object-array",
|
|
134
|
+
keyframes: [
|
|
135
|
+
{ percentage: 50, properties: { x: 100, y: 0, delay: 0.5 } },
|
|
136
|
+
{ percentage: 100, properties: { x: 200, y: 0 } },
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
const h = mountGroup([
|
|
140
|
+
positionTween("a"),
|
|
141
|
+
positionTween("b", { keyframes: steps } as Partial<GsapAnimation>),
|
|
142
|
+
positionTween("c"),
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
await expect(h.groupCommit(h.updates)).rejects.toMatchObject({
|
|
146
|
+
reason: "keyframes-uneditable",
|
|
147
|
+
detail: "array-step-delay",
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(h.written()).toEqual([]);
|
|
151
|
+
expect(trackStudioEditBlocked).toHaveBeenCalledWith(expect.objectContaining({ targetId: "b" }));
|
|
152
|
+
act(() => h.root.unmount());
|
|
153
|
+
});
|
|
154
|
+
|
|
130
155
|
it("refuses members animated in two files, since the batch writes one file", async () => {
|
|
131
156
|
const h = mountGroup(["a", "b", "c"].map((id) => positionTween(id)));
|
|
132
157
|
const updates = h.updates.map((u, i) => ({
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
publishKeyframeCache,
|
|
12
12
|
writeGsapAnimationsForElement,
|
|
13
13
|
} from "./gsapKeyframeCacheHelpers";
|
|
14
|
-
import { resolveClipTimingBasis,
|
|
14
|
+
import { resolveClipTimingBasis, toClipKeyframes } from "./gsapShared";
|
|
15
15
|
import {
|
|
16
16
|
deduplicateKeyframes,
|
|
17
17
|
isStaticPositionHold,
|
|
@@ -199,22 +199,7 @@ export function useGsapAnimationsForElement(
|
|
|
199
199
|
if (isStaticPositionHold(anim)) continue;
|
|
200
200
|
const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
|
|
201
201
|
if (!kf) continue;
|
|
202
|
-
|
|
203
|
-
// render at the correct position within the timeline clip.
|
|
204
|
-
const tweenPos =
|
|
205
|
-
anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
|
|
206
|
-
const tweenDur = anim.duration ?? elDuration;
|
|
207
|
-
for (const k of kf.keyframes) {
|
|
208
|
-
const absTime = toAbsoluteTime(tweenPos, tweenDur, k.percentage);
|
|
209
|
-
const clipPct = toClipPercentage(absTime, elStart, elDuration, k.percentage);
|
|
210
|
-
allKeyframes.push({
|
|
211
|
-
...k,
|
|
212
|
-
percentage: clipPct,
|
|
213
|
-
tweenPercentage: k.percentage,
|
|
214
|
-
propertyGroup: anim.propertyGroup,
|
|
215
|
-
animationId: anim.id,
|
|
216
|
-
});
|
|
217
|
-
}
|
|
202
|
+
allKeyframes.push(...toClipKeyframes(kf.keyframes, anim, elStart, elDuration));
|
|
218
203
|
format = kf.format;
|
|
219
204
|
if (kf.ease) ease = kf.ease;
|
|
220
205
|
if (kf.easeEach) easeEach = kf.easeEach;
|
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
type StudioApiAdapter,
|
|
15
15
|
} from "@hyperframes/studio-server";
|
|
16
16
|
import { consumeStudioWriteToken } from "../utils/studioFileVersion";
|
|
17
|
+
import {
|
|
18
|
+
beginStudioPendingEdit,
|
|
19
|
+
paintBackNewestStudioPendingEdit,
|
|
20
|
+
} from "../utils/studioPendingEdits";
|
|
17
21
|
import { usePersistentEditHistory } from "./usePersistentEditHistory";
|
|
18
22
|
|
|
19
23
|
const cleanup: Array<() => unknown> = [];
|
|
@@ -88,6 +92,22 @@ it("an edit Studio saved is undone and redone by the project's history, with the
|
|
|
88
92
|
expect(file()).toBe("B");
|
|
89
93
|
});
|
|
90
94
|
|
|
95
|
+
it("gives an edit that begins the history's claim count, so undo can tell its claims from older ones", async () => {
|
|
96
|
+
const { hook, save } = await studio();
|
|
97
|
+
save("B");
|
|
98
|
+
await act(() =>
|
|
99
|
+
hook().recordEdit({
|
|
100
|
+
label: "Moved Title",
|
|
101
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
const saving = beginStudioPendingEdit(() => () => {});
|
|
105
|
+
|
|
106
|
+
expect(paintBackNewestStudioPendingEdit()?.claimsAtBegin).toBe(hook().claims());
|
|
107
|
+
expect(hook().claims()).toBe(1);
|
|
108
|
+
saving.settle();
|
|
109
|
+
});
|
|
110
|
+
|
|
91
111
|
it("undoes the edit claimed since the key, not a later edit the server took in first", async () => {
|
|
92
112
|
const { dir, hook, file, save, readFile } = await studio();
|
|
93
113
|
const atKey = hook().claims();
|
|
@@ -366,6 +386,31 @@ it("a drag's edits under one key undo as one step, even before the drag goes idl
|
|
|
366
386
|
expect(file()).toBe("A");
|
|
367
387
|
});
|
|
368
388
|
|
|
389
|
+
it("a key one page holds does not join another page's edit under the same key", async () => {
|
|
390
|
+
const { hook, file, save, readFile } = await studio();
|
|
391
|
+
let reloaded!: ReturnType<typeof usePersistentEditHistory>;
|
|
392
|
+
function Reloaded() {
|
|
393
|
+
reloaded = usePersistentEditHistory({ projectId: "demo" });
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
const root = createRoot(document.createElement("div"));
|
|
397
|
+
await act(async () => root.render(createElement(Reloaded)));
|
|
398
|
+
cleanup.push(() => act(() => root.unmount()));
|
|
399
|
+
const held = { label: "Moved Title", coalesceKey: "timeline-move:1", coalesceMs: Infinity };
|
|
400
|
+
save("B");
|
|
401
|
+
await act(() =>
|
|
402
|
+
hook().recordEdit({ ...held, files: { "index.html": { before: "A", after: "B" } } }),
|
|
403
|
+
);
|
|
404
|
+
save("C");
|
|
405
|
+
await act(() =>
|
|
406
|
+
reloaded.recordEdit({ ...held, files: { "index.html": { before: "B", after: "C" } } }),
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
await act(() => reloaded.undo({ readFile }));
|
|
410
|
+
|
|
411
|
+
expect(file()).toBe("B");
|
|
412
|
+
});
|
|
413
|
+
|
|
369
414
|
it("an undo before the history view shows a drag's held claim still waits on the files it wrote", async () => {
|
|
370
415
|
const { hook, save, readFile } = await studio();
|
|
371
416
|
const server = globalThis.fetch;
|
|
@@ -3,7 +3,9 @@ import type { HistoryListItem, HistoryResult } from "@hyperframes/studio-server"
|
|
|
3
3
|
import { studioFileContentVersion, studioWriteHeaders } from "../utils/studioFileVersion";
|
|
4
4
|
import type { RestoreFiles } from "../utils/gsapUndoRestore";
|
|
5
5
|
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
6
|
+
import { setStudioPendingEditClaimClock } from "../utils/studioPendingEdits";
|
|
6
7
|
import type { RecordEditInput } from "../utils/studioFileHistory";
|
|
8
|
+
import { generateId } from "../utils/generateId";
|
|
7
9
|
|
|
8
10
|
interface ApplyCallbacks {
|
|
9
11
|
readFile: (path: string) => Promise<string>;
|
|
@@ -206,6 +208,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
206
208
|
const projectIdRef = useRef(projectId);
|
|
207
209
|
projectIdRef.current = projectId;
|
|
208
210
|
const [own] = useState(createOwnHistory);
|
|
211
|
+
const [pageKeyScope] = useState(generateId);
|
|
209
212
|
|
|
210
213
|
const refresh = useCallback(async () => {
|
|
211
214
|
if (!projectId) return;
|
|
@@ -228,6 +231,11 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
228
231
|
void refresh().finally(() => setLoaded(true));
|
|
229
232
|
}, [refresh, own]);
|
|
230
233
|
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
setStudioPendingEditClaimClock(own.claimCount);
|
|
236
|
+
return () => setStudioPendingEditClaimClock(null);
|
|
237
|
+
}, [own]);
|
|
238
|
+
|
|
231
239
|
const recordEdit = useCallback(
|
|
232
240
|
async ({ label, coalesceKey, coalesceMs, files, created = [] }: RecordEditInput) => {
|
|
233
241
|
if (!projectId) return;
|
|
@@ -237,7 +245,10 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
237
245
|
label,
|
|
238
246
|
paths,
|
|
239
247
|
overwrote: await overwroteVersions(files),
|
|
240
|
-
...(coalesceKey && {
|
|
248
|
+
...(coalesceKey && {
|
|
249
|
+
coalesceKey: `${pageKeyScope}:${coalesceKey}`,
|
|
250
|
+
idleMs: coalesceMs ?? DEFAULT_COALESCE_MS,
|
|
251
|
+
}),
|
|
241
252
|
});
|
|
242
253
|
const claimed = claimHeld(reply, label);
|
|
243
254
|
if (claimed) {
|
|
@@ -247,7 +258,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
|
|
|
247
258
|
heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null;
|
|
248
259
|
await refresh();
|
|
249
260
|
},
|
|
250
|
-
[projectId, refresh, own],
|
|
261
|
+
[projectId, refresh, own, pageKeyScope],
|
|
251
262
|
);
|
|
252
263
|
|
|
253
264
|
const step = useCallback(
|
|
@@ -4,6 +4,10 @@ import React, { act } from "react";
|
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
6
6
|
import type { TimelineElement } from "../player/store/timelineElement";
|
|
7
|
+
import {
|
|
8
|
+
beginStudioManualEditGesture,
|
|
9
|
+
endStudioManualEditGesture,
|
|
10
|
+
} from "../components/editor/manualEdits";
|
|
7
11
|
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
8
12
|
import { usePreviewPersistence } from "./usePreviewPersistence";
|
|
9
13
|
|
|
@@ -132,3 +136,82 @@ describe("undo that re-runs the top-level script over an element of a nested com
|
|
|
132
136
|
expect(nwid.getAttribute("style")).toBe("left: 40px; width: 337px");
|
|
133
137
|
});
|
|
134
138
|
});
|
|
139
|
+
|
|
140
|
+
describe("undo that lands while a newer gesture holds an element", () => {
|
|
141
|
+
const page = (left: number) =>
|
|
142
|
+
`<html><body><div id="root" data-composition-id="root"><div id="box" data-hf-id="hf-b" style="left: ${left}px"></div></div></body></html>`;
|
|
143
|
+
const files = { "index.html": { previous: page(0), restored: page(200) } };
|
|
144
|
+
|
|
145
|
+
function heldPreview(held = true) {
|
|
146
|
+
const doc = document.implementation.createHTMLDocument("");
|
|
147
|
+
doc.head.innerHTML = `<meta name="hf-scene-parts" content='{"shared":"s1","scenes":{}}'>`;
|
|
148
|
+
doc.body.innerHTML = `<div id="root" data-composition-id="root"><div id="box" data-hf-id="hf-b" style="left: 90px"></div></div>`;
|
|
149
|
+
const box = doc.getElementById("box")!;
|
|
150
|
+
if (held) beginStudioManualEditGesture(box, "move");
|
|
151
|
+
const contentWindow = {
|
|
152
|
+
__hfForceTimelineRebind: vi.fn(),
|
|
153
|
+
__timelines: {},
|
|
154
|
+
__player: { getTime: () => 1, seek: vi.fn() },
|
|
155
|
+
__hfStudioManualEditsApply: vi.fn(),
|
|
156
|
+
};
|
|
157
|
+
const iframe = { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement;
|
|
158
|
+
const reloadPreview = vi.fn();
|
|
159
|
+
let hook: ReturnType<typeof usePreviewPersistence> | null = null;
|
|
160
|
+
function Harness() {
|
|
161
|
+
hook = usePreviewPersistence({
|
|
162
|
+
showToast: () => {},
|
|
163
|
+
readOptionalProjectFile: async () => "",
|
|
164
|
+
writeProjectFile: async () => {},
|
|
165
|
+
recordEdit: async () => {},
|
|
166
|
+
previewIframeRef: { current: iframe },
|
|
167
|
+
activeCompPathRef: { current: "index.html" },
|
|
168
|
+
reloadPreview,
|
|
169
|
+
});
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
mountReactHarness(<Harness />);
|
|
173
|
+
usePlayerStore.getState().setSelectedElementId("box");
|
|
174
|
+
const sharedPart = () =>
|
|
175
|
+
JSON.parse(doc.querySelector<HTMLMetaElement>('meta[name="hf-scene-parts"]')!.content).shared;
|
|
176
|
+
return { box, win: contentWindow, reloadPreview, sharedPart, hook: () => hook! };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
it("leaves the held element where the gesture drew it and reloads the preview once it ends", async () => {
|
|
180
|
+
const { box, win, reloadPreview, sharedPart, hook } = heldPreview();
|
|
181
|
+
|
|
182
|
+
await act(async () => hook().syncHistoryPreviewAfterApply({ paths: ["index.html"], files }));
|
|
183
|
+
|
|
184
|
+
expect(box.style.left).toBe("90px");
|
|
185
|
+
expect(win.__player.seek).not.toHaveBeenCalled();
|
|
186
|
+
expect(win.__hfStudioManualEditsApply).not.toHaveBeenCalled();
|
|
187
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
188
|
+
endStudioManualEditGesture(box);
|
|
189
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
190
|
+
// The scene swap must load the restored file again, not keep the part it showed before the undo.
|
|
191
|
+
expect(sharedPart()).toBe("");
|
|
192
|
+
expect(usePlayerStore.getState().selectedElementId).toBe("box");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("does not paint a predicted undo under the gesture", () => {
|
|
196
|
+
const { box, win, hook } = heldPreview();
|
|
197
|
+
|
|
198
|
+
expect(hook().showHistoryRestoreNow(files)).toBeNull();
|
|
199
|
+
expect(box.style.left).toBe("90px");
|
|
200
|
+
expect(win.__player.seek).not.toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("puts a refused predicted undo back through the preview reload once a gesture took the element", async () => {
|
|
204
|
+
const { box, win, reloadPreview, hook } = heldPreview(false);
|
|
205
|
+
const putBack = hook().showHistoryRestoreNow(files)!;
|
|
206
|
+
beginStudioManualEditGesture(box, "move");
|
|
207
|
+
win.__player.seek.mockClear();
|
|
208
|
+
|
|
209
|
+
putBack();
|
|
210
|
+
await act(async () => {});
|
|
211
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
212
|
+
endStudioManualEditGesture(box);
|
|
213
|
+
|
|
214
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
215
|
+
expect(win.__player.seek).not.toHaveBeenCalled();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -4,7 +4,12 @@ import {
|
|
|
4
4
|
installStudioManualEditSeekReapply,
|
|
5
5
|
reapplyPositionEditsAfterSeek,
|
|
6
6
|
} from "../components/editor/manualEdits";
|
|
7
|
+
import {
|
|
8
|
+
afterStudioManualEditGestures,
|
|
9
|
+
isStudioManualEditGestureLiveIn,
|
|
10
|
+
} from "../components/editor/manualEditsDom";
|
|
7
11
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
12
|
+
import { markScenesStale } from "../player/sceneSwap";
|
|
8
13
|
import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue";
|
|
9
14
|
import {
|
|
10
15
|
flushStudioPendingEdits,
|
|
@@ -199,6 +204,12 @@ export function usePreviewPersistence({
|
|
|
199
204
|
|
|
200
205
|
// ── Sync preview after undo/redo ──
|
|
201
206
|
|
|
207
|
+
// Undo never repaints under a live gesture; the preview reloads once the last one ends.
|
|
208
|
+
const heldPreviewDoc = useCallback(() => {
|
|
209
|
+
const doc = previewIframeRef.current?.contentDocument;
|
|
210
|
+
return doc && isStudioManualEditGestureLiveIn(doc) ? doc : null;
|
|
211
|
+
}, [previewIframeRef]);
|
|
212
|
+
|
|
202
213
|
const syncHistoryPreviewAfterApply = useCallback(
|
|
203
214
|
async (restore: HistoryPreviewRestore) => {
|
|
204
215
|
// Prefer an in-place soft reload for a soft-reloadable restore (the change
|
|
@@ -220,6 +231,15 @@ export function usePreviewPersistence({
|
|
|
220
231
|
: Promise.reject(new Error("No project is open to read nested files from.")),
|
|
221
232
|
),
|
|
222
233
|
);
|
|
234
|
+
const held = heldPreviewDoc();
|
|
235
|
+
if (held) {
|
|
236
|
+
const paths = restore.paths ?? Object.keys(restore.files ?? {});
|
|
237
|
+
afterStudioManualEditGestures(held, () => {
|
|
238
|
+
markScenesStale(previewIframeRef.current, paths);
|
|
239
|
+
reloadPreview();
|
|
240
|
+
});
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
223
243
|
const strategy = applyUndoRestoreToPreview(
|
|
224
244
|
previewIframeRef.current,
|
|
225
245
|
activeCompPathRef.current,
|
|
@@ -238,24 +258,26 @@ export function usePreviewPersistence({
|
|
|
238
258
|
// reload. The full path above waits for the reloaded preview to report instead.
|
|
239
259
|
syncStoredAutomationFromPreview(previewIframeRef.current?.contentDocument ?? null);
|
|
240
260
|
},
|
|
241
|
-
[previewIframeRef, activeCompPathRef, reloadPreview],
|
|
261
|
+
[previewIframeRef, activeCompPathRef, reloadPreview, heldPreviewDoc],
|
|
242
262
|
);
|
|
243
263
|
|
|
244
264
|
// A restore the server has not confirmed yet: in place now, or not at all. A GSAP script re-run is not
|
|
245
265
|
// synchronous, and a pending save would land under it.
|
|
246
266
|
const showHistoryRestoreNow = useCallback(
|
|
247
267
|
(files: RestoreFiles): (() => void) | null => {
|
|
248
|
-
if (!domEditSaveQueueRef.current?.isIdle() || hasStudioPendingEdits()
|
|
268
|
+
if (!domEditSaveQueueRef.current?.isIdle() || hasStudioPendingEdits() || heldPreviewDoc())
|
|
269
|
+
return null;
|
|
249
270
|
const iframe = previewIframeRef.current;
|
|
250
271
|
const now = () => usePlayerStore.getState().currentTime;
|
|
251
272
|
const putBack = showRestoreInPlace(iframe, activeCompPathRef.current, files, now());
|
|
252
273
|
if (!putBack) return null;
|
|
253
274
|
return () => {
|
|
254
|
-
if (putBack(now()))
|
|
275
|
+
if (!heldPreviewDoc() && putBack(now()))
|
|
276
|
+
syncStoredAutomationFromPreview(iframe?.contentDocument ?? null);
|
|
255
277
|
else void syncHistoryPreviewAfterApply({ paths: Object.keys(files) });
|
|
256
278
|
};
|
|
257
279
|
},
|
|
258
|
-
[previewIframeRef, activeCompPathRef, syncHistoryPreviewAfterApply],
|
|
280
|
+
[previewIframeRef, activeCompPathRef, syncHistoryPreviewAfterApply, heldPreviewDoc],
|
|
259
281
|
);
|
|
260
282
|
|
|
261
283
|
// ── Migrate legacy studio-motion.json ──
|