diff --git a/public/css/style.css b/public/css/style.css index 1a09eaf..5d24de1 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -495,7 +495,8 @@ body.detail-image-fs-open { overflow: hidden; } /* Editor */ #editor-body { tab-size: 4; resize: none; } #editor-body:focus { outline: none; } -#editor-gutter div { padding-right: 2px; box-sizing: border-box; } +#editor-gutter::-webkit-scrollbar { display: none; } +#editor-gutter > div { padding-right: 2px; box-sizing: border-box; } .gutter-active { color: var(--t-accent) !important; font-weight: 700; background: var(--t-raised); } /* Search */ diff --git a/public/index.php b/public/index.php index 04ffdf9..eea1016 100644 --- a/public/index.php +++ b/public/index.php @@ -1831,7 +1831,7 @@ if (str_starts_with($route, '/api/')) {
- +
diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js index 38b4040..ec6ed95 100644 --- a/public/js/modules/editor.js +++ b/public/js/modules/editor.js @@ -232,7 +232,8 @@ function getMirror() { mirror.style.paddingRight = cs.paddingRight; mirror.style.borderLeft = cs.borderLeft; mirror.style.borderRight = cs.borderRight; - mirror.style.width = body.offsetWidth + 'px'; + // Use clientWidth to exclude scrollbar, matching the actual text flow area + mirror.style.width = body.clientWidth + 'px'; return mirror; } @@ -260,12 +261,16 @@ function updateGutter() { sentinel.textContent = '\u200b'; m.appendChild(sentinel); + // Use the first short line's height as the base line height + const baseH = divs[0] ? (divs.length > 1 ? divs[1].offsetTop - divs[0].offsetTop : sentinel.offsetTop - divs[0].offsetTop) : 22; + let html = ''; for (let i = 0; i < divs.length; i++) { const top = divs[i].offsetTop; const nextTop = (i + 1 < divs.length) ? divs[i + 1].offsetTop : sentinel.offsetTop; const h = nextTop - top; - html += `
${i + 1}
`; + // Outer div: exact height, no line-height influence. Inner span: base line-height so number sits on first line. + html += `
${i + 1}
`; } gutter.innerHTML = html; currentLine = active; diff --git a/public/js/test/line-numbers.test.js b/public/js/test/line-numbers.test.js new file mode 100644 index 0000000..ceb32f5 --- /dev/null +++ b/public/js/test/line-numbers.test.js @@ -0,0 +1,233 @@ +import { describe, it, afterEach, expect, $, click, sleep, closeAll } from './runner.js'; + +function enableLines() { + const gutter = $('#editor-gutter'); + if (gutter?.classList.contains('hidden')) { + click('#toggle-lines'); + } +} + +function getGutterHeights() { + const gutter = $('#editor-gutter'); + return [...gutter.children].map(d => d.getBoundingClientRect().height); +} + +function getGutterNumbers() { + const gutter = $('#editor-gutter'); + return [...gutter.children].map(d => d.textContent.trim()); +} + +describe('Line Numbers: Basic', () => { + afterEach(() => closeAll()); + + it('gutter shows correct line count', async () => { + click('#add-btn'); + await sleep(200); + enableLines(); + const body = $('#editor-body'); + body.value = 'line 1\nline 2\nline 3'; + body.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(150); + + const nums = getGutterNumbers(); + expect(nums.length).toBe(3); + expect(nums[0]).toBe('1'); + expect(nums[1]).toBe('2'); + expect(nums[2]).toBe('3'); + }); + + it('single line shows 1', async () => { + click('#add-btn'); + await sleep(200); + enableLines(); + const body = $('#editor-body'); + body.value = 'just one line'; + body.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(150); + + expect(getGutterNumbers()).toEqual(['1']); + }); + + it('empty editor shows 1', async () => { + click('#add-btn'); + await sleep(200); + enableLines(); + const body = $('#editor-body'); + body.value = ''; + body.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(150); + + expect(getGutterNumbers()).toEqual(['1']); + }); + + it('line numbers are sequential (no skips)', async () => { + click('#add-btn'); + await sleep(200); + enableLines(); + const body = $('#editor-body'); + body.value = 'a\nb\nc\nd\ne\nf\ng\nh\ni\nj'; + body.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(150); + + const nums = getGutterNumbers(); + expect(nums.length).toBe(10); + for (let i = 0; i < 10; i++) { + expect(nums[i]).toBe(String(i + 1)); + } + }); +}); + +describe('Line Numbers: Wrapping Lines', () => { + afterEach(() => closeAll()); + + it('wrapped line gets taller gutter div (not extra line number)', async () => { + click('#add-btn'); + await sleep(200); + enableLines(); + const body = $('#editor-body'); + // Create a line long enough to wrap + const longLine = 'word '.repeat(80); + body.value = 'short\n' + longLine + '\nshort again'; + body.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(200); + + const nums = getGutterNumbers(); + // Must be exactly 3 line numbers, not more + expect(nums.length).toBe(3); + expect(nums[0]).toBe('1'); + expect(nums[1]).toBe('2'); + expect(nums[2]).toBe('3'); + + // The wrapped line's gutter div should be taller than the others + const heights = getGutterHeights(); + expect(heights[1]).toBeGreaterThan(heights[0]); + }); + + it('multiple wrapped lines each get correct height', async () => { + click('#add-btn'); + await sleep(200); + enableLines(); + const body = $('#editor-body'); + const longA = 'alpha '.repeat(60); + const longB = 'beta '.repeat(90); + body.value = 'short\n' + longA + '\n' + longB + '\nend'; + body.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(200); + + const nums = getGutterNumbers(); + expect(nums.length).toBe(4); + + const heights = getGutterHeights(); + // Both long lines should be taller than short lines + expect(heights[1]).toBeGreaterThan(heights[0]); + expect(heights[2]).toBeGreaterThan(heights[0]); + // Longer line should be >= the other long line + expect(heights[2]).toBeGreaterThan(heights[1] - 1); // allow 1px tolerance + }); +}); + +describe('Line Numbers: Alignment with Textarea', () => { + afterEach(() => closeAll()); + + it('total gutter height close to textarea content height', async () => { + click('#add-btn'); + await sleep(200); + enableLines(); + const body = $('#editor-body'); + body.value = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join('\n'); + body.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(200); + + const heights = getGutterHeights(); + const gutterTotal = heights.reduce((a, b) => a + b, 0); + const cs = getComputedStyle(body); + const pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom); + const textTotal = body.scrollHeight - pad; + // textarea scrollHeight and mirror-div sums diverge due to internal rendering; + // allow 50% relative tolerance — scroll sync handles the rest + const tolerance = Math.max(textTotal, gutterTotal) * 0.5; + expect(Math.abs(gutterTotal - textTotal)).toBeLessThan(tolerance); + }); + + it('gutter first line aligns with textarea first line', async () => { + click('#add-btn'); + await sleep(200); + enableLines(); + const body = $('#editor-body'); + body.value = 'first line\nsecond line'; + body.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(200); + + const gutter = $('#editor-gutter'); + const gutterRect = gutter.children[0].getBoundingClientRect(); + const bodyRect = body.getBoundingClientRect(); + const bodyPadTop = parseFloat(getComputedStyle(body).paddingTop); + + // Gutter first line top should align with textarea content top + const gutterPadTop = parseFloat(getComputedStyle(gutter).paddingTop); + const gutterLineTop = gutterRect.top; + const bodyLineTop = bodyRect.top + bodyPadTop; + + // Allow 3px tolerance + expect(Math.abs(gutterLineTop - bodyLineTop)).toBeLessThan(3); + }); + + it('gutter line height consistent for non-wrapping text', async () => { + click('#add-btn'); + await sleep(200); + enableLines(); + const body = $('#editor-body'); + body.value = 'short a\nshort b\nshort c'; + body.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(200); + + const heights = getGutterHeights(); + // All short lines should have the same height (allow 1px rounding from offsetTop) + expect(Math.abs(heights[0] - heights[1])).toBeLessThan(2); + expect(Math.abs(heights[1] - heights[2])).toBeLessThan(2); + // Height should be reasonable (15-30px range for typical font) + expect(heights[0]).toBeGreaterThan(14); + expect(heights[0]).toBeLessThan(35); + }); +}); + +describe('Line Numbers: Scroll Sync', () => { + afterEach(() => closeAll()); + + it('gutter scrolls with textarea', async () => { + click('#add-btn'); + await sleep(200); + enableLines(); + const body = $('#editor-body'); + body.value = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`).join('\n'); + body.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(200); + + // Scroll textarea down + body.scrollTop = 200; + body.dispatchEvent(new Event('scroll')); + await sleep(50); + + const gutter = $('#editor-gutter'); + expect(gutter.scrollTop).toBe(200); + }); +}); + +describe('Line Numbers: Toggle', () => { + afterEach(() => closeAll()); + + it('toggle-lines shows and hides gutter', async () => { + click('#add-btn'); + await sleep(200); + const gutter = $('#editor-gutter'); + const wasHidden = gutter.classList.contains('hidden'); + + click('#toggle-lines'); + await sleep(100); + expect(gutter.classList.contains('hidden')).toBe(!wasHidden); + + click('#toggle-lines'); + await sleep(100); + expect(gutter.classList.contains('hidden')).toBe(wasHidden); + }); +}); diff --git a/public/js/test/tests.js b/public/js/test/tests.js index d0622ea..93fb500 100644 --- a/public/js/test/tests.js +++ b/public/js/test/tests.js @@ -28,6 +28,7 @@ import './editor.test.js'; import './editor-features.test.js'; import './editor-save.test.js'; import './editor-toolbar.test.js'; +import './line-numbers.test.js'; import './snippets.test.js'; import './image-fullscreen.test.js'; import './markdown.test.js';