Added bordered table editor

X-Lovable-Edit-ID: edt-dbe33b39-c442-4dad-a088-81de4dabe392
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 02:11:42 +00:00
co-authored by renee-png
+236 -31
View File
@@ -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: "<p>Start typing here…</p>",
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() {
<Outdent className="h-4 w-4" />
</ToolbarBtn>
<Separator orientation="vertical" className="h-6 mx-1" />
<ToolbarBtn title="Insert table" onClick={insertTable}>
<ToolbarBtn title="Insert table…" onClick={() => setTableDialogOpen(true)}>
<TableIcon className="h-4 w-4" />
</ToolbarBtn>
<ToolbarBtn
@@ -894,12 +998,61 @@ export function CustomFormBuilder() {
>
<Columns className="h-4 w-4" />
</ToolbarBtn>
<ToolbarBtn
title="Delete row"
onClick={() => editor?.chain().focus().deleteRow().run()}
>
<Rows className="h-4 w-4 opacity-50" />
</ToolbarBtn>
<ToolbarBtn
title="Delete column"
onClick={() => editor?.chain().focus().deleteColumn().run()}
>
<Columns className="h-4 w-4 opacity-50" />
</ToolbarBtn>
<ToolbarBtn
title="Delete table"
onClick={() => editor?.chain().focus().deleteTable().run()}
>
<X className="h-4 w-4" />
</ToolbarBtn>
<Separator orientation="vertical" className="h-6 mx-1" />
<ToolbarBtn
title="Toggle top border (active cell)"
onClick={() => toggleCellBorder("t")}
>
<span className="text-[10px] font-bold">▔</span>
</ToolbarBtn>
<ToolbarBtn
title="Toggle right border (active cell)"
onClick={() => toggleCellBorder("r")}
>
<span className="text-[10px] font-bold">▕</span>
</ToolbarBtn>
<ToolbarBtn
title="Toggle bottom border (active cell)"
onClick={() => toggleCellBorder("b")}
>
<span className="text-[10px] font-bold">▁</span>
</ToolbarBtn>
<ToolbarBtn
title="Toggle left border (active cell)"
onClick={() => toggleCellBorder("l")}
>
<span className="text-[10px] font-bold">▏</span>
</ToolbarBtn>
<ToolbarBtn
title="All borders on (active cell)"
onClick={() => setAllCellBorders(true)}
>
<Square className="h-4 w-4" />
</ToolbarBtn>
<ToolbarBtn
title="All borders off (active cell)"
onClick={() => setAllCellBorders(false)}
>
<SquareDashed className="h-4 w-4" />
</ToolbarBtn>
<ToolbarBtn
title="Toggle caption (paragraph below an image/table)"
onClick={toggleCaption}
@@ -1086,6 +1239,58 @@ export function CustomFormBuilder() {
});
}}
/>
<Dialog open={tableDialogOpen} onOpenChange={setTableDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Insert table</DialogTitle>
<DialogDescription>
Choose dimensions. You can toggle borders per cell from the toolbar after inserting.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">Rows</Label>
<Input
type="number"
min={1}
max={20}
value={tableRows}
onChange={(e) => setTableRows(Math.max(1, Math.min(20, parseInt(e.target.value) || 1)))}
/>
</div>
<div>
<Label className="text-xs">Columns</Label>
<Input
type="number"
min={1}
max={12}
value={tableCols}
onChange={(e) => setTableCols(Math.max(1, Math.min(12, parseInt(e.target.value) || 1)))}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Switch checked={tableHeader} onCheckedChange={setTableHeader} id="th-row" />
<Label htmlFor="th-row" className="cursor-pointer text-sm">
First row as header
</Label>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setTableDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
insertTable(tableRows, tableCols, tableHeader);
setTableDialogOpen(false);
}}
>
Insert
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}