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