From fdd7a248b2114c495a306317005e85eca4b17cfa Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 15 Feb 2026 19:18:08 +0100 Subject: [PATCH] Editor UX: zero horizontal shift for headings and bullets (Bear-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CSS fixes for the codemirror-live-markdown live preview: 1. Remove header underlines (border-bottom: none on .cm-heading-line) 2. Heading markers (## etc) positioned absolutely in left gutter area so they never push heading text horizontally when revealed 3. List bullets always occupy fixed width (font-size: 1em, not collapsed), showing • when inactive and source char (-) when active Verified via Playwright: 0px horizontal shift on both headings and bullets. Co-Authored-By: Claude Opus 4.6 --- e2e/zero-shift.spec.ts | 80 +++++++++++++++++++++++++++++++++++++++ src/components/Editor.css | 70 ++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 e2e/zero-shift.spec.ts diff --git a/e2e/zero-shift.spec.ts b/e2e/zero-shift.spec.ts new file mode 100644 index 00000000..e7501e30 --- /dev/null +++ b/e2e/zero-shift.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from '@playwright/test' + +test('zero horizontal shift: headings and bullets', async ({ page }) => { + await page.goto('http://localhost:5173') + await page.waitForTimeout(800) + + // Open "Build Laputa App" which has headings and bullets + const noteItem = page.locator('.note-list__item', { hasText: 'Build Laputa App' }) + await noteItem.click() + await page.waitForTimeout(800) + + const cmEditor = page.locator('.cm-editor') + await expect(cmEditor).toBeVisible() + + // Find heading and measure position BEFORE click + const headingText = page.locator('.cm-header-2', { hasText: 'Overview' }).first() + await expect(headingText).toBeVisible() + const beforeBox = await headingText.boundingBox() + console.log('Heading BEFORE:', JSON.stringify(beforeBox)) + + // Click heading to activate + if (beforeBox) { + await page.mouse.click(beforeBox.x + beforeBox.width / 2, beforeBox.y + beforeBox.height / 2) + } + await page.waitForTimeout(500) + + const afterBox = await headingText.boundingBox() + console.log('Heading AFTER:', JSON.stringify(afterBox)) + + // CRITICAL: zero horizontal shift + if (beforeBox && afterBox) { + const xShift = Math.abs(afterBox.x - beforeBox.x) + console.log(`Heading horizontal shift: ${xShift}px`) + expect(xShift).toBeLessThan(2) + } + + // Verify heading marker is position:absolute (out of flow) + const marker = page.locator('.cm-heading-line .cm-formatting-block').first() + if (await marker.count() > 0) { + const pos = await marker.evaluate(el => window.getComputedStyle(el).position) + expect(pos).toBe('absolute') + } + + // Test bullet line shift + const bulletLine = page.locator('.cm-line', { hasText: 'Four-panel layout working' }).first() + const bBefore = await bulletLine.boundingBox() + + if (bBefore) { + await page.mouse.click(bBefore.x + bBefore.width / 2, bBefore.y + bBefore.height / 2) + } + await page.waitForTimeout(500) + + const bAfter = await bulletLine.boundingBox() + if (bBefore && bAfter) { + const xShift = Math.abs(bAfter.x - bBefore.x) + console.log(`Bullet horizontal shift: ${xShift}px`) + expect(xShift).toBeLessThan(2) + } + + // Verify bullet marker is always full font-size (not collapsed) + const bulletMarker = page.locator('.cm-line:not(.cm-heading-line) .cm-formatting-block').first() + if (await bulletMarker.count() > 0) { + const fontSize = await bulletMarker.evaluate(el => window.getComputedStyle(el).fontSize) + expect(parseFloat(fontSize)).toBeGreaterThan(10) + } + + // Verify no heading underline (FIX 1) + const headingLine = page.locator('.cm-heading-line').first() + if (await headingLine.count() > 0) { + const border = await headingLine.evaluate(el => window.getComputedStyle(el).borderBottom) + expect(border).toContain('none') + } + + // Take final screenshots + const editorBox = await cmEditor.boundingBox() + if (editorBox) { + const clip = { x: editorBox.x, y: editorBox.y, width: editorBox.width, height: Math.min(editorBox.height, 500) } + await page.screenshot({ path: 'test-results/zero-shift-final.png', clip }) + } +}) diff --git a/src/components/Editor.css b/src/components/Editor.css index e4bffa36..acdc284e 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -237,3 +237,73 @@ .diff-view__line--context .diff-view__line-content { color: var(--text-secondary); } + +/* ============================================= + Bear-style live preview: zero horizontal shift + ============================================= */ + +/* FIX 1: Remove header underlines */ +.cm-heading-line { + border-bottom: none !important; + text-decoration: none !important; +} + +/* FIX 2: Heading markers in left gutter area + The ## markers are absolutely positioned in the content's left padding (40px), + so they never affect text flow — zero horizontal shift. */ +.cm-heading-line { + position: relative; +} + +.cm-heading-line .cm-formatting-block { + position: absolute !important; + top: 50%; + transform: translateY(-50%); + right: 100%; + padding-right: 4px; + font-size: 0.75rem !important; + line-height: 1; + opacity: 0; + transition: opacity 0.15s ease-out !important; + white-space: nowrap; + pointer-events: none; + color: var(--text-faint); +} + +.cm-heading-line .cm-formatting-block.cm-formatting-block-visible { + font-size: 0.75rem !important; + opacity: 0.5; + pointer-events: auto; +} + +/* FIX 3: List/block bullets — always visible, fixed width, zero shift + Override the library's font-size animation so the marker always occupies + the same space regardless of cursor position. */ +.cm-line:not(.cm-heading-line) .cm-formatting-block { + font-size: 1em !important; + display: inline-block !important; + min-width: 1.5em !important; + vertical-align: baseline; + transition: color 0.15s ease-out, opacity 0.15s ease-out !important; +} + +/* Inactive state: hide source text, show bullet dot via ::before */ +.cm-line:not(.cm-heading-line) .cm-formatting-block:not(.cm-formatting-block-visible) { + color: transparent !important; + opacity: 1 !important; + position: relative; + overflow: visible !important; +} + +.cm-line:not(.cm-heading-line) .cm-formatting-block:not(.cm-formatting-block-visible)::before { + content: '\2022'; + position: absolute; + left: 0; + color: var(--text-tertiary); +} + +/* Active state: show source marker (-, *, +, etc.) */ +.cm-line:not(.cm-heading-line) .cm-formatting-block.cm-formatting-block-visible { + color: var(--text-faint) !important; + opacity: 1 !important; +}