Major changes
This commit is contained in:
@@ -0,0 +1,791 @@
|
||||
'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) + `` + content.substring(end);
|
||||
updateContent(newText);
|
||||
} else {
|
||||
insertAtCursor('');
|
||||
}
|
||||
}, [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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user