@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
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
GsapOwnershipProtocolError,
|
|
18
18
|
GsapPreviewConvergenceError,
|
|
19
19
|
postGsapMutation,
|
|
20
|
+
postGsapMutations,
|
|
20
21
|
requireGsapOwnershipProtocol,
|
|
21
22
|
rollbackOwnedMutation,
|
|
22
23
|
type GsapMutationStatus,
|
|
@@ -140,7 +141,9 @@ function syncTimingEditPreview(
|
|
|
140
141
|
if (result === "cannot-soft-reload") reloadPreview();
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
|
|
144
|
+
const UNMUTATED: GsapMutationStatus = { mutated: false, scriptText: null };
|
|
145
|
+
|
|
146
|
+
export async function finishTimelineTimingFallback(input: {
|
|
144
147
|
iframe: HTMLIFrameElement | null;
|
|
145
148
|
projectId: string | null;
|
|
146
149
|
reloadPreview: () => void;
|
|
@@ -155,7 +158,7 @@ async function finishTimelineTimingFallback(input: {
|
|
|
155
158
|
*/
|
|
156
159
|
rebindWhenUnmutated: boolean;
|
|
157
160
|
}): Promise<void> {
|
|
158
|
-
let outcome
|
|
161
|
+
let outcome = UNMUTATED;
|
|
159
162
|
if (input.gsapMutation) {
|
|
160
163
|
try {
|
|
161
164
|
outcome = await input.gsapMutation();
|
|
@@ -185,9 +188,20 @@ async function finishTimelineTimingFallback(input: {
|
|
|
185
188
|
);
|
|
186
189
|
}
|
|
187
190
|
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
+
// Held until the next edit: the key is the gesture's own, so only its GSAP fold can join it.
|
|
192
|
+
const UNTIL_NEXT_EDIT = Number.POSITIVE_INFINITY;
|
|
193
|
+
let timingGestures = 0;
|
|
194
|
+
|
|
195
|
+
/** One timing gesture's undo step, shared by its timing write and its GSAP fold however long the fold takes. */
|
|
196
|
+
export function timingGestureStep(
|
|
197
|
+
kind: string,
|
|
198
|
+
given?: { coalesceKey?: string },
|
|
199
|
+
): { coalesceKey: string; coalesceMs: number } {
|
|
200
|
+
return {
|
|
201
|
+
coalesceKey: given?.coalesceKey ?? `${kind}:${++timingGestures}`,
|
|
202
|
+
coalesceMs: UNTIL_NEXT_EDIT,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
191
205
|
|
|
192
206
|
type OwnedMutationStep = {
|
|
193
207
|
path: string;
|
|
@@ -265,7 +279,7 @@ async function foldGsapMutationInQueue(input: {
|
|
|
265
279
|
const ownedSteps: OwnedMutationStep[] = [];
|
|
266
280
|
const runOwnedMutation: OwnedMutationRunner = async (path, mutation) => {
|
|
267
281
|
const pending = mutation();
|
|
268
|
-
if (!pending) return
|
|
282
|
+
if (!pending) return UNMUTATED;
|
|
269
283
|
const status = await pending;
|
|
270
284
|
if (!status.mutated) return status;
|
|
271
285
|
if (status.before === undefined || status.after === undefined) {
|
|
@@ -313,7 +327,7 @@ async function foldGsapMutationInQueue(input: {
|
|
|
313
327
|
await input.recordEdit({
|
|
314
328
|
label: input.label,
|
|
315
329
|
coalesceKey: input.coalesceKey,
|
|
316
|
-
coalesceMs:
|
|
330
|
+
coalesceMs: UNTIL_NEXT_EDIT,
|
|
317
331
|
files,
|
|
318
332
|
});
|
|
319
333
|
}
|
|
@@ -325,7 +339,7 @@ async function foldGsapMutationInQueue(input: {
|
|
|
325
339
|
}
|
|
326
340
|
|
|
327
341
|
// The mutations, the ownership read and any rollback hold every touched file's queue.
|
|
328
|
-
function foldGsapMutationIntoHistory(
|
|
342
|
+
export function foldGsapMutationIntoHistory(
|
|
329
343
|
input: Parameters<typeof foldGsapMutationInQueue>[0] & {
|
|
330
344
|
writeFile: StudioProjectFileWriter;
|
|
331
345
|
paths: readonly string[];
|
|
@@ -336,6 +350,30 @@ function foldGsapMutationIntoHistory(
|
|
|
336
350
|
);
|
|
337
351
|
}
|
|
338
352
|
|
|
353
|
+
export function shiftGsapMutation(elementId: string | undefined, delta: number) {
|
|
354
|
+
if (delta === 0 || !elementId) return null;
|
|
355
|
+
return { type: "shift-positions", targetSelector: `#${elementId}`, delta };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function scaleGsapMutation(
|
|
359
|
+
elementId: string | undefined,
|
|
360
|
+
oldStart: number,
|
|
361
|
+
oldDuration: number,
|
|
362
|
+
newStart: number,
|
|
363
|
+
newDuration: number,
|
|
364
|
+
) {
|
|
365
|
+
if (!elementId || oldDuration <= 0 || newDuration <= 0) return null;
|
|
366
|
+
if (oldStart === newStart && oldDuration === newDuration) return null;
|
|
367
|
+
return {
|
|
368
|
+
type: "scale-positions",
|
|
369
|
+
targetSelector: `#${elementId}`,
|
|
370
|
+
oldStart,
|
|
371
|
+
oldDuration,
|
|
372
|
+
newStart,
|
|
373
|
+
newDuration,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
339
377
|
/**
|
|
340
378
|
* Shift all GSAP animation positions targeting a given element by a time delta.
|
|
341
379
|
* Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
|
|
@@ -347,20 +385,12 @@ export async function shiftGsapPositions(
|
|
|
347
385
|
elementId: string,
|
|
348
386
|
delta: number,
|
|
349
387
|
): Promise<GsapMutationStatus> {
|
|
350
|
-
|
|
351
|
-
return
|
|
352
|
-
|
|
353
|
-
filePath,
|
|
354
|
-
{
|
|
355
|
-
type: "shift-positions",
|
|
356
|
-
targetSelector: `#${elementId}`,
|
|
357
|
-
delta,
|
|
358
|
-
},
|
|
359
|
-
"shift-positions failed",
|
|
360
|
-
);
|
|
388
|
+
const mutation = shiftGsapMutation(elementId, delta);
|
|
389
|
+
if (!mutation) return UNMUTATED;
|
|
390
|
+
return postGsapMutation(projectId, filePath, mutation, "shift-positions failed");
|
|
361
391
|
}
|
|
362
392
|
|
|
363
|
-
|
|
393
|
+
async function scaleGsapPositions(
|
|
364
394
|
projectId: string,
|
|
365
395
|
filePath: string,
|
|
366
396
|
elementId: string,
|
|
@@ -369,23 +399,9 @@ export async function scaleGsapPositions(
|
|
|
369
399
|
newStart: number,
|
|
370
400
|
newDuration: number,
|
|
371
401
|
): Promise<GsapMutationStatus> {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
return { mutated: false, scriptText: null };
|
|
376
|
-
return postGsapMutation(
|
|
377
|
-
projectId,
|
|
378
|
-
filePath,
|
|
379
|
-
{
|
|
380
|
-
type: "scale-positions",
|
|
381
|
-
targetSelector: `#${elementId}`,
|
|
382
|
-
oldStart,
|
|
383
|
-
oldDuration,
|
|
384
|
-
newStart,
|
|
385
|
-
newDuration,
|
|
386
|
-
},
|
|
387
|
-
"scale-positions failed",
|
|
388
|
-
);
|
|
402
|
+
const mutation = scaleGsapMutation(elementId, oldStart, oldDuration, newStart, newDuration);
|
|
403
|
+
if (!mutation) return UNMUTATED;
|
|
404
|
+
return postGsapMutation(projectId, filePath, mutation, "scale-positions failed");
|
|
389
405
|
}
|
|
390
406
|
|
|
391
407
|
/** The GSAP sync a committed SDK timing write made, or null when its timeline script is unchanged. */
|
|
@@ -502,8 +518,8 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
|
|
|
502
518
|
activeCompPath: string | null;
|
|
503
519
|
changes: readonly C[];
|
|
504
520
|
resolveChangePath: (element: TimelineElement) => string;
|
|
505
|
-
/**
|
|
506
|
-
|
|
521
|
+
/** The change's GSAP mutation; null skips a change with no timing delta. */
|
|
522
|
+
mutationFor: (change: C) => Record<string, unknown> | null;
|
|
507
523
|
sdkGsap?: GsapMutationStatus | null;
|
|
508
524
|
}): Promise<void> {
|
|
509
525
|
const activePath = input.activeCompPath || "index.html";
|
|
@@ -539,14 +555,18 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
|
|
|
539
555
|
let mutated = false;
|
|
540
556
|
let scriptText: GsapMutationStatus["scriptText"] = null;
|
|
541
557
|
let after: string | undefined;
|
|
558
|
+
const byPath = new Map<string, Record<string, unknown>[]>();
|
|
542
559
|
for (const change of input.changes) {
|
|
560
|
+
const mutation = input.mutationFor(change);
|
|
561
|
+
if (!mutation) continue;
|
|
543
562
|
const changePath = input.resolveChangePath(change.element);
|
|
563
|
+
byPath.set(changePath, [...(byPath.get(changePath) ?? []), mutation]);
|
|
564
|
+
}
|
|
565
|
+
for (const [changePath, mutations] of byPath) {
|
|
544
566
|
const status = await runOwnedMutation(changePath, () =>
|
|
545
|
-
input.
|
|
567
|
+
postGsapMutations(input.projectId, changePath, mutations, input.errorLabel),
|
|
546
568
|
);
|
|
547
569
|
mutated = mutated || status.mutated;
|
|
548
|
-
// The LAST mutation against the active comp carries the cumulative
|
|
549
|
-
// rewritten script for that file.
|
|
550
570
|
if (changePath === activePath) ({ scriptText, after } = status);
|
|
551
571
|
}
|
|
552
572
|
return { mutated, scriptText: otherFileChanged ? null : scriptText, after };
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
tweenTargetsElement,
|
|
23
23
|
} from "./gsapShared";
|
|
24
24
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
25
|
+
import { progressAtTime, runEaseOf, timeAtProgress } from "../utils/gsapKeyframeEases";
|
|
25
26
|
import { roundTo3 } from "../utils/rounding";
|
|
26
27
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
27
28
|
import {
|
|
@@ -365,19 +366,18 @@ async function commitKeyframeProps(
|
|
|
365
366
|
const newStart = Math.min(ct, ts);
|
|
366
367
|
const newEnd = Math.max(ct, ts + td);
|
|
367
368
|
const newDuration = Math.max(0.01, newEnd - newStart);
|
|
369
|
+
const runEase = runEaseOf(anim);
|
|
370
|
+
const toNewPct = (absTime: number) =>
|
|
371
|
+
Math.round(progressAtTime(runEase, ((absTime - newStart) / newDuration) * 100) * 10) / 10;
|
|
368
372
|
const remapped = kfs.map((kf) => {
|
|
369
|
-
const
|
|
370
|
-
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
|
|
373
|
+
const newPct = toNewPct(ts + (timeAtProgress(runEase, kf.percentage) / 100) * td);
|
|
371
374
|
const p: Record<string, number | string> = { ...kf.properties };
|
|
372
375
|
for (const k of Object.keys(properties)) {
|
|
373
376
|
if (!(k in p) && backfillDefaults[k] != null) p[k] = backfillDefaults[k];
|
|
374
377
|
}
|
|
375
378
|
return { percentage: newPct, properties: p };
|
|
376
379
|
});
|
|
377
|
-
remapped.push({
|
|
378
|
-
percentage: Math.round(((ct - newStart) / newDuration) * 1000) / 10,
|
|
379
|
-
properties,
|
|
380
|
-
});
|
|
380
|
+
remapped.push({ percentage: toNewPct(ct), properties });
|
|
381
381
|
remapped.sort((a, b) => a.percentage - b.percentage);
|
|
382
382
|
await commit(
|
|
383
383
|
selection,
|
|
@@ -8,28 +8,19 @@
|
|
|
8
8
|
|
|
9
9
|
import { useCallback, useRef } from "react";
|
|
10
10
|
import type { TimelineElement } from "../player";
|
|
11
|
-
import {
|
|
12
|
-
MAX_HAND_EDIT_CLIPS,
|
|
13
|
-
type BlockedTimelineEditIntent,
|
|
14
|
-
} from "../player/components/timelineEditing";
|
|
15
11
|
|
|
16
12
|
const BLOCKED_TOAST_INTERVAL_MS = 1500;
|
|
17
13
|
|
|
18
14
|
export function useBlockedTimelineEditToast(
|
|
19
15
|
showToast: (message: string, tone?: "info" | "error") => void,
|
|
20
|
-
): (element: TimelineElement
|
|
16
|
+
): (element: TimelineElement) => void {
|
|
21
17
|
const lastAtRef = useRef(0);
|
|
22
18
|
return useCallback(
|
|
23
|
-
(_element: TimelineElement
|
|
19
|
+
(_element: TimelineElement) => {
|
|
24
20
|
const now = Date.now();
|
|
25
21
|
if (now - lastAtRef.current < BLOCKED_TOAST_INTERVAL_MS) return;
|
|
26
22
|
lastAtRef.current = now;
|
|
27
|
-
showToast(
|
|
28
|
-
intent === "edit-many"
|
|
29
|
-
? `Select ${MAX_HAND_EDIT_CLIPS} or fewer clips to move or resize by hand, or describe the change.`
|
|
30
|
-
: "This clip can't be moved or resized from the timeline yet.",
|
|
31
|
-
"info",
|
|
32
|
-
);
|
|
23
|
+
showToast("This clip can't be moved or resized from the timeline yet.", "info");
|
|
33
24
|
},
|
|
34
25
|
[showToast],
|
|
35
26
|
);
|
|
@@ -3,7 +3,8 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
4
|
import { dragEditOutcome, preflightGsapRotationIntercept } from "./gsapRuntimeBridge";
|
|
5
5
|
import { preflightGsapResizeIntercept } from "./gsapResizePreflight";
|
|
6
|
-
import {
|
|
6
|
+
import { withTweenIndex } from "./gsapRuntimeTweenIndex";
|
|
7
|
+
import { gsapEditBlockMessage, type GsapEditOutcome } from "./gsapEditOutcome";
|
|
7
8
|
import { fetchParsedAnimations, parseCacheKey } from "./keyframeCacheAstLoad";
|
|
8
9
|
import { getAnimationsForElement } from "./gsapElementMatch";
|
|
9
10
|
import { gsapSourceFileForSelection } from "./useGsapAnimationFetchFallback";
|
|
@@ -32,6 +33,38 @@ function runCommitPreflights(
|
|
|
32
33
|
// Studio can hand a narrowed copy back as a new selection; narrowing starts from the resolved one.
|
|
33
34
|
const resolvedSelections = new WeakMap<DomEditSelection, DomEditSelection>();
|
|
34
35
|
|
|
36
|
+
interface CheckInputs {
|
|
37
|
+
animations: GsapAnimation[];
|
|
38
|
+
iframe: HTMLIFrameElement | null;
|
|
39
|
+
group: boolean;
|
|
40
|
+
version: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Checked extends CheckInputs {
|
|
44
|
+
preflight: CommitPreflight;
|
|
45
|
+
narrowed?: DomEditSelection;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A member whose selection, parse, preview and cache version are unchanged keeps its answer.
|
|
49
|
+
const checkedSelections = new WeakMap<DomEditSelection, Checked>();
|
|
50
|
+
|
|
51
|
+
function checkedPreflight(resolved: DomEditSelection, inputs: CheckInputs): Checked {
|
|
52
|
+
const known = checkedSelections.get(resolved);
|
|
53
|
+
const same =
|
|
54
|
+
known?.animations === inputs.animations &&
|
|
55
|
+
known.iframe === inputs.iframe &&
|
|
56
|
+
known.group === inputs.group &&
|
|
57
|
+
known.version === inputs.version;
|
|
58
|
+
if (same) return known;
|
|
59
|
+
const { animations, iframe, group } = inputs;
|
|
60
|
+
const checked = {
|
|
61
|
+
...inputs,
|
|
62
|
+
preflight: runCommitPreflights(resolved, animations, iframe, group),
|
|
63
|
+
};
|
|
64
|
+
checkedSelections.set(resolved, checked);
|
|
65
|
+
return checked;
|
|
66
|
+
}
|
|
67
|
+
|
|
35
68
|
const resolvedOf = (selection: DomEditSelection) => resolvedSelections.get(selection) ?? selection;
|
|
36
69
|
|
|
37
70
|
const MANUAL_FLAGS = [
|
|
@@ -44,7 +77,7 @@ const MANUAL_FLAGS = [
|
|
|
44
77
|
function refusal(preflight: CommitPreflight | null, check: keyof CommitPreflight): string | null {
|
|
45
78
|
const outcome = preflight?.[check];
|
|
46
79
|
if (!outcome) return "";
|
|
47
|
-
return outcome.status === "blocked" ?
|
|
80
|
+
return outcome.status === "blocked" ? gsapEditBlockMessage(outcome.reason, outcome.detail) : null;
|
|
48
81
|
}
|
|
49
82
|
|
|
50
83
|
/** Closes each manual flag whose commit Studio would refuse, and says why. */
|
|
@@ -123,17 +156,19 @@ export function useCommitPreflightCapabilities({
|
|
|
123
156
|
void parseTick;
|
|
124
157
|
if (!enabled || !projectId) return { selection, groupSelections };
|
|
125
158
|
const group = groupSelections.length > 1;
|
|
159
|
+
const iframe = previewIframeRef.current;
|
|
126
160
|
const narrow = (target: DomEditSelection) => {
|
|
127
161
|
const file = gsapSourceFileForSelection(target);
|
|
128
162
|
const animations = parsesRef.current.get(parseCacheKey(projectId, file))?.animations;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
163
|
+
if (!animations) return narrowCapabilities(target, null);
|
|
164
|
+
const inputs = { animations, iframe, group, version };
|
|
165
|
+
const checked = checkedPreflight(resolvedOf(target), inputs);
|
|
166
|
+
checked.narrowed ??= narrowCapabilities(target, checked.preflight);
|
|
167
|
+
return checked.narrowed;
|
|
133
168
|
};
|
|
134
|
-
return {
|
|
169
|
+
return withTweenIndex(() => ({
|
|
135
170
|
selection: selection && narrow(selection),
|
|
136
171
|
groupSelections: groupSelections.map(narrow),
|
|
137
|
-
};
|
|
138
|
-
}, [enabled, projectId, selection, groupSelections, parseTick, previewIframeRef]);
|
|
172
|
+
}));
|
|
173
|
+
}, [enabled, projectId, selection, groupSelections, parseTick, previewIframeRef, version]);
|
|
139
174
|
}
|
|
@@ -10,6 +10,7 @@ import { useDockLayoutStore } from "../components/dock/dockLayoutStore";
|
|
|
10
10
|
import type { PatchTarget } from "../utils/sourcePatcher";
|
|
11
11
|
import { logSelect } from "../utils/selectDebug";
|
|
12
12
|
import { onPreviewContentReplaced } from "../player/sceneSwap";
|
|
13
|
+
import type { ResolveDomSelectionOptions } from "./useDomSelectionTypes";
|
|
13
14
|
|
|
14
15
|
interface UseDomEditPreviewSyncParams {
|
|
15
16
|
previewIframe: HTMLIFrameElement | null;
|
|
@@ -24,7 +25,10 @@ interface UseDomEditPreviewSyncParams {
|
|
|
24
25
|
selection: DomEditSelection | null,
|
|
25
26
|
options?: { revealPanel?: boolean; preserveGroup?: boolean },
|
|
26
27
|
) => void;
|
|
27
|
-
buildDomSelectionFromTarget: (
|
|
28
|
+
buildDomSelectionFromTarget: (
|
|
29
|
+
element: HTMLElement,
|
|
30
|
+
options?: Pick<ResolveDomSelectionOptions, "previous">,
|
|
31
|
+
) => Promise<DomEditSelection | null>;
|
|
28
32
|
refreshPreviewDocumentVersion: () => void;
|
|
29
33
|
syncPreviewHotkeys: (iframe: HTMLIFrameElement | null) => void;
|
|
30
34
|
applyStudioManualEditsToPreviewRef: React.MutableRefObject<
|
|
@@ -96,7 +100,9 @@ export function useDomEditPreviewSync({
|
|
|
96
100
|
return;
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
const nextSelection = await buildDomSelectionFromTarget(nextElement
|
|
103
|
+
const nextSelection = await buildDomSelectionFromTarget(nextElement, {
|
|
104
|
+
previous: currentSelection,
|
|
105
|
+
});
|
|
100
106
|
if (nextSelection) {
|
|
101
107
|
applyDomSelection(nextSelection, { revealPanel: false, preserveGroup: true });
|
|
102
108
|
}
|
|
@@ -280,6 +280,33 @@ describe("useDomSelection", () => {
|
|
|
280
280
|
harness.cleanup();
|
|
281
281
|
});
|
|
282
282
|
|
|
283
|
+
it("keeps the shown selection when a group-preserving refresh reads nothing new", () => {
|
|
284
|
+
const element = document.createElement("div");
|
|
285
|
+
element.id = "headline";
|
|
286
|
+
const first = { ...makeSelection("Headline", element), dataAttributes: { start: "1" } };
|
|
287
|
+
const harness = renderHarness({
|
|
288
|
+
activeCompPath: "intro.html",
|
|
289
|
+
projectId: "project-1",
|
|
290
|
+
refreshKey: 0,
|
|
291
|
+
});
|
|
292
|
+
const refresh = (selection: typeof first) =>
|
|
293
|
+
act(() =>
|
|
294
|
+
harness.current().applyDomSelection(selection, { preserveGroup: true, revealPanel: false }),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
act(() => harness.current().applyDomSelection(first));
|
|
298
|
+
harness.timeline.setTimelineSelectionSet.mockClear();
|
|
299
|
+
refresh({ ...first, dataAttributes: { start: "1" } });
|
|
300
|
+
expect(harness.current().domEditSelection).toBe(first);
|
|
301
|
+
expect(harness.timeline.setTimelineSelectionSet).not.toHaveBeenCalled();
|
|
302
|
+
|
|
303
|
+
const moved = { ...first, dataAttributes: { start: "2" } };
|
|
304
|
+
refresh(moved);
|
|
305
|
+
expect(harness.current().domEditSelection).toBe(moved);
|
|
306
|
+
expect(harness.current().domEditGroupSelections).toEqual([moved]);
|
|
307
|
+
harness.cleanup();
|
|
308
|
+
});
|
|
309
|
+
|
|
283
310
|
it("clears a committed selection when the active composition path changes", () => {
|
|
284
311
|
const { selection, harness } = setupSelectedHarness();
|
|
285
312
|
expect(harness.current().domEditSelection).toBe(selection);
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
getPreviewTargetFromPointer,
|
|
6
6
|
} from "../utils/studioPreviewHelpers";
|
|
7
7
|
import {
|
|
8
|
+
domEditSelectionsEqual,
|
|
8
9
|
domEditSelectionsTargetSame,
|
|
9
10
|
domEditSelectionInGroup,
|
|
10
11
|
toggleDomEditGroupSelection,
|
|
@@ -23,6 +24,7 @@ import { logSelect } from "../utils/selectDebug";
|
|
|
23
24
|
import { announceTimelineSelection as announceSelectionToTimeline } from "./domSelectionTimelineMirror";
|
|
24
25
|
import type {
|
|
25
26
|
ApplyDomSelectionOptions,
|
|
27
|
+
ResolveDomSelectionOptions,
|
|
26
28
|
UseDomSelectionParams,
|
|
27
29
|
UseDomSelectionReturn,
|
|
28
30
|
} from "./useDomSelectionTypes";
|
|
@@ -125,6 +127,12 @@ export function useDomSelection({
|
|
|
125
127
|
previousGroup.length === 1 &&
|
|
126
128
|
domEditSelectionsTargetSame(currentSelection, selection) &&
|
|
127
129
|
domEditSelectionsTargetSame(previousGroup[0], selection);
|
|
130
|
+
const isUnchangedRefresh =
|
|
131
|
+
options?.preserveGroup &&
|
|
132
|
+
currentSelection !== null &&
|
|
133
|
+
(previousGroup.length === 0 || previousGroup.includes(currentSelection)) &&
|
|
134
|
+
domEditSelectionsEqual(currentSelection, selection);
|
|
135
|
+
if (isUnchangedRefresh) return;
|
|
128
136
|
if (isRepeatedSingleSelection) {
|
|
129
137
|
if (options?.revealPanel !== false) {
|
|
130
138
|
setRightCollapsed(false);
|
|
@@ -206,23 +214,14 @@ export function useDomSelection({
|
|
|
206
214
|
);
|
|
207
215
|
|
|
208
216
|
const buildDomSelectionFromTarget = useCallback(
|
|
209
|
-
(
|
|
210
|
-
target: HTMLElement,
|
|
211
|
-
options?: {
|
|
212
|
-
preferClipAncestor?: boolean;
|
|
213
|
-
skipSourceProbe?: boolean;
|
|
214
|
-
exactTarget?: boolean;
|
|
215
|
-
// Override the drill-in scope (used by canvas double-click to resolve the
|
|
216
|
-
// child inside a group before the activeGroupElement state has re-rendered).
|
|
217
|
-
activeGroupElement?: HTMLElement | null;
|
|
218
|
-
},
|
|
219
|
-
) => {
|
|
217
|
+
(target: HTMLElement, options?: ResolveDomSelectionOptions) => {
|
|
220
218
|
return resolveDomEditSelection(target, {
|
|
221
219
|
activeCompositionPath: activeCompPath,
|
|
222
220
|
isMasterView,
|
|
223
221
|
preferClipAncestor: options?.preferClipAncestor,
|
|
224
222
|
skipSourceProbe: options?.skipSourceProbe,
|
|
225
223
|
exactTarget: options?.exactTarget,
|
|
224
|
+
previous: options?.previous,
|
|
226
225
|
activeGroupElement:
|
|
227
226
|
options && "activeGroupElement" in options
|
|
228
227
|
? options.activeGroupElement
|
|
@@ -384,7 +383,7 @@ export function useDomSelection({
|
|
|
384
383
|
return;
|
|
385
384
|
}
|
|
386
385
|
|
|
387
|
-
const nextSelection = await buildDomSelectionFromTarget(element);
|
|
386
|
+
const nextSelection = await buildDomSelectionFromTarget(element, { previous: selection });
|
|
388
387
|
if (nextSelection) {
|
|
389
388
|
applyDomSelection(nextSelection, {
|
|
390
389
|
revealPanel: false,
|
|
@@ -411,7 +410,7 @@ export function useDomSelection({
|
|
|
411
410
|
for (const selection of selections) {
|
|
412
411
|
const element = findElementForSelection(doc, selection, activeCompPath);
|
|
413
412
|
if (!element) continue;
|
|
414
|
-
const nextSelection = await buildDomSelectionFromTarget(element);
|
|
413
|
+
const nextSelection = await buildDomSelectionFromTarget(element, { previous: selection });
|
|
415
414
|
if (nextSelection) nextGroup.push(nextSelection);
|
|
416
415
|
}
|
|
417
416
|
if (nextGroup.length === 0) return;
|
|
@@ -23,6 +23,7 @@ export interface ResolveDomSelectionOptions {
|
|
|
23
23
|
activeGroupElement?: HTMLElement | null;
|
|
24
24
|
/** Resolve this node itself instead of applying human group-capture behavior. */
|
|
25
25
|
exactTarget?: boolean;
|
|
26
|
+
previous?: DomEditSelection | null;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
// Shared by every hook that needs the project/timeline/panel context a DOM
|
|
@@ -5,12 +5,19 @@ import { createRoot, type Root } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
7
7
|
import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryActions";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
beginStudioPendingEdit,
|
|
10
|
+
setStudioPendingEditClaimClock,
|
|
11
|
+
trackStudioPendingEdit,
|
|
12
|
+
} from "../utils/studioPendingEdits";
|
|
9
13
|
|
|
10
14
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
15
|
|
|
12
16
|
let root: Root | null = null;
|
|
13
|
-
afterEach(() =>
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
act(() => root?.unmount());
|
|
19
|
+
setStudioPendingEditClaimClock(null);
|
|
20
|
+
});
|
|
14
21
|
|
|
15
22
|
type RestoreFiles = Record<string, { previous: string; restored: string }>;
|
|
16
23
|
type Prediction = { id: string; files: RestoreFiles };
|
|
@@ -26,12 +33,13 @@ function mount(
|
|
|
26
33
|
files?: RestoreFiles;
|
|
27
34
|
},
|
|
28
35
|
predicted?: Prediction,
|
|
36
|
+
claims: () => number = () => 7,
|
|
29
37
|
) {
|
|
30
38
|
const editHistory = {
|
|
31
39
|
undo: vi.fn<EditHistoryHandle["undo"]>(async () => result),
|
|
32
40
|
redo: vi.fn<EditHistoryHandle["redo"]>(async () => result),
|
|
33
41
|
predict: () => predicted ?? null,
|
|
34
|
-
claims
|
|
42
|
+
claims,
|
|
35
43
|
};
|
|
36
44
|
const putBack = vi.fn();
|
|
37
45
|
const deps = {
|
|
@@ -108,6 +116,49 @@ describe("useEditHistoryActions", () => {
|
|
|
108
116
|
});
|
|
109
117
|
});
|
|
110
118
|
|
|
119
|
+
it("reverts an edit still saving before painting an older predicted step", async () => {
|
|
120
|
+
const reapply = vi.fn();
|
|
121
|
+
const revert = vi.fn(() => reapply);
|
|
122
|
+
const saving = beginStudioPendingEdit(revert);
|
|
123
|
+
const { deps, actions } = mount(
|
|
124
|
+
{ ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e2" },
|
|
125
|
+
PREDICTED,
|
|
126
|
+
);
|
|
127
|
+
const undone = actions.undo();
|
|
128
|
+
expect(revert).toHaveBeenCalledTimes(1);
|
|
129
|
+
expect(deps.showHistoryRestoreNow).not.toHaveBeenCalled();
|
|
130
|
+
saving.settle();
|
|
131
|
+
await act(() => undone);
|
|
132
|
+
expect(deps.editHistory.undo).not.toHaveBeenCalled();
|
|
133
|
+
expect(reapply).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Claim counts: when the edit began, at the key, and once its save landed.
|
|
137
|
+
it.each([
|
|
138
|
+
["its claim counted while undo waited, the server undo is the shown revert", 7, 7, 8, 0],
|
|
139
|
+
["its claim counted before the key, the server undo is the shown revert", 7, 8, 8, 0],
|
|
140
|
+
["its claim never counted, the move is shown again", 7, 7, 7, 1],
|
|
141
|
+
])("an edit that lands while undo waits: %s", async (_, atBegin, atKey, atLand, reapplied) => {
|
|
142
|
+
let claimCount = atBegin;
|
|
143
|
+
setStudioPendingEditClaimClock(() => claimCount);
|
|
144
|
+
const reapply = vi.fn();
|
|
145
|
+
const saving = beginStudioPendingEdit(() => reapply);
|
|
146
|
+
claimCount = atKey;
|
|
147
|
+
const { deps, actions } = mount(
|
|
148
|
+
{ ok: true, label: "Undid: Move", paths: ["index.html"], undoes: "e8" },
|
|
149
|
+
PREDICTED,
|
|
150
|
+
() => claimCount,
|
|
151
|
+
);
|
|
152
|
+
const undone = actions.undo();
|
|
153
|
+
saving.settle(saving.adopt(() => Promise.resolve()));
|
|
154
|
+
claimCount = atLand;
|
|
155
|
+
await act(() => undone);
|
|
156
|
+
expect(deps.editHistory.undo).toHaveBeenCalledTimes(1);
|
|
157
|
+
expect(deps.editHistory.undo.mock.calls[0]![0].claimedAfter).toBe(atBegin);
|
|
158
|
+
expect(deps.showHistoryRestoreNow).not.toHaveBeenCalled();
|
|
159
|
+
expect(reapply).toHaveBeenCalledTimes(reapplied);
|
|
160
|
+
});
|
|
161
|
+
|
|
111
162
|
it("puts a shown step back when the server refuses it", async () => {
|
|
112
163
|
const { deps, actions } = mount(
|
|
113
164
|
{ ok: false, reason: "failed", message: "disk full" },
|
|
@@ -78,14 +78,14 @@ export function useEditHistoryActions({
|
|
|
78
78
|
const apply = useCallback(
|
|
79
79
|
async (direction: "undo" | "redo") => {
|
|
80
80
|
const noun = direction === "undo" ? "Undo" : "Redo";
|
|
81
|
-
//
|
|
82
|
-
const
|
|
81
|
+
// An edit still saving is newer than any step this tab predicts, so it paints back first.
|
|
82
|
+
const pendingEditShown = direction === "undo" ? paintBackNewestStudioPendingEdit() : null;
|
|
83
|
+
const predicted = pendingEditShown ? null : (editHistory.predict?.(direction) ?? null);
|
|
83
84
|
const predictedShown = predicted ? (showHistoryRestoreNow?.(predicted.files) ?? null) : null;
|
|
84
|
-
const pendingEditShown =
|
|
85
|
-
!predictedShown && direction === "undo" ? paintBackNewestStudioPendingEdit() : null;
|
|
86
85
|
const putBack = predictedShown ?? pendingEditShown?.showAgain;
|
|
87
86
|
const claimedAfter =
|
|
88
|
-
|
|
87
|
+
pendingEditShown?.claimsAtBegin ??
|
|
88
|
+
(direction === "undo" && hasStudioPendingEdits() ? editHistory.claims?.() : undefined);
|
|
89
89
|
let result: HistoryResult = { ok: false, reason: "failed" };
|
|
90
90
|
let serverSteppedShown = false;
|
|
91
91
|
let revertIsTheUndo = false;
|
|
@@ -99,9 +99,12 @@ export function useEditHistoryActions({
|
|
|
99
99
|
claimedAfter,
|
|
100
100
|
});
|
|
101
101
|
const stepped = Boolean(result.ok && result.label);
|
|
102
|
+
const editClaimed =
|
|
103
|
+
pendingEditShown !== null &&
|
|
104
|
+
(editHistory.claims?.() ?? 0) > pendingEditShown.claimsAtBegin;
|
|
102
105
|
serverSteppedShown = predictedShown
|
|
103
106
|
? stepped && result.undoes === predicted?.id
|
|
104
|
-
: stepped &&
|
|
107
|
+
: stepped && editClaimed;
|
|
105
108
|
} finally {
|
|
106
109
|
if (putBack && !serverSteppedShown && !revertIsTheUndo) putBack();
|
|
107
110
|
}
|