'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>
)
}