Added image support to editor

X-Lovable-Edit-ID: edt-ee6bc0fa-0ec5-4c05-b576-f50f9211cf4a
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 18:32:30 +00:00
co-authored by renee-png
3 changed files with 177 additions and 2 deletions
BIN
View File
Binary file not shown.
+1
View File
@@ -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",
+176 -2
View File
@@ -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: "<p>Start typing here…</p>",
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.
// <br> 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 <img>, 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() {
<Outdent className="h-4 w-4" />
</ToolbarBtn>
<Separator orientation="vertical" className="h-6 mx-1" />
<ToolbarBtn title="Insert image…" onClick={triggerImagePicker}>
<ImagePlus className="h-4 w-4" />
</ToolbarBtn>
<ToolbarBtn title="Insert table…" onClick={() => setTableDialogOpen(true)}>
<TableIcon className="h-4 w-4" />
</ToolbarBtn>