This commit is contained in:
2026-06-13 01:34:45 +02:00
parent 408b29896c
commit 3cb2e466d8
21 changed files with 1285 additions and 130 deletions
+78 -10
View File
@@ -15,6 +15,10 @@ import { FONT_WEIGHTS } from './qslAssets';
// An editor selection: an element index or the QSO box.
export type CardSelection = number | 'box' | null;
// Fraction of the font size between a glyph's em-box top and its cap line.
// Used to make a text element's y align to the visible letter tops.
const CAP_INSET = 0.18;
interface Props {
model: RenderModel;
assets: CardAssets;
@@ -140,7 +144,8 @@ function TextStack({ e, idx, content }: { e: CardElement; idx: number; content:
const shadow = p.shadow ?? DEF_SHADOW;
const outline = p.outline_color ?? '#2a3f5c';
const outlineW = ow || 9;
const isGel = preset === 'gel_gold' || preset === 'gel_silver';
const isGrunge = preset === 'gel_gold_grunge' || preset === 'gel_silver_grunge';
const isGel = preset === 'gel_gold' || preset === 'gel_silver' || isGrunge;
const halo = p.halo ?? DEF_HALO;
const bevel = p.bevel_offset ?? DEF_BEVEL;
const shine = p.shine ?? DEF_SHINE;
@@ -179,6 +184,19 @@ function TextStack({ e, idx, content }: { e: CardElement; idx: number; content:
opacity={shine.opacity}
/>
)}
{/* Distressed/vintage speckle, clipped to the glyphs (HS0ZLE look). */}
{isGrunge && (
<rect
x={0} y={0}
width={0.82 * (e.size ?? 0) * Math.max(content.length, 1)}
height={(e.size ?? 0) * 1.05}
fill="#241405"
filter={`url(#qsl-grunge-${idx})`}
clipPath={`url(#qsl-clip-${idx})`}
transform={faceT}
opacity={0.6}
/>
)}
{/* defs that belong to this stack (unique ids per element index) */}
<defs>
<linearGradient id={`qsl-grad-${idx}`} x1="0" y1="0" x2="0" y2="1">
@@ -205,6 +223,14 @@ function TextStack({ e, idx, content }: { e: CardElement; idx: number; content:
</filter>
</>
)}
{isGrunge && (
<filter id={`qsl-grunge-${idx}`} x="-5%" y="-5%" width="110%" height="110%">
{/* sparse dark specks: fractal noise → threshold into the alpha */}
<feTurbulence type="fractalNoise" baseFrequency="0.14 0.2" numOctaves={2} seed={9} result="n" />
<feColorMatrix in="n" type="matrix"
values="0 0 0 0 0.14 0 0 0 0 0.08 0 0 0 0 0.02 0 0 0 6 -4.2" />
</filter>
)}
<filter id={`qsl-blur-sh-${idx}`} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation={shadow.blur} />
</filter>
@@ -213,9 +239,23 @@ function TextStack({ e, idx, content }: { e: CardElement; idx: number; content:
);
}
// QSO box field column weights — the date string is far wider than the rest,
// so equal columns let it spill into the next field. Wider weight = more room.
const QSO_FIELD_WEIGHT: Record<string, number> = {
qso_date: 2, time_on: 1, band: 1, mode: 1, rst_sent: 0.9, freq: 1.4, submode: 1.1,
};
// QSOBoxView renders the confirmation box with per-QSO values.
function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, string> }) {
const colW = (box.w - 56) / Math.max(box.fields.length, 1);
const avail = box.w - 56;
const total = box.fields.reduce((s, f) => s + (QSO_FIELD_WEIGHT[f] ?? 1), 0) || 1;
let cursor = 28;
const cols = box.fields.map((f) => {
const w = (avail * (QSO_FIELD_WEIGHT[f] ?? 1)) / total;
const col = { f, x: cursor, w };
cursor += w;
return col;
});
return (
<>
<rect width={box.w} height={box.h} rx={box.radius} fill={box.bg} opacity={box.bg_opacity} />
@@ -223,13 +263,13 @@ function QSOBoxView({ box, values }: { box: QSOBox; values: Record<string, strin
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{box.title}
</text>
{box.fields.map((f, i) => (
<g key={f} transform={`translate(${28 + i * colW} ${box.h * 0.42})`}>
{cols.map(({ f, x }) => (
<g key={f} transform={`translate(${Math.round(x)} ${box.h * 0.42})`}>
<text fontSize={19} fill="#6b7a8c" letterSpacing={1.5}
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{(QSO_FIELD_LABELS[f] ?? f).toUpperCase()}
</text>
<text y={26} fontSize={30} fontWeight={700} fill="#14243a"
<text y={26} fontSize={28} fontWeight={700} fill="#14243a"
fontFamily="'Segoe UI', Arial, sans-serif" dominantBaseline="text-before-edge">
{values[f] ?? ''}
</text>
@@ -253,17 +293,39 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove,
const groupRefs = useRef(new Map<string, SVGGElement>());
const [selBox, setSelBox] = useState<{ t: string; x: number; y: number; w: number; h: number } | null>(null);
const drag = useRef<{ sel: Exclude<CardSelection, null>; sx: number; sy: number; ox: number; oy: number } | null>(null);
const measureCanvas = useRef<HTMLCanvasElement | null>(null);
// Measure the selected group for the dashed selection outline.
// Measure the selected group for the dashed selection outline. For text we
// use the glyphs' real ink metrics — getBBox would span the font's full
// ascent/descent, leaving a tall dead band above the letters and a frame
// that doesn't hug the call.
useEffect(() => {
if (selected === null || selected === undefined) { setSelBox(null); return; }
const key = String(selected);
const g = groupRefs.current.get(key);
if (!g) { setSelBox(null); return; }
const b = g.getBBox();
const transform = g.getAttribute('transform') ?? '';
const el = typeof selected === 'number' ? t.elements[selected] : undefined;
if (el && (el.type === 'callsign' || el.type === 'operator' || el.type === 'info_line') && el.size) {
const cv = (measureCanvas.current ??= document.createElement('canvas'));
const ctx = cv.getContext('2d');
if (ctx) {
const f = fontSpec(el.font);
ctx.font = `${f.weight} ${el.size}px ${f.family}`;
const m = ctx.measureText(el.text ?? '');
const ascent = m.fontBoundingBoxAscent ?? el.size * 0.8;
const baselineY = -el.size * CAP_INSET + ascent; // -CAP_INSET matches the render's inner shift
const inkTop = baselineY - (m.actualBoundingBoxAscent ?? el.size * 0.7);
const inkBottom = baselineY + (m.actualBoundingBoxDescent ?? 0);
const left = -(m.actualBoundingBoxLeft ?? 0);
const right = m.actualBoundingBoxRight ?? m.width;
setSelBox({ t: transform, x: left, y: inkTop, w: right - left, h: inkBottom - inkTop });
return;
}
}
const b = g.getBBox();
setSelBox({ t: transform, x: b.x, y: b.y, w: b.width, h: b.height });
}, [selected, model, assets, width]);
}, [selected, model, assets, width, t.elements]);
const startDrag = (sel: Exclude<CardSelection, null>, ox: number, oy: number) =>
(ev: React.PointerEvent<SVGGElement>) => {
@@ -331,6 +393,7 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove,
{t.elements.map((e, idx) => {
const key = String(idx);
if (e.hidden) return null; // toggled off in the editor
if (e.type === 'insert') {
const ph = assets.photos[e.photo ?? ''];
if (!ph || !e.w) return null;
@@ -372,14 +435,19 @@ export function CardPreview({ model, assets, width, selected, onSelect, onMove,
</g>
);
}
// text elements
// Text elements. The inner translate pulls the glyphs up by the em-box
// top→cap-height gap so the element's y means "top of the visible
// letters" — without it the call sits ~0.2em below its box and can't
// be pushed to the card's top edge.
return (
<g
key={key} ref={setGroupRef(key)} transform={elementTransform(e)}
onPointerDown={startDrag(idx, e.x, e.y)}
style={{ cursor: onMove ? 'move' : undefined }}
>
<TextStack e={e} idx={idx} content={e.text ?? ''} />
<g transform={`translate(0 ${-(e.size ?? 0) * CAP_INSET})`}>
<TextStack e={e} idx={idx} content={e.text ?? ''} />
</g>
</g>
);
})}