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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 08:56:13 +08:00
1 джерело 6dfd82de4c
коміт de84a643aa
17 files changed
+453 -55

No files matched your search

+59
Переглянути файл
@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { hideContextMenu, selectableText, showContextMenu, useContextMenuStore } from "@/ui";
import { installBrowserGuards } from "./browserGuards";
let dispose: () => void;
beforeEach(() => {
dispose = installBrowserGuards({ blockReload: false });
});
afterEach(() => {
dispose();
hideContextMenu();
window.getSelection()?.removeAllRanges();
document.body.innerHTML = "";
});
function rightClick(el: Element) {
const e = new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 5, clientY: 6 });
el.dispatchEvent(e);
return e;
}
describe("browser guards: context menu", () => {
it("never shows the native menu, and nothing on chrome", () => {
document.body.innerHTML = `<button id="b">Run</button>`;
const e = rightClick(document.getElementById("b")!);
expect(e.defaultPrevented).toBe(true);
expect(useContextMenuStore.getState().menu).toBeNull();
});
it("shows Copy on selectable read-only text (issue #36)", () => {
document.body.innerHTML = `<div class="${selectableText}"><p id="p">Hello</p></div>`;
const range = document.createRange();
range.selectNodeContents(document.getElementById("p")!);
window.getSelection()!.addRange(range);
const e = rightClick(document.getElementById("p")!);
expect(e.defaultPrevented).toBe(true);
const menu = useContextMenuStore.getState().menu;
expect(menu).toMatchObject({ x: 5, y: 6 });
expect(menu!.items[0]).toMatchObject({ label: "Copy", disabled: false });
});
it("keeps a component's own context menu", () => {
document.body.innerHTML = `<div class="${selectableText}"><p id="p">Hello</p></div>`;
const p = document.getElementById("p")!;
p.addEventListener("contextmenu", (e) => showContextMenu(e, [{ label: "Own" }]));
rightClick(p);
expect(useContextMenuStore.getState().menu!.items[0]).toMatchObject({ label: "Own" });
});
it("keeps out when a handler already prevented the default", () => {
document.body.innerHTML = `<div class="${selectableText}"><p id="p">Hello</p></div>`;
const p = document.getElementById("p")!;
p.addEventListener("contextmenu", (e) => e.preventDefault());
rightClick(p);
expect(useContextMenuStore.getState().menu).toBeNull();
});
});
+16 -2
Переглянути файл
@@ -2,8 +2,10 @@
* Makes the webview behave like a desktop app: never the browser context menu
* (its Reload/AutoFill items would act on the webview), no reload/print/
* save-page/history shortcuts, no navigation on file drop. Copy/paste/undo
* shortcuts stay untouched.
* shortcuts stay untouched. A right-click no component handled on selected or
* selectable read-only text gets IDEA's Copy menu instead of nothing.
*/
import { copyMenuFor, showContextMenu, useContextMenuStore } from "@/ui";
const BLOCKED = new Set([
"Meta+P",
@@ -35,7 +37,17 @@ function keyId(e: KeyboardEvent) {
export function installBrowserGuards(opts: { blockReload?: boolean } = {}): () => void {
const blockReload = opts.blockReload ?? import.meta.env.PROD;
const onContextMenu = (e: MouseEvent) => e.preventDefault();
let menuBefore: unknown = null;
const beforeContextMenu = () => {
menuBefore = useContextMenuStore.getState().menu;
};
const onContextMenu = (e: MouseEvent) => {
const handled = e.defaultPrevented || useContextMenuStore.getState().menu !== menuBefore;
e.preventDefault();
if (handled) return;
const items = copyMenuFor(e.target);
if (items) showContextMenu(e, items);
};
const onKeyDown = (e: KeyboardEvent) => {
const id = keyId(e);
if (BLOCKED.has(id) || (blockReload && RELOAD.has(id))) e.preventDefault();
@@ -43,11 +55,13 @@ export function installBrowserGuards(opts: { blockReload?: boolean } = {}): () =
const onDrag = (e: DragEvent) => {
if (!(e.target as Element | null)?.closest?.("[data-drop-target]")) e.preventDefault();
};
window.addEventListener("contextmenu", beforeContextMenu, true);
window.addEventListener("contextmenu", onContextMenu);
window.addEventListener("keydown", onKeyDown);
window.addEventListener("dragover", onDrag);
window.addEventListener("drop", onDrag);
return () => {
window.removeEventListener("contextmenu", beforeContextMenu, true);
window.removeEventListener("contextmenu", onContextMenu);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("dragover", onDrag);