@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.
- package/dist/assets/{hyperframes-player-DJJKab4_.js → hyperframes-player-Biu5_W0l.js} +1 -1
- package/dist/assets/{index-DE1dn3QB.js → index-B5Z-1RW9.js} +1 -1
- package/dist/assets/{index-Cc4by8r7.js → index-BdV5T-QI.js} +200 -194
- package/dist/assets/{index-C1dXTZvs.js → index-CWuozokW.js} +1 -1
- package/dist/assets/{index-Dx79V1Se.css → index-xB5O0hPw.css} +1 -1
- package/dist/{chunk-QZ6HNNFG.js → chunk-6GAO75IU.js} +16 -4
- package/dist/chunk-6GAO75IU.js.map +1 -0
- package/dist/{chunk-PQFEGZWM.js → chunk-MTAVNAGW.js} +2 -2
- package/dist/chunk-MTAVNAGW.js.map +1 -0
- package/dist/{chunk-QPSOTK6M.js → chunk-U3EE4ACX.js} +5 -5
- package/dist/chunk-U3EE4ACX.js.map +1 -0
- package/dist/{domEditingLayers-3BAP6NNO.js → domEditingLayers-NU6Z5QWA.js} +2 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.html +2 -2
- package/dist/index.js +2036 -1809
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/dist/styles.css +1 -1
- package/dist/ui.d.ts +3 -1
- package/dist/ui.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolPicker.tsx +19 -15
- package/src/components/TimelineToolbar.test.tsx +62 -0
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/DomEditCropHandles.test.tsx +63 -0
- package/src/components/editor/DomEditCropHandles.tsx +7 -10
- package/src/components/editor/DomEditSelectionChrome.tsx +3 -3
- package/src/components/editor/domEditOverlayGestures.ts +16 -0
- package/src/components/editor/domEditing.test.ts +9 -1
- package/src/components/editor/domEditingDom.ts +2 -1
- package/src/components/editor/fontAssets.test.ts +69 -0
- package/src/components/editor/fontAssets.ts +11 -2
- package/src/components/editor/groupDragMove.ts +13 -6
- package/src/components/editor/manualOffsetDrag.test.ts +56 -0
- package/src/components/editor/manualOffsetDrag.ts +16 -9
- package/src/components/editor/shiftDragAxisLock.test.tsx +358 -0
- package/src/components/editor/snapEngine.ts +3 -2
- package/src/components/editor/useDomEditOverlayGestures.ts +24 -9
- package/src/components/ui/Menu.test.tsx +21 -0
- package/src/components/ui/Popover.tsx +21 -1
- package/src/hooks/domSelectionTimelineMirror.ts +5 -4
- package/src/hooks/domStyleCommit.fonts.test.ts +55 -0
- package/src/hooks/useAppHotkeys.test.ts +7 -5
- package/src/hooks/useClipboard.ts +25 -7
- package/src/hooks/useDomSelection.test.ts +56 -32
- package/src/hooks/useDomSelection.ts +4 -3
- package/src/player/components/BeatStrip.tsx +6 -3
- package/src/player/components/Timeline.readOnly.test.tsx +670 -0
- package/src/player/components/Timeline.rulerSeek.test.tsx +1 -25
- package/src/player/components/TimelineAutomationLane.test.tsx +23 -0
- package/src/player/components/TimelineClipDiamonds.tsx +7 -2
- package/src/player/components/TimelineFxButton.tsx +3 -0
- package/src/player/components/TimelineLanes.tsx +3 -2
- package/src/player/components/TimelineProvider.tsx +23 -7
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/studioShortcuts.ts +1 -1
- package/src/player/components/timelineClipGestureHandlers.ts +3 -1
- package/src/player/components/timelineDragDrop.ts +11 -1
- package/src/player/components/timelineEditCapabilities.ts +1 -0
- package/src/player/components/timelineMountTestEnv.ts +24 -0
- package/src/player/components/timelineReadOnly.ts +64 -0
- package/src/player/components/useAutomationLaneGestures.ts +13 -2
- package/src/player/components/useTimelineKeyframeHandlers.ts +7 -0
- package/src/player/components/useTimelineProviderState.tsx +42 -14
- package/src/player/hooks/useShadowPreviewReload.ts +2 -2
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +69 -0
- package/src/player/hooks/useTimelinePlayer.ts +6 -0
- package/src/player/lib/timelineDOM.test.ts +25 -0
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineIframeHelpers.test.ts +15 -0
- package/src/player/store/playerStore.ts +4 -4
- package/src/utils/clipboardLook.test.ts +130 -0
- package/src/utils/clipboardLook.ts +82 -0
- package/src/utils/clipboardPayload.ts +8 -2
- package/src/utils/studioFontHelpers.ts +6 -3
- package/dist/chunk-PQFEGZWM.js.map +0 -1
- package/dist/chunk-QPSOTK6M.js.map +0 -1
- package/dist/chunk-QZ6HNNFG.js.map +0 -1
- /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
|
|
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
|
|
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("
|
|
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
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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 {
|
|
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(
|
|
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: () =>
|
|
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
|
|
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
|
|
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(
|
|
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();
|