From 71c53b65f364eef213019ab03023c60e215a87aa Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 11:54:07 -0500 Subject: [PATCH] Fix #167 r2: measure wrapped rows from rendered line geometry Round-1 accounting rounded each .codeline span's own height to rows, but in pre-wrap the newline text nodes between blocks each render as their own anonymous row, so per-span height undercounts and gutter numbers drift below their line's first visual row. Renumber now derives rows per line from the rendered gap between consecutive line tops (wrapped rows plus the newline row); the last line falls back to its own box height. Wrap-off path unchanged. --- internal/web/static/paste-lines.js | 41 +++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/internal/web/static/paste-lines.js b/internal/web/static/paste-lines.js index 0eac3d3..98eda6a 100644 --- a/internal/web/static/paste-lines.js +++ b/internal/web/static/paste-lines.js @@ -1,7 +1,13 @@ // #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 -// give each logical line its own box so offsetTop order stays correct even -// when highlighting spans cross no line boundaries. +// lines; the gutter must show one number per logical line, aligned with its +// FIRST visual row, and blank rows below it so later numbers don't drift. +// +// Row accounting must be based on actual rendered geometry, not per-span +// height: in a pre-wrap container the newline text nodes BETWEEN the +// .codeline blocks each render as their own anonymous-block row, so a +// logical line's visual footprint is (next line's top - this line's top), +// not this line's own box height. Measuring the gap between consecutive +// line tops captures both the wrapped rows and the newline row. (function () { var body = document.getElementById('codebody'); var gutter = document.getElementById('gutter'); @@ -28,21 +34,32 @@ var lines = body.querySelectorAll('.codeline'); 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 code column width must not change while measuring (the gutter is - // flex-shrink:0 so its own row count never affects it), and renumber - // must be idempotent to avoid a ResizeObserver feedback loop. + // Rows per logical line = rendered vertical gap to the next line's + // first row (wrapped rows + the pre-wrap newline row between blocks), + // divided by line-height. The last line has no following newline, so + // its own box height is correct. The code column width must not + // change while measuring (the gutter is flex-shrink:0 so its own row + // count never affects it), and renumber must be idempotent to avoid a + // ResizeObserver feedback loop: only width changes re-trigger it. var lh = parseFloat(getComputedStyle(body).lineHeight) || 1; + var tops = []; + for (var i = 0; i < lines.length; i++) { + tops.push(lines[i].getBoundingClientRect().top); + } gutter.textContent = ''; var frag = document.createDocumentFragment(); - for (var i = 0; i < lines.length; i++) { + for (var j = 0; j < lines.length; j++) { + var span; + if (j < lines.length - 1) { + span = tops[j + 1] - tops[j]; + } else { + span = lines[j].getBoundingClientRect().height; + } + var rows = Math.max(1, Math.round(span / lh)); var num = document.createElement('span'); num.className = 'gutline'; - num.textContent = String(i + 1); + num.textContent = String(j + 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';