1 Commits
Author SHA1 Message Date
fen 2149fcc2e1 Fix #167: gutter numbers align with wrapped-line starts
CI / docker (pull_request) Skipped
CI / test (pull_request) Successful in 49s
- 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.
2026-09-10 12:09:16 -05:00
2 changed files with 78 additions and 46 deletions
+4 -13
View File
@@ -279,10 +279,6 @@ body {
.btn-icon.wrap-toggle[aria-pressed="true"] { background: var(--accent); color: var(--bg); border-color: var(--accent); }
/* #152: wrap ON must break long unbroken tokens mid-word and allow no horizontal scrolling */
html[data-wrap] .codebody { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-all; overflow-x: hidden; }
/* #167: overflow:hidden zeroes the flex auto-minimum, so the codebody must
be told to fill the space left of the gutter or it collapses and the
gutter (flex-shrink:0) consumes the whole row */
html[data-wrap] .code .codebody { flex: 1 1 auto; min-width: 0; }
html[data-wrap] .editor { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-all; overflow-x: hidden; }
/* #152: with wrap on nothing may scroll horizontally, including the code container and mobile floats */
html[data-wrap] .code { overflow-x: hidden; }
@@ -297,12 +293,6 @@ html[data-wrap] .float { overflow-x: hidden; }
font-family: var(--font-mono); font-size: var(--code-fs); line-height: var(--code-lh);
padding: 14px 0; display: flex; overflow-x: auto;
}
/* #167: the gutter must not drive the flex layout — its content width
(row count × number width) shrinks the code column, which re-wraps lines,
which grows the gutter: a feedback loop. Pin the gutter with a fixed
basis, take its width out of the negotiation, and let the codebody take
the rest. */
.code .gutter { flex: 0 0 auto; width: 3ch; min-width: 3ch; overflow: visible; }
.code .gutter { flex-shrink: 0; }
/* gutter/code share line metrics; the editor gutter keeps its own padding (#50) */
.code .gutter { padding-top: 0; padding-bottom: 0; }
@@ -641,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; }
@@ -821,6 +815,3 @@ button[type="submit"]:focus-visible,
.mt18 { margin-top: 18px; }
.toggle-inline { display: inline-flex; }
.wrap-normal { word-break: normal; overflow-wrap: break-word; }
.created-banner { display: block; }
/* #167: gutter rows for wrapped paste view — one row per visual code line */
.gutline { display: block; }
+73 -32
View File
@@ -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');
@@ -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,51 +25,88 @@
for (var i = 0; i < parts.length; i++) {
out.push('<span class="codeline">' + parts[i] + '</span>');
}
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');
if (!wrapOn() || !lines.length) {
// wrap OFF: one number per logical line (pre-existing behavior,
// including the gutter scrolling with horizontal scroll).
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';
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();
renumber();
// 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);
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);