From 628695ffbfe2b24d44ed651bf331644efb82d830 Mon Sep 17 00:00:00 2001 From: will-lp1 Date: Wed, 16 Jul 2025 12:50:30 -0700 Subject: [PATCH 1/4] feat: reduce the size of the editor, moving plugins into their own dedicated files --- .../components/document/text-editor.tsx | 777 ++---------------- .../suggestion-overlay-provider.tsx | 2 +- apps/snow-leopard/lib/editor/diff-plugin.ts | 119 +++ .../snow-leopard/lib/editor/editor-plugins.ts | 44 + apps/snow-leopard/lib/editor/format-plugin.ts | 88 ++ .../lib/editor/inline-suggestion-plugin.ts | 105 ++- .../lib/editor/placeholder-plugin.ts | 42 +- apps/snow-leopard/lib/editor/save-plugin.ts | 74 +- ...context-plugin.ts => suggestion-plugin.ts} | 96 ++- 9 files changed, 601 insertions(+), 746 deletions(-) create mode 100644 apps/snow-leopard/lib/editor/diff-plugin.ts create mode 100644 apps/snow-leopard/lib/editor/editor-plugins.ts create mode 100644 apps/snow-leopard/lib/editor/format-plugin.ts rename apps/snow-leopard/lib/editor/{selection-context-plugin.ts => suggestion-plugin.ts} (57%) diff --git a/apps/snow-leopard/components/document/text-editor.tsx b/apps/snow-leopard/components/document/text-editor.tsx index d905881f..7df42638 100644 --- a/apps/snow-leopard/components/document/text-editor.tsx +++ b/apps/snow-leopard/components/document/text-editor.tsx @@ -1,76 +1,25 @@ "use client"; -import { exampleSetup } from "prosemirror-example-setup"; -import { inputRules } from "prosemirror-inputrules"; import { EditorState, Transaction } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import React, { memo, useEffect, useRef, useCallback, useState } from "react"; -import { toast } from "sonner"; import { documentSchema, headingRule } from "@/lib/editor/config"; -import { - buildContentFromDocument, - buildDocumentFromContent, -} from "@/lib/editor/functions"; - +import { buildContentFromDocument, buildDocumentFromContent } from "@/lib/editor/functions"; import { setActiveEditorView } from "@/lib/editor/editor-state"; -import { useAiOptions, useAiOptionsValue } from "@/hooks/ai-options"; - -import { - inlineSuggestionPlugin, - inlineSuggestionPluginKey, - START_SUGGESTION_LOADING, - SET_SUGGESTION, - CLEAR_SUGGESTION, - FINISH_SUGGESTION_LOADING, -} from "@/lib/editor/inline-suggestion-plugin"; - -import { placeholderPlugin } from "@/lib/editor/placeholder-plugin"; +import { EditorToolbar } from "@/components/document/editor-toolbar"; import { - savePlugin, savePluginKey, setSaveStatus, + createSaveFunction, + createForceSaveHandler, type SaveState, - type SaveStatus, } from "@/lib/editor/save-plugin"; +import { createEditorPlugins } from "@/lib/editor/editor-plugins"; +import { createInlineSuggestionCallback } from "@/lib/editor/inline-suggestion-plugin"; +import { type FormatState } from "@/lib/editor/format-plugin"; -import { synonymsPlugin } from "@/lib/editor/synonym-plugin"; -import { EditorToolbar } from "@/components/document/editor-toolbar"; -import { creationStreamingPlugin } from "@/lib/editor/creation-streaming-plugin"; -import { selectionContextPlugin } from "@/lib/editor/selection-context-plugin"; -import { diffEditor } from "@/lib/editor/diff"; - -const { nodes, marks } = documentSchema; - -function isMarkActive(state: EditorState, type: any): boolean { - const { from, $from, to, empty } = state.selection; - if (empty) { - return !!type.isInSet(state.storedMarks || $from.marks()); - } else { - return state.doc.rangeHasMark(from, to, type); - } -} - -function isBlockActive( - state: EditorState, - type: any, - attrs: Record = {} -): boolean { - const { $from } = state.selection; - const node = $from.node($from.depth); - return node?.hasMarkup(type, attrs); -} - -function isListActive(state: EditorState, type: any): boolean { - const { $from } = state.selection; - for (let d = $from.depth; d > 0; d--) { - if ($from.node(d).type === type) { - return true; - } - } - return false; -} type EditorProps = { content: string; @@ -96,239 +45,39 @@ function PureEditor({ const containerRef = useRef(null); const editorRef = useRef(null); const currentDocumentIdRef = useRef(documentId); - const [activeFormats, setActiveFormats] = useState>( - {} - ); - const abortControllerRef = useRef(null); - const savePromiseRef = useRef | void> | null>( - null - ); - - const previewOriginalContentRef = useRef(null); - const previewActiveRef = useRef(false); - const lastPreviewContentRef = useRef(null); - - const { suggestionLength, customInstructions, writingStyleSummary, applyStyle } = useAiOptionsValue(); - const suggestionLengthRef = useRef(suggestionLength); - const customInstructionsRef = useRef(customInstructions); - const writingStyleSummaryRef = useRef(writingStyleSummary); - const applyStyleRef = useRef(applyStyle); - useEffect(() => { - suggestionLengthRef.current = suggestionLength; - }, [suggestionLength]); - useEffect(() => { - customInstructionsRef.current = customInstructions; - }, [customInstructions]); - useEffect(() => { - writingStyleSummaryRef.current = writingStyleSummary; - }, [writingStyleSummary]); - useEffect(() => { - applyStyleRef.current = applyStyle; - }, [applyStyle]); + + const [activeFormats, setActiveFormats] = useState({ + h1: false, + h2: false, + p: false, + bulletList: false, + orderedList: false, + bold: false, + italic: false, + }); useEffect(() => { currentDocumentIdRef.current = documentId; }, [documentId]); - const performSave = useCallback( - async ( - contentToSave: string - ): Promise<{ updatedAt: string | Date } | null> => { - const docId = currentDocumentIdRef.current; - if ( - !docId || - docId === "init" || - docId === "undefined" || - docId === "null" - ) { - console.warn( - "[Editor Save Callback] Attempted to save with invalid or init documentId:", - docId - ); - throw new Error("Cannot save with invalid or initial document ID."); - } - - try { - const response = await fetch(`/api/document`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - id: docId, - content: contentToSave, - }), - }); - - if (!response.ok) { - const errorData = await response - .json() - .catch(() => ({ error: "Unknown API error" })); - console.error( - `[Editor Save Callback] Save failed: ${response.status}`, - errorData - ); - throw new Error( - `API Error: ${errorData.error || response.statusText}` - ); - } - - const result = await response.json(); - return { updatedAt: result.updatedAt || new Date().toISOString() }; - } catch (error) { - console.error( - `[Editor Save Callback] Error during save for ${docId}:`, - error - ); - throw error; - } - }, - [] - ); - + const performSave = useCallback(createSaveFunction(currentDocumentIdRef), []); const requestInlineSuggestionCallback = useCallback( - async (state: EditorState) => { - const editor = editorRef.current; - if (!editor) return; - - abortControllerRef.current?.abort(); - const controller = new AbortController(); - abortControllerRef.current = controller; - - try { - const { selection } = state; - const { head } = selection; - - const $head = state.doc.resolve(head); - const startOfNode = $head.start(); - const contextBefore = state.doc.textBetween(startOfNode, head, "\n"); - const endOfNode = $head.end(); - const contextAfter = state.doc.textBetween(head, endOfNode, "\n"); - const fullContent = state.doc.textContent; - - const response = await fetch("/api/inline-suggestion", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - documentId, - contextBefore, - contextAfter, - fullContent, - nodeType: "paragraph", - aiOptions: { - suggestionLength: suggestionLengthRef.current, - customInstructions: customInstructionsRef.current, - writingStyleSummary: writingStyleSummaryRef.current, - applyStyle: applyStyleRef.current, - }, - }), - signal: controller.signal, - }); - - if (!response.ok || !response.body) { - throw new Error(`API error: ${response.statusText}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let accumulatedSuggestion = ""; - let receivedAnyData = false; - - while (true) { - const { done, value } = await reader.read(); - if (done || controller.signal.aborted) break; - - const chunk = decoder.decode(value, { stream: true }); - const lines = chunk.split("\n\n"); - - for (const line of lines) { - if (line.startsWith("data: ")) { - try { - const data = JSON.parse(line.slice(5)); - if (data.type === "suggestion-delta") { - accumulatedSuggestion += data.content; - receivedAnyData = true; - if (editorRef.current) { - editorRef.current.dispatch( - editorRef.current.state.tr.setMeta(SET_SUGGESTION, { - text: accumulatedSuggestion, - }) - ); - } - } else if (data.type === "error") { - throw new Error(data.content); - } else if (data.type === "finish") { - break; - } - } catch (err) { - console.warn("Error parsing SSE line:", line, err); - } - } - } - } - if (!controller.signal.aborted && editorRef.current) { - editorRef.current.dispatch( - editorRef.current.state.tr.setMeta(FINISH_SUGGESTION_LOADING, true) - ); - } else if (controller.signal.aborted) { - if (editorRef.current) { - editorRef.current.dispatch( - editorRef.current.state.tr.setMeta(CLEAR_SUGGESTION, true) - ); - } - } - } catch (error: any) { - if (error.name !== "AbortError") { - console.error( - "[Editor Component] Error fetching inline suggestion:", - error - ); - toast.error(`Suggestion error: ${error.message}`); - if (editorRef.current) { - editorRef.current.dispatch( - editorRef.current.state.tr.setMeta(CLEAR_SUGGESTION, true) - ); - } - } - } finally { - if (abortControllerRef.current === controller) { - abortControllerRef.current = null; - } - } - }, - [editorRef, documentId] + createInlineSuggestionCallback(documentId), + [documentId] ); useEffect(() => { let view: EditorView | null = null; if (containerRef.current && !editorRef.current) { - const plugins = [ - creationStreamingPlugin(documentId), - placeholderPlugin( - documentId === "init" ? "Start typing" : "Start typing..." - ), - ...exampleSetup({ schema: documentSchema, menuBar: false }), - inputRules({ - rules: [ - headingRule(1), - headingRule(2), - headingRule(3), - headingRule(4), - headingRule(5), - headingRule(6), - ], - }), - inlineSuggestionPlugin({ - requestSuggestion: requestInlineSuggestionCallback, - }), - selectionContextPlugin(), - synonymsPlugin(), - savePlugin({ - saveFunction: performSave, - initialLastSaved: initialLastSaved, - debounceMs: 200, - documentId: documentId, - }), - ]; + const plugins = createEditorPlugins({ + documentId, + initialLastSaved, + performSave, + requestInlineSuggestion: (state) => + requestInlineSuggestionCallback(state, abortControllerRef, editorRef), + setActiveFormats, + }); const initialEditorState = EditorState.create({ doc: buildDocumentFromContent(content), @@ -350,23 +99,10 @@ function PureEditor({ const oldEditorState = editorView.state; const oldSaveState = savePluginKey.getState(oldEditorState); - const newState = editorView.state.apply(transaction); - editorView.updateState(newState); - setActiveFormats({ - h1: isBlockActive(newState, nodes.heading, { level: 1 }), - h2: isBlockActive(newState, nodes.heading, { level: 2 }), - p: isBlockActive(newState, nodes.paragraph), - bulletList: isListActive(newState, nodes.bullet_list), - orderedList: isListActive(newState, nodes.ordered_list), - bold: isMarkActive(newState, marks.strong), - italic: isMarkActive(newState, marks.em), - }); - const newSaveState = savePluginKey.getState(newState); - if (onStatusChange && newSaveState && newSaveState !== oldSaveState) { onStatusChange(newSaveState); } @@ -377,7 +113,6 @@ function PureEditor({ onCreateDocumentRequest ) { onCreateDocumentRequest(newSaveState.initialContent); - setTimeout(() => { if (editorView) { setSaveStatus(editorView, { createDocument: false }); @@ -394,70 +129,32 @@ function PureEditor({ if (onStatusChange && initialSaveState) { onStatusChange(initialSaveState); } - - setActiveFormats({ - h1: isBlockActive(initialEditorState, nodes.heading, { level: 1 }), - h2: isBlockActive(initialEditorState, nodes.heading, { level: 2 }), - p: isBlockActive(initialEditorState, nodes.paragraph), - bulletList: isListActive(initialEditorState, nodes.bullet_list), - orderedList: isListActive(initialEditorState, nodes.ordered_list), - bold: isMarkActive(initialEditorState, marks.strong), - italic: isMarkActive(initialEditorState, marks.em), - }); } else if (editorRef.current) { const currentView = editorRef.current; - const currentDocId = currentDocumentIdRef.current; - - if (documentId !== currentDocId) { - // Re-create plugins with the new documentId to ensure save plugin has correct doc ID - const newPlugins = [ - creationStreamingPlugin(documentId), - placeholderPlugin( - documentId === "init" ? "Start typing" : "Start typing..." - ), - ...exampleSetup({ schema: documentSchema, menuBar: false }), - inputRules({ - rules: [ - headingRule(1), - headingRule(2), - headingRule(3), - headingRule(4), - headingRule(5), - headingRule(6), - ], - }), - inlineSuggestionPlugin({ - requestSuggestion: requestInlineSuggestionCallback, - }), - selectionContextPlugin(), - synonymsPlugin(), - savePlugin({ - saveFunction: performSave, - initialLastSaved: initialLastSaved, - debounceMs: 200, - documentId: documentId, - }), - ]; - const newDoc = buildDocumentFromContent(content); + if (documentId !== currentDocumentIdRef.current) { + const newPlugins = createEditorPlugins({ + documentId, + initialLastSaved, + performSave, + requestInlineSuggestion: (state) => + requestInlineSuggestionCallback(state, abortControllerRef, editorRef), + setActiveFormats, + }); - // Create a completely new state and update the view + const newDoc = buildDocumentFromContent(content); const newState = EditorState.create({ doc: newDoc, plugins: newPlugins, }); currentView.updateState(newState); } else { - // Document ID is the same, check for external content updates const currentContent = buildContentFromDocument(currentView.state.doc); if (content !== currentContent) { const saveState = savePluginKey.getState(currentView.state); if (saveState?.isDirty) { - console.warn( - "[Editor] External content update received, but editor is dirty. Ignoring update." - ); + console.warn("[Editor] External content update received, but editor is dirty. Ignoring update."); } else { - console.log("[Editor] Content update for same document."); const newDocument = buildDocumentFromContent(content); const transaction = currentView.state.tr.replaceWith( 0, @@ -477,12 +174,7 @@ function PureEditor({ } return () => { - if (editorRef.current && !view) { - // console.log('[Editor] Destroying view on cleanup (not re-init)'); - // editorRef.current.destroy(); - // editorRef.current = null; - } else if (view) { - console.log("[Editor] Destroying view on effect re-run/unmount"); + if (view) { view.destroy(); if (editorRef.current === view) { editorRef.current = null; @@ -504,209 +196,12 @@ function PureEditor({ requestInlineSuggestionCallback, ]); - useEffect(() => { - const handleApplySuggestion = (event: CustomEvent) => { - if (!editorRef.current || !event.detail) return; - const editorView = editorRef.current; - const { state, dispatch } = editorView; - - const { - from, - to, - suggestion, - documentId: suggestionDocId, - } = event.detail; - - if (suggestionDocId !== documentId) { - console.warn( - `[Editor apply-suggestion] Event ignored: Document ID mismatch. Expected ${documentId}, got ${suggestionDocId}.` - ); - return; - } - - if ( - typeof from !== "number" || - typeof to !== "number" || - from < 0 || - to > state.doc.content.size || - from > to - ) { - console.error( - `[Editor apply-suggestion] Invalid range received: [${from}, ${to}]. Document size: ${state.doc.content.size}` - ); - toast.error("Cannot apply suggestion: Invalid text range."); - return; - } - - console.log( - `[Editor apply-suggestion] Event received for doc: ${documentId}` - ); - console.log( - `[Editor apply-suggestion] Applying suggestion "${suggestion}" at range [${from}, ${to}]` - ); - - try { - const transaction = state.tr.replaceWith( - from, - to, - state.schema.text(suggestion) - ); - - dispatch(transaction); - - toast.success("Suggestion applied"); - } catch (error) { - console.error( - `[Editor apply-suggestion] Error applying transaction:`, - error - ); - toast.error("Failed to apply suggestion."); - } - }; - - window.addEventListener( - "apply-suggestion", - handleApplySuggestion as EventListener - ); - return () => - window.removeEventListener( - "apply-suggestion", - handleApplySuggestion as EventListener - ); - }, [documentId]); - - useEffect(() => { - const handlePreviewUpdate = (event: CustomEvent) => { - if (!editorRef.current || !event.detail) return; - const { documentId: previewDocId, newContent } = event.detail; - if (previewDocId !== documentId) return; - - const editorView = editorRef.current; - - if (lastPreviewContentRef.current === newContent) return; // no-op if same preview - - if (!previewActiveRef.current) { - // store original content only once per preview lifecycle - previewOriginalContentRef.current = buildContentFromDocument(editorView.state.doc); - } - - const oldContent = previewOriginalContentRef.current ?? buildContentFromDocument(editorView.state.doc); - if (newContent === oldContent) return; // nothing to diff - - const oldDocNode = buildDocumentFromContent(oldContent); - const newDocNode = buildDocumentFromContent(newContent); - - const diffedDoc = diffEditor(documentSchema, oldDocNode.toJSON(), newDocNode.toJSON()); - - const tr = editorView.state.tr - .replaceWith(0, editorView.state.doc.content.size, diffedDoc.content) - .setMeta('external', true) - .setMeta('addToHistory', false); - - // batch DOM updates - requestAnimationFrame(() => editorView.dispatch(tr)); - - previewActiveRef.current = true; - lastPreviewContentRef.current = newContent; - }; - - const handleCancelPreview = (event: CustomEvent) => { - if (!editorRef.current || !event.detail) return; - const { documentId: cancelDocId } = event.detail; - if (cancelDocId !== documentId) return; - if (!previewActiveRef.current || previewOriginalContentRef.current === null) return; - - const editorView = editorRef.current; - const originalDocNode = buildDocumentFromContent(previewOriginalContentRef.current); - const tr = editorView.state.tr.replaceWith(0, editorView.state.doc.content.size, originalDocNode.content); - editorView.dispatch(tr); - - previewActiveRef.current = false; - previewOriginalContentRef.current = null; - lastPreviewContentRef.current = null; - }; - - window.addEventListener('preview-document-update', handlePreviewUpdate as EventListener); - window.addEventListener('cancel-document-update', handleCancelPreview as EventListener); - - return () => { - window.removeEventListener('preview-document-update', handlePreviewUpdate as EventListener); - window.removeEventListener('cancel-document-update', handleCancelPreview as EventListener); - }; - }, [documentId]); - - // When final apply-document-update happens, apply clean new content, clear preview, and flash highlight - useEffect(() => { - const handleApply = (event: CustomEvent) => { - if (!editorRef.current || !event.detail) return; - const { documentId: applyDocId } = event.detail; - if (applyDocId !== documentId) return; - - const editorView = editorRef.current; - const animationDuration = 500; // ms - - const finalizeApply = async () => { - const { state } = editorView; - let tr = state.tr; - const diffMarkType = state.schema.marks.diffMark; - const { DiffType } = await import('@/lib/editor/diff'); - - // Find all ranges to delete and marks to remove in one pass. - const rangesToDelete: { from: number; to: number }[] = []; - state.doc.descendants((node, pos) => { - if (!node.isText) return; - - const deletedMark = node.marks.find( - (mark) => mark.type === diffMarkType && mark.attrs.type === DiffType.Deleted - ); - if (deletedMark) { - rangesToDelete.push({ from: pos, to: pos + node.nodeSize }); - } - }); - - // Delete ranges in reverse order to avoid position shifting. - for (let i = rangesToDelete.length - 1; i >= 0; i--) { - const { from, to } = rangesToDelete[i]; - tr.delete(from, to); - } - - // Remove all (remaining) diff marks. - tr.removeMark(0, tr.doc.content.size, diffMarkType); - - // Don't add this transaction to history. - tr.setMeta('addToHistory', false); - - editorView.dispatch(tr); - - // 3. Clean up animation class - editorView.dom.classList.remove('applying-changes'); - - // Reset preview state - previewActiveRef.current = false; - previewOriginalContentRef.current = null; - lastPreviewContentRef.current = null; - }; - - // 1. Add class to trigger animations - editorView.dom.classList.add('applying-changes'); - - // 2. After animation, clean up the state - setTimeout(finalizeApply, animationDuration); - }; - window.addEventListener('apply-document-update', handleApply as EventListener); - return () => window.removeEventListener('apply-document-update', handleApply as EventListener); - }, [documentId]); - useEffect(() => { const handleCreationStreamFinished = (event: CustomEvent) => { const finishedDocId = event.detail.documentId; const editorView = editorRef.current; const currentEditorPropId = documentId; - console.log( - `[Editor] Received creation-stream-finished event. Event Doc ID: ${finishedDocId}, Editor Prop Doc ID: ${currentEditorPropId}` - ); - if ( editorView && finishedDocId === currentEditorPropId && @@ -718,96 +213,50 @@ function PureEditor({ saveState.status !== "saving" && saveState.status !== "debouncing" ) { - console.log( - `[Editor] Triggering initial save for newly created document ${currentEditorPropId} after stream finish.` - ); setSaveStatus(editorView, { triggerSave: true }); - } else { - console.log( - `[Editor] Skipping initial save trigger for ${currentEditorPropId} - already saving/debouncing or state unavailable.` - ); } } }; - window.addEventListener( - "editor:creation-stream-finished", - handleCreationStreamFinished as EventListener - ); - return () => - window.removeEventListener( - "editor:creation-stream-finished", - handleCreationStreamFinished as EventListener - ); - }, [documentId]); - - useEffect(() => { - const handleForceSave = async (event: any) => { - const forceSaveDocId = event.detail.documentId; + const handleForceSave = createForceSaveHandler(currentDocumentIdRef); + const wrappedForceSave = async (event: CustomEvent) => { const editorView = editorRef.current; - const currentEditorPropId = documentId; - - if ( - !editorView || - forceSaveDocId !== currentEditorPropId || - currentEditorPropId === "init" - ) { - return; - } - + if (!editorView) return; + try { const content = buildContentFromDocument(editorView.state.doc); - - console.log(`[Editor] Force-saving document ${currentEditorPropId}`); - - const response = await fetch("/api/document", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - id: currentEditorPropId, - content: content, - }), + const result = await handleForceSave({ + ...event, + detail: { ...event.detail, content } }); - - if (!response.ok) { - throw new Error(`API error: ${response.statusText}`); + + if (result && editorView) { + setSaveStatus(editorView, result); } - - const data = await response.json(); - setSaveStatus(editorView, { - status: "saved", - lastSaved: new Date(data.updatedAt || new Date().toISOString()), - isDirty: false, - }); } catch (error) { - console.error( - `[Editor] Force-save failed for ${currentEditorPropId}:`, - error - ); + console.error("Force save failed:", error); } }; - window.addEventListener( - "editor:force-save-document", - handleForceSave as unknown as EventListener - ); - return () => - window.removeEventListener( - "editor:force-save-document", - handleForceSave as unknown as EventListener - ); + window.addEventListener("editor:creation-stream-finished", handleCreationStreamFinished as EventListener); + window.addEventListener("editor:force-save-document", wrappedForceSave as unknown as EventListener); + + return () => { + window.removeEventListener("editor:creation-stream-finished", handleCreationStreamFinished as EventListener); + window.removeEventListener("editor:force-save-document", wrappedForceSave as unknown as EventListener); + }; }, [documentId]); return ( <> {isCurrentVersion && documentId !== "init" && ( - + } /> )} -
+