Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<h3>`, `<ol><li>`, `<strong>`, real `<table>`) 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`.

Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/extensions/clipboard/clipboard-parser.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
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) },
})
})
}
60 changes: 60 additions & 0 deletions packages/core/src/extensions/clipboard/clipboard-serializer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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 type { NodeName } from '../node-names.ts'
import { paragraphClipboardDOM } from '../paragraph.ts'

type NodeSerializers = Record<string, (node: ProseMirrorNode) => 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 (`<strong>`, `<em>`, real
* `<h1>`..`<h6>`) 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) => withSemanticTextblocks(nodesFromSchema(...args))
},
})
}

const semanticSerializerCache = new WeakMap<Schema, DOMSerializer>()

/**
* 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
}
294 changes: 294 additions & 0 deletions packages/core/src/extensions/clipboard/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
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(`
"
<h2
data-md="Title"
data-meowdown
data-pm-slice="0 0 []"
>
Title
</h2>
<p
data-md="plain **bold** text"
data-meowdown
>
plain
<strong>
bold
</strong>
text
</p>
"
`)
})

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(`
"
<ul
data-meowdown
data-pm-slice="0 0 []"
>
<li
class="prosemirror-flat-list"
data-list-kind="bullet"
>
<p
data-md="one"
data-meowdown
>
one
</p>
</li>
<li
class="prosemirror-flat-list"
data-list-kind="bullet"
>
<p
data-md="two"
data-meowdown
>
two
</p>
</li>
</ul>
<pre
data-language="js"
data-meowdown
>
<code class="language-js">
code
</code>
</pre>
"
`)
})
})

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 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(`
"before

* * *

after
"
`)
})

it('round-trips an html comment', () => {
expect(roundTrip('before\n\n<!-- note -->\n\nafter')).toMatchInlineSnapshot(`
"before

<!-- note -->

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(
`"<p data-md="Task" data-meowdown="" data-pm-slice="1 1 []">Task</p>"`,
)
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(
`"<p data-md="lain **bold** " data-meowdown="" data-pm-slice="1 1 []">lain <strong>bold</strong> </p>"`,
)
expect(pasteIntoEmptyDoc(html)).toMatchInlineSnapshot(`
"lain **bold**
"
`)
})
})
Loading
Loading