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

+11 -3
Переглянути файл
@@ -6,6 +6,7 @@ import { Button, IconButton } from "./Button";
import { Icon } from "./icons";
import { FOCUSABLE, onWindowDeactivate, openLayer, trapTab } from "./popupState";
import { useTx } from "@/platform/i18n";
import { selectableText } from "./selectableText";
import styles from "./Dialog.module.css";
/**
@@ -276,7 +277,12 @@ function DialogInner({
}, []);
const drag = (e: PointerEvent<HTMLDivElement>) => {
if (popup || e.button !== 0 || (e.target as Element).closest("button, input, label, a, select, textarea")) return;
if (
popup ||
e.button !== 0 ||
(e.target as Element).closest(`button, input, label, a, select, textarea, .${selectableText}`)
)
return;
const el = ref.current;
if (!el) return;
e.preventDefault();
@@ -355,8 +361,10 @@ function DialogInner({
<div className={styles.message} data-dialog-body="" onPointerDown={drag}>
<MessageIcon kind={icon} />
<div className={styles.messageText}>
{title != null && <div className={styles.messageTitle}>{title}</div>}
<div className={styles.messageBody}>{typeof children === "string" ? tx(children) : children}</div>
{title != null && <div className={clsx(styles.messageTitle, selectableText)}>{title}</div>}
<div className={clsx(styles.messageBody, selectableText)}>
{typeof children === "string" ? tx(children) : children}
</div>
{footerStart && <div className={styles.messageExtra}>{footerStart}</div>}
</div>
</div>
+4 -1
Переглянути файл
@@ -3,6 +3,7 @@ import clsx from "clsx";
import { Icon, type IconName } from "./icons";
import { IconButton } from "./Button";
import { T } from "./T";
import { selectableText } from "./selectableText";
import styles from "./Feedback.module.css";
export interface ProgressBarProps {
@@ -135,7 +136,9 @@ export function Toast({
<Icon name={LEVEL_ICON[level]} size={16} className={clsx(styles.toastIcon, styles[`level-${level}`])} />
<div className={styles.toastMain}>
<div className={styles.toastHead}>
<span className={styles.toastTitle}>{typeof title === "string" ? <T>{title}</T> : title}</span>
<span className={clsx(styles.toastTitle, selectableText)}>
{typeof title === "string" ? <T>{title}</T> : title}
</span>
{meta && <span className={styles.toastMeta}>{meta}</span>}
</div>
{body && <div className={styles.toastBody}>{typeof body === "string" ? <T>{body}</T> : body}</div>}
+6
Переглянути файл
@@ -0,0 +1,6 @@
/* Read-only content the user reads and copies (docs, previews, messages), as opposed to chrome. */
.selectable {
-webkit-user-select: text;
user-select: text;
cursor: auto;
}
+1
Переглянути файл
@@ -22,3 +22,4 @@ export * from "./BarButton";
export * from "./AppLogo";
export { lazyComponent, prefetchLazyComponents } from "./lazy";
export { T } from "./T";
export { selectableText, copyMenuFor, copyText } from "./selectableText";
+60
Переглянути файл
@@ -0,0 +1,60 @@
/**
* Read-only content surfaces stay selectable/copyable (issue #36): IDEA lets
* the user select and copy text in the Markdown preview, documentation popups,
* message dialogs and notifications, while chrome stays unselectable.
*/
import { afterEach, describe, expect, it } from "vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { renderCode, renderMarkup } from "@/editor/lsp/markdown";
import { MarkdownEditor } from "@/features/viewers/markdown/MarkdownEditor";
import { useLiveMarkdown } from "@/features/viewers/markdown/live";
import { useMarkdownModes } from "@/features/viewers/markdown/modes";
import { Dialog } from "./Dialog";
import { Toast } from "./Feedback";
import { selectableText } from "./selectableText";
afterEach(() => {
cleanup();
useLiveMarkdown.setState({}, true);
});
describe("selectable read-only surfaces", () => {
it("Markdown preview (the reported case)", () => {
act(() =>
useLiveMarkdown.setState({ "/p/README.md": { text: "# Title\n\nSome *text*", topLine: 0, scrollSeq: 0 } }),
);
act(() => useMarkdownModes.setState((s) => ({ byPath: { ...s.byPath, "/p/README.md": "preview" } })));
render(
<MarkdownEditor path="/p/README.md" tabId="t">
<div />
</MarkdownEditor>,
);
const preview = screen.getByRole("document", { name: "Markdown preview" });
expect(preview).toHaveClass(selectableText);
expect(screen.getByRole("toolbar", { name: "Markdown editor layout" }).closest(`.${selectableText}`)).toBeNull();
});
it("documentation markup and code (Quick Documentation, hover, completion docs)", () => {
expect(renderMarkup({ kind: "plaintext", value: "docs" })).toHaveClass(selectableText);
expect(renderCode("fn f()", null)).toHaveClass(selectableText);
});
it("message dialog title and body; dragging does not start on them", () => {
render(
<Dialog open onClose={() => {}} icon="warning" title="Delete?" buttons={[{ label: "OK", role: "default" }]}>
Message text
</Dialog>,
);
const body = screen.getByText("Message text");
expect(body).toHaveClass(selectableText);
expect(screen.getByText("Delete?")).toHaveClass(selectableText);
expect(screen.getByRole("button", { name: "OK" }).closest(`.${selectableText}`)).toBeNull();
// Not prevented: the press starts a text selection instead of moving the dialog.
expect(fireEvent.pointerDown(body, { button: 0 })).toBe(true);
});
it("notification title (the body opts in through its own style)", () => {
render(<Toast level="error" title="Build failed" body="error: E0425" onClose={() => {}} />);
expect(screen.getByText("Build failed").closest(`.${selectableText}`)).not.toBeNull();
});
});
+93
Переглянути файл
@@ -0,0 +1,93 @@
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");
});
});
+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");
}