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

+54
Переглянути файл
@@ -0,0 +1,54 @@
/**
* Read-only text the user can select and copy, like IDEA's Markdown preview,
* Quick Documentation and message panes. The workbench body is unselectable
* (chrome); content opts back in with `selectableText`. A right-click on a
* selection, or anywhere in such content, offers Copy (`copyMenuFor`).
*/
import { formatBinding } from "@/platform/keymap";
import type { MenuItem } from "./Menu";
import styles from "./SelectableText.module.css";
export const selectableText = styles.selectable!;
const EDITABLE = "input, textarea, [contenteditable=''], [contenteditable='true'], [contenteditable='plaintext-only']";
function selectionCovers(sel: Selection | null, target: Node): boolean {
if (!sel || sel.isCollapsed || !sel.toString()) return false;
for (let i = 0; i < sel.rangeCount; i++) {
const range = sel.getRangeAt(i);
if (range.intersectsNode(target) || target.contains(range.commonAncestorContainer)) return true;
}
return false;
}
/** The Copy menu for a right-click on `target`, or null when it is not on readable text. */
export function copyMenuFor(target: EventTarget | null): MenuItem[] | null {
const el = target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
if (!el || el.closest(EDITABLE)) return null;
const sel = window.getSelection();
const text = selectionCovers(sel, el) ? sel!.toString() : "";
if (!text && !el.closest(`.${selectableText}`)) return null;
return [
{
id: "copy",
label: "Copy",
icon: "copy",
shortcut: formatBinding("Meta+C"),
disabled: !text,
run: () => void copyText(text),
},
];
}
/** Writes `text` to the clipboard; falls back to copying the live selection. */
export async function copyText(text: string): Promise<void> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
} catch {
// Clipboard API refused (permissions): the selection is still there.
}
document.execCommand("copy");
}