From 7a2098d7a07da7b897cd4ebe13c2ca248c831dc0 Mon Sep 17 00:00:00 2001 From: Gokul Erusappan Date: Tue, 16 Jun 2026 12:58:18 +0530 Subject: [PATCH] first commit --- CanvasStage.jsx | 88 ++++ Editor.jsx | 555 +++++++++++++++++++++++ Editor.scss | 81 ++++ LayersPanel.jsx | 162 +++++++ PropertiesPanel.jsx | 827 ++++++++++++++++++++++++++++++++++ Toolbox.jsx | 65 +++ elements/ImageElement.jsx | 190 ++++++++ elements/LineElement.jsx | 71 +++ elements/QRCodeElement.jsx | 94 ++++ elements/RectangleElement.jsx | 76 ++++ elements/SummaryElement.jsx | 83 ++++ elements/TableElement.jsx | 131 ++++++ elements/TextElement.jsx | 125 +++++ useEditorStore.js | 369 +++++++++++++++ utils/dynamicBinder.js | 5 + utils/exportToHtml.js | 301 +++++++++++++ utils/pageSizes.js | 6 + utils/printHtml.js | 64 +++ 18 files changed, 3293 insertions(+) create mode 100644 CanvasStage.jsx create mode 100644 Editor.jsx create mode 100644 Editor.scss create mode 100644 LayersPanel.jsx create mode 100644 PropertiesPanel.jsx create mode 100644 Toolbox.jsx create mode 100644 elements/ImageElement.jsx create mode 100644 elements/LineElement.jsx create mode 100644 elements/QRCodeElement.jsx create mode 100644 elements/RectangleElement.jsx create mode 100644 elements/SummaryElement.jsx create mode 100644 elements/TableElement.jsx create mode 100644 elements/TextElement.jsx create mode 100644 useEditorStore.js create mode 100644 utils/dynamicBinder.js create mode 100644 utils/exportToHtml.js create mode 100644 utils/pageSizes.js create mode 100644 utils/printHtml.js diff --git a/CanvasStage.jsx b/CanvasStage.jsx new file mode 100644 index 0000000..719e8fd --- /dev/null +++ b/CanvasStage.jsx @@ -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 ( + { + // Deselect when clicking on empty canvas + if (e.target === e.target.getStage()) { + setSelectedId(null); + } + }} + > + + + {/* GRID */} + {Array.from({ length: 50 }).map((_, i) => ( + + ))} + + {/* CENTER GUIDES */} + + + + {elements.map((el) => { + if (el.type === "text") { + return ; + } + if (el.type === "table") { + return ; + } + if (el.type === "image") { + return ; + } + if (el.type === "qrcode") { + return ; + } + if (el.type === "line") { + return ; + } + if (el.type === "rectangle") { + return ; + } + if (el.type === "summary") { + return ; + } + return null; + })} + + + + ); +} \ No newline at end of file diff --git a/Editor.jsx b/Editor.jsx new file mode 100644 index 0000000..ebeadd9 --- /dev/null +++ b/Editor.jsx @@ -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 ( +
+ + {/* TOP BAR */} + + + {/* BODY */} +
+ + {/* LAYERS PANEL */} + {showLayers && } + + {/* LEFT TOOLBAR */} + + + {/* CANVAS */} +
+ {/* Page Size Selector */} +
+ + +
+ + {/* + {Math.round(zoom * 100)}% + */} + +
+
+ + {/* Canvas Area */} +
+
+
+ +
+
+
+
+ + {/* RIGHT PANEL */} +
+ +
+
+
+ {showLayers ? "Hide" : "Show"} Layers +
+ +
+ ↶ Undo +
+ +
+ ↷ Redo +
+ +
+ 🧪 Load Test +
+ +
+ 🗑️ Delete +
+ +
+ 👁️ Preview +
+ +
+ 💾 Save +
+
+
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/Editor.scss b/Editor.scss new file mode 100644 index 0000000..5191bdc --- /dev/null +++ b/Editor.scss @@ -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; + } +} \ No newline at end of file diff --git a/LayersPanel.jsx b/LayersPanel.jsx new file mode 100644 index 0000000..95d3168 --- /dev/null +++ b/LayersPanel.jsx @@ -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 ( +
+
+

Layers

+ +
+ +
+ {elements.length === 0 ? ( +
No elements
+ ) : ( + [...elements].reverse().map((element, index) => ( +
setSelectedId(element.id)} + > +
+ {getElementIcon(element.type)} + {getElementLabel(element)} +
+ +
+ )) + )} +
+
+ ); +} + +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", + }, +}; diff --git a/PropertiesPanel.jsx b/PropertiesPanel.jsx new file mode 100644 index 0000000..d425c63 --- /dev/null +++ b/PropertiesPanel.jsx @@ -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 ( +
+
+
+ ⋮⋮ +
+ +
+
+ updateColumnLabel(elementId, column.key, e.target.value)} + style={{ ...styles.input, width: 70, fontSize: 11 }} + placeholder="Label" + /> + updateColumnWidth(elementId, column.key, Number(e.target.value))} + style={{ ...styles.input, width: 50, fontSize: 11 }} + placeholder="W" + /> +
+
+ ); +} + +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 ( +
+
+

Select an element to edit properties

+
+
+ ); + } + + 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 ( +
+
+

Properties

+
+ + +
+
+ +
+ +
{selected.type.toUpperCase()}
+
+ + {/* POSITION */} +
+ +
+
+ X + + updateElement(selected.id, { x: Number(e.target.value) }) + } + style={styles.input} + /> +
+
+ Y + + updateElement(selected.id, { y: Number(e.target.value) }) + } + style={styles.input} + /> +
+
+
+ + {/* ROTATION */} + {selected.rotation !== undefined && ( +
+ + + updateElement(selected.id, { rotation: Number(e.target.value) }) + } + style={styles.input} + placeholder="Degrees" + /> +
+ )} + + {/* TEXT PROPERTIES */} + {selected.type === "text" && ( + <> +
+ +