@hyperframes/studio 0.8.126 → 0.8.128

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/assets/{hyperframes-player-BbbN-wWN.js → hyperframes-player-CKCx0CFA.js} +1 -1
  2. package/dist/assets/{index-Cll7uTNy.js → index-CF31Pq1E.js} +1 -1
  3. package/dist/assets/{index-B8kU2xXl.js → index-ClLzyuwb.js} +172 -172
  4. package/dist/assets/{index-nvLF29ZH.js → index-CwS08gWW.js} +1 -1
  5. package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
  6. package/dist/chunk-7UQWVN52.js.map +1 -0
  7. package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
  8. package/dist/chunk-DRVWEPO5.js.map +1 -0
  9. package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
  10. package/dist/chunk-TDWGXBUT.js.map +1 -0
  11. package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
  12. package/dist/index.d.ts +8 -9
  13. package/dist/index.html +1 -1
  14. package/dist/index.js +2184 -1745
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/package.json +8 -8
  18. package/src/components/TimelineToolbar.tsx +6 -7
  19. package/src/components/editor/MotionPathOverlay.layerPress.test.tsx +42 -0
  20. package/src/components/editor/MotionPathOverlay.tsx +20 -26
  21. package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
  22. package/src/components/editor/domEditingLayers.test.ts +45 -18
  23. package/src/components/editor/domEditingLayers.ts +11 -9
  24. package/src/components/editor/domEditingTypes.ts +1 -0
  25. package/src/components/editor/keyframeRetime.ts +11 -4
  26. package/src/components/editor/motionPathCommit.nodeDrop.test.ts +212 -0
  27. package/src/components/editor/motionPathCommit.ts +58 -0
  28. package/src/components/editor/motionPathGeometry.test.ts +13 -0
  29. package/src/components/editor/motionPathGeometry.ts +4 -2
  30. package/src/components/editor/probeSourceElement.ts +17 -1
  31. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
  32. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
  33. package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
  34. package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
  35. package/src/hooks/gsapDragCommit.ts +41 -6
  36. package/src/hooks/gsapDragPositionCommit.ts +90 -17
  37. package/src/hooks/gsapEditOutcome.ts +27 -2
  38. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
  39. package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
  40. package/src/hooks/gsapMutationClient.ts +35 -7
  41. package/src/hooks/gsapOvershootEase.test.ts +67 -0
  42. package/src/hooks/gsapParsedTween.test-helpers.ts +28 -1
  43. package/src/hooks/gsapParsedTween.ts +27 -7
  44. package/src/hooks/gsapPositionDetection.ts +5 -2
  45. package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
  46. package/src/hooks/gsapRuntimeBridge.ts +7 -1
  47. package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
  48. package/src/hooks/gsapRuntimeKeyframes.ts +20 -60
  49. package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
  50. package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
  51. package/src/hooks/gsapShared.test.ts +49 -35
  52. package/src/hooks/gsapShared.ts +35 -33
  53. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +198 -6
  54. package/src/hooks/gsapValueAtPlayhead.test.ts +142 -27
  55. package/src/hooks/gsapValueAtPlayhead.ts +49 -10
  56. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +72 -1
  57. package/src/hooks/gsapWholePropertyOffsetCommit.ts +6 -1
  58. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  59. package/src/hooks/timelineEditingHelpers.ts +40 -14
  60. package/src/hooks/timelineLinkEditPlan.ts +6 -4
  61. package/src/hooks/timelineMoveAdapter.ts +3 -5
  62. package/src/hooks/timelineTimingSync.test.ts +83 -125
  63. package/src/hooks/timelineTimingSync.ts +62 -42
  64. package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
  65. package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
  66. package/src/hooks/useCommitPreflightCapabilities.ts +44 -9
  67. package/src/hooks/useDomEditPreviewSync.ts +8 -2
  68. package/src/hooks/useDomSelection.test.ts +27 -0
  69. package/src/hooks/useDomSelection.ts +12 -13
  70. package/src/hooks/useDomSelectionTypes.ts +1 -0
  71. package/src/hooks/useEditHistoryActions.test.tsx +54 -3
  72. package/src/hooks/useEditHistoryActions.ts +9 -6
  73. package/src/hooks/useEnableKeyframes.test.ts +20 -15
  74. package/src/hooks/useEnableKeyframes.ts +54 -71
  75. package/src/hooks/useGestureCommit.ts +2 -1
  76. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +25 -0
  77. package/src/hooks/useGsapTweenCache.ts +2 -17
  78. package/src/hooks/usePersistentEditHistory.test.ts +45 -0
  79. package/src/hooks/usePersistentEditHistory.ts +13 -2
  80. package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
  81. package/src/hooks/usePreviewPersistence.ts +26 -4
  82. package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
  83. package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
  84. package/src/hooks/useTimelineDeleteOps.ts +5 -13
  85. package/src/hooks/useTimelineEditing.test.tsx +63 -4
  86. package/src/hooks/useTimelineEditing.ts +21 -295
  87. package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
  88. package/src/hooks/useTimelineGroupEditing.ts +15 -38
  89. package/src/player/components/Timeline.test.ts +12 -4
  90. package/src/player/components/TimelineClip.tsx +3 -0
  91. package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
  92. package/src/player/components/TimelineLanes.test.tsx +61 -5
  93. package/src/player/components/TimelineLanes.tsx +20 -28
  94. package/src/player/components/TimelinePropertyLanes.tsx +10 -1
  95. package/src/player/components/timelineCallbacks.ts +1 -4
  96. package/src/player/components/timelineClipDragCommit.test.ts +31 -0
  97. package/src/player/components/timelineClipDragCommit.ts +39 -28
  98. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  99. package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
  100. package/src/player/components/timelineClipGestureHandlers.ts +2 -20
  101. package/src/player/components/timelineEditing.ts +1 -3
  102. package/src/player/components/timelineGroupResizeCommit.ts +10 -9
  103. package/src/player/components/timelineLinkSelection.ts +1 -35
  104. package/src/player/components/timelineOptimisticRevision.ts +7 -4
  105. package/src/player/components/trackHeaderLaneState.ts +9 -2
  106. package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
  107. package/src/player/components/trackHeaderLaneValues.ts +54 -13
  108. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
  109. package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
  110. package/src/player/components/useTimelineClipDrag.ts +0 -3
  111. package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
  112. package/src/player/store/batchElementUpdates.test.ts +43 -0
  113. package/src/player/store/batchElementUpdates.ts +9 -0
  114. package/src/player/store/elementPatchQueue.ts +35 -0
  115. package/src/player/store/playerStore.ts +5 -6
  116. package/src/utils/domEditHelpers.ts +5 -0
  117. package/src/utils/globalTimeCompiler.test.ts +0 -4
  118. package/src/utils/globalTimeCompiler.ts +6 -4
  119. package/src/utils/gsapKeyframeEases.ts +79 -0
  120. package/src/utils/sameData.ts +9 -0
  121. package/src/utils/studioFileHistory.ts +1 -0
  122. package/src/utils/studioPendingEdits.ts +15 -1
  123. package/dist/chunk-246VB2G3.js.map +0 -1
  124. package/dist/chunk-3F47EFCT.js.map +0 -1
  125. package/dist/chunk-5EKWJZLM.js.map +0 -1
  126. /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
@@ -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
- async function finishTimelineTimingFallback(input: {
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: GsapMutationStatus = { mutated: false, scriptText: null };
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
- // Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
189
- // outlast one GSAP server round-trip, never a real second edit.
190
- const GSAP_HISTORY_COALESCE_MS = 10_000;
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 { mutated: false, scriptText: null };
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: GSAP_HISTORY_COALESCE_MS,
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
- if (delta === 0 || !elementId) return { mutated: false, scriptText: null };
351
- return postGsapMutation(
352
- projectId,
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
- export async function scaleGsapPositions(
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
- if (!elementId || oldDuration <= 0 || newDuration <= 0)
373
- return { mutated: false, scriptText: null };
374
- if (oldStart === newStart && oldDuration === newDuration)
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
- /** Per-change GSAP mutation; return null to skip a change with no timing delta. */
506
- mutateChange: (change: C, changePath: string) => Promise<GsapMutationStatus> | null;
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.mutateChange(change, changePath),
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 absTime = ts + (kf.percentage / 100) * td;
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, intent: BlockedTimelineEditIntent) => void {
16
+ ): (element: TimelineElement) => void {
21
17
  const lastAtRef = useRef(0);
22
18
  return useCallback(
23
- (_element: TimelineElement, intent: BlockedTimelineEditIntent) => {
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 { GSAP_EDIT_BLOCK_COPY, type GsapEditOutcome } from "./gsapEditOutcome";
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" ? GSAP_EDIT_BLOCK_COPY[outcome.reason] : null;
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
- const preflight = animations
130
- ? runCommitPreflights(resolvedOf(target), animations, previewIframeRef.current, group)
131
- : null;
132
- return narrowCapabilities(target, preflight);
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: (element: HTMLElement) => Promise<DomEditSelection | null>;
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 { trackStudioPendingEdit } from "../utils/studioPendingEdits";
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(() => act(() => root?.unmount()));
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: () => 7,
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
- // Paint the step in the key's own task when this tab knows it; the server's answer then confirms or corrects.
82
- const predicted = editHistory.predict?.(direction) ?? null;
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
- direction === "undo" && hasStudioPendingEdits() ? editHistory.claims?.() : undefined;
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 && Boolean(pendingEditShown);
107
+ : stepped && editClaimed;
105
108
  } finally {
106
109
  if (putBack && !serverSteppedShown && !revertIsTheUndo) putBack();
107
110
  }