Files
palette/internal/web/static/paste-lines.js
T
fen f27d125659 Fix #167: drop join newline between .codeline spans; fix gutter font-size mismatch
Root cause per QA on palette-dev (merge d402113): splitLines() joined
.codeline spans with '\n'; under white-space:pre-wrap each newline text
node between display:block spans rendered as its own extra visual row,
while renumber() counted gutter rows only from span heights — numbers
drifted up one row per wrapped line. Join with '' (spans are blocks).

Also: in the <=640px media query, .gutter { font-size: 15px } overrode
the paste-view gutter size while .code switched to 13px, so gutline rows
were ~36.7px vs 22.1px code rows at narrow widths — every number below
line 1 drifted. Scope the paste-view gutter to the code font size.

Verified with headless Chromium DOM geometry (desktop 1400x900 and
mobile 375x812): gutter number tops equal their .codeline tops for
lines wrapping to 2-3 rows, wrap OFF numbering unchanged
('1\n2\n3\n4\n5'), no horizontal scroll.
2026-09-10 12:24:46 -05:00

124 lines
5.3 KiB
JavaScript

// #167: with line wrapping on, a logical line can occupy several visual
// 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');
var code = document.getElementById('code');
if (!body || !gutter || !code) return;
var wrapOn = function () {
return document.documentElement.hasAttribute('data-wrap');
};
// Wrap each logical line (split on newline; spans never contain newlines
// 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');
var out = [];
for (var i = 0; i < parts.length; i++) {
out.push('<span class="codeline">' + parts[i] + '</span>');
}
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 = '';
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 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 = [];
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). 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(function () { setTimeout(renumber, 0); });
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-wrap'] });
var lastW = body.getBoundingClientRect().width;
if (window.ResizeObserver) {
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);
}
})();