diff --git a/packages/app/src/components/CodeMirrorPaperSurface.test.tsx b/packages/app/src/components/CodeMirrorPaperSurface.test.tsx index 72d4e639..6bf5c0d6 100644 --- a/packages/app/src/components/CodeMirrorPaperSurface.test.tsx +++ b/packages/app/src/components/CodeMirrorPaperSurface.test.tsx @@ -57,6 +57,39 @@ describe("CodeMirrorPaperSurface", () => { expect(onEditorReady).toHaveBeenLastCalledWith(null, view); }); + it("keeps configurable paragraph rhythm in measured block spacers", async () => { + const onEditorReady = vi.fn(); + const { container, rerender } = render( + {}} + paragraphSpacingPx={14} + />, + ); + + expect( + container.querySelector(".cm-markra-paragraph-spacer") + ?.style.height, + ).toBe("14px"); + + rerender( + {}} + paragraphSpacingPx={6} + />, + ); + + await waitFor(() => { + expect( + container.querySelector(".cm-markra-paragraph-spacer") + ?.style.height, + ).toBe("6px"); + }); + }); + it("uses the configured shortcut to paste clipboard text without formatting", async () => { const code = [ "const mockValue = items.at(0);", diff --git a/packages/app/src/components/CodeMirrorPaperSurface.tsx b/packages/app/src/components/CodeMirrorPaperSurface.tsx index f4ac8c22..9a08a01d 100644 --- a/packages/app/src/components/CodeMirrorPaperSurface.tsx +++ b/packages/app/src/components/CodeMirrorPaperSurface.tsx @@ -108,6 +108,7 @@ export interface CodeMirrorPaperSurfaceProps { onTextSelectionChange?: (selection: AiSelectionContext | null) => unknown; openExternalUrl?: (url: string) => unknown; openLocalAttachment?: (src: string) => unknown; + paragraphSpacingPx?: number; plugins?: readonly MarkraPlugin[]; readClipboardText?: ClipboardTextReader; readOnly?: boolean; @@ -145,6 +146,7 @@ interface MarkdownExtensionOptions { openExternalUrl: () => ((url: string) => unknown) | undefined; openLocalAttachment: () => ((src: string) => unknown) | undefined; openSpellcheckSuggestions: (view: EditorView) => boolean; + paragraphSpacingPx: number; resolveImageSrc: (source: string) => string | undefined; hideHeadingMarkersOnFocus: boolean; showCodeBlockLineNumbers: boolean; @@ -162,6 +164,7 @@ function markdownExtension({ openExternalUrl, openLocalAttachment, openSpellcheckSuggestions, + paragraphSpacingPx, resolveImageSrc, hideHeadingMarkersOnFocus, showCodeBlockLineNumbers, @@ -202,6 +205,7 @@ function markdownExtension({ highlight: extendedSyntax?.highlight ?? true, resolveLinkTarget: linkOptions?.resolveTarget, hideHeadingMarkersOnFocus, + paragraphSpacing: paragraphSpacingPx, plugins: [ blocksPlugin({ callout: extendedSyntax?.githubAlerts ?? true, @@ -361,6 +365,7 @@ export function CodeMirrorPaperSurface({ openExternalUrl, openLocalAttachment, plugins = emptyPlugins, + paragraphSpacingPx = 8, readClipboardText = readAppClipboardText, readOnly = false, resolveImageSrc, @@ -541,6 +546,7 @@ export function CodeMirrorPaperSurface({ openExternalUrl: () => openExternalUrlRef.current, openLocalAttachment: () => openLocalAttachmentRef.current, openSpellcheckSuggestions: openSpellcheckSuggestionMenu, + paragraphSpacingPx, resolveImageSrc: (source) => resolveImageSrcRef.current?.(source), hideHeadingMarkersOnFocus, showCodeBlockLineNumbers, @@ -689,6 +695,7 @@ export function CodeMirrorPaperSurface({ openExternalUrl: () => openExternalUrlRef.current, openLocalAttachment: () => openLocalAttachmentRef.current, openSpellcheckSuggestions: openSpellcheckSuggestionMenu, + paragraphSpacingPx, resolveImageSrc: (source) => resolveImageSrcRef.current?.(source), hideHeadingMarkersOnFocus, showCodeBlockLineNumbers, @@ -712,6 +719,7 @@ export function CodeMirrorPaperSurface({ Boolean(openExternalUrl), Boolean(openLocalAttachment), openSpellcheckSuggestionMenu, + paragraphSpacingPx, plugins, readClipboardText, hideHeadingMarkersOnFocus, diff --git a/packages/app/src/components/MarkdownPaper.tsx b/packages/app/src/components/MarkdownPaper.tsx index dbd0e271..383e80ec 100644 --- a/packages/app/src/components/MarkdownPaper.tsx +++ b/packages/app/src/components/MarkdownPaper.tsx @@ -195,6 +195,7 @@ export function MarkdownPaper({ onActiveOutlineIndexChange={onActiveOutlineIndexChange} onEditorReady={onEditorReady} onMarkdownChange={onMarkdownChange} + paragraphSpacingPx={paragraphSpacingPx} onSaveClipboardAttachment={onSaveClipboardAttachment} onSaveClipboardImage={onSaveClipboardImage} onSaveRemoteClipboardImage={onSaveRemoteClipboardImage} diff --git a/packages/app/src/styles.css b/packages/app/src/styles.css index b1076e6f..6e31d89b 100644 --- a/packages/app/src/styles.css +++ b/packages/app/src/styles.css @@ -987,36 +987,32 @@ /* Heading controls follow typography even when the final heading has no foldable content and therefore no fold-toggle decoration. */ .markdown-paper .cm-line.cm-markra-h1 { - --markra-heading-control-center-offset: -5.5px; + --markra-heading-control-center-offset: 0px; color: var(--editor-h1-color) !important; - padding-block: 0 16px !important; font-size: var(--editor-h1-font-size) !important; font-weight: var(--editor-h1-font-weight) !important; line-height: var(--editor-h1-line-height) !important; } .markdown-paper .cm-line.cm-markra-h2 { - --markra-heading-control-center-offset: 8px; + --markra-heading-control-center-offset: 0px; color: var(--editor-h2-color) !important; - padding-block: 28px 12px !important; font-size: var(--editor-h2-font-size) !important; font-weight: var(--editor-h2-font-weight) !important; line-height: var(--editor-h2-line-height) !important; } .markdown-paper .cm-line.cm-markra-h3 { - --markra-heading-control-center-offset: 9px; + --markra-heading-control-center-offset: 0px; color: var(--editor-h3-color) !important; - padding-block: 22px 4px !important; font-size: var(--editor-h3-font-size) !important; font-weight: var(--editor-h3-font-weight) !important; line-height: var(--editor-h3-line-height) !important; } .markdown-paper .cm-line.cm-markra-h4 { - --markra-heading-control-center-offset: 8px; + --markra-heading-control-center-offset: 0px; color: var(--editor-h4-color) !important; - padding-block: 18px 2px !important; font-size: var(--editor-h4-font-size) !important; font-weight: var(--editor-h4-font-weight) !important; line-height: var(--editor-h4-line-height) !important; @@ -1024,8 +1020,7 @@ .markdown-paper .cm-line.cm-markra-h5, .markdown-paper .cm-line.cm-markra-h6 { - --markra-heading-control-center-offset: 7px; - padding-block-start: 14px !important; + --markra-heading-control-center-offset: 0px; } .markdown-paper .cm-line.cm-markra-h5 { @@ -1065,12 +1060,6 @@ color: var(--editor-text-primary); } - /* Paragraph spacing is extra rhythm after content, not a replacement for - an authored blank line, which must retain the editor's normal line height. */ - .markdown-paper .cm-line.cm-markra-paragraph-end { - padding-block-end: var(--editor-paragraph-spacing) !important; - } - .markdown-paper .cm-line.cm-markra-list-item { position: relative; } @@ -1130,10 +1119,6 @@ color: var(--editor-text-secondary) !important; } - .markdown-paper .cm-line.cm-markra-empty-line + .cm-line.cm-markra-blockquote { - margin-block-start: 10px; - } - .markdown-paper .cm-line.cm-markra-list-item + .cm-line.cm-markra-list-item { --markra-block-toolbar-block-offset: 4px; padding-block-start: 4px !important; diff --git a/packages/app/src/styles.test.ts b/packages/app/src/styles.test.ts index 754e99c3..956a7328 100644 --- a/packages/app/src/styles.test.ts +++ b/packages/app/src/styles.test.ts @@ -52,7 +52,6 @@ describe("editor stylesheet", () => { expect(styles).toContain("font-size: 31px"); expect(styles).toContain(".markdown-paper .cm-line.cm-markra-h3"); expect(styles).toContain("font-size: 24px"); - expect(styles).toContain(".markdown-paper .cm-line.cm-markra-empty-line"); expect(styles).toContain(".markdown-paper .cm-line.cm-markra-list-item"); expect(styles).toContain('[data-markra-list-source="hidden"]::before'); expect(styles).toContain('content: "" !important'); @@ -96,6 +95,21 @@ describe("editor stylesheet", () => { } }); + it("keeps vertical heading rhythm out of editable CodeMirror line boxes", () => { + const styles = readFileSync(`${process.cwd()}/src/styles.css`, "utf8"); + + for (let level = 1; level <= 6; level += 1) { + const ruleStart = styles.indexOf( + `.markdown-paper .cm-line.cm-markra-h${level} {`, + ); + const ruleEnd = styles.indexOf("\n }", ruleStart); + const rule = styles.slice(ruleStart, ruleEnd); + + expect(ruleStart).toBeGreaterThanOrEqual(0); + expect(rule).not.toContain("padding-block"); + } + }); + it("keeps CodeMirror's preview selection theme-aware and readable", () => { const styles = readFileSync(`${process.cwd()}/src/styles.css`, "utf8"); const selectionStart = styles.indexOf( @@ -465,24 +479,25 @@ describe("editor stylesheet", () => { expect(styles).not.toContain( ".markdown-paper .cm-line.cm-markra-empty-line {", ); - expect(styles).toContain( + expect(styles).not.toContain( ".markdown-paper .cm-line.cm-markra-paragraph-end {", ); - expect(styles).toContain( + expect(styles).not.toContain( "padding-block-end: var(--editor-paragraph-spacing) !important;", ); expect(styles).not.toContain("cm-markra-paragraph-separator"); expect(styles).not.toContain( '.cm-markra-empty-line[data-markra-empty-source="hidden"] {', ); + expect(styles).not.toContain( + ".cm-line.cm-markra-empty-line + .cm-line.cm-markra-blockquote", + ); + expect(styles).not.toContain("margin-block-start: 10px;"); }); it("keeps CodeMirror block rhythm aligned with the original visual editor", () => { const styles = readFileSync(`${process.cwd()}/src/styles.css`, "utf8"); - expect(styles).toContain( - ".cm-line.cm-markra-empty-line + .cm-line.cm-markra-blockquote", - ); expect(styles).toContain( '.cm-line.cm-markra-list-item[data-list-depth="0"] +', ); @@ -661,24 +676,21 @@ describe("editor stylesheet", () => { ); expect(styles).toContain( ".markdown-paper .cm-line.cm-markra-h1 {\n" + - " --markra-heading-control-center-offset: -5.5px;", + " --markra-heading-control-center-offset: 0px;", ); expect(styles).toContain( ".markdown-paper .cm-line.cm-markra-h2 {\n" + - " --markra-heading-control-center-offset: 8px;", + " --markra-heading-control-center-offset: 0px;", ); expect(styles).toContain( ".markdown-paper .cm-line.cm-markra-h3 {\n" + - " --markra-heading-control-center-offset: 9px;", + " --markra-heading-control-center-offset: 0px;", ); expect(styles).toContain( ".markdown-paper .cm-line.cm-markra-h4 {\n" + - " --markra-heading-control-center-offset: 8px;", - ); - expect(styles).toContain( - "--markra-heading-control-center-offset: 7px;\n" + - " padding-block-start: 14px !important;", + " --markra-heading-control-center-offset: 0px;", ); + expect(styles).toContain("--markra-heading-control-center-offset: 0px;"); expect(styles).not.toContain( ".cm-line.cm-markra-h3.markra-heading-toggle-heading {\n" + " --markra-heading-control-center-offset:", diff --git a/packages/editor/src/codemirror/block-spacing.test.ts b/packages/editor/src/codemirror/block-spacing.test.ts new file mode 100644 index 00000000..15315eb5 --- /dev/null +++ b/packages/editor/src/codemirror/block-spacing.test.ts @@ -0,0 +1,160 @@ +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { liveMarkdown } from "./index.ts"; +import "./dom.test-support.ts"; + +const syntaxTreeIterations = vi.hoisted( + (): Array<{ from: number | undefined; to: number | undefined }> => [], +); + +vi.mock("@codemirror/language", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + syntaxTree(state: Parameters[0]) { + const tree = actual.syntaxTree(state); + return new Proxy(tree, { + get(target, property, receiver) { + if (property !== "iterate") { + return Reflect.get(target, property, receiver); + } + + return (spec: Parameters[0]) => { + syntaxTreeIterations.push({ from: spec.from, to: spec.to }); + return target.iterate(spec); + }; + }, + }); + }, + }; +}); + +const views: EditorView[] = []; + +function createView(doc: string, paragraphSpacing = 0) { + const parent = document.createElement("div"); + document.body.append(parent); + const view = new EditorView({ + parent, + state: EditorState.create({ + doc, + extensions: [liveMarkdown({ paragraphSpacing })], + }), + }); + views.push(view); + return view; +} + +afterEach(() => { + for (const view of views.splice(0)) view.destroy(); + document.body.replaceChildren(); +}); + +describe("block spacing", () => { + it("uses measured block spacers instead of enlarging editable heading lines", () => { + const view = createView([ + "# Synthetic title", + "", + "## Synthetic section", + "", + "### Synthetic detail", + "", + "#### Synthetic topic", + "", + "##### Synthetic note", + "", + "###### Synthetic leaf", + ].join("\n")); + + const spacers = Array.from( + view.dom.querySelectorAll(".cm-markra-heading-spacer"), + (spacer) => ({ + edge: spacer.dataset.headingEdge, + height: spacer.style.height, + level: spacer.dataset.headingLevel, + }), + ); + + expect(spacers).toEqual([ + { edge: "after", height: "16px", level: "1" }, + { edge: "before", height: "28px", level: "2" }, + { edge: "after", height: "12px", level: "2" }, + { edge: "before", height: "22px", level: "3" }, + { edge: "after", height: "4px", level: "3" }, + { edge: "before", height: "18px", level: "4" }, + { edge: "after", height: "2px", level: "4" }, + { edge: "before", height: "14px", level: "5" }, + { edge: "before", height: "14px", level: "6" }, + ]); + expect( + view.dom.querySelectorAll( + ".cm-markra-heading-spacer[aria-hidden='true']", + ), + ).toHaveLength(spacers.length); + }); + + it("uses a measured block spacer for configurable paragraph rhythm", () => { + const view = createView( + "Synthetic first paragraph\n\nSynthetic second paragraph", + 14, + ); + const spacers = view.dom.querySelectorAll( + ".cm-markra-paragraph-spacer", + ); + + expect(spacers).toHaveLength(1); + expect(spacers[0]?.style.height).toBe("14px"); + expect(spacers[0]?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("uses a measured block spacer before separated blockquotes", () => { + const view = createView("Synthetic lead\n\n> Synthetic quote"); + const spacers = view.dom.querySelectorAll( + ".cm-markra-blockquote-spacer", + ); + + expect(spacers).toHaveLength(1); + expect(spacers[0]?.style.height).toBe("10px"); + expect(spacers[0]?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("rebuilds spacing when plain text turns a blank row into a paragraph", () => { + const doc = "Synthetic first\n\n\n\nSynthetic last"; + const view = createView(doc, 14); + const insertion = view.state.doc.line(3).from; + + expect( + view.dom.querySelectorAll(".cm-markra-paragraph-spacer"), + ).toHaveLength(1); + + view.dispatch({ + changes: { from: insertion, insert: "Synthetic middle" }, + selection: { anchor: insertion + "Synthetic middle".length }, + userEvent: "input.type", + }); + + expect( + view.dom.querySelectorAll(".cm-markra-paragraph-spacer"), + ).toHaveLength(2); + }); + + it("maps spacing when an inline space cannot change block structure", () => { + const doc = "# Synthetic heading\n\nEdit here"; + const view = createView(doc, 14); + syntaxTreeIterations.splice(0); + + view.dispatch({ + changes: { from: doc.length, insert: " " }, + selection: { anchor: doc.length + 1 }, + userEvent: "input.type", + }); + + expect( + syntaxTreeIterations.filter( + ({ from, to }) => from === undefined && to === undefined, + ), + ).toHaveLength(1); + }); +}); diff --git a/packages/editor/src/codemirror/block-spacing.ts b/packages/editor/src/codemirror/block-spacing.ts new file mode 100644 index 00000000..a64788bd --- /dev/null +++ b/packages/editor/src/codemirror/block-spacing.ts @@ -0,0 +1,264 @@ +import { syntaxTree } from "@codemirror/language"; +import { + StateField, + type EditorState, + type Range, + type Transaction, +} from "@codemirror/state"; +import { + Decoration, + type DecorationSet, + EditorView, + WidgetType, + type EditorView as CodeMirrorView, +} from "@codemirror/view"; +import { + syntaxTreeChanged, +} from "./changes.ts"; +import { markraListDepth } from "./renderers.ts"; + +type HeadingEdge = "after" | "before"; +type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; + +const headingLevels: Readonly> = { + ATXHeading1: 1, + ATXHeading2: 2, + ATXHeading3: 3, + ATXHeading4: 4, + ATXHeading5: 5, + ATXHeading6: 6, + SetextHeading1: 1, + SetextHeading2: 2, +}; + +const headingSpacing: Readonly< + Record>> +> = { + 1: { after: 16, before: 0 }, + 2: { after: 12, before: 28 }, + 3: { after: 4, before: 22 }, + 4: { after: 2, before: 18 }, + 5: { after: 0, before: 14 }, + 6: { after: 0, before: 14 }, +}; + +abstract class MeasuredSpacerWidget extends WidgetType { + constructor(readonly height: number) { + super(); + } + + // CodeMirror estimates offscreen block heights from this value. Rendering + // the same value inline prevents long documents from accumulating pointer + // coordinate drift before every spacer has entered the viewport. + get estimatedHeight() { + return this.height; + } + + protected createSpacer(view: CodeMirrorView, className: string) { + const spacer = view.dom.ownerDocument.createElement("div"); + spacer.className = className; + spacer.style.height = `${this.height}px`; + spacer.setAttribute("aria-hidden", "true"); + return spacer; + } +} + +class HeadingSpacerWidget extends MeasuredSpacerWidget { + constructor( + readonly edge: HeadingEdge, + height: number, + readonly level: HeadingLevel, + ) { + super(height); + } + + eq(other: HeadingSpacerWidget) { + return other.edge === this.edge && + other.height === this.height && + other.level === this.level; + } + + toDOM(view: CodeMirrorView) { + const spacer = this.createSpacer(view, "cm-markra-heading-spacer"); + spacer.dataset.headingEdge = this.edge; + spacer.dataset.headingLevel = String(this.level); + return spacer; + } +} + +class ParagraphSpacerWidget extends MeasuredSpacerWidget { + eq(other: ParagraphSpacerWidget) { + return other.height === this.height; + } + + toDOM(view: CodeMirrorView) { + return this.createSpacer(view, "cm-markra-paragraph-spacer"); + } +} + +class BlockquoteSpacerWidget extends MeasuredSpacerWidget { + eq(other: BlockquoteSpacerWidget) { + return other.height === this.height; + } + + toDOM(view: CodeMirrorView) { + return this.createSpacer(view, "cm-markra-blockquote-spacer"); + } +} + +function addHeadingSpacer( + ranges: Range[], + edge: HeadingEdge, + height: number, + level: HeadingLevel, + position: number, +) { + if (height === 0) return; + ranges.push( + Decoration.widget({ + block: true, + side: edge === "before" ? -100 : 100, + widget: new HeadingSpacerWidget(edge, height, level), + }).range(position), + ); +} + +function hasFollowingContent(state: EditorState, lineNumber: number) { + for (let number = lineNumber + 1; number <= state.doc.lines; number += 1) { + if (state.doc.line(number).text.trim().length > 0) return true; + } + return false; +} + +function buildBlockSpacing( + state: EditorState, + paragraphSpacing: number, +) { + const ranges: Range[] = []; + + syntaxTree(state).iterate({ + enter(node) { + const level = headingLevels[node.type.name]; + if (level) { + const firstLine = state.doc.lineAt(node.from); + const lastLine = state.doc.lineAt(Math.max(node.from, node.to - 1)); + const spacing = headingSpacing[level]; + addHeadingSpacer( + ranges, + "before", + spacing.before, + level, + firstLine.from, + ); + addHeadingSpacer(ranges, "after", spacing.after, level, lastLine.to); + return; + } + + if (node.type.name === "Blockquote") { + const firstLine = state.doc.lineAt(node.from); + if ( + firstLine.number > 1 && + state.doc.line(firstLine.number - 1).text.trim().length === 0 + ) { + ranges.push( + Decoration.widget({ + block: true, + side: -90, + widget: new BlockquoteSpacerWidget(10), + }).range(firstLine.from), + ); + } + } + + if ( + paragraphSpacing <= 0 || + node.type.name !== "Paragraph" || + markraListDepth(node.node) > 0 || + node.to <= node.from + ) { + return; + } + + const lastLine = state.doc.lineAt(node.to - 1); + if (!hasFollowingContent(state, lastLine.number)) return; + ranges.push( + Decoration.widget({ + block: true, + side: 100, + widget: new ParagraphSpacerWidget(paragraphSpacing), + }).range(lastLine.to), + ); + }, + }); + + return Decoration.set(ranges, true); +} + +function normalizeSpacing(value: number) { + return Number.isFinite(value) ? Math.max(0, value) : 0; +} + +function transactionOnlyInsertsBlockSafeText(transaction: Transaction) { + if (!transaction.docChanged || !transaction.isUserEvent("input")) { + return false; + } + + let safeInsertion = true; + transaction.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => { + const source = inserted.toString(); + if ( + fromA !== toA || + /[\n\r\t#>*+=\-`~]/u.test(source) + ) { + safeInsertion = false; + return; + } + if (source.includes(" ")) { + const line = transaction.startState.doc.lineAt(fromA); + const offset = fromA - line.from; + const nextLine = line.text.slice(0, offset) + + source + + line.text.slice(offset); + if ( + /^[\t ]{0,3}(?:#{1,6}|>|[-+*]|\d+[.)])[\t ]+/u.test(nextLine) + ) { + safeInsertion = false; + } + } + }); + return safeInsertion; +} + +function plainTextChangeStaysInsideBlock(transaction: Transaction) { + if (!transactionOnlyInsertsBlockSafeText(transaction)) return false; + + let staysInsideBlock = true; + transaction.changes.iterChangedRanges((fromA) => { + if (!staysInsideBlock) return; + if (transaction.startState.doc.lineAt(fromA).text.trim().length === 0) { + staysInsideBlock = false; + } + }); + return staysInsideBlock; +} + +export function blockSpacingExtension(paragraphSpacing = 0) { + const normalizedParagraphSpacing = normalizeSpacing(paragraphSpacing); + + return StateField.define({ + create: (state) => buildBlockSpacing(state, normalizedParagraphSpacing), + provide: (field) => EditorView.decorations.from(field), + update(spacing, transaction) { + if (plainTextChangeStaysInsideBlock(transaction)) { + return spacing.map(transaction.changes); + } + if (transaction.docChanged) { + return buildBlockSpacing(transaction.state, normalizedParagraphSpacing); + } + if (transaction.selection) return spacing; + return syntaxTreeChanged(transaction.startState, transaction.state) + ? buildBlockSpacing(transaction.state, normalizedParagraphSpacing) + : spacing; + }, + }); +} diff --git a/packages/editor/src/codemirror/changes.ts b/packages/editor/src/codemirror/changes.ts index 0e54a8bc..5f952f30 100644 --- a/packages/editor/src/codemirror/changes.ts +++ b/packages/editor/src/codemirror/changes.ts @@ -2,6 +2,23 @@ import { syntaxTree } from "@codemirror/language"; import type { EditorState, Transaction } from "@codemirror/state"; import type { ViewUpdate } from "@codemirror/view"; +function onlyInsertsPlainText( + change: Pick, +) { + if (!change.docChanged) return false; + + let plainInsertion = true; + change.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => { + if ( + fromA !== toA || + !/^[\p{L}\p{M}\p{N}]+$/u.test(inserted.toString()) + ) { + plainInsertion = false; + } + }); + return plainInsertion; +} + export function syntaxTreeChanged( startState: EditorState, state: EditorState, @@ -23,16 +40,7 @@ export function updateOnlyInsertsPlainText(update: ViewUpdate) { return false; } - let plainInsertion = true; - update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => { - if ( - fromA !== toA || - !/^[\p{L}\p{M}\p{N}]+$/u.test(inserted.toString()) - ) { - plainInsertion = false; - } - }); - return plainInsertion; + return onlyInsertsPlainText(update); } function changesStayAfter( diff --git a/packages/editor/src/codemirror/preview.ts b/packages/editor/src/codemirror/preview.ts index a26fae83..a923a915 100644 --- a/packages/editor/src/codemirror/preview.ts +++ b/packages/editor/src/codemirror/preview.ts @@ -18,6 +18,7 @@ import { } from "./policy.ts"; import { getMarkraRenderers, + markraListDepth, type MarkraRenderer, type MarkraSyntaxNode, } from "./renderers.ts"; @@ -32,6 +33,7 @@ import { unescapeMarkdown } from "./syntax.ts"; import { createTaskDecoration } from "./tasks.ts"; import { isInsidePreformattedBlock } from "./blank-lines.ts"; import { syntaxTreeChanged, updateOnlyInsertsPlainText } from "./changes.ts"; +import { blockSpacingExtension } from "./block-spacing.ts"; const HEADING_CLASSES: Readonly> = { ATXHeading1: "cm-markra-h1", @@ -195,16 +197,6 @@ function emptyTaskMarkerRange(source: string) { }; } -function listDepth(node: MarkraSyntaxNode) { - let depth = 0; - let parent = node.parent; - while (parent) { - if (parent.name === "ListItem") depth += 1; - parent = parent.parent; - } - return depth; -} - function hasUnclosedInlineDestination( state: EditorView["state"], node: MarkraSyntaxNode, @@ -266,6 +258,7 @@ export interface LivePreviewConfig { resolveLinkTarget?: (context: MarkraLinkSourceContext) => string | null; reveal?: RevealPolicy; hideHeadingMarkersOnFocus?: boolean; + paragraphSpacing?: number; taskCheckboxes?: boolean; } @@ -394,7 +387,7 @@ function buildDecorations( let paragraphEndLine: number | null = null; if ( node.name === "Paragraph" && - listDepth(node.node as MarkraSyntaxNode) === 0 + markraListDepth(node.node as MarkraSyntaxNode) === 0 ) { const endLine = state.doc.lineAt(node.to - 1).number; let nextContentLine = endLine + 1; @@ -500,7 +493,7 @@ function buildDecorations( Decoration.line({ attributes: { "data-list-depth": String( - listDepth(node.node as MarkraSyntaxNode), + markraListDepth(node.node as MarkraSyntaxNode), ), "data-list-kind": listAttributes.kind, "data-list-marker": listAttributes.marker, @@ -953,6 +946,7 @@ function previewPlugin(config: LivePreviewConfig): Extension { export function livePreview(config: LivePreviewConfig = {}): Extension { return [ sourceDragSelectionExtension, + blockSpacingExtension(config.paragraphSpacing), previewPlugin(config), listMarkerSelectionPlugin, ]; diff --git a/packages/editor/src/codemirror/renderers.ts b/packages/editor/src/codemirror/renderers.ts index bf4f488c..f82d8211 100644 --- a/packages/editor/src/codemirror/renderers.ts +++ b/packages/editor/src/codemirror/renderers.ts @@ -16,6 +16,16 @@ export interface MarkraSyntaxNode { getChildren(name: string): readonly MarkraSyntaxNode[]; } +export function markraListDepth(node: MarkraSyntaxNode) { + let depth = 0; + let parent = node.parent; + while (parent) { + if (parent.name === "ListItem") depth += 1; + parent = parent.parent; + } + return depth; +} + export interface MarkraRendererContext { readonly node: MarkraSyntaxNode; readonly state: EditorState;