Fix document editor scrollbar and line-number sync

Fixes #1501
Fixes #1496
This commit is contained in:
Marius Popa
2026-06-03 07:40:19 +03:00
committed by GitHub
parent 13f0171ce8
commit 4ec53a296a
3 changed files with 264 additions and 13 deletions

View File

@@ -6322,13 +6322,170 @@ import * as Modals from './modalManager.js';
} }
/** Update the line number gutter */ /** 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'); const gutter = document.getElementById('doc-line-numbers');
if (!gutter) return; if (!textarea || !gutter) return;
const count = (text || '').split('\n').length;
let html = ''; const value = text || '';
for (let i = 1; i <= count; i++) html += i + '\n'; const lines = value.split('\n');
gutter.textContent = html; 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 */ /** 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 textarea = document.getElementById('doc-editor-textarea');
const gutter = document.getElementById('doc-line-numbers'); const gutter = document.getElementById('doc-line-numbers');
if (textarea && gutter) { if (textarea && gutter) {
gutter.scrollTop = textarea.scrollTop; _lineNumberContentEl(gutter).style.transform = `translateY(${-textarea.scrollTop}px)`;
} }
} }

View File

@@ -12075,10 +12075,47 @@ textarea.memory-add-input {
background: var(--bg); background: var(--bg);
overflow: hidden; overflow: hidden;
white-space: pre; 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; z-index: 2;
pointer-events: none; pointer-events: none;
user-select: 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 /* Find marks live in the syntax-highlight overlay, which sits at
z-index:0 under a transparent textarea so they're always visible z-index:0 under a transparent textarea so they're always visible
through the text layer. The previous color-mix variant could 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 area caret stays right, but typed text appears on a different row
than the caret. */ than the caret. */
scrollbar-gutter: stable; scrollbar-gutter: stable;
/* The highlight overlay hides its scrollbar, so the textarea must too /* Show a real scrollbar for long documents. scrollbar-gutter above keeps
otherwise the scrollbar shrinks the textarea's text-area width and the text column stable so the gutter, textarea, and find overlay stay
wraps lines earlier than the overlay, putting the caret on the wrong metrically aligned while the scrollbar is present. */
line entirely. */ scrollbar-width: thin;
scrollbar-width: none; scrollbar-color: color-mix(in srgb, var(--fg) 28%, transparent) transparent;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
tab-size: 4; tab-size: 4;
white-space: pre-wrap; white-space: pre-wrap;
@@ -12231,7 +12268,15 @@ mark.doc-find-mark.current {
font-kerning: none !important; font-kerning: none !important;
text-rendering: geometricPrecision !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:hover,
.doc-editor-textarea:focus, .doc-editor-textarea:focus,
.doc-editor-textarea:active { .doc-editor-textarea:active {

View File

@@ -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