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)}>