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';