diff --git a/bun.lockb b/bun.lockb
index 52098e0..2c090ab 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 760cef2..c40ef39 100644
--- a/package.json
+++ b/package.json
@@ -49,6 +49,7 @@
"@tanstack/react-router": "^1.168.0",
"@tanstack/react-start": "^1.167.14",
"@tanstack/router-plugin": "^1.167.10",
+ "@tiptap/extension-image": "^3.22.3",
"@tiptap/extension-table": "^3.22.3",
"@tiptap/extension-table-cell": "^3.22.3",
"@tiptap/extension-table-header": "^3.22.3",
diff --git a/src/components/forms/custom-form-builder.tsx b/src/components/forms/custom-form-builder.tsx
index 8b837d3..54e7e68 100644
--- a/src/components/forms/custom-form-builder.tsx
+++ b/src/components/forms/custom-form-builder.tsx
@@ -9,6 +9,7 @@ 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 { Image as TiptapImage } from "@tiptap/extension-image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -51,6 +52,7 @@ import {
X,
SquareDashed,
Square,
+ ImagePlus,
} from "lucide-react";
import { ClientHomeownerPicker } from "./form-pickers";
import { FormFieldDefsDialog } from "./form-field-defs-dialog";
@@ -85,6 +87,7 @@ import {
TableCell as DocxTableCell,
WidthType,
BorderStyle,
+ ImageRun,
} from "docx";
import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client";
@@ -305,6 +308,11 @@ export function CustomFormBuilder() {
TableRow,
BorderedTableHeader,
BorderedTableCell,
+ TiptapImage.configure({
+ inline: false,
+ allowBase64: true,
+ HTMLAttributes: { class: "cf-img", style: "max-width:100%;height:auto;" },
+ }),
],
content: "
Start typing here…
",
editorProps: {
@@ -315,6 +323,37 @@ export function CustomFormBuilder() {
},
});
+ // Hidden file input for image uploads
+ const insertImageFromFile = (file: File) => {
+ if (!editor) return;
+ if (!file.type.startsWith("image/")) {
+ toast.error("Please select an image file");
+ return;
+ }
+ if (file.size > 5 * 1024 * 1024) {
+ toast.error("Image too large", { description: "Max 5 MB. Resize before inserting." });
+ return;
+ }
+ const reader = new FileReader();
+ reader.onload = () => {
+ const src = String(reader.result || "");
+ if (!src) return;
+ editor.chain().focus().setImage({ src, alt: file.name }).run();
+ };
+ reader.readAsDataURL(file);
+ };
+
+ const triggerImagePicker = () => {
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = "image/png,image/jpeg,image/jpg,image/gif,image/webp";
+ input.onchange = () => {
+ const f = input.files?.[0];
+ if (f) insertImageFromFile(f);
+ };
+ input.click();
+ };
+
useEffect(() => {
fetchFirm().then(setFirm);
fetchCustomFieldDefs().then(setCustomDefs);
@@ -456,7 +495,15 @@ export function CustomFormBuilder() {
heading?: 0 | 1 | 2 | 3;
marker?: string; // list marker prefix like "1." or "•"
}
- | { kind: "table"; rows: TableCellSeg[][] };
+ | { kind: "table"; rows: TableCellSeg[][] }
+ | {
+ kind: "image";
+ src: string;
+ align: "left" | "center" | "right";
+ widthPx?: number;
+ heightPx?: number;
+ alt?: string;
+ };
// Walk a DOM subtree, emitting inline runs with bold/italic/underline marks.
//
becomes a newline character within the current run stream.
@@ -629,8 +676,27 @@ export function CustomFormBuilder() {
}
};
+ const parseImg = (img: HTMLImageElement, parentAlign: "left" | "center" | "right" = "left") => {
+ const src = img.getAttribute("src") || "";
+ if (!src) return;
+ const w = parseInt(img.getAttribute("width") || "", 10);
+ const h = parseInt(img.getAttribute("height") || "", 10);
+ out.push({
+ kind: "image",
+ src,
+ align: parentAlign,
+ widthPx: Number.isFinite(w) && w > 0 ? w : undefined,
+ heightPx: Number.isFinite(h) && h > 0 ? h : undefined,
+ alt: img.getAttribute("alt") || undefined,
+ });
+ };
+
const pushBlock = (el: HTMLElement) => {
const tag = el.tagName.toLowerCase();
+ if (tag === "img") {
+ parseImg(el as HTMLImageElement);
+ return;
+ }
if (tag === "table") {
const rows: TableCellSeg[][] = [];
el.querySelectorAll("tr").forEach((tr) => {
@@ -649,10 +715,22 @@ export function CustomFormBuilder() {
if (tag === "ol") return pushListItems(el, true, 0);
if (tag === "ul") return pushListItems(el, false, 0);
if (["p", "h1", "h2", "h3"].includes(tag)) {
- const align = (el.style.textAlign as any) || "left";
+ const align = ((el.style.textAlign as any) || "left") as
+ | "left"
+ | "center"
+ | "right"
+ | "justify";
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;
+ // If the block contains an
, emit it as a separate image segment
+ const imgs = Array.from(el.querySelectorAll("img"));
+ if (imgs.length > 0) {
+ const imgAlign: "left" | "center" | "right" =
+ align === "center" ? "center" : align === "right" ? "right" : "left";
+ imgs.forEach((img) => parseImg(img as HTMLImageElement, imgAlign));
+ return;
+ }
out.push({
kind: "text",
runs: mergeRuns(collectRuns(el)),
@@ -878,6 +956,47 @@ export function CustomFormBuilder() {
drawTablePdf(seg.rows);
continue;
}
+ if (seg.kind === "image") {
+ // Compute display size in points. Default cap to content width.
+ let wPt = seg.widthPx ? seg.widthPx * 0.75 : Math.min(maxW, 240);
+ let hPt = seg.heightPx ? seg.heightPx * 0.75 : 0;
+ if (wPt > maxW) {
+ const scale = maxW / wPt;
+ wPt = maxW;
+ if (hPt) hPt = hPt * scale;
+ }
+ // If height unknown, estimate from natural ratio via Image()
+ if (!hPt) {
+ try {
+ const im = new Image();
+ im.src = seg.src;
+ // For data URLs, naturalWidth/Height is generally available synchronously after a tick.
+ // We approximate with a default 4:3 if unavailable.
+ const nw = im.naturalWidth || 0;
+ const nh = im.naturalHeight || 0;
+ hPt = nw > 0 && nh > 0 ? wPt * (nh / nw) : wPt * 0.75;
+ } catch {
+ hPt = wPt * 0.75;
+ }
+ }
+ if (y + hPt > pageH - margin) {
+ doc.addPage();
+ y = margin;
+ }
+ let x = margin;
+ if (seg.align === "center") x = margin + (maxW - wPt) / 2;
+ else if (seg.align === "right") x = margin + (maxW - wPt);
+ try {
+ // jsPDF auto-detects format from data URL header
+ const fmt = (seg.src.match(/^data:image\/(png|jpeg|jpg|gif|webp)/i)?.[1] || "PNG").toUpperCase();
+ doc.addImage(seg.src, fmt as any, x, y, wPt, hPt);
+ } catch (e) {
+ // Skip image if it fails to embed
+ console.warn("Image embed failed", e);
+ }
+ y += hPt + 6;
+ continue;
+ }
const indentPx = seg.indent || 0;
const indentPt = indentPx * 0.75; // px → pt
const markerGapPt = seg.marker ? Math.max(14, doc.getTextWidth(seg.marker) + 8) : 0;
@@ -1024,6 +1143,58 @@ export function CustomFormBuilder() {
continue;
}
+ if (seg.kind === "image") {
+ // Decode data URL to bytes for ImageRun
+ const m = seg.src.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,(.*)$/i);
+ if (!m) {
+ // Skip non-data URLs we can't fetch synchronously
+ continue;
+ }
+ const fmtRaw = m[1].toLowerCase();
+ const fmt: "png" | "jpg" | "gif" = fmtRaw === "png" ? "png" : fmtRaw === "gif" ? "gif" : "jpg";
+ const b64 = m[2];
+ const bin = atob(b64);
+ const bytes = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
+ // Convert px → EMU/pt: docx uses pixels for ImageRun transformation
+ const wPx = seg.widthPx ?? 320;
+ let hPx = seg.heightPx ?? 0;
+ if (!hPx) {
+ // Fallback: read natural size synchronously via Image()
+ try {
+ const im = new Image();
+ im.src = seg.src;
+ const nw = im.naturalWidth || 0;
+ const nh = im.naturalHeight || 0;
+ hPx = nw > 0 && nh > 0 ? Math.round(wPx * (nh / nw)) : Math.round(wPx * 0.75);
+ } catch {
+ hPx = Math.round(wPx * 0.75);
+ }
+ }
+ const docxAlign =
+ seg.align === "center"
+ ? AlignmentType.CENTER
+ : seg.align === "right"
+ ? AlignmentType.RIGHT
+ : AlignmentType.LEFT;
+ children.push(
+ new DocxParagraph({
+ alignment: docxAlign,
+ children: [
+ new ImageRun({
+ type: fmt as any,
+ data: bytes,
+ transformation: { width: wPx, height: hPx },
+ altText: seg.alt
+ ? { title: seg.alt, description: seg.alt, name: seg.alt }
+ : undefined,
+ }),
+ ],
+ }),
+ );
+ continue;
+ }
+
const align =
seg.align === "center"
? AlignmentType.CENTER
@@ -1423,6 +1594,9 @@ export function CustomFormBuilder() {
+
+
+
setTableDialogOpen(true)}>