@sereneinserenade/tiptap-inline-suggestion
Version:
Tiptap Extension for adding inline suggestions. Ex. for AI autocompletion.
174 lines (141 loc) • 4.14 kB
text/typescript
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
inlineSuggestion: {
/**
* fetch inline suggestions
*/
fetchSuggestion: () => ReturnType;
};
}
}
export const inlineSuggestionPluginKey = new PluginKey("inlineSuggestion");
export interface InlineSuggestionOptions {
/**
* fetch inline suggestions
*
* @param existingText - existing text in the node
* @returns {string} - the suggestion to be shown
*/
fetchAutocompletion: (existingText: string) => Promise<string>;
}
export interface InlineSuggestionStorage {
data: {
currentSuggestion?: string;
nodeDetails?: {
from: number;
to: number;
};
};
}
export const InlineSuggestion = Extension.create<
InlineSuggestionOptions,
InlineSuggestionStorage
>({
name: "inlineSuggestion",
addOptions() {
return {
fetchAutocompletion: async () => {
const message =
"[@sereneinserenade/tiptap-inline-suggestions] Please add a fetchSuggestion function to fetch suggestions from.";
console.warn(message);
return message;
},
};
},
addStorage() {
return {
data: {},
};
},
addCommands() {
return {
fetchSuggestion:
() =>
({ state, chain, editor }) => {
if (this.storage.data.currentSuggestion) {
return chain()
.command(() => {
const chunkifiedSuggestion =
this.storage.data.currentSuggestion!.split("");
this.storage.data = {};
for (let i = 0; i < chunkifiedSuggestion.length; i++) {
setTimeout(
() =>
editor
.chain()
.insertContent(chunkifiedSuggestion[i])
.focus()
.run(),
2 * i
);
}
return true;
})
.run();
}
const { $from } = state.selection;
const node = $from.parent;
const [from, to] = [$from.start() - 1, $from.end() + 1];
const existingText = node.textContent;
if (existingText) {
this.options.fetchAutocompletion(existingText).then((res) => {
this.storage.data = {
currentSuggestion: res,
nodeDetails: {
from,
to,
},
};
editor.view.dispatch(
editor.view.state.tr.setMeta("addToHistory", false)
);
});
return true;
}
return false;
},
};
},
addProseMirrorPlugins() {
const getStorage = () => this.storage;
const fetchSuggestion = () => this.editor.commands.fetchSuggestion();
const handleNonTabKey = () => (this.storage.data = {});
return [
new Plugin({
key: inlineSuggestionPluginKey,
state: {
init() {
return DecorationSet.empty;
},
apply(tr) {
const storage = getStorage().data;
if (storage.currentSuggestion && storage.nodeDetails) {
const { from, to } = storage.nodeDetails;
const decoration = Decoration.node(from, to, {
"data-inline-suggestion": storage.currentSuggestion,
});
return DecorationSet.create(tr.doc, [decoration]);
}
return DecorationSet.empty;
},
},
props: {
decorations(state) {
return this.getState(state);
},
handleKeyDown(_, event) {
if (event.key === "Tab") {
event.preventDefault();
fetchSuggestion();
return true;
}
handleNonTabKey();
},
},
}),
];
},
});