Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions packages/studio/src/hooks/domSelectionTimelineMirror.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand Down
115 changes: 115 additions & 0 deletions packages/studio/src/hooks/useDomSelectionSelectionGuards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
54 changes: 54 additions & 0 deletions packages/studio/src/player/components/AutomationEnvelopePaths.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/** The base automation envelope plus the heavier segment grab affordance. */

import type { AutomationRange, HfAutomationLane } from "@hyperframes/core/audio-automation";
import { envelopeSegmentPath } from "./automationLaneGeometry";

interface AutomationEnvelopePathsProps {
path: string;
lane: HfAutomationLane;
range: AutomationRange;
accentColor: string;
activeSegment: number | null;
xOf(t: number): number;
yOf(v: number): number;
}

export function AutomationEnvelopePaths({
path,
lane,
range,
accentColor,
activeSegment,
xOf,
yOf,
}: AutomationEnvelopePathsProps) {
const activePath =
activeSegment === null
? null
: envelopeSegmentPath({ lane, range, index: activeSegment, xOf, yOf });

return (
<>
<path
data-automation-envelope=""
d={path}
fill="none"
stroke={accentColor}
strokeWidth={1.5}
opacity={lane.points.length === 0 ? 0.35 : 0.95}
/>
{activePath ? (
<path
data-automation-segment-active={activeSegment ?? undefined}
d={activePath}
fill="none"
stroke={accentColor}
strokeWidth={3}
strokeLinecap="round"
opacity={1}
pointerEvents="none"
/>
) : null}
</>
);
}
113 changes: 113 additions & 0 deletions packages/studio/src/player/components/TimelineAutomationLane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
import {
normalizeAutomation,
resolveAutomationRange,
sampleAutomationLane,
VOLUME_RANGE,
type HfAutomation,
} from "@hyperframes/core/audio-automation";
Expand Down Expand Up @@ -668,6 +669,118 @@ describe("TimelineAutomationLane point visibility", () => {
});
});

describe("TimelineAutomationLane segment drag", () => {
const four: HfAutomation = {
version: 1,
lanes: [
{
target: "volume",
points: [
{ t: 0, v: 1 },
{ t: 1, v: 0.8 },
{ t: 2, v: 0.6 },
{ t: 3.5, v: 0.2 },
],
},
],
};

const previewedPoints = (props: { onPreview: ReturnType<typeof vi.fn> }) =>
props.onPreview.mock.calls.at(-1)?.[0].lanes[0].points as {
t: number;
v: number;
viaX?: number;
viaY?: number;
}[];

it("thickens the segment and offers a grab cursor only within its hit proximity", () => {
const { container, svg } = mount(ramp);
const envelope = container.querySelector<SVGPathElement>("[data-automation-envelope]");
expect(envelope?.getAttribute("stroke-width")).toBe("1.5");

fire(svg, "pointermove", at(2, 0.5));
const active = container.querySelector<SVGPathElement>("[data-automation-segment-active]");
expect(active).not.toBeNull();
expect(active?.getAttribute("stroke-width")).toBe("3");
expect(svg.style.cursor).toBe("grab");

// Same time span, but far enough above the drawn ramp to be background.
fire(svg, "pointermove", at(2, 0.9));
expect(container.querySelector("[data-automation-segment-active]")).toBeNull();
expect(svg.style.cursor).toBe("crosshair");
});

it("moves both segment endpoints by the same time and value delta", () => {
const { svg, props } = mount(four);
// Midpoint of the segment from (1, .8) to (2, .6).
fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
fire(svg, "pointermove", { ...at(2, 0.5), buttons: 1 });

const points = previewedPoints(props);
expect(points[0]).toEqual({ t: 0, v: 1 });
expect(points[1]!.t).toBeCloseTo(1.5, 2);
expect(points[2]!.t).toBeCloseTo(2.5, 2);
expect(points[1]!.v).toBeCloseTo(0.6, 2);
expect(points[2]!.v).toBeCloseTo(0.4, 2);
expect(points[3]).toEqual({ t: 3.5, v: 0.2 });
});

it("treats a press outside the line's proximity as a background range drag", () => {
const onRangeSelect = vi.fn();
const { svg, props } = mount(four, { onRangeSelect });
fire(svg, "pointerdown", { ...at(1.5, 0.95), buttons: 1 });
fire(svg, "pointermove", { ...at(2.5, 0.95), buttons: 1 });
expect(onRangeSelect).toHaveBeenCalled();
expect(props.onPreview).not.toHaveBeenCalled();
});

it("preserves the segment's curve while translating its endpoints", () => {
const curved: HfAutomation = {
version: 1,
lanes: [
{
target: "volume",
points: [
{ t: 0, v: 1 },
{ t: 1, v: 0.8, viaX: 0.4, viaY: 0.7 },
{ t: 2, v: 0.6 },
{ t: 3.5, v: 0.2 },
],
},
],
};
const { svg, props } = mount(curved);
const lineValue = sampleAutomationLane(curved.lanes[0]!, 1.7, "linear");
fire(svg, "pointerdown", { ...at(1.7, lineValue), buttons: 1 });
fire(svg, "pointermove", { ...at(2.1, lineValue - 0.1), buttons: 1 });
const points = previewedPoints(props);
expect(points[1]?.viaX).toBe(0.4);
expect(points[1]?.viaY).toBe(0.7);
});

it("stops both endpoints together before the next breakpoint", () => {
const { svg, props } = mount(four);
fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
fire(svg, "pointermove", { ...at(4, 0.7), buttons: 1, altKey: true });
const points = previewedPoints(props);
expect(points[2]!.t).toBeLessThan(points[3]!.t);
expect(points[3]!.t - points[2]!.t).toBeCloseTo(0.001, 4);
expect(points[2]!.t - points[1]!.t).toBeCloseTo(1, 4);
});

it("previews every move and persists the segment once on release", () => {
const { svg, props } = mount(four);
fire(svg, "pointerdown", { ...at(1.5, 0.7), buttons: 1 });
for (const t of [1.7, 1.9, 2.1]) {
fire(svg, "pointermove", { ...at(t, 0.6), buttons: 1 });
}
expect(props.onPreview).toHaveBeenCalledTimes(3);
expect(props.onCommit).not.toHaveBeenCalled();
fire(svg, "pointerup", { ...at(2.1, 0.6), buttons: 0 });
expect(props.onCommit).toHaveBeenCalledTimes(1);
});
});

/** `mount`, plus the re-render a real store update causes — the persisted
* automation and the new selection coming back down as props. */
const mountRerenderable = (automation: HfAutomation, over: Record<string, unknown> = {}) => {
Expand Down
Loading
Loading