Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?

.env
.env.local
188 changes: 174 additions & 14 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,146 @@ 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<RichTextContent | undefined>();
const [modified, setModified] = useState(INITIAL_MODIFIED);
const [modifiedRichText, setModifiedRichText] = useState<RichTextContent | undefined>();
const [diffMode, setDiffMode] = useState<DiffMode>('char');
const [diffData, setDiffData] = useState<DiffPart[]>([]);
const [sentences, setSentences] = useState<Sentence[]>([]);
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<PlaybookEntry[]>(() => {
// 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<SummaryResult | null>(null);
const [isSummaryOpen, setIsSummaryOpen] = useState(false);
const [isGeneratingSummary, setIsGeneratingSummary] = useState(false);
const [summaryError, setSummaryError] = useState<string | null>(null);
const [highlightedSentenceId, setHighlightedSentenceId] = useState<string | null>(null);

// Modal state for importing modified document
const [showImportModal, setShowImportModal] = useState(false);

// Refs for scrolling synchronization
const originalRef = useRef<HTMLTextAreaElement>(null);
const modifiedRef = useRef<HTMLTextAreaElement>(null);
const originalRef = useRef<HTMLDivElement | HTMLTextAreaElement>(null);
const modifiedRef = useRef<HTMLDivElement | HTMLTextAreaElement>(null);
const diffRef = useRef<HTMLDivElement>(null);

// Ref to prevent circular scroll event loops
const isScrolling = useRef<ScrollSource>(ScrollSource.NONE);

// Ref to track modal timeout to prevent clearing it unnecessarily
const modalTimeoutRef = useRef<NodeJS.Timeout | null>(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);
Expand All @@ -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) => {
Expand All @@ -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<HTMLElement>) => {
if (ref.current && ref.current !== el) {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 (
<div className="flex flex-col h-screen bg-slate-50">
<Header
onCopy={handleCopy}
onExportPdf={handleExportPDF}
onExportPdf={handleExportPDF}
onExportWord={handleExportWord}
onReset={handleResetAll}
onToggleSync={() => setIsSyncScrolling(!isSyncScrolling)}
onGenerateSummary={handleGenerateSummary}
onOpenPlaybook={() => setIsPlaybookOpen(true)}
diffMode={diffMode}
onDiffModeChange={setDiffMode}
syncEnabled={isSyncScrolling}
Expand All @@ -182,8 +312,14 @@ export default function App() {
<EditorPanel
title="Original Text"
value={original}
richTextValue={originalRichText}
onChange={setOriginal}
onClear={() => setOriginal('')}
onRichTextChange={setOriginalRichText}
onClear={() => {
setOriginal('');
setOriginalRichText(undefined);
setShowImportModal(false);
}}
scrollRef={originalRef}
onScroll={(e) => handleScroll(ScrollSource.ORIGINAL, e.currentTarget)}
/>
Expand All @@ -195,9 +331,14 @@ export default function App() {
<div className="h-full bg-white rounded-lg shadow-sm border border-slate-200 overflow-hidden">
<EditorPanel
title="Modified Text"
value={modified}
value={modified}
richTextValue={modifiedRichText}
onChange={setModified}
onClear={() => setModified('')}
onRichTextChange={setModifiedRichText}
onClear={() => {
setModified('');
setModifiedRichText(undefined);
}}
scrollRef={modifiedRef}
onScroll={(e) => handleScroll(ScrollSource.MODIFIED, e.currentTarget)}
/>
Expand All @@ -214,6 +355,8 @@ export default function App() {
highlightedSentenceId={highlightedSentenceId}
scrollRef={diffRef}
onScroll={(e) => handleScroll(ScrollSource.DIFF, e.currentTarget)}
originalRichText={originalRichText}
modifiedRichText={modifiedRichText}
/>
</div>

Expand All @@ -229,7 +372,24 @@ export default function App() {
onClose={() => setIsSummaryOpen(false)}
/>
)}

{isPlaybookOpen && (
<PlaybookManager
entries={playbookEntries}
onEntriesChange={setPlaybookEntries}
onClose={() => setIsPlaybookOpen(false)}
/>
)}
</main>

{/* Import Modified Document Modal */}
<ImportModifiedModal
isOpen={showImportModal}
onClose={() => setShowImportModal(false)}
onImport={handleModalImport}
originalText={original}
originalRichText={originalRichText}
/>
</div>
);
}
69 changes: 69 additions & 0 deletions components/AcceptRejectControls.tsx
Original file line number Diff line number Diff line change
@@ -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<AcceptRejectControlsProps> = ({
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 (
<span className="inline-flex items-center gap-1 ml-2 align-middle">
{!isAccepted && !isRejected && (
<>
{onAccept && (
<button
onClick={() => onAccept(changeId)}
className="p-0.5 rounded hover:bg-green-100 text-green-600 hover:text-green-700 transition-colors"
title="Accept change"
>
<Check size={12} />
</button>
)}
{onReject && (
<button
onClick={() => onReject(changeId)}
className="p-0.5 rounded hover:bg-red-100 text-red-600 hover:text-red-700 transition-colors"
title="Reject change"
>
<X size={12} />
</button>
)}
</>
)}
{isAccepted && (
<span className="text-green-600 text-xs" title="Accepted">
</span>
)}
{isRejected && (
<span className="text-red-600 text-xs" title="Rejected">
</span>
)}
</span>
);
};








Loading