From 3046aa5991dd11ac795317d9b12c1239898f5cdb Mon Sep 17 00:00:00 2001 From: PathGao Date: Tue, 22 Sep 2026 02:27:25 +0800 Subject: [PATCH] fix(editor): the TOC button keeps a gutter to stand on with line numbers off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.toc-toggle-floating` is positioned against the pane — 8px from its left edge, 28px wide — so what it floats over is the line-number gutter. Measured on this Monaco build, contentLeft is 68px with line numbers on (42px of gutter, 26px of line decorations) and 26px with them off, which dropped the button onto the first 10px of every line (#810). Monaco reserves the gutter on `renderType !== Off`, and a render function validates as `Custom`: `() => ''` keeps the width and draws nothing in it, so "off" now lays out exactly as "on" does, minus the digits. --- scripts/editorOptionWiring.test.ts | 22 ++++++++++++++++++++++ src/lib/utils/editorOptions.ts | 20 +++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/scripts/editorOptionWiring.test.ts b/scripts/editorOptionWiring.test.ts index f0e3e064..f80d096e 100644 --- a/scripts/editorOptionWiring.test.ts +++ b/scripts/editorOptionWiring.test.ts @@ -752,3 +752,25 @@ test('space-delimited languages are left exactly as they were', () => { ); } }); + +test('turning line numbers off keeps the gutter the TOC button stands on', () => { + // #810: the floating table-of-contents button is positioned against the + // pane, and what it floats over is the line-number gutter. `'off'` is the + // one `lineNumbers` value that collapses that gutter, which dropped the + // button onto the text; a render function validates as `Custom` instead, so + // Monaco keeps the width and this draws nothing in it. The assertion is + // therefore that 'off' does NOT reach Monaco as 'off'. + const off = editorOptionsFromSettings({ ...SETTINGS, lineNumbers: 'off' }, 100).lineNumbers; + assert.equal(typeof off, 'function'); + assert.equal((off as (lineNumber: number) => string)(7), ''); + + // One value, not a closure per call: `editorOptionsFromSettings` runs in an + // effect, and a fresh arrow would be a changed option on every + // `updateOptions`. + assert.equal(off, editorOptionsFromSettings({ ...SETTINGS, lineNumbers: 'off' }, 100).lineNumbers); + + // The modes that already reserved the gutter are passed through untouched. + for (const lineNumbers of ['on', 'relative', 'interval']) { + assert.equal(editorOptionsFromSettings({ ...SETTINGS, lineNumbers }, 100).lineNumbers, lineNumbers); + } +}); diff --git a/src/lib/utils/editorOptions.ts b/src/lib/utils/editorOptions.ts index 7aa9f848..00103082 100644 --- a/src/lib/utils/editorOptions.ts +++ b/src/lib/utils/editorOptions.ts @@ -2,6 +2,21 @@ import type { editor as MonacoEditor } from "monaco-editor"; import { animatesCursor, animatesJumpScroll } from "./motion.js"; +/** + * "Line Numbers: off" without giving up the gutter they sit in. + * + * Monaco reserves that gutter on `renderType !== Off`, and a render function + * validates as `Custom` — so an empty string keeps the width and draws nothing + * in it. `'off'` is the one value that collapses it, and the floating table of + * contents button stands on what it collapsed (#810). + * + * A module constant rather than an inline arrow because + * `editorOptionsFromSettings` runs inside an effect: a new closure per call is + * a new option value, and Monaco would see this option change on every + * `updateOptions`. + */ +const BLANK_LINE_NUMBER = () => ""; + /** * The Monaco options derived from the settings store, in one place. * @@ -41,7 +56,10 @@ export function editorOptionsFromSettings( // of the window edge (#758). 'advanced' measures in the DOM and is slow // on large files, so only a proportional font pays for it. wrappingStrategy: fontIsMonospace ? "simple" : "advanced", - lineNumbers: settings.lineNumbers as "on" | "off" | "relative" | "interval", + lineNumbers: + settings.lineNumbers === "off" + ? BLANK_LINE_NUMBER + : (settings.lineNumbers as "on" | "relative" | "interval"), // A Monaco string enum, not a flag. Any non-empty string is truthy, so // a ternary on it can only ever produce "line" — which defeats both the // line-highlight toggle and Zen mode, whose whole effect is 'none'.