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",