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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user