diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/element-properties-panel.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/element-properties-panel.tsx index 2c0e3a1..94c2172 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/element-properties-panel.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/element-properties-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import { Bold, Italic, Trash2 } from "lucide-react"; +import { Bold, Copy, Italic, Trash2 } from "lucide-react"; import { LabelElement, LabelFontFamily, @@ -36,12 +36,14 @@ interface ElementPropertiesPanelProps { element: LabelElement; onChange: (patch: Partial) => void; onDelete: () => void; + onDuplicate: () => void; } export function ElementPropertiesPanel({ element, onChange, onDelete, + onDuplicate, }: ElementPropertiesPanelProps) { return (
@@ -49,9 +51,37 @@ export function ElementPropertiesPanel({

{element.type === "text" ? "Text" : element.type === "qrCode" ? "QR-Code" : element.type === "image" ? "Bild" : "Linie"}

- +
+ + +
+
+ +
+
+ + onChange({ x: Number(event.target.value) })} + /> +
+
+ + onChange({ y: Number(event.target.value) })} + /> +
{element.type === "text" ? : null} diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-canvas.tsx b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-canvas.tsx index 2874b41..3185635 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-canvas.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/label-templates/designer/label-canvas.tsx @@ -8,7 +8,7 @@ import { } from "@/lib/label-templates/types"; import { cn } from "@/lib/utils"; -export const CANVAS_SCALE = 4; // px per mm +export const BASE_SCALE = 4; // px per mm at 100% zoom const PT_TO_MM = 25.4 / 72; const MIN_SIZE_MM = 5; @@ -27,6 +27,7 @@ interface LabelCanvasProps { heightMm: number; elements: LabelElement[]; selectedId: string | null; + zoom?: number; onSelect: (id: string | null) => void; onChangeElement: (id: string, patch: Partial) => void; } @@ -36,9 +37,12 @@ export function LabelCanvas({ heightMm, elements, selectedId, + zoom = 1, onSelect, onChangeElement, }: LabelCanvasProps) { + const scale = BASE_SCALE * zoom; + function startDrag( event: ReactPointerEvent, element: LabelElement, @@ -52,8 +56,8 @@ export function LabelCanvas({ const origin = element; function onMove(moveEvent: PointerEvent) { - const dxMm = (moveEvent.clientX - startX) / CANVAS_SCALE; - const dyMm = (moveEvent.clientY - startY) / CANVAS_SCALE; + const dxMm = (moveEvent.clientX - startX) / scale; + const dyMm = (moveEvent.clientY - startY) / scale; if (mode === "move") { onChangeElement(origin.id, { @@ -92,13 +96,14 @@ export function LabelCanvas({ return (
onSelect(null)} > {elements.map((element) => ( startDrag(event, element, "move")} onStartResize={(event) => startDrag(event, element, "resize")} @@ -110,16 +115,18 @@ export function LabelCanvas({ function ElementBox({ element, + scale, selected, onStartMove, onStartResize, }: { element: LabelElement; + scale: number; selected: boolean; onStartMove: (event: ReactPointerEvent) => void; onStartResize: (event: ReactPointerEvent) => void; }) { - const box = boxFor(element); + const box = boxFor(element, scale); return (
- {renderContent(element)} + {renderContent(element, scale)} {selected ? (
+ typeof item === "object" && + item !== null && + "id" in item && + "type" in item && + "x" in item && + "y" in item && + ELEMENT_TYPES.includes((item as { type: unknown }).type as (typeof ELEMENT_TYPES)[number]), + ) + ); +} + +function slugify(name: string): string { + return ( + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") || "etikett" + ); +} + interface LabelDesignerProps { initial?: LabelDesignerValue; onSubmit: (value: LabelDesignerValue) => Promise; @@ -72,6 +121,7 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP const [elements, setElements] = useState(initial?.elements ?? []); const [selectedId, setSelectedId] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const [zoom, setZoom] = useState(1); const selectedElement = elements.find((element) => element.id === selectedId) ?? null; @@ -88,12 +138,34 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP ); } - function deleteSelected() { - if (!selectedId) return; - setElements((current) => current.filter((element) => element.id !== selectedId)); - setSelectedId(null); + function deleteElement(id: string) { + setElements((current) => current.filter((element) => element.id !== id)); + setSelectedId((current) => (current === id ? null : current)); } + function duplicateElement(id: string) { + const source = elements.find((element) => element.id === id); + if (!source) return; + const copy = { ...source, id: newId(), x: source.x + 4, y: source.y + 4 }; + addElement(copy); + } + + // Delete/Backspace removes the selected element, but never while the user + // is typing in a text field - only when focus is on the canvas itself. + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.key !== "Delete" && event.key !== "Backspace") return; + const target = event.target as HTMLElement | null; + if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return; + if (selectedId) { + event.preventDefault(); + deleteElement(selectedId); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [selectedId, elements]); + function handlePlaceholderInsert(key: PlaceholderKey) { addElement(newTextElement({ placeholderKey: key, text: "" })); } @@ -117,6 +189,48 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP reader.readAsDataURL(file); } + function handleExport() { + const payload: LabelDesignerValue = { name, elements, widthMm, heightMm, isDefault }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${slugify(name)}.json`; + link.click(); + URL.revokeObjectURL(url); + } + + function handleImportFile(event: ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + try { + const parsed: unknown = JSON.parse(String(reader.result)); + const importedElements = Array.isArray(parsed) + ? parsed + : (parsed as { elements?: unknown })?.elements; + if (!isLabelElementArray(importedElements)) { + toast.error("Datei enthält keine gültige Etikettenvorlage."); + return; + } + setElements(importedElements.map((element) => ({ ...element, id: newId() }))); + if (!Array.isArray(parsed)) { + const value = parsed as Partial; + if (typeof value.name === "string" && value.name && !name) setName(value.name); + if (typeof value.widthMm === "number") setWidthMm(value.widthMm); + if (typeof value.heightMm === "number") setHeightMm(value.heightMm); + } + setSelectedId(null); + toast.success("Vorlage importiert."); + } catch { + toast.error("Datei konnte nicht gelesen werden - ist es gültiges JSON?"); + } + }; + reader.readAsText(file); + } + async function handleSubmit() { if (!name.trim()) { toast.error("Bitte einen Namen für die Vorlage angeben."); @@ -185,8 +299,31 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP - + Elemente +
+ +
+ + {Math.round(zoom * 100)}% + + +
+
@@ -276,11 +438,13 @@ export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerP changeElement(selectedElement.id, patch)} - onDelete={deleteSelected} + onDelete={() => deleteElement(selectedElement.id)} + onDuplicate={() => duplicateElement(selectedElement.id)} /> ) : (

- Element im Etikett anklicken, um es zu bearbeiten. + Element im Etikett anklicken, um es zu bearbeiten. Entf/Backspace löscht das + ausgewählte Element.

)}