Files
wikiplus/components/article-editor.tsx
T
2026-01-24 16:52:19 -06:00

792 lines
25 KiB
TypeScript

'use client';
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { ReviewResultPanel } from './review-result-panel';
import { EditorToolbar } from './editor-toolbar';
import { EditorPreview } from './editor-preview';
import { DiffPreview } from './diff-preview';
import {
Maximize2,
Minimize2,
Eye,
EyeOff,
Search,
X,
ChevronDown,
FileText,
Save,
AlertCircle,
} from 'lucide-react';
interface ArticleEditorProps {
slug: string;
initialContent: string;
onCancel: () => void;
onSaved: (newContent: string) => void;
}
interface ReviewResult {
approved: boolean;
reason: string;
}
interface FindReplaceState {
isOpen: boolean;
findText: string;
replaceText: string;
matchCount: number;
currentMatch: number;
}
export function ArticleEditor({
slug,
initialContent,
onCancel,
onSaved,
}: ArticleEditorProps) {
const [content, setContent] = useState(initialContent);
const [isReviewing, setIsReviewing] = useState(false);
const [reviewResponse, setReviewResponse] = useState('');
const [reviewResult, setReviewResult] = useState<ReviewResult | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showPreview, setShowPreview] = useState(true);
const [showDiff, setShowDiff] = useState(false);
const [undoStack, setUndoStack] = useState<string[]>([initialContent]);
const [redoStack, setRedoStack] = useState<string[]>([]);
const [findReplace, setFindReplace] = useState<FindReplaceState>({
isOpen: false,
findText: '',
replaceText: '',
matchCount: 0,
currentMatch: 0,
});
const textareaRef = useRef<HTMLTextAreaElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const lastSavedContent = useRef(initialContent);
const isScrollingSyncRef = useRef(false);
const autoSaveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const [showRecoveryBanner, setShowRecoveryBanner] = useState(false);
// Word and character count
const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0;
const charCount = content.length;
const lineCount = content.split('\n').length;
const hasChanges = content !== initialContent;
// Storage key for auto-save
const storageKey = `article-draft-${slug}`;
// Check for recovered draft on mount
useEffect(() => {
const savedDraft = localStorage.getItem(storageKey);
if (savedDraft && savedDraft !== initialContent && savedDraft.length > 0) {
setShowRecoveryBanner(true);
}
}, [storageKey, initialContent]);
// Auto-save to localStorage
useEffect(() => {
if (hasChanges) {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
setAutoSaveStatus('saving');
autoSaveTimeoutRef.current = setTimeout(() => {
localStorage.setItem(storageKey, content);
setAutoSaveStatus('saved');
setTimeout(() => setAutoSaveStatus('idle'), 2000);
}, 1000);
}
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
};
}, [content, hasChanges, storageKey]);
// Clear draft on successful save
const clearDraft = useCallback(() => {
localStorage.removeItem(storageKey);
}, [storageKey]);
const recoverDraft = useCallback(() => {
const savedDraft = localStorage.getItem(storageKey);
if (savedDraft) {
updateContent(savedDraft);
setShowRecoveryBanner(false);
}
}, [storageKey]);
const dismissRecovery = useCallback(() => {
localStorage.removeItem(storageKey);
setShowRecoveryBanner(false);
}, [storageKey]);
// Synchronized scrolling between editor and preview
const handleEditorScroll = useCallback(() => {
if (isScrollingSyncRef.current || !textareaRef.current || !previewRef.current || !showPreview) return;
isScrollingSyncRef.current = true;
const textarea = textareaRef.current;
const preview = previewRef.current;
const scrollPercentage = textarea.scrollTop / (textarea.scrollHeight - textarea.clientHeight);
const previewScrollTop = scrollPercentage * (preview.scrollHeight - preview.clientHeight);
preview.scrollTop = previewScrollTop;
requestAnimationFrame(() => {
isScrollingSyncRef.current = false;
});
}, [showPreview]);
const handlePreviewScroll = useCallback(() => {
if (isScrollingSyncRef.current || !textareaRef.current || !previewRef.current || !showPreview) return;
isScrollingSyncRef.current = true;
const textarea = textareaRef.current;
const preview = previewRef.current;
const scrollPercentage = preview.scrollTop / (preview.scrollHeight - preview.clientHeight);
const textareaScrollTop = scrollPercentage * (textarea.scrollHeight - textarea.clientHeight);
textarea.scrollTop = textareaScrollTop;
requestAnimationFrame(() => {
isScrollingSyncRef.current = false;
});
}, [showPreview]);
// Update match count when find text changes
useEffect(() => {
if (findReplace.findText) {
const regex = new RegExp(escapeRegex(findReplace.findText), 'gi');
const matches = content.match(regex);
setFindReplace(prev => ({
...prev,
matchCount: matches?.length || 0,
currentMatch: matches?.length ? 1 : 0,
}));
} else {
setFindReplace(prev => ({ ...prev, matchCount: 0, currentMatch: 0 }));
}
}, [findReplace.findText, content]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!textareaRef.current) return;
const isMod = e.ctrlKey || e.metaKey;
if (isMod && e.key === 'b') {
e.preventDefault();
wrapSelection('**', '**');
} else if (isMod && e.key === 'i') {
e.preventDefault();
wrapSelection('*', '*');
} else if (isMod && e.key === 'k') {
e.preventDefault();
insertLink();
} else if (isMod && e.key === 'f') {
e.preventDefault();
setFindReplace(prev => ({ ...prev, isOpen: true }));
} else if (isMod && e.shiftKey && e.key === 'z') {
e.preventDefault();
handleRedo();
} else if (isMod && e.key === 'z') {
e.preventDefault();
handleUndo();
} else if (e.key === 'Escape') {
if (findReplace.isOpen) {
setFindReplace(prev => ({ ...prev, isOpen: false }));
} else if (isFullscreen) {
setIsFullscreen(false);
}
} else if (e.key === 'Tab' && document.activeElement === textareaRef.current) {
e.preventDefault();
insertAtCursor(' ');
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [content, findReplace.isOpen, isFullscreen]);
const escapeRegex = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const updateContent = useCallback((newContent: string) => {
setUndoStack(prev => [...prev.slice(-50), content]);
setRedoStack([]);
setContent(newContent);
}, [content]);
const handleUndo = useCallback(() => {
if (undoStack.length > 1) {
const newUndo = [...undoStack];
const current = newUndo.pop()!;
const previous = newUndo[newUndo.length - 1];
setRedoStack(prev => [...prev, current]);
setUndoStack(newUndo);
setContent(previous);
}
}, [undoStack]);
const handleRedo = useCallback(() => {
if (redoStack.length > 0) {
const newRedo = [...redoStack];
const next = newRedo.pop()!;
setUndoStack(prev => [...prev, content]);
setRedoStack(newRedo);
setContent(next);
}
}, [redoStack, content]);
const wrapSelection = useCallback((before: string, after: string) => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
const newText = content.substring(0, start) + before + selectedText + after + content.substring(end);
updateContent(newText);
// Restore selection
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(start + before.length, end + before.length);
}, 0);
}, [content, updateContent]);
const insertAtCursor = useCallback((text: string) => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newText = content.substring(0, start) + text + content.substring(end);
updateContent(newText);
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(start + text.length, start + text.length);
}, 0);
}, [content, updateContent]);
const insertLink = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
if (selectedText) {
wrapSelection('[', '](url)');
} else {
insertAtCursor('[link text](url)');
}
}, [content, wrapSelection, insertAtCursor]);
const insertHeading = useCallback((level: number) => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const lineStart = content.lastIndexOf('\n', start - 1) + 1;
const prefix = '#'.repeat(level) + ' ';
const newText = content.substring(0, lineStart) + prefix + content.substring(lineStart);
updateContent(newText);
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(lineStart + prefix.length, lineStart + prefix.length);
}, 0);
}, [content, updateContent]);
const insertList = useCallback((ordered: boolean) => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
if (selectedText) {
const lines = selectedText.split('\n');
const listLines = lines.map((line, i) =>
ordered ? `${i + 1}. ${line}` : `- ${line}`
).join('\n');
const newText = content.substring(0, start) + listLines + content.substring(end);
updateContent(newText);
} else {
insertAtCursor(ordered ? '\n1. ' : '\n- ');
}
}, [content, updateContent, insertAtCursor]);
const insertBlockquote = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
if (selectedText) {
const lines = selectedText.split('\n');
const quotedLines = lines.map(line => `> ${line}`).join('\n');
const newText = content.substring(0, start) + quotedLines + content.substring(end);
updateContent(newText);
} else {
insertAtCursor('\n> ');
}
}, [content, updateContent, insertAtCursor]);
const insertCodeBlock = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
if (selectedText) {
wrapSelection('\n```\n', '\n```\n');
} else {
insertAtCursor('\n```\ncode here\n```\n');
}
}, [wrapSelection, insertAtCursor]);
const insertTable = useCallback(() => {
const table = `
| Header 1 | Header 2 | Header 3 |
|----------|----------|----------|
| Cell 1 | Cell 2 | Cell 3 |
| Cell 4 | Cell 5 | Cell 6 |
`;
insertAtCursor(table);
}, [insertAtCursor]);
const insertTemplate = useCallback((template: string) => {
insertAtCursor('\n' + template + '\n');
}, [insertAtCursor]);
const insertImage = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
if (selectedText && (selectedText.startsWith('http') || selectedText.startsWith('/'))) {
const newText = content.substring(0, start) + `![Image](${selectedText})` + content.substring(end);
updateContent(newText);
} else {
insertAtCursor('![alt text](image-url)');
}
}, [content, updateContent, insertAtCursor]);
const handleFindNext = useCallback(() => {
if (!findReplace.findText || !textareaRef.current) return;
const textarea = textareaRef.current;
const start = textarea.selectionEnd;
const regex = new RegExp(escapeRegex(findReplace.findText), 'gi');
// Search from current position
const afterCursor = content.substring(start);
const match = regex.exec(afterCursor);
if (match) {
const matchStart = start + match.index;
const matchEnd = matchStart + match[0].length;
textarea.focus();
textarea.setSelectionRange(matchStart, matchEnd);
setFindReplace(prev => ({
...prev,
currentMatch: Math.min(prev.currentMatch + 1, prev.matchCount),
}));
} else {
// Wrap around to beginning
const beforeCursor = content.substring(0, start);
const wrapMatch = new RegExp(escapeRegex(findReplace.findText), 'gi').exec(content);
if (wrapMatch) {
textarea.focus();
textarea.setSelectionRange(wrapMatch.index, wrapMatch.index + wrapMatch[0].length);
setFindReplace(prev => ({ ...prev, currentMatch: 1 }));
}
}
}, [findReplace.findText, content]);
const handleReplace = useCallback(() => {
if (!findReplace.findText || !textareaRef.current) return;
const textarea = textareaRef.current;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = content.substring(start, end);
if (selectedText.toLowerCase() === findReplace.findText.toLowerCase()) {
const newText = content.substring(0, start) + findReplace.replaceText + content.substring(end);
updateContent(newText);
handleFindNext();
} else {
handleFindNext();
}
}, [findReplace, content, updateContent, handleFindNext]);
const handleReplaceAll = useCallback(() => {
if (!findReplace.findText) return;
const regex = new RegExp(escapeRegex(findReplace.findText), 'gi');
const newText = content.replace(regex, findReplace.replaceText);
updateContent(newText);
}, [findReplace, content, updateContent]);
const parseReviewAction = (text: string): ReviewResult | null => {
const match = text.match(/<action\s+approve="(true|false)"\s+reason="([^"]+)"\s*\/?>/i);
if (match) {
return {
approved: match[1].toLowerCase() === 'true',
reason: match[2],
};
}
return null;
};
const handleReview = async () => {
setIsReviewing(true);
setReviewResponse('');
setReviewResult(null);
try {
const response = await fetch(`/api/article/${encodeURIComponent(slug)}/review`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newContent: content }),
});
if (!response.ok) {
throw new Error('Failed to review changes');
}
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
let fullResponse = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
fullResponse += chunk;
setReviewResponse(fullResponse);
const result = parseReviewAction(fullResponse);
if (result) {
setReviewResult(result);
}
}
if (!reviewResult) {
const finalResult = parseReviewAction(fullResponse);
if (finalResult) {
setReviewResult(finalResult);
}
}
} catch (error) {
console.error('Review error:', error);
setReviewResult({
approved: false,
reason: 'Failed to complete review. Please try again.',
});
} finally {
setIsReviewing(false);
}
};
const handleSave = async () => {
if (!reviewResult?.approved) return;
setIsSaving(true);
try {
const response = await fetch(`/api/article/${encodeURIComponent(slug)}/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
newContent: content,
reason: reviewResult.reason,
}),
});
if (!response.ok) {
throw new Error('Failed to save changes');
}
lastSavedContent.current = content;
clearDraft();
onSaved(content);
} catch (error) {
console.error('Save error:', error);
alert('Failed to save changes. Please try again.');
} finally {
setIsSaving(false);
}
};
const handleContinueEditing = () => {
setReviewResult(null);
setReviewResponse('');
setShowDiff(false);
};
const toolbarActions = {
onBold: () => wrapSelection('**', '**'),
onItalic: () => wrapSelection('*', '*'),
onStrikethrough: () => wrapSelection('~~', '~~'),
onHeading: insertHeading,
onLink: insertLink,
onImage: insertImage,
onBulletList: () => insertList(false),
onNumberedList: () => insertList(true),
onBlockquote: insertBlockquote,
onCode: () => wrapSelection('`', '`'),
onCodeBlock: insertCodeBlock,
onTable: insertTable,
onUndo: handleUndo,
onRedo: handleRedo,
canUndo: undoStack.length > 1,
canRedo: redoStack.length > 0,
onInsertTemplate: insertTemplate,
};
return (
<div
ref={editorContainerRef}
className={`article-editor-container ${isFullscreen ? 'editor-fullscreen' : ''}`}
>
{/* Recovery banner */}
{showRecoveryBanner && (
<div className="recovery-banner">
<div className="recovery-banner-content">
<AlertCircle className="w-4 h-4" />
<span>A previous draft was found. Would you like to recover it?</span>
</div>
<div className="recovery-banner-actions">
<Button variant="ghost" size="xs" onClick={dismissRecovery}>
Discard
</Button>
<Button size="xs" onClick={recoverDraft}>
Recover Draft
</Button>
</div>
</div>
)}
{/* Header bar */}
<div className="editor-header">
<div className="editor-header-left">
<Button
variant="ghost"
size="sm"
onClick={onCancel}
disabled={isReviewing || isSaving}
>
<X className="w-4 h-4 mr-1" />
Cancel
</Button>
<div className="editor-status">
{hasChanges ? (
<span className="status-unsaved">
Unsaved changes
{autoSaveStatus === 'saving' && ' (saving...)'}
{autoSaveStatus === 'saved' && ' (draft saved)'}
</span>
) : (
<span className="status-saved">No changes</span>
)}
</div>
</div>
<div className="editor-header-center">
<span className="editor-stats">
{wordCount.toLocaleString()} words · {charCount.toLocaleString()} chars · {lineCount.toLocaleString()} lines
</span>
</div>
<div className="editor-header-right">
<Button
variant="ghost"
size="sm"
onClick={() => setFindReplace(prev => ({ ...prev, isOpen: !prev.isOpen }))}
title="Find & Replace (Ctrl+F)"
>
<Search className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowPreview(!showPreview)}
title={showPreview ? 'Hide Preview' : 'Show Preview'}
>
{showPreview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDiff(!showDiff)}
disabled={!hasChanges}
title="Preview Changes"
>
<FileText className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setIsFullscreen(!isFullscreen)}
title={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</Button>
<Button
onClick={handleReview}
disabled={isReviewing || isSaving || !hasChanges}
size="sm"
>
{isReviewing ? 'Reviewing...' : 'Submit for Review'}
</Button>
</div>
</div>
{/* Find & Replace bar */}
{findReplace.isOpen && (
<div className="find-replace-bar">
<div className="find-replace-inputs">
<div className="find-input-group">
<input
type="text"
placeholder="Find..."
value={findReplace.findText}
onChange={(e) => setFindReplace(prev => ({ ...prev, findText: e.target.value }))}
className="find-input"
autoFocus
/>
{findReplace.matchCount > 0 && (
<span className="match-count">
{findReplace.currentMatch} of {findReplace.matchCount}
</span>
)}
</div>
<input
type="text"
placeholder="Replace..."
value={findReplace.replaceText}
onChange={(e) => setFindReplace(prev => ({ ...prev, replaceText: e.target.value }))}
className="replace-input"
/>
</div>
<div className="find-replace-actions">
<Button variant="ghost" size="xs" onClick={handleFindNext} disabled={!findReplace.findText}>
<ChevronDown className="w-4 h-4" />
</Button>
<Button variant="ghost" size="xs" onClick={handleReplace} disabled={!findReplace.findText}>
Replace
</Button>
<Button variant="ghost" size="xs" onClick={handleReplaceAll} disabled={!findReplace.findText}>
Replace All
</Button>
<Button
variant="ghost"
size="xs"
onClick={() => setFindReplace(prev => ({ ...prev, isOpen: false }))}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
)}
{/* Toolbar */}
<EditorToolbar {...toolbarActions} disabled={isReviewing || isSaving} />
{/* Main editor area */}
<div className={`editor-main ${showPreview ? 'with-preview' : ''} ${showDiff ? 'with-diff' : ''}`}>
{showDiff && hasChanges ? (
<DiffPreview oldContent={initialContent} newContent={content} />
) : (
<>
<div className="editor-pane">
<textarea
ref={textareaRef}
className="editor-textarea-enhanced"
value={content}
onChange={(e) => updateContent(e.target.value)}
onScroll={handleEditorScroll}
disabled={isReviewing || isSaving}
placeholder="Write your article content in markdown..."
spellCheck
/>
</div>
{showPreview && (
<div className="preview-pane">
<div className="preview-header">
<Eye className="w-4 h-4" />
<span>Preview</span>
</div>
<div
ref={previewRef}
className="preview-content"
onScroll={handlePreviewScroll}
>
<EditorPreview content={content} />
</div>
</div>
)}
</>
)}
</div>
{/* Review section */}
{isReviewing && reviewResponse && (
<div className="review-stream">
<h4>AI Review in Progress...</h4>
<p className="review-stream-text">{reviewResponse}</p>
</div>
)}
{reviewResult && (
<ReviewResultPanel
approved={reviewResult.approved}
reason={reviewResult.reason}
onSave={handleSave}
onContinueEditing={handleContinueEditing}
isSaving={isSaving}
/>
)}
{/* Keyboard shortcuts help */}
<div className="editor-shortcuts-hint">
<span>Ctrl+B Bold</span>
<span>Ctrl+I Italic</span>
<span>Ctrl+K Link</span>
<span>Ctrl+Z Undo</span>
<span>Ctrl+F Find</span>
</div>
</div>
);
}