Files
P-Touch-Web/app/static/editor.js
T
2026-06-22 16:35:26 +02:00

641 lines
21 KiB
JavaScript

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 TEXT_FIT_EXTRA_MM = 6;
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 canvasTextLines(text) {
return String(text || "").split("\n");
}
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) + TEXT_FIT_EXTRA_MM);
}
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;
if (obj.manualSize) return obj.x + obj.w;
const { value } = objectFont(obj);
ctx.save();
ctx.font = value;
const lines = canvasTextLines(obj.text || "");
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 = canvasTextLines(obj.text || "");
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);