link.ts 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. import {
  2. Mark,
  3. markPasteRule,
  4. mergeAttributes,
  5. } from '@tiptap/core'
  6. import { Plugin, PluginKey } from 'prosemirror-state'
  7. export interface LinkOptions {
  8. /**
  9. * If enabled, links will be opened on click.
  10. */
  11. openOnClick: boolean,
  12. /**
  13. * Adds a link to the current selection if the pasted content only contains an url.
  14. */
  15. linkOnPaste: boolean,
  16. /**
  17. * A list of HTML attributes to be rendered.
  18. */
  19. HTMLAttributes: Record<string, any>,
  20. }
  21. declare module '@tiptap/core' {
  22. interface Commands<ReturnType> {
  23. link: {
  24. /**
  25. * Set a link mark
  26. */
  27. setLink: (attributes: { href: string, target?: string, rel?: string }) => ReturnType,
  28. /**
  29. * Toggle a link mark
  30. */
  31. toggleLink: (attributes: { href: string, target?: string, rel?: string }) => ReturnType,
  32. /**
  33. * Unset a link mark
  34. */
  35. unsetLink: () => ReturnType,
  36. }
  37. }
  38. }
  39. /**
  40. * A regex that matches any string that contains a link
  41. */
  42. export const pasteRegex = /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)/gi
  43. /**
  44. * A regex that matches an url
  45. */
  46. export const pasteRegexExact = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)$/gi
  47. export default Mark.create<LinkOptions>({
  48. name: 'link',
  49. priority: 1000,
  50. inclusive: false,
  51. defaultOptions: {
  52. openOnClick: true,
  53. linkOnPaste: true,
  54. HTMLAttributes: {
  55. target: '_blank',
  56. rel: 'noopener noreferrer',
  57. },
  58. },
  59. addAttributes() {
  60. return {
  61. href: {
  62. default: null,
  63. },
  64. target: {
  65. default: this.options.HTMLAttributes.target,
  66. },
  67. rel: {
  68. default: this.options.HTMLAttributes.rel,
  69. }
  70. }
  71. },
  72. parseHTML() {
  73. return [
  74. { tag: 'a[href]' },
  75. ]
  76. },
  77. renderHTML({ HTMLAttributes }) {
  78. return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
  79. },
  80. addCommands() {
  81. return {
  82. setLink: attributes => ({ commands }) => {
  83. return commands.setMark('link', attributes)
  84. },
  85. toggleLink: attributes => ({ commands }) => {
  86. return commands.toggleMark('link', attributes, { extendEmptyMarkRange: true })
  87. },
  88. unsetLink: () => ({ commands }) => {
  89. return commands.unsetMark('link', { extendEmptyMarkRange: true })
  90. },
  91. }
  92. },
  93. addProseMirrorPlugins() {
  94. const plugins = []
  95. if (this.options.openOnClick) {
  96. plugins.push(
  97. new Plugin({
  98. key: new PluginKey('handleClickLink'),
  99. props: {
  100. handleClick: (view, pos, event) => {
  101. const attrs = this.editor.getAttributes('link')
  102. const link = (event.target as HTMLElement)?.closest('a')
  103. if (link && attrs.href) {
  104. window.open(attrs.href, attrs.target)
  105. return true
  106. }
  107. return false
  108. },
  109. },
  110. }),
  111. )
  112. }
  113. if (this.options.linkOnPaste) {
  114. plugins.push(
  115. new Plugin({
  116. key: new PluginKey('handlePasteLink'),
  117. props: {
  118. handlePaste: (view, event, slice) => {
  119. const { state } = view
  120. const { selection } = state
  121. const { empty } = selection
  122. if (empty) {
  123. return false
  124. }
  125. let textContent = ''
  126. slice.content.forEach(node => {
  127. textContent += node.textContent
  128. })
  129. if (!textContent || !textContent.match(pasteRegexExact)) {
  130. return false
  131. }
  132. this.editor.commands.setMark(this.type, {
  133. href: textContent,
  134. })
  135. return true
  136. },
  137. },
  138. }),
  139. )
  140. }
  141. return plugins
  142. },
  143. })