fix(editor): keep selected text visible over highlighted ranges (#1454)

The selection is drawn in a layer behind the text, so any range with its own background (find
matches, warnings in light schemes, usages, semantic backgrounds) hid it: selected text there
kept its yellow or other highlight. Selected text is now also marked in the innermost span with
the selection colour, in text editors, notebook cells and the VCS viewers.
This commit is contained in:
l0ng-ai
2026-11-05 12:50:25 +08:00
committed by GitHub
parent 4a90f124c6
commit 7856fabbfe
5 changed files with 112 additions and 3 deletions
+2
View File
@@ -34,6 +34,7 @@ import {
selectLineStart,
} from "@codemirror/commands";
import { markdownImages } from "./markdownImages";
import { selectionMarks } from "./selectionMarks";
import type { EditorExtras } from "@/platform/editorSettings";
import { openideaHighlightStyle } from "./highlight";
import {
@@ -504,6 +505,7 @@ export function caretExtension(prefs: EditorExtras): Extension {
// Before drawSelection: its measure request must run before the cursor layer redraws.
a.smoothCaretMovement ? caretGlide : [],
drawSelection({ cursorBlinkRate: a.caretBlinking ? Math.max(100, Math.min(5000, a.caretBlinkPeriod)) * 2 : 0 }),
selectionMarks,
classes.length ? EditorView.editorAttributes.of({ class: classes.join(" ") }) : [],
];
}
+57
View File
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from "vitest";
import { EditorSelection, EditorState, Prec, type Extension } from "@codemirror/state";
import { Decoration, EditorView } from "@codemirror/view";
import { selectionMarks } from "./selectionMarks";
const views: EditorView[] = [];
afterEach(() => views.splice(0).forEach((v) => v.destroy()));
function view(doc: string, selection: EditorSelection, extensions: Extension) {
extensions = [EditorState.allowMultipleSelections.of(true), extensions];
const parent = document.createElement("div");
document.body.appendChild(parent);
const v = new EditorView({ state: EditorState.create({ doc, selection, extensions }), parent });
views.push(v);
return v;
}
/** A background mark as features add it (lint range, find match, usage, semantic bg…). */
const backgroundMark = (cls: string, from: number, to: number, prec: (e: Extension) => Extension = (e) => e) =>
prec(EditorView.decorations.of(Decoration.set(Decoration.mark({ class: cls }).range(from, to))));
const DOC = "return request.app.state.credits";
describe("selectionMarks", () => {
it.each([
["default", (e: Extension) => e],
["high (Lezer highlighter)", Prec.high],
["lowest", Prec.lowest],
])("paints selected text inside a %s-precedence background mark", (_, prec) => {
// The reporter's case: a light-scheme warning background over `request.app.state.credits`.
const v = view(DOC, EditorSelection.single(7, DOC.length), [
backgroundMark("cm-lintRange-warning", 7, DOC.length, prec),
selectionMarks,
]);
const selected = [...v.contentDOM.querySelectorAll(".cm-openidea-selected")];
expect(selected.map((e) => e.textContent).join("")).toBe(DOC.slice(7));
for (const el of selected) expect(el.closest(".cm-lintRange-warning")).not.toBeNull();
});
it("covers every range of a multi-selection, partially overlapping marks too", () => {
const v = view(DOC, EditorSelection.create([EditorSelection.range(0, 3), EditorSelection.range(10, 20)], 1), [
backgroundMark("cm-openidea-findMatch", 15, 24),
selectionMarks,
]);
const text = [...v.contentDOM.querySelectorAll(".cm-openidea-selected")].map((e) => e.textContent).join("|");
expect(text.replace(/\|/g, "")).toBe(DOC.slice(0, 3) + DOC.slice(10, 20));
const inMatch = [...v.contentDOM.querySelectorAll(".cm-openidea-findMatch .cm-openidea-selected")];
expect(inMatch.map((e) => e.textContent).join("")).toBe(DOC.slice(15, 20));
});
it("adds nothing for an empty selection and clears when the selection collapses", () => {
const v = view(DOC, EditorSelection.single(0, 6), [selectionMarks]);
expect(v.contentDOM.querySelector(".cm-openidea-selected")).not.toBeNull();
v.dispatch({ selection: EditorSelection.cursor(3) });
expect(v.contentDOM.querySelector(".cm-openidea-selected")).toBeNull();
});
});
+47
View File
@@ -0,0 +1,47 @@
import { Prec, RangeSetBuilder, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view";
/**
* `drawSelection` paints the selection in a layer behind the text, so any mark with its own
* background (light-scheme warnings, find matches, usages, semantic backgrounds…) hides it.
* This repaints selected text in the innermost span: higher-precedence marks nest closer to the
* text, and the only other `Prec.highest` marks (LSP semantic tokens) set colour, not background.
*/
const selectedMark = Decoration.mark({ class: "cm-openidea-selected" });
function selectedRanges(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const ranges = view.state.selection.ranges.filter((r) => !r.empty);
if (!ranges.length) return Decoration.none;
for (const { from: vFrom, to: vTo } of view.visibleRanges)
for (const r of ranges) {
const from = Math.max(r.from, vFrom);
const to = Math.min(r.to, vTo);
if (from < to) builder.add(from, to, selectedMark);
}
return builder.finish();
}
const plugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = selectedRanges(view);
}
update(u: ViewUpdate) {
if (u.selectionSet || u.docChanged || u.viewportChanged) this.decorations = selectedRanges(u.view);
}
},
{ decorations: (p) => p.decorations },
);
const theme = EditorView.baseTheme({
".cm-openidea-selected": {
backgroundColor: "var(--editor-selection-inactive, var(--bg-editor-selection-inactive))",
},
"&.cm-focused .cm-openidea-selected": {
backgroundColor: "var(--editor-selection, var(--bg-editor-selection))",
},
});
export const selectionMarks: Extension = [Prec.highest(plugin), theme];
+2
View File
@@ -12,6 +12,7 @@ import { defaultKeymap, indentWithTab } from "@codemirror/commands";
import { bracketMatching, indentOnInput, syntaxHighlighting } from "@codemirror/language";
import { openideaTheme } from "@/editor/core/cm/theme";
import { openideaHighlightStyle } from "@/editor/core/cm/highlight";
import { selectionMarks } from "@/editor/core/cm/selectionMarks";
import { findLanguageDescription, loadSupport } from "@/editor/core/cm/languages";
import { tx } from "@/platform/i18n";
import type { NotebookSession } from "./session";
@@ -43,6 +44,7 @@ export function CellEditor({
syntaxHighlighting(openideaHighlightStyle),
lineNumbers(),
drawSelection(),
selectionMarks,
highlightActiveLine(),
highlightSpecialChars(),
bracketMatching(),
+4 -3
View File
@@ -270,9 +270,10 @@ export const viewerTheme = EditorView.theme(
"&.cm-openidea-ligatures .cm-scroller": { fontVariantLigatures: "normal" },
".cm-content": { caretColor: "var(--caret, var(--fg-strong))", WebkitUserSelect: "text", userSelect: "text" },
...caretStyles,
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, ::selection": {
backgroundColor: "var(--bg-selected-strong)",
},
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, ::selection, .cm-openidea-selected, &.cm-focused .cm-openidea-selected":
{
backgroundColor: "var(--bg-selected-strong)",
},
".cm-activeLine": { backgroundColor: "var(--bg-current-line)" },
".cm-gutters": {
backgroundColor: "var(--bg-app)",