From 0823e8276a4a741fff4a008a8a09e7a6fd765e77 Mon Sep 17 00:00:00 2001 From: Caldalis <2726397382@qq.com> Date: Fri, 10 Jul 2026 17:40:18 +0800 Subject: [PATCH] feat(core): add offset-based sync-scroll preview primitives Implements roadmap #13 (markdown live preview sync scroll): three core primitives plus a reference split-preview pane in the electron demo, behind an opt-in toggle (default off). Offset-anchored, not percentage-based - markdown blocks render at wildly different heights, so proportional sync cannot keep two panes aligned. packages/core: - New rAF-throttled scroll event ({ offset, ratio }) computed via lineBlockAtHeight in CM6 content-space; no screen-coordinate math. - New EditorAPI.scrollToOffset(offset, ratio?) as the write-side counterpart, same line-based unit. - New renderPreviewHtml(ast): mdast -> HTML via the existing remark-rehype/rehype-stringify deps (transform-only, no re-parse), tagging top-level blocks with data-offset scroll anchors. - lezer-mdast-adapter: emit spec-shaped mdast for GFM tables (align parsed from the delimiter row instead of hardcoded []) and task-list items (gap text recovered into a paragraph child). Without these the preview rendered cell-less tables and textless task items; other getAst() consumers (e.g. plugin-wordcount silently skipping task-item text) were affected too. Scope rationale in design.md. - Export ScrollPayload type. apps/electron-demo: - New preview-panel.ts: read-only rendered pane, bidirectional block-top scroll sync with a two-frame echo guard; link clicks intercepted (plain click swallowed, Ctrl/Cmd-click opens externally, absolute http(s) URLs only). - Toolbar toggle + splitPreview setting (default off). - togglePanel now takes an explicit show-display value, fixing a latent display="" restore bug also affecting outline/vault/backlinks panels. openspec: add-sync-scroll-preview (proposal, design, specs, tasks). No new runtime dependencies. --- apps/electron-demo/src/renderer/app.ts | 41 ++- .../src/renderer/preview-panel.ts | 260 ++++++++++++++++++ apps/electron-demo/src/renderer/settings.ts | 3 + apps/electron-demo/test/preview-panel.test.ts | 100 +++++++ docs/ROADMAP.md | 2 +- docs/ROADMAP.zh.md | 2 +- .../changes/add-sync-scroll-preview/design.md | 114 ++++++++ .../add-sync-scroll-preview/proposal.md | 46 ++++ .../specs/editor-core/spec.md | 39 +++ .../specs/sync-scroll-preview/spec.md | 61 ++++ .../changes/add-sync-scroll-preview/tasks.md | 52 ++++ packages/core/README.md | 34 +++ packages/core/src/editor.ts | 52 +++- packages/core/src/index.ts | 2 + packages/core/src/lezer-mdast-adapter.ts | 60 +++- packages/core/src/live-preview-to-html.ts | 54 ++++ packages/core/src/types.ts | 8 + .../core/test/live-preview-to-html.test.ts | 104 +++++++ packages/core/test/scroll-event.test.ts | 112 ++++++++ 19 files changed, 1128 insertions(+), 18 deletions(-) create mode 100644 apps/electron-demo/src/renderer/preview-panel.ts create mode 100644 apps/electron-demo/test/preview-panel.test.ts create mode 100644 openspec/changes/add-sync-scroll-preview/design.md create mode 100644 openspec/changes/add-sync-scroll-preview/proposal.md create mode 100644 openspec/changes/add-sync-scroll-preview/specs/editor-core/spec.md create mode 100644 openspec/changes/add-sync-scroll-preview/specs/sync-scroll-preview/spec.md create mode 100644 openspec/changes/add-sync-scroll-preview/tasks.md create mode 100644 packages/core/src/live-preview-to-html.ts create mode 100644 packages/core/test/live-preview-to-html.test.ts create mode 100644 packages/core/test/scroll-event.test.ts diff --git a/apps/electron-demo/src/renderer/app.ts b/apps/electron-demo/src/renderer/app.ts index 3ac1de17..28e747b9 100644 --- a/apps/electron-demo/src/renderer/app.ts +++ b/apps/electron-demo/src/renderer/app.ts @@ -1,11 +1,12 @@ import { createState, type AppState } from "./state"; import { createEditorShell, type EditorShell } from "./editor-shell"; -import { loadSettings, createSettingsPanel, type EditorSettings } from "./settings"; +import { loadSettings, saveSettings, createSettingsPanel, type EditorSettings } from "./settings"; import { createOutlinePanel, type OutlinePanel } from "./outline-panel"; import { createSearchBar, type SearchBar } from "./search-bar"; import { createVaultPanel, type VaultPanel } from "./vault-panel"; import { LinkIndex, parseAnchor, findAnchorPosition } from "./link-index"; import { createBacklinksPanel, type BacklinksPanel } from "./backlinks-panel"; +import { createPreviewPanel, type PreviewPanel } from "./preview-panel"; import { perfStart, perfEnd, installLongTaskWatch } from "./perf"; installLongTaskWatch(50); @@ -17,6 +18,7 @@ let outline: OutlinePanel; let searchBar: SearchBar; let vault: VaultPanel; let backlinks: BacklinksPanel; +let preview: PreviewPanel; const linkIndex = new LinkIndex(); state.linkIndex = linkIndex; @@ -65,6 +67,12 @@ function createAppToolbar(): HTMLElement { backlinksBtn.style.fontSize = "14px"; backlinksBtn.addEventListener("click", toggleBacklinks); + const previewBtn = document.createElement("button"); + previewBtn.textContent = "โ—ง"; + previewBtn.title = "Toggle split preview (synced scroll)"; + previewBtn.style.fontSize = "14px"; + previewBtn.addEventListener("click", togglePreview); + const searchBtn = document.createElement("button"); searchBtn.textContent = "\uD83D\uDD0D"; // ๐Ÿ” searchBtn.title = "Search (Ctrl+F)"; @@ -86,6 +94,7 @@ function createAppToolbar(): HTMLElement { vaultToggleBtn, outlineBtn, backlinksBtn, + previewBtn, searchBtn, settingsBtn ); @@ -175,15 +184,20 @@ async function handleSaveAs(): Promise { } function handleSettings(): void { + let lastSplitPreview = settings.splitPreview; createSettingsPanel(settings, (next) => { settings = next; shell.applySettings(settings); + if (settings.splitPreview !== lastSplitPreview) { + lastSplitPreview = settings.splitPreview; + setPreviewVisible(settings.splitPreview); + } }); } -function togglePanel(panel: HTMLElement, onShow?: () => void): void { +function togglePanel(panel: HTMLElement, showDisplay: string, onShow?: () => void): void { if (panel.style.display === "none") { - panel.style.display = ""; + panel.style.display = showDisplay; onShow?.(); } else { panel.style.display = "none"; @@ -191,15 +205,26 @@ function togglePanel(panel: HTMLElement, onShow?: () => void): void { } function toggleOutline(): void { - togglePanel(outline.element, () => outline.update()); + togglePanel(outline.element, "flex", () => outline.update()); } function toggleVault(): void { - togglePanel(vault.element); + togglePanel(vault.element, "flex"); } function toggleBacklinks(): void { - togglePanel(backlinks.element, () => backlinks.refresh()); + togglePanel(backlinks.element, "flex", () => backlinks.refresh()); +} + +function setPreviewVisible(visible: boolean): void { + preview.element.style.display = visible ? "flex" : "none"; + if (visible) preview.update(); +} +function togglePreview(): void { + const next = preview.element.style.display === "none"; + setPreviewVisible(next); + settings.splitPreview = next; + saveSettings(settings); } async function handleVaultFileOpen(filePath: string): Promise { @@ -406,9 +431,11 @@ function boot(): void { onOpenFile: (filePath) => void handleVaultFileOpen(filePath), getActiveFile: () => state.activeFile, }); + preview = createPreviewPanel(shell.editor); + setPreviewVisible(settings.splitPreview); editorColumn.append(searchBar.element, editorContainer); - mainArea.append(vault.element, editorColumn, outline.element, backlinks.element); + mainArea.append(vault.element, editorColumn, preview.element, outline.element, backlinks.element); // External file changes โ†’ re-seed the index (cheap for typical vaults). window.nexusDemo.vault.onChanged(() => { diff --git a/apps/electron-demo/src/renderer/preview-panel.ts b/apps/electron-demo/src/renderer/preview-panel.ts new file mode 100644 index 00000000..686dc9aa --- /dev/null +++ b/apps/electron-demo/src/renderer/preview-panel.ts @@ -0,0 +1,260 @@ +import { renderPreviewHtml, type EditorAPI, type ScrollPayload } from "@floatboat/nexus-core"; + +export interface PreviewPanel { + element: HTMLElement; + update(): void; + destroy(): void; +} + +const PANEL_STYLES = ` + flex: 1; + min-width: 0; + border-left: 1px solid var(--nexus-border, #eee); + background: var(--nexus-bg, #fff); + display: flex; + flex-direction: column; + overflow: hidden; +`; + +const HEADER_STYLES = ` + padding: 10px 14px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--nexus-text-muted, #888); + border-bottom: 1px solid var(--nexus-border, #eee); + flex-shrink: 0; + font-family: system-ui, -apple-system, sans-serif; +`; + +const CONTENT_STYLES = ` + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px 24px; + font-family: system-ui, -apple-system, sans-serif; + font-size: 15px; + line-height: 1.6; + color: var(--nexus-text, #24292e); + /* Scroll-sync math (findNearestByOffset / findTopmostVisible) reads each + rendered element's offsetTop and compares it directly against this + element's own scrollTop. offsetTop is relative to the nearest + *positioned* ancestor, not the immediate parent โ€” without this, that + ancestor is (nothing else in the chain sets position), so every + computed offset is off by "distance from the top of the whole window" + and scrollTop assignments just clamp to the max, i.e. the pane never + visibly moves. position: relative makes this element the offsetParent + for its rendered content, so offsetTop lines up with its own scrollTop. */ + position: relative; +`; + +// Minimal baseline typography reusing the demo's existing theme CSS +// variables โ€” not a new stylesheet, just sane defaults so the rendered +// preview isn't unstyled browser-default HTML. +const CONTENT_TYPOGRAPHY = ` + .nexus-preview-content h1, .nexus-preview-content h2, .nexus-preview-content h3, + .nexus-preview-content h4, .nexus-preview-content h5, .nexus-preview-content h6 { + font-weight: 600; margin: 1.2em 0 0.5em; line-height: 1.3; + } + .nexus-preview-content p, .nexus-preview-content li, .nexus-preview-content td, .nexus-preview-content th { + /* remark-rehype keeps CommonMark soft line breaks as a literal "\n" in + the text node (one

per blank-line-separated paragraph, exactly per + spec) rather than a
. Default CSS (white-space: normal) collapses + that newline to a space, so a paragraph typed across several editor + lines renders as one run-on line in the preview. pre-line preserves + the newline as a visual line break while still collapsing runs of + spaces/tabs, so it doesn't reintroduce markdown-source indentation. */ + white-space: pre-line; + } + .nexus-preview-content p { margin: 0.6em 0; } + .nexus-preview-content code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + background: var(--nexus-bg-subtle, #f4f4f4); border-radius: 4px; padding: 0.1em 0.3em; font-size: 0.9em; + } + .nexus-preview-content pre { + background: var(--nexus-bg-subtle, #f4f4f4); border-radius: 4px; padding: 10px 12px; overflow: auto; + } + .nexus-preview-content pre code { background: none; padding: 0; } + .nexus-preview-content blockquote { + margin: 0.6em 0; padding: 4px 0 4px 14px; border-left: 3px solid var(--nexus-border, #ddd); + color: var(--nexus-text-muted, #888); + } + .nexus-preview-content table { border-collapse: collapse; margin: 0.6em 0; } + .nexus-preview-content th, .nexus-preview-content td { + border: 1px solid var(--nexus-border-subtle, #e2e2e2); padding: 4px 10px; text-align: left; + } + .nexus-preview-content a { color: var(--nexus-accent, #0969da); } +`; + +let styleInjected = false; +function ensureTypographyStyle(): void { + if (styleInjected) return; + styleInjected = true; + const style = document.createElement("style"); + style.textContent = CONTENT_TYPOGRAPHY; + document.head.appendChild(style); +} + +interface OffsetEntry { + offset: number; + el: HTMLElement; +} + +function getOffsetEntries(content: HTMLElement): OffsetEntry[] { + const entries: OffsetEntry[] = []; + content.querySelectorAll("[data-offset]").forEach((el) => { + const offset = Number(el.dataset.offset); + if (Number.isFinite(offset)) entries.push({ offset, el }); + }); + return entries; +} + +/** Entries are in document order (renderPreviewHtml walks children in source + * order), so the last entry with `keyOf(entry) <= threshold` is the nearest + * one at or before `threshold` โ€” a single forward scan, no sort/search + * needed. Shared by both scroll directions: `keyOf` is `entry.offset` when + * matching a source offset, `entry.el.offsetTop` when matching a scrollTop. */ +function findLastAtOrBefore( + entries: OffsetEntry[], + threshold: number, + keyOf: (entry: OffsetEntry) => number +): OffsetEntry | null { + let best: OffsetEntry | null = null; + for (const entry of entries) { + if (keyOf(entry) > threshold) break; + best = entry; + } + return best ?? entries[0] ?? null; +} + +export function createPreviewPanel(editor: EditorAPI): PreviewPanel { + ensureTypographyStyle(); + + const panel = document.createElement("div"); + panel.className = "nexus-preview-panel"; + panel.style.cssText = PANEL_STYLES; + + const header = document.createElement("div"); + header.style.cssText = HEADER_STYLES; + header.textContent = "Preview"; + + const content = document.createElement("div"); + content.className = "nexus-preview-content"; + content.style.cssText = CONTENT_STYLES; + + panel.append(header, content); + + // Guards against the two scroll bindings below triggering each other in a + // feedback loop: any programmatic scroll we apply sets this before the + // write and clears it two animation frames later, so the listener on the + // *other* side ignores the echo it causes. Two frames, not one: CM6's own + // scroll tracker (editor.ts) is itself rAF-throttled, so a programmatic + // `scrollDOM.scrollTop` write here echoes back through its native "scroll" + // event, into that throttle, and out through our `scroll` listener one + // frame later than the write itself โ€” a single-frame guard can clear + // before that echo arrives. + let isSyncingScroll = false; + let scrollRafId: number | null = null; + + function withSyncGuard(apply: () => void): void { + isSyncingScroll = true; + apply(); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + isSyncingScroll = false; + }); + }); + } + + // Both directions align the matched block's top edge only โ€” no sub-block + // ratio interpolation. `ratio` in the `scroll` event is computed against a + // single CM6 source line's height, but a rendered preview block can span + // many source lines (a multi-line paragraph, a whole code fence); reusing + // that ratio against the DOM block's full height (or vice versa, against + // `scrollToOffset`'s single-line height) mixes two different-sized units + // and lands in the wrong place within the block. Block-top alignment is + // still exact โ€” only the "how far *into* this block" refinement is + // dropped, and only where it couldn't be computed correctly. + function onEditorScroll({ offset }: ScrollPayload): void { + if (isSyncingScroll) return; + const target = findLastAtOrBefore(getOffsetEntries(content), offset, (e) => e.offset); + if (!target) return; + + withSyncGuard(() => { + content.scrollTop = target.el.offsetTop; + }); + } + + function onPanelScroll(): void { + if (isSyncingScroll || scrollRafId !== null) return; + scrollRafId = requestAnimationFrame(() => { + scrollRafId = null; + if (isSyncingScroll) return; + + const target = findLastAtOrBefore(getOffsetEntries(content), content.scrollTop, (e) => e.el.offsetTop); + if (!target) return; + + withSyncGuard(() => { + editor.scrollToOffset(target.offset); + }); + }); + } + + function update(): void { + content.innerHTML = renderPreviewHtml(editor.getAst()); + } + + // `new URL(href)` with no base throws unless `href` is already a valid + // absolute URL โ€” this rejects relative/malformed hrefs (e.g. the garbage + // `href="[Some Note]"` the wiki-link adapter quirk produces, see + // design.md) instead of letting `window.open` resolve them against the + // current page's own origin, which in dev is the Vite dev server itself โ€” + // silently reopening a whole extra copy of this app in a new window. + function isAbsoluteHttpUrl(href: string): boolean { + try { + const url = new URL(href); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } + } + + // renderPreviewHtml emits plain elements. Left unhandled, a click + // makes Electron's BrowserWindow itself navigate to that URL โ€” there's no + // will-navigate/setWindowOpenHandler guard in electron/main.ts, so the + // whole app would be replaced by the target page. Mirror the existing + // convention from live-preview-renderers.ts's inline link renderer: + // Ctrl/Cmd-click opens externally via window.open, a plain click is + // swallowed (preventDefault always, so the app itself never navigates). + function onContentClick(event: MouseEvent): void { + const anchor = (event.target as HTMLElement | null)?.closest?.("a[href]"); + if (!anchor) return; + event.preventDefault(); + if (event.ctrlKey || event.metaKey) { + const href = anchor.getAttribute("href")!; + if (isAbsoluteHttpUrl(href)) { + window.open(href, "_blank", "noopener,noreferrer"); + } + } + } + + update(); + editor.on("change", update); + editor.on("scroll", onEditorScroll); + content.addEventListener("scroll", onPanelScroll, { passive: true }); + content.addEventListener("click", onContentClick); + + return { + element: panel, + update, + destroy() { + if (scrollRafId !== null) cancelAnimationFrame(scrollRafId); + content.removeEventListener("scroll", onPanelScroll); + content.removeEventListener("click", onContentClick); + editor.off("change", update); + editor.off("scroll", onEditorScroll); + panel.remove(); + }, + }; +} diff --git a/apps/electron-demo/src/renderer/settings.ts b/apps/electron-demo/src/renderer/settings.ts index 73ccd135..a1673461 100644 --- a/apps/electron-demo/src/renderer/settings.ts +++ b/apps/electron-demo/src/renderer/settings.ts @@ -12,6 +12,7 @@ export interface EditorSettings { indentGuides: boolean; lineNumbers: boolean; livePreview: boolean; + splitPreview: boolean; } const STORAGE_KEY = "nexus-editor-settings"; @@ -28,6 +29,7 @@ export function defaultSettings(): EditorSettings { indentGuides: false, lineNumbers: true, livePreview: true, + splitPreview: false, }; } @@ -274,6 +276,7 @@ export function createSettingsPanel(settings: EditorSettings, onChange: OnChange body.appendChild(row("Indent guides", "Show indentation guide lines", createToggle(s.indentGuides, (v) => { s.indentGuides = v; emit(); }))); body.appendChild(row("Content max width", "Limit line width for readability (e.g. 720px)", createTextInput(s.contentMaxWidth, "e.g. 720px", (v) => { s.contentMaxWidth = v; emit(); }))); body.appendChild(row("Text direction", "Left-to-right or right-to-left", createSelect(["ltr", "rtl"], s.direction, (v) => { s.direction = v as "ltr" | "rtl"; emit(); }))); + body.appendChild(row("Split preview", "Show a synced, read-only rendered preview pane", createToggle(s.splitPreview, (v) => { s.splitPreview = v; emit(); }))); // -- Font section -- body.appendChild(sectionTitle("Font")); diff --git a/apps/electron-demo/test/preview-panel.test.ts b/apps/electron-demo/test/preview-panel.test.ts new file mode 100644 index 00000000..bc3bacc7 --- /dev/null +++ b/apps/electron-demo/test/preview-panel.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createEditor, type EditorAPI } from "@floatboat/nexus-core"; +import { createPreviewPanel } from "../src/renderer/preview-panel"; + +// Regression guard: renderPreviewHtml emits plain elements, and this +// app's electron/main.ts has no will-navigate / setWindowOpenHandler guard. +// Left unhandled, clicking a preview link makes the whole BrowserWindow +// navigate away from the app. These tests pin that a plain click is always +// swallowed, and Ctrl/Cmd-click is the only way out (via window.open, same +// convention as the inline live-preview link renderer). +describe("createPreviewPanel โ€” link click handling", () => { + let editor: EditorAPI; + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement("div"); + editor = createEditor({ container, initialValue: "[click me](https://example.com)" }); + }); + + afterEach(() => { + editor.destroy(); + }); + + it("prevents default navigation on a plain click and does not open externally", () => { + const panel = createPreviewPanel(editor); + const anchor = panel.element.querySelector("a[href]") as HTMLAnchorElement | null; + expect(anchor).not.toBeNull(); + + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + const notCancelled = anchor!.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }) + ); + + expect(notCancelled).toBe(false); // dispatchEvent returns false once preventDefault() is called + expect(openSpy).not.toHaveBeenCalled(); + + openSpy.mockRestore(); + panel.destroy(); + }); + + it("opens externally via window.open on Ctrl/Cmd-click", () => { + const panel = createPreviewPanel(editor); + const anchor = panel.element.querySelector("a[href]") as HTMLAnchorElement | null; + expect(anchor).not.toBeNull(); + + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + anchor!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, ctrlKey: true })); + + expect(openSpy).toHaveBeenCalledWith("https://example.com", "_blank", "noopener,noreferrer"); + + openSpy.mockRestore(); + panel.destroy(); + }); + + it("does not call window.open for a non-absolute href on Ctrl/Cmd-click", () => { + // Regression: [[Some Note]] wiki-link syntax isn't a real mdast node, and + // the underlying adapter's unresolved-shortcut-link fallback (see + // design.md) gives it a garbage href like "[Some Note]" โ€” not an + // absolute URL. Passing that straight to window.open() lets the browser + // resolve it against the current page's own origin (the Vite dev server + // in dev), silently opening a whole extra copy of this app in a new + // window instead of doing nothing. Ctrl/Cmd-click on such a link must be + // a no-op, not an attempt to "open" a same-origin path. + const wikiEditor = createEditor({ + container: document.createElement("div"), + initialValue: "See [[Some Note]] for details.", + }); + const panel = createPreviewPanel(wikiEditor); + const anchor = panel.element.querySelector("a[href]") as HTMLAnchorElement | null; + expect(anchor).not.toBeNull(); + // Sanity: confirm this test actually exercises a non-absolute href โ€” + // `new URL(href)` with no base throws for anything that isn't already + // an absolute URL. + expect(() => new URL(anchor!.getAttribute("href")!)).toThrow(); + + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + anchor!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, ctrlKey: true })); + + expect(openSpy).not.toHaveBeenCalled(); + + openSpy.mockRestore(); + panel.destroy(); + wikiEditor.destroy(); + }); + + it("ignores clicks that don't land on a link", () => { + const panel = createPreviewPanel(editor); + const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); + + const notCancelled = panel.element.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }) + ); + + expect(notCancelled).toBe(true); + expect(openSpy).not.toHaveBeenCalled(); + + openSpy.mockRestore(); + panel.destroy(); + }); +}); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d8ef0a05..28745415 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -50,7 +50,7 @@ This document maps every planned feature to **package ownership / priority / sta | # | Feature | Package | Priority | Status | Needs OpenSpec | Notes | |---|---|---|---|---|---|---| -| 13 | Markdown live preview sync scroll | `core` | P2 | planned | No | Only relevant in split-preview mode | +| 13 | Markdown live preview sync scroll | `core` + `electron-demo` | P2 | in-progress | No | Offset-based (not percentage) two-way scroll sync behind an opt-in split-preview toggle โ€” `openspec/changes/add-sync-scroll-preview` | | 14 | Custom keymap UI | `react` / `vue` + `core` | P2 | planned | Yes | Need to expose keymap register / query API first | ## 6. React / Vue SDK diff --git a/docs/ROADMAP.zh.md b/docs/ROADMAP.zh.md index eeb3e0b5..21b7c383 100644 --- a/docs/ROADMAP.zh.md +++ b/docs/ROADMAP.zh.md @@ -50,7 +50,7 @@ | # | ๅŠŸ่ƒฝ | ๅฝ’ๅฑžๅŒ… | ไผ˜ๅ…ˆ็บง | ็Šถๆ€ | ้œ€่ฆ OpenSpec | ๅค‡ๆณจ | |---|---|---|---|---|---|---| -| 13 | Markdown live preview ๅŒๆญฅๆปšๅŠจ | `core` | P2 | planned | ๅฆ | ไป…ๅœจๅˆ†ๅฑ preview ๅœบๆ™ฏ็”Ÿๆ•ˆ | +| 13 | Markdown live preview ๅŒๆญฅๆปšๅŠจ | `core` + `electron-demo` | P2 | in-progress | ๅฆ | ๅŸบไบŽๅญ—็ฌฆๅ็งป้‡๏ผˆ้ž็™พๅˆ†ๆฏ”๏ผ‰็š„ๅŒๅ‘ๆปšๅŠจๅŒๆญฅ๏ผŒ่—ๅœจ้ป˜่ฎคๅ…ณ้—ญ็š„ๅˆ†ๅฑ้ข„่งˆๅผ€ๅ…ณๅŽ้ข โ€”โ€” `openspec/changes/add-sync-scroll-preview` | | 14 | ่‡ชๅฎšไน‰ๅฟซๆท้”ฎ็•Œ้ข | `react` / `vue` + `core` | P2 | planned | ๆ˜ฏ | ้œ€่ฆๅ…ˆๆšด้œฒ keymap ๆณจๅ†Œ / ๆŸฅ่ฏข API | ## 6. React / Vue SDK diff --git a/openspec/changes/add-sync-scroll-preview/design.md b/openspec/changes/add-sync-scroll-preview/design.md new file mode 100644 index 00000000..e8e8e9e2 --- /dev/null +++ b/openspec/changes/add-sync-scroll-preview/design.md @@ -0,0 +1,114 @@ +## Context + +Nexus is AST-driven (`mdast`) with CodeMirror 6 as the editing surface and an inline, Obsidian-style live preview โ€” there is no rendered HTML preview surface anywhere in the codebase today. This design's bar: precise scroll alignment (not approximate), zero new dependencies, and a change scoped to exactly one concern (see `proposal.md` ยง Why). + +## Goals / Non-Goals + +- Goals: precise (offset-based) two-way scroll sync between the editor and a new read-only rendered preview pane; zero new runtime dependencies; opt-in, no default UX change; single concern (no bundled unrelated feature). +- Non-Goals: wiki-link rendering in the static preview; a keybinding customization UI; multi-pane layouts beyond one editor + one preview. + +## Decisions + +### Decision: sync anchor is a character offset, not a line number + +`lezer-mdast-adapter.ts`'s `position()` helper (`packages/core/src/lezer-mdast-adapter.ts:48-57`) hardcodes `line: 0, column: 0` on every node โ€” only `.offset` is ever populated: + +```ts +function position(from: number, to: number): PositionedNode["position"] { + return { + start: { line: 0, column: 0, offset: from }, + end: { line: 0, column: 0, offset: to }, + }; +} +``` + +The file's own comment (line 53) states live-preview only ever reads offsets. Line-based anchoring is therefore unavailable without changing a heavily-depended-on adapter that every other live-preview subsystem trusts, and doing so is out of scope for this change. Offset-based anchoring needs no such change and is naturally exact, since CM6's own coordinate system is already character offsets โ€” no conversion layer needed. + +- **Alternative considered**: percentage scroll (`scrollTop / scrollHeight`) โ€” rejected. Block heights vary too much (a fenced code block vs. a heading vs. a table) for percentage math to keep the two panes visually aligned. + +### Decision: `scroll` is a pushed event on `EditorEventMap`, not a pulled `getViewportOffset()` method + +Matches the existing `change` / `selectionChange` convention โ€” hosts already know how to subscribe to continuous editor state via `editor.on(...)`. A new pull-style method would be a second, inconsistent way to observe editor state, and would push the rAF-throttling responsibility onto every host instead of centralizing it once in core. + +### Decision: read side is a `scroll` event, write side is `scrollToOffset(offset, ratio?)` โ€” both stay in content-space, never screen-space + +The natural-looking write-side implementation would reuse `getCoordsAtPos()` (offset โ†’ screen rect) and translate that into a `scrollTop` delta on the host side. That leaks CM6 scroll-container geometry into `apps/electron-demo` and requires the host to reason about viewport rects it has no other reason to touch. Instead, both directions stay inside `core` and operate purely in CM6's content-space height coordinates: the read side uses `view.lineBlockAtHeight(view.scrollDOM.scrollTop)` (scroll offset โ†’ block), the write side uses `view.lineBlockAt(offset)` + directly setting `view.scrollDOM.scrollTop` (block โ†’ scroll offset). Symmetric, and the CM6 `EditorView` itself still never leaks past `core`. + +### Decision: preview HTML is generated from the already-computed `editor.getAst()` tree, not by re-parsing markdown text + +`editor.ts`'s `createTransformProcessor` (`editor.ts:217-229`) already demonstrates running a unified processor's transformers against an existing tree via `processor.runSync(tree)` instead of `processor.parse(text)`. `renderPreviewHtml` reuses this pattern: `unified().use(remarkRehype).use(rehypeStringify)`, then `processor.runSync(ast)` to get the hast tree, inject `data-offset` attributes, then `processor.stringify(hast)`. This avoids a second parse pass and keeps the preview byte-for-byte consistent with `getAst()` / the `change` event's AST โ€” no drift between what `getAst()` reports and what the preview shows. + +- **Alternative considered**: reusing `live-preview-renderers.ts`'s `renderLivePreviewNode` โ€” rejected because it's built to render one node as a self-contained CM6 widget and flattens child content to plain text via `getText()`. Reusing it for a full recursive preview would silently drop inline formatting (bold/italic/links inside a heading, for example). + +### Decision: feedback-loop prevention via a single `isSyncingScroll` boolean flag + +Set immediately before a programmatic scroll is applied, cleared on the next `requestAnimationFrame`. Two-way scroll bindings are a known source of infinite scroll ping-pong; this is the standard mitigation and keeps the demo-side code a plain flag instead of a state machine. + +### Decision: split preview defaults off + +Matches the existing pattern for opt-in visual changes in this codebase โ€” `multiCursor` defaults off for the same reason (see `add-core-multi-cursor/design.md` Decision 1). Avoids changing the demo's default layout/screenshots and keeps this change purely additive. + +## Discovered during implementation: pre-existing unresolved-shortcut-link bug + +Writing the `renderPreviewHtml` tests against real `[[Wiki Link]]` fixtures (not hand-built ASTs) surfaced that `[[Some Note]]` does not render as literal text the way the original proposal assumed. What actually happens, traced to `lezer-mdast-adapter.ts:310-360` (the `Link`/`Autolink`/`URL` case): + +1. The outer `[` / `]` pair does not form a valid nested link (CommonMark disallows an unescaped `[` inside a link label), so those two characters stay literal text โ€” this part matches the original assumption. +2. The inner `[Some Note]` is itself a syntactically valid CommonMark **shortcut reference link** with no matching definition. `@lezer/markdown` still tags it as a `Link` syntax node (parsing is definition-agnostic). The adapter's fallback โ€” `readSlice(node.from, node.to)` when no `URL` child exists โ€” is written for bare autolinks (``, `https://x` with no `[]`), but the same code path also catches this bracket-only, no-destination case, producing `href="[Some Note]"` (URL-encoded on output): a link element with a nonsensical, self-referential destination instead of degrading to plain text. + +This is a **pre-existing bug in a shared, heavily-depended-on adapter**, unrelated to sync-scroll-preview, and out of scope here: this change stays to one concern and doesn't bundle unrelated fixes into a feature PR. The spec for this change (`specs/sync-scroll-preview/spec.md`) describes the verified actual behavior rather than the originally-assumed "renders as plain text." A correct fix (skip the link entirely โ€” or treat it as a bracketed-text fallback โ€” when a `Link` node has no `URL` child *and* no matching `Definition`) belongs in its own standalone bug-fix PR against `lezer-mdast-adapter.ts`, with its own regression test, since any change to that file affects every consumer of `editor.getAst()`, not just this preview pane. + +## Discovered during self-review: ratio computed in two incompatible units + +A structured review of the implementation (8 independent finder passes, correctness + cleanup) surfaced that the `scroll` event's `ratio` and `scrollToOffset`'s `ratio` parameter were being used to mean two different things across the core/demo boundary: + +- `editor.ts`'s `createScrollTracker` computes `ratio` as the fractional position within a single CM6 **source line** (`view.lineBlockAtHeight` returns one line's `BlockInfo`). +- `preview-panel.ts`'s `findTopmostVisible` (now `findLastAtOrBefore`) computed `ratio` as the fractional position within a rendered top-level **AST block** (a `

`, `

  • `, etc.), which can span many source lines โ€” a multi-line paragraph or a whole fenced code block. + +Applying one side's ratio against the other side's (differently-sized) block produces a real misalignment for any multi-line block: e.g. scrolling to 60% through a 3-line paragraph in the preview would translate to `scrollToOffset(offset, 0.6)` in the editor, but `0.6` there means "60% through one line," not "60% through the paragraph" โ€” the editor lands near the top of the paragraph regardless of how far down the user actually was. + +**Fix**: dropped ratio-based sub-block interpolation from both directions of the sync โ€” `onEditorScroll` aligns `target.el.offsetTop` directly (no `+ ratio * height` term), and `onPanelScroll` calls `editor.scrollToOffset(target.offset)` with no ratio argument (defaults to the block's exact top). This is a narrower guarantee than originally designed โ€” block-top alignment only, not smooth sub-block interpolation โ€” but it's *correct*, whereas the two-unit ratio mixing was silently wrong. The `scroll` event still emits `ratio` (now exported as `ScrollPayload`, `packages/core/src/types.ts`) since it's still a well-defined, locally-meaningful value on the editor side alone; the bug was specifically in reusing it across the core/demo, CM6-line/DOM-block boundary, not in computing it. + +A correct sub-block fix would require plumbing mdast block-range boundaries into the core scroll tracker (which currently only knows about CM6 lines, not AST blocks) โ€” a bigger, more invasive change than this refinement is worth; block-level alignment is already far more precise than the percentage-based approach this feature exists to replace. + +## Discovered during self-review: shared `togglePanel()` bug fixed at the source, not worked around + +The offsetParent/`display:flex`-restore fix documented above (`## Discovered during implementation`) was initially applied only to `preview-panel.ts`'s own `setPreviewVisible`, leaving `apps/electron-demo/src/renderer/app.ts`'s shared `togglePanel()` helper โ€” used by the pre-existing outline/vault/backlinks panels, all of which are also `display: flex` internally โ€” with the same latent bug (`panel.style.display = ""` on show, instead of restoring an explicit value). Self-review flagged this from two independent angles: a reuse angle (the new panel duplicates `togglePanel`'s job instead of extending it) and an altitude angle (the fix belongs in the shared mechanism, not bolted onto one caller). + +**Fix**: `togglePanel(panel, showDisplay, onShow?)` now takes the show-state display value as an explicit parameter instead of clearing to `""`; all four call sites (`toggleOutline`, `toggleVault`, `toggleBacklinks`, and the preview panel's own toggle path) pass `"flex"`. This is judged in-scope rather than a bundled unrelated fix (the discipline this proposal otherwise holds to โ€” see the wiki-link bug above, deliberately deferred): unlike that adapter bug, this fix is exercising the exact mechanism the new preview panel itself depends on to show/hide correctly, at the shared call site that mechanism already lives at, with a net reduction in new code (removes the need for a bespoke workaround). + +## Discovered during self-review: minor hardening and cleanup + +- `preview-panel.ts`'s `isSyncingScroll` guard now clears two animation frames after a programmatic scroll, not one. CM6's own scroll tracker (`editor.ts`) is itself rAF-throttled, so a programmatic `scrollDOM.scrollTop` write echoes back through its native `scroll` event, into that internal throttle, and out through the `scroll` event listener one frame later than the write itself; a single-frame guard could clear before that echo arrives, letting it trigger a redundant re-sync. +- `findNearestByOffset` and `findTopmostVisible` were two copies of the same "last entry at-or-before a threshold" forward scan, differing only in which field was compared. Collapsed into one `findLastAtOrBefore(entries, threshold, keyOf)`. +- `apps/electron-demo/src/renderer/app.ts`'s settings-panel `onChange` fires on every field mutation (e.g. each `input` event while dragging the font-size slider), and was unconditionally calling `setPreviewVisible(settings.splitPreview)` โ€” a full preview re-render when visible โ€” even when `splitPreview` hadn't changed. Now tracks the last-applied value and only re-applies on an actual change. +- Exported `ScrollPayload` (the `scroll` event's `{ offset, ratio }` shape) from `packages/core/src/types.ts` / `index.ts`, matching the precedent set by `SelectionState` for `selectionChange` โ€” hosts (and this change's own `preview-panel.ts`) no longer need to hand-redeclare the inline shape. + +Considered and deliberately not changed: centralizing the rAF-throttle-a-scroll-handler boilerplate shared between `editor.ts` and `preview-panel.ts` (would require new cross-package public API surface to save ~5 lines โ€” not worth it); the per-frame `querySelectorAll("[data-offset]")` cost in the scroll-sync path (already an accepted trade-off โ€” see Risks / Trade-offs below); a main-process `will-navigate` / `setWindowOpenHandler` guard in `electron/main.ts` as a defense-in-depth alternative to `preview-panel.ts`'s own click interception (a strictly more robust fix protecting every current and future renderer surface, but a separate, standalone hardening effort โ€” this change's own preview pane is already safe without it). + +## Discovered during manual verification: the adapter emits non-spec mdast shapes โ€” GFM tables and task lists rendered empty + +Manual testing surfaced that tables rendered as empty `` shells and task-list items (`- [ ] โ€ฆ`) rendered as a bare checkbox with no text. Probed (not assumed) by dumping the actual mdast from `lezerStringToMdast` and the raw Lezer syntax tree; two distinct causes in `lezer-mdast-adapter.ts`, both of the same class โ€” **the adapter emitted structurally-plausible but non-spec mdast that its original consumer never noticed**, because the live-preview decoration pipeline only reads node offsets (the adapter's own line-53 comment says exactly this) and the editor's table/checkbox widgets re-derive everything from source text: + +1. **`adaptTable` hardcoded `align: []`.** The mdast the adapter produced actually had complete rows/cells/text โ€” but `mdast-util-to-hast`'s tableRow handler emits exactly `align.length` cells per row (the align array's length doubles as the column count in real remark output, where it's always populated from the delimiter row). An empty array = zero columns = every row rendered cell-less. Verified by taking the identical tree, setting `align = [null, null]`, and watching the same renderer produce a perfect table. **Fix**: parse the delimiter row (`| :-- | :-: | --: |` โ†’ left/center/right/null per column) via the existing `tableCellContentRanges` helper; fall back to `rows[0].children.map(() => null)` if no delimiter row is found, so `align.length` always matches the real column count. +2. **`adaptListItem`'s Task branch walked child nodes that don't exist.** In Lezer trees, plain text is implicit โ€” it lives in the *gaps* between nodes, not in nodes. A `Task` node's only structural child is its `TaskMarker` (`[ ]`/`[x]`); the item's text after it is a gap. The old code looped over Task's child nodes looking for block content, found only the (skipped) marker, and produced `children: []`. **Fix**: walk Task as *inline* content via the adapter's existing `emitInline` (which emits gap text as Text nodes and already skips `TaskMarker` via `MARKER_NODE_NAMES`), trim the marker-separating whitespace off the first text node, and wrap the result in the `paragraph` node mdast consumers expect. Nested blocks under a task item (child bullets etc.) were already handled by the outer loop and are unaffected. + +**Scope judgment โ€” why these two adapter fixes are in this change while the wiki-link adapter bug stays deferred:** the task-item text simply is not present in the tree โ€” no downstream layer can recover it, so there is no "work around it locally" option at all; and while the `align` symptom *could* have been papered over in `live-preview-to-html.ts`, both defects share one root cause (non-spec adapter output) and papering over one of them locally would leave `getAst()` still broken for every other consumer (the wordcount plugin, for instance, was silently not counting task-list item text). The wiki-link case is the opposite on every axis: non-standard syntax, cosmetic wrong-`href` output, a live workaround already in place (the preview's absolute-URL click guard), and a riskier fix (changing `Link` fallback semantics). Both fixes here are narrow, covered by new regression tests through the preview path, and the full suite (including live-preview's 100+ tests and wordcount's) passes unchanged. + +## Discovered during manual verification: window.open on a non-absolute href reopens the app + +Manual testing of the link-click-safety fix (Ctrl-clicking a rendered link) surfaced that Ctrl-clicking a wiki-link-produced garbage link (`href="[Some Note]"`, see the unresolved-shortcut-link bug above) opened a **new window showing the editor app itself**, not an external site. Cause: `window.open(href, ...)` was called with whatever string was in the `href` attribute, unvalidated. `"[Some Note]"` is not an absolute URL, so the browser resolves it as a path *relative to the current page* โ€” in dev, the Vite dev server's own origin โ€” and Vite's dev server serves `index.html` for unmatched paths (standard SPA fallback), so the "external" open silently reopened a whole extra copy of the app instead of failing visibly or doing nothing. + +**Fix**: `onContentClick` now only calls `window.open` when the href parses as an absolute `http:`/`https:` URL (`new URL(href)` with no base argument throws for anything that isn't already absolute โ€” this is the check, not a regex). Anything else โ€” including this specific wiki-link artifact, but also any other malformed or accidentally-relative href in arbitrary markdown content โ€” is now a no-op on Ctrl/Cmd-click too (the plain-click `preventDefault()` already made it a no-op for a plain click). This strengthens, rather than expands, the existing wiki-link "known limitation": Ctrl-clicking a broken wiki-link-produced anchor now safely does nothing instead of confusingly opening a new app window. + +## Risks / Trade-offs + +- rAF-throttled scroll listeners run on every animation frame while actively scrolling; mitigated by doing only a cheap `lineBlockAtHeight` lookup per frame. The tracker is installed for every editor instance, so hosts that never subscribe still pay that (small) per-scroll-frame lookup โ€” accepted for v1 rather than adding listener-count plumbing to the event emitter. +- The preview pane's `data-offset` nearest-neighbor lookup is a linear scan over top-level rendered blocks. Acceptable for typical documents (tens to low hundreds of blocks); not optimized for pathological documents with thousands of blocks. Flagged as a follow-up if it surfaces in practice โ€” not blocking for this change. + +## Migration Plan + +Purely additive. No existing behavior changes when `splitPreview` / the panel toggle is left at its default (off). No data migration. + +## Open Questions + +None blocking. Wiki-link rendering inside the static preview pane is deferred to a follow-up change once this lands (see `proposal.md` ยง Out of scope). diff --git a/openspec/changes/add-sync-scroll-preview/proposal.md b/openspec/changes/add-sync-scroll-preview/proposal.md new file mode 100644 index 00000000..6d6a838d --- /dev/null +++ b/openspec/changes/add-sync-scroll-preview/proposal.md @@ -0,0 +1,46 @@ +# Change: Add Sync-Scroll Preview Pane + +## Why + +Roadmap **#13 โ€” Markdown live preview sync scroll (`core`, P2)** is still open. Nexus's live preview is inline-only (Obsidian-style, decorations inside the same CM6 view) โ€” there is no rendered, read-only preview surface anywhere in the codebase, so "sync scroll" currently has nothing to scroll two of. + +This proposal scopes roadmap #13 narrowly: a read-only rendered preview pane plus offset-based (not percentage-based) two-way scroll sync, and nothing else. Percentage scroll (`scrollTop / scrollHeight`) is a non-starter โ€” Markdown blocks render at wildly different heights (a heading vs. a fenced code block vs. a table), so proportional math can't keep two panes visually aligned. The feature ships behind an opt-in demo toggle so no existing default behavior changes, and stays out of unrelated territory (no keybinding UI, no wiki-link rendering) to keep the change reviewable as one concern. + +## What Changes + +- **Core (`@floatboat/nexus-core`)**: + - New `scroll` event on `EditorEventMap`: `{ offset: number; ratio: number }` โ€” `offset` is the document character offset at the top of the visible viewport; `ratio` is the fractional scroll position within the rendered height of that offset's document line (a line-based unit โ€” see design.md ยง "ratio computed in two incompatible units"). Backed by a `ViewPlugin` on `view.scrollDOM`'s native `scroll` event, rAF-throttled, computed via `view.lineBlockAtHeight(view.scrollDOM.scrollTop)` โ€” a content-space CM6 API, no screen-coordinate math involved. + - New `EditorAPI.scrollToOffset(offset: number, ratio?: number): void` โ€” the write-side counterpart to the `scroll` event, so a host can drive the editor's scroll position from an external position (the preview pane, in this change). Implemented via `view.lineBlockAt(offset)` + setting `view.scrollDOM.scrollTop`, kept symmetric with the read side and avoiding any need to expose the CM6 `EditorView` itself to hosts. + - New exported function `renderPreviewHtml(ast: Root): string` โ€” converts an mdast tree (as returned by `editor.getAst()`) to an HTML string via the `remark-rehype` + `rehype-stringify` pipeline already used by `exportHTML()`, reusing the "transform an existing tree via `processor.runSync`" pattern already established by `createTransformProcessor` in `editor.ts` (no second parse pass). Every block-level element in the output is annotated with `data-offset=""`. + - No new runtime dependencies โ€” `unified`, `remark-rehype`, `rehype-stringify` are already direct dependencies of `packages/core`. +- **Electron demo (`apps/electron-demo`)**: + - New `preview-panel.ts` (same shape as `outline-panel.ts`): renders `renderPreviewHtml(editor.getAst())` into a read-only pane, re-rendering on `editor.on("change")`. + - Two-way scroll binding: editor `scroll` event โ†’ nearest `[data-offset]` element in the preview pane is aligned to the top of the pane; preview pane's own scroll โ†’ nearest `[data-offset]` element's offset is resolved and passed to `editor.scrollToOffset()`. Both directions guarded by an `isSyncingScroll` flag (cleared two animation frames after a programmatic scroll โ€” see design.md ยง "minor hardening and cleanup") to prevent feedback loops. + - New toolbar toggle button via `setPreviewVisible`/`togglePreview` in `app.ts` โ€” a small variant of the shared `togglePanel()` helper, since showing/hiding also persists `settings.splitPreview`, which `togglePanel`'s single `onShow` callback can't express. The shared helper itself now takes an explicit show-display value (see design.md ยง "shared `togglePanel()` bug fixed at the source"). Default **off**. + - New `EditorSettings.splitPreview: boolean` (default `false`), following the existing `livePreview` toggle convention in `settings.ts`. +- **Explicit v1 limitation**: wiki-link (`[[...]]`) syntax is not a real mdast node โ€” `wikilinks.ts` implements it via regex scan + CM6 decoration, not a remark node type. Verified against the actual Lezer-backed AST (not assumed): the outer bracket pair passes through as literal text, but the inner `[Target]` is parsed by `@lezer/markdown`'s CommonMark grammar as an unresolved shortcut-reference `Link` node, and `lezer-mdast-adapter.ts`'s `Link`/`Autolink`/`URL` case (a pre-existing, unrelated quirk โ€” falls back to using the whole node's raw text as `href` when no `URL` child is found, a fallback meant for bare autolinks) gives it a garbage self-referential `href`. This is a pre-existing adapter bug, out of scope for this change โ€” see `design.md` for the precise mechanism and a suggested standalone follow-up. Rendering wiki links properly in the static preview is left to a later change, to keep this PR to one concern. + +## Impact + +- Affected specs: + - `editor-core` (ADDED โ€” `scroll` event, `renderPreviewHtml` export) + - `sync-scroll-preview` (ADDED โ€” new capability: split preview pane, bidirectional offset-based scroll sync, demo wiring) +- Affected code: + - `packages/core/src/editor.ts` (scroll `ViewPlugin`, wire into extensions, emit `scroll` event) + - `packages/core/src/types.ts` (`EditorEventMap.scroll`) + - `packages/core/src/live-preview-to-html.ts` (NEW โ€” `renderPreviewHtml`) + - `packages/core/src/lezer-mdast-adapter.ts` (two narrow spec-compliance fixes the preview depends on: `table.align` populated from the delimiter row instead of hardcoded `[]`, and task-list item text recovered into a paragraph child โ€” see `design.md` ยง adapter emits non-spec mdast shapes) + - `packages/core/src/index.ts` (export `renderPreviewHtml`) + - `packages/core/test/scroll-event.test.ts` (NEW) + - `packages/core/test/live-preview-to-html.test.ts` (NEW) + - `apps/electron-demo/src/renderer/preview-panel.ts` (NEW) + - `apps/electron-demo/src/renderer/app.ts` (mount panel, toolbar button) + - `apps/electron-demo/src/renderer/settings.ts` (`splitPreview` toggle) + - `apps/electron-demo/test/preview-panel.test.ts` (NEW โ€” link-click safety regression guard) + - `packages/core/README.md`, `docs/ROADMAP.md`, `docs/ROADMAP.zh.md` +- New dependencies: none. +- Out of scope (explicit non-goals): + - Wiki-link rendering inside the static preview pane (see above). + - Custom keybinding override UI โ€” a different concern from scroll sync; explicitly not part of this change. + - Sync scroll for the *inline* live-preview mode without the split panel open โ€” this feature only applies when the split preview panel is open. + - Custom/print stylesheet for the preview pane โ€” it reuses the demo's existing theme CSS variables. diff --git a/openspec/changes/add-sync-scroll-preview/specs/editor-core/spec.md b/openspec/changes/add-sync-scroll-preview/specs/editor-core/spec.md new file mode 100644 index 00000000..b17f48f7 --- /dev/null +++ b/openspec/changes/add-sync-scroll-preview/specs/editor-core/spec.md @@ -0,0 +1,39 @@ +# Editor Core Spec โ€” Scroll Event, Preview HTML Rendering + +## ADDED Requirements + +### Requirement: Scroll Position Event + +`EditorEventMap` SHALL include a `scroll` event emitting `{ offset: number; ratio: number }`, where `offset` is the document character offset at the top of the visible editor viewport and `ratio` is the fractional scroll position within the rendered height of the document line containing that offset (a line-based unit, symmetric with `scrollToOffset`). The event SHALL be throttled to at most one emission per animation frame. + +#### Scenario: Scrolling the editor emits an offset +- **WHEN** the user scrolls the editor viewport +- **THEN** a `scroll` event SHALL fire with `offset` equal to the document position at the top of the visible content + +#### Scenario: Rapid scrolling is throttled +- **WHEN** multiple native scroll events fire within a single animation frame +- **THEN** at most one `scroll` event SHALL be emitted for that frame + +### Requirement: Scroll To Offset + +`EditorAPI` SHALL expose `scrollToOffset(offset: number, ratio?: number): void`, the write-side counterpart to the `scroll` event: it SHALL scroll the editor so that the given document character offset is positioned at the top of the viewport (adjusted by `ratio` within the rendered height of the offset's document line when provided โ€” the same line-based unit the `scroll` event reports), without requiring the caller to know CM6 screen-coordinate geometry. + +#### Scenario: Scrolling to an offset moves the viewport +- **WHEN** `scrollToOffset(500)` is called +- **THEN** the editor's viewport SHALL scroll so that document offset 500 is at (or adjacent to) the top of the visible content + +### Requirement: Preview HTML Rendering + +`@floatboat/nexus-core` SHALL export `renderPreviewHtml(ast: Root): string`, converting an mdast tree into an HTML string via the existing `remark-rehype` / `rehype-stringify` pipeline, without re-parsing markdown text. Every block-level element in the output SHALL carry a `data-offset` attribute equal to its source mdast node's `position.start.offset`. Inline formatting nested inside block nodes (emphasis, strong, links, inline code) SHALL be preserved in the output, not flattened to plain text. + +#### Scenario: Rendering carries offsets +- **WHEN** `renderPreviewHtml` is called with an AST containing a heading at offset 0 and a paragraph at offset 20 +- **THEN** the returned HTML SHALL contain elements with `data-offset="0"` and `data-offset="20"` respectively + +#### Scenario: No new parse pass +- **WHEN** `renderPreviewHtml(editor.getAst())` is called +- **THEN** the markdown source SHALL NOT be re-parsed by `remark-parse` โ€” only the already-produced tree is transformed + +#### Scenario: Inline formatting is preserved +- **WHEN** the AST contains a heading whose text includes a `strong` (bold) child node +- **THEN** the returned HTML SHALL render that heading with a nested `` element, not as flattened plain text diff --git a/openspec/changes/add-sync-scroll-preview/specs/sync-scroll-preview/spec.md b/openspec/changes/add-sync-scroll-preview/specs/sync-scroll-preview/spec.md new file mode 100644 index 00000000..48df5d5e --- /dev/null +++ b/openspec/changes/add-sync-scroll-preview/specs/sync-scroll-preview/spec.md @@ -0,0 +1,61 @@ +# Sync-Scroll Preview Spec โ€” Split Preview Panel (electron-demo) + +## ADDED Requirements + +### Requirement: Split Preview Panel + +The electron demo SHALL provide an optional, read-only rendered preview pane alongside the editor, toggled from the toolbar and defaulting to **off**. When open, the panel SHALL re-render whenever the editor's `change` event fires. + +#### Scenario: Panel closed by default +- **WHEN** the demo starts with default settings +- **THEN** the preview panel SHALL NOT be visible + +#### Scenario: Panel reflects edits +- **WHEN** the split preview panel is open +- **AND** the user edits the document +- **THEN** the panel's rendered content SHALL update to match the new document + +### Requirement: Bidirectional Offset-Based Scroll Sync + +When the split preview panel is open, scrolling either the editor or the preview pane SHALL scroll the other to the nearest corresponding position, matched by document character offset rather than a scroll percentage. Programmatic scroll updates triggered by this sync SHALL NOT themselves trigger a further sync in the opposite direction. + +#### Scenario: Editor scroll moves the preview +- **WHEN** the user scrolls the editor so a paragraph at offset 500 is at the top of the viewport +- **THEN** the preview pane SHALL scroll so the element with the nearest `data-offset` to 500 is aligned to the top of the pane + +#### Scenario: Preview scroll moves the editor +- **WHEN** the user scrolls the preview pane so the element with `data-offset="500"` is at the top +- **THEN** the editor SHALL scroll so document offset 500 is at the top of its viewport + +#### Scenario: No feedback loop +- **WHEN** a programmatic scroll is applied to the editor as a result of the preview pane scrolling +- **THEN** that scroll SHALL NOT trigger another sync of the preview pane in response + +### Requirement: Preview Links Never Navigate The App Window + +`renderPreviewHtml` emits plain `` elements, and `apps/electron-demo`'s main process has no `will-navigate` / `setWindowOpenHandler` guard. Clicking a link inside the preview pane SHALL NOT navigate the Electron `BrowserWindow` away from the app. A plain click SHALL be swallowed (`preventDefault`). Ctrl/Cmd-click SHALL open the link in the user's default browser via `window.open(url, "_blank", "noopener,noreferrer")`, mirroring the existing convention in `live-preview-renderers.ts`'s inline link renderer. + +#### Scenario: Plain click does not navigate the app +- **WHEN** the split preview panel is open and contains a rendered link +- **AND** the user clicks that link without a modifier key +- **THEN** the app window SHALL NOT navigate away from the editor + +#### Scenario: Ctrl/Cmd-click opens externally +- **WHEN** the user Ctrl-clicks (Cmd-click on macOS) a rendered link in the preview pane +- **AND** the link's `href` is an absolute `http(s)` URL +- **THEN** the link SHALL open via `window.open` with `noopener,noreferrer`, not by navigating the app window + +#### Scenario: Ctrl/Cmd-click on a non-absolute href is a no-op +- **WHEN** the user Ctrl-clicks (Cmd-click on macOS) a rendered link whose `href` is not a valid absolute `http(s)` URL (e.g. the garbage href produced by the wiki-link adapter quirk below) +- **THEN** `window.open` SHALL NOT be called +- **AND** the app window SHALL NOT navigate anywhere, including to a same-origin path that would reload the app itself + +### Requirement: Wiki-Links Are Not Specially Rendered in Preview (v1 limitation) + +`[[...]]` wiki-link syntax is not a standard mdast node type โ€” it is recognized by CM6 decorations (`wikilinks.ts`) at the editing-surface level only. In the split preview pane, the outer bracket pair SHALL pass through as literal text; the enclosed `[Target]` SHALL render however the underlying mdast `link` adapter resolves an unresolved shortcut reference (currently: a link element whose `href` is the bracketed label itself, a pre-existing behavior of `lezer-mdast-adapter.ts` unrelated to this change โ€” see `design.md`). Rendering wiki-links as a proper navigable link in the preview is deferred to a follow-up change. + +#### Scenario: Wiki link is not specially handled +- **WHEN** the source document contains `[[Some Note]]` +- **AND** the split preview panel is open +- **THEN** the preview SHALL NOT render a working, resolved wiki-link +- **AND** the outer `[` and `]` characters SHALL appear as literal text around whatever the inner `[Some Note]` resolves to diff --git a/openspec/changes/add-sync-scroll-preview/tasks.md b/openspec/changes/add-sync-scroll-preview/tasks.md new file mode 100644 index 00000000..67de6d4f --- /dev/null +++ b/openspec/changes/add-sync-scroll-preview/tasks.md @@ -0,0 +1,52 @@ +# Implementation Tasks + +## 1. Core โ€” scroll event + +- [x] 1.1 Add `scroll: (payload: { offset: number; ratio: number }) => void` to `EditorEventMap` in `packages/core/src/types.ts`, with JSDoc explaining `offset` (doc position at viewport top) and `ratio` (fractional position within that offset's block). +- [x] 1.2 Implement a `ViewPlugin` (in `editor.ts` or a small new module) listening to `view.scrollDOM`'s native `scroll` event, rAF-throttled, computing `{ offset, ratio }` via `view.lineBlockAtHeight(view.scrollDOM.scrollTop)` (content-space; no screen-coordinate math). Emit through the existing `emitter`. +- [x] 1.3 Wire the plugin into the `EditorView` extensions list in `editor.ts`. +- [x] 1.4 Add `scrollToOffset(offset: number, ratio?: number): void` to `EditorAPI` (types + implementation), the write-side counterpart to the `scroll` event: `view.lineBlockAt(offset)` + set `view.scrollDOM.scrollTop`, applying `ratio` within that block when provided. + +## 2. Core โ€” preview HTML rendering + +- [x] 2.1 New `packages/core/src/live-preview-to-html.ts` exporting `renderPreviewHtml(ast: Root): string`: build a `unified().use(remarkRehype).use(rehypeStringify)` processor (mirroring `createTransformProcessor`'s `runSync(tree)` pattern โ€” no `remark-parse` step), inject `data-offset` from each corresponding mdast node's `position.start.offset` onto block-level hast elements before stringifying. +- [x] 2.2 Export `renderPreviewHtml` from `packages/core/src/index.ts`. + +## 3. Demo โ€” preview panel + scroll wiring + +- [x] 3.1 New `apps/electron-demo/src/renderer/preview-panel.ts`, modeled on `outline-panel.ts`: `createPreviewPanel(editor) => { element, update(), destroy() }`. `update()` renders `renderPreviewHtml(editor.getAst())` into the panel; subscribes via `editor.on("change", update)`, unsubscribes in `destroy()`. +- [x] 3.2 Wire bidirectional scroll sync inside the panel module: + - `editor.on("scroll", ({ offset }) => ...)` โ†’ find the panel's `[data-offset]` element nearest `offset`, align it to the top of the pane. + - Panel's own native `scroll` listener (rAF-throttled) โ†’ find the topmost visible `[data-offset]` element, call `editor.scrollToOffset(offset)`. + - Guard both directions with an `isSyncingScroll` flag set before a programmatic scroll and cleared on the next `requestAnimationFrame`. (As planned; later widened to two frames during self-review โ€” see 6.2.) +- [x] 3.3 `app.ts`: mount `preview.element` into `mainArea` (between `editorColumn` and `outline`), add a toolbar toggle button (`togglePreview`/`setPreviewVisible` โ€” a small variant of the shared `togglePanel()` helper, since showing/hiding also needs to read/persist `settings.splitPreview`, which `togglePanel`'s single `onShow` callback can't do), default closed. +- [x] 3.4 `settings.ts`: add `splitPreview: boolean` (default `false`) to `EditorSettings` and `defaultSettings()`, following the `livePreview` toggle pattern; add a row in the settings panel UI. +- [x] 3.5 **Found during review, not in the original plan**: `renderPreviewHtml` emits plain `` elements, and `electron/main.ts` has no `will-navigate` / `setWindowOpenHandler` guard โ€” an unhandled click would navigate the whole `BrowserWindow` away from the app. Intercept clicks on `content`: always `preventDefault()`; Ctrl/Cmd-click opens via `window.open(url, "_blank", "noopener,noreferrer")`, mirroring the existing convention in `live-preview-renderers.ts`'s inline link renderer. New `apps/electron-demo/test/preview-panel.test.ts` (3 cases here; a 4th added later in 6.9) pins plain-click-is-swallowed, modifier-click-opens-externally, and non-link-clicks-are-ignored. + +## 4. Tests + +- [x] 4.1 New `packages/core/test/scroll-event.test.ts`: scrolling emits `{ offset, ratio }`; rapid scroll events are throttled to at most one emission per animation frame; no handler registered โ†’ no observable side effect. +- [x] 4.2 New `packages/core/test/live-preview-to-html.test.ts`: `data-offset` attributes present and match source node offsets; nested inline formatting (bold/link inside a heading) is preserved (regression guard against the `renderLivePreviewNode` flattening pitfall); `[[Wiki Link]]` is not specially rendered โ€” pins the actual verified behavior (outer brackets literal, inner `[Target]` picked up as an unresolved-shortcut-reference `Link` by the pre-existing adapter quirk โ€” see `design.md`), not the originally-assumed plain-text passthrough. +- [x] 4.3 `pnpm test`, `pnpm build`, `pnpm typecheck` all green. +- [x] 4.4 Manual verification in the running electron-demo, done by the human reviewer (agent has no Electron UI automation available). Confirmed working after three follow-up fixes surfaced by hands-on testing, none caught by unit tests since they're real-browser layout behavior jsdom doesn't reproduce: + - `preview-panel.ts`: `.nexus-preview-content` needs `position: relative` โ€” the scroll-alignment math reads each rendered element's `offsetTop`, which is relative to the nearest *positioned* ancestor, not the immediate parent; without this it resolved against `` and every computed scroll target was wrong. + - `preview-panel.ts`: `.nexus-preview-content` needs `min-height: 0` โ€” the classic flex-column child sizing gotcha (default `min-height: auto` lets a `flex: 1` child grow to its content size instead of clipping to available space), same pattern already handled correctly elsewhere in this app (`.editor-container .cm-editor`) but missed here. + - `app.ts` `setPreviewVisible`: showing the panel must set `style.display = "flex"`, not `""` โ€” clearing the inline `display` removes that declaration outright (no external stylesheet rule provides a fallback) rather than restoring the original `flex` value, collapsing the panel to the UA default `display: block` and breaking its internal header/content layout. (Also flagged here: the shared `togglePanel()` helper used by the outline/vault/backlinks panels had the same `style.display = ""` pattern and the same latent bug โ€” initially deferred as a follow-up, then fixed at the source during self-review; see 6.3.) + +## 5. Docs + +- [x] 5.1 `packages/core/README.md`: document the `scroll` event and `renderPreviewHtml` under the API reference. +- [x] 5.2 `docs/ROADMAP.md` + `docs/ROADMAP.zh.md`: row #13 โ†’ `in-progress`, link `add-sync-scroll-preview`. + +## 6. Self-review (structured correctness + cleanup pass, before submitting) + +- [x] 6.1 **Correctness**: `scroll`'s `ratio` (fraction within one CM6 source line) and the preview's block-scan `ratio` (fraction within a whole, possibly multi-line, rendered DOM block) were two different units being used interchangeably across the sync's two directions, producing real misalignment for multi-line blocks. Dropped ratio-based sub-block interpolation from both directions โ€” block-top alignment only (`preview-panel.ts`'s `onEditorScroll`/`onPanelScroll`, `editor.ts`'s `scrollToOffset` called with no ratio arg). See `design.md`. +- [x] 6.2 **Correctness (hardening)**: `isSyncingScroll` now clears two animation frames after a programmatic scroll, not one โ€” closes a plausible timing race where CM6's own internally-throttled scroll echo could arrive after a single-frame guard already cleared. +- [x] 6.3 **Reuse/altitude**: fixed the shared `togglePanel()` helper (`apps/electron-demo/src/renderer/app.ts`) at the source โ€” takes an explicit show-display value instead of clearing to `""` โ€” rather than working around it only in the new preview panel. Also fixes the outline/vault/backlinks panels, which had the same latent bug. +- [x] 6.4 **Simplification**: collapsed `findNearestByOffset` / `findTopmostVisible` (same forward-scan-with-fallback logic, different key) into one `findLastAtOrBefore`. +- [x] 6.5 **Efficiency**: the settings-panel `onChange` was unconditionally re-rendering the preview pane on every field mutation (e.g. dragging the font-size slider), not just when `splitPreview` changed. Now tracks the last-applied value. +- [x] 6.6 **API polish**: exported `ScrollPayload` from `packages/core/src/types.ts` / `index.ts`, matching the `SelectionState` precedent for `selectionChange`. +- [x] 6.7 `pnpm test` (531 tests), `pnpm typecheck`, `pnpm build`, `pnpm build:electron-demo` all green after the fixes above. +- [ ] 6.8 Considered, deliberately not done: centralizing the rAF-throttle boilerplate shared between `editor.ts` and `preview-panel.ts` (new cross-package API surface not worth it for ~5 lines); a main-process `will-navigate` / `setWindowOpenHandler` guard in `electron/main.ts` as defense-in-depth beyond `preview-panel.ts`'s own click interception (more robust, but a separate hardening effort โ€” this change's own preview pane is already safe without it). +- [x] 6.9 **Found during manual verification (round 2)**: Ctrl/Cmd-clicking the wiki-link adapter's garbage-href link (`href="[Some Note]"`) called `window.open` with a non-absolute string, which the browser resolved relative to the current page's own origin (the Vite dev server in dev) โ€” silently reopening a whole extra copy of the app in a new window instead of doing nothing. `onContentClick` now only calls `window.open` when `new URL(href)` (no base) succeeds and the protocol is `http:`/`https:`; anything else is a no-op, same as a plain click. New test case in `apps/electron-demo/test/preview-panel.test.ts` pins this against the actual wiki-link output. `pnpm test` (532 tests), `pnpm typecheck` green after the fix. +- [x] 6.10 **Found during manual verification (round 3)**: GFM tables rendered as empty `` shells and task-list items as bare checkboxes with no text. Both traced (via syntax-tree/mdast probes, not guesswork) to `lezer-mdast-adapter.ts` emitting non-spec mdast: `adaptTable` hardcoded `align: []` (and `mdast-util-to-hast` emits exactly `align.length` cells per row โ€” zero columns = cell-less rows), and `adaptListItem`'s Task branch looked for child nodes when Lezer keeps the item's text implicit in the gap after `TaskMarker`. Fixed both in the adapter (align parsed from the delimiter row with a null-per-column fallback; task text recovered via the existing `emitInline` + wrapped in a paragraph). Scope judgment vs. the deferred wiki-link adapter bug documented in `design.md`. Three new regression tests in `live-preview-to-html.test.ts` (table cells + alignment, task text + checkbox + inline formatting, nested blocks under a task). `pnpm test` (535 tests), `pnpm typecheck` green. diff --git a/packages/core/README.md b/packages/core/README.md index 267f274e..d24ef47a 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -71,6 +71,40 @@ editor.on("selectionChange", ({ anchor, head, ranges, mainIndex }) => { Multiple ranges in `setSelections` require `multiCursor: true` โ€” without the flag CodeMirror collapses the selection to its main range. +## Sync-scroll preview primitives + +Three low-level pieces for building a synced, read-only rendered preview pane alongside the editor (the electron demo's split-preview toggle is the reference implementation โ€” see `apps/electron-demo/src/renderer/preview-panel.ts`): + +```ts +import { renderPreviewHtml } from "@floatboat/nexus-core"; + +// Render the current AST to an HTML string. Every top-level block element +// carries a `data-offset` attribute (the block's source character offset) so +// a host can map scroll position back to a document location. No markdown +// re-parse โ€” it transforms the AST you already have. +const html = renderPreviewHtml(editor.getAst()); + +// Fires at most once per animation frame while the editor's viewport +// scrolls. `offset` is the document offset at the top of the viewport; +// `ratio` (0โ€“1) is how far scrolled within the rendered height of the +// document line at that offset (a line-based unit โ€” see the note below). +editor.on("scroll", ({ offset, ratio }) => { + // e.g. find the nearest `[data-offset]` element in your preview pane + // and align it to the top of the pane. +}); + +// The write-side counterpart: scroll the editor so `offset` sits at the top +// of the viewport (optionally refined by `ratio`, in the same line-based +// unit the `scroll` event reports). +editor.scrollToOffset(offset, ratio); +``` + +Both directions stay in CM6's content-space height coordinates internally โ€” no screen-coordinate math is required on the host side, and the CM6 `EditorView` itself is never exposed. + +**Note on `ratio`**: it is a fraction of one document line's rendered height, **not** of a whole markdown block โ€” only reuse it against something measured in the same unit (e.g. echoing one editor's `scroll` payload into another editor's `scrollToOffset`). When syncing against block-sized elements such as the `data-offset` blocks from `renderPreviewHtml`, align block tops and ignore `ratio` โ€” that's what the demo's preview panel does. + +**Known v1 limitation**: wiki-link (`[[...]]`) syntax is not a real mdast node (see `wikilinks.ts`), so `renderPreviewHtml` does not render it as a navigable link. + ## Other config highlights See the `EditorConfig` type for the full surface: `livePreview`, `plugins`, `theme` / `setTheme`, `locale`, `readOnly`, `tabSize`, `direction`, `indentGuides`, `parseDelayMs`, `slashMenuLimit`, `onChange` / `onFocus` / `onBlur` / `onAssetUpload`. diff --git a/packages/core/src/editor.ts b/packages/core/src/editor.ts index 07bf1f0c..c6f9dcf1 100644 --- a/packages/core/src/editor.ts +++ b/packages/core/src/editor.ts @@ -1,10 +1,10 @@ -import { Annotation, EditorSelection, EditorState } from "@codemirror/state"; +import { Annotation, EditorSelection, EditorState, type Extension } from "@codemirror/state"; // Annotation attached to dispatches that load content programmatically (e.g. // setDocument from file open) so updateListener can skip the user-edit path โ€” // no onChange emission, no AST reparse for the onChange pipeline. const silentDocChange = Annotation.define(); -import { EditorView, keymap, dropCursor, lineNumbers, type Direction } from "@codemirror/view"; +import { EditorView, ViewPlugin, keymap, dropCursor, lineNumbers, type Direction } from "@codemirror/view"; import { indentWithTab, undo as cmUndo, redo as cmRedo } from "@codemirror/commands"; import { closeBrackets } from "@codemirror/autocomplete"; import type { Root } from "mdast"; @@ -228,6 +228,46 @@ function createTransformProcessor(plugins: NexusPlugin[]): { runSync(tree: Root) return { runSync: (tree) => processor.runSync(tree) as Root }; } +/** + * Emits `scroll` (offset + within-block ratio) at most once per animation + * frame while the viewport scrolls. Uses CM6's content-space height APIs + * (`lineBlockAtHeight`) rather than `posAtCoords`/screen coordinates, so it + * needs no viewport rect math and works the same regardless of where the + * editor sits on the page. + */ +function createScrollTracker(emitter: EventEmitter): Extension { + return ViewPlugin.fromClass( + class { + private rafId: number | null = null; + + constructor(readonly view: EditorView) { + view.scrollDOM.addEventListener("scroll", this.onScroll, { passive: true }); + } + + onScroll = (): void => { + if (this.rafId !== null) return; + this.rafId = requestAnimationFrame(() => { + this.rafId = null; + this.emit(); + }); + }; + + emit(): void { + const { view } = this; + const scrollTop = Math.max(0, Math.min(view.scrollDOM.scrollTop, view.contentHeight)); + const block = view.lineBlockAtHeight(scrollTop); + const ratio = block.height > 0 ? Math.max(0, Math.min(1, (scrollTop - block.top) / block.height)) : 0; + emitter.emit("scroll", { offset: block.from, ratio }); + } + + destroy(): void { + if (this.rafId !== null) cancelAnimationFrame(this.rafId); + this.view.scrollDOM.removeEventListener("scroll", this.onScroll); + } + } + ); +} + export function createEditor(config: EditorConfig): EditorAPI { const plugins = config.plugins ?? []; debugNexus("create", { @@ -693,6 +733,7 @@ export function createEditor(config: EditorConfig): EditorAPI { return false; }, }), + createScrollTracker(emitter), ...createLivePreviewExtension(config.livePreview, { addColumn: locale.addColumn, addRow: locale.addRow, @@ -906,6 +947,13 @@ export function createEditor(config: EditorConfig): EditorAPI { const lines = view.state.doc.lines; return { characters, words, lines }; }, + scrollToOffset(offset, ratio) { + if (destroyed) return; + const clamped = clampPosition(offset, view.state.doc.length); + const block = view.lineBlockAt(clamped); + const clampedRatio = ratio === undefined ? 0 : Math.max(0, Math.min(1, ratio)); + view.scrollDOM.scrollTop = block.top + clampedRatio * block.height; + }, destroy() { debugNexus("destroy", { documentLength: view.state.doc.length, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 768852f0..2b5a0f80 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ export { createEditor } from "./editor"; +export { renderPreviewHtml } from "./live-preview-to-html"; export { markdownAutoPair } from "./markdown-autopair"; export { markdownFold, markdownFoldService } from "./markdown-fold"; export { markdownKeymap, handleMarkdownEnter } from "./markdown-keymap"; @@ -47,6 +48,7 @@ export type { NexusPlugin, ParseResult, ParserLike, + ScrollPayload, SelectionRangeJSON, SelectionState, SlashCommandDef, diff --git a/packages/core/src/lezer-mdast-adapter.ts b/packages/core/src/lezer-mdast-adapter.ts index fbd6edad..15c4b372 100644 --- a/packages/core/src/lezer-mdast-adapter.ts +++ b/packages/core/src/lezer-mdast-adapter.ts @@ -422,11 +422,30 @@ function adaptListItem(source: Source, node: SyntaxNode): ListItem { const marker = findChildByName(c, "TaskMarker"); const m = marker ?? c; checked = detectTaskChecked(readSlice(source, m.from, m.to)); - // Task wraps Paragraph content too โ€” descend so inner blocks are kept. - for (let inner = c.firstChild; inner; inner = inner.nextSibling) { - if (inner.name === "TaskMarker") continue; - const block = adaptBlockChild(source, inner); - if (block) children.push(block); + // The item's text is NOT a child node of Task โ€” Lezer keeps plain text + // implicit in the gaps between nodes, and Task's only structural child + // is the TaskMarker itself (plus any inline formatting nodes). Walk + // Task as inline content: emitInline emits the gap text as Text nodes + // and skips TaskMarker via MARKER_NODE_NAMES. Wrap the result in the + // paragraph mdast consumers (and mdast-util-to-hast's task-list + // handler) expect a listItem's text to live in. + const inline = emitInline(source, c); + const first = inline[0]; + if (first?.type === "text") { + // The gap starts right after `[x]` โ€” drop the separating whitespace. + const trimmed = first.value.replace(/^[ \t]+/, ""); + if (first.position && first.position.start.offset !== undefined) { + first.position.start.offset += first.value.length - trimmed.length; + } + first.value = trimmed; + if (!trimmed) inline.shift(); + } + if (inline.length > 0) { + children.push({ + type: "paragraph", + children: inline, + position: position(m.to, c.to), + }); } continue; } @@ -472,10 +491,34 @@ function adaptList(source: Source, node: SyntaxNode, ordered: boolean): List { }; } +/** + * Parse the delimiter row (`| :-- | :-: | --: |`) into mdast's per-column + * alignment. The array's length doubles as the table's column count for + * `mdast-util-to-hast`, whose tableRow handler emits exactly `align.length` + * cells per row โ€” an empty array makes every row render cell-less. + */ +function parseTableAlign(source: Source, delimiter: SyntaxNode): Table["align"] { + return tableCellContentRanges(source, delimiter.from, delimiter.to).map(({ from, to }) => { + const spec = readSlice(source, from, to); + const left = spec.startsWith(":"); + const right = spec.endsWith(":"); + if (left && right) return "center"; + if (right) return "right"; + if (left) return "left"; + return null; + }); +} + function adaptTable(source: Source, node: SyntaxNode): Table { const rows: TableRow[] = []; + let align: Table["align"] = []; for (let c = node.firstChild; c; c = c.nextSibling) { - if (c.name === "TableHeader" || c.name === "TableRow") { + if (c.name === "TableDelimiter") { + // The full-line `| --- | --- |` row is a direct Table child; the + // single-pipe delimiters inside rows are children of TableHeader / + // TableRow and never reach this branch. + align = parseTableAlign(source, c); + } else if (c.name === "TableHeader" || c.name === "TableRow") { const cells = adaptTableRowCells(source, c); rows.push({ type: "tableRow", @@ -484,9 +527,12 @@ function adaptTable(source: Source, node: SyntaxNode): Table { }); } } + if ((align?.length ?? 0) === 0 && rows.length > 0) { + align = rows[0].children.map(() => null); + } return { type: "table", - align: [], + align, children: rows, position: position(node.from, node.to), }; diff --git a/packages/core/src/live-preview-to-html.ts b/packages/core/src/live-preview-to-html.ts new file mode 100644 index 00000000..424ac918 --- /dev/null +++ b/packages/core/src/live-preview-to-html.ts @@ -0,0 +1,54 @@ +import type { Data, Root, RootContent } from "mdast"; +import rehypeStringify from "rehype-stringify"; +import remarkRehype from "remark-rehype"; +import { unified } from "unified"; + +// Built once, not per call โ€” see the identical rationale on `createParser` in +// editor.ts: resolving a unified plugin chain is measurably expensive even +// for empty input, and this pipeline is transform+stringify only (no +// `remark-parse`), so there is nothing per-document to configure. +const processor = unified().use(remarkRehype).use(rehypeStringify); +processor.freeze(); + +/** + * Tags every top-level block child of `ast` with a `data-offset` hint + * (`node.data.hProperties`, the mechanism `mdast-util-to-hast` reads to + * influence the emitted hast element) carrying the node's source character + * offset. Shallow โ€” only `ast.children` are tagged, not nested descendants, + * since a scroll-sync anchor only needs block-boundary granularity. + */ +function withTopLevelOffsets(ast: Root): Root { + return { + ...ast, + children: ast.children.map((child): RootContent => { + const offset = child.position?.start.offset; + if (offset === undefined) return child; + + const data: Data = { + ...child.data, + hProperties: { + ...(child.data?.hProperties as Record | undefined), + "data-offset": String(offset), + }, + }; + return { ...child, data }; + }), + }; +} + +/** + * Renders an mdast tree (typically `editor.getAst()`) to an HTML string, + * annotating each top-level block element with `data-offset` for scroll-sync + * consumers. Runs only the mdastโ†’hast transform + stringify โ€” the tree is + * never re-parsed from markdown text, so this stays in sync with whatever + * produced `ast` (the Lezer-backed live AST, by default). + * + * Known v1 limitation: wiki-link (`[[...]]`) syntax is not a real mdast node + * (see wikilinks.ts โ€” implemented via CM6 decoration, not a remark plugin), + * so it renders as literal text here. + */ +export function renderPreviewHtml(ast: Root): string { + const tagged = withTopLevelOffsets(ast); + const hast = processor.runSync(tagged); + return processor.stringify(hast) as string; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d8f767ae..fc666975 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -141,6 +141,12 @@ export interface SelectionState { mainIndex: number; } +/** Payload for the `scroll` event โ€” see {@link EditorEventMap.scroll}. */ +export interface ScrollPayload { + offset: number; + ratio: number; +} + export interface EditorEventMap { change: (doc: string, ast: Root) => void; focus: () => void; @@ -148,6 +154,7 @@ export interface EditorEventMap { /** `anchor` / `head` describe the main range; `ranges` carries all of them. */ selectionChange: (selection: { anchor: number; head: number } & SelectionState) => void; slashMenuChange: (state: SlashMenuState) => void; + scroll: (payload: ScrollPayload) => void; } export interface TocEntry { @@ -275,6 +282,7 @@ export interface EditorAPI { */ getPosAtDOM(node: HTMLElement): number | null; getDocumentStats(): { characters: number; words: number; lines: number }; + scrollToOffset(offset: number, ratio?: number): void; } export interface SlashCommandDef { diff --git a/packages/core/test/live-preview-to-html.test.ts b/packages/core/test/live-preview-to-html.test.ts new file mode 100644 index 00000000..42ee7a16 --- /dev/null +++ b/packages/core/test/live-preview-to-html.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { lezerStringToMdast } from "../src/lezer-mdast-adapter"; +import { renderPreviewHtml } from "../src/live-preview-to-html"; + +describe("renderPreviewHtml", () => { + it("tags top-level block elements with their source offset", () => { + const markdown = "# Heading\n\nA paragraph."; + const ast = lezerStringToMdast(markdown); + + const html = renderPreviewHtml(ast); + + const headingOffset = ast.children[0].position?.start.offset; + const paragraphOffset = ast.children[1].position?.start.offset; + expect(html).toContain(`data-offset="${headingOffset}"`); + expect(html).toContain(`data-offset="${paragraphOffset}"`); + }); + + it("preserves inline formatting nested inside a block", () => { + const markdown = "# A **bold** [link](https://example.com) heading"; + const ast = lezerStringToMdast(markdown); + + const html = renderPreviewHtml(ast); + + expect(html).toContain("bold"); + expect(html).toContain('link'); + }); + + it("renders a code block's content as plain text", () => { + const markdown = "```js\nconst x = 1;\n```"; + const ast = lezerStringToMdast(markdown); + + const html = renderPreviewHtml(ast); + + expect(html).toContain("const x = 1;"); + }); + + it("does not specially render wiki-link syntax (v1 limitation)", () => { + // [[...]] is not a real mdast node (wikilinks.ts is CM6-decoration-only). + // The outer bracket pair passes through as literal text; the inner + // [Some Note] is parsed as an unresolved CommonMark shortcut-reference + // link by the underlying Lezerโ†’mdast adapter (a pre-existing, unrelated + // quirk โ€” see design.md) rather than becoming a wiki-link. This test + // pins the actual behavior so a future adapter fix has a regression + // guard here too, not just a claim in the spec. + const markdown = "See [[Some Note]] for details."; + const ast = lezerStringToMdast(markdown); + + const html = renderPreviewHtml(ast); + + expect(html).toContain("[Some Note]"); + }); + + it("renders GFM table cells with their text and column alignment", () => { + // Regression: the adapter used to emit `align: []` on table nodes. + // mdast-util-to-hast's tableRow handler emits exactly `align.length` + // cells per row, so an empty align array rendered every row cell-less โ€” + // tables showed as empty shells in the preview. + const markdown = "| Name | Age |\n| :--- | ---: |\n| Alice | 30 |"; + const html = renderPreviewHtml(lezerStringToMdast(markdown)); + + expect(html).toContain('Name'); + expect(html).toContain('Age'); + expect(html).toContain('Alice'); + expect(html).toContain('30'); + }); + + it("renders task-list item text, checkbox state, and inline formatting", () => { + // Regression: Lezer keeps plain text implicit in the gaps between nodes, + // and a Task node's only structural child is its TaskMarker โ€” the + // adapter's old block-child walk over Task found nothing, so task items + // rendered as a bare checkbox with no text. + const markdown = "- [ ] buy **fresh** milk\n- [x] write code"; + const html = renderPreviewHtml(lezerStringToMdast(markdown)); + + expect(html).toContain(' buy fresh milk'); + expect(html).toContain(' write code'); + }); + + it("keeps nested blocks under a task item", () => { + const markdown = "- [ ] parent task\n - child bullet"; + const html = renderPreviewHtml(lezerStringToMdast(markdown)); + + expect(html).toContain("parent task"); + expect(html).toContain("
  • child bullet
  • "); + }); + + it("does not mutate the input AST", () => { + const markdown = "# Heading"; + const ast = lezerStringToMdast(markdown); + const before = JSON.stringify(ast); + + renderPreviewHtml(ast); + + expect(JSON.stringify(ast)).toBe(before); + }); + + it("returns an empty-safe result for a document with no content", () => { + const ast = lezerStringToMdast(""); + + expect(() => renderPreviewHtml(ast)).not.toThrow(); + }); +}); diff --git a/packages/core/test/scroll-event.test.ts b/packages/core/test/scroll-event.test.ts new file mode 100644 index 00000000..bdb2d099 --- /dev/null +++ b/packages/core/test/scroll-event.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createEditor } from "../src/index"; + +function getScroller(container: HTMLElement): HTMLElement { + const el = container.querySelector(".cm-scroller"); + if (!el) throw new Error("CM6 scroller element not found"); + return el as HTMLElement; +} + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +describe("scroll event", () => { + it("emits { offset, ratio } in response to a native scroll", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ container, initialValue: "line\n".repeat(200) }); + const handler = vi.fn(); + editor.on("scroll", handler); + + getScroller(container).dispatchEvent(new Event("scroll")); + await nextFrame(); + + expect(handler).toHaveBeenCalled(); + const payload = handler.mock.calls[0][0]; + expect(typeof payload.offset).toBe("number"); + expect(typeof payload.ratio).toBe("number"); + expect(payload.ratio).toBeGreaterThanOrEqual(0); + expect(payload.ratio).toBeLessThanOrEqual(1); + + editor.destroy(); + container.remove(); + }); + + it("throttles rapid native scroll events to one emission per animation frame", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ container, initialValue: "line\n".repeat(200) }); + const handler = vi.fn(); + editor.on("scroll", handler); + + const scroller = getScroller(container); + scroller.dispatchEvent(new Event("scroll")); + scroller.dispatchEvent(new Event("scroll")); + scroller.dispatchEvent(new Event("scroll")); + await nextFrame(); + + expect(handler).toHaveBeenCalledTimes(1); + + editor.destroy(); + container.remove(); + }); + + it("does not throw when no handler is registered", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ container, initialValue: "hello" }); + + expect(() => getScroller(container).dispatchEvent(new Event("scroll"))).not.toThrow(); + await nextFrame(); + + editor.destroy(); + container.remove(); + }); + + it("stops listening after destroy", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ container, initialValue: "hello" }); + const handler = vi.fn(); + editor.on("scroll", handler); + + const scroller = getScroller(container); + editor.destroy(); + scroller.dispatchEvent(new Event("scroll")); + await nextFrame(); + + expect(handler).not.toHaveBeenCalled(); + container.remove(); + }); +}); + +describe("scrollToOffset", () => { + it("does not throw for an in-range offset", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "hello world" }); + + expect(() => editor.scrollToOffset(5)).not.toThrow(); + + editor.destroy(); + }); + + it("clamps out-of-range offsets instead of throwing", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "hello" }); + + expect(() => editor.scrollToOffset(9999)).not.toThrow(); + expect(() => editor.scrollToOffset(-10)).not.toThrow(); + + editor.destroy(); + }); + + it("is a no-op after destroy", () => { + const container = document.createElement("div"); + const editor = createEditor({ container, initialValue: "hello" }); + editor.destroy(); + + expect(() => editor.scrollToOffset(0)).not.toThrow(); + }); +});