diff --git a/.gitignore b/.gitignore index a547bf3..ce0d618 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +.env +.env.local \ No newline at end of file diff --git a/App.tsx b/App.tsx index 2125480..97ad3fd 100644 --- a/App.tsx +++ b/App.tsx @@ -3,19 +3,36 @@ import { Header } from './components/Header'; import { EditorPanel } from './components/EditorPanel'; import { DiffPanel } from './components/DiffPanel'; import { SummaryPanel } from './components/SummaryPanel'; +import { PlaybookManager } from './components/PlaybookManager'; +import { ImportModifiedModal } from './components/ImportModifiedModal'; import { computeDiff, getDiffStats, generateHtmlDiff, DiffMode } from './services/diffService'; import { segmentDiffIntoSentences, generateRedlineSummary } from './services/aiService'; -import { DiffPart, ScrollSource, Sentence, SummaryResult } from './types'; +import { exportWordDocument } from './services/wordService'; +import { getApprovedRules } from './services/playbookService'; +import { DiffPart, ScrollSource, Sentence, SummaryResult, PlaybookEntry, RichTextContent } from './types'; import { INITIAL_ORIGINAL, INITIAL_MODIFIED } from './constants'; export default function App() { const [original, setOriginal] = useState(INITIAL_ORIGINAL); + const [originalRichText, setOriginalRichText] = useState(); const [modified, setModified] = useState(INITIAL_MODIFIED); + const [modifiedRichText, setModifiedRichText] = useState(); const [diffMode, setDiffMode] = useState('char'); const [diffData, setDiffData] = useState([]); const [sentences, setSentences] = useState([]); const [isSyncScrolling, setIsSyncScrolling] = useState(true); + // Playbook State - disabled for now (Coming Soon) + // Clear any old localStorage entries and don't persist + const [playbookEntries, setPlaybookEntries] = useState(() => { + // Clear old entries from localStorage since feature is disabled + if (typeof window !== 'undefined') { + localStorage.removeItem('playbookEntries'); + } + return []; + }); + const [isPlaybookOpen, setIsPlaybookOpen] = useState(false); + // AI Summary State const [summary, setSummary] = useState(null); const [isSummaryOpen, setIsSummaryOpen] = useState(false); @@ -23,19 +40,109 @@ export default function App() { const [summaryError, setSummaryError] = useState(null); const [highlightedSentenceId, setHighlightedSentenceId] = useState(null); + // Modal state for importing modified document + const [showImportModal, setShowImportModal] = useState(false); + // Refs for scrolling synchronization - const originalRef = useRef(null); - const modifiedRef = useRef(null); + const originalRef = useRef(null); + const modifiedRef = useRef(null); const diffRef = useRef(null); // Ref to prevent circular scroll event loops const isScrolling = useRef(ScrollSource.NONE); + + // Ref to track modal timeout to prevent clearing it unnecessarily + const modalTimeoutRef = useRef(null); + + // Check if we should show the import modal (original has content but modified is empty) + useEffect(() => { + // Helper to check if content exists (handles both plain text and rich text) + const hasContent = (text: string, richText?: RichTextContent): boolean => { + // Check plain text first + if (text && typeof text === 'string' && text.trim().length > 0) return true; + + // Check rich text + if (richText !== undefined && richText !== null) { + if (typeof richText === 'string') { + // For HTML strings, check if there's actual text content (not just tags) + const trimmed = richText.trim(); + if (trimmed.length === 0) return false; + // If it's HTML, check if there's text content beyond tags + if (trimmed.includes('<')) { + // Use DOMParser to extract text content + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(trimmed, 'text/html'); + const textContent = doc.body.textContent || ''; + return textContent.trim().length > 0; + } catch { + // Fallback: if parsing fails, check if there's text outside tags + const textWithoutTags = trimmed.replace(/<[^>]*>/g, '').trim(); + return textWithoutTags.length > 0; + } + } + return trimmed.length > 0; + } + // For JSONContent (TipTap), check if it has actual content + if (typeof richText === 'object' && richText !== null) { + const hasText = (node: any): boolean => { + if (typeof node === 'string' && node.trim().length > 0) return true; + if (node?.text && typeof node.text === 'string' && node.text.trim().length > 0) return true; + if (Array.isArray(node?.content) && node.content.length > 0) { + return node.content.some(hasText); + } + return false; + }; + return hasText(richText); + } + } + return false; + }; + + const hasOriginal = hasContent(original, originalRichText); + const hasModified = hasContent(modified, modifiedRichText); + + // Clear any pending timeout when conditions change + if (modalTimeoutRef.current) { + clearTimeout(modalTimeoutRef.current); + modalTimeoutRef.current = null; + } + + // Determine if modal should be shown + const shouldShow = hasOriginal && !hasModified; + + // Update modal state based on conditions + // Use a small delay to batch rapid state updates, but keep it minimal + if (shouldShow) { + modalTimeoutRef.current = setTimeout(() => { + // Double-check conditions haven't changed + const stillHasOriginal = hasContent(original, originalRichText); + const stillNoModified = !hasContent(modified, modifiedRichText); + + if (stillHasOriginal && stillNoModified) { + setShowImportModal(true); + } + modalTimeoutRef.current = null; + }, 150); + } else { + // Hide immediately if conditions aren't met + setShowImportModal(false); + } + + return () => { + if (modalTimeoutRef.current) { + clearTimeout(modalTimeoutRef.current); + modalTimeoutRef.current = null; + } + }; + }, [original, originalRichText, modified, modifiedRichText]); // Compute diff whenever text changes useEffect(() => { - // Simple debounce for very long text could be added here if needed, - // but React 18 auto-batching handles this reasonably well for typical legal docs. - const parts = computeDiff(original, modified, diffMode); + const originalContent = originalRichText || original; + const modifiedContent = modifiedRichText || modified; + + const parts = computeDiff(originalContent, modifiedContent, diffMode); setDiffData(parts); const sents = segmentDiffIntoSentences(parts); @@ -46,7 +153,7 @@ export default function App() { setSummary(null); setHighlightedSentenceId(null); } - }, [original, modified, diffMode]); + }, [original, originalRichText, modified, modifiedRichText, diffMode]); // Sync scroll logic const handleScroll = useCallback((source: ScrollSource, el: HTMLElement) => { @@ -55,8 +162,9 @@ export default function App() { isScrolling.current = source; - // Calculate percentage - const percentage = el.scrollTop / (el.scrollHeight - el.offsetHeight); + // Calculate percentage - handle edge cases + const scrollHeight = el.scrollHeight - el.offsetHeight; + const percentage = scrollHeight > 0 ? el.scrollTop / scrollHeight : 0; const syncTo = (ref: React.RefObject) => { if (ref.current && ref.current !== el) { @@ -95,7 +203,8 @@ export default function App() { setIsGeneratingSummary(true); setSummaryError(null); try { - const result = await generateRedlineSummary(sentences); + const approvedRules = getApprovedRules(playbookEntries); + const result = await generateRedlineSummary(sentences, approvedRules); setSummary(result); } catch (err: any) { setSummaryError(err.message || "Failed to generate summary"); @@ -151,21 +260,42 @@ export default function App() { const handleResetAll = () => { setOriginal(''); + setOriginalRichText(undefined); setModified(''); + setModifiedRichText(undefined); + setShowImportModal(false); // Optionally focus the first input for convenience originalRef.current?.focus(); }; + const handleExportWord = async () => { + try { + await exportWordDocument(diffData, 'redline-comparison.docx'); + } catch (err: any) { + alert(`Error exporting Word document: ${err.message}`); + } + }; + + const handleModalImport = (text: string, richText?: RichTextContent) => { + setModified(text); + if (richText) { + setModifiedRichText(richText); + } + setShowImportModal(false); + }; + const stats = getDiffStats(diffData); return (
setIsSyncScrolling(!isSyncScrolling)} onGenerateSummary={handleGenerateSummary} + onOpenPlaybook={() => setIsPlaybookOpen(true)} diffMode={diffMode} onDiffModeChange={setDiffMode} syncEnabled={isSyncScrolling} @@ -182,8 +312,14 @@ export default function App() { setOriginal('')} + onRichTextChange={setOriginalRichText} + onClear={() => { + setOriginal(''); + setOriginalRichText(undefined); + setShowImportModal(false); + }} scrollRef={originalRef} onScroll={(e) => handleScroll(ScrollSource.ORIGINAL, e.currentTarget)} /> @@ -195,9 +331,14 @@ export default function App() {
setModified('')} + onRichTextChange={setModifiedRichText} + onClear={() => { + setModified(''); + setModifiedRichText(undefined); + }} scrollRef={modifiedRef} onScroll={(e) => handleScroll(ScrollSource.MODIFIED, e.currentTarget)} /> @@ -214,6 +355,8 @@ export default function App() { highlightedSentenceId={highlightedSentenceId} scrollRef={diffRef} onScroll={(e) => handleScroll(ScrollSource.DIFF, e.currentTarget)} + originalRichText={originalRichText} + modifiedRichText={modifiedRichText} />
@@ -229,7 +372,24 @@ export default function App() { onClose={() => setIsSummaryOpen(false)} /> )} + + {isPlaybookOpen && ( + setIsPlaybookOpen(false)} + /> + )} + + {/* Import Modified Document Modal */} + setShowImportModal(false)} + onImport={handleModalImport} + originalText={original} + originalRichText={originalRichText} + />
); } \ No newline at end of file diff --git a/components/AcceptRejectControls.tsx b/components/AcceptRejectControls.tsx new file mode 100644 index 0000000..6936e9e --- /dev/null +++ b/components/AcceptRejectControls.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { Check, X } from 'lucide-react'; +import { DiffPart } from '../types'; + +interface AcceptRejectControlsProps { + part: DiffPart; + onAccept?: (changeId: string) => void; + onReject?: (changeId: string) => void; +} + +export const AcceptRejectControls: React.FC = ({ + part, + onAccept, + onReject +}) => { + // Only show controls for changes (additions or deletions) + if (!part.added && !part.removed) { + return null; + } + + const changeId = part.changeId || `change-${part.value.substring(0, 10)}-${part.added ? 'add' : 'remove'}`; + const isAccepted = part.accepted === true; + const isRejected = part.rejected === true; + + return ( + + {!isAccepted && !isRejected && ( + <> + {onAccept && ( + + )} + {onReject && ( + + )} + + )} + {isAccepted && ( + + ✓ + + )} + {isRejected && ( + + ✗ + + )} + + ); +}; + + + + + + + + diff --git a/components/DiffPanel.tsx b/components/DiffPanel.tsx index 9deb3fd..db1cf33 100644 --- a/components/DiffPanel.tsx +++ b/components/DiffPanel.tsx @@ -1,6 +1,7 @@ -import React, { useState, useEffect } from 'react'; -import { PanelProps } from '../types'; -import { Code, Eye } from 'lucide-react'; +import React, { useState, useEffect, useMemo } from 'react'; +import { PanelProps, RichTextContent } from '../types'; +import { Code, Eye, FileText } from 'lucide-react'; +import { generateMarkedHtml } from '../services/diffService'; export const DiffPanel: React.FC = ({ title, @@ -10,7 +11,9 @@ export const DiffPanel: React.FC = ({ onScroll, scrollRef, className = "", - id + id, + originalRichText, + modifiedRichText }) => { const [isRawMode, setIsRawMode] = useState(false); @@ -23,37 +26,119 @@ export const DiffPanel: React.FC = ({ } }, [highlightedSentenceId]); - const renderPart = (part: any, index: number) => { + // Helper to strip HTML tags and get plain text + const stripHtml = (text: string): string => { + if (typeof document === 'undefined') { + return text.replace(/<[^>]*>/g, ''); + } + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = text; + return tempDiv.textContent || tempDiv.innerText || text; + }; + + // Generate marked HTML with diff styling + const markedHtml = useMemo(() => { + if (!diffParts || diffParts.length === 0) return null; + + // Check if we have rich text content + const modifiedHtml = typeof modifiedRichText === 'string' ? modifiedRichText : null; + const originalHtml = typeof originalRichText === 'string' ? originalRichText : null; + + // Use the new HTML generation function if we have rich text + if (modifiedHtml || originalHtml) { + return generateMarkedHtml(originalHtml, modifiedHtml, diffParts); + } + + return null; + }, [diffParts, originalRichText, modifiedRichText]); + + // Check if we should use HTML rendering + const useHtmlRendering = markedHtml !== null && !isRawMode; + + // Helper to render text with formatting if available (for fallback text mode) + const renderTextWithFormatting = (text: string, formatting?: any) => { + const cleanText = stripHtml(text); + + if (formatting && (formatting.bold || formatting.italic || formatting.underline)) { + let content: React.ReactNode = cleanText; + + if (formatting.underline) { + content = {content}; + } + if (formatting.italic) { + content = {content}; + } + if (formatting.bold) { + content = {content}; + } + return content; + } + + return cleanText; + }; + + const renderPart = (part: any, index: number | string) => { + const displayValue = stripHtml(part.value); + if (part.added) { return ( - {part.value} + {renderTextWithFormatting(displayValue, part.formatting)} ); } if (part.removed) { return ( - {part.value} + {renderTextWithFormatting(displayValue, part.formatting)} + + ); + } + return ( + + {renderTextWithFormatting(displayValue, part.formatting)} + + ); + }; + + // Render fallback text-based diff (when no HTML available) + const renderTextDiff = () => { + if (!diffParts || diffParts.length === 0) { + return ( + + No content to compare. Add text to Original and Modified panels. ); } - return {part.value}; + + return diffParts.map((part, index) => { + const parts: React.ReactNode[] = []; + const lines = part.value.split('\n'); + lines.forEach((line, lineIndex) => { + if (lineIndex > 0) { + parts.push(
); + } + if (line) { + parts.push(renderPart({ ...part, value: line }, `${index}-${lineIndex}`)); + } + }); + return {parts}; + }); }; return (
-
+
{title}
+ {/* Spacer to align with the B/I/U toolbar in Editor panels */} +
+
} onScroll={onScroll} - className="flex-1 w-full p-6 overflow-y-auto bg-white font-serif text-lg leading-relaxed text-slate-800 shadow-inner whitespace-pre-wrap" + className="flex-1 w-full p-6 overflow-y-auto bg-white font-serif text-lg leading-relaxed text-slate-800 shadow-inner" > - {isRawMode ? ( + {/* Check if we only have removed parts (original content but no modified) */} + {diffParts && diffParts.length > 0 && diffParts.every(part => part.removed) && !diffParts.some(part => part.added) ? ( +
+
+ +
+

+ No comparison available. Import or paste a modified document to see the redline comparison. +

+
+ ) : isRawMode ? (
{diffParts && diffParts.map((part, index) => { + const cleanValue = stripHtml(part.value); if (part.added) { - return {`{++${part.value}++}`}; + return {`{++${cleanValue}++}`}; } if (part.removed) { - return {`{--${part.value}--}`}; + return {`{--${cleanValue}--}`}; } - return {part.value}; + return {cleanValue}; })}
+ ) : useHtmlRendering ? ( + // Render HTML directly with diff markers - preserves paragraph structure +
) : ( <> {sentences ? ( - sentences.map((sentence) => ( - - {sentence.parts.map((part, index) => renderPart(part, index))} - + sentences.map((sentence, sentenceIndex) => ( + + + {sentence.parts.map((part, index) => renderPart(part, index))} + + {sentenceIndex < sentences.length - 1 &&
} +
)) ) : ( - <> - {diffParts && diffParts.map((part, index) => renderPart(part, index))} - {(!diffParts || diffParts.length === 0) && ( - No content to compare. Add text to Original and Modified panels. - )} - + renderTextDiff() )} )}
+ + {/* Inject CSS for diff markers */} +
); -}; \ No newline at end of file +}; diff --git a/components/EditorPanel.tsx b/components/EditorPanel.tsx index 7c962f5..b297bb8 100644 --- a/components/EditorPanel.tsx +++ b/components/EditorPanel.tsx @@ -1,21 +1,86 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { PanelProps } from '../types'; -import { Trash2 } from 'lucide-react'; +import { Trash2, Upload } from 'lucide-react'; +import { RichTextEditor } from './RichTextEditor'; +import { importWordDocument } from '../services/wordService'; export const EditorPanel: React.FC = ({ title, - value, + value = '', + richTextValue, onChange, + onRichTextChange, onClear, onScroll, scrollRef, - className = "" + className = "", + onImportWord }) => { + const fileInputRef = useRef(null); + + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (file.name.endsWith('.docx')) { + try { + const result = await importWordDocument(file); + // When rich text is available, set both rich text and plain text + if (onRichTextChange) { + onRichTextChange(result.html); + // Also update plain text if onChange is provided + if (onChange) { + onChange(result.text); + } + } else if (onChange) { + // Fallback to plain text only if no rich text handler + onChange(result.text); + } + } catch (error: any) { + alert(`Error importing Word document: ${error.message}`); + } + } else { + // Plain text file + const text = await file.text(); + if (onChange) { + onChange(text); + } + } + + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleImportClick = () => { + if (onImportWord) { + onImportWord(); + } else { + fileInputRef.current?.click(); + } + }; + return (
-
+
{title}
+ + {onClear && value && value.length > 0 && (
-