@hyperframes/studio 0.7.55 → 0.7.57
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--Z69cEkE.js +459 -0
- package/dist/assets/{index-CPetwHFV.js → index-Bf1x1y8H.js} +1 -1
- package/dist/assets/index-DfmYkU44.js +423 -0
- package/dist/assets/{index-BXaqaVKt.js → index-KsfE1bUu.js} +1 -1
- package/dist/assets/index-_pqzyxB1.css +1 -0
- package/dist/{chunk-SOTCF4DF.js → chunk-5QSIMBEJ.js} +2 -1
- package/dist/chunk-5QSIMBEJ.js.map +1 -0
- package/dist/{domEditingLayers-2ECJK24D.js → domEditingLayers-6LQGKPOI.js} +2 -2
- package/dist/index.d.ts +172 -144
- package/dist/index.html +2 -2
- package/dist/index.js +43613 -40346
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +106 -96
- package/src/components/EditorShell.tsx +253 -0
- package/src/components/StudioGlobalDragOverlay.tsx +1 -3
- package/src/components/StudioLeftSidebar.tsx +13 -3
- package/src/components/StudioRightPanel.tsx +8 -3
- package/src/components/TimelineToolbar.test.tsx +2 -2
- package/src/components/TimelineToolbar.tsx +179 -121
- package/src/components/editor/CanvasContextMenu.test.tsx +152 -1
- package/src/components/editor/CanvasContextMenu.tsx +33 -18
- package/src/components/editor/DomEditCropHandles.test.tsx +74 -3
- package/src/components/editor/DomEditCropHandles.tsx +64 -29
- package/src/components/editor/DomEditOverlay.test.ts +245 -279
- package/src/components/editor/DomEditOverlay.tsx +126 -125
- package/src/components/editor/DomEditOverlayHover.test.tsx +90 -0
- package/src/components/editor/DomEditRotateHandle.tsx +38 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +80 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +257 -0
- package/src/components/editor/LayersPanel.test.ts +66 -2
- package/src/components/editor/LayersPanel.tsx +53 -2
- package/src/components/editor/OffCanvasIndicators.test.tsx +34 -0
- package/src/components/editor/OffCanvasIndicators.tsx +49 -10
- package/src/components/editor/SnapGuideOverlay.tsx +28 -23
- package/src/components/editor/SnapToolbar.test.tsx +0 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +214 -0
- package/src/components/editor/anchoredResizeReleaseShift.test.ts +106 -10
- package/src/components/editor/canvasContextMenuZOrder.test.ts +186 -2
- package/src/components/editor/canvasContextMenuZOrder.ts +144 -22
- package/src/components/editor/domEditNudge.test.ts +80 -0
- package/src/components/editor/domEditNudge.ts +44 -0
- package/src/components/editor/domEditOverlayGeometry.test.ts +167 -1
- package/src/components/editor/domEditOverlayGeometry.ts +280 -20
- package/src/components/editor/domEditOverlayGestures.ts +44 -41
- package/src/components/editor/domEditOverlayStartGesture.ts +62 -2
- package/src/components/editor/domEditResizeLocal.test.ts +131 -0
- package/src/components/editor/domEditResizeLocal.ts +125 -0
- package/src/components/editor/domEditingDom.ts +1 -1
- package/src/components/editor/manualEditsDomPatches.test.ts +47 -0
- package/src/components/editor/manualOffsetDrag.ts +55 -12
- package/src/components/editor/offCanvasIndicatorGeometry.ts +31 -10
- package/src/components/editor/offCanvasIndicatorRefresh.test.tsx +25 -1
- package/src/components/editor/propertyPanelColorGradingSection.tsx +15 -21
- package/src/components/editor/resizeDraft.ts +108 -0
- package/src/components/editor/snapEngine.test.ts +12 -78
- package/src/components/editor/snapEngine.ts +13 -53
- package/src/components/editor/useCanvasContextMenuState.ts +92 -0
- package/src/components/editor/useDomEditNudge.test.tsx +228 -0
- package/src/components/editor/useDomEditNudge.ts +254 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +115 -116
- package/src/components/editor/useDomEditOverlayRects.ts +9 -2
- package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
- package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
- package/src/components/nle/AssetPreviewOverlay.tsx +171 -0
- package/src/components/nle/NLEContext.test.ts +144 -0
- package/src/components/nle/NLEContext.tsx +328 -0
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/nle/PreviewOverlays.tsx +260 -0
- package/src/components/nle/PreviewPane.tsx +163 -0
- package/src/components/nle/TimelinePane.test.ts +60 -0
- package/src/components/nle/TimelinePane.tsx +293 -0
- package/src/components/nle/TimelineResizeDivider.tsx +8 -2
- package/src/components/nle/useCompositionStack.test.tsx +44 -0
- package/src/components/nle/useCompositionStack.ts +17 -9
- package/src/components/nle/useTimelineEditCallbacks.ts +217 -0
- package/src/components/sidebar/AssetCard.test.tsx +106 -0
- package/src/components/sidebar/AssetCard.tsx +343 -0
- package/src/components/sidebar/AssetContextMenu.tsx +15 -30
- package/src/components/sidebar/AssetsTab.test.ts +96 -0
- package/src/components/sidebar/AssetsTab.tsx +71 -206
- package/src/components/sidebar/AudioRow.tsx +56 -2
- package/src/components/sidebar/BlocksTab.tsx +8 -1
- package/src/components/sidebar/LeftSidebar.tsx +4 -1
- package/src/components/sidebar/assetHelpers.ts +29 -0
- package/src/contexts/DomEditContext.tsx +9 -0
- package/src/contexts/StudioContext.tsx +9 -8
- package/src/contexts/TimelineEditContext.tsx +3 -7
- package/src/hooks/deleteSelectedKeyframes.ts +35 -0
- package/src/hooks/domEditCommitTypes.ts +23 -1
- package/src/hooks/domSelectionTestHarness.ts +14 -0
- package/src/hooks/gestureTransaction.test.ts +311 -0
- package/src/hooks/gestureTransaction.ts +199 -0
- package/src/hooks/gsapDragCommit.test.ts +141 -41
- package/src/hooks/gsapDragCommit.ts +64 -74
- package/src/hooks/gsapDragStaticSetHelpers.ts +9 -21
- package/src/hooks/gsapResizeIntercept.test.ts +69 -0
- package/src/hooks/gsapResizeIntercept.ts +38 -10
- package/src/hooks/gsapRuntimeBridge.test.ts +49 -13
- package/src/hooks/gsapRuntimeBridge.ts +4 -4
- package/src/hooks/gsapScriptCommitTypes.ts +16 -5
- package/src/hooks/gsapShared.test.ts +19 -1
- package/src/hooks/gsapShared.ts +14 -0
- package/src/hooks/patchDocumentRootDuration.test.ts +71 -0
- package/src/hooks/timelineEditingGsap.ts +14 -0
- package/src/hooks/timelineEditingHelpers.test.ts +125 -1
- package/src/hooks/timelineEditingHelpers.ts +53 -292
- package/src/hooks/timelineMoveAdapter.test.ts +81 -0
- package/src/hooks/timelineMoveAdapter.ts +39 -0
- package/src/hooks/timelineTimingSync.test.ts +150 -0
- package/src/hooks/timelineTimingSync.ts +412 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +36 -8
- package/src/hooks/useAnimatedPropertyCommit.ts +31 -26
- package/src/hooks/useAppHotkeys.ts +16 -25
- package/src/hooks/useBlockHandlers.ts +55 -27
- package/src/hooks/useCaptionDetection.ts +2 -0
- package/src/hooks/useCompositionDimensions.ts +32 -5
- package/src/hooks/useContextMenuDismiss.ts +31 -7
- package/src/hooks/useDomEditCommits.test.tsx +245 -1
- package/src/hooks/useDomEditCommits.ts +162 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
- package/src/hooks/useDomEditSession.ts +2 -2
- package/src/hooks/useDomEditTextCommits.test.tsx +135 -0
- package/src/hooks/useDomEditTextCommits.ts +8 -3
- package/src/hooks/useDomEditWiring.ts +7 -9
- package/src/hooks/useDomGeometryCommits.ts +17 -2
- package/src/hooks/useDomSelection.test.ts +2 -36
- package/src/hooks/useDomSelection.ts +81 -51
- package/src/hooks/useDomSelectionSelectionGuards.test.ts +276 -0
- package/src/hooks/useElementLifecycleOps.test.tsx +423 -0
- package/src/hooks/useElementLifecycleOps.ts +53 -71
- package/src/hooks/useElementPicker.ts +7 -8
- package/src/hooks/useEnableKeyframes.test.ts +142 -1
- package/src/hooks/useEnableKeyframes.ts +73 -29
- package/src/hooks/useGestureCommit.test.tsx +130 -0
- package/src/hooks/useGestureCommit.ts +32 -8
- package/src/hooks/useGroupCommits.ts +7 -28
- package/src/hooks/useGsapAwareEditing.test.tsx +239 -0
- package/src/hooks/useGsapAwareEditing.ts +115 -20
- package/src/hooks/useGsapKeyframeOps.test.tsx +59 -0
- package/src/hooks/useGsapKeyframeOps.ts +38 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +40 -1
- package/src/hooks/useGsapScriptCommits.ts +134 -50
- package/src/hooks/useGsapSelectionHandlers.ts +49 -16
- package/src/hooks/useMusicBeatAnalysis.ts +72 -36
- package/src/hooks/usePersistentEditHistory.test.ts +33 -0
- package/src/hooks/usePersistentEditHistory.ts +92 -58
- package/src/hooks/usePreviewPersistence.ts +25 -7
- package/src/hooks/useRazorSplit.history.test.tsx +148 -18
- package/src/hooks/useRazorSplit.test.ts +269 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +64 -0
- package/src/hooks/useRazorSplit.ts +107 -36
- package/src/hooks/useRenderClipContent.ts +24 -6
- package/src/hooks/useStudioContextValue.ts +15 -5
- package/src/hooks/useStudioUrlState.ts +2 -4
- package/src/hooks/useTimelineAssetDropOps.ts +175 -0
- package/src/hooks/useTimelineEditing.test.tsx +398 -241
- package/src/hooks/useTimelineEditing.ts +181 -265
- package/src/hooks/useTimelineEditingTypes.ts +6 -0
- package/src/hooks/useTimelineGroupEditing.ts +167 -132
- package/src/index.ts +1 -1
- package/src/player/components/ImageThumbnail.test.tsx +173 -0
- package/src/player/components/ImageThumbnail.tsx +160 -0
- package/src/player/components/PlayerControls.tsx +5 -5
- package/src/player/components/PlayheadIndicator.tsx +55 -5
- package/src/player/components/Timeline.test.ts +183 -23
- package/src/player/components/Timeline.tsx +194 -194
- package/src/player/components/TimelineCanvas.tsx +218 -523
- package/src/player/components/TimelineClip.tsx +4 -1
- package/src/player/components/TimelineClipDiamonds.tsx +9 -40
- package/src/player/components/TimelineEmptyState.tsx +3 -1
- package/src/player/components/TimelineLanes.tsx +487 -0
- package/src/player/components/TimelineOverlays.tsx +122 -0
- package/src/player/components/TimelineRuler.tsx +58 -48
- package/src/player/components/VideoThumbnail.test.tsx +152 -0
- package/src/player/components/VideoThumbnail.tsx +28 -7
- package/src/player/components/thumbnailUtils.test.ts +127 -0
- package/src/player/components/thumbnailUtils.ts +54 -0
- package/src/player/components/timelineCallbacks.ts +18 -18
- package/src/player/components/timelineClipChildren.tsx +39 -0
- package/src/player/components/timelineClipDragCommit.test.ts +1026 -0
- package/src/player/components/timelineClipDragCommit.ts +498 -0
- package/src/player/components/timelineClipDragPreview.test.ts +144 -0
- package/src/player/components/timelineClipDragPreview.ts +323 -38
- package/src/player/components/timelineClipDragTypes.ts +65 -0
- package/src/player/components/timelineCollision.test.ts +42 -2
- package/src/player/components/timelineDragDrop.ts +69 -60
- package/src/player/components/timelineEditCapabilities.ts +52 -0
- package/src/player/components/timelineEditing.test.ts +1 -1
- package/src/player/components/timelineEditing.ts +76 -59
- package/src/player/components/timelineGroupEditing.test.ts +126 -0
- package/src/player/components/timelineGroupEditing.ts +133 -0
- package/src/player/components/timelineGroupResizeCommit.ts +54 -0
- package/src/player/components/timelineLayout.test.ts +95 -23
- package/src/player/components/timelineLayout.ts +207 -75
- package/src/player/components/timelineMarquee.test.ts +197 -0
- package/src/player/components/timelineMarquee.ts +112 -0
- package/src/player/components/timelineOptimisticRevision.ts +42 -0
- package/src/player/components/timelineRevealScroll.test.ts +133 -0
- package/src/player/components/timelineRevealScroll.ts +91 -0
- package/src/player/components/timelineStackingSync.test.ts +60 -0
- package/src/player/components/timelineStackingSync.ts +27 -2
- package/src/player/components/timelineTheme.ts +6 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
- package/src/player/components/timelineZones.test.ts +122 -310
- package/src/player/components/timelineZones.ts +82 -135
- package/src/player/components/useResolvedTimelineEditCallbacks.ts +25 -3
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +241 -0
- package/src/player/components/useTimelineClipDrag.ts +380 -386
- package/src/player/components/useTimelineEditPinning.ts +121 -0
- package/src/player/components/useTimelineGeometry.ts +129 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -16
- package/src/player/components/useTimelineRangeSelection.ts +341 -50
- package/src/player/components/useTimelineRevealClip.ts +56 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +77 -0
- package/src/player/components/useTimelineStackingSync.ts +94 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -3
- package/src/player/hooks/usePlaybackKeyboard.ts +5 -0
- 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 +52 -53
- package/src/player/hooks/useTimelinePlayerLoop.ts +85 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
- package/src/player/index.ts +5 -2
- package/src/player/lib/runtimeProtocol.test.ts +48 -0
- package/src/player/lib/runtimeProtocol.ts +65 -0
- package/src/player/lib/time.test.ts +19 -19
- package/src/player/lib/time.ts +6 -2
- package/src/player/lib/timelineDOM.test.ts +25 -98
- package/src/player/lib/timelineDOM.ts +21 -81
- package/src/player/lib/timelineElementHelpers.ts +73 -5
- package/src/player/lib/timelineIframeHelpers.ts +4 -4
- package/src/player/store/playerStore.test.ts +38 -13
- package/src/player/store/playerStore.ts +99 -3
- package/src/styles/studio.css +27 -0
- package/src/utils/assetPreviewDismiss.test.ts +27 -0
- package/src/utils/assetPreviewDismiss.ts +29 -0
- package/src/utils/assetPreviewStore.ts +33 -0
- package/src/utils/blockInstaller.ts +10 -2
- package/src/utils/editHistory.test.ts +35 -0
- package/src/utils/gsapSoftReload.test.ts +119 -1
- package/src/utils/gsapSoftReload.ts +153 -0
- package/src/utils/mediaTypes.ts +3 -2
- package/src/utils/resizeDebug.ts +55 -0
- package/src/utils/rootDuration.test.ts +90 -1
- package/src/utils/rootDuration.ts +76 -13
- package/src/utils/sdkCutover.ts +4 -0
- package/src/utils/studioFileHistory.ts +37 -7
- package/src/utils/studioHelpers.ts +62 -0
- package/src/utils/studioPreviewHelpers.test.ts +65 -8
- package/src/utils/studioPreviewHelpers.ts +10 -0
- package/src/utils/studioTelemetry.ts +4 -1
- package/src/utils/studioUrlState.test.ts +0 -1
- package/src/utils/timelineAssetDrop.test.ts +144 -52
- package/src/utils/timelineAssetDrop.ts +67 -29
- package/src/utils/timelineDiscovery.ts +0 -17
- package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
- package/dist/assets/index-BRwkMj0w.js +0 -423
- package/dist/assets/index-Dq7FEg0K.css +0 -1
- package/dist/chunk-SOTCF4DF.js.map +0 -1
- package/src/components/StudioPreviewArea.tsx +0 -500
- package/src/components/nle/NLELayout.test.ts +0 -12
- package/src/components/nle/NLELayout.tsx +0 -591
- package/src/player/components/TimelineLayerGutter.tsx +0 -61
- package/src/player/components/TimelineSelectionOverlays.tsx +0 -55
- package/src/player/components/timelineMarqueeSelection.test.ts +0 -87
- package/src/player/components/timelineSnapTargets.test.ts +0 -144
- package/src/player/components/timelineSnapTargets.ts +0 -164
- package/src/player/components/useTimelineClipDrag.test.tsx +0 -506
- package/src/player/components/useTimelineClipGroupDrag.ts +0 -417
- package/src/player/components/useTimelineMarqueeSelection.test.tsx +0 -231
- package/src/player/components/useTimelineMarqueeSelection.ts +0 -276
- package/src/utils/timelineDiscovery.test.ts +0 -90
- /package/dist/{domEditingLayers-2ECJK24D.js.map → domEditingLayers-6LQGKPOI.js.map} +0 -0
|
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
6
6
|
import type { MutableRefObject } from "react";
|
|
7
7
|
import type { DomEditSelection, DomEditTextField } from "../components/editor/domEditing";
|
|
8
8
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
9
|
+
import { usePlayerStore } from "../player";
|
|
9
10
|
import { StudioSaveHttpError } from "../utils/studioSaveDiagnostics";
|
|
10
11
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
11
12
|
import { useDomEditCommits } from "./useDomEditCommits";
|
|
@@ -27,6 +28,7 @@ interface RenderedDomEditCommits {
|
|
|
27
28
|
hook: ReturnType<typeof useDomEditCommits>;
|
|
28
29
|
showToast: ReturnType<typeof makeShowToast>;
|
|
29
30
|
recordEdit: ReturnType<typeof vi.fn<() => Promise<void>>>;
|
|
31
|
+
reloadPreview: ReturnType<typeof vi.fn>;
|
|
30
32
|
cleanup: () => void;
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -209,6 +211,7 @@ function renderDomEditCommits(
|
|
|
209
211
|
const previewIframeRef: MutableRefObject<HTMLIFrameElement | null> = { current: iframe };
|
|
210
212
|
const projectIdRef: MutableRefObject<string | null> = { current: "p1" };
|
|
211
213
|
const domEditSaveTimestampRef: MutableRefObject<number> = { current: 0 };
|
|
214
|
+
const reloadPreview = vi.fn();
|
|
212
215
|
|
|
213
216
|
function Probe() {
|
|
214
217
|
captured.current = useDomEditCommits({
|
|
@@ -223,7 +226,7 @@ function renderDomEditCommits(
|
|
|
223
226
|
importedFontAssetsRef: { current: options.importedFontAssets ?? [] },
|
|
224
227
|
projectId: "p1",
|
|
225
228
|
projectIdRef,
|
|
226
|
-
reloadPreview
|
|
229
|
+
reloadPreview,
|
|
227
230
|
domEditSelection: selection,
|
|
228
231
|
applyDomSelection: vi.fn(),
|
|
229
232
|
clearDomSelection: vi.fn(),
|
|
@@ -244,6 +247,7 @@ function renderDomEditCommits(
|
|
|
244
247
|
hook: captured.current,
|
|
245
248
|
showToast,
|
|
246
249
|
recordEdit,
|
|
250
|
+
reloadPreview,
|
|
247
251
|
cleanup: () => {
|
|
248
252
|
act(() => {
|
|
249
253
|
root.unmount();
|
|
@@ -253,6 +257,246 @@ function renderDomEditCommits(
|
|
|
253
257
|
} satisfies RenderedDomEditCommits;
|
|
254
258
|
}
|
|
255
259
|
|
|
260
|
+
describe("useDomEditCommits z-index reorder persistence", () => {
|
|
261
|
+
beforeEach(() => {
|
|
262
|
+
ensureCssEscape();
|
|
263
|
+
vi.clearAllMocks();
|
|
264
|
+
vi.unstubAllGlobals();
|
|
265
|
+
document.body.replaceChildren();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("persists an N-element reorder with one batch POST, one undo entry, and NO iframe reload", async () => {
|
|
269
|
+
const original =
|
|
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
|
+
const after =
|
|
272
|
+
'<div id="a" style="z-index: 3"></div><div id="b" style="z-index: 2"></div><div id="c" style="z-index: 1"></div>';
|
|
273
|
+
const fetchMock = vi.fn(
|
|
274
|
+
async (
|
|
275
|
+
input: Parameters<typeof fetch>[0],
|
|
276
|
+
_init?: Parameters<typeof fetch>[1],
|
|
277
|
+
): Promise<Response> => {
|
|
278
|
+
const url = requestUrl(input);
|
|
279
|
+
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
|
|
280
|
+
if (url.includes("/file-mutations/patch-elements-batch/")) {
|
|
281
|
+
return jsonResponse({
|
|
282
|
+
ok: true,
|
|
283
|
+
changed: true,
|
|
284
|
+
matched: [true, true, true],
|
|
285
|
+
content: after,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
if (url.includes("/file-mutations/patch-element/")) {
|
|
289
|
+
return jsonResponse({ ok: true, changed: true, matched: true, content: after });
|
|
290
|
+
}
|
|
291
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
295
|
+
const { iframe, element } = createPreviewElement(
|
|
296
|
+
'<div data-hf-id="hf-card"></div><div id="b"></div><div id="c"></div>',
|
|
297
|
+
);
|
|
298
|
+
element.id = "a";
|
|
299
|
+
const elements = [
|
|
300
|
+
element,
|
|
301
|
+
iframe.contentDocument!.getElementById("b")!,
|
|
302
|
+
iframe.contentDocument!.getElementById("c")!,
|
|
303
|
+
];
|
|
304
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
await act(async () => {
|
|
308
|
+
await rendered.hook.handleDomZIndexReorderCommit(
|
|
309
|
+
elements.map((item, index) => ({
|
|
310
|
+
element: item,
|
|
311
|
+
zIndex: 3 - index,
|
|
312
|
+
id: item.id,
|
|
313
|
+
sourceFile: "index.html",
|
|
314
|
+
})),
|
|
315
|
+
"z-reorder:test",
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const batchPosts = fetchMock.mock.calls.filter(([input]) =>
|
|
320
|
+
requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
|
|
321
|
+
);
|
|
322
|
+
const singlePosts = fetchMock.mock.calls.filter(([input]) =>
|
|
323
|
+
requestUrl(input).includes("/file-mutations/patch-element/"),
|
|
324
|
+
);
|
|
325
|
+
expect(batchPosts).toHaveLength(1);
|
|
326
|
+
expect(singlePosts).toHaveLength(0);
|
|
327
|
+
expect(JSON.parse(String(batchPosts[0]?.[1]?.body))).toEqual({
|
|
328
|
+
patches: expect.arrayContaining([
|
|
329
|
+
expect.objectContaining({ target: expect.objectContaining({ id: "a" }) }),
|
|
330
|
+
expect.objectContaining({ target: expect.objectContaining({ id: "b" }) }),
|
|
331
|
+
expect.objectContaining({ target: expect.objectContaining({ id: "c" }) }),
|
|
332
|
+
]),
|
|
333
|
+
});
|
|
334
|
+
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
|
335
|
+
expect(rendered.recordEdit).toHaveBeenCalledWith({
|
|
336
|
+
label: "Reorder layers",
|
|
337
|
+
kind: "manual",
|
|
338
|
+
coalesceKey: "z-reorder:test",
|
|
339
|
+
files: { "index.html": { before: original, after } },
|
|
340
|
+
});
|
|
341
|
+
// FIX: a z-only reorder must NOT remount the preview iframe ("the blink").
|
|
342
|
+
// The live DOM + store already hold the final state and the server matched
|
|
343
|
+
// every style-only patch, so the reload is provably redundant.
|
|
344
|
+
expect(rendered.reloadPreview).not.toHaveBeenCalled();
|
|
345
|
+
} finally {
|
|
346
|
+
rendered.cleanup();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("falls back to reloading when the server response omits matched[]", async () => {
|
|
351
|
+
// Without a matched[] confirmation the persist can't be proven in sync with
|
|
352
|
+
// the live DOM — the skip-reload path must not engage.
|
|
353
|
+
const original = '<div id="a" style="z-index: 1"></div>';
|
|
354
|
+
const after = '<div id="a" style="z-index: 2"></div>';
|
|
355
|
+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
356
|
+
const url = requestUrl(input);
|
|
357
|
+
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
|
|
358
|
+
if (url.includes("/file-mutations/patch-elements-batch/")) {
|
|
359
|
+
return jsonResponse({ ok: true, changed: true, content: after });
|
|
360
|
+
}
|
|
361
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
362
|
+
});
|
|
363
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
364
|
+
const { iframe, element } = createPreviewElement();
|
|
365
|
+
element.id = "a";
|
|
366
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
367
|
+
|
|
368
|
+
try {
|
|
369
|
+
await act(async () => {
|
|
370
|
+
await rendered.hook.handleDomZIndexReorderCommit([
|
|
371
|
+
{ element, zIndex: 2, id: "a", sourceFile: "index.html" },
|
|
372
|
+
]);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
|
376
|
+
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
377
|
+
} finally {
|
|
378
|
+
rendered.cleanup();
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("warns and reports telemetry for unmatched batch patches without throwing", async () => {
|
|
383
|
+
// The server reports per-patch matched[]: #b was not found in the source.
|
|
384
|
+
// The matched subset persisted, so the commit must complete (no rollback of
|
|
385
|
+
// applied state) while surfacing the partial failure. An unmatched target
|
|
386
|
+
// also means the live DOM shows z-order the disk lacks, so the skip-reload
|
|
387
|
+
// path must NOT engage — the reload reconverges the preview with disk.
|
|
388
|
+
const original = '<div id="a" style="z-index: 1"></div>';
|
|
389
|
+
const after = '<div id="a" style="z-index: 2"></div>';
|
|
390
|
+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
391
|
+
const url = requestUrl(input);
|
|
392
|
+
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
|
|
393
|
+
if (url.includes("/file-mutations/patch-elements-batch/")) {
|
|
394
|
+
return jsonResponse({ ok: true, changed: true, matched: [true, false], content: after });
|
|
395
|
+
}
|
|
396
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
397
|
+
});
|
|
398
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
399
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
400
|
+
const { iframe, element } = createPreviewElement(
|
|
401
|
+
'<div data-hf-id="hf-card"></div><div id="b"></div>',
|
|
402
|
+
);
|
|
403
|
+
element.id = "a";
|
|
404
|
+
const second = iframe.contentDocument!.getElementById("b")!;
|
|
405
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe);
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
await act(async () => {
|
|
409
|
+
await rendered.hook.handleDomZIndexReorderCommit([
|
|
410
|
+
{ element, zIndex: 2, id: "a", sourceFile: "index.html" },
|
|
411
|
+
{ element: second, zIndex: 1, id: "b", sourceFile: "index.html" },
|
|
412
|
+
]);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// No throw: the applied live state stays, the matched subset is recorded.
|
|
416
|
+
expect(element.style.zIndex).toBe("2");
|
|
417
|
+
expect(second.style.zIndex).toBe("1");
|
|
418
|
+
expect(rendered.recordEdit).toHaveBeenCalledTimes(1);
|
|
419
|
+
expect(rendered.reloadPreview).toHaveBeenCalledTimes(1);
|
|
420
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
421
|
+
expect.stringContaining("could not match 1 patch target(s) in index.html"),
|
|
422
|
+
"b",
|
|
423
|
+
);
|
|
424
|
+
expect(trackStudioEvent).toHaveBeenCalledWith(
|
|
425
|
+
"save_failure",
|
|
426
|
+
expect.objectContaining({
|
|
427
|
+
mutation_type: "z-reorder-unmatched",
|
|
428
|
+
file_path: "index.html",
|
|
429
|
+
error_message: expect.stringContaining("b"),
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
} finally {
|
|
433
|
+
warnSpy.mockRestore();
|
|
434
|
+
rendered.cleanup();
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("rolls back live state after a failed batch POST without a disk write-back", async () => {
|
|
439
|
+
const original = '<div id="a" style="z-index: 7"></div><div id="b"></div>';
|
|
440
|
+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
|
|
441
|
+
const url = requestUrl(input);
|
|
442
|
+
if (url.includes("/api/projects/p1/files/")) return jsonResponse({ content: original });
|
|
443
|
+
if (url.includes("/file-mutations/patch-elements-batch/")) {
|
|
444
|
+
return jsonResponse({ error: "batch rejected" }, 500);
|
|
445
|
+
}
|
|
446
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
447
|
+
});
|
|
448
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
449
|
+
const writeProjectFile = vi.fn(async () => {});
|
|
450
|
+
const { iframe, element } = createPreviewElement(
|
|
451
|
+
'<div data-hf-id="hf-card" style="z-index: 7"></div><div id="b"></div>',
|
|
452
|
+
);
|
|
453
|
+
element.id = "a";
|
|
454
|
+
const second = iframe.contentDocument!.getElementById("b")!;
|
|
455
|
+
usePlayerStore.getState().setElements([
|
|
456
|
+
{ id: "a", tag: "div", start: 0, duration: 1, track: 0, zIndex: 7, hasExplicitZIndex: true },
|
|
457
|
+
{ id: "b", tag: "div", start: 0, duration: 1, track: 1, zIndex: 0, hasExplicitZIndex: false },
|
|
458
|
+
]);
|
|
459
|
+
const rendered = renderDomEditCommits(createSelection(element), iframe, { writeProjectFile });
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
let rejection: unknown;
|
|
463
|
+
await act(async () => {
|
|
464
|
+
try {
|
|
465
|
+
await rendered.hook.handleDomZIndexReorderCommit([
|
|
466
|
+
{ element, zIndex: 2, id: "a", sourceFile: "index.html", key: "a" },
|
|
467
|
+
{ element: second, zIndex: 1, id: "b", sourceFile: "index.html", key: "b" },
|
|
468
|
+
]);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
rejection = error;
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
expect(rejection).toBeInstanceOf(Error);
|
|
475
|
+
expect((rejection as Error).message).toContain("batch rejected");
|
|
476
|
+
expect(element.style.zIndex).toBe("7");
|
|
477
|
+
expect(second.style.zIndex).toBe("");
|
|
478
|
+
expect(
|
|
479
|
+
usePlayerStore
|
|
480
|
+
.getState()
|
|
481
|
+
.elements.map(({ zIndex, hasExplicitZIndex }) => ({ zIndex, hasExplicitZIndex })),
|
|
482
|
+
).toEqual([
|
|
483
|
+
{ zIndex: 7, hasExplicitZIndex: true },
|
|
484
|
+
{ zIndex: 0, hasExplicitZIndex: false },
|
|
485
|
+
]);
|
|
486
|
+
expect(writeProjectFile).not.toHaveBeenCalled();
|
|
487
|
+
expect(rendered.recordEdit).not.toHaveBeenCalled();
|
|
488
|
+
expect(rendered.reloadPreview).not.toHaveBeenCalled();
|
|
489
|
+
expect(
|
|
490
|
+
fetchMock.mock.calls.filter(([input]) =>
|
|
491
|
+
requestUrl(input).includes("/file-mutations/patch-elements-batch/"),
|
|
492
|
+
),
|
|
493
|
+
).toHaveLength(1);
|
|
494
|
+
} finally {
|
|
495
|
+
rendered.cleanup();
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
256
500
|
async function commitStyleAgainst(response: Parameters<typeof stubPatchFetch>[0]) {
|
|
257
501
|
stubPatchFetch(response);
|
|
258
502
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
@@ -4,11 +4,19 @@ import { FONT_EXT } from "../utils/mediaTypes";
|
|
|
4
4
|
|
|
5
5
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
6
6
|
import { primaryFontFamilyValue } from "../utils/studioFontHelpers";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
createStudioSaveHttpError,
|
|
9
|
+
StudioSaveHttpError,
|
|
10
|
+
trackStudioSaveFailure,
|
|
11
|
+
} from "../utils/studioSaveDiagnostics";
|
|
8
12
|
import { buildDomEditPatchTarget, type DomEditSelection } from "../components/editor/domEditing";
|
|
9
13
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
10
14
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
11
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
CommitDomEditPatchBatches,
|
|
17
|
+
DomEditPatchBatch,
|
|
18
|
+
PersistDomEditOperations,
|
|
19
|
+
} from "./domEditCommitTypes";
|
|
12
20
|
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
13
21
|
import {
|
|
14
22
|
DomEditPersistUnsafeValueError,
|
|
@@ -20,6 +28,7 @@ import { useDomEditTextCommits } from "./useDomEditTextCommits";
|
|
|
20
28
|
import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
|
21
29
|
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
|
22
30
|
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
|
|
31
|
+
import { readProjectFileContent } from "../utils/studioFileHistory";
|
|
23
32
|
|
|
24
33
|
// ── Helpers ──
|
|
25
34
|
|
|
@@ -48,9 +57,87 @@ interface RecordEditInput {
|
|
|
48
57
|
label: string;
|
|
49
58
|
kind: EditHistoryKind;
|
|
50
59
|
coalesceKey?: string;
|
|
60
|
+
coalesceMs?: number;
|
|
51
61
|
files: Record<string, { before: string; after: string }>;
|
|
52
62
|
}
|
|
53
63
|
|
|
64
|
+
/** Human-readable identifier for a batch patch target (for the unmatched warning). */
|
|
65
|
+
function describeBatchPatchTarget(patch: DomEditPatchBatch["patches"][number]): string {
|
|
66
|
+
return patch.target.id ?? patch.target.hfId ?? patch.target.selector ?? "(unaddressed)";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Surface server-reported unmatched patches. The matched subset already
|
|
71
|
+
* persisted, so this must NOT throw (a throw would roll back applied state) —
|
|
72
|
+
* warn and emit save-failure telemetry with a distinct reason instead.
|
|
73
|
+
*/
|
|
74
|
+
function reportUnmatchedBatchPatches(batch: DomEditPatchBatch, matched: boolean[]): void {
|
|
75
|
+
const unmatchedIds = batch.patches
|
|
76
|
+
.filter((_, index) => matched[index] === false)
|
|
77
|
+
.map(describeBatchPatchTarget);
|
|
78
|
+
if (unmatchedIds.length === 0) return;
|
|
79
|
+
console.warn(
|
|
80
|
+
`[studio] z-index reorder: server could not match ${unmatchedIds.length} patch target(s) in ` +
|
|
81
|
+
`${batch.sourceFile} (their z-order will revert on reload):`,
|
|
82
|
+
unmatchedIds.join(", "),
|
|
83
|
+
);
|
|
84
|
+
trackStudioSaveFailure({
|
|
85
|
+
source: "dom_edit",
|
|
86
|
+
error: new Error(`Batch patch target(s) unmatched: ${unmatchedIds.join(", ")}`),
|
|
87
|
+
filePath: batch.sourceFile,
|
|
88
|
+
mutationType: "z-reorder-unmatched",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function patchElementBatch(projectId: string, batch: DomEditPatchBatch) {
|
|
93
|
+
const before = await readProjectFileContent(projectId, batch.sourceFile);
|
|
94
|
+
const response = await fetch(
|
|
95
|
+
`/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-elements-batch/${encodeURIComponent(batch.sourceFile)}`,
|
|
96
|
+
{
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
body: JSON.stringify({ patches: batch.patches }),
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
const rejection = await readErrorResponseBody(response);
|
|
104
|
+
throw new StudioSaveHttpError(formatPatchRejectionMessage(rejection), response.status);
|
|
105
|
+
}
|
|
106
|
+
const result = (await response.json()) as {
|
|
107
|
+
changed?: boolean;
|
|
108
|
+
matched?: boolean[];
|
|
109
|
+
content?: string;
|
|
110
|
+
};
|
|
111
|
+
if (Array.isArray(result.matched)) reportUnmatchedBatchPatches(batch, result.matched);
|
|
112
|
+
return {
|
|
113
|
+
sourceFile: batch.sourceFile,
|
|
114
|
+
changed: result.changed === true,
|
|
115
|
+
// Skip-reload safety: the persist is only provably in sync with the live
|
|
116
|
+
// DOM when the server confirmed EVERY patch target matched. A missing /
|
|
117
|
+
// short matched[] is treated as unknown (false) so the caller falls back
|
|
118
|
+
// to reloading rather than silently diverging from disk.
|
|
119
|
+
allMatched:
|
|
120
|
+
Array.isArray(result.matched) &&
|
|
121
|
+
result.matched.length === batch.patches.length &&
|
|
122
|
+
result.matched.every(Boolean),
|
|
123
|
+
before,
|
|
124
|
+
after: typeof result.content === "string" ? result.content : before,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A batch is reload-skippable only when it is style-only: every operation is an
|
|
130
|
+
* `inline-style` write. The z-reorder commit applies those exact styles to the
|
|
131
|
+
* live iframe DOM synchronously, so persisting them adds nothing the preview
|
|
132
|
+
* doesn't already show. Any other op type (attribute / text-content / …) can
|
|
133
|
+
* have server-side semantics the live DOM hasn't mirrored — reload for those.
|
|
134
|
+
*/
|
|
135
|
+
function batchesAreInlineStyleOnly(batches: DomEditPatchBatch[]): boolean {
|
|
136
|
+
return batches.every((batch) =>
|
|
137
|
+
batch.patches.every((patch) => patch.operations.every((op) => op.type === "inline-style")),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
54
141
|
export interface UseDomEditCommitsParams {
|
|
55
142
|
activeCompPath: string | null;
|
|
56
143
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
@@ -271,6 +358,7 @@ export function useDomEditCommits({
|
|
|
271
358
|
label: options?.label ?? "Edit layer",
|
|
272
359
|
kind: "manual",
|
|
273
360
|
coalesceKey: options?.coalesceKey,
|
|
361
|
+
coalesceMs: options?.coalesceMs,
|
|
274
362
|
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
|
275
363
|
});
|
|
276
364
|
forceReloadSdkSession?.();
|
|
@@ -292,6 +380,77 @@ export function useDomEditCommits({
|
|
|
292
380
|
],
|
|
293
381
|
);
|
|
294
382
|
|
|
383
|
+
const commitDomEditPatchBatches: CommitDomEditPatchBatches = useCallback(
|
|
384
|
+
(batches, options) =>
|
|
385
|
+
queueDomEditSave(async () => {
|
|
386
|
+
const pid = projectIdRef.current;
|
|
387
|
+
if (!pid) throw new Error("No active project");
|
|
388
|
+
const unsafeFields = batches.flatMap((batch) =>
|
|
389
|
+
batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch)),
|
|
390
|
+
);
|
|
391
|
+
if (unsafeFields.length > 0) {
|
|
392
|
+
showToast("Couldn't save edit because it contains invalid layout values", "error");
|
|
393
|
+
throw new DomEditPersistUnsafeValueError(
|
|
394
|
+
`DOM patch contains unsafe values: ${formatUnsafeFieldList(unsafeFields)}`,
|
|
395
|
+
{ alreadyToasted: true },
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
domEditSaveTimestampRef.current = Date.now();
|
|
400
|
+
const results = await Promise.all(batches.map((batch) => patchElementBatch(pid, batch)));
|
|
401
|
+
const files = Object.fromEntries(
|
|
402
|
+
results
|
|
403
|
+
.filter((result) => result.changed)
|
|
404
|
+
.map((result) => [result.sourceFile, { before: result.before, after: result.after }]),
|
|
405
|
+
);
|
|
406
|
+
if (Object.keys(files).length === 0) return;
|
|
407
|
+
await editHistory.recordEdit({
|
|
408
|
+
label: options.label,
|
|
409
|
+
kind: "manual",
|
|
410
|
+
coalesceKey: options.coalesceKey,
|
|
411
|
+
files,
|
|
412
|
+
});
|
|
413
|
+
forceReloadSdkSession?.();
|
|
414
|
+
// A z-only reorder already applied its inline styles to the live iframe
|
|
415
|
+
// DOM (and the store) synchronously, so remounting the iframe here only
|
|
416
|
+
// produces a visible blink. Skip the reload when the caller asked for it
|
|
417
|
+
// AND the persist is provably in sync: style-only ops, every target
|
|
418
|
+
// matched. Any unmatched patch means the live DOM now shows state disk
|
|
419
|
+
// doesn't hold — reload so the preview reconverges. (The SSE/file-watcher
|
|
420
|
+
// reload is independently suppressed by domEditSaveTimestampRef above.)
|
|
421
|
+
const skipSafe =
|
|
422
|
+
options.skipReload === true &&
|
|
423
|
+
batchesAreInlineStyleOnly(batches) &&
|
|
424
|
+
results.every((result) => result.allMatched);
|
|
425
|
+
if (!skipSafe) reloadPreview();
|
|
426
|
+
}).catch((error) => {
|
|
427
|
+
const alreadyToasted =
|
|
428
|
+
(error instanceof StudioSaveHttpError ||
|
|
429
|
+
error instanceof DomEditPersistUnsafeValueError) &&
|
|
430
|
+
error.alreadyToasted;
|
|
431
|
+
if (!alreadyToasted) {
|
|
432
|
+
showToast(error instanceof Error ? error.message : "Failed to reorder layers", "error");
|
|
433
|
+
}
|
|
434
|
+
trackStudioSaveFailure({
|
|
435
|
+
source: "dom_edit",
|
|
436
|
+
error,
|
|
437
|
+
filePath: batches.map((batch) => batch.sourceFile).join(","),
|
|
438
|
+
mutationType: "z-reorder",
|
|
439
|
+
label: options.label,
|
|
440
|
+
});
|
|
441
|
+
throw error;
|
|
442
|
+
}),
|
|
443
|
+
[
|
|
444
|
+
domEditSaveTimestampRef,
|
|
445
|
+
editHistory,
|
|
446
|
+
forceReloadSdkSession,
|
|
447
|
+
projectIdRef,
|
|
448
|
+
queueDomEditSave,
|
|
449
|
+
reloadPreview,
|
|
450
|
+
showToast,
|
|
451
|
+
],
|
|
452
|
+
);
|
|
453
|
+
|
|
295
454
|
// ── Text & style commits (delegated to useDomEditTextCommits) ──
|
|
296
455
|
|
|
297
456
|
const {
|
|
@@ -352,7 +511,7 @@ export function useDomEditCommits({
|
|
|
352
511
|
onTrySdkDelete,
|
|
353
512
|
onReorderShadow,
|
|
354
513
|
forceReloadSdkSession,
|
|
355
|
-
|
|
514
|
+
commitDomEditPatchBatches,
|
|
356
515
|
});
|
|
357
516
|
|
|
358
517
|
return {
|
|
@@ -15,6 +15,7 @@ interface UseDomEditPositionPatchCommitParams {
|
|
|
15
15
|
interface PositionPatchOptions {
|
|
16
16
|
label: string;
|
|
17
17
|
coalesceKey: string;
|
|
18
|
+
coalesceMs?: number;
|
|
18
19
|
skipRefresh?: boolean;
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -30,6 +31,7 @@ export function useDomEditPositionPatchCommit({
|
|
|
30
31
|
await persistDomEditOperations(selection, patches, {
|
|
31
32
|
label: options.label,
|
|
32
33
|
coalesceKey: options.coalesceKey,
|
|
34
|
+
coalesceMs: options.coalesceMs,
|
|
33
35
|
skipRefresh: options.skipRefresh ?? true,
|
|
34
36
|
});
|
|
35
37
|
}).catch((error) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
2
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
3
|
-
import type { TimelineElement } from "../player";
|
|
3
|
+
import type { SelectElementOptions, TimelineElement } from "../player";
|
|
4
4
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
5
5
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
6
6
|
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
@@ -38,7 +38,7 @@ export interface UseDomEditSessionParams {
|
|
|
38
38
|
compositionLoading: boolean;
|
|
39
39
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
40
40
|
timelineElements: TimelineElement[];
|
|
41
|
-
setSelectedTimelineElementId: (id: string | null) => void;
|
|
41
|
+
setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void;
|
|
42
42
|
setRightCollapsed: (collapsed: boolean) => void;
|
|
43
43
|
setRightPanelTab: (tab: RightPanelTab) => void;
|
|
44
44
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { DomEditSelection, DomEditTextField } from "../components/editor/domEditing";
|
|
5
|
+
import { mountReactHarness } from "./domSelectionTestHarness";
|
|
6
|
+
import { useDomEditTextCommits, type UseDomEditTextCommitsParams } from "./useDomEditTextCommits";
|
|
7
|
+
|
|
8
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
9
|
+
|
|
10
|
+
interface Deferred<T> {
|
|
11
|
+
promise: Promise<T>;
|
|
12
|
+
resolve: (value: T | PromiseLike<T>) => void;
|
|
13
|
+
reject: (reason?: unknown) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createDeferred<T>(): Deferred<T> {
|
|
17
|
+
let resolve: Deferred<T>["resolve"] | undefined;
|
|
18
|
+
let reject: Deferred<T>["reject"] | undefined;
|
|
19
|
+
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
20
|
+
resolve = resolvePromise;
|
|
21
|
+
reject = rejectPromise;
|
|
22
|
+
});
|
|
23
|
+
if (!resolve || !reject) throw new Error("deferred callbacks were not initialized");
|
|
24
|
+
return { promise, resolve, reject };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function textField(value: string): DomEditTextField {
|
|
28
|
+
return {
|
|
29
|
+
key: "self",
|
|
30
|
+
label: "Text",
|
|
31
|
+
value,
|
|
32
|
+
tagName: "div",
|
|
33
|
+
attributes: [],
|
|
34
|
+
inlineStyles: {},
|
|
35
|
+
computedStyles: {},
|
|
36
|
+
source: "self",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function selectionFor(element: HTMLElement): DomEditSelection {
|
|
41
|
+
return {
|
|
42
|
+
id: element.id,
|
|
43
|
+
element,
|
|
44
|
+
label: "Card",
|
|
45
|
+
tagName: "div",
|
|
46
|
+
sourceFile: "index.html",
|
|
47
|
+
compositionPath: "index.html",
|
|
48
|
+
isCompositionHost: false,
|
|
49
|
+
isInsideLockedComposition: false,
|
|
50
|
+
boundingBox: { x: 0, y: 0, width: 100, height: 100 },
|
|
51
|
+
textContent: element.textContent,
|
|
52
|
+
dataAttributes: {},
|
|
53
|
+
inlineStyles: {},
|
|
54
|
+
computedStyles: {},
|
|
55
|
+
textFields: [textField(element.textContent ?? "")],
|
|
56
|
+
capabilities: {
|
|
57
|
+
canSelect: true,
|
|
58
|
+
canEditStyles: true,
|
|
59
|
+
canCrop: true,
|
|
60
|
+
canMove: true,
|
|
61
|
+
canResize: true,
|
|
62
|
+
canApplyManualOffset: true,
|
|
63
|
+
canApplyManualSize: true,
|
|
64
|
+
canApplyManualRotation: true,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let cleanup: (() => void) | null = null;
|
|
70
|
+
|
|
71
|
+
function renderTextCommitHook(params: UseDomEditTextCommitsParams) {
|
|
72
|
+
const captured: { hook: ReturnType<typeof useDomEditTextCommits> | null } = { hook: null };
|
|
73
|
+
function TextCommitProbe() {
|
|
74
|
+
captured.hook = useDomEditTextCommits(params);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const root = mountReactHarness(<TextCommitProbe />);
|
|
78
|
+
cleanup = () => act(() => root.unmount());
|
|
79
|
+
if (!captured.hook) throw new Error("hook did not initialize");
|
|
80
|
+
return captured.hook;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
cleanup?.();
|
|
85
|
+
cleanup = null;
|
|
86
|
+
document.body.replaceChildren();
|
|
87
|
+
vi.restoreAllMocks();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("useDomEditTextCommits", () => {
|
|
91
|
+
it("does not let a stale failed fields commit revert newer text", async () => {
|
|
92
|
+
const iframe = document.createElement("iframe");
|
|
93
|
+
document.body.append(iframe);
|
|
94
|
+
const doc = iframe.contentDocument;
|
|
95
|
+
if (!doc) throw new Error("expected iframe document");
|
|
96
|
+
doc.body.innerHTML = '<div id="card">Original</div>';
|
|
97
|
+
const element = doc.getElementById("card");
|
|
98
|
+
const HTMLElementCtor = doc.defaultView?.HTMLElement;
|
|
99
|
+
if (!HTMLElementCtor || !(element instanceof HTMLElementCtor)) {
|
|
100
|
+
throw new Error("expected preview element");
|
|
101
|
+
}
|
|
102
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
103
|
+
const selection = selectionFor(element);
|
|
104
|
+
const stalePersist = createDeferred<void>();
|
|
105
|
+
const persistDomEditOperations = vi
|
|
106
|
+
.fn()
|
|
107
|
+
.mockImplementationOnce(() => stalePersist.promise)
|
|
108
|
+
.mockResolvedValueOnce(undefined);
|
|
109
|
+
const hook = renderTextCommitHook({
|
|
110
|
+
activeCompPath: "index.html",
|
|
111
|
+
previewIframeRef: { current: iframe },
|
|
112
|
+
showToast: vi.fn(),
|
|
113
|
+
domEditSelection: selection,
|
|
114
|
+
applyDomSelection: vi.fn(),
|
|
115
|
+
refreshDomEditSelectionFromPreview: vi.fn(),
|
|
116
|
+
buildDomSelectionFromTarget: vi.fn(async () => null),
|
|
117
|
+
persistDomEditOperations,
|
|
118
|
+
resolveImportedFontAsset: () => null,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
let staleCommit: Promise<void> | undefined;
|
|
122
|
+
act(() => {
|
|
123
|
+
staleCommit = hook.commitDomTextFields(selection, [textField("Stale")]);
|
|
124
|
+
});
|
|
125
|
+
await act(async () => {
|
|
126
|
+
await hook.commitDomTextFields(selection, [textField("Newest")]);
|
|
127
|
+
});
|
|
128
|
+
stalePersist.reject(new Error("stale request failed"));
|
|
129
|
+
await act(async () => {
|
|
130
|
+
await staleCommit;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(element.innerHTML).toBe("Newest");
|
|
134
|
+
});
|
|
135
|
+
});
|