bookwiz.io / components / Editor.tsx
Editor.tsx
Raw
'use client'

import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { useEditor, EditorContent, Editor as TipTapEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Typography from '@tiptap/extension-typography'
import Table from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import Link from '@tiptap/extension-link'
import { Markdown } from 'tiptap-markdown'
import SearchAndReplace from '@sereneinserenade/tiptap-search-and-replace'
import SlashCommandExtension from './SlashCommandExtension'
import BubbleMenuComponent from './BubbleMenu'
import { FileSystemItem } from '@/lib/types/database'
import DiffViewer from './DiffViewer'
import FloatingToast from './FloatingToast'

interface EditorProps {
  file: FileSystemItem | null
  onContentChange?: (content: string) => Promise<void>
  bookId?: string
  onFileOperationComplete?: () => void
  onDiffModeRequest?: () => void
  onEditorReady?: (editor: TipTapEditor | null) => void
}

export default function Editor({ file, onContentChange, bookId, onFileOperationComplete, onDiffModeRequest, onEditorReady }: EditorProps) {
  const [wordCount, setWordCount] = useState(0)
  const [saving, setSaving] = useState(false)
  const [saveError, setSaveError] = useState<string | null>(null)
  const [isTyping, setIsTyping] = useState(false)
  const [lastSavedContent, setLastSavedContent] = useState('')
  const [hasPendingSave, setHasPendingSave] = useState(false)
  const [hasExternalChanges, setHasExternalChanges] = useState(false)
  const [showDiffMode, setShowDiffMode] = useState(false)
  const [originalContent, setOriginalContent] = useState('')
  const [hasPendingAIChanges, setHasPendingAIChanges] = useState(false)
  const [isProcessingAIOperation, setIsProcessingAIOperation] = useState(false)
  const [showSuccessToast, setShowSuccessToast] = useState(false)
  const [successMessage, setSuccessMessage] = useState('')
  const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
  const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
  const currentFileId = useRef<string | null>(null)
  const recentSaveTimestamp = useRef<number>(0)
  const isInitialLoad = useRef<boolean>(true)
  const wasPageHiddenRef = useRef<boolean>(false)
  const wordCountTimeoutRef = useRef<NodeJS.Timeout | null>(null)

  // Memoize editor extensions to prevent unnecessary re-renders
  const editorExtensions = useMemo(() => [
    StarterKit.configure({
      heading: {
        levels: [1, 2, 3, 4, 5, 6],
      },
      bulletList: {
        keepMarks: true,
        keepAttributes: false,
      },
      orderedList: {
        keepMarks: true,
        keepAttributes: false,
      },
      blockquote: {
        HTMLAttributes: {
          class: 'border-l-4 border-slate-500 pl-4 italic',
        },
      },
      codeBlock: {
        HTMLAttributes: {
          class: 'bg-slate-800 rounded-md p-4 font-mono text-sm',
        },
      },
      code: {
        HTMLAttributes: {
          class: 'bg-slate-700 px-1 py-0.5 rounded text-sm font-mono',
        },
      },
    }),
    Typography,
    Markdown.configure({
      html: true,
      tightLists: true,
      tightListClass: 'tight',
      bulletListMarker: '-',
      linkify: false,
      breaks: false,
      transformPastedText: true,
      transformCopiedText: true,
    }),
    Table.configure({
      resizable: true,
    }),
    TableRow,
    TableCell,
    TableHeader,
    TaskList,
    TaskItem,
    Link.configure({
      openOnClick: false,
      HTMLAttributes: {
        class: 'text-teal-400 hover:text-teal-300 underline',
      },
    }),
    SlashCommandExtension,
    SearchAndReplace.configure({
      searchResultClass: 'search-result-highlight',
    }),
  ], [])

  const editor = useEditor({
    extensions: editorExtensions,
    content: '',
    immediatelyRender: false,
    editorProps: {
      attributes: {
        class: 'prose prose-invert prose-slate max-w-none focus:outline-none',
        'data-placeholder': 'Start writing...',
      },
      // Performance optimizations
      handleDOMEvents: {
        scroll: (view, event) => {
          // Prevent default scroll behavior that might cause lag
          return false
        },
      },
    },
    onUpdate: ({ editor }) => {
      // Get markdown content for storage
      const content = editor.storage.markdown.getMarkdown()
      handleContentChange(content)
    },
    onFocus: () => {
      setIsTyping(true)
    },
    onBlur: () => {
      handleBlur()
    },
  })

  // Optimized word count calculation with debouncing
  const updateWordCount = useCallback(() => {
    if (!editor) return
    
    // Clear existing timeout
    if (wordCountTimeoutRef.current) {
      clearTimeout(wordCountTimeoutRef.current)
    }
    
    // Debounce word count calculation
    wordCountTimeoutRef.current = setTimeout(() => {
      const text = editor.getText()
      const words = text.trim().split(/\s+/).filter(word => word.length > 0)
      setWordCount(words.length)
    }, 500) // Increased debounce time for better performance
  }, [editor])

  const performSave = useCallback(async (contentToSave: string) => {
    if (!file || !onContentChange || file.type !== 'file') return
    if (contentToSave === lastSavedContent) return

    try {
      setSaving(true)
      setSaveError(null)
      recentSaveTimestamp.current = Date.now()
      
      await onContentChange(contentToSave)
      setLastSavedContent(contentToSave)
    } catch (error) {
      console.error('Error saving file:', error)
      setSaveError('Failed to save changes')
      recentSaveTimestamp.current = 0 // Reset on error
    } finally {
      setSaving(false)
      setHasPendingSave(false)
      setIsTyping(false)
    }
  }, [file, onContentChange, lastSavedContent])

  // Notify parent when editor is ready
  useEffect(() => {
    if (onEditorReady) {
      onEditorReady(editor)
    }
  }, [editor, onEditorReady])

  // Handle page visibility to preserve editor state during tab switches
  useEffect(() => {
    const handleVisibilityChange = () => {
      if (document.hidden) {
        wasPageHiddenRef.current = true
        
        // Auto-save current content when page becomes hidden
        if (file && onContentChange && editor) {
          const content = editor.storage.markdown.getMarkdown()
          if (content !== lastSavedContent && content.trim()) {
            performSave(content)
          }
        }
      } else if (wasPageHiddenRef.current) {
        wasPageHiddenRef.current = false
      }
    }

    document.addEventListener('visibilitychange', handleVisibilityChange)
    return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
  }, [file, onContentChange, editor, lastSavedContent, performSave])

  // Handle file content sync
  useEffect(() => {
    if (!editor) return

    if (!file) {
      editor.commands.setContent('')
      setLastSavedContent('')
      setHasExternalChanges(false)
      currentFileId.current = null
      isInitialLoad.current = true
      return
    }

    // New file - always update
    if (file.id !== currentFileId.current) {
      currentFileId.current = file.id
      const newContent = file.content || ''
      editor.commands.setContent(newContent)
      setLastSavedContent(newContent)
      setSaveError(null)
      setIsTyping(false)
      setHasPendingSave(false)
      setHasExternalChanges(false)
      recentSaveTimestamp.current = 0
      isInitialLoad.current = true
      wasPageHiddenRef.current = false
      return
    }

    // Same file - check if content changed
    const fileContent = file.content || ''
    const currentContent = editor.storage.markdown.getMarkdown()
    const now = Date.now()
    
    // If this is the initial load for this file, don't show external changes notification
    if (isInitialLoad.current) {
      isInitialLoad.current = false
      if (fileContent !== currentContent) {
        editor.commands.setContent(fileContent)
        setLastSavedContent(fileContent)
      }
      return
    }
    
    // Skip content sync if page was recently hidden to preserve editor state
    if (wasPageHiddenRef.current) {
      return
    }
    
    // If we recently saved (within 3 seconds), ignore updates to prevent notification loops
    if (now - recentSaveTimestamp.current < 3000) {
      setHasExternalChanges(false)
      return
    }

    // If content actually changed and user isn't actively typing
    if (fileContent !== currentContent && !isTyping) {
      // Don't update editor content if we have pending AI changes or are in diff mode
      if (!hasPendingAIChanges && !showDiffMode) {
        editor.commands.setContent(fileContent)
        setLastSavedContent(fileContent)
      }
      
      // Show brief notification only if it's significantly different AND we don't have pending AI changes
      // (to avoid interfering with diff mode)
      if (Math.abs(fileContent.length - currentContent.length) > 10 && !hasPendingAIChanges) {
        setHasExternalChanges(true)
        setTimeout(() => setHasExternalChanges(false), 2000)
      }
    } else if (fileContent !== currentContent && isTyping) {
      // User is typing - just mark external changes available (but not if we have pending AI changes)
      if (!hasPendingAIChanges) {
        setHasExternalChanges(true)
      }
    }
  }, [file?.content, file?.id, file, isTyping, editor, hasPendingAIChanges, showDiffMode])

  // Update word count when editor content changes - optimized with debouncing
  useEffect(() => {
    if (!editor) return

    updateWordCount()
    
    const handleUpdate = () => {
      updateWordCount()
    }

    editor.on('update', handleUpdate)
    return () => {
      editor.off('update', handleUpdate)
    }
  }, [editor, updateWordCount])

  const debouncedSave = useCallback(async (newContent: string) => {
    // Clear existing timeout
    if (saveTimeoutRef.current) {
      clearTimeout(saveTimeoutRef.current)
    }

    setHasPendingSave(true)

    // Set new timeout for debounced save
    saveTimeoutRef.current = setTimeout(() => {
      performSave(newContent)
    }, 1500)
  }, [performSave])

  const handleContentChange = (newContent: string) => {
    setIsTyping(true)
    setSaveError(null)
    setHasExternalChanges(false) // User is actively editing
    
    // Clear existing typing timeout
    if (typingTimeoutRef.current) {
      clearTimeout(typingTimeoutRef.current)
    }

    // Set typing timeout
    typingTimeoutRef.current = setTimeout(() => {
      setIsTyping(false)
    }, 2000)
    
    // Trigger debounced save
    debouncedSave(newContent)
  }

  // Save immediately when user leaves the editor
  const handleBlur = useCallback(() => {
    if (saveTimeoutRef.current) {
      clearTimeout(saveTimeoutRef.current)
    }
    if (typingTimeoutRef.current) {
      clearTimeout(typingTimeoutRef.current)
    }
    
    setIsTyping(false)
    setHasPendingSave(false)
    
    // Save immediately on blur if there are unsaved changes
    if (file && onContentChange && editor) {
      const content = editor.storage.markdown.getMarkdown()
      if (content !== lastSavedContent) {
        performSave(content)
      }
    }
  }, [file, onContentChange, editor, lastSavedContent, performSave])

  // Handle external changes notification click
  const handleAcceptExternalChanges = useCallback(() => {
    if (file && editor) {
      const fileContent = file.content || ''
      editor.commands.setContent(fileContent)
      setLastSavedContent(fileContent)
      setHasExternalChanges(false)
    }
  }, [file, editor])

  // Handle AI operation accept
  const handleAcceptAIChanges = async () => {
    if (!bookId) return
    
    setIsProcessingAIOperation(true)
    try {
      // TODO: Replace with version control system integration
      // For now, just clear the pending AI changes state
      setHasPendingAIChanges(false)
      setShowDiffMode(false)
      setOriginalContent('')
      // Show success message
      setSuccessMessage('AI changes accepted successfully')
      setShowSuccessToast(true)
      // Trigger a content refresh
      await checkForPendingAIChanges()
      // Notify parent that file operation completed
      onFileOperationComplete?.()
      // Also dispatch custom event for other components to listen
      if (typeof window !== 'undefined' && file) {
        window.dispatchEvent(new CustomEvent('ai-operation-completed', { 
          detail: { type: 'accept', fileId: file.id } 
        }))
      }
    } catch (error) {
      console.error('Failed to accept AI changes:', error)
    } finally {
      setIsProcessingAIOperation(false)
    }
  }

  // Handle AI operation revert
  const handleRevertAIChanges = async () => {
    if (!bookId) return
    
    setIsProcessingAIOperation(true)
    try {
      // TODO: Replace with version control system integration
      // For now, just clear the pending AI changes state
      setHasPendingAIChanges(false)
      setShowDiffMode(false)
      setOriginalContent('')
      // Show success message
      setSuccessMessage('AI changes reverted successfully')
      setShowSuccessToast(true)
      // Trigger a content refresh
      await checkForPendingAIChanges()
      // Notify parent that file operation completed
      onFileOperationComplete?.()
      // Also dispatch custom event for other components to listen
      if (typeof window !== 'undefined' && file) {
        window.dispatchEvent(new CustomEvent('ai-operation-completed', { 
          detail: { type: 'revert', fileId: file.id } 
        }))
      }
    } catch (error) {
      console.error('Failed to revert AI changes:', error)
    } finally {
      setIsProcessingAIOperation(false)
    }
  }

  // Toggle diff mode
  const toggleDiffMode = () => {
    setShowDiffMode(!showDiffMode)
  }

  // Cleanup timeouts on unmount
  useEffect(() => {
    return () => {
      if (saveTimeoutRef.current) {
        clearTimeout(saveTimeoutRef.current)
      }
      if (typingTimeoutRef.current) {
        clearTimeout(typingTimeoutRef.current)
      }
      if (wordCountTimeoutRef.current) {
        clearTimeout(wordCountTimeoutRef.current)
      }
    }
  }, [])

  // Check for pending AI changes for this file
  const checkForPendingAIChanges = useCallback(async () => {
    if (!file || !bookId) {
      setHasPendingAIChanges(false)
      // Only set showDiffMode to false if it wasn't explicitly set to true by user
      if (!showDiffMode) {
        setShowDiffMode(false)
      }
      return
    }

    try {
      // TODO: Replace with version control system integration
      // For now, just clear pending AI changes state
      setHasPendingAIChanges(false)
      // Only set showDiffMode to false if it wasn't explicitly set to true by user
      if (!showDiffMode) {
        setShowDiffMode(false)
      }
    } catch (error) {
      console.error('โŒ Editor: Error checking for pending AI changes:', error)
      setHasPendingAIChanges(false)
      // Only set showDiffMode to false if it wasn't explicitly set to true by user
      if (!showDiffMode) {
        setShowDiffMode(false)
      }
    }
  }, [file, bookId, showDiffMode])

  // Check for pending AI changes when file or bookId changes
  useEffect(() => {
    checkForPendingAIChanges()
  }, [checkForPendingAIChanges])

  // Expose diff mode trigger to parent (only for AI changes now)
  useEffect(() => {
    if (onDiffModeRequest) {
      // Store the toggle function so parent can call it
      (window as any).editorToggleDiffMode = async () => {
        console.log('๐ŸŽฏ Editor: editorToggleDiffMode called for AI changes');
        
        // Mark that diff mode was requested with timestamp
        (window as any).diffModeRequestTime = Date.now();
        
        console.log('๐Ÿค– Editor: Checking AI changes');
        if (hasPendingAIChanges && originalContent !== '') {
          console.log('โœ… Editor: AI changes found, showing diff mode');
          setShowDiffMode(true);
          delete (window as any).diffModeRequestTime;
        } else {
          console.log('๐Ÿ”„ Editor: No AI changes, rechecking');
          // Try to recheck for pending changes if we don't have them
          if (!hasPendingAIChanges) {
            try {
              await checkForPendingAIChanges();
              // The auto-show logic in the useEffect below will handle showing the diff mode
            } catch (error) {
              console.error('Error checking for pending AI changes:', error);
            }
          }
        }
      }
    }
  }, [onDiffModeRequest, hasPendingAIChanges, originalContent, file?.id, file?.name, checkForPendingAIChanges])

  // Auto-show diff mode when pending changes are detected
  useEffect(() => {
    // If we have pending changes and original content, but diff mode was requested but not shown
    if (hasPendingAIChanges && originalContent !== '' && !showDiffMode) {
      // Check if a diff mode was recently requested (within last 2 seconds)
      const wasRecentlyRequested = (window as any).diffModeRequestTime && 
        (Date.now() - (window as any).diffModeRequestTime) < 2000
      
      if (wasRecentlyRequested) {
        setShowDiffMode(true)
        // Clear the request timestamp
        delete (window as any).diffModeRequestTime
      }
    }
  }, [hasPendingAIChanges, originalContent, showDiffMode])

  // Search highlighting functionality
  useEffect(() => {
    if (!editor) return

    const handleSearchGotoLine = (event: CustomEvent) => {
      const { lineNumber, searchQuery, caseSensitive, useRegex, matchWholeWord } = event.detail
      
      if (searchQuery) {
        // Set search term in the extension
        editor.commands.setSearchTerm(searchQuery)
        
        // Get the content and calculate position for the line
        const content = editor.storage.markdown.getMarkdown()
        const lines = content.split('\n')
        
        if (lineNumber && lineNumber <= lines.length) {
          // Calculate the character position for the target line
          let characterPos = 0
          for (let i = 0; i < lineNumber - 1; i++) {
            characterPos += lines[i].length + 1 // +1 for newline
          }
          
          // Focus the editor and jump to the line
          editor.commands.focus()
          editor.commands.setTextSelection(characterPos)
          
          // Scroll the line into view
          setTimeout(() => {
            const element = document.querySelector('.ProseMirror')
            if (element) {
              const selection = editor.state.selection
              const coords = editor.view.coordsAtPos(selection.from)
              element.scrollTop = coords.top - element.getBoundingClientRect().top - 100
            }
          }, 100)
        }
      }
    }

    const handleSearchHighlight = (event: CustomEvent) => {
      const { searchQuery } = event.detail
      
      if (searchQuery) {
        // Set search term in the extension to highlight throughout the document
        editor.commands.setSearchTerm(searchQuery)
        editor.commands.focus()
      }
    }

    // Add event listeners
    window.addEventListener('search-goto-line', handleSearchGotoLine as EventListener)
    window.addEventListener('search-highlight', handleSearchHighlight as EventListener)

    // Cleanup
    return () => {
      window.removeEventListener('search-goto-line', handleSearchGotoLine as EventListener)
      window.removeEventListener('search-highlight', handleSearchHighlight as EventListener)
    }
  }, [editor])

  if (!file) {
    return (
      <div className="h-full flex items-center justify-center text-slate-400 bg-slate-900">
        <div className="text-center">
          <div className="text-6xl mb-4">๐Ÿ“</div>
          <p className="text-lg">Select a file to start editing</p>
          <p className="text-sm mt-2 text-slate-500">
            Create a new file or select an existing one from the file explorer
          </p>
        </div>
      </div>
    )
  }

  if (file.type === 'folder') {
    return (
      <div className="h-full flex items-center justify-center text-slate-400 bg-slate-900">
        <div className="text-center">
          <div className="text-6xl mb-4">๐Ÿ“</div>
          <p className="text-lg">Folder selected</p>
          <p className="text-sm mt-2 text-slate-500">
            Select a file to start editing
          </p>
        </div>
      </div>
    )
  }

  const hasUnsavedChanges = editor ? editor.storage.markdown.getMarkdown() !== lastSavedContent : false

  // Show diff mode if enabled (only for AI changes now - version control diffs are handled at book page level)
  if (showDiffMode && hasPendingAIChanges && originalContent !== '') {
    console.log('๐ŸŽจ Editor: Rendering DiffViewer for AI changes');
    return (
      <DiffViewer
        originalContent={originalContent}
        modifiedContent={editor ? editor.storage.markdown.getMarkdown() : ''}
        fileName={file.name}
        onAccept={handleAcceptAIChanges}
        onRevert={handleRevertAIChanges}
        isLoading={isProcessingAIOperation}
      />
    )
  }

  return (
    <div className="h-full flex flex-col bg-slate-900/60 relative">
      {/* External changes notification */}
      <FloatingToast
        show={hasExternalChanges && !isTyping && !saving && !hasPendingSave}
        type="external-changes"
        message="File updated externally"
        position="top-right"
        actions={[
          {
            label: 'Reload',
            onClick: handleAcceptExternalChanges,
            variant: 'primary'
          }
        ]}
        autoHide={8000}
      />

      {/* Success notification */}
      <FloatingToast
        show={showSuccessToast}
        type="success"
        message={successMessage}
        position="top-right"
        autoHide={2000}
        onDismiss={() => setShowSuccessToast(false)}
      />
      
      <div className="flex-1 relative tiptap-editor-container">
        <div className="tiptap-editor-content">
          <EditorContent 
            editor={editor} 
            className="h-full"
          />
          {editor && <BubbleMenuComponent editor={editor} />}
        </div>
      </div>
      
      <div className="h-8 bg-slate-800 border-t border-slate-700 flex items-center justify-between px-4 text-xs text-slate-400">
        <div className="flex items-center gap-3">
          {saving && (
            <span className="text-yellow-400 flex items-center gap-1">
              <div className="w-2 h-2 bg-yellow-400 rounded-full animate-pulse"></div>
              Saving...
            </span>
          )}
          {!saving && hasUnsavedChanges && (isTyping || hasPendingSave) && (
            <span className="text-blue-400 flex items-center gap-1">
              <div className="w-2 h-2 bg-blue-400 rounded-full animate-pulse"></div>
              {isTyping ? 'Typing...' : 'Ready to save...'}
            </span>
          )}
          {!saving && hasUnsavedChanges && !isTyping && !hasPendingSave && (
            <span className="text-orange-400 flex items-center gap-1">
              <div className="w-2 h-2 bg-orange-400 rounded-full animate-pulse"></div>
              Unsaved
            </span>
          )}
          {saveError && (
            <span className="text-red-400" title={saveError}>
              Save failed
            </span>
          )}
          {!saving && !hasUnsavedChanges && !saveError && (
            <span className="text-green-400 flex items-center gap-1">
              <div className="w-2 h-2 bg-green-400 rounded-full"></div>
              Saved
            </span>
          )}
          {hasExternalChanges && !isTyping && (
            <span className="text-blue-300 flex items-center gap-1">
              <div className="w-2 h-2 bg-blue-300 rounded-full animate-pulse"></div>
              External changes available
            </span>
          )}
        </div>
        <span>{wordCount} words</span>
      </div>
    </div>
  )
}