AutoCompletion.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. /*
  2. Here we initialize the plugin with keyword mapping.
  3. Intended to handle user interactions seamlessly.
  4. Observe the keydown events for proactive suggestions.
  5. Provide a mechanism for accepting AI suggestions.
  6. Evaluate each input change with debounce logic.
  7. Next, we implement touch and mouse interactions.
  8. Anchor the user experience to intuitive behavior.
  9. Intelligently reset suggestions on new input.
  10. */
  11. import { Extension } from '@tiptap/core';
  12. import { Plugin, PluginKey } from 'prosemirror-state';
  13. export const AIAutocompletion = Extension.create({
  14. name: 'aiAutocompletion',
  15. addOptions() {
  16. return {
  17. generateCompletion: () => Promise.resolve(''),
  18. debounceTime: 1000
  19. };
  20. },
  21. addGlobalAttributes() {
  22. return [
  23. {
  24. types: ['paragraph'],
  25. attributes: {
  26. class: {
  27. default: null,
  28. parseHTML: (element) => element.getAttribute('class'),
  29. renderHTML: (attributes) => {
  30. if (!attributes.class) return {};
  31. return { class: attributes.class };
  32. }
  33. },
  34. 'data-prompt': {
  35. default: null,
  36. parseHTML: (element) => element.getAttribute('data-prompt'),
  37. renderHTML: (attributes) => {
  38. if (!attributes['data-prompt']) return {};
  39. return { 'data-prompt': attributes['data-prompt'] };
  40. }
  41. },
  42. 'data-suggestion': {
  43. default: null,
  44. parseHTML: (element) => element.getAttribute('data-suggestion'),
  45. renderHTML: (attributes) => {
  46. if (!attributes['data-suggestion']) return {};
  47. return { 'data-suggestion': attributes['data-suggestion'] };
  48. }
  49. }
  50. }
  51. }
  52. ];
  53. },
  54. addProseMirrorPlugins() {
  55. let debounceTimer = null;
  56. let loading = false;
  57. let touchStartX = 0;
  58. let touchStartY = 0;
  59. let isComposing = false;
  60. const handleAICompletion = (view) => {
  61. const { state, dispatch } = view;
  62. const { selection } = state;
  63. const { $head } = selection;
  64. // Start debounce logic for AI generation only if the cursor is at the end of the paragraph
  65. if (selection.empty && $head.pos === $head.end()) {
  66. // Set up debounce for AI generation
  67. if (this.options.debounceTime !== null) {
  68. clearTimeout(debounceTimer);
  69. // Capture current position
  70. const currentPos = $head.before();
  71. debounceTimer = setTimeout(() => {
  72. if (isComposing) return false;
  73. const newState = view.state;
  74. const newSelection = newState.selection;
  75. const newNode = newState.doc.nodeAt(currentPos);
  76. // Check if the node still exists and is still a paragraph
  77. if (
  78. newNode &&
  79. newNode.type.name === 'paragraph' &&
  80. newSelection.$head.pos === newSelection.$head.end() &&
  81. newSelection.$head.pos === currentPos + newNode.nodeSize - 1
  82. ) {
  83. const prompt = newNode.textContent;
  84. if (prompt.trim() !== '') {
  85. if (loading) return true;
  86. loading = true;
  87. this.options
  88. .generateCompletion(prompt)
  89. .then((suggestion) => {
  90. if (suggestion && suggestion.trim() !== '') {
  91. if (view.state.selection.$head.pos === view.state.selection.$head.end()) {
  92. if (view.state === newState) {
  93. view.dispatch(
  94. newState.tr.setNodeMarkup(currentPos, null, {
  95. ...newNode.attrs,
  96. class: 'ai-autocompletion',
  97. 'data-prompt': prompt,
  98. 'data-suggestion': suggestion
  99. })
  100. );
  101. }
  102. }
  103. }
  104. })
  105. .finally(() => {
  106. loading = false;
  107. });
  108. }
  109. }
  110. }, this.options.debounceTime);
  111. }
  112. }
  113. };
  114. return [
  115. new Plugin({
  116. key: new PluginKey('aiAutocompletion'),
  117. props: {
  118. handleKeyDown: (view, event) => {
  119. const { state, dispatch } = view;
  120. const { selection } = state;
  121. const { $head } = selection;
  122. if ($head.parent.type.name !== 'paragraph') return false;
  123. const node = $head.parent;
  124. if (event.key === 'Tab') {
  125. // if (!node.attrs['data-suggestion']) {
  126. // // Generate completion
  127. // if (loading) return true
  128. // loading = true
  129. // const prompt = node.textContent
  130. // this.options.generateCompletion(prompt).then(suggestion => {
  131. // if (suggestion && suggestion.trim() !== '') {
  132. // dispatch(state.tr.setNodeMarkup($head.before(), null, {
  133. // ...node.attrs,
  134. // class: 'ai-autocompletion',
  135. // 'data-prompt': prompt,
  136. // 'data-suggestion': suggestion,
  137. // }))
  138. // }
  139. // // If suggestion is empty or null, do nothing
  140. // }).finally(() => {
  141. // loading = false
  142. // })
  143. // }
  144. if (node.attrs['data-suggestion']) {
  145. // Accept suggestion
  146. const suggestion = node.attrs['data-suggestion'];
  147. dispatch(
  148. state.tr.insertText(suggestion, $head.pos).setNodeMarkup($head.before(), null, {
  149. ...node.attrs,
  150. class: null,
  151. 'data-prompt': null,
  152. 'data-suggestion': null
  153. })
  154. );
  155. return true;
  156. }
  157. } else {
  158. if (node.attrs['data-suggestion']) {
  159. // Reset suggestion on any other key press
  160. dispatch(
  161. state.tr.setNodeMarkup($head.before(), null, {
  162. ...node.attrs,
  163. class: null,
  164. 'data-prompt': null,
  165. 'data-suggestion': null
  166. })
  167. );
  168. }
  169. handleAICompletion(view);
  170. }
  171. return false;
  172. },
  173. handleDOMEvents: {
  174. compositionstart: () => {
  175. isComposing = true;
  176. return false;
  177. },
  178. compositionend: (view) => {
  179. isComposing = false;
  180. handleAICompletion(view);
  181. return false;
  182. },
  183. touchstart: (view, event) => {
  184. touchStartX = event.touches[0].clientX;
  185. touchStartY = event.touches[0].clientY;
  186. return false;
  187. },
  188. touchend: (view, event) => {
  189. const touchEndX = event.changedTouches[0].clientX;
  190. const touchEndY = event.changedTouches[0].clientY;
  191. const deltaX = touchEndX - touchStartX;
  192. const deltaY = touchEndY - touchStartY;
  193. // Check if the swipe was primarily horizontal and to the right
  194. if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX > 50) {
  195. const { state, dispatch } = view;
  196. const { selection } = state;
  197. const { $head } = selection;
  198. const node = $head.parent;
  199. if (node.type.name === 'paragraph' && node.attrs['data-suggestion']) {
  200. const suggestion = node.attrs['data-suggestion'];
  201. dispatch(
  202. state.tr.insertText(suggestion, $head.pos).setNodeMarkup($head.before(), null, {
  203. ...node.attrs,
  204. class: null,
  205. 'data-prompt': null,
  206. 'data-suggestion': null
  207. })
  208. );
  209. return true;
  210. }
  211. }
  212. return false;
  213. },
  214. // Add mousedown behavior
  215. // mouseup: (view, event) => {
  216. // const { state, dispatch } = view;
  217. // const { selection } = state;
  218. // const { $head } = selection;
  219. // const node = $head.parent;
  220. // // Reset debounce timer on mouse click
  221. // clearTimeout(debounceTimer);
  222. // // If a suggestion exists and the cursor moves, remove the suggestion
  223. // if (
  224. // node.type.name === 'paragraph' &&
  225. // node.attrs['data-suggestion'] &&
  226. // view.state.selection.$head.pos !== view.state.selection.$head.end()
  227. // ) {
  228. // dispatch(
  229. // state.tr.setNodeMarkup($head.before(), null, {
  230. // ...node.attrs,
  231. // class: null,
  232. // 'data-prompt': null,
  233. // 'data-suggestion': null
  234. // })
  235. // );
  236. // }
  237. // return false;
  238. // }
  239. mouseup: (view, event) => {
  240. const { state, dispatch } = view;
  241. // Reset debounce timer on mouse click
  242. clearTimeout(debounceTimer);
  243. // Iterate over all nodes in the document
  244. const tr = state.tr;
  245. state.doc.descendants((node, pos) => {
  246. if (node.type.name === 'paragraph' && node.attrs['data-suggestion']) {
  247. // Remove suggestion from this paragraph
  248. tr.setNodeMarkup(pos, null, {
  249. ...node.attrs,
  250. class: null,
  251. 'data-prompt': null,
  252. 'data-suggestion': null
  253. });
  254. }
  255. });
  256. // Apply the transaction if any changes were made
  257. if (tr.docChanged) {
  258. dispatch(tr);
  259. }
  260. return false;
  261. }
  262. }
  263. }
  264. })
  265. ];
  266. }
  267. });