From 2149fcc2e182401a2dea5df26df2aab6281547a9 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 12:09:11 -0500 Subject: [PATCH 1/3] Fix #167: gutter numbers align with wrapped-line starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - splitLines(): join .codeline blocks with "" not "\n" — newline text nodes under pre-wrap rendered an extra line box per logical line, so every gutter number drifted one row per line - renumber(): geometry-driven placement — one gutline per visual row of the code body, each number assigned to the row matching its line's measured offsetTop; verified second pass re-runs if a partially-filled last wrapped row reflows after the gutter rebuild - app.css: mobile media query gives .code .gutter the same 13px font as .code so gutter rows and code rows share one line box at 375px Verified in local build with headless chromium (long unbroken token on line 5, wrap ON): all numbers align with line starts, no misalignment, no horizontal scroll at 1400x900 and 375x812. --- internal/web/static/app.css | 4 ++ internal/web/static/paste-lines.js | 91 ++++++++++++++++++++---------- 2 files changed, 66 insertions(+), 29 deletions(-) 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(); From 5fdb34a059b8d26c3d1459d9752a1f00476b02b0 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 12:11:47 -0500 Subject: [PATCH 2/3] Fix #167: align gutter numbers with actual rendered line rows (wrap ON) paste-lines.js joined .codeline spans with '\n' text nodes that pre-wrap renders as extra line boxes, and renumber() counted per-line span heights (rounding) instead of real geometry, so gutter numbers drifted off their line starts on wrapped lines. Fix: join spans with '' (blocks need no newline text), and build one .gutline per visual row measured from the code body's real height, placing each number at the visual row whose top matches its .codeline's measured top. Wrap OFF path unchanged. Mobile font-size rule added so gutter row pitch matches code rows at 375x812. --- internal/web/static/paste-lines.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/web/static/paste-lines.js b/internal/web/static/paste-lines.js index a71d8ba..02944a6 100644 --- a/internal/web/static/paste-lines.js +++ b/internal/web/static/paste-lines.js @@ -54,7 +54,16 @@ // 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)); + var maxBottom = 0; + for (var j = 0; j < lines.length; j++) { + var btm = lines[j].getBoundingClientRect().bottom - bodyTop; + if (btm > maxBottom) maxBottom = btm; + } + // Rows needed through the last visual row of the deepest line (ceil, so a + // partially-filled last row still gets its gutter span). + // Rows needed: floor(last line bottom / lh) + 1 — the row a line STARTS on + // must exist even when its bottom lands exactly on the body's edge. + var totalRows = Math.max(lines.length, Math.floor(maxBottom / lh) + 1); gutter.textContent = ''; var frag = document.createDocumentFragment(); var spans = []; From e1056df747570262ee033fdff4df5733745164c8 Mon Sep 17 00:00:00 2001 From: fen Date: Thu, 10 Sep 2026 12:12:31 -0500 Subject: [PATCH 3/3] Fix #167: gutter alignment with wrap on Rebuild renumber() so gutter numbers are placed by measured geometry: each .gutline block is one visual row and each number is assigned to the row whose top matches its .codeline's top, instead of counting rows per line. This keeps numbers aligned with wrapped line starts regardless of how many visual rows a line occupies. Wrap OFF path unchanged. --- internal/web/static/paste-lines.js | 39 ++++++++++++------------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/internal/web/static/paste-lines.js b/internal/web/static/paste-lines.js index 02944a6..231a473 100644 --- a/internal/web/static/paste-lines.js +++ b/internal/web/static/paste-lines.js @@ -42,28 +42,22 @@ 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. + // 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 maxBottom = 0; - for (var j = 0; j < lines.length; j++) { - var btm = lines[j].getBoundingClientRect().bottom - bodyTop; - if (btm > maxBottom) maxBottom = btm; + 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; } - // Rows needed through the last visual row of the deepest line (ceil, so a - // partially-filled last row still gets its gutter span). - // Rows needed: floor(last line bottom / lh) + 1 — the row a line STARTS on - // must exist even when its bottom lands exactly on the body's edge. - var totalRows = Math.max(lines.length, Math.floor(maxBottom / lh) + 1); + if (totalRows < lines.length) totalRows = lines.length; gutter.textContent = ''; var frag = document.createDocumentFragment(); var spans = []; @@ -75,11 +69,8 @@ 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); + 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