-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Open
Labels
Open SourceThe issue or pull reuqest is related to the open source packages of Tiptap.The issue or pull reuqest is related to the open source packages of Tiptap.
Description
Affected Packages
core
Version(s)
3.13.0
Bug Description
Hi,
I am getting Applying a mismatched transaction ERROR, after trying to implement "auto complete" in editor.
Do you know the reason?
if (this.storage.data.currentSuggestion && this.storage.suggestionTriggerPoint) {
const suggestion = this.storage.data.currentSuggestion;
const trigger = this.storage.suggestionTriggerPoint;
this.storage.data = {};
this.storage.suggestionTriggerPoint = null;
const { from } = trigger;
const currentSelection = state.selection;
const commandChain = editor.chain().focus();
// Restore cursor to trigger point
if (currentSelection.from !== from || currentSelection.to !== from) {
commandChain.setTextSelection(from);
}
const { font, maxWidth } = getEditorFontInfo();
const blocks = splitIntoBlocks({ text: suggestion, maxWidth, font });
blocks.forEach((block, index) => {
if (block.length) {
commandChain.insertContent(block);
}
if (index < blocks.length - 1) {
commandChain.splitBlock();
}
});
commandChain.run();
return true;
}
This is the code snippet which is responsible for applying the transaction.
This is whole extension.
/* eslint-disable max-lines */
/* eslint-disable max-lines-per-function */
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { getEditorFontInfo, splitIntoBlocks } from "@/components/features/editor/editor.utils";
import type {
InlineSuggestionOptions,
InlineSuggestionStorage,
} from "@/components/features/editor/extensions/inline-suggestion/inline-suggestion.types";
import { fetchAutocompletion } from "@/components/features/editor/extensions/inline-suggestion/utils/fetch-autocompletion";
const SUGGESTION_DELAY = 500;
export const inlineSuggestionPluginKey = new PluginKey("inlineSuggestion");
export const InlineSuggestion = Extension.create<InlineSuggestionOptions, InlineSuggestionStorage>({
name: "inlineSuggestion",
addOptions() {
return {
enabled: true,
};
},
addStorage() {
return {
enabled: this.options.enabled ?? true,
inflightController: null,
debounceTimer: undefined,
suggestionTriggerPoint: null,
data: {},
};
},
onUpdate() {
this.storage.enabled = this.options.enabled ?? true;
},
addCommands() {
return {
setInlineSuggestionsEnabled:
(enabled: boolean) =>
({ editor }) => {
this.storage.enabled = enabled;
if (!enabled) {
this.storage.data = {};
this.storage.suggestionTriggerPoint = null;
if (this.storage.inflightController) {
try {
this.storage.inflightController.abort?.("disabled");
} catch {
// ignore
}
this.storage.inflightController = null;
}
if (this.storage.debounceTimer) {
clearTimeout(this.storage.debounceTimer);
this.storage.debounceTimer = undefined;
}
}
editor.view.dispatch(editor.view.state.tr.setMeta("addToHistory", false));
return true;
},
clearInlineSuggestion:
() =>
({ editor }) => {
this.storage.data = {};
this.storage.suggestionTriggerPoint = null;
if (this.storage.inflightController) {
try {
this.storage.inflightController.abort?.("cleared");
} catch {
// ignore
}
this.storage.inflightController = null;
}
if (this.storage.debounceTimer) {
clearTimeout(this.storage.debounceTimer);
this.storage.debounceTimer = undefined;
}
editor.view.dispatch(editor.view.state.tr.setMeta("addToHistory", false));
return true;
},
fetchSuggestion:
() =>
({ state, editor }) => {
if (this.storage.data.currentSuggestion && this.storage.suggestionTriggerPoint) {
const suggestion = this.storage.data.currentSuggestion;
const trigger = this.storage.suggestionTriggerPoint;
this.storage.data = {};
this.storage.suggestionTriggerPoint = null;
const { from } = trigger;
const currentSelection = state.selection;
const commandChain = editor.chain().focus();
// Restore cursor to trigger point
if (currentSelection.from !== from || currentSelection.to !== from) {
commandChain.setTextSelection(from);
}
const { font, maxWidth } = getEditorFontInfo();
const blocks = splitIntoBlocks({ text: suggestion, maxWidth, font });
blocks.forEach((block, index) => {
if (block.length) {
commandChain.insertContent(block);
}
if (index < blocks.length - 1) {
commandChain.splitBlock();
}
});
commandChain.run();
return true;
}
if (!this.storage.enabled) {
return false;
}
const { $from } = state.selection;
const node = $from.parent;
const existingText = node.textContent || "";
const cursorOffset = $from.parentOffset;
const title = (state.doc.firstChild?.textContent ?? "").trim() || "(Untitled)";
if (!existingText.trim()) {
return false;
}
if (this.storage.inflightController) {
try {
this.storage.inflightController.abort?.("cancelled");
} catch {
// ignolre
}
this.storage.inflightController = null;
}
const controller = new AbortController();
this.storage.inflightController = controller;
const triggerFrom = state.selection.from;
const blockId = node.attrs.id as string | undefined;
this.storage.suggestionTriggerPoint = {
from: triggerFrom,
blockId: blockId ?? null,
};
fetchAutocompletion({
data: {
existingText,
cursorOffset,
title,
},
signal: controller.signal,
onSuggestion: (suggestion: string) => {
const triggerPoint = this.storage.suggestionTriggerPoint?.from ?? triggerFrom;
const currentCursorPos = editor.state.selection.from;
//! Only show suggestion if cursor is still at trigger position
if (currentCursorPos !== triggerPoint) {
this.storage.data = {};
this.storage.inflightController = null;
return;
}
this.storage.data = {
currentSuggestion: suggestion,
nodeDetails: { from: triggerPoint, to: triggerPoint },
};
editor.view.dispatch(editor.view.state.tr.setMeta("addToHistory", false));
},
onComplete: () => {
const triggerPoint = this.storage.suggestionTriggerPoint?.from ?? triggerFrom;
const currentCursorPos = editor.state.selection.from;
if (currentCursorPos !== triggerPoint && this.storage.data.currentSuggestion) {
this.storage.data = {};
}
this.storage.inflightController = null;
editor.view.dispatch(editor.view.state.tr.setMeta("addToHistory", false));
},
onError: () => {
this.storage.data = {};
this.storage.inflightController = null;
editor.view.dispatch(editor.view.state.tr.setMeta("addToHistory", false));
},
});
return true;
},
};
},
addProseMirrorPlugins() {
const getStorage = () => this.storage;
const editor = this.editor;
const fetchSuggestion = () => editor.commands.fetchSuggestion();
const isInFirstParagraph = (state: typeof editor.state) => {
const { $from } = state.selection;
const firstChild = state.doc.firstChild;
const currentNode = $from.parent;
return firstChild === currentNode;
};
return [
new Plugin({
key: inlineSuggestionPluginKey,
state: {
init() {
return DecorationSet.empty;
},
apply(tr) {
const storage = getStorage().data;
if (storage.currentSuggestion && storage.nodeDetails) {
const { from } = storage.nodeDetails;
const suggestionText = storage.currentSuggestion ?? "";
const decoration = Decoration.widget(
from,
() => {
const span = document.createElement("span");
span.classList.add("inline-suggestion-placeholder");
span.setAttribute("data-inline-suggestion", suggestionText);
span.setAttribute("aria-hidden", "true");
span.style.whiteSpace = "pre-wrap";
if (suggestionText.length) {
const segments = suggestionText.split(/\n/u);
segments.forEach((segment, index) => {
if (segment.length) {
span.appendChild(document.createTextNode(segment));
}
if (index < segments.length - 1) {
span.appendChild(document.createElement("br"));
}
});
}
return span;
},
{ side: 1, ignoreSelection: true },
);
return DecorationSet.create(tr.doc, [decoration]);
}
return DecorationSet.empty;
},
},
props: {
decorations(state) {
return this.getState(state);
},
handleKeyDown(view, event) {
const storage = getStorage();
if (event.key === "Tab") {
event.preventDefault();
if (!storage.enabled) {
return false;
}
if (!storage.data.currentSuggestion) {
const { from } = view.state.selection;
const pos = view.state.doc.resolve(from);
const blockId = pos.parent.attrs.id as string;
storage.suggestionTriggerPoint = {
from,
blockId: blockId ?? null,
};
}
fetchSuggestion();
return true;
}
if (event.key === "Escape") {
if (storage.data.currentSuggestion) {
editor.commands.clearInlineSuggestion();
return true;
}
}
if (storage.data.currentSuggestion) {
storage.data = {};
view.dispatch(view.state.tr.setMeta("addToHistory", false));
}
return false;
},
handleTextInput(view, from, to, text) {
const storage = getStorage();
if (!storage.enabled) {
return false;
}
//! Don't trigger on deletions
if (from !== to) {
return false;
}
if (storage.debounceTimer) {
clearTimeout(storage.debounceTimer);
storage.debounceTimer = undefined;
}
if (storage.data.currentSuggestion) {
storage.data = {};
view.dispatch(view.state.tr.setMeta("addToHistory", false));
}
if (storage.inflightController) {
try {
storage.inflightController.abort?.("cancelled");
} catch {
// ignore
}
storage.inflightController = null;
}
const lastChar = text[text.length - 1] || "";
const isSpace = lastChar === " ";
// Only trigger on space
if (!isSpace) {
return false;
}
// Check if cursor is at end or followed by whitespace
const cursorPos = view.state.selection.from;
const $pos = view.state.doc.resolve(cursorPos);
const textAfterCursor = $pos.parent.textContent.slice($pos.parentOffset);
if (textAfterCursor.length > 0 && !/^\s/u.test(textAfterCursor)) {
return false;
}
storage.debounceTimer = setTimeout(() => {
storage.debounceTimer = undefined;
const currentState = view.state;
if (isInFirstParagraph(currentState)) {
return;
}
const triggerFrom = currentState.selection.from;
const resolvedPos = currentState.doc.resolve(triggerFrom);
const blockId = resolvedPos.parent.attrs.id as string;
storage.suggestionTriggerPoint = {
from: triggerFrom,
blockId: blockId ?? null,
};
fetchSuggestion();
}, SUGGESTION_DELAY);
return false;
},
handleClick(view, pos) {
const storage = getStorage();
if (storage.data.currentSuggestion) {
const resolvedPos = view.state.doc.resolve(pos);
const blockId = resolvedPos.parent.attrs.id as string;
storage.suggestionTriggerPoint = {
from: pos,
blockId: blockId ?? null,
};
}
return false;
},
},
}),
];
},
});
Browser Used
Other
Code Example URL
No response
Expected Behavior
It should work just fine without transaction mismatch error...
Additional Context (Optional)
I am using bun, nextjs@16.0.* tiptap@3.13.0 react@19
Dependency Updates
- Yes, I've updated all my dependencies.
Metadata
Metadata
Assignees
Labels
Open SourceThe issue or pull reuqest is related to the open source packages of Tiptap.The issue or pull reuqest is related to the open source packages of Tiptap.