Initial P-Touch web editor
This commit is contained in:
@@ -0,0 +1,464 @@
|
||||
const MM_TO_SCREEN = 6;
|
||||
const STORAGE_KEY = "ptouch-web-layout";
|
||||
|
||||
const state = {
|
||||
zoom: 1.5,
|
||||
selectedId: null,
|
||||
dragging: null,
|
||||
design: {
|
||||
tapeWidthMm: 24,
|
||||
lengthMm: 70,
|
||||
marginMm: 2,
|
||||
orientation: "landscape",
|
||||
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"),
|
||||
marginMm: document.getElementById("marginMm"),
|
||||
copies: document.getElementById("copies"),
|
||||
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 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 syncDesignControls() {
|
||||
state.design.tapeWidthMm = Number(controls.tapeWidth.value);
|
||||
state.design.lengthMm = Number(controls.lengthMm.value);
|
||||
state.design.marginMm = Number(controls.marginMm.value);
|
||||
state.design.orientation = document.querySelector("input[name='orientation']:checked").value;
|
||||
}
|
||||
|
||||
function applyDesignControls() {
|
||||
controls.tapeWidth.value = state.design.tapeWidthMm;
|
||||
controls.lengthMm.value = state.design.lengthMm;
|
||||
controls.marginMm.value = state.design.marginMm;
|
||||
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();
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.font = `${obj.bold ? "700 " : ""}${mmToPx(obj.fontSizeMm || 5)}px system-ui, sans-serif`;
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.textAlign = obj.align || "left";
|
||||
const x = mmToPx(obj.x);
|
||||
const y = mmToPx(obj.y);
|
||||
const w = mmToPx(obj.w);
|
||||
const h = mmToPx(obj.h);
|
||||
const lines = String(obj.text || "").split("\n");
|
||||
const lineHeight = mmToPx((obj.fontSizeMm || 5) * 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, w);
|
||||
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 = "#000";
|
||||
ctx.fillStyle = "#000";
|
||||
|
||||
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`;
|
||||
|
||||
ctx.fillStyle = "#fff";
|
||||
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);
|
||||
drawRulers();
|
||||
controls.zoomLabel.textContent = `${Math.round(state.zoom * 100)}%`;
|
||||
updateProps();
|
||||
}
|
||||
|
||||
function addObject(type, data = {}) {
|
||||
syncDesignControls();
|
||||
const size = canvasMm();
|
||||
const base = {
|
||||
id: uid(),
|
||||
type,
|
||||
x: state.design.marginMm,
|
||||
y: state.design.marginMm,
|
||||
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,
|
||||
};
|
||||
state.design.objects.push(base);
|
||||
state.selectedId = base.id;
|
||||
draw();
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
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);
|
||||
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");
|
||||
}
|
||||
|
||||
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") addObject("text", { w: 35, h: 10, text: "Text" });
|
||||
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);
|
||||
} 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", "marginMm"].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);
|
||||
draw();
|
||||
setMessage(`Band erkannt: ${payload.width} mm`, "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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addObject("text", { x: 4, y: 4, w: 36, h: 10, text: "P-Touch", fontSizeMm: 5.5, bold: true });
|
||||
state.selectedId = null;
|
||||
draw();
|
||||
@@ -0,0 +1,183 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #171717;
|
||||
color: #f1f1f1;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #171717;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.mobile-shell {
|
||||
width: min(100%, 620px);
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: max(14px, env(safe-area-inset-top)) 14px calc(96px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 3px 0 0;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
header a {
|
||||
color: #9fc5ff;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
background: #252525;
|
||||
border: 1px solid #383838;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: #ddd;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
textarea,
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border: 1px solid #555;
|
||||
border-radius: 7px;
|
||||
background: #303030;
|
||||
color: #fff;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
border: 1px solid #575757;
|
||||
border-radius: 7px;
|
||||
background: #3a3a3a;
|
||||
color: #fff;
|
||||
padding: 10px 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: #2374e1;
|
||||
border-color: #2374e1;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.input-action {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.check-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.check-row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 44px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #494949;
|
||||
border-radius: 7px;
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
.check-row input {
|
||||
width: 20px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.preview {
|
||||
min-height: 132px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: auto;
|
||||
background: #1f1f1f;
|
||||
border: 1px dashed #555;
|
||||
border-radius: 7px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.preview img {
|
||||
max-width: 100%;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
.message {
|
||||
min-height: 22px;
|
||||
margin: 0;
|
||||
color: #d5d5d5;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
color: #ffb4b4;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
color: #a7e8af;
|
||||
}
|
||||
|
||||
.bottom-actions {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
width: min(100%, 620px);
|
||||
margin: 0 auto;
|
||||
padding: 12px 14px calc(12px + env(safe-area-inset-bottom));
|
||||
background: rgba(23, 23, 23, 0.96);
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
const fields = {
|
||||
text: document.getElementById("mobileText"),
|
||||
tape: document.getElementById("mobileTape"),
|
||||
length: document.getElementById("mobileLength"),
|
||||
margin: document.getElementById("mobileMargin"),
|
||||
font: document.getElementById("mobileFont"),
|
||||
copies: document.getElementById("mobileCopies"),
|
||||
align: document.getElementById("mobileAlign"),
|
||||
bold: document.getElementById("mobileBold"),
|
||||
frame: document.getElementById("mobileFrame"),
|
||||
preview: document.getElementById("mobilePreview"),
|
||||
message: document.getElementById("mobileMessage"),
|
||||
};
|
||||
|
||||
function setMessage(text, type = "") {
|
||||
fields.message.textContent = text;
|
||||
fields.message.className = `message ${type}`;
|
||||
}
|
||||
|
||||
function mobileDesign() {
|
||||
const tapeWidth = Number(fields.tape.value);
|
||||
const length = Number(fields.length.value);
|
||||
const margin = Number(fields.margin.value);
|
||||
const text = fields.text.value.trim() || " ";
|
||||
const objects = [];
|
||||
if (fields.frame.checked) {
|
||||
objects.push({
|
||||
id: "frame",
|
||||
type: "shape",
|
||||
x: margin,
|
||||
y: margin,
|
||||
w: Math.max(2, length - margin * 2),
|
||||
h: Math.max(2, tapeWidth - margin * 2),
|
||||
lineMm: 0.25,
|
||||
fill: false,
|
||||
});
|
||||
}
|
||||
objects.push({
|
||||
id: "text",
|
||||
type: "text",
|
||||
x: margin + (fields.frame.checked ? 1 : 0),
|
||||
y: margin,
|
||||
w: Math.max(2, length - margin * 2 - (fields.frame.checked ? 2 : 0)),
|
||||
h: Math.max(2, tapeWidth - margin * 2),
|
||||
text,
|
||||
fontSizeMm: Number(fields.font.value),
|
||||
bold: fields.bold.checked,
|
||||
align: fields.align.value,
|
||||
valign: "middle",
|
||||
});
|
||||
return {
|
||||
tapeWidthMm: tapeWidth,
|
||||
lengthMm: length,
|
||||
marginMm: margin,
|
||||
orientation: "landscape",
|
||||
objects,
|
||||
};
|
||||
}
|
||||
|
||||
async function send(url) {
|
||||
setMessage("Rendere Etikett...");
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ design: mobileDesign(), copies: Number(fields.copies.value) || 1 }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!payload.ok) {
|
||||
setMessage(payload.error || "Fehler", "error");
|
||||
return;
|
||||
}
|
||||
fields.preview.src = `${payload.image}?t=${Date.now()}`;
|
||||
setMessage(payload.message || "Vorschau erstellt.", "success");
|
||||
}
|
||||
|
||||
document.getElementById("mobilePreviewBtn").addEventListener("click", () => send("/api/preview"));
|
||||
document.getElementById("mobilePrintBtn").addEventListener("click", () => send("/api/print"));
|
||||
document.getElementById("mobileDetect").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;
|
||||
}
|
||||
fields.tape.value = String(payload.width);
|
||||
setMessage(`Band erkannt: ${payload.width} mm`, "success");
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #202020;
|
||||
color: #e8e8e8;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
.file-button {
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
background: #3a3a3a;
|
||||
color: #f2f2f2;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.file-button:hover {
|
||||
background: #454545;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: #2374e1;
|
||||
border-color: #2374e1;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: #5a2d2d;
|
||||
border-color: #7d3a3a;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #565656;
|
||||
border-radius: 5px;
|
||||
background: #303030;
|
||||
color: #f3f3f3;
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr) 320px;
|
||||
grid-template-rows: 78px minmax(0, 1fr);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr) 160px 230px;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #111;
|
||||
background: #2d2d2d;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
color: #aaa;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
color: #9fc5ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.toolbar button,
|
||||
.file-button {
|
||||
min-width: 64px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-button input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.zoom-group,
|
||||
.print-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.zoom-group span {
|
||||
min-width: 56px;
|
||||
text-align: center;
|
||||
color: #cfcfcf;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
overflow: auto;
|
||||
background: #282828;
|
||||
border-right: 1px solid #111;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
border-right: 0;
|
||||
border-left: 1px solid #111;
|
||||
}
|
||||
|
||||
.sidebar section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-bottom: 18px;
|
||||
margin-bottom: 18px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.sidebar h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sidebar p {
|
||||
margin: 0;
|
||||
color: #a8a8a8;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.sidebar label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: #d7d7d7;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.radio-row,
|
||||
.check-row {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.radio-row label,
|
||||
.check-row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.radio-row input,
|
||||
.check-row input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.input-action {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-action button {
|
||||
min-width: 92px;
|
||||
padding-inline: 9px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
.stage-wrap {
|
||||
position: absolute;
|
||||
inset: 24px 0 34px 28px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
align-items: start;
|
||||
justify-items: start;
|
||||
padding: 72px;
|
||||
}
|
||||
|
||||
#labelCanvas {
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 1px #bdbdbd, 0 18px 50px rgba(0, 0, 0, 0.35);
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
.ruler {
|
||||
position: absolute;
|
||||
color: #aaa;
|
||||
font-size: 11px;
|
||||
background: #262626;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ruler-x {
|
||||
top: 0;
|
||||
left: 28px;
|
||||
right: 0;
|
||||
height: 24px;
|
||||
border-bottom: 1px solid #171717;
|
||||
}
|
||||
|
||||
.ruler-y {
|
||||
top: 24px;
|
||||
left: 0;
|
||||
bottom: 34px;
|
||||
width: 28px;
|
||||
border-right: 1px solid #171717;
|
||||
}
|
||||
|
||||
.message-line {
|
||||
position: absolute;
|
||||
left: 46px;
|
||||
right: 18px;
|
||||
bottom: 8px;
|
||||
min-height: 20px;
|
||||
color: #d7d7d7;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message-line.error {
|
||||
color: #ffb1b1;
|
||||
}
|
||||
|
||||
.message-line.success {
|
||||
color: #a8e6b0;
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
min-height: 120px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px dashed #555;
|
||||
background: #222;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.preview-box img {
|
||||
max-width: 100%;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
code {
|
||||
overflow-wrap: anywhere;
|
||||
color: #b9d5ff;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto minmax(420px, 1fr) auto;
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.stage-wrap {
|
||||
min-height: 420px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user