@hyperframes/studio 0.7.57 → 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--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
- package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
- package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-Bf1x1y8H.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 +15 -4
- package/dist/index.html +2 -2
- package/dist/index.js +5954 -4474
- 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 +27 -14
- package/src/components/editor/CanvasContextMenu.tsx +71 -6
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/LayersPanel.tsx +110 -99
- package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
- 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/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/nle/PreviewOverlays.tsx +27 -2
- 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/hooks/domEditCommitTypes.ts +17 -1
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +92 -1
- package/src/hooks/timelineEditingHelpers.ts +32 -15
- package/src/hooks/timelineMoveAdapter.ts +2 -1
- package/src/hooks/timelineTimingSync.test.ts +703 -33
- package/src/hooks/timelineTimingSync.ts +282 -97
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useDomEditCommits.test.tsx +316 -38
- package/src/hooks/useDomEditCommits.ts +143 -78
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
- package/src/hooks/useElementLifecycleOps.ts +174 -81
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +302 -154
- package/src/hooks/useTimelineEditing.ts +97 -93
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +35 -3
- package/src/player/components/Timeline.test.ts +31 -21
- package/src/player/components/Timeline.tsx +62 -91
- package/src/player/components/TimelineCanvas.tsx +36 -5
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +18 -5
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +279 -3
- package/src/player/components/timelineClipDragCommit.ts +173 -71
- 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 +22 -6
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineStackingSync.test.ts +42 -1
- package/src/player/components/timelineStackingSync.ts +31 -16
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- 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/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
- package/src/player/components/useTimelineStackingSync.ts +5 -3
- 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 +12 -1
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +3 -0
- package/src/player/lib/timelineElementHelpers.ts +35 -11
- package/src/player/store/playerStore.ts +12 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -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/dist/assets/index-_pqzyxB1.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { TimelineElement } from "../store/playerStore";
|
|
12
12
|
import type { ClipManifestClip } from "./playbackTypes";
|
|
13
|
+
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
|
|
13
14
|
import {
|
|
14
15
|
resolveMediaElement,
|
|
15
16
|
applyMediaMetadataFromElement,
|
|
@@ -228,6 +229,7 @@ export function createImplicitTimelineLayersFromDOM(
|
|
|
228
229
|
selector,
|
|
229
230
|
selectorIndex,
|
|
230
231
|
sourceFile,
|
|
232
|
+
stackingContextId: resolveCssStackingContextId(child),
|
|
231
233
|
start: 0,
|
|
232
234
|
tag: child.tagName.toLowerCase(),
|
|
233
235
|
timingSource: "implicit",
|
|
@@ -303,6 +305,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
|
|
|
303
305
|
selector,
|
|
304
306
|
selectorIndex,
|
|
305
307
|
sourceFile,
|
|
308
|
+
stackingContextId: resolveCssStackingContextId(el),
|
|
306
309
|
timingSource: "authored",
|
|
307
310
|
zIndex: readTimelineElementZIndex(el),
|
|
308
311
|
};
|
|
@@ -10,6 +10,31 @@
|
|
|
10
10
|
import type { TimelineElement } from "../store/playerStore";
|
|
11
11
|
import type { ClipManifestClip } from "./playbackTypes";
|
|
12
12
|
import { isFinitePositive } from "./playbackAdapter";
|
|
13
|
+
import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Layer-reveal lift transparency
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Attributes carrying the pre-lift state of a Layers-panel selection reveal
|
|
21
|
+
* (useLayerRevealOverride): while a layer is selected it PAINTS on top via a
|
|
22
|
+
* temporary inline z-index, but the lift is a purely visual, ephemeral studio
|
|
23
|
+
* affordance — every z reader must keep reporting the element's TRUE z
|
|
24
|
+
* (stored here) so menus, badges, the lane mirror, and the panel sort never
|
|
25
|
+
* reason on the lifted value. A z-reorder commit removes the attributes (the
|
|
26
|
+
* commit is the new truth).
|
|
27
|
+
*/
|
|
28
|
+
export const LAYER_REVEAL_PRIOR_Z_ATTR = "data-hf-reveal-prior-z";
|
|
29
|
+
export const LAYER_REVEAL_PRIOR_POSITION_ATTR = "data-hf-reveal-prior-pos";
|
|
30
|
+
|
|
31
|
+
/** The lifted element's true (pre-lift) z, or null when no lift is active. */
|
|
32
|
+
export function readLayerRevealPriorZ(el: Element): number | null {
|
|
33
|
+
const raw = el.getAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
|
|
34
|
+
if (raw == null) return null;
|
|
35
|
+
const n = Number.parseInt(raw, 10);
|
|
36
|
+
return Number.isFinite(n) ? n : null;
|
|
37
|
+
}
|
|
13
38
|
|
|
14
39
|
// ---------------------------------------------------------------------------
|
|
15
40
|
// Duration attribute helpers
|
|
@@ -22,8 +47,11 @@ import { isFinitePositive } from "./playbackAdapter";
|
|
|
22
47
|
* back to computed style; "auto" / empty / unparseable ⇒ 0. Works with a
|
|
23
48
|
* detached parse Document (no defaultView) as well as a live iframe. Mirrors
|
|
24
49
|
* canvasContextMenuZOrder.parseZIndex semantics so the two directions agree.
|
|
50
|
+
* Reveal-lift transparent: an active lift reports the stored TRUE z.
|
|
25
51
|
*/
|
|
26
52
|
export function readTimelineElementZIndex(el: Element): number {
|
|
53
|
+
const prior = readLayerRevealPriorZ(el);
|
|
54
|
+
if (prior != null) return prior;
|
|
27
55
|
const html = el as HTMLElement;
|
|
28
56
|
const parseZ = (value: string | null | undefined): number | null => {
|
|
29
57
|
if (value == null || value === "" || value === "auto") return null;
|
|
@@ -237,17 +265,13 @@ export function getTimelineElementSelectorIndex(
|
|
|
237
265
|
el: Element,
|
|
238
266
|
selector: string | undefined,
|
|
239
267
|
): number | undefined {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
return matchIndex >= 0 ? matchIndex : undefined;
|
|
248
|
-
} catch {
|
|
249
|
-
return undefined;
|
|
250
|
-
}
|
|
268
|
+
return getSourceScopedSelectorIndex(
|
|
269
|
+
doc,
|
|
270
|
+
el,
|
|
271
|
+
selector,
|
|
272
|
+
getTimelineElementSourceFile(el),
|
|
273
|
+
getTimelineElementSourceFile,
|
|
274
|
+
);
|
|
251
275
|
}
|
|
252
276
|
|
|
253
277
|
export function buildTimelineElementKey(params: {
|
|
@@ -44,7 +44,7 @@ export interface TimelineElement {
|
|
|
44
44
|
zIndex?: number;
|
|
45
45
|
/** True when the effective z-index was authored inline or through CSS, not auto. */
|
|
46
46
|
hasExplicitZIndex?: boolean;
|
|
47
|
-
/**
|
|
47
|
+
/** Canonical CSS stacking context this element's z-index participates in. */
|
|
48
48
|
stackingContextId?: string | null;
|
|
49
49
|
/** Nearest parent composition context, matching RuntimeTimelineClip. */
|
|
50
50
|
parentCompositionId?: string | null;
|
|
@@ -124,6 +124,13 @@ interface PlayerState {
|
|
|
124
124
|
zoomMode: ZoomMode;
|
|
125
125
|
/** Timeline zoom percent relative to the fit width when in manual mode */
|
|
126
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;
|
|
127
134
|
/** Work-area in-point (seconds). When set, loop starts here and A jumps here. */
|
|
128
135
|
inPoint: number | null;
|
|
129
136
|
/** Work-area out-point (seconds). When set, loop ends here and E jumps here. */
|
|
@@ -212,6 +219,7 @@ interface PlayerState {
|
|
|
212
219
|
) => void;
|
|
213
220
|
setZoomMode: (mode: ZoomMode) => void;
|
|
214
221
|
setManualZoomPercent: (percent: number) => void;
|
|
222
|
+
bumpZEditVersion: () => void;
|
|
215
223
|
setInPoint: (time: number | null) => void;
|
|
216
224
|
setOutPoint: (time: number | null) => void;
|
|
217
225
|
reset: () => void;
|
|
@@ -274,6 +282,7 @@ export interface DomClipChild {
|
|
|
274
282
|
/** The manifest sub-comp host clip id this descendant ultimately lives under. */
|
|
275
283
|
hostId: string;
|
|
276
284
|
label: string;
|
|
285
|
+
stackingContextId: string;
|
|
277
286
|
}
|
|
278
287
|
|
|
279
288
|
interface BeatHistoryEntry {
|
|
@@ -308,6 +317,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
308
317
|
loopEnabled: false,
|
|
309
318
|
zoomMode: "fit",
|
|
310
319
|
manualZoomPercent: 100,
|
|
320
|
+
zEditVersion: 0,
|
|
311
321
|
timelinePps: 100,
|
|
312
322
|
timelineFitPps: 100,
|
|
313
323
|
inPoint: null,
|
|
@@ -494,6 +504,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
494
504
|
}),
|
|
495
505
|
setManualZoomPercent: (percent) =>
|
|
496
506
|
set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
|
|
507
|
+
bumpZEditVersion: () => set((state) => ({ zEditVersion: state.zEditVersion + 1 })),
|
|
497
508
|
setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
|
|
498
509
|
setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),
|
|
499
510
|
setTimelineReady: (ready) => set({ timelineReady: ready }),
|
|
@@ -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",
|
|
@@ -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
|
-
});
|