From 7856fabbfe8214c8307e1ba160711b1b2bfa0db1 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:50:25 +0800 Subject: [PATCH] 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. --- src/editor/core/cm/prefs.ts | 2 + src/editor/core/cm/selectionMarks.test.ts | 57 +++++++++++++++++++++++ src/editor/core/cm/selectionMarks.ts | 47 +++++++++++++++++++ src/features/notebook/CellEditor.tsx | 2 + src/features/vcs/cm.ts | 7 +-- 5 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 src/editor/core/cm/selectionMarks.test.ts create mode 100644 src/editor/core/cm/selectionMarks.ts diff --git a/src/editor/core/cm/prefs.ts b/src/editor/core/cm/prefs.ts index 333a054b..81fd80ee 100644 --- a/src/editor/core/cm/prefs.ts +++ b/src/editor/core/cm/prefs.ts @@ -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(" ") }) : [], ]; } diff --git a/src/editor/core/cm/selectionMarks.test.ts b/src/editor/core/cm/selectionMarks.test.ts new file mode 100644 index 00000000..7c3c2e77 --- /dev/null +++ b/src/editor/core/cm/selectionMarks.test.ts @@ -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(); + }); +}); diff --git a/src/editor/core/cm/selectionMarks.ts b/src/editor/core/cm/selectionMarks.ts new file mode 100644 index 00000000..eadee2ce --- /dev/null +++ b/src/editor/core/cm/selectionMarks.ts @@ -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(); + 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]; diff --git a/src/features/notebook/CellEditor.tsx b/src/features/notebook/CellEditor.tsx index 627ea58f..9089b89f 100644 --- a/src/features/notebook/CellEditor.tsx +++ b/src/features/notebook/CellEditor.tsx @@ -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(), diff --git a/src/features/vcs/cm.ts b/src/features/vcs/cm.ts index 131c06af..07ae5810 100644 --- a/src/features/vcs/cm.ts +++ b/src/features/vcs/cm.ts @@ -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)",