@hyperframes/studio 0.8.113 → 0.8.115
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-DzJbtQVO.js → hyperframes-player-C-9N8nk0.js} +1 -1
- package/dist/assets/{index-n5FUn1xv.js → index-BoUM6Xqb.js} +1 -1
- package/dist/assets/{index-DRhF3v4Z.js → index-CAvZ0AA-.js} +204 -204
- package/dist/assets/{index-DgwDQK5G.js → index-CVuorIMv.js} +1 -1
- package/dist/assets/index-t1LH6H5H.css +1 -0
- package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
- package/dist/chunk-5EKWJZLM.js.map +1 -0
- package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
- package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
- package/dist/chunk-TXZMMOMA.js.map +1 -0
- package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
- package/dist/index.d.ts +46 -22
- package/dist/index.html +2 -2
- package/dist/index.js +3899 -3487
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/package.json +10 -8
- package/src/App.tsx +84 -86
- package/src/components/EditorShell.tsx +3 -3
- package/src/components/LinkedSelectionToggle.tsx +3 -2
- package/src/components/StudioHeader.tsx +4 -4
- package/src/components/StudioLeftPanels.tsx +3 -3
- package/src/components/StudioOverlays.tsx +4 -4
- package/src/components/StudioRightPanels.tsx +14 -9
- package/src/components/StudioToast.tsx +1 -1
- package/src/components/TimelineToolbar.tsx +3 -3
- package/src/components/dock/Dock.tsx +5 -7
- package/src/components/editor/CanvasContextMenu.tsx +3 -2
- package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
- package/src/components/editor/DomEditCropHandles.tsx +7 -4
- package/src/components/editor/DomEditOverlay.tsx +5 -1
- package/src/components/editor/FileTreeNodes.tsx +13 -10
- package/src/components/editor/InlineTextToolbar.tsx +11 -27
- package/src/components/editor/MotionPathOverlay.tsx +7 -2
- package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
- package/src/components/editor/SnapToolbar.test.tsx +100 -1
- package/src/components/editor/SnapToolbar.tsx +43 -5
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
- package/src/components/editor/audioFxTelemetry.ts +2 -3
- package/src/components/editor/colorBrowserCanvas.test.ts +116 -0
- package/src/components/editor/colorValue.test.ts +16 -0
- package/src/components/editor/colorValue.ts +100 -24
- package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
- package/src/components/editor/cropResize.test.ts +11 -0
- package/src/components/editor/cropResize.ts +6 -6
- package/src/components/editor/domEditingDom.ts +4 -0
- package/src/components/editor/domEditingLayers.ts +1 -5
- package/src/components/editor/gradientValue.ts +28 -36
- package/src/components/editor/gsapAnimatesProperty.ts +0 -54
- package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
- package/src/components/editor/inlineTextStyleRange.ts +5 -2
- package/src/components/editor/inlineTextStyleRead.ts +9 -0
- package/src/components/editor/manualEdits.ts +1 -0
- package/src/components/editor/manualEditsDom.ts +2 -2
- package/src/components/editor/manualEditsParsing.ts +7 -0
- package/src/components/editor/manualEditsSeekReapply.ts +2 -2
- package/src/components/editor/marqueeCommit.test.tsx +106 -37
- package/src/components/editor/marqueeCommit.ts +24 -8
- package/src/components/editor/motionPathCommit.test.ts +29 -1
- package/src/components/editor/motionPathCommit.ts +21 -9
- package/src/components/editor/probeSourceElement.test.ts +45 -0
- package/src/components/editor/probeSourceElement.ts +39 -15
- package/src/components/editor/propertyPanelColor.tsx +1 -1
- package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
- package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
- package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
- package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
- package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
- package/src/components/editor/propertyPanelFxSection.tsx +30 -28
- package/src/components/editor/propertyPanelTypes.ts +6 -3
- package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +25 -0
- package/src/components/editor/useDomEditNudge.test.tsx +37 -0
- package/src/components/editor/useDomEditNudge.ts +2 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
- package/src/components/editor/useDomEditZOrder.ts +12 -2
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
- package/src/components/sidebar/AssetContextMenu.tsx +4 -4
- package/src/components/ui/menuStyle.test.ts +61 -0
- package/src/components/ui/menuStyle.ts +10 -0
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/contexts/TimelineEditContext.tsx +1 -0
- package/src/domEditExports.test.tsx +9 -1
- package/src/hooks/domEditCommitRunner.ts +1 -1
- package/src/hooks/elementOffsetStager.ts +5 -4
- package/src/hooks/gsapDragCommit.ts +2 -20
- package/src/hooks/gsapDragPositionCommit.ts +18 -3
- package/src/hooks/gsapGestureOutcome.test.ts +161 -0
- package/src/hooks/gsapGestureOutcome.ts +64 -0
- package/src/hooks/gsapKeyframeCommit.ts +3 -12
- package/src/hooks/gsapResizeIntercept.test.ts +33 -0
- package/src/hooks/gsapResizeIntercept.ts +1 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
- package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
- package/src/hooks/gsapRuntimeReaders.ts +3 -90
- package/src/hooks/gsapScriptCommitTypes.ts +5 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
- package/src/hooks/keyframeCacheAstLoad.ts +6 -5
- package/src/hooks/plainRotation.ts +3 -2
- package/src/hooks/previewPromotionSelection.test.tsx +1 -1
- package/src/hooks/refreshTimelineRowText.test.ts +65 -0
- package/src/hooks/refreshTimelineRowText.ts +29 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
- package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
- package/src/hooks/useAppHotkeys.ts +11 -5
- package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
- package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
- package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
- package/src/hooks/useDomEditTextCommits.ts +5 -1
- package/src/hooks/useDomEditWiring.ts +4 -2
- package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
- package/src/hooks/useDomGeometryCommit.ts +7 -4
- package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
- package/src/hooks/useDomGeometryCommits.ts +3 -2
- package/src/hooks/useDomSelection.test.ts +26 -0
- package/src/hooks/useDomSelection.ts +10 -3
- package/src/hooks/useDomSelectionTypes.ts +6 -1
- package/src/hooks/useEnableKeyframes.test.ts +2 -2
- package/src/hooks/useEnableKeyframes.ts +5 -3
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
- package/src/hooks/useFrameCapture.ts +15 -8
- package/src/hooks/useGestureCommit.test.tsx +225 -55
- package/src/hooks/useGestureCommit.ts +90 -56
- package/src/hooks/useGestureTrailOverlay.tsx +33 -0
- package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
- package/src/hooks/useGsapAwareEditing.ts +96 -195
- package/src/hooks/useGsapAwareGroupMove.ts +179 -0
- package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
- package/src/hooks/useGsapKeyframeOps.ts +36 -10
- package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
- package/src/hooks/useGsapScriptCommits.ts +3 -0
- package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
- package/src/hooks/useGsapSelectionHandlers.ts +24 -18
- package/src/hooks/useGsapTweenCache.ts +18 -12
- package/src/hooks/usePanelLayout.ts +5 -1
- package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
- package/src/hooks/usePreviewInteraction.test.ts +21 -0
- package/src/hooks/usePreviewInteraction.ts +2 -0
- package/src/hooks/useRenderClipContent.test.ts +18 -0
- package/src/hooks/useRenderClipContent.ts +4 -5
- package/src/hooks/useShellSlices.ts +53 -0
- package/src/hooks/useStableHandlers.test.tsx +77 -0
- package/src/hooks/useStableHandlers.ts +44 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -1
- package/src/hooks/useStudioSidePanels.test.tsx +44 -0
- package/src/hooks/useStudioSidePanels.tsx +23 -0
- package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
- package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
- package/src/hooks/useTimelineDeleteOps.ts +21 -27
- package/src/hooks/useTimelineEditing.test.tsx +1 -1
- package/src/hooks/useTimelineEditing.ts +33 -27
- package/src/player/components/AutomationSelectionMenu.tsx +3 -2
- package/src/player/components/ClipContextMenu.test.tsx +73 -0
- package/src/player/components/ClipContextMenu.tsx +33 -31
- package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
- package/src/player/components/OutOfSyncBadge.tsx +5 -5
- package/src/player/components/TextClipContent.test.ts +19 -0
- package/src/player/components/TextClipContent.tsx +27 -0
- package/src/player/components/TrackGapContextMenu.tsx +3 -6
- package/src/player/components/clipMenuToolItems.test.tsx +23 -0
- package/src/player/components/clipMenuToolItems.tsx +13 -9
- package/src/player/components/timelineCallbacks.ts +1 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
- package/src/player/components/timelineClipGestureHandlers.ts +20 -2
- package/src/player/components/timelineEditing.ts +3 -1
- package/src/player/components/timelineLinkSelection.ts +35 -1
- package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
- package/src/player/components/useTimelineClipDrag.ts +6 -7
- package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
- package/src/player/hooks/timelinePlayerSync.ts +6 -1
- package/src/player/lib/timelineElementHelpers.test.ts +87 -1
- package/src/player/lib/timelineElementHelpers.ts +6 -15
- package/src/player/lib/timelineText.ts +40 -0
- package/src/player/store/timelineElement.ts +12 -0
- package/src/styles/theme.css +3 -0
- package/src/utils/frameCapture.ts +3 -1
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
- package/src/utils/keyframeUsage.ts +79 -0
- package/src/utils/previewFeatureUsage.test.ts +32 -0
- package/src/utils/previewFeatureUsage.ts +46 -0
- package/src/utils/studioTelemetry.test.ts +107 -0
- package/src/utils/studioTelemetry.ts +49 -4
- package/src/webmcp/StudioAgentTools.tsx +2 -2
- package/dist/assets/index-DbfKVTId.css +0 -1
- package/dist/chunk-BIUXP3PG.js.map +0 -1
- package/dist/chunk-K47E2ZJA.js.map +0 -1
- /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
- /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
|
@@ -334,3 +334,29 @@ describe("useDomSelection", () => {
|
|
|
334
334
|
harness.cleanup();
|
|
335
335
|
});
|
|
336
336
|
});
|
|
337
|
+
|
|
338
|
+
it("reports unchanged marquee membership and one new additive member", () => {
|
|
339
|
+
const { harness, card, chip } = renderCardAndChip();
|
|
340
|
+
try {
|
|
341
|
+
act(() => harness.current().applyDomSelection(card));
|
|
342
|
+
let receipt!: ReturnType<ReturnType<typeof useDomSelection>["applyMarqueeSelection"]>;
|
|
343
|
+
act(() => {
|
|
344
|
+
receipt = harness.current().applyMarqueeSelection([chip], true);
|
|
345
|
+
});
|
|
346
|
+
expect(receipt).toEqual({ changed: true, count: 2 });
|
|
347
|
+
act(() => {
|
|
348
|
+
receipt = harness.current().applyMarqueeSelection([card, chip], true);
|
|
349
|
+
});
|
|
350
|
+
expect(receipt).toEqual({ changed: false, count: 2 });
|
|
351
|
+
act(() => {
|
|
352
|
+
receipt = harness.current().applyMarqueeSelection([], true);
|
|
353
|
+
});
|
|
354
|
+
expect(receipt).toEqual({ changed: false, count: 2 });
|
|
355
|
+
act(() => {
|
|
356
|
+
receipt = harness.current().applyMarqueeSelection([], false);
|
|
357
|
+
});
|
|
358
|
+
expect(receipt).toEqual({ changed: true, count: 0 });
|
|
359
|
+
} finally {
|
|
360
|
+
harness.cleanup();
|
|
361
|
+
}
|
|
362
|
+
});
|
|
@@ -487,12 +487,13 @@ export function useDomSelection({
|
|
|
487
487
|
// fallow-ignore-next-line complexity
|
|
488
488
|
(selections: DomEditSelection[], additive: boolean) => {
|
|
489
489
|
logSelect("marquee", { hits: selections.length, additive });
|
|
490
|
+
const current = domEditSelectionRef.current;
|
|
491
|
+
const currentGroup = domEditGroupSelectionsRef.current;
|
|
490
492
|
if (selections.length === 0) {
|
|
493
|
+
const count = seedDomEditGroupWithSelection(currentGroup, current).length;
|
|
491
494
|
if (!additive) applyDomSelection(null, { revealPanel: false });
|
|
492
|
-
return;
|
|
495
|
+
return { changed: !additive && count > 0, count: additive ? count : 0 };
|
|
493
496
|
}
|
|
494
|
-
const current = domEditSelectionRef.current;
|
|
495
|
-
const currentGroup = domEditGroupSelectionsRef.current;
|
|
496
497
|
let nextGroup: DomEditSelection[];
|
|
497
498
|
if (additive) {
|
|
498
499
|
nextGroup = seedDomEditGroupWithSelection(currentGroup, current);
|
|
@@ -507,11 +508,17 @@ export function useDomSelection({
|
|
|
507
508
|
}
|
|
508
509
|
}
|
|
509
510
|
const nextSelection = additive && current ? current : selections[0];
|
|
511
|
+
const previous = seedDomEditGroupWithSelection(currentGroup, current);
|
|
512
|
+
const changed =
|
|
513
|
+
previous.length !== nextGroup.length ||
|
|
514
|
+
nextGroup.some((selection) => !domEditSelectionInGroup(previous, selection)) ||
|
|
515
|
+
!domEditSelectionsTargetSame(current, nextSelection);
|
|
510
516
|
domEditSelectionRef.current = nextSelection;
|
|
511
517
|
domEditGroupSelectionsRef.current = nextGroup;
|
|
512
518
|
setDomEditSelection(nextSelection);
|
|
513
519
|
setDomEditGroupSelections(nextGroup);
|
|
514
520
|
announceTimelineSelection(nextGroup, nextSelection, true);
|
|
521
|
+
return { changed, count: nextGroup.length };
|
|
515
522
|
},
|
|
516
523
|
[applyDomSelection, announceTimelineSelection],
|
|
517
524
|
);
|
|
@@ -3,6 +3,11 @@ import type { DomEditSelection } from "../components/editor/domEditing";
|
|
|
3
3
|
import type { SelectElementOptions, TimelineElement } from "../player";
|
|
4
4
|
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
5
5
|
|
|
6
|
+
export interface DomSelectionResult {
|
|
7
|
+
changed: boolean;
|
|
8
|
+
count: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
6
11
|
export interface ApplyDomSelectionOptions {
|
|
7
12
|
revealPanel?: boolean;
|
|
8
13
|
additive?: boolean;
|
|
@@ -85,5 +90,5 @@ export interface UseDomSelectionReturn {
|
|
|
85
90
|
handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
|
|
86
91
|
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
|
|
87
92
|
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
|
|
88
|
-
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) =>
|
|
93
|
+
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => DomSelectionResult;
|
|
89
94
|
}
|
|
@@ -314,7 +314,7 @@ describe("applyArcKeyframeAtPlayhead", () => {
|
|
|
314
314
|
],
|
|
315
315
|
ease: "none",
|
|
316
316
|
},
|
|
317
|
-
{ label: "Add keyframe", softReload: true },
|
|
317
|
+
{ label: "Add keyframe", softReload: true, keyframeAction: "add" },
|
|
318
318
|
);
|
|
319
319
|
});
|
|
320
320
|
|
|
@@ -336,7 +336,7 @@ describe("applyArcKeyframeAtPlayhead", () => {
|
|
|
336
336
|
duration: 10,
|
|
337
337
|
keyframes: expect.arrayContaining([{ percentage: 25, properties: { x: 25, y: 25 } }]),
|
|
338
338
|
}),
|
|
339
|
-
{ label: "Add keyframe", softReload: true },
|
|
339
|
+
{ label: "Add keyframe", softReload: true, keyframeAction: "add" },
|
|
340
340
|
);
|
|
341
341
|
});
|
|
342
342
|
});
|
|
@@ -159,7 +159,7 @@ async function replaceSetWithSingleKeyframe(
|
|
|
159
159
|
keyframes: [{ percentage: 0, properties: position }],
|
|
160
160
|
ease: setAnim.ease,
|
|
161
161
|
},
|
|
162
|
-
{ label: "Enable keyframes", softReload: true },
|
|
162
|
+
{ label: "Enable keyframes", keyframeAction: "add", softReload: true },
|
|
163
163
|
);
|
|
164
164
|
}
|
|
165
165
|
|
|
@@ -270,6 +270,7 @@ async function extendKeyframedTweenToPlayhead(
|
|
|
270
270
|
},
|
|
271
271
|
{
|
|
272
272
|
label: "Add keyframe",
|
|
273
|
+
keyframeAction: "add",
|
|
273
274
|
softReload: true,
|
|
274
275
|
...commitOverrides,
|
|
275
276
|
},
|
|
@@ -377,7 +378,7 @@ export async function promoteSetToKeyframes(
|
|
|
377
378
|
],
|
|
378
379
|
ease: setAnim.ease,
|
|
379
380
|
},
|
|
380
|
-
{ label: "Add keyframe", softReload: true },
|
|
381
|
+
{ label: "Add keyframe", keyframeAction: "add", softReload: true },
|
|
381
382
|
);
|
|
382
383
|
}
|
|
383
384
|
|
|
@@ -454,7 +455,7 @@ export async function applyArcKeyframeAtPlayhead(
|
|
|
454
455
|
}),
|
|
455
456
|
ease: "none",
|
|
456
457
|
},
|
|
457
|
-
{ label: "Add keyframe", softReload: true },
|
|
458
|
+
{ label: "Add keyframe", keyframeAction: "add", softReload: true },
|
|
458
459
|
);
|
|
459
460
|
}
|
|
460
461
|
|
|
@@ -503,6 +504,7 @@ export function useEnableKeyframes(
|
|
|
503
504
|
enableKeyframesTransactionCounter += 1;
|
|
504
505
|
const coalesceKey = `enable-keyframes:${flatAnim.id}:${enableKeyframesTransactionCounter}`;
|
|
505
506
|
const convertCommitOverrides: Partial<CommitMutationOptions> = {
|
|
507
|
+
keyframeTelemetry: false,
|
|
506
508
|
skipReload: true,
|
|
507
509
|
coalesceKey,
|
|
508
510
|
coalesceMs: Number.POSITIVE_INFINITY,
|
|
@@ -143,6 +143,85 @@ describe("external file change coordinator", () => {
|
|
|
143
143
|
expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("scene.html", ["scene.html"]);
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
it("keeps a waiting head edit's thumbnail refresh when a later write replaces it", async () => {
|
|
147
|
+
let release = () => {};
|
|
148
|
+
const inFlight = new Promise<void>((resolve) => (release = resolve));
|
|
149
|
+
const onAcceptedPersistedFileChange = vi.fn();
|
|
150
|
+
let drains = 0;
|
|
151
|
+
await mountCoordinator({
|
|
152
|
+
onAcceptedPersistedFileChange,
|
|
153
|
+
drainPendingChanges: async () => {
|
|
154
|
+
if (drains++ === 0) await inFlight;
|
|
155
|
+
return { status: "clean" as const };
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
await act(async () => handler?.({ path: "other.html", content: "a", version: "v1" }));
|
|
159
|
+
await act(async () => handler?.({ path: "index.html", content: "b", version: "v2" }));
|
|
160
|
+
await act(async () =>
|
|
161
|
+
handler?.({
|
|
162
|
+
path: "index.html",
|
|
163
|
+
content: "c",
|
|
164
|
+
version: "v3",
|
|
165
|
+
affectedCompositions: ["index.html"],
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
await act(async () => release());
|
|
169
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("index.html", null);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("keeps a head edit's thumbnail refresh through a conflict that a later write replaces", async () => {
|
|
173
|
+
const onAcceptedPersistedFileChange = vi.fn();
|
|
174
|
+
const conflict = new StudioFileConflictError({
|
|
175
|
+
filePath: "index.html",
|
|
176
|
+
currentVersion: "v1",
|
|
177
|
+
currentContent: "theirs",
|
|
178
|
+
attemptedContent: "mine",
|
|
179
|
+
});
|
|
180
|
+
const { captured } = await mountCoordinator({
|
|
181
|
+
onAcceptedPersistedFileChange,
|
|
182
|
+
drainPendingChanges: async () => ({ status: "conflict" as const, error: conflict }),
|
|
183
|
+
});
|
|
184
|
+
await act(async () => handler?.({ path: "index.html", content: "head", version: "v1" }));
|
|
185
|
+
await act(async () =>
|
|
186
|
+
handler?.({
|
|
187
|
+
path: "index.html",
|
|
188
|
+
content: "body",
|
|
189
|
+
version: "v2",
|
|
190
|
+
affectedCompositions: ["index.html"],
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
await act(async () => captured.handle?.useExternalFile());
|
|
194
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("index.html", null);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("keeps a held head edit's thumbnail refresh when a later write saves cleanly", async () => {
|
|
198
|
+
const onAcceptedPersistedFileChange = vi.fn();
|
|
199
|
+
const conflict = new StudioFileConflictError({
|
|
200
|
+
filePath: "index.html",
|
|
201
|
+
currentVersion: "v1",
|
|
202
|
+
currentContent: "theirs",
|
|
203
|
+
attemptedContent: "mine",
|
|
204
|
+
});
|
|
205
|
+
let drains = 0;
|
|
206
|
+
await mountCoordinator({
|
|
207
|
+
onAcceptedPersistedFileChange,
|
|
208
|
+
drainPendingChanges: async () =>
|
|
209
|
+
drains++ === 0
|
|
210
|
+
? { status: "conflict" as const, error: conflict }
|
|
211
|
+
: { status: "clean" as const },
|
|
212
|
+
});
|
|
213
|
+
await act(async () => handler?.({ path: "index.html", content: "head", version: "v1" }));
|
|
214
|
+
await act(async () =>
|
|
215
|
+
handler?.({
|
|
216
|
+
path: "index.html",
|
|
217
|
+
content: "body",
|
|
218
|
+
version: "v2",
|
|
219
|
+
affectedCompositions: ["index.html"],
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
expect(onAcceptedPersistedFileChange).toHaveBeenLastCalledWith("index.html", null);
|
|
223
|
+
});
|
|
224
|
+
|
|
146
225
|
it("does not refresh the tree for a suppressed self-write echo", async () => {
|
|
147
226
|
const refreshFileTree = vi.fn();
|
|
148
227
|
await mountCoordinator({ refreshFileTree });
|
|
@@ -333,6 +412,29 @@ describe("external file change coordinator", () => {
|
|
|
333
412
|
expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
|
|
334
413
|
});
|
|
335
414
|
|
|
415
|
+
it("keeps the failed file's payload when a snapshot delete fails for another file", async () => {
|
|
416
|
+
const { captured } = await mountCoordinator({
|
|
417
|
+
drainPendingChanges: vi
|
|
418
|
+
.fn()
|
|
419
|
+
.mockResolvedValueOnce({ status: "failed" as const, error: new Error("offline") })
|
|
420
|
+
.mockResolvedValueOnce({ status: "clean" as const }),
|
|
421
|
+
getPendingCandidate: () => ({ path: "scene.html", content: "studio" }),
|
|
422
|
+
deleteConflictSnapshot: vi.fn(async () => {
|
|
423
|
+
throw new Error("delete failed");
|
|
424
|
+
}),
|
|
425
|
+
});
|
|
426
|
+
await act(async () =>
|
|
427
|
+
handler?.({ path: "scene.html", content: "scene-external", version: "s1" }),
|
|
428
|
+
);
|
|
429
|
+
await act(async () =>
|
|
430
|
+
handler?.({ path: "index.html", content: "index-external", version: "i1" }),
|
|
431
|
+
);
|
|
432
|
+
expect(captured.handle?.blocked).toMatchObject({
|
|
433
|
+
status: "failed",
|
|
434
|
+
payload: { path: "scene.html", content: "scene-external", version: "s1" },
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
336
438
|
it("restores and overwrites from a durable failed draft", async () => {
|
|
337
439
|
const overwriteConflict = vi.fn(async () => undefined);
|
|
338
440
|
const onAcceptedPersistedFileChange = vi.fn();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
|
2
2
|
import {
|
|
3
|
+
mergeFileChangeAffectedCompositions,
|
|
3
4
|
readFileChangeAffectedCompositions,
|
|
4
5
|
readFileChangeAffectsPreview,
|
|
5
6
|
readFileChangeField,
|
|
@@ -163,7 +164,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
163
164
|
onAcceptedPersistedFileChange,
|
|
164
165
|
refreshFileTree,
|
|
165
166
|
}: ExternalFileChangeCoordinatorOptions): ExternalFileChangeCoordinatorHandle {
|
|
166
|
-
const [blocked,
|
|
167
|
+
const [blocked, setBlockedState] = useState<ExternalFileChangeBlockedState | null>(null);
|
|
167
168
|
const generationRef = useRef(0);
|
|
168
169
|
const mountedRef = useRef(true);
|
|
169
170
|
const lastEventIdentityRef = useRef<string | null>(null);
|
|
@@ -172,6 +173,14 @@ export function useExternalFileChangeCoordinator({
|
|
|
172
173
|
const drainingRef = useRef(false);
|
|
173
174
|
const pendingPayloadRef = useRef<{ payload: unknown } | null>(null);
|
|
174
175
|
blockedRef.current = blocked;
|
|
176
|
+
// A newer blocked change replaces the held one, so it inherits the thumbnails the held one owed.
|
|
177
|
+
const setBlocked = useCallback((next: ExternalFileChangeBlockedState | null) => {
|
|
178
|
+
setBlockedState((held) =>
|
|
179
|
+
next && held
|
|
180
|
+
? { ...next, payload: mergeFileChangeAffectedCompositions(held.payload, next.payload) }
|
|
181
|
+
: next,
|
|
182
|
+
);
|
|
183
|
+
}, []);
|
|
175
184
|
|
|
176
185
|
useEffect(() => {
|
|
177
186
|
mountedRef.current = true;
|
|
@@ -185,7 +194,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
185
194
|
generationRef.current += 1;
|
|
186
195
|
setBlocked(null);
|
|
187
196
|
lastEventIdentityRef.current = null;
|
|
188
|
-
}, [projectId, activeCompPath]);
|
|
197
|
+
}, [projectId, activeCompPath, setBlocked]);
|
|
189
198
|
|
|
190
199
|
useEffect(() => {
|
|
191
200
|
if (!projectId || !recoveryFilePath || !loadConflictSnapshot) return;
|
|
@@ -229,7 +238,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
229
238
|
return () => {
|
|
230
239
|
cancelled = true;
|
|
231
240
|
};
|
|
232
|
-
}, [loadConflictSnapshot, projectId, recoveryFilePath]);
|
|
241
|
+
}, [loadConflictSnapshot, projectId, recoveryFilePath, setBlocked]);
|
|
233
242
|
|
|
234
243
|
const reloadAcceptedGeneration = useCallback(
|
|
235
244
|
(path: string, affectsPreview = true) => {
|
|
@@ -275,14 +284,22 @@ export function useExternalFileChangeCoordinator({
|
|
|
275
284
|
await deleteConflictSnapshot(projectId!, path);
|
|
276
285
|
} catch (error) {
|
|
277
286
|
if (mountedRef.current && generation === generationRef.current) {
|
|
278
|
-
setBlocked({
|
|
287
|
+
setBlocked({
|
|
288
|
+
...previousBlocked,
|
|
289
|
+
generation,
|
|
290
|
+
error,
|
|
291
|
+
payload: mergeFileChangeAffectedCompositions(payload, previousBlocked.payload),
|
|
292
|
+
});
|
|
279
293
|
}
|
|
280
294
|
return;
|
|
281
295
|
}
|
|
282
296
|
}
|
|
283
297
|
if (!mountedRef.current || generation !== generationRef.current) return;
|
|
284
298
|
setBlocked(null);
|
|
285
|
-
|
|
299
|
+
const owed = previousBlocked
|
|
300
|
+
? mergeFileChangeAffectedCompositions(previousBlocked.payload, payload)
|
|
301
|
+
: payload;
|
|
302
|
+
onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(owed));
|
|
286
303
|
reloadAcceptedGeneration(path, readFileChangeAffectsPreview(payload));
|
|
287
304
|
return;
|
|
288
305
|
}
|
|
@@ -344,6 +361,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
344
361
|
},
|
|
345
362
|
[
|
|
346
363
|
drainPendingChanges,
|
|
364
|
+
setBlocked,
|
|
347
365
|
projectId,
|
|
348
366
|
deleteConflictSnapshot,
|
|
349
367
|
getPendingCandidate,
|
|
@@ -419,7 +437,12 @@ export function useExternalFileChangeCoordinator({
|
|
|
419
437
|
waiting != null &&
|
|
420
438
|
readFileChangeAffectsPreview(waiting) &&
|
|
421
439
|
!readFileChangeAffectsPreview(payload);
|
|
422
|
-
if (!waitingChangeOutranksThis)
|
|
440
|
+
if (!waitingChangeOutranksThis) {
|
|
441
|
+
pendingPayloadRef.current = {
|
|
442
|
+
payload:
|
|
443
|
+
waiting == null ? payload : mergeFileChangeAffectedCompositions(waiting, payload),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
423
446
|
void startDrainLoop();
|
|
424
447
|
},
|
|
425
448
|
[projectId, pendingTimelineEditPathRef, startDrainLoop, onAcceptedPersistedFileChange],
|
|
@@ -469,6 +492,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
469
492
|
},
|
|
470
493
|
[
|
|
471
494
|
deleteConflictSnapshot,
|
|
495
|
+
setBlocked,
|
|
472
496
|
discardPendingChanges,
|
|
473
497
|
onUseExternalFile,
|
|
474
498
|
onAcceptedPersistedFileChange,
|
|
@@ -527,6 +551,7 @@ export function useExternalFileChangeCoordinator({
|
|
|
527
551
|
reloadAcceptedGeneration(conflict.filePath);
|
|
528
552
|
}, [
|
|
529
553
|
deleteConflictSnapshot,
|
|
554
|
+
setBlocked,
|
|
530
555
|
discardPendingChanges,
|
|
531
556
|
overwriteConflict,
|
|
532
557
|
projectId,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback, useRef, type MouseEvent } from "react";
|
|
1
|
+
import { useMemo, useState, useCallback, useRef, type MouseEvent } from "react";
|
|
2
2
|
import { useMountEffect } from "./useMountEffect";
|
|
3
3
|
import { liveTime, usePlayerStore } from "../player";
|
|
4
4
|
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
|
|
@@ -34,8 +34,10 @@ export function useFrameCapture({
|
|
|
34
34
|
return isPlaying ? livePlayheadRef.current : currentTime;
|
|
35
35
|
}, []);
|
|
36
36
|
|
|
37
|
+
const [captureStamp, setCaptureStamp] = useState(Date.now);
|
|
37
38
|
const refreshCaptureFrameTime = useCallback(() => {
|
|
38
39
|
setCaptureFrameTime(playheadTime());
|
|
40
|
+
setCaptureStamp(Date.now());
|
|
39
41
|
}, [playheadTime]);
|
|
40
42
|
|
|
41
43
|
const handleCaptureFrameClick = useCallback(
|
|
@@ -103,13 +105,18 @@ export function useFrameCapture({
|
|
|
103
105
|
[activeCompPath, playheadTime, projectId, showToast, waitForPendingDomEditSaves],
|
|
104
106
|
);
|
|
105
107
|
|
|
106
|
-
const captureFrameHref =
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
108
|
+
const captureFrameHref = useMemo(
|
|
109
|
+
() =>
|
|
110
|
+
projectId
|
|
111
|
+
? buildFrameCaptureUrl({
|
|
112
|
+
projectId,
|
|
113
|
+
compositionPath: activeCompPath,
|
|
114
|
+
currentTime: captureFrameTime,
|
|
115
|
+
version: captureStamp,
|
|
116
|
+
})
|
|
117
|
+
: "#",
|
|
118
|
+
[projectId, activeCompPath, captureFrameTime, captureStamp],
|
|
119
|
+
);
|
|
113
120
|
const captureFrameFilename = buildFrameCaptureFilename(activeCompPath, captureFrameTime);
|
|
114
121
|
|
|
115
122
|
return {
|