Merge fix-167 (PR #200) into dev
CI / test (push) Successful in 38s
CI / docker (push) Successful in 43s

Resolves #167

QA: fen
This commit is contained in:
fen
2026-09-10 12:14:21 -05:00
2 changed files with 76 additions and 37 deletions
+4
View File
@@ -644,6 +644,10 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; }
.stats-head { font-size: 13px; } .stats-head { font-size: 13px; }
.stats-grid { font-size: 13px; padding: 10px 12px; } .stats-grid { font-size: 13px; padding: 10px 12px; }
.code { font-size: 13px; } .code { font-size: 13px; }
/* #167: gutter and code must share one line box at mobile width too — a
taller gutter font stacks its rows taller than the code rows and every
number drifts off its line start */
.code .gutter { font-size: 13px; }
.codebody { padding: 0 12px; } .codebody { padding: 0 12px; }
.footnote { font-size: 12px; padding: 8px 12px; gap: 10px; } .footnote { font-size: 12px; padding: 8px 12px; gap: 10px; }
.iconbtn { font-size: 13px; padding: 6px 10px; } .iconbtn { font-size: 13px; padding: 6px 10px; }
+71 -36
View File
@@ -1,7 +1,8 @@
// #167: with line wrapping on, a logical line can occupy several visual // #167: with line wrapping on, a logical line can occupy several visual
// lines; the gutter must show one number per VISUAL line. Per-line spans // lines; the gutter must show one number per VISUAL line, and each number
// give each logical line its own box so its height identifies the wrapped // must sit on the visual row where its logical line STARTS. Per-line spans
// row count; blank gutter rows keep numbers aligned with line starts. // give each logical line its own box so offsetTop order stays correct even
// when highlighting spans cross no line boundaries.
(function () { (function () {
var body = document.getElementById('codebody'); var body = document.getElementById('codebody');
var gutter = document.getElementById('gutter'); var gutter = document.getElementById('gutter');
@@ -14,12 +15,9 @@
// Wrap each logical line (split on newline; spans never contain newlines // Wrap each logical line (split on newline; spans never contain newlines
// because HighlightCode highlights per line) in a .codeline block. The // because HighlightCode highlights per line) in a .codeline block. The
// blocks must be adjacent with NO newline text between them (#194): // spans are display:block, so they are joined with '' — a '\n' join leaves
// .codeline is display:block, and under pre-wrap each interleaved '\n' // newline text nodes between blocks that pre-wrap renders as an extra line
// text node renders as an extra phantom row in the code column that // box per line, which would shift every following number down one row (#167).
// renumber() does not count, drifting the gutter off alignment on
// wrapped lines. Blank lines become empty blocks; .codeline:empty keeps
// them one row tall (see app.css).
function splitLines() { function splitLines() {
var html = body.innerHTML; var html = body.innerHTML;
var parts = html.split('\n'); var parts = html.split('\n');
@@ -30,48 +28,85 @@
body.innerHTML = out.join(''); body.innerHTML = out.join('');
} }
// One .gutline block per visual row. Numbers are placed at the gutter row
// whose top matches their .codeline's top; filler rows pad the gaps so
// alignment is driven by measured geometry, not by uniform row counts.
function renumber() { function renumber() {
var lines = body.querySelectorAll('.codeline'); var lines = body.querySelectorAll('.codeline');
if (!wrapOn() || !lines.length) {
// wrap OFF: one number per logical line (pre-existing behavior,
// including the gutter scrolling with horizontal scroll).
var s = ''; var s = '';
if (wrapOn() && lines.length) {
// Each logical line block occupies rows = height / line-height when
// wrapped; its number sits on the first row and the remaining rows get
// blank gutter lines so numbers stay aligned with line starts.
// The gutter has a fixed CSS width now (.code .gutter), so its row
// count never changes the code column width: measurement is stable
// with no measurement/layout feedback.
var lh = parseFloat(getComputedStyle(body).lineHeight) || 1;
gutter.textContent = '';
var frag = document.createDocumentFragment();
for (var i = 0; i < lines.length; i++) {
var num = document.createElement('span');
num.className = 'gutline';
num.textContent = String(i + 1);
frag.appendChild(num);
var rows = Math.max(1, Math.round(lines[i].getBoundingClientRect().height / lh));
for (var r = 1; r < rows; r++) {
var blank = document.createElement('span');
blank.className = 'gutline';
blank.textContent = '\u00a0';
frag.appendChild(blank);
}
}
gutter.appendChild(frag);
} else {
for (var k = 1; k <= lines.length; k++) s += k + '\n'; for (var k = 1; k <= lines.length; k++) s += k + '\n';
gutter.textContent = lines.length ? s.slice(0, -1) : '1'; gutter.textContent = lines.length ? s.slice(0, -1) : '1';
return;
} }
// Measure each logical line's geometry. Reading all rects first avoids
// interleaved layout reads/writes. A line's number goes on the visual row
// whose top matches the line's top; the gutter is sized to cover the
// deepest row any line STARTS on (a trailing empty logical line renders
// with zero height but still needs its number row).
var bodyTop = body.getBoundingClientRect().top;
var lh = parseFloat(getComputedStyle(body).lineHeight) || 1;
var rowIdx = [];
var totalRows = 1;
for (var i = 0; i < lines.length; i++) {
var row = Math.round((lines[i].getBoundingClientRect().top - bodyTop) / lh);
if (row < 0) row = 0;
rowIdx.push(row);
if (row + 1 > totalRows) totalRows = row + 1;
}
if (totalRows < lines.length) totalRows = lines.length;
gutter.textContent = '';
var frag = document.createDocumentFragment();
var spans = [];
for (var r = 0; r < totalRows; r++) {
var cell = document.createElement('span');
cell.className = 'gutline';
cell.textContent = '\u00a0';
spans.push(cell);
frag.appendChild(cell);
}
gutter.appendChild(frag);
for (var j = 0; j < rowIdx.length; j++) {
spans[rowIdx[j]].textContent = String(j + 1);
}
// A wrapped line whose last visual row is only partially filled can
// settle a hair under N * line-height after the gutter is rebuilt; a
// gutter pass that changes the code column width reflows it. Verify the
// placement one frame later and re-run if any line's row moved (#167).
var placed = {};
for (var q = 0; q < tops.length; q++) placed[q] = Math.round((tops[q] - bodyTop) / lh);
requestAnimationFrame(function () {
var moved = false;
var tops2 = [];
for (var q2 = 0; q2 < lines.length; q2++) tops2.push(lines[q2].getBoundingClientRect().top);
var bodyTop2 = body.getBoundingClientRect().top;
for (var q3 = 0; q3 < tops2.length; q3++) {
if (Math.round((tops2[q3] - bodyTop2) / lh) !== placed[q3]) { moved = true; break; }
}
if (moved) renumber();
});
} }
splitLines(); splitLines();
renumber(); renumber();
// Re-renumber on toggle (theme.js toggles data-wrap on <html>) and on any // Re-renumber on toggle (theme.js toggles data-wrap on <html>) and on any
// size change (paste of lots of text, window resize, zoom). // size change (paste of lots of text, window resize, zoom). Only width
// changes of the code column affect wrapping, so observe width only —
// height changes caused by our own renumbering must not re-trigger.
var mo = new MutationObserver(renumber); var mo = new MutationObserver(renumber);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-wrap'] }); mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-wrap'] });
var lastW = body.getBoundingClientRect().width;
if (window.ResizeObserver) { if (window.ResizeObserver) {
var ro = new ResizeObserver(function () { renumber(); }); var ro = new ResizeObserver(function () {
var w = body.getBoundingClientRect().width;
if (Math.abs(w - lastW) < 0.5) return;
lastW = w;
renumber();
});
ro.observe(body); ro.observe(body);
} else { } else {
window.addEventListener('resize', renumber); window.addEventListener('resize', renumber);