diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 7b996e9..119ee8f 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -644,6 +644,10 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; } .stats-head { font-size: 13px; } .stats-grid { font-size: 13px; padding: 10px 12px; } .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; } .footnote { font-size: 12px; padding: 8px 12px; gap: 10px; } .iconbtn { font-size: 13px; padding: 6px 10px; } diff --git a/internal/web/static/paste-lines.js b/internal/web/static/paste-lines.js index 58b907b..231a473 100644 --- a/internal/web/static/paste-lines.js +++ b/internal/web/static/paste-lines.js @@ -1,7 +1,8 @@ // #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 its height identifies the wrapped -// row count; blank gutter rows keep numbers aligned with line starts. +// lines; the gutter must show one number per VISUAL line, and each number +// must sit on the visual row where its logical line STARTS. Per-line spans +// give each logical line its own box so offsetTop order stays correct even +// when highlighting spans cross no line boundaries. (function () { var body = document.getElementById('codebody'); var gutter = document.getElementById('gutter'); @@ -14,12 +15,9 @@ // Wrap each logical line (split on newline; spans never contain newlines // because HighlightCode highlights per line) in a .codeline block. The - // blocks must be adjacent with NO newline text between them (#194): - // .codeline is display:block, and under pre-wrap each interleaved '\n' - // text node renders as an extra phantom row in the code column that - // 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). + // spans are display:block, so they are joined with '' — a '\n' join leaves + // newline text nodes between blocks that pre-wrap renders as an extra line + // box per line, which would shift every following number down one row (#167). function splitLines() { var html = body.innerHTML; var parts = html.split('\n'); @@ -30,48 +28,85 @@ 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() { 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 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 { + if (!wrapOn() || !lines.length) { + // wrap OFF: one number per logical line (pre-existing behavior, + // including the gutter scrolling with horizontal scroll). + var s = ''; for (var k = 1; k <= lines.length; k++) s += k + '\n'; 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(); renumber(); // Re-renumber on toggle (theme.js toggles data-wrap on ) 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); mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-wrap'] }); + var lastW = body.getBoundingClientRect().width; 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); } else { window.addEventListener('resize', renumber);