@hyperframes/studio 0.8.89 → 0.8.91
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-CVoS7kv8.js → hyperframes-player-zT2Bdx_l.js} +1 -1
- package/dist/assets/{index-D35-L_yn.js → index-DSOLcE8X.js} +1 -1
- package/dist/assets/{index-DBub9qQY.js → index-DV-z19zB.js} +1 -1
- package/dist/assets/{index-BM8PwYby.js → index-V-0rfhe6.js} +168 -168
- package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
- package/dist/chunk-QPSOTK6M.js.map +1 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.html +1 -1
- package/dist/index.js +1349 -1066
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +12 -0
- package/src/components/editor/MotionPathOverlay.tsx +5 -1
- package/src/components/editor/commitPreflightHandles.test.tsx +7 -20
- package/src/components/editor/elementPositionOffset.test.ts +62 -0
- package/src/components/editor/elementPositionOffset.ts +64 -0
- package/src/components/editor/motionPathCommit.test.ts +25 -5
- package/src/components/editor/motionPathSelection.ts +10 -3
- package/src/components/editor/useInlineTextEditing.tsx +2 -4
- package/src/components/editor/useLayerRevealOverride.test.ts +8 -0
- package/src/components/editor/useLayerRevealOverride.ts +2 -1
- package/src/components/nle/NLEContext.tsx +1 -1
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
- package/src/hooks/domEditPersistFailure.ts +8 -6
- package/src/hooks/elementOffsetStager.ts +65 -0
- package/src/hooks/gestureTransaction.ts +4 -2
- package/src/hooks/gsapDragCommit.ts +1 -0
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
- package/src/hooks/gsapRuntimeBridge.ts +50 -14
- package/src/hooks/gsapShared.test.ts +1 -1
- package/src/hooks/gsapShared.ts +16 -12
- package/src/hooks/gsapTweenReach.ts +30 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -1
- package/src/hooks/useClipboard.source.test.ts +58 -0
- package/src/hooks/useClipboard.ts +12 -3
- package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
- package/src/hooks/useDomGeometryCommit.ts +42 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
- package/src/hooks/useDomGeometryCommits.ts +14 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
- package/src/hooks/useGsapAwareEditing.ts +44 -7
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
- package/src/hooks/useRazorSplit.test.ts +5 -5
- package/src/hooks/useTimelineEditing.test.tsx +384 -0
- package/src/hooks/useTimelineEditing.ts +12 -12
- package/src/hooks/useTimelineGroupEditing.ts +16 -7
- package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTypes.ts +1 -1
- package/src/player/components/timelineClipDragCommit.test.ts +18 -8
- package/src/player/components/timelineClipDragCommit.ts +5 -0
- package/src/player/components/timelineClipDragPreview.test.ts +70 -2
- package/src/player/components/timelineClipDragPreview.ts +15 -12
- package/src/player/components/timelineEditing.ts +2 -1
- package/src/player/components/timelineGroupEditing.ts +7 -2
- package/src/player/components/timelineMultiDragPreview.test.ts +10 -8
- package/src/player/components/timelineMultiDragPreview.ts +8 -5
- package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
- package/src/player/components/timelineProviderStateBuilders.ts +7 -2
- package/src/player/components/timelineZMirror.test.ts +2 -2
- package/src/player/components/timelineZMirror.ts +1 -1
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
- package/src/player/components/useTimelineClipDrag.ts +3 -2
- package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
- package/src/player/components/useTimelinePlayhead.ts +56 -27
- package/src/player/components/useTimelineProviderState.tsx +1 -0
- package/src/player/components/useTimelineZoom.ts +9 -1
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
- package/src/player/hooks/useTimelinePlayer.ts +7 -4
- package/src/player/lib/timelineDOM.test.ts +79 -0
- package/src/player/lib/timelineDOM.ts +16 -7
- package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
- package/src/player/lib/timelineIframeHelpers.ts +12 -3
- package/src/player/sceneSwap.ts +21 -0
- package/src/player/store/liveTime.ts +6 -0
- package/src/player/store/playerStore.ts +2 -0
- package/src/player/store/timelineElement.ts +28 -3
- package/src/utils/gsapUndoRestore.test.ts +58 -0
- package/src/utils/gsapUndoRestore.ts +6 -1
- package/src/utils/razorSplitTransaction.test.ts +1 -1
- package/src/utils/razorSplitTransaction.ts +3 -3
- package/src/utils/studioPreviewHelpers.test.ts +29 -0
- package/src/utils/studioPreviewHelpers.ts +37 -80
- package/dist/chunk-KU4F7QZ3.js.map +0 -1
|
@@ -12,6 +12,7 @@ import type { TimelineElement } from "../store/playerStore";
|
|
|
12
12
|
import type { ClipManifestClip, IframeWindow, TimelineLike } from "./playbackTypes";
|
|
13
13
|
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
|
|
14
14
|
import { readClipTiming } from "@hyperframes/core/composition-contract";
|
|
15
|
+
import { createRuntimeStartTimeResolver } from "@hyperframes/core/runtime/start-resolver";
|
|
15
16
|
import { groupInfoFor } from "./timelineGroupInfo";
|
|
16
17
|
import { transitionLabelsForDocument } from "./timelineTransitionMetadata";
|
|
17
18
|
import {
|
|
@@ -228,10 +229,13 @@ export function parseTimelineFromDOM(
|
|
|
228
229
|
const nodes = doc.querySelectorAll("[data-start]");
|
|
229
230
|
const els: TimelineElement[] = [];
|
|
230
231
|
let trackCounter = 0;
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
232
|
+
const timelineRegistry = timelines ?? (doc.defaultView as IframeWindow | null)?.__timelines;
|
|
233
|
+
const transitionLabels = transitionLabelsForDocument(doc, timelineRegistry);
|
|
234
|
+
const masterStart = createRuntimeStartTimeResolver({
|
|
235
|
+
timelineRegistry,
|
|
236
|
+
includeAuthoredTimingAttrs: true,
|
|
237
|
+
documentRef: doc,
|
|
238
|
+
});
|
|
235
239
|
|
|
236
240
|
// fallow-ignore-next-line complexity
|
|
237
241
|
nodes.forEach((node) => {
|
|
@@ -239,11 +243,14 @@ export function parseTimelineFromDOM(
|
|
|
239
243
|
if (isTimelineIgnoredElement(node)) return;
|
|
240
244
|
const el = node as HTMLElement;
|
|
241
245
|
const timing = readClipTiming(el);
|
|
242
|
-
|
|
243
|
-
|
|
246
|
+
if (timing.start == null) return;
|
|
247
|
+
const tagLower = el.tagName.toLowerCase();
|
|
248
|
+
const start =
|
|
249
|
+
tagLower === "video" || tagLower === "audio"
|
|
250
|
+
? masterStart.resolveMediaStartForElement(el)
|
|
251
|
+
: masterStart.resolveStartForElement(el);
|
|
244
252
|
if (Number.isFinite(rootDuration) && rootDuration > 0 && start >= rootDuration) return;
|
|
245
253
|
|
|
246
|
-
const tagLower = el.tagName.toLowerCase();
|
|
247
254
|
let dur = timing.duration ?? 0;
|
|
248
255
|
if (dur <= 0) dur = Math.max(0, rootDuration - start);
|
|
249
256
|
if (Number.isFinite(rootDuration) && rootDuration > 0) {
|
|
@@ -287,6 +294,8 @@ export function parseTimelineFromDOM(
|
|
|
287
294
|
: "element",
|
|
288
295
|
tag: tagLower,
|
|
289
296
|
start,
|
|
297
|
+
parentCompositionStart: masterStart.resolveHostStartForElement(el),
|
|
298
|
+
...(masterStart.isRootGlobalMediaStartForElement(el) && { authoredStartIsMasterTime: true }),
|
|
290
299
|
duration: dur,
|
|
291
300
|
track,
|
|
292
301
|
domId: el.id || undefined,
|
|
@@ -103,6 +103,42 @@ describe("buildMissingCompositionElements — hfId (R7)", () => {
|
|
|
103
103
|
});
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
describe("buildMissingCompositionElements — nested master time", () => {
|
|
107
|
+
it("places hosts two and three levels deep at their master time", () => {
|
|
108
|
+
const doc = makeDoc(`
|
|
109
|
+
<div data-composition-id="main" data-start="0" data-duration="20">
|
|
110
|
+
<div id="intro" data-composition-id="intro" data-start="2" data-duration="10">
|
|
111
|
+
<div data-composition-id="intro">
|
|
112
|
+
<div id="logo" data-composition-id="logo" data-start="3" data-duration="5">
|
|
113
|
+
<div data-composition-id="logo">
|
|
114
|
+
<div id="badge" data-composition-id="badge" data-start="1" data-duration="2"></div>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
`);
|
|
121
|
+
const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 20);
|
|
122
|
+
expect(missing.map((e) => [e.domId, e.start, e.duration, e.parentCompositionStart])).toEqual([
|
|
123
|
+
["intro", 2, 10, 0],
|
|
124
|
+
["logo", 5, 5, 2],
|
|
125
|
+
["badge", 6, 2, 5],
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("starts a host after a referenced scene's authored length", () => {
|
|
130
|
+
const doc = makeDoc(`
|
|
131
|
+
<div data-composition-id="main" data-start="0" data-duration="20">
|
|
132
|
+
<div id="s1" data-composition-id="s1" data-start="0" data-hf-authored-duration="8"></div>
|
|
133
|
+
<div id="s2" data-composition-id="s2" data-start="s1 + 1" data-duration="4"></div>
|
|
134
|
+
</div>
|
|
135
|
+
`);
|
|
136
|
+
const win = { __timelines: { s1: { duration: () => 6 } } } as unknown as IframeWindow;
|
|
137
|
+
const { missing } = buildMissingCompositionElements(doc, win, [], 20);
|
|
138
|
+
expect(missing.find((e) => e.domId === "s2")?.start).toBe(9);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
106
142
|
describe("setPreviewMediaVolume", () => {
|
|
107
143
|
it("sends a clamped runtime volume to a direct preview iframe", () => {
|
|
108
144
|
const iframe = document.createElement("iframe");
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import type { TimelineElement } from "../store/playerStore";
|
|
14
14
|
import type { IframeWindow } from "./playbackTypes";
|
|
15
15
|
import { readClipTiming } from "@hyperframes/core/composition-contract";
|
|
16
|
+
import { createRuntimeStartTimeResolver } from "@hyperframes/core/runtime/start-resolver";
|
|
16
17
|
import {
|
|
17
18
|
getTimelineElementSelector,
|
|
18
19
|
getTimelineElementSourceFile,
|
|
@@ -414,15 +415,16 @@ function buildMissingCompositionEntry(params: {
|
|
|
414
415
|
rootDuration: number;
|
|
415
416
|
fallbackIndex: number;
|
|
416
417
|
resolveEnd: (refId: string, visiting: ReadonlySet<string>) => number | null;
|
|
418
|
+
masterTime: ReturnType<typeof createRuntimeStartTimeResolver>;
|
|
417
419
|
}): TimelineElement | null {
|
|
418
|
-
const { doc, iframeWin, element, compositionId, rootDuration, fallbackIndex
|
|
419
|
-
|
|
420
|
+
const { doc, iframeWin, element, compositionId, rootDuration, fallbackIndex } = params;
|
|
421
|
+
const { resolveEnd, masterTime } = params;
|
|
420
422
|
const transitionLabels = transitionLabelsForDocument(doc, iframeWin.__timelines);
|
|
421
423
|
const timing = readClipTiming(element, {
|
|
422
424
|
resolveReferenceEnd: (refId) => resolveEnd(refId, new Set([compositionId])),
|
|
423
425
|
});
|
|
424
426
|
const window = clampCompositionWindow(
|
|
425
|
-
|
|
427
|
+
masterTime.resolveStartForElement(element),
|
|
426
428
|
timing.duration ?? timelineDuration(iframeWin, compositionId),
|
|
427
429
|
rootDuration,
|
|
428
430
|
);
|
|
@@ -456,6 +458,7 @@ function buildMissingCompositionEntry(params: {
|
|
|
456
458
|
key: identity.key,
|
|
457
459
|
tag: element.tagName.toLowerCase(),
|
|
458
460
|
start: window.start,
|
|
461
|
+
parentCompositionStart: masterTime.resolveHostStartForElement(element),
|
|
459
462
|
duration: window.duration,
|
|
460
463
|
track: timing.trackIndex,
|
|
461
464
|
authoredTrack: timing.trackIndex,
|
|
@@ -494,6 +497,11 @@ export function buildMissingCompositionElements(
|
|
|
494
497
|
const missing: TimelineElement[] = [];
|
|
495
498
|
|
|
496
499
|
const resolveEnd = createReferenceEndResolver(createTimedElementLookup(doc), iframeWin);
|
|
500
|
+
const masterTime = createRuntimeStartTimeResolver({
|
|
501
|
+
timelineRegistry: iframeWin.__timelines,
|
|
502
|
+
includeAuthoredTimingAttrs: true,
|
|
503
|
+
documentRef: doc,
|
|
504
|
+
});
|
|
497
505
|
|
|
498
506
|
for (const host of hosts) {
|
|
499
507
|
const el = host as HTMLElement;
|
|
@@ -508,6 +516,7 @@ export function buildMissingCompositionElements(
|
|
|
508
516
|
rootDuration,
|
|
509
517
|
fallbackIndex: missing.length,
|
|
510
518
|
resolveEnd,
|
|
519
|
+
masterTime,
|
|
511
520
|
});
|
|
512
521
|
if (entry) missing.push(entry);
|
|
513
522
|
}
|
package/src/player/sceneSwap.ts
CHANGED
|
@@ -17,6 +17,27 @@ export function onPreviewContentReplaced(
|
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export function markScenesStale(iframe: HTMLIFrameElement | null, files: readonly string[]): void {
|
|
21
|
+
const doc = iframe?.contentDocument;
|
|
22
|
+
const meta = doc?.querySelector<HTMLMetaElement>('meta[name="hf-scene-parts"]');
|
|
23
|
+
if (!doc || !meta) return;
|
|
24
|
+
let parts: { shared: string; scenes: Record<string, string> };
|
|
25
|
+
try {
|
|
26
|
+
parts = JSON.parse(meta.content);
|
|
27
|
+
} catch {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
const hosts = doc.querySelectorAll(`[data-composition-file="${CSS.escape(file)}"]`);
|
|
32
|
+
if (hosts.length === 0 && file.endsWith(".html")) parts.shared = "";
|
|
33
|
+
for (const host of hosts) {
|
|
34
|
+
const scene = host.closest("[data-hf-scene]")?.getAttribute("data-hf-scene");
|
|
35
|
+
if (scene && scene in parts.scenes) parts.scenes[scene] = "";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
meta.content = JSON.stringify(parts);
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
/** Null when the preview cannot swap; rejects when superseded, cancelled or a full reload is needed. */
|
|
21
42
|
export function sceneSwapFor(
|
|
22
43
|
iframe: HTMLIFrameElement,
|
|
@@ -2,12 +2,18 @@
|
|
|
2
2
|
type TimeListener = (time: number) => void;
|
|
3
3
|
const timeListeners = new Set<TimeListener>();
|
|
4
4
|
let latestTime = 0;
|
|
5
|
+
let seekCount = 0;
|
|
5
6
|
|
|
6
7
|
export const liveTime = {
|
|
7
8
|
notify: (time: number) => {
|
|
8
9
|
latestTime = time;
|
|
9
10
|
timeListeners.forEach((listener) => listener(time));
|
|
10
11
|
},
|
|
12
|
+
notifySeek: (time: number) => {
|
|
13
|
+
seekCount += 1;
|
|
14
|
+
liveTime.notify(time);
|
|
15
|
+
},
|
|
16
|
+
seekCount: () => seekCount,
|
|
11
17
|
latest: () => latestTime,
|
|
12
18
|
subscribe: (listener: TimeListener) => {
|
|
13
19
|
timeListeners.add(listener);
|
|
@@ -83,6 +83,7 @@ interface PlayerState extends PlayerStoreSlices {
|
|
|
83
83
|
zoomMode: ZoomMode;
|
|
84
84
|
/** Timeline zoom percent relative to the fit width when in manual mode */
|
|
85
85
|
manualZoomPercent: number;
|
|
86
|
+
userZoomCount: number;
|
|
86
87
|
/**
|
|
87
88
|
* Bumped on every live z-index edit (handleDomZIndexReorderCommit apply AND
|
|
88
89
|
* rollback). Flashless z commits (skipReload) never reload the iframe or
|
|
@@ -279,6 +280,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
279
280
|
loopEnabled: false,
|
|
280
281
|
zoomMode: "fit",
|
|
281
282
|
manualZoomPercent: 100,
|
|
283
|
+
userZoomCount: 0,
|
|
282
284
|
zEditVersion: 0,
|
|
283
285
|
timelinePps: 100,
|
|
284
286
|
timelineFitPps: 100,
|
|
@@ -84,13 +84,38 @@ export interface TimelineElement {
|
|
|
84
84
|
audioGroupFxChain?: string;
|
|
85
85
|
audioGroupAutomation?: string;
|
|
86
86
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
87
|
+
* Master start of the composition this row runs in, which its tweens and its
|
|
88
|
+
* `data-start` are local to; 0 at the root. Writes go through toAuthoredStart.
|
|
89
89
|
*/
|
|
90
|
-
|
|
90
|
+
parentCompositionStart?: number;
|
|
91
|
+
/** A legacy root-global media start: its `data-start` is already master time. */
|
|
92
|
+
authoredStartIsMasterTime?: boolean;
|
|
93
|
+
/** Legacy marker for an inline sub-composition child; current rows never set it. */
|
|
91
94
|
expandedHostKey?: string;
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
type RowClock = Pick<
|
|
98
|
+
TimelineElement,
|
|
99
|
+
"start" | "parentCompositionStart" | "authoredStartIsMasterTime"
|
|
100
|
+
>;
|
|
101
|
+
const authoredOffset = (element: RowClock) =>
|
|
102
|
+
element.authoredStartIsMasterTime ? 0 : (element.parentCompositionStart ?? 0);
|
|
103
|
+
|
|
104
|
+
/** The earliest master start this row can take: its host's start, or its own if already earlier. */
|
|
105
|
+
export function clampToHostStart(element: RowClock, masterTime: number): number {
|
|
106
|
+
return Math.max(Math.min(authoredOffset(element), element.start), masterTime);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A master-time position on this row, as the `data-start` its source file stores. */
|
|
110
|
+
export function toAuthoredStart(element: RowClock, masterTime: number): number {
|
|
111
|
+
return Math.max(0, clampToHostStart(element, masterTime) - authoredOffset(element));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A master-time position on the clock this row's tweens run on. */
|
|
115
|
+
export function toCompositionTime(element: RowClock, masterTime: number): number {
|
|
116
|
+
return masterTime - (element.parentCompositionStart ?? 0);
|
|
117
|
+
}
|
|
118
|
+
|
|
94
119
|
/**
|
|
95
120
|
* The fields `updateElement` may write.
|
|
96
121
|
*
|
|
@@ -259,6 +259,64 @@ describe("applyUndoRestoreToPreview", () => {
|
|
|
259
259
|
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
260
260
|
});
|
|
261
261
|
|
|
262
|
+
it("blanks a restored scene's live hash before reloading, so the scene swap takes it", () => {
|
|
263
|
+
const { iframe, doc } = buildLiveIframe(
|
|
264
|
+
`<div data-hf-scene="scene0" data-composition-file="scenes/intro.html"><h1>Rep0</h1></div>` +
|
|
265
|
+
`<div data-hf-scene="scene1" data-composition-file="scenes/outro.html"></div>`,
|
|
266
|
+
);
|
|
267
|
+
const meta = doc.createElement("meta");
|
|
268
|
+
meta.name = "hf-scene-parts";
|
|
269
|
+
meta.content = JSON.stringify({ shared: "s", scenes: { scene0: "h0", scene1: "h1" } });
|
|
270
|
+
doc.head.append(meta);
|
|
271
|
+
let partsAtReload: unknown;
|
|
272
|
+
const reloadPreview = vi.fn(() => (partsAtReload = JSON.parse(meta.content)));
|
|
273
|
+
const files = {
|
|
274
|
+
"scenes/intro.html": { previous: "<h1>Rep0</h1>", restored: "<h1>Alpha</h1>" },
|
|
275
|
+
};
|
|
276
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
277
|
+
expect(partsAtReload).toEqual({ shared: "s", scenes: { scene0: "", scene1: "h1" } });
|
|
278
|
+
|
|
279
|
+
const withRoot = {
|
|
280
|
+
...files,
|
|
281
|
+
[ROOT]: { previous: wrap("<p>b</p>"), restored: wrap("<p>a</p>") },
|
|
282
|
+
};
|
|
283
|
+
applyUndoRestoreToPreview(iframe, ROOT, withRoot, 3, reloadPreview);
|
|
284
|
+
// A restored page outside every scene forces the full reload, never a partial swap.
|
|
285
|
+
expect(partsAtReload).toEqual({ shared: "", scenes: { scene0: "", scene1: "h1" } });
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("leaves the scene hashes alone on a soft restore, so the next edit can still swap", () => {
|
|
289
|
+
const { iframe, doc } = buildLiveIframe(`<div id="a" style="color: red">t</div>`);
|
|
290
|
+
const meta = doc.createElement("meta");
|
|
291
|
+
meta.name = "hf-scene-parts";
|
|
292
|
+
meta.content = JSON.stringify({ shared: "s", scenes: { scene0: "h0" } });
|
|
293
|
+
doc.head.append(meta);
|
|
294
|
+
const files = {
|
|
295
|
+
[ROOT]: {
|
|
296
|
+
previous: wrap(`<div id="a" style="color: red">t</div>`),
|
|
297
|
+
restored: wrap(`<div id="a" style="color: blue">t</div>`),
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn())).toBe("soft");
|
|
301
|
+
expect(JSON.parse(meta.content)).toEqual({ shared: "s", scenes: { scene0: "h0" } });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it.each([
|
|
305
|
+
["a restored stylesheet leaves the shared hash", "styles.css", '{"shared":"s","scenes":{}}'],
|
|
306
|
+
["an unreadable manifest still reloads", "scenes/intro.html", "not json"],
|
|
307
|
+
])("%s", (_name, path, content) => {
|
|
308
|
+
const { iframe, doc } = buildLiveIframe(`<div id="a">t</div>`);
|
|
309
|
+
const meta = doc.createElement("meta");
|
|
310
|
+
meta.name = "hf-scene-parts";
|
|
311
|
+
meta.content = content;
|
|
312
|
+
doc.head.append(meta);
|
|
313
|
+
const reloadPreview = vi.fn();
|
|
314
|
+
const files = { [path]: { previous: "a", restored: "b" } };
|
|
315
|
+
expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
|
|
316
|
+
expect(reloadPreview).toHaveBeenCalledTimes(1);
|
|
317
|
+
expect(meta.content).toBe(content);
|
|
318
|
+
});
|
|
319
|
+
|
|
262
320
|
it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
|
|
263
321
|
const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
|
|
264
322
|
const reloadPreview = vi.fn();
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
extractGsapScriptText,
|
|
9
9
|
findGsapScriptElements,
|
|
10
10
|
} from "./gsapSoftReload";
|
|
11
|
+
import { markScenesStale } from "../player/sceneSwap";
|
|
11
12
|
|
|
12
13
|
type PreviewWindow = Window & {
|
|
13
14
|
__player?: { seek?: (t: number) => void };
|
|
@@ -184,7 +185,7 @@ export function applyUndoRestoreToPreview(
|
|
|
184
185
|
activeCompPath: string | null,
|
|
185
186
|
files: Record<string, UndoRestoreFile> | undefined,
|
|
186
187
|
currentTime: number,
|
|
187
|
-
|
|
188
|
+
reload: () => void,
|
|
188
189
|
): "soft" | "full" {
|
|
189
190
|
// The master view carries a NULL activeCompPath but the root iframe shows
|
|
190
191
|
// index.html — the codebase-wide convention (`activeCompPath || "index.html"`).
|
|
@@ -192,6 +193,10 @@ export function applyUndoRestoreToPreview(
|
|
|
192
193
|
// full-reloaded: the original "undo always blinks".
|
|
193
194
|
const activeDocPath = activeCompPath ?? "index.html";
|
|
194
195
|
const paths = files ? Object.keys(files) : [];
|
|
196
|
+
const reloadPreview = () => {
|
|
197
|
+
markScenesStale(iframe, paths);
|
|
198
|
+
reload();
|
|
199
|
+
};
|
|
195
200
|
// Soft path only covers the single active-comp document in the root iframe.
|
|
196
201
|
if (!iframe || !files || paths.length !== 1 || paths[0] !== activeDocPath) {
|
|
197
202
|
reloadPreview();
|
|
@@ -48,7 +48,7 @@ describe("buildAtomicCutIntents", () => {
|
|
|
48
48
|
|
|
49
49
|
it("rebases each nested target into its own source-file coordinates", () => {
|
|
50
50
|
const intents = buildAtomicCutIntents(
|
|
51
|
-
[element({ start: 8, duration: 4,
|
|
51
|
+
[element({ start: 8, duration: 4, parentCompositionStart: 6, sourceFile: "scene.html" })],
|
|
52
52
|
10,
|
|
53
53
|
"index.html",
|
|
54
54
|
);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TimelineElement } from "../player";
|
|
2
|
+
import { toAuthoredStart } from "../player/store/timelineElement";
|
|
2
3
|
import type { RecordEditInput } from "../hooks/timelineEditingHelpers";
|
|
3
4
|
import { buildPatchTarget } from "./timelineElementSplit";
|
|
4
5
|
import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
|
|
@@ -61,12 +62,11 @@ function buildCutTarget(
|
|
|
61
62
|
target: CutTarget["target"],
|
|
62
63
|
splitTime: number,
|
|
63
64
|
): CutTarget {
|
|
64
|
-
const basis = element.expandedParentStart;
|
|
65
65
|
return {
|
|
66
66
|
target,
|
|
67
67
|
...(element.domId ? { originalId: element.domId } : {}),
|
|
68
|
-
splitTime:
|
|
69
|
-
elementStart:
|
|
68
|
+
splitTime: Math.max(0, toAuthoredStart(element, splitTime)),
|
|
69
|
+
elementStart: toAuthoredStart(element, element.start),
|
|
70
70
|
elementDuration: element.duration,
|
|
71
71
|
...(element.playbackStart != null ? { playbackStart: element.playbackStart } : {}),
|
|
72
72
|
...(element.playbackRate != null ? { playbackRate: element.playbackRate } : {}),
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
4
|
import {
|
|
5
5
|
coversComposition,
|
|
6
|
+
getAllPreviewTargetsFromPointer,
|
|
6
7
|
getPreviewTargetFromPointer,
|
|
7
8
|
pauseStudioPreviewPlayback,
|
|
8
9
|
} from "./studioPreviewHelpers";
|
|
@@ -141,6 +142,34 @@ describe("getPreviewTargetFromPointer", () => {
|
|
|
141
142
|
iframe.remove();
|
|
142
143
|
});
|
|
143
144
|
|
|
145
|
+
it("hit-tests without adding styles to the preview document", () => {
|
|
146
|
+
const { iframe, doc } = createPreviewIframe();
|
|
147
|
+
doc.body.innerHTML = `<main data-composition-id="scene"><h1 id="headline">Title</h1></main>`;
|
|
148
|
+
const scene = doc.querySelector<HTMLElement>("main")!;
|
|
149
|
+
const headline = doc.getElementById("headline")!;
|
|
150
|
+
stubRect(iframe, domRect(0, 0, 400, 300));
|
|
151
|
+
stubRect(scene, domRect(0, 0, 400, 300));
|
|
152
|
+
stubRect(headline, domRect(40, 40, 160, 48));
|
|
153
|
+
const stylesAtHitTest: number[] = [];
|
|
154
|
+
doc.elementsFromPoint = () => {
|
|
155
|
+
stylesAtHitTest.push(doc.querySelectorAll("style").length + doc.adoptedStyleSheets.length);
|
|
156
|
+
return [headline, scene];
|
|
157
|
+
};
|
|
158
|
+
// First call runs the once-per-document inheritance probe, a body mutation.
|
|
159
|
+
expect(getPreviewTargetFromPointer(iframe, 80, 64, "index.html")).toBe(headline);
|
|
160
|
+
const observer = new MutationObserver(() => {});
|
|
161
|
+
observer.observe(doc, { subtree: true, childList: true, attributes: true });
|
|
162
|
+
|
|
163
|
+
expect(getPreviewTargetFromPointer(iframe, 80, 64, "index.html")).toBe(headline);
|
|
164
|
+
expect(getAllPreviewTargetsFromPointer(iframe, 80, 64, "index.html")).toEqual([headline]);
|
|
165
|
+
const mutations = observer.takeRecords();
|
|
166
|
+
observer.disconnect();
|
|
167
|
+
|
|
168
|
+
expect(stylesAtHitTest).toEqual([0, 0, 0]);
|
|
169
|
+
expect(mutations).toEqual([]);
|
|
170
|
+
iframe.remove();
|
|
171
|
+
});
|
|
172
|
+
|
|
144
173
|
it("skips candidates hidden from author hit-testing by inherited pointer-events:none", () => {
|
|
145
174
|
const { iframe, doc } = createPreviewIframe();
|
|
146
175
|
|
|
@@ -70,28 +70,6 @@ function resolvePreviewLocalPointer(
|
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
const POINTER_EVENTS_OVERRIDE_ID = "__hf_studio_pointer_events_override__";
|
|
74
|
-
|
|
75
|
-
function forcePointerEventsAuto(doc: Document): HTMLStyleElement | null {
|
|
76
|
-
try {
|
|
77
|
-
const style = doc.createElement("style");
|
|
78
|
-
style.id = POINTER_EVENTS_OVERRIDE_ID;
|
|
79
|
-
style.textContent = "* { pointer-events: auto !important; }";
|
|
80
|
-
doc.head.appendChild(style);
|
|
81
|
-
return style;
|
|
82
|
-
} catch {
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function removePointerEventsOverride(style: HTMLStyleElement | null): void {
|
|
88
|
-
try {
|
|
89
|
-
style?.remove();
|
|
90
|
-
} catch {
|
|
91
|
-
// cross-origin or detached doc
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
73
|
const pointerEventsInheritanceFallbackByDocument = new WeakMap<Document, boolean>();
|
|
96
74
|
|
|
97
75
|
function needsPointerEventsInheritanceFallback(doc: Document, win: Window): boolean {
|
|
@@ -222,45 +200,33 @@ export function getPreviewTargetFromPointer(
|
|
|
222
200
|
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
|
|
223
201
|
if (!localPointer) return null;
|
|
224
202
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
candidates.find((el) => !isFullBleedTarget(el, localPointer.viewport)) ?? null;
|
|
234
|
-
if (visualTarget) return visualTarget;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Belt-and-suspenders: elementsFromPoint is universally supported in the
|
|
238
|
-
// browsers this ships in, so the override is already removed by this
|
|
239
|
-
// point in practice — but guard the environment without it too, so
|
|
240
|
-
// hasAuthorPointerEventsNone below never reads a forced-auto value.
|
|
241
|
-
removePointerEventsOverride(overrideStyle);
|
|
242
|
-
overrideStyle = null;
|
|
243
|
-
|
|
244
|
-
// No element hit (e.g. empty space inside an animated group's overlay) — fall
|
|
245
|
-
// back to the group whose member-union contains the point, so the whole group
|
|
246
|
-
// area is hoverable/selectable, not just where a member currently sits.
|
|
247
|
-
const groupHit = findGroupAtPoint(doc, localPointer.x, localPointer.y);
|
|
248
|
-
if (
|
|
249
|
-
groupHit &&
|
|
250
|
-
!hasAuthorPointerEventsNone(groupHit) &&
|
|
251
|
-
getDomLayerPatchTarget(groupHit, activeCompositionPath)
|
|
252
|
-
)
|
|
253
|
-
return groupHit;
|
|
254
|
-
|
|
255
|
-
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
|
|
256
|
-
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
|
|
257
|
-
if (hasAuthorPointerEventsNone(fallback)) return null;
|
|
258
|
-
if (!isElementComputedVisible(fallback)) return null;
|
|
259
|
-
if (isFullBleedTarget(fallback, localPointer.viewport)) return null;
|
|
260
|
-
return fallback;
|
|
261
|
-
} finally {
|
|
262
|
-
removePointerEventsOverride(overrideStyle);
|
|
203
|
+
// Hit-test without touching the preview document: any style added here, even
|
|
204
|
+
// briefly, restyles every element in the film on each click and hover.
|
|
205
|
+
if (typeof doc.elementsFromPoint === "function") {
|
|
206
|
+
const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
|
|
207
|
+
const candidates = filterAuthorInteractiveTargets(elements, activeCompositionPath);
|
|
208
|
+
const visualTarget =
|
|
209
|
+
candidates.find((el) => !isFullBleedTarget(el, localPointer.viewport)) ?? null;
|
|
210
|
+
if (visualTarget) return visualTarget;
|
|
263
211
|
}
|
|
212
|
+
|
|
213
|
+
// No element hit (e.g. empty space inside an animated group's overlay) — fall
|
|
214
|
+
// back to the group whose member-union contains the point, so the whole group
|
|
215
|
+
// area is hoverable/selectable, not just where a member currently sits.
|
|
216
|
+
const groupHit = findGroupAtPoint(doc, localPointer.x, localPointer.y);
|
|
217
|
+
if (
|
|
218
|
+
groupHit &&
|
|
219
|
+
!hasAuthorPointerEventsNone(groupHit) &&
|
|
220
|
+
getDomLayerPatchTarget(groupHit, activeCompositionPath)
|
|
221
|
+
)
|
|
222
|
+
return groupHit;
|
|
223
|
+
|
|
224
|
+
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
|
|
225
|
+
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
|
|
226
|
+
if (hasAuthorPointerEventsNone(fallback)) return null;
|
|
227
|
+
if (!isElementComputedVisible(fallback)) return null;
|
|
228
|
+
if (isFullBleedTarget(fallback, localPointer.viewport)) return null;
|
|
229
|
+
return fallback;
|
|
264
230
|
}
|
|
265
231
|
|
|
266
232
|
/** Returns all independently-selectable elements at the pointer (topmost first). */
|
|
@@ -283,27 +249,18 @@ export function getAllPreviewTargetsFromPointer(
|
|
|
283
249
|
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
|
|
284
250
|
if (!localPointer) return [];
|
|
285
251
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
overrideStyle = null;
|
|
292
|
-
return filterAuthorInteractiveTargets(elements, activeCompositionPath).filter(
|
|
293
|
-
(el) => !isFullBleedTarget(el, localPointer.viewport),
|
|
294
|
-
);
|
|
295
|
-
}
|
|
296
|
-
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
|
|
297
|
-
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return [];
|
|
298
|
-
removePointerEventsOverride(overrideStyle);
|
|
299
|
-
overrideStyle = null;
|
|
300
|
-
if (hasAuthorPointerEventsNone(fallback)) return [];
|
|
301
|
-
if (!isElementComputedVisible(fallback)) return [];
|
|
302
|
-
if (isFullBleedTarget(fallback, localPointer.viewport)) return [];
|
|
303
|
-
return [fallback];
|
|
304
|
-
} finally {
|
|
305
|
-
removePointerEventsOverride(overrideStyle);
|
|
252
|
+
if (typeof doc.elementsFromPoint === "function") {
|
|
253
|
+
const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
|
|
254
|
+
return filterAuthorInteractiveTargets(elements, activeCompositionPath).filter(
|
|
255
|
+
(el) => !isFullBleedTarget(el, localPointer.viewport),
|
|
256
|
+
);
|
|
306
257
|
}
|
|
258
|
+
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
|
|
259
|
+
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return [];
|
|
260
|
+
if (hasAuthorPointerEventsNone(fallback)) return [];
|
|
261
|
+
if (!isElementComputedVisible(fallback)) return [];
|
|
262
|
+
if (isFullBleedTarget(fallback, localPointer.viewport)) return [];
|
|
263
|
+
return [fallback];
|
|
307
264
|
}
|
|
308
265
|
|
|
309
266
|
function objectLike(value: unknown): object | null {
|