@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.73",
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.73",
56
- "@hyperframes/sdk": "0.8.73",
57
- "@hyperframes/player": "0.8.73",
58
- "@hyperframes/studio-server": "0.8.73",
59
- "@hyperframes/parsers": "0.8.73"
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.73"
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: 5,
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
  </>
@@ -17,6 +17,7 @@ export function TimelineRulerPart() {
17
17
  beatAnalysis={props.beatAnalysis}
18
18
  contentOrigin={props.contentOrigin}
19
19
  renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
20
+ scrollRef={props.scrollRef}
20
21
  />
21
22
  );
22
23
  }
@@ -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
- }, 100);
97
+ }, TIMELINE_SCROLL_SETTLE_MS);
95
98
  }, []);
96
99
 
97
100
  const syncShortcutHintVisibility = useCallback(() => {