diff --git a/bun.lockb b/bun.lockb index a940daa..0d10c59 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 17dc66c..115edd1 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,10 @@ "@tanstack/react-router": "^1.168.0", "@tanstack/react-start": "^1.167.14", "@tanstack/router-plugin": "^1.167.10", + "@tiptap/extension-table": "^3.22.3", + "@tiptap/extension-table-cell": "^3.22.3", + "@tiptap/extension-table-header": "^3.22.3", + "@tiptap/extension-table-row": "^3.22.3", "@tiptap/extension-text-align": "^3.22.3", "@tiptap/extension-underline": "^3.22.3", "@tiptap/react": "^3.22.3", diff --git a/src/components/forms/custom-form-builder.tsx b/src/components/forms/custom-form-builder.tsx index 82536e1..f526cc9 100644 --- a/src/components/forms/custom-form-builder.tsx +++ b/src/components/forms/custom-form-builder.tsx @@ -3,6 +3,11 @@ import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Underline from "@tiptap/extension-underline"; import TextAlign from "@tiptap/extension-text-align"; +import Paragraph from "@tiptap/extension-paragraph"; +import { Table } from "@tiptap/extension-table"; +import { TableRow } from "@tiptap/extension-table-row"; +import { TableCell } from "@tiptap/extension-table-cell"; +import { TableHeader } from "@tiptap/extension-table-header"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -38,6 +43,11 @@ import { Search, Trash2, FileText, + Table as TableIcon, + Rows, + Columns, + Captions, + X, } from "lucide-react"; import { ClientHomeownerPicker } from "./form-pickers"; import { @@ -62,10 +72,50 @@ import { TextRun, HeadingLevel, AlignmentType, + Table as DocxTable, + TableRow as DocxTableRow, + TableCell as DocxTableCell, + WidthType, + BorderStyle, } from "docx"; import { toast } from "sonner"; import { supabase } from "@/integrations/supabase/client"; +// Paragraph extension with indent (px) + caption flag, persisted as inline styles +const PaddedParagraph = Paragraph.extend({ + addAttributes() { + return { + ...this.parent?.(), + indent: { + default: 0, + parseHTML: (el) => { + const pl = (el as HTMLElement).style.paddingLeft; + if (!pl) return 0; + const n = parseInt(pl, 10); + return isNaN(n) ? 0 : n; + }, + renderHTML: (attrs) => { + const i = Number(attrs.indent ?? 0); + if (!i) return {}; + return { style: `padding-left: ${i}px` }; + }, + }, + caption: { + default: false, + parseHTML: (el) => (el as HTMLElement).getAttribute("data-caption") === "true", + renderHTML: (attrs) => + attrs.caption + ? { + "data-caption": "true", + style: + "text-align:center;font-style:italic;font-size:0.9em;color:#555;margin-top:4px;", + } + : {}, + }, + }; + }, +}); + const FONT_FAMILIES = [ { value: "Bookman Old Style", label: "Bookman Old Style", pdf: "times" }, { value: "Times New Roman", label: "Times New Roman", pdf: "times" }, @@ -120,14 +170,20 @@ export function CustomFormBuilder() { const editor = useEditor({ extensions: [ - StarterKit.configure({ heading: { levels: [1, 2, 3] } }), + StarterKit.configure({ heading: { levels: [1, 2, 3] }, paragraph: false }), + PaddedParagraph, Underline, TextAlign.configure({ types: ["heading", "paragraph"] }), + Table.configure({ resizable: true, HTMLAttributes: { class: "cf-table" } }), + TableRow, + TableHeader, + TableCell, ], content: "
Start typing here…
", editorProps: { attributes: { - class: "prose prose-sm max-w-none focus:outline-none px-10 py-8 bg-white text-black min-h-[500px]", + class: + "prose prose-sm max-w-none focus:outline-none px-10 py-8 bg-white text-black min-h-[500px]", }, }, }); @@ -162,46 +218,101 @@ export function CustomFormBuilder() { const insertSignatureLine = (label: string = "Signature") => { if (!editor) return; - editor.chain().focus().insertContent(` --
______________________________
-${label}
- `).run(); + editor + .chain() + .focus() + .insertContent([ + { type: "paragraph", content: [{ type: "text", text: " " }] }, + { type: "paragraph", content: [{ type: "text", text: "______________________________" }] }, + { type: "paragraph", content: [{ type: "text", text: label }] }, + ]) + .run(); }; + const INDENT_STEP = 36; // ~0.5" + const indent = () => { if (!editor) return; - // tiptap doesn't have indent built-in; emulate with non-breaking spaces at start - editor.chain().focus().insertContent(" ").run(); + const cur = Number(editor.getAttributes("paragraph").indent ?? 0); + editor.chain().focus().updateAttributes("paragraph", { indent: cur + INDENT_STEP }).run(); }; const outdent = () => { if (!editor) return; - // Best-effort: not perfectly removing — let user use undo. We expose for symmetry. - editor.chain().focus().run(); + const cur = Number(editor.getAttributes("paragraph").indent ?? 0); + editor + .chain() + .focus() + .updateAttributes("paragraph", { indent: Math.max(0, cur - INDENT_STEP) }) + .run(); }; - // Convert editor HTML into plain text lines for PDF/docx (preserves paragraphs). - const getRenderedSegments = (): { text: string; align: "left" | "center" | "right" | "justify"; bold?: boolean; italic?: boolean }[] => { + const insertTable = () => { + if (!editor) return; + editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); + }; + + const toggleCaption = () => { + if (!editor) return; + const cur = Boolean(editor.getAttributes("paragraph").caption); + editor.chain().focus().updateAttributes("paragraph", { caption: !cur }).run(); + }; + + // Linearized document segments for export. Tables are flattened into a "table" segment + // containing 2D rows of strings; paragraphs become text segments preserving alignment + indent. + type Segment = + | { + kind: "text"; + text: string; + align: "left" | "center" | "right" | "justify"; + indent: number; + caption?: boolean; + heading?: 0 | 1 | 2 | 3; + } + | { kind: "table"; rows: string[][]; hasHeader: boolean }; + + const getRenderedSegments = (): Segment[] => { if (!editor) return []; const html = editor.getHTML(); const tmp = document.createElement("div"); tmp.innerHTML = html; - const out: { text: string; align: "left" | "center" | "right" | "justify"; bold?: boolean; italic?: boolean }[] = []; - const walk = (node: ChildNode) => { - if (node.nodeType === Node.ELEMENT_NODE) { - const el = node as HTMLElement; - const tag = el.tagName.toLowerCase(); - if (["p", "h1", "h2", "h3", "li"].includes(tag)) { - const align = (el.style.textAlign as any) || "left"; - const text = el.textContent || ""; - out.push({ text, align }); - return; - } + const out: Segment[] = []; + + const cellText = (cell: HTMLElement) => (cell.textContent ?? "").replace(/\s+/g, " ").trim(); + + const pushBlock = (el: HTMLElement) => { + const tag = el.tagName.toLowerCase(); + if (tag === "table") { + const rows: string[][] = []; + let hasHeader = false; + el.querySelectorAll("tr").forEach((tr) => { + const cells = Array.from(tr.children) as HTMLElement[]; + if (cells.some((c) => c.tagName.toLowerCase() === "th")) hasHeader = true; + rows.push(cells.map(cellText)); + }); + out.push({ kind: "table", rows, hasHeader }); + return; } - node.childNodes.forEach(walk); + if (["p", "h1", "h2", "h3", "li"].includes(tag)) { + const align = (el.style.textAlign as any) || "left"; + const indentPx = parseInt(el.style.paddingLeft || "0", 10) || 0; + const caption = el.getAttribute("data-caption") === "true"; + const heading = tag === "h1" ? 1 : tag === "h2" ? 2 : tag === "h3" ? 3 : 0; + out.push({ + kind: "text", + text: el.textContent || "", + align, + indent: indentPx, + caption, + heading: heading as 0 | 1 | 2 | 3, + }); + return; + } + // Recurse into wrappers (lists, etc.) + Array.from(el.children).forEach((child) => pushBlock(child as HTMLElement)); }; - tmp.childNodes.forEach(walk); + + Array.from(tmp.children).forEach((el) => pushBlock(el as HTMLElement)); return out; }; @@ -238,24 +349,68 @@ export function CustomFormBuilder() { } doc.setFontSize(fontSize); + const lineH = fontSize * 1.4; + + const drawTablePdf = (rows: string[][], hasHeader: boolean) => { + if (!rows.length) return; + const cols = Math.max(...rows.map((r) => r.length)); + const colW = maxW / cols; + const cellPad = 4; + for (let ri = 0; ri < rows.length; ri++) { + const row = rows[ri]; + // Compute row height based on tallest cell + const cellLines = row.map((c) => + doc.splitTextToSize(applyVariables(c, ctx) || " ", colW - cellPad * 2), + ); + const rowH = Math.max(...cellLines.map((l) => l.length)) * lineH + cellPad * 2; + if (y + rowH > pageH - margin) { + doc.addPage(); + y = margin; + } + // Draw cells + for (let ci = 0; ci < cols; ci++) { + const x = margin + ci * colW; + doc.setDrawColor(180); + doc.rect(x, y, colW, rowH); + const isHeader = hasHeader && ri === 0; + doc.setFont(pdfFont, isHeader ? "bold" : "normal"); + const lines = cellLines[ci] ?? [""]; + let ty = y + cellPad + lineH * 0.8; + for (const ln of lines) { + doc.text(ln, x + cellPad, ty); + ty += lineH; + } + } + y += rowH; + } + y += 6; + }; + for (const seg of segments) { + if (seg.kind === "table") { + drawTablePdf(seg.rows, seg.hasHeader); + continue; + } const text = applyVariables(seg.text, ctx) || " "; - doc.setFont(pdfFont, "normal"); - const lines = doc.splitTextToSize(text, maxW); + doc.setFont(pdfFont, seg.heading && seg.heading > 0 ? "bold" : "normal"); + const indentPx = seg.indent || 0; + const indentPt = indentPx * 0.75; // px → pt + const segMaxW = maxW - indentPt; + const lines = doc.splitTextToSize(text, segMaxW); for (const line of lines) { if (y > pageH - margin) { doc.addPage(); y = margin; } - let x = margin; + let x = margin + indentPt; if (seg.align === "center") x = pageW / 2; else if (seg.align === "right") x = pageW - margin; doc.text(line, x, y, { align: seg.align === "center" ? "center" : seg.align === "right" ? "right" : "left", }); - y += fontSize * 1.4; + y += lineH; } - y += 4; + y += seg.caption ? 2 : 4; } const filenameBase = (renderTitle(ctx) || "Custom_Form").replace(/\s+/g, "_"); @@ -279,7 +434,7 @@ export function CustomFormBuilder() { if (!editor) return; const ctx = await getContext(); const segments = getRenderedSegments(); - const children: DocxParagraph[] = []; + const children: Array