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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user