@hyperframes/studio 0.8.127 → 0.8.129

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 (119) hide show
  1. package/dist/assets/{hyperframes-player-uqzDaxVL.js → hyperframes-player-DfEcBl9r.js} +1 -1
  2. package/dist/assets/{index-DXSYlqmV.js → index-9ZaZFCMZ.js} +1 -1
  3. package/dist/assets/{index-CLBNYlDY.js → index-Bmy-KVib.js} +1 -1
  4. package/dist/assets/{index-COhbL1xq.js → index-C9YkEyN4.js} +189 -189
  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 +1396 -917
  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/commitPreflightHandles.test.tsx +32 -0
  20. package/src/components/editor/domEditingLayers.test.ts +45 -18
  21. package/src/components/editor/domEditingLayers.ts +11 -9
  22. package/src/components/editor/domEditingTypes.ts +1 -0
  23. package/src/components/editor/keyframeRetime.ts +11 -4
  24. package/src/components/editor/probeSourceElement.ts +17 -1
  25. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
  26. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
  27. package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
  28. package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
  29. package/src/hooks/gsapDragCommit.ts +8 -5
  30. package/src/hooks/gsapDragPositionCommit.ts +90 -17
  31. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
  32. package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
  33. package/src/hooks/gsapMutationClient.ts +35 -7
  34. package/src/hooks/gsapOvershootEase.test.ts +67 -0
  35. package/src/hooks/gsapParsedTween.ts +5 -6
  36. package/src/hooks/gsapPositionDetection.ts +5 -2
  37. package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
  38. package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
  39. package/src/hooks/gsapRuntimeKeyframes.ts +19 -59
  40. package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
  41. package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
  42. package/src/hooks/gsapShared.test.ts +48 -35
  43. package/src/hooks/gsapShared.ts +30 -32
  44. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +93 -3
  45. package/src/hooks/gsapValueAtPlayhead.test.ts +13 -0
  46. package/src/hooks/gsapValueAtPlayhead.ts +3 -4
  47. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  48. package/src/hooks/timelineEditingHelpers.ts +40 -14
  49. package/src/hooks/timelineLinkEditPlan.ts +6 -4
  50. package/src/hooks/timelineMoveAdapter.ts +3 -5
  51. package/src/hooks/timelineTimingSync.test.ts +83 -125
  52. package/src/hooks/timelineTimingSync.ts +82 -63
  53. package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
  54. package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
  55. package/src/hooks/useCommitPreflightCapabilities.ts +42 -7
  56. package/src/hooks/useDomEditPreviewSync.ts +8 -2
  57. package/src/hooks/useDomSelection.test.ts +27 -0
  58. package/src/hooks/useDomSelection.ts +12 -13
  59. package/src/hooks/useDomSelectionTypes.ts +1 -0
  60. package/src/hooks/useEditHistoryActions.test.tsx +54 -3
  61. package/src/hooks/useEditHistoryActions.ts +9 -6
  62. package/src/hooks/useEnableKeyframes.test.ts +20 -15
  63. package/src/hooks/useEnableKeyframes.ts +54 -71
  64. package/src/hooks/useGestureCommit.ts +2 -1
  65. package/src/hooks/useGsapTweenCache.ts +2 -17
  66. package/src/hooks/usePersistentEditHistory.test.ts +45 -0
  67. package/src/hooks/usePersistentEditHistory.ts +13 -2
  68. package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
  69. package/src/hooks/usePreviewPersistence.ts +26 -4
  70. package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
  71. package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
  72. package/src/hooks/useTimelineDeleteOps.ts +5 -13
  73. package/src/hooks/useTimelineEditing.test.tsx +63 -4
  74. package/src/hooks/useTimelineEditing.ts +21 -295
  75. package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
  76. package/src/hooks/useTimelineGroupEditing.ts +15 -38
  77. package/src/player/components/Timeline.test.ts +12 -4
  78. package/src/player/components/TimelineClip.tsx +3 -0
  79. package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
  80. package/src/player/components/TimelineLanes.test.tsx +61 -5
  81. package/src/player/components/TimelineLanes.tsx +20 -28
  82. package/src/player/components/TimelinePropertyLanes.tsx +10 -1
  83. package/src/player/components/timelineCallbacks.ts +1 -4
  84. package/src/player/components/timelineClipDragCommit.test.ts +31 -0
  85. package/src/player/components/timelineClipDragCommit.ts +39 -28
  86. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  87. package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
  88. package/src/player/components/timelineClipGestureHandlers.ts +2 -20
  89. package/src/player/components/timelineEditing.ts +1 -3
  90. package/src/player/components/timelineGroupResizeCommit.ts +10 -9
  91. package/src/player/components/timelineLinkSelection.ts +1 -35
  92. package/src/player/components/timelineOptimisticRevision.ts +7 -4
  93. package/src/player/components/trackHeaderLaneState.ts +9 -2
  94. package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
  95. package/src/player/components/trackHeaderLaneValues.ts +54 -13
  96. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
  97. package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
  98. package/src/player/components/useTimelineClipDrag.ts +0 -3
  99. package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
  100. package/src/player/store/batchElementUpdates.test.ts +43 -0
  101. package/src/player/store/batchElementUpdates.ts +9 -0
  102. package/src/player/store/elementPatchQueue.ts +35 -0
  103. package/src/player/store/playerStore.ts +5 -6
  104. package/src/utils/domEditHelpers.ts +5 -0
  105. package/src/utils/globalTimeCompiler.test.ts +0 -4
  106. package/src/utils/globalTimeCompiler.ts +6 -4
  107. package/src/utils/gsapKeyframeEases.ts +79 -0
  108. package/src/utils/gsapLiveRetime.realGsap.test.ts +544 -0
  109. package/src/utils/gsapLiveRetime.ts +286 -0
  110. package/src/utils/gsapSoftReload.ts +16 -7
  111. package/src/utils/gsapUndoRestore.test.ts +43 -0
  112. package/src/utils/gsapUndoRestore.ts +6 -3
  113. package/src/utils/sameData.ts +9 -0
  114. package/src/utils/studioFileHistory.ts +1 -0
  115. package/src/utils/studioPendingEdits.ts +15 -1
  116. package/dist/chunk-246VB2G3.js.map +0 -1
  117. package/dist/chunk-3F47EFCT.js.map +0 -1
  118. package/dist/chunk-5EKWJZLM.js.map +0 -1
  119. /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,
@@ -29,6 +30,7 @@ import {
29
30
  type StudioProjectFileWriter,
30
31
  } from "../utils/studioFileMutationCoordinator";
31
32
  import { studioApiFetch } from "../utils/studioApiFetch";
33
+ import { moveLiveTweens } from "../utils/gsapLiveRetime";
32
34
 
33
35
  export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
34
36
  if (targetPath.includes("\0") || targetPath.includes("..")) {
@@ -82,8 +84,8 @@ function rebindPreviewTiming(iframe: HTMLIFrameElement | null, currentTime: numb
82
84
  }
83
85
 
84
86
  /**
85
- * Sync the live preview after a TIMING-ONLY edit (move / resize), preferring a
86
- * soft reload over the full iframe reload that flashes every clip.
87
+ * Sync the live preview after a timing-only edit (move / resize): move the live tweens when only
88
+ * their timing changed, else a soft reload rather than the full reload that flashes every clip.
87
89
  *
88
90
  * Why this is safe WITHOUT re-deriving timeline elements: a move/resize commit has
89
91
  * already (a) patched the live DOM timing attributes, (b) updated the store's
@@ -115,32 +117,41 @@ function rebindPreviewTiming(iframe: HTMLIFrameElement | null, currentTime: numb
115
117
  * script is now stale, so a rebind against it would show wrong positions →
116
118
  * full-reload.
117
119
  */
118
- function syncTimingEditPreview(
120
+ async function syncTimingEditPreview(
119
121
  iframe: HTMLIFrameElement | null,
120
122
  outcome: GsapMutationStatus,
121
- currentTime: number,
123
+ projectId: string | null,
122
124
  reloadPreview: () => void,
123
125
  rebindWhenUnmutated: boolean,
124
- nestedFiles: Map<string, string> | null | undefined,
125
- ): void {
126
+ ): Promise<void> {
127
+ const currentTime = () => usePlayerStore.getState().currentTime;
126
128
  if (!outcome.mutated && rebindWhenUnmutated) {
127
- if (!rebindPreviewTiming(iframe, currentTime)) reloadPreview();
129
+ if (!rebindPreviewTiming(iframe, currentTime())) reloadPreview();
128
130
  return;
129
131
  }
130
- if (!iframe || !outcome.scriptText) {
132
+ const { scriptText } = outcome;
133
+ if (!iframe || !scriptText) {
131
134
  reloadPreview();
132
135
  return;
133
136
  }
134
- const result = applySoftReload(iframe, outcome.scriptText, {
137
+ if (moveLiveTweens(iframe, scriptText, currentTime(), reloadPreview)) return;
138
+ const nestedFiles = await settleNestedReads(
139
+ projectId
140
+ ? readNestedFiles(iframe, scriptText, (path) => readFileContent(projectId, path))
141
+ : null,
142
+ );
143
+ const result = applySoftReload(iframe, scriptText, {
135
144
  onAsyncFailure: reloadPreview,
136
- currentTimeOverride: currentTime,
145
+ currentTimeOverride: currentTime(),
137
146
  authoredHtml: outcome.after,
138
147
  nestedFiles,
139
148
  });
140
149
  if (result === "cannot-soft-reload") reloadPreview();
141
150
  }
142
151
 
143
- async function finishTimelineTimingFallback(input: {
152
+ const UNMUTATED: GsapMutationStatus = { mutated: false, scriptText: null };
153
+
154
+ export async function finishTimelineTimingFallback(input: {
144
155
  iframe: HTMLIFrameElement | null;
145
156
  projectId: string | null;
146
157
  reloadPreview: () => void;
@@ -155,7 +166,7 @@ async function finishTimelineTimingFallback(input: {
155
166
  */
156
167
  rebindWhenUnmutated: boolean;
157
168
  }): Promise<void> {
158
- let outcome: GsapMutationStatus = { mutated: false, scriptText: null };
169
+ let outcome = UNMUTATED;
159
170
  if (input.gsapMutation) {
160
171
  try {
161
172
  outcome = await input.gsapMutation();
@@ -167,27 +178,29 @@ async function finishTimelineTimingFallback(input: {
167
178
  return;
168
179
  }
169
180
  }
170
- const { projectId } = input;
171
- const nestedFiles = await settleNestedReads(
172
- projectId && outcome.scriptText
173
- ? readNestedFiles(input.iframe, outcome.scriptText, (path) =>
174
- readFileContent(projectId, path),
175
- )
176
- : null,
177
- );
178
- syncTimingEditPreview(
181
+ await syncTimingEditPreview(
179
182
  input.iframe,
180
183
  outcome,
181
- usePlayerStore.getState().currentTime,
184
+ input.projectId,
182
185
  input.reloadPreview,
183
186
  input.rebindWhenUnmutated,
184
- nestedFiles,
185
187
  );
186
188
  }
187
189
 
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;
190
+ // Held until the next edit: the key is the gesture's own, so only its GSAP fold can join it.
191
+ const UNTIL_NEXT_EDIT = Number.POSITIVE_INFINITY;
192
+ let timingGestures = 0;
193
+
194
+ /** One timing gesture's undo step, shared by its timing write and its GSAP fold however long the fold takes. */
195
+ export function timingGestureStep(
196
+ kind: string,
197
+ given?: { coalesceKey?: string },
198
+ ): { coalesceKey: string; coalesceMs: number } {
199
+ return {
200
+ coalesceKey: given?.coalesceKey ?? `${kind}:${++timingGestures}`,
201
+ coalesceMs: UNTIL_NEXT_EDIT,
202
+ };
203
+ }
191
204
 
192
205
  type OwnedMutationStep = {
193
206
  path: string;
@@ -265,7 +278,7 @@ async function foldGsapMutationInQueue(input: {
265
278
  const ownedSteps: OwnedMutationStep[] = [];
266
279
  const runOwnedMutation: OwnedMutationRunner = async (path, mutation) => {
267
280
  const pending = mutation();
268
- if (!pending) return { mutated: false, scriptText: null };
281
+ if (!pending) return UNMUTATED;
269
282
  const status = await pending;
270
283
  if (!status.mutated) return status;
271
284
  if (status.before === undefined || status.after === undefined) {
@@ -313,7 +326,7 @@ async function foldGsapMutationInQueue(input: {
313
326
  await input.recordEdit({
314
327
  label: input.label,
315
328
  coalesceKey: input.coalesceKey,
316
- coalesceMs: GSAP_HISTORY_COALESCE_MS,
329
+ coalesceMs: UNTIL_NEXT_EDIT,
317
330
  files,
318
331
  });
319
332
  }
@@ -325,7 +338,7 @@ async function foldGsapMutationInQueue(input: {
325
338
  }
326
339
 
327
340
  // The mutations, the ownership read and any rollback hold every touched file's queue.
328
- function foldGsapMutationIntoHistory(
341
+ export function foldGsapMutationIntoHistory(
329
342
  input: Parameters<typeof foldGsapMutationInQueue>[0] & {
330
343
  writeFile: StudioProjectFileWriter;
331
344
  paths: readonly string[];
@@ -336,6 +349,30 @@ function foldGsapMutationIntoHistory(
336
349
  );
337
350
  }
338
351
 
352
+ export function shiftGsapMutation(elementId: string | undefined, delta: number) {
353
+ if (delta === 0 || !elementId) return null;
354
+ return { type: "shift-positions", targetSelector: `#${elementId}`, delta };
355
+ }
356
+
357
+ export function scaleGsapMutation(
358
+ elementId: string | undefined,
359
+ oldStart: number,
360
+ oldDuration: number,
361
+ newStart: number,
362
+ newDuration: number,
363
+ ) {
364
+ if (!elementId || oldDuration <= 0 || newDuration <= 0) return null;
365
+ if (oldStart === newStart && oldDuration === newDuration) return null;
366
+ return {
367
+ type: "scale-positions",
368
+ targetSelector: `#${elementId}`,
369
+ oldStart,
370
+ oldDuration,
371
+ newStart,
372
+ newDuration,
373
+ };
374
+ }
375
+
339
376
  /**
340
377
  * Shift all GSAP animation positions targeting a given element by a time delta.
341
378
  * Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
@@ -347,20 +384,12 @@ export async function shiftGsapPositions(
347
384
  elementId: string,
348
385
  delta: number,
349
386
  ): 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
- );
387
+ const mutation = shiftGsapMutation(elementId, delta);
388
+ if (!mutation) return UNMUTATED;
389
+ return postGsapMutation(projectId, filePath, mutation, "shift-positions failed");
361
390
  }
362
391
 
363
- export async function scaleGsapPositions(
392
+ async function scaleGsapPositions(
364
393
  projectId: string,
365
394
  filePath: string,
366
395
  elementId: string,
@@ -369,23 +398,9 @@ export async function scaleGsapPositions(
369
398
  newStart: number,
370
399
  newDuration: number,
371
400
  ): 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
- );
401
+ const mutation = scaleGsapMutation(elementId, oldStart, oldDuration, newStart, newDuration);
402
+ if (!mutation) return UNMUTATED;
403
+ return postGsapMutation(projectId, filePath, mutation, "scale-positions failed");
389
404
  }
390
405
 
391
406
  /** The GSAP sync a committed SDK timing write made, or null when its timeline script is unchanged. */
@@ -502,8 +517,8 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
502
517
  activeCompPath: string | null;
503
518
  changes: readonly C[];
504
519
  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;
520
+ /** The change's GSAP mutation; null skips a change with no timing delta. */
521
+ mutationFor: (change: C) => Record<string, unknown> | null;
507
522
  sdkGsap?: GsapMutationStatus | null;
508
523
  }): Promise<void> {
509
524
  const activePath = input.activeCompPath || "index.html";
@@ -539,14 +554,18 @@ export async function finishGroupTimingGsapFallback<C extends { element: Timelin
539
554
  let mutated = false;
540
555
  let scriptText: GsapMutationStatus["scriptText"] = null;
541
556
  let after: string | undefined;
557
+ const byPath = new Map<string, Record<string, unknown>[]>();
542
558
  for (const change of input.changes) {
559
+ const mutation = input.mutationFor(change);
560
+ if (!mutation) continue;
543
561
  const changePath = input.resolveChangePath(change.element);
562
+ byPath.set(changePath, [...(byPath.get(changePath) ?? []), mutation]);
563
+ }
564
+ for (const [changePath, mutations] of byPath) {
544
565
  const status = await runOwnedMutation(changePath, () =>
545
- input.mutateChange(change, changePath),
566
+ postGsapMutations(input.projectId, changePath, mutations, input.errorLabel),
546
567
  );
547
568
  mutated = mutated || status.mutated;
548
- // The LAST mutation against the active comp carries the cumulative
549
- // rewritten script for that file.
550
569
  if (changePath === activePath) ({ scriptText, after } = status);
551
570
  }
552
571
  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,6 +3,7 @@ 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 { withTweenIndex } from "./gsapRuntimeTweenIndex";
6
7
  import { gsapEditBlockMessage, type GsapEditOutcome } from "./gsapEditOutcome";
7
8
  import { fetchParsedAnimations, parseCacheKey } from "./keyframeCacheAstLoad";
8
9
  import { getAnimationsForElement } from "./gsapElementMatch";
@@ -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 = [
@@ -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" },