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

+2 -1
Переглянути файл
@@ -25,6 +25,7 @@ import {
type TreeHandle,
type TreeRowState,
T,
selectableText,
} from "@/ui";
import {
cancelInspection,
@@ -438,7 +439,7 @@ function ProblemDetails({ node, root }: { node: Node | null; root: string }) {
<Icon name={sev} size={14} className={styles[`sev-${sev}`]} />
<span>{p.d.source ? `${p.d.source}${p.d.code ? ` (${p.d.code})` : ""}` : <T>Language server</T>}</span>
</div>
<p className={styles.detailsMessage}>{p.d.message}</p>
<p className={clsx(styles.detailsMessage, selectableText)}>{p.d.message}</p>
<div className={styles.muted}>
{relativeDir(root, p.path) ? `${relativeDir(root, p.path)}/` : ""}
{basename(p.path)}:{p.d.range.start.line + 1}:{p.d.range.start.col + 1}
+7 -6
Переглянути файл
@@ -3,7 +3,8 @@ import { create } from "zustand";
import { executeAction } from "@/platform/actions";
import { t as translate, useT } from "@/platform/i18n";
import { commands, isTauri, type AppInfo, type BuildInfo } from "@/platform/ipc";
import { Dialog, IconButton } from "@/ui";
import clsx from "clsx";
import { Dialog, IconButton, selectableText } from "@/ui";
import logo from "../../../src-tauri/icons/128x128.png";
import { buildInfo, guessOs } from "./host";
import styles from "./app.module.css";
@@ -82,13 +83,13 @@ function AboutDialog({ onClose }: { onClose: () => void }) {
<div className={styles.about}>
<img className={styles.logo} src={logo} alt="" width={64} height={64} />
<div className={styles.aboutBody}>
<div className={styles.productName}>OpenIDEA {build?.version ?? ""}</div>
<div className={clsx(styles.productName, selectableText)}>OpenIDEA {build?.version ?? ""}</div>
{build && (
<div className={styles.aboutLine}>
<div className={clsx(styles.aboutLine, selectableText)}>
{t("app.about.build")} #{build.build} ({build.profile})
</div>
)}
<div className={styles.tagline}>{t("app.about.tagline")}</div>
<div className={clsx(styles.tagline, selectableText)}>{t("app.about.tagline")}</div>
<dl className={styles.facts}>
{rows
.filter(([k]) => k !== t("app.about.version") && k !== t("app.about.build"))
@@ -116,12 +117,12 @@ function AboutDialog({ onClose }: { onClose: () => void }) {
</button>
)}
</div>
<div className={styles.license}>
<div className={clsx(styles.license, selectableText)}>
{t("app.about.copyright")}
<br />
{t("app.about.license")}
</div>
<div className={styles.disclaimer}>
<div className={clsx(styles.disclaimer, selectableText)}>
{t("app.about.thirdParty")} {t("app.about.disclaimer")}
</div>
</div>
+3 -3
Переглянути файл
@@ -9,7 +9,7 @@ import { workflowSettings } from "@/platform/workflowSettings";
import { activeSession, evaluate } from "../state";
import { inlineValue } from "../values";
import { tx } from "@/platform/i18n";
import { iconMarkup } from "@/ui";
import { iconMarkup, selectableText } from "@/ui";
import { mountValueTree } from "../ui/ValueHintTree";
import styles from "./editor.module.css";
@@ -59,7 +59,7 @@ export function valueTooltip(): Extension {
above: true,
create: () => {
const dom = document.createElement("div");
dom.className = styles.valueTooltip!;
dom.className = `${styles.valueTooltip} ${selectableText}`;
let tree: { unmount(): void } | null = null;
if (value.variablesReference > 0) {
// IDEA's value hint: the "+" expands the object into a variables tree in place.
@@ -73,7 +73,7 @@ export function valueTooltip(): Extension {
expand.addEventListener("click", () => {
if (tree) return;
expand.remove();
dom.classList.add(styles.valueTooltipTree!);
dom.classList.replace(selectableText, styles.valueTooltipTree!);
dom.replaceChildren();
tree = mountValueTree(dom, expr.text, value, session.id);
});
+2 -2
Переглянути файл
@@ -8,7 +8,7 @@ import clsx from "clsx";
import { call, commands, isTauri } from "@/platform/ipc";
import { maybeEditorService } from "@/workbench/contributions";
import { highlightInto } from "@/editor/lsp/markdown";
import { IconButton } from "@/ui";
import { IconButton, selectableText } from "@/ui";
import { useLiveMarkdown } from "./live";
import { modeOf, useMarkdownModes } from "./modes";
import { ANCHOR_PREFIX, previewOffsetForLine, renderMarkdown, resolveLink, slugify } from "./render";
@@ -140,7 +140,7 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) {
return (
<div
ref={ref}
className={styles.preview}
className={clsx(styles.preview, selectableText)}
tabIndex={-1}
role="document"
aria-label="Markdown preview"