diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 16a77c1..8c2b963 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -319,8 +319,10 @@ html[data-wrap] .float { overflow-x: hidden; } .codebody { padding: 0 18px; white-space: pre; overflow-x: auto; } /* #167: each logical line is its own block so offsetTop identifies its first visual row */ .codeline { display: block; } -/* #167 rev: gutter number spans must stack one per visual row (wrap on) */ -.code .gutter .gutline { display: block; } +/* #167: gutter number spans must stack one per visual row (wrap on). + #274: the /new editor gutter uses the same .gutline blocks when its own + wrap toggle is on, so scope the rule to any gutter, not just .code. */ +.code .gutter .gutline, .editor-wrap .gutter .gutline { display: block; } /* #194: codeline blocks are adjacent (no '\n' text between them), so an empty block (blank source line) needs its own line box to stay one row */ .codeline:empty::before { content: "\200B"; } diff --git a/internal/web/static/new.js b/internal/web/static/new.js index 0782762..3b4453f 100644 --- a/internal/web/static/new.js +++ b/internal/web/static/new.js @@ -2,13 +2,71 @@ const $ = id => document.getElementById(id); const content = $('content'), gutter = $('gutter'); +// #274: with wrap on, a logical line occupies several VISUAL rows in the +// textarea, so one number per logical line drifts off its text (same bug the +// paste view fixed in #167). A textarea can't be split into spans, so the +// wrapped row count per logical line is measured with a hidden mirror div +// that shares the editor's font, line metrics and wrapping rules, and the +// gutter renders one .gutline block per visual row with the number on the +// FIRST row of its logical line (fillers elsewhere). +let mirror = null; +function measureRows(lines) { + if (!mirror) { + mirror = document.createElement('div'); + mirror.style.position = 'absolute'; + mirror.style.visibility = 'hidden'; + mirror.style.top = '0'; + mirror.style.left = '-9999px'; + document.body.appendChild(mirror); + } + const cs = getComputedStyle(content); + mirror.style.font = cs.font; + mirror.style.lineHeight = cs.lineHeight; + mirror.style.whiteSpace = 'pre-wrap'; + mirror.style.overflowWrap = 'anywhere'; + mirror.style.wordBreak = 'break-all'; + mirror.style.width = (content.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)) + 'px'; + const lh = parseFloat(cs.lineHeight) || 1; + const starts = []; + let total = 0; + const n = Math.max(lines.length, 1); + for (let i = 0; i < n; i++) { + // A trailing newline yields an empty last line: it still occupies one row. + mirror.textContent = lines[i] + '\n'; + let rows = Math.max(1, Math.round(mirror.getBoundingClientRect().height / lh)); + starts.push(total); + total += rows; + } + return { starts, total }; +} + function updateGutter() { - const lines = content.value.split('\n').length; - let s = ''; - for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n'; - gutter.textContent = s; + const lines = content.value.split('\n'); + const n = Math.max(lines.length, 1); + if (!document.documentElement.hasAttribute('data-wrap')) { + let s = ''; + for (let i = 1; i <= n; i++) s += i + '\n'; + gutter.textContent = s.slice(0, -1); + return; + } + const { starts, total } = measureRows(lines); + gutter.textContent = ''; + const frag = document.createDocumentFragment(); + const spans = []; + for (let r = 0; r < total; r++) { + const c = document.createElement('span'); + c.className = 'gutline'; + c.textContent = '\u00a0'; + spans.push(c); + frag.appendChild(c); + } + gutter.appendChild(frag); + for (let j = 0; j < starts.length; j++) spans[starts[j]].textContent = String(j + 1); } content.addEventListener('input', updateGutter); +// #274: the wrap toggle and width changes re-wrap the textarea; re-measure. +new MutationObserver(updateGutter).observe(document.documentElement, { attributes: true, attributeFilter: ['data-wrap'] }); +window.addEventListener('resize', updateGutter); // #259: the editor scrolls itself; keep the gutter's numbers in step with it. content.addEventListener('scroll', () => { gutter.scrollTop = content.scrollTop; }); updateGutter();