/* 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, '"');
}
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
? ' '
: '' + esc(fieldLabel(part.field)) + ' ' +
'' + esc(fieldSample(part.field)) + ' ';
return '
' +
'' + (index + 1) + ' ' + body +
'' +
'↑ ' +
'↓ ' +
'× ' +
' ';
}).join('');
return '' +
'
Conținut cod QR ' +
'' + parts.length + ' / ' + MAX_QR_PARTS + '
' +
(rows ? '
' + rows + ' '
: '
Niciun câmp; codul conține textul complet implicit.
') +
'
Rezultat: ' +
esc(qrPayload(block) || '(gol)') + '
' +
'
' +
'= MAX_QR_PARTS ? ' disabled' : '') + '>+ Adaugă câmp ' +
'= MAX_QR_PARTS ? ' disabled' : '') + '>+ Text liber ' +
'
';
}
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 = '' + esc(item.weight) + ' ' + esc(item.unit) + ' ';
return inline
? '' + weight + ' ' + esc(item.component_name) + ' '
: '' + weight + ' ' + esc(item.component_name) + '
';
}).join(inline ? ' · ' : '');
}
if (block.type === 'qr') {
var data = qrPayload(block) || 'PF-001 | LOT | 450 g';
return ' ';
}
if (block.type === 'image') {
return 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 '' +
esc(blockLabel(block)) + ' ';
}).join('') || 'Niciun element încă. ';
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 '' + label + ' ' +
'
';
}
function checkbox(label, name, checked) {
return ' ' + label + ' ';
}
function select(label, name, value, options) {
return '' + label + ' ' +
'' +
options.map(function (option) {
return '' +
esc(option.label) + ' ';
}).join('') + '
';
}
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 += 'Text ' +
'
';
}
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 += 'Se acceptă /static/…, http(s)://… sau data:image/…
';
}
if (block.type !== 'line' && block.type !== 'qr' && block.type !== 'image') {
html += '' +
input('Mărime (mm)', 'size', style.size || 3.2, 'type="number" step="0.2" min="1.5" max="40"') +
'
';
html += '' +
checkbox('Aldin', 'bold', style.bold) +
checkbox('Cursiv', 'italic', style.italic) +
checkbox('Majuscule', 'uppercase', style.uppercase) +
'
';
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 += 'Culoare ' +
'
';
}
html += 'Poziție și mărime (mm) ' +
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"') +
'
';
html += '' +
'Duplică ' +
'Șterge ' +
'
';
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 += '' + esc(group) + ' ';
}
var isPicked = multi && picked.indexOf(option.key) >= 0;
html += '' +
'' +
(multi ? '' +
(isPicked ? '✓' : '') + ' ' : '') +
esc(option.label) + ' ' +
'' + esc(option.sample) + ' ' +
' ';
});
pickerList.innerHTML = html || 'Niciun câmp găsit. ';
}
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 = 'Rezultat: ' +
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();
})();