mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
fix(editor): keep selections whole over line backgrounds and indent guides (#1568)
* fix(editor): keep selections whole over line backgrounds and indent guides Multi-line selections could show a 1px seam between their stacked rectangles, indent guides showed as short dashes inside a selection, and a breakpoint or execution line hid the selection past its text. - Snap selection rectangle edges to device pixels so stacked pieces share one edge. - Paint caret-row, breakpoint and execution line backgrounds on a pseudo-element below the selection layer. - Draw indent guides on a pseudo-element above the text. * test(e2e): check the visible selection layer in the theme flow
This commit is contained in:
+1
-1
@@ -111,7 +111,7 @@ for (const palette of palettes) {
|
||||
);
|
||||
await editor.locator(".cm-content").click();
|
||||
await page.keyboard.press("Meta+a");
|
||||
await expect(editor.locator(".cm-selectionBackground").first()).toBeVisible();
|
||||
await expect(editor.locator(".cm-selectionBackground:visible").first()).toBeVisible();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await page.keyboard.press("Meta+f");
|
||||
await page.getByRole("textbox", { name: "Find", exact: true }).fill("Order");
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "@codemirror/commands";
|
||||
import { markdownImages } from "./markdownImages";
|
||||
import { selectionMarks } from "./selectionMarks";
|
||||
import { seamlessSelection } from "./selectionLayer";
|
||||
import type { EditorExtras } from "@/platform/editorSettings";
|
||||
import { openideaHighlightStyle } from "./highlight";
|
||||
import {
|
||||
@@ -506,6 +507,7 @@ export function caretExtension(prefs: EditorExtras): Extension {
|
||||
a.smoothCaretMovement ? caretGlide : [],
|
||||
drawSelection({ cursorBlinkRate: a.caretBlinking ? Math.max(100, Math.min(5000, a.caretBlinkPeriod)) * 2 : 0 }),
|
||||
selectionMarks,
|
||||
seamlessSelection(),
|
||||
classes.length ? EditorView.editorAttributes.of({ class: classes.join(" ") }) : [],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RectangleMarker } from "@codemirror/view";
|
||||
import { snapMarkers } from "./selectionLayer";
|
||||
|
||||
describe("snapMarkers", () => {
|
||||
it("makes stacked selection rectangles share their edge exactly", () => {
|
||||
// Middle piece's bottom (top + height) and the last line's top differ by float error around x.25.
|
||||
const middle = new RectangleMarker("cm-selectionBackground", 6, 26.249999999, 500, 22.000000002);
|
||||
const last = new RectangleMarker("cm-selectionBackground", 6, 48.25, 40, 22);
|
||||
const [a, b] = snapMarkers([middle, last], "cm-selectionBackground", 2) as [RectangleMarker, RectangleMarker];
|
||||
expect(a.top + a.height).toBe(b.top);
|
||||
});
|
||||
|
||||
it("snaps edges to device pixels and keeps horizontal geometry", () => {
|
||||
const m = snapMarkers([new RectangleMarker("x", 6.3, 10.3, 100, 21.9)], "x", 2)[0]!;
|
||||
expect(m.top).toBe(10.5);
|
||||
expect(m.top + m.height).toBe(32);
|
||||
expect(m.left).toBe(6.3);
|
||||
expect(m.width).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { EditorView, RectangleMarker, layer } from "@codemirror/view";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
|
||||
/** Rounds a CSS length to the nearest device pixel. */
|
||||
export function snapToDevicePixel(v: number, dpr: number): number {
|
||||
return Math.round(v * dpr) / dpr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-emits selection rectangles with top and bottom edges snapped to device pixels. A multi-line
|
||||
* selection is drawn as several stacked rectangles; their shared edge comes from separate float
|
||||
* sums, and when it lands on a fractional pixel each side rounds differently and a 1px seam shows.
|
||||
*/
|
||||
export function snapMarkers(markers: readonly RectangleMarker[], className: string, dpr: number): RectangleMarker[] {
|
||||
return markers.map((m) => {
|
||||
const top = snapToDevicePixel(m.top, dpr);
|
||||
const bottom = snapToDevicePixel(m.top + m.height, dpr);
|
||||
return new RectangleMarker(className, m.left, top, m.width, bottom - top);
|
||||
});
|
||||
}
|
||||
|
||||
const CLASS = "cm-selectionBackground";
|
||||
|
||||
const snappedSelectionLayer = layer({
|
||||
above: false,
|
||||
class: "cm-selectionLayer",
|
||||
mount: (dom) => dom.classList.add("cm-openidea-selectionLayer"),
|
||||
markers(view) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const out: RectangleMarker[] = [];
|
||||
for (const r of view.state.selection.ranges)
|
||||
if (!r.empty) out.push(...snapMarkers(RectangleMarker.forRange(view, CLASS, r), CLASS, dpr));
|
||||
return out;
|
||||
},
|
||||
update: (u) => u.docChanged || u.selectionSet || u.viewportChanged,
|
||||
});
|
||||
|
||||
/** Use with `drawSelection()`: replaces its selection layer, which is hidden. */
|
||||
export function seamlessSelection(): Extension {
|
||||
return [
|
||||
snappedSelectionLayer,
|
||||
EditorView.theme({
|
||||
".cm-selectionLayer:not(.cm-openidea-selectionLayer)": {
|
||||
display: "none",
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -177,7 +177,17 @@ export const openideaTheme = EditorView.theme({
|
||||
"&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": {
|
||||
background: "var(--editor-selection)",
|
||||
},
|
||||
".cm-activeLine": { backgroundColor: "var(--editor-caret-row)" },
|
||||
// Row backgrounds go on a pseudo-element under the selection layer: on the line itself they would hide it.
|
||||
".cm-activeLine": { backgroundColor: "transparent", "--openidea-line-bg": "var(--editor-caret-row)" },
|
||||
".cm-activeLine, .cm-openidea-lineBg": { position: "relative" },
|
||||
".cm-activeLine::before, .cm-openidea-lineBg::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
inset: "0",
|
||||
zIndex: "-1000",
|
||||
background: "var(--openidea-line-bg)",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
".cm-selectionMatch": {
|
||||
backgroundColor: "var(--bg-identifier-under-caret, color-mix(in srgb, var(--fg) 9%, transparent))",
|
||||
},
|
||||
@@ -263,7 +273,13 @@ export const openideaTheme = EditorView.theme({
|
||||
".cm-foldPlaceholder:hover": { backgroundColor: "color-mix(in srgb, var(--fg-muted) 24%, transparent)" },
|
||||
|
||||
// Indent guides (cm/indentGuides.ts)
|
||||
".cm-openidea-guides": {
|
||||
// Above the text so a selection, or a mark's background, never covers part of a guide.
|
||||
".cm-openidea-guides": { position: "relative" },
|
||||
".cm-openidea-guides::after": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
inset: "0",
|
||||
pointerEvents: "none",
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(to right, var(--editor-indent-guide) 0 1px, transparent 1px calc(var(--openidea-guide-step) * 1ch))",
|
||||
backgroundSize: "calc(var(--openidea-guide-levels) * var(--openidea-guide-step) * 1ch) 100%",
|
||||
|
||||
@@ -84,7 +84,7 @@ class BreakpointMarker extends GutterMarker {
|
||||
}
|
||||
}
|
||||
|
||||
const bpLine = Decoration.line({ class: styles.bpLine });
|
||||
const bpLine = Decoration.line({ class: `${styles.bpLine} cm-openidea-lineBg` });
|
||||
const DISABLED_LOOKS = new Set<BreakpointLook>(["disabled", "muted", "methodDisabled", "fieldDisabled"]);
|
||||
|
||||
const setMarkers = StateEffect.define<RangeSet<BreakpointMarker>>();
|
||||
|
||||
@@ -126,11 +126,11 @@
|
||||
|
||||
/* Beat the caret row (`.cm-activeLine`), as IDEA does; the suspended line wins over a breakpoint. */
|
||||
:global(.cm-editor) :global(.cm-line).bpLine {
|
||||
background: var(--bg-breakpoint-line);
|
||||
--openidea-line-bg: var(--bg-breakpoint-line);
|
||||
}
|
||||
|
||||
:global(.cm-editor) :global(.cm-line).execLine {
|
||||
background: var(--bg-debug-line);
|
||||
--openidea-line-bg: var(--bg-debug-line);
|
||||
}
|
||||
|
||||
.inlineValue {
|
||||
|
||||
@@ -61,7 +61,7 @@ export function inlineValuesFor(text: string, values: Record<string, string>): s
|
||||
function lineDecorations(state: EditorState, exec: Exec | null): DecorationSet {
|
||||
if (!exec || exec.line >= state.doc.lines) return Decoration.none;
|
||||
const line = state.doc.line(exec.line + 1);
|
||||
return Decoration.set([Decoration.line({ class: styles.execLine }).range(line.from)]);
|
||||
return Decoration.set([Decoration.line({ class: `${styles.execLine} cm-openidea-lineBg` }).range(line.from)]);
|
||||
}
|
||||
|
||||
export function executionPoint(ctx: EditorDocContext): Extension {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { bracketMatching, indentOnInput, syntaxHighlighting } from "@codemirror/
|
||||
import { openideaTheme } from "@/editor/core/cm/theme";
|
||||
import { openideaHighlightStyle } from "@/editor/core/cm/highlight";
|
||||
import { selectionMarks } from "@/editor/core/cm/selectionMarks";
|
||||
import { seamlessSelection } from "@/editor/core/cm/selectionLayer";
|
||||
import { findLanguageDescription, loadSupport } from "@/editor/core/cm/languages";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type { NotebookSession } from "./session";
|
||||
@@ -45,6 +46,7 @@ export function CellEditor({
|
||||
lineNumbers(),
|
||||
drawSelection(),
|
||||
selectionMarks,
|
||||
seamlessSelection(),
|
||||
highlightActiveLine(),
|
||||
highlightSpecialChars(),
|
||||
bracketMatching(),
|
||||
|
||||
Reference in New Issue
Block a user