@hyperframes/studio 0.8.48 → 0.8.49
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-CqON2Rzo.js +464 -0
- package/dist/assets/{index-B3-orIjW.js → index-BGovDksE.js} +1 -1
- package/dist/assets/{index-C9gJKpqQ.js → index-Bgmx2h7W.js} +135 -135
- package/dist/assets/{index-D_j-0PD3.js → index-mIgSsKqs.js} +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.html +1 -1
- package/dist/index.js +443 -303
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/hooks/useBlockHandlers.ts +7 -2
- package/src/hooks/useTimelineAssetDropOps.test.tsx +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +4 -2
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
- package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
- package/src/player/components/timelineClipDragCommit.test.ts +114 -0
- package/src/player/components/timelineClipDragCommit.ts +18 -49
- package/src/player/components/timelineClipDragPreview.test.ts +173 -1
- package/src/player/components/timelineClipDragPreview.ts +8 -1
- package/src/player/components/timelineCollision.test.ts +23 -0
- package/src/player/components/timelineCollision.ts +27 -0
- package/src/player/components/timelineDragLanding.ts +108 -0
- package/src/player/components/timelineDropReveal.test.ts +34 -0
- package/src/player/components/timelineDropReveal.ts +12 -0
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
- package/src/player/components/useTimelineGeometry.test.tsx +117 -0
- package/src/player/components/useTimelineGeometry.ts +7 -2
- package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
- package/src/player/components/useTimelineLogicalFocus.ts +1 -0
- package/src/player/lib/timelineElementHelpers.ts +8 -0
- package/src/player/store/readinessSlice.ts +4 -3
- package/src/utils/blockInstaller.test.ts +4 -0
- package/src/utils/blockInstaller.ts +9 -3
- package/src/utils/retryBudget.ts +20 -0
- package/src/utils/sdkCutover.familyGate.test.ts +5 -1
- package/src/utils/sdkCutover.ts +7 -4
- package/src/utils/sdkCutoverEligibility.test.ts +96 -1
- package/src/utils/sdkCutoverEligibility.ts +34 -11
- package/src/utils/timelineCompositionInsert.ts +2 -1
- package/dist/assets/hyperframes-player-CtVX6jL_.js +0 -464
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type TimelineLogicalTarget,
|
|
12
12
|
} from "./timelineKeyboardNavigation";
|
|
13
13
|
import { computeRevealScroll } from "./timelineRevealScroll";
|
|
14
|
+
import { recordRetryAttempt, type RetryBudgetState } from "../../utils/retryBudget";
|
|
14
15
|
|
|
15
16
|
interface TimelineFocusCoordinatorInput {
|
|
16
17
|
scrollRef: RefObject<HTMLDivElement | null>;
|
|
@@ -24,6 +25,7 @@ interface TimelineFocusCoordinatorInput {
|
|
|
24
25
|
projectId: string | null;
|
|
25
26
|
sessionEpoch: number;
|
|
26
27
|
syncScrollViewport: (element: HTMLDivElement) => void;
|
|
28
|
+
lastScrollLeftRef: RefObject<number>;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export interface TimelineFocusCoordinatorState {
|
|
@@ -77,6 +79,7 @@ function scrollToTarget(
|
|
|
77
79
|
pixelsPerSecond: number,
|
|
78
80
|
contentOrigin: number,
|
|
79
81
|
allowHorizontal: boolean,
|
|
82
|
+
lastScrollLeftRef: RefObject<number>,
|
|
80
83
|
): boolean {
|
|
81
84
|
const rowIndex = rowGeometry.getRowIndex(resolution.row.physicalTrackKey);
|
|
82
85
|
if (rowIndex < 0) return false;
|
|
@@ -103,11 +106,26 @@ function scrollToTarget(
|
|
|
103
106
|
stickyTop: RULER_H,
|
|
104
107
|
allowHorizontal: allowHorizontal && left !== null,
|
|
105
108
|
});
|
|
106
|
-
if (target.left !== null)
|
|
109
|
+
if (target.left !== null) {
|
|
110
|
+
container.scrollLeft = target.left;
|
|
111
|
+
// Keep the geometry effect's "restore scroll after an edit re-derives the
|
|
112
|
+
// elements" in sync, or it reads the pre-reveal position on the next tick
|
|
113
|
+
// and scrolls the freshly-revealed clip back out of view.
|
|
114
|
+
lastScrollLeftRef.current = target.left;
|
|
115
|
+
}
|
|
107
116
|
if (target.top !== null) container.scrollTop = target.top;
|
|
108
117
|
return target.left !== null || target.top !== null;
|
|
109
118
|
}
|
|
110
119
|
|
|
120
|
+
// A request racing a fresh element's own creation (drop/paste) resolves within
|
|
121
|
+
// a render or two; past this many misses on the same nonce it truly never will.
|
|
122
|
+
const MAX_UNRESOLVED_FOCUS_RETRIES = 5;
|
|
123
|
+
|
|
124
|
+
// Backstop for a request whose target never appears and nothing else ever
|
|
125
|
+
// re-renders: the retry budget above can't advance without a rerun, so this
|
|
126
|
+
// bounds it by wall time instead, independent of re-renders.
|
|
127
|
+
const UNRESOLVED_FOCUS_TIMEOUT_MS = 4000;
|
|
128
|
+
|
|
111
129
|
/** Model-first focus actor; mounting is a consequence of its returned pins. */
|
|
112
130
|
// Resolution, fallback, reveal, and focus form one ordered state machine.
|
|
113
131
|
// fallow-ignore-next-line complexity
|
|
@@ -123,11 +141,13 @@ export function useTimelineFocusCoordinator({
|
|
|
123
141
|
projectId,
|
|
124
142
|
sessionEpoch,
|
|
125
143
|
syncScrollViewport,
|
|
144
|
+
lastScrollLeftRef,
|
|
126
145
|
}: TimelineFocusCoordinatorInput): TimelineFocusCoordinatorState {
|
|
127
146
|
const request = usePlayerStore((state) => state.timelineFocus);
|
|
128
147
|
const previousRowsRef = useRef(logicalRows);
|
|
129
148
|
const resolvedRef = useRef<{ nonce: number; id: string } | null>(null);
|
|
130
149
|
const appliedRef = useRef<{ nonce: number; id: string } | null>(null);
|
|
150
|
+
const unresolvedFocusAttemptsRef = useRef<RetryBudgetState<number>>({ id: -1, count: 0 });
|
|
131
151
|
const resolution = useMemo<ResolvedFocus | null>(() => {
|
|
132
152
|
if (isCurrentRequest(request, projectId, sessionEpoch)) {
|
|
133
153
|
if (resolvedRef.current?.nonce !== request.nonce) {
|
|
@@ -163,8 +183,26 @@ export function useTimelineFocusCoordinator({
|
|
|
163
183
|
useEffect(() => {
|
|
164
184
|
if (!isCurrentRequest(request, projectId, sessionEpoch)) return;
|
|
165
185
|
if (!resolution) {
|
|
166
|
-
|
|
167
|
-
|
|
186
|
+
// A request issued the same tick as its element (a fresh drop/paste) can
|
|
187
|
+
// outrun logicalRows by a render or two; only give up once it clearly
|
|
188
|
+
// never resolves, so the reveal isn't lost to that ordinary race.
|
|
189
|
+
const withinBudget = recordRetryAttempt(
|
|
190
|
+
unresolvedFocusAttemptsRef,
|
|
191
|
+
request.nonce,
|
|
192
|
+
MAX_UNRESOLVED_FOCUS_RETRIES,
|
|
193
|
+
);
|
|
194
|
+
if (!withinBudget) {
|
|
195
|
+
usePlayerStore.getState().clearTimelineFocus(request.nonce);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const nonce = request.nonce;
|
|
199
|
+
const timeout = setTimeout(() => {
|
|
200
|
+
usePlayerStore.getState().clearTimelineFocus(nonce);
|
|
201
|
+
}, UNRESOLVED_FOCUS_TIMEOUT_MS);
|
|
202
|
+
return () => clearTimeout(timeout);
|
|
203
|
+
}
|
|
204
|
+
if (unresolvedFocusAttemptsRef.current.count !== 0) {
|
|
205
|
+
unresolvedFocusAttemptsRef.current = { id: -1, count: 0 };
|
|
168
206
|
}
|
|
169
207
|
if (resolution.target.id !== request.id) {
|
|
170
208
|
usePlayerStore.getState().requestTimelineFocus(resolution.target.id);
|
|
@@ -187,6 +225,7 @@ export function useTimelineFocusCoordinator({
|
|
|
187
225
|
pixelsPerSecond,
|
|
188
226
|
contentOrigin,
|
|
189
227
|
allowHorizontal,
|
|
228
|
+
lastScrollLeftRef,
|
|
190
229
|
)
|
|
191
230
|
) {
|
|
192
231
|
syncScrollViewport(container);
|
|
@@ -197,6 +236,7 @@ export function useTimelineFocusCoordinator({
|
|
|
197
236
|
allowHorizontal,
|
|
198
237
|
contentOrigin,
|
|
199
238
|
elements,
|
|
239
|
+
lastScrollLeftRef,
|
|
200
240
|
pixelsPerSecond,
|
|
201
241
|
projectId,
|
|
202
242
|
request,
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act, useRef } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
7
|
+
import { useTimelineGeometry } from "./useTimelineGeometry";
|
|
8
|
+
|
|
9
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
10
|
+
|
|
11
|
+
const element: TimelineElement = { id: "hero", tag: "div", start: 20, duration: 2, track: 1 };
|
|
12
|
+
|
|
13
|
+
const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
|
|
14
|
+
|
|
15
|
+
function Harness({
|
|
16
|
+
expandedElements,
|
|
17
|
+
lastScrollLeftRef,
|
|
18
|
+
scrollWidth = 20_000,
|
|
19
|
+
}: {
|
|
20
|
+
expandedElements: TimelineElement[];
|
|
21
|
+
lastScrollLeftRef: React.RefObject<number>;
|
|
22
|
+
scrollWidth?: number;
|
|
23
|
+
}) {
|
|
24
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
const isDragging = useRef(false);
|
|
26
|
+
const ppsRef = useRef(0);
|
|
27
|
+
const fitPpsRef = useRef(0);
|
|
28
|
+
useTimelineGeometry({
|
|
29
|
+
viewportWidth: 1600,
|
|
30
|
+
effectiveDuration: 60,
|
|
31
|
+
zoomMode: "manual",
|
|
32
|
+
manualZoomPercent: 100,
|
|
33
|
+
ppsRef,
|
|
34
|
+
fitPpsRef,
|
|
35
|
+
draggedClip: null,
|
|
36
|
+
resizingClip: null,
|
|
37
|
+
expandedElements,
|
|
38
|
+
isDragging,
|
|
39
|
+
scrollRef,
|
|
40
|
+
lastScrollLeftRef,
|
|
41
|
+
contentOrigin: 32,
|
|
42
|
+
});
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
ref={(node) => {
|
|
46
|
+
scrollRef.current = node;
|
|
47
|
+
if (node) {
|
|
48
|
+
Object.defineProperty(node, "clientWidth", { configurable: true, value: 1600 });
|
|
49
|
+
Object.defineProperty(node, "scrollWidth", { configurable: true, value: scrollWidth });
|
|
50
|
+
}
|
|
51
|
+
}}
|
|
52
|
+
/>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let host: HTMLDivElement;
|
|
57
|
+
let root: Root;
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
usePlayerStore.setState({ timelineProjectId: "project-a" });
|
|
60
|
+
host = document.createElement("div");
|
|
61
|
+
document.body.append(host);
|
|
62
|
+
root = createRoot(host);
|
|
63
|
+
});
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
act(() => root.unmount());
|
|
66
|
+
usePlayerStore.getState().reset();
|
|
67
|
+
document.body.replaceChildren();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("useTimelineGeometry restore-scroll effect", () => {
|
|
71
|
+
it("reads lastScrollLeftRef at frame time, not effect time, so a same-commit reveal isn't undone", async () => {
|
|
72
|
+
// A sibling effect in the same commit (a fresh timeline-focus reveal) can
|
|
73
|
+
// update lastScrollLeftRef and the DOM scrollLeft together after this
|
|
74
|
+
// effect has already scheduled its frame. The frame must see that update,
|
|
75
|
+
// not the value that was current when it was scheduled.
|
|
76
|
+
const lastScrollLeftRef = { current: 132 };
|
|
77
|
+
act(() => {
|
|
78
|
+
root.render(<Harness expandedElements={[]} lastScrollLeftRef={lastScrollLeftRef} />);
|
|
79
|
+
});
|
|
80
|
+
const scroll = host.firstElementChild as HTMLDivElement;
|
|
81
|
+
scroll.scrollLeft = 132;
|
|
82
|
+
|
|
83
|
+
// Flush this effect (it schedules a frame, capturing whatever it reads
|
|
84
|
+
// right now) BEFORE the sibling mutates the ref, exactly as in a real
|
|
85
|
+
// commit where this effect registers and runs ahead of the reveal's.
|
|
86
|
+
act(() => {
|
|
87
|
+
root.render(<Harness expandedElements={[element]} lastScrollLeftRef={lastScrollLeftRef} />);
|
|
88
|
+
});
|
|
89
|
+
// Simulate the sibling reveal effect: it runs after this one in the same
|
|
90
|
+
// commit and updates both the DOM and the ref before the frame fires.
|
|
91
|
+
lastScrollLeftRef.current = 450;
|
|
92
|
+
scroll.scrollLeft = 450;
|
|
93
|
+
await act(async () => {
|
|
94
|
+
await nextFrame();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(scroll.scrollLeft).toBe(450);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("still restores the pre-edit position when nothing else changed the ref mid-frame", async () => {
|
|
101
|
+
const lastScrollLeftRef = { current: 300 };
|
|
102
|
+
act(() => {
|
|
103
|
+
root.render(<Harness expandedElements={[]} lastScrollLeftRef={lastScrollLeftRef} />);
|
|
104
|
+
});
|
|
105
|
+
const scroll = host.firstElementChild as HTMLDivElement;
|
|
106
|
+
scroll.scrollLeft = 0;
|
|
107
|
+
|
|
108
|
+
act(() => {
|
|
109
|
+
root.render(<Harness expandedElements={[element]} lastScrollLeftRef={lastScrollLeftRef} />);
|
|
110
|
+
});
|
|
111
|
+
await act(async () => {
|
|
112
|
+
await nextFrame();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(scroll.scrollLeft).toBe(300);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -93,9 +93,14 @@ export function useTimelineGeometry({
|
|
|
93
93
|
useEffect(() => {
|
|
94
94
|
if (zoomMode !== "manual" || isDragging.current) return;
|
|
95
95
|
const el = scrollRef.current;
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
if (!el) return;
|
|
97
|
+
// Read the ref inside the frame, not here: a sibling effect in the same
|
|
98
|
+
// commit (a fresh reveal) can still update lastScrollLeftRef before this
|
|
99
|
+
// frame runs, and a value captured now would fight that reveal with a
|
|
100
|
+
// stale target.
|
|
98
101
|
const raf = requestAnimationFrame(() => {
|
|
102
|
+
const target = lastScrollLeftRef.current;
|
|
103
|
+
if (target <= 0) return;
|
|
99
104
|
const max = Math.max(0, el.scrollWidth - el.clientWidth);
|
|
100
105
|
const next = Math.min(target, max);
|
|
101
106
|
if (Math.abs(el.scrollLeft - next) > 0.5) el.scrollLeft = next;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act, useMemo, useRef } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
7
|
+
import { createTimelineRowGeometry } from "./timelineLayout";
|
|
8
|
+
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
|
9
|
+
import { timelineClipFocusId, timelineTrackRowId } from "./timelineNavigationIdentity";
|
|
10
|
+
import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator";
|
|
11
|
+
import { useTimelineGeometry } from "./useTimelineGeometry";
|
|
12
|
+
|
|
13
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
14
|
+
|
|
15
|
+
const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
|
|
16
|
+
|
|
17
|
+
const oldElement = { id: "old", tag: "div", start: 0, duration: 1, track: 1 };
|
|
18
|
+
const heroElement = { id: "hero", tag: "div", start: 20, duration: 2, track: 1 };
|
|
19
|
+
const clipId = timelineClipFocusId("hero");
|
|
20
|
+
const rowId = timelineTrackRowId(1);
|
|
21
|
+
const beforeRows: readonly TimelineLogicalRow[] = [
|
|
22
|
+
{
|
|
23
|
+
id: rowId,
|
|
24
|
+
kind: "row",
|
|
25
|
+
physicalTrackKey: 1,
|
|
26
|
+
logicalIndex: 0,
|
|
27
|
+
level: 1,
|
|
28
|
+
parentId: null,
|
|
29
|
+
elementId: null,
|
|
30
|
+
expandable: false,
|
|
31
|
+
expanded: false,
|
|
32
|
+
items: [],
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
const afterRows: readonly TimelineLogicalRow[] = [
|
|
36
|
+
{
|
|
37
|
+
id: rowId,
|
|
38
|
+
kind: "row",
|
|
39
|
+
physicalTrackKey: 1,
|
|
40
|
+
logicalIndex: 0,
|
|
41
|
+
level: 1,
|
|
42
|
+
parentId: null,
|
|
43
|
+
elementId: null,
|
|
44
|
+
expandable: false,
|
|
45
|
+
expanded: false,
|
|
46
|
+
items: [{ id: clipId, kind: "clip", rowId, elementId: "hero", time: 21 }],
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// Renders the two real production hooks side by side, in a caller-chosen call
|
|
51
|
+
// order, to test whether their interaction actually depends on that order —
|
|
52
|
+
// as opposed to simulating one hook's effect by hand.
|
|
53
|
+
function OrderHarness({
|
|
54
|
+
order,
|
|
55
|
+
expandedElements,
|
|
56
|
+
logicalRows,
|
|
57
|
+
lastScrollLeftRef,
|
|
58
|
+
}: {
|
|
59
|
+
order: "geometry-first" | "coordinator-first";
|
|
60
|
+
expandedElements: (typeof oldElement)[];
|
|
61
|
+
logicalRows: readonly TimelineLogicalRow[];
|
|
62
|
+
lastScrollLeftRef: React.RefObject<number>;
|
|
63
|
+
}) {
|
|
64
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
65
|
+
const isDragging = useRef(false);
|
|
66
|
+
const ppsRef = useRef(0);
|
|
67
|
+
const fitPpsRef = useRef(0);
|
|
68
|
+
const rowGeometry = useMemo(() => createTimelineRowGeometry([1], [48]), []);
|
|
69
|
+
|
|
70
|
+
const runGeometry = () =>
|
|
71
|
+
useTimelineGeometry({
|
|
72
|
+
viewportWidth: 300,
|
|
73
|
+
effectiveDuration: 60,
|
|
74
|
+
zoomMode: "manual",
|
|
75
|
+
manualZoomPercent: 100,
|
|
76
|
+
ppsRef,
|
|
77
|
+
fitPpsRef,
|
|
78
|
+
draggedClip: null,
|
|
79
|
+
resizingClip: null,
|
|
80
|
+
expandedElements,
|
|
81
|
+
isDragging,
|
|
82
|
+
scrollRef,
|
|
83
|
+
lastScrollLeftRef,
|
|
84
|
+
contentOrigin: 32,
|
|
85
|
+
});
|
|
86
|
+
const runFocus = () =>
|
|
87
|
+
useTimelineFocusCoordinator({
|
|
88
|
+
scrollRef,
|
|
89
|
+
logicalRows,
|
|
90
|
+
elements: expandedElements,
|
|
91
|
+
rowGeometry,
|
|
92
|
+
pixelsPerSecond: 100,
|
|
93
|
+
contentOrigin: 32,
|
|
94
|
+
allowHorizontal: true,
|
|
95
|
+
viewportVersion: logicalRows.length,
|
|
96
|
+
projectId: "project-a",
|
|
97
|
+
sessionEpoch: 1,
|
|
98
|
+
syncScrollViewport: () => {},
|
|
99
|
+
lastScrollLeftRef,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// `order` is fixed for the lifetime of one mounted harness, so this call
|
|
103
|
+
// order is stable across that instance's renders.
|
|
104
|
+
if (order === "geometry-first") {
|
|
105
|
+
runGeometry();
|
|
106
|
+
runFocus();
|
|
107
|
+
} else {
|
|
108
|
+
runFocus();
|
|
109
|
+
runGeometry();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div
|
|
114
|
+
ref={(node) => {
|
|
115
|
+
scrollRef.current = node;
|
|
116
|
+
if (node) {
|
|
117
|
+
Object.defineProperty(node, "clientWidth", { configurable: true, value: 300 });
|
|
118
|
+
Object.defineProperty(node, "clientHeight", { configurable: true, value: 100 });
|
|
119
|
+
Object.defineProperty(node, "scrollWidth", { configurable: true, value: 20_000 });
|
|
120
|
+
}
|
|
121
|
+
}}
|
|
122
|
+
/>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let host: HTMLDivElement;
|
|
127
|
+
let root: Root;
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
usePlayerStore.setState({ timelineProjectId: "project-a", timelineSessionEpoch: 1 });
|
|
130
|
+
host = document.createElement("div");
|
|
131
|
+
document.body.append(host);
|
|
132
|
+
root = createRoot(host);
|
|
133
|
+
});
|
|
134
|
+
afterEach(() => {
|
|
135
|
+
act(() => root.unmount());
|
|
136
|
+
usePlayerStore.getState().reset();
|
|
137
|
+
document.body.replaceChildren();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe.each(["geometry-first", "coordinator-first"] as const)(
|
|
141
|
+
"useTimelineGeometry + useTimelineFocusCoordinator, %s",
|
|
142
|
+
(order) => {
|
|
143
|
+
it("a same-commit reveal survives the geometry restore effect regardless of hook registration order", async () => {
|
|
144
|
+
const lastScrollLeftRef = { current: 132 };
|
|
145
|
+
await act(async () => {
|
|
146
|
+
root.render(
|
|
147
|
+
<OrderHarness
|
|
148
|
+
order={order}
|
|
149
|
+
expandedElements={[oldElement]}
|
|
150
|
+
logicalRows={beforeRows}
|
|
151
|
+
lastScrollLeftRef={lastScrollLeftRef}
|
|
152
|
+
/>,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
const scroll = host.firstElementChild as HTMLDivElement;
|
|
156
|
+
scroll.scrollLeft = 132;
|
|
157
|
+
await act(async () => {
|
|
158
|
+
await nextFrame();
|
|
159
|
+
});
|
|
160
|
+
expect(scroll.scrollLeft).toBe(132);
|
|
161
|
+
|
|
162
|
+
// One commit: the drop both adds the element (expandedElements changes,
|
|
163
|
+
// arming the geometry restore effect) and resolves the focus request
|
|
164
|
+
// (arming the coordinator's reveal), exactly as Timeline.tsx renders them.
|
|
165
|
+
usePlayerStore.getState().requestTimelineFocus(clipId);
|
|
166
|
+
await act(async () => {
|
|
167
|
+
root.render(
|
|
168
|
+
<OrderHarness
|
|
169
|
+
order={order}
|
|
170
|
+
expandedElements={[oldElement, heroElement]}
|
|
171
|
+
logicalRows={afterRows}
|
|
172
|
+
lastScrollLeftRef={lastScrollLeftRef}
|
|
173
|
+
/>,
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
await act(async () => {
|
|
177
|
+
await nextFrame();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(scroll.scrollLeft).toBe(1_944);
|
|
181
|
+
expect(lastScrollLeftRef.current).toBe(1_944);
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
);
|
|
@@ -63,6 +63,7 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
|
|
|
63
63
|
projectId,
|
|
64
64
|
sessionEpoch: input.sessionEpoch,
|
|
65
65
|
syncScrollViewport: input.syncScrollViewport,
|
|
66
|
+
lastScrollLeftRef: input.lastScrollLeftRef,
|
|
66
67
|
});
|
|
67
68
|
const rows = useTimelineRowVirtualization({
|
|
68
69
|
scrollRef: input.scrollRef,
|
|
@@ -392,6 +392,14 @@ export function deriveTimelineStoreKey(params: {
|
|
|
392
392
|
return buildTimelineElementKey({ id: "", fallbackIndex: 0, ...params });
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
+
/**
|
|
396
|
+
* {@link deriveTimelineStoreKey} for a caller that already has a DOM id in
|
|
397
|
+
* hand (e.g. one it just minted), so the undefined branch never applies.
|
|
398
|
+
*/
|
|
399
|
+
export function deriveTimelineStoreKeyForDomId(domId: string, sourceFile?: string): string {
|
|
400
|
+
return deriveTimelineStoreKey({ domId, sourceFile })!;
|
|
401
|
+
}
|
|
402
|
+
|
|
395
403
|
// ---------------------------------------------------------------------------
|
|
396
404
|
// DOM node querying
|
|
397
405
|
// ---------------------------------------------------------------------------
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** Gates `timelineReady` on the composition's declared readiness inputs
|
|
2
|
-
* (media,
|
|
3
|
-
* is module-scope, not store state: it
|
|
4
|
-
* something a component reads, so
|
|
2
|
+
* (media, compute, and the paint-and-idle default) instead of just a known
|
|
3
|
+
* duration. The generation counter is module-scope, not store state: it
|
|
4
|
+
* guards an in-flight settlement, not something a component reads, so
|
|
5
|
+
* bumping it shouldn't trigger a render. */
|
|
5
6
|
import type { StoreApi } from "zustand";
|
|
6
7
|
import { settleCompositionReadiness } from "@hyperframes/core/composition-readiness";
|
|
7
8
|
|
|
@@ -63,5 +63,9 @@ describe("addBlockToProject", () => {
|
|
|
63
63
|
expect(source).toContain('data-start="2.5"');
|
|
64
64
|
expect(source).toContain('data-duration="4.25"');
|
|
65
65
|
expect(source).toContain('data-track-index="3"');
|
|
66
|
+
// hostKey addresses the new element the same way the timeline selects
|
|
67
|
+
// clips (sourceFile#domId), so the caller can select and reveal it.
|
|
68
|
+
expect(result?.hostKey).toBe("compositions/scene.html#camcorder-hud");
|
|
69
|
+
expect(source).toContain('id="camcorder-hud"');
|
|
66
70
|
});
|
|
67
71
|
});
|
|
@@ -11,6 +11,7 @@ import { formatTimelineAttributeNumber } from "../player/components/timelineEdit
|
|
|
11
11
|
import { saveProjectFilesWithHistory } from "./studioFileHistory";
|
|
12
12
|
import type { EditHistoryKind } from "./editHistory";
|
|
13
13
|
import { extendRootDurationInSource } from "./rootDuration";
|
|
14
|
+
import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
|
|
14
15
|
|
|
15
16
|
function getMaxZIndexFromIframe(iframe: HTMLIFrameElement | null): number {
|
|
16
17
|
try {
|
|
@@ -174,7 +175,7 @@ function buildSubCompositionHtml({
|
|
|
174
175
|
|
|
175
176
|
export async function addBlockToProject(
|
|
176
177
|
opts: AddBlockOptions,
|
|
177
|
-
): Promise<{ block: RegistryItem; compositionPath: string } | null> {
|
|
178
|
+
): Promise<{ block: RegistryItem; compositionPath: string; hostKey: string } | null> {
|
|
178
179
|
const {
|
|
179
180
|
projectId,
|
|
180
181
|
blockName,
|
|
@@ -213,8 +214,9 @@ export async function addBlockToProject(
|
|
|
213
214
|
currentTime: opts.currentTime ?? 0,
|
|
214
215
|
});
|
|
215
216
|
const { width, height } = resolveTimelineAssetCompositionSize(originalContent);
|
|
217
|
+
const hostId = buildUniqueCompositionId(block.name, collectHtmlIds(originalContent));
|
|
216
218
|
const subComposition = buildSubCompositionHtml({
|
|
217
|
-
id:
|
|
219
|
+
id: hostId,
|
|
218
220
|
compositionFile,
|
|
219
221
|
start,
|
|
220
222
|
duration,
|
|
@@ -242,7 +244,11 @@ export async function addBlockToProject(
|
|
|
242
244
|
await refreshFileTree();
|
|
243
245
|
reloadPreview();
|
|
244
246
|
|
|
245
|
-
return {
|
|
247
|
+
return {
|
|
248
|
+
block,
|
|
249
|
+
compositionPath: compositionFile,
|
|
250
|
+
hostKey: deriveTimelineStoreKeyForDomId(hostId, targetPath),
|
|
251
|
+
};
|
|
246
252
|
} catch (error) {
|
|
247
253
|
const message = error instanceof Error ? error.message : "Failed to add block";
|
|
248
254
|
showToast(message);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { MutableRefObject } from "react";
|
|
2
|
+
|
|
3
|
+
export interface RetryBudgetState<K> {
|
|
4
|
+
id: K;
|
|
5
|
+
count: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// A target still resolving (a just-dropped/pasted/duplicated element, or a
|
|
9
|
+
// preview still reloading) heals within a few retries; one that never will
|
|
10
|
+
// would otherwise retry forever. A new id resets the count.
|
|
11
|
+
export function recordRetryAttempt<K>(
|
|
12
|
+
ref: MutableRefObject<RetryBudgetState<K>>,
|
|
13
|
+
id: K,
|
|
14
|
+
maxRetries: number,
|
|
15
|
+
): boolean {
|
|
16
|
+
const previous = ref.current;
|
|
17
|
+
const count = previous.id === id ? previous.count + 1 : 1;
|
|
18
|
+
ref.current = { id, count };
|
|
19
|
+
return count <= maxRetries;
|
|
20
|
+
}
|
|
@@ -41,6 +41,10 @@ describe("SDK family gate mapping", () => {
|
|
|
41
41
|
{} as never,
|
|
42
42
|
deps,
|
|
43
43
|
),
|
|
44
|
-
|
|
44
|
+
// The dom family is off, so the gate declines on the FLAG, not on the op
|
|
45
|
+
// batch (which is a perfectly eligible inline-style edit). Before the
|
|
46
|
+
// reason split this reported `ineligible_operation`, which read as "the
|
|
47
|
+
// SDK cannot express this edit" when the truth was "this family is off".
|
|
48
|
+
).resolves.toMatchObject({ status: "declined", reason: "feature_disabled" });
|
|
45
49
|
});
|
|
46
50
|
});
|
package/src/utils/sdkCutover.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { patchOpsToSdkEditOps } from "./sdkOpMapping";
|
|
|
7
7
|
import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow";
|
|
8
8
|
import {
|
|
9
9
|
isResolverDisagreement,
|
|
10
|
+
sdkCutoverIneligibleReason,
|
|
10
11
|
shouldDeclineTextCutoverForTarget,
|
|
11
|
-
shouldUseSdkCutover,
|
|
12
12
|
} from "./sdkCutoverEligibility";
|
|
13
13
|
import {
|
|
14
14
|
asCutoverError,
|
|
@@ -81,11 +81,14 @@ export async function sdkCutoverPersist(
|
|
|
81
81
|
deps: CutoverDeps,
|
|
82
82
|
options?: CutoverOptions,
|
|
83
83
|
): Promise<CutoverResult> {
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
// Name WHICH eligibility check failed: on v0.8.47 this one string covered 26 of
|
|
85
|
+
// 30 dom declines. The two below aren't properties of the batch, so they differ.
|
|
86
|
+
if (!sdkFamilyEnabled("dom")) return declinedCutover("feature_disabled", "dom");
|
|
86
87
|
if (!sdkSession) return declinedCutover("session_unavailable", "dom");
|
|
87
88
|
const hfId = selection.hfId;
|
|
88
|
-
|
|
89
|
+
const ineligible = sdkCutoverIneligibleReason(hfId, ops);
|
|
90
|
+
// `!hfId` is already a reason; testing it again narrows the type for the rest.
|
|
91
|
+
if (ineligible || !hfId) return declinedCutover(ineligible ?? "target_unaddressable", "dom");
|
|
89
92
|
const target = sdkSession.getElement(hfId);
|
|
90
93
|
if (!target)
|
|
91
94
|
return declinedCutover("target_not_found", "dom", isResolverDisagreement(sdkSession, hfId));
|