chatbot-template/components/chat/text-editor.tsx

243 lines
6.9 KiB
TypeScript
Raw Normal View History

"use client";
2024-10-30 16:01:24 +05:30
import { exampleSetup } from "prosemirror-example-setup";
import { inputRules } from "prosemirror-inputrules";
import { EditorState } from "prosemirror-state";
import { type Decoration, DecorationSet, EditorView } from "prosemirror-view";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
2024-10-30 16:01:24 +05:30
import type { Suggestion } from "@/lib/db/schema";
2024-10-30 16:01:24 +05:30
import {
documentSchema,
handleTransaction,
headingRule,
} from "@/lib/editor/config";
import {
buildContentFromDocument,
buildDocumentFromContent,
createDecorations,
} from "@/lib/editor/functions";
import {
projectWithPositions,
2024-10-30 16:01:24 +05:30
suggestionsPlugin,
suggestionsPluginKey,
type UISuggestion,
} from "@/lib/editor/suggestions";
import { SuggestionDialog } from "./suggestion";
2024-10-30 16:01:24 +05:30
type EditorProps = {
content: string;
2025-01-27 14:19:47 +05:30
onSaveContent: (updatedContent: string, debounce: boolean) => void;
status: "streaming" | "idle";
isCurrentVersion: boolean;
2024-10-30 16:01:24 +05:30
currentVersionIndex: number;
suggestions: Suggestion[];
onSuggestionSelect?: (suggestion: UISuggestion | null) => void;
onSuggestionApply?: () => void;
activeSuggestion?: UISuggestion | null;
2024-10-30 16:01:24 +05:30
};
function PureEditor({
content,
2025-01-27 14:19:47 +05:30
onSaveContent,
suggestions,
status,
2024-10-30 16:01:24 +05:30
}: EditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<EditorView | null>(null);
const [activeSuggestion, setActiveSuggestion] = useState<UISuggestion | null>(
null
);
const suggestionsRef = useRef<UISuggestion[]>([]);
2024-10-30 16:01:24 +05:30
useEffect(() => {
if (containerRef.current && !editorRef.current) {
const state = EditorState.create({
doc: buildDocumentFromContent(content),
plugins: [
...exampleSetup({ schema: documentSchema, menuBar: false }),
inputRules({
rules: [
headingRule(1),
headingRule(2),
headingRule(3),
headingRule(4),
headingRule(5),
headingRule(6),
],
}),
suggestionsPlugin,
],
});
editorRef.current = new EditorView(containerRef.current, {
state,
handleDOMEvents: {
click(_view, event) {
const target = event.target as HTMLElement;
const highlight = target.closest(".suggestion-highlight");
if (highlight) {
const id = highlight.getAttribute("data-suggestion-id");
const found = suggestionsRef.current.find((s) => s.id === id);
if (found) {
setActiveSuggestion(found);
}
return true;
}
return false;
},
},
});
2024-10-30 16:01:24 +05:30
}
return () => {
if (editorRef.current) {
editorRef.current.destroy();
editorRef.current = null;
2024-10-30 16:01:24 +05:30
}
};
}, [content]);
2024-10-30 16:01:24 +05:30
useEffect(() => {
if (editorRef.current) {
editorRef.current.setProps({
dispatchTransaction: (transaction) => {
2025-01-27 14:19:47 +05:30
handleTransaction({
transaction,
editorRef,
onSaveContent,
});
},
});
2024-10-30 16:01:24 +05:30
}
2025-01-27 14:19:47 +05:30
}, [onSaveContent]);
2024-10-30 16:01:24 +05:30
useEffect(() => {
if (editorRef.current && content) {
const currentContent = buildContentFromDocument(
editorRef.current.state.doc
2024-10-30 16:01:24 +05:30
);
if (status === "streaming") {
const newDocument = buildDocumentFromContent(content);
2024-10-30 16:01:24 +05:30
const transaction = editorRef.current.state.tr.replaceWith(
0,
editorRef.current.state.doc.content.size,
newDocument.content
);
transaction.setMeta("no-save", true);
editorRef.current.dispatch(transaction);
return;
}
2024-10-30 16:01:24 +05:30
if (currentContent !== content) {
const newDocument = buildDocumentFromContent(content);
2024-10-30 16:01:24 +05:30
const transaction = editorRef.current.state.tr.replaceWith(
0,
editorRef.current.state.doc.content.size,
newDocument.content
);
transaction.setMeta("no-save", true);
editorRef.current.dispatch(transaction);
2024-10-30 16:01:24 +05:30
}
}
}, [content, status]);
useEffect(() => {
2024-11-15 12:18:17 -05:00
if (editorRef.current?.state.doc && content) {
const projectedSuggestions = projectWithPositions(
editorRef.current.state.doc,
suggestions
).filter(
(suggestion) => suggestion.selectionStart && suggestion.selectionEnd
);
2024-10-30 16:01:24 +05:30
suggestionsRef.current = projectedSuggestions;
const decorations = createDecorations(
projectedSuggestions,
editorRef.current
);
const transaction = editorRef.current.state.tr;
transaction.setMeta(suggestionsPluginKey, { decorations });
editorRef.current.dispatch(transaction);
}
}, [suggestions, content]);
const handleApply = useCallback(() => {
if (!editorRef.current || !activeSuggestion) {
return;
}
const { state, dispatch } = editorRef.current;
const currentState = suggestionsPluginKey.getState(state);
const currentDecorations = currentState?.decorations;
if (currentDecorations) {
const newDecorations = DecorationSet.create(
state.doc,
currentDecorations.find().filter((decoration: Decoration) => {
return decoration.spec.suggestionId !== activeSuggestion.id;
})
);
const decorationTransaction = state.tr;
decorationTransaction.setMeta(suggestionsPluginKey, {
decorations: newDecorations,
selected: null,
});
dispatch(decorationTransaction);
}
const textTransaction = editorRef.current.state.tr.replaceWith(
activeSuggestion.selectionStart,
activeSuggestion.selectionEnd,
state.schema.text(activeSuggestion.suggestedText)
);
textTransaction.setMeta("no-debounce", true);
dispatch(textTransaction);
setActiveSuggestion(null);
}, [activeSuggestion]);
return (
<>
<div
className="prose dark:prose-invert prose-neutral relative max-w-none"
ref={containerRef}
/>
{activeSuggestion &&
containerRef.current?.closest("[data-slot='artifact-content']") &&
createPortal(
<SuggestionDialog
onApply={handleApply}
onClose={() => setActiveSuggestion(null)}
suggestion={activeSuggestion}
/>,
containerRef.current.closest(
"[data-slot='artifact-content']"
) as HTMLElement
)}
</>
);
2024-10-30 16:01:24 +05:30
}
function areEqual(prevProps: EditorProps, nextProps: EditorProps) {
2024-11-15 12:18:17 -05:00
return (
prevProps.suggestions === nextProps.suggestions &&
prevProps.currentVersionIndex === nextProps.currentVersionIndex &&
prevProps.isCurrentVersion === nextProps.isCurrentVersion &&
!(prevProps.status === "streaming" && nextProps.status === "streaming") &&
2024-11-15 12:18:17 -05:00
prevProps.content === nextProps.content &&
2025-01-27 14:19:47 +05:30
prevProps.onSaveContent === nextProps.onSaveContent
2024-11-15 12:18:17 -05:00
);
2024-10-30 16:01:24 +05:30
}
export const Editor = memo(PureEditor, areEqual);