Fixed indent & added tables

X-Lovable-Edit-ID: edt-f8c74cb0-57d4-4dae-88da-5c63d0e767bf
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 01:58:17 +00:00
co-authored by renee-png
4 changed files with 278 additions and 33 deletions
BIN
View File
Binary file not shown.
+4
View File
@@ -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",
+268 -33
View File
@@ -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: "<p>Start typing here…</p>",
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(`
<p>&nbsp;</p>
<p>______________________________</p>
<p>${label}</p>
`).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("&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;").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<DocxParagraph | DocxTable> = [];
if (!hideTitle && title.trim()) {
children.push(
@@ -299,7 +454,54 @@ 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,
};
for (const seg of segments) {
if (seg.kind === "table") {
const cols = Math.max(...seg.rows.map((r) => r.length));
const totalW = 9360; // US letter content width @ 1" margins
const colW = Math.floor(totalW / cols);
children.push(
new DocxTable({
width: { size: totalW, type: WidthType.DXA },
columnWidths: Array(cols).fill(colW),
rows: seg.rows.map(
(row, ri) =>
new DocxTableRow({
children: Array.from({ length: cols }).map((_, ci) => {
const isHeader = seg.hasHeader && ri === 0;
return new DocxTableCell({
borders: cellBorders,
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),
font: fontFamily,
size: fontSize * 2,
bold: isHeader,
}),
],
}),
],
});
}),
}),
),
}),
);
children.push(new DocxParagraph({ text: "" }));
continue;
}
const align =
seg.align === "center"
? AlignmentType.CENTER
@@ -308,14 +510,18 @@ export function CustomFormBuilder() {
: seg.align === "justify"
? AlignmentType.JUSTIFIED
: AlignmentType.LEFT;
const indentTwips = Math.round((seg.indent || 0) * 15); // ~px → twips (1px≈15twip)
children.push(
new DocxParagraph({
alignment: align,
indent: indentTwips ? { left: indentTwips } : undefined,
children: [
new TextRun({
text: applyVariables(seg.text, ctx),
font: fontFamily,
size: fontSize * 2,
italics: seg.caption,
bold: !!seg.heading && seg.heading > 0,
}),
],
}),
@@ -617,10 +823,39 @@ export function CustomFormBuilder() {
<ToolbarBtn title="Indent" onClick={indent}>
<IndentIcon className="h-4 w-4" />
</ToolbarBtn>
<ToolbarBtn title="Outdent (use Undo)" onClick={outdent}>
<ToolbarBtn title="Outdent" onClick={outdent}>
<Outdent className="h-4 w-4" />
</ToolbarBtn>
<Separator orientation="vertical" className="h-6 mx-1" />
<ToolbarBtn title="Insert table" onClick={insertTable}>
<TableIcon className="h-4 w-4" />
</ToolbarBtn>
<ToolbarBtn
title="Add row below"
onClick={() => editor?.chain().focus().addRowAfter().run()}
>
<Rows className="h-4 w-4" />
</ToolbarBtn>
<ToolbarBtn
title="Add column right"
onClick={() => editor?.chain().focus().addColumnAfter().run()}
>
<Columns className="h-4 w-4" />
</ToolbarBtn>
<ToolbarBtn
title="Delete table"
onClick={() => editor?.chain().focus().deleteTable().run()}
>
<X className="h-4 w-4" />
</ToolbarBtn>
<ToolbarBtn
title="Toggle caption (paragraph below an image/table)"
onClick={toggleCaption}
active={Boolean(editor?.getAttributes("paragraph").caption)}
>
<Captions className="h-4 w-4" />
</ToolbarBtn>
<Separator orientation="vertical" className="h-6 mx-1" />
<Button
size="sm"
variant="ghost"
+6
View File
@@ -143,3 +143,9 @@
}
h1, h2, h3, h4 { font-family: var(--font-serif); letter-spacing: -0.01em; }
}
/* Custom form editor: table + caption styling */
.ProseMirror table { border-collapse: collapse; margin: 8px 0; width: 100%; table-layout: fixed; }
.ProseMirror th, .ProseMirror td { border: 1px solid #999; padding: 6px 8px; vertical-align: top; min-width: 40px; }
.ProseMirror th { background: #f3f4f6; font-weight: 600; text-align: left; }
.ProseMirror .selectedCell { background: rgba(59,130,246,0.15); }