1
0

editor-mention-html.js 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. // Convert between <mention> (react-native-enriched-html) and
  2. // UNA's <a class="bx-mention-link" data-profile-id href> format.
  3. const MENTION_TAG_RE = /<mention\b([^>]*)>([\s\S]*?)<\/mention>/gi
  4. // UNA links with class bx-mention-link or bx-tag
  5. const UNA_LINK_RE = /<a\b([^>]*?\bclass="[^"]*\b(?:bx-mention-link|bx-tag)\b[^"]*"[^>]*?)>([\s\S]*?)<\/a>/gi
  6. function attr(attrs, name) {
  7. const m = attrs.match(new RegExp(`${name}\\s*=\\s*"([^"]*)"`, 'i'))
  8. return m ? m[1] : ''
  9. }
  10. // On save: <mention> -> <a class="bx-mention-link">
  11. export function mentionsToUnaLinks(html) {
  12. if (!html) return html
  13. return html.replace(MENTION_TAG_RE, (_full, attrs, inner) => {
  14. const indicator = attr(attrs, 'indicator') || '@'
  15. const profileId = attr(attrs, 'data-profile-id')
  16. const href = attr(attrs, 'href')
  17. const cls = indicator === '#' ? 'bx-tag' : 'bx-mention-link'
  18. // Store the name without the leading indicator; renderers add it back.
  19. let text = inner.trim()
  20. if (text.startsWith(indicator)) text = text.slice(indicator.length)
  21. const idAttr = profileId ? ` data-profile-id="${profileId}"` : ''
  22. const hrefAttr = href ? ` href="${href}"` : ''
  23. return `<a class="${cls}"${idAttr}${hrefAttr}>${text}</a>`
  24. })
  25. }
  26. // On load: <a class="bx-mention-link"> -> <mention> (so mentions in existing
  27. // content become editable mention nodes again)
  28. export function unaLinksToMentions(html) {
  29. if (!html) return html
  30. return html.replace(UNA_LINK_RE, (_full, attrs, inner) => {
  31. const cls = attr(attrs, 'class')
  32. const indicator = /\bbx-tag\b/.test(cls) ? '#' : '@'
  33. const profileId = attr(attrs, 'data-profile-id')
  34. const href = attr(attrs, 'href')
  35. let name = inner.trim()
  36. if (name.startsWith(indicator)) name = name.slice(indicator.length)
  37. // Display the indicator (e.g. "@Name") in both the text attr and the inner
  38. // content so the editor and the EnrichedText renderer show it consistently.
  39. const display = `${indicator}${name}`
  40. const idAttr = profileId ? ` data-profile-id="${profileId}"` : ''
  41. const hrefAttr = href ? ` href="${href}"` : ''
  42. // double quotes are required for correct parsing (issue #404)
  43. return `<mention indicator="${indicator}" text="${display}"${idAttr}${hrefAttr}>${display}</mention>`
  44. })
  45. }
  46. // ---- Auto-linking plain URLs ----
  47. //
  48. // The native enriched editor auto-detects URLs as you type, but the web (Tiptap)
  49. // build ships the link extension with `autolink`/`linkOnPaste` disabled, so plain
  50. // URLs never become links there. To keep behaviour consistent across platforms —
  51. // and to make existing/plain-text content clickable in the renderer — we linkify
  52. // plain URLs/emails in JS at save and render time. Text already inside an anchor,
  53. // mention, or code span is left untouched.
  54. // Common TLDs for bare-domain detection (e.g. "example.com"). File-extension-like
  55. // TLDs (js, ts, sh, so, ...) are intentionally excluded to avoid turning code
  56. // snippets / filenames into links.
  57. const COMMON_TLDS = [
  58. 'com', 'org', 'net', 'edu', 'gov', 'mil', 'int', 'io', 'co', 'dev', 'app',
  59. 'ai', 'me', 'info', 'biz', 'tv', 'xyz', 'online', 'site', 'tech', 'store',
  60. 'blog', 'news', 'live', 'cloud', 'design', 'space', 'world', 'life', 'email',
  61. 'link', 'page', 'fm', 'gg', 'to', 'us', 'uk', 'ca', 'au', 'de', 'fr', 'es',
  62. 'it', 'nl', 'ru', 'jp', 'cn', 'in', 'br', 'mx', 'pl', 'se', 'no', 'fi', 'dk',
  63. 'ch', 'at', 'be', 'pt', 'gr', 'cz', 'ie', 'nz', 'za', 'kr', 'sg', 'hk', 'tr',
  64. 'ua', 'ro', 'hu',
  65. ].join('|')
  66. // email | explicit url (http(s):// or www.) | bare domain with a known TLD
  67. const URL_TOKEN_RE = new RegExp(
  68. '([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,24})' +
  69. '|((?:https?:\\/\\/|www\\.)[^\\s<>"\']+)' +
  70. '|(\\b(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\\.)+(?:' + COMMON_TLDS + ')\\b(?:\\/[^\\s<>"\']*)?)',
  71. 'gi'
  72. )
  73. // Tags whose text content must never be linkified.
  74. const LINKIFY_SKIP_TAGS = new Set(['a', 'mention', 'code', 'pre', 'codeblock', 'script', 'style'])
  75. const LINKIFY_VOID_TAGS = new Set(['br', 'img', 'hr', 'input', 'meta', 'link', 'source', 'wbr'])
  76. const LINKIFY_TAG_RE = /<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b[^>]*?(\/?)>/g
  77. const TRAILING_PUNCT_RE = /[).,;:!?'"\]}]+$/
  78. function linkifyText(text) {
  79. if (!text || (text.indexOf('@') === -1 && text.indexOf('.') === -1)) return text
  80. return text.replace(URL_TOKEN_RE, (match, email) => {
  81. if (email) {
  82. return `<a href="mailto:${email}">${email}</a>`
  83. }
  84. let token = match
  85. let trailing = ''
  86. const tm = token.match(TRAILING_PUNCT_RE)
  87. if (tm) {
  88. trailing = tm[0]
  89. token = token.slice(0, token.length - trailing.length)
  90. // Keep a trailing ")" that closes a "(" inside the URL, e.g.
  91. // wikipedia.org/wiki/Foo_(bar)
  92. while (trailing[0] === ')' && (token.split('(').length > token.split(')').length)) {
  93. token += ')'
  94. trailing = trailing.slice(1)
  95. }
  96. }
  97. if (!token) return match
  98. const href = /^https?:\/\//i.test(token) ? token : 'https://' + token
  99. return `<a href="${href}">${token}</a>${trailing}`
  100. })
  101. }
  102. export function linkifyHtml(html) {
  103. if (!html || typeof html !== 'string') return html
  104. if (html.indexOf('@') === -1 && html.indexOf('.') === -1) return html
  105. let out = ''
  106. let last = 0
  107. let skipDepth = 0
  108. let m
  109. LINKIFY_TAG_RE.lastIndex = 0
  110. while ((m = LINKIFY_TAG_RE.exec(html)) !== null) {
  111. const between = html.slice(last, m.index)
  112. out += skipDepth > 0 ? between : linkifyText(between)
  113. last = LINKIFY_TAG_RE.lastIndex
  114. const full = m[0]
  115. const isClose = m[1] === '/'
  116. const tag = m[2].toLowerCase()
  117. const selfClose = m[3] === '/' || LINKIFY_VOID_TAGS.has(tag)
  118. out += full
  119. if (LINKIFY_SKIP_TAGS.has(tag) && !selfClose) {
  120. if (isClose) skipDepth = Math.max(0, skipDepth - 1)
  121. else skipDepth += 1
  122. }
  123. }
  124. const tail = html.slice(last)
  125. out += skipDepth > 0 ? tail : linkifyText(tail)
  126. return out
  127. }
  128. // Ensure an href is navigable: bare domains / www. links get an https:// scheme
  129. // so they open in the browser instead of being treated as in-app routes. The
  130. // native auto-linker stores bare hrefs (e.g. "example.com"), so renderers call
  131. // this before navigating.
  132. export function normalizeLinkHref(href) {
  133. if (!href) return href
  134. // iOS EnrichedText uses greedy href=".+" and can append leftover tag
  135. // attributes (class/rel/target) onto the URL. Cut at the first raw
  136. // quote or space so only the real href is opened.
  137. if (/^https?:\/\//i.test(href) && /[\s"']/.test(href)) {
  138. href = href.replace(/^(https?:\/\/[^\s"']+).*/i, '$1')
  139. }
  140. if (/^(https?:|mailto:|tel:|\/|#|data:|blob:)/i.test(href)) return href
  141. // Bare domain or www. host (e.g. "example.com", "www.example.com/x") → add scheme.
  142. if (/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,24}(?:[/?#]|$)/.test(href)) {
  143. return 'https://' + href
  144. }
  145. return href
  146. }