From ddd8ab5eb9133fd210ed5f963b9f625f00bc925f Mon Sep 17 00:00:00 2001 From: Derek Schwede Date: Mon, 8 Dec 2025 21:11:22 -0800 Subject: [PATCH 01/10] feat: add rich text editor component --- components/RichTextEditor.tsx | 177 ++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 components/RichTextEditor.tsx diff --git a/components/RichTextEditor.tsx b/components/RichTextEditor.tsx new file mode 100644 index 0000000..ce1f0fb --- /dev/null +++ b/components/RichTextEditor.tsx @@ -0,0 +1,177 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { useEditor, EditorContent } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import Placeholder from '@tiptap/extension-placeholder'; +import Underline from '@tiptap/extension-underline'; +import { Bold, Italic, Underline as UnderlineIcon } from 'lucide-react'; +import { RichTextContent } from '../types'; + +interface RichTextEditorProps { + content: RichTextContent; + onChange: (content: RichTextContent) => void; + placeholder?: string; + onScroll?: (e: React.UIEvent) => void; + scrollRef?: React.RefObject; + className?: string; +} + +export const RichTextEditor: React.FC = ({ + content, + onChange, + placeholder = 'Paste your text here...', + onScroll, + scrollRef, + className = '' +}) => { + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + // Disable heading, bullet list, etc. - keep it simple for legal docs + heading: false, + bulletList: false, + orderedList: false, + blockquote: false, + codeBlock: false, + horizontalRule: false, + }), + Underline.configure({ + // Explicitly configure to avoid conflicts + }), + Placeholder.configure({ + placeholder, + }), + ], + content: typeof content === 'string' ? content : content, + onUpdate: ({ editor }) => { + const html = editor.getHTML(); + onChange(html); + }, + editorProps: { + attributes: { + class: 'prose prose-sm max-w-none focus:outline-none p-6 font-serif text-lg leading-relaxed text-slate-800', + style: 'u { text-decoration: underline; }', + }, + }, + }); + + // Update editor content when prop changes + useEffect(() => { + if (!editor) return; + + // Get current editor HTML for comparison + const currentHtml = editor.getHTML(); + + // Determine what the new content should be + let newContent: string | any; + if (typeof content === 'string') { + // If content is a string (HTML), use it directly + newContent = content; + } else { + // If content is JSONContent, use it directly + newContent = content; + } + + // Only update if content has actually changed + // For HTML strings, compare the HTML directly + // For JSON, we need to serialize and compare + const contentChanged = typeof content === 'string' + ? content !== currentHtml + : JSON.stringify(content) !== JSON.stringify(editor.getJSON()); + + if (contentChanged) { + // TipTap's setContent can handle HTML strings directly + // It will parse the HTML and preserve formatting like , , + editor.commands.setContent(newContent, false); + } + }, [content, editor]); + + // Handle scroll events on the wrapper div + useEffect(() => { + if (!wrapperRef.current || !onScroll) return; + + const handleScroll = (e: Event) => { + const target = e.currentTarget as HTMLElement; + // Create a synthetic event with the wrapper element as the target + const syntheticEvent = { + currentTarget: target, + target: target + } as React.UIEvent; + onScroll(syntheticEvent); + }; + + const wrapper = wrapperRef.current; + wrapper.addEventListener('scroll', handleScroll); + + return () => { + wrapper.removeEventListener('scroll', handleScroll); + }; + }, [onScroll]); + + // Set scroll ref to the actual scrollable container + // Use a ref to track the wrapper div that actually scrolls + const wrapperRef = React.useRef(null); + + useEffect(() => { + if (scrollRef && wrapperRef.current) { + // The scrollable element is the wrapper div with overflow-y-auto + (scrollRef as React.MutableRefObject).current = wrapperRef.current; + } + }, [editor, scrollRef]); + + const toggleBold = useCallback(() => { + editor?.chain().focus().toggleBold().run(); + }, [editor]); + + const toggleItalic = useCallback(() => { + editor?.chain().focus().toggleItalic().run(); + }, [editor]); + + const toggleUnderline = useCallback(() => { + editor?.chain().focus().toggleUnderline().run(); + }, [editor]); + + if (!editor) { + return
Loading editor...
; + } + + return ( +
+ {/* Toolbar */} +
+ + + +
+ + {/* Editor Content */} +
+ +
+
+ ); +}; + From 527d5d47ecf9fe47c83bce394509fc399fa39a18 Mon Sep 17 00:00:00 2001 From: Derek Schwede Date: Mon, 8 Dec 2025 21:11:33 -0800 Subject: [PATCH 02/10] feat: add Word document import/export --- components/ImportModifiedModal.tsx | 195 ++++++++++++++++++++++ services/wordService.ts | 258 +++++++++++++++++++++++++++++ 2 files changed, 453 insertions(+) create mode 100644 components/ImportModifiedModal.tsx create mode 100644 services/wordService.ts diff --git a/components/ImportModifiedModal.tsx b/components/ImportModifiedModal.tsx new file mode 100644 index 0000000..eda6925 --- /dev/null +++ b/components/ImportModifiedModal.tsx @@ -0,0 +1,195 @@ +import React, { useRef, useState, useEffect } from 'react'; +import { X, Upload, FileText, Copy } from 'lucide-react'; +import { importWordDocument } from '../services/wordService'; +import { RichTextContent } from '../types'; + +interface ImportModifiedModalProps { + isOpen: boolean; + onClose: () => void; + onImport: (text: string, richText?: RichTextContent) => void; + originalText: string; + originalRichText?: RichTextContent; +} + +export const ImportModifiedModal: React.FC = ({ + isOpen, + onClose, + onImport, + originalText, + originalRichText +}) => { + const fileInputRef = useRef(null); + const textareaRef = useRef(null); + const [selectedOption, setSelectedOption] = useState<'copy' | 'import' | 'paste' | null>(null); + + // Reset selected option when modal closes + useEffect(() => { + if (!isOpen) { + setSelectedOption(null); + } + }, [isOpen]); + + if (!isOpen) return null; + + const handleCopyOriginal = () => { + onImport(originalText, originalRichText); + onClose(); + }; + + const handleImportClick = () => { + setSelectedOption('import'); + fileInputRef.current?.click(); + }; + + 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); + onImport(result.text, result.html); + onClose(); + } catch (error: any) { + alert(`Error importing Word document: ${error.message}`); + } + } else { + // Plain text file + const text = await file.text(); + onImport(text); + onClose(); + } + + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handlePasteClick = () => { + setSelectedOption('paste'); + // Focus textarea after a brief delay to ensure it's rendered + setTimeout(() => { + textareaRef.current?.focus(); + }, 100); + }; + + const handleTextareaChange = (e: React.ChangeEvent) => { + const text = e.target.value; + if (text.trim().length > 0) { + onImport(text); + onClose(); + } + }; + + return ( +
+
e.stopPropagation()} + > +
+

Start Modifications

+ +
+ +
+

+ You've uploaded an original document. Choose how you want to begin your modified version. +

+ +
+ {/* Three Option Buttons */} +
+ {/* Copy Original */} + + + {/* Import File */} + + + {/* Paste Text */} + +
+ + {/* Paste Textarea - Only shown when Paste Text is selected */} + {selectedOption === 'paste' && ( +
+
+ + Paste Modified Text +
+