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..66dcdb29
--- /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.
+ */
+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/clipboard/plain-text.test.ts b/packages/core/src/extensions/clipboard/plain-text.test.ts
new file mode 100644
index 00000000..2e06d75a
--- /dev/null
+++ b/packages/core/src/extensions/clipboard/plain-text.test.ts
@@ -0,0 +1,198 @@
+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('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"`)
+ })
+
+ 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  end')).toMatchInlineSnapshot(
+ `"see  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..f819171c
--- /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.
+ */
+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. */
+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.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  end'))
+ .toMatchInlineSnapshot(`
+ "
+
+ see
+
+ 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..a4265084
--- /dev/null
+++ b/packages/core/src/extensions/clipboard/semantic-inline.ts
@@ -0,0 +1,192 @@
+import { isHTMLElement } from '@ocavue/utils'
+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',
+}
+
+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 createSourceTextRule(
+ tag: string,
+ node: string,
+ getAttrs?: TagParseRule['getAttrs'],
+): TagParseRule {
+ return {
+ tag: `${tag}[data-md]`,
+ node,
+ priority: 100,
+ getAttrs,
+ getContent: (dom, schema) => {
+ const element = isHTMLElement(dom) ? dom : undefined
+ const source = element?.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(atomUnitToDOM(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')
+ for (const [index, line] of lines.entries()) {
+ if (index > 0) parent.append(document.createElement('br'))
+ if (line) parent.append(document.createTextNode(line))
+ }
+}
+
+function atomUnitToDOM(atom: Mark, sourceText: string): 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/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/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(),
diff --git a/packages/core/src/extensions/heading.ts b/packages/core/src/extensions/heading.ts
index c4d22d85..28e867d4 100644
--- a/packages/core/src/extensions/heading.ts
+++ b/packages/core/src/extensions/heading.ts
@@ -16,8 +16,12 @@ import {
defineHeadingSpec,
type HeadingAttrs,
} from '@prosekit/extensions/heading'
+import type { ProseMirrorNode, TagParseRule } from '@prosekit/pm/model'
import type { Command } from '@prosekit/pm/state'
+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 {
@@ -54,6 +58,27 @@ function defineHeadingWhitespace(): HeadingSpecExtension {
return defineNodeSpec({ name: 'heading' satisfies NodeName, whitespace: 'pre' })
}
+/** 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) =>
+ createSourceTextRule(`h${level}`, 'heading' satisfies NodeName, (dom) => ({
+ level,
+ setextUnderline: parsePositiveInteger(dom.getAttribute('data-setext-underline')) ?? null,
+ closingHashes: parsePositiveInteger(dom.getAttribute('data-closing-hashes')) ?? null,
+ })),
+ )
+}
+
type SetextUnderlineExtension = Extension<{
Nodes: { heading: { setextUnderline?: number | null } }
}>
@@ -66,12 +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) => {
- 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) => parsePositiveInteger(node.getAttribute('data-setext-underline')) ?? null,
})
}
@@ -88,12 +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) => {
- 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) => parsePositiveInteger(node.getAttribute('data-closing-hashes')) ?? null,
})
}
diff --git a/packages/core/src/extensions/html-paste.test.ts b/packages/core/src/extensions/html-paste.test.ts
index 30e0aa33..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, '')
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,10 +49,33 @@ 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('')))
- // `data-pm-slice` marks PM-native HTML; the literal `**` is already in the text.
- pasteHTML(view, '')
+ 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
+ fixture.set(n.doc(n.paragraph('')))
+ // Pre-`data-meowdown` clipboard HTML: the editor DOM with `md-mark` spans.
+ pasteHTML(
+ view,
+ '',
+ )
+ expect(docToMarkdown(editor.state.doc).trim()).toBe('**b**')
+ })
+
+ it('converts HTML from a foreign ProseMirror editor', () => {
+ using fixture = setupFixture()
+ const { editor, n, view } = 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, '')
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
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  end',
- 'see  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/extensions/paragraph.ts b/packages/core/src/extensions/paragraph.ts
index 5aff6180..a0bf6fce 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 { createSourceTextRule, semanticTextblockDOM } 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 [createSourceTextRule('p', 'paragraph' satisfies NodeName)]
+}
+
type MeowdownParagraphExtension = Union<[ParagraphSpecExtension, ParagraphCommandsExtension]>
export function defineMeowdownParagraph(): MeowdownParagraphExtension {
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index eaff2db8..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,
@@ -100,7 +99,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/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
+}
diff --git a/packages/react/src/components/editor-extensions.tsx b/packages/react/src/components/editor-extensions.tsx
index adee11f7..0fd6841c 100644
--- a/packages/react/src/components/editor-extensions.tsx
+++ b/packages/react/src/components/editor-extensions.tsx
@@ -6,12 +6,10 @@ import {
defineFilePaste,
defineFileView,
defineFollowLinkHandler,
- defineHTMLPaste,
defineImage,
defineImageClickHandler,
defineLinkClickHandler,
defineLinkPaste,
- defineMarkdownCopy,
definePlaceholder,
defineReadonly,
defineSpellCheckPlugin,
@@ -177,18 +175,6 @@ export function EditorExtensions({
}, [linkPaste]),
)
- useExtension(
- useMemo(() => {
- return defineHTMLPaste()
- }, []),
- )
-
- useExtension(
- useMemo(() => {
- return defineMarkdownCopy()
- }, []),
- )
-
useExtension(
useMemo(() => {
return bulletAfterHeading ? defineBulletAfterHeading() : null