| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | import { Extension } from '@tiptap/core'; |
| | import { Plugin, PluginKey } from 'prosemirror-state'; |
| |
|
| | export const AIAutocompletion = Extension.create({ |
| | name: 'aiAutocompletion', |
| |
|
| | addOptions() { |
| | return { |
| | generateCompletion: () => Promise.resolve(''), |
| | debounceTime: 1000 |
| | }; |
| | }, |
| |
|
| | addGlobalAttributes() { |
| | return [ |
| | { |
| | types: ['paragraph'], |
| | attributes: { |
| | class: { |
| | default: null, |
| | parseHTML: (element) => element.getAttribute('class'), |
| | renderHTML: (attributes) => { |
| | if (!attributes.class) return {}; |
| | return { class: attributes.class }; |
| | } |
| | }, |
| | 'data-prompt': { |
| | default: null, |
| | parseHTML: (element) => element.getAttribute('data-prompt'), |
| | renderHTML: (attributes) => { |
| | if (!attributes['data-prompt']) return {}; |
| | return { 'data-prompt': attributes['data-prompt'] }; |
| | } |
| | }, |
| | 'data-suggestion': { |
| | default: null, |
| | parseHTML: (element) => element.getAttribute('data-suggestion'), |
| | renderHTML: (attributes) => { |
| | if (!attributes['data-suggestion']) return {}; |
| | return { 'data-suggestion': attributes['data-suggestion'] }; |
| | } |
| | } |
| | } |
| | } |
| | ]; |
| | }, |
| |
|
| | addProseMirrorPlugins() { |
| | let debounceTimer = null; |
| | let loading = false; |
| |
|
| | let touchStartX = 0; |
| | let touchStartY = 0; |
| |
|
| | let isComposing = false; |
| |
|
| | const handleAICompletion = (view) => { |
| | const { state, dispatch } = view; |
| | const { selection } = state; |
| | const { $head } = selection; |
| |
|
| | |
| | if (selection.empty && $head.pos === $head.end()) { |
| | |
| | if (this.options.debounceTime !== null) { |
| | clearTimeout(debounceTimer); |
| |
|
| | |
| | const currentPos = $head.before(); |
| |
|
| | debounceTimer = setTimeout(() => { |
| | if (isComposing) return false; |
| |
|
| | const newState = view.state; |
| | const newSelection = newState.selection; |
| | const newNode = newState.doc.nodeAt(currentPos); |
| |
|
| | |
| | if ( |
| | newNode && |
| | newNode.type.name === 'paragraph' && |
| | newSelection.$head.pos === newSelection.$head.end() && |
| | newSelection.$head.pos === currentPos + newNode.nodeSize - 1 |
| | ) { |
| | const prompt = newNode.textContent; |
| |
|
| | if (prompt.trim() !== '') { |
| | if (loading) return true; |
| | loading = true; |
| | this.options |
| | .generateCompletion(prompt) |
| | .then((suggestion) => { |
| | if (suggestion && suggestion.trim() !== '') { |
| | if (view.state.selection.$head.pos === view.state.selection.$head.end()) { |
| | if (view.state === newState) { |
| | view.dispatch( |
| | newState.tr.setNodeMarkup(currentPos, null, { |
| | ...newNode.attrs, |
| | class: 'ai-autocompletion', |
| | 'data-prompt': prompt, |
| | 'data-suggestion': suggestion |
| | }) |
| | ); |
| | } |
| | } |
| | } |
| | }) |
| | .finally(() => { |
| | loading = false; |
| | }); |
| | } |
| | } |
| | }, this.options.debounceTime); |
| | } |
| | } |
| | }; |
| |
|
| | return [ |
| | new Plugin({ |
| | key: new PluginKey('aiAutocompletion'), |
| | props: { |
| | handleKeyDown: (view, event) => { |
| | const { state, dispatch } = view; |
| | const { selection } = state; |
| | const { $head } = selection; |
| |
|
| | if ($head.parent.type.name !== 'paragraph') return false; |
| |
|
| | const node = $head.parent; |
| |
|
| | if (event.key === 'Tab') { |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | if (node.attrs['data-suggestion']) { |
| | |
| | const suggestion = node.attrs['data-suggestion']; |
| | dispatch( |
| | state.tr.insertText(suggestion, $head.pos).setNodeMarkup($head.before(), null, { |
| | ...node.attrs, |
| | class: null, |
| | 'data-prompt': null, |
| | 'data-suggestion': null |
| | }) |
| | ); |
| | return true; |
| | } |
| | } else { |
| | if (node.attrs['data-suggestion']) { |
| | |
| | dispatch( |
| | state.tr.setNodeMarkup($head.before(), null, { |
| | ...node.attrs, |
| | class: null, |
| | 'data-prompt': null, |
| | 'data-suggestion': null |
| | }) |
| | ); |
| | } |
| |
|
| | handleAICompletion(view); |
| | } |
| | return false; |
| | }, |
| | handleDOMEvents: { |
| | compositionstart: () => { |
| | isComposing = true; |
| | return false; |
| | }, |
| | compositionend: (view) => { |
| | isComposing = false; |
| | handleAICompletion(view); |
| | return false; |
| | }, |
| | touchstart: (view, event) => { |
| | touchStartX = event.touches[0].clientX; |
| | touchStartY = event.touches[0].clientY; |
| | return false; |
| | }, |
| | touchend: (view, event) => { |
| | const touchEndX = event.changedTouches[0].clientX; |
| | const touchEndY = event.changedTouches[0].clientY; |
| |
|
| | const deltaX = touchEndX - touchStartX; |
| | const deltaY = touchEndY - touchStartY; |
| |
|
| | |
| | if (Math.abs(deltaX) > Math.abs(deltaY) && deltaX > 50) { |
| | const { state, dispatch } = view; |
| | const { selection } = state; |
| | const { $head } = selection; |
| | const node = $head.parent; |
| |
|
| | if (node.type.name === 'paragraph' && node.attrs['data-suggestion']) { |
| | const suggestion = node.attrs['data-suggestion']; |
| | dispatch( |
| | state.tr.insertText(suggestion, $head.pos).setNodeMarkup($head.before(), null, { |
| | ...node.attrs, |
| | class: null, |
| | 'data-prompt': null, |
| | 'data-suggestion': null |
| | }) |
| | ); |
| | return true; |
| | } |
| | } |
| | return false; |
| | }, |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | |
| | |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | |
| | |
| | mouseup: (view, event) => { |
| | const { state, dispatch } = view; |
| |
|
| | |
| | clearTimeout(debounceTimer); |
| |
|
| | |
| | const tr = state.tr; |
| | state.doc.descendants((node, pos) => { |
| | if (node.type.name === 'paragraph' && node.attrs['data-suggestion']) { |
| | |
| | tr.setNodeMarkup(pos, null, { |
| | ...node.attrs, |
| | class: null, |
| | 'data-prompt': null, |
| | 'data-suggestion': null |
| | }); |
| | } |
| | }); |
| |
|
| | |
| | if (tr.docChanged) { |
| | dispatch(tr); |
| | } |
| |
|
| | return false; |
| | } |
| | } |
| | } |
| | }) |
| | ]; |
| | } |
| | }); |
| |
|