diff --git a/bun.lockb b/bun.lockb index a7d1590..f3958de 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index ae673e7..384fdf0 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,10 @@ "@tanstack/react-router": "^1.168.0", "@tanstack/react-start": "^1.167.14", "@tanstack/router-plugin": "^1.167.10", + "@tiptap/extension-text-align": "^3.22.3", + "@tiptap/extension-underline": "^3.22.3", + "@tiptap/react": "^3.22.3", + "@tiptap/starter-kit": "^3.22.3", "@types/file-saver": "^2.0.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/components/documents/rich-text-editor.tsx b/src/components/documents/rich-text-editor.tsx new file mode 100644 index 0000000..330cd27 --- /dev/null +++ b/src/components/documents/rich-text-editor.tsx @@ -0,0 +1,149 @@ +import { useEditor, EditorContent, Editor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import Underline from "@tiptap/extension-underline"; +import TextAlign from "@tiptap/extension-text-align"; +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { + Bold, Italic, Underline as UnderlineIcon, List, ListOrdered, + AlignLeft, AlignCenter, AlignRight, AlignJustify, Quote, Undo, Redo, + Heading2, Superscript as SupIcon, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface RichTextEditorProps { + value: string; // HTML + onChange: (html: string) => void; + onInsertFootnote?: () => void; + className?: string; + minHeight?: number; +} + +function ToolbarButton({ + onClick, active, disabled, children, title, +}: { + onClick: () => void; active?: boolean; disabled?: boolean; children: React.ReactNode; title: string; +}) { + return ( + + ); +} + +export function RichTextEditor({ + value, onChange, onInsertFootnote, className, minHeight = 400, +}: RichTextEditorProps) { + const editor = useEditor({ + extensions: [ + StarterKit.configure({ heading: { levels: [2, 3] } }), + Underline, + TextAlign.configure({ types: ["heading", "paragraph"] }), + ], + content: value || "

", + onUpdate: ({ editor }) => onChange(editor.getHTML()), + editorProps: { + attributes: { + class: "prose prose-sm max-w-none focus:outline-none px-10 py-8 bg-white text-black", + style: 'font-family: "Bookman Old Style", "URW Bookman", Georgia, serif; font-size: 12pt; line-height: 1.5;', + }, + }, + }); + + // Keep editor in sync when value is reset externally + useEffect(() => { + if (editor && value !== editor.getHTML()) { + editor.commands.setContent(value || "

", { emitUpdate: false }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value === ""]); + + if (!editor) return null; + + return ( +
+ +
+
+ +
+
+
+ ); +} + +function Toolbar({ editor, onInsertFootnote }: { editor: Editor; onInsertFootnote?: () => void }) { + return ( +
+ editor.chain().focus().undo().run()} disabled={!editor.can().undo()}> + + + editor.chain().focus().redo().run()} disabled={!editor.can().redo()}> + + + + + editor.chain().focus().toggleBold().run()}> + + + editor.chain().focus().toggleItalic().run()}> + + + editor.chain().focus().toggleUnderline().run()}> + + + + + editor.chain().focus().toggleHeading({ level: 2 }).run()}> + + + editor.chain().focus().toggleBulletList().run()}> + + + editor.chain().focus().toggleOrderedList().run()}> + + + editor.chain().focus().toggleBlockquote().run()}> + + + + + editor.chain().focus().setTextAlign("left").run()}> + + + editor.chain().focus().setTextAlign("center").run()}> + + + editor.chain().focus().setTextAlign("right").run()}> + + + editor.chain().focus().setTextAlign("justify").run()}> + + + + {onInsertFootnote && ( + <> + + + + )} +
+ ); +} + +// Helper exposed for the parent to insert a numbered footnote marker +export function insertFootnoteMarker(editor: Editor | null, num: number) { + if (!editor) return; + editor.chain().focus().insertContent(`[${num}]`).run(); +} diff --git a/src/lib/docx-pleading.ts b/src/lib/docx-pleading.ts index b4fd212..71770a8 100644 --- a/src/lib/docx-pleading.ts +++ b/src/lib/docx-pleading.ts @@ -11,9 +11,16 @@ import { TextRun, WidthType, BorderStyle, + Footer, + LevelFormat, } from "docx"; import { saveAs } from "file-saver"; +export interface PleadingFootnote { + id: number; + text: string; +} + export interface PleadingInput { courtType: "CIRCUIT" | "COUNTY"; circuit: string; // e.g. "ELEVENTH" @@ -22,7 +29,9 @@ export interface PleadingInput { defendants: string; // multi-line caseNumber: string; title?: string; - body?: string; + bodyHtml?: string; // rich HTML body + body?: string; // legacy plain text fallback + footnotes?: PleadingFootnote[]; } const FONT = "Bookman Old Style"; @@ -44,8 +53,16 @@ const verticalLine = { right: { style: BorderStyle.SINGLE, size: 8, color: "000000" }, }; -function run(text: string, opts: { bold?: boolean } = {}) { - return new TextRun({ text, bold: opts.bold, font: FONT, size: SIZE }); +function run(text: string, opts: { bold?: boolean; italic?: boolean; underline?: boolean; superscript?: boolean; size?: number } = {}) { + return new TextRun({ + text, + bold: opts.bold, + italics: opts.italic, + underline: opts.underline ? {} : undefined, + superScript: opts.superscript, + font: FONT, + size: opts.size ?? SIZE, + }); } function p(text: string, opts: { bold?: boolean; align?: (typeof AlignmentType)[keyof typeof AlignmentType] } = {}) { @@ -59,6 +76,108 @@ function emptyP() { return new Paragraph({ children: [run("")] }); } +// ---------- HTML → docx Paragraphs ---------- + +type RunStyle = { bold?: boolean; italic?: boolean; underline?: boolean; superscript?: boolean }; + +function alignFromStyle(node: Element): (typeof AlignmentType)[keyof typeof AlignmentType] | undefined { + const ta = (node.getAttribute("style") || "").match(/text-align:\s*(left|center|right|justify)/i)?.[1]?.toLowerCase(); + switch (ta) { + case "center": return AlignmentType.CENTER; + case "right": return AlignmentType.RIGHT; + case "justify": return AlignmentType.JUSTIFIED; + case "left": return AlignmentType.LEFT; + default: return undefined; + } +} + +function collectRuns(node: Node, style: RunStyle, out: TextRun[]) { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent || ""; + if (text) out.push(run(text, style)); + return; + } + if (node.nodeType !== Node.ELEMENT_NODE) return; + const el = node as Element; + const tag = el.tagName.toLowerCase(); + const next: RunStyle = { ...style }; + if (tag === "strong" || tag === "b") next.bold = true; + if (tag === "em" || tag === "i") next.italic = true; + if (tag === "u") next.underline = true; + if (tag === "sup") next.superscript = true; + if (tag === "br") { + out.push(new TextRun({ text: "", break: 1, font: FONT, size: SIZE })); + return; + } + el.childNodes.forEach((child) => collectRuns(child, next, out)); +} + +function blockToParagraphs(el: Element, listCtx?: { ref: string; level: number }): Paragraph[] { + const tag = el.tagName.toLowerCase(); + const align = alignFromStyle(el); + + if (tag === "ul" || tag === "ol") { + const ref = tag === "ul" ? "pl-bullets" : "pl-numbers"; + const level = (listCtx?.level ?? -1) + 1; + const out: Paragraph[] = []; + el.querySelectorAll(":scope > li").forEach((li) => { + const runs: TextRun[] = []; + li.childNodes.forEach((child) => { + if (child.nodeType === Node.ELEMENT_NODE && /^(ul|ol)$/i.test((child as Element).tagName)) return; + collectRuns(child, {}, runs); + }); + out.push(new Paragraph({ + numbering: { reference: ref, level }, + children: runs.length ? runs : [run("")], + })); + li.querySelectorAll(":scope > ul, :scope > ol").forEach((nested) => { + out.push(...blockToParagraphs(nested as Element, { ref, level })); + }); + }); + return out; + } + + if (tag === "blockquote") { + const out: Paragraph[] = []; + el.childNodes.forEach((child) => { + if (child.nodeType === Node.ELEMENT_NODE) { + out.push(...blockToParagraphs(child as Element).map((par) => par)); + } else if (child.nodeType === Node.TEXT_NODE && (child.textContent || "").trim()) { + out.push(new Paragraph({ + alignment: align, + indent: { left: 720 }, + children: [run(child.textContent || "")], + })); + } + }); + if (out.length === 0) out.push(new Paragraph({ indent: { left: 720 }, children: [run("")] })); + return out; + } + + if (tag === "h1" || tag === "h2" || tag === "h3") { + const runs: TextRun[] = []; + el.childNodes.forEach((c) => collectRuns(c, { bold: true }, runs)); + return [new Paragraph({ alignment: align, children: runs.length ? runs : [run("")] })]; + } + + // Default: paragraph + const runs: TextRun[] = []; + el.childNodes.forEach((c) => collectRuns(c, {}, runs)); + return [new Paragraph({ alignment: align, children: runs.length ? runs : [run("")] })]; +} + +function htmlToParagraphs(html: string): Paragraph[] { + if (typeof window === "undefined" || !html) return []; + const doc = new DOMParser().parseFromString(`
${html}
`, "text/html"); + const root = doc.body.firstElementChild; + if (!root) return []; + const out: Paragraph[] = []; + Array.from(root.children).forEach((child) => { + out.push(...blockToParagraphs(child)); + }); + return out; +} + export function buildPleadingDoc(input: PleadingInput): Document { const headerLine1 = `IN THE ${input.courtType} COURT OF THE ${input.circuit} JUDICIAL CIRCUIT,`; const headerLine2 = `IN AND FOR ${input.county.toUpperCase()} COUNTY, FLORIDA`; @@ -114,14 +233,59 @@ export function buildPleadingDoc(input: PleadingInput): Document { bodyParagraphs.push(p(input.title.toUpperCase(), { bold: true, align: AlignmentType.CENTER })); bodyParagraphs.push(emptyP()); } - if (input.body) { + if (input.bodyHtml && input.bodyHtml.trim()) { + bodyParagraphs.push(...htmlToParagraphs(input.bodyHtml)); + } else if (input.body) { input.body.split("\n").forEach((line) => bodyParagraphs.push(p(line))); } + // Footnotes rendered as endnote-style block at bottom of document + const footnotes = (input.footnotes || []).filter((f) => f.text.trim().length > 0); + if (footnotes.length > 0) { + bodyParagraphs.push(emptyP()); + bodyParagraphs.push(new Paragraph({ + border: { top: { style: BorderStyle.SINGLE, size: 6, color: "000000", space: 4 } }, + children: [run("")], + })); + footnotes.forEach((f) => { + bodyParagraphs.push(new Paragraph({ + children: [ + run(`${f.id}`, { superscript: true, size: 20 }), + run(" "), + run(f.text, { size: 20 }), + ], + })); + }); + } + return new Document({ styles: { default: { document: { run: { font: FONT, size: SIZE } } }, }, + numbering: { + config: [ + { + reference: "pl-bullets", + levels: [0, 1, 2].map((lvl) => ({ + level: lvl, + format: LevelFormat.BULLET, + text: "\u2022", + alignment: AlignmentType.LEFT, + style: { paragraph: { indent: { left: 720 * (lvl + 1), hanging: 360 } } }, + })), + }, + { + reference: "pl-numbers", + levels: [0, 1, 2].map((lvl) => ({ + level: lvl, + format: LevelFormat.DECIMAL, + text: `%${lvl + 1}.`, + alignment: AlignmentType.LEFT, + style: { paragraph: { indent: { left: 720 * (lvl + 1), hanging: 360 } } }, + })), + }, + ], + }, sections: [ { properties: { @@ -130,6 +294,11 @@ export function buildPleadingDoc(input: PleadingInput): Document { margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 }, }, }, + footers: { + default: new Footer({ + children: [new Paragraph({ alignment: AlignmentType.CENTER, children: [run("")] })], + }), + }, children: [ p(headerLine1, { bold: true, align: AlignmentType.CENTER }), p(headerLine2, { bold: true, align: AlignmentType.CENTER }), diff --git a/src/routes/documents.pleading.new.tsx b/src/routes/documents.pleading.new.tsx index 1156e11..8daf654 100644 --- a/src/routes/documents.pleading.new.tsx +++ b/src/routes/documents.pleading.new.tsx @@ -12,11 +12,12 @@ import { } from "@/components/ui/select"; import { FL_CIRCUITS } from "@/lib/florida"; import { Packer } from "docx"; -import { buildPleadingDoc, downloadPleading } from "@/lib/docx-pleading"; +import { buildPleadingDoc, downloadPleading, type PleadingFootnote } from "@/lib/docx-pleading"; +import { RichTextEditor } from "@/components/documents/rich-text-editor"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/lib/auth"; import { toast } from "sonner"; -import { Download, Save, Loader2 } from "lucide-react"; +import { Download, Save, Loader2, Plus, Trash2 } from "lucide-react"; export const Route = createFileRoute("/documents/pleading/new")({ component: PleadingNewPage, @@ -32,7 +33,8 @@ function PleadingNewPage() { const [defendants, setDefendants] = useState(""); const [caseNumber, setCaseNumber] = useState(""); const [title, setTitle] = useState(""); - const [body, setBody] = useState(""); + const [bodyHtml, setBodyHtml] = useState("

"); + const [footnotes, setFootnotes] = useState([]); const [docName, setDocName] = useState("Pleading"); const [saving, setSaving] = useState(false); @@ -41,7 +43,6 @@ function PleadingNewPage() { return c?.counties ?? []; }, [circuit]); - // Reset county when circuit changes if current county isn't valid const handleCircuitChange = (v: string) => { setCircuit(v); const c = FL_CIRCUITS.find((x) => x.value === v); @@ -51,7 +52,7 @@ function PleadingNewPage() { const input = { courtType, circuit, county, plaintiffs, defendants, caseNumber, - title, body, + title, bodyHtml, footnotes, }; const headerLine1 = `IN THE ${courtType} COURT OF THE ${circuit} JUDICIAL CIRCUIT,`; @@ -75,7 +76,7 @@ function PleadingNewPage() { const { error: insErr } = await supabase.from("generated_documents").insert({ name: docName || "Pleading", kind: "pleading", - payload: input, + payload: input as any, storage_path: path, created_by: user?.id, }); @@ -89,6 +90,17 @@ function PleadingNewPage() { } }; + const addFootnote = () => { + const nextId = (footnotes[footnotes.length - 1]?.id ?? 0) + 1; + setFootnotes([...footnotes, { id: nextId, text: "" }]); + }; + const updateFootnote = (id: number, text: string) => + setFootnotes(footnotes.map((f) => (f.id === id ? { ...f, text } : f))); + const removeFootnote = (id: number) => { + const remaining = footnotes.filter((f) => f.id !== id).map((f, i) => ({ ...f, id: i + 1 })); + setFootnotes(remaining); + }; + return ( @@ -106,8 +118,8 @@ function PleadingNewPage() { } /> + {/* Top row: settings + caption preview */}
- {/* Form */}
@@ -161,29 +173,25 @@ function PleadingNewPage() {
-
- - setCaseNumber(e.target.value)} placeholder="e.g. 2025-CA-001234" /> -
- -
- - setTitle(e.target.value)} placeholder="e.g. COMPLAINT FOR DAMAGES" /> -
- -
- -