@hyperframes/studio 0.7.103 → 0.7.105
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-DUB-xtR5.js → hyperframes-player-Jx07OQgw.js} +1 -1
- package/dist/assets/{index-DHc-xHLb.js → index-CryZ0PGi.js} +1 -1
- package/dist/assets/{index-Cez_yvk5.js → index-Dqi2GmOp.js} +1 -1
- package/dist/assets/{index-CxC2vn2-.js → index-DuxKGsHZ.js} +214 -214
- 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
|
@@ -73,14 +73,159 @@ describe("applyPreviewSync", () => {
|
|
|
73
73
|
|
|
74
74
|
syncDragPreview(result(), reloadPreview);
|
|
75
75
|
|
|
76
|
-
expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(
|
|
77
|
+
FAKE_IFRAME,
|
|
78
|
+
"#a",
|
|
79
|
+
{
|
|
80
|
+
kind: "set",
|
|
81
|
+
props: { x: 10 },
|
|
82
|
+
},
|
|
83
|
+
undefined,
|
|
84
|
+
false,
|
|
85
|
+
);
|
|
80
86
|
expect(applySoftReload).not.toHaveBeenCalled();
|
|
81
87
|
expect(reloadPreview).not.toHaveBeenCalled();
|
|
82
88
|
});
|
|
83
89
|
|
|
90
|
+
it("instantPatches: patches every element the batch wrote, rendering once at the end", () => {
|
|
91
|
+
patchRuntimeTweenInPlace.mockReturnValue(true);
|
|
92
|
+
const reloadPreview = vi.fn();
|
|
93
|
+
|
|
94
|
+
applyPreviewSync(
|
|
95
|
+
FAKE_IFRAME,
|
|
96
|
+
result(),
|
|
97
|
+
{
|
|
98
|
+
label: "Move animated layer (group)",
|
|
99
|
+
softReload: true,
|
|
100
|
+
instantPatches: [
|
|
101
|
+
{ selector: "#a", change: { kind: "set" as const, props: { x: 1 } } },
|
|
102
|
+
{ selector: "#b", change: { kind: "set" as const, props: { x: 2 } } },
|
|
103
|
+
{ selector: "#c", change: { kind: "set" as const, props: { x: 3 } } },
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
reloadPreview,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Only the last patch re-renders — the earlier two defer their seek, so the
|
|
110
|
+
// group repaints once instead of once per member.
|
|
111
|
+
expect(patchRuntimeTweenInPlace.mock.calls.map((call) => [call[1], call[4]])).toEqual([
|
|
112
|
+
["#a", true],
|
|
113
|
+
["#b", true],
|
|
114
|
+
["#c", false],
|
|
115
|
+
]);
|
|
116
|
+
expect(applySoftReload).not.toHaveBeenCalled();
|
|
117
|
+
expect(reloadPreview).not.toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("applies both plural and singular patches when a caller supplies both", () => {
|
|
121
|
+
patchRuntimeTweenInPlace.mockReturnValue(true);
|
|
122
|
+
|
|
123
|
+
applyPreviewSync(
|
|
124
|
+
FAKE_IFRAME,
|
|
125
|
+
result(),
|
|
126
|
+
{
|
|
127
|
+
label: "mixed patch contract",
|
|
128
|
+
instantPatches: [
|
|
129
|
+
{ selector: "#group-a", change: { kind: "set" as const, props: { x: 1 } } },
|
|
130
|
+
],
|
|
131
|
+
instantPatch: {
|
|
132
|
+
selector: "#single-b",
|
|
133
|
+
change: { kind: "set" as const, props: { x: 2 } },
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
vi.fn(),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(patchRuntimeTweenInPlace.mock.calls.map((call) => [call[1], call[4]])).toEqual([
|
|
140
|
+
["#group-a", true],
|
|
141
|
+
["#single-b", false],
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("instantPatches: one patch that misses falls the whole batch back to the reload", () => {
|
|
146
|
+
patchRuntimeTweenInPlace.mockImplementation((_iframe, selector) => selector !== "#b");
|
|
147
|
+
applySoftReload.mockReturnValue("applied");
|
|
148
|
+
const reloadPreview = vi.fn();
|
|
149
|
+
|
|
150
|
+
applyPreviewSync(
|
|
151
|
+
FAKE_IFRAME,
|
|
152
|
+
result({ scriptText: "SCRIPT" }),
|
|
153
|
+
{
|
|
154
|
+
label: "Move animated layer (group)",
|
|
155
|
+
softReload: true,
|
|
156
|
+
instantPatches: [
|
|
157
|
+
{ selector: "#a", change: { kind: "set" as const, props: { x: 1 } } },
|
|
158
|
+
{ selector: "#b", change: { kind: "set" as const, props: { x: 2 } } },
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
reloadPreview,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// A half-patched preview is worse than a reloaded one: "#a" landed, "#b" did
|
|
165
|
+
// not, so the reload repaints both from the written source.
|
|
166
|
+
expect(applySoftReload).toHaveBeenCalled();
|
|
167
|
+
expect(trackStudioEvent).toHaveBeenCalledWith("gsap_instant_patch_fallback", {
|
|
168
|
+
selector: "#b",
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("carries a deferred patch miss into the final batch render", () => {
|
|
173
|
+
const previewFallbackLatch = { pending: false };
|
|
174
|
+
applySoftReload.mockReturnValue("applied");
|
|
175
|
+
const reloadPreview = vi.fn();
|
|
176
|
+
patchRuntimeTweenInPlace.mockReturnValueOnce(false).mockReturnValueOnce(true);
|
|
177
|
+
|
|
178
|
+
applyPreviewSync(
|
|
179
|
+
FAKE_IFRAME,
|
|
180
|
+
result({ scriptText: "SCRIPT" }),
|
|
181
|
+
{
|
|
182
|
+
label: "Move animated layer (group)",
|
|
183
|
+
softReload: true,
|
|
184
|
+
deferPreviewSync: true,
|
|
185
|
+
previewFallbackLatch,
|
|
186
|
+
instantPatch: { selector: "#missed", change: { kind: "set", props: { x: 1 } } },
|
|
187
|
+
},
|
|
188
|
+
reloadPreview,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
expect(previewFallbackLatch.pending).toBe(true);
|
|
192
|
+
expect(applySoftReload).not.toHaveBeenCalled();
|
|
193
|
+
|
|
194
|
+
applyPreviewSync(
|
|
195
|
+
FAKE_IFRAME,
|
|
196
|
+
result({ scriptText: "SCRIPT" }),
|
|
197
|
+
{
|
|
198
|
+
label: "Move animated layer (group)",
|
|
199
|
+
softReload: true,
|
|
200
|
+
previewFallbackLatch,
|
|
201
|
+
instantPatch: { selector: "#final", change: { kind: "set", props: { x: 2 } } },
|
|
202
|
+
},
|
|
203
|
+
reloadPreview,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
expect(previewFallbackLatch.pending).toBe(false);
|
|
207
|
+
expect(applySoftReload).toHaveBeenCalledTimes(1);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("falls back immediately when a deferred patch miss has no final-render latch", () => {
|
|
211
|
+
patchRuntimeTweenInPlace.mockReturnValue(false);
|
|
212
|
+
applySoftReload.mockReturnValue("applied");
|
|
213
|
+
|
|
214
|
+
applyPreviewSync(
|
|
215
|
+
FAKE_IFRAME,
|
|
216
|
+
result({ scriptText: "SCRIPT" }),
|
|
217
|
+
{
|
|
218
|
+
label: "Deferred standalone write",
|
|
219
|
+
softReload: true,
|
|
220
|
+
deferPreviewSync: true,
|
|
221
|
+
instantPatch: { selector: "#missed", change: { kind: "set", props: { x: 1 } } },
|
|
222
|
+
},
|
|
223
|
+
vi.fn(),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
expect(applySoftReload).toHaveBeenCalledTimes(1);
|
|
227
|
+
});
|
|
228
|
+
|
|
84
229
|
it("instantPatch + patch fails: falls back to the soft reload, passing onAsyncFailure", () => {
|
|
85
230
|
patchRuntimeTweenInPlace.mockReturnValue(false);
|
|
86
231
|
applySoftReload.mockReturnValue("applied");
|
|
@@ -338,10 +483,51 @@ describe("runCommit — instantPatch wiring", () => {
|
|
|
338
483
|
|
|
339
484
|
// The file already matched (changed:false) but the runtime patch deferred
|
|
340
485
|
// from the paired first commit must still land.
|
|
341
|
-
expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(
|
|
342
|
-
|
|
343
|
-
|
|
486
|
+
expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(
|
|
487
|
+
FAKE_IFRAME,
|
|
488
|
+
"#a",
|
|
489
|
+
{
|
|
490
|
+
kind: "set",
|
|
491
|
+
props: { x: 485, y: 311 },
|
|
492
|
+
},
|
|
493
|
+
undefined,
|
|
494
|
+
false,
|
|
495
|
+
);
|
|
496
|
+
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("no-op batch still applies every plural instant patch", async () => {
|
|
500
|
+
patchRuntimeTweenInPlace.mockReturnValue(true);
|
|
501
|
+
mockFetchResult({ changed: false });
|
|
502
|
+
const deps = renderCommitHook();
|
|
503
|
+
const batch = deps.api.commitMutation.batch;
|
|
504
|
+
if (!batch) throw new Error("batch capability missing");
|
|
505
|
+
|
|
506
|
+
await act(async () => {
|
|
507
|
+
await batch(
|
|
508
|
+
[
|
|
509
|
+
{
|
|
510
|
+
selection,
|
|
511
|
+
mutation: { type: "update-property", property: "x", value: 10 },
|
|
512
|
+
options: {
|
|
513
|
+
label: "Move layer",
|
|
514
|
+
instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
selection: { ...selection, id: "b", selector: "#b" },
|
|
519
|
+
mutation: { type: "update-property", property: "x", value: 20 },
|
|
520
|
+
options: {
|
|
521
|
+
label: "Move layer",
|
|
522
|
+
instantPatch: { selector: "#b", change: { kind: "set", props: { x: 20 } } },
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
],
|
|
526
|
+
{ label: "Move animated layer (group)" },
|
|
527
|
+
);
|
|
344
528
|
});
|
|
529
|
+
|
|
530
|
+
expect(patchRuntimeTweenInPlace.mock.calls.map((call) => call[1])).toEqual(["#a", "#b"]);
|
|
345
531
|
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
346
532
|
});
|
|
347
533
|
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
useGsapSaveFailureTelemetry,
|
|
37
37
|
useSafeGsapCommitMutation,
|
|
38
38
|
} from "./useSafeGsapCommitMutation";
|
|
39
|
+
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
39
40
|
|
|
40
41
|
async function mutateGsapScript(
|
|
41
42
|
projectId: string,
|
|
@@ -46,7 +47,7 @@ async function mutateGsapScript(
|
|
|
46
47
|
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(sourceFile)}`,
|
|
47
48
|
{
|
|
48
49
|
method: "POST",
|
|
49
|
-
headers: { "Content-Type": "application/json" },
|
|
50
|
+
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
50
51
|
body: JSON.stringify(mutation),
|
|
51
52
|
},
|
|
52
53
|
);
|
|
@@ -65,7 +66,7 @@ async function mutateGsapScriptBatch(
|
|
|
65
66
|
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations-batch/${encodeURIComponent(sourceFile)}`,
|
|
66
67
|
{
|
|
67
68
|
method: "POST",
|
|
68
|
-
headers: { "Content-Type": "application/json" },
|
|
69
|
+
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
69
70
|
body: JSON.stringify({ mutations }),
|
|
70
71
|
},
|
|
71
72
|
);
|
|
@@ -125,7 +126,10 @@ function finishUnchangedMutation(
|
|
|
125
126
|
reloadPreview: () => void,
|
|
126
127
|
): boolean {
|
|
127
128
|
if (result.changed !== false) return false;
|
|
128
|
-
if (
|
|
129
|
+
if (
|
|
130
|
+
!options.skipReload &&
|
|
131
|
+
(instantPatchesFor(options).length > 0 || options.previewFallbackLatch?.pending)
|
|
132
|
+
) {
|
|
129
133
|
applyPreviewSync(iframe, result, options, reloadPreview);
|
|
130
134
|
}
|
|
131
135
|
return true;
|
|
@@ -248,19 +252,37 @@ export function applyPreviewSync(
|
|
|
248
252
|
options: CommitMutationOptions,
|
|
249
253
|
reloadPreview: () => void,
|
|
250
254
|
): void {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
255
|
+
const patches = instantPatchesFor(options);
|
|
256
|
+
let needsFallback = options.previewFallbackLatch?.pending === true;
|
|
257
|
+
if (patches.length > 0) {
|
|
258
|
+
const deferSeek = options.deferPreviewSync === true;
|
|
259
|
+
const missed = patches.find(
|
|
260
|
+
(patch, index) =>
|
|
261
|
+
!patchRuntimeTweenInPlace(
|
|
262
|
+
iframe,
|
|
263
|
+
patch.selector,
|
|
264
|
+
patch.change,
|
|
265
|
+
undefined,
|
|
266
|
+
deferSeek || index < patches.length - 1,
|
|
267
|
+
),
|
|
256
268
|
);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
269
|
+
if (missed) {
|
|
270
|
+
// The instant path couldn't patch in place — record the fallback so we can
|
|
271
|
+
// track how often the fast path misses before the soft/full reload below.
|
|
272
|
+
trackStudioEvent("gsap_instant_patch_fallback", { selector: missed.selector });
|
|
273
|
+
needsFallback = true;
|
|
274
|
+
}
|
|
275
|
+
// Patched in place — elements are already correct on screen; no reload needed
|
|
276
|
+
// unless an earlier deferred batch left one member unpatched.
|
|
277
|
+
if (!needsFallback) return;
|
|
278
|
+
}
|
|
279
|
+
// Written, but the caller has more writes to make and will render after the last.
|
|
280
|
+
if (options.deferPreviewSync && options.previewFallbackLatch) {
|
|
281
|
+
options.previewFallbackLatch.pending = needsFallback;
|
|
282
|
+
return;
|
|
263
283
|
}
|
|
284
|
+
if (options.deferPreviewSync && !needsFallback) return;
|
|
285
|
+
if (options.previewFallbackLatch) options.previewFallbackLatch.pending = false;
|
|
264
286
|
if (options.softReload && result.scriptText) {
|
|
265
287
|
// A soft-reloadable edit escalates to a full iframe remount ONLY on the
|
|
266
288
|
// PERMANENT "cannot-soft-reload" result (the preview is genuinely stale/
|
|
@@ -280,6 +302,15 @@ export function applyPreviewSync(
|
|
|
280
302
|
}
|
|
281
303
|
}
|
|
282
304
|
|
|
305
|
+
function instantPatchesFor(
|
|
306
|
+
options: CommitMutationOptions,
|
|
307
|
+
): NonNullable<CommitMutationOptions["instantPatches"]> {
|
|
308
|
+
return [
|
|
309
|
+
...(options.instantPatches ?? []),
|
|
310
|
+
...(options.instantPatch ? [options.instantPatch] : []),
|
|
311
|
+
];
|
|
312
|
+
}
|
|
313
|
+
|
|
283
314
|
// oxfmt-ignore
|
|
284
315
|
// fallow-ignore-next-line complexity
|
|
285
316
|
export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, publishSdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) {
|
|
@@ -355,7 +386,13 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
|
|
355
386
|
);
|
|
356
387
|
if (!result) return;
|
|
357
388
|
options.onResult?.(result);
|
|
358
|
-
|
|
389
|
+
// Each call brings its own fast-path patch; the batch wrote them all, so the
|
|
390
|
+
// preview sync applies them all rather than just the last call's.
|
|
391
|
+
const instantPatches = calls
|
|
392
|
+
.map(({ options: callOptions }) => callOptions.instantPatch)
|
|
393
|
+
.filter((patch) => patch !== undefined);
|
|
394
|
+
const { instantPatch: _instantPatch, ...batchOptions } = options;
|
|
395
|
+
await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, instantPatches.length > 0 ? { ...batchOptions, instantPatches } : batchOptions);
|
|
359
396
|
}, [showToast, finalizeSuccessfulMutation]);
|
|
360
397
|
|
|
361
398
|
// Every GSAP-script commit is a read-modify-write of one file. Overlapping
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
buildStudioHash,
|
|
8
8
|
parseStudioUrlStateFromHash,
|
|
9
9
|
type StudioUrlSelectionState,
|
|
10
|
+
type StudioUrlSelectionTarget,
|
|
10
11
|
type StudioUrlState,
|
|
11
12
|
} from "../utils/studioUrlState";
|
|
12
13
|
|
|
@@ -22,6 +23,8 @@ interface UseStudioUrlStateParams {
|
|
|
22
23
|
rightCollapsed: boolean;
|
|
23
24
|
activeCompPathHydrated: boolean;
|
|
24
25
|
domEditSelection: DomEditSelection | null;
|
|
26
|
+
domEditGroupSelections: DomEditSelection[];
|
|
27
|
+
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
|
|
25
28
|
buildDomSelectionFromTarget: (
|
|
26
29
|
target: HTMLElement,
|
|
27
30
|
options?: { preferClipAncestor?: boolean },
|
|
@@ -38,8 +41,7 @@ interface UseStudioUrlStateParams {
|
|
|
38
41
|
initialState: StudioUrlState;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
function
|
|
42
|
-
if (!selection) return null;
|
|
44
|
+
function toPersistedTarget(selection: DomEditSelection): StudioUrlSelectionTarget | null {
|
|
43
45
|
if (!selection.id && !selection.selector) return null;
|
|
44
46
|
return {
|
|
45
47
|
sourceFile: selection.sourceFile || undefined,
|
|
@@ -49,12 +51,109 @@ function toPersistedSelection(selection: DomEditSelection | null): StudioUrlSele
|
|
|
49
51
|
};
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
function selectionTargetKey(selection: StudioUrlSelectionTarget): string {
|
|
55
|
+
return [
|
|
56
|
+
selection.sourceFile ?? "",
|
|
57
|
+
selection.id ?? "",
|
|
58
|
+
selection.selector ?? "",
|
|
59
|
+
selection.selectorIndex ?? "",
|
|
60
|
+
].join("|");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toPersistedSelection(
|
|
64
|
+
selection: DomEditSelection | null,
|
|
65
|
+
// Optional: a caller that only ever has one selection has nothing to add, and
|
|
66
|
+
// the URL must still carry that one rather than throwing on the way out.
|
|
67
|
+
group: DomEditSelection[] = [],
|
|
68
|
+
): StudioUrlSelectionState | null {
|
|
69
|
+
if (!selection) return null;
|
|
70
|
+
const primary = toPersistedTarget(selection);
|
|
71
|
+
if (!primary) return null;
|
|
72
|
+
// The primary is already carried by the top-level fields; the rest ride along so the link
|
|
73
|
+
// reopens the same multi-selection instead of a single element.
|
|
74
|
+
const primaryKey = selectionTargetKey(primary);
|
|
75
|
+
const members = new Map<string, StudioUrlSelectionTarget>();
|
|
76
|
+
for (const member of group) {
|
|
77
|
+
const target = toPersistedTarget(member);
|
|
78
|
+
if (!target) continue;
|
|
79
|
+
const key = selectionTargetKey(target);
|
|
80
|
+
if (key !== primaryKey) members.set(key, target);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
...primary,
|
|
84
|
+
group: members.size > 0 ? [...members.values()] : undefined,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
52
88
|
function replaceHash(nextHash: string) {
|
|
53
89
|
if (typeof window === "undefined") return;
|
|
54
90
|
if (window.location.hash === nextHash) return;
|
|
55
91
|
window.history.replaceState(null, "", nextHash);
|
|
56
92
|
}
|
|
57
93
|
|
|
94
|
+
interface ResolveUrlSelectionsParams {
|
|
95
|
+
doc: Document;
|
|
96
|
+
primaryElement: HTMLElement;
|
|
97
|
+
selection: StudioUrlSelectionState;
|
|
98
|
+
group: StudioUrlSelectionTarget[];
|
|
99
|
+
activeCompPath: string | null;
|
|
100
|
+
isCurrent: () => boolean;
|
|
101
|
+
buildDomSelection: UseStudioUrlStateParams["buildDomSelectionFromTarget"];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function findUrlSelectionElement(
|
|
105
|
+
doc: Document,
|
|
106
|
+
target: StudioUrlSelectionTarget,
|
|
107
|
+
fallbackSourceFile: string,
|
|
108
|
+
activeCompPath: string | null,
|
|
109
|
+
): HTMLElement | null {
|
|
110
|
+
return findElementForSelection(
|
|
111
|
+
doc,
|
|
112
|
+
{
|
|
113
|
+
sourceFile: target.sourceFile ?? fallbackSourceFile,
|
|
114
|
+
id: target.id,
|
|
115
|
+
selector: target.selector,
|
|
116
|
+
selectorIndex: target.selectorIndex,
|
|
117
|
+
},
|
|
118
|
+
activeCompPath,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function buildOptionalDomSelection(
|
|
123
|
+
element: HTMLElement | null,
|
|
124
|
+
buildDomSelection: UseStudioUrlStateParams["buildDomSelectionFromTarget"],
|
|
125
|
+
): Promise<DomEditSelection | null> {
|
|
126
|
+
if (!element) return null;
|
|
127
|
+
return buildDomSelection(element, { preferClipAncestor: false });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function resolveUrlSelections({
|
|
131
|
+
doc,
|
|
132
|
+
primaryElement,
|
|
133
|
+
selection,
|
|
134
|
+
group,
|
|
135
|
+
activeCompPath,
|
|
136
|
+
isCurrent,
|
|
137
|
+
buildDomSelection,
|
|
138
|
+
}: ResolveUrlSelectionsParams): Promise<DomEditSelection[] | null> {
|
|
139
|
+
const primary = await buildDomSelection(primaryElement, { preferClipAncestor: false });
|
|
140
|
+
if (!isCurrent()) return null;
|
|
141
|
+
if (!primary) return [];
|
|
142
|
+
const members = [primary];
|
|
143
|
+
for (const member of group) {
|
|
144
|
+
const element = findUrlSelectionElement(
|
|
145
|
+
doc,
|
|
146
|
+
member,
|
|
147
|
+
selection.sourceFile ?? "",
|
|
148
|
+
activeCompPath,
|
|
149
|
+
);
|
|
150
|
+
const resolved = await buildOptionalDomSelection(element, buildDomSelection);
|
|
151
|
+
if (!isCurrent()) return null;
|
|
152
|
+
if (resolved) members.push(resolved);
|
|
153
|
+
}
|
|
154
|
+
return members;
|
|
155
|
+
}
|
|
156
|
+
|
|
58
157
|
export function useStudioUrlState({
|
|
59
158
|
projectId,
|
|
60
159
|
activeCompPath,
|
|
@@ -67,6 +166,8 @@ export function useStudioUrlState({
|
|
|
67
166
|
rightCollapsed,
|
|
68
167
|
activeCompPathHydrated,
|
|
69
168
|
domEditSelection,
|
|
169
|
+
domEditGroupSelections,
|
|
170
|
+
applyMarqueeSelection,
|
|
70
171
|
buildDomSelectionFromTarget,
|
|
71
172
|
applyDomSelection,
|
|
72
173
|
setRightPanelTab,
|
|
@@ -82,6 +183,7 @@ export function useStudioUrlState({
|
|
|
82
183
|
const [selectionHydrated, setSelectionHydrated] = useState(initialState.selection == null);
|
|
83
184
|
const pendingSelectionRef = useRef(initialState.selection);
|
|
84
185
|
const stableTimeRef = useRef<number | null>(initialState.currentTime);
|
|
186
|
+
const selectionApplySeqRef = useRef(0);
|
|
85
187
|
|
|
86
188
|
const buildUrlState = useCallback(
|
|
87
189
|
(): StudioUrlState => ({
|
|
@@ -91,10 +193,10 @@ export function useStudioUrlState({
|
|
|
91
193
|
rightCollapsed,
|
|
92
194
|
timelineVisible: null,
|
|
93
195
|
selection: hydratedSelectionRef.current
|
|
94
|
-
? toPersistedSelection(domEditSelection)
|
|
196
|
+
? toPersistedSelection(domEditSelection, domEditGroupSelections)
|
|
95
197
|
: pendingSelectionRef.current,
|
|
96
198
|
}),
|
|
97
|
-
[activeCompPath, domEditSelection, rightCollapsed, rightPanelTab],
|
|
199
|
+
[activeCompPath, domEditGroupSelections, domEditSelection, rightCollapsed, rightPanelTab],
|
|
98
200
|
);
|
|
99
201
|
|
|
100
202
|
// Resolve a URL selection to a live element and apply it. Shared by the initial
|
|
@@ -103,6 +205,7 @@ export function useStudioUrlState({
|
|
|
103
205
|
// a missing element or null selection clears the selection and returns true.
|
|
104
206
|
const applyUrlSelection = useCallback(
|
|
105
207
|
(selection: StudioUrlSelectionState | null): boolean => {
|
|
208
|
+
const applySeq = ++selectionApplySeqRef.current;
|
|
106
209
|
if (!selection) {
|
|
107
210
|
applyDomSelection(null, { revealPanel: false });
|
|
108
211
|
return true;
|
|
@@ -128,12 +231,32 @@ export function useStudioUrlState({
|
|
|
128
231
|
applyDomSelection(null, { revealPanel: false });
|
|
129
232
|
return true;
|
|
130
233
|
}
|
|
131
|
-
|
|
132
|
-
|
|
234
|
+
const group = selection.group ?? [];
|
|
235
|
+
void resolveUrlSelections({
|
|
236
|
+
doc,
|
|
237
|
+
primaryElement: element,
|
|
238
|
+
selection,
|
|
239
|
+
group,
|
|
240
|
+
activeCompPath,
|
|
241
|
+
isCurrent: () => applySeq === selectionApplySeqRef.current,
|
|
242
|
+
buildDomSelection: buildDomSelectionFromTarget,
|
|
243
|
+
}).then((members) => {
|
|
244
|
+
if (!members) return;
|
|
245
|
+
const primary = members[0];
|
|
246
|
+
if (!primary) return applyDomSelection(null, { revealPanel: false });
|
|
247
|
+
if (group.length === 0) return applyDomSelection(primary, { revealPanel: false });
|
|
248
|
+
// Missing group members are dropped without failing the rest.
|
|
249
|
+
applyMarqueeSelection(members, false);
|
|
133
250
|
});
|
|
134
251
|
return true;
|
|
135
252
|
},
|
|
136
|
-
[
|
|
253
|
+
[
|
|
254
|
+
activeCompPath,
|
|
255
|
+
applyDomSelection,
|
|
256
|
+
applyMarqueeSelection,
|
|
257
|
+
buildDomSelectionFromTarget,
|
|
258
|
+
previewIframeRef,
|
|
259
|
+
],
|
|
137
260
|
);
|
|
138
261
|
|
|
139
262
|
useEffect(() => {
|
|
@@ -36,6 +36,7 @@ import { serializeZLaneGesture } from "../components/nle/zLaneGesture";
|
|
|
36
36
|
import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover";
|
|
37
37
|
import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes";
|
|
38
38
|
import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
|
|
39
|
+
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
39
40
|
|
|
40
41
|
type TimelineMoveUpdates = Pick<TimelineElement, "start" | "track"> & {
|
|
41
42
|
stackingReorder?: TimelineStackingReorderIntent | null;
|
|
@@ -412,7 +413,7 @@ export function useTimelineEditing({
|
|
|
412
413
|
`/api/projects/${pid}/file-mutations/remove-element/${encodeURIComponent(targetPath)}`,
|
|
413
414
|
{
|
|
414
415
|
method: "POST",
|
|
415
|
-
headers: { "Content-Type": "application/json" },
|
|
416
|
+
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
416
417
|
body: JSON.stringify({ target: patchTarget }),
|
|
417
418
|
},
|
|
418
419
|
);
|
|
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
|
|
|
2
2
|
import type { TimelineElement } from "../player";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
4
4
|
import { resolveTimelineIdForSelection } from "../utils/studioHelpers";
|
|
5
|
+
import { logSelect } from "../utils/selectDebug";
|
|
5
6
|
|
|
6
7
|
interface UseTimelineSelectionPreviewSyncParams {
|
|
7
8
|
selectedElementId: string | null;
|
|
@@ -93,6 +94,13 @@ export function useTimelineSelectionPreviewSync({
|
|
|
93
94
|
|
|
94
95
|
if (selectedIds.length === 0) {
|
|
95
96
|
missingSelectionKeyRef.current = "";
|
|
97
|
+
// The timeline holds nothing, so the canvas is about to hold nothing either.
|
|
98
|
+
// This is the path that silently drops a selection the user can still see.
|
|
99
|
+
logSelect("timeline-empty", {
|
|
100
|
+
had: currentIds.length,
|
|
101
|
+
previousKey: previousSelectedKey.length > 0,
|
|
102
|
+
clearing: previousSelectedKey.length > 0 && currentIds.length > 0,
|
|
103
|
+
});
|
|
96
104
|
if (previousSelectedKey.length > 0 && currentIds.length > 0) {
|
|
97
105
|
applyDomSelection(null, { revealPanel: false });
|
|
98
106
|
}
|
|
@@ -127,6 +135,11 @@ export function useTimelineSelectionPreviewSync({
|
|
|
127
135
|
return;
|
|
128
136
|
}
|
|
129
137
|
missingSelectionKeyRef.current = "";
|
|
138
|
+
logSelect("timeline-sync", {
|
|
139
|
+
wanted: selectedIds.length,
|
|
140
|
+
had: currentIds.length,
|
|
141
|
+
resolved: selections.length,
|
|
142
|
+
});
|
|
130
143
|
if (selections.length === 0) {
|
|
131
144
|
applyDomSelection(null, { revealPanel: false });
|
|
132
145
|
} else if (selections.length === 1) {
|
|
@@ -4,6 +4,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
|
|
4
4
|
import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
|
|
5
5
|
import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
|
|
6
6
|
import { useTimelinePlayerLoop } from "./useTimelinePlayerLoop";
|
|
7
|
+
import { logReload } from "../../utils/reloadDebug";
|
|
7
8
|
|
|
8
9
|
export type { ClipManifestClip } from "../lib/playbackTypes";
|
|
9
10
|
export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
|
|
@@ -445,6 +446,7 @@ export function useTimelinePlayer() {
|
|
|
445
446
|
const refreshPlayer = useCallback(() => {
|
|
446
447
|
const iframe = iframeRef.current;
|
|
447
448
|
if (!iframe) return;
|
|
449
|
+
logReload("refreshPlayer", () => ({ stack: new Error("refreshPlayer").stack }));
|
|
448
450
|
saveSeekPosition();
|
|
449
451
|
// Hide the iframe across the full reload so the user never sees the reloading
|
|
450
452
|
// document's RAW DOM (every clip stacked and visible) in the window between the
|