Skip to content
Draft
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
7 changes: 7 additions & 0 deletions packages/studio/src/contexts/DomEditContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,13 @@ export function useDomEditSelectionContext(): DomEditSelectionValue {
return ctx;
}

/** Optional counterpart to useDomEditActionsContextOptional — same reason: the
* player package's own components mount outside a provider in standalone and
* test trees, where "no dom-edit selection" is the correct answer. */
export function useDomEditSelectionContextOptional(): DomEditSelectionValue | null {
return useContext(DomEditSelectionContext);
}

/** @deprecated Prefer useDomEditActionsContext or useDomEditSelectionContext. */
export function useDomEditContext(): DomEditValue {
return { ...useDomEditActionsContext(), ...useDomEditSelectionContext() };
Expand Down
87 changes: 86 additions & 1 deletion packages/studio/src/hooks/useAppHotkeys.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { dispatchPlainKey } from "./useAppHotkeys";
import { dispatchModifierKey, dispatchPlainKey } from "./useAppHotkeys";
import { usePlayerStore } from "../player/store/playerStore";
import { clearAutomationClipboard, copyRange } from "../player/components/automationClipboard";
import { VOLUME_RANGE } from "@hyperframes/core/audio-automation";
import type { TimelineElement } from "../player/store/timelineElement";

/** Minimal valid fixture — TimelineElement only requires these five fields. */
Expand Down Expand Up @@ -38,7 +40,11 @@ function callbacks() {
const press = (key: string) =>
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });

const chord = (key: string) =>
new KeyboardEvent("keydown", { key, metaKey: true, bubbles: true, cancelable: true });

afterEach(() => {
clearAutomationClipboard();
usePlayerStore.getState().clearAutomationSelection();
usePlayerStore.setState({
elements: [],
Expand Down Expand Up @@ -111,3 +117,82 @@ describe("dispatchPlainKey — Delete arbitration", () => {
expect(e.defaultPrevented).toBe(true);
});
});

describe("dispatchModifierKey — Cmd+C/Cmd+V arbitration", () => {
const clip: TimelineElement = {
id: "bgm",
key: "bgm",
tag: "audio",
start: 0,
duration: 6,
track: 0,
};

it("lets the clip clipboard have Cmd+C when no automation range is active", () => {
usePlayerStore.setState({ elements: [clip], selectedElementId: "bgm" });
const cb = callbacks();
dispatchModifierKey(chord("c"), "c", cb);
expect(cb.handleCopy).toHaveBeenCalled();
});

it("keeps Cmd+C from the clip clipboard when an automation range is active", () => {
// Both clipboards arming on one press double-wrote and toasted "Copied clip".
usePlayerStore.setState({ elements: [clip], selectedElementId: "bgm" });
usePlayerStore.getState().setAutomationSelection({
elementKey: "bgm",
target: "volume",
t0: 1,
t1: 3,
});
const cb = callbacks();
const e = chord("c");
expect(dispatchModifierKey(e, "c", cb)).toBe(true);
expect(cb.handleCopy).not.toHaveBeenCalled();
// No preventDefault: the automation handler downstream still needs the key.
expect(e.defaultPrevented).toBe(false);
});

it("lets the clip clipboard have Cmd+V when the automation clipboard is empty", () => {
// Nothing to paste means nothing to claim — the clip paste should still run.
clearAutomationClipboard();
usePlayerStore.setState({ elements: [clip], selectedElementId: "bgm" });
usePlayerStore.getState().setAutomationSelection({
elementKey: "bgm",
target: "volume",
t0: 1,
t1: 3,
});
const cb = callbacks();
dispatchModifierKey(chord("v"), "v", cb);
expect(cb.handlePaste).toHaveBeenCalled();
});

it("keeps Cmd+V from duplicating the clip while an automation paste is pending", () => {
clearAutomationClipboard();
copyRange(
null,
{
target: "volume",
points: [
{ t: 1, v: 1 },
{ t: 3, v: 0.25 },
],
},
VOLUME_RANGE,
1,
3,
);
usePlayerStore.setState({ elements: [clip], selectedElementId: "bgm" });
usePlayerStore.getState().setAutomationSelection({
elementKey: "bgm",
target: "volume",
t0: 1,
t1: 3,
});
const cb = callbacks();
const e = chord("v");
dispatchModifierKey(e, "v", cb);
expect(cb.handlePaste).not.toHaveBeenCalled();
expect(e.defaultPrevented).toBe(false);
});
});
18 changes: 17 additions & 1 deletion packages/studio/src/hooks/useAppHotkeys.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef } from "react";
import { automationOwnsKey } from "./useAutomationSelectionKeyboard";
import { usePlayerStore } from "../player";
import type { TimelineElement } from "../player";
import type { DomEditSelection } from "../components/editor/domEditing";
Expand Down Expand Up @@ -158,7 +159,14 @@ interface HotkeyCallbacks {
showToast: (message: string, tone?: "error" | "info") => void;
}

function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): boolean {
/** Exported for tests, like dispatchPlainKey below: lets the Cmd+C/Cmd+V
* arbitration between an automation range and the clip clipboard be asserted
* without standing up the whole hook. */
export function dispatchModifierKey(
event: KeyboardEvent,
key: string,
cb: HotkeyCallbacks,
): boolean {
if (
!shouldIgnoreHistoryShortcut(event.target) &&
handleUndoRedoKey(
Expand Down Expand Up @@ -196,6 +204,14 @@ function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallba
}

if (!event.shiftKey && !event.altKey && !isEditableTarget(event.target)) {
// An active automation range owns Cmd+C/Cmd+V, the same way it owns Delete
// below. This listener is on window/capture and runs before
// useAutomationSelectionKeyboard's document/capture handler, so without
// this the clip clipboard also claimed the key: Cmd+V duplicated the clip
// while the automation paste wrote the same file, and Cmd+C armed both
// clipboards and toasted "Copied clip". Return without preventDefault so
// the downstream handler still sees the key.
if (automationOwnsKey(event)) return true;
if (key === "c") {
if (cb.handleCopy()) {
event.preventDefault();
Expand Down
Loading