fix(editor): keep the corner overlay from covering line ends (#2289)

The inspection widget and the Review Mode group float in `.cm-editor`,
outside `.cm-scroller`: they neither scroll with the text nor count in
`scrollWidth`, so at full horizontal scroll the last characters of a line
landed under them.

Reserve the corner's measured width in the lines' right padding, next to
the room the fixed padding already keeps for the overview lane.

Co-authored-by: yhqian <[email protected]>
Co-authored-by: Claude Code <[email protected]>
Цей коміт міститься в:
authored and GitHub committed 2026-11-09 15:20:49 +08:00
1 джерело 1a008fd17e
коміт e77ee39047
6 files changed
+196 -2

No files matched your search

+32
Переглянути файл
@@ -0,0 +1,32 @@
import { expect, openFromTree, test } from "./fixtures";
/** The end of a long line must stay visible: the floating corner widgets leave it room. */
test("the inspection widget does not cover the end of a long line", async ({ workbench: page }) => {
const editor = await openFromTree(page, "build.gradle.kts");
const content = editor.locator(".cm-content");
await content.click();
await page.keyboard.press("Meta+a");
await page.keyboard.insertText("x".repeat(600));
await expect(editor.locator(".cm-line")).toHaveCount(1);
await expect(editor.locator(".cm-openidea-corner")).toBeAttached();
const scroller = editor.locator(".cm-scroller");
await expect.poll(() => scroller.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeGreaterThan(0);
await scroller.evaluate((el) => {
el.scrollLeft = el.scrollWidth;
});
const rects = await editor.evaluate((el) => {
const line = el.querySelector(".cm-line")!;
const range = document.createRange();
range.selectNodeContents(line);
return {
textRight: range.getBoundingClientRect().right,
cornerLeft: el.querySelector(".cm-openidea-corner")!.getBoundingClientRect().left,
scrollLeft: el.querySelector(".cm-scroller")!.scrollLeft,
};
});
// Scrolled as far right as it goes, and the last character sits left of the widget.
expect(rects.scrollLeft).toBeGreaterThan(0);
expect(rects.cornerLeft).toBeGreaterThan(rects.textRight);
});
+3
Переглянути файл
@@ -33,6 +33,7 @@ import type { LanguageDescription } from "@codemirror/language";
import type { LineEnding } from "@/platform/ipc";
import { create } from "zustand";
import { maybeEditorService } from "@/workbench/contributions";
import { cornerInset } from "@/editor/core/cm/cornerInset";
/** Documents above this size (UTF-16 units) skip per-line decorations. */
export const HEAVY_LIMIT = 8_000_000;
@@ -358,6 +359,8 @@ export function editorCorner(view: EditorView): HTMLElement {
gap: "4px",
});
view.dom.appendChild(el);
// The lines leave room for its measured width, so it never covers a line end.
view.plugin(cornerInset)?.track(el);
return el;
}
+73
Переглянути файл
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { editorCorner } from "@/editor/api";
import { cornerInset } from "./cornerInset";
let view: EditorView;
let resize: (() => void) | null = null;
beforeEach(() => {
vi.stubGlobal(
"ResizeObserver",
class {
constructor(callback: () => void) {
resize = callback;
}
observe() {}
disconnect() {
resize = null;
}
},
);
view = new EditorView({
state: EditorState.create({ doc: "const a = 1;", extensions: [cornerInset] }),
parent: document.body,
});
});
afterEach(() => {
view.destroy();
vi.unstubAllGlobals();
});
const inset = () => view.dom.style.getPropertyValue("--cm-corner-inset");
/** The corner's measured width arrives from the ResizeObserver, not from jsdom. */
function resizeTo(width: number) {
const corner = view.dom.querySelector<HTMLElement>(".cm-openidea-corner");
if (!corner) throw new Error("no corner");
corner.getBoundingClientRect = () => ({ width }) as DOMRect;
resize?.();
}
describe("corner inset", () => {
it("leaves the corner's width plus a gap for the text: 20px check, 32+ with counts", () => {
editorCorner(view);
resizeTo(20);
expect(inset()).toBe("24px");
resizeTo(32);
expect(inset()).toBe("36px");
});
it("clears the reserved room when the corner is empty", () => {
editorCorner(view);
resizeTo(20);
resizeTo(0);
expect(inset()).toBe("");
});
it("keeps at least half of the editor width for the text", () => {
Object.defineProperty(view.scrollDOM, "clientWidth", { configurable: true, value: 200 });
editorCorner(view);
resizeTo(400);
expect(inset()).toBe("100px");
});
it("does nothing without the extension", () => {
const plain = new EditorView({ state: EditorState.create({ doc: "x" }), parent: document.body });
expect(() => editorCorner(plain)).not.toThrow();
expect(plain.dom.style.getPropertyValue("--cm-corner-inset")).toBe("");
plain.destroy();
});
});
+81
Переглянути файл
@@ -0,0 +1,81 @@
/**
* Keeps the end of a line clear of the floating corner widgets (`editorCorner()`
* in `editor/api.ts`: the inspection widget, the Review Mode group).
*
* The corner is absolutely positioned in `.cm-editor`, outside `.cm-scroller`:
* scrolling does not move it and it does not count in `scrollWidth`, so at full
* horizontal scroll the last characters of a line land under it. Its width is
* not a constant (a green check, then counts and previous/next buttons as
* problems appear), so the lines' right padding follows its measured width
* through `--cm-corner-inset` (`theme.ts`), the way the fixed line padding
* already reserves room for the overview lane.
*/
import { type EditorView, ViewPlugin } from "@codemirror/view";
/** Space between the corner's left edge and the end of a line. */
const CORNER_GAP = 4;
/** Width changes of the corner that matter: `getBoundingClientRect()` is fractional. */
const round = (width: number) => Math.ceil(width);
class CornerInset {
private corner: HTMLElement | null = null;
private observer: ResizeObserver | null = null;
private inset = -1;
constructor(private readonly view: EditorView) {
this.sync();
}
/** A new corner: `editorCorner()` calls this right after appending it. */
track(corner: HTMLElement) {
if (corner === this.corner) return;
this.observer?.disconnect();
this.corner = corner;
if (typeof ResizeObserver === "undefined") return;
// The widget's content grows and shrinks (diagnostic counts, Review Mode).
this.observer = new ResizeObserver(() => this.measure());
this.observer.observe(corner);
this.measure();
}
update() {
// The corner is created lazily, possibly after this plugin: catch it on the next update.
if (!this.corner) this.sync();
}
private sync() {
const corner = this.view.dom.querySelector<HTMLElement>(":scope > .cm-openidea-corner");
if (corner) this.track(corner);
}
private measure() {
const corner = this.corner;
if (!corner) return;
const width = corner.getBoundingClientRect().width;
let inset = width > 0 ? round(width) + CORNER_GAP : 0;
// A narrow window or a wide corner: keep at least half of the width for text.
const room = this.view.scrollDOM.clientWidth;
if (inset > 0 && room > 0) inset = Math.min(inset, Math.floor(room / 2));
this.apply(inset);
}
private apply(inset: number) {
if (inset === this.inset) return;
this.inset = inset;
const style = this.view.dom.style;
// An empty corner needs no room; the `theme.ts` fallback is 0.
if (inset > 0) style.setProperty("--cm-corner-inset", `${inset}px`);
else style.removeProperty("--cm-corner-inset");
// The line boxes changed: CodeMirror re-measures the content and the scroll extent.
this.view.requestMeasure();
}
destroy() {
this.observer?.disconnect();
this.observer = null;
this.corner = null;
}
}
export const cornerInset = ViewPlugin.fromClass(CornerInset);
+2
Переглянути файл
@@ -34,6 +34,7 @@ import { openideaHighlightStyle } from "./highlight";
import { openideaTheme } from "./theme";
import { caretRowHighlight } from "./caretRow";
import { changeLane, overviewLane } from "./lanes";
import { cornerInset } from "./cornerInset";
import { foldingExtension } from "./folding";
import { syntaxHighlightField } from "./syntaxHighlight";
import { indentGuides } from "./indentGuides";
@@ -370,6 +371,7 @@ export function baseExtensions(o: SetupOptions): Extension {
methodLinesField,
findExtension(),
overviewLane,
cornerInset,
codeLensExtension,
EditorView.contentAttributes.of({
spellcheck: "false",
+5 -2
Переглянути файл
@@ -153,7 +153,8 @@ export const openideaTheme = EditorView.theme({
caretColor: "var(--caret, var(--fg-strong))",
cursor: "text",
},
".cm-line": { padding: "0 16px 0 6px" },
// The right side also leaves room for the floating corner widgets (core/cm/cornerInset.ts).
".cm-line": { padding: "0 calc(16px + var(--cm-corner-inset, 0px)) 0 6px" },
...caretStyles,
"&.cm-openidea-ligatures .cm-scroller, &.cm-openidea-ligatures .cm-openidea-sticky": {
fontVariantLigatures: "normal",
@@ -370,5 +371,7 @@ export const openideaTheme = EditorView.theme({
".cm-openidea-stickyText": { flex: "1", overflow: "hidden" },
// Soft-wrap: continuation rows indent slightly, like IDEA's wrap sign column.
"&.cm-lineWrapping .cm-line, .cm-lineWrapping .cm-line": { paddingRight: "24px" },
"&.cm-lineWrapping .cm-line, .cm-lineWrapping .cm-line": {
paddingRight: "calc(24px + var(--cm-corner-inset, 0px))",
},
});