AutoCompletion.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  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. return [
  60. new Plugin({
  61. key: new PluginKey('aiAutocompletion'),
  62. props: {
  63. handleKeyDown: (view, event) => {
  64. const { state, dispatch } = view
  65. const { selection } = state
  66. const { $head } = selection
  67. if ($head.parent.type.name !== 'paragraph') return false
  68. const node = $head.parent
  69. if (event.key === 'Tab') {
  70. // if (!node.attrs['data-suggestion']) {
  71. // // Generate completion
  72. // if (loading) return true
  73. // loading = true
  74. // const prompt = node.textContent
  75. // this.options.generateCompletion(prompt).then(suggestion => {
  76. // if (suggestion && suggestion.trim() !== '') {
  77. // dispatch(state.tr.setNodeMarkup($head.before(), null, {
  78. // ...node.attrs,
  79. // class: 'ai-autocompletion',
  80. // 'data-prompt': prompt,
  81. // 'data-suggestion': suggestion,
  82. // }))
  83. // }
  84. // // If suggestion is empty or null, do nothing
  85. // }).finally(() => {
  86. // loading = false
  87. // })
  88. // }
  89. if (node.attrs['data-suggestion']) {
  90. // Accept suggestion
  91. const suggestion = node.attrs['data-suggestion']
  92. dispatch(state.tr
  93. .insertText(suggestion, $head.pos)
  94. .setNodeMarkup($head.before(), null, {
  95. ...node.attrs,
  96. class: null,
  97. 'data-prompt': null,
  98. 'data-suggestion': null,
  99. })
  100. )
  101. return true
  102. }
  103. } else {
  104. if (node.attrs['data-suggestion']) {
  105. // Reset suggestion on any other key press
  106. dispatch(state.tr.setNodeMarkup($head.before(), null, {
  107. ...node.attrs,
  108. class: null,
  109. 'data-prompt': null,
  110. 'data-suggestion': null,
  111. }))
  112. }
  113. // Start debounce logic for AI generation only if the cursor is at the end of the paragraph
  114. if (selection.empty && $head.pos === $head.end()) {
  115. // Set up debounce for AI generation
  116. if (this.options.debounceTime !== null) {
  117. clearTimeout(debounceTimer)
  118. // Capture current position
  119. const currentPos = $head.before()
  120. debounceTimer = setTimeout(() => {
  121. const newState = view.state
  122. const newNode = newState.doc.nodeAt(currentPos)
  123. const currentIsAtEnd = newState.selection.$head.pos === newState.selection.$head.end()
  124. // Check if the node still exists and is still a paragraph
  125. if (newNode && newNode.type.name === 'paragraph' && currentIsAtEnd) {
  126. const prompt = newNode.textContent
  127. if (prompt.trim() !== ''){
  128. if (loading) return true
  129. loading = true
  130. this.options.generateCompletion(prompt).then(suggestion => {
  131. if (suggestion && suggestion.trim() !== '') {
  132. view.dispatch(newState.tr.setNodeMarkup(currentPos, null, {
  133. ...newNode.attrs,
  134. class: 'ai-autocompletion',
  135. 'data-prompt': prompt,
  136. 'data-suggestion': suggestion,
  137. }))
  138. }
  139. }).finally(() => {
  140. loading = false
  141. })
  142. }
  143. }
  144. }, this.options.debounceTime)
  145. }
  146. }
  147. }
  148. return false
  149. },
  150. handleDOMEvents: {
  151. touchstart: (view, event) => {
  152. touchStartX = event.touches[0].clientX;
  153. touchStartY = event.touches[0].clientY;
  154. return false;
  155. },
  156. touchend: (view, event) => {
  157. const touchEndX = event.changedTouches[0].clientX;
  158. const touchEndY = event.changedTouches[0].clientY;
  159. const deltaX = touchEndX - touchStartX;
  160. const deltaY = touchEndY - touchStartY;
  161. // Check if the swipe was primarily horizontal and to the right
  162. if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX > 50) {
  163. const { state, dispatch } = view;
  164. const { selection } = state;
  165. const { $head } = selection;
  166. const node = $head.parent;
  167. if (node.type.name === 'paragraph' && node.attrs['data-suggestion']) {
  168. const suggestion = node.attrs['data-suggestion'];
  169. dispatch(state.tr
  170. .insertText(suggestion, $head.pos)
  171. .setNodeMarkup($head.before(), null, {
  172. ...node.attrs,
  173. class: null,
  174. 'data-prompt': null,
  175. 'data-suggestion': null,
  176. })
  177. );
  178. return true;
  179. }
  180. }
  181. return false;
  182. },
  183. mousedown: () => {
  184. // Reset debounce timer on mouse click
  185. clearTimeout(debounceTimer)
  186. return false
  187. },
  188. },
  189. },
  190. }),
  191. ]
  192. },
  193. })