diff --git a/.gitignore b/.gitignore index 12b534b0..071d7394 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ apps/desktop/dist-electron/ apps/desktop/dist-electron*/ apps/desktop/out/ apps/desktop/node_modules/ +apps/desktop/test-results/ +apps/desktop/playwright-report/ +apps/desktop/e2e/dist/ assets/ !apps/desktop/src/assets/ !apps/desktop/src/assets/fonts/ diff --git a/apps/desktop/e2e/harness/index.html b/apps/desktop/e2e/harness/index.html new file mode 100644 index 00000000..3a87b93d --- /dev/null +++ b/apps/desktop/e2e/harness/index.html @@ -0,0 +1,65 @@ + + + + + NeverWrite live-preview harness + + + +
+ + + + diff --git a/apps/desktop/e2e/harness/main.ts b/apps/desktop/e2e/harness/main.ts new file mode 100644 index 00000000..3ef5aa7b --- /dev/null +++ b/apps/desktop/e2e/harness/main.ts @@ -0,0 +1,64 @@ +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { EditorSelection, EditorState } from "@codemirror/state"; +import { drawSelection, EditorView } from "@codemirror/view"; + +import { linkReferenceField } from "../../src/features/editor/extensions/livePreviewHelpers"; +import { createInlineLivePreviewPlugin } from "../../src/features/editor/extensions/livePreviewInline"; +import { livePreviewTheme } from "../../src/features/editor/extensions/livePreviewTheme"; + +type MountOptions = { + doc: string; + selection: number | { anchor: number; head?: number }; +}; + +declare global { + interface Window { + mountEditor: (options: MountOptions) => void; + editorView: EditorView | null; + } +} + +let view: EditorView | null = null; + +function toSelection(selection: MountOptions["selection"]) { + if (typeof selection === "number") return EditorSelection.cursor(selection); + if (selection.head === undefined || selection.head === selection.anchor) { + return EditorSelection.cursor(selection.anchor); + } + return EditorSelection.range(selection.anchor, selection.head); +} + +window.mountEditor = ({ doc, selection }: MountOptions) => { + view?.destroy(); + const parent = document.getElementById("editor"); + if (!parent) throw new Error("missing #editor mount node"); + parent.innerHTML = ""; + + view = new EditorView({ + state: EditorState.create({ + doc, + selection: toSelection(selection), + extensions: [ + markdown({ base: markdownLanguage }), + linkReferenceField, + createInlineLivePreviewPlugin(), + livePreviewTheme, + // The real editor (src/features/editor/Editor.tsx, + // FileTextTabView.tsx) uses drawSelection() so the caret is a + // CM-rendered element, not the native browser caret. Mirror + // that here so e2e measurements reflect what users see. + drawSelection(), + ], + }), + parent, + }); + + view.focus(); + // Ensure the contenteditable inside the editor gets focus too, so the + // caret element actually renders in headless browsers. + const contentEl = parent.querySelector(".cm-content") as HTMLElement | null; + contentEl?.focus(); + window.editorView = view; +}; + +window.editorView = null; diff --git a/apps/desktop/e2e/playwright.config.ts b/apps/desktop/e2e/playwright.config.ts new file mode 100644 index 00000000..28880b4f --- /dev/null +++ b/apps/desktop/e2e/playwright.config.ts @@ -0,0 +1,29 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: "http://localhost:5180", + trace: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: { + command: "npx vite --config e2e/vite.harness.config.ts --port 5180", + url: "http://localhost:5180", + cwd: "..", + reuseExistingServer: !process.env.CI, + stdout: "pipe", + stderr: "pipe", + timeout: 60_000, + }, +}); diff --git a/apps/desktop/e2e/tests/livePreviewLists.spec.ts b/apps/desktop/e2e/tests/livePreviewLists.spec.ts new file mode 100644 index 00000000..8b5a29b1 --- /dev/null +++ b/apps/desktop/e2e/tests/livePreviewLists.spec.ts @@ -0,0 +1,301 @@ +import { expect, test } from "@playwright/test"; + +/** + * Regression coverage for jsgrrchg/NeverWrite#102. + * + * Two visual properties must hold for an active empty list item: + * + * 1. Horizontal: the caret sits at the line's content-box left edge + * (flush with the rendered pseudo-bullet + gap). If the trailing + * source space leaks through, the caret drifts ~1ch past that. + * + * 2. Vertical / visibility: the caret has a non-zero rect. If the + * line's source content is fully collapsed to font-size: 0 hidden + * spans, the native caret's getClientRects() reports a 0x0 rect + * and the caret is invisible. + * + * Both assertions together catch: + * - main today: caret is visible but ~9.6 px past content-box left. + * - "collapse the full prefix" attempt: caret is at the right x but + * has 0 height/width. + */ + +type CaretMeasurement = { + cursorLeft: number; + cursorWidth: number; + cursorHeight: number; + contentBoxLeft: number; +}; + +const MIN_CARET_HEIGHT_PX = 10; // anything below this is effectively invisible +const POSITION_TOLERANCE_PX = 2; + +async function measureCaretAtLine( + page: import("@playwright/test").Page, + lineNumber: number, +): Promise { + return page.evaluate((nth) => { + const lines = document.querySelectorAll(".cm-content > .cm-line"); + const line = lines[nth - 1] as HTMLElement | undefined; + if (!line) throw new Error(`line ${nth} not found (have ${lines.length})`); + + // With drawSelection() (matching the real editor), the caret is a + // CM-rendered .cm-cursor element inside .cm-cursorLayer rather than + // the native browser caret. Measure that. + const cursor = document.querySelector( + ".cm-cursorLayer .cm-cursor", + ) as HTMLElement | null; + if (!cursor) throw new Error("caret element not found"); + + const caretRect = cursor.getBoundingClientRect(); + const lineRect = line.getBoundingClientRect(); + const paddingLeft = parseFloat( + window.getComputedStyle(line).paddingLeft, + ); + + return { + cursorLeft: caretRect.left, + cursorWidth: caretRect.width, + cursorHeight: caretRect.height, + contentBoxLeft: lineRect.left + paddingLeft, + }; + }, lineNumber); +} + +function expectCaretAnchoredToBullet(measurement: CaretMeasurement) { + expect(Math.abs(measurement.cursorLeft - measurement.contentBoxLeft)) + .toBeLessThan(POSITION_TOLERANCE_PX); + expect(measurement.cursorHeight).toBeGreaterThanOrEqual( + MIN_CARET_HEIGHT_PX, + ); +} + +test.beforeEach(async ({ page }) => { + await page.goto("/"); + await page.waitForFunction( + () => typeof (window as unknown as { mountEditor?: unknown }) + .mountEditor === "function", + ); +}); + +// --------------------------------------------------------------------------- +// Unordered list — active empty +// --------------------------------------------------------------------------- + +test("active empty top-level list item: caret sits flush with the bullet (#102)", async ({ + page, +}) => { + await page.evaluate(() => { + window.mountEditor({ doc: "- ", selection: 2 }); + }); + await page.waitForSelector(".cm-lp-li-line"); + await page.locator(".cm-content").focus(); + + expectCaretAnchoredToBullet(await measureCaretAtLine(page, 1)); +}); + +test("active empty nested list item: caret sits flush with the bullet (#102)", async ({ + page, +}) => { + await page.evaluate(() => { + window.mountEditor({ doc: "- parent\n - ", selection: 15 }); + }); + await page.waitForSelector(".cm-lp-li-line"); + await page.locator(".cm-content").focus(); + + expect(await page.locator(".cm-lp-li-line").count()).toBeGreaterThanOrEqual( + 2, + ); + expectCaretAnchoredToBullet(await measureCaretAtLine(page, 2)); +}); + +test("active empty middle list item: caret sits flush with the bullet (#102)", async ({ + page, +}) => { + // Empty item sandwiched between two non-empty items at the same level. + await page.evaluate(() => { + window.mountEditor({ + doc: "- alpha\n- \n- gamma", + selection: 10, + }); + }); + await page.waitForSelector(".cm-lp-li-line"); + await page.locator(".cm-content").focus(); + + expectCaretAnchoredToBullet(await measureCaretAtLine(page, 2)); +}); + +test("active empty middle item with nested child below (issue repro #102)", async ({ + page, +}) => { + // Exact case from the issue body: + // - Probando + // - + // - eeeee + const doc = "- Probando\n- \n - eeeee"; + await page.evaluate((d) => { + window.mountEditor({ doc: d, selection: 13 }); + }, doc); + await page.waitForSelector(".cm-lp-li-line"); + await page.locator(".cm-content").focus(); + + expectCaretAnchoredToBullet(await measureCaretAtLine(page, 2)); +}); + +test("clicking an active empty list prefix keeps the caret visible (#102)", async ({ + page, +}) => { + const doc = "- eee\n- ewgfreg\n- ewfwef\n- "; + await page.evaluate((d) => { + window.mountEditor({ doc: d, selection: d.length }); + }, doc); + await page.waitForSelector(".cm-lp-li-line"); + + const emptyLine = page.locator(".cm-content > .cm-line").nth(3); + const box = await emptyLine.boundingBox(); + if (!box) throw new Error("empty list line not measurable"); + + await page.mouse.click(box.x + 8, box.y + box.height / 2); + + const selectionHead = await page.evaluate(() => { + return window.editorView?.state.selection.main.head ?? null; + }); + + expect(selectionHead).toBe(doc.length); + expectCaretAnchoredToBullet(await measureCaretAtLine(page, 4)); +}); + +test("clicking an inactive empty list item activates its caret anchor (#102)", async ({ + page, +}) => { + const doc = "- eee\n- \n - rrrrr"; + await page.evaluate((d) => { + window.mountEditor({ doc: d, selection: 5 }); + }, doc); + await page.waitForSelector(".cm-lp-li-line"); + + const emptyLine = page.locator(".cm-content > .cm-line").nth(1); + const box = await emptyLine.boundingBox(); + if (!box) throw new Error("empty list line not measurable"); + + await page.mouse.click(box.x + 8, box.y + box.height / 2); + + const selectionHead = await page.evaluate(() => { + return window.editorView?.state.selection.main.head ?? null; + }); + + expect(selectionHead).toBe(8); + expectCaretAnchoredToBullet(await measureCaretAtLine(page, 2)); +}); + +// --------------------------------------------------------------------------- +// Task list — active empty (acceptance criterion: tasks not regressed) +// --------------------------------------------------------------------------- + +test("active empty top-level task item: caret sits flush with the checkbox (#102)", async ({ + page, +}) => { + await page.evaluate(() => { + window.mountEditor({ doc: "- [ ] ", selection: 6 }); + }); + await page.waitForSelector(".cm-lp-task-line"); + await page.locator(".cm-content").focus(); + + expectCaretAnchoredToBullet(await measureCaretAtLine(page, 1)); +}); + +test("active empty nested task item: caret sits flush with the checkbox (#102)", async ({ + page, +}) => { + const doc = "- [ ] parent\n - [ ] "; + await page.evaluate((d) => { + window.mountEditor({ doc: d, selection: d.length }); + }, doc); + await page.waitForSelector(".cm-lp-task-line"); + await page.locator(".cm-content").focus(); + + expectCaretAnchoredToBullet(await measureCaretAtLine(page, 2)); +}); + +// --------------------------------------------------------------------------- +// Ordered list — active empty (different presentation code path) +// --------------------------------------------------------------------------- + +test("active empty top-level ordered list item: caret sits flush with the marker (#102)", async ({ + page, +}) => { + await page.evaluate(() => { + window.mountEditor({ doc: "1. ", selection: 3 }); + }); + await page.waitForSelector(".cm-lp-li-line"); + await page.locator(".cm-content").focus(); + + expectCaretAnchoredToBullet(await measureCaretAtLine(page, 1)); +}); + +// --------------------------------------------------------------------------- +// Inactive baseline — must not regress +// --------------------------------------------------------------------------- + +test("inactive empty list item still renders a bullet without raw markdown", async ({ + page, +}) => { + // Cursor is on line 1 ("- alpha"); line 2 is "- " but inactive. + // Live preview should keep the bullet visible and hide the raw "- ". + await page.evaluate(() => { + window.mountEditor({ doc: "- alpha\n- ", selection: 5 }); + }); + await page.waitForSelector(".cm-lp-li-line"); + await page.locator(".cm-content").focus(); + + const line2 = await page.evaluate(() => { + const lines = document.querySelectorAll(".cm-content > .cm-line"); + const line = lines[1] as HTMLElement | undefined; + if (!line) return null; + + // innerText still includes text styled with width:0 + font-size:0, + // so assert on actual rendered geometry instead. + const sourceMarkerWidths: number[] = []; + for (const child of Array.from(line.childNodes)) { + if (child.nodeType !== Node.ELEMENT_NODE) continue; + const el = child as HTMLElement; + const text = el.textContent ?? ""; + if (!text.includes("-")) continue; + sourceMarkerWidths.push(el.getBoundingClientRect().width); + } + + return { + hasLiClass: line.classList.contains("cm-lp-li-line"), + maxMarkerWidth: sourceMarkerWidths.length + ? Math.max(...sourceMarkerWidths) + : 0, + }; + }); + + expect(line2).not.toBeNull(); + expect(line2!.hasLiClass).toBe(true); + // Any DOM child holding the raw "- " source must render at zero width + // so the marker is not visually duplicated next to the pseudo-bullet. + expect(line2!.maxMarkerWidth).toBeLessThan(1); +}); + +// --------------------------------------------------------------------------- +// Control — non-empty item must continue to work +// --------------------------------------------------------------------------- + +test("non-empty list item: caret sits past content-box (control)", async ({ + page, +}) => { + await page.evaluate(() => { + window.mountEditor({ doc: "- abc", selection: 5 }); + }); + await page.waitForSelector(".cm-lp-li-line"); + await page.locator(".cm-content").focus(); + + const measurement = await measureCaretAtLine(page, 1); + + expect(measurement.cursorLeft).toBeGreaterThan(measurement.contentBoxLeft); + expect(measurement.cursorHeight).toBeGreaterThanOrEqual( + MIN_CARET_HEIGHT_PX, + ); +}); diff --git a/apps/desktop/e2e/tsconfig.json b/apps/desktop/e2e/tsconfig.json new file mode 100644 index 00000000..56fb58b5 --- /dev/null +++ b/apps/desktop/e2e/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.app.json", + "compilerOptions": { + "tsBuildInfoFile": "../node_modules/.tmp/tsconfig.e2e.tsbuildinfo", + "types": ["node"], + "noEmit": true + }, + "include": ["./**/*.ts"], + "exclude": [] +} diff --git a/apps/desktop/e2e/vite.harness.config.ts b/apps/desktop/e2e/vite.harness.config.ts new file mode 100644 index 00000000..747c5726 --- /dev/null +++ b/apps/desktop/e2e/vite.harness.config.ts @@ -0,0 +1,18 @@ +import { fileURLToPath, URL } from "node:url"; +import { defineConfig, mergeConfig } from "vite"; + +import baseConfig from "../vite.config"; + +export default mergeConfig( + baseConfig, + defineConfig({ + root: fileURLToPath(new URL("./harness", import.meta.url)), + server: { + port: 5180, + strictPort: true, + }, + build: { + outDir: fileURLToPath(new URL("./dist", import.meta.url)), + }, + }), +); diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 4261d482..3701a243 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -49,6 +49,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.60.0", "@tailwindcss/vite": "^4.2.4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -2532,6 +2533,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", @@ -9823,6 +9840,53 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 43084427..cc9fdb99 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -14,6 +14,8 @@ "lint": "eslint .", "test": "vitest run", "test:watch": "vitest", + "test:e2e": "playwright test --config e2e/playwright.config.ts", + "test:e2e:ui": "playwright test --config e2e/playwright.config.ts --ui", "test:split-grid-baseline": "vitest run src/app/store/workspaceLayoutTree.test.ts src/app/store/workspaceLayoutNavigation.test.ts src/app/store/editorStore.test.ts src/app/store/editorSession.test.ts src/app/store/layoutStore.test.ts src/features/editor/MultiPaneWorkspace.test.tsx src/features/editor/EditorPaneBar.test.tsx src/features/editor/workspaceTabDropPreview.test.ts src/features/editor/UnifiedBar.test.tsx src/features/vault/FileTree.test.tsx src/features/ai/components/reviewMultiSessionIntegration.test.tsx src/features/ai/components/AIReviewView.test.tsx", "preview": "vite preview", "electron:dev": "node scripts/run-electron-dev.mjs", @@ -73,6 +75,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.60.0", "@tailwindcss/vite": "^4.2.4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", diff --git a/apps/desktop/src/features/editor/extensions/livePreviewInline.test.ts b/apps/desktop/src/features/editor/extensions/livePreviewInline.test.ts index 784bf19b..6a754699 100644 --- a/apps/desktop/src/features/editor/extensions/livePreviewInline.test.ts +++ b/apps/desktop/src/features/editor/extensions/livePreviewInline.test.ts @@ -231,7 +231,7 @@ describe("createInlineLivePreviewPlugin", () => { const decorations = collectDecorations(view, plugin); - expect(hasHiddenRange(decorations, 0, 1)).toBe(true); + expect(hasHiddenRange(decorations, 0, 2)).toBe(true); expect( decorations.some((deco) => deco.className.split(" ").includes("cm-lp-li-line"), @@ -242,6 +242,22 @@ describe("createInlineLivePreviewPlugin", () => { parent.remove(); }); + it("collapses the full prefix for an active nested empty list item", () => { + const doc = "- parent\n - "; + const { plugin, parent, view } = createView( + doc, + EditorSelection.cursor(doc.length), + ); + + const decorations = collectDecorations(view, plugin); + + expect(hasHiddenRange(decorations, 0, 2)).toBe(true); + expect(hasHiddenRange(decorations, 9, doc.length)).toBe(true); + + view.destroy(); + parent.remove(); + }); + it("keeps task markers hidden even when the caret is on the same line", () => { const doc = "- [ ] task"; const { plugin, parent, view } = createView( @@ -352,8 +368,8 @@ describe("createInlineLivePreviewPlugin", () => { const decorations = collectDecorations(view, plugin); - expect(hasHiddenRange(decorations, 0, 1)).toBe(true); - expect(hasHiddenRange(decorations, 2, 5)).toBe(true); + expect(hasHiddenRange(decorations, 0, 2)).toBe(true); + expect(hasHiddenRange(decorations, 2, doc.length)).toBe(true); expect( decorations.some((deco) => deco.className.split(" ").includes("cm-lp-task-line"), diff --git a/apps/desktop/src/features/editor/extensions/livePreviewInline.ts b/apps/desktop/src/features/editor/extensions/livePreviewInline.ts index 2814810a..8cdbbc9a 100644 --- a/apps/desktop/src/features/editor/extensions/livePreviewInline.ts +++ b/apps/desktop/src/features/editor/extensions/livePreviewInline.ts @@ -7,6 +7,7 @@ import { WidgetType, } from "@codemirror/view"; import { + EditorSelection, type EditorState, RangeSetBuilder, StateEffect, @@ -140,6 +141,29 @@ class InlineBreakWidget extends WidgetType { } } +class EmptyListCaretAnchorWidget extends WidgetType { + eq() { + return true; + } + + toDOM() { + const span = document.createElement("span"); + span.className = "cm-lp-caret-anchor"; + span.setAttribute("aria-hidden", "true"); + return span; + } + + ignoreEvent() { + return true; + } +} + +const emptyListCaretAnchorWidget = new EmptyListCaretAnchorWidget(); +const emptyListCaretAnchorDecoration = Decoration.widget({ + widget: emptyListCaretAnchorWidget, + side: -1, +}); + function createMathMark(display: "inline" | "block") { return Decoration.mark({ class: display === "block" ? "cm-lp-math-block" : "cm-lp-math-inline", @@ -293,6 +317,19 @@ function isActiveEmptyListLine( return item?.isEmpty === true; } +function registerEmptyListCaretAnchorDependency( + context: BuildContext, + lineFrom: number, + lineTo: number, +) { + const item = parseMarkdownListItem( + context.state.doc.sliceString(lineFrom, lineTo), + ); + if (!item?.isEmpty) return; + + registerRevealSensitiveRange(context, "line", lineFrom, lineTo); +} + function getListItemPresentation( listItem: SyntaxNode, state: EditorState, @@ -491,6 +528,48 @@ function hideRange( pushDeco(context, from, to, deco); } +function addEmptyListCaretAnchor(context: BuildContext, pos: number) { + pushDeco(context, pos, pos, emptyListCaretAnchorDecoration); +} + +function getActiveEmptyListPrefixEndAtPos( + state: EditorState, + pos: number, +): number | null { + const line = state.doc.lineAt(pos); + const item = parseMarkdownListItem(line.text); + if (!item?.isEmpty) return null; + + const prefixEnd = line.from + item.prefixLength; + if (pos < line.from || pos >= prefixEnd) return null; + + return prefixEnd; +} + +function moveEmptyListPrefixClickToContentStart( + event: MouseEvent, + view: EditorView, +) { + if (event.button !== 0) return false; + + const pos = view.posAtCoords({ + x: event.clientX, + y: event.clientY, + }); + if (pos === null) return false; + + const prefixEnd = getActiveEmptyListPrefixEndAtPos(view.state, pos); + if (prefixEnd === null) return false; + + event.preventDefault(); + view.dispatch({ + selection: EditorSelection.cursor(prefixEnd), + scrollIntoView: true, + }); + view.focus(); + return true; +} + function registerRevealSensitiveRange( context: BuildContext, strategy: RevealSensitiveRange["strategy"], @@ -761,6 +840,7 @@ const listMarkRule: NodeRule = (node, context) => { const isTaskItem = listItem ? hasDescendant(listItem, "TaskMarker") : false; const line = context.state.doc.lineAt(node.from); registerRevealSensitiveRange(context, "multiline-line", line.from, line.to); + registerEmptyListCaretAnchorDependency(context, line.from, line.to); if ( selectionHasMultilineRangeTouchingLine( context.state, @@ -777,7 +857,10 @@ const listMarkRule: NodeRule = (node, context) => { line.to, ); - hideRange(context, line.from, activeEmptyItem ? node.to : hideTo); + hideRange(context, line.from, hideTo); + if (activeEmptyItem && !isTaskItem) { + addEmptyListCaretAnchor(context, hideTo); + } if (isTaskItem) return; @@ -932,7 +1015,10 @@ const taskMarkerRule: NodeRule = (node, context) => { markerWidth: LIVE_PREVIEW_TASK_MARKER_WIDTH, }); - hideRange(context, node.from, activeEmptyItem ? node.to : prefixEnd); + hideRange(context, node.from, prefixEnd); + if (activeEmptyItem) { + addEmptyListCaretAnchor(context, prefixEnd); + } addLineDecoration( context.lineDecos, line.from, @@ -1833,7 +1919,12 @@ export function createInlineLivePreviewPlugin() { return buildResult.decorations; } }, - { decorations: (value) => value.decorations }, + { + decorations: (value) => value.decorations, + eventHandlers: { + mousedown: moveEmptyListPrefixClickToContentStart, + }, + }, ); } diff --git a/apps/desktop/src/features/editor/extensions/livePreviewListMetrics.ts b/apps/desktop/src/features/editor/extensions/livePreviewListMetrics.ts index c7cd90fa..832bf1ec 100644 --- a/apps/desktop/src/features/editor/extensions/livePreviewListMetrics.ts +++ b/apps/desktop/src/features/editor/extensions/livePreviewListMetrics.ts @@ -11,6 +11,7 @@ export const LIVE_PREVIEW_TASK_CHECKBOX_RADIUS_EM = 0.24; export const LIVE_PREVIEW_TASK_HIT_SLOP_PX = 2; export const LIVE_PREVIEW_ORDERED_MARKER_MIN_WIDTH_CH = 2.4; export const LIVE_PREVIEW_ORDERED_MARKER_PADDING_CH = 0.55; +export const LIVE_PREVIEW_LIST_MARKER_OPTICAL_OFFSET = "-0.05em"; export const LIVE_PREVIEW_UNORDERED_MARKER_WIDTH = `${LIVE_PREVIEW_UNORDERED_MARKER_WIDTH_EM}em`; export const LIVE_PREVIEW_TASK_MARKER_WIDTH = `${LIVE_PREVIEW_TASK_MARKER_WIDTH_EM}em`; @@ -20,7 +21,7 @@ export const LIVE_PREVIEW_LIST_CONTINUATION_PADDING_Y = `${LIVE_PREVIEW_LIST_CON export const LIVE_PREVIEW_TASK_CHECKBOX_SIZE = `${LIVE_PREVIEW_TASK_CHECKBOX_SIZE_EM}em`; export const LIVE_PREVIEW_TASK_CHECKBOX_RADIUS = `${LIVE_PREVIEW_TASK_CHECKBOX_RADIUS_EM}em`; export const LIVE_PREVIEW_TASK_HIT_SLOP = `${LIVE_PREVIEW_TASK_HIT_SLOP_PX}px`; -export const LIVE_PREVIEW_LIST_MARKER_TOP = `calc(var(--cm-lp-list-padding-y, ${LIVE_PREVIEW_LIST_PADDING_Y}) + ((var(--text-input-line-height) * 1em - 1em) / 2))`; +export const LIVE_PREVIEW_LIST_MARKER_TOP = `calc(var(--cm-lp-list-padding-y, ${LIVE_PREVIEW_LIST_PADDING_Y}) + ((var(--text-input-line-height) * 1em - 1em) / 2) + ${LIVE_PREVIEW_LIST_MARKER_OPTICAL_OFFSET})`; export const LIVE_PREVIEW_TASK_CHECKBOX_TOP = `calc(var(--cm-lp-list-padding-y, ${LIVE_PREVIEW_LIST_PADDING_Y}) + ((var(--text-input-line-height) * 1em - ${LIVE_PREVIEW_TASK_CHECKBOX_SIZE}) / 2))`; export const LIVE_PREVIEW_TASK_CHECKBOX_TICK_OFFSET = "0.26em"; export const LIVE_PREVIEW_TASK_CHECKBOX_PARTIAL_OFFSET = "0.40em"; diff --git a/apps/desktop/src/features/editor/extensions/livePreviewTheme.ts b/apps/desktop/src/features/editor/extensions/livePreviewTheme.ts index 28319059..f903d7ea 100644 --- a/apps/desktop/src/features/editor/extensions/livePreviewTheme.ts +++ b/apps/desktop/src/features/editor/extensions/livePreviewTheme.ts @@ -16,6 +16,13 @@ import { } from "./livePreviewListMetrics"; export const livePreviewTheme = EditorView.baseTheme({ + ".cm-lp-caret-anchor": { + display: "inline-block", + width: "0", + height: "1em", + overflow: "hidden", + verticalAlign: "text-bottom", + }, ".cm-lp-hidden": { display: "inline-block", fontSize: "0",