mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00: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
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user