From c97e4b71665cd023c12fc70a8525b7d3bc3c808d Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 13 Jul 2026 00:33:36 +1000 Subject: [PATCH 01/16] feat(core): add semantic clipboard DOM for `paragraph` and `heading` --- .../clipboard/semantic-inline.test.ts | 227 ++++++++++++++++++ .../extensions/clipboard/semantic-inline.ts | 190 +++++++++++++++ packages/core/src/extensions/heading.ts | 43 +++- packages/core/src/extensions/paragraph.ts | 13 +- 4 files changed, 460 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/extensions/clipboard/semantic-inline.test.ts create mode 100644 packages/core/src/extensions/clipboard/semantic-inline.ts diff --git a/packages/core/src/extensions/clipboard/semantic-inline.test.ts b/packages/core/src/extensions/clipboard/semantic-inline.test.ts new file mode 100644 index 00000000..d66f983f --- /dev/null +++ b/packages/core/src/extensions/clipboard/semantic-inline.test.ts @@ -0,0 +1,227 @@ +import { formatHTML } from 'diffable-html-snapshot' +import { describe, expect, it } from 'vitest' + +import { markdownToDoc } from '../../converters/md-to-pm.ts' +import { setupFixture, type Fixture } from '../../testing/index.ts' +import { headingClipboardDOM } from '../heading.ts' +import { paragraphClipboardDOM } from '../paragraph.ts' + +function firstTextblockDOM(fixture: Fixture, markdown: string): string { + const { editor, view } = fixture + fixture.set(markdownToDoc(markdown, { nodes: editor.nodes })) + const textblock = view.state.doc.child(0) + const element = + textblock.type.name === 'heading' + ? headingClipboardDOM(textblock) + : paragraphClipboardDOM(textblock) + return formatHTML(element.outerHTML) +} + +describe('paragraphClipboardDOM', () => { + it('serializes plain text', () => { + using fixture = setupFixture() + expect(firstTextblockDOM(fixture, 'hello')).toMatchInlineSnapshot(` + " +

+ hello +

+ " + `) + }) + + it('drops syntax characters and wraps semantic marks', () => { + using fixture = setupFixture() + expect(firstTextblockDOM(fixture, 'a **b** *c* `d` ~~e~~ ==f==')).toMatchInlineSnapshot(` + " +

+ a + + b + + + c + + + d + + + e + + + f + +

+ " + `) + }) + + it('keeps mark nesting', () => { + using fixture = setupFixture() + expect(firstTextblockDOM(fixture, '**bold *italic* bold**')).toMatchInlineSnapshot(` + " +

+ + bold + + italic + + bold + +

+ " + `) + }) + + it('serializes a link with its href', () => { + using fixture = setupFixture() + expect(firstTextblockDOM(fixture, 'see [docs](https://example.com)')).toMatchInlineSnapshot(` + " +

+ see + + docs + +

+ " + `) + }) + + it('serializes a bare autolink', () => { + using fixture = setupFixture() + expect(firstTextblockDOM(fixture, 'visit https://example.com now')).toMatchInlineSnapshot(` + " +

+ visit + + https://example.com + + now +

+ " + `) + }) + + it('replaces an image source with an img element', () => { + using fixture = setupFixture() + expect(firstTextblockDOM(fixture, 'see ![cat](https://example.com/cat.png) end')) + .toMatchInlineSnapshot(` + " +

+ see + cat + end +

+ " + `) + }) + + it('renders wikilink display text', () => { + using fixture = setupFixture() + expect(firstTextblockDOM(fixture, 'see [[note]] end')).toMatchInlineSnapshot(` + " +

+ see note end +

+ " + `) + }) + + it('keeps math source text', () => { + using fixture = setupFixture() + expect(firstTextblockDOM(fixture, 'see $E=mc^2$ end')).toMatchInlineSnapshot(` + " +

+ see $E=mc^2$ end +

+ " + `) + }) + + it('keeps a tag as plain text', () => { + using fixture = setupFixture() + expect(firstTextblockDOM(fixture, 'hello #meow end')).toMatchInlineSnapshot(` + " +

+ hello #meow end +

+ " + `) + }) + + it('renders a soft break as br', () => { + using fixture = setupFixture() + const { n, view } = fixture + fixture.set(n.doc(n.paragraph('line1\nline2'))) + const element = paragraphClipboardDOM(view.state.doc.child(0)) + expect(formatHTML(element.outerHTML)).toMatchInlineSnapshot(` + " +

+ line1 +
+ line2 +

+ " + `) + }) + + it('serializes an empty paragraph', () => { + using fixture = setupFixture() + const { n, view } = fixture + fixture.set(n.doc(n.paragraph())) + const element = paragraphClipboardDOM(view.state.doc.child(0)) + expect(formatHTML(element.outerHTML)).toMatchInlineSnapshot(` + " +

+

+ " + `) + }) +}) + +describe('headingClipboardDOM', () => { + it('serializes an ATX heading with the source prefix in data-md', () => { + using fixture = setupFixture() + expect(firstTextblockDOM(fixture, '### a **b**')).toMatchInlineSnapshot(` + " +

+ a + + b + +

+ " + `) + }) + + it('keeps setext underline metadata', () => { + using fixture = setupFixture() + expect(firstTextblockDOM(fixture, 'title\n=====')).toMatchInlineSnapshot(` + " +

+ title +

+ " + `) + }) + + it('keeps closing hashes metadata', () => { + using fixture = setupFixture() + expect(firstTextblockDOM(fixture, '## title ##')).toMatchInlineSnapshot(` + " +

+ title +

+ " + `) + }) +}) diff --git a/packages/core/src/extensions/clipboard/semantic-inline.ts b/packages/core/src/extensions/clipboard/semantic-inline.ts new file mode 100644 index 00000000..df31e363 --- /dev/null +++ b/packages/core/src/extensions/clipboard/semantic-inline.ts @@ -0,0 +1,190 @@ +import { Fragment } from '@prosekit/pm/model' +import type { Mark, ProseMirrorNode, TagParseRule } from '@prosekit/pm/model' + +import type { + MdFileAttrs, + MdImageAttrs, + MdLinkTextAttrs, + MdWikilinkAttrs, +} from '../inline-marks.ts' +import type { MarkName } from '../mark-names.ts' + +/** Syntax characters, dropped from the semantic clipboard HTML. */ +const SYNTAX_MARK_NAMES: ReadonlySet = new Set([ + 'mdMark', + 'mdLinkUri', + 'mdLinkTitle', +]) + +/** Marks covering a whole source unit, emitted as one replacement per unit. */ +const ATOM_MARK_NAMES: ReadonlySet = new Set([ + 'mdImage', + 'mdWikilink', + 'mdMath', + 'mdFile', +]) + +const SEMANTIC_TAGS: Partial> = { + mdStrong: 'strong', + mdEm: 'em', + mdCode: 'code', + mdDel: 'del', + mdHighlight: 'mark', + mdLinkText: 'a', +} + +export function findAtomMark(marks: readonly Mark[]): Mark | undefined { + return marks.find((mark) => ATOM_MARK_NAMES.has(mark.type.name)) +} + +export function hasSyntaxMark(marks: readonly Mark[]): boolean { + return marks.some((mark) => SYNTAX_MARK_NAMES.has(mark.type.name)) +} + +export interface InlineRun { + atom: Mark | undefined + text: string + children: ProseMirrorNode[] +} + +/** + * Group a textblock's text nodes into atom units and plain runs. A unit's + * text nodes share one mark instance (the inline parser creates each unit + * mark once), so instance identity splits adjacent same-attrs units. + */ +export function groupInlineRuns(textblock: ProseMirrorNode): InlineRun[] { + const runs: InlineRun[] = [] + textblock.forEach((child) => { + if (!child.isText || !child.text) return + const atom = findAtomMark(child.marks) + const last = runs.at(-1) + if (atom != null && last != null && last.atom === atom) { + last.text += child.text + last.children.push(child) + return + } + runs.push({ atom, text: child.text, children: [child] }) + }) + return runs +} + +/** + * The semantic DOM of one textblock: `data-md` holds the full source text, + * the children are the rendered inline content without syntax characters. + */ +export function semanticTextblockDOM( + tagName: string, + node: ProseMirrorNode, + attrs: Record = {}, +): HTMLElement { + const element = document.createElement(tagName) + element.setAttribute('data-md', node.textContent) + for (const [name, value] of Object.entries(attrs)) { + if (value != null) element.setAttribute(name, value) + } + serializeSemanticInline(node, element) + return element +} + +/** + * The clipboard parse rule for a textblock: the content comes from the + * `data-md` source text verbatim, the semantic child elements are ignored. + * The inline mark plugin re-derives marks after the paste transaction. + */ +export function sourceTextRule( + tag: string, + node: string, + getAttrs?: TagParseRule['getAttrs'], +): TagParseRule { + return { + tag: `${tag}[data-md]`, + node, + priority: 100, + getAttrs, + getContent: (dom, schema) => { + const source = (dom as HTMLElement).getAttribute('data-md') ?? '' + return source ? Fragment.from(schema.text(source)) : Fragment.empty + }, + } +} + +interface OpenWrapper { + mark: Mark + element: HTMLElement +} + +function serializeSemanticInline(textblock: ProseMirrorNode, out: HTMLElement): void { + const open: OpenWrapper[] = [] + + for (const run of groupInlineRuns(textblock)) { + if (run.atom != null) { + open.length = 0 + out.append(atomUnitDOM(run.atom, run.text)) + continue + } + for (const child of run.children) { + if (hasSyntaxMark(child.marks)) continue + const semantic = child.marks.filter((mark) => SEMANTIC_TAGS[mark.type.name as MarkName]) + syncOpenWrappers(open, semantic, out) + const parent = open.at(-1)?.element ?? out + appendTextWithBreaks(parent, child.text ?? '') + } + } +} + +function syncOpenWrappers(open: OpenWrapper[], next: readonly Mark[], out: HTMLElement): void { + let common = 0 + while (common < open.length && common < next.length && next[common].eq(open[common].mark)) { + common++ + } + open.length = common + for (let index = common; index < next.length; index++) { + const mark = next[index] + const element = document.createElement(SEMANTIC_TAGS[mark.type.name as MarkName] ?? 'span') + if (mark.type.name === ('mdLinkText' satisfies MarkName)) { + element.setAttribute('href', (mark.attrs as MdLinkTextAttrs).href) + } + ;(open.at(-1)?.element ?? out).append(element) + open.push({ mark, element }) + } +} + +/** A soft break (a literal `\n` in the source) renders as `
`. */ +function appendTextWithBreaks(parent: HTMLElement, text: string): void { + const lines = text.split('\n') + lines.forEach((line, index) => { + if (index > 0) parent.append(document.createElement('br')) + if (line) parent.append(document.createTextNode(line)) + }) +} + +function atomUnitDOM(atom: Mark, sourceText: string): globalThis.Node { + switch (atom.type.name as MarkName) { + case 'mdImage': { + const attrs = atom.attrs as MdImageAttrs + const image = document.createElement('img') + image.setAttribute('src', attrs.src) + if (attrs.alt) image.setAttribute('alt', attrs.alt) + if (attrs.title) image.setAttribute('title', attrs.title) + if (attrs.width != null) image.setAttribute('width', String(attrs.width)) + if (attrs.height != null) image.setAttribute('height', String(attrs.height)) + return image + } + case 'mdWikilink': { + const attrs = atom.attrs as MdWikilinkAttrs + return document.createTextNode(attrs.display || attrs.target) + } + case 'mdFile': { + const attrs = atom.attrs as MdFileAttrs + const anchor = document.createElement('a') + anchor.setAttribute('href', attrs.href) + anchor.append(document.createTextNode(attrs.name || attrs.href)) + return anchor + } + // HTML has no standard math representation; the source text ($ included) + // survives any markdown-aware consumer. + case 'mdMath': + default: + return document.createTextNode(sourceText) + } +} diff --git a/packages/core/src/extensions/heading.ts b/packages/core/src/extensions/heading.ts index c4d22d85..5f927c60 100644 --- a/packages/core/src/extensions/heading.ts +++ b/packages/core/src/extensions/heading.ts @@ -16,8 +16,10 @@ import { defineHeadingSpec, type HeadingAttrs, } from '@prosekit/extensions/heading' +import type { ProseMirrorNode, TagParseRule } from '@prosekit/pm/model' import type { Command } from '@prosekit/pm/state' +import { semanticTextblockDOM, sourceTextRule } from './clipboard/semantic-inline.ts' import type { NodeName } from './node-names.ts' export interface MeowdownHeadingAttrs extends HeadingAttrs { @@ -54,6 +56,33 @@ function defineHeadingWhitespace(): HeadingSpecExtension { return defineNodeSpec({ name: 'heading' satisfies NodeName, whitespace: 'pre' }) } +function parsePositiveCount(raw: string | null): number | null { + if (raw == null) return null + const count = Number.parseInt(raw, 10) + return Number.isSafeInteger(count) && count > 0 ? count : null +} + +/** The clipboard DOM of a heading: semantic inline content plus `data-md`. */ +export function headingClipboardDOM(node: ProseMirrorNode): HTMLElement { + const attrs = node.attrs as MeowdownHeadingAttrs + return semanticTextblockDOM(`h${attrs.level}`, node, { + 'data-setext-underline': + attrs.setextUnderline != null ? String(attrs.setextUnderline) : undefined, + 'data-closing-hashes': attrs.closingHashes != null ? String(attrs.closingHashes) : undefined, + }) +} + +/** The clipboard parse rules restoring a heading's source text from `data-md`. */ +export function headingFromDOM(): TagParseRule[] { + return [1, 2, 3, 4, 5, 6].map((level) => + sourceTextRule(`h${level}`, 'heading' satisfies NodeName, (dom) => ({ + level, + setextUnderline: parsePositiveCount(dom.getAttribute('data-setext-underline')), + closingHashes: parsePositiveCount(dom.getAttribute('data-closing-hashes')), + })), + ) +} + type SetextUnderlineExtension = Extension<{ Nodes: { heading: { setextUnderline?: number | null } } }> @@ -66,12 +95,7 @@ function defineSetextUnderlineAttr(): SetextUnderlineExtension { // A heading split or created in the editor is ATX; only a parsed setext // heading carries a length, which must survive an editor DOM re-parse. toDOM: (value) => (value != null ? ['data-setext-underline', String(value)] : null), - parseDOM: (node) => { - const raw = node.getAttribute('data-setext-underline') - if (raw == null) return null - const length = Number.parseInt(raw, 10) - return Number.isSafeInteger(length) && length > 0 ? length : null - }, + parseDOM: (node) => parsePositiveCount(node.getAttribute('data-setext-underline')), }) } @@ -88,12 +112,7 @@ function defineHeadingClosingHashesAttr(): ClosingHashesExtension { // created or edited in the editor has none, and the count must survive a DOM // re-parse. toDOM: (value) => (value != null ? ['data-closing-hashes', String(value)] : null), - parseDOM: (node) => { - const raw = node.getAttribute('data-closing-hashes') - if (raw == null) return null - const length = Number.parseInt(raw, 10) - return Number.isSafeInteger(length) && length > 0 ? length : null - }, + parseDOM: (node) => parsePositiveCount(node.getAttribute('data-closing-hashes')), }) } diff --git a/packages/core/src/extensions/paragraph.ts b/packages/core/src/extensions/paragraph.ts index 5aff6180..d2f63329 100644 --- a/packages/core/src/extensions/paragraph.ts +++ b/packages/core/src/extensions/paragraph.ts @@ -11,8 +11,9 @@ import { defineParagraphKeymap, type ParagraphCommandsExtension, } from '@prosekit/extensions/paragraph' -import type { Attrs } from '@prosekit/pm/model' +import type { Attrs, ProseMirrorNode, TagParseRule } from '@prosekit/pm/model' +import { semanticTextblockDOM, sourceTextRule } from './clipboard/semantic-inline.ts' import type { NodeName } from './node-names.ts' type ParagraphSpecExtension = Extension<{ @@ -35,6 +36,16 @@ function defineMeowdownParagraphSpec(): ParagraphSpecExtension { }) } +/** The clipboard DOM of a paragraph: semantic inline content plus `data-md`. */ +export function paragraphClipboardDOM(node: ProseMirrorNode): HTMLElement { + return semanticTextblockDOM('p', node) +} + +/** The clipboard parse rules restoring a paragraph's source text from `data-md`. */ +export function paragraphFromDOM(): TagParseRule[] { + return [sourceTextRule('p', 'paragraph' satisfies NodeName)] +} + type MeowdownParagraphExtension = Union<[ParagraphSpecExtension, ParagraphCommandsExtension]> export function defineMeowdownParagraph(): MeowdownParagraphExtension { From 34a5a43bb271cf127ed65c18465bd4009a9a4c19 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 13 Jul 2026 00:39:19 +1000 Subject: [PATCH 02/16] feat(core): wire semantic clipboard serializer and `data-md` parser --- .../extensions/clipboard/clipboard-parser.ts | 30 ++ .../clipboard/clipboard-serializer.ts | 36 +++ .../extensions/clipboard/clipboard.test.ts | 286 ++++++++++++++++++ .../src/extensions/clipboard/clipboard.ts | 12 + packages/core/src/extensions/extension.ts | 2 + 5 files changed, 366 insertions(+) create mode 100644 packages/core/src/extensions/clipboard/clipboard-parser.ts create mode 100644 packages/core/src/extensions/clipboard/clipboard-serializer.ts create mode 100644 packages/core/src/extensions/clipboard/clipboard.test.ts create mode 100644 packages/core/src/extensions/clipboard/clipboard.ts diff --git a/packages/core/src/extensions/clipboard/clipboard-parser.ts b/packages/core/src/extensions/clipboard/clipboard-parser.ts new file mode 100644 index 00000000..960c030d --- /dev/null +++ b/packages/core/src/extensions/clipboard/clipboard-parser.ts @@ -0,0 +1,30 @@ +import { definePlugin, type PlainExtension } from '@prosekit/core' +import { DOMParser, type Schema } from '@prosekit/pm/model' +import { Plugin, PluginKey } from '@prosekit/pm/state' + +import { headingFromDOM } from '../heading.ts' +import { paragraphFromDOM } from '../paragraph.ts' + +/** + * The clipboard parser: schema rules plus the `data-md` rules that restore a + * textblock's source text from meowdown's own clipboard HTML. Registered as + * `clipboardParser` (not in the schema) so static HTML parsing is unaffected. + */ +export function createClipboardParser(schema: Schema): DOMParser { + return new DOMParser(schema, [ + ...paragraphFromDOM(), + ...headingFromDOM(), + ...DOMParser.fromSchema(schema).rules, + ]) +} + +const clipboardParserKey = new PluginKey('meowdown-clipboard-parser') + +export function defineClipboardParser(): PlainExtension { + return definePlugin(({ schema }) => { + return new Plugin({ + key: clipboardParserKey, + props: { clipboardParser: createClipboardParser(schema) }, + }) + }) +} diff --git a/packages/core/src/extensions/clipboard/clipboard-serializer.ts b/packages/core/src/extensions/clipboard/clipboard-serializer.ts new file mode 100644 index 00000000..4c63782c --- /dev/null +++ b/packages/core/src/extensions/clipboard/clipboard-serializer.ts @@ -0,0 +1,36 @@ +import { defineClipboardSerializer, type PlainExtension } from '@prosekit/core' + +import { headingClipboardDOM } from '../heading.ts' +import { paragraphClipboardDOM } from '../paragraph.ts' +import type { NodeName } from '../node-names.ts' + +/** + * Serialize copied textblocks as semantic HTML (``, ``, real + * `

`..`

`) with the source text preserved in `data-md`, and stamp every + * top-level element with `data-meowdown` so the paste side can tell meowdown's + * own clipboard HTML from foreign HTML even when no textblock is present + * (e.g. a code-block-only copy). + */ +export function defineSemanticClipboardSerializer(): PlainExtension { + return defineClipboardSerializer({ + serializeFragmentWrapper: (serializeFragment) => { + return (...args) => { + const fragment = serializeFragment(...args) + for (const child of fragment.children) { + child.setAttribute('data-meowdown', '') + } + return fragment + } + }, + nodesFromSchemaWrapper: (nodesFromSchema) => { + return (...args) => { + const nodes = nodesFromSchema(...args) + return { + ...nodes, + ['paragraph' satisfies NodeName]: (node) => ({ dom: paragraphClipboardDOM(node) }), + ['heading' satisfies NodeName]: (node) => ({ dom: headingClipboardDOM(node) }), + } + } + }, + }) +} diff --git a/packages/core/src/extensions/clipboard/clipboard.test.ts b/packages/core/src/extensions/clipboard/clipboard.test.ts new file mode 100644 index 00000000..90ad358e --- /dev/null +++ b/packages/core/src/extensions/clipboard/clipboard.test.ts @@ -0,0 +1,286 @@ +import { pasteHTML } from '@prosekit/core/test' +import { TextSelection } from '@prosekit/pm/state' +import { formatHTML } from 'diffable-html-snapshot' +import { describe, expect, it } from 'vitest' + +import { markdownToDoc } from '../../converters/md-to-pm.ts' +import { docToMarkdown } from '../../converters/pm-to-md.ts' +import { setupFixture, type Fixture } from '../../testing/index.ts' + +/** + * The clipboard HTML of the whole document as a closed slice: the shape a + * block copy (e.g. a block handle drag) produces, where `transformCopied` + * (`unwrapListSlice`) does not fire. + */ +function captureClipboardHTML(fixture: Fixture, markdown: string): string { + const { editor, view } = fixture + fixture.set(markdownToDoc(markdown, { nodes: editor.nodes })) + const doc = view.state.doc + return view.serializeForClipboard(doc.slice(0, doc.content.size)).dom.innerHTML +} + +/** The clipboard HTML of a select-all text selection, `transformCopied` included. */ +function captureSelectionHTML(fixture: Fixture, markdown: string): string { + const { editor, view } = fixture + fixture.set(markdownToDoc(markdown, { nodes: editor.nodes })) + const doc = view.state.doc + const selection = TextSelection.between(doc.resolve(0), doc.resolve(doc.content.size)) + view.dispatch(view.state.tr.setSelection(selection)) + return view.serializeForClipboard(view.state.selection.content()).dom.innerHTML +} + +function pasteIntoEmptyDoc(html: string): string { + using target = setupFixture() + const { n, view } = target + target.set(n.doc(n.paragraph())) + pasteHTML(view, html) + return docToMarkdown(view.state.doc) +} + +function roundTrip(markdown: string): string { + using source = setupFixture() + return pasteIntoEmptyDoc(captureClipboardHTML(source, markdown)) +} + +describe('clipboard HTML', () => { + it('serializes a document to semantic HTML', () => { + using fixture = setupFixture() + const html = captureClipboardHTML(fixture, '## Title\n\nplain **bold** text') + expect(formatHTML(html)).toMatchInlineSnapshot(` + " +

+ Title +

+

+ plain + + bold + + text +

+ " + `) + }) + + it('stamps data-meowdown on every top-level element', () => { + using fixture = setupFixture() + const html = captureClipboardHTML(fixture, '- one\n- two\n\n```js\ncode\n```') + expect(formatHTML(html)).toMatchInlineSnapshot(` + " +
    +
  • +

    + one +

    +
  • +
  • +

    + two +

    +
  • +
+
+        
+          code
+        
+      
+ " + `) + }) +}) + +describe('clipboard round trip', () => { + it('round-trips a heading with inline marks', () => { + expect(roundTrip('### a **b** `c`')).toMatchInlineSnapshot(` + "### a **b** \`c\` + " + `) + }) + + it('round-trips a setext heading', () => { + expect(roundTrip('title\n=====')).toMatchInlineSnapshot(` + "title + ===== + " + `) + }) + + it('round-trips a heading with closing hashes', () => { + expect(roundTrip('## title ##')).toMatchInlineSnapshot(` + "## title ## + " + `) + }) + + it('round-trips a nested list', () => { + expect(roundTrip('- parent\n - child')).toMatchInlineSnapshot(` + "- parent + - child + " + `) + }) + + it('round-trips a round task', () => { + expect(roundTrip('+ [ ] Task')).toMatchInlineSnapshot(` + "+ [ ] Task + " + `) + }) + + it('round-trips a blockquote with two paragraphs', () => { + expect(roundTrip('> one\n>\n> two')).toMatchInlineSnapshot(` + "> one + > + > two + " + `) + }) + + it('round-trips a fenced code block', () => { + expect(roundTrip('~~~~js\nconst x = 1\n~~~~')).toMatchInlineSnapshot(` + "~~~~js + const x = 1 + ~~~~ + " + `) + }) + + it('round-trips a table', () => { + expect(roundTrip('| a | b |\n| :-: | - |\n| 1 | 2 |')).toMatchInlineSnapshot(` + "| a | b | + | :-: | --- | + | 1 | 2 | + " + `) + }) + + it('round-trips a thematic break with a non-canonical marker', () => { + expect(roundTrip('before\n\n* * *\n\nafter')).toMatchInlineSnapshot(` + "before + + * * * + + after + " + `) + }) + + it('round-trips an html comment', () => { + expect(roundTrip('before\n\n\n\nafter')).toMatchInlineSnapshot(` + "before + + + + after + " + `) + }) + + it('round-trips gap paragraphs', () => { + expect(roundTrip('aaa\n\n\n\nbbb')).toMatchInlineSnapshot(` + "aaa + + + + bbb + " + `) + }) + + it('round-trips a wikilink with a display alias', () => { + expect(roundTrip('see [[note|alias]] end')).toMatchInlineSnapshot(` + "see [[note|alias]] end + " + `) + }) + + it('round-trips an inline image', () => { + expect(roundTrip('see ![cat](https://example.com/cat.png) end')).toMatchInlineSnapshot(` + "see ![cat](https://example.com/cat.png) end + " + `) + }) + + it('round-trips inline math', () => { + expect(roundTrip('see $E=mc^2$ end')).toMatchInlineSnapshot(` + "see $E=mc^2$ end + " + `) + }) + + it('round-trips a soft break inside a paragraph', () => { + expect(roundTrip('line1\nline2')).toMatchInlineSnapshot(` + "line1 + line2 + " + `) + }) +}) + +describe('selection copy', () => { + // Selecting everything inside a single list item is "a selection within one + // item", so flat-list's `unwrapListSlice` intentionally strips the list + // wrapper: pasting into another item must not create a nested list. + it('unwraps a single task item into plain text', () => { + using source = setupFixture() + const html = captureSelectionHTML(source, '+ [ ] Task') + expect(html).toMatchInlineSnapshot( + `"

Task

"`, + ) + expect(pasteIntoEmptyDoc(html)).toMatchInlineSnapshot(` + "Task + " + `) + }) + + it('keeps sibling items as a list', () => { + using source = setupFixture() + expect(pasteIntoEmptyDoc(captureSelectionHTML(source, '- one\n- two'))).toMatchInlineSnapshot(` + "- one + - two + " + `) + }) + + it('keeps inline marks in a partial paragraph selection', () => { + using source = setupFixture() + const { editor, view } = source + source.set(markdownToDoc('plain **bold** end', { nodes: editor.nodes })) + // 1..15 covers `lain **bold** e` inside the paragraph + const selection = TextSelection.create(view.state.doc, 2, 16) + view.dispatch(view.state.tr.setSelection(selection)) + const html = view.serializeForClipboard(view.state.selection.content()).dom.innerHTML + expect(html).toMatchInlineSnapshot( + `"

lain bold

"`, + ) + expect(pasteIntoEmptyDoc(html)).toMatchInlineSnapshot(` + "lain **bold** + " + `) + }) +}) diff --git a/packages/core/src/extensions/clipboard/clipboard.ts b/packages/core/src/extensions/clipboard/clipboard.ts new file mode 100644 index 00000000..13e2eac2 --- /dev/null +++ b/packages/core/src/extensions/clipboard/clipboard.ts @@ -0,0 +1,12 @@ +import { union, type PlainExtension } from '@prosekit/core' + +import { defineClipboardParser } from './clipboard-parser.ts' +import { defineSemanticClipboardSerializer } from './clipboard-serializer.ts' + +/** + * The clipboard pipeline: semantic HTML with `data-md` round-trip attributes + * on copy, and the matching `data-md` parser on paste. + */ +export function defineClipboard(): PlainExtension { + return union(defineSemanticClipboardSerializer(), defineClipboardParser()) +} diff --git a/packages/core/src/extensions/extension.ts b/packages/core/src/extensions/extension.ts index 8347ffd5..08975def 100644 --- a/packages/core/src/extensions/extension.ts +++ b/packages/core/src/extensions/extension.ts @@ -13,6 +13,7 @@ import { defineText } from '@prosekit/extensions/text' import { defineVirtualSelection } from '@prosekit/extensions/virtual-selection' import { ATOM_SOURCE_MARK_NAMES, defineAtomMarkNavigation } from './atom-mark-navigation.ts' +import { defineClipboard } from './clipboard/clipboard.ts' import { defineCodeBlockSyntaxHighlight } from './code-block-highlight.ts' import { defineCodeBlock } from './code-block.ts' import { defineEditorCommands } from './commands.ts' @@ -68,6 +69,7 @@ function defineEditorExtensionImpl(options: EditorExtensionOptions) { defineWikilink(), defineMath(), defineMarkMode(options.markMode ?? 'focus'), + defineClipboard(), defineVirtualCaret(), defineScrollToSelection(), defineHiddenRunCaret(), From 863ceb7422222feac4ec14a87a7db8a3124e0335 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 13 Jul 2026 00:43:36 +1000 Subject: [PATCH 03/16] feat(core): markdown-shaped plain text copy with mode-aware inline --- .../src/extensions/clipboard/clipboard.ts | 10 +- .../extensions/clipboard/plain-text.test.ts | 165 ++++++++++++++++++ .../src/extensions/clipboard/plain-text.ts | 94 ++++++++++ .../extensions/clipboard/semantic-inline.ts | 2 +- .../core/src/extensions/mark-mode.test.ts | 51 ------ packages/core/src/extensions/mark-mode.ts | 18 +- .../core/src/extensions/markdown-copy.test.ts | 34 ---- packages/core/src/extensions/markdown-copy.ts | 37 ---- packages/core/src/index.ts | 1 - .../src/components/editor-extensions.tsx | 7 - 10 files changed, 272 insertions(+), 147 deletions(-) create mode 100644 packages/core/src/extensions/clipboard/plain-text.test.ts create mode 100644 packages/core/src/extensions/clipboard/plain-text.ts delete mode 100644 packages/core/src/extensions/markdown-copy.test.ts delete mode 100644 packages/core/src/extensions/markdown-copy.ts diff --git a/packages/core/src/extensions/clipboard/clipboard.ts b/packages/core/src/extensions/clipboard/clipboard.ts index 13e2eac2..9a021075 100644 --- a/packages/core/src/extensions/clipboard/clipboard.ts +++ b/packages/core/src/extensions/clipboard/clipboard.ts @@ -2,11 +2,17 @@ import { union, type PlainExtension } from '@prosekit/core' import { defineClipboardParser } from './clipboard-parser.ts' import { defineSemanticClipboardSerializer } from './clipboard-serializer.ts' +import { definePlainTextSerializer } from './plain-text.ts' /** * The clipboard pipeline: semantic HTML with `data-md` round-trip attributes - * on copy, and the matching `data-md` parser on paste. + * plus a markdown-shaped `text/plain` on copy, and the matching `data-md` + * parser on paste. */ export function defineClipboard(): PlainExtension { - return union(defineSemanticClipboardSerializer(), defineClipboardParser()) + return union( + defineSemanticClipboardSerializer(), + definePlainTextSerializer(), + defineClipboardParser(), + ) } diff --git a/packages/core/src/extensions/clipboard/plain-text.test.ts b/packages/core/src/extensions/clipboard/plain-text.test.ts new file mode 100644 index 00000000..b3caf99a --- /dev/null +++ b/packages/core/src/extensions/clipboard/plain-text.test.ts @@ -0,0 +1,165 @@ +import { TextSelection } from '@prosekit/pm/state' +import { describe, expect, it } from 'vitest' + +import { markdownToDoc } from '../../converters/md-to-pm.ts' +import { setupFixture, type Fixture } from '../../testing/index.ts' +import type { MarkMode } from '../mark-mode.ts' + +function setupPlainText(mode: MarkMode, markdown: string): Fixture { + const fixture = setupFixture({ extensionOptions: { markMode: mode } }) + const { editor } = fixture + fixture.set(markdownToDoc(markdown, { nodes: editor.nodes })) + return fixture +} + +/** The `text/plain` flavor of a whole-document block copy. */ +function copyText(mode: MarkMode, markdown: string): string { + using fixture = setupPlainText(mode, markdown) + const { view } = fixture + const doc = view.state.doc + return view.serializeForClipboard(doc.slice(0, doc.content.size)).text +} + +/** The `text/plain` flavor of an inline selection inside one paragraph. */ +function copySelectionText(mode: MarkMode, markdown: string, from: number, to: number): string { + using fixture = setupPlainText(mode, markdown) + const { view } = fixture + const selection = TextSelection.create(view.state.doc, from, to) + view.dispatch(view.state.tr.setSelection(selection)) + return view.serializeForClipboard(view.state.selection.content()).text +} + +describe('plain text copy in show and focus mode', () => { + it('keeps the full inline source in show mode', () => { + expect(copyText('show', 'a *b* **c** `d`')).toMatchInlineSnapshot(`"a *b* **c** \`d\`"`) + }) + + it('keeps the full inline source in focus mode', () => { + expect(copyText('focus', 'a *b* **c** `d`')).toMatchInlineSnapshot(`"a *b* **c** \`d\`"`) + }) + + it('emits block markers for a heading and a list', () => { + expect(copyText('show', '### title\n\n- one\n - two')).toMatchInlineSnapshot(` + "### title + + - one + - two" + `) + }) + + it('emits blockquote and fence markers', () => { + expect(copyText('show', '> quote\n\n```js\ncode\n```')).toMatchInlineSnapshot(` + "> quote + + \`\`\`js + code + \`\`\`" + `) + }) + + it('keeps a partial paragraph selection as inline source', () => { + // 2..16 covers `lain **bold** e` inside `plain **bold** end` + expect(copySelectionText('show', 'plain **bold** end', 2, 16)).toMatchInlineSnapshot( + `"lain **bold**"`, + ) + }) +}) + +describe('plain text copy in hide mode', () => { + it('strips emphasis syntax but keeps block markers', () => { + expect(copyText('hide', '### a **b**\n\n- item *x*')).toMatchInlineSnapshot(` + "### a b + + - item x" + `) + }) + + it('keeps blockquote structure with stripped inline syntax', () => { + expect(copyText('hide', '> x **y**\n>\n> z')).toMatchInlineSnapshot(` + "> x y + > + > z" + `) + }) + + it('keeps table structure with stripped inline syntax', () => { + expect(copyText('hide', '| a | b |\n| - | - |\n| **1** | 2 |')).toMatchInlineSnapshot(` + "| a | b | + | --- | --- | + | 1 | 2 |" + `) + }) + + it('strips link syntax down to the label', () => { + expect(copyText('hide', 'see [docs](http://x.test)')).toMatchInlineSnapshot(`"see docs"`) + }) + + it('keeps a bare autolink', () => { + expect(copyText('hide', 'visit https://example.com now')).toMatchInlineSnapshot( + `"visit https://example.com now"`, + ) + }) + + it('keeps the whole image source', () => { + expect(copyText('hide', 'see ![cat](https://example.com/cat.png) end')).toMatchInlineSnapshot( + `"see ![cat](https://example.com/cat.png) end"`, + ) + }) + + it('keeps the whole math source', () => { + expect(copyText('hide', 'see $E=mc^2$ end')).toMatchInlineSnapshot(`"see $E=mc^2$ end"`) + }) + + it('replaces a wikilink with its target', () => { + expect(copyText('hide', 'see [[note]] end')).toMatchInlineSnapshot(`"see note end"`) + }) + + it('replaces a wikilink with its display alias', () => { + expect(copyText('hide', 'see [[note|alias]] end')).toMatchInlineSnapshot(`"see alias end"`) + }) + + it('keeps a tag verbatim', () => { + expect(copyText('hide', 'Hello #meow end')).toMatchInlineSnapshot(`"Hello #meow end"`) + }) + + it('strips syntax from a partial paragraph selection', () => { + expect(copySelectionText('hide', 'plain **bold** end', 2, 16)).toMatchInlineSnapshot( + `"lain bold"`, + ) + }) + + it('keeps code block content verbatim', () => { + expect(copyText('hide', '```js\nconst asterisks = "**"\n```')).toMatchInlineSnapshot(` + "\`\`\`js + const asterisks = "**" + \`\`\`" + `) + }) +}) + +describe('plain text copy block layout', () => { + it('separates paragraphs with a blank line', () => { + expect(copyText('show', 'aaa\n\nbbb')).toMatchInlineSnapshot(` + "aaa + + bbb" + `) + }) + + it('keeps gap paragraphs as extra blank lines', () => { + expect(copyText('show', 'aaa\n\n\n\nbbb')).toMatchInlineSnapshot(` + "aaa + + + + bbb" + `) + }) + + it('keeps a soft break inside a paragraph', () => { + expect(copyText('show', 'line1\nline2')).toMatchInlineSnapshot(` + "line1 + line2" + `) + }) +}) diff --git a/packages/core/src/extensions/clipboard/plain-text.ts b/packages/core/src/extensions/clipboard/plain-text.ts new file mode 100644 index 00000000..c90aae55 --- /dev/null +++ b/packages/core/src/extensions/clipboard/plain-text.ts @@ -0,0 +1,94 @@ +import { definePlugin, type PlainExtension } from '@prosekit/core' +import { Fragment, Slice } from '@prosekit/pm/model' +import type { ProseMirrorNode, Schema } from '@prosekit/pm/model' +import { Plugin, PluginKey } from '@prosekit/pm/state' + +import { docToMarkdown } from '../../converters/pm-to-md.ts' +import type { MdWikilinkAttrs } from '../inline-marks.ts' +import { getMarkMode } from '../mark-mode.ts' +import type { MarkName } from '../mark-names.ts' + +import { groupInlineRuns, hasSyntaxMark } from './semantic-inline.ts' + +/** + * Serialize a slice to Markdown. The copied fragment is wrapped in a `doc` so + * the block serializer can walk it; a purely inline fragment (a + * partial-paragraph copy) does not fit `doc`'s `block+` content, so it falls + * back to the inline source text, which is valid inline markdown already. + */ +export function sliceToMarkdown(schema: Schema, slice: Slice): string { + const fragment = slice.content + let doc: ProseMirrorNode | undefined + try { + doc = schema.topNodeType.createAndFill(undefined, fragment) ?? undefined + } catch { + doc = undefined + } + if (!doc) return fragment.textBetween(0, fragment.size, '\n', '\n') + return docToMarkdown(doc).replace(/\n+$/, '') +} + +/** + * The `text/plain` flavor is markdown: block markers always survive, and the + * mark mode decides the inline layer. In hide mode the inline syntax + * characters are stripped first, so the copied text matches what is visible; + * focus and show keep the full source. + */ +export function definePlainTextSerializer(): PlainExtension { + return definePlugin( + new Plugin({ + key: new PluginKey('meowdown-plain-text-copy'), + props: { + clipboardTextSerializer: (slice, view) => { + const hide = getMarkMode(view.state) === 'hide' + const cleaned = hide ? stripHiddenInline(slice) : slice + return sliceToMarkdown(view.state.schema, cleaned) + }, + }, + }), + ) +} + +/** Drop the inline text a hide-mode editor never shows. */ +export function stripHiddenInline(slice: Slice): Slice { + return new Slice(mapFragment(slice.content), slice.openStart, slice.openEnd) +} + +function mapFragment(fragment: Fragment): Fragment { + const nodes: ProseMirrorNode[] = [] + fragment.forEach((node) => { + nodes.push(node.isTextblock ? filterTextblock(node) : mapChildren(node)) + }) + return Fragment.from(nodes) +} + +function mapChildren(node: ProseMirrorNode): ProseMirrorNode { + return node.childCount > 0 ? node.copy(mapFragment(node.content)) : node +} + +/** + * Keep the visible inline text: drop syntax characters, replace a wikilink + * with its display text. An image or math unit renders as a non-text preview, + * so its source is kept whole; stripping it would paste a bare remainder. + */ +function filterTextblock(textblock: ProseMirrorNode): ProseMirrorNode { + const schema = textblock.type.schema + const parts: ProseMirrorNode[] = [] + for (const run of groupInlineRuns(textblock)) { + const atom = run.atom + if (atom != null) { + if (atom.type.name === ('mdWikilink' satisfies MarkName)) { + const attrs = atom.attrs as MdWikilinkAttrs + const visible = attrs.display || attrs.target + if (visible) parts.push(schema.text(visible)) + } else { + parts.push(...run.children) + } + continue + } + for (const child of run.children) { + if (!hasSyntaxMark(child.marks)) parts.push(child) + } + } + return textblock.copy(Fragment.from(parts)) +} diff --git a/packages/core/src/extensions/clipboard/semantic-inline.ts b/packages/core/src/extensions/clipboard/semantic-inline.ts index df31e363..f920a11d 100644 --- a/packages/core/src/extensions/clipboard/semantic-inline.ts +++ b/packages/core/src/extensions/clipboard/semantic-inline.ts @@ -33,7 +33,7 @@ const SEMANTIC_TAGS: Partial> = { mdLinkText: 'a', } -export function findAtomMark(marks: readonly Mark[]): Mark | undefined { +function findAtomMark(marks: readonly Mark[]): Mark | undefined { return marks.find((mark) => ATOM_MARK_NAMES.has(mark.type.name)) } diff --git a/packages/core/src/extensions/mark-mode.test.ts b/packages/core/src/extensions/mark-mode.test.ts index 47a8ea8c..85c52e74 100644 --- a/packages/core/src/extensions/mark-mode.test.ts +++ b/packages/core/src/extensions/mark-mode.test.ts @@ -19,17 +19,6 @@ function renderHTML(mode: MarkMode, text: string): string { return fixture.htmlSnapshot } -/** Mount one paragraph in `mode` and assert what a full-document copy yields. */ -function expectClipboard(mode: MarkMode, text: string, expected: string): void { - using fixture = setupFixture({ extensionOptions: { markMode: mode } }) - const { n } = fixture - fixture.set(n.doc(n.paragraph(text))) - const serialize = fixture.view.someProp('clipboardTextSerializer') - const { doc } = fixture - const actual = serialize ? serialize(doc.slice(0, doc.content.size), fixture.view) : null - expect(actual).toBe(expected) -} - describe('focus mode', () => { it("sets data-mark-mode attribute to 'focus'", async () => { using fixture = setupFixture() @@ -845,10 +834,6 @@ describe('focus mode', () => { `) }) - it('strips syntax from the copied text just like hide mode', () => { - expectClipboard('focus', 'Hello **bold** end', 'Hello bold end') - }) - it.skipIf( // TODO: this test fails in Firefox. // Firefox's native backspace splits the paragraph next to a zero-size @@ -930,38 +915,6 @@ describe('hide mode', () => { `) }) - it('strips ** from the copied text', () => { - expectClipboard('hide', 'Hello **bold** end', 'Hello bold end') - }) - - it('strips link [..](..) syntax from the copied text', () => { - expectClipboard('hide', 'see [docs](http://x.test)', 'see docs') - }) - - it('keeps a bare autolink in the copied text', () => { - expectClipboard('hide', 'visit https://example.com now', 'visit https://example.com now') - }) - - it('keeps the whole image source so a copied image stays markdown', () => { - expectClipboard( - 'hide', - 'see ![cat](https://example.com/cat.png) end', - 'see ![cat](https://example.com/cat.png) end', - ) - }) - - it('keeps the whole [[ ]] source in the copied text', () => { - expectClipboard('hide', 'see [[note]] end', 'see [[note]] end') - }) - - it('keeps the whole $math$ source so a copied formula stays markdown', () => { - expectClipboard('hide', 'see $E=mc^2$ end', 'see $E=mc^2$ end') - }) - - it('keeps #tag verbatim in the copied text', () => { - expectClipboard('hide', 'Hello #meow end', 'Hello #meow end') - }) - it('handles backspace correctly around bold', async () => { using fixture = setupFixture({ extensionOptions: { markMode: 'hide' } }) const { n } = fixture @@ -1011,10 +964,6 @@ describe('show mode', () => { " `) }) - - it('yields no clipboard text of its own, so copy falls through and keeps the ** syntax', () => { - expectClipboard('show', 'Hello **bold** end', '') - }) }) describe('mark mode lifecycle', () => { diff --git a/packages/core/src/extensions/mark-mode.ts b/packages/core/src/extensions/mark-mode.ts index cb343190..36d496a0 100644 --- a/packages/core/src/extensions/mark-mode.ts +++ b/packages/core/src/extensions/mark-mode.ts @@ -3,16 +3,14 @@ import type { Command, EditorState } from '@prosekit/pm/state' import { Plugin, PluginKey } from '@prosekit/pm/state' import { Decoration, DecorationSet } from '@prosekit/pm/view' -import { cleanTextFromSlice } from '../utils/clean-text.ts' - import type { MarkName } from './mark-names.ts' /** - * Controls how markdown syntax characters are rendered and how the - * editor serializes content to the clipboard. + * Controls how markdown syntax characters are rendered and how the clipboard's + * `text/plain` treats the inline layer (see `definePlainTextSerializer`). * - * - 'hide': syntax chars never visible; copy strips them. - * - 'focus': syntax chars hidden by default; revealed near cursor; copy strips them. + * - 'hide': syntax chars never visible; copy strips the inline syntax. + * - 'focus': syntax chars hidden by default; revealed near cursor; copy keeps them. * - 'show': syntax chars always visible (dim grey); copy keeps them. */ export type MarkMode = 'hide' | 'focus' | 'show' @@ -42,14 +40,6 @@ function createMarkModePlugin(initialMode: MarkMode): Plugin { if (mode === 'hide') return computeMathRevealDecorations(state) return }, - // In show mode the empty string is falsy, so `someProp` falls through to - // the next serializer (`defineMarkdownCopy` in the full editor) and the - // copied text keeps the syntax. - clipboardTextSerializer: (slice, view) => { - return getCurrentMarkMode(view.state) === 'show' - ? '' - : cleanTextFromSlice(slice, { preserveMathSource: true }) - }, }, }) } diff --git a/packages/core/src/extensions/markdown-copy.test.ts b/packages/core/src/extensions/markdown-copy.test.ts deleted file mode 100644 index 7a47b856..00000000 --- a/packages/core/src/extensions/markdown-copy.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { setupFixture } from '../testing/index.ts' - -import { defineMarkdownCopy } from './markdown-copy.ts' - -describe('copy as markdown', () => { - it('serializes a copied bullet list to markdown text', () => { - using fixture = setupFixture() - const { n, view } = fixture - fixture.editor.use(defineMarkdownCopy()) - fixture.set( - n.doc( - n.list({ kind: 'bullet' }, n.paragraph('one')), - n.list({ kind: 'bullet' }, n.paragraph('two')), - ), - ) - - const slice = view.state.doc.slice(0, view.state.doc.content.size) - const text = view.someProp('clipboardTextSerializer', (serialize) => serialize(slice, view)) - expect(text).toBe('- one\n- two') - }) - - it('falls back to inline text for a partial-paragraph copy', () => { - using fixture = setupFixture() - const { n, view } = fixture - fixture.editor.use(defineMarkdownCopy()) - fixture.set(n.doc(n.paragraph('hello world'))) - - const slice = view.state.doc.slice(1, 6) - const text = view.someProp('clipboardTextSerializer', (serialize) => serialize(slice, view)) - expect(text).toBe('hello') - }) -}) diff --git a/packages/core/src/extensions/markdown-copy.ts b/packages/core/src/extensions/markdown-copy.ts deleted file mode 100644 index 71ddb63d..00000000 --- a/packages/core/src/extensions/markdown-copy.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { definePlugin, type PlainExtension } from '@prosekit/core' -import type { ProseMirrorNode } from '@prosekit/pm/model' -import { Plugin, PluginKey } from '@prosekit/pm/state' - -import { docToMarkdown } from '../converters/pm-to-md.ts' - -const markdownCopyKey = new PluginKey('meowdown-markdown-copy') - -/** - * Serialize copied/cut content to Markdown for the clipboard's `text/plain`, so - * pasting meowdown content into a plain-text field yields real Markdown (`- ` - * list markers, blank-line block separation) instead of bare `textContent`. - * - * The copied fragment is wrapped in a `doc` so the existing block serializer can - * walk it. A purely inline fragment (a partial-paragraph copy) does not fit - * `doc`'s `block+` content, so it falls back to the inline text. - */ -export function defineMarkdownCopy(): PlainExtension { - return definePlugin( - new Plugin({ - key: markdownCopyKey, - props: { - clipboardTextSerializer: (slice, view) => { - const fragment = slice.content - let doc: ProseMirrorNode | undefined - try { - doc = view.state.schema.topNodeType.createAndFill(undefined, fragment) ?? undefined - } catch { - doc = undefined - } - if (!doc) return fragment.textBetween(0, fragment.size, '\n', '\n') - return docToMarkdown(doc).replace(/\n+$/, '') - }, - }, - }), - ) -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index eaff2db8..319f0a2f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -100,7 +100,6 @@ export { defineLinkPaste } from './extensions/link-paste.ts' export type { MarkChunk } from './extensions/mark-chunk.ts' export type { MarkMode } from './extensions/mark-mode.ts' export type { MarkName } from './extensions/mark-names.ts' -export { defineMarkdownCopy } from './extensions/markdown-copy.ts' export type { NodeName } from './extensions/node-names.ts' export { definePendingReplacementHandler, diff --git a/packages/react/src/components/editor-extensions.tsx b/packages/react/src/components/editor-extensions.tsx index adee11f7..beddbbe2 100644 --- a/packages/react/src/components/editor-extensions.tsx +++ b/packages/react/src/components/editor-extensions.tsx @@ -11,7 +11,6 @@ import { defineImageClickHandler, defineLinkClickHandler, defineLinkPaste, - defineMarkdownCopy, definePlaceholder, defineReadonly, defineSpellCheckPlugin, @@ -183,12 +182,6 @@ export function EditorExtensions({ }, []), ) - useExtension( - useMemo(() => { - return defineMarkdownCopy() - }, []), - ) - useExtension( useMemo(() => { return bulletAfterHeading ? defineBulletAfterHeading() : null From bf2c0f82b87ec0db338a2e03f4547f5049a33442 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 13 Jul 2026 00:47:58 +1000 Subject: [PATCH 04/16] feat(core): blank-line aware plain paste and meowdown HTML signature --- .../clipboard/clipboard-serializer.ts | 42 ++++- .../src/extensions/clipboard/clipboard.ts | 6 +- .../extensions/clipboard/plain-paste.test.ts | 162 ++++++++++++++++++ .../src/extensions/clipboard/plain-paste.ts | 73 ++++++++ .../core/src/extensions/html-paste.test.ts | 30 +++- packages/core/src/extensions/html-paste.ts | 21 ++- 6 files changed, 317 insertions(+), 17 deletions(-) create mode 100644 packages/core/src/extensions/clipboard/plain-paste.test.ts create mode 100644 packages/core/src/extensions/clipboard/plain-paste.ts diff --git a/packages/core/src/extensions/clipboard/clipboard-serializer.ts b/packages/core/src/extensions/clipboard/clipboard-serializer.ts index 4c63782c..516a6645 100644 --- a/packages/core/src/extensions/clipboard/clipboard-serializer.ts +++ b/packages/core/src/extensions/clipboard/clipboard-serializer.ts @@ -1,8 +1,20 @@ import { defineClipboardSerializer, type PlainExtension } from '@prosekit/core' +import { DOMSerializer } from '@prosekit/pm/model' +import type { DOMOutputSpec, ProseMirrorNode, Schema } from '@prosekit/pm/model' import { headingClipboardDOM } from '../heading.ts' -import { paragraphClipboardDOM } from '../paragraph.ts' import type { NodeName } from '../node-names.ts' +import { paragraphClipboardDOM } from '../paragraph.ts' + +type NodeSerializers = Record DOMOutputSpec> + +function withSemanticTextblocks(nodes: NodeSerializers): NodeSerializers { + return { + ...nodes, + ['paragraph' satisfies NodeName]: (node) => ({ dom: paragraphClipboardDOM(node) }), + ['heading' satisfies NodeName]: (node) => ({ dom: headingClipboardDOM(node) }), + } +} /** * Serialize copied textblocks as semantic HTML (``, ``, real @@ -23,14 +35,26 @@ export function defineSemanticClipboardSerializer(): PlainExtension { } }, nodesFromSchemaWrapper: (nodesFromSchema) => { - return (...args) => { - const nodes = nodesFromSchema(...args) - return { - ...nodes, - ['paragraph' satisfies NodeName]: (node) => ({ dom: paragraphClipboardDOM(node) }), - ['heading' satisfies NodeName]: (node) => ({ dom: headingClipboardDOM(node) }), - } - } + return (...args) => withSemanticTextblocks(nodesFromSchema(...args)) }, }) } + +const semanticSerializerCache = new WeakMap() + +/** + * The semantic serializer as a plain `DOMSerializer`, for callers outside the + * clipboard facet (`defineHTMLPaste` re-serializes converted foreign HTML with + * it, so the intermediate HTML also carries `data-md`). + */ +export function getSemanticDOMSerializer(schema: Schema): DOMSerializer { + let serializer = semanticSerializerCache.get(schema) + if (serializer == null) { + serializer = new DOMSerializer( + withSemanticTextblocks(DOMSerializer.nodesFromSchema(schema)), + DOMSerializer.marksFromSchema(schema), + ) + semanticSerializerCache.set(schema, serializer) + } + return serializer +} diff --git a/packages/core/src/extensions/clipboard/clipboard.ts b/packages/core/src/extensions/clipboard/clipboard.ts index 9a021075..9aa735da 100644 --- a/packages/core/src/extensions/clipboard/clipboard.ts +++ b/packages/core/src/extensions/clipboard/clipboard.ts @@ -2,17 +2,19 @@ import { union, type PlainExtension } from '@prosekit/core' import { defineClipboardParser } from './clipboard-parser.ts' import { defineSemanticClipboardSerializer } from './clipboard-serializer.ts' +import { definePlainTextPaste } from './plain-paste.ts' import { definePlainTextSerializer } from './plain-text.ts' /** * The clipboard pipeline: semantic HTML with `data-md` round-trip attributes - * plus a markdown-shaped `text/plain` on copy, and the matching `data-md` - * parser on paste. + * plus a markdown-shaped `text/plain` on copy; the matching `data-md` parser + * and the blank-line-aware plain text parser on paste. */ export function defineClipboard(): PlainExtension { return union( defineSemanticClipboardSerializer(), definePlainTextSerializer(), defineClipboardParser(), + definePlainTextPaste(), ) } diff --git a/packages/core/src/extensions/clipboard/plain-paste.test.ts b/packages/core/src/extensions/clipboard/plain-paste.test.ts new file mode 100644 index 00000000..4287262d --- /dev/null +++ b/packages/core/src/extensions/clipboard/plain-paste.test.ts @@ -0,0 +1,162 @@ +import { pasteText } from '@prosekit/core/test' +import type { EditorView } from '@prosekit/pm/view' +import { describe, expect, it } from 'vitest' + +import { docToMarkdown } from '../../converters/pm-to-md.ts' +import { setupFixture } from '../../testing/index.ts' + +/** + * Dispatch a real paste event carrying only `text/plain`. Unlike the + * `pasteText` helper (which forces ProseMirror's plain-text flag, i.e. a + * Shift-paste), this exercises the regular paste path. Firefox discards the + * DataTransfer passed to the ClipboardEvent constructor, so when the text did + * not survive, shadow the getter with the real object. + */ +function firePlainPaste(view: EditorView, text: string): void { + const clipboardData = new DataTransfer() + clipboardData.setData('text/plain', text) + const event = new ClipboardEvent('paste', { clipboardData, cancelable: true }) + if (event.clipboardData?.getData('text/plain') !== text) { + Object.defineProperty(event, 'clipboardData', { value: clipboardData }) + } + view.dom.dispatchEvent(event) +} + +function pastePlainText(text: string): string { + using fixture = setupFixture() + const { n, view } = fixture + fixture.set(n.doc(n.paragraph())) + firePlainPaste(view, text) + return docToMarkdown(view.state.doc) +} + +describe('plain text paste', () => { + it('inserts a single line inline', () => { + using fixture = setupFixture() + const { n, view } = fixture + fixture.set(n.doc(n.paragraph('before after'))) + firePlainPaste(view, 'pasted') + expect(docToMarkdown(view.state.doc)).toMatchInlineSnapshot(` + "before pastedafter + " + `) + }) + + it('keeps a single newline as a soft break', () => { + expect(pastePlainText('aaa\nbbb')).toMatchInlineSnapshot(` + "aaa + bbb + " + `) + }) + + it('does not insert an empty paragraph for one blank line', () => { + expect(pastePlainText('aaa\n\nbbb')).toMatchInlineSnapshot(` + "aaa + + bbb + " + `) + }) + + it('restores one gap paragraph for two blank lines', () => { + expect(pastePlainText('aaa\n\n\nbbb')).toMatchInlineSnapshot(` + "aaa + + + bbb + " + `) + }) + + it('trims leading and trailing newlines', () => { + expect(pastePlainText('\n\naaa\n\n')).toMatchInlineSnapshot(` + "aaa + " + `) + }) + + it('inserts nothing for whitespace-only newlines', () => { + expect(pastePlainText('\n\n\n')).toMatchInlineSnapshot(` + " + " + `) + }) + + it('normalizes CRLF', () => { + expect(pastePlainText('aaa\r\n\r\nbbb\r\nccc')).toMatchInlineSnapshot(` + "aaa + + bbb + ccc + " + `) + }) + + it('keeps tabs and spaces', () => { + expect(pastePlainText('a\t b')).toMatchInlineSnapshot(` + "a b + " + `) + }) + + it('renders pasted inline markdown source immediately', () => { + using fixture = setupFixture() + const { n, view } = fixture + fixture.set(n.doc(n.paragraph())) + firePlainPaste(view, 'a **b** c') + expect(docToMarkdown(view.state.doc)).toMatchInlineSnapshot(` + "a **b** c + " + `) + expect(fixture.htmlSnapshot).toMatchInlineSnapshot(` + " +

+ a + + + + ** + + b + + ** + + + + c +

+ " + `) + }) + + it('keeps block markdown syntax as literal text', () => { + expect(pastePlainText('# title\ntext')).toMatchInlineSnapshot(` + "# title + text + " + `) + }) +}) + +describe('plain text paste with shift', () => { + // `pasteText` sets ProseMirror's plain-text flag, the same path a + // Shift-paste takes: every newline run becomes a paragraph break. + it('splits every newline run into paragraphs', () => { + using fixture = setupFixture() + const { n, view } = fixture + fixture.set(n.doc(n.paragraph())) + pasteText(view, 'aaa\nbbb\n\nccc') + expect(docToMarkdown(view.state.doc)).toMatchInlineSnapshot(` + "aaa + + bbb + + ccc + " + `) + }) +}) diff --git a/packages/core/src/extensions/clipboard/plain-paste.ts b/packages/core/src/extensions/clipboard/plain-paste.ts new file mode 100644 index 00000000..66fbf4e1 --- /dev/null +++ b/packages/core/src/extensions/clipboard/plain-paste.ts @@ -0,0 +1,73 @@ +import { definePlugin, getNodeType, type PlainExtension } from '@prosekit/core' +import { DOMParser, DOMSerializer, Fragment, Slice } from '@prosekit/pm/model' +import type { ProseMirrorNode, ResolvedPos, Schema } from '@prosekit/pm/model' +import { Plugin, PluginKey } from '@prosekit/pm/state' + +import type { NodeName } from '../node-names.ts' + +/** + * Turn pasted plain text into blocks with markdown newline semantics: a blank + * line separates paragraphs (`aaa\n\nbbb` inserts no empty paragraph), a + * single `\n` stays a soft break inside one paragraph, and a run of K blank + * lines restores K-1 empty paragraphs (the gap-paragraph model of + * `md-to-pm.ts`). Leading and trailing newlines are trimmed. + */ +export function plainTextToSlice(schema: Schema, raw: string): Slice { + const text = raw.replaceAll(/\r\n?/g, '\n') + const trimmed = text.replace(/^\n+/, '').replace(/\n+$/, '') + if (!trimmed) return Slice.empty + + const paragraph = getNodeType(schema, 'paragraph' satisfies NodeName) + const blocks: ProseMirrorNode[] = [] + // Splitting with a captured separator keeps the blank-line runs: even + // indexes are block texts, odd indexes are the `\n{2,}` runs between them. + const parts = trimmed.split(/(\n{2,})/) + for (let index = 0; index < parts.length; index++) { + const part = parts[index] + if (index % 2 === 0) { + blocks.push(paragraph.create(null, part ? schema.text(part) : undefined)) + } else { + const blankLines = part.length - 1 + for (let gap = 1; gap < blankLines; gap++) { + blocks.push(paragraph.create()) + } + } + } + return Slice.maxOpen(Fragment.from(blocks)) +} + +/** + * ProseMirror's own plain-text handling, kept for Shift-paste: every newline + * run becomes a paragraph break. Mirrors `parseFromClipboard` in + * prosemirror-view, which this prop replaces. + */ +function defaultTextSlice(schema: Schema, text: string, $context: ResolvedPos): Slice { + const marks = $context.marks() + const serializer = DOMSerializer.fromSchema(schema) + const container = document.createElement('div') + for (const block of text.split(/(?:\r\n?|\n)+/)) { + const paragraphDOM = container.appendChild(document.createElement('p')) + if (block) { + paragraphDOM.appendChild(serializer.serializeNode(schema.text(block, marks))) + } + } + return DOMParser.fromSchema(schema).parseSlice(container, { + preserveWhitespace: true, + context: $context, + }) +} + +export function definePlainTextPaste(): PlainExtension { + return definePlugin( + new Plugin({ + key: new PluginKey('meowdown-plain-paste'), + props: { + clipboardTextParser: (text, $context, plain, view) => { + const { schema } = view.state + if (plain) return defaultTextSlice(schema, text, $context) + return plainTextToSlice(schema, text) + }, + }, + }), + ) +} diff --git a/packages/core/src/extensions/html-paste.test.ts b/packages/core/src/extensions/html-paste.test.ts index 30e0aa33..ea72a19e 100644 --- a/packages/core/src/extensions/html-paste.test.ts +++ b/packages/core/src/extensions/html-paste.test.ts @@ -62,8 +62,34 @@ describe('paste rich-text HTML', () => { const { editor, n, view } = fixture useHTMLPaste(fixture) fixture.set(n.doc(n.paragraph('
'))) - // `data-pm-slice` marks PM-native HTML; the literal `**` is already in the text. - pasteHTML(view, '

x **b** y

') + pasteHTML( + view, + '

x b y

', + ) + expect(docToMarkdown(editor.state.doc).trim()).toBe('x **b** y') + }) + + it('leaves clipboard HTML from an older meowdown to the default path', () => { + using fixture = setupFixture() + const { editor, n, view } = fixture + useHTMLPaste(fixture) + fixture.set(n.doc(n.paragraph('
'))) + // Pre-`data-meowdown` clipboard HTML: the editor DOM with `md-mark` spans. + pasteHTML( + view, + '

**b**

', + ) + expect(docToMarkdown(editor.state.doc).trim()).toBe('**b**') + }) + + it('converts HTML from a foreign ProseMirror editor', () => { + using fixture = setupFixture() + const { editor, n, view } = fixture + useHTMLPaste(fixture) + fixture.set(n.doc(n.paragraph('
'))) + // A foreign PM editor writes `data-pm-slice` without any meowdown signature; + // its semantic tags must convert to markdown instead of losing the format. + pasteHTML(view, '

x b y

') expect(docToMarkdown(editor.state.doc).trim()).toBe('x **b** y') }) }) diff --git a/packages/core/src/extensions/html-paste.ts b/packages/core/src/extensions/html-paste.ts index b6d8f8e0..407a483e 100644 --- a/packages/core/src/extensions/html-paste.ts +++ b/packages/core/src/extensions/html-paste.ts @@ -1,21 +1,34 @@ import { definePlugin, type PlainExtension } from '@prosekit/core' -import { DOMSerializer } from '@prosekit/pm/model' import { Plugin, PluginKey } from '@prosekit/pm/state' import { htmlToMarkdown } from '../converters/html-to-md.ts' import { markdownToDoc } from '../converters/md-to-pm.ts' +import { getSemanticDOMSerializer } from './clipboard/clipboard-serializer.ts' import { getNodeBuildersForSchema } from './schema.ts' const htmlPasteKey = new PluginKey('meowdown-html-paste') +/** + * meowdown's own clipboard HTML, which must skip the markdown conversion and + * go to the native `data-md` parse path. Foreign ProseMirror editors also + * write `data-pm-slice`, so the check needs a meowdown-specific signature: + * the `data-meowdown` stamp, or (for HTML copied from an older meowdown) the + * editor DOM's `md-mark` spans next to `data-pm-slice`. + */ +function isMeowdownClipboardHTML(html: string): boolean { + if (html.includes('data-meowdown')) return true + return html.includes('data-pm-slice') && html.includes('md-mark') +} + /** * Paste foreign rich-text HTML as meowdown Markdown. Rewrites the clipboard's * `text/html` through `transformPastedHTML`: foreign HTML is converted to a * Markdown string, reparsed into meowdown nodes (literal source text, no marks), * and re-serialized to HTML so ProseMirror's own clipboard parser inserts it with * the right open depths. `bold` thus lands as the text `**bold**`, - * which the inline-mark plugin renders. + * which the inline-mark plugin renders. The re-serialized HTML carries `data-md`, + * so the textblock contents survive the whitespace-collapsing HTML parse. */ export function defineHTMLPaste(): PlainExtension { return definePlugin( @@ -23,7 +36,7 @@ export function defineHTMLPaste(): PlainExtension { key: htmlPasteKey, props: { transformPastedHTML: (html, view) => { - if (html.includes('data-pm-slice')) return html + if (isMeowdownClipboardHTML(html)) return html const parent = view.state.selection.$from.parent if (!parent.inlineContent || parent.type.spec.code) return html @@ -33,7 +46,7 @@ export function defineHTMLPaste(): PlainExtension { const nodes = getNodeBuildersForSchema(view.state.schema) const doc = markdownToDoc(markdown, { nodes }) - const serializer = DOMSerializer.fromSchema(view.state.schema) + const serializer = getSemanticDOMSerializer(view.state.schema) const container = document.createElement('div') container.append(serializer.serializeFragment(doc.content)) return container.innerHTML From 4aed8b270d0c7b5dd4c257390195e7fb104608af Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 13 Jul 2026 00:51:17 +1000 Subject: [PATCH 05/16] refactor: fold the clipboard pipeline into `defineEditorExtension` --- packages/core/README.md | 2 +- .../src/extensions/clipboard/clipboard-parser.ts | 2 +- .../core/src/extensions/clipboard/clipboard.ts | 8 ++++++-- .../core/src/extensions/clipboard/plain-paste.ts | 2 +- .../core/src/extensions/clipboard/plain-text.ts | 4 ++-- .../src/extensions/clipboard/semantic-inline.ts | 4 ++-- packages/core/src/extensions/html-paste.test.ts | 16 +--------------- packages/core/src/index.ts | 1 - .../react/src/components/editor-extensions.tsx | 7 ------- 9 files changed, 14 insertions(+), 32 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index 70b38d4b..69df0a51 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -130,7 +130,7 @@ GFM table column alignment (`| :-: |` in the delimiter row) is kept as an `align Pasting a lone tweet or YouTube link can auto-embed it: [`defineEmbedPaste`](https://npmx.dev/package-docs/@meowdown%2Fcore#function-defineEmbedPaste) (`@meowdown/react`'s `embedPaste` prop, on by default). -Pasting rich-text HTML from a browser (a bullet list, **bold**, a link, ...) converts it to Markdown so the formatting survives instead of arriving as plain text: [`defineHTMLPaste`](https://npmx.dev/package-docs/@meowdown%2Fcore#function-defineHTMLPaste) rewrites the clipboard's `text/html` through the unified (rehype / remark) pipeline; meowdown's own clipboard (tagged `data-pm-slice`) and any paste landing in a code block are left to the default path. Going the other way, [`defineMarkdownCopy`](https://npmx.dev/package-docs/@meowdown%2Fcore#function-defineMarkdownCopy) serializes copied content to Markdown for the clipboard's `text/plain` flavor. Neither is part of `defineEditorExtension`; `@meowdown/react` applies both by default, a headless host adds them explicitly. +The clipboard pipeline ships inside `defineEditorExtension`. Copying writes two flavors: `text/html` is standard semantic HTML (`

`, `
  1. `, ``, real ``) with the Markdown source preserved in `data-md` attributes so meowdown-to-meowdown pastes stay byte-exact, and `text/plain` is Markdown, where block markers always survive and the mark mode decides the inline layer (hide strips the syntax characters, focus and show keep the full source). Pasting picks a path by flavor: meowdown's own HTML (stamped `data-meowdown`) parses natively; foreign rich-text HTML, including other ProseMirror editors, converts to Markdown through the unified (rehype / remark) pipeline; plain text follows Markdown newline semantics (a blank line separates paragraphs without inserting an empty one, a single newline stays a soft break) while a Shift-paste keeps ProseMirror's line-per-paragraph behavior; a paste landing in a code block stays plain text. Enter at the end of the document's first heading (the title line) can start a fresh empty bullet instead of a plain paragraph: [`defineBulletAfterHeading`](https://npmx.dev/package-docs/@meowdown%2Fcore#function-defineBulletAfterHeading) (`@meowdown/react`'s `bulletAfterHeading` prop). Not part of `defineEditorExtension`. diff --git a/packages/core/src/extensions/clipboard/clipboard-parser.ts b/packages/core/src/extensions/clipboard/clipboard-parser.ts index 960c030d..11e4d116 100644 --- a/packages/core/src/extensions/clipboard/clipboard-parser.ts +++ b/packages/core/src/extensions/clipboard/clipboard-parser.ts @@ -10,7 +10,7 @@ import { paragraphFromDOM } from '../paragraph.ts' * textblock's source text from meowdown's own clipboard HTML. Registered as * `clipboardParser` (not in the schema) so static HTML parsing is unaffected. */ -export function createClipboardParser(schema: Schema): DOMParser { +function createClipboardParser(schema: Schema): DOMParser { return new DOMParser(schema, [ ...paragraphFromDOM(), ...headingFromDOM(), diff --git a/packages/core/src/extensions/clipboard/clipboard.ts b/packages/core/src/extensions/clipboard/clipboard.ts index 9aa735da..22c9c176 100644 --- a/packages/core/src/extensions/clipboard/clipboard.ts +++ b/packages/core/src/extensions/clipboard/clipboard.ts @@ -1,5 +1,7 @@ import { union, type PlainExtension } from '@prosekit/core' +import { defineHTMLPaste } from '../html-paste.ts' + import { defineClipboardParser } from './clipboard-parser.ts' import { defineSemanticClipboardSerializer } from './clipboard-serializer.ts' import { definePlainTextPaste } from './plain-paste.ts' @@ -7,14 +9,16 @@ import { definePlainTextSerializer } from './plain-text.ts' /** * The clipboard pipeline: semantic HTML with `data-md` round-trip attributes - * plus a markdown-shaped `text/plain` on copy; the matching `data-md` parser - * and the blank-line-aware plain text parser on paste. + * plus a markdown-shaped `text/plain` on copy; the matching `data-md` parser, + * the foreign-HTML markdown conversion, and the blank-line-aware plain text + * parser on paste. */ export function defineClipboard(): PlainExtension { return union( defineSemanticClipboardSerializer(), definePlainTextSerializer(), defineClipboardParser(), + defineHTMLPaste(), definePlainTextPaste(), ) } diff --git a/packages/core/src/extensions/clipboard/plain-paste.ts b/packages/core/src/extensions/clipboard/plain-paste.ts index 66fbf4e1..66dcdb29 100644 --- a/packages/core/src/extensions/clipboard/plain-paste.ts +++ b/packages/core/src/extensions/clipboard/plain-paste.ts @@ -12,7 +12,7 @@ import type { NodeName } from '../node-names.ts' * lines restores K-1 empty paragraphs (the gap-paragraph model of * `md-to-pm.ts`). Leading and trailing newlines are trimmed. */ -export function plainTextToSlice(schema: Schema, raw: string): Slice { +function plainTextToSlice(schema: Schema, raw: string): Slice { const text = raw.replaceAll(/\r\n?/g, '\n') const trimmed = text.replace(/^\n+/, '').replace(/\n+$/, '') if (!trimmed) return Slice.empty diff --git a/packages/core/src/extensions/clipboard/plain-text.ts b/packages/core/src/extensions/clipboard/plain-text.ts index c90aae55..f819171c 100644 --- a/packages/core/src/extensions/clipboard/plain-text.ts +++ b/packages/core/src/extensions/clipboard/plain-text.ts @@ -16,7 +16,7 @@ import { groupInlineRuns, hasSyntaxMark } from './semantic-inline.ts' * partial-paragraph copy) does not fit `doc`'s `block+` content, so it falls * back to the inline source text, which is valid inline markdown already. */ -export function sliceToMarkdown(schema: Schema, slice: Slice): string { +function sliceToMarkdown(schema: Schema, slice: Slice): string { const fragment = slice.content let doc: ProseMirrorNode | undefined try { @@ -50,7 +50,7 @@ export function definePlainTextSerializer(): PlainExtension { } /** Drop the inline text a hide-mode editor never shows. */ -export function stripHiddenInline(slice: Slice): Slice { +function stripHiddenInline(slice: Slice): Slice { return new Slice(mapFragment(slice.content), slice.openStart, slice.openEnd) } diff --git a/packages/core/src/extensions/clipboard/semantic-inline.ts b/packages/core/src/extensions/clipboard/semantic-inline.ts index f920a11d..50f4bd26 100644 --- a/packages/core/src/extensions/clipboard/semantic-inline.ts +++ b/packages/core/src/extensions/clipboard/semantic-inline.ts @@ -152,10 +152,10 @@ function syncOpenWrappers(open: OpenWrapper[], next: readonly Mark[], out: HTMLE /** A soft break (a literal `\n` in the source) renders as `
    `. */ function appendTextWithBreaks(parent: HTMLElement, text: string): void { const lines = text.split('\n') - lines.forEach((line, index) => { + for (const [index, line] of lines.entries()) { if (index > 0) parent.append(document.createElement('br')) if (line) parent.append(document.createTextNode(line)) - }) + } } function atomUnitDOM(atom: Mark, sourceText: string): globalThis.Node { diff --git a/packages/core/src/extensions/html-paste.test.ts b/packages/core/src/extensions/html-paste.test.ts index ea72a19e..c5b5581d 100644 --- a/packages/core/src/extensions/html-paste.test.ts +++ b/packages/core/src/extensions/html-paste.test.ts @@ -2,19 +2,12 @@ import { pasteHTML } from '@prosekit/core/test' import { describe, expect, it } from 'vitest' import { docToMarkdown } from '../converters/pm-to-md.ts' -import { setupFixture, type Fixture } from '../testing/index.ts' - -import { defineHTMLPaste } from './html-paste.ts' - -function useHTMLPaste(fixture: Fixture): void { - fixture.editor.use(defineHTMLPaste()) -} +import { setupFixture } from '../testing/index.ts' describe('paste rich-text HTML', () => { it('keeps bold and italic', () => { using fixture = setupFixture() const { editor, n, view } = fixture - useHTMLPaste(fixture) fixture.set(n.doc(n.paragraph(''))) pasteHTML(view, '

    hi bold and italic

    ') expect(docToMarkdown(editor.state.doc).trim()).toBe('hi **bold** and *italic*') @@ -23,7 +16,6 @@ describe('paste rich-text HTML', () => { it('keeps a bullet list with formatted items', () => { using fixture = setupFixture() const { editor, n, view } = fixture - useHTMLPaste(fixture) fixture.set(n.doc(n.paragraph('
    '))) pasteHTML(view, '
    • one
    • two
    ') expect(docToMarkdown(editor.state.doc).trim()).toBe('- one\n- **two**') @@ -32,7 +24,6 @@ describe('paste rich-text HTML', () => { it('keeps a link', () => { using fixture = setupFixture() const { editor, n, view } = fixture - useHTMLPaste(fixture) fixture.set(n.doc(n.paragraph('
    '))) pasteHTML(view, '

    see link

    ') expect(docToMarkdown(editor.state.doc).trim()).toBe('see [link](https://x.com)') @@ -41,7 +32,6 @@ describe('paste rich-text HTML', () => { it('replaces the selection when pasting onto it', () => { using fixture = setupFixture() const { editor, n, view } = fixture - useHTMLPaste(fixture) fixture.set(n.doc(n.paragraph('drop me'))) pasteHTML(view, '

    kept

    ') expect(docToMarkdown(editor.state.doc).trim()).toBe('**kept**') @@ -50,7 +40,6 @@ describe('paste rich-text HTML', () => { it('pastes plain text inside a code block without converting', () => { using fixture = setupFixture() const { editor, n, view } = fixture - useHTMLPaste(fixture) fixture.set(n.doc(n.codeBlock('
    '))) pasteHTML(view, '

    bold

    ') // The handler bails; the default path inserts the HTML's text, no `**`. @@ -60,7 +49,6 @@ describe('paste rich-text HTML', () => { it('leaves meowdown-native clipboard HTML to the default path', () => { using fixture = setupFixture() const { editor, n, view } = fixture - useHTMLPaste(fixture) fixture.set(n.doc(n.paragraph('
    '))) pasteHTML( view, @@ -72,7 +60,6 @@ describe('paste rich-text HTML', () => { it('leaves clipboard HTML from an older meowdown to the default path', () => { using fixture = setupFixture() const { editor, n, view } = fixture - useHTMLPaste(fixture) fixture.set(n.doc(n.paragraph(''))) // Pre-`data-meowdown` clipboard HTML: the editor DOM with `md-mark` spans. pasteHTML( @@ -85,7 +72,6 @@ describe('paste rich-text HTML', () => { it('converts HTML from a foreign ProseMirror editor', () => { using fixture = setupFixture() const { editor, n, view } = fixture - useHTMLPaste(fixture) fixture.set(n.doc(n.paragraph(''))) // A foreign PM editor writes `data-pm-slice` without any meowdown signature; // its semantic tags must convert to markdown instead of losing the format. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 319f0a2f..11e9875f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -55,7 +55,6 @@ export { export { defineFollowLinkHandler, type FollowLinkHandlers } from './extensions/follow-link.ts' export { getLinkUnitAt, type LinkUnit } from './extensions/get-link-unit-at.ts' export { defineHTMLComment, type MeowdownHTMLCommentAttrs } from './extensions/html-comment.ts' -export { defineHTMLPaste } from './extensions/html-paste.ts' export { defineImageClickHandler, type ImageClickHandler, diff --git a/packages/react/src/components/editor-extensions.tsx b/packages/react/src/components/editor-extensions.tsx index beddbbe2..0fd6841c 100644 --- a/packages/react/src/components/editor-extensions.tsx +++ b/packages/react/src/components/editor-extensions.tsx @@ -6,7 +6,6 @@ import { defineFilePaste, defineFileView, defineFollowLinkHandler, - defineHTMLPaste, defineImage, defineImageClickHandler, defineLinkClickHandler, @@ -176,12 +175,6 @@ export function EditorExtensions({ }, [linkPaste]), ) - useExtension( - useMemo(() => { - return defineHTMLPaste() - }, []), - ) - useExtension( useMemo(() => { return bulletAfterHeading ? defineBulletAfterHeading() : null From 387c24fc0ae7a99979fa50877e7403fbdf3634da Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 13 Jul 2026 01:27:53 +1000 Subject: [PATCH 06/16] review --- packages/core/src/extensions/clipboard/semantic-inline.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/extensions/clipboard/semantic-inline.ts b/packages/core/src/extensions/clipboard/semantic-inline.ts index 50f4bd26..08a2b6d5 100644 --- a/packages/core/src/extensions/clipboard/semantic-inline.ts +++ b/packages/core/src/extensions/clipboard/semantic-inline.ts @@ -90,7 +90,7 @@ export function semanticTextblockDOM( * The clipboard parse rule for a textblock: the content comes from the * `data-md` source text verbatim, the semantic child elements are ignored. * The inline mark plugin re-derives marks after the paste transaction. - */ + */ // REVIEW: rename this function from sourceTextRule to createSourceTextRule export function sourceTextRule( tag: string, node: string, From 91073c9191c4ef54ba777fd65c7ba406a046269f Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 13 Jul 2026 01:29:10 +1000 Subject: [PATCH 07/16] review --- packages/core/src/extensions/clipboard/semantic-inline.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/extensions/clipboard/semantic-inline.ts b/packages/core/src/extensions/clipboard/semantic-inline.ts index 08a2b6d5..5e572b40 100644 --- a/packages/core/src/extensions/clipboard/semantic-inline.ts +++ b/packages/core/src/extensions/clipboard/semantic-inline.ts @@ -8,6 +8,7 @@ import type { MdWikilinkAttrs, } from '../inline-marks.ts' import type { MarkName } from '../mark-names.ts' +import { isHTMLElement } from '@ocavue/utils' /** Syntax characters, dropped from the semantic clipboard HTML. */ const SYNTAX_MARK_NAMES: ReadonlySet = new Set([ @@ -102,7 +103,9 @@ export function sourceTextRule( priority: 100, getAttrs, getContent: (dom, schema) => { - const source = (dom as HTMLElement).getAttribute('data-md') ?? '' + // REVIEW: I've changed this a bit. I've added `isHTMLElement` runtime check. + const element = isHTMLElement(dom) ? dom : undefined + const source = element?.getAttribute('data-md') ?? '' return source ? Fragment.from(schema.text(source)) : Fragment.empty }, } From 6c080d586fe0a095f091f4b1586c5590a9561f89 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 13 Jul 2026 01:30:53 +1000 Subject: [PATCH 08/16] review --- packages/core/src/extensions/clipboard/semantic-inline.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/extensions/clipboard/semantic-inline.ts b/packages/core/src/extensions/clipboard/semantic-inline.ts index 5e572b40..67460804 100644 --- a/packages/core/src/extensions/clipboard/semantic-inline.ts +++ b/packages/core/src/extensions/clipboard/semantic-inline.ts @@ -161,6 +161,7 @@ function appendTextWithBreaks(parent: HTMLElement, text: string): void { } } +// REVIEW: do not use globalThis.Node in the while codebase. Just use Node directly. function atomUnitDOM(atom: Mark, sourceText: string): globalThis.Node { switch (atom.type.name as MarkName) { case 'mdImage': { From d9c49a33c12224ded042b83056b7a345e8cfed57 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 13 Jul 2026 01:33:05 +1000 Subject: [PATCH 09/16] review --- packages/core/src/extensions/clipboard/semantic-inline.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/extensions/clipboard/semantic-inline.ts b/packages/core/src/extensions/clipboard/semantic-inline.ts index 67460804..97e82a2d 100644 --- a/packages/core/src/extensions/clipboard/semantic-inline.ts +++ b/packages/core/src/extensions/clipboard/semantic-inline.ts @@ -162,6 +162,7 @@ function appendTextWithBreaks(parent: HTMLElement, text: string): void { } // REVIEW: do not use globalThis.Node in the while codebase. Just use Node directly. +// REVIEW: rename this function to `atomUnitToDOM` function atomUnitDOM(atom: Mark, sourceText: string): globalThis.Node { switch (atom.type.name as MarkName) { case 'mdImage': { From f3d01540f233996df5da51c5349a7a2fdc3f6950 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:36:00 +0000 Subject: [PATCH 10/16] [autofix.ci] apply automated fixes --- packages/core/src/extensions/clipboard/semantic-inline.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/extensions/clipboard/semantic-inline.ts b/packages/core/src/extensions/clipboard/semantic-inline.ts index 97e82a2d..2645f84e 100644 --- a/packages/core/src/extensions/clipboard/semantic-inline.ts +++ b/packages/core/src/extensions/clipboard/semantic-inline.ts @@ -1,3 +1,4 @@ +import { isHTMLElement } from '@ocavue/utils' import { Fragment } from '@prosekit/pm/model' import type { Mark, ProseMirrorNode, TagParseRule } from '@prosekit/pm/model' @@ -8,7 +9,6 @@ import type { MdWikilinkAttrs, } from '../inline-marks.ts' import type { MarkName } from '../mark-names.ts' -import { isHTMLElement } from '@ocavue/utils' /** Syntax characters, dropped from the semantic clipboard HTML. */ const SYNTAX_MARK_NAMES: ReadonlySet = new Set([ From 584fc15994b7e6874c3df2b46b9e8db2048bfc24 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 13 Jul 2026 01:39:42 +1000 Subject: [PATCH 11/16] fix --- packages/core/src/extensions/heading.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/core/src/extensions/heading.ts b/packages/core/src/extensions/heading.ts index 5f927c60..46c3265d 100644 --- a/packages/core/src/extensions/heading.ts +++ b/packages/core/src/extensions/heading.ts @@ -56,6 +56,9 @@ function defineHeadingWhitespace(): HeadingSpecExtension { return defineNodeSpec({ name: 'heading' satisfies NodeName, whitespace: 'pre' }) } +// REVIEW: move this to a shared utils file, since it doesn't depend on headings at all. It's a general parse helper. +// The input argument should be string|null|undefined. The output return should be number|undefined. +// There should be a parseSafeInteger and parseSavePositiveInteger two functions function parsePositiveCount(raw: string | null): number | null { if (raw == null) return null const count = Number.parseInt(raw, 10) From 913b54f9ae4d351b34ae5111bbd30f4148c75d2e Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 13 Jul 2026 01:40:34 +1000 Subject: [PATCH 12/16] review --- packages/core/src/extensions/heading.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/extensions/heading.ts b/packages/core/src/extensions/heading.ts index 46c3265d..a070678c 100644 --- a/packages/core/src/extensions/heading.ts +++ b/packages/core/src/extensions/heading.ts @@ -58,7 +58,8 @@ function defineHeadingWhitespace(): HeadingSpecExtension { // REVIEW: move this to a shared utils file, since it doesn't depend on headings at all. It's a general parse helper. // The input argument should be string|null|undefined. The output return should be number|undefined. -// There should be a parseSafeInteger and parseSavePositiveInteger two functions +// There should be a parseSafeInteger and parseSavePositiveInteger two functions. +// This function can be used in packages/core/src/extensions/code-block.ts too function parsePositiveCount(raw: string | null): number | null { if (raw == null) return null const count = Number.parseInt(raw, 10) From a3f9de8cd822b01c6c2b5cc98038f84525647885 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 13 Jul 2026 01:43:47 +1000 Subject: [PATCH 13/16] review --- packages/core/src/extensions/clipboard/plain-text.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/extensions/clipboard/plain-text.test.ts b/packages/core/src/extensions/clipboard/plain-text.test.ts index b3caf99a..5364466b 100644 --- a/packages/core/src/extensions/clipboard/plain-text.test.ts +++ b/packages/core/src/extensions/clipboard/plain-text.test.ts @@ -90,6 +90,8 @@ describe('plain text copy in hide mode', () => { `) }) + // REVIEW: let's add more tests for example bullet list, ordered list, tasks list with differernt shapes of checkboxes, nested list, + it('strips link syntax down to the label', () => { expect(copyText('hide', 'see [docs](http://x.test)')).toMatchInlineSnapshot(`"see docs"`) }) From b445e0c35e40585b9ca25d5d1570c98124f10fb6 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 13 Jul 2026 01:45:02 +1000 Subject: [PATCH 14/16] fix --- packages/core/src/extensions/clipboard/clipboard.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/extensions/clipboard/clipboard.test.ts b/packages/core/src/extensions/clipboard/clipboard.test.ts index 90ad358e..d87136be 100644 --- a/packages/core/src/extensions/clipboard/clipboard.test.ts +++ b/packages/core/src/extensions/clipboard/clipboard.test.ts @@ -179,6 +179,8 @@ describe('clipboard round trip', () => { `) }) + // REVIEW: Add a ordered list test case here . + it('round-trips a thematic break with a non-canonical marker', () => { expect(roundTrip('before\n\n* * *\n\nafter')).toMatchInlineSnapshot(` "before From d8f0554da0e48b3b9ee842861700e0ccfb9aa951 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 13 Jul 2026 01:53:19 +1000 Subject: [PATCH 15/16] refactor(core): extract `parseInteger` utils and rename clipboard helpers --- .../extensions/clipboard/semantic-inline.ts | 11 ++++----- packages/core/src/extensions/code-block.ts | 8 +++---- packages/core/src/extensions/heading.ts | 24 +++++++------------ packages/core/src/extensions/paragraph.ts | 4 ++-- packages/core/src/utils/parse-integer.ts | 10 ++++++++ 5 files changed, 28 insertions(+), 29 deletions(-) create mode 100644 packages/core/src/utils/parse-integer.ts diff --git a/packages/core/src/extensions/clipboard/semantic-inline.ts b/packages/core/src/extensions/clipboard/semantic-inline.ts index 2645f84e..a4265084 100644 --- a/packages/core/src/extensions/clipboard/semantic-inline.ts +++ b/packages/core/src/extensions/clipboard/semantic-inline.ts @@ -91,8 +91,8 @@ export function semanticTextblockDOM( * The clipboard parse rule for a textblock: the content comes from the * `data-md` source text verbatim, the semantic child elements are ignored. * The inline mark plugin re-derives marks after the paste transaction. - */ // REVIEW: rename this function from sourceTextRule to createSourceTextRule -export function sourceTextRule( + */ +export function createSourceTextRule( tag: string, node: string, getAttrs?: TagParseRule['getAttrs'], @@ -103,7 +103,6 @@ export function sourceTextRule( priority: 100, getAttrs, getContent: (dom, schema) => { - // REVIEW: I've changed this a bit. I've added `isHTMLElement` runtime check. const element = isHTMLElement(dom) ? dom : undefined const source = element?.getAttribute('data-md') ?? '' return source ? Fragment.from(schema.text(source)) : Fragment.empty @@ -122,7 +121,7 @@ function serializeSemanticInline(textblock: ProseMirrorNode, out: HTMLElement): for (const run of groupInlineRuns(textblock)) { if (run.atom != null) { open.length = 0 - out.append(atomUnitDOM(run.atom, run.text)) + out.append(atomUnitToDOM(run.atom, run.text)) continue } for (const child of run.children) { @@ -161,9 +160,7 @@ function appendTextWithBreaks(parent: HTMLElement, text: string): void { } } -// REVIEW: do not use globalThis.Node in the while codebase. Just use Node directly. -// REVIEW: rename this function to `atomUnitToDOM` -function atomUnitDOM(atom: Mark, sourceText: string): globalThis.Node { +function atomUnitToDOM(atom: Mark, sourceText: string): Node { switch (atom.type.name as MarkName) { case 'mdImage': { const attrs = atom.attrs as MdImageAttrs diff --git a/packages/core/src/extensions/code-block.ts b/packages/core/src/extensions/code-block.ts index 6851632b..09d39433 100644 --- a/packages/core/src/extensions/code-block.ts +++ b/packages/core/src/extensions/code-block.ts @@ -6,6 +6,8 @@ import { import { defineTextBlockEnterRule } from '@prosekit/extensions/enter-rule' import { defineTextBlockInputRule } from '@prosekit/extensions/input-rule' +import { parseInteger } from '../utils/parse-integer.ts' + import type { NodeName } from './node-names.ts' export type CodeBlockFenceStyle = 'tilde' | 'indented' | 'dollar' @@ -59,10 +61,8 @@ function defineFenceLengthAttr(): FenceLengthExtension { // must survive an editor DOM re-parse. toDOM: (value) => (value != null ? ['data-fence-length', String(value)] : null), parseDOM: (node) => { - const raw = node.getAttribute('data-fence-length') - if (raw == null) return null - const length = Number.parseInt(raw, 10) - return Number.isSafeInteger(length) && length > 3 ? length : null + const length = parseInteger(node.getAttribute('data-fence-length')) + return length != null && length > 3 ? length : null }, }) } diff --git a/packages/core/src/extensions/heading.ts b/packages/core/src/extensions/heading.ts index a070678c..28e867d4 100644 --- a/packages/core/src/extensions/heading.ts +++ b/packages/core/src/extensions/heading.ts @@ -19,7 +19,9 @@ import { import type { ProseMirrorNode, TagParseRule } from '@prosekit/pm/model' import type { Command } from '@prosekit/pm/state' -import { semanticTextblockDOM, sourceTextRule } from './clipboard/semantic-inline.ts' +import { parsePositiveInteger } from '../utils/parse-integer.ts' + +import { createSourceTextRule, semanticTextblockDOM } from './clipboard/semantic-inline.ts' import type { NodeName } from './node-names.ts' export interface MeowdownHeadingAttrs extends HeadingAttrs { @@ -56,16 +58,6 @@ function defineHeadingWhitespace(): HeadingSpecExtension { return defineNodeSpec({ name: 'heading' satisfies NodeName, whitespace: 'pre' }) } -// REVIEW: move this to a shared utils file, since it doesn't depend on headings at all. It's a general parse helper. -// The input argument should be string|null|undefined. The output return should be number|undefined. -// There should be a parseSafeInteger and parseSavePositiveInteger two functions. -// This function can be used in packages/core/src/extensions/code-block.ts too -function parsePositiveCount(raw: string | null): number | null { - if (raw == null) return null - const count = Number.parseInt(raw, 10) - return Number.isSafeInteger(count) && count > 0 ? count : null -} - /** The clipboard DOM of a heading: semantic inline content plus `data-md`. */ export function headingClipboardDOM(node: ProseMirrorNode): HTMLElement { const attrs = node.attrs as MeowdownHeadingAttrs @@ -79,10 +71,10 @@ export function headingClipboardDOM(node: ProseMirrorNode): HTMLElement { /** The clipboard parse rules restoring a heading's source text from `data-md`. */ export function headingFromDOM(): TagParseRule[] { return [1, 2, 3, 4, 5, 6].map((level) => - sourceTextRule(`h${level}`, 'heading' satisfies NodeName, (dom) => ({ + createSourceTextRule(`h${level}`, 'heading' satisfies NodeName, (dom) => ({ level, - setextUnderline: parsePositiveCount(dom.getAttribute('data-setext-underline')), - closingHashes: parsePositiveCount(dom.getAttribute('data-closing-hashes')), + setextUnderline: parsePositiveInteger(dom.getAttribute('data-setext-underline')) ?? null, + closingHashes: parsePositiveInteger(dom.getAttribute('data-closing-hashes')) ?? null, })), ) } @@ -99,7 +91,7 @@ function defineSetextUnderlineAttr(): SetextUnderlineExtension { // A heading split or created in the editor is ATX; only a parsed setext // heading carries a length, which must survive an editor DOM re-parse. toDOM: (value) => (value != null ? ['data-setext-underline', String(value)] : null), - parseDOM: (node) => parsePositiveCount(node.getAttribute('data-setext-underline')), + parseDOM: (node) => parsePositiveInteger(node.getAttribute('data-setext-underline')) ?? null, }) } @@ -116,7 +108,7 @@ function defineHeadingClosingHashesAttr(): ClosingHashesExtension { // created or edited in the editor has none, and the count must survive a DOM // re-parse. toDOM: (value) => (value != null ? ['data-closing-hashes', String(value)] : null), - parseDOM: (node) => parsePositiveCount(node.getAttribute('data-closing-hashes')), + parseDOM: (node) => parsePositiveInteger(node.getAttribute('data-closing-hashes')) ?? null, }) } diff --git a/packages/core/src/extensions/paragraph.ts b/packages/core/src/extensions/paragraph.ts index d2f63329..a0bf6fce 100644 --- a/packages/core/src/extensions/paragraph.ts +++ b/packages/core/src/extensions/paragraph.ts @@ -13,7 +13,7 @@ import { } from '@prosekit/extensions/paragraph' import type { Attrs, ProseMirrorNode, TagParseRule } from '@prosekit/pm/model' -import { semanticTextblockDOM, sourceTextRule } from './clipboard/semantic-inline.ts' +import { createSourceTextRule, semanticTextblockDOM } from './clipboard/semantic-inline.ts' import type { NodeName } from './node-names.ts' type ParagraphSpecExtension = Extension<{ @@ -43,7 +43,7 @@ export function paragraphClipboardDOM(node: ProseMirrorNode): HTMLElement { /** The clipboard parse rules restoring a paragraph's source text from `data-md`. */ export function paragraphFromDOM(): TagParseRule[] { - return [sourceTextRule('p', 'paragraph' satisfies NodeName)] + return [createSourceTextRule('p', 'paragraph' satisfies NodeName)] } type MeowdownParagraphExtension = Union<[ParagraphSpecExtension, ParagraphCommandsExtension]> diff --git a/packages/core/src/utils/parse-integer.ts b/packages/core/src/utils/parse-integer.ts new file mode 100644 index 00000000..c3ccc664 --- /dev/null +++ b/packages/core/src/utils/parse-integer.ts @@ -0,0 +1,10 @@ +export function parseInteger(raw: string | null | undefined): number | undefined { + if (raw == null) return undefined + const value = Number.parseInt(raw, 10) + return Number.isSafeInteger(value) ? value : undefined +} + +export function parsePositiveInteger(raw: string | null | undefined): number | undefined { + const value = parseInteger(raw) + return value != null && value > 0 ? value : undefined +} From 7fe5fd04bde4193aeca440bca0dfd5b822ff18cb Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 13 Jul 2026 01:53:20 +1000 Subject: [PATCH 16/16] test(core): cover ordered list and task list shapes in clipboard copy --- .../extensions/clipboard/clipboard.test.ts | 8 ++++- .../extensions/clipboard/plain-text.test.ts | 33 ++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/core/src/extensions/clipboard/clipboard.test.ts b/packages/core/src/extensions/clipboard/clipboard.test.ts index d87136be..95a2ea4a 100644 --- a/packages/core/src/extensions/clipboard/clipboard.test.ts +++ b/packages/core/src/extensions/clipboard/clipboard.test.ts @@ -179,7 +179,13 @@ describe('clipboard round trip', () => { `) }) - // REVIEW: Add a ordered list test case here . + it('round-trips an ordered list', () => { + expect(roundTrip('1. first\n2. second')).toMatchInlineSnapshot(` + "1. first + 2. second + " + `) + }) it('round-trips a thematic break with a non-canonical marker', () => { expect(roundTrip('before\n\n* * *\n\nafter')).toMatchInlineSnapshot(` diff --git a/packages/core/src/extensions/clipboard/plain-text.test.ts b/packages/core/src/extensions/clipboard/plain-text.test.ts index 5364466b..2e06d75a 100644 --- a/packages/core/src/extensions/clipboard/plain-text.test.ts +++ b/packages/core/src/extensions/clipboard/plain-text.test.ts @@ -90,7 +90,38 @@ describe('plain text copy in hide mode', () => { `) }) - // REVIEW: let's add more tests for example bullet list, ordered list, tasks list with differernt shapes of checkboxes, nested list, + it('keeps bullet list markers', () => { + expect(copyText('hide', '- one **b**\n- two')).toMatchInlineSnapshot(` + "- one b + - two" + `) + }) + + it('keeps ordered list markers', () => { + expect(copyText('hide', '1. first *x*\n2. second')).toMatchInlineSnapshot(` + "1. first x + 2. second" + `) + }) + + it('keeps task checkboxes in all shapes', () => { + expect(copyText('hide', '- [ ] open\n- [x] done\n+ [ ] round open\n+ [x] round done')) + .toMatchInlineSnapshot(` + "- [ ] open + - [x] done + + [ ] round open + + [x] round done" + `) + }) + + it('keeps a nested list shape', () => { + expect(copyText('hide', '- parent *x*\n 1. one\n 2. two\n- tail')).toMatchInlineSnapshot(` + "- parent x + 1. one + 2. two + - tail" + `) + }) it('strips link syntax down to the label', () => { expect(copyText('hide', 'see [docs](http://x.test)')).toMatchInlineSnapshot(`"see docs"`)