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