const MM_TO_SCREEN = 6; const PRINTABLE_TAPE_PX = { 3.5: 24, 6: 32, 9: 52, 12: 76, 18: 120, 24: 128, }; const STORAGE_KEY = "ptouch-web-layout"; const state = { zoom: 1.5, selectedId: null, dragging: null, tapeInfo: null, design: { tapeWidthMm: 24, lengthMm: 70, autoLength: true, marginMm: 2, orientation: "landscape", cutMode: "auto", halfcut: "off", objects: [], }, }; const canvas = document.getElementById("labelCanvas"); const ctx = canvas.getContext("2d"); const message = document.getElementById("message"); const controls = { tapeWidth: document.getElementById("tapeWidth"), lengthMm: document.getElementById("lengthMm"), autoLength: document.getElementById("autoLength"), marginMm: document.getElementById("marginMm"), cutMode: document.getElementById("cutMode"), copies: document.getElementById("copies"), tapeInfo: document.getElementById("tapeInfo"), tapeInfoType: document.getElementById("tapeInfoType"), tapePrintableWidth: document.getElementById("tapePrintableWidth"), tapeBgSwatch: document.getElementById("tapeBgSwatch"), tapeBgText: document.getElementById("tapeBgText"), tapeTextSwatch: document.getElementById("tapeTextSwatch"), tapeTextText: document.getElementById("tapeTextText"), queueStatus: document.getElementById("queueStatus"), zoomLabel: document.getElementById("zoomLabel"), previewImage: document.getElementById("previewImage"), noSelection: document.getElementById("noSelection"), objectProps: document.getElementById("objectProps"), propText: document.getElementById("propText"), propX: document.getElementById("propX"), propY: document.getElementById("propY"), propW: document.getElementById("propW"), propH: document.getElementById("propH"), propFont: document.getElementById("propFont"), propLine: document.getElementById("propLine"), propAlign: document.getElementById("propAlign"), propBold: document.getElementById("propBold"), propFill: document.getElementById("propFill"), propRows: document.getElementById("propRows"), propCols: document.getElementById("propCols"), }; function uid() { return `obj-${Date.now()}-${Math.random().toString(16).slice(2)}`; } function canvasMm() { const d = state.design; return d.orientation === "portrait" ? { w: d.tapeWidthMm, h: d.lengthMm } : { w: d.lengthMm, h: d.tapeWidthMm }; } function scale() { return MM_TO_SCREEN * state.zoom; } function mmToPx(v) { return v * scale(); } function pxToMm(v) { return v / scale(); } function inkColor() { return state.tapeInfo?.textColor?.css || "#000"; } function objectFont(obj) { const fontPx = mmToPx(obj.fontSizeMm || 5); return { size: fontPx, value: `${obj.bold ? "700 " : ""}${fontPx}px DejaVu Sans, Arial, system-ui, sans-serif`, }; } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function wrapCanvasText(text, maxWidthPx) { const lines = []; for (const rawLine of String(text || "").split("\n")) { let current = ""; for (const word of rawLine.split(" ")) { const candidate = current ? `${current} ${word}` : word; if (ctx.measureText(candidate).width <= maxWidthPx) { current = candidate; continue; } if (current) lines.push(current); current = word; } lines.push(current); } return lines.length ? lines : [""]; } function textNaturalWidthMm(obj) { const { value } = objectFont(obj); ctx.save(); ctx.font = value; const lines = String(obj.text || " ").split("\n"); const maxTextWidthPx = Math.max(...lines.map((line) => ctx.measureText(line || " ").width), 0); ctx.restore(); return Math.max(2, pxToMm(maxTextWidthPx) + 1); } function autoFitTextObject(obj) { if (!state.design.autoLength || obj.manualSize || (obj.type !== "text" && obj.type !== "symbol")) return; const maxLength = state.design.orientation === "portrait" ? state.design.tapeWidthMm : 500; const available = Math.max(2, maxLength - obj.x - state.design.marginMm); obj.w = Math.min(available, textNaturalWidthMm(obj)); } function autoFitTextObjects() { for (const obj of state.design.objects) autoFitTextObject(obj); } function objectContentEndMm(obj) { if (obj.hidden) return 0; if (state.design.orientation === "portrait") return obj.y + obj.h; if (obj.type !== "text" && obj.type !== "symbol") return obj.x + obj.w; const { value } = objectFont(obj); ctx.save(); ctx.font = value; const lines = wrapCanvasText(obj.text || "", mmToPx(obj.w)); const textWidthMm = Math.max(...lines.map((line) => pxToMm(ctx.measureText(line).width)), 0); ctx.restore(); if (obj.align === "center") return obj.x + obj.w / 2 + textWidthMm / 2; if (obj.align === "right") return obj.x + obj.w; return obj.x + Math.min(obj.w, textWidthMm); } function contentLengthMm() { if (!state.design.objects.length) return Number(controls.lengthMm.value) || state.design.lengthMm || 70; const margin = Number(controls.marginMm.value) || 0; const lengthEnd = state.design.objects.reduce((max, obj) => { return Math.max(max, objectContentEndMm(obj)); }, margin); return Math.ceil(clamp(lengthEnd + margin, 10, 500)); } function selected() { return state.design.objects.find((obj) => obj.id === state.selectedId) || null; } function setMessage(text, type = "") { message.textContent = text; message.className = `message-line ${type}`; } function updateTapeInfo(info) { state.tapeInfo = info || null; controls.tapeInfo.hidden = !info; if (!info) return; const media = info.mediaType?.name || "Unbekannt"; const bg = info.tapeColor || { name: "Unbekannt", css: "#d1d5db" }; const text = info.textColor || { name: "Unbekannt", css: "#111111" }; controls.tapeInfoType.textContent = `${info.width} mm · ${media}${info.mediaType?.code ? ` (${info.mediaType.code})` : ""}`; controls.tapePrintableWidth.textContent = info.printableWidthPx ? `${info.printableWidthPx} px` : "-"; controls.tapeBgText.textContent = `${bg.name}${bg.code ? ` (${bg.code})` : ""}`; controls.tapeBgSwatch.style.background = bg.css; controls.tapeTextText.textContent = `${text.name}${text.code ? ` (${text.code})` : ""}`; controls.tapeTextSwatch.style.background = text.css; } function syncDesignControls() { state.design.tapeWidthMm = Number(controls.tapeWidth.value); state.design.lengthMm = Number(controls.lengthMm.value); state.design.marginMm = Number(controls.marginMm.value); state.design.cutMode = controls.cutMode.value; state.design.autoLength = controls.autoLength.checked; state.design.halfcut = "off"; state.design.orientation = document.querySelector("input[name='orientation']:checked").value; controls.lengthMm.disabled = state.design.autoLength; if (state.design.autoLength) { autoFitTextObjects(); state.design.lengthMm = contentLengthMm(); controls.lengthMm.value = state.design.lengthMm; } } function applyDesignControls() { controls.tapeWidth.value = state.design.tapeWidthMm; controls.lengthMm.value = state.design.lengthMm; controls.autoLength.checked = state.design.autoLength !== false; controls.marginMm.value = state.design.marginMm; controls.cutMode.value = state.design.cutMode || "auto"; document.querySelector(`input[name='orientation'][value='${state.design.orientation}']`).checked = true; } function drawRulers() { const rulerX = document.getElementById("rulerX"); const rulerY = document.getElementById("rulerY"); const size = canvasMm(); const step = 10; rulerX.innerHTML = ""; rulerY.innerHTML = ""; for (let x = 0; x <= size.w; x += step) { const mark = document.createElement("span"); mark.textContent = x; mark.style.position = "absolute"; mark.style.left = `${mmToPx(x) + 72}px`; mark.style.top = "5px"; rulerX.appendChild(mark); } for (let y = 0; y <= size.h; y += step) { const mark = document.createElement("span"); mark.textContent = y; mark.style.position = "absolute"; mark.style.top = `${mmToPx(y) + 72}px`; mark.style.left = "4px"; rulerY.appendChild(mark); } } function drawTextObject(obj) { ctx.save(); const x = mmToPx(obj.x); const y = mmToPx(obj.y); const w = mmToPx(obj.w); const h = mmToPx(obj.h); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip(); ctx.fillStyle = inkColor(); const { size: fontPx, value: font } = objectFont(obj); ctx.font = font; ctx.textBaseline = "middle"; ctx.textAlign = obj.align || "left"; const lines = wrapCanvasText(obj.text || "", w); const lineHeight = fontPx * 1.15; const total = lineHeight * lines.length; let cy = y + h / 2 - total / 2 + lineHeight / 2; const tx = obj.align === "center" ? x + w / 2 : obj.align === "right" ? x + w : x; for (const line of lines) { ctx.fillText(line, tx, cy); cy += lineHeight; } ctx.restore(); } function drawObject(obj) { const x = mmToPx(obj.x); const y = mmToPx(obj.y); const w = mmToPx(obj.w); const h = mmToPx(obj.h); ctx.save(); ctx.lineWidth = Math.max(1, mmToPx(obj.lineMm || 0.25)); ctx.strokeStyle = inkColor(); ctx.fillStyle = inkColor(); if (obj.type === "text" || obj.type === "symbol") { drawTextObject(obj); } else if (obj.type === "shape") { if (obj.fill) ctx.fillRect(x, y, w, h); ctx.strokeRect(x, y, w, h); } else if (obj.type === "line") { ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + w, y + h); ctx.stroke(); } else if (obj.type === "table") { const rows = obj.rows || 2; const cols = obj.cols || 2; ctx.strokeRect(x, y, w, h); for (let c = 1; c < cols; c++) { ctx.beginPath(); ctx.moveTo(x + (w * c) / cols, y); ctx.lineTo(x + (w * c) / cols, y + h); ctx.stroke(); } for (let r = 1; r < rows; r++) { ctx.beginPath(); ctx.moveTo(x, y + (h * r) / rows); ctx.lineTo(x + w, y + (h * r) / rows); ctx.stroke(); } } else if (obj.type === "qr" || obj.type === "barcode") { ctx.strokeRect(x, y, w, h); ctx.font = `${Math.max(10, mmToPx(2.5))}px system-ui`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(obj.type === "qr" ? "QR" : "BARCODE", x + w / 2, y + h / 2); } else if (obj.type === "image") { if (obj.img) { ctx.drawImage(obj.img, x, y, w, h); } else { ctx.strokeRect(x, y, w, h); ctx.fillText("Bild", x + 8, y + 18); } } if (obj.id === state.selectedId) { ctx.strokeStyle = "#2374e1"; ctx.lineWidth = 2; ctx.strokeRect(x - 3, y - 3, w + 6, h + 6); ctx.fillStyle = "#2374e1"; ctx.fillRect(x + w - 5, y + h - 5, 10, 10); } ctx.restore(); } function draw() { syncDesignControls(); const size = canvasMm(); canvas.width = Math.round(mmToPx(size.w)); canvas.height = Math.round(mmToPx(size.h)); canvas.style.width = `${canvas.width}px`; canvas.style.height = `${canvas.height}px`; const tapeColor = state.tapeInfo?.tapeColor?.css || "#fff"; ctx.fillStyle = tapeColor; ctx.fillRect(0, 0, canvas.width, canvas.height); const margin = mmToPx(state.design.marginMm); ctx.strokeStyle = "#b7b7b7"; ctx.setLineDash([2, 2]); ctx.strokeRect(margin, margin, canvas.width - margin * 2, canvas.height - margin * 2); ctx.setLineDash([]); for (const obj of state.design.objects) drawObject(obj); if (state.design.cutMode === "cutmark" || state.design.cutMode === "cutmark-chain") { ctx.strokeStyle = inkColor(); ctx.setLineDash([4, 4]); const x = canvas.width - mmToPx(1.5); ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); ctx.setLineDash([]); } drawRulers(); controls.zoomLabel.textContent = `${Math.round(state.zoom * 100)}%`; updateProps(); } function addObject(type, data = {}) { syncDesignControls(); const size = canvasMm(); const margin = state.design.marginMm; const base = { id: uid(), type, x: margin, y: margin, w: Math.min(32, size.w - state.design.marginMm * 2), h: Math.min(10, size.h - state.design.marginMm * 2), text: "Text", fontSizeMm: 5, lineMm: 0.25, align: "left", bold: false, fill: false, rows: 2, cols: 2, ...data, }; autoFitTextObject(base); state.design.objects.push(base); state.selectedId = base.id; draw(); if (type === "text" || type === "symbol") { controls.propText.focus(); controls.propText.select(); } } function hitTest(mx, my) { for (let i = state.design.objects.length - 1; i >= 0; i--) { const obj = state.design.objects[i]; if (mx >= obj.x && mx <= obj.x + obj.w && my >= obj.y && my <= obj.y + obj.h) return obj; } return null; } function canvasPoint(event) { const rect = canvas.getBoundingClientRect(); return { x: pxToMm(event.clientX - rect.left), y: pxToMm(event.clientY - rect.top) }; } function updateProps() { const obj = selected(); controls.noSelection.hidden = Boolean(obj); controls.objectProps.hidden = !obj; if (!obj) return; controls.propText.value = obj.text || ""; controls.propX.value = obj.x; controls.propY.value = obj.y; controls.propW.value = obj.w; controls.propH.value = obj.h; controls.propFont.value = obj.fontSizeMm || 5; controls.propLine.value = obj.lineMm || 0.25; controls.propAlign.value = obj.align || "left"; controls.propBold.checked = Boolean(obj.bold); controls.propFill.checked = Boolean(obj.fill); controls.propRows.value = obj.rows || 2; controls.propCols.value = obj.cols || 2; } function applyProps() { const obj = selected(); if (!obj) return; const manuallySizing = document.activeElement === controls.propW || document.activeElement === controls.propH; const editingText = document.activeElement === controls.propText; obj.text = controls.propText.value; obj.x = Number(controls.propX.value); obj.y = Number(controls.propY.value); obj.w = Number(controls.propW.value); obj.h = Number(controls.propH.value); if (manuallySizing) obj.manualSize = true; if (editingText && obj.text) obj.manualSize = false; obj.fontSizeMm = Number(controls.propFont.value); obj.lineMm = Number(controls.propLine.value); obj.align = controls.propAlign.value; obj.bold = controls.propBold.checked; obj.fill = controls.propFill.checked; obj.rows = Number(controls.propRows.value); obj.cols = Number(controls.propCols.value); autoFitTextObject(obj); draw(); } async function sendDesign(url) { setMessage("Rendere Etikett..."); const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ design: state.design, copies: Number(controls.copies.value) || 1 }), }); const payload = await response.json(); if (!payload.ok) { setMessage(payload.error || "Fehler", "error"); return; } if (payload.image) controls.previewImage.src = `${payload.image}?t=${Date.now()}`; setMessage(payload.message || "Vorschau erstellt.", "success"); updateQueueStatus(); } async function updateQueueStatus() { if (!controls.queueStatus) return; try { const response = await fetch("/api/queue"); const payload = await response.json(); if (!payload.ok) return; const firstWaiting = payload.jobs?.find((job) => job.lastError); controls.queueStatus.textContent = payload.enabled ? `Warteschlange: ${payload.length}${firstWaiting ? " (wartet auf Drucker)" : ""}` : "Warteschlange: aus"; } catch { controls.queueStatus.textContent = "Warteschlange: unbekannt"; } } document.querySelectorAll("[data-tool]").forEach((button) => { button.addEventListener("click", () => { const tool = button.dataset.tool; if (tool === "frame") addObject("shape", { w: 28, h: 14, fill: false }); if (tool === "rect") addObject("shape", { w: 18, h: 10, fill: true }); if (tool === "line") addObject("line", { w: 24, h: 0.1 }); if (tool === "text") { syncDesignControls(); const size = canvasMm(); addObject("text", { w: Math.max(2, size.w - state.design.marginMm * 2), h: Math.max(2, size.h - state.design.marginMm * 2), text: "", fontSizeMm: Math.max(2, size.h - state.design.marginMm * 2 - 2), bold: false, manualSize: true, }); } if (tool === "table") addObject("table", { w: 36, h: 14, rows: 2, cols: 3 }); if (tool === "qr") addObject("qr", { w: 16, h: 16, text: "https://example.local" }); if (tool === "barcode") addObject("barcode", { w: 42, h: 12, text: "1234567890" }); if (tool === "symbol") addObject("symbol", { w: 10, h: 10, text: "★", fontSizeMm: 7, align: "center" }); }); }); document.getElementById("imageInput").addEventListener("change", (event) => { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { const img = new Image(); img.onload = () => addObject("image", { w: 20, h: 14, dataUrl: reader.result, img }); img.src = reader.result; }; reader.readAsDataURL(file); }); canvas.addEventListener("mousedown", (event) => { const point = canvasPoint(event); const obj = hitTest(point.x, point.y); state.selectedId = obj ? obj.id : null; if (obj) { const resize = point.x > obj.x + obj.w - 2 && point.y > obj.y + obj.h - 2; state.dragging = { id: obj.id, dx: point.x - obj.x, dy: point.y - obj.y, resize }; } draw(); }); window.addEventListener("mousemove", (event) => { if (!state.dragging) return; const obj = selected(); if (!obj) return; const point = canvasPoint(event); if (state.dragging.resize) { obj.w = Math.max(2, point.x - obj.x); obj.h = Math.max(2, point.y - obj.y); obj.manualSize = true; } else { obj.x = Math.max(0, point.x - state.dragging.dx); obj.y = Math.max(0, point.y - state.dragging.dy); } draw(); }); window.addEventListener("mouseup", () => { state.dragging = null; }); window.addEventListener("keydown", (event) => { const editable = event.target.closest("input, textarea, select"); if (editable) return; if ((event.key === "Delete" || event.key === "Backspace") && state.selectedId) { event.preventDefault(); state.design.objects = state.design.objects.filter((obj) => obj.id !== state.selectedId); state.selectedId = null; draw(); setMessage("Objekt geloescht.", "success"); } }); ["tapeWidth", "lengthMm", "autoLength", "marginMm", "cutMode"].forEach((id) => document.getElementById(id).addEventListener("input", draw)); document.querySelectorAll("input[name='orientation']").forEach((input) => input.addEventListener("change", draw)); Object.values(controls).forEach((control) => { if (control && control.id && control.id.startsWith("prop")) control.addEventListener("input", applyProps); }); document.getElementById("deleteObj").addEventListener("click", () => { state.design.objects = state.design.objects.filter((obj) => obj.id !== state.selectedId); state.selectedId = null; draw(); }); document.getElementById("duplicateObj").addEventListener("click", () => { const obj = selected(); if (!obj) return; const copy = { ...obj, id: uid(), x: obj.x + 2, y: obj.y + 2 }; state.design.objects.push(copy); state.selectedId = copy.id; draw(); }); document.getElementById("zoomOut").addEventListener("click", () => { state.zoom = Math.max(0.5, state.zoom - 0.25); draw(); }); document.getElementById("zoomIn").addEventListener("click", () => { state.zoom = Math.min(4, state.zoom + 0.25); draw(); }); document.getElementById("previewBtn").addEventListener("click", () => sendDesign("/api/preview")); document.getElementById("printBtn").addEventListener("click", () => sendDesign("/api/print")); document.getElementById("detectTape").addEventListener("click", async () => { setMessage("Erkenne Band..."); const response = await fetch("/api/tape-info"); const payload = await response.json(); if (!payload.ok) { setMessage(payload.error || "Band konnte nicht erkannt werden.", "error"); return; } controls.tapeWidth.value = String(payload.width); state.design.tapeWidthMm = Number(payload.width); updateTapeInfo(payload); draw(); const bg = payload.tapeColor?.name ? `, ${payload.tapeColor.name}` : ""; const text = payload.textColor?.name ? ` / ${payload.textColor.name}` : ""; setMessage(`Band erkannt: ${payload.width} mm${bg}${text}`, "success"); }); document.getElementById("saveLayout").addEventListener("click", () => { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.design)); setMessage("Layout gespeichert.", "success"); }); document.getElementById("loadLayout").addEventListener("click", () => { const saved = localStorage.getItem(STORAGE_KEY); if (!saved) return setMessage("Kein gespeichertes Layout gefunden.", "error"); state.design = JSON.parse(saved); state.selectedId = null; applyDesignControls(); hydrateImages(); draw(); }); document.getElementById("clearLayout").addEventListener("click", () => { state.design.objects = []; state.selectedId = null; draw(); }); document.getElementById("exportJson").addEventListener("click", async () => { await navigator.clipboard.writeText(JSON.stringify(state.design, null, 2)); setMessage("Layout-JSON in die Zwischenablage kopiert.", "success"); }); function hydrateImages() { for (const obj of state.design.objects) { if (obj.type === "image" && obj.dataUrl) { const img = new Image(); img.onload = draw; img.src = obj.dataUrl; obj.img = img; } } } draw(); updateQueueStatus(); setInterval(updateQueueStatus, 10000);