first commit

This commit is contained in:
Gokul Erusappan 2026-06-16 12:58:18 +05:30
commit 7a2098d7a0
18 changed files with 3293 additions and 0 deletions

88
CanvasStage.jsx Normal file
View File

@ -0,0 +1,88 @@
import { Stage, Layer, Line } from "react-konva";
import useEditorStore from "./useEditorStore";
import TextElement from "./elements/TextElement";
import TableElement from "./elements/TableElement";
import ImageElement from "./elements/ImageElement.jsx";
import QRCodeElement from "./elements/QRCodeElement";
import LineElement from "./elements/LineElement";
import SummaryElement from "./elements/SummaryElement";
import RectangleElement from "./elements/RectangleElement";
export default function CanvasStage() {
const elements = useEditorStore((state) => state.elements);
const page = useEditorStore((state) => state.page);
const setSelectedId = useEditorStore((state) => state.setSelectedId);
const centerX = page.width / 2;
const centerY = page.height / 2;
return (
<Stage
width={page.width}
height={page.height}
style={{ border: "1px solid #ccc" }}
onClick={(e) => {
// Deselect when clicking on empty canvas
if (e.target === e.target.getStage()) {
setSelectedId(null);
}
}}
>
<Layer>
{/* GRID */}
{Array.from({ length: 50 }).map((_, i) => (
<Line
key={i}
points={[i * 20, 0, i * 20, page.height]}
stroke="#eee"
/>
))}
{/* CENTER GUIDES */}
<Line
points={[centerX, 0, centerX, page.height]}
stroke="#ff00ff"
strokeWidth={1}
dash={[5, 5]}
opacity={0.3}
listening={false}
/>
<Line
points={[0, centerY, page.width, centerY]}
stroke="#ff00ff"
strokeWidth={1}
dash={[5, 5]}
opacity={0.3}
listening={false}
/>
{elements.map((el) => {
if (el.type === "text") {
return <TextElement key={el.id} element={el} />;
}
if (el.type === "table") {
return <TableElement key={el.id} element={el} />;
}
if (el.type === "image") {
return <ImageElement key={el.id} element={el} />;
}
if (el.type === "qrcode") {
return <QRCodeElement key={el.id} element={el} />;
}
if (el.type === "line") {
return <LineElement key={el.id} element={el} />;
}
if (el.type === "rectangle") {
return <RectangleElement key={el.id} element={el} />;
}
if (el.type === "summary") {
return <SummaryElement key={el.id} element={el} />;
}
return null;
})}
</Layer>
</Stage>
);
}

555
Editor.jsx Normal file
View File

@ -0,0 +1,555 @@
import { useEffect, useState } from "react";
import useEditorStore from "./useEditorStore.js";
import CanvasStage from "./CanvasStage";
import Toolbox from "./Toolbox";
import PropertiesPanel from "./PropertiesPanel";
import LayersPanel from "./LayersPanel";
import { PAGE_SIZES } from "./utils/pageSizes";
import "./Editor.scss";
import { generateHtml } from "./utils/exportToHtml.js";
import { printHtml } from "./utils/printHtml.js";
export default function Editor() {
const {
undo,
redo,
deleteSelected,
copyElement,
pasteElement,
exportTemplate,
selectedId,
setPageSize,
page,
toggleLayers,
showLayers,
} = useEditorStore();
const [zoom, setZoom] = useState(0.9);
const [selectedPageSize, setSelectedPageSize] = useState("A4");
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
// Ignore shortcuts when typing in input/textarea
const isTyping =
e.target.tagName === "INPUT" ||
e.target.tagName === "TEXTAREA" ||
e.target.isContentEditable;
if (isTyping) return;
// Ctrl/Cmd + Z = Undo
if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) {
e.preventDefault();
undo();
}
// Ctrl/Cmd + Shift + Z = Redo
if ((e.ctrlKey || e.metaKey) && e.key === "z" && e.shiftKey) {
e.preventDefault();
redo();
}
// Ctrl/Cmd + Y = Redo (alternative)
if ((e.ctrlKey || e.metaKey) && e.key === "y") {
e.preventDefault();
redo();
}
// Delete/Backspace = Delete selected (only when not typing)
if ((e.key === "Delete" || e.key === "Backspace") && selectedId) {
e.preventDefault();
deleteSelected();
}
// Ctrl/Cmd + C = Copy
if ((e.ctrlKey || e.metaKey) && e.key === "c" && selectedId) {
e.preventDefault();
copyElement();
}
// Ctrl/Cmd + V = Paste
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
e.preventDefault();
pasteElement();
}
// Ctrl/Cmd + D = Duplicate
if ((e.ctrlKey || e.metaKey) && e.key === "d" && selectedId) {
e.preventDefault();
const { duplicateElement } = useEditorStore.getState();
duplicateElement(selectedId);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [undo, redo, deleteSelected, copyElement, pasteElement, selectedId]);
const handleSave = () => {
debugger
const json = exportTemplate();
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "invoice-template.json";
a.click();
URL.revokeObjectURL(url);
};
const handlePageSizeChange = (e) => {
const sizeName = e.target.value;
setSelectedPageSize(sizeName);
const size = PAGE_SIZES[sizeName];
setPageSize({ ...size, name: sizeName });
};
const handleZoomIn = () => {
setZoom((prev) => Math.min(prev + 0.1, 2));
};
const handleZoomOut = () => {
setZoom((prev) => Math.max(prev - 0.1, 0.3));
};
const handleLoadTestTemplate = async () => {
const testTemplate = {
"elements": [
{
"id": "73079a52-87ac-4bd8-a10e-b901e316a911",
"type": "text",
"x": 30,
"y": 40,
"text": "Pozomind Technologies",
"fontSize": 25,
"fontFamily": "Arial",
"fontWeight": "normal",
"color": "#ff0000",
"align": "left",
"bold": true,
"italic": false,
"underline": true,
"rotation": 0
},
{
"id": "af702c6a-03b0-498b-8511-3737b754c15d",
"type": "image",
"x": 630,
"y": 20,
"width": 150,
"height": 100,
"src": "http://192.168.1.38/upload/getfile?fileId=69cbba1bdb04001678b283b8.png",
"rotation": 0
},
{
"id": "58ae6bb8-039c-46f3-b5de-6000553a89cb",
"type": "text",
"x": 30,
"y": 80,
"text": "51, Aahhaa Restaurant Back Side, Amirtha Nagar,\nStep Colony, Dharga, Hosur, Krishnagiri,\nTamil Nadu - 635126",
"fontSize": 18,
"fontFamily": "Arial",
"fontWeight": "normal",
"color": "#000000",
"align": "left",
"bold": false,
"italic": false,
"underline": false,
"rotation": 0
},
{
"id": "969bda69-023b-461e-9c65-7842004d1289",
"type": "table",
"x": 30,
"y": 210,
"width": 480,
"columns": [
{
"key": "sno",
"label": "S.No",
"width": 50,
"visible": true
},
{
"key": "item",
"label": "Item",
"width": 150,
"visible": true
},
{
"key": "hsn",
"label": "HSN",
"width": 60,
"visible": true
},
{
"key": "qty",
"label": "Qty",
"width": 50,
"visible": true
},
{
"key": "mrp",
"label": "MRP",
"width": 60,
"visible": true
},
{
"key": "rate",
"label": "Rate",
"width": 60,
"visible": true
},
{
"key": "dis",
"label": "Dis",
"width": 50,
"visible": true
},
{
"key": "tax",
"label": "Tax %",
"width": 60,
"visible": true
},
{
"key": "amt",
"label": "Amt",
"width": 70,
"visible": true
}
],
"sampleData": [
{
"sno": "1",
"item": "ITEM 1",
"hsn": "01",
"qty": "2",
"mrp": "100",
"rate": "88",
"dis": "0",
"tax": "5",
"amt": "176"
},
{
"sno": "2",
"item": "ITEM 2",
"hsn": "02",
"qty": "1",
"mrp": "350",
"rate": "300",
"dis": "0",
"tax": "5",
"amt": "300"
},
{
"sno": "3",
"item": "ITEM 3",
"hsn": "03",
"qty": "1",
"mrp": "250",
"rate": "200",
"dis": "0",
"tax": "0",
"amt": "200"
},
{
"sno": "4",
"item": "ITEM 4",
"hsn": "04",
"qty": "1",
"mrp": "50",
"rate": "30",
"dis": "0",
"tax": "18",
"amt": "30"
},
{
"sno": "5",
"item": "ITEM 5",
"hsn": "05",
"qty": "1",
"mrp": "50",
"rate": "30",
"dis": "0",
"tax": "40",
"amt": "30"
}
],
"rowHeight": 30,
"headerBg": "#f0f0f0",
"borderColor": "#000000",
"showSampleData": true,
"rotation": 0
},
{
"id": "098a7eac-5660-40d7-91b2-d7df56fccfd4",
"type": "text",
"x": 360,
"y": 170,
"text": "Takeaway",
"fontSize": 18,
"fontFamily": "Arial",
"fontWeight": "normal",
"color": "#000000",
"align": "left",
"bold": false,
"italic": false,
"underline": false,
"rotation": 0
},
{
"id": "dcfcd7b5-965c-4aa7-bfa6-9bba2dd1f147",
"type": "qrcode",
"x": 670,
"y": 450,
"size": 100,
"data": "https://example.com",
"rotation": 0
},
{
"id": "a2812eeb-f2fa-44df-a4a9-cd70b69a0115",
"type": "summary",
"x": 30,
"y": 460,
"fields": [
{
"key": "items",
"label": "Items",
"visible": true,
"width": 150
},
{
"key": "qty",
"label": "Qty",
"visible": true,
"width": 100
},
{
"key": "amount",
"label": "Amount",
"visible": true,
"width": 150
}
],
"fontSize": 14,
"labelColor": "#000000",
"valueColor": "#000000",
"spacing": 10,
"rotation": 0
},
{
"id": "6a4de268-9839-4ee6-b612-c6b4669830ea",
"type": "line",
"x": 40.82365943816583,
"y": 150.00000000000014,
"width": 729.4637752663348,
"thickness": 2,
"color": "#000000",
"rotation": 0
}
],
"page": {
"width": 794,
"height": 1123,
"name": "A4"
}
};
const { importTemplate } = useEditorStore.getState();
importTemplate(JSON.stringify(testTemplate));
const data = {
companyName: "Pozomind Technologies",
address: "123 Main St",
items: "10",
qty: "25",
amount: "₹5000",
tableData: [
{ sno: "1", item: "Product A", qty: "2", amt: "200" },
{ sno: "1", item: "Product A", qty: "2", amt: "200" },
{ sno: "1", item: "Product A", qty: "2", amt: "200" },
{ sno: "1", item: "Product A", qty: "2", amt: "200" },
{ sno: "1", item: "Product A", qty: "2", amt: "200" },
{ sno: "1", item: "Product A", qty: "2", amt: "200" },
{ sno: "1", item: "Product A", qty: "2", amt: "200" },
{ sno: "1", item: "Product A", qty: "2", amt: "200" },
{ sno: "1", item: "Product A", qty: "2", amt: "200" },
{ sno: "1", item: "Product A", qty: "2", amt: "200" },
{ sno: "1", item: "Product A", qty: "2", amt: "200" },
{ sno: "1", item: "Product A", qty: "2", amt: "200" },
{ sno: "1", item: "Product A", qty: "2", amt: "200" },
{ sno: "1", item: "Product A", qty: "2", amt: "200" },
]
};
const html = generateHtml(testTemplate, data);
await printHtml(html);
};
return (
<div style={{ height: "100vh", display: "flex", flexDirection: "column", width: '83vw', fontFamily: 'Poppins, sans-serif' }}>
{/* TOP BAR */}
{/* BODY */}
<div style={{ flex: 1, display: "flex" }}>
{/* LAYERS PANEL */}
{showLayers && <LayersPanel />}
{/* LEFT TOOLBAR */}
<Toolbox />
{/* CANVAS */}
<div style={{
flex: 1,
background: "#f3f3f3",
display: "flex",
flexDirection: "column",
overflow: "auto"
}}>
{/* Page Size Selector */}
<div style={{
padding: "12px 16px",
background: "#fff",
borderBottom: "1px solid #ddd",
display: "flex",
alignItems: "center",
gap: "12px"
}}>
<label style={{ fontSize: 13, fontWeight: 600 }}>Page Size:</label>
<select
value={selectedPageSize}
onChange={handlePageSizeChange}
style={{
padding: "6px 12px",
border: "1px solid #ddd",
borderRadius: 4,
fontSize: 13
}}
>
<option value="A4">A4 (210 × 297 mm)</option>
<option value="A5">A5 (148 × 210 mm)</option>
<option value="THERMAL_2IN">2 inch Thermal</option>
<option value="THERMAL_3IN">3 inch Thermal</option>
</select>
<div style={{ marginLeft: "auto", display: "flex", gap: 8, alignItems: "center" }}>
<button
onClick={handleZoomOut}
style={{
padding: "6px 12px",
border: "1px solid #ddd",
borderRadius: 4,
background: "#fff",
cursor: "pointer",
fontSize: 16
}}
title="Zoom Out"
>
</button>
{/* <span
onClick={handleZoomReset}
style={{
fontSize: 13,
fontWeight: 600,
minWidth: 50,
textAlign: "center",
cursor: "pointer",
userSelect: "none"
}}
title="Reset Zoom"
>
{Math.round(zoom * 100)}%
</span> */}
<button
onClick={handleZoomIn}
style={{
padding: "6px 12px",
border: "1px solid #ddd",
borderRadius: 4,
background: "#fff",
cursor: "pointer",
fontSize: 16
}}
title="Zoom In"
>
+
</button>
</div>
</div>
{/* Canvas Area */}
<div style={{
flex: 1,
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: 20
}}>
<div style={{
transform: `scale(${zoom})`,
transformOrigin: "center center",
transition: "transform 0.2s ease"
}}>
<div style={{
background: "#fff",
boxShadow: "0 4px 20px rgba(0,0,0,0.1)"
}}>
<CanvasStage />
</div>
</div>
</div>
</div>
{/* RIGHT PANEL */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '35px' }}>
<PropertiesPanel />
<div style={{
background: "#fff",
borderBottom: "1px solid #ddd",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
padding: "16px",
width: '280px'
}}>
<div style={{
display: "flex", gap: "8px", flexWrap: "wrap", alignItems: "center", justifyContent: "center", fontFamily: 'Poppins, sans-serif'
}}>
<div className="editor-action-btn secondary" onClick={toggleLayers}>
{showLayers ? "Hide" : "Show"} Layers
</div>
<div className="editor-action-btn secondary" onClick={undo}>
Undo
</div>
<div className="editor-action-btn secondary" onClick={redo}>
Redo
</div>
<div className="editor-action-btn" onClick={handleLoadTestTemplate} style={{ background: "#ff9800", color: "#fff" }}>
🧪 Load Test
</div>
<div
className={`editor-action-btn danger ${!selectedId ? "disabled" : ""}`}
onClick={selectedId ? deleteSelected : undefined}
>
🗑 Delete
</div>
<div className="editor-action-btn">
👁 Preview
</div>
<div className="editor-action-btn primary" onClick={handleSave}>
💾 Save
</div>
</div>
</div>
</div>
</div>
</div>
);
}

81
Editor.scss Normal file
View File

@ -0,0 +1,81 @@
.editor-action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: 13px;
font-weight: 500;
background: #ffffff;
border: 1px solid #ddd;
border-radius: 6px;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
}
.editor-action-btn:hover {
background: #f5f5f5;
border-color: #bbb;
}
.editor-action-btn:active {
transform: scale(0.96);
background: #eaeaea;
}
.editor-action-btn.disabled {
opacity: 0.4;
pointer-events: none;
cursor: not-allowed;
}
/* Default */
.editor-action-btn {
color: #333;
}
/* Primary (Save) */
.editor-action-btn.primary {
background: #4f46e5;
color: #fff;
border-color: #4f46e5;
}
.editor-action-btn.primary:hover {
background: #4338ca;
}
/* Danger (Delete) */
.editor-action-btn.danger {
color: #dc2626;
border-color: #fca5a5;
}
.editor-action-btn.danger:hover {
background: #fee2e2;
}
/* Secondary (Undo/Redo) */
.editor-action-btn.secondary {
background: #f9fafb;
}
.editor-action-btn {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.editor-action-btn.icon-only {
padding: 6px;
}
.editor-tool-btn {
cursor: pointer;
svg {
background: unset !important;
}
}

162
LayersPanel.jsx Normal file
View File

@ -0,0 +1,162 @@
import useEditorStore from "./useEditorStore";
import { Eye, EyeOff, Trash2 } from "lucide-react";
export default function LayersPanel() {
const elements = useEditorStore((state) => state.elements);
const selectedId = useEditorStore((state) => state.selectedId);
const setSelectedId = useEditorStore((state) => state.setSelectedId);
const deleteElement = useEditorStore((state) => state.deleteElement);
const showLayers = useEditorStore((state) => state.showLayers);
const toggleLayers = useEditorStore((state) => state.toggleLayers);
if (!showLayers) return null;
const getElementIcon = (type) => {
const icons = {
text: "T",
image: "🖼️",
qrcode: "⊡",
line: "─",
rectangle: "▭",
summary: "Σ",
table: "⊞",
};
return icons[type] || "•";
};
const getElementLabel = (element) => {
if (element.type === "text") return element.text.substring(0, 20);
if (element.type === "image") return element.src ? "Image" : "Empty Image";
return element.type.charAt(0).toUpperCase() + element.type.slice(1);
};
return (
<div style={styles.panel}>
<div style={styles.header}>
<h4 style={styles.title}>Layers</h4>
<button onClick={toggleLayers} style={styles.closeBtn}>
×
</button>
</div>
<div style={styles.layersList}>
{elements.length === 0 ? (
<div style={styles.emptyState}>No elements</div>
) : (
[...elements].reverse().map((element, index) => (
<div
key={element.id}
style={{
...styles.layerItem,
...(selectedId === element.id ? styles.layerItemSelected : {}),
}}
onClick={() => setSelectedId(element.id)}
>
<div style={styles.layerInfo}>
<span style={styles.layerIcon}>{getElementIcon(element.type)}</span>
<span style={styles.layerLabel}>{getElementLabel(element)}</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
deleteElement(element.id);
}}
style={styles.deleteBtn}
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
</div>
);
}
const styles = {
panel: {
width: 220,
background: "#fff",
borderRight: "1px solid #ddd",
display: "flex",
flexDirection: "column",
height: "100%",
},
header: {
padding: "12px 16px",
borderBottom: "1px solid #ddd",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
title: {
margin: 0,
fontSize: 14,
fontWeight: 600,
},
closeBtn: {
background: "none",
border: "none",
fontSize: 24,
cursor: "pointer",
padding: 0,
lineHeight: 1,
color: "#666",
},
layersList: {
flex: 1,
overflowY: "auto",
padding: 8,
},
emptyState: {
textAlign: "center",
color: "#999",
fontSize: 13,
marginTop: 20,
},
layerItem: {
padding: "8px 12px",
marginBottom: 4,
borderRadius: 4,
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
transition: "all 0.2s",
border: "1px solid transparent",
},
layerItemSelected: {
background: "#e3f2fd",
border: "1px solid #2196f3",
},
layerInfo: {
display: "flex",
alignItems: "center",
gap: 8,
flex: 1,
overflow: "hidden",
},
layerIcon: {
fontSize: 16,
width: 20,
textAlign: "center",
},
layerLabel: {
fontSize: 13,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
deleteBtn: {
background: "none",
border: "none",
cursor: "pointer",
padding: 4,
display: "flex",
alignItems: "center",
color: "#666",
opacity: 0.6,
transition: "opacity 0.2s",
},
};

827
PropertiesPanel.jsx Normal file
View File

@ -0,0 +1,827 @@
import useEditorStore from "./useEditorStore";
import { useState } from "react";
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
function SortableColumnItem({ column, elementId, updateColumnWidth, updateColumnLabel, toggleColumnVisibility }) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: column.key });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={{ ...styles.columnItem, ...style }} {...attributes}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flex: 1 }}>
<div {...listeners} style={{ cursor: "grab", padding: "0 4px" }}>
</div>
<label style={styles.checkbox}>
<input
type="checkbox"
checked={column.visible}
onChange={() => toggleColumnVisibility(elementId, column.key)}
/>
<span>{column.key}</span>
</label>
</div>
<div style={{ display: "flex", gap: 4 }}>
<input
type="text"
value={column.label}
onChange={(e) => updateColumnLabel(elementId, column.key, e.target.value)}
style={{ ...styles.input, width: 70, fontSize: 11 }}
placeholder="Label"
/>
<input
type="number"
value={column.width}
onChange={(e) => updateColumnWidth(elementId, column.key, Number(e.target.value))}
style={{ ...styles.input, width: 50, fontSize: 11 }}
placeholder="W"
/>
</div>
</div>
);
}
export default function PropertiesPanel() {
const elements = useEditorStore((state) => state.elements);
const selectedId = useEditorStore((state) => state.selectedId);
const updateElement = useEditorStore((state) => state.updateElement);
const duplicateElement = useEditorStore((state) => state.duplicateElement);
const deleteElement = useEditorStore((state) => state.deleteElement);
const toggleColumnVisibility = useEditorStore((state) => state.toggleColumnVisibility);
const updateColumnWidth = useEditorStore((state) => state.updateColumnWidth);
const updateColumnLabel = useEditorStore((state) => state.updateColumnLabel);
const reorderColumns = useEditorStore((state) => state.reorderColumns);
const selected = elements.find((el) => el.id === selectedId);
const [imageUpload, setImageUpload] = useState(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
if (!selected) {
return (
<div style={styles.panel}>
<div style={styles.emptyState}>
<p>Select an element to edit properties</p>
</div>
</div>
);
}
const handleImageUpload = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
updateElement(selected.id, { src: event.target.result });
};
reader.readAsDataURL(file);
}
};
const handleDragEnd = (event) => {
const { active, over } = event;
if (active.id !== over.id) {
const oldIndex = selected.columns.findIndex((col) => col.key === active.id);
const newIndex = selected.columns.findIndex((col) => col.key === over.id);
const newColumns = arrayMove(selected.columns, oldIndex, newIndex);
reorderColumns(selected.id, newColumns);
}
};
return (
<div style={styles.panel}>
<div style={styles.header}>
<h4 style={styles.title}>Properties</h4>
<div style={styles.actions}>
<button
onClick={() => duplicateElement(selected.id)}
style={styles.iconBtn}
title="Duplicate"
>
📋
</button>
<button
onClick={() => deleteElement(selected.id)}
style={styles.iconBtn}
title="Delete"
>
🗑
</button>
</div>
</div>
<div style={styles.section}>
<label style={styles.label}>Type</label>
<div style={styles.badge}>{selected.type.toUpperCase()}</div>
</div>
{/* POSITION */}
<div style={styles.section}>
<label style={styles.label}>Position</label>
<div style={styles.row}>
<div style={styles.inputGroup}>
<span style={styles.inputLabel}>X</span>
<input
type="number"
value={selected.x}
onChange={(e) =>
updateElement(selected.id, { x: Number(e.target.value) })
}
style={styles.input}
/>
</div>
<div style={styles.inputGroup}>
<span style={styles.inputLabel}>Y</span>
<input
type="number"
value={selected.y}
onChange={(e) =>
updateElement(selected.id, { y: Number(e.target.value) })
}
style={styles.input}
/>
</div>
</div>
</div>
{/* ROTATION */}
{selected.rotation !== undefined && (
<div style={styles.section}>
<label style={styles.label}>Rotation</label>
<input
type="number"
value={Math.round(selected.rotation)}
onChange={(e) =>
updateElement(selected.id, { rotation: Number(e.target.value) })
}
style={styles.input}
placeholder="Degrees"
/>
</div>
)}
{/* TEXT PROPERTIES */}
{selected.type === "text" && (
<>
<div style={styles.section}>
<label style={styles.label}>Text</label>
<textarea
value={selected.text}
onChange={(e) =>
updateElement(selected.id, { text: e.target.value })
}
style={{ ...styles.input, minHeight: 60, resize: "vertical" }}
/>
</div>
<div style={styles.section}>
<label style={styles.label}>Font Size</label>
<input
type="number"
value={selected.fontSize}
onChange={(e) =>
updateElement(selected.id, {
fontSize: Number(e.target.value),
})
}
style={styles.input}
/>
</div>
<div style={styles.section}>
<label style={styles.label}>Font Family</label>
<select
value={selected.fontFamily}
onChange={(e) =>
updateElement(selected.id, { fontFamily: e.target.value })
}
style={styles.input}
>
<option value="Arial">Arial</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Courier New">Courier New</option>
<option value="Georgia">Georgia</option>
<option value="Verdana">Verdana</option>
</select>
</div>
<div style={styles.section}>
<label style={styles.label}>Font Weight</label>
<select
value={selected.fontWeight || "normal"}
onChange={(e) =>
updateElement(selected.id, { fontWeight: e.target.value })
}
style={styles.input}
>
<option value="normal">Normal (400)</option>
<option value="500">Medium (500)</option>
<option value="600">Semi Bold (600)</option>
<option value="bold">Bold (700)</option>
<option value="800">Extra Bold (800)</option>
<option value="900">Black (900)</option>
</select>
</div>
<div style={styles.section}>
<label style={styles.label}>Color</label>
<input
type="color"
value={selected.color}
onChange={(e) =>
updateElement(selected.id, { color: e.target.value })
}
style={{ ...styles.input, height: 40 }}
/>
</div>
<div style={styles.section}>
<label style={styles.label}>Style</label>
<div style={styles.row}>
<label style={styles.checkbox}>
<input
type="checkbox"
checked={selected.bold}
onChange={(e) =>
updateElement(selected.id, { bold: e.target.checked })
}
/>
<span>Bold</span>
</label>
<label style={styles.checkbox}>
<input
type="checkbox"
checked={selected.italic}
onChange={(e) =>
updateElement(selected.id, { italic: e.target.checked })
}
/>
<span>Italic</span>
</label>
</div>
<div style={{ marginTop: 8 }}>
<label style={styles.checkbox}>
<input
type="checkbox"
checked={selected.underline}
onChange={(e) =>
updateElement(selected.id, { underline: e.target.checked })
}
/>
<span>Underline</span>
</label>
</div>
</div>
<div style={styles.section}>
<label style={styles.label}>Alignment</label>
<select
value={selected.align}
onChange={(e) =>
updateElement(selected.id, { align: e.target.value })
}
style={styles.input}
>
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
</div>
</>
)}
{/* IMAGE PROPERTIES */}
{selected.type === "image" && (
<>
<div style={styles.section}>
<label style={styles.label}>Image</label>
{selected.src ? (
<div style={{ position: "relative" }}>
<img
src={selected.src}
alt="Preview"
style={{
width: "100%",
height: 120,
objectFit: "cover",
borderRadius: 4,
border: "1px solid #ddd",
}}
/>
<button
onClick={() => updateElement(selected.id, { src: null })}
style={{
position: "absolute",
top: 8,
right: 8,
background: "#ff4444",
color: "#fff",
border: "none",
borderRadius: 4,
padding: "4px 8px",
cursor: "pointer",
fontSize: 12,
}}
>
Delete
</button>
</div>
) : (
<input
type="file"
accept="image/*"
onChange={handleImageUpload}
style={styles.input}
/>
)}
</div>
<div style={styles.section}>
<label style={styles.label}>Size</label>
<div style={styles.row}>
<div style={styles.inputGroup}>
<span style={styles.inputLabel}>W</span>
<input
type="number"
value={selected.width}
onChange={(e) =>
updateElement(selected.id, {
width: Number(e.target.value),
})
}
style={styles.input}
/>
</div>
<div style={styles.inputGroup}>
<span style={styles.inputLabel}>H</span>
<input
type="number"
value={selected.height}
onChange={(e) =>
updateElement(selected.id, {
height: Number(e.target.value),
})
}
style={styles.input}
/>
</div>
</div>
</div>
</>
)}
{/* QRCODE PROPERTIES */}
{selected.type === "qrcode" && (
<>
<div style={styles.section}>
<label style={styles.label}>QR Code Data</label>
<textarea
value={selected.data}
onChange={(e) =>
updateElement(selected.id, { data: e.target.value })
}
style={{ ...styles.input, minHeight: 60, resize: "vertical" }}
placeholder="Enter URL or text"
/>
</div>
<div style={styles.section}>
<label style={styles.label}>Size</label>
<input
type="number"
value={selected.size}
onChange={(e) =>
updateElement(selected.id, {
size: Number(e.target.value),
})
}
style={styles.input}
/>
</div>
</>
)}
{/* LINE PROPERTIES */}
{selected.type === "line" && (
<>
<div style={styles.section}>
<label style={styles.label}>Width</label>
<input
type="number"
value={selected.width}
onChange={(e) =>
updateElement(selected.id, {
width: Number(e.target.value),
})
}
style={styles.input}
/>
</div>
<div style={styles.section}>
<label style={styles.label}>Thickness</label>
<input
type="number"
value={selected.thickness}
onChange={(e) =>
updateElement(selected.id, {
thickness: Number(e.target.value),
})
}
style={styles.input}
/>
</div>
<div style={styles.section}>
<label style={styles.label}>Color</label>
<input
type="color"
value={selected.color}
onChange={(e) =>
updateElement(selected.id, { color: e.target.value })
}
style={{ ...styles.input, height: 40 }}
/>
</div>
</>
)}
{/* RECTANGLE PROPERTIES */}
{selected.type === "rectangle" && (
<>
<div style={styles.section}>
<label style={styles.label}>Size</label>
<div style={styles.row}>
<div style={styles.inputGroup}>
<span style={styles.inputLabel}>W</span>
<input
type="number"
value={selected.width}
onChange={(e) =>
updateElement(selected.id, {
width: Number(e.target.value),
})
}
style={styles.input}
/>
</div>
<div style={styles.inputGroup}>
<span style={styles.inputLabel}>H</span>
<input
type="number"
value={selected.height}
onChange={(e) =>
updateElement(selected.id, {
height: Number(e.target.value),
})
}
style={styles.input}
/>
</div>
</div>
</div>
<div style={styles.section}>
<label style={styles.label}>Fill Color</label>
<input
type="color"
value={selected.fill === "transparent" ? "#ffffff" : selected.fill}
onChange={(e) =>
updateElement(selected.id, { fill: e.target.value })
}
style={{ ...styles.input, height: 40 }}
/>
<label style={{ ...styles.checkbox, marginTop: 8 }}>
<input
type="checkbox"
checked={selected.fill === "transparent"}
onChange={(e) =>
updateElement(selected.id, {
fill: e.target.checked ? "transparent" : "#ffffff",
})
}
/>
<span>Transparent</span>
</label>
</div>
<div style={styles.section}>
<label style={styles.label}>Border Color</label>
<input
type="color"
value={selected.stroke}
onChange={(e) =>
updateElement(selected.id, { stroke: e.target.value })
}
style={{ ...styles.input, height: 40 }}
/>
</div>
<div style={styles.section}>
<label style={styles.label}>Border Width</label>
<input
type="number"
value={selected.strokeWidth}
onChange={(e) =>
updateElement(selected.id, {
strokeWidth: Number(e.target.value),
})
}
style={styles.input}
/>
</div>
<div style={styles.section}>
<label style={styles.label}>Corner Radius</label>
<input
type="number"
value={selected.cornerRadius}
onChange={(e) =>
updateElement(selected.id, {
cornerRadius: Number(e.target.value),
})
}
style={styles.input}
/>
</div>
</>
)}
{/* SUMMARY PROPERTIES */}
{selected.type === "summary" && (
<>
<div style={styles.section}>
<label style={styles.label}>Font Size</label>
<input
type="number"
value={selected.fontSize}
onChange={(e) =>
updateElement(selected.id, {
fontSize: Number(e.target.value),
})
}
style={styles.input}
/>
</div>
<div style={styles.section}>
<label style={styles.label}>Spacing</label>
<input
type="number"
value={selected.spacing}
onChange={(e) =>
updateElement(selected.id, {
spacing: Number(e.target.value),
})
}
style={styles.input}
/>
</div>
<div style={styles.section}>
<label style={styles.label}>Label Color</label>
<input
type="color"
value={selected.labelColor}
onChange={(e) =>
updateElement(selected.id, { labelColor: e.target.value })
}
style={{ ...styles.input, height: 40 }}
/>
</div>
<div style={styles.section}>
<label style={styles.label}>Value Color</label>
<input
type="color"
value={selected.valueColor}
onChange={(e) =>
updateElement(selected.id, { valueColor: e.target.value })
}
style={{ ...styles.input, height: 40 }}
/>
</div>
<div style={styles.section}>
<label style={styles.label}>Fields</label>
{selected.fields.map((field, idx) => (
<div key={field.key} style={styles.columnItem}>
<label style={styles.checkbox}>
<input
type="checkbox"
checked={field.visible}
onChange={() => {
const newFields = [...selected.fields];
newFields[idx].visible = !newFields[idx].visible;
updateElement(selected.id, { fields: newFields });
}}
/>
<span>{field.label}</span>
</label>
<input
type="text"
value={field.label}
onChange={(e) => {
const newFields = [...selected.fields];
newFields[idx].label = e.target.value;
updateElement(selected.id, { fields: newFields });
}}
style={{ ...styles.input, width: 80 }}
placeholder="Label"
/>
</div>
))}
</div>
</>
)}
{/* TABLE PROPERTIES */}
{selected.type === "table" && (
<>
<div style={styles.section}>
<label style={styles.label}>Row Height</label>
<input
type="number"
value={selected.rowHeight}
onChange={(e) =>
updateElement(selected.id, {
rowHeight: Number(e.target.value),
})
}
style={styles.input}
/>
</div>
<div style={styles.section}>
<label style={styles.label}>Columns (Drag to reorder)</label>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={selected.columns.map((col) => col.key)}
strategy={verticalListSortingStrategy}
>
{selected.columns.map((col) => (
<SortableColumnItem
key={col.key}
column={col}
elementId={selected.id}
updateColumnWidth={updateColumnWidth}
updateColumnLabel={updateColumnLabel}
toggleColumnVisibility={toggleColumnVisibility}
/>
))}
</SortableContext>
</DndContext>
</div>
<div style={styles.section}>
<label style={styles.label}>Header Background</label>
<input
type="color"
value={selected.headerBg}
onChange={(e) =>
updateElement(selected.id, { headerBg: e.target.value })
}
style={{ ...styles.input, height: 40 }}
/>
</div>
<div style={styles.section}>
<label style={styles.label}>Border Color</label>
<input
type="color"
value={selected.borderColor}
onChange={(e) =>
updateElement(selected.id, { borderColor: e.target.value })
}
style={{ ...styles.input, height: 40 }}
/>
</div>
<div style={styles.section}>
<label style={styles.checkbox}>
<input
type="checkbox"
checked={selected.showSampleData}
onChange={(e) =>
updateElement(selected.id, { showSampleData: e.target.checked })
}
/>
<span>Show Sample Data</span>
</label>
</div>
</>
)}
</div>
);
}
const styles = {
panel: {
width: 280,
background: "#fff",
borderLeft: "1px solid #ddd",
padding: 16,
overflowY: "auto",
},
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
},
title: {
margin: 0,
fontSize: 16,
fontWeight: 600,
},
actions: {
display: "flex",
gap: 4,
},
iconBtn: {
background: "none",
border: "1px solid #ddd",
borderRadius: 4,
padding: "4px 8px",
cursor: "pointer",
fontSize: 14,
},
emptyState: {
textAlign: "center",
color: "#999",
marginTop: 40,
},
section: {
marginBottom: 16,
},
label: {
display: "block",
fontSize: 12,
fontWeight: 600,
marginBottom: 6,
color: "#333",
},
input: {
width: "100%",
padding: 8,
border: "1px solid #ddd",
borderRadius: 4,
fontSize: 13,
boxSizing: "border-box",
},
row: {
display: "flex",
gap: 8,
},
inputGroup: {
flex: 1,
display: "flex",
flexDirection: "column",
},
inputLabel: {
fontSize: 11,
color: "#666",
marginBottom: 4,
},
checkbox: {
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 13,
cursor: "pointer",
},
badge: {
display: "inline-block",
padding: "4px 8px",
background: "#e3f2fd",
color: "#1976d2",
borderRadius: 4,
fontSize: 11,
fontWeight: 600,
},
columnItem: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "6px 0",
borderBottom: "1px solid #f0f0f0",
},
};

65
Toolbox.jsx Normal file
View File

@ -0,0 +1,65 @@
import useEditorStore from "./useEditorStore";
import { CiViewTable } from "react-icons/ci";
import { CiImageOn } from "react-icons/ci";
import { MdOutlineTextFields } from "react-icons/md";
import { BsQrCode } from "react-icons/bs";
import { AiOutlineLine } from "react-icons/ai";
import { TbSum } from "react-icons/tb";
import { RiRectangleLine } from "react-icons/ri";
export default function Toolbox() {
const addElement = useEditorStore((state) => state.addElement);
const tools = [
{ type: "text", label: <MdOutlineTextFields size={25} />, title: "Text" },
{ type: "image", label: <CiImageOn size={25} />, title: "Image" },
{ type: "qrcode", label: <BsQrCode size={25} />, title: "QR Code" },
{ type: "line", label: <AiOutlineLine size={25} />, title: "Line" },
{ type: "rectangle", label: <RiRectangleLine size={25} />, title: "Rectangle" },
{ type: "summary", label: <TbSum size={25} />, title: "Summary" },
{ type: "table", label: <CiViewTable size={25} />, title: "Table" },
];
return (
<div style={{
width: 60,
background: "#fff",
borderRight: "1px solid #ddd",
display: "flex",
flexDirection: "column",
alignItems: "center",
paddingTop: 10
}}>
{tools.map((t) => (
<div
key={t.type}
onClick={() => addElement(t.type)}
title={t.title}
style={{
width: 40,
height: 40,
marginBottom: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
borderRadius: 6,
background: "#f5f5f5",
fontSize: "16px",
fontWeight: "bold",
transition: "all 0.2s",
}}
onMouseEnter={(e) => {
e.target.style.background = "#e0e0e0";
}}
onMouseLeave={(e) => {
e.target.style.background = "#f5f5f5";
}}
className="editor-tool-btn"
>
{t.label}
</div>
))}
</div>
);
}

190
elements/ImageElement.jsx Normal file
View File

@ -0,0 +1,190 @@
import { Image as KonvaImage, Transformer, Rect, Text } from "react-konva";
import { useRef, useEffect, useState } from "react";
import useEditorStore from "../useEditorStore";
export default function ImageElement({ element }) {
const updateElement = useEditorStore((state) => state.updateElement);
const setSelectedId = useEditorStore((state) => state.setSelectedId);
const selectedId = useEditorStore((state) => state.selectedId);
const shapeRef = useRef();
const trRef = useRef();
const [image, setImage] = useState(null);
const [isHovered, setIsHovered] = useState(false);
const isSelected = selectedId === element.id;
useEffect(() => {
if (element.src) {
const img = new window.Image();
img.src = element.src;
img.onload = () => {
setImage(img);
};
} else {
setImage(null);
}
}, [element.src]);
useEffect(() => {
if (isSelected && trRef.current && shapeRef.current) {
trRef.current.nodes([shapeRef.current]);
trRef.current.getLayer().batchDraw();
}
}, [isSelected]);
const handleImageUpload = () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
updateElement(element.id, { src: event.target.result });
};
reader.readAsDataURL(file);
}
};
input.click();
};
const handleDeleteImage = (e) => {
e.cancelBubble = true;
updateElement(element.id, { src: null });
};
if (!image) {
// Placeholder when no image
return (
<>
<Rect
ref={shapeRef}
x={element.x}
y={element.y}
width={element.width}
height={element.height}
fill="#f0f0f0"
stroke="#ddd"
strokeWidth={2}
dash={[5, 5]}
rotation={element.rotation}
draggable
onClick={() => {
setSelectedId(element.id);
handleImageUpload();
}}
onDragEnd={(e) => {
const snap = (v) => Math.round(v / 10) * 10;
updateElement(element.id, {
x: snap(e.target.x()),
y: snap(e.target.y()),
});
}}
/>
<Text
text="📷 Click to Upload"
x={element.x + element.width / 2 - 60}
y={element.y + element.height / 2 - 10}
fontSize={14}
fill="#999"
listening={false}
/>
{isSelected && (
<Transformer
ref={trRef}
rotateEnabled={true}
boundBoxFunc={(oldBox, newBox) => {
if (newBox.width < 20 || newBox.height < 20) {
return oldBox;
}
return newBox;
}}
/>
)}
</>
);
}
return (
<>
<KonvaImage
ref={shapeRef}
image={image}
x={element.x}
y={element.y}
width={element.width}
height={element.height}
rotation={element.rotation}
draggable
onClick={() => setSelectedId(element.id)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onDragEnd={(e) => {
const snap = (v) => Math.round(v / 10) * 10;
updateElement(element.id, {
x: snap(e.target.x()),
y: snap(e.target.y()),
});
}}
onTransformEnd={() => {
const node = shapeRef.current;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
node.scaleX(1);
node.scaleY(1);
updateElement(element.id, {
x: node.x(),
y: node.y(),
width: Math.max(5, node.width() * scaleX),
height: Math.max(5, node.height() * scaleY),
rotation: node.rotation(),
});
}}
/>
{/* Delete button on hover */}
{isHovered && (
<Rect
x={element.x + element.width - 30}
y={element.y + 5}
width={25}
height={25}
fill="#ff4444"
cornerRadius={4}
onClick={handleDeleteImage}
onTap={handleDeleteImage}
/>
)}
{isHovered && (
<Text
text="×"
x={element.x + element.width - 22}
y={element.y + 8}
fontSize={18}
fill="#fff"
fontStyle="bold"
onClick={handleDeleteImage}
onTap={handleDeleteImage}
/>
)}
{isSelected && (
<Transformer
ref={trRef}
rotateEnabled={true}
boundBoxFunc={(oldBox, newBox) => {
// Limit resize
if (newBox.width < 20 || newBox.height < 20) {
return oldBox;
}
return newBox;
}}
/>
)}
</>
);
}

71
elements/LineElement.jsx Normal file
View File

@ -0,0 +1,71 @@
import { Line, Transformer } from "react-konva";
import { useRef, useEffect } from "react";
import useEditorStore from "../useEditorStore";
export default function LineElement({ element }) {
const updateElement = useEditorStore((state) => state.updateElement);
const setSelectedId = useEditorStore((state) => state.setSelectedId);
const selectedId = useEditorStore((state) => state.selectedId);
const shapeRef = useRef();
const trRef = useRef();
const isSelected = selectedId === element.id;
useEffect(() => {
if (isSelected && trRef.current && shapeRef.current) {
trRef.current.nodes([shapeRef.current]);
trRef.current.getLayer().batchDraw();
}
}, [isSelected]);
return (
<>
<Line
ref={shapeRef}
points={[0, 0, element.width, 0]}
x={element.x}
y={element.y}
stroke={element.color}
strokeWidth={element.thickness}
rotation={element.rotation}
draggable
onClick={() => setSelectedId(element.id)}
onDragEnd={(e) => {
const snap = (v) => Math.round(v / 10) * 10;
updateElement(element.id, {
x: snap(e.target.x()),
y: snap(e.target.y()),
});
}}
onTransformEnd={() => {
const node = shapeRef.current;
const scaleX = node.scaleX();
node.scaleX(1);
updateElement(element.id, {
x: node.x(),
y: node.y(),
width: Math.max(20, element.width * scaleX),
rotation: node.rotation(),
});
}}
/>
{isSelected && (
<Transformer
ref={trRef}
enabledAnchors={["middle-left", "middle-right"]}
rotateEnabled={true}
boundBoxFunc={(oldBox, newBox) => {
if (newBox.width < 20) {
return oldBox;
}
return newBox;
}}
/>
)}
</>
);
}

View File

@ -0,0 +1,94 @@
import { Image as KonvaImage, Transformer } from "react-konva";
import { useRef, useEffect, useState } from "react";
import useEditorStore from "../useEditorStore";
import QRCode from "qrcode";
export default function QRCodeElement({ element }) {
const updateElement = useEditorStore((state) => state.updateElement);
const setSelectedId = useEditorStore((state) => state.setSelectedId);
const selectedId = useEditorStore((state) => state.selectedId);
const shapeRef = useRef();
const trRef = useRef();
const [qrImage, setQrImage] = useState(null);
const isSelected = selectedId === element.id;
useEffect(() => {
const generateQR = async () => {
try {
const url = await QRCode.toDataURL(element.data || "https://example.com", {
width: element.size,
margin: 1,
});
const img = new window.Image();
img.src = url;
img.onload = () => {
setQrImage(img);
};
} catch (err) {
console.error("QR Code generation error:", err);
}
};
generateQR();
}, [element.data, element.size]);
useEffect(() => {
if (isSelected && trRef.current && shapeRef.current) {
trRef.current.nodes([shapeRef.current]);
trRef.current.getLayer().batchDraw();
}
}, [isSelected]);
if (!qrImage) return null;
return (
<>
<KonvaImage
ref={shapeRef}
image={qrImage}
x={element.x}
y={element.y}
width={element.size}
height={element.size}
rotation={element.rotation}
draggable
onClick={() => setSelectedId(element.id)}
onDragEnd={(e) => {
const snap = (v) => Math.round(v / 10) * 10;
updateElement(element.id, {
x: snap(e.target.x()),
y: snap(e.target.y()),
});
}}
onTransformEnd={() => {
const node = shapeRef.current;
const scaleX = node.scaleX();
node.scaleX(1);
node.scaleY(1);
updateElement(element.id, {
x: node.x(),
y: node.y(),
size: Math.max(50, node.width() * scaleX),
rotation: node.rotation(),
});
}}
/>
{isSelected && (
<Transformer
ref={trRef}
rotateEnabled={true}
boundBoxFunc={(oldBox, newBox) => {
if (newBox.width < 50 || newBox.height < 50) {
return oldBox;
}
return newBox;
}}
/>
)}
</>
);
}

View File

@ -0,0 +1,76 @@
import { Rect, Transformer } from "react-konva";
import { useRef, useEffect } from "react";
import useEditorStore from "../useEditorStore";
export default function RectangleElement({ element }) {
const updateElement = useEditorStore((state) => state.updateElement);
const setSelectedId = useEditorStore((state) => state.setSelectedId);
const selectedId = useEditorStore((state) => state.selectedId);
const shapeRef = useRef();
const trRef = useRef();
const isSelected = selectedId === element.id;
useEffect(() => {
if (isSelected && trRef.current && shapeRef.current) {
trRef.current.nodes([shapeRef.current]);
trRef.current.getLayer().batchDraw();
}
}, [isSelected]);
return (
<>
<Rect
ref={shapeRef}
x={element.x}
y={element.y}
width={element.width}
height={element.height}
fill={element.fill}
stroke={element.stroke}
strokeWidth={element.strokeWidth}
cornerRadius={element.cornerRadius}
rotation={element.rotation}
draggable
onClick={() => setSelectedId(element.id)}
onDragEnd={(e) => {
const snap = (v) => Math.round(v / 10) * 10;
updateElement(element.id, {
x: snap(e.target.x()),
y: snap(e.target.y()),
});
}}
onTransformEnd={() => {
const node = shapeRef.current;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
node.scaleX(1);
node.scaleY(1);
updateElement(element.id, {
x: node.x(),
y: node.y(),
width: Math.max(10, node.width() * scaleX),
height: Math.max(10, node.height() * scaleY),
rotation: node.rotation(),
});
}}
/>
{isSelected && (
<Transformer
ref={trRef}
rotateEnabled={true}
boundBoxFunc={(oldBox, newBox) => {
if (newBox.width < 10 || newBox.height < 10) {
return oldBox;
}
return newBox;
}}
/>
)}
</>
);
}

View File

@ -0,0 +1,83 @@
import { Group, Text, Transformer } from "react-konva";
import { useRef, useEffect } from "react";
import useEditorStore from "../useEditorStore";
export default function SummaryElement({ element }) {
const updateElement = useEditorStore((state) => state.updateElement);
const setSelectedId = useEditorStore((state) => state.setSelectedId);
const selectedId = useEditorStore((state) => state.selectedId);
const groupRef = useRef();
const trRef = useRef();
const isSelected = selectedId === element.id;
useEffect(() => {
if (isSelected && trRef.current && groupRef.current) {
trRef.current.nodes([groupRef.current]);
trRef.current.getLayer().batchDraw();
}
}, [isSelected]);
const visibleFields = element.fields.filter((field) => field.visible);
return (
<>
<Group
ref={groupRef}
x={element.x}
y={element.y}
rotation={element.rotation}
draggable
onClick={() => setSelectedId(element.id)}
onDragEnd={(e) => {
const snap = (v) => Math.round(v / 10) * 10;
updateElement(element.id, {
x: snap(e.target.x()),
y: snap(e.target.y()),
});
}}
onTransformEnd={() => {
const node = groupRef.current;
updateElement(element.id, {
rotation: node.rotation(),
});
}}
>
{visibleFields.map((field, index) => {
const yPos = index * (element.fontSize + element.spacing);
return (
<Group key={field.key}>
{/* Label */}
<Text
text={`${field.label}:`}
x={0}
y={yPos}
fontSize={element.fontSize}
fill={element.labelColor}
fontStyle="bold"
/>
{/* Value placeholder */}
<Text
text={`{{${field.key}}}`}
x={field.width}
y={yPos}
fontSize={element.fontSize}
fill={element.valueColor}
/>
</Group>
);
})}
</Group>
{isSelected && (
<Transformer
ref={trRef}
enabledAnchors={[]}
rotateEnabled={true}
/>
)}
</>
);
}

131
elements/TableElement.jsx Normal file
View File

@ -0,0 +1,131 @@
import { Group, Rect, Text, Transformer } from "react-konva";
import { useRef, useEffect } from "react";
import useEditorStore from "../useEditorStore";
export default function TableElement({ element }) {
const updateElement = useEditorStore((state) => state.updateElement);
const setSelectedId = useEditorStore((state) => state.setSelectedId);
const selectedId = useEditorStore((state) => state.selectedId);
const groupRef = useRef();
const trRef = useRef();
const isSelected = selectedId === element.id;
useEffect(() => {
if (isSelected && trRef.current && groupRef.current) {
trRef.current.nodes([groupRef.current]);
trRef.current.getLayer().batchDraw();
}
}, [isSelected]);
const visibleColumns = element.columns.filter((col) => col.visible);
const totalWidth = visibleColumns.reduce((sum, col) => sum + col.width, 0);
return (
<>
<Group
ref={groupRef}
x={element.x}
y={element.y}
rotation={element.rotation}
draggable
onClick={() => setSelectedId(element.id)}
onDragEnd={(e) => {
const snap = (v) => Math.round(v / 10) * 10;
updateElement(element.id, {
x: snap(e.target.x()),
y: snap(e.target.y()),
});
}}
onTransformEnd={() => {
const node = groupRef.current;
updateElement(element.id, {
rotation: node.rotation(),
});
}}
>
{/* Header Row */}
{visibleColumns.map((col, i) => {
const xPos = visibleColumns
.slice(0, i)
.reduce((sum, c) => sum + c.width, 0);
return (
<Group key={col.key}>
<Rect
x={xPos}
y={0}
width={col.width}
height={element.rowHeight}
fill={element.headerBg}
stroke={element.borderColor}
strokeWidth={1}
/>
<Text
text={col.label}
x={xPos + 5}
y={element.rowHeight / 2 - 7}
fontSize={12}
fontStyle="bold"
fill="#000"
/>
</Group>
);
})}
{/* Sample Data Rows */}
{element.showSampleData && element.sampleData && element.sampleData.map((row, rowIdx) => (
<Group key={rowIdx}>
{visibleColumns.map((col, colIdx) => {
const xPos = visibleColumns
.slice(0, colIdx)
.reduce((sum, c) => sum + c.width, 0);
const yPos = element.rowHeight * (rowIdx + 1);
return (
<Group key={`${rowIdx}-${col.key}`}>
<Rect
x={xPos}
y={yPos}
width={col.width}
height={element.rowHeight}
stroke={element.borderColor}
strokeWidth={1}
fill="#fff"
/>
<Text
text={row[col.key] || ""}
x={xPos + 5}
y={yPos + element.rowHeight / 2 - 7}
fontSize={11}
fill="#000"
/>
</Group>
);
})}
</Group>
))}
</Group>
{isSelected && (
<Transformer
ref={trRef}
enabledAnchors={[
"top-left",
"top-right",
"bottom-left",
"bottom-right",
]}
rotateEnabled={true}
boundBoxFunc={(oldBox, newBox) => {
if (newBox.width < 100 || newBox.height < 50) {
return oldBox;
}
return newBox;
}}
/>
)}
</>
);
}

125
elements/TextElement.jsx Normal file
View File

@ -0,0 +1,125 @@
import { Text, Transformer } from "react-konva";
import { useRef, useEffect, useState } from "react";
import useEditorStore from "../useEditorStore";
import { Html } from "react-konva-utils";
export default function TextElement({ element }) {
const updateElement = useEditorStore((state) => state.updateElement);
const setSelectedId = useEditorStore((state) => state.setSelectedId);
const selectedId = useEditorStore((state) => state.selectedId);
const shapeRef = useRef();
const trRef = useRef();
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(element.text);
const isSelected = selectedId === element.id;
useEffect(() => {
if (isSelected && trRef.current && shapeRef.current) {
trRef.current.nodes([shapeRef.current]);
trRef.current.getLayer().batchDraw();
}
}, [isSelected]);
useEffect(() => {
setEditValue(element.text);
}, [element.text]);
const handleDoubleClick = () => {
setIsEditing(true);
setEditValue(element.text);
};
const handleBlur = () => {
setIsEditing(false);
updateElement(element.id, { text: editValue });
};
const handleKeyDown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleBlur();
}
if (e.key === "Escape") {
setIsEditing(false);
setEditValue(element.text);
}
};
return (
<>
{isEditing ? (
<Html
divProps={{
style: {
position: "absolute",
top: `${element.y}px`,
left: `${element.x}px`,
transform: `rotate(${element.rotation}deg)`,
transformOrigin: "top left",
},
}}
>
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
autoFocus
style={{
fontSize: `${element.fontSize}px`,
fontFamily: element.fontFamily,
color: element.color,
textAlign: element.align,
fontWeight: element.fontWeight || "normal",
fontStyle: element.italic ? "italic" : "normal",
textDecoration: element.underline ? "underline" : "none",
border: "2px solid #2196f3",
outline: "none",
padding: "2px 4px",
background: "rgba(255, 255, 255, 0.95)",
resize: "none",
minWidth: "100px",
minHeight: `${element.fontSize + 10}px`,
}}
/>
</Html>
) : (
<Text
ref={shapeRef}
text={element.text}
fontSize={element.fontSize}
fontFamily={element.fontFamily}
fill={element.color}
align={element.align}
fontStyle={`${element.bold ? "bold" : ""} ${element.italic ? "italic" : ""} ${element.fontWeight || "normal"}`}
textDecoration={element.underline ? "underline" : ""}
x={element.x}
y={element.y}
rotation={element.rotation}
draggable
onClick={() => setSelectedId(element.id)}
onDblClick={handleDoubleClick}
onDblTap={handleDoubleClick}
onDragEnd={(e) => {
const snap = (v) => Math.round(v / 10) * 10;
updateElement(element.id, {
x: snap(e.target.x()),
y: snap(e.target.y()),
});
}}
onTransformEnd={() => {
const node = shapeRef.current;
updateElement(element.id, {
rotation: node.rotation(),
});
}}
/>
)}
{isSelected && !isEditing && <Transformer ref={trRef} enabledAnchors={[]} rotateEnabled={true} />}
</>
);
}

369
useEditorStore.js Normal file
View File

@ -0,0 +1,369 @@
import { create } from "zustand";
import { devtools } from "zustand/middleware";
import { v4 as uuidv4 } from "uuid";
const createElementByType = (type, count) => {
const baseId = uuidv4();
if (type === "text") {
return {
id: baseId,
type: "text",
x: 50 + count * 20,
y: 50 + count * 20,
text: "New Text",
fontSize: 18,
fontFamily: "Arial",
fontWeight: "normal",
color: "#000000",
align: "left",
bold: false,
italic: false,
underline: false,
rotation: 0,
};
}
if (type === "image") {
return {
id: baseId,
type: "image",
x: 50 + count * 20,
y: 50 + count * 20,
width: 100,
height: 100,
src: null,
rotation: 0,
};
}
if (type === "qrcode") {
return {
id: baseId,
type: "qrcode",
x: 50 + count * 20,
y: 50 + count * 20,
size: 100,
data: "https://example.com",
rotation: 0,
};
}
if (type === "line") {
return {
id: baseId,
type: "line",
x: 50,
y: 100 + count * 20,
width: 400,
thickness: 2,
color: "#000000",
rotation: 0,
};
}
if (type === "rectangle") {
return {
id: baseId,
type: "rectangle",
x: 50 + count * 20,
y: 50 + count * 20,
width: 200,
height: 100,
fill: "transparent",
stroke: "#000000",
strokeWidth: 2,
cornerRadius: 0,
rotation: 0,
};
}
if (type === "summary") {
return {
id: baseId,
type: "summary",
x: 50,
y: 300 + count * 20,
fields: [
{ key: "items", label: "Items", visible: true, width: 150 },
{ key: "qty", label: "Qty", visible: true, width: 100 },
{ key: "amount", label: "Amount", visible: true, width: 150 },
],
fontSize: 14,
labelColor: "#000000",
valueColor: "#000000",
spacing: 10,
rotation: 0,
};
}
if (type === "table") {
return {
id: baseId,
type: "table",
x: 50,
y: 200 + count * 20,
width: 480,
columns: [
{ key: "sno", label: "S.No", width: 50, visible: true },
{ key: "item", label: "Item", width: 150, visible: true },
{ key: "hsn", label: "HSN", width: 60, visible: true },
{ key: "qty", label: "Qty", width: 50, visible: true },
{ key: "mrp", label: "MRP", width: 60, visible: true },
{ key: "rate", label: "Rate", width: 60, visible: true },
{ key: "dis", label: "Dis", width: 50, visible: true },
{ key: "tax", label: "Tax %", width: 60, visible: true },
{ key: "amt", label: "Amt", width: 70, visible: true },
],
sampleData: [
{ sno: "1", item: "ITEM 1", hsn: "01", qty: "2", mrp: "100", rate: "88", dis: "0", tax: "5", amt: "176" },
{ sno: "2", item: "ITEM 2", hsn: "02", qty: "1", mrp: "350", rate: "300", dis: "0", tax: "5", amt: "300" },
{ sno: "3", item: "ITEM 3", hsn: "03", qty: "1", mrp: "250", rate: "200", dis: "0", tax: "0", amt: "200" },
{ sno: "4", item: "ITEM 4", hsn: "04", qty: "1", mrp: "50", rate: "30", dis: "0", tax: "18", amt: "30" },
{ sno: "5", item: "ITEM 5", hsn: "05", qty: "1", mrp: "50", rate: "30", dis: "0", tax: "40", amt: "30" },
],
rowHeight: 30,
headerBg: "#f0f0f0",
borderColor: "#000000",
showSampleData: true,
rotation: 0,
};
}
return null;
};
const useEditorStore = create(
devtools((set, get) => ({
// State
elements: [],
selectedId: null,
clipboard: null,
history: [],
historyIndex: -1,
page: {
width: 794,
height: 1123,
name: "A4",
},
showLayers: true,
// Actions
addElement: (type) => {
const state = get();
const newElement = createElementByType(type, state.elements.length);
if (!newElement) return;
set((state) => ({
elements: [...state.elements, newElement],
selectedId: newElement.id,
history: [...state.history.slice(0, state.historyIndex + 1), state.elements],
historyIndex: state.historyIndex + 1,
}));
},
updateElement: (id, updates) => {
set((state) => ({
elements: state.elements.map((el) =>
el.id === id ? { ...el, ...updates } : el
),
}));
},
deleteElement: (id) => {
set((state) => ({
elements: state.elements.filter((el) => el.id !== id),
selectedId: state.selectedId === id ? null : state.selectedId,
}));
},
deleteSelected: () => {
const { selectedId, deleteElement } = get();
if (selectedId) deleteElement(selectedId);
},
setSelectedId: (id) => set({ selectedId: id }),
duplicateElement: (id) => {
const state = get();
const element = state.elements.find((el) => el.id === id);
if (!element) return;
const newElement = {
...element,
id: uuidv4(),
x: element.x + 20,
y: element.y + 20,
};
set((state) => ({
elements: [...state.elements, newElement],
selectedId: newElement.id,
}));
},
copyElement: () => {
const { selectedId, elements } = get();
const element = elements.find((el) => el.id === selectedId);
if (element) {
set({ clipboard: element });
}
},
pasteElement: () => {
const { clipboard } = get();
if (!clipboard) return;
const newElement = {
...clipboard,
id: uuidv4(),
x: clipboard.x + 20,
y: clipboard.y + 20,
};
set((state) => ({
elements: [...state.elements, newElement],
selectedId: newElement.id,
}));
},
moveElement: (id, direction) => {
const step = 10;
const deltas = {
up: { x: 0, y: -step },
down: { x: 0, y: step },
left: { x: -step, y: 0 },
right: { x: step, y: 0 },
};
const delta = deltas[direction];
if (!delta) return;
set((state) => ({
elements: state.elements.map((el) =>
el.id === id
? { ...el, x: el.x + delta.x, y: el.y + delta.y }
: el
),
}));
},
reorderColumns: (elementId, newColumns) => {
set((state) => ({
elements: state.elements.map((el) =>
el.id === elementId ? { ...el, columns: newColumns } : el
),
}));
},
toggleColumnVisibility: (elementId, columnKey) => {
set((state) => ({
elements: state.elements.map((el) => {
if (el.id === elementId && el.type === "table") {
return {
...el,
columns: el.columns.map((col) =>
col.key === columnKey
? { ...col, visible: !col.visible }
: col
),
};
}
return el;
}),
}));
},
updateColumnWidth: (elementId, columnKey, width) => {
set((state) => ({
elements: state.elements.map((el) => {
if (el.id === elementId && el.type === "table") {
return {
...el,
columns: el.columns.map((col) =>
col.key === columnKey ? { ...col, width } : col
),
};
}
return el;
}),
}));
},
updateColumnLabel: (elementId, columnKey, label) => {
set((state) => ({
elements: state.elements.map((el) => {
if (el.id === elementId && el.type === "table") {
return {
...el,
columns: el.columns.map((col) =>
col.key === columnKey ? { ...col, label } : col
),
};
}
return el;
}),
}));
},
// Undo/Redo
undo: () => {
const { historyIndex, history } = get();
if (historyIndex > 0) {
set({
elements: history[historyIndex - 1],
historyIndex: historyIndex - 1,
});
}
},
redo: () => {
const { historyIndex, history } = get();
if (historyIndex < history.length - 1) {
set({
elements: history[historyIndex + 1],
historyIndex: historyIndex + 1,
});
}
},
// Page settings
setPageSize: (size) => {
set({ page: { ...get().page, ...size } });
},
toggleLayers: () => {
set((state) => ({ showLayers: !state.showLayers }));
},
// Save/Load
exportTemplate: () => {
const { elements, page } = get();
return JSON.stringify({ elements, page }, null, 2);
},
importTemplate: (jsonString) => {
try {
const data = JSON.parse(jsonString);
set({
elements: data.elements || [],
page: data.page || get().page,
selectedId: null,
});
} catch (error) {
console.error("Invalid template JSON", error);
}
},
clearCanvas: () => {
set({
elements: [],
selectedId: null,
history: [],
historyIndex: -1,
});
},
}))
);
export default useEditorStore;

5
utils/dynamicBinder.js Normal file
View File

@ -0,0 +1,5 @@
export function bindData(text, data) {
return text.replace(/{{(.*?)}}/g, (_, key) => {
return data[key.trim()] ?? "";
});
}

301
utils/exportToHtml.js Normal file
View File

@ -0,0 +1,301 @@
import { bindData } from "./dynamicBinder";
function generateTextElement(el, data) {
const fontWeight = el.bold ? "bold" : el.fontWeight || "normal";
const fontStyle = el.italic ? "italic" : "normal";
const textDecoration = el.underline ? "underline" : "none";
return `
<div style="
position: absolute;
top: ${el.y}px;
left: ${el.x}px;
font-size: ${el.fontSize}px;
font-family: ${el.fontFamily || 'Arial'};
font-weight: ${fontWeight};
font-style: ${fontStyle};
text-decoration: ${textDecoration};
color: ${el.color || '#000000'};
text-align: ${el.align || 'left'};
transform: rotate(${el.rotation || 0}deg);
transform-origin: top left;
white-space: pre-wrap;
">
${bindData(el.text, data)}
</div>
`;
}
function generateImageElement(el, data) {
if (!el.src) return '';
return `
<img src="${bindData(el.src, data)}" style="
position: absolute;
top: ${el.y}px;
left: ${el.x}px;
width: ${el.width}px;
height: ${el.height}px;
transform: rotate(${el.rotation || 0}deg);
transform-origin: top left;
" />
`;
}
function generateQRCodeElement(el, data) {
// For HTML export, we'll use a QR code API or placeholder
const qrData = encodeURIComponent(bindData(el.data, data));
const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${el.size}x${el.size}&data=${qrData}`;
return `
<img src="${qrUrl}" style="
position: absolute;
top: ${el.y}px;
left: ${el.x}px;
width: ${el.size}px;
height: ${el.size}px;
transform: rotate(${el.rotation || 0}deg);
transform-origin: top left;
" />
`;
}
function generateLineElement(el) {
return `
<div style="
position: absolute;
top: ${el.y}px;
left: ${el.x}px;
width: ${el.width}px;
height: ${el.thickness}px;
background-color: ${el.color || '#000000'};
transform: rotate(${el.rotation || 0}deg);
transform-origin: top left;
"></div>
`;
}
function generateRectangleElement(el) {
return `
<div style="
position: absolute;
top: ${el.y}px;
left: ${el.x}px;
width: ${el.width}px;
height: ${el.height}px;
background-color: ${el.fill || 'transparent'};
border: ${el.strokeWidth || 0}px solid ${el.stroke || '#000000'};
border-radius: ${el.cornerRadius || 0}px;
transform: rotate(${el.rotation || 0}deg);
transform-origin: top left;
box-sizing: border-box;
"></div>
`;
}
function generateTableElement(el, data) {
const visibleColumns = el.columns.filter(col => col.visible);
const tableData = data?.tableData || el.sampleData || [];
let tableHtml = `
<div style="
position: absolute;
top: ${el.y}px;
left: ${el.x}px;
transform: rotate(${el.rotation || 0}deg);
transform-origin: top left;
">
<table style="
border-collapse: collapse;
width: auto;
">
<thead>
<tr>
`;
// Header row
visibleColumns.forEach(col => {
tableHtml += `
<th style="
width: ${col.width}px;
height: ${el.rowHeight}px;
background-color: ${el.headerBg || '#f0f0f0'};
border: 1px solid ${el.borderColor || '#000000'};
padding: 5px;
font-size: 12px;
font-weight: bold;
text-align: left;
">
${col.label}
</th>
`;
});
tableHtml += `
</tr>
</thead>
<tbody>
`;
// Data rows
if (el.showSampleData && tableData.length > 0) {
tableData.forEach(row => {
tableHtml += '<tr>';
visibleColumns.forEach(col => {
const cellValue = bindData(row[col.key] || '', data);
tableHtml += `
<td style="
width: ${col.width}px;
height: ${el.rowHeight}px;
border: 1px solid ${el.borderColor || '#000000'};
padding: 5px;
font-size: 11px;
text-align: left;
">
${cellValue}
</td>
`;
});
tableHtml += '</tr>';
});
}
tableHtml += `
</tbody>
</table>
</div>
`;
return tableHtml;
}
function generateSummaryElement(el, data) {
const visibleFields = el.fields.filter(field => field.visible);
let summaryHtml = `
<div style="
position: absolute;
top: ${el.y}px;
left: ${el.x}px;
transform: rotate(${el.rotation || 0}deg);
transform-origin: top left;
">
`;
visibleFields.forEach((field, index) => {
const yPos = index * (el.fontSize + el.spacing);
const value = bindData(`{{${field.key}}}`, data);
summaryHtml += `
<div style="
position: relative;
margin-bottom: ${el.spacing}px;
display: flex;
align-items: center;
">
<span style="
font-size: ${el.fontSize}px;
color: ${el.labelColor || '#000000'};
font-weight: bold;
display: inline-block;
width: ${field.width}px;
">
${field.label}:
</span>
<span style="
font-size: ${el.fontSize}px;
color: ${el.valueColor || '#000000'};
margin-left: 10px;
">
${value}
</span>
</div>
`;
});
summaryHtml += '</div>';
return summaryHtml;
}
export function generateHtml(template, data = {}) {
const elementsHtml = template.elements
.map((el) => {
switch (el.type) {
case "text":
return generateTextElement(el, data);
case "image":
return generateImageElement(el, data);
case "qrcode":
return generateQRCodeElement(el, data);
case "line":
return generateLineElement(el);
case "rectangle":
return generateRectangleElement(el);
case "table":
return generateTableElement(el, data);
case "summary":
return generateSummaryElement(el, data);
default:
return "";
}
})
.join("");
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invoice</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background: #f5f5f5;
padding: 20px;
}
.page-container {
position: relative;
width: ${template.page.width}px;
height: ${template.page.height}px;
background: white;
margin: 0 auto;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
@media print {
body {
background: white;
padding: 0;
}
.page-container {
width: ${template.page.width}px;
height: ${template.page.height}px;
box-shadow: none;
margin: 0;
page-break-after: always;
}
@page {
size: ${template.page.width}px ${template.page.height}px;
margin: 0;
}
}
</style>
</head>
<body>
<div class="page-container">
${elementsHtml}
</div>
</body>
</html>
`;
}

6
utils/pageSizes.js Normal file
View File

@ -0,0 +1,6 @@
export const PAGE_SIZES = {
A4: { width: 794, height: 1123 },
A5: { width: 559, height: 794 },
THERMAL_2IN: { width: 227, height: 1000 },
THERMAL_3IN: { width: 340, height: 1000 },
};

64
utils/printHtml.js Normal file
View File

@ -0,0 +1,64 @@
/**
* Print HTML content using iframe approach
* @param {string} htmlContent - Complete HTML string to print
* @param {string} additionalStyles - Optional additional CSS styles
* @returns {Promise<void>}
*/
export async function printHtml(htmlContent, additionalStyles = '') {
return new Promise((resolve, reject) => {
try {
// Create hidden iframe
const iframe = document.createElement('iframe');
iframe.style.position = 'absolute';
iframe.style.width = '0px';
iframe.style.height = '0px';
iframe.style.border = 'none';
iframe.style.visibility = 'hidden';
document.body.appendChild(iframe);
const doc = iframe.contentWindow.document;
doc.open();
// Write the complete HTML content
doc.write(htmlContent);
// Add additional styles if provided
if (additionalStyles) {
doc.write(`<style>${additionalStyles}</style>`);
}
doc.close();
// Handle print completion
iframe.contentWindow.onafterprint = () => {
document.body.removeChild(iframe);
resolve();
};
// Wait for content to load before printing
iframe.contentWindow.onload = () => {
console.log('Print content loaded');
setTimeout(() => {
iframe.contentWindow.focus();
iframe.contentWindow.print();
}, 300); // Allow rendering delay
};
} catch (error) {
console.error('Error in printHtml:', error);
reject(error);
}
});
}
/**
* Print template with data binding
* @param {object} template - Template object with elements and page config
* @param {object} data - Data object for binding
* @returns {Promise<void>}
*/
export async function printTemplate(template, data = {}) {
const { generateHtml } = await import('./exportToHtml');
const html = generateHtml(template, data);
return printHtml(html);
}