@hyperframes/studio 0.8.73 → 0.8.74
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-Bs9qG2hg.js +464 -0
- package/dist/assets/{index-CMFPPAGq.js → index-B86QTmD2.js} +1 -1
- package/dist/assets/{index-DxIHGgGZ.js → index-DhTyI9zH.js} +107 -107
- package/dist/assets/{index-DKInxDDv.js → index-vccPjvCl.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +1387 -1306
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/player/components/TimelineRuler.test.tsx +98 -0
- package/src/player/components/TimelineRuler.tsx +99 -2
- package/src/player/components/TimelineRulerPart.tsx +1 -0
- package/src/player/components/useTimelineScrollViewport.ts +4 -1
- package/dist/assets/hyperframes-player-BUauoSsb.js +0 -464
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.74",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -52,11 +52,11 @@
|
|
|
52
52
|
"dockview-react": "8.3.1",
|
|
53
53
|
"gsap": "^3.13.0",
|
|
54
54
|
"mediabunny": "^1.45.3",
|
|
55
|
-
"@hyperframes/core": "0.8.
|
|
56
|
-
"@hyperframes/
|
|
57
|
-
"@hyperframes/
|
|
58
|
-
"@hyperframes/studio-server": "0.8.
|
|
59
|
-
"@hyperframes/
|
|
55
|
+
"@hyperframes/core": "0.8.74",
|
|
56
|
+
"@hyperframes/parsers": "0.8.74",
|
|
57
|
+
"@hyperframes/sdk": "0.8.74",
|
|
58
|
+
"@hyperframes/studio-server": "0.8.74",
|
|
59
|
+
"@hyperframes/player": "0.8.74"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"vite": "^6.4.2",
|
|
73
73
|
"vitest": "^4.1.11",
|
|
74
74
|
"zustand": "^5.0.0",
|
|
75
|
-
"@hyperframes/producer": "0.8.
|
|
75
|
+
"@hyperframes/producer": "0.8.74"
|
|
76
76
|
},
|
|
77
77
|
"peerDependencies": {
|
|
78
78
|
"react": "19",
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { TimelineRuler } from "./TimelineRuler";
|
|
7
|
+
import { TIMELINE_SCROLL_SETTLE_MS } from "./useTimelineScrollViewport";
|
|
8
|
+
import type { TimelineTheme } from "./timelineTheme";
|
|
9
|
+
|
|
10
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.useRealTimers();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("TimelineRuler", () => {
|
|
17
|
+
it("masks the one tick label the track header would slice, once the scroll settles", () => {
|
|
18
|
+
vi.useFakeTimers();
|
|
19
|
+
const scroller = document.createElement("div");
|
|
20
|
+
let scrollLeft = 0;
|
|
21
|
+
Object.defineProperty(scroller, "scrollLeft", { get: () => scrollLeft });
|
|
22
|
+
const host = document.createElement("div");
|
|
23
|
+
scroller.append(host);
|
|
24
|
+
const root = createRoot(host);
|
|
25
|
+
act(() =>
|
|
26
|
+
root.render(
|
|
27
|
+
<TimelineRuler
|
|
28
|
+
major={[0, 10, 20]}
|
|
29
|
+
minor={[]}
|
|
30
|
+
pps={20}
|
|
31
|
+
trackContentWidth={600}
|
|
32
|
+
totalH={100}
|
|
33
|
+
effectiveDuration={30}
|
|
34
|
+
majorTickInterval={10}
|
|
35
|
+
theme={{} as TimelineTheme}
|
|
36
|
+
contentOrigin={80}
|
|
37
|
+
scrollRef={{ current: scroller }}
|
|
38
|
+
/>,
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
const mask = () =>
|
|
42
|
+
host.querySelector<HTMLElement>("[data-timeline-ruler-label-mask]")?.style.left ?? null;
|
|
43
|
+
const scrollTo = (left: number) =>
|
|
44
|
+
act(() => {
|
|
45
|
+
scrollLeft = left;
|
|
46
|
+
scroller.dispatchEvent(new Event("scroll"));
|
|
47
|
+
});
|
|
48
|
+
const settle = () => act(() => vi.advanceTimersByTime(TIMELINE_SCROLL_SETTLE_MS));
|
|
49
|
+
|
|
50
|
+
expect(mask()).toBeNull();
|
|
51
|
+
// The 10s label starts at 200 - 0.5 + 5 = 204.5 in ruler space.
|
|
52
|
+
scrollTo(204);
|
|
53
|
+
expect(mask()).toBeNull();
|
|
54
|
+
settle();
|
|
55
|
+
expect(mask()).toBe("4.5px");
|
|
56
|
+
scrollTo(205);
|
|
57
|
+
expect(mask()).toBeNull();
|
|
58
|
+
settle();
|
|
59
|
+
expect(mask()).toBe("204.5px");
|
|
60
|
+
act(() => root.unmount());
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("masks right away when the scroll element is swapped mid-scroll", () => {
|
|
64
|
+
vi.useFakeTimers();
|
|
65
|
+
const make = (left: number) => {
|
|
66
|
+
const el = document.createElement("div");
|
|
67
|
+
Object.defineProperty(el, "scrollLeft", { get: () => left });
|
|
68
|
+
return el;
|
|
69
|
+
};
|
|
70
|
+
const first = make(205);
|
|
71
|
+
const host = document.createElement("div");
|
|
72
|
+
const root = createRoot(host);
|
|
73
|
+
const render = (el: HTMLDivElement) =>
|
|
74
|
+
act(() =>
|
|
75
|
+
root.render(
|
|
76
|
+
<TimelineRuler
|
|
77
|
+
major={[0, 10, 20]}
|
|
78
|
+
minor={[]}
|
|
79
|
+
pps={20}
|
|
80
|
+
trackContentWidth={600}
|
|
81
|
+
totalH={100}
|
|
82
|
+
effectiveDuration={30}
|
|
83
|
+
majorTickInterval={10}
|
|
84
|
+
theme={{} as TimelineTheme}
|
|
85
|
+
contentOrigin={80}
|
|
86
|
+
scrollRef={{ current: el }}
|
|
87
|
+
/>,
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
render(first);
|
|
91
|
+
act(() => first.dispatchEvent(new Event("scroll")));
|
|
92
|
+
render(make(205));
|
|
93
|
+
expect(host.querySelector<HTMLElement>("[data-timeline-ruler-label-mask]")?.style.left).toBe(
|
|
94
|
+
"204.5px",
|
|
95
|
+
);
|
|
96
|
+
act(() => root.unmount());
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { memo } from "react";
|
|
1
|
+
import { memo, useCallback, useRef, useSyncExternalStore, type RefObject } from "react";
|
|
2
|
+
import { TIMELINE_SCROLL_SETTLE_MS } from "./useTimelineScrollViewport";
|
|
2
3
|
import type { TimelineTheme } from "./timelineTheme";
|
|
3
4
|
import { RULER_H, getTimelineBeatEntries } from "./timelineLayout";
|
|
4
5
|
import { formatTimelineTickLabel } from "./timelineRulerGeometry";
|
|
@@ -19,8 +20,95 @@ interface TimelineRulerProps {
|
|
|
19
20
|
beatAnalysis?: MusicBeatAnalysis | null;
|
|
20
21
|
contentOrigin: number;
|
|
21
22
|
renderTimeRange?: TimelineTimeRange;
|
|
23
|
+
scrollRef?: RefObject<HTMLDivElement | null>;
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
const TICK_LABEL_INSET_PX = 5;
|
|
27
|
+
|
|
28
|
+
// Index of the major tick whose label starts under the track-header corner, which
|
|
29
|
+
// covers ruler x < scrollLeft; -1 when none does.
|
|
30
|
+
function tickIndexUnderHeader(major: number[], pps: number, scrollLeft: number): number {
|
|
31
|
+
let lo = 0;
|
|
32
|
+
let hi = major.length - 1;
|
|
33
|
+
let found = -1;
|
|
34
|
+
while (lo <= hi) {
|
|
35
|
+
const mid = (lo + hi) >> 1;
|
|
36
|
+
if (major[mid]! * pps - 0.5 + TICK_LABEL_INSET_PX < scrollLeft) {
|
|
37
|
+
found = mid;
|
|
38
|
+
lo = mid + 1;
|
|
39
|
+
} else hi = mid - 1;
|
|
40
|
+
}
|
|
41
|
+
return found;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Paints over the one label the header corner would slice, once a scroll settles. Moving it
|
|
46
|
+
* on every tick crossing cost 2-3x the scroll budget on a large timeline, so mid-scroll a
|
|
47
|
+
* fragment can still show.
|
|
48
|
+
*/
|
|
49
|
+
const HeaderSlicedLabelMask = memo(function HeaderSlicedLabelMask({
|
|
50
|
+
scrollRef,
|
|
51
|
+
major,
|
|
52
|
+
pps,
|
|
53
|
+
background,
|
|
54
|
+
}: {
|
|
55
|
+
scrollRef: RefObject<HTMLDivElement | null>;
|
|
56
|
+
major: number[];
|
|
57
|
+
pps: number;
|
|
58
|
+
background: string;
|
|
59
|
+
}) {
|
|
60
|
+
const scrollingRef = useRef(false);
|
|
61
|
+
const subscribe = useCallback(
|
|
62
|
+
(onChange: () => void) => {
|
|
63
|
+
const el = scrollRef.current;
|
|
64
|
+
let settle: ReturnType<typeof setTimeout> | undefined;
|
|
65
|
+
const onScroll = () => {
|
|
66
|
+
if (!scrollingRef.current) {
|
|
67
|
+
scrollingRef.current = true;
|
|
68
|
+
onChange();
|
|
69
|
+
}
|
|
70
|
+
clearTimeout(settle);
|
|
71
|
+
settle = setTimeout(() => {
|
|
72
|
+
scrollingRef.current = false;
|
|
73
|
+
onChange();
|
|
74
|
+
}, TIMELINE_SCROLL_SETTLE_MS);
|
|
75
|
+
};
|
|
76
|
+
el?.addEventListener("scroll", onScroll, { passive: true });
|
|
77
|
+
return () => {
|
|
78
|
+
clearTimeout(settle);
|
|
79
|
+
scrollingRef.current = false;
|
|
80
|
+
el?.removeEventListener("scroll", onScroll);
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
[scrollRef],
|
|
84
|
+
);
|
|
85
|
+
const index = useSyncExternalStore(
|
|
86
|
+
subscribe,
|
|
87
|
+
() =>
|
|
88
|
+
scrollingRef.current
|
|
89
|
+
? -1
|
|
90
|
+
: tickIndexUnderHeader(major, pps, scrollRef.current?.scrollLeft ?? 0),
|
|
91
|
+
() => -1,
|
|
92
|
+
);
|
|
93
|
+
if (index < 0) return null;
|
|
94
|
+
const labelLeft = major[index]! * pps - 0.5 + TICK_LABEL_INSET_PX;
|
|
95
|
+
const next = major[index + 1];
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
data-timeline-ruler-label-mask=""
|
|
99
|
+
className="absolute pointer-events-none"
|
|
100
|
+
style={{
|
|
101
|
+
left: labelLeft,
|
|
102
|
+
top: 4,
|
|
103
|
+
height: 12,
|
|
104
|
+
// Up to the next label, so only this label's text band is covered.
|
|
105
|
+
width: next === undefined ? 80 : (next - major[index]!) * pps - TICK_LABEL_INSET_PX,
|
|
106
|
+
background,
|
|
107
|
+
}}
|
|
108
|
+
/>
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
24
112
|
export const TimelineRuler = memo(function TimelineRuler({
|
|
25
113
|
major,
|
|
26
114
|
minor,
|
|
@@ -33,6 +121,7 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
33
121
|
beatAnalysis,
|
|
34
122
|
contentOrigin,
|
|
35
123
|
renderTimeRange,
|
|
124
|
+
scrollRef,
|
|
36
125
|
}: TimelineRulerProps) {
|
|
37
126
|
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
|
38
127
|
const beatTimes = beatAnalysis?.beatTimes ?? [];
|
|
@@ -131,7 +220,7 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
131
220
|
className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
|
|
132
221
|
style={{
|
|
133
222
|
color: theme.tickText,
|
|
134
|
-
left:
|
|
223
|
+
left: TICK_LABEL_INSET_PX,
|
|
135
224
|
top: 5,
|
|
136
225
|
fontSize: 10,
|
|
137
226
|
}}
|
|
@@ -143,6 +232,14 @@ export const TimelineRuler = memo(function TimelineRuler({
|
|
|
143
232
|
<div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
|
|
144
233
|
</div>
|
|
145
234
|
))}
|
|
235
|
+
{scrollRef && (
|
|
236
|
+
<HeaderSlicedLabelMask
|
|
237
|
+
scrollRef={scrollRef}
|
|
238
|
+
major={major}
|
|
239
|
+
pps={pps}
|
|
240
|
+
background={theme.shellBackground}
|
|
241
|
+
/>
|
|
242
|
+
)}
|
|
146
243
|
</div>
|
|
147
244
|
</div>
|
|
148
245
|
</>
|
|
@@ -3,6 +3,9 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
|
|
3
3
|
import { shouldShowTimelineShortcutHint } from "./timelineLayout";
|
|
4
4
|
import { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } from "./timelineRowVirtualizationFlag";
|
|
5
5
|
|
|
6
|
+
/** How long the timeline waits after the last scroll event before treating the scroll as settled. */
|
|
7
|
+
export const TIMELINE_SCROLL_SETTLE_MS = 100;
|
|
8
|
+
|
|
6
9
|
export interface TimelineScrollViewportSnapshot {
|
|
7
10
|
readonly scrollLeft: number;
|
|
8
11
|
readonly scrollTop: number;
|
|
@@ -91,7 +94,7 @@ export function useTimelineScrollViewport(
|
|
|
91
94
|
scrollingRef.current = false;
|
|
92
95
|
if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current);
|
|
93
96
|
publish();
|
|
94
|
-
},
|
|
97
|
+
}, TIMELINE_SCROLL_SETTLE_MS);
|
|
95
98
|
}, []);
|
|
96
99
|
|
|
97
100
|
const syncShortcutHintVisibility = useCallback(() => {
|