@hyperframes/studio 0.6.95 → 0.6.96
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/index-B0twsRu0.css +1 -0
- package/dist/assets/index-BA979yF1.js +251 -0
- package/dist/assets/{index-CAANLw9Q.js → index-BWFaypdT.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +10 -5
- package/src/components/SaveQueuePausedBanner.tsx +23 -0
- package/src/components/StudioPreviewArea.tsx +7 -0
- package/src/components/StudioRightPanel.tsx +1 -38
- package/src/components/editor/DomEditOverlay.test.ts +169 -29
- package/src/components/editor/DomEditOverlay.tsx +13 -23
- package/src/components/editor/GestureRecordControl.tsx +98 -0
- package/src/components/editor/PropertyPanel.tsx +22 -38
- package/src/components/editor/domEditing.test.ts +84 -0
- package/src/components/editor/domEditingLayers.ts +19 -0
- package/src/components/editor/domEditingRootLayer.ts +64 -0
- package/src/components/editor/manualEditingAvailability.test.ts +1 -2
- package/src/components/editor/manualEditingAvailability.ts +0 -7
- package/src/contexts/DomEditContext.tsx +1 -6
- package/src/hooks/gsapScriptCommitHelpers.ts +128 -0
- package/src/hooks/useDomEditCommits.ts +97 -123
- package/src/hooks/useDomEditPositionPatchCommit.ts +53 -0
- package/src/hooks/useDomEditSession.ts +59 -65
- package/src/hooks/useFileManager.ts +19 -5
- package/src/hooks/useGsapAnimationFetchFallback.ts +19 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +25 -0
- package/src/hooks/useGsapScriptCommits.ts +152 -140
- package/src/hooks/useGsapSelectionHandlers.ts +38 -8
- package/src/hooks/usePreviewPersistence.ts +90 -51
- package/src/hooks/useSafeGsapCommitMutation.ts +66 -0
- package/src/hooks/useStudioContextValue.ts +3 -19
- package/src/player/hooks/useTimelinePlayer.ts +25 -28
- package/src/player/lib/playbackAdapter.test.ts +86 -1
- package/src/player/lib/playbackAdapter.ts +62 -0
- package/src/utils/domEditSaveQueue.test.ts +117 -0
- package/src/utils/domEditSaveQueue.ts +87 -0
- package/src/utils/studioHelpers.ts +1 -1
- package/src/utils/studioSaveDiagnostics.test.ts +127 -0
- package/src/utils/studioSaveDiagnostics.ts +200 -0
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/studioUrlState.ts +2 -8
- package/dist/assets/index-DujOjou6.js +0 -251
- package/dist/assets/index-rm9tn9nH.css +0 -1
- package/src/components/editor/EaseCurveEditor.tsx +0 -221
- package/src/components/editor/MotionPanel.tsx +0 -277
- package/src/components/editor/MotionPanelFields.tsx +0 -185
- package/src/components/editor/MotionPathOverlay.tsx +0 -146
- package/src/components/editor/SpringEaseEditor.tsx +0 -256
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
|
+
import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
|
|
2
3
|
import { usePlayerStore } from "../player";
|
|
3
4
|
import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
4
5
|
import { FONT_EXT } from "../utils/mediaTypes";
|
|
@@ -6,6 +7,7 @@ import type { PatchOperation } from "../utils/sourcePatcher";
|
|
|
6
7
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
7
8
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
8
9
|
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
|
|
10
|
+
import { createStudioSaveHttpError } from "../utils/studioSaveDiagnostics";
|
|
9
11
|
import {
|
|
10
12
|
buildDomEditPatchTarget,
|
|
11
13
|
getDomEditTargetKey,
|
|
@@ -27,23 +29,40 @@ import {
|
|
|
27
29
|
buildClearPathOffsetPatches,
|
|
28
30
|
buildClearBoxSizePatches,
|
|
29
31
|
buildClearRotationPatches,
|
|
30
|
-
buildMotionPatches,
|
|
31
|
-
buildClearMotionPatches,
|
|
32
32
|
} from "../components/editor/manualEditsDom";
|
|
33
|
-
import {
|
|
34
|
-
writeStudioMotionToElement,
|
|
35
|
-
clearStudioMotionFromElement,
|
|
36
|
-
applyStudioMotionFromDom,
|
|
37
|
-
type StudioGsapMotion,
|
|
38
|
-
} from "../components/editor/studioMotion";
|
|
39
33
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
40
34
|
import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
|
|
41
35
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
36
|
+
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
|
|
42
37
|
import { useDomEditTextCommits } from "./useDomEditTextCommits";
|
|
43
38
|
|
|
44
39
|
// ── Helpers ──
|
|
45
40
|
type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
|
|
46
41
|
|
|
42
|
+
function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
|
|
43
|
+
return fields.map((field) => field.path).join(", ");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readErrorResponseBody(
|
|
47
|
+
response: Response,
|
|
48
|
+
): Promise<{ error?: string; fields?: string[] } | null> {
|
|
49
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
50
|
+
if (!contentType.includes("application/json")) return null;
|
|
51
|
+
return (await response.json().catch(() => null)) as { error?: string; fields?: string[] } | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string {
|
|
55
|
+
if (!body?.error) return "Couldn't save edit";
|
|
56
|
+
const fields = Array.isArray(body.fields)
|
|
57
|
+
? body.fields.filter((field): field is string => typeof field === "string")
|
|
58
|
+
: [];
|
|
59
|
+
const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : "";
|
|
60
|
+
return `Couldn't save edit: ${body.error}${suffix}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const GSAP_CSS_FALLBACK_BLOCKED_MESSAGE =
|
|
64
|
+
"This element is GSAP-animated — dragging via CSS would corrupt keyframes";
|
|
65
|
+
|
|
47
66
|
// fallow-ignore-next-line complexity
|
|
48
67
|
function isElementGsapTargeted(iframe: HTMLIFrameElement | null, element: HTMLElement): boolean {
|
|
49
68
|
// When the GSAP drag intercept is disabled for debugging, treat every
|
|
@@ -183,7 +202,9 @@ export function useDomEditCommits({
|
|
|
183
202
|
const readResponse = await fetch(
|
|
184
203
|
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
185
204
|
);
|
|
186
|
-
if (!readResponse.ok)
|
|
205
|
+
if (!readResponse.ok) {
|
|
206
|
+
throw await createStudioSaveHttpError(readResponse, `Failed to read ${targetPath}`);
|
|
207
|
+
}
|
|
187
208
|
const readData = (await readResponse.json()) as { content?: string };
|
|
188
209
|
const originalContent = readData.content;
|
|
189
210
|
if (typeof originalContent !== "string") {
|
|
@@ -193,6 +214,13 @@ export function useDomEditCommits({
|
|
|
193
214
|
if (options?.shouldSave && !options.shouldSave()) return;
|
|
194
215
|
|
|
195
216
|
const patchTarget = buildDomEditPatchTarget(selection);
|
|
217
|
+
const patchBody = { target: patchTarget, operations };
|
|
218
|
+
const unsafeFields = findUnsafeDomPatchValues(patchBody);
|
|
219
|
+
if (unsafeFields.length > 0) {
|
|
220
|
+
const fields = formatUnsafeFieldList(unsafeFields);
|
|
221
|
+
showToast("Couldn't save edit because it contains invalid layout values", "error");
|
|
222
|
+
throw new Error(`DOM patch contains unsafe values: ${fields}`);
|
|
223
|
+
}
|
|
196
224
|
|
|
197
225
|
// Mark the save timestamp before the file write so the SSE file-change
|
|
198
226
|
// handler suppresses the reload even if the event arrives before the
|
|
@@ -204,10 +232,13 @@ export function useDomEditCommits({
|
|
|
204
232
|
{
|
|
205
233
|
method: "POST",
|
|
206
234
|
headers: { "Content-Type": "application/json" },
|
|
207
|
-
body: JSON.stringify(
|
|
235
|
+
body: JSON.stringify(patchBody),
|
|
208
236
|
},
|
|
209
237
|
);
|
|
210
|
-
if (!patchResponse.ok)
|
|
238
|
+
if (!patchResponse.ok) {
|
|
239
|
+
showToast(formatPatchRejectionMessage(await readErrorResponseBody(patchResponse)), "error");
|
|
240
|
+
throw await createStudioSaveHttpError(patchResponse, `Failed to patch ${targetPath}`);
|
|
241
|
+
}
|
|
211
242
|
|
|
212
243
|
const patchData = (await patchResponse.json()) as {
|
|
213
244
|
ok?: boolean;
|
|
@@ -265,6 +296,7 @@ export function useDomEditCommits({
|
|
|
265
296
|
projectIdRef,
|
|
266
297
|
domEditSaveTimestampRef,
|
|
267
298
|
reloadPreview,
|
|
299
|
+
showToast,
|
|
268
300
|
],
|
|
269
301
|
);
|
|
270
302
|
|
|
@@ -290,93 +322,88 @@ export function useDomEditCommits({
|
|
|
290
322
|
resolveImportedFontAsset,
|
|
291
323
|
});
|
|
292
324
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
patches: PatchOperation[],
|
|
300
|
-
options: { label: string; coalesceKey: string; skipRefresh?: boolean },
|
|
301
|
-
) => {
|
|
302
|
-
void queueDomEditSave(async () => {
|
|
303
|
-
await persistDomEditOperations(selection, patches, {
|
|
304
|
-
label: options.label,
|
|
305
|
-
coalesceKey: options.coalesceKey,
|
|
306
|
-
skipRefresh: options.skipRefresh ?? true,
|
|
307
|
-
});
|
|
308
|
-
// fallow-ignore-next-line complexity
|
|
309
|
-
}).catch((error) => {
|
|
310
|
-
const message = error instanceof Error ? error.message : "Failed to save position";
|
|
311
|
-
showToast(message);
|
|
312
|
-
trackStudioEvent("save_failure", {
|
|
313
|
-
source: "dom_edit",
|
|
314
|
-
label: options.label,
|
|
315
|
-
error_message: message,
|
|
316
|
-
target_id: selection.id ?? undefined,
|
|
317
|
-
target_selector: selection.selector ?? undefined,
|
|
318
|
-
target_source_file: selection.sourceFile ?? undefined,
|
|
319
|
-
});
|
|
320
|
-
});
|
|
321
|
-
},
|
|
322
|
-
[persistDomEditOperations, queueDomEditSave, showToast],
|
|
323
|
-
);
|
|
325
|
+
const commitPositionPatchToHtml = useDomEditPositionPatchCommit({
|
|
326
|
+
activeCompPath,
|
|
327
|
+
persistDomEditOperations,
|
|
328
|
+
queueDomEditSave,
|
|
329
|
+
showToast,
|
|
330
|
+
});
|
|
324
331
|
|
|
325
332
|
// ── Position commits ──
|
|
326
333
|
|
|
327
334
|
const handleDomPathOffsetCommit = useCallback(
|
|
328
335
|
(selection: DomEditSelection, next: { x: number; y: number }) => {
|
|
336
|
+
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
|
|
337
|
+
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
338
|
+
showToast(error.message, "error");
|
|
339
|
+
return Promise.reject(error);
|
|
340
|
+
}
|
|
329
341
|
applyStudioPathOffset(selection.element, next);
|
|
330
|
-
|
|
331
|
-
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
|
|
342
|
+
return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
|
|
332
343
|
label: "Move layer",
|
|
333
344
|
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
|
|
334
345
|
});
|
|
335
346
|
},
|
|
336
|
-
[commitPositionPatchToHtml, previewIframeRef],
|
|
347
|
+
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
337
348
|
);
|
|
338
349
|
|
|
339
350
|
const handleDomGroupPathOffsetCommit = useCallback(
|
|
340
351
|
(updates: DomEditGroupPathOffsetCommit[]) => {
|
|
341
|
-
if (updates.length === 0) return;
|
|
352
|
+
if (updates.length === 0) return Promise.resolve();
|
|
353
|
+
const blockedUpdate = updates.find(({ selection }) =>
|
|
354
|
+
isElementGsapTargeted(previewIframeRef.current, selection.element),
|
|
355
|
+
);
|
|
356
|
+
if (blockedUpdate) {
|
|
357
|
+
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
358
|
+
showToast(error.message, "error");
|
|
359
|
+
return Promise.reject(error);
|
|
360
|
+
}
|
|
342
361
|
const coalesceKey = updates
|
|
343
362
|
.map((u) => getDomEditTargetKey(u.selection))
|
|
344
363
|
.sort()
|
|
345
364
|
.join(":");
|
|
346
|
-
|
|
365
|
+
const saves = updates.map(({ selection, next }) => {
|
|
347
366
|
applyStudioPathOffset(selection.element, next);
|
|
348
|
-
|
|
349
|
-
commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
|
|
367
|
+
return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
|
|
350
368
|
label: `Move ${updates.length} layers`,
|
|
351
369
|
coalesceKey: `group-path-offset:${coalesceKey}`,
|
|
352
370
|
});
|
|
353
|
-
}
|
|
371
|
+
});
|
|
372
|
+
return Promise.all(saves).then(() => undefined);
|
|
354
373
|
},
|
|
355
|
-
[commitPositionPatchToHtml, previewIframeRef],
|
|
374
|
+
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
356
375
|
);
|
|
357
376
|
|
|
358
377
|
const handleDomBoxSizeCommit = useCallback(
|
|
359
378
|
(selection: DomEditSelection, next: { width: number; height: number }) => {
|
|
379
|
+
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
|
|
380
|
+
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
381
|
+
showToast(error.message, "error");
|
|
382
|
+
return Promise.reject(error);
|
|
383
|
+
}
|
|
360
384
|
applyStudioBoxSize(selection.element, next);
|
|
361
|
-
|
|
362
|
-
commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), {
|
|
385
|
+
return commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), {
|
|
363
386
|
label: "Resize layer box",
|
|
364
387
|
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
|
|
365
388
|
});
|
|
366
389
|
},
|
|
367
|
-
[commitPositionPatchToHtml, previewIframeRef],
|
|
390
|
+
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
368
391
|
);
|
|
369
392
|
|
|
370
393
|
const handleDomRotationCommit = useCallback(
|
|
371
394
|
(selection: DomEditSelection, next: { angle: number }) => {
|
|
395
|
+
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
|
|
396
|
+
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
397
|
+
showToast(error.message, "error");
|
|
398
|
+
return Promise.reject(error);
|
|
399
|
+
}
|
|
372
400
|
applyStudioRotation(selection.element, next);
|
|
373
|
-
|
|
374
|
-
commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
|
|
401
|
+
return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
|
|
375
402
|
label: "Rotate layer",
|
|
376
403
|
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
|
|
377
404
|
});
|
|
378
405
|
},
|
|
379
|
-
[commitPositionPatchToHtml, previewIframeRef],
|
|
406
|
+
[commitPositionPatchToHtml, previewIframeRef, showToast],
|
|
380
407
|
);
|
|
381
408
|
|
|
382
409
|
const handleDomManualEditsReset = useCallback(
|
|
@@ -391,73 +418,15 @@ export function useDomEditCommits({
|
|
|
391
418
|
clearStudioBoxSize(element);
|
|
392
419
|
clearStudioRotation(element);
|
|
393
420
|
// skipRefresh:false triggers reloadPreview() which re-syncs selection on load
|
|
394
|
-
commitPositionPatchToHtml(selection, clearPatches, {
|
|
421
|
+
void commitPositionPatchToHtml(selection, clearPatches, {
|
|
395
422
|
label: "Reset layer edits",
|
|
396
423
|
coalesceKey: `manual-reset:${getDomEditTargetKey(selection)}`,
|
|
397
424
|
skipRefresh: false,
|
|
398
|
-
});
|
|
425
|
+
}).catch(() => undefined);
|
|
399
426
|
},
|
|
400
427
|
[commitPositionPatchToHtml],
|
|
401
428
|
);
|
|
402
429
|
|
|
403
|
-
// ── Motion commits (HTML-attribute–backed) ──
|
|
404
|
-
|
|
405
|
-
// fallow-ignore-next-line complexity
|
|
406
|
-
const handleDomMotionCommit = useCallback(
|
|
407
|
-
(
|
|
408
|
-
selection: DomEditSelection,
|
|
409
|
-
motion: Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">,
|
|
410
|
-
) => {
|
|
411
|
-
// 1. Write motion data as JSON attribute on the element
|
|
412
|
-
writeStudioMotionToElement(selection.element, motion);
|
|
413
|
-
// 2. Apply the GSAP timeline from DOM attributes
|
|
414
|
-
let doc: Document | null = null;
|
|
415
|
-
try {
|
|
416
|
-
doc = previewIframeRef.current?.contentDocument ?? null;
|
|
417
|
-
} catch {
|
|
418
|
-
// cross-origin guard
|
|
419
|
-
}
|
|
420
|
-
if (doc) applyStudioMotionFromDom(doc);
|
|
421
|
-
// 3. Build patches and persist to HTML
|
|
422
|
-
const patches = buildMotionPatches(selection.element);
|
|
423
|
-
commitPositionPatchToHtml(selection, patches, {
|
|
424
|
-
label: "Set GSAP motion",
|
|
425
|
-
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
|
|
426
|
-
});
|
|
427
|
-
refreshDomEditSelectionFromPreview(selection);
|
|
428
|
-
},
|
|
429
|
-
[commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
|
|
430
|
-
);
|
|
431
|
-
|
|
432
|
-
// fallow-ignore-next-line complexity
|
|
433
|
-
const handleDomMotionClear = useCallback(
|
|
434
|
-
(selection: DomEditSelection) => {
|
|
435
|
-
const clearPatches = buildClearMotionPatches(selection.element);
|
|
436
|
-
// Get gsap from the preview window for proper cleanup
|
|
437
|
-
let gsap: { set?: (target: HTMLElement, vars: Record<string, unknown>) => void } | undefined;
|
|
438
|
-
try {
|
|
439
|
-
gsap = (previewIframeRef.current?.contentWindow as { gsap?: typeof gsap })?.gsap;
|
|
440
|
-
} catch {
|
|
441
|
-
// cross-origin guard
|
|
442
|
-
}
|
|
443
|
-
clearStudioMotionFromElement(selection.element, gsap);
|
|
444
|
-
let doc: Document | null = null;
|
|
445
|
-
try {
|
|
446
|
-
doc = previewIframeRef.current?.contentDocument ?? null;
|
|
447
|
-
} catch {
|
|
448
|
-
// cross-origin guard
|
|
449
|
-
}
|
|
450
|
-
if (doc) applyStudioMotionFromDom(doc);
|
|
451
|
-
commitPositionPatchToHtml(selection, clearPatches, {
|
|
452
|
-
label: "Clear GSAP motion",
|
|
453
|
-
coalesceKey: `motion:${getDomEditTargetKey(selection)}`,
|
|
454
|
-
skipRefresh: false,
|
|
455
|
-
});
|
|
456
|
-
refreshDomEditSelectionFromPreview(selection);
|
|
457
|
-
},
|
|
458
|
-
[commitPositionPatchToHtml, previewIframeRef, refreshDomEditSelectionFromPreview],
|
|
459
|
-
);
|
|
460
|
-
|
|
461
430
|
// fallow-ignore-next-line complexity
|
|
462
431
|
const handleDomEditElementDelete = useCallback(
|
|
463
432
|
// fallow-ignore-next-line complexity
|
|
@@ -471,7 +440,9 @@ export function useDomEditCommits({
|
|
|
471
440
|
const response = await fetch(
|
|
472
441
|
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
473
442
|
);
|
|
474
|
-
if (!response.ok)
|
|
443
|
+
if (!response.ok) {
|
|
444
|
+
throw await createStudioSaveHttpError(response, `Failed to read ${targetPath}`);
|
|
445
|
+
}
|
|
475
446
|
|
|
476
447
|
const data = (await response.json()) as { content?: string };
|
|
477
448
|
const originalContent = data.content;
|
|
@@ -492,7 +463,12 @@ export function useDomEditCommits({
|
|
|
492
463
|
body: JSON.stringify({ target: patchTarget }),
|
|
493
464
|
},
|
|
494
465
|
);
|
|
495
|
-
if (!removeResponse.ok)
|
|
466
|
+
if (!removeResponse.ok) {
|
|
467
|
+
throw await createStudioSaveHttpError(
|
|
468
|
+
removeResponse,
|
|
469
|
+
`Failed to delete element from ${targetPath}`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
496
472
|
|
|
497
473
|
const removeData = (await removeResponse.json()) as { changed?: boolean; content?: string };
|
|
498
474
|
const patchedContent =
|
|
@@ -556,7 +532,7 @@ export function useDomEditCommits({
|
|
|
556
532
|
} catch {
|
|
557
533
|
/* cross-origin or detached — skip */
|
|
558
534
|
}
|
|
559
|
-
commitPositionPatchToHtml(
|
|
535
|
+
void commitPositionPatchToHtml(
|
|
560
536
|
{
|
|
561
537
|
element: entry.element,
|
|
562
538
|
id: entry.id ?? null,
|
|
@@ -571,7 +547,7 @@ export function useDomEditCommits({
|
|
|
571
547
|
coalesceKey,
|
|
572
548
|
skipRefresh: i < entries.length - 1,
|
|
573
549
|
},
|
|
574
|
-
);
|
|
550
|
+
).catch(() => undefined);
|
|
575
551
|
}
|
|
576
552
|
},
|
|
577
553
|
[commitPositionPatchToHtml],
|
|
@@ -592,8 +568,6 @@ export function useDomEditCommits({
|
|
|
592
568
|
handleDomBoxSizeCommit,
|
|
593
569
|
handleDomRotationCommit,
|
|
594
570
|
handleDomManualEditsReset,
|
|
595
|
-
handleDomMotionCommit,
|
|
596
|
-
handleDomMotionClear,
|
|
597
571
|
handleDomEditElementDelete,
|
|
598
572
|
handleDomZIndexReorderCommit,
|
|
599
573
|
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
|
+
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
4
|
+
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
|
|
5
|
+
import { DomEditSaveQueueOpenError } from "../utils/domEditSaveQueue";
|
|
6
|
+
import type { PersistDomEditOperations } from "./useDomEditCommits";
|
|
7
|
+
|
|
8
|
+
interface UseDomEditPositionPatchCommitParams {
|
|
9
|
+
activeCompPath: string | null;
|
|
10
|
+
persistDomEditOperations: PersistDomEditOperations;
|
|
11
|
+
queueDomEditSave: (save: () => Promise<void>) => Promise<void>;
|
|
12
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface PositionPatchOptions {
|
|
16
|
+
label: string;
|
|
17
|
+
coalesceKey: string;
|
|
18
|
+
skipRefresh?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useDomEditPositionPatchCommit({
|
|
22
|
+
activeCompPath,
|
|
23
|
+
persistDomEditOperations,
|
|
24
|
+
queueDomEditSave,
|
|
25
|
+
showToast,
|
|
26
|
+
}: UseDomEditPositionPatchCommitParams) {
|
|
27
|
+
return useCallback(
|
|
28
|
+
(selection: DomEditSelection, patches: PatchOperation[], options: PositionPatchOptions) => {
|
|
29
|
+
return queueDomEditSave(async () => {
|
|
30
|
+
await persistDomEditOperations(selection, patches, {
|
|
31
|
+
label: options.label,
|
|
32
|
+
coalesceKey: options.coalesceKey,
|
|
33
|
+
skipRefresh: options.skipRefresh ?? true,
|
|
34
|
+
});
|
|
35
|
+
}).catch((error) => {
|
|
36
|
+
if (error instanceof DomEditSaveQueueOpenError) return;
|
|
37
|
+
showToast(error instanceof Error ? error.message : "Failed to save position");
|
|
38
|
+
trackStudioSaveFailure({
|
|
39
|
+
source: "dom_edit",
|
|
40
|
+
error,
|
|
41
|
+
filePath: selection.sourceFile ?? activeCompPath ?? "index.html",
|
|
42
|
+
mutationType: "position",
|
|
43
|
+
label: options.label,
|
|
44
|
+
targetId: selection.id,
|
|
45
|
+
targetSelector: selection.selector,
|
|
46
|
+
targetSourceFile: selection.sourceFile,
|
|
47
|
+
});
|
|
48
|
+
throw error;
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
[activeCompPath, persistDomEditOperations, queueDomEditSave, showToast],
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -15,14 +15,12 @@ import type { SidebarTab } from "../components/sidebar/LeftSidebar";
|
|
|
15
15
|
import { useAskAgentModal } from "./useAskAgentModal";
|
|
16
16
|
import { useDomSelection } from "./useDomSelection";
|
|
17
17
|
import { usePreviewInteraction } from "./usePreviewInteraction";
|
|
18
|
-
import { useDomEditCommits } from "./useDomEditCommits";
|
|
18
|
+
import { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE, useDomEditCommits } from "./useDomEditCommits";
|
|
19
19
|
import { useGsapScriptCommits } from "./useGsapScriptCommits";
|
|
20
20
|
import {
|
|
21
21
|
useGsapAnimationsForElement,
|
|
22
22
|
useGsapCacheVersion,
|
|
23
23
|
usePopulateKeyframeCacheForFile,
|
|
24
|
-
fetchParsedAnimations,
|
|
25
|
-
getAnimationsForElement,
|
|
26
24
|
} from "./useGsapTweenCache";
|
|
27
25
|
import {
|
|
28
26
|
tryGsapDragIntercept,
|
|
@@ -30,6 +28,8 @@ import {
|
|
|
30
28
|
tryGsapRotationIntercept,
|
|
31
29
|
} from "./gsapRuntimeBridge";
|
|
32
30
|
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
|
|
31
|
+
import { useGsapAnimationFetchFallback } from "./useGsapAnimationFetchFallback";
|
|
32
|
+
import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
|
|
33
33
|
import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers";
|
|
34
34
|
|
|
35
35
|
// ── Types ──
|
|
@@ -285,6 +285,7 @@ export function useDomEditSession({
|
|
|
285
285
|
reloadPreview,
|
|
286
286
|
onCacheInvalidate: bumpGsapCache,
|
|
287
287
|
onFileContentChanged: updateEditingFileContent,
|
|
288
|
+
showToast,
|
|
288
289
|
});
|
|
289
290
|
|
|
290
291
|
// ── Commit handlers (delegated to useDomEditCommits) ──
|
|
@@ -303,8 +304,6 @@ export function useDomEditSession({
|
|
|
303
304
|
handleDomBoxSizeCommit,
|
|
304
305
|
handleDomRotationCommit,
|
|
305
306
|
handleDomManualEditsReset,
|
|
306
|
-
handleDomMotionCommit,
|
|
307
|
-
handleDomMotionClear,
|
|
308
307
|
handleDomEditElementDelete,
|
|
309
308
|
handleDomZIndexReorderCommit,
|
|
310
309
|
} = useDomEditCommits({
|
|
@@ -327,76 +326,66 @@ export function useDomEditSession({
|
|
|
327
326
|
buildDomSelectionFromTarget,
|
|
328
327
|
});
|
|
329
328
|
|
|
329
|
+
const trackGsapInteractionFailure = useGsapInteractionFailureTelemetry(activeCompPath, showToast);
|
|
330
|
+
|
|
331
|
+
const makeFetchFallback = useGsapAnimationFetchFallback(projectId, gsapSourceFile);
|
|
332
|
+
|
|
330
333
|
// GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated.
|
|
331
334
|
const handleGsapAwarePathOffsetCommit = useCallback(
|
|
332
335
|
async (selection: DomEditSelection, next: { x: number; y: number }) => {
|
|
333
336
|
const hasGsapAnims = selectedGsapAnimations.length > 0;
|
|
334
337
|
if (hasGsapAnims && !STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) {
|
|
335
|
-
showToast(
|
|
336
|
-
|
|
337
|
-
"error",
|
|
338
|
-
);
|
|
339
|
-
return;
|
|
338
|
+
showToast(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE, "error");
|
|
339
|
+
throw new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
340
340
|
}
|
|
341
341
|
if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
);
|
|
357
|
-
if (handled) return;
|
|
342
|
+
try {
|
|
343
|
+
const handled = await tryGsapDragIntercept(
|
|
344
|
+
selection,
|
|
345
|
+
next,
|
|
346
|
+
selectedGsapAnimations,
|
|
347
|
+
previewIframeRef.current,
|
|
348
|
+
gsapCommitMutation,
|
|
349
|
+
makeFetchFallback(selection),
|
|
350
|
+
);
|
|
351
|
+
if (handled) return;
|
|
352
|
+
} catch (error) {
|
|
353
|
+
trackGsapInteractionFailure(error, selection, "drag", "Move animated layer");
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
358
356
|
}
|
|
359
|
-
handleDomPathOffsetCommit(selection, next);
|
|
357
|
+
return handleDomPathOffsetCommit(selection, next);
|
|
360
358
|
},
|
|
361
359
|
[
|
|
362
360
|
handleDomPathOffsetCommit,
|
|
363
361
|
selectedGsapAnimations,
|
|
364
362
|
gsapCommitMutation,
|
|
365
363
|
previewIframeRef,
|
|
366
|
-
|
|
367
|
-
|
|
364
|
+
makeFetchFallback,
|
|
365
|
+
trackGsapInteractionFailure,
|
|
368
366
|
showToast,
|
|
369
367
|
],
|
|
370
368
|
);
|
|
371
369
|
|
|
372
|
-
const makeFetchFallback = useCallback(
|
|
373
|
-
(selection: DomEditSelection) => async () => {
|
|
374
|
-
const pid = projectId;
|
|
375
|
-
if (!pid) return [];
|
|
376
|
-
const parsed = await fetchParsedAnimations(pid, gsapSourceFile);
|
|
377
|
-
if (!parsed) return [];
|
|
378
|
-
return getAnimationsForElement(parsed.animations, {
|
|
379
|
-
id: selection.id ?? null,
|
|
380
|
-
selector: selection.selector ?? null,
|
|
381
|
-
});
|
|
382
|
-
},
|
|
383
|
-
[projectId, gsapSourceFile],
|
|
384
|
-
);
|
|
385
|
-
|
|
386
370
|
const handleGsapAwareBoxSizeCommit = useCallback(
|
|
387
371
|
async (selection: DomEditSelection, next: { width: number; height: number }) => {
|
|
388
372
|
if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
373
|
+
try {
|
|
374
|
+
const handled = await tryGsapResizeIntercept(
|
|
375
|
+
selection,
|
|
376
|
+
next,
|
|
377
|
+
selectedGsapAnimations,
|
|
378
|
+
previewIframeRef.current,
|
|
379
|
+
gsapCommitMutation,
|
|
380
|
+
makeFetchFallback(selection),
|
|
381
|
+
);
|
|
382
|
+
if (handled) return;
|
|
383
|
+
} catch (error) {
|
|
384
|
+
trackGsapInteractionFailure(error, selection, "resize", "Resize animated layer");
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
398
387
|
}
|
|
399
|
-
handleDomBoxSizeCommit(selection, next);
|
|
388
|
+
return handleDomBoxSizeCommit(selection, next);
|
|
400
389
|
},
|
|
401
390
|
[
|
|
402
391
|
handleDomBoxSizeCommit,
|
|
@@ -404,23 +393,29 @@ export function useDomEditSession({
|
|
|
404
393
|
gsapCommitMutation,
|
|
405
394
|
previewIframeRef,
|
|
406
395
|
makeFetchFallback,
|
|
396
|
+
trackGsapInteractionFailure,
|
|
407
397
|
],
|
|
408
398
|
);
|
|
409
399
|
|
|
410
400
|
const handleGsapAwareRotationCommit = useCallback(
|
|
411
401
|
async (selection: DomEditSelection, next: { angle: number }) => {
|
|
412
402
|
if (STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && gsapCommitMutation) {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
403
|
+
try {
|
|
404
|
+
const handled = await tryGsapRotationIntercept(
|
|
405
|
+
selection,
|
|
406
|
+
next.angle,
|
|
407
|
+
selectedGsapAnimations,
|
|
408
|
+
previewIframeRef.current,
|
|
409
|
+
gsapCommitMutation,
|
|
410
|
+
makeFetchFallback(selection),
|
|
411
|
+
);
|
|
412
|
+
if (handled) return;
|
|
413
|
+
} catch (error) {
|
|
414
|
+
trackGsapInteractionFailure(error, selection, "rotation", "Rotate animated layer");
|
|
415
|
+
throw error;
|
|
416
|
+
}
|
|
422
417
|
}
|
|
423
|
-
handleDomRotationCommit(selection, next);
|
|
418
|
+
return handleDomRotationCommit(selection, next);
|
|
424
419
|
},
|
|
425
420
|
[
|
|
426
421
|
handleDomRotationCommit,
|
|
@@ -428,6 +423,7 @@ export function useDomEditSession({
|
|
|
428
423
|
gsapCommitMutation,
|
|
429
424
|
previewIframeRef,
|
|
430
425
|
makeFetchFallback,
|
|
426
|
+
trackGsapInteractionFailure,
|
|
431
427
|
],
|
|
432
428
|
);
|
|
433
429
|
|
|
@@ -536,8 +532,6 @@ export function useDomEditSession({
|
|
|
536
532
|
handleDomBoxSizeCommit: handleGsapAwareBoxSizeCommit,
|
|
537
533
|
handleDomRotationCommit: handleGsapAwareRotationCommit,
|
|
538
534
|
handleDomManualEditsReset,
|
|
539
|
-
handleDomMotionCommit,
|
|
540
|
-
handleDomMotionClear,
|
|
541
535
|
handleDomTextCommit,
|
|
542
536
|
handleDomTextFieldStyleCommit,
|
|
543
537
|
handleDomAddTextField,
|
|
@@ -6,6 +6,11 @@ import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
|
6
6
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
7
7
|
import { findTagByTarget, type PatchTarget } from "../utils/sourcePatcher";
|
|
8
8
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
9
|
+
import {
|
|
10
|
+
createStudioSaveHttpError,
|
|
11
|
+
retryStudioSave,
|
|
12
|
+
StudioSaveNetworkError,
|
|
13
|
+
} from "../utils/studioSaveDiagnostics";
|
|
9
14
|
|
|
10
15
|
// ── Types ──
|
|
11
16
|
|
|
@@ -97,12 +102,21 @@ export function useFileManager({
|
|
|
97
102
|
const writeProjectFile = useCallback(async (path: string, content: string): Promise<void> => {
|
|
98
103
|
const pid = projectIdRef.current;
|
|
99
104
|
if (!pid) throw new Error("No active project");
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
105
|
+
await retryStudioSave(async () => {
|
|
106
|
+
let response: Response;
|
|
107
|
+
try {
|
|
108
|
+
response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, {
|
|
109
|
+
method: "PUT",
|
|
110
|
+
headers: { "Content-Type": "text/plain" },
|
|
111
|
+
body: content,
|
|
112
|
+
});
|
|
113
|
+
} catch (error) {
|
|
114
|
+
throw new StudioSaveNetworkError(`Failed to save ${path}: network error`, {
|
|
115
|
+
cause: error,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (!response.ok) throw await createStudioSaveHttpError(response, `Failed to save ${path}`);
|
|
104
119
|
});
|
|
105
|
-
if (!response.ok) throw new Error(`Failed to save ${path}`);
|
|
106
120
|
if (editingPathRef.current === path) {
|
|
107
121
|
setEditingFile({ path, content });
|
|
108
122
|
}
|