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" />
-
-
-