@hyperframes/studio 0.8.95 → 0.8.97

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.
Files changed (79) hide show
  1. package/dist/assets/{hyperframes-player-DJJKab4_.js → hyperframes-player-Biu5_W0l.js} +1 -1
  2. package/dist/assets/{index-DE1dn3QB.js → index-B5Z-1RW9.js} +1 -1
  3. package/dist/assets/{index-Cc4by8r7.js → index-BdV5T-QI.js} +200 -194
  4. package/dist/assets/{index-C1dXTZvs.js → index-CWuozokW.js} +1 -1
  5. package/dist/assets/{index-Dx79V1Se.css → index-xB5O0hPw.css} +1 -1
  6. package/dist/{chunk-QZ6HNNFG.js → chunk-6GAO75IU.js} +16 -4
  7. package/dist/chunk-6GAO75IU.js.map +1 -0
  8. package/dist/{chunk-PQFEGZWM.js → chunk-MTAVNAGW.js} +2 -2
  9. package/dist/chunk-MTAVNAGW.js.map +1 -0
  10. package/dist/{chunk-QPSOTK6M.js → chunk-U3EE4ACX.js} +5 -5
  11. package/dist/chunk-U3EE4ACX.js.map +1 -0
  12. package/dist/{domEditingLayers-3BAP6NNO.js → domEditingLayers-NU6Z5QWA.js} +2 -2
  13. package/dist/index.d.ts +8 -2
  14. package/dist/index.html +2 -2
  15. package/dist/index.js +2036 -1809
  16. package/dist/index.js.map +1 -1
  17. package/dist/player.js +2 -2
  18. package/dist/styles.css +1 -1
  19. package/dist/ui.d.ts +3 -1
  20. package/dist/ui.js +1 -1
  21. package/package.json +8 -8
  22. package/src/components/TimelineToolPicker.tsx +19 -15
  23. package/src/components/TimelineToolbar.test.tsx +62 -0
  24. package/src/components/TimelineToolbar.tsx +3 -1
  25. package/src/components/editor/DomEditCropHandles.test.tsx +63 -0
  26. package/src/components/editor/DomEditCropHandles.tsx +7 -10
  27. package/src/components/editor/DomEditSelectionChrome.tsx +3 -3
  28. package/src/components/editor/domEditOverlayGestures.ts +16 -0
  29. package/src/components/editor/domEditing.test.ts +9 -1
  30. package/src/components/editor/domEditingDom.ts +2 -1
  31. package/src/components/editor/fontAssets.test.ts +69 -0
  32. package/src/components/editor/fontAssets.ts +11 -2
  33. package/src/components/editor/groupDragMove.ts +13 -6
  34. package/src/components/editor/manualOffsetDrag.test.ts +56 -0
  35. package/src/components/editor/manualOffsetDrag.ts +16 -9
  36. package/src/components/editor/shiftDragAxisLock.test.tsx +358 -0
  37. package/src/components/editor/snapEngine.ts +3 -2
  38. package/src/components/editor/useDomEditOverlayGestures.ts +24 -9
  39. package/src/components/ui/Menu.test.tsx +21 -0
  40. package/src/components/ui/Popover.tsx +21 -1
  41. package/src/hooks/domSelectionTimelineMirror.ts +5 -4
  42. package/src/hooks/domStyleCommit.fonts.test.ts +55 -0
  43. package/src/hooks/useAppHotkeys.test.ts +7 -5
  44. package/src/hooks/useClipboard.ts +25 -7
  45. package/src/hooks/useDomSelection.test.ts +56 -32
  46. package/src/hooks/useDomSelection.ts +4 -3
  47. package/src/player/components/BeatStrip.tsx +6 -3
  48. package/src/player/components/Timeline.readOnly.test.tsx +670 -0
  49. package/src/player/components/Timeline.rulerSeek.test.tsx +1 -25
  50. package/src/player/components/TimelineAutomationLane.test.tsx +23 -0
  51. package/src/player/components/TimelineClipDiamonds.tsx +7 -2
  52. package/src/player/components/TimelineFxButton.tsx +3 -0
  53. package/src/player/components/TimelineLanes.tsx +3 -2
  54. package/src/player/components/TimelineProvider.tsx +23 -7
  55. package/src/player/components/TimelineTypes.ts +2 -0
  56. package/src/player/components/studioShortcuts.ts +1 -1
  57. package/src/player/components/timelineClipGestureHandlers.ts +3 -1
  58. package/src/player/components/timelineDragDrop.ts +11 -1
  59. package/src/player/components/timelineEditCapabilities.ts +1 -0
  60. package/src/player/components/timelineMountTestEnv.ts +24 -0
  61. package/src/player/components/timelineReadOnly.ts +64 -0
  62. package/src/player/components/useAutomationLaneGestures.ts +13 -2
  63. package/src/player/components/useTimelineKeyframeHandlers.ts +7 -0
  64. package/src/player/components/useTimelineProviderState.tsx +42 -14
  65. package/src/player/hooks/useShadowPreviewReload.ts +2 -2
  66. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +69 -0
  67. package/src/player/hooks/useTimelinePlayer.ts +6 -0
  68. package/src/player/lib/timelineDOM.test.ts +25 -0
  69. package/src/player/lib/timelineDOM.ts +4 -2
  70. package/src/player/lib/timelineIframeHelpers.test.ts +15 -0
  71. package/src/player/store/playerStore.ts +4 -4
  72. package/src/utils/clipboardLook.test.ts +130 -0
  73. package/src/utils/clipboardLook.ts +82 -0
  74. package/src/utils/clipboardPayload.ts +8 -2
  75. package/src/utils/studioFontHelpers.ts +6 -3
  76. package/dist/chunk-PQFEGZWM.js.map +0 -1
  77. package/dist/chunk-QPSOTK6M.js.map +0 -1
  78. package/dist/chunk-QZ6HNNFG.js.map +0 -1
  79. /package/dist/{domEditingLayers-3BAP6NNO.js.map → domEditingLayers-NU6Z5QWA.js.map} +0 -0
@@ -0,0 +1,55 @@
1
+ // @vitest-environment jsdom
2
+ import { expect, it } from "vitest";
3
+ import type { DomEditSelection } from "../components/editor/domEditing";
4
+ import type { ImportedFontAsset } from "../components/editor/fontAssets";
5
+ import { commitDomStyles, type DomStyleCommitContext } from "./domStyleCommit";
6
+
7
+ const selection = (id: string): DomEditSelection =>
8
+ ({
9
+ id,
10
+ selector: `#${id}`,
11
+ label: id,
12
+ tagName: "p",
13
+ sourceFile: "index.html",
14
+ compositionPath: "index.html",
15
+ textFields: [],
16
+ inlineStyles: {},
17
+ computedStyles: {},
18
+ dataAttributes: {},
19
+ capabilities: { canSelect: true, canEditStyles: true },
20
+ }) as unknown as DomEditSelection;
21
+
22
+ const poppins = (file: string, weight: string): ImportedFontAsset => ({
23
+ family: "Poppins",
24
+ path: `assets/fonts/Poppins/${file}`,
25
+ url: `/preview/assets/fonts/Poppins/${file}`,
26
+ weight,
27
+ style: "normal",
28
+ });
29
+
30
+ it("each font a host hands over is saved as its own face, with the weight and style it draws", async () => {
31
+ let html = '<html><head></head><body><h1 id="title"></h1><p id="subtitle"></p></body></html>';
32
+ let listed: ImportedFontAsset | null = null;
33
+ const context: DomStyleCommitContext = {
34
+ activeCompPath: "index.html",
35
+ previewIframeRef: { current: null },
36
+ showToast: () => undefined,
37
+ versions: new Map(),
38
+ resolveImportedFontAsset: () => listed,
39
+ persistDomEditOperations: (async (
40
+ _selection: DomEditSelection,
41
+ _operations: unknown,
42
+ options?: { prepareContent?: (html: string, sourceFile: string) => string },
43
+ ) => {
44
+ html = options?.prepareContent?.(html, "index.html") ?? html;
45
+ }) as unknown as DomStyleCommitContext["persistDomEditOperations"],
46
+ };
47
+ listed = poppins("Poppins-SemiBold.ttf", "600");
48
+ await commitDomStyles(context, selection("title"), { "font-family": "Poppins" });
49
+ listed = poppins("Poppins-Regular.ttf", "400");
50
+ await commitDomStyles(context, selection("subtitle"), { "font-family": "Poppins" });
51
+ const faces = html.match(/@font-face[^}]*}/g) ?? [];
52
+ expect(faces).toHaveLength(2);
53
+ expect(faces[0]).toContain('Poppins-SemiBold.ttf"); font-weight: 600; font-style: normal;');
54
+ expect(faces[1]).toContain('Poppins-Regular.ttf"); font-weight: 400; font-style: normal;');
55
+ });
@@ -65,13 +65,14 @@ describe("dispatchPlainKey — select leftward / rightward", () => {
65
65
  { ...bgmElement, id: "early", key: "early", start: 0, track: 0 },
66
66
  { ...bgmElement, id: "at", key: "at", start: 4, track: 1 },
67
67
  { ...bgmElement, id: "late", key: "late", start: 7, track: 2 },
68
+ { ...bgmElement, id: "gone", key: "gone", start: 0, duration: 2, track: 3 },
68
69
  ];
69
70
  beforeEach(() => usePlayerStore.setState({ elements: clips, currentTime: 4 }));
70
71
 
71
- it("[ selects every clip starting before the playhead, on every track", () => {
72
+ it("[ selects every clip that started before the playhead, crossing clips included", () => {
72
73
  const event = press("[");
73
74
  dispatchPlainKey(event, "[", callbacks());
74
- expect([...usePlayerStore.getState().selectedElementIds]).toEqual(["early"]);
75
+ expect([...usePlayerStore.getState().selectedElementIds].sort()).toEqual(["early", "gone"]);
75
76
  expect(usePlayerStore.getState().selectedElementId).toBe("early");
76
77
  expect(event.defaultPrevented).toBe(true);
77
78
  });
@@ -84,6 +85,7 @@ describe("dispatchPlainKey — select leftward / rightward", () => {
84
85
  expect([...usePlayerStore.getState().selectedElementIds].sort()).toEqual([
85
86
  "at",
86
87
  "early",
88
+ "gone",
87
89
  "late",
88
90
  ]);
89
91
  } finally {
@@ -98,11 +100,11 @@ describe("dispatchPlainKey — select leftward / rightward", () => {
98
100
  expect(usePlayerStore.getState().activeKeyframePct).toBeNull();
99
101
  });
100
102
 
101
- it("] selects every clip starting at or after the playhead, on every track", () => {
103
+ it("] selects every clip still running at or after the playhead, crossing clips included", () => {
102
104
  dispatchPlainKey(press("]"), "]", callbacks());
103
105
  const { selectedElementIds, selectedElementId } = usePlayerStore.getState();
104
- expect([...selectedElementIds].sort()).toEqual(["at", "late"]);
105
- expect(selectedElementId).toBe("at");
106
+ expect([...selectedElementIds].sort()).toEqual(["at", "early", "late"]);
107
+ expect(selectedElementId).toBe("early");
106
108
  });
107
109
 
108
110
  it("selects nothing when no clip is on that side", () => {
@@ -11,6 +11,7 @@ import {
11
11
  deduplicateIds,
12
12
  insertAsSibling,
13
13
  } from "../utils/clipboardPayload";
14
+ import { carryLook, renamedIds } from "../utils/clipboardLook";
14
15
  import { collectHtmlIds } from "../utils/studioHelpers";
15
16
  import { insertTimelineAssetIntoSource } from "../utils/timelineAssetDrop";
16
17
  import { extendRootDurationInSource } from "../utils/rootDuration";
@@ -165,15 +166,16 @@ export function pasteElementHtml(
165
166
  return insertAsSibling(content, deduped, payload.originSelector, payload.originSelectorIndex);
166
167
  }
167
168
 
168
- /** Shared insertion path for paste and duplicate, anchored at the playhead or
169
- * the selection's end respectively. Returns the final ids so the caller can
170
- * select what it just placed, and the furthest end any clip lands at so the
171
- * caller can grow the root composition's duration to cover it. */
169
+ /** Shared insertion path for paste and duplicate, anchored at the playhead or the selection's end. Returns the
170
+ * final ids so the caller can select what it placed, and the furthest end any clip lands at so it can grow the
171
+ * root duration to cover it. `fromThisFile`: the clips came from `content`, so a renamed copy takes its
172
+ * original's look. */
172
173
  export function pasteTimelineClips(
173
174
  content: string,
174
175
  clips: readonly TimelineClipboardClip[],
175
176
  anchorTime: number,
176
177
  liveElements: readonly TimelineElement[],
178
+ fromThisFile = false,
177
179
  ): { content: string; ids: string[]; requiredEnd: number } {
178
180
  const groupMinStart = Math.min(...clips.map((c) => c.start));
179
181
  let existingIds = collectHtmlIds(content);
@@ -209,6 +211,11 @@ export function pasteTimelineClips(
209
211
  .replace(/data-track-index="[^"]*"/, `data-track-index="${newTrack}"`);
210
212
  const withPatched = patchedRootTag + deduped.slice(rootTagEnd + 1);
211
213
  result = insertTimelineAssetIntoSource(result, withPatched);
214
+ if (fromThisFile) {
215
+ const authored = Number(rootTag.match(/data-start="([^"]*)"/)?.[1]);
216
+ const authoredStart = Number.isFinite(authored) ? authored : clip.start;
217
+ result = carryLook(result, renamedIds(reminted, deduped), newStart - authoredStart);
218
+ }
212
219
 
213
220
  const id = patchedRootTag.match(ID_ATTR_RE)?.[1];
214
221
  if (id) ids.push(id);
@@ -294,7 +301,12 @@ export function useClipboard({
294
301
  const sourceFile = targets.elements[0]?.sourceFile || activeCompPath || "index.html";
295
302
  return readClips(targets).then((clips) => {
296
303
  showToast(clips.length > 1 ? `Copied ${clips.length} clips` : "Copied clip", "info");
297
- return { kind: "timeline-clip", clips, sourceFile };
304
+ return {
305
+ kind: "timeline-clip",
306
+ clips,
307
+ sourceFile,
308
+ projectId: projectIdRef.current ?? undefined,
309
+ };
298
310
  });
299
311
  }, [activeCompPath, findSelectedClips, readClips, showToast]);
300
312
 
@@ -364,7 +376,13 @@ export function useClipboard({
364
376
  const paste = (originalContent: string) => {
365
377
  if (payload.kind !== "timeline-clip") return pasteElementHtml(originalContent, payload);
366
378
  const { currentTime, elements } = usePlayerStore.getState();
367
- const pasted = pasteTimelineClips(originalContent, payload.clips, currentTime, elements);
379
+ const pasted = pasteTimelineClips(
380
+ originalContent,
381
+ payload.clips,
382
+ currentTime,
383
+ elements,
384
+ payload.sourceFile === targetPath && payload.projectId === pid,
385
+ );
368
386
  pastedIds = pasted.ids;
369
387
  // A clip pasted past the current composition end would exist in the
370
388
  // file but never appear on the timeline or in playback/export (the
@@ -433,7 +451,7 @@ export function useClipboard({
433
451
  .getState()
434
452
  .elements.filter((el) => pathOf(el) === targetPath)
435
453
  .map((el) => ({ ...el, start: toAuthoredStart(el, el.start) }));
436
- const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
454
+ const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements, true);
437
455
  ids = pasted.ids;
438
456
  return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
439
457
  };
@@ -29,9 +29,13 @@ function renderHarness(
29
29
  cleanup: () => void;
30
30
  timeline: TimelineSpies;
31
31
  } {
32
+ // Reads back what was last published, as the timeline store does.
33
+ let publishedSet: ReadonlySet<string> = new Set();
32
34
  const timeline: TimelineSpies = {
33
35
  setSelectedTimelineElementId: vi.fn(),
34
- setTimelineSelectionSet: vi.fn(),
36
+ setTimelineSelectionSet: vi.fn((ids: Set<string>) => {
37
+ publishedSet = ids;
38
+ }),
35
39
  };
36
40
  const host = document.createElement("div");
37
41
  document.body.append(host);
@@ -47,7 +51,7 @@ function renderHarness(
47
51
  captionEditMode: false,
48
52
  previewIframeRef: { current: null },
49
53
  timelineElements: options.timelineElements ?? [],
50
- getTimelineSelectionSet: () => new Set(),
54
+ getTimelineSelectionSet: () => publishedSet,
51
55
  setSelectedTimelineElementId: timeline.setSelectedTimelineElementId,
52
56
  setTimelineSelectionSet: timeline.setTimelineSelectionSet,
53
57
  setRightCollapsed: vi.fn(),
@@ -107,6 +111,17 @@ function timelineElement(domId: string): TimelineElement {
107
111
  } as TimelineElement;
108
112
  }
109
113
 
114
+ function renderCardAndChip() {
115
+ const card = makeSelection("Card", Object.assign(document.createElement("div"), { id: "card" }));
116
+ const chip = makeSelection("Chip", Object.assign(document.createElement("div"), { id: "chip" }));
117
+ document.body.append(card.element, chip.element);
118
+ const harness = renderHarness(
119
+ { activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 },
120
+ { timelineElements: [timelineElement("card"), timelineElement("chip")] },
121
+ );
122
+ return { harness, card, chip };
123
+ }
124
+
110
125
  /**
111
126
  * A marquee builds the group correctly and then used to lose it: it announced only
112
127
  * the primary to the timeline, the timeline is the source of truth for what is
@@ -116,24 +131,9 @@ function timelineElement(domId: string): TimelineElement {
116
131
  */
117
132
  describe("useDomSelection marquee", () => {
118
133
  it("announces every marquee'd element to the timeline, anchored on the primary", () => {
119
- const first = document.createElement("div");
120
- first.id = "card";
121
- const second = document.createElement("div");
122
- second.id = "chip";
123
- document.body.append(first, second);
124
- const harness = renderHarness(
125
- { activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 },
126
- { timelineElements: [timelineElement("card"), timelineElement("chip")] },
127
- );
134
+ const { harness, card, chip } = renderCardAndChip();
128
135
 
129
- act(() =>
130
- harness
131
- .current()
132
- .applyMarqueeSelection(
133
- [makeSelection("Card", first), makeSelection("Chip", second)],
134
- false,
135
- ),
136
- );
136
+ act(() => harness.current().applyMarqueeSelection([card, chip], false));
137
137
 
138
138
  expect(harness.current().domEditGroupSelections).toHaveLength(2);
139
139
  expect(harness.timeline.setTimelineSelectionSet).toHaveBeenCalledWith(
@@ -181,20 +181,10 @@ describe("useDomSelection marquee", () => {
181
181
  */
182
182
  describe("useDomSelection additive", () => {
183
183
  it("announces both members when a second element joins the selection", () => {
184
- const first = document.createElement("div");
185
- first.id = "card";
186
- const second = document.createElement("div");
187
- second.id = "chip";
188
- document.body.append(first, second);
189
- const harness = renderHarness(
190
- { activeCompPath: "index.html", projectId: "project-1", refreshKey: 0 },
191
- { timelineElements: [timelineElement("card"), timelineElement("chip")] },
192
- );
184
+ const { harness, card, chip } = renderCardAndChip();
193
185
 
194
- act(() => harness.current().applyDomSelection(makeSelection("Card", first)));
195
- act(() =>
196
- harness.current().applyDomSelection(makeSelection("Chip", second), { additive: true }),
197
- );
186
+ act(() => harness.current().applyDomSelection(card));
187
+ act(() => harness.current().applyDomSelection(chip, { additive: true }));
198
188
 
199
189
  expect(harness.current().domEditGroupSelections).toHaveLength(2);
200
190
  expect(harness.timeline.setTimelineSelectionSet).toHaveBeenLastCalledWith(
@@ -205,6 +195,40 @@ describe("useDomSelection additive", () => {
205
195
  });
206
196
  harness.cleanup();
207
197
  });
198
+
199
+ // The timeline syncs its set back onto the canvas, so a set left at two re-adds the member.
200
+ it.each([
201
+ ["shift+click removes a member", "toggle"],
202
+ ["a marquee catches one member of a live group", "marquee"],
203
+ ] as const)("publishes the smaller set when %s", (_, shrink) => {
204
+ const { harness, card, chip } = renderCardAndChip();
205
+
206
+ act(() => harness.current().applyMarqueeSelection([card, chip], false));
207
+ act(() =>
208
+ shrink === "toggle"
209
+ ? harness.current().applyDomSelection(chip, { additive: true })
210
+ : harness.current().applyMarqueeSelection([card], false),
211
+ );
212
+
213
+ expect(harness.current().domEditGroupSelections).toHaveLength(1);
214
+ expect(harness.timeline.setTimelineSelectionSet).toHaveBeenLastCalledWith(new Set(["card"]));
215
+ expect(harness.timeline.setSelectedTimelineElementId).toHaveBeenLastCalledWith("card", {
216
+ preserveSet: true,
217
+ });
218
+ harness.cleanup();
219
+ });
220
+
221
+ it("keeps the live set when a single member is re-announced", () => {
222
+ const { harness, card, chip } = renderCardAndChip();
223
+
224
+ act(() => harness.current().applyMarqueeSelection([card, chip], false));
225
+ act(() => harness.current().applyDomSelection(chip));
226
+
227
+ expect(harness.timeline.setTimelineSelectionSet).toHaveBeenLastCalledWith(
228
+ new Set(["card", "chip"]),
229
+ );
230
+ harness.cleanup();
231
+ });
208
232
  });
209
233
 
210
234
  describe("useDomSelection", () => {
@@ -83,7 +83,7 @@ export function useDomSelection({
83
83
  // ── Callbacks ──
84
84
 
85
85
  const announceTimelineSelection = useCallback(
86
- (group: DomEditSelection[], primary: DomEditSelection | null) =>
86
+ (group: DomEditSelection[], primary: DomEditSelection | null, replaceSet?: boolean) =>
87
87
  announceSelectionToTimeline(
88
88
  {
89
89
  timelineElements,
@@ -93,6 +93,7 @@ export function useDomSelection({
93
93
  },
94
94
  group,
95
95
  primary,
96
+ replaceSet,
96
97
  ),
97
98
  [
98
99
  getTimelineSelectionSet,
@@ -179,7 +180,7 @@ export function useDomSelection({
179
180
  setRightPanelTab("design");
180
181
  }
181
182
  }
182
- announceTimelineSelection(nextGroup, nextSelection);
183
+ announceTimelineSelection(nextGroup, nextSelection, isAdditiveSelection);
183
184
  return;
184
185
  }
185
186
 
@@ -510,7 +511,7 @@ export function useDomSelection({
510
511
  domEditGroupSelectionsRef.current = nextGroup;
511
512
  setDomEditSelection(nextSelection);
512
513
  setDomEditGroupSelections(nextGroup);
513
- announceTimelineSelection(nextGroup, nextSelection);
514
+ announceTimelineSelection(nextGroup, nextSelection, true);
514
515
  },
515
516
  [applyDomSelection, announceTimelineSelection],
516
517
  );
@@ -12,6 +12,7 @@ import {
12
12
  resolveTimelineAutoScrollLoopAction,
13
13
  } from "./timelineEditing";
14
14
  import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
15
+ import { useTimelineReadOnlyPress } from "./timelineReadOnly";
15
16
 
16
17
  export const BEAT_BAND_H = 14; // dark band height at top of track
17
18
  const BEAT_HIT_W = 24; // grab width per beat (px) — ≥24px pointer target
@@ -88,7 +89,7 @@ function claimBeatDrag(pointerId?: number): BeatDragActor | null {
88
89
  return actor;
89
90
  }
90
91
 
91
- function cancelBeatDrag(): void {
92
+ export function cancelBeatDrag(): void {
92
93
  claimBeatDrag();
93
94
  }
94
95
 
@@ -377,6 +378,7 @@ export const BeatStrip = memo(function BeatStrip({
377
378
  );
378
379
  const sessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
379
380
  const projectId = usePlayerStore((state) => state.timelineProjectId);
381
+ const readOnlyPress = useTimelineReadOnlyPress();
380
382
 
381
383
  if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
382
384
  const activeBeatIndex = activeActor
@@ -420,7 +422,7 @@ export const BeatStrip = memo(function BeatStrip({
420
422
  top: 0,
421
423
  width: BEAT_HIT_W,
422
424
  height: BEAT_BAND_H,
423
- cursor: "ew-resize",
425
+ cursor: readOnlyPress ? "default" : "ew-resize",
424
426
  pointerEvents: "auto",
425
427
  touchAction: "none",
426
428
  }}
@@ -429,13 +431,14 @@ export const BeatStrip = memo(function BeatStrip({
429
431
  // selection (which otherwise "selects" the whole panel mid-drag).
430
432
  e.preventDefault();
431
433
  e.stopPropagation();
434
+ if (readOnlyPress) return readOnlyPress();
432
435
  // ⌥ starts no drag: the delete lands on release below, so a
433
436
  // slipped ⌥-drag abandons instead of destroying the beat.
434
437
  if (e.altKey) return;
435
438
  beginBeatDrag(e, t, pps);
436
439
  }}
437
440
  onClick={(e) => {
438
- if (!e.altKey) return;
441
+ if (!e.altKey || readOnlyPress) return;
439
442
  // ⌥-click deletes. Deliberately NOT double-click: a stuttered drag
440
443
  // attempt reads as a double-click and would destroy the beat.
441
444
  e.stopPropagation();