/* 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)') + '

    ' + '
    ' + '' + '' + '
    '; } 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 '
  • '; }).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 '
    ' + '
    '; } function checkbox(label, name, checked) { return ''; } function select(label, name, value, options) { return '
    ' + '
    '; } 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 += '
    ' + '
    '; } 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 += '
    ' + '
    '; } 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 += '
    ' + '' + '' + '
    '; 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 += '
  • '; }); 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(); })();