image.ts 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import { mergeAttributes, Node, nodeInputRule } from '@tiptap/core';
  2. export interface ImageOptions {
  3. /**
  4. * Controls if the image node should be inline or not.
  5. * @default false
  6. * @example true
  7. */
  8. inline: boolean;
  9. /**
  10. * Controls if base64 images are allowed. Enable this if you want to allow
  11. * base64 image urls in the `src` attribute.
  12. * @default false
  13. * @example true
  14. */
  15. allowBase64: boolean;
  16. /**
  17. * HTML attributes to add to the image element.
  18. * @default {}
  19. * @example { class: 'foo' }
  20. */
  21. HTMLAttributes: Record<string, any>;
  22. }
  23. export interface SetImageOptions {
  24. src: string;
  25. alt?: string;
  26. title?: string;
  27. width?: number;
  28. height?: number;
  29. }
  30. declare module '@tiptap/core' {
  31. interface Commands<ReturnType> {
  32. image: {
  33. /**
  34. * Add an image
  35. * @param options The image attributes
  36. * @example
  37. * editor
  38. * .commands
  39. * .setImage({ src: 'https://tiptap.dev/logo.png', alt: 'tiptap', title: 'tiptap logo' })
  40. */
  41. setImage: (options: SetImageOptions) => ReturnType;
  42. };
  43. }
  44. }
  45. /**
  46. * Matches an image to a ![image](src "title") on input.
  47. */
  48. export const inputRegex = /(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/;
  49. /**
  50. * This extension allows you to insert images.
  51. * @see https://www.tiptap.dev/api/nodes/image
  52. */
  53. export const Image = Node.create<ImageOptions>({
  54. name: 'image',
  55. addOptions() {
  56. return {
  57. inline: false,
  58. allowBase64: false,
  59. HTMLAttributes: {}
  60. };
  61. },
  62. inline() {
  63. return this.options.inline;
  64. },
  65. group() {
  66. return this.options.inline ? 'inline' : 'block';
  67. },
  68. draggable: true,
  69. addAttributes() {
  70. return {
  71. file: {
  72. default: null
  73. },
  74. src: {
  75. default: null
  76. },
  77. alt: {
  78. default: null
  79. },
  80. title: {
  81. default: null
  82. },
  83. width: {
  84. default: null
  85. },
  86. height: {
  87. default: null
  88. }
  89. };
  90. },
  91. parseHTML() {
  92. return [
  93. {
  94. tag: this.options.allowBase64 ? 'img[src]' : 'img[src]:not([src^="data:"])'
  95. }
  96. ];
  97. },
  98. renderHTML({ HTMLAttributes }) {
  99. if (HTMLAttributes.file) {
  100. delete HTMLAttributes.file;
  101. }
  102. return ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
  103. },
  104. addNodeView() {
  105. return ({ node, editor }) => {
  106. const domImg = document.createElement('img');
  107. domImg.setAttribute('src', node.attrs.src || '');
  108. domImg.setAttribute('alt', node.attrs.alt || '');
  109. domImg.setAttribute('title', node.attrs.title || '');
  110. const container = document.createElement('div');
  111. const img = document.createElement('img');
  112. const fileId = node.attrs.src.replace('data://', '');
  113. img.setAttribute('id', `image:${fileId}`);
  114. img.classList.add('rounded-md', 'max-h-72', 'w-fit', 'object-contain');
  115. const editorFiles = editor.storage?.files || [];
  116. if (editorFiles && node.attrs.src.startsWith('data://')) {
  117. const file = editorFiles.find((f) => f.id === fileId);
  118. if (file) {
  119. img.setAttribute('src', file.url || '');
  120. } else {
  121. img.setAttribute('src', '/no-image.png');
  122. }
  123. } else {
  124. img.setAttribute('src', node.attrs.src || '');
  125. }
  126. img.setAttribute('alt', node.attrs.alt || '');
  127. img.setAttribute('title', node.attrs.title || '');
  128. img.addEventListener('data', (e) => {
  129. const files = e?.files || [];
  130. if (files && node.attrs.src.startsWith('data://')) {
  131. const file = editorFiles.find((f) => f.id === fileId);
  132. if (file) {
  133. img.setAttribute('src', file.url || '');
  134. } else {
  135. img.setAttribute('src', '/no-image.png');
  136. }
  137. }
  138. });
  139. container.append(img);
  140. return {
  141. dom: img,
  142. contentDOM: domImg
  143. };
  144. };
  145. },
  146. addCommands() {
  147. return {
  148. setImage:
  149. (options) =>
  150. ({ commands }) => {
  151. return commands.insertContent({
  152. type: this.name,
  153. attrs: options
  154. });
  155. }
  156. };
  157. },
  158. addInputRules() {
  159. return [
  160. nodeInputRule({
  161. find: inputRegex,
  162. type: this.type,
  163. getAttributes: (match) => {
  164. const [, , alt, src, title] = match;
  165. return { src, alt, title };
  166. }
  167. })
  168. ];
  169. }
  170. });