From cef29045c7f0bfe5e03cfeeaffa58a85f9a08131 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 2 Jul 2026 16:45:45 +1000 Subject: [PATCH] fix: let the browser handle copy when text is selected or no clip is targeted --- src/core/inputs/controls.ts | 6 ++- tests/controls-copy.test.ts | 103 ++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tests/controls-copy.test.ts diff --git a/src/core/inputs/controls.ts b/src/core/inputs/controls.ts index 3ba5244..6cec4dd 100644 --- a/src/core/inputs/controls.ts +++ b/src/core/inputs/controls.ts @@ -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); } } diff --git a/tests/controls-copy.test.ts b/tests/controls-copy.test.ts new file mode 100644 index 0000000..aca0301 --- /dev/null +++ b/tests/controls-copy.test.ts @@ -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 { + 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(); + }); +});