792 lines
25 KiB
TypeScript
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) + `` + 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>
|
|
);
|
|
}
|