@hyperframes/studio 0.8.47 → 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-B9LfWNyM.css +1 -0
- package/dist/assets/{index-ryL7q1QG.js → index-BGovDksE.js} +1 -1
- package/dist/assets/{index-j-4hCrZU.js → index-Bgmx2h7W.js} +208 -208
- package/dist/assets/{index-B50xQfSG.js → index-mIgSsKqs.js} +1 -1
- package/dist/index.d.ts +70 -26
- package/dist/index.html +2 -2
- package/dist/index.js +1452 -918
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +17 -26
- package/src/components/AskAgentModal.tsx +9 -7
- package/src/components/TimelineToolbar.tsx +26 -1
- package/src/components/editor/PropertyPanelFlat.tsx +8 -8
- package/src/components/editor/domEditing.ts +7 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
- package/src/components/editor/domEditingAgentPrompt.ts +136 -34
- package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
- package/src/components/editor/propertyPanelAutomation.ts +15 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
- package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
- package/src/components/editor/useVolumeAutomation.ts +23 -2
- package/src/components/panels/VariablesPanel.tsx +5 -8
- package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
- package/src/components/sidebar/CompositionsTab.tsx +15 -0
- package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
- package/src/components/sidebar/LeftSidebar.tsx +7 -0
- package/src/contexts/FileManagerContext.tsx +0 -3
- package/src/hooks/appHotkeysDispatch.ts +256 -0
- package/src/hooks/domEditDeleteMembers.test.ts +30 -0
- package/src/hooks/domEditDeleteMembers.ts +19 -0
- package/src/hooks/useActiveComposition.ts +69 -0
- package/src/hooks/useAppHotkeys.test.ts +23 -1
- package/src/hooks/useAppHotkeys.ts +6 -239
- package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
- package/src/hooks/useAutoOpenRootComposition.ts +33 -0
- package/src/hooks/useBlockHandlers.ts +7 -2
- package/src/hooks/useDomEditCommits.ts +13 -15
- package/src/hooks/useDomEditSession.test.tsx +90 -1
- package/src/hooks/useDomEditSession.ts +31 -34
- package/src/hooks/useDomSelectionTypes.ts +7 -2
- package/src/hooks/useElementLifecycleOps.ts +0 -8
- package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
- package/src/hooks/useFileManager.ts +0 -2
- package/src/hooks/useFileTree.test.tsx +214 -0
- package/src/hooks/useFileTree.ts +66 -24
- package/src/hooks/useGroupCommits.test.ts +98 -0
- package/src/hooks/useGroupCommits.ts +66 -9
- package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
- package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
- package/src/hooks/useTimelineAssetDropOps.test.tsx +87 -0
- package/src/hooks/useTimelineAssetDropOps.ts +24 -18
- package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
- package/src/hooks/useTimelineDeleteOps.ts +103 -9
- package/src/hooks/useTimelineEditing.ts +1 -0
- package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
- package/src/hooks/useTimelineGroupEditing.ts +19 -5
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
- package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
- package/src/index.ts +10 -0
- package/src/player/components/Timeline.test.ts +0 -4
- package/src/player/components/Timeline.tsx +0 -1
- package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
- package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
- package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/automationLaneData.test.ts +11 -3
- package/src/player/components/automationLaneData.ts +5 -4
- package/src/player/components/automationLaneGeometry.ts +15 -0
- 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/timelineDragDrop.test.tsx +21 -4
- package/src/player/components/timelineDragDrop.ts +9 -25
- 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/timelineGapCommit.test.ts +96 -0
- package/src/player/components/timelineGapCommit.ts +56 -11
- package/src/player/components/timelineGaps.ts +2 -2
- package/src/player/components/timelineLayout.test.ts +0 -1
- package/src/player/components/timelineLayout.ts +1 -6
- package/src/player/components/timelineZones.test.ts +20 -1
- package/src/player/components/timelineZones.ts +10 -3
- package/src/player/components/useAutomationLaneGestures.ts +2 -10
- 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/components/useTimelineTrackLayout.ts +7 -6
- package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
- package/src/player/lib/timelineElementHelpers.ts +8 -0
- package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
- package/src/player/lib/timelineIframeHelpers.ts +1 -0
- package/src/player/store/playerStore.ts +16 -21
- 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/razorSplitTransaction.test.ts +10 -0
- package/src/utils/razorSplitTransaction.ts +5 -0
- package/src/utils/retryBudget.ts +20 -0
- package/src/utils/sdkCutover.familyGate.test.ts +5 -1
- package/src/utils/sdkCutover.test.ts +51 -0
- 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/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +41 -5
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- package/src/utils/studioUrlState.ts +15 -7
- package/src/utils/timelineCompositionInsert.ts +2 -1
- package/src/utils/timelineInspector.ts +10 -0
- package/dist/assets/hyperframes-player-B7fsB8C2.js +0 -464
- package/dist/assets/index-DnRfAiK2.css +0 -1
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
canShiftTrackGapClips,
|
|
6
6
|
commitCloseAllTrackGaps,
|
|
7
7
|
commitCloseTrackGap,
|
|
8
|
+
resolveMainTrackDeleteRippleShifts,
|
|
9
|
+
resolveShiftedElements,
|
|
8
10
|
} from "./timelineGapCommit";
|
|
9
11
|
import { resolveAllTrackGaps } from "./timelineGaps";
|
|
10
12
|
|
|
@@ -150,6 +152,25 @@ describe("commitCloseAllTrackGaps", () => {
|
|
|
150
152
|
});
|
|
151
153
|
});
|
|
152
154
|
|
|
155
|
+
describe("resolveShiftedElements", () => {
|
|
156
|
+
it("maps each shift to its element and new start", () => {
|
|
157
|
+
const elements = [el("a", 0, 2), el("c", 8, 1)];
|
|
158
|
+
const resolved = resolveShiftedElements(elements, [{ key: "c", newStart: 2 }]);
|
|
159
|
+
expect(resolved).toEqual([{ element: elements[1], start: 2 }]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("resolves by key when present, falling back to id", () => {
|
|
163
|
+
const withKey: TimelineElement = { ...el("a", 0, 2), key: "a-key" };
|
|
164
|
+
const resolved = resolveShiftedElements([withKey], [{ key: "a-key", newStart: 5 }]);
|
|
165
|
+
expect(resolved).toEqual([{ element: withKey, start: 5 }]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("drops a shift whose key matches no element", () => {
|
|
169
|
+
const elements = [el("a", 0, 2)];
|
|
170
|
+
expect(resolveShiftedElements(elements, [{ key: "ghost", newStart: 0 }])).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
153
174
|
describe("canShiftTrackGapClips", () => {
|
|
154
175
|
it("is true only when every named clip is movable", () => {
|
|
155
176
|
const lane = [el("a", 1, 2), lockedEl("b", 5, 3)];
|
|
@@ -161,3 +182,78 @@ describe("canShiftTrackGapClips", () => {
|
|
|
161
182
|
expect(canShiftTrackGapClips([el("a", 0, 1)], [{ key: "ghost", newStart: 0 }])).toBe(false);
|
|
162
183
|
});
|
|
163
184
|
});
|
|
185
|
+
|
|
186
|
+
describe("resolveMainTrackDeleteRippleShifts", () => {
|
|
187
|
+
it("closes the gap left by a deleted main-track clip", () => {
|
|
188
|
+
const deleted = [el("b", 2, 2)];
|
|
189
|
+
const survivors = [el("a", 0, 2), el("c", 8, 1)]; // "b" already removed from this set
|
|
190
|
+
// "c" shifts left by exactly "b"'s 2s duration (8 -> 6), not all the way
|
|
191
|
+
// to 2: the 4-6..8 span was already an open gap before the delete, and
|
|
192
|
+
// ripple must not also swallow it.
|
|
193
|
+
expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toEqual([
|
|
194
|
+
{ key: "c", newStart: 6 },
|
|
195
|
+
]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("does not also close a pre-existing gap the delete never touched", () => {
|
|
199
|
+
// a(0-2), b(2-4)[deleted], GAP(4-6), c(6-7): deleting b should slide c
|
|
200
|
+
// left by b's 2s only, landing at 4 — flush against a, the pre-existing
|
|
201
|
+
// gap collapsed only because it's now adjacent, not doubly-counted.
|
|
202
|
+
const deleted = [el("b", 2, 2)];
|
|
203
|
+
const survivors = [el("a", 0, 2), el("c", 6, 1)];
|
|
204
|
+
expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toEqual([
|
|
205
|
+
{ key: "c", newStart: 4 },
|
|
206
|
+
]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("leaves an untouched clip before the delete point exactly where it was", () => {
|
|
210
|
+
// a(0-2), GAP(2-5), b(5-7)[deleted]: nothing follows b, so nothing
|
|
211
|
+
// shifts — a keeps its own leading position, ripple included.
|
|
212
|
+
const deleted = [el("b", 5, 2)];
|
|
213
|
+
const survivors = [el("a", 0, 2)];
|
|
214
|
+
expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("returns null when ripple is off", () => {
|
|
218
|
+
const deleted = [el("b", 2, 2)];
|
|
219
|
+
const survivors = [el("a", 0, 2), el("c", 8, 1)];
|
|
220
|
+
expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, false)).toBeNull();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("returns null when nothing deleted was on the main track", () => {
|
|
224
|
+
const deletedOverlay = [el("o", 2, 2, 1)]; // track 1, not main
|
|
225
|
+
const survivors = [el("a", 0, 2), el("c", 8, 1)];
|
|
226
|
+
expect(resolveMainTrackDeleteRippleShifts(survivors, deletedOverlay, true)).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("returns null when the survivors are already gapless", () => {
|
|
230
|
+
const deleted = [el("b", 100, 1)]; // deleted from far off the end
|
|
231
|
+
const survivors = [el("a", 0, 2), el("c", 2, 1)]; // already contiguous
|
|
232
|
+
expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("refuses the whole ripple (not a partial one) when a shifting survivor is locked", () => {
|
|
236
|
+
const deleted = [el("b", 2, 2)];
|
|
237
|
+
const survivors = [el("a", 0, 2), lockedEl("c", 8, 1)];
|
|
238
|
+
expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("ignores audio and non-main-track survivors even when a main-track clip was deleted", () => {
|
|
242
|
+
const deleted = [el("b", 2, 2)];
|
|
243
|
+
const survivors = [el("a", 0, 2), el("c", 8, 1), el("overlay", 3, 1, 1)];
|
|
244
|
+
const shifts = resolveMainTrackDeleteRippleShifts(survivors, deleted, true);
|
|
245
|
+
expect(shifts).toEqual([{ key: "c", newStart: 6 }]); // "overlay" (track 1) untouched
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("sums only the deleted spans that precede each survivor, for a multi-select delete", () => {
|
|
249
|
+
// a(0-2), b(2-4)[deleted], c(4-6), d(6-8)[deleted], e(8-9): a marquee
|
|
250
|
+
// delete of b and d in one batch. "c" sits before d, so it shifts by
|
|
251
|
+
// b's 2s alone; "e" sits after both, so it shifts by their 4s combined.
|
|
252
|
+
const deleted = [el("b", 2, 2), el("d", 6, 2)];
|
|
253
|
+
const survivors = [el("a", 0, 2), el("c", 4, 2), el("e", 8, 1)];
|
|
254
|
+
expect(resolveMainTrackDeleteRippleShifts(survivors, deleted, true)).toEqual([
|
|
255
|
+
{ key: "c", newStart: 2 },
|
|
256
|
+
{ key: "e", newStart: 4 },
|
|
257
|
+
]);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -10,8 +10,11 @@ import {
|
|
|
10
10
|
resolveAllTrackGaps,
|
|
11
11
|
resolveCloseGapShifts,
|
|
12
12
|
resolveTrackGapAt,
|
|
13
|
+
round3,
|
|
14
|
+
TRACK_GAP_EPSILON_S,
|
|
13
15
|
type TrackGapShift,
|
|
14
16
|
} from "./timelineGaps";
|
|
17
|
+
import { isMainTrackElement } from "./timelineZones";
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
20
|
* Commit layer for the track-gap context menu ("Close gap" / "Close all gaps").
|
|
@@ -35,16 +38,29 @@ const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
|
35
38
|
// timelineClipDragCommit.ts.
|
|
36
39
|
let gapCloseGestureSeq = 0;
|
|
37
40
|
|
|
38
|
-
/**
|
|
41
|
+
/** Resolve each shift to its named element, dropping any shift whose key
|
|
42
|
+
* matches nothing — the type stays honest without a non-null assertion. */
|
|
43
|
+
export function resolveShiftedElements(
|
|
44
|
+
elements: readonly TimelineElement[],
|
|
45
|
+
shifts: readonly TrackGapShift[],
|
|
46
|
+
): Array<{ element: TimelineElement; start: number }> {
|
|
47
|
+
const byKey = new Map(elements.map((e) => [keyOf(e), e]));
|
|
48
|
+
return shifts.flatMap((s) => {
|
|
49
|
+
const element = byKey.get(s.key);
|
|
50
|
+
return element ? [{ element, start: s.newStart }] : [];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True when every shift names a real clip that may be time-moved. */
|
|
39
55
|
export function canShiftTrackGapClips(
|
|
40
56
|
laneElements: readonly TimelineElement[],
|
|
41
57
|
shifts: readonly TrackGapShift[],
|
|
42
58
|
): boolean {
|
|
43
|
-
const
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
59
|
+
const resolved = resolveShiftedElements(laneElements, shifts);
|
|
60
|
+
return (
|
|
61
|
+
resolved.length === shifts.length &&
|
|
62
|
+
resolved.every((r) => getTimelineEditCapabilities(r.element).canMove)
|
|
63
|
+
);
|
|
48
64
|
}
|
|
49
65
|
|
|
50
66
|
function buildShiftEdits(
|
|
@@ -52,11 +68,10 @@ function buildShiftEdits(
|
|
|
52
68
|
shifts: readonly TrackGapShift[],
|
|
53
69
|
): TimelineMoveEdit[] | null {
|
|
54
70
|
if (shifts.length === 0 || !canShiftTrackGapClips(laneElements, shifts)) return null;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
});
|
|
71
|
+
return resolveShiftedElements(laneElements, shifts).map(({ element, start }) => ({
|
|
72
|
+
element,
|
|
73
|
+
updates: { start, track: element.track },
|
|
74
|
+
}));
|
|
60
75
|
}
|
|
61
76
|
|
|
62
77
|
function commitShifts(
|
|
@@ -101,3 +116,33 @@ export function commitCloseAllTrackGaps(
|
|
|
101
116
|
deps,
|
|
102
117
|
);
|
|
103
118
|
}
|
|
119
|
+
|
|
120
|
+
/** What a ripple-edit delete must additionally shift on the main track. Pure
|
|
121
|
+
* — no IO; the caller persists the shifts itself. Null means nothing to
|
|
122
|
+
* ripple: off, nothing deleted was on the main track, already gapless, or a
|
|
123
|
+
* shifting clip is locked (same whole-action refusal as the gap-close menu). */
|
|
124
|
+
export function resolveMainTrackDeleteRippleShifts(
|
|
125
|
+
survivingElements: readonly TimelineElement[],
|
|
126
|
+
deletedElements: readonly TimelineElement[],
|
|
127
|
+
rippleEnabled: boolean,
|
|
128
|
+
): TrackGapShift[] | null {
|
|
129
|
+
const deletedMainTrack = deletedElements.filter(isMainTrackElement);
|
|
130
|
+
if (!rippleEnabled || deletedMainTrack.length === 0) return null;
|
|
131
|
+
const survivors = survivingElements.filter(isMainTrackElement);
|
|
132
|
+
// Shift each survivor left by the duration of the deleted clips before it —
|
|
133
|
+
// the removed span itself, not a gap re-resolved on the survivors, which
|
|
134
|
+
// can't tell an untouched adjacent gap from the one just opened.
|
|
135
|
+
const shifts: TrackGapShift[] = [];
|
|
136
|
+
for (const survivor of survivors) {
|
|
137
|
+
const width = deletedMainTrack
|
|
138
|
+
.filter((d) => d.start < survivor.start)
|
|
139
|
+
.reduce((sum, d) => sum + d.duration, 0);
|
|
140
|
+
if (width <= 0) continue;
|
|
141
|
+
const newStart = round3(Math.max(laneGapFloor(survivors), survivor.start - width));
|
|
142
|
+
if (Math.abs(newStart - survivor.start) > TRACK_GAP_EPSILON_S) {
|
|
143
|
+
shifts.push({ key: keyOf(survivor), newStart });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (shifts.length === 0 || !canShiftTrackGapClips(survivors, shifts)) return null;
|
|
147
|
+
return shifts;
|
|
148
|
+
}
|
|
@@ -13,7 +13,7 @@ import type { TimelineElement } from "../store/playerStore";
|
|
|
13
13
|
* - Computed starts are rounded to millisecond precision, matching the drag
|
|
14
14
|
* commit's `round3`.
|
|
15
15
|
*/
|
|
16
|
-
const TRACK_GAP_EPSILON_S = 1e-3;
|
|
16
|
+
export const TRACK_GAP_EPSILON_S = 1e-3;
|
|
17
17
|
|
|
18
18
|
const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
19
19
|
|
|
@@ -28,7 +28,7 @@ const keyOf = (e: TimelineElement) => e.key ?? e.id;
|
|
|
28
28
|
export function laneGapFloor(elements: readonly TimelineElement[]): number {
|
|
29
29
|
return Math.max(0, ...elements.map((e) => e.expandedParentStart ?? 0));
|
|
30
30
|
}
|
|
31
|
-
const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
|
31
|
+
export const round3 = (v: number) => Math.round(v * 1000) / 1000;
|
|
32
32
|
const endOf = (e: TimelineElement) => e.start + e.duration;
|
|
33
33
|
|
|
34
34
|
/** Lane clips sorted by start (key as a deterministic tie-break). */
|
|
@@ -568,8 +568,6 @@ export function resolveTimelineAssetDrop(
|
|
|
568
568
|
scrollTop: number;
|
|
569
569
|
contentOrigin: number;
|
|
570
570
|
pixelsPerSecond: number;
|
|
571
|
-
duration: number;
|
|
572
|
-
clampStartToDuration?: boolean;
|
|
573
571
|
rowHeights?: readonly number[];
|
|
574
572
|
trackOrder: number[];
|
|
575
573
|
},
|
|
@@ -584,10 +582,7 @@ export function resolveTimelineAssetDrop(
|
|
|
584
582
|
});
|
|
585
583
|
const contentY = clientY - input.rectTop + input.scrollTop;
|
|
586
584
|
const pointerStart = Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100;
|
|
587
|
-
const start = Math.max(
|
|
588
|
-
0,
|
|
589
|
-
input.clampStartToDuration === false ? pointerStart : Math.min(input.duration, pointerStart),
|
|
590
|
-
);
|
|
585
|
+
const start = Math.max(0, pointerStart);
|
|
591
586
|
// Row from the shared row→y inverse so the top pad is honoured; a drop in the
|
|
592
587
|
// pad above the first lane floors to row 0, a drop in the bottom pad rounds
|
|
593
588
|
// past the last lane (getDefaultDroppedTrack then appends a new track).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
-
import { classifyZone, normalizeToZones } from "./timelineZones";
|
|
3
|
+
import { classifyZone, isMainTrackElement, normalizeToZones } from "./timelineZones";
|
|
4
4
|
|
|
5
5
|
function el(id: string, tag: string, track: number, duration = 2): TimelineElement {
|
|
6
6
|
return { id, tag, start: 0, duration, track };
|
|
@@ -55,6 +55,25 @@ describe("classifyZone", () => {
|
|
|
55
55
|
});
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
describe("isMainTrackElement", () => {
|
|
59
|
+
it("is true for a visual clip on display track 0", () => {
|
|
60
|
+
expect(isMainTrackElement(el("v", "video", 0))).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("is false for a visual clip on any other track", () => {
|
|
64
|
+
expect(isMainTrackElement(el("v", "video", 1))).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("is false for an audio clip even on track 0 (audio-only project has no main track)", () => {
|
|
68
|
+
expect(isMainTrackElement(el("m", "audio", 0))).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("is false for an inline-expanded sub-composition child on track 0", () => {
|
|
72
|
+
const child: TimelineElement = { ...el("c", "video", 0), expandedParentStart: 4 };
|
|
73
|
+
expect(isMainTrackElement(child)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
58
77
|
describe("normalizeToZones — CapCut-stable lanes follow the track-index (never z)", () => {
|
|
59
78
|
it("orders visual lanes by authored track-index (ascending), audio at the bottom", () => {
|
|
60
79
|
// img (track 0), vid (track 2), mus (audio, track 5). Lanes follow the track
|
|
@@ -2,9 +2,9 @@ import type { TimelineElement } from "../store/playerStore";
|
|
|
2
2
|
import { isAudioTimelineElement } from "../../utils/timelineInspector";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Free-form vertical zones, top → bottom: visual, audio.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Free-form vertical zones, top → bottom: visual, audio. Canvas layering is
|
|
6
|
+
* CSS z-index (the renderer ignores track index), so a track's only structural
|
|
7
|
+
* job is to keep visual clips grouped above audio clips.
|
|
8
8
|
*/
|
|
9
9
|
export type TrackZone = "visual" | "audio";
|
|
10
10
|
|
|
@@ -14,6 +14,13 @@ export function classifyZone(el: TimelineElement): TrackZone {
|
|
|
14
14
|
return isAudioTimelineElement(el) ? "audio" : "visual";
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** The "main track" is a convention, not a schema field: the first
|
|
18
|
+
* visual-zone display lane, matched only when it actually holds a visual
|
|
19
|
+
* clip and isn't an inline-expanded sub-composition child. */
|
|
20
|
+
export function isMainTrackElement(el: TimelineElement): boolean {
|
|
21
|
+
return el.track === 0 && classifyZone(el) === "visual" && el.expandedParentStart == null;
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
const keyOf = (el: TimelineElement) => el.key ?? el.id;
|
|
18
25
|
|
|
19
26
|
const EPS = 1e-6;
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
|
15
15
|
import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
|
|
16
|
-
import { curveForDrag, formatValue, GRAB_PX,
|
|
16
|
+
import { curveForDrag, formatValue, GRAB_PX, mergeInsertPoint } from "./automationLaneGeometry";
|
|
17
17
|
import { pointInSelection } from "./automationLaneSelection";
|
|
18
18
|
import { capturePointer } from "./automationLanePointer";
|
|
19
19
|
import { useAutomationEdgeStretch } from "./useAutomationEdgeStretch";
|
|
@@ -548,15 +548,7 @@ export function useAutomationLaneGestures({
|
|
|
548
548
|
return;
|
|
549
549
|
}
|
|
550
550
|
const { t, v } = pointAt(e.clientX, e.clientY);
|
|
551
|
-
|
|
552
|
-
// A lane's first point alone would be a constant, which is not what
|
|
553
|
-
// clicking an empty lane means: seed the far end at the same value so the
|
|
554
|
-
// envelope has somewhere to go.
|
|
555
|
-
const seeded = lane.points.length === 0 && t > POINT_MERGE_SEC ? [{ t: 0, v }] : [];
|
|
556
|
-
commitPoints(
|
|
557
|
-
[...seeded, ...kept, { t, v }].sort((a, b) => a.t - b.t),
|
|
558
|
-
true,
|
|
559
|
-
);
|
|
551
|
+
commitPoints(mergeInsertPoint(lane.points, t, v), true);
|
|
560
552
|
},
|
|
561
553
|
[lane, pointAt, commitPoints, readOnly, hitIndex, segmentIndex],
|
|
562
554
|
);
|
|
@@ -35,12 +35,16 @@ function Harness({
|
|
|
35
35
|
mountedId,
|
|
36
36
|
logicalRows = rows,
|
|
37
37
|
projectId = "project-a",
|
|
38
|
+
lastScrollLeftRef: sharedLastScrollLeftRef,
|
|
38
39
|
}: {
|
|
39
40
|
mountedId?: string;
|
|
40
41
|
logicalRows?: readonly TimelineLogicalRow[];
|
|
41
42
|
projectId?: string;
|
|
43
|
+
lastScrollLeftRef?: React.RefObject<number>;
|
|
42
44
|
}) {
|
|
43
45
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
46
|
+
const ownLastScrollLeftRef = useRef(0);
|
|
47
|
+
const lastScrollLeftRef = sharedLastScrollLeftRef ?? ownLastScrollLeftRef;
|
|
44
48
|
const rowGeometry = useMemo(() => {
|
|
45
49
|
const rowKeys = [...new Set(logicalRows.map((row) => row.physicalTrackKey))];
|
|
46
50
|
return createTimelineRowGeometry(
|
|
@@ -60,6 +64,7 @@ function Harness({
|
|
|
60
64
|
projectId,
|
|
61
65
|
sessionEpoch: 1,
|
|
62
66
|
syncScrollViewport,
|
|
67
|
+
lastScrollLeftRef,
|
|
63
68
|
});
|
|
64
69
|
return (
|
|
65
70
|
<div
|
|
@@ -171,4 +176,65 @@ describe("useTimelineFocusCoordinator", () => {
|
|
|
171
176
|
expect(usePlayerStore.getState().timelineFocus?.id).toBe(nextRowId);
|
|
172
177
|
expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(nextRowId);
|
|
173
178
|
});
|
|
179
|
+
|
|
180
|
+
it("keeps a request alive across a few renders where its target hasn't landed yet", async () => {
|
|
181
|
+
// A request issued the same tick as its own element's creation (a fresh
|
|
182
|
+
// drop) can race logicalRows by a render or two; it must not be dropped
|
|
183
|
+
// before the element actually appears.
|
|
184
|
+
usePlayerStore.getState().requestTimelineFocus(clipId);
|
|
185
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
186
|
+
const notYetRows: readonly TimelineLogicalRow[] = [{ ...rows[0]!, items: [] }];
|
|
187
|
+
await act(async () => root.render(<Harness logicalRows={notYetRows} />));
|
|
188
|
+
}
|
|
189
|
+
expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
|
|
190
|
+
|
|
191
|
+
await act(async () => root.render(<Harness logicalRows={rows} mountedId={clipId} />));
|
|
192
|
+
expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
|
|
193
|
+
expect(document.activeElement?.getAttribute("data-timeline-focus-id")).toBe(clipId);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("gives up once a target never lands after its retry budget", async () => {
|
|
197
|
+
usePlayerStore.getState().requestTimelineFocus(clipId);
|
|
198
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
199
|
+
// A fresh array reference each render, like a real re-render caused by
|
|
200
|
+
// unrelated state, or the memoized resolution never recomputes at all.
|
|
201
|
+
const neverRows: readonly TimelineLogicalRow[] = [{ ...rows[0]!, items: [] }];
|
|
202
|
+
await act(async () => root.render(<Harness logicalRows={neverRows} />));
|
|
203
|
+
}
|
|
204
|
+
expect(usePlayerStore.getState().timelineFocus).toBeNull();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("clears an unresolved request on a timeout even if nothing else ever re-renders", async () => {
|
|
208
|
+
// The render-count retry budget can't advance without a rerun; if the
|
|
209
|
+
// target never lands and nothing else re-renders, only a wall-time
|
|
210
|
+
// backstop clears it — otherwise it sits forever, eligible to resolve
|
|
211
|
+
// against an unrelated element that later reuses its id.
|
|
212
|
+
vi.useFakeTimers();
|
|
213
|
+
try {
|
|
214
|
+
usePlayerStore.getState().requestTimelineFocus(clipId);
|
|
215
|
+
const neverRows: readonly TimelineLogicalRow[] = [{ ...rows[0]!, items: [] }];
|
|
216
|
+
await act(async () => root.render(<Harness logicalRows={neverRows} />));
|
|
217
|
+
expect(usePlayerStore.getState().timelineFocus?.id).toBe(clipId);
|
|
218
|
+
|
|
219
|
+
await act(async () => {
|
|
220
|
+
vi.advanceTimersByTime(4001);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(usePlayerStore.getState().timelineFocus).toBeNull();
|
|
224
|
+
} finally {
|
|
225
|
+
vi.useRealTimers();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("keeps lastScrollLeftRef in sync with a reveal so a sibling restore effect doesn't undo it", async () => {
|
|
230
|
+
// Timeline.tsx's own "restore scroll after an edit re-derives the elements"
|
|
231
|
+
// effect reads this ref to decide what to scroll back to. A reveal that
|
|
232
|
+
// doesn't update it looks, to that effect, like scroll drift to undo.
|
|
233
|
+
const lastScrollLeftRef = { current: 999 };
|
|
234
|
+
usePlayerStore.getState().requestTimelineFocus(clipId);
|
|
235
|
+
await act(async () => root.render(<Harness lastScrollLeftRef={lastScrollLeftRef} />));
|
|
236
|
+
const scroll = host.firstElementChild as HTMLDivElement;
|
|
237
|
+
|
|
238
|
+
expect(lastScrollLeftRef.current).toBe(scroll.scrollLeft);
|
|
239
|
+
});
|
|
174
240
|
});
|
|
@@ -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
|
+
});
|