дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
6 files changed
+196
-2
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
|
||||
@@ -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))",
|
||||
},
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача