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 new file mode 100644 index 00000000..11e4d116 --- /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. + */ +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..516a6645 --- /dev/null +++ b/packages/core/src/extensions/clipboard/clipboard-serializer.ts @@ -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 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 + * `

    `..`

    `) 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() + +/** + * 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.test.ts b/packages/core/src/extensions/clipboard/clipboard.test.ts new file mode 100644 index 00000000..95a2ea4a --- /dev/null +++ b/packages/core/src/extensions/clipboard/clipboard.test.ts @@ -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(` + " +

    + 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 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\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..22c9c176 --- /dev/null +++ b/packages/core/src/extensions/clipboard/clipboard.ts @@ -0,0 +1,24 @@ +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' +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, + * 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.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..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 ![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..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 ![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..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, '
    • 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,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, '

    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 + 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 + 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 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/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