diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 463e78a..bdba0f8 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -631,6 +631,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 0eac3d3..a71d8ba 100644 --- a/internal/web/static/paste-lines.js +++ b/internal/web/static/paste-lines.js @@ -1,5 +1,6 @@ // #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 +// 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 () { @@ -13,7 +14,10 @@ }; // Wrap each logical line (split on newline; spans never contain newlines - // because HighlightCode highlights per line) in a .codeline block. + // because HighlightCode highlights per line) in a .codeline block. The + // 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'); @@ -21,40 +25,69 @@ for (var i = 0; i < parts.length; i++) { out.push('' + parts[i] + ''); } - body.innerHTML = out.join('\n'); + 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 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. - 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 offsetTop (viewport-relative for + // comparison with gutter rows rendered in the same scroll flow). + // Reading all rects first avoids interleaved layout reads/writes. + var tops = []; + for (var i = 0; i < lines.length; i++) { + tops.push(lines[i].getBoundingClientRect().top); + } + // Build one gutline per visual row of the tallest column (the code + // body itself); each number is assigned to the visual row whose top + // is closest to its line's top. + var bodyTop = body.getBoundingClientRect().top; + var lh = parseFloat(getComputedStyle(body).lineHeight) || 1; + var totalRows = Math.max(lines.length, Math.ceil(body.getBoundingClientRect().height / lh)); + 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 < tops.length; j++) { + var row = Math.round((tops[j] - bodyTop) / lh); + if (row < 0) row = 0; + if (row > totalRows - 1) row = totalRows - 1; + spans[row].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();