@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
|
@@ -29,11 +29,22 @@ export interface TimelineElement {
|
|
|
29
29
|
start: number;
|
|
30
30
|
duration: number;
|
|
31
31
|
track: number;
|
|
32
|
+
/**
|
|
33
|
+
* The data-track-index as written in the source file. Set at the manifest
|
|
34
|
+
* translation boundary (createTimelineElementFromManifestClip) from the
|
|
35
|
+
* runtime clip's verbatim track, and preserved through display-lane remaps
|
|
36
|
+
* (normalizeToZones packs sparse authored tracks onto contiguous display
|
|
37
|
+
* lanes; expanded sub-comp children get synthetic display rows). Lane edits
|
|
38
|
+
* must persist THIS space — writing a display-lane number into a sparse file
|
|
39
|
+
* re-targets the wrong track. For an expanded child the value is in its OWN
|
|
40
|
+
* source file's coordinate space, not the host timeline's.
|
|
41
|
+
*/
|
|
42
|
+
authoredTrack?: number;
|
|
32
43
|
/** Resolved z-index for stacking-aware timeline ordering. */
|
|
33
44
|
zIndex?: number;
|
|
34
45
|
/** True when the effective z-index was authored inline or through CSS, not auto. */
|
|
35
46
|
hasExplicitZIndex?: boolean;
|
|
36
|
-
/**
|
|
47
|
+
/** Canonical CSS stacking context this element's z-index participates in. */
|
|
37
48
|
stackingContextId?: string | null;
|
|
38
49
|
/** Nearest parent composition context, matching RuntimeTimelineClip. */
|
|
39
50
|
parentCompositionId?: string | null;
|
|
@@ -113,6 +124,13 @@ interface PlayerState {
|
|
|
113
124
|
zoomMode: ZoomMode;
|
|
114
125
|
/** Timeline zoom percent relative to the fit width when in manual mode */
|
|
115
126
|
manualZoomPercent: number;
|
|
127
|
+
/**
|
|
128
|
+
* Bumped on every live z-index edit (handleDomZIndexReorderCommit apply AND
|
|
129
|
+
* rollback). Flashless z commits (skipReload) never reload the iframe or
|
|
130
|
+
* bump refreshKey, so DOM-derived views (the Layers panel's z-sorted tree)
|
|
131
|
+
* subscribe to this to re-read the live DOM while playback is paused.
|
|
132
|
+
*/
|
|
133
|
+
zEditVersion: number;
|
|
116
134
|
/** Work-area in-point (seconds). When set, loop starts here and A jumps here. */
|
|
117
135
|
inPoint: number | null;
|
|
118
136
|
/** Work-area out-point (seconds). When set, loop ends here and E jumps here. */
|
|
@@ -153,6 +171,9 @@ interface PlayerState {
|
|
|
153
171
|
/** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
|
|
154
172
|
timelineSnapEnabled: boolean;
|
|
155
173
|
setTimelineSnapEnabled: (enabled: boolean) => void;
|
|
174
|
+
/** Transport + ruler readout: timecode ("time") or frame number ("frame"). */
|
|
175
|
+
timeDisplayMode: "time" | "frame";
|
|
176
|
+
setTimeDisplayMode: (mode: "time" | "frame") => void;
|
|
156
177
|
/**
|
|
157
178
|
* Pin the timeline zoom to its current visual scale before a duration-changing
|
|
158
179
|
* edit, so a subsequent duration change (which recomputes fit-pps) stops
|
|
@@ -198,6 +219,7 @@ interface PlayerState {
|
|
|
198
219
|
) => void;
|
|
199
220
|
setZoomMode: (mode: ZoomMode) => void;
|
|
200
221
|
setManualZoomPercent: (percent: number) => void;
|
|
222
|
+
bumpZEditVersion: () => void;
|
|
201
223
|
setInPoint: (time: number | null) => void;
|
|
202
224
|
setOutPoint: (time: number | null) => void;
|
|
203
225
|
reset: () => void;
|
|
@@ -210,6 +232,16 @@ interface PlayerState {
|
|
|
210
232
|
requestSeek: (time: number) => void;
|
|
211
233
|
clearSeekRequest: () => void;
|
|
212
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Request the timeline to scroll a clip into view (e.g. clicking an
|
|
237
|
+
* already-added asset card in the sidebar). Consumed and cleared by
|
|
238
|
+
* useTimelineRevealClip. The nonce makes repeat requests for the same
|
|
239
|
+
* clip observable so a second click re-reveals after the user scrolls away.
|
|
240
|
+
*/
|
|
241
|
+
clipRevealRequest: { elementId: string; nonce: number } | null;
|
|
242
|
+
requestClipReveal: (elementId: string) => void;
|
|
243
|
+
clearClipRevealRequest: () => void;
|
|
244
|
+
|
|
213
245
|
lintFindingsByElement: Map<string, { count: number; messages: string[] }>;
|
|
214
246
|
setLintFindingsByElement: (map: Map<string, { count: number; messages: string[] }>) => void;
|
|
215
247
|
|
|
@@ -250,6 +282,7 @@ export interface DomClipChild {
|
|
|
250
282
|
/** The manifest sub-comp host clip id this descendant ultimately lives under. */
|
|
251
283
|
hostId: string;
|
|
252
284
|
label: string;
|
|
285
|
+
stackingContextId: string;
|
|
253
286
|
}
|
|
254
287
|
|
|
255
288
|
interface BeatHistoryEntry {
|
|
@@ -284,6 +317,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
284
317
|
loopEnabled: false,
|
|
285
318
|
zoomMode: "fit",
|
|
286
319
|
manualZoomPercent: 100,
|
|
320
|
+
zEditVersion: 0,
|
|
287
321
|
timelinePps: 100,
|
|
288
322
|
timelineFitPps: 100,
|
|
289
323
|
inPoint: null,
|
|
@@ -341,6 +375,13 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
341
375
|
requestSeek: (time) => set({ requestedSeekTime: time }),
|
|
342
376
|
clearSeekRequest: () => set({ requestedSeekTime: null }),
|
|
343
377
|
|
|
378
|
+
clipRevealRequest: null,
|
|
379
|
+
requestClipReveal: (elementId) =>
|
|
380
|
+
set((s) => ({
|
|
381
|
+
clipRevealRequest: { elementId, nonce: (s.clipRevealRequest?.nonce ?? 0) + 1 },
|
|
382
|
+
})),
|
|
383
|
+
clearClipRevealRequest: () => set({ clipRevealRequest: null }),
|
|
384
|
+
|
|
344
385
|
lintFindingsByElement: new Map(),
|
|
345
386
|
setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }),
|
|
346
387
|
|
|
@@ -416,6 +457,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
416
457
|
writeStudioUiPreferences({ timelineSnapEnabled: enabled });
|
|
417
458
|
set({ timelineSnapEnabled: enabled });
|
|
418
459
|
},
|
|
460
|
+
timeDisplayMode: readStudioUiPreferences().timeDisplayMode ?? "time",
|
|
461
|
+
setTimeDisplayMode: (mode) => {
|
|
462
|
+
writeStudioUiPreferences({ timeDisplayMode: mode });
|
|
463
|
+
set({ timeDisplayMode: mode });
|
|
464
|
+
},
|
|
419
465
|
pinTimelineZoom: (currentPixelsPerSecond, fitPixelsPerSecond) =>
|
|
420
466
|
set((s) => {
|
|
421
467
|
// Already pinned (or the user manually zoomed) — never clobber that.
|
|
@@ -458,6 +504,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
458
504
|
}),
|
|
459
505
|
setManualZoomPercent: (percent) =>
|
|
460
506
|
set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
|
|
507
|
+
bumpZEditVersion: () => set((state) => ({ zEditVersion: state.zEditVersion + 1 })),
|
|
461
508
|
setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
|
|
462
509
|
setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),
|
|
463
510
|
setTimelineReady: (ready) => set({ timelineReady: ready }),
|
|
@@ -522,6 +569,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
522
569
|
activeTool: "select",
|
|
523
570
|
selectedKeyframes: new Set(),
|
|
524
571
|
selectedElementIds: new Set(),
|
|
572
|
+
clipRevealRequest: null,
|
|
525
573
|
keyframeCache: new Map(),
|
|
526
574
|
beatAnalysis: null,
|
|
527
575
|
beatEdits: null,
|
|
@@ -12,6 +12,7 @@ const {
|
|
|
12
12
|
trackStudioRenderStart,
|
|
13
13
|
trackStudioRazorSplit,
|
|
14
14
|
trackStudioExpandedClipEdit,
|
|
15
|
+
trackStudioFeedback,
|
|
15
16
|
} = await import("./events");
|
|
16
17
|
|
|
17
18
|
describe("studio telemetry events", () => {
|
|
@@ -69,4 +70,13 @@ describe("studio telemetry events", () => {
|
|
|
69
70
|
trackStudioExpandedClipEdit({ action: "resize" });
|
|
70
71
|
expect(trackEvent).toHaveBeenCalledWith("studio_expanded_clip_edit", { action: "resize" });
|
|
71
72
|
});
|
|
73
|
+
|
|
74
|
+
it.each([0, 10])("trackStudioFeedback preserves NPS boundary %i and its scale", (rating) => {
|
|
75
|
+
trackStudioFeedback({ rating });
|
|
76
|
+
|
|
77
|
+
expect(trackEvent).toHaveBeenCalledWith(
|
|
78
|
+
"survey sent",
|
|
79
|
+
expect.objectContaining({ $survey_response: rating, rating_scale: 10 }),
|
|
80
|
+
);
|
|
81
|
+
});
|
|
72
82
|
});
|
package/src/telemetry/events.ts
CHANGED
|
@@ -67,6 +67,7 @@ export function trackStudioFeedback(props: { rating: number; comment?: string })
|
|
|
67
67
|
trackEvent("survey sent", {
|
|
68
68
|
$survey_id: "studio_experience",
|
|
69
69
|
$survey_response: props.rating,
|
|
70
|
+
rating_scale: 10,
|
|
70
71
|
...(props.comment ? { $survey_response_2: props.comment } : {}),
|
|
71
72
|
doctor_summary: getBrowserDoctorSummary(),
|
|
72
73
|
source: "studio",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { shouldDismissAssetPreview } from "./assetPreviewDismiss";
|
|
3
|
+
|
|
4
|
+
describe("shouldDismissAssetPreview", () => {
|
|
5
|
+
const idle = { isPlaying: false, currentTime: 3.5, requestedSeekTime: null };
|
|
6
|
+
|
|
7
|
+
it("keeps the preview open while nothing moves", () => {
|
|
8
|
+
expect(shouldDismissAssetPreview(3.5, idle)).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("tolerates sub-epsilon float noise in currentTime echoes", () => {
|
|
12
|
+
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 3.5 + 1e-9 })).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("dismisses when playback starts", () => {
|
|
16
|
+
expect(shouldDismissAssetPreview(3.5, { ...idle, isPlaying: true })).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("dismisses when the playhead is scrubbed/seeked to a new time", () => {
|
|
20
|
+
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 4.2 })).toBe(true);
|
|
21
|
+
expect(shouldDismissAssetPreview(3.5, { ...idle, currentTime: 0 })).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("dismisses on a pending out-of-loop seek request", () => {
|
|
25
|
+
expect(shouldDismissAssetPreview(3.5, { ...idle, requestedSeekTime: 3.5 })).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dismissal rule for the sidebar asset preview overlay (AssetPreviewOverlay):
|
|
3
|
+
* the preview is a transient "look at this asset" state, so any playhead
|
|
4
|
+
* activity — starting playback, or seeking/scrubbing away from where the
|
|
5
|
+
* playhead sat when the preview opened — hands focus back to the canvas and
|
|
6
|
+
* closes it.
|
|
7
|
+
*
|
|
8
|
+
* Pure — unit-tested. The overlay captures `openedTime` when the preview
|
|
9
|
+
* opens and feeds every subsequent player-store snapshot through this.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface AssetPreviewDismissSnapshot {
|
|
13
|
+
isPlaying: boolean;
|
|
14
|
+
currentTime: number;
|
|
15
|
+
/** Pending out-of-loop seek request (playerStore.requestedSeekTime). */
|
|
16
|
+
requestedSeekTime: number | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Tolerance for float noise in currentTime echoes (well under one frame). */
|
|
20
|
+
const TIME_EPSILON_S = 1e-6;
|
|
21
|
+
|
|
22
|
+
export function shouldDismissAssetPreview(
|
|
23
|
+
openedTime: number,
|
|
24
|
+
snapshot: AssetPreviewDismissSnapshot,
|
|
25
|
+
): boolean {
|
|
26
|
+
if (snapshot.isPlaying) return true;
|
|
27
|
+
if (snapshot.requestedSeekTime !== null) return true;
|
|
28
|
+
return Math.abs(snapshot.currentTime - openedTime) > TIME_EPSILON_S;
|
|
29
|
+
}
|
|
@@ -13,7 +13,7 @@ interface DomEditSaveQueueOptions {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export interface DomEditSaveQueue {
|
|
16
|
-
enqueue: (save: () => Promise<
|
|
16
|
+
enqueue: <T>(save: () => Promise<T>) => Promise<T>;
|
|
17
17
|
waitForIdle: () => Promise<void>;
|
|
18
18
|
reset: () => void;
|
|
19
19
|
destroy: () => void;
|
|
@@ -52,10 +52,11 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
|
|
|
52
52
|
});
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
-
const run = async (save: () => Promise<
|
|
55
|
+
const run = async <T>(save: () => Promise<T>): Promise<T> => {
|
|
56
56
|
try {
|
|
57
|
-
await save();
|
|
57
|
+
const result = await save();
|
|
58
58
|
if (!breakerOpen) consecutiveFailures = 0;
|
|
59
|
+
return result;
|
|
59
60
|
} catch (error) {
|
|
60
61
|
consecutiveFailures += 1;
|
|
61
62
|
if (consecutiveFailures >= failureThreshold) open(error);
|
|
@@ -277,6 +277,88 @@ describe("edit history", () => {
|
|
|
277
277
|
expect(state.undo[0].files["index.html"].after).toBe("timing+gsap");
|
|
278
278
|
});
|
|
279
279
|
|
|
280
|
+
it("keeps timing-only files when a multi-file GSAP follow-up touches one path", () => {
|
|
281
|
+
const timing = buildEditHistoryEntry({
|
|
282
|
+
projectId: "project-1",
|
|
283
|
+
label: "Move timeline clips",
|
|
284
|
+
kind: "timeline",
|
|
285
|
+
coalesceKey: "timeline-group-move:a,b",
|
|
286
|
+
files: {
|
|
287
|
+
"index.html": { before: "index-before", after: "index-timing" },
|
|
288
|
+
"scene.html": { before: "scene-before", after: "scene-timing" },
|
|
289
|
+
},
|
|
290
|
+
now: 0,
|
|
291
|
+
id: "timing",
|
|
292
|
+
});
|
|
293
|
+
const gsap = buildEditHistoryEntry({
|
|
294
|
+
projectId: "project-1",
|
|
295
|
+
label: "Move timeline clips",
|
|
296
|
+
kind: "timeline",
|
|
297
|
+
coalesceKey: "timeline-group-move:a,b",
|
|
298
|
+
coalesceMs: 10_000,
|
|
299
|
+
files: {
|
|
300
|
+
"index.html": { before: "index-timing", after: "index-timing+gsap" },
|
|
301
|
+
},
|
|
302
|
+
now: 2000,
|
|
303
|
+
id: "gsap",
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const state = pushEditHistoryEntry(
|
|
307
|
+
pushEditHistoryEntry(createEmptyEditHistory(), timing),
|
|
308
|
+
gsap,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
expect(state.undo).toHaveLength(1);
|
|
312
|
+
expect(Object.keys(state.undo[0].files)).toEqual(["index.html", "scene.html"]);
|
|
313
|
+
expect(state.undo[0].files).toMatchObject({
|
|
314
|
+
"index.html": { before: "index-before", after: "index-timing+gsap" },
|
|
315
|
+
"scene.html": { before: "scene-before", after: "scene-timing" },
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("keeps a discontinuous GSAP follow-up separate and preserves foreign bytes on undo", () => {
|
|
320
|
+
const timing = buildEditHistoryEntry({
|
|
321
|
+
projectId: "project-1",
|
|
322
|
+
label: "Move timeline clip",
|
|
323
|
+
kind: "timeline",
|
|
324
|
+
coalesceKey: "timeline-move:clip",
|
|
325
|
+
files: { "index.html": { before: "A", after: "B" } },
|
|
326
|
+
now: 0,
|
|
327
|
+
id: "timing",
|
|
328
|
+
});
|
|
329
|
+
const gsapAfterForeignWrite = buildEditHistoryEntry({
|
|
330
|
+
projectId: "project-1",
|
|
331
|
+
label: "Move timeline clip",
|
|
332
|
+
kind: "timeline",
|
|
333
|
+
coalesceKey: "timeline-move:clip",
|
|
334
|
+
coalesceMs: 10_000,
|
|
335
|
+
files: { "index.html": { before: "F", after: "G" } },
|
|
336
|
+
now: 2000,
|
|
337
|
+
id: "gsap",
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const state = pushEditHistoryEntry(
|
|
341
|
+
pushEditHistoryEntry(createEmptyEditHistory(), timing),
|
|
342
|
+
gsapAfterForeignWrite,
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
expect(state.undo.map((entry) => entry.id)).toEqual(["timing", "gsap"]);
|
|
346
|
+
const undoGsap = undoEditHistory(state, { "index.html": hashEditHistoryContent("G") }, 3000);
|
|
347
|
+
expect(undoGsap.ok).toBe(true);
|
|
348
|
+
expect(undoGsap.filesToWrite).toEqual({ "index.html": "F" });
|
|
349
|
+
|
|
350
|
+
const undoTiming = undoEditHistory(
|
|
351
|
+
undoGsap.state,
|
|
352
|
+
{ "index.html": hashEditHistoryContent("F") },
|
|
353
|
+
4000,
|
|
354
|
+
);
|
|
355
|
+
expect(undoTiming).toMatchObject({
|
|
356
|
+
ok: false,
|
|
357
|
+
reason: "content-mismatch",
|
|
358
|
+
filesToWrite: {},
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
280
362
|
it("does not merge a slow follow-up without the coalesceMs override", () => {
|
|
281
363
|
const timing = buildEditHistoryEntry({
|
|
282
364
|
projectId: "project-1",
|
package/src/utils/editHistory.ts
CHANGED
|
@@ -126,9 +126,21 @@ export function pushEditHistoryEntry(
|
|
|
126
126
|
previous &&
|
|
127
127
|
previous.coalesceKey &&
|
|
128
128
|
previous.coalesceKey === entry.coalesceKey &&
|
|
129
|
-
entry.createdAt - previous.createdAt <= coalesceMs
|
|
129
|
+
entry.createdAt - previous.createdAt <= coalesceMs &&
|
|
130
|
+
// A shared key/window is not enough: bytes from an external writer may
|
|
131
|
+
// have landed between the two Studio-owned edits. Only fold a path when
|
|
132
|
+
// the follow-up starts from the exact output the previous entry recorded.
|
|
133
|
+
// Otherwise keep both entries so undo restores the foreign bytes first and
|
|
134
|
+
// the older entry's content check safely blocks at that ownership boundary.
|
|
135
|
+
Object.entries(entry.files).every(([path, snapshot]) => {
|
|
136
|
+
const previousSnapshot = previous.files[path];
|
|
137
|
+
return !previousSnapshot || previousSnapshot.after === snapshot.before;
|
|
138
|
+
})
|
|
130
139
|
) {
|
|
131
|
-
|
|
140
|
+
// A follow-up may touch only a subset of the files in the original edit
|
|
141
|
+
// (for example, only one file in a multi-file timing move has GSAP). Keep
|
|
142
|
+
// previous-only paths in the single coalesced undo entry.
|
|
143
|
+
const files: Record<string, EditHistoryFileSnapshot> = { ...previous.files };
|
|
132
144
|
for (const [path, snapshot] of Object.entries(entry.files)) {
|
|
133
145
|
const previousSnapshot = previous.files[path];
|
|
134
146
|
files[path] = previousSnapshot
|
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
import { describe, it, expect, vi } from "vitest";
|
|
4
4
|
import {
|
|
5
5
|
applySoftReload,
|
|
6
|
+
applySoftReloadFinalization,
|
|
6
7
|
ensureMotionPathPluginLoaded,
|
|
7
|
-
diffSoftReloadableRestore,
|
|
8
|
-
applyUndoRestoreToPreview,
|
|
9
8
|
} from "./gsapSoftReload";
|
|
10
9
|
|
|
11
10
|
const SCRIPT_TEXT = `
|
|
@@ -66,6 +65,7 @@ function buildMockIframe(overrides: Record<string, unknown> = {}) {
|
|
|
66
65
|
iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement,
|
|
67
66
|
contentWindow,
|
|
68
67
|
mockTimeline,
|
|
68
|
+
container,
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
71
|
|
|
@@ -288,6 +288,58 @@ describe("applySoftReload", () => {
|
|
|
288
288
|
});
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
+
// ── Finalization-only path: seek → rebind → manual edits, with NO script
|
|
292
|
+
// execution — the flashless sync for timing edits that changed no script.
|
|
293
|
+
describe("applySoftReloadFinalization", () => {
|
|
294
|
+
it("seeks, rebinds, and reapplies manual edits without touching any script", () => {
|
|
295
|
+
const { iframe, contentWindow, container, mockTimeline } = buildMockIframe();
|
|
296
|
+
const scriptsBefore = container.querySelectorAll("script").length;
|
|
297
|
+
|
|
298
|
+
expect(applySoftReloadFinalization(iframe, 2.0)).toBe(true);
|
|
299
|
+
|
|
300
|
+
expect(contentWindow.__player.seek).toHaveBeenCalledWith(2.0);
|
|
301
|
+
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
|
|
302
|
+
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalledTimes(1);
|
|
303
|
+
// No script executed or removed; the live timeline was never killed.
|
|
304
|
+
expect(container.querySelectorAll("script").length).toBe(scriptsBefore);
|
|
305
|
+
expect(mockTimeline.kill).not.toHaveBeenCalled();
|
|
306
|
+
expect(contentWindow.__timelines.root).toBe(mockTimeline);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("runs inside __hfSuppressSceneMutations when the runtime provides it", () => {
|
|
310
|
+
const suppress = vi.fn(<T>(fn: () => T): T => fn());
|
|
311
|
+
const { iframe, contentWindow } = buildMockIframe({
|
|
312
|
+
__hfSuppressSceneMutations: suppress,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(applySoftReloadFinalization(iframe, 1.5)).toBe(true);
|
|
316
|
+
|
|
317
|
+
expect(suppress).toHaveBeenCalledTimes(1);
|
|
318
|
+
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("does NOT require gsap — a script-less runtime with the rebind hook works", () => {
|
|
322
|
+
const { iframe, contentWindow } = buildMockIframe({ gsap: undefined });
|
|
323
|
+
expect(applySoftReloadFinalization(iframe, 0)).toBe(true);
|
|
324
|
+
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("returns false when the iframe or the rebind hook is unavailable", () => {
|
|
328
|
+
expect(applySoftReloadFinalization(null, 0)).toBe(false);
|
|
329
|
+
const { iframe } = buildMockIframe({ __hfForceTimelineRebind: undefined });
|
|
330
|
+
expect(applySoftReloadFinalization(iframe, 0)).toBe(false);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("returns false when the rebind throws (caller full-reloads)", () => {
|
|
334
|
+
const { iframe } = buildMockIframe({
|
|
335
|
+
__hfForceTimelineRebind: vi.fn(() => {
|
|
336
|
+
throw new Error("runtime mid-teardown");
|
|
337
|
+
}),
|
|
338
|
+
});
|
|
339
|
+
expect(applySoftReloadFinalization(iframe, 0)).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
291
343
|
function buildBootstrapIframe(overrides: Record<string, unknown> = {}) {
|
|
292
344
|
const head = document.createElement("div");
|
|
293
345
|
const appendedScripts: HTMLScriptElement[] = [];
|
|
@@ -452,116 +504,3 @@ describe("applySoftReload authored-opacity restore", () => {
|
|
|
452
504
|
expect(restoreOpacity(el)).toBe("");
|
|
453
505
|
});
|
|
454
506
|
});
|
|
455
|
-
|
|
456
|
-
// ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
|
|
457
|
-
|
|
458
|
-
const wrap = (body: string) => `<html><body>${body}</body></html>`;
|
|
459
|
-
|
|
460
|
-
describe("diffSoftReloadableRestore", () => {
|
|
461
|
-
it("reports the changed id for an attribute/inline-style-only diff", () => {
|
|
462
|
-
const prev = wrap(`<div id="a" style="translate: 10px 10px">t</div>`);
|
|
463
|
-
const next = wrap(`<div id="a" style="translate: 0px 0px">t</div>`);
|
|
464
|
-
expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: ["a"] });
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
it("treats a structural change (added element) as NOT soft-reloadable", () => {
|
|
468
|
-
const prev = wrap(`<div id="a">t</div>`);
|
|
469
|
-
const next = wrap(`<div id="a">t</div><div id="a-split">t</div>`);
|
|
470
|
-
expect(diffSoftReloadableRestore(prev, next)).toBeNull();
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
it("treats an element text/child change as NOT soft-reloadable", () => {
|
|
474
|
-
const prev = wrap(`<div id="a">one</div>`);
|
|
475
|
-
const next = wrap(`<div id="a">two</div>`);
|
|
476
|
-
expect(diffSoftReloadableRestore(prev, next)).toBeNull();
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
it("allows a GSAP-script-only change (no id'd-attribute diff)", () => {
|
|
480
|
-
const prev = wrap(
|
|
481
|
-
`<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:1});</script>`,
|
|
482
|
-
);
|
|
483
|
-
const next = wrap(
|
|
484
|
-
`<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:9});</script>`,
|
|
485
|
-
);
|
|
486
|
-
expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: [] });
|
|
487
|
-
});
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
function buildLiveIframe(bodyHtml: string) {
|
|
491
|
-
const doc = document.implementation.createHTMLDocument("");
|
|
492
|
-
doc.body.innerHTML = bodyHtml;
|
|
493
|
-
const contentWindow = {
|
|
494
|
-
gsap: { timeline: () => {} },
|
|
495
|
-
__hfForceTimelineRebind: () => {},
|
|
496
|
-
__timelines: {} as Record<string, unknown>,
|
|
497
|
-
__player: { getTime: () => 3, seek: vi.fn() },
|
|
498
|
-
__hfStudioManualEditsApply: vi.fn(),
|
|
499
|
-
};
|
|
500
|
-
return {
|
|
501
|
-
iframe: { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement,
|
|
502
|
-
contentWindow,
|
|
503
|
-
doc,
|
|
504
|
-
};
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
describe("applyUndoRestoreToPreview", () => {
|
|
508
|
-
const ROOT = "index.html";
|
|
509
|
-
|
|
510
|
-
it("soft-applies an attribute/style-only restore: syncs the live element, no full reload", () => {
|
|
511
|
-
const { iframe, contentWindow, doc } = buildLiveIframe(
|
|
512
|
-
`<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
|
|
513
|
-
);
|
|
514
|
-
const reloadPreview = vi.fn();
|
|
515
|
-
const files = {
|
|
516
|
-
[ROOT]: {
|
|
517
|
-
previous: wrap(
|
|
518
|
-
`<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
|
|
519
|
-
),
|
|
520
|
-
restored: wrap(`<div id="a" style="translate: 0px 0px" data-hf-path-offset="true">t</div>`),
|
|
521
|
-
},
|
|
522
|
-
};
|
|
523
|
-
const outcome = applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview);
|
|
524
|
-
expect(outcome).toBe("soft");
|
|
525
|
-
expect(reloadPreview).not.toHaveBeenCalled();
|
|
526
|
-
// Live element reverted to the restored inline style.
|
|
527
|
-
expect(doc.getElementById("a")!.getAttribute("style")).toBe("translate: 0px 0px");
|
|
528
|
-
// No GSAP script in the restore → the manual-edit reapply runs, playhead held.
|
|
529
|
-
expect(contentWindow.__player.seek).toHaveBeenCalledWith(3);
|
|
530
|
-
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
it("full-reloads a multi-file restore", () => {
|
|
534
|
-
const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
|
|
535
|
-
const reloadPreview = vi.fn();
|
|
536
|
-
const files = {
|
|
537
|
-
[ROOT]: {
|
|
538
|
-
previous: wrap(`<div id="a" style="x">t</div>`),
|
|
539
|
-
restored: wrap(`<div id="a">t</div>`),
|
|
540
|
-
},
|
|
541
|
-
"scenes/intro.html": { previous: "a", restored: "b" },
|
|
542
|
-
};
|
|
543
|
-
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
544
|
-
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
it("full-reloads a structural restore (split/delete undo)", () => {
|
|
548
|
-
const { iframe } = buildLiveIframe(`<div id="a">t</div><div id="a-split">t</div>`);
|
|
549
|
-
const reloadPreview = vi.fn();
|
|
550
|
-
const files = {
|
|
551
|
-
[ROOT]: {
|
|
552
|
-
previous: wrap(`<div id="a">t</div><div id="a-split">t</div>`),
|
|
553
|
-
restored: wrap(`<div id="a">t</div>`),
|
|
554
|
-
},
|
|
555
|
-
};
|
|
556
|
-
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
557
|
-
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
|
|
561
|
-
const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
|
|
562
|
-
const reloadPreview = vi.fn();
|
|
563
|
-
const files = { "scenes/intro.html": { previous: "a", restored: "b" } };
|
|
564
|
-
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
565
|
-
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
566
|
-
});
|
|
567
|
-
});
|