@hyperframes/studio 0.7.56 → 0.7.58
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-CtTDO63S.js +459 -0
- package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
- package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-C4csZims.js → index-DeQPzqwH.js} +1 -1
- package/dist/assets/index-uahwWkgw.css +1 -0
- package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/chunk-OBAG3GWK.js.map +1 -0
- package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/index.d.ts +41 -4
- package/dist/index.html +2 -2
- package/dist/index.js +7436 -5423
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/editor/CanvasContextMenu.test.tsx +166 -2
- package/src/components/editor/CanvasContextMenu.tsx +100 -20
- package/src/components/editor/DomEditOverlay.tsx +34 -62
- package/src/components/editor/LayersPanel.tsx +111 -91
- package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
- package/src/components/nle/PreviewOverlays.tsx +61 -16
- package/src/components/nle/TimelinePane.test.ts +3 -0
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
- package/src/components/nle/zLaneGesture.test.ts +176 -0
- package/src/components/nle/zLaneGesture.ts +77 -0
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +19 -2
- package/src/components/sidebar/AudioRow.tsx +19 -2
- package/src/hooks/domEditCommitTypes.ts +29 -2
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +183 -1
- package/src/hooks/timelineEditingHelpers.ts +63 -259
- package/src/hooks/timelineMoveAdapter.test.ts +44 -40
- package/src/hooks/timelineMoveAdapter.ts +7 -4
- package/src/hooks/timelineTimingSync.test.ts +820 -0
- package/src/hooks/timelineTimingSync.ts +597 -0
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useDomEditCommits.test.tsx +392 -23
- package/src/hooks/useDomEditCommits.ts +180 -53
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
- package/src/hooks/useElementLifecycleOps.ts +175 -69
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +591 -277
- package/src/hooks/useTimelineEditing.ts +202 -282
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +187 -134
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +7 -2
- package/src/player/components/Timeline.test.ts +105 -18
- package/src/player/components/Timeline.tsx +69 -98
- package/src/player/components/TimelineCanvas.tsx +47 -7
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +70 -47
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +370 -11
- package/src/player/components/timelineClipDragCommit.ts +267 -68
- package/src/player/components/timelineGapCommit.test.ts +163 -0
- package/src/player/components/timelineGapCommit.ts +103 -0
- package/src/player/components/timelineGaps.test.ts +221 -0
- package/src/player/components/timelineGaps.ts +182 -0
- package/src/player/components/timelineLayout.test.ts +2 -1
- package/src/player/components/timelineLayout.ts +119 -30
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +102 -1
- package/src/player/components/timelineStackingSync.ts +45 -5
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/timelineZoom.test.ts +2 -2
- package/src/player/components/timelineZoom.ts +7 -3
- package/src/player/components/useTimelineClipDrag.ts +1 -1
- package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
- package/src/player/components/useTimelineGapHighlights.ts +110 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -3
- package/src/player/components/useTimelineRangeSelection.ts +6 -4
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
- package/src/player/components/useTimelineStackingSync.ts +17 -4
- package/src/player/components/useTimelineTrackDerivations.ts +38 -0
- package/src/player/components/useTrackGapMenu.ts +155 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
- package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
- package/src/player/hooks/useTimelinePlayer.ts +4 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
- package/src/player/index.ts +3 -1
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/lib/timelineElementHelpers.ts +54 -11
- package/src/player/lib/timelineIframeHelpers.ts +2 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +49 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/domEditSaveQueue.ts +4 -3
- package/src/utils/editHistory.test.ts +82 -0
- package/src/utils/editHistory.ts +14 -2
- package/src/utils/gsapSoftReload.test.ts +54 -115
- package/src/utils/gsapSoftReload.ts +42 -152
- package/src/utils/gsapUndoRestore.test.ts +269 -0
- package/src/utils/gsapUndoRestore.ts +269 -0
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +62 -86
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-D-GyYi2d.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
|
@@ -8,6 +8,7 @@ import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop";
|
|
|
8
8
|
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
|
|
9
9
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
10
10
|
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
|
11
|
+
import { findElementForSelection } from "../components/editor/domEditingElement";
|
|
11
12
|
import { readFileContent } from "./timelineEditingHelpers";
|
|
12
13
|
|
|
13
14
|
interface RecordEditInput {
|
|
@@ -34,6 +35,7 @@ interface UseClipboardOptions {
|
|
|
34
35
|
function getElementOuterHtml(
|
|
35
36
|
iframeRef: React.MutableRefObject<HTMLIFrameElement | null>,
|
|
36
37
|
selection: DomEditSelection,
|
|
38
|
+
activeCompositionPath: string | null,
|
|
37
39
|
): string | null {
|
|
38
40
|
let doc: Document | null = null;
|
|
39
41
|
try {
|
|
@@ -43,15 +45,7 @@ function getElementOuterHtml(
|
|
|
43
45
|
}
|
|
44
46
|
if (!doc) return null;
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
if (selection.id) {
|
|
48
|
-
el = doc.getElementById(selection.id);
|
|
49
|
-
}
|
|
50
|
-
if (!el && selection.selector) {
|
|
51
|
-
const matches = doc.querySelectorAll(selection.selector);
|
|
52
|
-
el = matches[selection.selectorIndex ?? 0] ?? null;
|
|
53
|
-
}
|
|
54
|
-
return el && "outerHTML" in el ? (el as Element).outerHTML : null;
|
|
48
|
+
return findElementForSelection(doc, selection, activeCompositionPath)?.outerHTML ?? null;
|
|
55
49
|
}
|
|
56
50
|
|
|
57
51
|
export function useClipboard({
|
|
@@ -71,6 +65,9 @@ export function useClipboard({
|
|
|
71
65
|
const projectIdRef = useRef(projectId);
|
|
72
66
|
projectIdRef.current = projectId;
|
|
73
67
|
|
|
68
|
+
// The copy-mode branches predate this change; this diff only replaces its
|
|
69
|
+
// duplicated DOM lookup with the canonical composition-aware resolver.
|
|
70
|
+
// fallow-ignore-next-line complexity
|
|
74
71
|
const handleCopy = useCallback((): boolean => {
|
|
75
72
|
const { selectedElementId, elements } = usePlayerStore.getState();
|
|
76
73
|
|
|
@@ -84,13 +81,18 @@ export function useClipboard({
|
|
|
84
81
|
try {
|
|
85
82
|
const doc = previewIframeRef.current?.contentDocument;
|
|
86
83
|
if (doc) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
84
|
+
html =
|
|
85
|
+
findElementForSelection(
|
|
86
|
+
doc,
|
|
87
|
+
{
|
|
88
|
+
hfId: element.hfId,
|
|
89
|
+
id: element.domId,
|
|
90
|
+
selector: element.selector,
|
|
91
|
+
selectorIndex: element.selectorIndex,
|
|
92
|
+
sourceFile: targetPath,
|
|
93
|
+
},
|
|
94
|
+
activeCompPath,
|
|
95
|
+
)?.outerHTML ?? null;
|
|
94
96
|
}
|
|
95
97
|
} catch {
|
|
96
98
|
// cross-origin frame
|
|
@@ -110,7 +112,7 @@ export function useClipboard({
|
|
|
110
112
|
// DOM element copy
|
|
111
113
|
const domSelection = domEditSelectionRef.current;
|
|
112
114
|
if (domSelection) {
|
|
113
|
-
const html = getElementOuterHtml(previewIframeRef, domSelection);
|
|
115
|
+
const html = getElementOuterHtml(previewIframeRef, domSelection, activeCompPath);
|
|
114
116
|
if (!html) {
|
|
115
117
|
showToast("Unable to copy this element.", "info");
|
|
116
118
|
return false;
|
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { useMountEffect } from "./useMountEffect";
|
|
3
3
|
import type { CompositionDimensions } from "../components/renders/RenderQueue";
|
|
4
|
+
import { acceptStudioRuntimeMessage } from "../player/lib/runtimeProtocol";
|
|
5
|
+
|
|
6
|
+
function readCompositionSizeMessage(data: unknown): CompositionDimensions | null {
|
|
7
|
+
if (!isStageSizeMessage(data)) return null;
|
|
8
|
+
const message = data;
|
|
9
|
+
if (!acceptStudioRuntimeMessage(message)) return null;
|
|
10
|
+
return readPositiveDimensions(message.width, message.height);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isStageSizeMessage(value: unknown): value is Record<string, unknown> {
|
|
14
|
+
if (typeof value !== "object") return false;
|
|
15
|
+
if (value === null) return false;
|
|
16
|
+
const message = value as Record<string, unknown>;
|
|
17
|
+
return message.source === "hf-preview" && message.type === "stage-size";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readPositiveNumber(value: unknown): number | null {
|
|
21
|
+
if (typeof value !== "number") return null;
|
|
22
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readPositiveDimensions(width: unknown, height: unknown): CompositionDimensions | null {
|
|
26
|
+
const parsedWidth = readPositiveNumber(width);
|
|
27
|
+
const parsedHeight = readPositiveNumber(height);
|
|
28
|
+
if (parsedWidth === null || parsedHeight === null) return null;
|
|
29
|
+
return { width: parsedWidth, height: parsedHeight };
|
|
30
|
+
}
|
|
4
31
|
|
|
5
32
|
export function useCompositionDimensions() {
|
|
6
33
|
const [compositionDimensions, setCompositionDimensions] = useState<CompositionDimensions | null>(
|
|
@@ -9,12 +36,12 @@ export function useCompositionDimensions() {
|
|
|
9
36
|
|
|
10
37
|
useMountEffect(() => {
|
|
11
38
|
const handleMessage = (e: MessageEvent) => {
|
|
12
|
-
const
|
|
13
|
-
if (
|
|
14
|
-
const { width, height } = data as { width: number; height: number };
|
|
15
|
-
if (!(width > 0) || !(height > 0)) return;
|
|
39
|
+
const dimensions = readCompositionSizeMessage(e.data);
|
|
40
|
+
if (!dimensions) return;
|
|
16
41
|
setCompositionDimensions((prev) =>
|
|
17
|
-
prev && prev.width === width && prev.height === height
|
|
42
|
+
prev && prev.width === dimensions.width && prev.height === dimensions.height
|
|
43
|
+
? prev
|
|
44
|
+
: dimensions,
|
|
18
45
|
);
|
|
19
46
|
};
|
|
20
47
|
window.addEventListener("message", handleMessage);
|
|
@@ -265,7 +265,7 @@ describe("useDomEditCommits z-index reorder persistence", () => {
|
|
|
265
265
|
document.body.replaceChildren();
|
|
266
266
|
});
|
|
267
267
|
|
|
268
|
-
it("persists an N-element reorder with one batch POST, one undo entry, and
|
|
268
|
+
it("persists an N-element reorder with one batch POST, one undo entry, and NO iframe reload", async () => {
|
|
269
269
|
const original =
|
|
270
270
|
'<div id="a" style="z-index: 1"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 3"></div>';
|
|
271
271
|
const after =
|
|
@@ -276,18 +276,20 @@ describe("useDomEditCommits z-index reorder persistence", () => {
|
|
|
276
276
|
_init?: Parameters<typeof fetch>[1],
|
|
277
277
|
): Promise<Response> => {
|
|
278
278
|
const url = requestUrl(input);
|
|
279
|
-
if (url.
|
|
280
|
-
if (url.includes("/file-mutations/patch-elements-batch/")) {
|
|
279
|
+
if (url.endsWith("/file-mutations/patch-element-batches")) {
|
|
281
280
|
return jsonResponse({
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
281
|
+
durable: true,
|
|
282
|
+
files: [
|
|
283
|
+
{
|
|
284
|
+
sourceFile: "index.html",
|
|
285
|
+
changed: true,
|
|
286
|
+
matched: [true, true, true],
|
|
287
|
+
before: original,
|
|
288
|
+
after,
|
|
289
|
+
},
|
|
290
|
+
],
|
|
286
291
|
});
|
|
287
292
|
}
|
|
288
|
-
if (url.includes("/file-mutations/patch-element/")) {
|
|
289
|
-
return jsonResponse({ ok: true, changed: true, matched: true, content: after });
|
|
290
|
-
}
|
|
291
293
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
292
294
|
},
|
|
293
295
|
);
|
|
@@ -317,39 +319,262 @@ describe("useDomEditCommits z-index reorder persistence", () => {
|
|
|
317
319
|
});
|
|
318
320
|
|
|
319
321
|
const batchPosts = fetchMock.mock.calls.filter(([input]) =>
|
|
320
|
-
requestUrl(input).
|
|
322
|
+
requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
|
|
321
323
|
);
|
|
322
324
|
const singlePosts = fetchMock.mock.calls.filter(([input]) =>
|
|
323
|
-
requestUrl(input).includes("/file-mutations/patch-
|
|
325
|
+
requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
|
|
324
326
|
);
|
|
325
327
|
expect(batchPosts).toHaveLength(1);
|
|
326
328
|
expect(singlePosts).toHaveLength(0);
|
|
327
329
|
expect(JSON.parse(String(batchPosts[0]?.[1]?.body))).toEqual({
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
330
|
+
batches: [
|
|
331
|
+
{
|
|
332
|
+
sourceFile: "index.html",
|
|
333
|
+
patches: expect.arrayContaining([
|
|
334
|
+
expect.objectContaining({ target: expect.objectContaining({ id: "a" }) }),
|
|
335
|
+
expect.objectContaining({ target: expect.objectContaining({ id: "b" }) }),
|
|
336
|
+
expect.objectContaining({ target: expect.objectContaining({ id: "c" }) }),
|
|
337
|
+
]),
|
|
338
|
+
},
|
|
339
|
+
],
|
|
333
340
|
});
|
|
334
341
|
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
|
335
342
|
expect(rendered.recordEdit).toHaveBeenCalledWith({
|
|
336
343
|
label: "Reorder layers",
|
|
337
344
|
kind: "manual",
|
|
338
345
|
coalesceKey: "z-reorder:test",
|
|
346
|
+
// Unbounded per-gesture fold window (keys are unique per gesture):
|
|
347
|
+
// the z entry and its mirror/lane counterpart fold across the server
|
|
348
|
+
// round-trip that separates them.
|
|
349
|
+
coalesceMs: Number.POSITIVE_INFINITY,
|
|
339
350
|
files: { "index.html": { before: original, after } },
|
|
340
351
|
});
|
|
352
|
+
// FIX: a z-only reorder must NOT remount the preview iframe ("the blink").
|
|
353
|
+
// The live DOM + store already hold the final state and the server matched
|
|
354
|
+
// every style-only patch, so the reload is provably redundant.
|
|
355
|
+
expect(rendered.reloadPreview).not.toHaveBeenCalled();
|
|
356
|
+
} finally {
|
|
357
|
+
rendered.cleanup();
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("falls back to reloading when the server response omits matched[]", async () => {
|
|
362
|
+
// Without a matched[] confirmation the persist can't be proven in sync with
|
|
363
|
+
// the live DOM — the skip-reload path must not engage.
|
|
364
|
+
const original = '<div id="a" style="z-index: 1"></div>';
|
|
365
|
+
const after = '<div id="a" style="z-index: 2"></div>';
|
|
366
|
+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
367
|
+
const url = requestUrl(input);
|
|
368
|
+
if (url.endsWith("/file-mutations/patch-element-batches")) {
|
|
369
|
+
return jsonResponse({
|
|
370
|
+
durable: true,
|
|
371
|
+
files: [{ sourceFile: "index.html", changed: true, before: original, after }],
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
375
|
+
});
|
|
376
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
377
|
+
const { iframe, element } = createPreviewElement();
|
|
378
|
+
element.id = "a";
|
|
379
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
await act(async () => {
|
|
383
|
+
await rendered.hook.handleDomZIndexReorderCommit([
|
|
384
|
+
{ element, zIndex: 2, id: "a", sourceFile: "index.html" },
|
|
385
|
+
]);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
|
389
|
+
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
390
|
+
} finally {
|
|
391
|
+
rendered.cleanup();
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("warns and reports telemetry for unmatched batch patches without throwing", async () => {
|
|
396
|
+
// The server reports per-patch matched[]: #b was not found in the source,
|
|
397
|
+
// so it atomically refuses the whole multi-file gesture. The reload
|
|
398
|
+
// reconverges the preview with disk while the lifecycle owner rolls back
|
|
399
|
+
// both optimistic writes.
|
|
400
|
+
const original = '<div id="a" style="z-index: 1"></div>';
|
|
401
|
+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
402
|
+
const url = requestUrl(input);
|
|
403
|
+
if (url.endsWith("/file-mutations/patch-element-batches")) {
|
|
404
|
+
return jsonResponse({
|
|
405
|
+
durable: false,
|
|
406
|
+
files: [
|
|
407
|
+
{
|
|
408
|
+
sourceFile: "index.html",
|
|
409
|
+
changed: false,
|
|
410
|
+
matched: [true, false],
|
|
411
|
+
before: original,
|
|
412
|
+
after: original,
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
418
|
+
});
|
|
419
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
420
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
421
|
+
const { iframe, element } = createPreviewElement(
|
|
422
|
+
'<div data-hf-id="hf-card"></div><div id="b"></div>',
|
|
423
|
+
);
|
|
424
|
+
element.id = "a";
|
|
425
|
+
const second = iframe.contentDocument!.getElementById("b")!;
|
|
426
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
await act(async () => {
|
|
430
|
+
await rendered.hook.handleDomZIndexReorderCommit([
|
|
431
|
+
{ element, zIndex: 2, id: "a", sourceFile: "index.html" },
|
|
432
|
+
{ element: second, zIndex: 1, id: "b", sourceFile: "index.html" },
|
|
433
|
+
]);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// No throw: incomplete durability rolls back both optimistic writes.
|
|
437
|
+
expect(element.style.zIndex).toBe("");
|
|
438
|
+
expect(second.style.zIndex).toBe("");
|
|
439
|
+
expect(rendered.recordEdit).not.toHaveBeenCalled();
|
|
440
|
+
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
441
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
442
|
+
expect.stringContaining("could not match 1 patch target(s) in index.html"),
|
|
443
|
+
"b",
|
|
444
|
+
);
|
|
445
|
+
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
446
|
+
"save_failure",
|
|
447
|
+
expect.objectContaining({
|
|
448
|
+
mutation_type: "z-reorder-unmatched",
|
|
449
|
+
file_path: "index.html",
|
|
450
|
+
error_message: expect.stringContaining("b"),
|
|
451
|
+
}),
|
|
452
|
+
);
|
|
453
|
+
} finally {
|
|
454
|
+
warnSpy.mockRestore();
|
|
455
|
+
rendered.cleanup();
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("uses one atomic request and rolls back every file when one target is unmatched", async () => {
|
|
460
|
+
const indexOriginal = '<div id="a" style="z-index: 4"></div>';
|
|
461
|
+
const sceneOriginal = '<div id="b" style="z-index: 5"></div>';
|
|
462
|
+
const fetchMock = vi.fn(
|
|
463
|
+
async (
|
|
464
|
+
input: Parameters<typeof fetch>[0],
|
|
465
|
+
init?: Parameters<typeof fetch>[1],
|
|
466
|
+
): Promise<Response> => {
|
|
467
|
+
const url = requestUrl(input);
|
|
468
|
+
if (url.endsWith("/file-mutations/patch-element-batches")) {
|
|
469
|
+
expect(JSON.parse(String(init?.body))).toEqual({
|
|
470
|
+
batches: [
|
|
471
|
+
expect.objectContaining({ sourceFile: "index.html" }),
|
|
472
|
+
expect.objectContaining({ sourceFile: "scene.html" }),
|
|
473
|
+
],
|
|
474
|
+
});
|
|
475
|
+
return jsonResponse({
|
|
476
|
+
durable: false,
|
|
477
|
+
files: [
|
|
478
|
+
{
|
|
479
|
+
sourceFile: "index.html",
|
|
480
|
+
changed: false,
|
|
481
|
+
matched: [true],
|
|
482
|
+
before: indexOriginal,
|
|
483
|
+
after: indexOriginal,
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
sourceFile: "scene.html",
|
|
487
|
+
changed: false,
|
|
488
|
+
matched: [false],
|
|
489
|
+
before: sceneOriginal,
|
|
490
|
+
after: sceneOriginal,
|
|
491
|
+
},
|
|
492
|
+
],
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
496
|
+
},
|
|
497
|
+
);
|
|
498
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
499
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
500
|
+
const { iframe, element } = createPreviewElement(
|
|
501
|
+
'<div data-hf-id="hf-card" style="z-index: 4"></div><div id="b" style="z-index: 5"></div>',
|
|
502
|
+
);
|
|
503
|
+
element.id = "a";
|
|
504
|
+
const second = iframe.contentDocument!.getElementById("b")!;
|
|
505
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
let result:
|
|
509
|
+
| Awaited<ReturnType<typeof rendered.hook.handleDomZIndexReorderCommit>>
|
|
510
|
+
| undefined;
|
|
511
|
+
await act(async () => {
|
|
512
|
+
result = await rendered.hook.handleDomZIndexReorderCommit([
|
|
513
|
+
{ element, zIndex: 8, id: "a", sourceFile: "index.html" },
|
|
514
|
+
{ element: second, zIndex: 9, id: "b", sourceFile: "scene.html" },
|
|
515
|
+
]);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
expect(result).toEqual({ durable: false, allMatched: false, changed: false });
|
|
519
|
+
expect(element.style.zIndex).toBe("4");
|
|
520
|
+
expect(second.style.zIndex).toBe("5");
|
|
521
|
+
expect(rendered.recordEdit).not.toHaveBeenCalled();
|
|
522
|
+
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
523
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
524
|
+
} finally {
|
|
525
|
+
warnSpy.mockRestore();
|
|
526
|
+
rendered.cleanup();
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("reloads and reports non-durable when an unmatched batch changes no bytes", async () => {
|
|
531
|
+
const original = '<div id="a" style="z-index: 1"></div>';
|
|
532
|
+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
533
|
+
const url = requestUrl(input);
|
|
534
|
+
if (url.endsWith("/file-mutations/patch-element-batches")) {
|
|
535
|
+
return jsonResponse({
|
|
536
|
+
durable: false,
|
|
537
|
+
files: [
|
|
538
|
+
{
|
|
539
|
+
sourceFile: "index.html",
|
|
540
|
+
changed: false,
|
|
541
|
+
matched: [false],
|
|
542
|
+
before: original,
|
|
543
|
+
after: original,
|
|
544
|
+
},
|
|
545
|
+
],
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
549
|
+
});
|
|
550
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
551
|
+
const { iframe, element } = createPreviewElement();
|
|
552
|
+
element.id = "a";
|
|
553
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
let result:
|
|
557
|
+
| Awaited<ReturnType<typeof rendered.hook.handleDomZIndexReorderCommit>>
|
|
558
|
+
| undefined;
|
|
559
|
+
await act(async () => {
|
|
560
|
+
result = await rendered.hook.handleDomZIndexReorderCommit([
|
|
561
|
+
{ element, zIndex: 2, id: "missing", sourceFile: "index.html" },
|
|
562
|
+
]);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
expect(result).toEqual({ durable: false, allMatched: false, changed: false });
|
|
566
|
+
expect(element.style.zIndex).toBe("");
|
|
567
|
+
expect(rendered.recordEdit).not.toHaveBeenCalled();
|
|
341
568
|
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
342
569
|
} finally {
|
|
343
570
|
rendered.cleanup();
|
|
344
571
|
}
|
|
345
572
|
});
|
|
346
573
|
|
|
347
|
-
it("rolls back
|
|
348
|
-
const original = '<div id="a" style="z-index: 7"></div><div id="b"></div>';
|
|
574
|
+
it("rolls back and reloads after a rejected batch POST", async () => {
|
|
349
575
|
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
350
576
|
const url = requestUrl(input);
|
|
351
|
-
if (url.
|
|
352
|
-
if (url.includes("/file-mutations/patch-elements-batch/")) {
|
|
577
|
+
if (url.endsWith("/file-mutations/patch-element-batches")) {
|
|
353
578
|
return jsonResponse({ error: "batch rejected" }, 500);
|
|
354
579
|
}
|
|
355
580
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
@@ -394,10 +619,154 @@ describe("useDomEditCommits z-index reorder persistence", () => {
|
|
|
394
619
|
]);
|
|
395
620
|
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
396
621
|
expect(rendered.recordEdit).not.toHaveBeenCalled();
|
|
397
|
-
expect(rendered.reloadPreview).
|
|
622
|
+
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
623
|
+
expect(
|
|
624
|
+
fetchMock.mock.calls.filter(([input]) =>
|
|
625
|
+
requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
|
|
626
|
+
),
|
|
627
|
+
).toHaveLength(1);
|
|
628
|
+
} finally {
|
|
629
|
+
rendered.cleanup();
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("reloads after the server commits but the aggregate response is lost", async () => {
|
|
634
|
+
const original = '<div data-hf-id="hf-card" id="a" style="z-index: 7"></div>';
|
|
635
|
+
const after = '<div data-hf-id="hf-card" id="a" style="z-index: 2"></div>';
|
|
636
|
+
let diskContent = original;
|
|
637
|
+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
638
|
+
const url = requestUrl(input);
|
|
639
|
+
if (url.endsWith("/file-mutations/patch-element-batches")) {
|
|
640
|
+
diskContent = after;
|
|
641
|
+
throw new TypeError("connection reset after commit");
|
|
642
|
+
}
|
|
643
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
644
|
+
});
|
|
645
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
646
|
+
const { iframe, element } = createPreviewElement(original);
|
|
647
|
+
usePlayerStore.getState().setElements([
|
|
648
|
+
{
|
|
649
|
+
id: "a",
|
|
650
|
+
tag: "div",
|
|
651
|
+
start: 0,
|
|
652
|
+
duration: 1,
|
|
653
|
+
track: 0,
|
|
654
|
+
zIndex: 7,
|
|
655
|
+
hasExplicitZIndex: true,
|
|
656
|
+
},
|
|
657
|
+
]);
|
|
658
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
659
|
+
|
|
660
|
+
try {
|
|
661
|
+
let rejection: unknown;
|
|
662
|
+
await act(async () => {
|
|
663
|
+
try {
|
|
664
|
+
await rendered.hook.handleDomZIndexReorderCommit([
|
|
665
|
+
{ element, zIndex: 2, id: "a", sourceFile: "index.html", key: "a" },
|
|
666
|
+
]);
|
|
667
|
+
} catch (error) {
|
|
668
|
+
rejection = error;
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
expect(rejection).toBeInstanceOf(Error);
|
|
673
|
+
expect((rejection as Error).message).toContain("connection reset after commit");
|
|
674
|
+
expect(diskContent).toBe(after);
|
|
675
|
+
expect(element.style.zIndex).toBe("7");
|
|
676
|
+
expect(usePlayerStore.getState().elements[0]).toMatchObject({
|
|
677
|
+
zIndex: 7,
|
|
678
|
+
hasExplicitZIndex: true,
|
|
679
|
+
});
|
|
680
|
+
expect(rendered.recordEdit).not.toHaveBeenCalled();
|
|
681
|
+
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
682
|
+
expect(
|
|
683
|
+
fetchMock.mock.calls.filter(([input]) =>
|
|
684
|
+
requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
|
|
685
|
+
),
|
|
686
|
+
).toHaveLength(1);
|
|
687
|
+
} finally {
|
|
688
|
+
rendered.cleanup();
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it.each([
|
|
693
|
+
[
|
|
694
|
+
"changed=false with different snapshots",
|
|
695
|
+
{
|
|
696
|
+
durable: true,
|
|
697
|
+
files: [
|
|
698
|
+
{
|
|
699
|
+
sourceFile: "index.html",
|
|
700
|
+
changed: false,
|
|
701
|
+
matched: [true],
|
|
702
|
+
before: '<div id="a" style="z-index: 1"></div>',
|
|
703
|
+
after: '<div id="a" style="z-index: 2"></div>',
|
|
704
|
+
},
|
|
705
|
+
],
|
|
706
|
+
},
|
|
707
|
+
],
|
|
708
|
+
[
|
|
709
|
+
"changed=true with identical snapshots",
|
|
710
|
+
{
|
|
711
|
+
durable: true,
|
|
712
|
+
files: [
|
|
713
|
+
{
|
|
714
|
+
sourceFile: "index.html",
|
|
715
|
+
changed: true,
|
|
716
|
+
matched: [true],
|
|
717
|
+
before: '<div id="a" style="z-index: 1"></div>',
|
|
718
|
+
after: '<div id="a" style="z-index: 1"></div>',
|
|
719
|
+
},
|
|
720
|
+
],
|
|
721
|
+
},
|
|
722
|
+
],
|
|
723
|
+
[
|
|
724
|
+
"durable=false with a changed file",
|
|
725
|
+
{
|
|
726
|
+
durable: false,
|
|
727
|
+
files: [
|
|
728
|
+
{
|
|
729
|
+
sourceFile: "index.html",
|
|
730
|
+
changed: true,
|
|
731
|
+
matched: [true],
|
|
732
|
+
before: '<div id="a" style="z-index: 1"></div>',
|
|
733
|
+
after: '<div id="a" style="z-index: 2"></div>',
|
|
734
|
+
},
|
|
735
|
+
],
|
|
736
|
+
},
|
|
737
|
+
],
|
|
738
|
+
])("rejects and reloads for malformed 200 response: %s", async (_label, responseBody) => {
|
|
739
|
+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
740
|
+
const url = requestUrl(input);
|
|
741
|
+
if (url.endsWith("/file-mutations/patch-element-batches")) {
|
|
742
|
+
return jsonResponse(responseBody);
|
|
743
|
+
}
|
|
744
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
745
|
+
});
|
|
746
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
747
|
+
const { iframe, element } = createPreviewElement();
|
|
748
|
+
element.id = "a";
|
|
749
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
750
|
+
|
|
751
|
+
try {
|
|
752
|
+
let rejection: unknown;
|
|
753
|
+
await act(async () => {
|
|
754
|
+
try {
|
|
755
|
+
await rendered.hook.handleDomZIndexReorderCommit([
|
|
756
|
+
{ element, zIndex: 2, id: "a", sourceFile: "index.html", key: "a" },
|
|
757
|
+
]);
|
|
758
|
+
} catch (error) {
|
|
759
|
+
rejection = error;
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
expect(rejection).toBeInstanceOf(Error);
|
|
764
|
+
expect((rejection as Error).message).toBe("Invalid atomic element patch response");
|
|
765
|
+
expect(rendered.recordEdit).not.toHaveBeenCalled();
|
|
766
|
+
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
398
767
|
expect(
|
|
399
768
|
fetchMock.mock.calls.filter(([input]) =>
|
|
400
|
-
requestUrl(input).
|
|
769
|
+
requestUrl(input).endsWith("/file-mutations/patch-element-batches"),
|
|
401
770
|
),
|
|
402
771
|
).toHaveLength(1);
|
|
403
772
|
} finally {
|