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 */
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)`;
}
}

View File

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