diff --git a/src/components/forms/custom-form-builder.tsx b/src/components/forms/custom-form-builder.tsx index 2f587e8..1c52bb9 100644 --- a/src/components/forms/custom-form-builder.tsx +++ b/src/components/forms/custom-form-builder.tsx @@ -48,6 +48,8 @@ import { Columns, Captions, X, + SquareDashed, + Square, } from "lucide-react"; import { ClientHomeownerPicker } from "./form-pickers"; import { FormFieldDefsDialog } from "./form-field-defs-dialog"; @@ -130,6 +132,65 @@ const FONT_FAMILIES = [ const FONT_SIZES = [9, 10, 11, 12, 13, 14, 16, 18, 20, 24]; +// Extend table cells with per-side border attributes (default: all on). +type CellBorders = { t: boolean; r: boolean; b: boolean; l: boolean }; +const DEFAULT_BORDERS: CellBorders = { t: true, r: true, b: true, l: true }; + +function borderStyleString(b: CellBorders): string { + const on = "1px solid #999"; + const off = "1px solid transparent"; + return `border-top:${b.t ? on : off};border-right:${b.r ? on : off};border-bottom:${b.b ? on : off};border-left:${b.l ? on : off};`; +} + +function parseBordersFromAttr(val: unknown): CellBorders { + if (typeof val === "string") { + return { + t: val.includes("t"), + r: val.includes("r"), + b: val.includes("b"), + l: val.includes("l"), + }; + } + if (val && typeof val === "object") return { ...DEFAULT_BORDERS, ...(val as any) }; + return DEFAULT_BORDERS; +} + +function bordersToAttr(b: CellBorders): string { + return `${b.t ? "t" : ""}${b.r ? "r" : ""}${b.b ? "b" : ""}${b.l ? "l" : ""}` || "none"; +} + +const BorderedTableCell = TableCell.extend({ + addAttributes() { + return { + ...this.parent?.(), + borders: { + default: "trbl", + parseHTML: (el: HTMLElement) => el.getAttribute("data-borders") ?? "trbl", + renderHTML: (attrs: { borders?: string }) => { + const b = parseBordersFromAttr(attrs.borders); + return { "data-borders": bordersToAttr(b), style: borderStyleString(b) }; + }, + }, + }; + }, +}); + +const BorderedTableHeader = TableHeader.extend({ + addAttributes() { + return { + ...this.parent?.(), + borders: { + default: "trbl", + parseHTML: (el: HTMLElement) => el.getAttribute("data-borders") ?? "trbl", + renderHTML: (attrs: { borders?: string }) => { + const b = parseBordersFromAttr(attrs.borders); + return { "data-borders": bordersToAttr(b), style: borderStyleString(b) }; + }, + }, + }; + }, +}); + interface SavedTemplate { id: string; name: string; @@ -177,6 +238,12 @@ export function CustomFormBuilder() { const [caseSearch, setCaseSearch] = useState(""); const [saveFormat, setSaveFormat] = useState<"pdf" | "docx">("pdf"); + // Insert-table dialog + const [tableDialogOpen, setTableDialogOpen] = useState(false); + const [tableRows, setTableRows] = useState(3); + const [tableCols, setTableCols] = useState(3); + const [tableHeader, setTableHeader] = useState(true); + const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, paragraph: false }), @@ -185,8 +252,8 @@ export function CustomFormBuilder() { TextAlign.configure({ types: ["heading", "paragraph"] }), Table.configure({ resizable: true, HTMLAttributes: { class: "cf-table" } }), TableRow, - TableHeader, - TableCell, + BorderedTableHeader, + BorderedTableCell, ], content: "

Start typing here…

", editorProps: { @@ -256,9 +323,34 @@ export function CustomFormBuilder() { .run(); }; - const insertTable = () => { + const insertTable = (rows: number, cols: number, withHeaderRow: boolean) => { if (!editor) return; - editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); + editor + .chain() + .focus() + .insertTable({ + rows: Math.max(1, Math.min(20, rows)), + cols: Math.max(1, Math.min(12, cols)), + withHeaderRow, + }) + .run(); + }; + + const toggleCellBorder = (side: "t" | "r" | "b" | "l") => { + if (!editor) return; + const isHeader = editor.isActive("tableHeader"); + const nodeName = isHeader ? "tableHeader" : "tableCell"; + const cur = parseBordersFromAttr(editor.getAttributes(nodeName).borders); + const next = { ...cur, [side]: !cur[side] }; + editor.chain().focus().updateAttributes(nodeName, { borders: bordersToAttr(next) }).run(); + }; + + const setAllCellBorders = (on: boolean) => { + if (!editor) return; + const isHeader = editor.isActive("tableHeader"); + const nodeName = isHeader ? "tableHeader" : "tableCell"; + const next: CellBorders = { t: on, r: on, b: on, l: on }; + editor.chain().focus().updateAttributes(nodeName, { borders: bordersToAttr(next) }).run(); }; const toggleCaption = () => { @@ -267,8 +359,8 @@ export function CustomFormBuilder() { 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. + // Linearized document segments for export. Tables now carry per-cell border data. + type TableCellSeg = { text: string; borders: CellBorders; isHeader: boolean }; type Segment = | { kind: "text"; @@ -278,7 +370,7 @@ export function CustomFormBuilder() { caption?: boolean; heading?: 0 | 1 | 2 | 3; } - | { kind: "table"; rows: string[][]; hasHeader: boolean }; + | { kind: "table"; rows: TableCellSeg[][] }; const getRenderedSegments = (): Segment[] => { if (!editor) return []; @@ -292,14 +384,18 @@ export function CustomFormBuilder() { const pushBlock = (el: HTMLElement) => { const tag = el.tagName.toLowerCase(); if (tag === "table") { - const rows: string[][] = []; - let hasHeader = false; + const rows: TableCellSeg[][] = []; 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)); + rows.push( + cells.map((c) => ({ + text: cellText(c), + borders: parseBordersFromAttr(c.getAttribute("data-borders") ?? "trbl"), + isHeader: c.tagName.toLowerCase() === "th", + })), + ); }); - out.push({ kind: "table", rows, hasHeader }); + out.push({ kind: "table", rows }); return; } if (["p", "h1", "h2", "h3", "li"].includes(tag)) { @@ -366,7 +462,7 @@ export function CustomFormBuilder() { doc.setFontSize(fontSize); const lineH = fontSize * 1.4; - const drawTablePdf = (rows: string[][], hasHeader: boolean) => { + const drawTablePdf = (rows: TableCellSeg[][]) => { if (!rows.length) return; const cols = Math.max(...rows.map((r) => r.length)); const colW = maxW / cols; @@ -375,19 +471,24 @@ export function CustomFormBuilder() { const row = rows[ri]; // Compute row height based on tallest cell const cellLines = row.map((c) => - doc.splitTextToSize(applyVariables(c, ctx) || " ", colW - cellPad * 2), + doc.splitTextToSize(applyVariables(c.text, 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; + const cell = row[ci]; + const borders = cell?.borders ?? DEFAULT_BORDERS; + doc.setDrawColor(120); + doc.setLineWidth(0.5); + if (borders.t) doc.line(x, y, x + colW, y); + if (borders.b) doc.line(x, y + rowH, x + colW, y + rowH); + if (borders.l) doc.line(x, y, x, y + rowH); + if (borders.r) doc.line(x + colW, y, x + colW, y + rowH); + const isHeader = cell?.isHeader ?? false; doc.setFont(pdfFont, isHeader ? "bold" : "normal"); const lines = cellLines[ci] ?? [""]; let ty = y + cellPad + lineH * 0.8; @@ -403,7 +504,7 @@ export function CustomFormBuilder() { for (const seg of segments) { if (seg.kind === "table") { - drawTablePdf(seg.rows, seg.hasHeader); + drawTablePdf(seg.rows); continue; } const text = applyVariables(seg.text, ctx) || " "; @@ -469,13 +570,14 @@ export function CustomFormBuilder() { children.push(new DocxParagraph({ text: "" })); } - const tableBorder = { style: BorderStyle.SINGLE, size: 4, color: "999999" }; - const cellBorders = { - top: tableBorder, - bottom: tableBorder, - left: tableBorder, - right: tableBorder, - }; + const onBorder = { style: BorderStyle.SINGLE, size: 4, color: "999999" }; + const offBorder = { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }; + const docxBorders = (b: CellBorders) => ({ + top: b.t ? onBorder : offBorder, + bottom: b.b ? onBorder : offBorder, + left: b.l ? onBorder : offBorder, + right: b.r ? onBorder : offBorder, + }); for (const seg of segments) { if (seg.kind === "table") { @@ -487,19 +589,21 @@ export function CustomFormBuilder() { width: { size: totalW, type: WidthType.DXA }, columnWidths: Array(cols).fill(colW), rows: seg.rows.map( - (row, ri) => + (row) => new DocxTableRow({ children: Array.from({ length: cols }).map((_, ci) => { - const isHeader = seg.hasHeader && ri === 0; + const cell = row[ci]; + const borders = cell?.borders ?? DEFAULT_BORDERS; + const isHeader = cell?.isHeader ?? false; return new DocxTableCell({ - borders: cellBorders, + borders: docxBorders(borders), width: { size: colW, type: WidthType.DXA }, margins: { top: 80, bottom: 80, left: 120, right: 120 }, children: [ new DocxParagraph({ children: [ new TextRun({ - text: applyVariables(row[ci] ?? "", ctx), + text: applyVariables(cell?.text ?? "", ctx), font: fontFamily, size: fontSize * 2, bold: isHeader, @@ -879,7 +983,7 @@ export function CustomFormBuilder() { - + setTableDialogOpen(true)}> + editor?.chain().focus().deleteRow().run()} + > + + + editor?.chain().focus().deleteColumn().run()} + > + + editor?.chain().focus().deleteTable().run()} > + + toggleCellBorder("t")} + > + ▔ + + toggleCellBorder("r")} + > + ▕ + + toggleCellBorder("b")} + > + ▁ + + toggleCellBorder("l")} + > + ▏ + + setAllCellBorders(true)} + > + + + setAllCellBorders(false)} + > + + + + + + + Insert table + + Choose dimensions. You can toggle borders per cell from the toolbar after inserting. + + +
+
+ + setTableRows(Math.max(1, Math.min(20, parseInt(e.target.value) || 1)))} + /> +
+
+ + setTableCols(Math.max(1, Math.min(12, parseInt(e.target.value) || 1)))} + /> +
+
+
+ + +
+ + + + +
+
); }