From 4ec53a296a121e1000de737d91ecc926def423ee Mon Sep 17 00:00:00 2001 From: Marius Popa Date: Wed, 3 Jun 2026 07:40:19 +0300 Subject: [PATCH] Fix document editor scrollbar and line-number sync Fixes #1501 Fixes #1496 --- static/js/document.js | 171 +++++++++++++++++++++++++-- static/style.css | 57 ++++++++- tests/test_document_editor_scroll.py | 49 ++++++++ 3 files changed, 264 insertions(+), 13 deletions(-) create mode 100644 tests/test_document_editor_scroll.py diff --git a/static/js/document.js b/static/js/document.js index 7d0870e..6696d60 100644 --- a/static/js/document.js +++ b/static/js/document.js @@ -6322,13 +6322,170 @@ import * as Modals from './modalManager.js'; } /** Update the line number gutter */ - function updateLineNumbers(text) { + let _lineNumberResizeObserver = null; + let _lineNumberObservedTextarea = null; + let _lineNumberResizeRaf = null; + + function _lineNumberContentEl(gutter) { + let inner = gutter.querySelector('.doc-line-number-content'); + if (!inner) { + inner = document.createElement('div'); + inner.className = 'doc-line-number-content'; + gutter.textContent = ''; + gutter.appendChild(inner); + } + return inner; + } + + function _lineNumberStyleSignature(style) { + return [ + style.fontFamily, + style.fontSize, + style.fontWeight, + style.fontStyle, + style.lineHeight, + style.letterSpacing, + style.tabSize, + style.fontFeatureSettings, + style.fontVariantLigatures, + style.fontKerning, + ].join('|'); + } + + function _textareaTextWidth(textarea, style) { + const paddingLeft = parseFloat(style.paddingLeft) || 0; + const paddingRight = parseFloat(style.paddingRight) || 0; + return Math.max(0, textarea.clientWidth - paddingLeft - paddingRight); + } + + function _lineHeightPx(style) { + const parsed = parseFloat(style.lineHeight); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + const fontSize = parseFloat(style.fontSize) || 11; + return fontSize * 1.45; + } + + function _lineNumberMeasureEl(textarea) { + const wrap = document.getElementById('doc-editor-wrap') || textarea.parentElement || document.body; + let probe = wrap.querySelector('.doc-line-number-measure'); + if (!probe) { + probe = document.createElement('textarea'); + probe.className = 'doc-line-number-measure'; + probe.setAttribute('aria-hidden', 'true'); + probe.tabIndex = -1; + probe.readOnly = true; + probe.wrap = 'soft'; + wrap.appendChild(probe); + } + return probe; + } + + function _syncLineNumberMeasureStyle(probe, style, textWidth) { + probe.style.width = textWidth + 'px'; + probe.style.fontFamily = style.fontFamily; + probe.style.fontSize = style.fontSize; + probe.style.fontWeight = style.fontWeight; + probe.style.fontStyle = style.fontStyle; + probe.style.lineHeight = style.lineHeight; + probe.style.letterSpacing = style.letterSpacing; + probe.style.tabSize = style.tabSize; + probe.style.fontFeatureSettings = style.fontFeatureSettings; + probe.style.fontVariantLigatures = style.fontVariantLigatures; + probe.style.fontKerning = style.fontKerning; + probe.style.textRendering = style.textRendering; + probe.style.whiteSpace = style.whiteSpace; + probe.style.wordWrap = style.wordWrap; + probe.style.overflowWrap = style.overflowWrap; + } + + function _measureLineNumberHeights(textarea, lines, textWidth, style) { + const probe = _lineNumberMeasureEl(textarea); + _syncLineNumberMeasureStyle(probe, style, textWidth); + const lineHeight = _lineHeightPx(style); + return lines.map(line => { + probe.value = line || ' '; + const visualRows = Math.max(1, Math.round(probe.scrollHeight / lineHeight)); + return visualRows * lineHeight; + }); + } + + function _renderLineNumberRows(inner, heights) { + const frag = document.createDocumentFragment(); + for (let i = 0; i < heights.length; i++) { + const row = document.createElement('div'); + row.className = 'doc-line-number-row'; + row.style.height = `${heights[i]}px`; + + const label = document.createElement('span'); + label.className = 'doc-line-number-label'; + label.textContent = String(i + 1); + row.appendChild(label); + frag.appendChild(row); + } + inner.textContent = ''; + inner.appendChild(frag); + } + + function _scheduleLineNumberRerender() { + if (_lineNumberResizeRaf) return; + const run = () => { + _lineNumberResizeRaf = null; + const textarea = document.getElementById('doc-editor-textarea'); + if (textarea) updateLineNumbers(textarea.value, true); + }; + if (typeof requestAnimationFrame === 'function') { + _lineNumberResizeRaf = requestAnimationFrame(run); + } else { + run(); + } + } + + function _ensureLineNumberResizeObserver(textarea) { + if (typeof ResizeObserver === 'undefined') return; + if (!_lineNumberResizeObserver) { + _lineNumberResizeObserver = new ResizeObserver(_scheduleLineNumberRerender); + } + if (_lineNumberObservedTextarea === textarea) return; + if (_lineNumberObservedTextarea) { + _lineNumberResizeObserver.unobserve(_lineNumberObservedTextarea); + } + _lineNumberObservedTextarea = textarea; + _lineNumberResizeObserver.observe(textarea); + } + + if (typeof window !== 'undefined') { + window.addEventListener('resize', _scheduleLineNumberRerender); + } + + function updateLineNumbers(text, force = false) { + const textarea = document.getElementById('doc-editor-textarea'); const gutter = document.getElementById('doc-line-numbers'); - if (!gutter) return; - const count = (text || '').split('\n').length; - let html = ''; - for (let i = 1; i <= count; i++) html += i + '\n'; - gutter.textContent = html; + if (!textarea || !gutter) return; + + const value = text || ''; + const lines = value.split('\n'); + const inner = _lineNumberContentEl(gutter); + const style = getComputedStyle(textarea); + const textWidth = _textareaTextWidth(textarea, style); + const styleSig = _lineNumberStyleSignature(style); + + _ensureLineNumberResizeObserver(textarea); + if ( + !force && + inner._lineNumberText === value && + inner._lineNumberWidth === textWidth && + inner._lineNumberStyleSig === styleSig + ) { + syncGutterScroll(); + return; + } + + const heights = _measureLineNumberHeights(textarea, lines, textWidth, style); + _renderLineNumberRows(inner, heights); + inner._lineNumberText = value; + inner._lineNumberWidth = textWidth; + inner._lineNumberStyleSig = styleSig; + syncGutterScroll(); } /** Sync line number gutter scroll with textarea */ @@ -6336,7 +6493,7 @@ import * as Modals from './modalManager.js'; const textarea = document.getElementById('doc-editor-textarea'); const gutter = document.getElementById('doc-line-numbers'); if (textarea && gutter) { - gutter.scrollTop = textarea.scrollTop; + _lineNumberContentEl(gutter).style.transform = `translateY(${-textarea.scrollTop}px)`; } } diff --git a/static/style.css b/static/style.css index 3269ce9..bbf05ee 100644 --- a/static/style.css +++ b/static/style.css @@ -12075,10 +12075,47 @@ textarea.memory-add-input { background: var(--bg); overflow: hidden; white-space: pre; + tab-size: 4; + font-variant-ligatures: none !important; + font-feature-settings: "kern" 0, "liga" 0, "calt" 0, "dlig" 0 !important; + font-kerning: none !important; + text-rendering: geometricPrecision !important; z-index: 2; pointer-events: none; user-select: none; } +.doc-line-number-content { + display: block; + will-change: transform; +} +.doc-line-number-row { + position: relative; + box-sizing: border-box; +} +.doc-line-number-label { + position: absolute; + top: 0; + left: 0; + width: 36px; + text-align: right; +} +.doc-line-number-measure { + position: absolute !important; + visibility: hidden !important; + pointer-events: none !important; + left: -9999px !important; + top: 0 !important; + height: 0 !important; + min-height: 0 !important; + max-height: none !important; + overflow: hidden !important; + padding: 0 !important; + border: 0 !important; + resize: none !important; + box-sizing: content-box !important; + color: transparent !important; + background: transparent !important; +} /* Find marks live in the syntax-highlight overlay, which sits at z-index:0 under a transparent textarea — so they're always visible through the text layer. The previous color-mix variant could @@ -12209,11 +12246,11 @@ mark.doc-find-mark.current { area — caret stays right, but typed text appears on a different row than the caret. */ scrollbar-gutter: stable; - /* The highlight overlay hides its scrollbar, so the textarea must too — - otherwise the scrollbar shrinks the textarea's text-area width and - wraps lines earlier than the overlay, putting the caret on the wrong - line entirely. */ - scrollbar-width: none; + /* Show a real scrollbar for long documents. scrollbar-gutter above keeps + the text column stable so the gutter, textarea, and find overlay stay + metrically aligned while the scrollbar is present. */ + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--fg) 28%, transparent) transparent; -webkit-overflow-scrolling: touch; tab-size: 4; white-space: pre-wrap; @@ -12231,7 +12268,15 @@ mark.doc-find-mark.current { font-kerning: none !important; text-rendering: geometricPrecision !important; } -.doc-editor-textarea::-webkit-scrollbar { display: none; } +.doc-editor-textarea::-webkit-scrollbar { width: 8px; } +.doc-editor-textarea::-webkit-scrollbar-track { background: transparent; } +.doc-editor-textarea::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--fg) 24%, transparent); + border-radius: 999px; +} +.doc-editor-textarea::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--fg) 36%, transparent); +} .doc-editor-textarea:hover, .doc-editor-textarea:focus, .doc-editor-textarea:active { diff --git a/tests/test_document_editor_scroll.py b/tests/test_document_editor_scroll.py new file mode 100644 index 0000000..b556252 --- /dev/null +++ b/tests/test_document_editor_scroll.py @@ -0,0 +1,49 @@ +"""Regression guards for the Documents editor scrolling UI. + +Issues #1501 and #1496 both come from the same surface: the document editor +hid its real textarea scrollbar, and the line-number gutter tried to scroll an +overflow-hidden element. Long wrapped lines add another wrinkle: the textarea +can have more visual rows than logical newline rows, so the gutter rows must +match the textarea's measured row heights. Keep these as static checks because +document.js is browser-coupled and not importable in pytest. +""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +DOC_JS = (ROOT / "static/js/document.js").read_text() +STYLE_CSS = (ROOT / "static/style.css").read_text() + + +def test_document_textarea_scrollbar_is_visible(): + textarea_rule_start = STYLE_CSS.index(".doc-editor-textarea {\n position: absolute;") + textarea_rule_end = STYLE_CSS.index(".doc-editor-textarea::placeholder", textarea_rule_start) + textarea_css = STYLE_CSS[textarea_rule_start:textarea_rule_end] + + assert "overflow-y: scroll;" in textarea_css + assert "scrollbar-width: thin;" in textarea_css + assert ".doc-editor-textarea::-webkit-scrollbar { width: 8px; }" in STYLE_CSS + assert ".doc-editor-textarea::-webkit-scrollbar { display: none; }" not in STYLE_CSS + + +def test_line_number_gutter_translates_inner_content(): + assert "function _lineNumberContentEl(gutter)" in DOC_JS + assert "inner.className = 'doc-line-number-content';" in DOC_JS + assert ".style.transform = `translateY(${-textarea.scrollTop}px)`;" in DOC_JS + assert "gutter.scrollTop = textarea.scrollTop;" not in DOC_JS + assert ".doc-line-number-content" in STYLE_CSS + + +def test_line_number_gutter_accounts_for_wrapped_rows(): + assert "function _measureLineNumberHeights(textarea, lines, textWidth, style)" in DOC_JS + assert "probe = document.createElement('textarea');" in DOC_JS + assert "probe.wrap = 'soft';" in DOC_JS + assert "probe.value = line || ' ';" in DOC_JS + assert "Math.round(probe.scrollHeight / lineHeight)" in DOC_JS + assert "row.style.height = `${heights[i]}px`;" in DOC_JS + assert "label.className = 'doc-line-number-label';" in DOC_JS + assert "inner.textContent = lines;" not in DOC_JS + assert ".doc-line-number-row" in STYLE_CSS + assert ".doc-line-number-label" in STYLE_CSS + assert ".doc-line-number-measure" in STYLE_CSS