mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
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:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user