A fixed 720 px cap cut a 98 mm label off at the RST column and wasted half the window; the scale now follows the centre pane's measured size, with a floor so small stock never renders unreadably.
556 lines
29 KiB
TypeScript
556 lines
29 KiB
TypeScript
// 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<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 paneRef = useRef<HTMLDivElement>(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<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;
|
||
// 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<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 ref={paneRef} 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>
|
||
);
|
||
}
|