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
6 changes: 5 additions & 1 deletion src/core/inputs/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,13 @@ export class Controls {
}
case "KeyC": {
if (event.metaKey || event.ctrlKey) {
event.preventDefault();
// Clip copy must not swallow the page's default copy: defer to the browser when the
// user has text selected anywhere in the document, or when no clip is targeted.
const selection = window.getSelection();
if (selection && !selection.isCollapsed) break;
const selected = this.edit.getSelectedClipInfo();
if (selected) {
event.preventDefault();
this.edit.copyClip(selected.trackIndex, selected.clipIndex);
}
}
Expand Down
103 changes: 103 additions & 0 deletions tests/controls-copy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* @jest-environment jsdom
*
* Copy Shortcut Guard Tests
*
* Cmd/Ctrl+C must only be intercepted when it will act on a clip: with no clip
* selected, or with an active text selection anywhere in the document, the
* browser's default copy has to run so host-app text stays copyable.
*/
import { Controls } from "@core/inputs/controls";

import type { Edit } from "@core/edit-session";

interface MockEdit {
getSelectedClipInfo: jest.Mock;
copyClip: jest.Mock;
}

function makeEdit(selected: { trackIndex: number; clipIndex: number } | null): MockEdit {
return {
getSelectedClipInfo: jest.fn().mockReturnValue(selected),
copyClip: jest.fn()
};
}

function dispatchCopy(target: EventTarget): KeyboardEvent {
const event = new KeyboardEvent("keydown", {
code: "KeyC",
key: "c",
metaKey: true,
bubbles: true,
cancelable: true
});
target.dispatchEvent(event);
return event;
}

function selectText(text: string): void {
const container = document.createElement("div");
container.textContent = text;
document.body.appendChild(container);
const range = document.createRange();
range.selectNodeContents(container);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}

describe("Controls copy shortcut", () => {
let controls: Controls;
let edit: MockEdit;

async function loadControls(selected: { trackIndex: number; clipIndex: number } | null): Promise<void> {
edit = makeEdit(selected);
controls = new Controls(edit as unknown as Edit);
await controls.load();
}

afterEach(() => {
controls.dispose();
window.getSelection()?.removeAllRanges();
document.body.innerHTML = "";
});

it("lets the browser handle copy when no clip is selected", async () => {
await loadControls(null);

const event = dispatchCopy(document.body);

expect(event.defaultPrevented).toBe(false);
expect(edit.copyClip).not.toHaveBeenCalled();
});

it("copies the selected clip when nothing else claims the shortcut", async () => {
await loadControls({ trackIndex: 1, clipIndex: 2 });

const event = dispatchCopy(document.body);

expect(event.defaultPrevented).toBe(true);
expect(edit.copyClip).toHaveBeenCalledWith(1, 2);
});

it("prefers an active text selection over the selected clip", async () => {
await loadControls({ trackIndex: 1, clipIndex: 2 });
selectText("copy this message text");

const event = dispatchCopy(document.body);

expect(event.defaultPrevented).toBe(false);
expect(edit.copyClip).not.toHaveBeenCalled();
});

it("stays exempt inside editable fields", async () => {
await loadControls({ trackIndex: 1, clipIndex: 2 });
const textarea = document.createElement("textarea");
document.body.appendChild(textarea);

const event = dispatchCopy(textarea);

expect(event.defaultPrevented).toBe(false);
expect(edit.copyClip).not.toHaveBeenCalled();
});
});
Loading