feat(studio): drag automation segments (#3465)

* feat(studio): drag automation segments

* fix(studio): clear clip selection for group effects

* fix(studio): replace clip selection with audio bus

* fix(studio): make audio bus selection authoritative
This commit is contained in:
Vance Ingalls
2026-08-24 12:44:05 -07:00
committed by GitHub
parent 21dc4aed0c
commit 7caf4b8871
12 changed files with 703 additions and 74 deletions
@@ -1,4 +1,5 @@
import type { SelectElementOptions, TimelineElement } from "../player";
import { HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
import { findMatchingTimelineElementId, findTimelineIdByAncestor } from "../utils/studioHelpers";
import type { DomEditSelection } from "../components/editor/domEditing";
import { logSelect } from "../utils/selectDebug";
@@ -60,9 +61,17 @@ export function announceTimelineSelection(
anchor,
anchorPublished: anchor != null && publishedMembers.has(anchor),
});
// A canvas target can be editable without owning a timeline row. Preserve that
// canvas-only selection when the timeline has nothing truthful to represent.
if (!timelineAnchor) return;
// A canvas target can be editable without owning a timeline row. Most such
// targets live inside a clip and must not erase its timeline context. A mixer
// bus is different: it is itself the editing target, so its title replaces any
// selected clips even though the bus has no clip row of its own.
if (!timelineAnchor) {
if (primary.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG) {
setTimelineSelectionSet(new Set());
setSelectedTimelineElementId(null);
}
return;
}
// A late async primary that already belongs to the live set must preserve the
// group. A fresh single click does not belong to it, so publish the singleton
// first; otherwise `preserveSet` clears the set and sync wipes the canvas.
@@ -130,6 +130,121 @@ describe("useDomSelection — Variables tab preservation", () => {
});
});
describe("useDomSelection — canvas-only targets replace timeline clips", () => {
beforeEach(() => {
deferreds.clear();
usePlayerStore.getState().clearSelection();
});
afterEach(() => {
deferreds.clear();
usePlayerStore.getState().clearSelection();
});
it("deselects every clip when an audio bus is selected", () => {
const store = usePlayerStore.getState();
store.setSelectedElementId("voice-1");
store.setSelectedElementIds(new Set(["voice-1", "voice-2"]));
const bus = document.createElement("hf-audio-group");
bus.id = "voiceover";
const harness = renderHarness({
rightPanelTab: "design",
setRightPanelTab: vi.fn(),
iframe: null,
timelineElements: [
{ id: "voice-1", tag: "audio", start: 0, duration: 1, track: 0 },
{ id: "voice-2", tag: "audio", start: 1, duration: 1, track: 1 },
],
setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
});
act(() => harness.current().applyDomSelection(makeSelection("Voiceover", bus)));
expect(harness.current().domEditSelection?.id).toBe("voiceover");
expect(usePlayerStore.getState().selectedElementId).toBeNull();
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set());
harness.cleanup();
});
it("lets a bus supersede a clip selection that is still resolving", async () => {
const iframe = document.createElement("iframe");
document.body.append(iframe);
const doc = iframe.contentDocument!;
const clipNode = doc.createElement("audio");
clipNode.id = "voice-1";
const busNode = doc.createElement("hf-audio-group");
busNode.id = "voiceover";
doc.body.append(clipNode, busNode);
const clip: TimelineElement = {
id: "voice-1",
domId: "voice-1",
tag: "audio",
start: 0,
duration: 1,
track: 0,
};
const bus: TimelineElement = {
id: "voiceover",
domId: "voiceover",
tag: "audio",
start: 0,
duration: 10,
track: -0.5,
};
const harness = renderHarness({
rightPanelTab: "design",
setRightPanelTab: vi.fn(),
iframe,
// The bus is a synthetic row target, not a clip in the store.
timelineElements: [clip],
setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
});
let pendingClip = Promise.resolve();
let pendingBus = Promise.resolve();
act(() => {
pendingClip = harness.current().handleTimelineElementSelect(clip);
pendingBus = harness.current().handleTimelineElementSelect(bus);
});
await act(async () => {
deferreds.get("voiceover")?.resolve();
await pendingBus;
deferreds.get("voice-1")?.resolve();
await pendingClip;
});
expect(harness.current().domEditSelection?.id).toBe("voiceover");
expect(usePlayerStore.getState().selectedElementId).toBeNull();
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set());
harness.cleanup();
iframe.remove();
});
it("preserves clip context for a non-bus canvas-only selection", () => {
const store = usePlayerStore.getState();
store.setSelectedElementId("voice-1");
const decoration = document.createElement("div");
decoration.id = "decoration";
const harness = renderHarness({
rightPanelTab: "design",
setRightPanelTab: vi.fn(),
iframe: null,
timelineElements: [{ id: "voice-1", tag: "audio", start: 0, duration: 1, track: 0 }],
setSelectedTimelineElementId: usePlayerStore.getState().setSelectedElementId,
setTimelineSelectionSet: usePlayerStore.getState().setSelectedElementIds,
});
act(() => harness.current().applyDomSelection(makeSelection("Decoration", decoration)));
expect(usePlayerStore.getState().selectedElementId).toBe("voice-1");
expect(usePlayerStore.getState().selectedElementIds).toEqual(new Set(["voice-1"]));
harness.cleanup();
});
});
describe("useDomSelection — timeline-select race guard", () => {
beforeEach(() => deferreds.clear());
afterEach(() => deferreds.clear());