дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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
Цей коміт міститься в:
1 джерело
6dfd82de4c
коміт
de84a643aa
17 files changed
+453
-55
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача