Files
openidea/src/ui/selectableText.test.ts
T
l0ng-ai de84a643aa fix(ui): Make read-only content selectable with a Copy context menu (#48)
Root cause: body is user-select: none and each feature had to opt back in; the Markdown preview (and other read-only views) never did, and the app-wide contextmenu guard left no Copy item.
Fix: shared selectableText class (src/ui) for read-only content, and a fallback Copy context menu from browserGuards when right-clicking a selection or a selectable area (component menus still win).
Siblings covered: Quick Documentation / hover / completion docs, hover errors, Quick Definition, debugger value tooltip, inspection messages, message dialogs (no drag on body text), notification titles, About dialog; existing selectable consoles now get Copy too.
Verified in-app on macOS: select in Markdown preview, right-click Copy puts the text on the clipboard; tab context menu unchanged.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 08:56:13 +08:00

94 lines
3.6 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { copyMenuFor, copyText, selectableText } from "./selectableText";
function mount(html: string) {
document.body.innerHTML = html;
return document.body;
}
function select(node: Node) {
const range = document.createRange();
range.selectNodeContents(node);
const sel = window.getSelection()!;
sel.removeAllRanges();
sel.addRange(range);
}
afterEach(() => {
window.getSelection()?.removeAllRanges();
document.body.innerHTML = "";
vi.restoreAllMocks();
});
describe("selectableText", () => {
it("is a real class name", () => {
expect(selectableText).toBeTruthy();
});
});
describe("copyMenuFor", () => {
it("offers Copy of the selection in a selectable pane (issue #36: Markdown preview)", () => {
mount(`<div class="${selectableText}" role="document"><p id="p">Hello <b id="b">world</b></p></div>`);
select(document.getElementById("p")!);
const items = copyMenuFor(document.getElementById("b"));
expect(items).toHaveLength(1);
expect(items![0]).toMatchObject({ label: "Copy", disabled: false });
});
it("offers a disabled Copy in a selectable pane with nothing selected", () => {
mount(`<div class="${selectableText}"><p id="p">Hello</p></div>`);
expect(copyMenuFor(document.getElementById("p"))![0]).toMatchObject({ label: "Copy", disabled: true });
});
it("resolves text-node targets", () => {
mount(`<div class="${selectableText}"><p id="p">Hello</p></div>`);
expect(copyMenuFor(document.getElementById("p")!.firstChild)).not.toBeNull();
});
it("offers Copy on a selection in any pane that opted in with its own CSS", () => {
mount(`<pre id="console">line 1</pre>`);
select(document.getElementById("console")!);
expect(copyMenuFor(document.getElementById("console"))![0]).toMatchObject({ disabled: false });
});
it("stays out of chrome without a selection", () => {
mount(`<button id="b">Run</button><div id="row">tree row</div>`);
expect(copyMenuFor(document.getElementById("b"))).toBeNull();
expect(copyMenuFor(document.getElementById("row"))).toBeNull();
});
it("ignores a selection elsewhere when right-clicking chrome", () => {
mount(`<div class="${selectableText}"><p id="p">Hello</p></div><div id="row">tree row</div>`);
select(document.getElementById("p")!);
expect(copyMenuFor(document.getElementById("row"))).toBeNull();
});
it.each(["<input id='t'>", "<textarea id='t'></textarea>", "<div id='t' contenteditable='true'>x</div>"])(
"leaves editable targets alone: %s",
(html) => {
mount(`<div class="${selectableText}">${html}</div>`);
expect(copyMenuFor(document.getElementById("t"))).toBeNull();
},
);
it("copies the text captured when the menu opened", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
mount(`<div class="${selectableText}"><p id="p">Hello</p></div>`);
select(document.getElementById("p")!);
const items = copyMenuFor(document.getElementById("p"))!;
window.getSelection()!.removeAllRanges();
(items[0] as { run: () => void }).run();
await Promise.resolve();
expect(writeText).toHaveBeenCalledWith("Hello");
});
it("falls back to execCommand when the clipboard API is missing", async () => {
Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
const exec = vi.fn().mockReturnValue(true);
document.execCommand = exec;
await copyText("x");
expect(exec).toHaveBeenCalledWith("copy");
});
});