opengrammer
Version:
A grammar checker npm package that scans text for grammar mistakes and provides suggestions with visual indicators
511 lines (425 loc) • 15.9 kB
JavaScript
// TipTap/ProseMirror integration for OpenGrammer
// Requires @tiptap/pm to be installed as a peer dependency
import { checkAndFormat } from "./index.js";
import { UpdateStyle } from "./decorationhandler.js";
/**
* Setup grammar checking for a TipTap editor
*
* Requires @tiptap/pm to be installed: npm install @tiptap/pm
*
* @param {Editor} editor - TipTap editor instance
* @param {Object} options - Configuration options
* @param {Object} options.decorations - Optional: Decoration and DecorationSet from @tiptap/pm/view
* @returns {Object} Object with check, destroy methods
*/
export function setupTipTap(editor, options = {}) {
console.error(
"'setupTipTap' Been renamed in latest version please switch to 'GrammerCheckContentTipTap' ",
);
return GrammerCheckContentTipTap(editor, options);
}
export function GrammerCheckContentTipTap(editor, options = {}) {
if (!editor || !editor.view) {
console.warn("setupTipTap requires a valid TipTap editor instance");
return;
}
// Get Decoration and DecorationSet
// User can pass them in options, or we try to import
let Decoration, DecorationSet;
if (options.decorations) {
Decoration = options.decorations.Decoration;
DecorationSet = options.decorations.DecorationSet;
} else {
// Try to import - will fail if @tiptap/pm not installed
// User should import and pass: import { Decoration, DecorationSet } from '@tiptap/pm/view'
try {
// This is a dynamic import that will work if the module is available
// We'll handle this at runtime
console.warn(
"TipTap integration: Please import Decoration and DecorationSet from @tiptap/pm/view",
);
console.warn(
'Example: import { Decoration, DecorationSet } from "@tiptap/pm/view"',
);
console.warn(
"Then pass them: setupTipTap(editor, { decorations: { Decoration, DecorationSet } })",
);
return {
check: () =>
console.warn(
"TipTap integration not initialized - missing Decoration/DecorationSet",
),
destroy: () => {},
getErrors: () => [],
getDecorations: () => null,
};
} catch (e) {
console.error("Failed to initialize TipTap integration:", e);
return;
}
}
if (!Decoration || !DecorationSet) {
console.warn(
"Decoration and DecorationSet are required for TipTap integration",
);
return;
}
const {
debounceMs = 1000,
autoCheckOnLoad = true,
autoCheckOnBlur = true,
debug = false,
decorationClass = "grammar-error",
decorationStyle = {},
tooltipStyle = {},
tooltipMessageStyle = {},
tooltipSuggestionsStyle = {},
tooltipLabelStyle = {},
tooltipListStyle = {},
tooltipItemStyle = {},
decorations: _decorations, // Remove from options
} = options;
// Helper to convert style object to CSS string
function styleObjectToString(styleObj) {
if (typeof styleObj === "string") {
return styleObj;
}
if (typeof styleObj === "object" && styleObj !== null) {
return Object.entries(styleObj)
.map(([key, value]) => {
const kebabKey = key.replace(/([A-Z])/g, "-$1").toLowerCase();
return `${kebabKey}: ${value}`;
})
.join("; ");
}
return "";
}
// Default decoration styles
const defaultDecorationStyle = UpdateStyle("grammarerror", decorationStyle);
// Default tooltip styles
const defaultTooltipStyle = UpdateStyle("TooltipStyle", tooltipStyle);
const defaultMessageStyle = UpdateStyle(
"defaultMessageStyle",
tooltipMessageStyle,
);
const defaultSuggestionsStyle = UpdateStyle(
"defaultSuggestionsStyle",
tooltipSuggestionsStyle,
);
const defaultLabelStyle = UpdateStyle("defaultLabelStyle", tooltipLabelStyle);
const defaultListStyle = UpdateStyle("defaultListStyle", tooltipListStyle);
const defaultItemStyle = UpdateStyle("defaultItemStyle", tooltipItemStyle);
let isChecking = false;
let debounceTimer = null;
let grammarErrors = [];
let decorationSet = DecorationSet.empty;
let tooltipHandlers = null;
function log(...args) {
if (debug) {
console.log("[OpenGrammer TipTap]", ...args);
}
}
// Build plain text from document structure (matching how we count in charOffsetToProseMirrorPos)
// This ensures consistency between text extraction and position mapping
function getPlainTextFromDoc() {
let text = '';
const doc = editor.state.doc;
// Walk through nodes in document order - this MUST match charOffsetToProseMirrorPos
doc.nodesBetween(0, doc.content.size, (node) => {
if (node.isText) {
text += node.textContent;
}
return true;
});
return text;
}
// Convert character offset to ProseMirror position
// This function MUST visit nodes in the exact same order as getPlainTextFromDoc()
function charOffsetToProseMirrorPos(charOffset) {
const doc = editor.state.doc;
let charCount = 0;
let pmPos = 1;
let found = false;
// Walk through nodes in the exact same order as getPlainTextFromDoc()
// This ensures character offsets map correctly to ProseMirror positions
doc.nodesBetween(0, doc.content.size, (node, nodePos) => {
if (node.isText) {
const nodeLength = node.textContent.length;
// Check if our target offset is within this text node
if (!found && charCount <= charOffset && charOffset < charCount + nodeLength) {
// Calculate the offset within this text node
const offsetInNode = charOffset - charCount;
// nodePos is the absolute ProseMirror position where this text node starts
// Add the character offset within the node to get the exact position
pmPos = nodePos + offsetInNode;
found = true;
return false; // Stop iterating
}
// Accumulate character count (matching getPlainTextFromDoc)
charCount += nodeLength;
}
return true; // Continue iterating
});
// Fallback: if offset is beyond all text, place at end of document
if (!found && charCount > 0 && charOffset >= charCount) {
pmPos = doc.content.size;
}
// Clamp to valid document bounds
const validPos = Math.max(1, Math.min(pmPos, doc.content.size));
return validPos;
}
// Create decorations from errors
function createDecorations(errors) {
const decorations = [];
const doc = editor.state.doc;
errors.forEach((error) => {
try {
const startPos = charOffsetToProseMirrorPos(error.startIndex);
const endPos = charOffsetToProseMirrorPos(error.endIndex);
// Ensure positions are valid
const maxPos = doc.content.size;
const validStartPos = Math.max(1, Math.min(startPos, maxPos));
const validEndPos = Math.max(1, Math.min(endPos, maxPos));
if (error.startIndex < error.endIndex) {
log(
`Error at char ${error.startIndex}-${error.endIndex} mapped to pos ${validStartPos}-${validEndPos}`,
);
const decoration = Decoration.inline(validStartPos, validEndPos, {
class: decorationClass,
style: styleObjectToString(defaultDecorationStyle),
"data-error-id": error.id || String(error.startIndex),
"data-suggestions": JSON.stringify(error.suggestions || []),
"data-message": error.message || "Grammar error",
});
decorations.push(decoration);
}
} catch (e) {
log("Error creating decoration:", error, e);
}
});
return DecorationSet.create(doc, decorations);
}
// Setup tooltip handlers
function setupTooltips() {
const editorDom = editor.view.dom;
if (!editorDom || tooltipHandlers) return;
function handleMouseEnter(e) {
const target = e.target;
const errorEl = target.closest(`.${decorationClass}`);
if (errorEl) {
const suggestions = JSON.parse(
errorEl.getAttribute("data-suggestions") || "[]",
);
const message = errorEl.getAttribute("data-message") || "Grammar error";
// Remove existing tooltip
const existingTooltip = document.querySelector(".grammar-tooltip");
if (existingTooltip) existingTooltip.remove();
// Create tooltip element
const tooltip = document.createElement("div");
tooltip.className = "grammar-tooltip";
const tooltipStyleStr = styleObjectToString(defaultTooltipStyle);
if (tooltipStyleStr) {
tooltip.style.cssText = tooltipStyleStr;
}
const messageEl = document.createElement("div");
messageEl.className = "grammar-tooltip-message";
const messageStyleStr = styleObjectToString(defaultMessageStyle);
if (messageStyleStr) {
messageEl.style.cssText = messageStyleStr;
}
messageEl.textContent = message;
tooltip.appendChild(messageEl);
if (suggestions && suggestions.length > 0) {
const suggestionsEl = document.createElement("div");
suggestionsEl.className = "grammar-tooltip-suggestions";
const suggestionsStyleStr = styleObjectToString(
defaultSuggestionsStyle,
);
if (suggestionsStyleStr) {
suggestionsEl.style.cssText = suggestionsStyleStr;
}
const suggestionsLabel = document.createElement("div");
suggestionsLabel.className = "grammar-tooltip-label";
const labelStyleStr = styleObjectToString(defaultLabelStyle);
if (labelStyleStr) {
suggestionsLabel.style.cssText = labelStyleStr;
}
suggestionsLabel.textContent = "Suggestions:";
suggestionsEl.appendChild(suggestionsLabel);
const suggestionsList = document.createElement("ul");
suggestionsList.className = "grammar-tooltip-list";
const listStyleStr = styleObjectToString(defaultListStyle);
if (listStyleStr) {
suggestionsList.style.cssText = listStyleStr;
}
suggestions.forEach((suggestion) => {
const li = document.createElement("li");
const itemStyleStr = styleObjectToString(defaultItemStyle);
if (itemStyleStr) {
li.style.cssText = itemStyleStr;
}
li.textContent = suggestion;
suggestionsList.appendChild(li);
});
suggestionsEl.appendChild(suggestionsList);
tooltip.appendChild(suggestionsEl);
}
document.body.appendChild(tooltip);
// Position tooltip
const rect = errorEl.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const padding = 10;
let left = rect.left + rect.width / 2 - tooltipRect.width / 2;
let top = rect.top - tooltipRect.height - padding;
// Keep tooltip on screen
if (left < padding) {
left = padding;
} else if (left + tooltipRect.width > window.innerWidth - padding) {
left = window.innerWidth - tooltipRect.width - padding;
}
if (top < padding) {
top = rect.bottom + padding;
}
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
// Fade in animation
tooltip.style.opacity = "0";
tooltip.style.transform = "translateY(-5px)";
tooltip.style.transition = "opacity 0.2s ease, transform 0.2s ease";
setTimeout(() => {
tooltip.style.opacity = "1";
tooltip.style.transform = "translateY(0)";
}, 10);
}
}
function handleMouseLeave() {
const tooltip = document.querySelector(".grammar-tooltip");
if (tooltip) {
tooltip.remove();
}
}
editorDom.addEventListener("mouseenter", handleMouseEnter, true);
editorDom.addEventListener("mouseleave", handleMouseLeave, true);
tooltipHandlers = { handleMouseEnter, handleMouseLeave };
log("Tooltip handlers attached");
}
// Main grammar check function
function checkGrammar() {
if (isChecking) {
log("Already checking, skipping...");
return;
}
isChecking = true;
// Use consistent text extraction method that matches position mapping
const plainText = getPlainTextFromDoc();
const textLength = plainText.length;
const docSize = editor.state.doc.content.size;
log("Checking grammar...", { textLength, docSize });
const startTime = performance.now();
const result = checkAndFormat(plainText);
const checkTime = performance.now() - startTime;
log(
`Found ${result.errors.length} error(s) in ${checkTime.toFixed(2)}ms`,
result.errors,
);
// Debug: Log position mappings for errors
if (result.errors.length > 0 && debug) {
result.errors.forEach((error) => {
const startPos = charOffsetToProseMirrorPos(error.startIndex);
const endPos = charOffsetToProseMirrorPos(error.endIndex);
log(`Error "${error.text}" at char ${error.startIndex}-${error.endIndex} mapped to PM pos ${startPos}-${endPos} (doc size: ${docSize})`);
});
}
grammarErrors = result.errors;
decorationSet = createDecorations(result.errors);
// Apply decorations to editor
const currentDecorations = editor.view.props.decorations;
editor.view.setProps({
decorations: (state) => {
// If there are existing decorations, merge them
// For now, just return grammar decorations
// Override this if you need to preserve other decorations
return decorationSet;
},
});
// Setup tooltips if not already done
if (!tooltipHandlers) {
setupTooltips();
}
isChecking = false;
}
// Debounced check
function debouncedCheck() {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
log("Editor updated, scheduling grammar check");
debounceTimer = setTimeout(() => {
log("Debounce timeout reached, checking grammar");
checkGrammar();
}, debounceMs);
}
// Handle blur
function handleBlur() {
log("Editor blurred, checking grammar");
if (debounceTimer) {
clearTimeout(debounceTimer);
}
checkGrammar();
}
// Setup event listeners
const editorDom = editor.view.dom;
editor.on("update", debouncedCheck);
if (autoCheckOnBlur && editorDom) {
editorDom.addEventListener("blur", handleBlur);
}
if (autoCheckOnLoad) {
log("Initializing grammar checker on load");
setTimeout(() => {
checkGrammar();
}, 500);
}
log("TipTap grammar checker initialized", {
debounceMs,
autoCheckOnLoad,
autoCheckOnBlur,
debug,
});
// Return API
return {
check: checkGrammar,
destroy: () => {
log("Destroying TipTap grammar checker");
if (debounceTimer) {
clearTimeout(debounceTimer);
}
editor.off("update", debouncedCheck);
if (editorDom) {
editorDom.removeEventListener("blur", handleBlur);
if (tooltipHandlers) {
editorDom.removeEventListener(
"mouseenter",
tooltipHandlers.handleMouseEnter,
true,
);
editorDom.removeEventListener(
"mouseleave",
tooltipHandlers.handleMouseLeave,
true,
);
tooltipHandlers = null;
}
// Remove tooltips
document
.querySelectorAll(".grammar-tooltip")
.forEach((el) => el.remove());
// Clear decorations
editor.view.setProps({ decorations: undefined });
}
decorationSet = DecorationSet.empty;
grammarErrors = [];
},
getErrors: () => grammarErrors,
getDecorations: () => decorationSet,
};
}