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
This commit is contained in:
l0ng-ai
2026-10-27 08:56:13 +08:00
committed by GitHub
parent 6dfd82de4c
commit de84a643aa
17 changed files with 453 additions and 55 deletions
+12 -4
View File
@@ -14,6 +14,7 @@ import {
SymbolBadge,
fileIconName,
fuzzyMatch,
selectableText,
T,
} from "@/ui";
import { basename, relativePath, useProject } from "@/platform/project";
@@ -329,7 +330,7 @@ function DocView({ host, history, themeClasses }: { host: DocHost; history: DocH
{entry && (
<DomContent
key={`${history.index}:${history.entries.length}`}
className={styles.docBody}
className={clsx(styles.docBody, selectableText)}
build={() => renderMarkup(entry.markup, entry.language, docLinkHandler(host))}
/>
)}
@@ -352,7 +353,13 @@ function DocView({ host, history, themeClasses }: { host: DocHost; history: DocH
/>
<span className={styles.spacer} />
{entry?.source && (
<IconButton icon="edit" size="small" title="Edit Source" shortcut="F4" onClick={() => void editDocSource(host)} />
<IconButton
icon="edit"
size="small"
title="Edit Source"
shortcut="F4"
onClick={() => void editDocSource(host)}
/>
)}
{host === "popup" && (
<IconButton
@@ -373,7 +380,8 @@ function DocPopup({ popup }: { popup: Extract<LspPopup, { kind: "doc" }> }) {
const scope = useRef<HTMLDivElement>(null);
const opened = useRef(focusSeq);
useEffect(() => {
if (focusSeq !== opened.current) scope.current?.querySelector<HTMLElement>("[data-lsp-doc]")?.focus({ preventScroll: true });
if (focusSeq !== opened.current)
scope.current?.querySelector<HTMLElement>("[data-lsp-doc]")?.focus({ preventScroll: true });
}, [focusSeq]);
return (
<Popup
@@ -463,7 +471,7 @@ function DefinitionPopup({ popup }: { popup: Extract<LspPopup, { kind: "definiti
<div className={clsx(popup.themeClasses, styles.docScope)}>
<DomContent
key={popup.index}
className={styles.defBody}
className={clsx(styles.defBody, selectableText)}
build={() => renderCode(target.text, target.language, "cm-lsp-code")}
/>
</div>
+68 -19
View File
@@ -25,13 +25,22 @@ import {
} from "@codemirror/autocomplete";
import { EditorSelection, Prec, type Text } from "@codemirror/state";
import { EditorView, ViewPlugin, keymap, type ViewUpdate } from "@codemirror/view";
import { call, commands, isTauri, nextRequestId, type CompletionItem, type Markup, type RequestId } from "@/platform/ipc";
import {
call,
commands,
isTauri,
nextRequestId,
type CompletionItem,
type Markup,
type RequestId,
} from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { completionSymbolKind, identifierEnd, toCmCompletion, triggerCharacter } from "./completionItems";
import { lspSnippetToCm, snippetVariables } from "./snippet";
import { docOf, isUnavailable, lspDoc, lspRequest, reportLspError } from "./lsp";
import { offsetToPos, posToOffset, textEditsToChanges } from "./positions";
import { isEmptyMarkup, renderCode, renderMarkup } from "./markdown";
import { selectableText } from "@/ui/selectableText";
import { iconNode } from "./iconMarkup";
import { triggerSignatureHelp } from "./signature";
import { serverBackoff } from "./availability";
@@ -101,7 +110,7 @@ function buildInfo(c: LspCompletion, delay: number): Promise<CompletionInfo> {
const hasDoc = !isEmptyMarkup(item.documentation);
if (!hasDoc && !item.detail) return null;
const dom = document.createElement("div");
dom.className = "cm-lsp-completion-doc";
dom.className = `cm-lsp-completion-doc ${selectableText}`;
if (item.detail) dom.appendChild(renderCode(item.detail, c.req.language, "cm-lsp-code cm-lsp-doc-signature"));
if (hasDoc) dom.appendChild(renderMarkup(item.documentation!, c.req.language));
return dom;
@@ -160,7 +169,8 @@ export function applyLspCompletion(
const item = completion.resolved ?? completion.lsp;
const state = view.state;
const delta = to - req.pos;
const mapOld = (offset: number) => Math.max(0, Math.min(state.doc.length, offset < req.pos ? offset : offset + delta));
const mapOld = (offset: number) =>
Math.max(0, Math.min(state.doc.length, offset < req.pos ? offset : offset + delta));
let start = from;
let end = to;
@@ -190,7 +200,9 @@ export function applyLspCompletion(
}
snippet(lspSnippetToCm(item.insertText, snippetVariables(req.path)))(view, completion, start, end);
} else {
const parens = editorPrefs().completion.insertParentheses ? callParens(item, state.doc.lineAt(start), start, end) : null;
const parens = editorPrefs().completion.insertParentheses
? callParens(item, state.doc.lineAt(start), start, end)
: null;
insertedParens = parens;
const changes = state.changes([...extra, { from: start, to: end, insert: item.insertText + (parens ? "()" : "") }]);
const caret = changes.mapPos(end, 1) - (parens === "inside" ? 1 : 0);
@@ -203,7 +215,8 @@ export function applyLspCompletion(
});
}
if (!completion.resolved && !item.additionalEdits.length && item.data != null) void applyLateEdits(view, completion, start);
if (!completion.resolved && !item.additionalEdits.length && item.data != null)
void applyLateEdits(view, completion, start);
if (insertedParens === "inside" && !item.command) setTimeout(() => triggerSignatureHelp(view), 0);
runCommand(view, item);
}
@@ -240,7 +253,9 @@ export function callParens(
async function applyLateEdits(view: EditorView, c: LspCompletion, insertedAt: number) {
const item = await resolveItem(c.req.doc, c.lsp);
if (!item.additionalEdits.length || !view.dom.isConnected) return;
const edits = textEditsToChanges(c.req.text, item.additionalEdits).filter((e) => e.to <= Math.min(insertedAt, c.req.pos));
const edits = textEditsToChanges(c.req.text, item.additionalEdits).filter(
(e) => e.to <= Math.min(insertedAt, c.req.pos),
);
if (!edits.length) return;
const limit = Math.max(...edits.map((e) => e.to));
if (view.state.doc.sliceString(0, limit) !== c.req.text.sliceString(0, limit)) return;
@@ -259,7 +274,10 @@ function runCommand(view: EditorView, item: CompletionItem) {
else if (!CLIENT_COMMANDS.has(cmd) && !cmd.startsWith("editor.")) {
const doc = docOf(view);
// Server commands (auto-import, postfix…); resulting edits come back as workspace/applyEdit.
if (doc) void lspRequest(() => commands.lspExecuteCommand(doc.doc, command)).catch((e: unknown) => reportLspError(view, e, false));
if (doc)
void lspRequest(() => commands.lspExecuteCommand(doc.doc, command)).catch((e: unknown) =>
reportLspError(view, e, false),
);
}
}
@@ -278,7 +296,10 @@ function cancelInflight() {
/** Language-data completion sources (keywords etc.), used when no server answers. */
async function fallback(ctx: CompletionContext): Promise<CompletionResult | null> {
const sources = ctx.state.languageDataAt<CompletionSource | readonly (string | Completion)[]>("autocomplete", ctx.pos);
const sources = ctx.state.languageDataAt<CompletionSource | readonly (string | Completion)[]>(
"autocomplete",
ctx.pos,
);
for (const s of sources) {
const source = typeof s === "function" ? s : completeFromList(s);
const r = await source(ctx);
@@ -317,7 +338,9 @@ export function mergeResults(results: readonly CompletionResult[]): CompletionRe
const [first] = results;
if (!first || results.length === 1) return first ?? null;
const same = results.filter((r) => r.from === first.from);
const validFor = same.every((r) => r.validFor instanceof RegExp && r.validFor.source === (first.validFor as RegExp | undefined)?.source)
const validFor = same.every(
(r) => r.validFor instanceof RegExp && r.validFor.source === (first.validFor as RegExp | undefined)?.source,
)
? first.validFor
: undefined;
return { from: first.from, to: first.to, options: same.flatMap((r) => r.options), validFor };
@@ -360,7 +383,9 @@ async function lspCompletionSource(ctx: CompletionContext): Promise<CompletionRe
const req: RequestContext = { doc: doc.doc, path: doc.path, language: doc.uiMode, pos: ctx.pos, text: ctx.state.doc };
const prefs = editorPrefs().completion;
const typed = word?.text ?? "";
const items = prefs.matchCase ? list.items.filter((it) => matchesCase(it.filterText ?? it.label, typed, prefs.caseSensitivity)) : list.items;
const items = prefs.matchCase
? list.items.filter((it) => matchesCase(it.filterText ?? it.label, typed, prefs.caseSensitivity))
: list.items;
return {
from: word ? word.from : ctx.pos,
options: items.map((item) => makeCompletion(item, req)),
@@ -396,7 +421,10 @@ export function sortAlphabetically(result: CompletionResult, typed: string): Com
update: validFor
? (_cur, from, to, ctx) => {
const text = ctx.state.sliceDoc(from, to);
const ok = typeof validFor === "function" ? validFor(text, from, to, ctx.state) : new RegExp(`^(?:${validFor.source})$`, validFor.flags).test(text);
const ok =
typeof validFor === "function"
? validFor(text, from, to, ctx.state)
: new RegExp(`^(?:${validFor.source})$`, validFor.flags).test(text);
return ok ? sortAlphabetically({ ...result, from }, text) : null;
}
: undefined,
@@ -455,7 +483,9 @@ function gated(source: CompletionSource): CompletionSource {
if (only) return source === contributedSource ? only(ctx) : null;
if (!ctx.explicit && (!editorPrefs().completion.autoPopup || inplaceRenameActive(ctx.state))) return null;
if (!editorPrefs().completion.sortAlphabetically) return source(ctx);
return Promise.resolve(source(ctx)).then((r) => r && sortAlphabetically(r, ctx.state.sliceDoc(r.from, r.to ?? ctx.pos)));
return Promise.resolve(source(ctx)).then(
(r) => r && sortAlphabetically(r, ctx.state.sliceDoc(r.from, r.to ?? ctx.pos)),
);
};
}
@@ -478,7 +508,12 @@ const explicitPending = new WeakSet<EditorView>();
const NAVIGATION_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown"]);
/** Whether typing `ch` finishes the open lookup with its selected item. */
export function commitsLookup(ch: string, focused: boolean, insertOnSpecialKeys: boolean, itemChars: readonly string[] = []): boolean {
export function commitsLookup(
ch: string,
focused: boolean,
insertOnSpecialKeys: boolean,
itemChars: readonly string[] = [],
): boolean {
if (!focused && !insertOnSpecialKeys) return false;
return COMMIT_CHARS.includes(ch) || itemChars.includes(ch);
}
@@ -495,11 +530,15 @@ const lookupKeys = Prec.highest(
const selected = selectedCompletion(view.state);
if (!selected) return false;
const itemChars = isLsp(selected) ? selected.lsp.commitCharacters : (selected.commitCharacters ?? []);
if (!commitsLookup(e.key, focusedLookups.has(view), editorPrefs().completion.insertOnSpecialKeys, itemChars)) return false;
if (!commitsLookup(e.key, focusedLookups.has(view), editorPrefs().completion.insertOnSpecialKeys, itemChars))
return false;
if (!acceptCompletion(view)) return false;
// A call snippet or inserted parentheses already opened the call: the typed one is consumed.
const head = view.state.selection.main.head;
if (e.key === "(" && (view.state.sliceDoc(head - 1, head) === "(" || view.state.sliceDoc(head - 2, head) === "()")) {
if (
e.key === "(" &&
(view.state.sliceDoc(head - 1, head) === "(" || view.state.sliceDoc(head - 2, head) === "()")
) {
e.preventDefault();
return true;
}
@@ -526,7 +565,8 @@ const lookupTracker = ViewPlugin.fromClass(
resetLookupDocs();
if (explicitPending.has(view)) {
explicitPending.delete(view);
if (prev === "pending" && !u.docChanged) queueMicrotask(() => showHint(view, "No suggestions", { timeout: 2000 }));
if (prev === "pending" && !u.docChanged)
queueMicrotask(() => showHint(view, "No suggestions", { timeout: 2000 }));
}
} else if (status === "active" && explicitPending.has(view)) {
explicitPending.delete(view);
@@ -535,7 +575,12 @@ const lookupTracker = ViewPlugin.fromClass(
const doc = u.state.doc;
setTimeout(() => {
const now = currentCompletions(view.state);
if (view.state.doc === doc && completionStatus(view.state) === "active" && now.length === 1 && now[0] === options[0])
if (
view.state.doc === doc &&
completionStatus(view.state) === "active" &&
now.length === 1 &&
now[0] === options[0]
)
acceptCompletion(view);
}, AUTO_INSERT_DELAY);
}
@@ -583,7 +628,9 @@ function typeColumn(c: Completion): Node | null {
async function toggleSortByName(view: EditorView) {
const on = !editorPrefs().completion.sortAlphabetically;
if (isTauri()) {
const settings = await call(commands.settingsSet("global", "editor.completion.sortAlphabetically", JSON.stringify(on)));
const settings = await call(
commands.settingsSet("global", "editor.completion.sortAlphabetically", JSON.stringify(on)),
);
useProject.setState({ settings });
}
if (!view.dom.isConnected) return;
@@ -647,7 +694,9 @@ export function completionExtension() {
optionClass: (c) => {
// IDEA shows keywords in bold.
const keyword = (isLsp(c) ? c.lsp.kind : c.type) === "keyword" ? " cm-lsp-keyword-option" : "";
return (isLsp(c) ? (c.lsp.deprecated ? "cm-lsp-option cm-lsp-deprecated-option" : "cm-lsp-option") : "") + keyword;
return (
(isLsp(c) ? (c.lsp.deprecated ? "cm-lsp-option cm-lsp-deprecated-option" : "cm-lsp-option") : "") + keyword
);
},
tooltipClass: () => "cm-lsp-completion",
}),
+40 -9
View File
@@ -5,11 +5,21 @@
*/
import { EditorSelection } from "@codemirror/state";
import { hoverTooltip, type EditorView } from "@codemirror/view";
import { commands, isTauri, nextRequestId, type Hover, type Location, type Pos, type Range, type RequestId } from "@/platform/ipc";
import {
commands,
isTauri,
nextRequestId,
type Hover,
type Location,
type Pos,
type Range,
type RequestId,
} from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
import { diagnosticsAt, type PlacedDiagnostic } from "./diagnostics";
import { isEmptyMarkup, languageForPath, renderMarkup } from "./markdown";
import { selectableText } from "@/ui/selectableText";
import { docOf, lspRequest, reportLspError } from "./lsp";
import { offsetToPos, rangeToOffsets } from "./positions";
import { showHint } from "./hint";
@@ -51,7 +61,13 @@ function renderDiagnostics(view: EditorView, diags: PlacedDiagnostic[]): HTMLEle
for (const { diagnostic: d, from } of diags) {
const row = document.createElement("div");
row.className = `cm-lsp-diag cm-lsp-diag-${d.severity}`;
row.appendChild(iconNode(d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "info", 16, "cm-lsp-diag-icon"));
row.appendChild(
iconNode(
d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "info",
16,
"cm-lsp-diag-icon",
),
);
const msg = document.createElement("div");
msg.className = "cm-lsp-diag-message";
msg.textContent = d.message;
@@ -110,7 +126,7 @@ export function hoverExtension() {
above: true,
create: () => {
const dom = document.createElement("div");
dom.className = "cm-lsp-hover";
dom.className = `cm-lsp-hover ${selectableText}`;
if (diags.length) dom.appendChild(renderDiagnostics(view, diags));
if (hover) dom.appendChild(renderMarkup(hover.contents, lang));
return { dom };
@@ -141,7 +157,13 @@ function showDoc(view: EditorView, entry: DocEntry, at: number) {
showInDocWindow(entry, view.themeClasses);
return;
}
openPopup({ kind: "doc", anchor: caretAnchor(view, at), entries: [entry], index: 0, themeClasses: view.themeClasses });
openPopup({
kind: "doc",
anchor: caretAnchor(view, at),
entries: [entry],
index: 0,
themeClasses: view.themeClasses,
});
}
/**
@@ -210,7 +232,10 @@ export function definitionSpan(
async function declarationRange(loc: Location): Promise<Range | null> {
try {
return await withDocument(loc.path, async (doc) => (await structure.structuralRanges(doc, loc.range.start)).declaration?.range ?? null);
return await withDocument(
loc.path,
async (doc) => (await structure.structuralRanges(doc, loc.range.start)).declaration?.range ?? null,
);
} catch {
return null;
}
@@ -249,14 +274,20 @@ export async function quickDefinition(view: EditorView, kind: "definition" | "ty
showHint(view, kind === "typeDefinition" ? "No type definition found" : "No definition found");
return;
}
const targets = (await Promise.all(locations.slice(0, MAX_TARGETS).map((l) => definitionTarget(l, doc.uiMode)))).filter(
(t): t is DefinitionTarget => !!t,
);
const targets = (
await Promise.all(locations.slice(0, MAX_TARGETS).map((l) => definitionTarget(l, doc.uiMode)))
).filter((t): t is DefinitionTarget => !!t);
if (!targets.length) {
showHint(view, "Cannot read the definition's file");
return;
}
openPopup({ kind: "definition", anchor: caretAnchor(view, pos), targets, index: 0, themeClasses: view.themeClasses });
openPopup({
kind: "definition",
anchor: caretAnchor(view, pos),
targets,
index: 0,
themeClasses: view.themeClasses,
});
} catch (e) {
reportLspError(view, e, true);
}
+15 -5
View File
@@ -14,6 +14,7 @@ import type { Markup } from "@/bindings";
import { maybeEditorService } from "@/workbench/contributions";
import { isTauri } from "@/platform/ipc";
import { isVirtualUri } from "@/platform/virtualDocs";
import { selectableText } from "@/ui/selectableText";
type Parser = typeof import("./markdownParser");
@@ -71,7 +72,10 @@ export function isEmptyMarkup(m: Markup | null | undefined): boolean {
/**
* The declaration line(s) of hover documentation, for the brief ⌘-hover info: the first fenced code block, else the first paragraph.
*/
export function declarationSummary(m: Markup, maxLines = 6): { code: string; language: string | null } | { text: string } | null {
export function declarationSummary(
m: Markup,
maxLines = 6,
): { code: string; language: string | null } | { text: string } | null {
if (m.kind === "markdown") {
const fence = /^\s*(```|~~~)[ \t]*([\w+#.-]*)[^\n]*\n([\s\S]*?)\n\s*\1/m.exec(m.value);
if (fence && fence[3]!.trim()) {
@@ -80,7 +84,10 @@ export function declarationSummary(m: Markup, maxLines = 6): { code: string; lan
return { code, language: fence[2] || null };
}
}
const para = m.value.trim().split(/\n\s*\n/)[0]?.trim();
const para = m.value
.trim()
.split(/\n\s*\n/)[0]
?.trim();
if (!para) return null;
return { text: para.length > 200 ? `${para.slice(0, 199)}…` : para };
}
@@ -187,7 +194,10 @@ export function sourceLink(href: string): { path: string; line: number; col: num
export function openDocLink(href: string) {
const target = sourceLink(href);
if (target) {
void maybeEditorService()?.openFile(target.path, { selection: { line: target.line, col: target.col }, focus: true });
void maybeEditorService()?.openFile(target.path, {
selection: { line: target.line, col: target.col },
focus: true,
});
} else if (/^https?:\/\//.test(href) && isTauri()) {
void import("@tauri-apps/plugin-opener").then((m) => m.openUrl(href)).catch(() => {});
}
@@ -210,7 +220,7 @@ function linkClickHandler(onLink?: DocLinkHandler) {
/** Renders markup into a `.cm-lsp-markdown` element. */
export function renderMarkup(m: Markup, fallbackLanguage: string | null = null, onLink?: DocLinkHandler): HTMLElement {
const dom = document.createElement("div");
dom.className = "cm-lsp-markdown";
dom.className = `cm-lsp-markdown ${selectableText}`;
dom.innerHTML = markupToHtml(m);
highlightBlocks(dom, fallbackLanguage);
if (m.kind === "markdown" && !parser) {
@@ -228,7 +238,7 @@ export function renderMarkup(m: Markup, fallbackLanguage: string | null = null,
/** Renders a code snippet (e.g. a signature or definition) as highlighted mono text. */
export function renderCode(text: string, language: string | null, className = "cm-lsp-code"): HTMLElement {
const pre = document.createElement("pre");
pre.className = className;
pre.className = `${className} ${selectableText}`;
const code = document.createElement("code");
code.textContent = text;
pre.appendChild(code);
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});
@@ -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"
+11 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}
+59
View File
@@ -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
View File
@@ -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);