first commit
This commit is contained in:
commit
7a2098d7a0
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export function bindData(text, data) {
|
||||||
|
return text.replace(/{{(.*?)}}/g, (_, key) => {
|
||||||
|
return data[key.trim()] ?? "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
@ -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 },
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue