Files
cantarire-etichetare/cantarire/static/js/designer.js
T
ske087 bff6d70b17 Initial commit: aplicatie de cantarire si etichetare
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
2026-10-18 23:38:45 +03:00

1263 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 ? ' &middot; ' : '');
}
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();
})();