bff6d70b17
Aplicatie Flask + SQLite pentru productie alimentara: cantarirea componentelor unui produs finit si tiparirea etichetei prin QZ Tray. - produse finite (retete) formate din componente cantarite - designer vizual de etichete (mm/px, zona sigura, campuri variabile, cod QR cu continut din mai multe bucati) - legare produs finit -> sablon de eticheta - navigare pe doua taburi: Cantarire / Setari - administrare: componente, produse finite, sabloane, campuri, istoric - backup, resetare baza de date, checklist de pregatire, /healthz - CLI de administrare si serviciu systemd pentru productie - vendor/syncpulse-scale-bridge (GPL-3.0-or-later) pentru citirea cantarului
1263 lines
44 KiB
JavaScript
1263 lines
44 KiB
JavaScript
/* Designer vizual de etichete.
|
||
*
|
||
* Model identic cu `cantarire/designer.py`: blocuri pozitionate in mm.
|
||
* Previzualizarea foloseste datele demo trimise de server, deci arata ca la
|
||
* tiparire. Serverul revalideaza si randeaza acelasi model.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
var root = document.getElementById('designer');
|
||
if (!root) return;
|
||
|
||
var cfg = window.DESIGNER || {};
|
||
var saveUrl = root.dataset.saveUrl;
|
||
var qrUrl = root.dataset.qrUrl;
|
||
var fields = cfg.fields || [];
|
||
var demoFields = cfg.demoFields || {};
|
||
var demoItems = cfg.demoItems || [];
|
||
|
||
var design = cfg.design || { width_mm: 100, height_mm: 60, blocks: [] };
|
||
design.blocks = design.blocks || [];
|
||
|
||
// Designuri vechi: blocul QR avea doar `field`; il aratam ca o singura parte.
|
||
design.blocks.forEach(function (block) {
|
||
if (block.type !== 'qr') return;
|
||
if (!Array.isArray(block.parts)) {
|
||
block.parts = [{ field: block.field || 'package.qr' }];
|
||
}
|
||
if (!block.separator) block.separator = 'pipe';
|
||
});
|
||
|
||
var state = { selected: null, zoom: 6, dirty: false, seq: 1 };
|
||
var dragging = null;
|
||
var toastTimer = null;
|
||
|
||
// Marja de siguranta a imprimantei termice (mm), afisata ca contur rosu.
|
||
var SAFE_MM = Number(cfg.safeMm) || 3;
|
||
var SAFE_STORAGE = 'cantarire.designer.safe';
|
||
|
||
// Popup-ul de alegere a campului variabil.
|
||
var ITEMS_KEY = '__items__';
|
||
var LAST_FIELD_STORAGE = 'cantarire.designer.lastField';
|
||
var FIELD_GROUPS = {
|
||
recipe: 'Produs finit',
|
||
package: 'Pachet',
|
||
company: 'Firmă',
|
||
custom: 'Câmpuri proprii'
|
||
};
|
||
|
||
// Continutul codului QR: parti in ordine, separate printr-un text.
|
||
var QR_SEPARATORS = {
|
||
pipe: ' | ', dash: ' - ', slash: '/', space: ' ', newline: '\n', none: ''
|
||
};
|
||
var QR_EXTRA_LABELS = {
|
||
'package.qr': 'Conținut complet (produs + lot + greutate + dată)'
|
||
};
|
||
var MAX_QR_PARTS = 12;
|
||
|
||
var canvas = document.getElementById('canvas');
|
||
var layer = document.getElementById('canvas-blocks');
|
||
var blockList = document.getElementById('block-list');
|
||
var blockCount = document.getElementById('block-count');
|
||
var propsBody = document.getElementById('props-body');
|
||
var propsEmpty = document.getElementById('props-empty');
|
||
var stateBadge = document.getElementById('designer-state');
|
||
|
||
var elWidth = document.getElementById('label-width');
|
||
var elHeight = document.getElementById('label-height');
|
||
var elZoom = document.getElementById('designer-zoom');
|
||
var elToast = document.getElementById('toast');
|
||
var safeEl = document.getElementById('canvas-safe');
|
||
var safeToggle = document.getElementById('safe-toggle');
|
||
var safeHint = document.getElementById('safe-hint');
|
||
var safeArrange = document.getElementById('safe-arrange');
|
||
var picker = document.getElementById('field-picker');
|
||
var pickerList = document.getElementById('field-picker-list');
|
||
var pickerSearch = document.getElementById('field-picker-search');
|
||
var pickerTitle = document.getElementById('field-picker-title');
|
||
var pickerHint = document.getElementById('field-picker-hint');
|
||
var pickerConfirm = document.getElementById('field-picker-confirm');
|
||
var pickerOpen = false;
|
||
var pickerMode = 'single';
|
||
var picked = [];
|
||
|
||
// -- utilitare -----------------------------------------------------------
|
||
function toast(text, kind) {
|
||
if (!elToast) return;
|
||
elToast.textContent = text;
|
||
elToast.className = 'toast show ' + (kind || '');
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(function () { elToast.className = 'toast ' + (kind || ''); }, 3000);
|
||
}
|
||
|
||
function markDirty() {
|
||
state.dirty = true;
|
||
if (stateBadge) {
|
||
stateBadge.textContent = 'nesalvat';
|
||
stateBadge.className = 'badge warn';
|
||
}
|
||
}
|
||
|
||
function markClean() {
|
||
state.dirty = false;
|
||
if (stateBadge) {
|
||
stateBadge.textContent = 'salvat';
|
||
stateBadge.className = 'badge ok';
|
||
}
|
||
}
|
||
|
||
function esc(value) {
|
||
return String(value === null || value === undefined ? '' : value)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function clamp(value, low, high) {
|
||
return Math.min(Math.max(value, low), high);
|
||
}
|
||
|
||
function fmt(value) {
|
||
var number = Number(value);
|
||
return isFinite(number) ? number.toFixed(3) : '0';
|
||
}
|
||
|
||
function short(value) {
|
||
var text = Number(value);
|
||
return (Math.round(text * 100) / 100).toString();
|
||
}
|
||
|
||
function nextId() {
|
||
var id;
|
||
do {
|
||
id = 'b' + (state.seq++);
|
||
} while (findBlock(id));
|
||
return id;
|
||
}
|
||
|
||
function findBlock(id) {
|
||
return design.blocks.filter(function (block) { return block.id === id; })[0] || null;
|
||
}
|
||
|
||
function selectedBlock() {
|
||
return state.selected ? findBlock(state.selected) : null;
|
||
}
|
||
|
||
function defaultStyle(type) {
|
||
var base = {
|
||
size: type === 'items' ? 3.4 : (type === 'field' && false ? 4 : 3.2),
|
||
bold: false, italic: false, align: 'left', uppercase: false, color: '#000000'
|
||
};
|
||
if (type === 'field') base.size = 4;
|
||
if (type === 'items') base.separator = 'stack';
|
||
return base;
|
||
}
|
||
|
||
// -- randare -------------------------------------------------------------
|
||
function fontCss(style, heightMm) {
|
||
var parts = [
|
||
'font-size:' + px(style.size || 3.2).toFixed(2) + 'px',
|
||
'font-weight:' + (style.bold ? '700' : '400'),
|
||
'font-style:' + (style.italic ? 'italic' : 'normal'),
|
||
'text-align:' + (style.align || 'left'),
|
||
'color:' + (style.color || '#000000'),
|
||
'line-height:1.15',
|
||
'height:' + px(heightMm).toFixed(2) + 'px',
|
||
'overflow:hidden',
|
||
'white-space:pre-wrap',
|
||
'word-break:break-word'
|
||
];
|
||
if (style.uppercase) parts.push('text-transform:uppercase');
|
||
return parts.join(';');
|
||
}
|
||
|
||
/* 1 mm din model = `zoom` px pe canvas. NU se foloseste unitatea CSS `mm`
|
||
* aici: aceea este fixa (96/25.4 px) si ar rupe potrivirea cu canvas-ul,
|
||
* facand blocurile sa para mai mici si imposibil de tras pana la margine. */
|
||
function px(mm) {
|
||
return Math.round((Number(mm) || 0) * state.zoom * 100) / 100;
|
||
}
|
||
|
||
function positionCss(block) {
|
||
return 'left:' + px(block.x) + 'px;top:' + px(block.y) + 'px;width:' +
|
||
px(block.w) + 'px;height:' + px(block.h) + 'px';
|
||
}
|
||
|
||
function isOutsideSafe(block) {
|
||
var width = Number(design.width_mm) || 100;
|
||
var height = Number(design.height_mm) || 60;
|
||
var eps = 0.001;
|
||
return block.x < SAFE_MM - eps || block.y < SAFE_MM - eps ||
|
||
block.x + block.w > width - SAFE_MM + eps ||
|
||
block.y + block.h > height - SAFE_MM + eps;
|
||
}
|
||
|
||
function outsideSafeCount() {
|
||
return design.blocks.filter(isOutsideSafe).length;
|
||
}
|
||
|
||
// -- aranjare automata in zona sigura ------------------------------------
|
||
function safeBox() {
|
||
var width = Number(design.width_mm) || 100;
|
||
var height = Number(design.height_mm) || 60;
|
||
var box = {
|
||
left: SAFE_MM,
|
||
top: SAFE_MM,
|
||
right: width - SAFE_MM,
|
||
bottom: height - SAFE_MM
|
||
};
|
||
// Eticheta e prea mica pentru marja: folosim marginile fizice.
|
||
if (box.right - box.left < 2 || box.bottom - box.top < 2) {
|
||
box = { left: 0, top: 0, right: width, bottom: height, tight: true };
|
||
}
|
||
box.width = box.right - box.left;
|
||
box.height = box.bottom - box.top;
|
||
return box;
|
||
}
|
||
|
||
function layoutBounds(blocks) {
|
||
var minX = Infinity;
|
||
var minY = Infinity;
|
||
var maxX = -Infinity;
|
||
var maxY = -Infinity;
|
||
blocks.forEach(function (block) {
|
||
minX = Math.min(minX, block.x);
|
||
minY = Math.min(minY, block.y);
|
||
maxX = Math.max(maxX, block.x + block.w);
|
||
maxY = Math.max(maxY, block.y + block.h);
|
||
});
|
||
return { minX: minX, minY: minY, maxX: maxX, maxY: maxY,
|
||
w: Math.max(0, maxX - minX), h: Math.max(0, maxY - minY) };
|
||
}
|
||
|
||
function round1(value) {
|
||
return Math.round((Number(value) || 0) * 10) / 10;
|
||
}
|
||
|
||
/* Aduce tot aranjamentul in zona sigura, pastrand proportiile.
|
||
*
|
||
* Se scaleaza ansamblul (pozitii, marimi si fonturi) cat sa incapa in
|
||
* contur, apoi se translateaza in coltul zonei sigure. Asa elementele nu se
|
||
* suprapun si eticheta arata la fel, doar mai mica. */
|
||
function arrangeInsideSafe() {
|
||
if (!design.blocks.length) {
|
||
return { moved: 0, scale: 1, remaining: 0, tight: false };
|
||
}
|
||
var box = safeBox();
|
||
var bounds = layoutBounds(design.blocks);
|
||
|
||
var scale = 1;
|
||
if (bounds.w > box.width && bounds.w > 0) scale = Math.min(scale, box.width / bounds.w);
|
||
if (bounds.h > box.height && bounds.h > 0) scale = Math.min(scale, box.height / bounds.h);
|
||
scale = clamp(scale, 0.1, 1);
|
||
|
||
var moved = 0;
|
||
design.blocks.forEach(function (block) {
|
||
var x = box.left + (block.x - bounds.minX) * scale;
|
||
var y = box.top + (block.y - bounds.minY) * scale;
|
||
var w = block.w * scale;
|
||
var h = Math.max(0.3, block.h * scale);
|
||
|
||
// Plasare cu precizie de 0,1 mm, apoi limite dure in zona sigura.
|
||
w = clamp(round1(w), 1, Math.max(1, box.width));
|
||
h = clamp(round1(h), 0.3, Math.max(0.3, box.height));
|
||
x = clamp(round1(x), box.left, Math.max(box.left, box.right - w));
|
||
y = clamp(round1(y), box.top, Math.max(box.top, box.bottom - h));
|
||
|
||
if (Math.abs(x - block.x) > 0.001 || Math.abs(y - block.y) > 0.001 ||
|
||
Math.abs(w - block.w) > 0.001 || Math.abs(h - block.h) > 0.001) {
|
||
moved += 1;
|
||
}
|
||
block.x = x;
|
||
block.y = y;
|
||
block.w = w;
|
||
block.h = h;
|
||
|
||
if (scale < 1 && block.style && block.style.size) {
|
||
block.style.size = Math.max(1.5, round1(block.style.size * scale));
|
||
}
|
||
});
|
||
|
||
return { moved: moved, scale: scale, remaining: outsideSafeCount(),
|
||
tight: Boolean(box.tight) };
|
||
}
|
||
|
||
function fieldValue(block) {
|
||
var value = demoFields[block.field || ''] || '';
|
||
return (block.prefix || '') + value + (block.suffix || '');
|
||
}
|
||
|
||
// -- continutul codului QR ----------------------------------------------
|
||
function fieldLabel(key) {
|
||
if (QR_EXTRA_LABELS[key]) return QR_EXTRA_LABELS[key];
|
||
var found = fields.filter(function (field) { return field.key === key; })[0];
|
||
return found ? (found.label || key) : key;
|
||
}
|
||
|
||
function fieldSample(key) {
|
||
if (key === 'package.qr') {
|
||
return [demoFields['recipe.code'], demoFields['package.lot'],
|
||
demoFields['package.weight_unit'],
|
||
demoFields['package.completed_at']].filter(Boolean).join(' | ');
|
||
}
|
||
var value = demoFields[key];
|
||
return value === undefined || value === null ? '' : String(value);
|
||
}
|
||
|
||
function qrPayload(block) {
|
||
var parts = block.parts || [];
|
||
if (!parts.length) {
|
||
return demoFields[block.field || 'package.qr'] || '';
|
||
}
|
||
var separator = QR_SEPARATORS[block.separator || 'pipe'];
|
||
if (separator === undefined) separator = ' | ';
|
||
return parts.map(function (part) {
|
||
return part.text !== undefined && part.text !== null
|
||
? String(part.text) : fieldSample(part.field);
|
||
}).filter(function (piece) { return piece; }).join(separator);
|
||
}
|
||
|
||
function qrPartsHtml(block) {
|
||
var parts = block.parts || [];
|
||
var rows = parts.map(function (part, index) {
|
||
var body = part.text !== undefined && part.text !== null
|
||
? '<input type="text" data-part-text="' + index + '" maxlength="60" value="' +
|
||
esc(part.text) + '" placeholder="text liber">'
|
||
: '<span class="qr-part-body"><strong>' + esc(fieldLabel(part.field)) + '</strong>' +
|
||
'<span class="qr-part-sample">' + esc(fieldSample(part.field)) + '</span></span>';
|
||
return '<li class="qr-part">' +
|
||
'<span class="qr-part-num">' + (index + 1) + '</span>' + body +
|
||
'<span class="qr-part-tools">' +
|
||
'<button type="button" class="qr-part-btn" data-part-move="up" data-index="' + index +
|
||
'" title="Mută mai sus" aria-label="Mută mai sus"' +
|
||
(index === 0 ? ' disabled' : '') + '>↑</button>' +
|
||
'<button type="button" class="qr-part-btn" data-part-move="down" data-index="' + index +
|
||
'" title="Mută mai jos" aria-label="Mută mai jos"' +
|
||
(index === parts.length - 1 ? ' disabled' : '') + '>↓</button>' +
|
||
'<button type="button" class="qr-part-btn danger" data-part-remove="' + index +
|
||
'" title="Șterge" aria-label="Șterge">×</button>' +
|
||
'</span></li>';
|
||
}).join('');
|
||
|
||
return '<div class="qr-parts">' +
|
||
'<div class="qr-parts-head"><span>Conținut cod QR</span>' +
|
||
'<span class="muted small">' + parts.length + ' / ' + MAX_QR_PARTS + '</span></div>' +
|
||
(rows ? '<ol class="qr-part-list">' + rows + '</ol>'
|
||
: '<p class="muted small">Niciun câmp; codul conține textul complet implicit.</p>') +
|
||
'<p class="qr-preview-line"><span class="muted small">Rezultat:</span> ' +
|
||
esc(qrPayload(block) || '(gol)') + '</p>' +
|
||
'<div class="qr-part-actions">' +
|
||
'<button class="btn" type="button" data-qr-add' +
|
||
(parts.length >= MAX_QR_PARTS ? ' disabled' : '') + '>+ Adaugă câmp</button>' +
|
||
'<button class="btn ghost" type="button" data-qr-add-text' +
|
||
(parts.length >= MAX_QR_PARTS ? ' disabled' : '') + '>+ Text liber</button>' +
|
||
'</div></div>';
|
||
}
|
||
|
||
function blockInner(block) {
|
||
var style = block.style || {};
|
||
if (block.type === 'text') {
|
||
return esc(block.text || '');
|
||
} if (block.type === 'field') {
|
||
return esc(fieldValue(block));
|
||
}
|
||
if (block.type === 'items') {
|
||
var inline = style.separator === 'inline';
|
||
return demoItems.map(function (item) {
|
||
var weight = '<span class="d-item-w">' + esc(item.weight) + ' ' + esc(item.unit) + '</span>';
|
||
return inline
|
||
? '<span class="d-item">' + weight + ' ' + esc(item.component_name) + '</span>'
|
||
: '<div class="d-item">' + weight + ' ' + esc(item.component_name) + '</div>';
|
||
}).join(inline ? ' · ' : '');
|
||
}
|
||
if (block.type === 'qr') {
|
||
var data = qrPayload(block) || 'PF-001 | LOT | 450 g';
|
||
return '<img alt="" src="' + qrUrl + '?data=' + encodeURIComponent(data) + '">';
|
||
}
|
||
if (block.type === 'image') {
|
||
return block.src ? '<img alt="" src="' + esc(block.src) + '">' : '';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function renderBlocks() {
|
||
if (!layer) return;
|
||
layer.innerHTML = '';
|
||
|
||
design.blocks.forEach(function (block) {
|
||
var node = document.createElement('div');
|
||
node.className = 'c-block c-' + block.type + (state.selected === block.id ? ' selected' : '');
|
||
node.dataset.id = block.id;
|
||
node.style.cssText = positionCss(block);
|
||
|
||
if (block.type === 'line') {
|
||
node.style.background = (block.style && block.style.color) || '#000';
|
||
node.style.minHeight = '1px';
|
||
} else if (block.type === 'field' || block.type === 'text' || block.type === 'items') {
|
||
node.style.cssText += ';' + fontCss(block.style || {}, block.h);
|
||
} else if (block.type === 'qr' || block.type === 'image') {
|
||
node.classList.add('c-media');
|
||
}
|
||
if (block.type === 'image' && !block.src) node.classList.add('c-empty');
|
||
if (isOutsideSafe(block)) node.classList.add('outside-safe');
|
||
|
||
node.innerHTML = blockInner(block);
|
||
|
||
var handle = document.createElement('span');
|
||
handle.className = 'c-resize';
|
||
handle.dataset.handle = 'resize';
|
||
node.appendChild(handle);
|
||
|
||
layer.appendChild(node);
|
||
});
|
||
|
||
renderBlockList();
|
||
}
|
||
|
||
function blockLabel(block) {
|
||
if (block.type === 'text') {
|
||
return 'Text: ' + (block.text || '').slice(0, 24);
|
||
}
|
||
if (block.type === 'field') {
|
||
var found = fields.filter(function (f) { return f.key === block.field; })[0];
|
||
return 'Câmp: ' + (found ? found.label : block.field);
|
||
}
|
||
if (block.type === 'items') return 'Defalcare componente';
|
||
if (block.type === 'qr') {
|
||
var parts = block.parts || [];
|
||
var preview = qrPayload(block).slice(0, 22);
|
||
return 'Cod QR (' + parts.length + '): ' + (preview || 'gol');
|
||
}
|
||
if (block.type === 'line') return 'Linie';
|
||
return 'Imagine';
|
||
}
|
||
|
||
function renderBlockList() {
|
||
if (!blockList) return;
|
||
blockList.innerHTML = design.blocks.map(function (block) {
|
||
return '<li><button type="button" class="block-item' +
|
||
(state.selected === block.id ? ' active' : '') + '" data-select="' + block.id + '">' +
|
||
esc(blockLabel(block)) + '</button></li>';
|
||
}).join('') || '<li class="muted small">Niciun element încă.</li>';
|
||
if (blockCount) blockCount.textContent = String(design.blocks.length);
|
||
}
|
||
|
||
function applySize() {
|
||
if (!canvas) return;
|
||
var w = Number(design.width_mm) || 100;
|
||
var h = Number(design.height_mm) || 60;
|
||
canvas.style.width = (w * state.zoom) + 'px';
|
||
canvas.style.height = (h * state.zoom) + 'px';
|
||
canvas.style.setProperty('--grid', (10 * state.zoom) + 'px');
|
||
if (safeEl) {
|
||
var inset = px(SAFE_MM);
|
||
safeEl.style.left = inset + 'px';
|
||
safeEl.style.top = inset + 'px';
|
||
safeEl.style.right = inset + 'px';
|
||
safeEl.style.bottom = inset + 'px';
|
||
}
|
||
updateSafeHint();
|
||
if (elWidth) elWidth.value = w;
|
||
if (elHeight) elHeight.value = h;
|
||
}
|
||
|
||
function updateSafeHint() {
|
||
if (!safeHint) return;
|
||
if (!design.blocks.length) {
|
||
safeHint.textContent = 'Zona sigură: ' + SAFE_MM + ' mm de la fiecare margine.';
|
||
safeHint.className = 'safe-hint muted';
|
||
if (safeArrange) safeArrange.classList.add('hidden');
|
||
return;
|
||
}
|
||
var count = outsideSafeCount();
|
||
if (safeArrange) safeArrange.classList.toggle('hidden', count === 0);
|
||
if (count === 0) {
|
||
safeHint.textContent = 'Toate elementele sunt în zona sigură.';
|
||
safeHint.className = 'safe-hint ok';
|
||
} else {
|
||
safeHint.textContent = count + (count === 1 ? ' element iese' : ' elemente ies') +
|
||
' din zona sigură. Folosește „Rearanjează în zona sigură”.';
|
||
safeHint.className = 'safe-hint warn';
|
||
}
|
||
}
|
||
|
||
// -- proprietati ---------------------------------------------------------
|
||
function input(label, name, value, attrs) {
|
||
return '<div class="field"><label for="p-' + name + '">' + label + '</label>' +
|
||
'<input id="p-' + name + '" data-prop="' + name + '" ' + (attrs || '') +
|
||
' value="' + esc(value) + '"></div>';
|
||
}
|
||
|
||
function checkbox(label, name, checked) {
|
||
return '<label class="check"><input type="checkbox" data-prop="' + name + '"' +
|
||
(checked ? ' checked' : '') + '> ' + label + '</label>';
|
||
}
|
||
|
||
function select(label, name, value, options) {
|
||
return '<div class="field"><label for="p-' + name + '">' + label + '</label>' +
|
||
'<select id="p-' + name + '" data-prop="' + name + '">' +
|
||
options.map(function (option) {
|
||
return '<option value="' + esc(option.value) + '"' +
|
||
(String(option.value) === String(value) ? ' selected' : '') + '>' +
|
||
esc(option.label) + '</option>';
|
||
}).join('') + '</select></div>';
|
||
}
|
||
|
||
function renderProps() {
|
||
var block = selectedBlock();
|
||
if (!block) {
|
||
if (propsEmpty) propsEmpty.classList.remove('hidden');
|
||
if (propsBody) {
|
||
propsBody.classList.add('hidden');
|
||
propsBody.innerHTML = '';
|
||
}
|
||
return;
|
||
}
|
||
if (propsEmpty) propsEmpty.classList.add('hidden');
|
||
if (!propsBody) return;
|
||
propsBody.classList.remove('hidden');
|
||
|
||
var style = block.style || {};
|
||
var html = '';
|
||
|
||
if (block.type === 'text') {
|
||
html += '<div class="field"><label for="p-text">Text</label>' +
|
||
'<textarea id="p-text" data-prop="text" rows="3">' + esc(block.text || '') + '</textarea></div>';
|
||
}
|
||
if (block.type === 'field') {
|
||
html += select('Câmp', 'field', block.field, fields.map(function (f) {
|
||
return { value: f.key, label: f.label };
|
||
}));
|
||
html += input('Prefix', 'prefix', block.prefix || '', 'maxlength="40"');
|
||
html += input('Sufix', 'suffix', block.suffix || '', 'maxlength="40"');
|
||
}
|
||
if (block.type === 'qr') {
|
||
html += select('Separator', 'separator', block.separator || 'pipe', [
|
||
{ value: 'pipe', label: 'Bară verticală ( | )' },
|
||
{ value: 'dash', label: 'Liniuță ( - )' },
|
||
{ value: 'slash', label: 'Slash ( / )' },
|
||
{ value: 'space', label: 'Spațiu' },
|
||
{ value: 'newline', label: 'Rând nou' },
|
||
{ value: 'none', label: 'Fără separator' }
|
||
]);
|
||
html += qrPartsHtml(block);
|
||
}
|
||
if (block.type === 'items') {
|
||
html += select('Aranjare', 'separator', style.separator || 'stack', [
|
||
{ value: 'stack', label: 'Pe linii' },
|
||
{ value: 'inline', label: 'Pe un rând' }
|
||
]);
|
||
}
|
||
if (block.type === 'image') {
|
||
html += input('Adresă imagine', 'src', block.src || '',
|
||
'placeholder="/static/img/logo.png sau https://…"');
|
||
html += '<p class="muted small">Se acceptă /static/…, http(s)://… sau data:image/…</p>';
|
||
}
|
||
|
||
if (block.type !== 'line' && block.type !== 'qr' && block.type !== 'image') {
|
||
html += '<div class="props-row">' +
|
||
input('Mărime (mm)', 'size', style.size || 3.2, 'type="number" step="0.2" min="1.5" max="40"') +
|
||
'</div>';
|
||
html += '<div class="props-checks">' +
|
||
checkbox('Aldin', 'bold', style.bold) +
|
||
checkbox('Cursiv', 'italic', style.italic) +
|
||
checkbox('Majuscule', 'uppercase', style.uppercase) +
|
||
'</div>';
|
||
html += select('Aliniere', 'align', style.align || 'left', [
|
||
{ value: 'left', label: 'Stânga' },
|
||
{ value: 'center', label: 'Centru' },
|
||
{ value: 'right', label: 'Dreapta' }
|
||
]);
|
||
}
|
||
if (block.type === 'line') {
|
||
html += '<div class="field"><label for="p-color">Culoare</label>' +
|
||
'<input id="p-color" data-prop="color" type="color" value="' +
|
||
esc(style.color || '#000000') + '"></div>';
|
||
}
|
||
|
||
html += '<h3>Poziție și mărime (mm)</h3><div class="props-grid">' +
|
||
input('X', 'x', short(block.x), 'type="number" step="0.5" min="0"') +
|
||
input('Y', 'y', short(block.y), 'type="number" step="0.5" min="0"') +
|
||
input('Lățime', 'w', short(block.w), 'type="number" step="0.5" min="1"') +
|
||
input('Înălțime', 'h', short(block.h), 'type="number" step="0.5" min="0.3"') +
|
||
'</div>';
|
||
|
||
html += '<div class="actions">' +
|
||
'<button class="btn" type="button" data-action="duplicate">Duplică</button>' +
|
||
'<button class="btn danger" type="button" data-action="delete">Șterge</button>' +
|
||
'</div>';
|
||
|
||
propsBody.innerHTML = html;
|
||
}
|
||
|
||
function renderAll() {
|
||
applySize();
|
||
renderBlocks();
|
||
renderProps();
|
||
}
|
||
|
||
// -- adaugare ------------------------------------------------------------
|
||
function addBlock(type, preset) {
|
||
var width = Number(design.width_mm) || 100;
|
||
var height = Number(design.height_mm) || 60;
|
||
var block = {
|
||
id: nextId(),
|
||
type: type,
|
||
x: 4,
|
||
y: Math.min(height - 8, 4 + design.blocks.length * 3),
|
||
w: type === 'qr' ? 20 : Math.min(width - 8, 60),
|
||
h: type === 'qr' ? 20 : (type === 'line' ? 0.4 : 6),
|
||
style: defaultStyle(type)
|
||
};
|
||
if (type === 'text') block.text = 'Text nou';
|
||
if (type === 'field') {
|
||
block.field = 'recipe.name';
|
||
block.prefix = '';
|
||
block.suffix = '';
|
||
}
|
||
if (type === 'items') block.h = Math.min(20, height - block.y);
|
||
if (type === 'qr') {
|
||
block.field = 'package.qr';
|
||
block.separator = 'pipe';
|
||
block.parts = [{ field: 'package.qr' }];
|
||
if (block.x + block.w > width) block.x = Math.max(0, width - block.w - 2);
|
||
}
|
||
if (type === 'line') block.w = Math.max(10, width - 8);
|
||
|
||
if (preset) {
|
||
Object.keys(preset).forEach(function (key) { block[key] = preset[key]; });
|
||
}
|
||
|
||
design.blocks.push(block);
|
||
state.selected = block.id;
|
||
markDirty();
|
||
renderAll();
|
||
}
|
||
|
||
// -- popup: alegerea campului variabil ----------------------------------
|
||
function lastField() {
|
||
try {
|
||
return window.localStorage.getItem(LAST_FIELD_STORAGE) || '';
|
||
} catch (error) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function rememberField(key) {
|
||
try {
|
||
window.localStorage.setItem(LAST_FIELD_STORAGE, key);
|
||
} catch (error) { /* localStorage poate fi blocat */ }
|
||
}
|
||
|
||
function pickerOptions(mode) {
|
||
var options = [];
|
||
if (mode !== 'multi') {
|
||
options.push({
|
||
key: ITEMS_KEY,
|
||
label: 'Defalcare componente',
|
||
sample: demoItems.map(function (item) {
|
||
return item.weight + ' ' + item.unit + ' ' + item.component_name;
|
||
}).join(' · '),
|
||
group: 'Componente cântărite'
|
||
});
|
||
}
|
||
fields.forEach(function (field) {
|
||
options.push({
|
||
key: field.key,
|
||
label: field.label || field.key,
|
||
sample: field.sample || '',
|
||
group: field.group || FIELD_GROUPS[String(field.key).split('.')[0]] || 'Altele',
|
||
custom: Boolean(field.custom)
|
||
});
|
||
});
|
||
if (mode === 'multi') {
|
||
// „Continut complet” sta in grupul Pachet, langa celelalte campuri de pachet.
|
||
var insertAt = 0;
|
||
options.forEach(function (option, index) {
|
||
if (String(option.key).indexOf('package.') === 0) insertAt = index + 1;
|
||
});
|
||
options.splice(insertAt, 0, {
|
||
key: 'package.qr',
|
||
label: QR_EXTRA_LABELS['package.qr'],
|
||
sample: fieldSample('package.qr'),
|
||
group: 'Pachet'
|
||
});
|
||
}
|
||
return options;
|
||
}
|
||
|
||
function renderPickerList(query) {
|
||
if (!pickerList) return;
|
||
var needle = String(query || '').trim().toLowerCase();
|
||
var recent = lastField();
|
||
var multi = pickerMode === 'multi';
|
||
var group = null;
|
||
var html = '';
|
||
pickerOptions(pickerMode).forEach(function (option) {
|
||
if (needle) {
|
||
var haystack = (option.label + ' ' + option.key + ' ' + option.sample).toLowerCase();
|
||
if (haystack.indexOf(needle) < 0) return;
|
||
}
|
||
if (option.group !== group) {
|
||
group = option.group;
|
||
html += '<li class="picker-group">' + esc(group) + '</li>';
|
||
}
|
||
var isPicked = multi && picked.indexOf(option.key) >= 0;
|
||
html += '<li><button type="button" class="picker-item' +
|
||
(option.key === recent ? ' recent' : '') +
|
||
(option.custom ? ' custom' : '') +
|
||
(isPicked ? ' picked' : '') + '" data-pick="' + esc(option.key) + '"' +
|
||
(multi ? ' aria-pressed="' + (isPicked ? 'true' : 'false') + '"' : '') + '>' +
|
||
'<span class="picker-label">' +
|
||
(multi ? '<span class="picker-check" aria-hidden="true">' +
|
||
(isPicked ? '✓' : '') + '</span>' : '') +
|
||
esc(option.label) + '</span>' +
|
||
'<span class="picker-sample">' + esc(option.sample) + '</span>' +
|
||
'</button></li>';
|
||
});
|
||
pickerList.innerHTML = html || '<li class="picker-empty">Niciun câmp găsit.</li>';
|
||
}
|
||
|
||
function updatePickerFoot() {
|
||
if (!pickerConfirm) return;
|
||
var multi = pickerMode === 'multi';
|
||
pickerConfirm.classList.toggle('hidden', !multi);
|
||
pickerConfirm.disabled = !picked.length;
|
||
pickerConfirm.textContent = picked.length
|
||
? 'Adaugă (' + picked.length + ')' : 'Adaugă';
|
||
if (pickerHint) {
|
||
pickerHint.textContent = multi
|
||
? 'Bifează câmpurile dorite, apoi Adaugă; ordinea alegerii contează.'
|
||
: 'Click pe un câmp pentru a-l adăuga pe etichetă.';
|
||
}
|
||
}
|
||
|
||
function openFieldPicker(mode) {
|
||
if (!picker) {
|
||
addBlock('field');
|
||
return;
|
||
}
|
||
pickerMode = mode === 'multi' ? 'multi' : 'single';
|
||
picked = [];
|
||
pickerOpen = true;
|
||
if (pickerTitle) {
|
||
pickerTitle.textContent = pickerMode === 'multi'
|
||
? 'Adaugă câmpuri în codul QR' : 'Alege câmpul variabil';
|
||
}
|
||
if (pickerSearch) pickerSearch.value = '';
|
||
renderPickerList('');
|
||
updatePickerFoot();
|
||
picker.classList.remove('hidden');
|
||
window.setTimeout(function () {
|
||
if (pickerSearch) pickerSearch.focus();
|
||
}, 20);
|
||
}
|
||
|
||
function closeFieldPicker() {
|
||
if (!picker) return;
|
||
pickerOpen = false;
|
||
pickerMode = 'single';
|
||
picked = [];
|
||
picker.classList.add('hidden');
|
||
}
|
||
|
||
function togglePicked(key) {
|
||
var at = picked.indexOf(key);
|
||
if (at >= 0) picked.splice(at, 1);
|
||
else picked.push(key);
|
||
renderPickerList(pickerSearch ? pickerSearch.value : '');
|
||
updatePickerFoot();
|
||
}
|
||
|
||
function addPickedParts() {
|
||
var block = selectedBlock();
|
||
if (!block || block.type !== 'qr' || !picked.length) {
|
||
closeFieldPicker();
|
||
return;
|
||
}
|
||
var parts = block.parts || [];
|
||
picked.forEach(function (key) {
|
||
if (parts.length >= MAX_QR_PARTS) return;
|
||
parts.push({ field: key });
|
||
});
|
||
block.parts = parts;
|
||
if (!block.separator) block.separator = 'pipe';
|
||
markDirty();
|
||
closeFieldPicker();
|
||
refreshQrBlock(block);
|
||
renderProps();
|
||
}
|
||
|
||
function chooseField(key) {
|
||
if (!key) return;
|
||
rememberField(key);
|
||
closeFieldPicker();
|
||
if (key === ITEMS_KEY) addBlock('items');
|
||
else addBlock('field', { field: key });
|
||
}
|
||
|
||
// -- editarea partilor din QR ------------------------------------------
|
||
function refreshQrBlock(block) {
|
||
var node = layer && layer.querySelector('.c-block[data-id="' + block.id + '"]');
|
||
if (node) {
|
||
node.innerHTML = blockInner(block);
|
||
var handle = document.createElement('span');
|
||
handle.className = 'c-resize';
|
||
handle.dataset.handle = 'resize';
|
||
node.appendChild(handle);
|
||
}
|
||
updateQrPreview(block);
|
||
renderBlockList();
|
||
}
|
||
|
||
function updateQrPreview(block) {
|
||
var line = propsBody && propsBody.querySelector('.qr-preview-line');
|
||
if (!line) return;
|
||
line.innerHTML = '<span class="muted small">Rezultat:</span> ' +
|
||
esc(qrPayload(block) || '(gol)');
|
||
}
|
||
|
||
function removeQrPart(index) {
|
||
var block = selectedBlock();
|
||
if (!block || block.type !== 'qr') return;
|
||
block.parts = (block.parts || []).filter(function (_item, position) {
|
||
return position !== index;
|
||
});
|
||
markDirty();
|
||
refreshQrBlock(block);
|
||
renderProps();
|
||
}
|
||
|
||
function moveQrPart(index, direction) {
|
||
var block = selectedBlock();
|
||
if (!block || block.type !== 'qr') return;
|
||
var parts = block.parts || [];
|
||
var target = direction === 'up' ? index - 1 : index + 1;
|
||
if (target < 0 || target >= parts.length) return;
|
||
var moved = parts.splice(index, 1)[0];
|
||
parts.splice(target, 0, moved);
|
||
block.parts = parts;
|
||
markDirty();
|
||
refreshQrBlock(block);
|
||
renderProps();
|
||
}
|
||
|
||
function addQrTextPart() {
|
||
var block = selectedBlock();
|
||
if (!block || block.type !== 'qr') return;
|
||
var parts = block.parts || [];
|
||
if (parts.length >= MAX_QR_PARTS) return;
|
||
parts.push({ text: 'text' });
|
||
block.parts = parts;
|
||
markDirty();
|
||
refreshQrBlock(block);
|
||
renderProps();
|
||
var input = propsBody && propsBody.querySelector('[data-part-text="' +
|
||
(parts.length - 1) + '"]');
|
||
if (input) { input.focus(); input.select(); }
|
||
}
|
||
|
||
function setQrPartText(index, value) {
|
||
var block = selectedBlock();
|
||
if (!block || block.type !== 'qr') return;
|
||
var part = (block.parts || [])[index];
|
||
if (!part || part.text === undefined) return;
|
||
part.text = String(value).slice(0, 60);
|
||
markDirty();
|
||
refreshQrBlock(block);
|
||
}
|
||
|
||
function deleteSelected() {
|
||
var block = selectedBlock();
|
||
if (!block) return;
|
||
design.blocks = design.blocks.filter(function (item) { return item.id !== block.id; });
|
||
state.selected = null;
|
||
markDirty();
|
||
renderAll();
|
||
}
|
||
|
||
function duplicateSelected() {
|
||
var block = selectedBlock();
|
||
if (!block) return;
|
||
var copy = JSON.parse(JSON.stringify(block));
|
||
copy.id = nextId();
|
||
copy.x = clamp(block.x + 2, 0, (Number(design.width_mm) || 100) - block.w);
|
||
copy.y = clamp(block.y + 2, 0, (Number(design.height_mm) || 60) - block.h);
|
||
design.blocks.push(copy);
|
||
state.selected = copy.id;
|
||
markDirty();
|
||
renderAll();
|
||
}
|
||
|
||
// -- drag & resize -------------------------------------------------------
|
||
function mmFromEvent(event) {
|
||
var rect = canvas.getBoundingClientRect();
|
||
return {
|
||
x: (event.clientX - rect.left) / state.zoom,
|
||
y: (event.clientY - rect.top) / state.zoom
|
||
};
|
||
}
|
||
|
||
function selectBlock(id) {
|
||
state.selected = id;
|
||
if (layer) {
|
||
layer.querySelectorAll('.c-block').forEach(function (node) {
|
||
node.classList.toggle('selected', node.dataset.id === id);
|
||
});
|
||
}
|
||
renderBlockList();
|
||
renderProps();
|
||
}
|
||
|
||
function beginDrag(event, block, mode) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
var point = mmFromEvent(event);
|
||
dragging = {
|
||
mode: mode,
|
||
id: block.id,
|
||
startX: point.x,
|
||
startY: point.y,
|
||
origin: { x: block.x, y: block.y, w: block.w, h: block.h }
|
||
};
|
||
selectBlock(block.id);
|
||
}
|
||
|
||
if (layer) {
|
||
layer.addEventListener('pointerdown', function (event) {
|
||
var node = event.target.closest('.c-block');
|
||
if (!node) return;
|
||
var block = findBlock(node.dataset.id);
|
||
if (!block) return;
|
||
var isHandle = event.target.dataset && event.target.dataset.handle === 'resize';
|
||
beginDrag(event, block, isHandle ? 'resize' : 'move');
|
||
});
|
||
}
|
||
|
||
document.addEventListener('pointermove', function (event) {
|
||
if (!dragging) return;
|
||
var block = findBlock(dragging.id);
|
||
if (!block) return;
|
||
var point = mmFromEvent(event);
|
||
var dx = Math.round((point.x - dragging.startX) * 2) / 2;
|
||
var dy = Math.round((point.y - dragging.startY) * 2) / 2;
|
||
var width = Number(design.width_mm) || 100;
|
||
var height = Number(design.height_mm) || 60;
|
||
|
||
if (dragging.mode === 'move') {
|
||
block.x = clamp(Math.round((dragging.origin.x + dx) * 2) / 2, 0, width - block.w);
|
||
block.y = clamp(Math.round((dragging.origin.y + dy) * 2) / 2, 0, height - block.h);
|
||
} else {
|
||
block.w = clamp(Math.round((dragging.origin.w + dx) * 2) / 2, 2, width - block.x);
|
||
block.h = clamp(Math.round((dragging.origin.h + dy) * 2) / 2, 0.3, height - block.y);
|
||
}
|
||
var node = layer.querySelector('.c-block[data-id="' + block.id + '"]');
|
||
if (node) {
|
||
node.style.cssText = positionCss(block) +
|
||
(block.type === 'line' ? ';min-height:1px' : ';' + fontCss(block.style || {}, block.h));
|
||
if (block.type === 'line') {
|
||
node.style.background = (block.style && block.style.color) || '#000';
|
||
}
|
||
node.classList.toggle('outside-safe', isOutsideSafe(block));
|
||
}
|
||
updateSafeHint();
|
||
markDirty();
|
||
syncPositionInputs(block);
|
||
});
|
||
|
||
function syncPositionInputs(block) {
|
||
['x', 'y', 'w', 'h'].forEach(function (prop) {
|
||
var input = propsBody && propsBody.querySelector('[data-prop="' + prop + '"]');
|
||
if (input && document.activeElement !== input) input.value = short(block[prop]);
|
||
});
|
||
}
|
||
|
||
document.addEventListener('pointercancel', function () {
|
||
dragging = null;
|
||
});
|
||
|
||
document.addEventListener('pointerup', function () {
|
||
if (!dragging) return;
|
||
dragging = null;
|
||
markDirty();
|
||
renderProps();
|
||
renderBlockList();
|
||
});
|
||
|
||
// -- evenimente UI -------------------------------------------------------
|
||
document.querySelectorAll('[data-add]').forEach(function (button) {
|
||
button.addEventListener('click', function () { addBlock(button.dataset.add); });
|
||
});
|
||
|
||
document.querySelectorAll('[data-add-picker]').forEach(function (button) {
|
||
button.addEventListener('click', function () { openFieldPicker('single'); });
|
||
});
|
||
|
||
if (picker) {
|
||
picker.addEventListener('click', function (event) {
|
||
if (event.target.closest('[data-picker-close]')) {
|
||
closeFieldPicker();
|
||
return;
|
||
}
|
||
var item = event.target.closest('[data-pick]');
|
||
if (!item) return;
|
||
if (pickerMode === 'multi') togglePicked(item.dataset.pick);
|
||
else chooseField(item.dataset.pick);
|
||
});
|
||
}
|
||
|
||
if (pickerConfirm) {
|
||
pickerConfirm.addEventListener('click', addPickedParts);
|
||
}
|
||
|
||
if (pickerSearch) {
|
||
pickerSearch.addEventListener('input', function () {
|
||
renderPickerList(pickerSearch.value);
|
||
});
|
||
pickerSearch.addEventListener('keydown', function (event) {
|
||
if (event.key !== 'Enter') return;
|
||
event.preventDefault();
|
||
if (pickerMode === 'multi') {
|
||
addPickedParts();
|
||
return;
|
||
}
|
||
var first = pickerList && pickerList.querySelector('[data-pick]');
|
||
if (first) chooseField(first.dataset.pick);
|
||
});
|
||
}
|
||
|
||
document.addEventListener('keydown', function (event) {
|
||
if (event.key === 'Escape' && pickerOpen) closeFieldPicker();
|
||
});
|
||
|
||
if (blockList) {
|
||
blockList.addEventListener('click', function (event) {
|
||
var button = event.target.closest('[data-select]');
|
||
if (!button) return;
|
||
selectBlock(button.dataset.select);
|
||
});
|
||
}
|
||
|
||
if (propsBody) {
|
||
propsBody.addEventListener('input', function (event) {
|
||
var block = selectedBlock();
|
||
if (!block) return;
|
||
var dataset = event.target.dataset || {};
|
||
if (dataset.partText !== undefined) {
|
||
setQrPartText(Number(dataset.partText), event.target.value);
|
||
return;
|
||
}
|
||
var prop = dataset.prop;
|
||
if (!prop) return;
|
||
var value = event.target.type === 'checkbox' ? event.target.checked : event.target.value;
|
||
applyProp(block, prop, value);
|
||
});
|
||
propsBody.addEventListener('click', function (event) {
|
||
var dataset = event.target.dataset || {};
|
||
if (dataset.partRemove !== undefined) {
|
||
removeQrPart(Number(dataset.partRemove));
|
||
return;
|
||
}
|
||
if (dataset.partMove) {
|
||
moveQrPart(Number(dataset.index), dataset.partMove);
|
||
return;
|
||
}
|
||
if (event.target.closest('[data-qr-add]')) {
|
||
openFieldPicker('multi');
|
||
return;
|
||
}
|
||
if (event.target.closest('[data-qr-add-text]')) {
|
||
addQrTextPart();
|
||
return;
|
||
}
|
||
var action = dataset.action;
|
||
if (action === 'delete') deleteSelected();
|
||
if (action === 'duplicate') duplicateSelected();
|
||
});
|
||
}
|
||
|
||
function applyProp(block, prop, value) {
|
||
var numeric = ['x', 'y', 'w', 'h', 'size'];
|
||
if (numeric.indexOf(prop) >= 0) {
|
||
var number = Number(value);
|
||
if (!isFinite(number)) return;
|
||
if (prop === 'size') block.style.size = clamp(number, 1.5, 40);
|
||
else if (prop === 'x') block.x = clamp(number, 0, (Number(design.width_mm) || 100) - block.w);
|
||
else if (prop === 'y') block.y = clamp(number, 0, (Number(design.height_mm) || 60) - block.h);
|
||
else if (prop === 'w') block.w = clamp(number, 2, (Number(design.width_mm) || 100) - block.x);
|
||
else if (prop === 'h') block.h = clamp(number, 0.3, (Number(design.height_mm) || 60) - block.y);
|
||
} else if (['bold', 'italic', 'uppercase'].indexOf(prop) >= 0) {
|
||
block.style[prop] = Boolean(value);
|
||
} else if (prop === 'separator' && block.type === 'qr') {
|
||
block.separator = value;
|
||
} else if (['align', 'color', 'separator'].indexOf(prop) >= 0) {
|
||
if (!block.style) block.style = {};
|
||
block.style[prop] = value;
|
||
} else {
|
||
block[prop] = value;
|
||
}
|
||
markDirty();
|
||
|
||
var node = layer && layer.querySelector('.c-block[data-id="' + block.id + '"]');
|
||
if (node) {
|
||
node.style.cssText = positionCss(block) + (block.type === 'line' ? ';min-height:1px' : ';' +
|
||
(block.type === 'field' || block.type === 'text' || block.type === 'items'
|
||
? fontCss(block.style || {}, block.h) : ''));
|
||
if (block.type === 'line') node.style.background = block.style.color || '#000';
|
||
node.innerHTML = blockInner(block);
|
||
var handle = document.createElement('span');
|
||
handle.className = 'c-resize';
|
||
handle.dataset.handle = 'resize';
|
||
node.appendChild(handle);
|
||
node.classList.toggle('outside-safe', isOutsideSafe(block));
|
||
}
|
||
if (block.type === 'qr') updateQrPreview(block);
|
||
updateSafeHint();
|
||
renderBlockList();
|
||
}
|
||
|
||
if (elWidth) {
|
||
elWidth.addEventListener('change', function () {
|
||
design.width_mm = clamp(Number(elWidth.value) || 100, 10, 500);
|
||
// Nu mutam elementele aici: cele care ies din zona sigura sunt marcate
|
||
// si pot fi aduse inapoi cu „Rearanjează în zona sigură”.
|
||
markDirty();
|
||
renderAll();
|
||
});
|
||
}
|
||
if (elHeight) {
|
||
elHeight.addEventListener('change', function () {
|
||
design.height_mm = clamp(Number(elHeight.value) || 60, 10, 500);
|
||
markDirty();
|
||
renderAll();
|
||
});
|
||
}
|
||
if (elZoom) {
|
||
elZoom.addEventListener('input', function () {
|
||
state.zoom = Number(elZoom.value) || 6;
|
||
applySize();
|
||
renderBlocks();
|
||
});
|
||
}
|
||
|
||
if (safeToggle) {
|
||
var showSafe = true;
|
||
try {
|
||
showSafe = window.localStorage.getItem(SAFE_STORAGE) !== '0';
|
||
} catch (error) { /* localStorage poate fi blocat */ }
|
||
safeToggle.checked = showSafe;
|
||
if (safeEl) safeEl.classList.toggle('hidden', !showSafe);
|
||
safeToggle.addEventListener('change', function () {
|
||
if (safeEl) safeEl.classList.toggle('hidden', !safeToggle.checked);
|
||
try {
|
||
window.localStorage.setItem(SAFE_STORAGE, safeToggle.checked ? '1' : '0');
|
||
} catch (error) { /* ignorat */ }
|
||
});
|
||
}
|
||
|
||
if (safeArrange) {
|
||
safeArrange.addEventListener('click', function () {
|
||
var result = arrangeInsideSafe();
|
||
markDirty();
|
||
renderBlocks();
|
||
renderProps();
|
||
updateSafeHint();
|
||
if (result.remaining) {
|
||
toast(result.remaining + ' elemente nu au încăput în zona sigură. ' +
|
||
'Micșorează-le sau mută-le manual.', 'warn');
|
||
} else if (result.moved) {
|
||
var percent = Math.round(result.scale * 100);
|
||
var suffix = result.scale < 1 ? ' (' + percent + '% din mărime)' : '';
|
||
toast((result.moved === 1 ? '1 element adus' : result.moved + ' elemente aduse') +
|
||
' în zona sigură' + suffix + '.', 'success');
|
||
} else {
|
||
toast('Elementele erau deja în zona sigură.', '');
|
||
}
|
||
});
|
||
}
|
||
|
||
document.addEventListener('keydown', function (event) {
|
||
var tag = (event.target.tagName || '').toLowerCase();
|
||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||
var block = selectedBlock();
|
||
if (!block) return;
|
||
|
||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||
event.preventDefault();
|
||
deleteSelected();
|
||
return;
|
||
}
|
||
var step = event.shiftKey ? 2 : 0.5;
|
||
var moved = true;
|
||
if (event.key === 'ArrowLeft') block.x = clamp(block.x - step, 0, design.width_mm - block.w);
|
||
else if (event.key === 'ArrowRight') block.x = clamp(block.x + step, 0, design.width_mm - block.w);
|
||
else if (event.key === 'ArrowUp') block.y = clamp(block.y - step, 0, design.height_mm - block.h);
|
||
else if (event.key === 'ArrowDown') block.y = clamp(block.y + step, 0, design.height_mm - block.h);
|
||
else moved = false;
|
||
|
||
if (moved) {
|
||
event.preventDefault();
|
||
markDirty();
|
||
renderBlocks();
|
||
renderProps();
|
||
}
|
||
});
|
||
|
||
// -- salvare -------------------------------------------------------------
|
||
var saveButton = document.getElementById('designer-save');
|
||
if (saveButton) {
|
||
saveButton.addEventListener('click', function () {
|
||
saveButton.disabled = true;
|
||
saveButton.textContent = 'Se salvează…';
|
||
fetch(saveUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||
body: JSON.stringify({
|
||
design: {
|
||
version: design.version || 1,
|
||
width_mm: design.width_mm,
|
||
height_mm: design.height_mm,
|
||
blocks: design.blocks
|
||
}
|
||
})
|
||
})
|
||
.then(function (response) {
|
||
return response.json().then(function (data) {
|
||
return { status: response.status, data: data };
|
||
});
|
||
})
|
||
.then(function (result) {
|
||
if (!result.data.ok) {
|
||
toast(result.data.error || 'Salvarea a eșuat.', 'error');
|
||
return;
|
||
}
|
||
markClean();
|
||
var outside = outsideSafeCount();
|
||
if (outside) {
|
||
toast('Design salvat, dar ' + outside +
|
||
(outside === 1 ? ' element este' : ' elemente sunt') +
|
||
' în afara zonei sigure.', 'warn');
|
||
} else {
|
||
toast('Design salvat (' + result.data.blocks + ' elemente).', 'success');
|
||
}
|
||
})
|
||
.catch(function () { toast('Eroare de rețea la salvare.', 'error'); })
|
||
.finally(function () {
|
||
saveButton.disabled = false;
|
||
saveButton.textContent = 'Salvează';
|
||
});
|
||
});
|
||
}
|
||
|
||
window.addEventListener('beforeunload', function (event) {
|
||
if (!state.dirty) return;
|
||
event.preventDefault();
|
||
event.returnValue = '';
|
||
});
|
||
|
||
// -- start ---------------------------------------------------------------
|
||
if (elZoom) elZoom.value = String(state.zoom);
|
||
markClean();
|
||
renderAll();
|
||
})();
|