@hyperframes/studio 0.7.102 → 0.7.104
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-BTnkvhf3.js → hyperframes-player-Bj2HAymq.js} +1 -1
- package/dist/assets/{index-CnrSn9Ua.js → index-8UzO6PkK.js} +1 -1
- package/dist/assets/{index-DN614u1l.js → index-DzfDa1ci.js} +212 -212
- package/dist/assets/{index-B4MocVKM.js → index-TDfRaO1Y.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +779 -294
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +6 -2
- package/src/captions/hooks/useCaptionSync.ts +2 -1
- package/src/components/editor/DomEditOverlay.test.ts +89 -2
- package/src/components/editor/DomEditOverlay.tsx +40 -23
- package/src/components/editor/domEditOverlayGeometry.test.ts +94 -0
- package/src/components/editor/domEditOverlayGeometry.ts +59 -2
- package/src/components/editor/domEditOverlayGestures.ts +42 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +17 -0
- package/src/components/editor/groupDragMove.ts +110 -0
- package/src/components/editor/groupDropKeepsSelection.test.ts +70 -0
- package/src/components/editor/manualEditsDom.ts +15 -13
- package/src/components/editor/manualOffsetDrag.test.ts +92 -5
- package/src/components/editor/manualOffsetDrag.ts +22 -17
- package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +64 -0
- package/src/components/editor/snapEngageTravel.test.ts +65 -0
- package/src/components/editor/snapEngine.ts +21 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +40 -53
- package/src/components/editor/useDomEditOverlayRects.ts +4 -2
- package/src/hooks/domSelectionTimelineMirror.ts +73 -0
- package/src/hooks/gestureTransaction.test.ts +7 -12
- package/src/hooks/gestureTransaction.ts +7 -20
- package/src/hooks/gsapRuntimePatch.test.ts +43 -0
- package/src/hooks/gsapRuntimePatch.ts +11 -1
- package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
- package/src/hooks/gsapScriptCommitTypes.ts +20 -0
- package/src/hooks/keyframeCacheAstLoad.test.ts +108 -0
- package/src/hooks/keyframeCacheAstLoad.ts +29 -1
- package/src/hooks/timelineTimingSync.ts +4 -1
- package/src/hooks/useDomEditCommits.ts +2 -1
- package/src/hooks/useDomEditCommitsHelpers.ts +2 -1
- package/src/hooks/useDomEditPreviewSync.ts +23 -0
- package/src/hooks/useDomEditSession.test.tsx +43 -62
- package/src/hooks/useDomEditSession.ts +10 -2
- package/src/hooks/useDomEditWiring.ts +6 -0
- package/src/hooks/useDomSelection.test.ts +133 -3
- package/src/hooks/useDomSelection.ts +67 -40
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +6 -2
- package/src/hooks/useElementLifecycleOps.ts +2 -1
- package/src/hooks/useExternalFileChangeCoordinator.ts +16 -3
- package/src/hooks/useFileManager.ts +2 -8
- package/src/hooks/useGroupCommits.ts +2 -1
- package/src/hooks/useGsapAnimationFetchFallback.ts +6 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +36 -0
- package/src/hooks/useGsapAwareEditing.ts +88 -15
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -5
- package/src/hooks/useGsapScriptCommits.test.tsx +193 -7
- package/src/hooks/useGsapScriptCommits.ts +52 -15
- package/src/hooks/useStudioUrlState.ts +130 -7
- package/src/hooks/useTimelineEditing.ts +2 -1
- package/src/hooks/useTimelineSelectionPreviewSync.ts +13 -0
- package/src/player/hooks/useTimelinePlayer.ts +2 -0
- package/src/utils/dragDebug.ts +98 -0
- package/src/utils/razorSplitTransaction.test.ts +15 -3
- package/src/utils/razorSplitTransaction.ts +2 -0
- package/src/utils/reloadDebug.ts +10 -0
- package/src/utils/resizeDebug.ts +19 -29
- package/src/utils/selectDebug.ts +9 -0
- package/src/utils/studioDebug.test.ts +32 -0
- package/src/utils/studioDebug.ts +30 -0
- package/src/utils/studioFileVersion.ts +16 -1
- package/src/utils/studioUrlState.test.ts +163 -1
- package/src/utils/studioUrlState.ts +60 -3
- package/src/utils/timelineCompositionInsert.ts +2 -1
|
@@ -4,11 +4,7 @@ import {
|
|
|
4
4
|
getAllPreviewTargetsFromPointer,
|
|
5
5
|
getPreviewTargetFromPointer,
|
|
6
6
|
} from "../utils/studioPreviewHelpers";
|
|
7
|
-
import {
|
|
8
|
-
findMatchingTimelineElementId,
|
|
9
|
-
findTimelineIdByAncestor,
|
|
10
|
-
type RightPanelTab,
|
|
11
|
-
} from "../utils/studioHelpers";
|
|
7
|
+
import { type RightPanelTab } from "../utils/studioHelpers";
|
|
12
8
|
import {
|
|
13
9
|
domEditSelectionsTargetSame,
|
|
14
10
|
domEditSelectionInGroup,
|
|
@@ -24,6 +20,8 @@ import {
|
|
|
24
20
|
} from "../components/editor/domEditing";
|
|
25
21
|
import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
|
|
26
22
|
import { useStudioTestHooks } from "./useStudioTestHooks";
|
|
23
|
+
import { logSelect } from "../utils/selectDebug";
|
|
24
|
+
import { announceTimelineSelection as announceSelectionToTimeline } from "./domSelectionTimelineMirror";
|
|
27
25
|
|
|
28
26
|
// ── Types ──
|
|
29
27
|
|
|
@@ -47,7 +45,10 @@ export interface UseDomSelectionParams {
|
|
|
47
45
|
captionEditMode: boolean;
|
|
48
46
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
49
47
|
timelineElements: TimelineElement[];
|
|
48
|
+
getTimelineSelectionSet: () => ReadonlySet<string>;
|
|
50
49
|
setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void;
|
|
50
|
+
/** Publishes a whole multi-selection to the timeline; the anchor is set separately. */
|
|
51
|
+
setTimelineSelectionSet: (ids: Set<string>) => void;
|
|
51
52
|
setRightCollapsed: (collapsed: boolean) => void;
|
|
52
53
|
setRightPanelTab: (tab: RightPanelTab) => void;
|
|
53
54
|
previewIframe: HTMLIFrameElement | null;
|
|
@@ -109,7 +110,9 @@ export function useDomSelection({
|
|
|
109
110
|
captionEditMode,
|
|
110
111
|
previewIframeRef,
|
|
111
112
|
timelineElements,
|
|
113
|
+
getTimelineSelectionSet,
|
|
112
114
|
setSelectedTimelineElementId,
|
|
115
|
+
setTimelineSelectionSet,
|
|
113
116
|
setRightCollapsed,
|
|
114
117
|
setRightPanelTab,
|
|
115
118
|
previewIframe,
|
|
@@ -145,6 +148,26 @@ export function useDomSelection({
|
|
|
145
148
|
|
|
146
149
|
// ── Callbacks ──
|
|
147
150
|
|
|
151
|
+
const announceTimelineSelection = useCallback(
|
|
152
|
+
(group: DomEditSelection[], primary: DomEditSelection | null) =>
|
|
153
|
+
announceSelectionToTimeline(
|
|
154
|
+
{
|
|
155
|
+
timelineElements,
|
|
156
|
+
getTimelineSelectionSet,
|
|
157
|
+
setSelectedTimelineElementId,
|
|
158
|
+
setTimelineSelectionSet,
|
|
159
|
+
},
|
|
160
|
+
group,
|
|
161
|
+
primary,
|
|
162
|
+
),
|
|
163
|
+
[
|
|
164
|
+
getTimelineSelectionSet,
|
|
165
|
+
setSelectedTimelineElementId,
|
|
166
|
+
setTimelineSelectionSet,
|
|
167
|
+
timelineElements,
|
|
168
|
+
],
|
|
169
|
+
);
|
|
170
|
+
|
|
148
171
|
const applyDomSelection = useCallback(
|
|
149
172
|
// fallow-ignore-next-line complexity
|
|
150
173
|
(
|
|
@@ -156,11 +179,12 @@ export function useDomSelection({
|
|
|
156
179
|
},
|
|
157
180
|
) => {
|
|
158
181
|
if (!selection) {
|
|
182
|
+
logSelect("clear", { hadGroup: domEditGroupSelectionsRef.current.length });
|
|
159
183
|
domEditSelectionRef.current = null;
|
|
160
184
|
domEditGroupSelectionsRef.current = [];
|
|
161
185
|
setDomEditSelection(null);
|
|
162
186
|
setDomEditGroupSelections([]);
|
|
163
|
-
|
|
187
|
+
announceTimelineSelection([], null);
|
|
164
188
|
return;
|
|
165
189
|
}
|
|
166
190
|
|
|
@@ -186,6 +210,13 @@ export function useDomSelection({
|
|
|
186
210
|
: (nextGroup[0] ?? null)
|
|
187
211
|
: selection;
|
|
188
212
|
|
|
213
|
+
logSelect("apply", {
|
|
214
|
+
additive: isAdditiveSelection,
|
|
215
|
+
target: selection.selector ?? selection.id ?? null,
|
|
216
|
+
wasInGroup,
|
|
217
|
+
prevGroup: previousGroup.length,
|
|
218
|
+
nextGroup: nextGroup.length,
|
|
219
|
+
});
|
|
189
220
|
domEditSelectionRef.current = nextSelection;
|
|
190
221
|
domEditGroupSelectionsRef.current = nextGroup;
|
|
191
222
|
setDomEditSelection(nextSelection);
|
|
@@ -208,21 +239,13 @@ export function useDomSelection({
|
|
|
208
239
|
setRightPanelTab("design");
|
|
209
240
|
}
|
|
210
241
|
}
|
|
211
|
-
|
|
212
|
-
findMatchingTimelineElementId(nextSelection, timelineElements) ??
|
|
213
|
-
findTimelineIdByAncestor(
|
|
214
|
-
nextSelection.element,
|
|
215
|
-
timelineElements,
|
|
216
|
-
nextSelection.sourceFile || "index.html",
|
|
217
|
-
);
|
|
218
|
-
// Late marquee notify: a primary already in the live set must not collapse it.
|
|
219
|
-
setSelectedTimelineElementId(nextSelectedTimelineId, { preserveSet: true });
|
|
242
|
+
announceTimelineSelection(nextGroup, nextSelection);
|
|
220
243
|
return;
|
|
221
244
|
}
|
|
222
245
|
|
|
223
|
-
|
|
246
|
+
announceTimelineSelection([], null);
|
|
224
247
|
},
|
|
225
|
-
[
|
|
248
|
+
[announceTimelineSelection, setRightCollapsed, setRightPanelTab],
|
|
226
249
|
);
|
|
227
250
|
|
|
228
251
|
const clearDomSelection = useCallback(() => {
|
|
@@ -375,6 +398,13 @@ export function useDomSelection({
|
|
|
375
398
|
[applyDomSelection, buildDomSelectionForTimelineElement],
|
|
376
399
|
);
|
|
377
400
|
|
|
401
|
+
// Forward handle to the group refresher defined below: the single-selection
|
|
402
|
+
// refresher falls back to it when the primary is gone, and a ref keeps that from
|
|
403
|
+
// forcing either callback to be declared in the other's dependency list.
|
|
404
|
+
const refreshDomEditGroupSelectionsFromPreviewRef = useRef<
|
|
405
|
+
(selections: DomEditSelection[]) => Promise<void>
|
|
406
|
+
>(async () => {});
|
|
407
|
+
|
|
378
408
|
const refreshDomEditSelectionFromPreview = useCallback(
|
|
379
409
|
// fallow-ignore-next-line complexity
|
|
380
410
|
async (selection: DomEditSelection) => {
|
|
@@ -389,6 +419,17 @@ export function useDomSelection({
|
|
|
389
419
|
|
|
390
420
|
const element = findElementForSelection(doc, selection, activeCompPath);
|
|
391
421
|
if (!element) {
|
|
422
|
+
// Losing the primary is not losing the selection. When a group is live,
|
|
423
|
+
// re-resolve it and keep whoever still exists rather than wiping the lot.
|
|
424
|
+
const group = domEditGroupSelectionsRef.current;
|
|
425
|
+
logSelect("refresh-lost", {
|
|
426
|
+
target: selection.selector ?? selection.id ?? null,
|
|
427
|
+
group: group.length,
|
|
428
|
+
});
|
|
429
|
+
if (group.length > 1) {
|
|
430
|
+
await refreshDomEditGroupSelectionsFromPreviewRef.current(group);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
392
433
|
applyDomSelection(null, { revealPanel: false });
|
|
393
434
|
return;
|
|
394
435
|
}
|
|
@@ -436,25 +477,17 @@ export function useDomSelection({
|
|
|
436
477
|
setDomEditSelection(nextSelection);
|
|
437
478
|
setDomEditGroupSelections(nextGroup);
|
|
438
479
|
|
|
439
|
-
|
|
440
|
-
setSelectedTimelineElementId(
|
|
441
|
-
findMatchingTimelineElementId(nextSelection, timelineElements),
|
|
442
|
-
);
|
|
443
|
-
} else {
|
|
444
|
-
setSelectedTimelineElementId(null);
|
|
445
|
-
}
|
|
480
|
+
announceTimelineSelection(nextGroup, nextSelection);
|
|
446
481
|
},
|
|
447
|
-
[
|
|
448
|
-
activeCompPath,
|
|
449
|
-
buildDomSelectionFromTarget,
|
|
450
|
-
setSelectedTimelineElementId,
|
|
451
|
-
timelineElements,
|
|
452
|
-
previewIframeRef,
|
|
453
|
-
],
|
|
482
|
+
[activeCompPath, announceTimelineSelection, buildDomSelectionFromTarget, previewIframeRef],
|
|
454
483
|
);
|
|
455
484
|
|
|
456
485
|
// ── Effects ──
|
|
457
486
|
|
|
487
|
+
useEffect(() => {
|
|
488
|
+
refreshDomEditGroupSelectionsFromPreviewRef.current = refreshDomEditGroupSelectionsFromPreview;
|
|
489
|
+
}, [refreshDomEditGroupSelectionsFromPreview]);
|
|
490
|
+
|
|
458
491
|
// Clear hover unconditionally on composition/project/preview change
|
|
459
492
|
// eslint-disable-next-line no-restricted-syntax
|
|
460
493
|
useEffect(() => {
|
|
@@ -503,6 +536,7 @@ export function useDomSelection({
|
|
|
503
536
|
const applyMarqueeSelection = useCallback(
|
|
504
537
|
// fallow-ignore-next-line complexity
|
|
505
538
|
(selections: DomEditSelection[], additive: boolean) => {
|
|
539
|
+
logSelect("marquee", { hits: selections.length, additive });
|
|
506
540
|
if (selections.length === 0) {
|
|
507
541
|
if (!additive) applyDomSelection(null, { revealPanel: false });
|
|
508
542
|
return;
|
|
@@ -527,16 +561,9 @@ export function useDomSelection({
|
|
|
527
561
|
domEditGroupSelectionsRef.current = nextGroup;
|
|
528
562
|
setDomEditSelection(nextSelection);
|
|
529
563
|
setDomEditGroupSelections(nextGroup);
|
|
530
|
-
|
|
531
|
-
findMatchingTimelineElementId(nextSelection, timelineElements) ??
|
|
532
|
-
findTimelineIdByAncestor(
|
|
533
|
-
nextSelection.element,
|
|
534
|
-
timelineElements,
|
|
535
|
-
nextSelection.sourceFile || "index.html",
|
|
536
|
-
);
|
|
537
|
-
setSelectedTimelineElementId(nextTimelineId);
|
|
564
|
+
announceTimelineSelection(nextGroup, nextSelection);
|
|
538
565
|
},
|
|
539
|
-
[applyDomSelection,
|
|
566
|
+
[applyDomSelection, announceTimelineSelection],
|
|
540
567
|
);
|
|
541
568
|
|
|
542
569
|
return {
|
|
@@ -49,6 +49,7 @@ interface HarnessProps {
|
|
|
49
49
|
iframe: HTMLIFrameElement | null;
|
|
50
50
|
timelineElements: TimelineElement[];
|
|
51
51
|
setSelectedTimelineElementId?: (id: string | null, options?: SelectElementOptions) => void;
|
|
52
|
+
setTimelineSelectionSet?: (ids: Set<string>) => void;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
function renderHarness(props: HarnessProps) {
|
|
@@ -66,7 +67,10 @@ function renderHarness(props: HarnessProps) {
|
|
|
66
67
|
captionEditMode: false,
|
|
67
68
|
previewIframeRef: { current: props.iframe },
|
|
68
69
|
timelineElements: props.timelineElements,
|
|
70
|
+
getTimelineSelectionSet: () => usePlayerStore.getState().selectedElementIds,
|
|
69
71
|
setSelectedTimelineElementId: props.setSelectedTimelineElementId ?? vi.fn(),
|
|
72
|
+
setTimelineSelectionSet:
|
|
73
|
+
props.setTimelineSelectionSet ?? usePlayerStore.getState().setSelectedElementIds,
|
|
70
74
|
setRightCollapsed: vi.fn(),
|
|
71
75
|
setRightPanelTab: props.setRightPanelTab,
|
|
72
76
|
previewIframe: props.iframe,
|
|
@@ -236,7 +240,7 @@ describe("useDomSelection — marquee multi-select survives the late async prima
|
|
|
236
240
|
iframe.remove();
|
|
237
241
|
});
|
|
238
242
|
|
|
239
|
-
it("collapses the set
|
|
243
|
+
it("collapses the set to a fresh single-click target instead of publishing an empty set", async () => {
|
|
240
244
|
const iframe = document.createElement("iframe");
|
|
241
245
|
document.body.append(iframe);
|
|
242
246
|
const doc = iframe.contentDocument!;
|
|
@@ -268,7 +272,7 @@ describe("useDomSelection — marquee multi-select survives the late async prima
|
|
|
268
272
|
await pending;
|
|
269
273
|
});
|
|
270
274
|
|
|
271
|
-
expect(usePlayerStore.getState().selectedElementIds
|
|
275
|
+
expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["d"]);
|
|
272
276
|
expect(usePlayerStore.getState().selectedElementId).toBe("d");
|
|
273
277
|
harness.cleanup();
|
|
274
278
|
iframe.remove();
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from "../components/editor/useLayerRevealOverride";
|
|
22
22
|
import type { CommitDomEditPatchBatches, DomEditPatchBatch } from "./domEditCommitTypes";
|
|
23
23
|
import { cutoverCommittedOrThrow, type CutoverResult } from "../utils/sdkCutover";
|
|
24
|
+
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
24
25
|
|
|
25
26
|
interface UseElementLifecycleOpsParams extends DomEditCommitBaseParams {
|
|
26
27
|
/** Route delete through SDK when session resolves the hf-id. */
|
|
@@ -115,7 +116,7 @@ export function useElementLifecycleOps({
|
|
|
115
116
|
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
|
116
117
|
{
|
|
117
118
|
method: "POST",
|
|
118
|
-
headers: { "Content-Type": "application/json" },
|
|
119
|
+
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
119
120
|
body: JSON.stringify({ target: patchTarget }),
|
|
120
121
|
},
|
|
121
122
|
);
|
|
@@ -4,6 +4,7 @@ import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
|
|
|
4
4
|
import type { ExternalConflictSnapshot } from "../utils/externalConflictStorage";
|
|
5
5
|
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
|
|
6
6
|
import { consumeStudioWriteToken } from "../utils/studioFileVersion";
|
|
7
|
+
import { logReload } from "../utils/reloadDebug";
|
|
7
8
|
|
|
8
9
|
type ExternalChangeDrainResult =
|
|
9
10
|
| { status: "clean" }
|
|
@@ -194,6 +195,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
194
195
|
|
|
195
196
|
const reloadAcceptedGeneration = useCallback(
|
|
196
197
|
(path: string) => {
|
|
198
|
+
logReload("reload", { path, by: "external-change coordinator" });
|
|
197
199
|
reloadPreview();
|
|
198
200
|
reloadSdkSession(path);
|
|
199
201
|
},
|
|
@@ -221,11 +223,22 @@ export function useExternalFileChangeCoordinator({
|
|
|
221
223
|
pendingTimelinePaths.delete(path);
|
|
222
224
|
|
|
223
225
|
const content = readFileChangeContent(payload);
|
|
224
|
-
|
|
225
|
-
|
|
226
|
+
const token = readFileChangeWriteToken(payload);
|
|
227
|
+
logReload("file-change", { path, token: token ?? null, hasContent: content != null });
|
|
228
|
+
if (consumeStudioWriteToken(token)) {
|
|
229
|
+
logReload("suppressed", { path, why: "own write token" });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (content != null && isSelfWriteEcho(path, content)) {
|
|
233
|
+
logReload("suppressed", { path, why: "own content echo" });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
226
236
|
|
|
227
237
|
const identity = eventIdentity(path, payload);
|
|
228
|
-
if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current)
|
|
238
|
+
if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) {
|
|
239
|
+
logReload("suppressed", { path, why: "duplicate event" });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
229
242
|
lastEventIdentityRef.current = identity;
|
|
230
243
|
const generation = ++generationRef.current;
|
|
231
244
|
const result = await drainPendingChanges();
|
|
@@ -10,11 +10,7 @@ import {
|
|
|
10
10
|
StudioFileConflictError,
|
|
11
11
|
StudioSaveNetworkError,
|
|
12
12
|
} from "../utils/studioSaveDiagnostics";
|
|
13
|
-
import {
|
|
14
|
-
createStudioWriteToken,
|
|
15
|
-
markStudioWriteToken,
|
|
16
|
-
studioExpectedFileVersion,
|
|
17
|
-
} from "../utils/studioFileVersion";
|
|
13
|
+
import { studioExpectedFileVersion, studioWriteHeaders } from "../utils/studioFileVersion";
|
|
18
14
|
import { useFileTree } from "./useFileTree";
|
|
19
15
|
import { useEditorSave } from "./useEditorSave";
|
|
20
16
|
|
|
@@ -124,8 +120,6 @@ export function useFileManager({
|
|
|
124
120
|
await retryStudioSave(async () => {
|
|
125
121
|
// Each request gets its own receipt identity. If a committed request loses its response,
|
|
126
122
|
// the retry can produce a second filesystem receipt that must be suppressed independently.
|
|
127
|
-
const writeToken = createStudioWriteToken();
|
|
128
|
-
markStudioWriteToken(writeToken);
|
|
129
123
|
let response: Response;
|
|
130
124
|
try {
|
|
131
125
|
response = await fetch(
|
|
@@ -134,7 +128,7 @@ export function useFileManager({
|
|
|
134
128
|
method: "PUT",
|
|
135
129
|
headers: {
|
|
136
130
|
"Content-Type": "text/plain",
|
|
137
|
-
|
|
131
|
+
...studioWriteHeaders(),
|
|
138
132
|
...(expectedVersion ? { "If-Match": expectedVersion } : { "If-None-Match": "*" }),
|
|
139
133
|
},
|
|
140
134
|
body: content,
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type DomEditCommitBaseParams,
|
|
6
6
|
} from "../utils/studioFileHistory";
|
|
7
7
|
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
|
|
8
|
+
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
8
9
|
|
|
9
10
|
interface UseGroupCommitsParams extends DomEditCommitBaseParams {
|
|
10
11
|
/** Resync the SDK session after a server-side write (the wrapper/unwrap changes
|
|
@@ -75,7 +76,7 @@ async function commitStructuralMutation(
|
|
|
75
76
|
`/api/projects/${pid}/file-mutations/${route}/${encodeURIComponent(targetPath)}`,
|
|
76
77
|
{
|
|
77
78
|
method: "POST",
|
|
78
|
-
headers: { "Content-Type": "application/json" },
|
|
79
|
+
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
79
80
|
body: JSON.stringify(body),
|
|
80
81
|
},
|
|
81
82
|
);
|
|
@@ -33,6 +33,8 @@ export type ElementAnimationsOutcome =
|
|
|
33
33
|
export interface GsapAnimationFetchOptions {
|
|
34
34
|
/** Refuse the edit when the parse endpoint is unavailable instead of treating it as no motion. */
|
|
35
35
|
failOnFetchError?: boolean;
|
|
36
|
+
/** Ignore an overlapping pre-write parse and read the source after a durable write. */
|
|
37
|
+
fresh?: boolean;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
/**
|
|
@@ -56,11 +58,13 @@ async function fetchElementAnimationsWithRetry(
|
|
|
56
58
|
gsapSourceFile: string,
|
|
57
59
|
target: { id: string | null; selector: string | null },
|
|
58
60
|
failOnFetchError: boolean,
|
|
61
|
+
fresh: boolean,
|
|
59
62
|
): Promise<GsapAnimation[]> {
|
|
60
63
|
let coldAttempts = 0;
|
|
61
64
|
let errorAttempts = 0;
|
|
62
65
|
for (;;) {
|
|
63
|
-
const parsed = await fetchParsedAnimations(projectId, gsapSourceFile);
|
|
66
|
+
const parsed = await fetchParsedAnimations(projectId, gsapSourceFile, { fresh });
|
|
67
|
+
fresh = false;
|
|
64
68
|
const outcome = selectElementAnimationsOrRetry(parsed, target);
|
|
65
69
|
if (outcome.kind === "resolved") return outcome.animations;
|
|
66
70
|
if (outcome.kind === "fetch-error") {
|
|
@@ -89,6 +93,7 @@ export function useGsapAnimationFetchFallback(projectId: string | null, gsapSour
|
|
|
89
93
|
gsapSourceFile,
|
|
90
94
|
target,
|
|
91
95
|
options?.failOnFetchError === true,
|
|
96
|
+
options?.fresh === true,
|
|
92
97
|
);
|
|
93
98
|
},
|
|
94
99
|
[projectId, gsapSourceFile],
|
|
@@ -301,6 +301,42 @@ describe("useGsapAwareEditing anchored resize", () => {
|
|
|
301
301
|
act(() => root.unmount());
|
|
302
302
|
});
|
|
303
303
|
|
|
304
|
+
it("reports only the first group preflight failure in input order", async () => {
|
|
305
|
+
const failures = [new Error("first blocked"), new Error("second blocked")];
|
|
306
|
+
const trackGsapInteractionFailure = vi.fn();
|
|
307
|
+
const priorDragImplementation = mocks.drag.getMockImplementation();
|
|
308
|
+
mocks.drag.mockImplementation(async (selection) => {
|
|
309
|
+
throw selection.id === "a" ? failures[0] : failures[1];
|
|
310
|
+
});
|
|
311
|
+
const { groupCommit, root } = mountGroupHandler({
|
|
312
|
+
gsapCommitMutation: vi.fn().mockResolvedValue(undefined),
|
|
313
|
+
makeFetchFallback: () => vi.fn().mockResolvedValue([]),
|
|
314
|
+
trackGsapInteractionFailure,
|
|
315
|
+
});
|
|
316
|
+
const updates = [
|
|
317
|
+
{
|
|
318
|
+
selection: { element: document.createElement("div"), id: "a", selector: "#a" },
|
|
319
|
+
next: { x: 10, y: 10 },
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
selection: { element: document.createElement("div"), id: "b", selector: "#b" },
|
|
323
|
+
next: { x: 20, y: 20 },
|
|
324
|
+
},
|
|
325
|
+
] as unknown as DomEditGroupPathOffsetCommit[];
|
|
326
|
+
|
|
327
|
+
await expect(groupCommit(updates)).rejects.toBe(failures[0]);
|
|
328
|
+
expect(trackGsapInteractionFailure).toHaveBeenCalledOnce();
|
|
329
|
+
expect(trackGsapInteractionFailure).toHaveBeenCalledWith(
|
|
330
|
+
failures[0],
|
|
331
|
+
updates[0]?.selection,
|
|
332
|
+
"drag",
|
|
333
|
+
"Move animated layer (group)",
|
|
334
|
+
);
|
|
335
|
+
mocks.drag.mockReset();
|
|
336
|
+
if (priorDragImplementation) mocks.drag.mockImplementation(priorDragImplementation);
|
|
337
|
+
act(() => root.unmount());
|
|
338
|
+
});
|
|
339
|
+
|
|
304
340
|
it("restores once when resize persistence fails", async () => {
|
|
305
341
|
const error = new Error("resize failed");
|
|
306
342
|
const restore = vi.fn();
|
|
@@ -24,7 +24,11 @@ import {
|
|
|
24
24
|
useGsapSaveFailureTelemetry,
|
|
25
25
|
useSafeGsapCommitMutation,
|
|
26
26
|
} from "./useSafeGsapCommitMutation";
|
|
27
|
-
import type {
|
|
27
|
+
import type {
|
|
28
|
+
CommitMutation,
|
|
29
|
+
CommitMutationCall,
|
|
30
|
+
CommitMutationOptions,
|
|
31
|
+
} from "./gsapScriptCommitTypes";
|
|
28
32
|
import { setElementGsapPosition } from "../utils/elementGsap";
|
|
29
33
|
import { logResize, logResizeSettle } from "../utils/resizeDebug";
|
|
30
34
|
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
|
@@ -37,6 +41,18 @@ import type { GsapAnimationFetchOptions } from "./useGsapAnimationFetchFallback"
|
|
|
37
41
|
// into one another's undo entry (module-local counter, not Date.now()).
|
|
38
42
|
let groupDragCommitCounter = 0;
|
|
39
43
|
|
|
44
|
+
function firstPreflightFailure(
|
|
45
|
+
results: PromiseSettledResult<void>[],
|
|
46
|
+
updates: DomEditGroupPathOffsetCommit[],
|
|
47
|
+
): { error: unknown; selection: DomEditSelection } | null {
|
|
48
|
+
for (const [index, result] of results.entries()) {
|
|
49
|
+
if (result.status !== "rejected") continue;
|
|
50
|
+
const selection = updates[index]?.selection;
|
|
51
|
+
if (selection) return { error: result.reason, selection };
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
export interface UseGsapAwareEditingParams {
|
|
41
57
|
domEditSelection: DomEditSelection | null;
|
|
42
58
|
selectedGsapAnimations: GsapAnimation[];
|
|
@@ -50,7 +66,7 @@ export interface UseGsapAwareEditingParams {
|
|
|
50
66
|
) => () => Promise<GsapAnimation[]>;
|
|
51
67
|
trackGsapInteractionFailure: (
|
|
52
68
|
error: unknown,
|
|
53
|
-
selection: DomEditSelection,
|
|
69
|
+
selection: DomEditSelection | null,
|
|
54
70
|
mutationType: string,
|
|
55
71
|
label: string,
|
|
56
72
|
) => void;
|
|
@@ -155,19 +171,54 @@ export function useGsapAwareEditing({
|
|
|
155
171
|
// it survives the N sequential server round-trips) onto each commit —
|
|
156
172
|
// otherwise each member records its own entry and it takes N presses to undo.
|
|
157
173
|
const coalesceKey = `group-drag:${++groupDragCommitCounter}`;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
174
|
+
// Members are written one at a time, and a write that re-renders the preview
|
|
175
|
+
// re-runs the whole script — which still holds the OLD position of every
|
|
176
|
+
// member not yet written. Those members snap back to where they started and
|
|
177
|
+
// stay there until their own write lands, which is the single element seen
|
|
178
|
+
// jumping mid-commit while the rest of the group sat still. The drafted
|
|
179
|
+
// positions are already on screen, so holding the render until the last
|
|
180
|
+
// member has been written costs nothing and never shows a half-moved group.
|
|
181
|
+
let renderOnCommit = false;
|
|
182
|
+
const previewFallbackLatch = { pending: false };
|
|
183
|
+
const withGroupOptions = (options: CommitMutationOptions): CommitMutationOptions => ({
|
|
184
|
+
...options,
|
|
185
|
+
coalesceKey,
|
|
186
|
+
coalesceMs: Number.POSITIVE_INFINITY,
|
|
187
|
+
deferPreviewSync: !renderOnCommit,
|
|
188
|
+
previewFallbackLatch,
|
|
189
|
+
});
|
|
190
|
+
// Every member writes the same file. Queue their mutations and send them as
|
|
191
|
+
// ONE request instead of one round trip per member: the server reads, parses
|
|
192
|
+
// and writes the composition once, and the preview patches once.
|
|
193
|
+
const queued: CommitMutationCall[] = [];
|
|
194
|
+
const flushQueued = async () => {
|
|
195
|
+
if (queued.length === 0) return;
|
|
196
|
+
const calls = queued.splice(0, queued.length);
|
|
197
|
+
if (!gsapCommitMutation.batch) {
|
|
198
|
+
for (const call of calls) {
|
|
199
|
+
await gsapCommitMutation(call.selection, call.mutation, call.options);
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
await gsapCommitMutation.batch(calls, {
|
|
204
|
+
...(calls.at(-1)?.options ?? { label: "Move animated layer (group)" }),
|
|
205
|
+
label: "Move animated layer (group)",
|
|
163
206
|
});
|
|
207
|
+
};
|
|
208
|
+
const coalescedCommit: typeof gsapCommitMutation = (selection, mutation, options) => {
|
|
209
|
+
queued.push({ selection, mutation, options: withGroupOptions(options) });
|
|
210
|
+
return Promise.resolve();
|
|
211
|
+
};
|
|
164
212
|
const preflightAnimations = new Map<DomEditSelection, GsapAnimation[]>();
|
|
165
213
|
// Editability is user-atomic: prove every member can be written before
|
|
166
214
|
// the first source mutation. Network failures after this point retain the
|
|
167
215
|
// existing multi-request semantics, but a blocked member can never leave
|
|
168
216
|
// earlier siblings partially moved.
|
|
169
|
-
|
|
170
|
-
|
|
217
|
+
// Every member reads the same file, and a preflight writes nothing — so run
|
|
218
|
+
// them together. The parse layer shares one in-flight request per file, which
|
|
219
|
+
// turns N sequential round trips into one.
|
|
220
|
+
const preflightResults = await Promise.allSettled(
|
|
221
|
+
updates.map(async ({ selection }) => {
|
|
171
222
|
const animations = await makeFetchFallback(selection, { failOnFetchError: true })();
|
|
172
223
|
preflightAnimations.set(selection, animations);
|
|
173
224
|
const outcome = await tryGsapDragIntercept(
|
|
@@ -180,12 +231,20 @@ export function useGsapAwareEditing({
|
|
|
180
231
|
{ preflightOnly: true },
|
|
181
232
|
);
|
|
182
233
|
assertGsapEditPersisted(outcome);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
const preflightFailure = firstPreflightFailure(preflightResults, updates);
|
|
237
|
+
if (preflightFailure) {
|
|
238
|
+
trackGsapInteractionFailure(
|
|
239
|
+
preflightFailure.error,
|
|
240
|
+
preflightFailure.selection,
|
|
241
|
+
"drag",
|
|
242
|
+
"Move animated layer (group)",
|
|
243
|
+
);
|
|
244
|
+
throw preflightFailure.error;
|
|
187
245
|
}
|
|
188
|
-
for (const { selection, next } of updates) {
|
|
246
|
+
for (const [index, { selection, next }] of updates.entries()) {
|
|
247
|
+
renderOnCommit = index === updates.length - 1;
|
|
189
248
|
try {
|
|
190
249
|
const outcome = await tryGsapDragIntercept(
|
|
191
250
|
selection,
|
|
@@ -193,7 +252,13 @@ export function useGsapAwareEditing({
|
|
|
193
252
|
preflightAnimations.get(selection) ?? [],
|
|
194
253
|
previewIframeRef.current,
|
|
195
254
|
coalescedCommit,
|
|
196
|
-
|
|
255
|
+
// The intercept re-reads the file to resolve a stale or shared tween.
|
|
256
|
+
// Anything already queued has to be on disk before that read, or it
|
|
257
|
+
// resolves against a file missing writes it is about to build on.
|
|
258
|
+
async () => {
|
|
259
|
+
await flushQueued();
|
|
260
|
+
return makeFetchFallback(selection, { fresh: true })();
|
|
261
|
+
},
|
|
197
262
|
{ preflightPassed: true },
|
|
198
263
|
);
|
|
199
264
|
assertGsapEditPersisted(outcome);
|
|
@@ -202,6 +267,14 @@ export function useGsapAwareEditing({
|
|
|
202
267
|
throw error;
|
|
203
268
|
}
|
|
204
269
|
}
|
|
270
|
+
try {
|
|
271
|
+
await flushQueued();
|
|
272
|
+
} catch (error) {
|
|
273
|
+
// The aggregate write has no uniquely failing member; do not misattribute
|
|
274
|
+
// its telemetry to whichever member happened to be last in the array.
|
|
275
|
+
trackGsapInteractionFailure(error, null, "drag", "Move animated layer (group)");
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
205
278
|
},
|
|
206
279
|
[gsapCommitMutation, previewIframeRef, makeFetchFallback, trackGsapInteractionFailure],
|
|
207
280
|
);
|
|
@@ -8,16 +8,16 @@ export function useGsapInteractionFailureTelemetry(
|
|
|
8
8
|
showToast: (message: string, tone?: "error" | "info") => void,
|
|
9
9
|
) {
|
|
10
10
|
return useCallback(
|
|
11
|
-
(error: unknown, selection: DomEditSelection, mutationType: string, label: string) => {
|
|
11
|
+
(error: unknown, selection: DomEditSelection | null, mutationType: string, label: string) => {
|
|
12
12
|
trackStudioSaveFailure({
|
|
13
13
|
source: "gsap_commit",
|
|
14
14
|
error,
|
|
15
|
-
filePath: selection
|
|
15
|
+
filePath: selection?.sourceFile ?? activeCompPath ?? "index.html",
|
|
16
16
|
mutationType,
|
|
17
17
|
label,
|
|
18
|
-
targetId: selection
|
|
19
|
-
targetSelector: selection
|
|
20
|
-
targetSourceFile: selection
|
|
18
|
+
targetId: selection?.id,
|
|
19
|
+
targetSelector: selection?.selector,
|
|
20
|
+
targetSourceFile: selection?.sourceFile,
|
|
21
21
|
});
|
|
22
22
|
showToast(
|
|
23
23
|
isGsapEditBlockedError(error) ? error.message : "Failed to save animated edit.",
|