@hyperframes/studio 0.8.138 → 0.8.139
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-B30apksz.js → hyperframes-player-CckOoCJ8.js} +1 -1
- package/dist/assets/{index-DIJuIBwI.js → index-BmIPrb8I.js} +1 -1
- package/dist/assets/{index-BdjA0CLD.js → index-Br1Whc0f.js} +192 -192
- package/dist/assets/{index-DXVvWfQG.js → index-DsEkE5_G.js} +1 -1
- package/dist/{chunk-4S3AYCOG.js → chunk-E422JDF5.js} +2 -2
- package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
- package/dist/chunk-TFLU2MGI.js.map +1 -0
- package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
- package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.html +1 -1
- package/dist/index.js +846 -469
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/App.tsx +9 -4
- package/src/components/editor/DomEditOverlay.tsx +9 -5
- package/src/components/editor/PropertyPanel.test.tsx +600 -748
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
- package/src/components/editor/domEditOverlayGeometry.ts +7 -7
- package/src/components/editor/domEditOverlayGestures.ts +18 -0
- package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
- package/src/components/editor/dragUndoPaint.test.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
- package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +7 -7
- package/src/components/editor/manualEditsTypes.ts +5 -0
- package/src/components/editor/manualOffsetDrag.test.ts +23 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -0
- package/src/components/editor/previewGestureStarts.ts +284 -0
- package/src/components/editor/rotateInlineSave.test.ts +1 -0
- package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
- package/src/components/nle/NLEContext.test.ts +26 -0
- package/src/components/nle/NLEContext.tsx +8 -3
- package/src/hooks/draggedGsapPosition.ts +8 -0
- package/src/hooks/editMoment.ts +13 -0
- package/src/hooks/gsapDragCommit.ts +2 -1
- package/src/hooks/gsapDragPositionCommit.ts +19 -7
- package/src/hooks/gsapPositionDetection.ts +5 -3
- package/src/hooks/gsapResizeIntercept.ts +11 -4
- package/src/hooks/gsapRuntimeBridge.ts +31 -11
- package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
- package/src/hooks/gsapValueAtPlayhead.ts +12 -5
- package/src/hooks/timelineEditingHelpers.test.ts +22 -0
- package/src/hooks/timelineEditingHelpers.ts +18 -2
- package/src/hooks/useDomSelection.test.ts +21 -2
- package/src/hooks/useEditHistoryActions.test.tsx +20 -0
- package/src/hooks/useEditHistoryActions.ts +2 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
- package/src/hooks/useGsapAwareEditing.ts +148 -136
- package/src/hooks/useGsapAwareGroupMove.ts +126 -113
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
- package/src/hooks/usePreviewPersistence.ts +1 -4
- package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
- package/src/hooks/useTimelineDeleteOps.ts +9 -10
- package/src/index.ts +1 -0
- package/src/player/hooks/useShadowPreviewReload.ts +6 -6
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
- package/src/player/hooks/useTimelinePlayer.ts +8 -5
- package/src/player/lib/thumbnailScheduler.test.ts +20 -1
- package/src/player/lib/thumbnailScheduler.ts +1 -1
- package/src/player/lib/timelineElementHelpers.ts +0 -11
- package/src/player/previewReloading.test.ts +48 -0
- package/src/player/previewReloading.ts +48 -0
- package/src/utils/rootDuration.ts +5 -4
- package/src/utils/studioPendingEdits.test.ts +27 -0
- package/src/utils/studioPendingEdits.ts +9 -0
- package/src/utils/timelineAssetDrop.test.ts +13 -4
- package/src/utils/timelineAssetDrop.ts +6 -2
- package/dist/chunk-TDWGXBUT.js.map +0 -1
- /package/dist/{chunk-4S3AYCOG.js.map → chunk-E422JDF5.js.map} +0 -0
- /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
- /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
2
|
+
import type { EditMoment } from "../components/editor/manualEditsTypes";
|
|
3
|
+
import type { DragStamp } from "./draggedGsapPosition";
|
|
4
|
+
|
|
5
|
+
export function playheadMoment(): EditMoment {
|
|
6
|
+
const { currentTime, activeKeyframePct } = usePlayerStore.getState();
|
|
7
|
+
return { time: currentTime, keyframePct: activeKeyframePct };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The playhead an edit writes at: its gesture's press, else the playhead now. */
|
|
11
|
+
export function editMoment(stamp?: DragStamp): EditMoment {
|
|
12
|
+
return stamp?.at ?? playheadMoment();
|
|
13
|
+
}
|
|
@@ -56,8 +56,9 @@ function newTweenTarget(selection: DomEditSelection): string | null {
|
|
|
56
56
|
export function computeCurrentPercentage(
|
|
57
57
|
selection: DomEditSelection,
|
|
58
58
|
animation?: GsapAnimation,
|
|
59
|
+
time = usePlayerStore.getState().currentTime,
|
|
59
60
|
): number {
|
|
60
|
-
return computeElementPercentage(
|
|
61
|
+
return computeElementPercentage(time, selection, animation);
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
// When a drag edits a SELECTED keyframe, park the playhead on that keyframe's exact
|
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
} from "./gsapDragCommit";
|
|
22
22
|
import type { GsapEditOutcome } from "./gsapEditOutcome";
|
|
23
23
|
import { commitValueAtPlayhead, planValueEdit } from "./gsapValueAtPlayhead";
|
|
24
|
+
import { editMoment } from "./editMoment";
|
|
25
|
+
import type { EditMoment } from "../components/editor/manualEditsTypes";
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* The tween's keyframes with one inserted at `percentage`. Any existing keyframe
|
|
@@ -95,6 +97,7 @@ export function gsapPositionFromDragOutcome(
|
|
|
95
97
|
studioOffset: { x: number; y: number },
|
|
96
98
|
gsapPos: { x: number; y: number },
|
|
97
99
|
iframe: HTMLIFrameElement | null,
|
|
100
|
+
moment?: EditMoment,
|
|
98
101
|
): GsapEditOutcome {
|
|
99
102
|
if (anim.arcPath?.enabled) return { status: "persisted" };
|
|
100
103
|
const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
|
|
@@ -104,6 +107,7 @@ export function gsapPositionFromDragOutcome(
|
|
|
104
107
|
);
|
|
105
108
|
const plan = planValueEdit(selection, anim, { x: newX, y: newY }, iframe, {
|
|
106
109
|
backfill: { x: baseGsapX, y: baseGsapY },
|
|
110
|
+
moment,
|
|
107
111
|
});
|
|
108
112
|
return plan.ok
|
|
109
113
|
? { status: "persisted" }
|
|
@@ -132,7 +136,8 @@ export async function commitGsapPositionFromDrag(
|
|
|
132
136
|
const restoreOffset = () => restoreDragOffset(el, stamp);
|
|
133
137
|
|
|
134
138
|
if (anim.arcPath?.enabled) {
|
|
135
|
-
const { activeKeyframePct, currentTime
|
|
139
|
+
const { keyframePct: activeKeyframePct, time: currentTime } = editMoment(stamp);
|
|
140
|
+
const { setActiveKeyframePct } = usePlayerStore.getState();
|
|
136
141
|
const tweenStart = resolveTweenStart(anim);
|
|
137
142
|
const tweenDuration = resolveTweenDuration(anim);
|
|
138
143
|
if (
|
|
@@ -165,7 +170,7 @@ export async function commitGsapPositionFromDrag(
|
|
|
165
170
|
);
|
|
166
171
|
return { status: "persisted" };
|
|
167
172
|
}
|
|
168
|
-
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
|
173
|
+
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim, currentTime);
|
|
169
174
|
const keyframes = anim.keyframes?.keyframes ?? [];
|
|
170
175
|
// A drag counts as on a waypoint when it plays within KEYFRAME_PCT_MATCH of it,
|
|
171
176
|
// so landing a fraction of a percent off an authored waypoint updates that point
|
|
@@ -212,9 +217,16 @@ export async function commitGsapPositionFromDrag(
|
|
|
212
217
|
);
|
|
213
218
|
return { status: "persisted" };
|
|
214
219
|
}
|
|
215
|
-
return commitValueAtPlayhead(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
+
return commitValueAtPlayhead(
|
|
221
|
+
selection,
|
|
222
|
+
anim,
|
|
223
|
+
{ x: newX, y: newY },
|
|
224
|
+
iframe,
|
|
225
|
+
{ ...callbacks, stamp },
|
|
226
|
+
{
|
|
227
|
+
label: "Move layer",
|
|
228
|
+
backfill: { x: baseGsapX, y: baseGsapY },
|
|
229
|
+
beforeReload: restoreOffset,
|
|
230
|
+
},
|
|
231
|
+
);
|
|
220
232
|
}
|
|
@@ -44,9 +44,9 @@ function animHasPosition(anim: GsapAnimation): boolean {
|
|
|
44
44
|
export function findGsapPositionAnimation(
|
|
45
45
|
animations: GsapAnimation[],
|
|
46
46
|
selector?: string,
|
|
47
|
+
currentTime = usePlayerStore.getState().currentTime,
|
|
47
48
|
): GsapAnimation | null {
|
|
48
49
|
if (animations.length === 0) return null;
|
|
49
|
-
const currentTime = usePlayerStore.getState().currentTime;
|
|
50
50
|
|
|
51
51
|
const scored = animations.filter(animHasPosition).map((a) => {
|
|
52
52
|
let score = 0;
|
|
@@ -87,9 +87,11 @@ function statesValueAt(anim: GsapAnimation, time: number): boolean {
|
|
|
87
87
|
* playhead (the later one, which renders, if both do), then most keyframes.
|
|
88
88
|
*/
|
|
89
89
|
// fallow-ignore-next-line complexity
|
|
90
|
-
export function pickClosestToPlayhead(
|
|
90
|
+
export function pickClosestToPlayhead(
|
|
91
|
+
anims: GsapAnimation[],
|
|
92
|
+
ct = usePlayerStore.getState().currentTime,
|
|
93
|
+
): GsapAnimation | null {
|
|
91
94
|
if (anims.length <= 1) return anims[0] ?? null;
|
|
92
|
-
const ct = usePlayerStore.getState().currentTime;
|
|
93
95
|
return anims.reduce((best, a) => {
|
|
94
96
|
const dist = playheadDistance(a, ct);
|
|
95
97
|
const bestDist = playheadDistance(best, ct);
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type DragStamp,
|
|
34
34
|
} from "./draggedGsapPosition";
|
|
35
35
|
import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
|
|
36
|
+
import { editMoment } from "./editMoment";
|
|
36
37
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
37
38
|
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
|
|
38
39
|
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
@@ -141,8 +142,9 @@ function firstSizeKey(
|
|
|
141
142
|
size: { width: number; height: number },
|
|
142
143
|
resizeGroup: PropertyGroupName,
|
|
143
144
|
animations: GsapAnimation[],
|
|
145
|
+
currentTime: number,
|
|
144
146
|
): Record<string, unknown> | null {
|
|
145
|
-
const { autoKeyframeEnabled
|
|
147
|
+
const { autoKeyframeEnabled } = usePlayerStore.getState();
|
|
146
148
|
const keyframed = animations.some(
|
|
147
149
|
(a) => a.keyframes && animationWritesAnyProperty(a, MOVED_OR_SIZED),
|
|
148
150
|
);
|
|
@@ -180,6 +182,7 @@ export async function tryGsapResizeIntercept(
|
|
|
180
182
|
draw: <T>(run: () => T) => T = (run) => run(),
|
|
181
183
|
stamp?: DragStamp,
|
|
182
184
|
): Promise<GsapEditOutcome> {
|
|
185
|
+
const moment = editMoment(stamp);
|
|
183
186
|
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
|
|
184
187
|
const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations);
|
|
185
188
|
if (outcome.status === "blocked") return outcome;
|
|
@@ -199,6 +202,7 @@ export async function tryGsapResizeIntercept(
|
|
|
199
202
|
selection,
|
|
200
203
|
commitMutation,
|
|
201
204
|
postSplitFetch,
|
|
205
|
+
moment.time,
|
|
202
206
|
);
|
|
203
207
|
|
|
204
208
|
const anim =
|
|
@@ -218,7 +222,7 @@ export async function tryGsapResizeIntercept(
|
|
|
218
222
|
animationWritesAnyProperty(a, SIZE_PROPS),
|
|
219
223
|
);
|
|
220
224
|
if (!scriptWritesSize) {
|
|
221
|
-
const firstKey = firstSizeKey(selection, sized, resizeGroup, workingAnimations);
|
|
225
|
+
const firstKey = firstSizeKey(selection, sized, resizeGroup, workingAnimations, moment.time);
|
|
222
226
|
if (!firstKey) return { status: "element-size" };
|
|
223
227
|
await commitMutation(selection, firstKey, { label: "Resize", softReload: true });
|
|
224
228
|
return handOverDraftSize(selection, { status: "persisted" }, sized, draw);
|
|
@@ -249,6 +253,7 @@ export async function tryGsapResizeIntercept(
|
|
|
249
253
|
resolveTweenDuration(a) > 0 &&
|
|
250
254
|
animationWritesAnyProperty(a, resizeProperties),
|
|
251
255
|
),
|
|
256
|
+
moment.time,
|
|
252
257
|
);
|
|
253
258
|
if (animatedTween) {
|
|
254
259
|
logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
|
|
@@ -284,8 +289,9 @@ export async function tryGsapResizeIntercept(
|
|
|
284
289
|
return { status: "blocked", reason: "source-uneditable", detail: "zero-duration-tween" };
|
|
285
290
|
}
|
|
286
291
|
|
|
287
|
-
const {
|
|
288
|
-
const
|
|
292
|
+
const { setActiveKeyframePct } = usePlayerStore.getState();
|
|
293
|
+
const activeKeyframePct = moment.keyframePct;
|
|
294
|
+
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim, moment.time);
|
|
289
295
|
const selector = selectorFromSelection(selection);
|
|
290
296
|
|
|
291
297
|
let resizeProps: Record<string, number>;
|
|
@@ -473,6 +479,7 @@ export async function tryGsapResizeIntercept(
|
|
|
473
479
|
currentAnimations.filter(
|
|
474
480
|
(a) => a.propertyGroup === "position" && !isInstantHold(a) && resolveTweenDuration(a) > 0,
|
|
475
481
|
),
|
|
482
|
+
moment.time,
|
|
476
483
|
);
|
|
477
484
|
if (positionTween) {
|
|
478
485
|
logResize("scale-finalize", { route: "position-keyframe", tweenId: positionTween.id });
|
|
@@ -31,7 +31,9 @@ import { tweenReach, tweensForThisElement } from "./gsapTweenReach";
|
|
|
31
31
|
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
32
32
|
import { roundTo3 } from "../utils/rounding";
|
|
33
33
|
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
34
|
-
import type
|
|
34
|
+
import { readDragStamp, type DragStamp } from "./draggedGsapPosition";
|
|
35
|
+
import { editMoment } from "./editMoment";
|
|
36
|
+
import type { EditMoment } from "../components/editor/manualEditsTypes";
|
|
35
37
|
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
|
36
38
|
import {
|
|
37
39
|
findGsapPositionAnimation,
|
|
@@ -66,12 +68,13 @@ export async function resolveGroupTween(
|
|
|
66
68
|
_selection: DomEditSelection,
|
|
67
69
|
_commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
68
70
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
71
|
+
time?: number,
|
|
69
72
|
): Promise<{ anim: GsapAnimation; animations: GsapAnimation[] } | null> {
|
|
70
73
|
const inGroup = (list: GsapAnimation[]) => {
|
|
71
74
|
const tagged = list.filter((a) => a.propertyGroup === group);
|
|
72
75
|
const props = new Set(PROPERTY_GROUPS[group]);
|
|
73
76
|
const mixed = list.filter((a) => !a.propertyGroup && animationWritesAnyProperty(a, props));
|
|
74
|
-
return pickClosestToPlayhead(tagged.length > 0 ? tagged : mixed);
|
|
77
|
+
return pickClosestToPlayhead(tagged.length > 0 ? tagged : mixed, time);
|
|
75
78
|
};
|
|
76
79
|
const anim = inGroup(animations);
|
|
77
80
|
if (anim) return { anim, animations };
|
|
@@ -205,6 +208,7 @@ async function planDrag(
|
|
|
205
208
|
allAnimations: GsapAnimation[],
|
|
206
209
|
iframe: HTMLIFrameElement | null,
|
|
207
210
|
options: { altKey?: boolean },
|
|
211
|
+
moment: EditMoment,
|
|
208
212
|
): Promise<GsapEditOutcome> {
|
|
209
213
|
const selector = selectorFromSelection(selection);
|
|
210
214
|
if (!selector) return { status: "blocked", reason: "no-selector" };
|
|
@@ -213,8 +217,15 @@ async function planDrag(
|
|
|
213
217
|
const animations = keeper
|
|
214
218
|
? own.filter((a) => a === keeper || !isPositionWriteOf(selector)(a))
|
|
215
219
|
: own;
|
|
216
|
-
const resolved = await resolveGroupTween(
|
|
217
|
-
|
|
220
|
+
const resolved = await resolveGroupTween(
|
|
221
|
+
"position",
|
|
222
|
+
animations,
|
|
223
|
+
selection,
|
|
224
|
+
async () => {},
|
|
225
|
+
undefined,
|
|
226
|
+
moment.time,
|
|
227
|
+
);
|
|
228
|
+
const posAnim = resolved?.anim ?? findGsapPositionAnimation(animations, selector, moment.time);
|
|
218
229
|
const route = dragRoute(posAnim, iframe, selector, options.altKey);
|
|
219
230
|
if (route === "static") return { status: "persisted" };
|
|
220
231
|
if (!posAnim) {
|
|
@@ -227,7 +238,7 @@ async function planDrag(
|
|
|
227
238
|
: { status: "persisted" };
|
|
228
239
|
}
|
|
229
240
|
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
|
|
230
|
-
return gsapPositionFromDragOutcome(selection, posAnim, offset, gsapPos, iframe);
|
|
241
|
+
return gsapPositionFromDragOutcome(selection, posAnim, offset, gsapPos, iframe, moment);
|
|
231
242
|
}
|
|
232
243
|
|
|
233
244
|
/** Commits a drag through the GSAP script. Callers reject `blocked` (the gesture layer
|
|
@@ -247,6 +258,9 @@ export async function tryGsapDragIntercept(
|
|
|
247
258
|
stamp?: DragStamp;
|
|
248
259
|
},
|
|
249
260
|
): Promise<GsapEditOutcome> {
|
|
261
|
+
const stamp = options?.stamp ?? readDragStamp(selection.element);
|
|
262
|
+
const moment = editMoment(stamp);
|
|
263
|
+
const time = moment.time;
|
|
250
264
|
if (!options?.preflightPassed) {
|
|
251
265
|
const preflight = await preflightGsapDragIntercept(
|
|
252
266
|
selection,
|
|
@@ -258,7 +272,7 @@ export async function tryGsapDragIntercept(
|
|
|
258
272
|
if (preflight.status !== "persisted") return preflight;
|
|
259
273
|
if (options?.preflightOnly) {
|
|
260
274
|
return options.group
|
|
261
|
-
? planDrag(selection, offset, allAnimations, iframe, options)
|
|
275
|
+
? planDrag(selection, offset, allAnimations, iframe, options, moment)
|
|
262
276
|
: preflight;
|
|
263
277
|
}
|
|
264
278
|
}
|
|
@@ -302,16 +316,17 @@ export async function tryGsapDragIntercept(
|
|
|
302
316
|
selection,
|
|
303
317
|
commitMutation,
|
|
304
318
|
fetchFallbackAnimations,
|
|
319
|
+
time,
|
|
305
320
|
);
|
|
306
321
|
|
|
307
322
|
let posAnim = resolved?.anim ?? null;
|
|
308
323
|
let resolvedAnimations = resolved?.animations ?? workingAnimations;
|
|
309
324
|
if (!posAnim) {
|
|
310
|
-
posAnim = findGsapPositionAnimation(workingAnimations, selector);
|
|
325
|
+
posAnim = findGsapPositionAnimation(workingAnimations, selector, time);
|
|
311
326
|
if (!posAnim && fetchFallbackAnimations) {
|
|
312
327
|
const fresh = await fetchFallbackAnimations();
|
|
313
328
|
resolvedAnimations = fresh;
|
|
314
|
-
posAnim = findGsapPositionAnimation(fresh, selector);
|
|
329
|
+
posAnim = findGsapPositionAnimation(fresh, selector, time);
|
|
315
330
|
}
|
|
316
331
|
}
|
|
317
332
|
|
|
@@ -349,13 +364,14 @@ export async function tryGsapDragIntercept(
|
|
|
349
364
|
a.propertyGroup === posAnim!.propertyGroup &&
|
|
350
365
|
isXYPositionWrite(a) === isXYPositionWrite(posAnim!),
|
|
351
366
|
),
|
|
367
|
+
time,
|
|
352
368
|
);
|
|
353
369
|
if (freshMatch && freshMatch.id !== posAnim.id) {
|
|
354
370
|
posAnim = freshMatch;
|
|
355
371
|
}
|
|
356
372
|
}
|
|
357
373
|
|
|
358
|
-
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations, stamp
|
|
374
|
+
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations, stamp };
|
|
359
375
|
if (route === "whole-path") {
|
|
360
376
|
await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
|
|
361
377
|
} else {
|
|
@@ -412,7 +428,9 @@ export async function tryGsapRotationIntercept(
|
|
|
412
428
|
iframe: HTMLIFrameElement | null,
|
|
413
429
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
414
430
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
431
|
+
stamp?: DragStamp,
|
|
415
432
|
): Promise<GsapEditOutcome> {
|
|
433
|
+
const time = editMoment(stamp).time;
|
|
416
434
|
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
|
|
417
435
|
const outcome = preflightGsapRotationIntercept(selection, animations, iframe, fetchedAnimations);
|
|
418
436
|
if (outcome.status === "blocked") return outcome;
|
|
@@ -429,6 +447,7 @@ export async function tryGsapRotationIntercept(
|
|
|
429
447
|
selection,
|
|
430
448
|
commitMutation,
|
|
431
449
|
postSplitFetch,
|
|
450
|
+
time,
|
|
432
451
|
);
|
|
433
452
|
const resolvedAnimations = resolved?.animations ?? workingAnimations;
|
|
434
453
|
|
|
@@ -440,6 +459,7 @@ export async function tryGsapRotationIntercept(
|
|
|
440
459
|
if (!anim) {
|
|
441
460
|
anim = pickClosestToPlayhead(
|
|
442
461
|
workingAnimations.filter((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)),
|
|
462
|
+
time,
|
|
443
463
|
);
|
|
444
464
|
}
|
|
445
465
|
|
|
@@ -462,7 +482,7 @@ export async function tryGsapRotationIntercept(
|
|
|
462
482
|
return { status: "persisted" };
|
|
463
483
|
}
|
|
464
484
|
|
|
465
|
-
const pct = computeCurrentPercentage(selection, anim);
|
|
485
|
+
const pct = computeCurrentPercentage(selection, anim, time);
|
|
466
486
|
|
|
467
487
|
// With auto-keyframe off (#1808), a rotation tween already exists for this
|
|
468
488
|
// element (checked above) so nudge it as a whole rather than adding a
|
|
@@ -485,7 +505,7 @@ export async function tryGsapRotationIntercept(
|
|
|
485
505
|
anim,
|
|
486
506
|
{ rotation: newRotation },
|
|
487
507
|
iframe,
|
|
488
|
-
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
|
508
|
+
{ commitMutation, fetchAnimations: fetchFallbackAnimations, stamp },
|
|
489
509
|
{ label: "Rotate", backfill: { rotation: newRotation }, holdFromStart: true },
|
|
490
510
|
);
|
|
491
511
|
}
|
|
@@ -8,6 +8,7 @@ import { boxSteps, liveTween, previewWith, tween } from "./gsapParsedTween.test-
|
|
|
8
8
|
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
|
|
9
9
|
import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
|
|
10
10
|
import { planValueAtPlayhead, planValueEdit, type PlayheadEdit } from "./gsapValueAtPlayhead";
|
|
11
|
+
import { STUDIO_EDIT_MOMENT_ATTR } from "../components/editor/manualEditsTypes";
|
|
11
12
|
|
|
12
13
|
let el: HTMLElement;
|
|
13
14
|
let selection: DomEditSelection;
|
|
@@ -181,6 +182,20 @@ describe("a move on a GSAP-animated layer, at the playhead", () => {
|
|
|
181
182
|
]);
|
|
182
183
|
});
|
|
183
184
|
|
|
185
|
+
it("keys the time the drag was pressed at, wherever the playhead is when it commits", async () => {
|
|
186
|
+
const { keys, live } = boxSteps([
|
|
187
|
+
[2, { x: 60 }],
|
|
188
|
+
[1, { x: 120 }],
|
|
189
|
+
]);
|
|
190
|
+
el.setAttribute(STUDIO_EDIT_MOMENT_ATTR, JSON.stringify({ time: 1, keyframePct: null }));
|
|
191
|
+
const { mutations } = await drag([keys], [live(el)], { time: 2, base: [30, 0], dx: 10 });
|
|
192
|
+
expect(mutations[0].keyframes[0]).toEqual({
|
|
193
|
+
percentage: 33.333,
|
|
194
|
+
properties: { x: 40, y: 0 },
|
|
195
|
+
ease: "none",
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
184
199
|
it("never writes x and y into a tween that only animates size", async () => {
|
|
185
200
|
const size = tween({
|
|
186
201
|
id: "#box-to-0-size",
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
3
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
4
|
+
import type { EditMoment } from "../components/editor/manualEditsTypes";
|
|
5
|
+
import { editMoment, playheadMoment } from "./editMoment";
|
|
4
6
|
import { resolveTweenDuration, resolveTweenStart } from "../utils/globalTimeCompiler";
|
|
5
7
|
import { ARRAY_STEP_EASE, PERCENTAGE_SEGMENT_EASE, runEaseOf } from "../utils/gsapKeyframeEases";
|
|
6
8
|
import { roundTo3 } from "../utils/rounding";
|
|
@@ -287,11 +289,15 @@ export function planValueEdit(
|
|
|
287
289
|
anim: GsapAnimation,
|
|
288
290
|
values: Record<string, number>,
|
|
289
291
|
iframe: HTMLIFrameElement | null,
|
|
290
|
-
{
|
|
292
|
+
{
|
|
293
|
+
backfill,
|
|
294
|
+
holdFromStart,
|
|
295
|
+
moment = playheadMoment(),
|
|
296
|
+
}: Pick<PlayheadEdit, "backfill" | "holdFromStart"> & { moment?: EditMoment } = {},
|
|
291
297
|
): PlayheadEditPlan {
|
|
292
298
|
// One keyframe of a tween its siblings share would move them all.
|
|
293
299
|
if (tweenReach(anim, selection.element) === "shared") return refuse("shared-tween");
|
|
294
|
-
const { activeKeyframePct, currentTime } =
|
|
300
|
+
const { keyframePct: activeKeyframePct, time: currentTime } = moment;
|
|
295
301
|
const tween = findParsedTween(iframe, selection.element, anim);
|
|
296
302
|
const timed = withExactStepTimes(anim, tween);
|
|
297
303
|
return planValueAtPlayhead({
|
|
@@ -321,11 +327,12 @@ export async function commitValueAtPlayhead(
|
|
|
321
327
|
},
|
|
322
328
|
): Promise<GsapEditOutcome> {
|
|
323
329
|
await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
|
324
|
-
const
|
|
330
|
+
const moment = editMoment(callbacks.stamp);
|
|
331
|
+
const { setActiveKeyframePct } = usePlayerStore.getState();
|
|
325
332
|
const tween = findParsedTween(iframe, selection.element, anim);
|
|
326
333
|
const parkAt =
|
|
327
|
-
|
|
328
|
-
const plan = planValueEdit(selection, anim, values, iframe, options);
|
|
334
|
+
moment.keyframePct == null ? null : exactKeyframePct(anim, tween, moment.keyframePct);
|
|
335
|
+
const plan = planValueEdit(selection, anim, values, iframe, { ...options, moment });
|
|
329
336
|
if (!plan.ok) return { status: "blocked", reason: "keyframes-uneditable", detail: plan.reason };
|
|
330
337
|
await callbacks.commitMutation(selection, plan.mutation, {
|
|
331
338
|
label: options.label,
|
|
@@ -233,6 +233,28 @@ describe("extendRootDurationIfNeeded", () => {
|
|
|
233
233
|
});
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
+
describe("buildTimelineMoveTimingPatch", () => {
|
|
237
|
+
it.each([
|
|
238
|
+
{ root: ' data-duration="20"', length: 'data-composition-id="c" data-duration="8"' },
|
|
239
|
+
{ root: "", length: 'data-composition-id="c">' },
|
|
240
|
+
])(
|
|
241
|
+
"parses the saved file once to move a clip and sync the length (root$root)",
|
|
242
|
+
({ root, length }) => {
|
|
243
|
+
const source = `<div data-composition-id="c"${root}><div id="a" class="clip" data-start="1" data-duration="3"></div></div>`;
|
|
244
|
+
const parse = vi.spyOn(DOMParser.prototype, "parseFromString");
|
|
245
|
+
try {
|
|
246
|
+
const patched = buildTimelineMoveTimingPatch(source, { id: "a" }, 5, 3);
|
|
247
|
+
|
|
248
|
+
expect(patched).toContain('data-start="5"');
|
|
249
|
+
expect(patched).toContain(length);
|
|
250
|
+
expect(parse).toHaveBeenCalledTimes(1);
|
|
251
|
+
} finally {
|
|
252
|
+
parse.mockRestore();
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
236
258
|
describe("buildTimelineResizeTimingPatch", () => {
|
|
237
259
|
it("moves a source-only in-point by the caller's own start change", () => {
|
|
238
260
|
const source = `<div id="root"><video id="a" class="clip" data-start="5" data-duration="3" data-media-start="0.337"></video></div>`;
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from "../player/components/timelineEditing";
|
|
16
16
|
import { getElementZIndex } from "../player/lib/layerOrdering";
|
|
17
17
|
import {
|
|
18
|
-
|
|
18
|
+
furthestClipEndFromDocument,
|
|
19
19
|
getTimelineElementIdentity,
|
|
20
20
|
playbackStartAttributeForElement,
|
|
21
21
|
readPlaybackStartAttributes,
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
30
30
|
import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
|
|
31
31
|
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
|
32
|
+
import { rootCompositionDurationOf } from "../utils/rootDuration";
|
|
32
33
|
import { readFileContent } from "./timelineTimingSync";
|
|
33
34
|
import {
|
|
34
35
|
findElementForSelection,
|
|
@@ -263,7 +264,22 @@ export function applyTimelineMoveAttributes(
|
|
|
263
264
|
// Root data-duration follows the furthest clip end in the source; the store's durations are
|
|
264
265
|
// truncated to the current length, so reading them would ratchet the duration down every edit.
|
|
265
266
|
export function syncCompositionDurationToContent(source: string): string {
|
|
266
|
-
return
|
|
267
|
+
return syncCompositionDurationWithEnd(source).source;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The synced source and the clip end it was synced to, from one parse of the source. */
|
|
271
|
+
export function syncCompositionDurationWithEnd(source: string): {
|
|
272
|
+
source: string;
|
|
273
|
+
contentEnd: number;
|
|
274
|
+
} {
|
|
275
|
+
const doc = new DOMParser().parseFromString(source, "text/html");
|
|
276
|
+
const contentEnd = furthestClipEndFromDocument(doc);
|
|
277
|
+
const synced = setCompositionDurationToContent(
|
|
278
|
+
source,
|
|
279
|
+
contentEnd,
|
|
280
|
+
rootCompositionDurationOf(doc),
|
|
281
|
+
);
|
|
282
|
+
return { source: synced, contentEnd };
|
|
267
283
|
}
|
|
268
284
|
|
|
269
285
|
export function buildTimelineMoveTimingPatch(
|
|
@@ -22,7 +22,7 @@ interface TimelineSpies {
|
|
|
22
22
|
|
|
23
23
|
function renderHarness(
|
|
24
24
|
initialProps: HarnessProps,
|
|
25
|
-
options: { timelineElements?: TimelineElement[] } = {},
|
|
25
|
+
options: { timelineElements?: TimelineElement[]; previewDoc?: Document } = {},
|
|
26
26
|
): {
|
|
27
27
|
current: () => ReturnType<typeof useDomSelection>;
|
|
28
28
|
rerender: (props: HarnessProps) => void;
|
|
@@ -49,7 +49,11 @@ function renderHarness(
|
|
|
49
49
|
isMasterView: false,
|
|
50
50
|
compIdToSrc: new Map(),
|
|
51
51
|
captionEditMode: false,
|
|
52
|
-
previewIframeRef: {
|
|
52
|
+
previewIframeRef: {
|
|
53
|
+
current: options.previewDoc
|
|
54
|
+
? ({ contentDocument: options.previewDoc } as HTMLIFrameElement)
|
|
55
|
+
: null,
|
|
56
|
+
},
|
|
53
57
|
timelineElements: options.timelineElements ?? [],
|
|
54
58
|
getTimelineSelectionSet: () => publishedSet,
|
|
55
59
|
setSelectedTimelineElementId: timeline.setSelectedTimelineElementId,
|
|
@@ -307,6 +311,21 @@ describe("useDomSelection", () => {
|
|
|
307
311
|
harness.cleanup();
|
|
308
312
|
});
|
|
309
313
|
|
|
314
|
+
it("drops the selection when the reloaded preview no longer has its element", async () => {
|
|
315
|
+
const selection = makeSelection("Headline", document.createElement("div"));
|
|
316
|
+
const harness = renderHarness(
|
|
317
|
+
{ activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 },
|
|
318
|
+
{ previewDoc: document.implementation.createHTMLDocument("") },
|
|
319
|
+
);
|
|
320
|
+
act(() => harness.current().applyDomSelection(selection));
|
|
321
|
+
|
|
322
|
+
await act(() => harness.current().refreshDomEditSelectionFromPreview(selection));
|
|
323
|
+
|
|
324
|
+
expect(harness.current().domEditSelection).toBeNull();
|
|
325
|
+
expect(harness.timeline.setSelectedTimelineElementId).toHaveBeenLastCalledWith(null);
|
|
326
|
+
harness.cleanup();
|
|
327
|
+
});
|
|
328
|
+
|
|
310
329
|
it("clears a committed selection when the active composition path changes", () => {
|
|
311
330
|
const { selection, harness } = setupSelectedHarness();
|
|
312
331
|
expect(harness.current().domEditSelection).toBe(selection);
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
beginStudioPendingEdit,
|
|
10
10
|
isStudioEditSaving,
|
|
11
11
|
setStudioPendingEditClaimClock,
|
|
12
|
+
setStudioWaitingPressCancel,
|
|
12
13
|
trackStudioPendingEdit,
|
|
13
14
|
} from "../utils/studioPendingEdits";
|
|
14
15
|
|
|
@@ -18,6 +19,7 @@ let root: Root | null = null;
|
|
|
18
19
|
afterEach(() => {
|
|
19
20
|
act(() => root?.unmount());
|
|
20
21
|
setStudioPendingEditClaimClock(null);
|
|
22
|
+
setStudioWaitingPressCancel(null);
|
|
21
23
|
});
|
|
22
24
|
|
|
23
25
|
type RestoreFiles = Record<string, { previous: string; restored: string }>;
|
|
@@ -71,6 +73,24 @@ const PREDICTED = { id: "e1", files: { "index.html": { previous: "B", restored:
|
|
|
71
73
|
const SERVER_FILES = { "index.html": { previous: "B", restored: "A2" } };
|
|
72
74
|
|
|
73
75
|
describe("useEditHistoryActions", () => {
|
|
76
|
+
it("takes back a canvas press still waiting to run, with no history step", async () => {
|
|
77
|
+
const { deps, actions } = mount({ ok: true, label: "Undid: Move" });
|
|
78
|
+
const cancel = vi.fn(() => (setStudioWaitingPressCancel(null), true));
|
|
79
|
+
setStudioWaitingPressCancel(cancel);
|
|
80
|
+
const revert = vi.fn(() => () => {});
|
|
81
|
+
const saving = beginStudioPendingEdit(revert);
|
|
82
|
+
await act(() => actions.redo());
|
|
83
|
+
expect(cancel, "redo leaves the press").not.toHaveBeenCalled();
|
|
84
|
+
await act(() => actions.undo());
|
|
85
|
+
expect(cancel).toHaveBeenCalledTimes(1);
|
|
86
|
+
expect(revert, "the press is newer than any edit still saving").not.toHaveBeenCalled();
|
|
87
|
+
expect(deps.editHistory.undo).not.toHaveBeenCalled();
|
|
88
|
+
|
|
89
|
+
await act(() => actions.undo());
|
|
90
|
+
expect(revert, "the following Cmd+Z takes back the edit still saving").toHaveBeenCalledTimes(1);
|
|
91
|
+
saving.settle();
|
|
92
|
+
});
|
|
93
|
+
|
|
74
94
|
it("asks for the edit claimed after the key's claim count only when an edit was saving at the key", async () => {
|
|
75
95
|
const { deps, actions } = mount({ ok: false, reason: "empty" });
|
|
76
96
|
await act(() => actions.undo());
|
|
@@ -5,6 +5,7 @@ import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordin
|
|
|
5
5
|
import type { RestoreFiles } from "../utils/gsapUndoRestore";
|
|
6
6
|
import {
|
|
7
7
|
beginStudioShownRestore,
|
|
8
|
+
cancelNewestStudioWaitingPress,
|
|
8
9
|
hasStudioPendingEdits,
|
|
9
10
|
paintBackNewestStudioPendingEdit,
|
|
10
11
|
} from "../utils/studioPendingEdits";
|
|
@@ -79,6 +80,7 @@ export function useEditHistoryActions({
|
|
|
79
80
|
const apply = useCallback(
|
|
80
81
|
async (direction: "undo" | "redo") => {
|
|
81
82
|
const noun = direction === "undo" ? "Undo" : "Redo";
|
|
83
|
+
if (direction === "undo" && cancelNewestStudioWaitingPress()) return;
|
|
82
84
|
// An edit still saving is newer than any step this tab predicts, so it paints back first.
|
|
83
85
|
const pendingEditShown = direction === "undo" ? paintBackNewestStudioPendingEdit() : null;
|
|
84
86
|
const predicted = pendingEditShown ? null : (editHistory.predict?.(direction) ?? null);
|
|
@@ -42,6 +42,7 @@ vi.mock("./useSafeGsapCommitMutation", () => ({
|
|
|
42
42
|
}));
|
|
43
43
|
|
|
44
44
|
import { useGsapAwareEditing } from "./useGsapAwareEditing";
|
|
45
|
+
import { isPreviewChanging } from "../player/previewReloading";
|
|
45
46
|
import { tryGsapRotationIntercept } from "./gsapRuntimeBridge";
|
|
46
47
|
|
|
47
48
|
afterEach(() => {
|
|
@@ -235,6 +236,43 @@ describe("useGsapAwareEditing keeps the route a gesture chose at press", () => {
|
|
|
235
236
|
});
|
|
236
237
|
});
|
|
237
238
|
|
|
239
|
+
describe("useGsapAwareEditing holds a canvas press while a drop saves", () => {
|
|
240
|
+
it.each([
|
|
241
|
+
["a move through the script", false, "move"],
|
|
242
|
+
["a group move through the script", false, "group"],
|
|
243
|
+
["a move saved as CSS", true, "move"],
|
|
244
|
+
])("%s", async (_, plainTranslate, kind) => {
|
|
245
|
+
let land: () => void = () => {};
|
|
246
|
+
const landed = new Promise<void>((resolve) => (land = resolve));
|
|
247
|
+
mocks.drag.mockImplementation(async () => {
|
|
248
|
+
await landed;
|
|
249
|
+
return { status: "persisted" };
|
|
250
|
+
});
|
|
251
|
+
const save = vi.fn().mockReturnValue(landed);
|
|
252
|
+
const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
|
|
253
|
+
const { pathOffsetCommit, groupCommit, root } = mountGroupHandler({
|
|
254
|
+
stageElementPositionOffset,
|
|
255
|
+
});
|
|
256
|
+
const box = { element: document.createElement("div"), id: "box", selector: "#box" };
|
|
257
|
+
const selection = box as unknown as DomEditSelection;
|
|
258
|
+
const to = { x: 40, y: 20 };
|
|
259
|
+
let saved!: Promise<unknown>;
|
|
260
|
+
act(() => {
|
|
261
|
+
saved =
|
|
262
|
+
kind === "group"
|
|
263
|
+
? groupCommit([{ selection, next: to, plainTranslate }])
|
|
264
|
+
: pathOffsetCommit(selection, to, { plainTranslate });
|
|
265
|
+
});
|
|
266
|
+
expect(isPreviewChanging(), "from the drop").toBe(!plainTranslate);
|
|
267
|
+
await act(async () => {
|
|
268
|
+
land();
|
|
269
|
+
await saved;
|
|
270
|
+
});
|
|
271
|
+
expect(isPreviewChanging()).toBe(false);
|
|
272
|
+
act(() => root.unmount());
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
238
276
|
describe("useGsapAwareEditing keeps the box route a resize chose at press", () => {
|
|
239
277
|
it.each([
|
|
240
278
|
["the CSS route, on an element GSAP has since taken over", true],
|