feat(labels): the Label Designer — QSO and address labels for paper QSL
The designer's data model lives in internal/labels: STOCKS are the physical roll in the printer (geometry in mm, margins, dpi — seeded with the common Brother DK sizes, the QL family being what prompted the feature), TEMPLATES are one design each, of two kinds: the QSO label glued on the card, whose repeating table carries several contacts of the same station, and the address label for the envelope, whose lines collapse when a variable is empty. Everything is measured in millimetres — labels are sold in mm, and an operator lining a design up against a physical sticker thinks in mm; pixels exist only in the renderer, at the stock's dpi. The canvas renderer is shared between the editor's preview and the future print path, so there is no second implementation for the preview to disagree with. The preview is fed with the log's latest contacts rather than lorem ipsum: real data shows a too-narrow column immediately. Printing (PDF, one page per label at exact size) is the next module; nothing here prints yet.
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
const PREVIEW_MAX_W = 720;
|
||||
const PREVIEW_MAX_H = 420;
|
||||
|
||||
export function LabelDesignerModal({ open, onClose }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [stocks, setStocks] = useState<LabelStock[]>([]);
|
||||
const [saved, setSaved] = useState<TplInfo[]>([]);
|
||||
const [samples, setSamples] = useState<LabelSample[]>([]);
|
||||
const [myVars, setMyVars] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// The design being edited (null = nothing open yet).
|
||||
const [tplId, setTplId] = useState(0);
|
||||
const [tpl, setTpl] = useState<LabelTemplate | null>(null);
|
||||
const [tplName, setTplName] = useState('');
|
||||
const [forProfile, setForProfile] = useState(true);
|
||||
const [sel, setSel] = useState<number | null>(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<LabelStock | null>(null);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const boxesRef = useRef<ElementBox[]>([]);
|
||||
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 <MY*> 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<string, string>;
|
||||
}, [samples, myVars]);
|
||||
|
||||
// ── canvas ────────────────────────────────────────────────────────────
|
||||
const scale = useMemo(() => {
|
||||
if (!stock) return 4;
|
||||
return Math.min(PREVIEW_MAX_W / stock.w_mm, PREVIEW_MAX_H / stock.h_mm);
|
||||
}, [stock]);
|
||||
|
||||
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<LabelElement>) {
|
||||
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') => (
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px] text-muted-foreground">{label}</Label>
|
||||
<Input type="number" step={step} value={value} className={cn('h-7 text-xs font-mono', w)}
|
||||
onChange={(ev) => set(parseFloat(ev.target.value) || 0)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4">
|
||||
<div className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-6xl h-[92vh] flex flex-col overflow-hidden">
|
||||
{/* header */}
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-border shrink-0">
|
||||
<Tag className="size-4 text-primary" />
|
||||
<span className="font-semibold text-sm">{t('lbl.title')}</span>
|
||||
<div className="flex-1" />
|
||||
{tpl && (
|
||||
<>
|
||||
<Input className="h-8 w-56 text-sm" value={tplName} onChange={(ev) => { setTplName(ev.target.value); setDirty(true); }}
|
||||
placeholder={t('lbl.namePh')} />
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||
<Checkbox checked={forProfile} onCheckedChange={(c) => setForProfile(!!c)} />
|
||||
{t('lbl.forProfile')}
|
||||
</label>
|
||||
<Button size="sm" className="h-8" onClick={save} disabled={!dirty && tplId !== 0}>
|
||||
{t('lbl.save')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={onClose}><X className="size-4" /></Button>
|
||||
</div>
|
||||
{error && <div className="px-4 py-1.5 text-xs text-danger border-b border-border/60">{error}</div>}
|
||||
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
{/* left: saved templates + stocks */}
|
||||
<div className="w-64 border-r border-border flex flex-col min-h-0 shrink-0">
|
||||
<div className="p-2 flex gap-1.5">
|
||||
<Button size="sm" variant="outline" className="h-8 flex-1" onClick={() => newTemplate('qso')}>
|
||||
<Plus className="size-3.5" /> {t('lbl.newQso')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-8 flex-1" onClick={() => newTemplate('address')}>
|
||||
<Plus className="size-3.5" /> {t('lbl.newAddr')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-2 space-y-1">
|
||||
{saved.map((s) => (
|
||||
<div key={s.id}
|
||||
className={cn('rounded-md border px-2 py-1.5 cursor-pointer text-xs',
|
||||
s.id === tplId ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/50')}
|
||||
onClick={() => void openTemplate(s)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{s.kind === 'qso' ? <Tag className="size-3 shrink-0 text-muted-foreground" /> : <MapPin className="size-3 shrink-0 text-muted-foreground" />}
|
||||
<span className="font-medium truncate flex-1">{s.name}</span>
|
||||
<button type="button" title={t('lbl.setDefault')}
|
||||
onClick={(ev) => { ev.stopPropagation(); void LabelSetDefaultTemplate(s.id).then(refresh); }}>
|
||||
<Star className={cn('size-3.5', s.is_default ? 'text-warning fill-warning' : 'text-muted-foreground/40')} />
|
||||
</button>
|
||||
<button type="button" title={t('lbl.delete')}
|
||||
onClick={(ev) => { ev.stopPropagation(); void removeTemplate(s.id); }}>
|
||||
<Trash2 className={cn('size-3.5', deleteArm === s.id ? 'text-danger' : 'text-muted-foreground/40')} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground pl-4">
|
||||
{s.kind === 'qso' ? t('lbl.kindQso') : t('lbl.kindAddr')} · {s.updated_at}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{saved.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground px-1 py-3">{t('lbl.empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* stocks */}
|
||||
<div className="border-t border-border p-2 space-y-1.5">
|
||||
<button type="button" className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
onClick={() => setStockOpen((v) => !v)}>
|
||||
{t('lbl.stocks')} {stockOpen ? '▾' : '▸'}
|
||||
</button>
|
||||
{stockOpen && (
|
||||
<div className="space-y-1.5">
|
||||
<Select value={String(stockDraft?.id ?? '')} onValueChange={(v) => {
|
||||
const s = stocks.find((x) => String(x.id) === v);
|
||||
if (s) setStockDraft({ ...s });
|
||||
}}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder={t('lbl.pickStock')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{stocks.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs flex-1"
|
||||
onClick={() => setStockDraft({ name: t('lbl.customStock'), w_mm: 90, h_mm: 29, margin_top_mm: 1.5, margin_right_mm: 3, margin_bottom_mm: 1.5, margin_left_mm: 3, dpi: 300 })}>
|
||||
<Plus className="size-3" /> {t('lbl.newStock')}
|
||||
</Button>
|
||||
{stockDraft?.id ? (
|
||||
<Button size="sm" variant="ghost" className="h-7 text-xs text-danger"
|
||||
onClick={() => { void LabelDeleteStock(stockDraft.id!).then(() => { setStockDraft(null); return refresh(); }); }}>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{stockDraft && (
|
||||
<div className="space-y-1.5">
|
||||
<Input className="h-7 text-xs" value={stockDraft.name}
|
||||
onChange={(ev) => setStockDraft({ ...stockDraft, name: ev.target.value })} />
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{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')}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{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')}
|
||||
</div>
|
||||
<Button size="sm" className="h-7 text-xs w-full" onClick={() => void saveStock()}>{t('lbl.saveStock')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* centre: preview */}
|
||||
<div className="flex-1 min-w-0 flex flex-col items-center justify-center bg-muted/30 overflow-auto p-6 gap-3">
|
||||
{tpl && stock ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{t('lbl.stock')}:</span>
|
||||
<Select value={String(tpl.stock_id || stock.id)} onValueChange={(v) => {
|
||||
setTpl({ ...tpl, stock_id: parseInt(v, 10) }); setDirty(true);
|
||||
}}>
|
||||
<SelectTrigger className="h-7 text-xs w-72"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{stocks.map((s) => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="font-mono">{stock.w_mm}×{stock.h_mm} mm</span>
|
||||
</div>
|
||||
<canvas ref={canvasRef} className="shadow-lg rounded-sm cursor-move"
|
||||
onMouseDown={onCanvasDown} onMouseMove={onCanvasMove}
|
||||
onMouseUp={onCanvasUp} onMouseLeave={onCanvasUp} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs"
|
||||
onClick={() => addElement({ type: 'text', x_mm: stock.margin_left_mm, y_mm: stock.h_mm / 2, text: t('lbl.newText'), size_pt: 9 })}>
|
||||
<Plus className="size-3" /> {t('lbl.addText')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs"
|
||||
onClick={() => addElement({ type: 'line', x_mm: stock.margin_left_mm, y_mm: stock.h_mm / 2, w_mm: stock.w_mm - stock.margin_left_mm - stock.margin_right_mm, thickness_mm: 0.3 })}>
|
||||
<Plus className="size-3" /> {t('lbl.addLine')}
|
||||
</Button>
|
||||
{tpl.kind === 'address' && (
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs"
|
||||
onClick={() => addElement({ type: 'addr_block', x_mm: stock.margin_left_mm + 2, y_mm: stock.margin_top_mm + 2, lines: ['<NAME>', '<STREET>', '<ZIP> <CITY>', '<COUNTRY>'], size_pt: 11, line_gap_mm: 1.6 })}>
|
||||
<Plus className="size-3" /> {t('lbl.addAddr')}
|
||||
</Button>
|
||||
)}
|
||||
{tpl.kind === 'qso' && !tpl.elements.some((x) => x.type === 'qso_table') && (
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs"
|
||||
onClick={() => addElement({ type: 'qso_table', x_mm: stock.margin_left_mm, y_mm: stock.margin_top_mm + 7, w_mm: stock.w_mm - stock.margin_left_mm - stock.margin_right_mm, rows_max: 4, row_h_mm: 4, header: true, size_pt: 7.5, columns: TABLE_FIELDS.slice(0, 6).map((c) => ({ ...c })) })}>
|
||||
<Plus className="size-3" /> {t('lbl.addTable')}
|
||||
</Button>
|
||||
)}
|
||||
{sel != null && (
|
||||
<Button size="sm" variant="ghost" className="h-7 text-xs text-danger" onClick={removeSelected}>
|
||||
<Trash2 className="size-3" /> {t('lbl.removeEl')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground text-center max-w-sm leading-relaxed">
|
||||
{t('lbl.intro')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* right: properties of the selection */}
|
||||
{tpl && (
|
||||
<div className="w-72 border-l border-border overflow-y-auto p-3 space-y-3 shrink-0">
|
||||
{!e ? (
|
||||
<div className="text-xs text-muted-foreground leading-relaxed">{t('lbl.selectHint')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t(`lbl.el_${e.type}` as any)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{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 }))}
|
||||
</div>
|
||||
|
||||
{e.type === 'text' && (
|
||||
<>
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px] text-muted-foreground">{t('lbl.text')}</Label>
|
||||
<Input className="h-7 text-xs" value={e.text ?? ''} onChange={(ev) => patchEl(sel!, { text: ev.target.value })} />
|
||||
</div>
|
||||
<VarPicker onPick={(v) => patchEl(sel!, { text: `${e.text ?? ''}<${v}>` })} t={t} />
|
||||
<div className="flex gap-2 items-end">
|
||||
{numField('pt', e.size_pt ?? 9, (v) => patchEl(sel!, { size_pt: v }), 0.5, 'w-16')}
|
||||
<label className="flex items-center gap-1 text-xs cursor-pointer pb-1.5">
|
||||
<Checkbox checked={!!e.bold} onCheckedChange={(c) => patchEl(sel!, { bold: !!c })} /> B
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-xs italic cursor-pointer pb-1.5">
|
||||
<Checkbox checked={!!e.italic} onCheckedChange={(c) => patchEl(sel!, { italic: !!c })} /> I
|
||||
</label>
|
||||
<Select value={e.align ?? 'left'} onValueChange={(v) => patchEl(sel!, { align: v as any })}>
|
||||
<SelectTrigger className="h-7 text-xs w-24"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">{t('lbl.alignLeft')}</SelectItem>
|
||||
<SelectItem value="center">{t('lbl.alignCenter')}</SelectItem>
|
||||
<SelectItem value="right">{t('lbl.alignRight')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{e.type === 'line' && numField(t('lbl.thickness'), e.thickness_mm ?? 0.3, (v) => patchEl(sel!, { thickness_mm: v }), 0.1)}
|
||||
|
||||
{e.type === 'addr_block' && (
|
||||
<>
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-[10px] text-muted-foreground">{t('lbl.addrLines')}</Label>
|
||||
<textarea
|
||||
className="w-full h-28 rounded-md border border-input bg-background px-2 py-1 text-xs font-mono"
|
||||
value={(e.lines ?? []).join('\n')}
|
||||
onChange={(ev) => patchEl(sel!, { lines: ev.target.value.split('\n') })} />
|
||||
<div className="text-[10px] text-muted-foreground">{t('lbl.addrHint')}</div>
|
||||
</div>
|
||||
<VarPicker onPick={(v) => patchEl(sel!, { lines: [...(e.lines ?? []), `<${v}>`] })} t={t} />
|
||||
<div className="flex gap-2">
|
||||
{numField('pt', e.size_pt ?? 11, (v) => patchEl(sel!, { size_pt: v }), 0.5, 'w-16')}
|
||||
{numField(t('lbl.lineGap'), e.line_gap_mm ?? 1.5, (v) => patchEl(sel!, { line_gap_mm: v }), 0.1, 'w-20')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{e.type === 'qso_table' && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
{numField(t('lbl.rows'), e.rows_max ?? 4, (v) => patchEl(sel!, { rows_max: Math.max(1, Math.round(v)) }), 1, 'w-16')}
|
||||
{numField(t('lbl.rowH'), e.row_h_mm ?? 4, (v) => patchEl(sel!, { row_h_mm: v }), 0.1, 'w-20')}
|
||||
{numField('pt', e.size_pt ?? 7.5, (v) => patchEl(sel!, { size_pt: v }), 0.5, 'w-16')}
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||
<Checkbox checked={!!e.header} onCheckedChange={(c) => patchEl(sel!, { header: !!c })} />
|
||||
{t('lbl.header')}
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">{t('lbl.columns')}</Label>
|
||||
{(e.columns ?? []).map((c, ci) => (
|
||||
<div key={ci} className="flex items-center gap-1">
|
||||
<Select value={c.field} onValueChange={(v) => {
|
||||
const f = TABLE_FIELDS.find((x) => x.field === v);
|
||||
const columns = e.columns!.map((x, i) => (i === ci ? { ...x, field: v, label: f?.label ?? v } : x));
|
||||
patchEl(sel!, { columns });
|
||||
}}>
|
||||
<SelectTrigger className="h-6 text-[11px] flex-1"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{TABLE_FIELDS.map((f) => <SelectItem key={f.field} value={f.field}>{f.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input type="number" step={0.5} className="h-6 w-14 text-[11px] font-mono" value={c.w_mm}
|
||||
onChange={(ev) => {
|
||||
const columns = e.columns!.map((x, i) => (i === ci ? { ...x, w_mm: parseFloat(ev.target.value) || 1 } : x));
|
||||
patchEl(sel!, { columns });
|
||||
}} />
|
||||
<button type="button" onClick={() => patchEl(sel!, { columns: e.columns!.filter((_, i) => i !== ci) })}>
|
||||
<Trash2 className="size-3 text-muted-foreground/60" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button size="sm" variant="outline" className="h-6 text-[11px] w-full"
|
||||
onClick={() => patchEl(sel!, { columns: [...(e.columns ?? []), { ...TABLE_FIELDS[0] }] })}>
|
||||
<Plus className="size-3" /> {t('lbl.addColumn')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// VarPicker inserts a <VARIABLE> — a menu beats remembering the exact spelling.
|
||||
function VarPicker({ onPick, t }: { onPick: (v: string) => void; t: (k: string) => string }) {
|
||||
return (
|
||||
<Select value="" onValueChange={(v) => { if (v) onPick(v); }}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder={t('lbl.insertVar')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{LABEL_VARS.map((v) => <SelectItem key={v} value={v}>{`<${v}>`}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// labelRender draws a label template onto a canvas. It is THE renderer: the
|
||||
// designer's preview and (later) the pages rasterised into the print PDF both
|
||||
// come through here, which is what makes the preview trustworthy — there is no
|
||||
// second implementation to disagree with it.
|
||||
//
|
||||
// The canvas is scaled so that 1 mm = `scale` px; print passes dpi/25.4, the
|
||||
// preview passes whatever fits its box. All layout maths stays in mm.
|
||||
|
||||
import type { LabelElement, LabelSample, LabelStock, LabelTemplate } from './labelTypes';
|
||||
|
||||
export interface RenderData {
|
||||
// Variable values for text/address elements (<CALL> → value). Missing keys
|
||||
// render as the bare <NAME> so the designer can SEE an unresolved variable.
|
||||
vars: Record<string, string>;
|
||||
// Rows for the qso_table element.
|
||||
qsos: LabelSample[];
|
||||
}
|
||||
|
||||
export interface ElementBox { x: number; y: number; w: number; h: number } // mm
|
||||
|
||||
const PT_TO_MM = 25.4 / 72;
|
||||
|
||||
// resolveVars substitutes <VAR> markers, collapsing to '' only when the value
|
||||
// is known-empty; unknown markers stay visible on purpose.
|
||||
export function resolveVars(text: string, vars: Record<string, string>): string {
|
||||
return text.replace(/<([A-Z_]+)>/g, (m, k) => (k in vars ? vars[k] : m));
|
||||
}
|
||||
|
||||
// render draws the whole label and returns each element's bounding box in mm —
|
||||
// the editor's hit-testing works off these, so a drag grabs exactly what was
|
||||
// painted, table rows included.
|
||||
export function render(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
t: LabelTemplate,
|
||||
stock: LabelStock,
|
||||
data: RenderData,
|
||||
scale: number,
|
||||
): ElementBox[] {
|
||||
const W = stock.w_mm, H = stock.h_mm;
|
||||
ctx.save();
|
||||
ctx.scale(scale, scale);
|
||||
// The physical label: white, whatever the app theme — this is paper.
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.fillStyle = '#000';
|
||||
|
||||
// The canvas thinks in mm from here on; fonts are set per element in pt and
|
||||
// drawn with an unscaled-pt trick: setTransform back to px for text quality.
|
||||
const boxes: ElementBox[] = [];
|
||||
for (const e of t.elements) {
|
||||
boxes.push(drawElement(ctx, e, t, data, scale));
|
||||
}
|
||||
ctx.restore();
|
||||
return boxes;
|
||||
}
|
||||
|
||||
// drawMargins paints the unprintable border as a dashed guide — editor only,
|
||||
// never part of a print rasterisation.
|
||||
export function drawMargins(ctx: CanvasRenderingContext2D, stock: LabelStock, scale: number): void {
|
||||
ctx.save();
|
||||
ctx.scale(scale, scale);
|
||||
ctx.strokeStyle = 'rgba(80,140,255,0.55)';
|
||||
ctx.lineWidth = 0.15;
|
||||
ctx.setLineDash([1.2, 1.2]);
|
||||
ctx.strokeRect(
|
||||
stock.margin_left_mm, stock.margin_top_mm,
|
||||
stock.w_mm - stock.margin_left_mm - stock.margin_right_mm,
|
||||
stock.h_mm - stock.margin_top_mm - stock.margin_bottom_mm,
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawText(
|
||||
ctx: CanvasRenderingContext2D, text: string, xMm: number, yTopMm: number,
|
||||
wMm: number | undefined, sizePt: number, scale: number,
|
||||
opts: { bold?: boolean; italic?: boolean; align?: string; face?: string },
|
||||
): number {
|
||||
// Text is drawn in PX space (resetting the mm scale) so the browser rasterises
|
||||
// the font at device resolution instead of scaling a 1-mm-tall glyph up.
|
||||
const hMm = sizePt * PT_TO_MM;
|
||||
ctx.save();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
// The font is specified in px = (pt → mm) × scale, so a 10 pt line measures
|
||||
// exactly 10 pt on the printed label whatever the preview zoom is.
|
||||
ctx.font = `${opts.italic ? 'italic ' : ''}${opts.bold ? 'bold ' : ''}${hMm * scale}px ${opts.face && opts.face.trim() ? opts.face : 'Arial, Helvetica, sans-serif'}`;
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.textBaseline = 'top';
|
||||
let x = xMm * scale;
|
||||
if (wMm && opts.align === 'center') {
|
||||
ctx.textAlign = 'center';
|
||||
x = (xMm + wMm / 2) * scale;
|
||||
} else if (wMm && opts.align === 'right') {
|
||||
ctx.textAlign = 'right';
|
||||
x = (xMm + wMm) * scale;
|
||||
} else {
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
ctx.fillText(text, x, yTopMm * scale);
|
||||
ctx.restore();
|
||||
return hMm;
|
||||
}
|
||||
|
||||
function drawElement(
|
||||
ctx: CanvasRenderingContext2D, e: LabelElement, t: LabelTemplate,
|
||||
data: RenderData, scale: number,
|
||||
): ElementBox {
|
||||
switch (e.type) {
|
||||
case 'text': {
|
||||
const size = e.size_pt || 9;
|
||||
const text = resolveVars(e.text ?? '', data.vars);
|
||||
drawText(ctx, text, e.x_mm, e.y_mm, e.w_mm, size, scale,
|
||||
{ bold: e.bold, italic: e.italic, align: e.align, face: t.font });
|
||||
const h = size * PT_TO_MM * 1.25;
|
||||
return { x: e.x_mm, y: e.y_mm, w: e.w_mm || Math.max(20, text.length * size * PT_TO_MM * 0.55), h };
|
||||
}
|
||||
case 'line': {
|
||||
const th = e.thickness_mm || 0.3;
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(e.x_mm, e.y_mm, e.w_mm || 10, th);
|
||||
// A hairline is a 0.3 mm target nobody can grab; the box pads it.
|
||||
return { x: e.x_mm, y: e.y_mm - 0.8, w: e.w_mm || 10, h: th + 1.6 };
|
||||
}
|
||||
case 'addr_block': {
|
||||
const size = e.size_pt || 11;
|
||||
const gap = e.line_gap_mm ?? 1.5;
|
||||
// Collapse-empty is the point of the block: a missing street must pull
|
||||
// the city UP, not leave a hole in the middle of an address.
|
||||
const lines = (e.lines ?? [])
|
||||
.map((l) => resolveVars(l, data.vars).trim())
|
||||
.filter((l) => l !== '');
|
||||
let y = e.y_mm;
|
||||
let maxW = 0;
|
||||
for (const l of lines) {
|
||||
const h = drawText(ctx, l, e.x_mm, y, undefined, size, scale,
|
||||
{ bold: e.bold, italic: e.italic, face: t.font });
|
||||
y += h + gap;
|
||||
maxW = Math.max(maxW, l.length * size * PT_TO_MM * 0.55);
|
||||
}
|
||||
return { x: e.x_mm, y: e.y_mm, w: Math.max(20, maxW), h: Math.max(4, y - e.y_mm) };
|
||||
}
|
||||
case 'qso_table': {
|
||||
const cols = e.columns ?? [];
|
||||
const size = e.size_pt || 7.5;
|
||||
const rowH = e.row_h_mm || 4;
|
||||
const wTot = cols.reduce((a, c) => a + c.w_mm, 0);
|
||||
let y = e.y_mm;
|
||||
if (e.header) {
|
||||
let x = e.x_mm;
|
||||
for (const c of cols) {
|
||||
drawText(ctx, c.label, x + 0.6, y + (rowH - size * PT_TO_MM) / 2, c.w_mm - 1.2, size, scale,
|
||||
{ bold: true, align: c.align, face: t.font });
|
||||
x += c.w_mm;
|
||||
}
|
||||
// Rule under the header, not a full grid: on a 29 mm label the grid IS
|
||||
// the noise.
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(e.x_mm, y + rowH - 0.25, wTot, 0.25);
|
||||
y += rowH;
|
||||
}
|
||||
const rows = data.qsos.slice(0, e.rows_max || 4);
|
||||
for (const q of rows) {
|
||||
let x = e.x_mm;
|
||||
for (const c of cols) {
|
||||
const v = String((q as any)[c.field] ?? '');
|
||||
drawText(ctx, v, x + 0.6, y + (rowH - size * PT_TO_MM) / 2, c.w_mm - 1.2, size, scale,
|
||||
{ align: c.align, face: t.font });
|
||||
x += c.w_mm;
|
||||
}
|
||||
y += rowH;
|
||||
}
|
||||
return { x: e.x_mm, y: e.y_mm, w: wTot, h: y - e.y_mm };
|
||||
}
|
||||
}
|
||||
return { x: e.x_mm, y: e.y_mm, w: 10, h: 4 };
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// TypeScript mirror of the label template schema (v1) defined in
|
||||
// internal/labels/labels.go. Documents cross the Wails boundary as JSON
|
||||
// strings; these types are the frontend's contract with that schema.
|
||||
//
|
||||
// Everything is in MILLIMETRES — pixels only exist inside labelRender, at the
|
||||
// stock's dpi. See the Go package comment for why.
|
||||
|
||||
export interface LabelStock {
|
||||
id?: number;
|
||||
name: string;
|
||||
w_mm: number;
|
||||
h_mm: number;
|
||||
margin_top_mm: number;
|
||||
margin_right_mm: number;
|
||||
margin_bottom_mm: number;
|
||||
margin_left_mm: number;
|
||||
dpi: number;
|
||||
}
|
||||
|
||||
export interface LabelColumn {
|
||||
field: string;
|
||||
label: string;
|
||||
w_mm: number;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
export interface LabelElement {
|
||||
type: 'text' | 'line' | 'qso_table' | 'addr_block';
|
||||
x_mm: number;
|
||||
y_mm: number;
|
||||
w_mm?: number;
|
||||
// text
|
||||
text?: string;
|
||||
size_pt?: number;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
// line
|
||||
thickness_mm?: number;
|
||||
// qso_table
|
||||
columns?: LabelColumn[];
|
||||
rows_max?: number;
|
||||
row_h_mm?: number;
|
||||
header?: boolean;
|
||||
// addr_block
|
||||
lines?: string[];
|
||||
line_gap_mm?: number;
|
||||
}
|
||||
|
||||
export interface LabelTemplate {
|
||||
version: 1;
|
||||
kind: 'qso' | 'address';
|
||||
name?: string;
|
||||
stock_id: number;
|
||||
font?: string;
|
||||
elements: LabelElement[];
|
||||
}
|
||||
|
||||
export interface LabelSample {
|
||||
callsign: string; qso_date: string; time_on: string; band: string;
|
||||
freq: string; mode: string; rst_sent: string; rst_rcvd: string;
|
||||
name: string; qth: string; country: string;
|
||||
}
|
||||
|
||||
// The QSO-table columns the designer offers, with default widths that fit their
|
||||
// content at 8 pt — the operator adjusts from there against real data.
|
||||
export const TABLE_FIELDS: { field: string; label: string; w_mm: number }[] = [
|
||||
{ field: 'qso_date', label: 'Date', w_mm: 17 },
|
||||
{ field: 'time_on', label: 'UTC', w_mm: 10 },
|
||||
{ field: 'band', label: 'Band', w_mm: 10 },
|
||||
{ field: 'freq', label: 'MHz', w_mm: 13 },
|
||||
{ field: 'mode', label: 'Mode', w_mm: 11 },
|
||||
{ field: 'rst_sent', label: 'RST', w_mm: 9 },
|
||||
{ field: 'rst_rcvd', label: 'RST rx', w_mm: 9 },
|
||||
];
|
||||
|
||||
// The variables a text or address element may carry. Resolution at PRINT time
|
||||
// uses the real QSO/profile; the designer resolves them against the sample so
|
||||
// the preview reads like a finished label.
|
||||
export const LABEL_VARS = [
|
||||
'CALL', 'NAME', 'QTH', 'COUNTRY', 'VIA',
|
||||
'STREET', 'ZIP', 'CITY', 'STATE',
|
||||
'MYCALL', 'MYNAME', 'MYSTREET', 'MYZIP', 'MYCITY', 'MYCOUNTRY',
|
||||
] as const;
|
||||
|
||||
// Starter documents for a fresh template — a usable label, not a blank page:
|
||||
// an empty canvas asks the operator to invent the feature, a finished-looking
|
||||
// starter only asks them to adjust it.
|
||||
export function starterTemplate(kind: 'qso' | 'address', stock: LabelStock): LabelTemplate {
|
||||
const w = stock.w_mm - stock.margin_left_mm - stock.margin_right_mm;
|
||||
const x = stock.margin_left_mm;
|
||||
if (kind === 'qso') {
|
||||
return {
|
||||
version: 1, kind, stock_id: stock.id ?? 0,
|
||||
elements: [
|
||||
{ type: 'text', x_mm: x, y_mm: stock.margin_top_mm + 1, w_mm: w, text: 'To Radio <CALL>', size_pt: 10, bold: true },
|
||||
{ type: 'line', x_mm: x, y_mm: stock.margin_top_mm + 6.2, w_mm: w, thickness_mm: 0.3 },
|
||||
{
|
||||
type: 'qso_table', x_mm: x, y_mm: stock.margin_top_mm + 7.5, w_mm: w,
|
||||
rows_max: Math.max(1, Math.min(5, Math.floor((stock.h_mm - stock.margin_top_mm - stock.margin_bottom_mm - 12) / 4))),
|
||||
row_h_mm: 4, header: true, size_pt: 7.5,
|
||||
columns: TABLE_FIELDS.slice(0, 6).map((c) => ({ ...c })),
|
||||
} as LabelElement,
|
||||
{ type: 'text', x_mm: x, y_mm: stock.h_mm - stock.margin_bottom_mm - 4, w_mm: w, text: 'PSE QSL · 73 de <MYCALL>', size_pt: 8 },
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 1, kind, stock_id: stock.id ?? 0,
|
||||
elements: [
|
||||
{
|
||||
type: 'addr_block', x_mm: x + 2, y_mm: stock.margin_top_mm + 3,
|
||||
lines: ['<NAME> · <CALL>', '<STREET>', '<ZIP> <CITY>', '<COUNTRY>'],
|
||||
size_pt: 11, line_gap_mm: 1.6,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user