markdown-paste.ts 4.1 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. 'use client'
  2. import { useEffect } from 'react'
  3. import { marked, type Token, type Links } from 'marked'
  4. import { tableImageHtml } from './markdown-table'
  5. /*
  6. * Markdown paste for the enriched editor (web). Plain text that reads as Markdown
  7. * (`## Title`, lists, quotes, `**bold**`, links, fenced code, GFM tables) is
  8. * converted to HTML (tables as table blocks) and pasted through ProseMirror's own
  9. * pipeline, so the library's paste normalizer and mention handling still apply.
  10. */
  11. // Block syntax at the start of a line.
  12. const MD_BLOCK = /^(#{1,6}\s+\S|>\s?\S|[-*+]\s+\S|\d+[.)]\s+\S|```|~~~|\|.*\|\s*$)/m
  13. // Inline syntax: **bold**, __bold__, [text](http…), `code`.
  14. const MD_INLINE = /(\*\*[^*\n]+\*\*|__[^_\n]+__|\[[^\]\n]+\]\(https?:\/\/[^)\s]+\)|`[^`\n]+`)/
  15. // Clipboard HTML that already carries structure (a web page, Docs, Word): keep it.
  16. const SEMANTIC_HTML = /<(h[1-6]|ul|ol|li|table|blockquote|pre|strong|b|em|i|a)\b/i
  17. export function looksLikeMarkdown(text: string) {
  18. return MD_BLOCK.test(text) || MD_INLINE.test(text)
  19. }
  20. /**
  21. * Rows of a table copied from a rendered page often arrive separated by blank
  22. * lines, which ends a GFM table after its first row. Join consecutive `|…|` rows.
  23. */
  24. function joinTableRows(text: string) {
  25. return text.replace(/^(\|.*\|)[ \t]*\n(?:[ \t]*\n)+(?=\|)/gm, '$1\n')
  26. }
  27. /**
  28. * A table block keeps only its own Markdown, so reference links (`[Docs][docs]`,
  29. * `[docs][]`, `[docs]`) defined elsewhere in the paste become inline links.
  30. * Code spans are kept as they are.
  31. */
  32. function inlineReferenceLinks(raw: string, links: Links) {
  33. return raw.replace(/(`+)[^`]*?\1|\[([^\]\n]+)\]\[([^\]\n]*)\]|\[([^\]\n]+)\](?![([:])/g, (match, code, text, label, short) => {
  34. if (code) return match
  35. const key = String(label || text || short).toLowerCase().replace(/\s+/g, ' ').trim()
  36. const def = links[key]
  37. if (!def?.href) return match
  38. // A raw `|` would split the table cell, `>` would end the <…> destination.
  39. const href = def.href.replace(/\|/g, '%7C').replace(/>/g, '%3E')
  40. const title = def.title ? ` "${def.title.replace(/["|]/g, '')}"` : ''
  41. return `[${text ?? short}](<${href}>${title})`
  42. })
  43. }
  44. /**
  45. * Markdown → body HTML. Tables become table blocks (an atomic image the post view
  46. * draws as the table, see markdown-table.ts); everything else is plain HTML.
  47. */
  48. export function markdownToHtml(text: string) {
  49. const tokens = marked.lexer(joinTableRows(text.replace(/\r\n?/g, '\n')), { gfm: true })
  50. const parse = (list: Token[]) => marked.parser(Object.assign(list, { links: tokens.links }), { gfm: true, breaks: false, async: false }) as string
  51. let html = ''
  52. let run: Token[] = []
  53. for (const token of tokens) {
  54. if (token.type !== 'table') {
  55. run.push(token)
  56. continue
  57. }
  58. if (run.length) html += parse(run)
  59. run = []
  60. html += tableImageHtml(inlineReferenceLinks(token.raw, tokens.links))
  61. }
  62. if (run.length) html += parse(run)
  63. return html
  64. }
  65. /** Web: paste Markdown text into the TipTap editor as formatted content. */
  66. export function useMarkdownPaste(tipTap: any, enabled = true) {
  67. useEffect(() => {
  68. const view = tipTap?.view
  69. const dom: HTMLElement | undefined = view?.dom
  70. if (!enabled || !dom || typeof view.pasteHTML !== 'function') return undefined
  71. const onPaste = (event: ClipboardEvent) => {
  72. const data = event.clipboardData
  73. if (!data || Array.from(data.files || []).length) return
  74. const text = data.getData('text/plain')
  75. if (!text || !looksLikeMarkdown(text)) return
  76. if (SEMANTIC_HTML.test(data.getData('text/html') || '')) return
  77. event.preventDefault()
  78. event.stopPropagation()
  79. // No event: ProseMirror builds an empty one, so this listener doesn't re-enter.
  80. view.pasteHTML(markdownToHtml(text))
  81. }
  82. // Capture: runs before ProseMirror's own paste handler on the same element.
  83. dom.addEventListener('paste', onPaste, true)
  84. return () => dom.removeEventListener('paste', onPaste, true)
  85. }, [tipTap, enabled])
  86. }