// Label Designer — QSO labels for QSL cards and address labels for envelopes. // // Same architecture as the QSL card designer, stripped to what a monochrome // sticker needs: saved templates on the left, a mm-true canvas on the right, // click to select, drag to move. The canvas renderer (labelRender) is shared // with the future print path, so what the preview shows is what the PDF gets. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { X, Plus, Trash2, Star, Tag, MapPin } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { LabelListStocks, LabelSaveStock, LabelDeleteStock, LabelListTemplates, LabelGetTemplate, LabelSaveTemplate, LabelDeleteTemplate, LabelSetDefaultTemplate, LabelSampleQSOs, GetActiveProfile, } from '../../../wailsjs/go/main/App'; import type { LabelElement, LabelSample, LabelStock, LabelTemplate } from './labelTypes'; import { LABEL_VARS, TABLE_FIELDS, starterTemplate } from './labelTypes'; import { drawMargins, render, type ElementBox } from './labelRender'; interface Props { open: boolean; onClose: () => void } interface TplInfo { id: number; name: string; kind: string; stock_id: number; is_default: boolean; updated_at: string; } // The preview fills whatever room the centre pane offers — a label cut off at // the edge cannot be judged, and 90 mm across a fixed 720 px wasted half the // window. Measured live so a resize re-fits. const PREVIEW_MIN_SCALE = 2; export function LabelDesignerModal({ open, onClose }: Props) { const { t } = useI18n(); const [stocks, setStocks] = useState([]); const [saved, setSaved] = useState([]); const [samples, setSamples] = useState([]); const [myVars, setMyVars] = useState>({}); const [error, setError] = useState(''); // The design being edited (null = nothing open yet). const [tplId, setTplId] = useState(0); const [tpl, setTpl] = useState(null); const [tplName, setTplName] = useState(''); const [forProfile, setForProfile] = useState(true); const [sel, setSel] = useState(null); const [dirty, setDirty] = useState(false); const [deleteArm, setDeleteArm] = useState(0); // Stock editor (collapsed by default — geometry is set once per roll). const [stockOpen, setStockOpen] = useState(false); const [stockDraft, setStockDraft] = useState(null); const canvasRef = useRef(null); const paneRef = useRef(null); const [paneSize, setPaneSize] = useState({ w: 720, h: 420 }); useEffect(() => { const el = paneRef.current; if (!el) return; const ro = new ResizeObserver(() => { setPaneSize({ w: el.clientWidth, h: el.clientHeight }); }); ro.observe(el); return () => ro.disconnect(); }, [open, tpl != null]); const boxesRef = useRef([]); const dragRef = useRef<{ idx: number; dxMm: number; dyMm: number } | null>(null); const stock = useMemo( () => stocks.find((s) => s.id === tpl?.stock_id) ?? stocks[0], [stocks, tpl?.stock_id]); const refresh = useCallback(async () => { try { setStocks((await LabelListStocks()) as any as LabelStock[]); setSaved(((await LabelListTemplates()) ?? []) as any as TplInfo[]); } catch (e: any) { setError(String(e?.message ?? e)); } }, []); useEffect(() => { if (!open) return; setError(''); setTpl(null); setTplId(0); setSel(null); setDirty(false); void refresh(); LabelSampleQSOs(5).then((r: any) => setSamples(r ?? [])).catch(() => {}); // The preview resolves from the active profile so the address label // reads like the finished sticker, not like a form. GetActiveProfile().then((p: any) => setMyVars({ MYCALL: p?.callsign ?? 'MYCALL', MYNAME: p?.op_name ?? p?.operator ?? '', MYSTREET: p?.my_street ?? '', MYZIP: p?.my_postal_code ?? '', MYCITY: p?.my_city ?? '', MYCOUNTRY: p?.my_country ?? '', })).catch(() => setMyVars({ MYCALL: 'MYCALL' })); }, [open, refresh]); // Preview variable values: the first sample QSO plays the DX station. const vars = useMemo(() => { const q = samples[0]; return { CALL: q?.callsign ?? 'DL1ABC', NAME: q?.name ?? 'Hans', QTH: q?.qth ?? 'Berlin', COUNTRY: q?.country ?? 'Germany', VIA: 'DJ5XX', STREET: 'Funkerstrasse 12', ZIP: '10115', CITY: q?.qth || 'Berlin', STATE: '', ...myVars, } as Record; }, [samples, myVars]); // ── canvas ──────────────────────────────────────────────────────────── const scale = useMemo(() => { if (!stock) return 4; // Leave room for the pane's padding and the two control strips above and // below the canvas; never shrink below a scale where 8 pt is readable. const availW = Math.max(200, paneSize.w - 56); const availH = Math.max(120, paneSize.h - 130); return Math.max(PREVIEW_MIN_SCALE, Math.min(availW / stock.w_mm, availH / stock.h_mm)); }, [stock, paneSize]); const paint = useCallback(() => { const cv = canvasRef.current; if (!cv || !tpl || !stock) return; const dpr = window.devicePixelRatio || 1; cv.width = Math.round(stock.w_mm * scale * dpr); cv.height = Math.round(stock.h_mm * scale * dpr); cv.style.width = `${stock.w_mm * scale}px`; cv.style.height = `${stock.h_mm * scale}px`; const ctx = cv.getContext('2d'); if (!ctx) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); boxesRef.current = render(ctx, tpl, stock, { vars, qsos: samples }, scale); drawMargins(ctx, stock, scale); // Selection outline, drawn after everything: the editor's own chrome. if (sel != null && boxesRef.current[sel]) { const b = boxesRef.current[sel]; ctx.save(); ctx.scale(scale, scale); ctx.strokeStyle = 'rgba(255,140,0,0.9)'; ctx.lineWidth = 0.3; ctx.setLineDash([1, 0.8]); ctx.strokeRect(b.x - 0.6, b.y - 0.6, b.w + 1.2, b.h + 1.2); ctx.restore(); } }, [tpl, stock, vars, samples, scale, sel]); useEffect(() => { paint(); }, [paint]); const mmFromEvent = (ev: React.MouseEvent): { x: number; y: number } => { const r = canvasRef.current!.getBoundingClientRect(); return { x: (ev.clientX - r.left) / scale, y: (ev.clientY - r.top) / scale }; }; const onCanvasDown = (ev: React.MouseEvent) => { if (!tpl) return; const p = mmFromEvent(ev); // Topmost element under the pointer wins — iterate backwards. for (let i = boxesRef.current.length - 1; i >= 0; i--) { const b = boxesRef.current[i]; if (p.x >= b.x && p.x <= b.x + b.w && p.y >= b.y && p.y <= b.y + b.h) { setSel(i); dragRef.current = { idx: i, dxMm: p.x - tpl.elements[i].x_mm, dyMm: p.y - tpl.elements[i].y_mm }; return; } } setSel(null); }; const onCanvasMove = (ev: React.MouseEvent) => { const d = dragRef.current; if (!d || !tpl) return; const p = mmFromEvent(ev); // Snapped to 0.5 mm: free pixels look precise on screen and print ragged. const x = Math.round((p.x - d.dxMm) * 2) / 2; const y = Math.round((p.y - d.dyMm) * 2) / 2; patchEl(d.idx, { x_mm: x, y_mm: y }); }; const onCanvasUp = () => { dragRef.current = null; }; // ── template edits ──────────────────────────────────────────────────── function patchEl(idx: number, patch: Partial) { setTpl((cur) => { if (!cur) return cur; const elements = cur.elements.map((e, i) => (i === idx ? { ...e, ...patch } : e)); return { ...cur, elements }; }); setDirty(true); } function addElement(e: LabelElement) { setTpl((cur) => (cur ? { ...cur, elements: [...cur.elements, e] } : cur)); setSel(tpl ? tpl.elements.length : 0); setDirty(true); } function removeSelected() { if (sel == null) return; setTpl((cur) => (cur ? { ...cur, elements: cur.elements.filter((_, i) => i !== sel) } : cur)); setSel(null); setDirty(true); } function newTemplate(kind: 'qso' | 'address') { const s = stocks[0]; if (!s) { setError(t('lbl.noStock')); return; } setTpl(starterTemplate(kind, s)); setTplId(0); setTplName(kind === 'qso' ? t('lbl.newQsoName') : t('lbl.newAddrName')); setSel(null); setDirty(true); } async function openTemplate(info: TplInfo) { try { const doc = await LabelGetTemplate(info.id); setTpl(JSON.parse(doc) as LabelTemplate); setTplId(info.id); setTplName(info.name); setSel(null); setDirty(false); } catch (e: any) { setError(String(e?.message ?? e)); } } async function save() { if (!tpl) return; try { const id = await LabelSaveTemplate(tplId, tplName.trim() || 'Label', JSON.stringify(tpl), forProfile); setTplId(id as number); setDirty(false); await refresh(); } catch (e: any) { setError(String(e?.message ?? e)); } } async function removeTemplate(id: number) { if (deleteArm !== id) { setDeleteArm(id); window.setTimeout(() => setDeleteArm(0), 2500); return; } try { await LabelDeleteTemplate(id); if (id === tplId) { setTpl(null); setTplId(0); } await refresh(); } catch (e: any) { setError(String(e?.message ?? e)); } } // ── stock editor ────────────────────────────────────────────────────── async function saveStock() { if (!stockDraft) return; try { await LabelSaveStock(stockDraft as any); setStockDraft(null); await refresh(); } catch (e: any) { setError(String(e?.message ?? e)); } } if (!open) return null; const e = sel != null && tpl ? tpl.elements[sel] : undefined; const numField = (label: string, value: number, set: (v: number) => void, step = 0.5, w = 'w-20') => (
set(parseFloat(ev.target.value) || 0)} />
); return (
{/* header */}
{t('lbl.title')}
{tpl && ( <> { setTplName(ev.target.value); setDirty(true); }} placeholder={t('lbl.namePh')} /> )}
{error &&
{error}
}
{/* left: saved templates + stocks */}
{saved.map((s) => (
void openTemplate(s)}>
{s.kind === 'qso' ? : } {s.name}
{s.kind === 'qso' ? t('lbl.kindQso') : t('lbl.kindAddr')} · {s.updated_at}
))} {saved.length === 0 && (
{t('lbl.empty')}
)}
{/* stocks */}
{stockOpen && (
{stockDraft?.id ? ( ) : null}
{stockDraft && (
setStockDraft({ ...stockDraft, name: ev.target.value })} />
{numField(t('lbl.widthMm'), stockDraft.w_mm, (v) => setStockDraft({ ...stockDraft, w_mm: v }), 0.5, 'w-full')} {numField(t('lbl.heightMm'), stockDraft.h_mm, (v) => setStockDraft({ ...stockDraft, h_mm: v }), 0.5, 'w-full')}
{numField('↑', stockDraft.margin_top_mm, (v) => setStockDraft({ ...stockDraft, margin_top_mm: v }), 0.5, 'w-full')} {numField('→', stockDraft.margin_right_mm, (v) => setStockDraft({ ...stockDraft, margin_right_mm: v }), 0.5, 'w-full')} {numField('↓', stockDraft.margin_bottom_mm, (v) => setStockDraft({ ...stockDraft, margin_bottom_mm: v }), 0.5, 'w-full')} {numField('←', stockDraft.margin_left_mm, (v) => setStockDraft({ ...stockDraft, margin_left_mm: v }), 0.5, 'w-full')}
)}
)}
{/* centre: preview */}
{tpl && stock ? ( <>
{t('lbl.stock')}: {stock.w_mm}×{stock.h_mm} mm
{tpl.kind === 'address' && ( )} {tpl.kind === 'qso' && !tpl.elements.some((x) => x.type === 'qso_table') && ( )} {sel != null && ( )}
) : (
{t('lbl.intro')}
)}
{/* right: properties of the selection */} {tpl && (
{!e ? (
{t('lbl.selectHint')}
) : ( <>
{t(`lbl.el_${e.type}` as any)}
{numField('X mm', e.x_mm, (v) => patchEl(sel!, { x_mm: v }))} {numField('Y mm', e.y_mm, (v) => patchEl(sel!, { y_mm: v }))} {(e.type === 'text' || e.type === 'line' || e.type === 'qso_table') && numField('W mm', e.w_mm ?? 0, (v) => patchEl(sel!, { w_mm: v }))}
{e.type === 'text' && ( <>
patchEl(sel!, { text: ev.target.value })} />
patchEl(sel!, { text: `${e.text ?? ''}<${v}>` })} t={t} />
{numField('pt', e.size_pt ?? 9, (v) => patchEl(sel!, { size_pt: v }), 0.5, 'w-16')}
)} {e.type === 'line' && numField(t('lbl.thickness'), e.thickness_mm ?? 0.3, (v) => patchEl(sel!, { thickness_mm: v }), 0.1)} {e.type === 'addr_block' && ( <>