Label Studio
Saved

Your projects

`; return {doc, labels, pages}; } window.buildPrintDoc = buildPrintDoc; async function doPrint(calibrationOverride, opts){ if (!App.project) return; const labels0=computeLabels(); if (!labels0.length){ toast('Nothing to print — add data or enable repeat mode'); return; } const dest = (opts && opts.dest) || 'printer'; if (!printScaleAcked()){ if (!await openPrintChecklist({dest})) { track('print.cancelled', {kind:dest}); return; } } else if (dest === 'pdf'){ toast('In the dialog choose “Save as PDF” as the destination — and keep the scale at 100%.', {timeout:7000}); } const calib = calibrationOverride || SETTINGS.settings.calibration || {x:0,y:0}; const {doc, labels, pages} = await buildPrintDoc(calib); let iframe=$('#printFrame'); if (iframe) iframe.remove(); iframe=el('iframe',{id:'printFrame', class:'ls-private', style:{position:'fixed',right:'0',bottom:'0',width:'0',height:'0',border:'0'}}); document.body.appendChild(iframe); iframe.srcdoc=doc; iframe.onload=async ()=>{ const win=iframe.contentWindow; try{ await win.document.fonts.ready; }catch(e){} const imgs=Array.from(win.document.images); await Promise.all(imgs.map(im=> im.complete?Promise.resolve():new Promise(res=>{ im.onload=im.onerror=res; }))); await new Promise(r=>setTimeout(r,150)); win.focus(); win.print(); recordPrint(labels.length, pages.length); }; } function recordPrint(count, pages){ track('print.run', {labels:count, pages, per_page:presetFacts(design().page).perPage, preset:(presetById(design().page.preset)||{}).id, elements:design().elements.length, copies:O().copiesEach||1, repeat:O().repeatMode?1:0}); try{ const hist=JSON.parse(localStorage.getItem('ls_printlog_'+MODE+'_'+App.project.id)||'[]'); hist.unshift({at:new Date().toISOString(), count, pages, output:deepClone(O())}); localStorage.setItem('ls_printlog_'+MODE+'_'+App.project.id, JSON.stringify(hist.slice(0,20))); }catch(e){} } function openCalibration(){ const cur=SETTINGS.settings.calibration||{x:0,y:0}; const xIn=el('input',{class:'num-inp',type:'number',step:'0.1',value:String(cur.x||0)}); const yIn=el('input',{class:'num-inp',type:'number',step:'0.1',value:String(cur.y||0)}); const body=el('div',{}, el('p',{style:{fontSize:'13px',color:'var(--muted)'},text:'Print a test sheet, measure how far the crosshair is from the true corner of a label, and enter the X/Y offset in mm. Applied to all future prints.'}), el('div',{class:'scale-warn',style:{marginTop:'10px'}}, el('i',{'data-lucide':'ruler'}), el('div',{}, el('b',{text:'Print the test sheet at 100% / Actual size'}), el('span',{text:'If you print it scaled, you will measure the scaling and bake it into every future print. ' + 'Use plain paper — no need to burn a label sheet on this.'}))), el('div',{class:'prop-grid2',style:{marginTop:'10px'}}, el('div',{},el('label',{style:{fontSize:'12px'},text:'X offset (mm)'}),xIn), el('div',{},el('label',{style:{fontSize:'12px'},text:'Y offset (mm)'}),yIn)), ); const m=modal({title:'Print calibration', body, size:'sm', foot:[ el('button',{class:'btn',text:'Print test sheet',onclick:()=>printCalibrationSheet()}), el('button',{class:'btn primary',text:'Save offsets',onclick:async()=>{ SETTINGS.settings.calibration={x:+xIn.value||0,y:+yIn.value||0}; await saveWsSettings(); toast('Calibration saved'); m.close(); }})]}); } function scaleCheckRuler(pg){ const BAR = 100; const H = 6; const EDGE = 4; const [pw, ph] = pg.size; if (pw < BAR + 20) return null; const lastRowBottom = pg.marginTop + pg.rows*pg.labelH + Math.max(0, pg.rows-1)*pg.gutterY; const bandBelow = (ph - EDGE) - lastRowBottom; const bandAbove = pg.marginTop - EDGE; let top; if (bandBelow >= H) top = lastRowBottom + (bandBelow - H)/2; else if (bandAbove >= H) top = EDGE + (bandAbove - H)/2; else return null; const left = (pw - BAR)/2; const wrap = el('div',{style:{position:'absolute', left:round2(left)+'mm', top:round2(top)+'mm', width:BAR+'mm', height:H+'mm'}}); let ticks = ''; for (let i=0;i<=BAR;i+=10){ const tall = (i===0 || i===BAR); ticks += `
`; } wrap.innerHTML = `
` + ticks + `
` + `This bar must measure exactly 100 mm — if it is shorter, your printer scaled the page.
`; return wrap; } async function printCalibrationSheet(){ if (!printScaleAcked()){ if (!await openPrintChecklist({dest:'calibration'})) return; } track('print.calibration', {per_page:presetFacts(design().page).perPage, preset:(presetById(design().page.preset)||{}).id}); const pg=design().page; const host=el('div'); const pageEl=el('div',{class:'print-page'}); const labels=[]; for(let r=0;r{ const {x,y}=labelPos(cell.col,cell.row); const wrap=el('div',{class:'print-label',style:{left:x+'mm',top:y+'mm',width:pg.labelW+'mm',height:pg.labelH+'mm',outline:'0.2mm solid #999'}}); wrap.innerHTML=`
${i+1}
`; pageEl.appendChild(wrap); }); const ruler = scaleCheckRuler(pg); if (ruler) pageEl.appendChild(ruler); host.appendChild(pageEl); const doc=`${host.innerHTML}`; let iframe=$('#printFrame'); if(iframe) iframe.remove(); iframe=el('iframe',{id:'printFrame',class:'ls-private',style:{position:'fixed',right:'0',bottom:'0',width:'0',height:'0',border:'0'}}); document.body.appendChild(iframe); iframe.srcdoc=doc; iframe.onload=()=>{ iframe.contentWindow.focus(); iframe.contentWindow.print(); }; } /* templates / import-export / polish */ function mkText(x,y,w,h,runs,opts){ return Object.assign({id:uid(),type:'text',x,y,w,h,rotation:0,locked:false,hidden:false,name:'Text', runs, fit:'shrinkWrap', minSize:6, align:'left', valign:'top', direction:'auto', hideLineIfBlank:false, style:{font:'Heebo',size:11,color:'#1f2937',lineHeight:1.25,letterSpacing:0}}, opts||{}); } function starterDesigns(){ const addr = pageFromPreset(presetById('avery-5160')); const ship = pageFromPreset(presetById('avery-5163')); const badge= pageFromPreset(presetById('name-badge')); const round= pageFromPreset(presetById('round-60mm')); return [ {name:'Address label (Hebrew sample)', page:addr, els:[ mkText(4,3,58,7,[{f:'First Name',style:{bold:true}},{t:' '},{f:'Last Name',style:{bold:true}}],{style:{font:'Heebo',size:12,color:'#1f2937',lineHeight:1.2}}), mkText(4,10,58,6,[{f:'Street'}],{style:{font:'Heebo',size:9,color:'#374151',lineHeight:1.2},hideLineIfBlank:true}), mkText(4,16,58,6,[{f:'City'}],{style:{font:'Heebo',size:9,color:'#374151',lineHeight:1.2}}), ]}, {name:'Shipping label', page:ship, els:[ {id:uid(),type:'shape',shape:'rect',x:0,y:0,w:ship.labelW,h:8,rotation:0,fill:'#4f46e5',stroke:null,strokeWidth:0,name:'Bar'}, mkText(4,1.5,70,5,[{t:'SHIP TO'}],{style:{font:'Inter',size:9,color:'#ffffff',bold:true,lineHeight:1}}), mkText(4,12,70,8,[{f:'First Name',style:{bold:true}},{t:' '},{f:'Last Name',style:{bold:true}}],{style:{font:'Inter',size:14,color:'#111827'}}), mkText(4,22,70,7,[{f:'Street'}],{style:{font:'Inter',size:11,color:'#374151'},hideLineIfBlank:true}), mkText(4,30,70,7,[{f:'City'}],{style:{font:'Inter',size:11,color:'#374151'}}), {id:uid(),type:'barcode',x:72,y:30,w:26,h:16,rotation:0,format:'code128',value:{kind:'merge',field:'sku'},showText:true,name:'Barcode'}, ]}, {name:'Name badge', page:badge, els:[ {id:uid(),type:'shape',shape:'rect',x:0,y:0,w:badge.labelW,h:12,rotation:0,fill:'#4f46e5',stroke:null,strokeWidth:0,name:'Header'}, mkText(0,2,badge.labelW,8,[{t:'HELLO my name is'}],{align:'center',style:{font:'Inter',size:11,color:'#ffffff',bold:true}}), mkText(4,22,badge.labelW-8,20,[{f:'First Name'}],{align:'center',valign:'middle',fit:'shrink',style:{font:'Varela Round',size:30,color:'#111827'}}), mkText(4,44,badge.labelW-8,8,[{f:'City'}],{align:'center',style:{font:'Inter',size:11,color:'#6b7280'}}), ]}, {name:'Product / jar label', page:round, els:[ mkText(6,14,48,10,[{f:'First Name'}],{align:'center',fit:'shrink',style:{font:'Suez One',size:18,color:'#111827'}}), {id:uid(),type:'shape',shape:'rect',x:12,y:26,w:36,h:0.6,rotation:0,fill:'#4f46e5',name:'Rule'}, mkText(6,30,48,8,[{f:'City'}],{align:'center',style:{font:'Assistant',size:10,color:'#4b5563'}}), ]}, {name:'Barcode asset tag', page:pageFromPreset(presetById('avery-5167')), els:[ mkText(2,1,40,4,[{t:'ASSET '},{f:'sku'}],{style:{font:'Courier Prime',size:8,color:'#111827',bold:true}}), {id:uid(),type:'barcode',x:2,y:5,w:40,h:6,rotation:0,format:'code128',value:{kind:'merge',field:'sku'},showText:false,name:'Barcode'}, ]}, {name:'Return address', page:pageFromPreset(presetById('avery-5167')), els:[ mkText(2,1.5,40,4,[{f:'First Name',style:{bold:true}},{t:' '},{f:'Last Name',style:{bold:true}}],{style:{font:'Heebo',size:8,lineHeight:1.1}}), mkText(2,5,40,4,[{f:'Street'}],{style:{font:'Heebo',size:7,color:'#374151',lineHeight:1.1},hideLineIfBlank:true}), mkText(2,8.5,40,4,[{f:'City'}],{style:{font:'Heebo',size:7,color:'#374151',lineHeight:1.1}}), ]}, ].map(t=>({name:t.name, design:{version:1, page:t.page, labelStyle:{background:null,border:null}, elements:t.els}})); } async function openTemplateGallery(){ const body=el('div'); const grid=el('div',{class:'tpl-grid'}); body.appendChild(el('div',{style:{marginBottom:'10px'}}, el('button',{class:'btn sm',html:icon('upload','ic-sm')+' Import project (.json)',onclick:()=>importProject()}))); body.appendChild(el('h3',{style:{fontSize:'13px',margin:'6px 0'},text:'Starter templates'})); body.appendChild(grid); const dbWrap=el('div'); body.appendChild(dbWrap); const m=modal({title:'New from template', body, size:'lg'}); starterDesigns().forEach(t=>{ const card=el('div',{class:'tpl-card', onclick:async()=>{ m.close(); await createFromDesign(t.name, t.design); }}); const thumb=el('div',{class:'thumb'}); card.appendChild(thumb); card.appendChild(el('div',{class:'tpl-name',text:t.name})); grid.appendChild(card); rasterizeLabel(t.design, columns().length?sampleRow():{}, 3).then(png=>{ thumb.appendChild(el('img',{src:png})); }).catch(()=>{ thumb.innerHTML=icon('layout-template','ic'); refreshIcons(); }); }); try{ const templates=await Store.listTemplates(); if (templates.length){ dbWrap.appendChild(el('h3',{style:{fontSize:'13px',margin:'14px 0 6px'},text:'Saved templates'})); const g2=el('div',{class:'tpl-grid'}); dbWrap.appendChild(g2); templates.forEach(t=>{ const card=el('div',{class:'tpl-card', onclick:async()=>{ m.close(); await createFromDesign(t.name, t.design_json); }}); const thumb=el('div',{class:'thumb'}); if(t.thumbnail) thumb.appendChild(el('img',{src:t.thumbnail})); else thumb.innerHTML=icon('layout-template','ic'); card.appendChild(thumb); card.appendChild(el('div',{class:'tpl-name',style:{display:'flex',alignItems:'center'}}, el('span',{class:'grow',text:t.name}), el('button',{class:'btn icon sm danger',html:icon('trash-2','ic-sm'),onclick:async(e)=>{ e.stopPropagation(); await Store.deleteTemplate(t.id); openTemplateGallery(); m.close(); }}))); g2.appendChild(card); }); } }catch(e){} refreshIcons(); } async function createFromDesign(name, designObj){ const id=await createProject(name); App.project.design=normalizeDesign(deepClone(designObj)); App.project.design.wizardDone = true; track('template.use', {elements:(App.project.design.elements||[]).length, preset:(presetById(App.project.design.page.preset)||{}).id}); Ed.history=[]; Ed.selection=[]; markDirty('design'); await runSave(); App.emit('project:loaded', App.project); setTab('design'); toast('Created from template'); } async function saveAsTemplate(){ if (!App.project){ toast('Open a project first'); return; } const name=await promptModal('Template name', App.project.name+' template'); if (name==null) return; let thumb=null; try{ thumb=await rasterizeLabel(design(), canvasRow(), 4); }catch(e){} try{ await Store.saveTemplate({name, design_json:design(), thumbnail:thumb}); track('template.save', {elements:design().elements.length, preset:(presetById(design().page.preset)||{}).id}); toast('Saved as template'); }catch(e){ toast('Could not save template: '+e.message); } } function exportProject(){ const p=App.project; if(!p) return; const data={_type:'labelstudio.project', version:1, name:p.name, data:p.data, design:p.design, output:p.output}; const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const a=el('a',{href:URL.createObjectURL(blob), download:(p.name||'project')+'.json'}); document.body.appendChild(a); a.click(); a.remove(); track('project.export', {rows:p.data.rows.length, columns:p.data.columns.length, elements:(p.design.elements||[]).length}); } function importProject(){ const fi=el('input',{type:'file',accept:'.json,application/json'}); fi.addEventListener('change', async ()=>{ const f=fi.files[0]; if(!f) return; try{ const txt=await f.text(); const d=JSON.parse(txt); if (d._type!=='labelstudio.project') throw new Error('Not a Label Studio project file'); const id=await createProject(d.name||'Imported'); App.project.data=normalizeData(d.data); App.project.design=normalizeDesign(d.design); App.project.output=Object.assign(App.project.output, d.output||{}); markDirty('data'); markDirty('design'); markDirty('output'); await runSave(); App.emit('project:loaded', App.project); await reloadHome(); track('project.import', {rows:App.project.data.rows.length, columns:App.project.data.columns.length, elements:design().elements.length}); toast('Project imported'); }catch(e){ toast('Import failed: '+e.message); } }); fi.click(); } function showPrintHistory(){ const hist=(()=>{ try{ return JSON.parse(localStorage.getItem('ls_printlog_'+MODE+'_'+App.project.id)||'[]'); }catch(e){ return []; } })(); const body=el('div'); if (!hist.length) body.appendChild(el('p',{style:{color:'var(--muted)'},text:'No print history yet.'})); hist.forEach(h=>{ body.appendChild(el('div',{class:'row',style:{padding:'8px',borderBottom:'1px solid var(--line2)'}}, el('span',{class:'grow',text:`Printed ${h.count} labels on ${new Date(h.at).toLocaleDateString()} (${h.pages} pages)`}), el('button',{class:'btn sm',text:'Reprint',onclick:()=>{ Object.assign(O(),h.output); markOutputChanged(); doPrint(); }}))); }); modal({title:'Print history', body, size:'sm'}); } function enhanceHome(){ const row=$('#pane-home .row'); if(!row || row.dataset.enhanced) return; row.dataset.enhanced='1'; const imp=el('button',{class:'btn',html:icon('upload','ic-sm')+' Import',onclick:importProject}); row.insertBefore(imp, $('#newFromTpl')); } App.on('project:loaded', ()=>{ enhanceHome(); }); /* boot */ if (document.readyState==='loading') document.addEventListener('DOMContentLoaded', boot); else boot();