diff --git a/e2e/overviewLane.spec.ts b/e2e/overviewLane.spec.ts new file mode 100644 index 00000000..6c51df7c --- /dev/null +++ b/e2e/overviewLane.spec.ts @@ -0,0 +1,30 @@ +import { expect, openFromTree, test } from "./fixtures"; + +// Headless Chromium hides scrollbars by default; this test drags one. +test.use({ launchOptions: { ignoreDefaultArgs: ["--hide-scrollbars"] } }); + +test("the scrollbar thumb drags even where overview marks cover it (#752)", async ({ workbench: page }) => { + const editor = await openFromTree(page, "build.gradle.kts"); + await editor.locator(".cm-content").click(); + await page.keyboard.press("Meta+a"); + await page.keyboard.insertText(Array.from({ length: 400 }, (_, i) => `val mark${i} = ${i}`).join("\n")); + // A match on every line covers the overview lane, as a new file's change mark does. + await page.keyboard.press("Meta+f"); + await page.getByRole("textbox", { name: "Find" }).fill("mark"); + await expect(editor.locator(".cm-openidea-overviewMark").first()).toBeVisible(); + const scroller = editor.locator(".cm-scroller"); + await scroller.evaluate((s) => (s.scrollTop = 0)); + const box = (await scroller.boundingBox())!; + // Over a mark and inside the thumb, which is at the top of the track. + const x = box.x + box.width - 5; + const y = box.y + 12; + await page.mouse.move(x - 40, y); + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x, y + 150, { steps: 10 }); + await page.mouse.up(); + expect(await scroller.evaluate((s) => s.scrollTop)).toBeGreaterThan(0); + // Away from the thumb the marks take the pointer again. + await page.mouse.move(x, box.y + box.height - 20); + await expect(editor.locator(".cm-openidea-overview")).not.toHaveClass(/cm-openidea-overview-onThumb/); +}); diff --git a/src/editor/core/cm/ideaParity.test.ts b/src/editor/core/cm/ideaParity.test.ts index 38640f8f..bad82ca4 100644 --- a/src/editor/core/cm/ideaParity.test.ts +++ b/src/editor/core/cm/ideaParity.test.ts @@ -23,7 +23,7 @@ import { foldSelectionEffects, } from "./folding"; import { columnModeExtension, extendBlock, toggleColumnMode } from "./columnMode"; -import { nearestMark, unfoldAround } from "./lanes"; +import { nearestMark, scrollbarThumb, unfoldAround } from "./lanes"; import { lineRangeSelection } from "./gutterDrag"; import { matchBraceTarget } from "./caretCommands"; import { overwriteField, toggleOverwrite } from "./overwrite"; @@ -291,6 +291,16 @@ describe("error stripe", () => { expect(nearestMark(marks, 21)).toBeNull(); }); + it("finds the scrollbar thumb the marks must let through", () => { + // Nothing to scroll: no thumb. + expect(scrollbarThumb(0, 400, 400)).toBeNull(); + // A quarter of the document visible: a 100px thumb, at the top, then at the bottom. + expect(scrollbarThumb(0, 1600, 400)).toEqual({ top: 0, bottom: 100 }); + expect(scrollbarThumb(1200, 1600, 400)).toEqual({ top: 300, bottom: 400 }); + // A long document keeps the 32px minimum thumb; halfway down it sits mid-track. + expect(scrollbarThumb(49800, 100000, 400)).toEqual({ top: 184, bottom: 216 }); + }); + it("unfolds what hides the target line", () => { const s = EditorState.create({ doc: "f {\n x\n}", extensions: [codeFolding()] }).update({ effects: foldEffect.of({ from: 3, to: 8 }), diff --git a/src/editor/core/cm/lanes.ts b/src/editor/core/cm/lanes.ts index cb876831..9e6ac7c6 100644 --- a/src/editor/core/cm/lanes.ts +++ b/src/editor/core/cm/lanes.ts @@ -27,6 +27,27 @@ export function nearestMark(marks: re return best; } +/** The smallest scrollbar thumb (`::-webkit-scrollbar-thumb` min-height in global.css). */ +const MIN_THUMB = 32; + +/** + * Where the vertical scrollbar thumb of a scroller is, in px from the top of its track, or null + * when nothing scrolls. The overview lane lies over the scrollbar, so its marks must not take + * the pointer where the thumb is (#752). + */ +export function scrollbarThumb( + scrollTop: number, + scrollHeight: number, + clientHeight: number, + minThumb = MIN_THUMB, +): { top: number; bottom: number } | null { + const max = scrollHeight - clientHeight; + if (max <= 0 || clientHeight <= 0) return null; + const length = Math.min(clientHeight, Math.max(minThumb, (clientHeight * clientHeight) / scrollHeight)); + const top = (Math.min(Math.max(scrollTop, 0), max) / max) * (clientHeight - length); + return { top, bottom: top + length }; +} + /** Unfolds the folds hiding `pos` or the rest of its line (navigating to a mark shows it). */ export function unfoldAround(state: EditorState, pos: number): StateEffect[] { const lineEnd = state.doc.lineAt(pos).to; @@ -41,12 +62,17 @@ class OverviewLane { dom: HTMLElement; private scheduled = false; private lastKey = ""; + /** The last pointer position over the editor, to re-check the thumb when it scrolls. */ + private pointer: { x: number; y: number } | null = null; constructor(private view: EditorView) { this.dom = document.createElement("div"); this.dom.className = "cm-openidea-overview"; this.dom.setAttribute("aria-hidden", "true"); this.dom.addEventListener("mousedown", this.onMouseDown); + view.dom.addEventListener("mousemove", this.onPointerMove); + view.dom.addEventListener("mouseleave", this.onPointerLeave); + view.scrollDOM.addEventListener("scroll", this.onScroll, { passive: true }); view.dom.appendChild(this.dom); this.schedule(); } @@ -132,6 +158,38 @@ class OverviewLane { }); } + /** + * Over the scrollbar thumb the marks let the pointer through, so the thumb can be dragged + * even where a mark covers it (a new file is one mark the height of the lane, #752). + */ + private updateThumbPassThrough() { + const p = this.pointer; + let over = false; + if (p) { + const lane = this.dom.getBoundingClientRect(); + const s = this.view.scrollDOM; + const thumb = scrollbarThumb(s.scrollTop, s.scrollHeight, s.clientHeight); + const y = p.y - s.getBoundingClientRect().top; + over = !!thumb && p.x >= lane.left && p.x <= lane.right && y >= thumb.top && y <= thumb.bottom; + } + this.dom.classList.toggle("cm-openidea-overview-onThumb", over); + } + + private onPointerMove = (e: MouseEvent) => { + this.pointer = { x: e.clientX, y: e.clientY }; + // A thumb drag in progress keeps the marks out of the way until the button is released. + if (e.buttons === 0) this.updateThumbPassThrough(); + }; + + private onPointerLeave = () => { + this.pointer = null; + this.updateThumbPassThrough(); + }; + + private onScroll = () => { + if (this.pointer) this.updateThumbPassThrough(); + }; + private onMouseDown = (e: MouseEvent) => { if (e.button !== 0) return; const marks = [...this.dom.querySelectorAll(".cm-openidea-overviewMark")].map((el) => { @@ -153,6 +211,9 @@ class OverviewLane { destroy() { this.dom.removeEventListener("mousedown", this.onMouseDown); + this.view.dom.removeEventListener("mousemove", this.onPointerMove); + this.view.dom.removeEventListener("mouseleave", this.onPointerLeave); + this.view.scrollDOM.removeEventListener("scroll", this.onScroll); this.dom.remove(); } } diff --git a/src/editor/core/cm/theme.ts b/src/editor/core/cm/theme.ts index 0a95f917..0390d1f1 100644 --- a/src/editor/core/cm/theme.ts +++ b/src/editor/core/cm/theme.ts @@ -283,6 +283,8 @@ export const openideaTheme = EditorView.theme({ cursor: "pointer", }, ".cm-openidea-overviewMark-thin": { left: "4px", width: "4px", minHeight: "2px" }, + // The scrollbar thumb wins over the marks under it (cm/lanes.ts, #752). + ".cm-openidea-overview-onThumb .cm-openidea-overviewMark": { pointerEvents: "none" }, ".cm-openidea-overviewCaret": { position: "absolute", left: "2px",