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
+39 -5
View File
@@ -41,6 +41,7 @@ import { Menubar, type Menu } from '@/components/Menubar';
import { QSLManagerPanel } from '@/components/QSLManagerModal';
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { QSOEditModal } from '@/components/QSOEditModal';
@@ -631,6 +632,19 @@ export default function App() {
return next;
});
}, []);
// Single band map docked beside the table (toggled by the toolbar button,
// visible across tabs). Independent of the multi-band "Band Map" tab.
const [showBandMap, setShowBandMap] = useState(false);
const [bandMapSide, setBandMapSide] = useState<'left' | 'right'>(
() => (localStorage.getItem('bandmap.side') === 'left' ? 'left' : 'right'),
);
const toggleBandMapSide = useCallback(() => {
setBandMapSide((s) => {
const next = s === 'right' ? 'left' : 'right';
writeUiPref('bandmap.side', next);
return next;
});
}, []);
type SortKey = 'time' | 'call' | 'freq' | 'band' | 'mode' | 'spotter' | 'source';
const [clusterSort, setClusterSort] = useState<{ key: SortKey; dir: 'asc' | 'desc' }>({ key: 'time', dir: 'desc' });
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
@@ -2256,10 +2270,10 @@ export default function App() {
</Button>
)}
<Button
variant={activeTab === 'bandmap' ? 'default' : 'outline'}
variant={showBandMap ? 'default' : 'outline'}
size="sm"
onClick={() => setActiveTab('bandmap')}
title="Open the Band Map tab (several bands side by side)"
onClick={() => setShowBandMap((v) => !v)}
title="Toggle a single band map docked on the side (use the Band Map tab for several at once)"
className="h-8"
>
Band map
@@ -2376,7 +2390,7 @@ export default function App() {
className={cn('bg-card shadow-sm border-border',
compact
? 'flex gap-2 items-end flex-nowrap px-3 py-2 border-b shrink-0 overflow-hidden'
: 'flex flex-col gap-1.5 px-2.5 py-1.5 flex-1 min-w-[520px] max-w-[760px] border rounded-lg [&_label]:text-[11px] [&_label]:mb-0.5 [&_label]:h-3')}
: 'flex flex-col gap-1.5 px-2.5 py-1.5 flex-1 min-w-[520px] max-w-[660px] border rounded-lg [&_label]:text-[11px] [&_label]:mb-0.5 [&_label]:h-3')}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
e.preventDefault();
@@ -2552,7 +2566,8 @@ export default function App() {
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
{compact ? null : <>
<div className="grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)] grid-cols-[1fr]">
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
showBandMap ? (bandMapSide === 'left' ? 'grid-cols-[300px_1fr]' : 'grid-cols-[1fr_300px]') : 'grid-cols-[1fr]')}>
<section className="bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col min-h-0 flex-1">
<TabsList className="px-3 shrink-0">
@@ -3081,6 +3096,21 @@ export default function App() {
</TabsContent>
</Tabs>
</section>
{showBandMap && (
<div className={cn('bg-card border border-border rounded-lg shadow-sm flex flex-col min-h-0 overflow-hidden', bandMapSide === 'left' && 'order-first')}>
<BandMap
side={bandMapSide}
onToggleSide={toggleBandMapSide}
band={band}
spots={spots.filter((s) => s.band === band)}
spotStatus={spotStatus}
currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
onSpotClick={handleSpotClick}
onClose={() => setShowBandMap(false)}
/>
</div>
)}
</div>
</>}
@@ -3173,6 +3203,10 @@ export default function App() {
/>
)}
<AutoEQSL
onSent={(call) => showToast(`eQSL sent to ${call}`)}
onError={(msg) => showToast(msg)}
/>
<QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} />
<SendEQSLModal
open={eqslQsoId !== null}
+30 -5
View File
@@ -17,6 +17,7 @@ import {
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
GetSecretStatus, SetPassphrase, RemovePassphrase,
GetEmailSettings, SaveEmailSettings, TestEmail,
QSLGetEmailTemplates, QSLSaveEmailTemplates,
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
ListClusterServers, SaveClusterServer, DeleteClusterServer,
GetClusterAutoConnect, SetClusterAutoConnect,
@@ -462,6 +463,10 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
});
const [emailMsg, setEmailMsg] = useState('');
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
// eQSL card e-mail (subject/body templates + auto-send on log).
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
const [eqslCfg, setEqslCfg] = useState<EQSLCfg>({ subject: '', body: '', auto_send: false });
const setEqslField = (patch: Partial<EQSLCfg>) => setEqslCfg((s) => ({ ...s, ...patch }));
// ClubLog Country File (cty.xml) exception status.
type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number };
const [clubInfo, setClubInfo] = useState<ClubInfo>({ enabled: false, loaded: false, date: '', count: 0 });
@@ -632,6 +637,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
reloadAudioDevices();
reloadDvk();
} catch (e: any) {
@@ -791,6 +797,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
await SaveWinkeyerSettings(wk as any);
await SaveAudioSettings(audioCfg as any);
await SaveEmailSettings(emailCfg as any);
await QSLSaveEmailTemplates(eqslCfg as any);
await SaveBackupSettings(backupCfg as any);
await SaveQSLDefaults(qslDefaults as any);
await SaveExternalServices(extSvc as any);
@@ -860,16 +867,16 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<Input className="font-mono uppercase" value={p.operator ?? ''} onChange={(e) => updateActive({ operator: e.target.value })} placeholder="F4XYZ" />
<div className="text-[10px] text-muted-foreground">Who's at the radio (ADIF OPERATOR).</div>
</div>
<div className="space-y-1 col-span-2">
<Label>Operator name</Label>
<Input className="max-w-xs" value={p.op_name ?? ''} onChange={(e) => updateActive({ op_name: e.target.value })} placeholder="e.g. Greg" />
<div className="text-[10px] text-muted-foreground">Your first name used as the signature on QSL cards.</div>
</div>
<div className="space-y-1 col-span-2">
<Label>Owner callsign</Label>
<Input className="font-mono uppercase max-w-xs" value={p.owner_callsign ?? ''} onChange={(e) => updateActive({ owner_callsign: e.target.value })} placeholder="(leave blank if same as station)" />
<div className="text-[10px] text-muted-foreground">Legal station owner only differs at club stations or remote setups (ADIF STATION_OWNER).</div>
</div>
<div className="space-y-1 col-span-2">
<Label>Operator name</Label>
<Input className="max-w-xs" value={p.op_name ?? ''} onChange={(e) => updateActive({ op_name: e.target.value })} placeholder="e.g. Greg" />
<div className="text-[10px] text-muted-foreground">Your first name used as the signature on QSL cards.</div>
</div>
<div className="col-span-2 text-[10px] text-muted-foreground uppercase tracking-wider mt-1">
Auto-filled from the callsign editable (stamped as MY_* on each QSO)
</div>
@@ -3077,6 +3084,24 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</Button>
<span className="text-[11px] text-muted-foreground">{emailMsg}</span>
</div>
<div className="pt-2 mt-2 border-t border-border space-y-2">
<Label className="text-sm font-semibold">eQSL card e-mail</Label>
<div className="text-[11px] text-muted-foreground">
Message sent with the QSL card. Variables: {'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'}.
</div>
<Input className="h-8" placeholder="Subject" value={eqslCfg.subject}
onChange={(e) => setEqslField({ subject: e.target.value })} />
<Textarea rows={3} className="text-sm" placeholder="Body" value={eqslCfg.body}
onChange={(e) => setEqslField({ body: e.target.value })} />
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={eqslCfg.auto_send} onCheckedChange={(c) => setEqslField({ auto_send: !!c })} />
Auto-send eQSL when a QSO is logged
</label>
<div className="text-[11px] text-muted-foreground">
Sends automatically only when the contact has an e-mail address and a default QSL template exists.
</div>
</div>
</div>
</>
);
+100
View File
@@ -0,0 +1,100 @@
// AutoEQSL drives automatic eQSL sending. The backend decides eligibility on
// log (auto-send on, recipient e-mail known, default template, not already
// sent) and emits "qsl:autosend"; this component does the part Go can't —
// render the card off-screen, rasterize it to JPEG and hand it back to
// SendEQSL. Jobs are processed one at a time. Mounted once, near the app root.
import { useCallback, useEffect, useRef, useState } from 'react';
import { EventsOn } from '../../../wailsjs/runtime/runtime';
import { RenderEQSL, SendEQSL } from '../../../wailsjs/go/main/App';
import type { RenderModel } from './qslTypes';
import { loadCardAssets, type CardAssets } from './qslAssets';
import { CardPreview } from './CardPreview';
import { rasterizeCard } from './rasterize';
interface Job {
qsoId: number;
templateId: number;
callsign: string;
}
interface Props {
onSent?: (callsign: string) => void;
onError?: (message: string) => void;
}
export function AutoEQSL({ onSent, onError }: Props) {
const queue = useRef<Job[]>([]);
const busy = useRef(false);
const [current, setCurrent] = useState<{ job: Job; model: RenderModel; assets: CardAssets } | null>(null);
const svgEl = useRef<SVGSVGElement | null>(null);
// Pull the next job, fetch its render model + assets, then mount it (the
// effect below rasterizes once the DOM has it).
const pump = useCallback(async () => {
if (busy.current) return;
const job = queue.current.shift();
if (!job) return;
busy.current = true;
try {
const model = JSON.parse(await RenderEQSL(job.qsoId, job.templateId)) as RenderModel;
const assets = await loadCardAssets(model.template, job.templateId);
setCurrent({ job, model, assets });
} catch (e) {
onError?.(`Auto eQSL: ${e}`);
busy.current = false;
void pump();
}
}, [onError]);
useEffect(() => {
const off = EventsOn('qsl:autosend', (p: { qsoId: number; templateId: number; callsign: string }) => {
queue.current.push({ qsoId: p.qsoId, templateId: p.templateId, callsign: p.callsign });
void pump();
});
return () => off();
}, [pump]);
// Once a job is mounted off-screen, wait for fonts + paint, rasterize, send.
useEffect(() => {
if (!current) return;
let cancelled = false;
void (async () => {
try {
await document.fonts.ready;
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
if (cancelled || !svgEl.current) return;
const card = current.model.template.card;
const jpeg = await rasterizeCard(svgEl.current, card.w, card.h, 'image/jpeg');
await SendEQSL(current.job.qsoId, current.job.templateId, jpeg);
onSent?.(current.job.callsign);
} catch (e) {
onError?.(`Auto eQSL: ${e}`);
} finally {
if (!cancelled) {
setCurrent(null);
busy.current = false;
void pump();
}
}
})();
return () => { cancelled = true; };
}, [current, pump, onSent, onError]);
if (!current) return null;
// Off-screen at full card resolution so the rasterized output matches the
// editor preview exactly.
return (
<div
aria-hidden
style={{ position: 'fixed', left: -100000, top: 0, opacity: 0, pointerEvents: 'none' }}
>
<CardPreview
model={current.model}
assets={current.assets}
width={current.model.template.card.w}
svgRef={(el) => { svgEl.current = el; }}
/>
</div>
);
}
+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>
);
})}
+38 -3
View File
@@ -9,6 +9,7 @@ import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import type { CardTemplate, CardElement, QSOBox, QSLPresetInfo, StyleParams } from './qslTypes';
import type { CardSelection } from './CardPreview';
import { StylePresetPicker, NumberField } from './StylePresetPicker';
@@ -21,16 +22,29 @@ interface Props {
onPatchElement: (idx: number, patch: Partial<CardElement>) => void;
onPatchBox: (patch: Partial<QSOBox>) => void;
onScrim: (enabled: boolean) => void;
onSelect?: (sel: CardSelection) => void;
}
const TYPE_LABELS: Record<string, string> = {
callsign: 'Callsign',
operator: 'Operator name',
info_line: 'Info line',
country: 'Country block',
country: 'Country + flag',
insert: 'Photo insert',
};
// A readable layer name, disambiguating the two info lines and numbering inserts.
function layerLabel(el: CardElement, i: number, all: CardElement[]): string {
if (el.type === 'info_line') {
return /rig|antenna|ant\b/i.test(el.text ?? '') ? 'Station (rig / ant)' : 'Info line (zones)';
}
if (el.type === 'insert') {
const n = all.slice(0, i + 1).filter((x) => x.type === 'insert').length;
return `Photo insert ${n}`;
}
return TYPE_LABELS[el.type] ?? el.type;
}
function TextControls({ e, idx, presets, fontFamilies, onPatch }: {
e: CardElement; idx: number; presets: QSLPresetInfo[]; fontFamilies: string[];
onPatch: (idx: number, patch: Partial<CardElement>) => void;
@@ -71,12 +85,12 @@ function TextControls({ e, idx, presets, fontFamilies, onPatch }: {
);
}
export function EditorPanel({ template, sel, presets, fontFamilies, onPatchElement, onPatchBox, onScrim }: Props) {
export function EditorPanel({ template, sel, presets, fontFamilies, onPatchElement, onPatchBox, onScrim, onSelect }: Props) {
const e = typeof sel === 'number' ? template.elements[sel] : undefined;
const box = template.qso_box;
return (
<div className="flex w-72 shrink-0 flex-col gap-3 overflow-y-auto pr-1 text-sm">
<div className="flex w-80 shrink-0 flex-col gap-3 overflow-y-auto pr-1 text-sm">
<div className="space-y-2 rounded-md border border-border p-3">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Card</div>
<label className="flex items-center gap-2 text-sm">
@@ -88,6 +102,27 @@ export function EditorPanel({ template, sel, presets, fontFamilies, onPatchEleme
</label>
</div>
{/* Layers: show/hide each piece, click a name to edit it. */}
<div className="space-y-1 rounded-md border border-border p-3">
<div className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Show on card</div>
{template.elements.map((el, i) => (
<div key={i} className={cn('flex items-center gap-2 rounded px-1 py-0.5', sel === i && 'bg-muted')}>
<Checkbox checked={!el.hidden} onCheckedChange={(v) => onPatchElement(i, { hidden: v !== true })} />
<button type="button" className="flex-1 truncate text-left text-sm" onClick={() => onSelect?.(i)}>
{layerLabel(el, i, template.elements)}
</button>
</div>
))}
{box && (
<div className={cn('flex items-center gap-2 rounded px-1 py-0.5', sel === 'box' && 'bg-muted')}>
<Checkbox checked={box.enabled} onCheckedChange={(v) => onPatchBox({ enabled: v === true })} />
<button type="button" className="flex-1 truncate text-left text-sm" onClick={() => onSelect?.('box')}>
QSO info box
</button>
</div>
)}
</div>
<div className="space-y-2 rounded-md border border-border p-3">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{e ? TYPE_LABELS[e.type] ?? e.type : sel === 'box' ? 'QSO box' : 'Selection'}
@@ -11,14 +11,11 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
QSLPickPhotos, QSLGenerateProposals, QSLListTemplates, QSLGetTemplate,
QSLSaveTemplate, QSLSetDefaultTemplate, QSLDeleteTemplate, QSLSavePreview,
QSLPreviewDataURL, QSLResolvePreview, QSLStylePresets,
QSLGetEmailTemplates, QSLSaveEmailTemplates,
} from '../../../wailsjs/go/main/App';
import { main } from '../../../wailsjs/go/models';
import type {
CardTemplate, CardElement, QSOBox, RenderModel, QSLTemplateInfo, QSLPresetInfo,
} from './qslTypes';
@@ -66,9 +63,6 @@ export function QslDesignerModal({ open, onClose }: Props) {
const [presets, setPresets] = useState<QSLPresetInfo[]>([]);
const [fontFamilies, setFontFamilies] = useState<string[]>([]);
const [deleteArm, setDeleteArm] = useState(0);
const [mailSubject, setMailSubject] = useState('');
const [mailBody, setMailBody] = useState('');
const [mailSaved, setMailSaved] = useState(false);
const svgEl = useRef<SVGSVGElement | null>(null);
useEffect(() => {
@@ -80,7 +74,6 @@ export function QslDesignerModal({ open, onClose }: Props) {
setEditing(null);
void refreshSaved();
void QSLStylePresets().then((p) => setPresets(p as QSLPresetInfo[]));
void QSLGetEmailTemplates().then((t) => { setMailSubject(t.subject); setMailBody(t.body); setMailSaved(false); });
void loadFonts().then(({ fonts }) =>
setFontFamilies([...fonts.map((f) => f.family), 'system-bold-sans']));
}, [open]);
@@ -103,7 +96,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
async function choosePhotos() {
try {
const paths = ((await QSLPickPhotos()) ?? []) as string[];
if (paths.length) setPhotoPaths(paths.slice(0, 6));
if (paths.length) setPhotoPaths(paths.slice(0, 3));
} catch (e) {
setError(String(e));
}
@@ -237,7 +230,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-[1180px]">
<DialogContent className="max-w-[1260px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="size-5 text-amber-500" />
@@ -261,7 +254,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
<section className="space-y-2">
<h3 className="text-sm font-semibold">New design</h3>
<p className="text-xs text-muted-foreground">
Pick 16 photos OpsLog analyzes them and proposes three card designs
Pick 13 photos OpsLog analyzes them and proposes three card designs
with your callsign, name, zones and country placed automatically.
</p>
<div className="flex items-center gap-2">
@@ -317,25 +310,9 @@ export function QslDesignerModal({ open, onClose }: Props) {
</div>
</section>
<section className="space-y-2">
<h3 className="text-sm font-semibold">eQSL e-mail message</h3>
<p className="text-xs text-muted-foreground">
{'{CALL}'} {'{DATE}'} {'{BAND}'} {'{MODE}'} {'{MYCALL}'} fill in per QSO.
</p>
<Input className="h-8" value={mailSubject} placeholder="Subject"
onChange={(e) => { setMailSubject(e.target.value); setMailSaved(false); }} />
<Textarea rows={3} value={mailBody} placeholder="Body"
onChange={(e) => { setMailBody(e.target.value); setMailSaved(false); }} />
<Button variant="outline" size="sm" disabled={mailSaved}
onClick={async () => {
try {
await QSLSaveEmailTemplates(new main.QSLEmailTemplates({ subject: mailSubject, body: mailBody }));
setMailSaved(true);
} catch (e) { setError(String(e)); }
}}>
{mailSaved ? 'Saved' : 'Save message'}
</Button>
</section>
<p className="text-xs text-muted-foreground">
The eQSL e-mail message and the auto-send option are in Settings E-mail (SMTP).
</p>
</div>
)}
@@ -392,6 +369,7 @@ export function QslDesignerModal({ open, onClose }: Props) {
onPatchElement={patchElement}
onPatchBox={patchBox}
onScrim={onScrim}
onSelect={setSel}
/>
</div>
)}
@@ -35,10 +35,10 @@ function SliderRow({ label, value, min, max, step, onChange }: {
}) {
return (
<div className="flex items-center justify-between gap-2">
<Label className="w-24 shrink-0 text-xs text-muted-foreground">{label}</Label>
<input type="range" className="flex-1" min={min} max={max} step={step}
<Label className="w-20 shrink-0 text-xs text-muted-foreground">{label}</Label>
<input type="range" className="min-w-0 flex-1" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(parseFloat(e.target.value))} />
<span className="w-8 text-right text-xs tabular-nums">{value}</span>
<span className="w-10 shrink-0 text-right text-xs tabular-nums">{value}</span>
</div>
);
}
+1
View File
@@ -72,6 +72,7 @@ export interface CardElement {
x: number;
y: number;
rotate?: number;
hidden?: boolean; // toggled off in the editor
// text elements
text?: string;
font?: string;
+576
View File
@@ -0,0 +1,576 @@
// Canvas-2D call-text renderer, ported faithfully from the user's two
// reference generators ("glossy bulle" / XV9Q and "western 3D" / HS0ZLE).
// SVG filters can only approximate these looks; the exact technique is
// canvas — inflated glyphs (fill + stroke to dilate), contour bands via
// boolean compositing, a candy gloss that hugs the outline, an orange inner
// shadow, and organic multi-layer grunge.
//
// renderTextFx() runs the recipe offscreen, trims the transparent padding,
// and returns a PNG data URL + its size in logical (card) pixels. CardPreview
// embeds that as an <image> so the same output drives the editor preview,
// thumbnails and the final e-mail rasterization.
export type TextFxKind = 'glossy' | 'western';
export interface TextFxParams {
kind: TextFxKind;
text: string;
weight: string; // canvas font weight, e.g. "800" or "400"
family: string; // canvas font family, e.g. '"Baloo 2", sans-serif'
size: number; // font size in card px
space: number; // letter spacing in px
cTop: string;
cMid: string;
cBot: string;
cDark: string; // dark edge / outline
cOuter?: string; // silver rim (glossy only)
// glossy
plump?: number; // 0..0.06 — inflation as a fraction of size
edge?: number; // 0..1 — dark edge weight
outerw?: number; // 0..1 — silver rim weight
gloss?: number; // 0..1 — gloss intensity
glossH?: number; // 0..1 — gloss height
glossI?: number; // 0..0.14 — gloss inset
innerB?: number; // 0..1 — bottom inner shadow
// western
depth?: number; // 3D extrusion depth in px
angle?: number; // extrusion direction in radians
slant?: number; // shear, -0.4..0.4
grunge?: number; // 0..1 — distress amount
bevel?: number; // 0..1 — top-edge light
seed?: number;
}
export interface TextFxResult {
url: string; // PNG data URL of the trimmed text
w: number; // logical (card-px) width
h: number; // logical (card-px) height
}
const SS = 2; // supersample for crisp downscaling
function supportsFilter(c: CanvasRenderingContext2D) {
return typeof c.filter === 'string';
}
function shade(hex: string, pct: number) {
const n = parseInt(hex.slice(1), 16);
const f = (v: number) => Math.max(0, Math.min(255, Math.round(v + (255 * pct) / 100)));
return 'rgb(' + f(n >> 16) + ',' + f((n >> 8) & 255) + ',' + f(n & 255) + ')';
}
function deepen(hex: string) {
const n = parseInt(hex.slice(1), 16);
return 'rgb(' + Math.round((n >> 16) * 0.96) + ',' + Math.round(((n >> 8) & 255) * 0.62) + ',' + Math.round((n & 255) * 0.25) + ')';
}
function rustOf(hex: string) {
const n = parseInt(hex.slice(1), 16);
const r = Math.min(255, Math.round((n >> 16) * 0.72));
const g = Math.round(((n >> 8) & 255) * 0.49);
const b = Math.min(255, Math.round((n & 255) * 0.3) + 10);
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
function mulberry32(seed: number) {
return function () {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hashStr(s: string) {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
// ── glossy bubble (XV9Q) ────────────────────────────────────────────────
function renderGlossy(p: Required<Pick<TextFxParams, 'text' | 'weight' | 'family' | 'size' | 'space' | 'plump' | 'edge' | 'outerw' | 'gloss' | 'glossH' | 'glossI' | 'innerB' | 'cTop' | 'cMid' | 'cBot' | 'cDark' | 'cOuter'>>, scale: number): HTMLCanvasElement {
const probe = document.createElement('canvas').getContext('2d')!;
const font = p.weight + ' ' + p.size + 'px ' + p.family;
probe.letterSpacing = p.space + 'px';
probe.font = font;
const m = probe.measureText(p.text);
const asc = m.actualBoundingBoxAscent, desc = m.actualBoundingBoxDescent;
const capH = asc + desc;
const plumpPx = p.size * p.plump;
const edgeW = p.size * 0.1 * p.edge;
const outerW = edgeW + p.size * 0.22 * p.outerw + 2 * plumpPx;
const pad = Math.ceil(outerW / 2 + p.size * 0.12);
const w = Math.ceil(m.width + pad * 2);
const h = Math.ceil(capH + pad * 2);
const canvas = document.createElement('canvas');
canvas.width = w * scale;
canvas.height = h * scale;
const c = canvas.getContext('2d')!;
const x = pad, y = pad + asc;
const top = y - asc - plumpPx, bot = y + desc + plumpPx;
const mk = () => {
const k = document.createElement('canvas');
k.width = canvas.width;
k.height = canvas.height;
return k;
};
const setT = (cx: CanvasRenderingContext2D) => {
cx.setTransform(scale, 0, 0, scale, 0, 0);
cx.letterSpacing = p.space + 'px';
cx.font = font;
cx.textBaseline = 'alphabetic';
cx.lineJoin = 'round';
cx.lineCap = 'round';
};
const glyph = (delta: number, dx?: number, dy?: number) => {
const k = mk();
const g = k.getContext('2d')!;
setT(g);
g.fillStyle = '#fff';
g.strokeStyle = '#fff';
g.fillText(p.text, x + (dx || 0), y + (dy || 0));
if (delta > 0.1) {
g.lineWidth = delta * 2;
g.strokeText(p.text, x + (dx || 0), y + (dy || 0));
} else if (delta < -0.1) {
g.globalCompositeOperation = 'destination-out';
g.lineWidth = -delta * 2;
g.strokeText(p.text, x + (dx || 0), y + (dy || 0));
}
return k;
};
const clipTo = (k: HTMLCanvasElement, mask: HTMLCanvasElement) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'destination-in';
g.drawImage(mask, 0, 0);
g.restore();
return k;
};
const contourBand = (shiftX: number, shiftY: number, insetPx: number) => {
const k = glyph(plumpPx, 0, 0);
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'destination-out';
g.drawImage(glyph(plumpPx, shiftX, shiftY), 0, 0);
g.restore();
return clipTo(k, glyph(plumpPx - insetPx, 0, 0));
};
const vRamp = (k: HTMLCanvasElement, f0: number, f1: number, keepTop: boolean) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(scale, 0, 0, scale, 0, 0);
g.globalCompositeOperation = 'destination-in';
const gr = g.createLinearGradient(0, top, 0, bot);
const a1 = keepTop ? 1 : 0, a2 = keepTop ? 0 : 1;
gr.addColorStop(0, 'rgba(255,255,255,' + a1 + ')');
gr.addColorStop(Math.max(0.001, Math.min(0.998, f0)), 'rgba(255,255,255,' + a1 + ')');
gr.addColorStop(Math.max(0.002, Math.min(0.999, f1)), 'rgba(255,255,255,' + a2 + ')');
gr.addColorStop(1, 'rgba(255,255,255,' + a2 + ')');
g.fillStyle = gr;
g.fillRect(0, 0, k.width / scale, k.height / scale);
g.restore();
return k;
};
const blurred = (src: HTMLCanvasElement, px: number) => {
const k = mk();
const g = k.getContext('2d')!;
if (supportsFilter(g) && px > 0.3) {
g.filter = 'blur(' + (px * scale).toFixed(2) + 'px)';
g.drawImage(src, 0, 0);
g.filter = 'none';
} else {
g.drawImage(src, 0, 0);
}
return k;
};
const tint = (k: HTMLCanvasElement, color: string, alpha: number) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'source-in';
g.fillStyle = color;
g.fillRect(0, 0, k.width, k.height);
g.restore();
if (alpha < 1) {
const k2 = mk();
const g2 = k2.getContext('2d')!;
g2.globalAlpha = alpha;
g2.drawImage(k, 0, 0);
return k2;
}
return k;
};
const paintText = (cx: CanvasRenderingContext2D, paint: string | CanvasGradient) => {
cx.fillStyle = paint;
cx.fillText(p.text, x, y);
if (plumpPx > 0.1) {
cx.strokeStyle = paint;
cx.lineWidth = plumpPx * 2;
cx.strokeText(p.text, x, y);
}
};
c.setTransform(1, 0, 0, 1, 0, 0);
c.clearRect(0, 0, canvas.width, canvas.height);
setT(c);
// drop shadow
c.save();
c.shadowColor = 'rgba(10,15,25,0.40)';
c.shadowBlur = p.size * 0.045 * scale;
c.shadowOffsetY = p.size * 0.03 * scale;
c.lineWidth = Math.max(outerW, edgeW + 2 * plumpPx, 1);
c.strokeStyle = 'rgba(10,15,25,0.40)';
c.strokeText(p.text, x, y);
c.restore();
const face = mk();
const f = face.getContext('2d')!;
setT(f);
if (p.outerw > 0) {
const gOuter = f.createLinearGradient(0, top - outerW / 2, 0, bot + outerW / 2);
gOuter.addColorStop(0, '#ffffff');
gOuter.addColorStop(0.45, p.cOuter);
gOuter.addColorStop(1, shade(p.cOuter, -22));
f.lineWidth = outerW;
f.strokeStyle = gOuter;
f.strokeText(p.text, x, y);
}
if (p.edge > 0) {
f.lineWidth = edgeW + 2 * plumpPx;
f.strokeStyle = p.cDark;
f.strokeText(p.text, x, y);
}
const gBody = f.createLinearGradient(0, top, 0, bot);
gBody.addColorStop(0, p.cTop);
gBody.addColorStop(0.55, p.cMid);
gBody.addColorStop(1, p.cBot);
paintText(f, gBody);
if (p.innerB > 0) {
let band = contourBand(0, -0.22 * capH, p.size * 0.03);
band = blurred(band, p.size * 0.04);
band = clipTo(band, glyph(plumpPx, 0, 0));
band = vRamp(band, 0.45, 0.8, false);
band = tint(band, deepen(p.cBot), 0.7);
f.save();
f.setTransform(1, 0, 0, 1, 0, 0);
f.globalCompositeOperation = 'source-atop';
f.globalAlpha = Math.min(1, p.innerB);
f.drawImage(band, 0, 0);
f.restore();
}
if (p.gloss > 0) {
const shiftY = p.glossH * capH;
const shiftX = 0.12 * shiftY;
const inset = p.size * p.glossI;
const clip0 = p.glossH * 0.6, clip1 = Math.min(0.95, p.glossH * 1.4);
let band = contourBand(shiftX, shiftY, inset);
band = vRamp(band, clip0, clip1, true);
let halo = blurred(band, p.size * 0.014);
halo = clipTo(halo, glyph(plumpPx - inset * 0.5, 0, 0));
let core = mk();
core.getContext('2d')!.drawImage(band, 0, 0);
core = clipTo(core, glyph(plumpPx - inset * 1.35, 0, 0));
core = blurred(core, p.size * 0.007);
f.save();
f.setTransform(1, 0, 0, 1, 0, 0);
f.globalCompositeOperation = 'source-atop';
f.globalAlpha = Math.min(1, 0.85 * p.gloss);
f.drawImage(halo, 0, 0);
f.globalAlpha = Math.min(1, 1.15 * p.gloss);
f.drawImage(core, 0, 0);
f.restore();
}
c.setTransform(1, 0, 0, 1, 0, 0);
c.drawImage(face, 0, 0);
return canvas;
}
// ── western 3D (HS0ZLE) ─────────────────────────────────────────────────
function noiseMask(W: number, H: number, featurePx: number, thresh: number, seed: number, softenPx: number, stretchY = 1): HTMLCanvasElement {
const sw = Math.max(2, Math.round(W / featurePx));
const sh = Math.max(2, Math.round(H / (featurePx * stretchY)));
const small = document.createElement('canvas');
small.width = sw;
small.height = sh;
const sg = small.getContext('2d')!;
const id = sg.createImageData(sw, sh);
const rnd = mulberry32(seed);
for (let i = 0; i < sw * sh; i++) {
const v = (rnd() * 255) | 0;
id.data[i * 4] = v;
id.data[i * 4 + 1] = v;
id.data[i * 4 + 2] = v;
id.data[i * 4 + 3] = 255;
}
sg.putImageData(id, 0, 0);
const big = document.createElement('canvas');
big.width = W;
big.height = H;
const bg = big.getContext('2d')!;
bg.imageSmoothingEnabled = true;
bg.imageSmoothingQuality = 'high';
if (supportsFilter(bg)) bg.filter = 'blur(' + (featurePx * 0.35).toFixed(1) + 'px)';
bg.drawImage(small, 0, 0, W, H);
if (supportsFilter(bg)) bg.filter = 'none';
const src = bg.getImageData(0, 0, W, H);
const out = bg.createImageData(W, H);
const k = 1 / Math.max(1e-6, (1 - thresh) * 0.35);
for (let i = 0; i < W * H; i++) {
const v = src.data[i * 4] / 255;
let a = (v - thresh) * k;
a = a < 0 ? 0 : a > 1 ? 1 : a;
out.data[i * 4] = 255;
out.data[i * 4 + 1] = 255;
out.data[i * 4 + 2] = 255;
out.data[i * 4 + 3] = (a * 255) | 0;
}
const mask = document.createElement('canvas');
mask.width = W;
mask.height = H;
const mg = mask.getContext('2d')!;
if (supportsFilter(mg) && softenPx > 0.3) mg.filter = 'blur(' + softenPx.toFixed(1) + 'px)';
const tmp = document.createElement('canvas');
tmp.width = W;
tmp.height = H;
tmp.getContext('2d')!.putImageData(out, 0, 0);
mg.drawImage(tmp, 0, 0);
if (supportsFilter(mg)) mg.filter = 'none';
return mask;
}
function renderWestern(p: Required<Pick<TextFxParams, 'text' | 'weight' | 'family' | 'size' | 'space' | 'depth' | 'angle' | 'slant' | 'grunge' | 'bevel' | 'seed' | 'cTop' | 'cMid' | 'cBot' | 'cDark'>>, scale: number): HTMLCanvasElement {
const probe = document.createElement('canvas').getContext('2d')!;
const font = p.weight + ' ' + p.size + 'px ' + p.family;
probe.letterSpacing = p.space + 'px';
probe.font = font;
const m = probe.measureText(p.text);
const asc = m.actualBoundingBoxAscent, desc = m.actualBoundingBoxDescent;
const capH = asc + desc;
const edgeW = p.size * 0.07;
const dx = Math.cos(p.angle) * p.depth, dy = Math.sin(p.angle) * p.depth;
const slantPad = Math.abs(p.slant) * capH;
const pad = Math.ceil(edgeW + p.size * 0.1 + p.depth + slantPad);
const w = Math.ceil(m.width + pad * 2);
const h = Math.ceil(capH + pad * 2);
const canvas = document.createElement('canvas');
canvas.width = w * scale;
canvas.height = h * scale;
const c = canvas.getContext('2d')!;
const x = pad - Math.min(0, dx), y = pad + asc - Math.min(0, dy);
const top = y - asc, bot = y + desc;
const mk = () => {
const k = document.createElement('canvas');
k.width = canvas.width;
k.height = canvas.height;
return k;
};
const setT = (cx: CanvasRenderingContext2D) => {
cx.setTransform(scale, 0, 0, scale, 0, 0);
cx.transform(1, 0, -p.slant, 1, p.slant * y, 0);
cx.letterSpacing = p.space + 'px';
cx.font = font;
cx.textBaseline = 'alphabetic';
cx.lineJoin = 'round';
cx.lineCap = 'butt'; // their source set 'miter' here, which browsers ignore on lineCap
};
const glyph = (dxx?: number, dyy?: number) => {
const k = mk();
const g = k.getContext('2d')!;
setT(g);
g.fillStyle = '#fff';
g.fillText(p.text, x + (dxx || 0), y + (dyy || 0));
return k;
};
const clipTo = (k: HTMLCanvasElement, mask: HTMLCanvasElement) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'destination-in';
g.drawImage(mask, 0, 0);
g.restore();
return k;
};
const blurred = (src: HTMLCanvasElement, px: number) => {
const k = mk();
const g = k.getContext('2d')!;
if (supportsFilter(g) && px > 0.3) {
g.filter = 'blur(' + (px * scale).toFixed(2) + 'px)';
g.drawImage(src, 0, 0);
g.filter = 'none';
} else {
g.drawImage(src, 0, 0);
}
return k;
};
const tintAlpha = (k: HTMLCanvasElement, color: string, alpha: number) => {
const g = k.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'source-in';
g.fillStyle = color;
g.fillRect(0, 0, k.width, k.height);
g.restore();
const k2 = mk();
const g2 = k2.getContext('2d')!;
g2.globalAlpha = alpha;
g2.drawImage(k, 0, 0);
return k2;
};
c.setTransform(1, 0, 0, 1, 0, 0);
c.clearRect(0, 0, canvas.width, canvas.height);
setT(c);
// drop shadow
c.save();
c.shadowColor = 'rgba(15,8,4,0.50)';
c.shadowBlur = p.size * 0.035 * scale;
c.shadowOffsetX = p.size * 0.02 * scale;
c.shadowOffsetY = p.size * 0.035 * scale;
c.lineWidth = edgeW;
c.strokeStyle = 'rgba(15,8,4,0.50)';
c.fillStyle = 'rgba(15,8,4,0.50)';
c.strokeText(p.text, x, y);
c.fillText(p.text, x, y);
c.restore();
// 3D extrusion
if (p.depth > 0) {
const deepCol = shade(p.cDark, -4);
c.lineWidth = edgeW;
const steps = Math.ceil(p.depth);
for (let i = steps; i >= 1; i--) {
const t = i / steps;
c.strokeStyle = deepCol;
c.fillStyle = deepCol;
c.strokeText(p.text, x + dx * t, y + dy * t);
c.fillText(p.text, x + dx * t, y + dy * t);
}
}
const face = mk();
const f = face.getContext('2d')!;
setT(f);
f.lineWidth = edgeW;
f.strokeStyle = p.cDark;
f.strokeText(p.text, x, y);
const gBody = f.createLinearGradient(0, top, 0, bot);
gBody.addColorStop(0, p.cTop);
gBody.addColorStop(0.5, p.cMid);
gBody.addColorStop(1, p.cBot);
f.fillStyle = gBody;
f.fillText(p.text, x, y);
const M = glyph(0, 0);
if (p.bevel > 0) {
let band = glyph(0, 0);
const g = band.getContext('2d')!;
g.save();
g.setTransform(1, 0, 0, 1, 0, 0);
g.globalCompositeOperation = 'destination-out';
g.drawImage(glyph(0.02 * p.size, 0.06 * capH), 0, 0);
g.restore();
band = blurred(band, p.size * 0.008);
band = clipTo(band, M);
band = tintAlpha(band, '#ffeba8', p.bevel);
f.save();
f.setTransform(1, 0, 0, 1, 0, 0);
f.globalCompositeOperation = 'source-atop';
f.drawImage(band, 0, 0);
f.restore();
}
if (p.grunge > 0) {
const W = canvas.width, H = canvas.height;
const S = p.size * scale;
const seed = (hashStr(p.text + '|' + p.family) ^ (p.seed * 2654435761)) >>> 0;
const rust = rustOf(p.cBot);
const layers = [
{ mask: noiseMask(W, H, 0.022 * S, 0.72, seed, 0.6 * scale), color: rust, a: 0.8 },
{ mask: noiseMask(W, H, 0.011 * S, 0.7, seed + 11, 0.5 * scale, 4.0), color: rust, a: 0.6 },
{ mask: noiseMask(W, H, 0.0066 * S, 0.74, seed + 7, 0.3 * scale), color: rust, a: 0.75 },
{ mask: noiseMask(W, H, 0.0154 * S, 0.86, seed + 3, 0.4 * scale), color: p.cDark, a: 0.9 },
];
f.save();
f.setTransform(1, 0, 0, 1, 0, 0);
f.globalCompositeOperation = 'source-atop';
for (const L of layers) {
let k = clipTo(L.mask, M);
k = tintAlpha(k, L.color, L.a * p.grunge);
f.drawImage(k, 0, 0);
}
f.restore();
}
c.setTransform(1, 0, 0, 1, 0, 0);
c.drawImage(face, 0, 0);
return canvas;
}
// Trim transparent padding so the element's x/y maps to the visible glyphs.
function trim(canvas: HTMLCanvasElement): { canvas: HTMLCanvasElement; w: number; h: number } {
const ctx = canvas.getContext('2d')!;
const W = canvas.width, H = canvas.height;
const data = ctx.getImageData(0, 0, W, H).data;
let minX = W, minY = H, maxX = -1, maxY = -1;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
if (data[(y * W + x) * 4 + 3] > 8) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX < minX) return { canvas, w: W, h: H };
const cw = maxX - minX + 1, ch = maxY - minY + 1;
const out = document.createElement('canvas');
out.width = cw;
out.height = ch;
out.getContext('2d')!.drawImage(canvas, minX, minY, cw, ch, 0, 0, cw, ch);
return { canvas: out, w: cw, h: ch };
}
export function renderTextFx(p: TextFxParams): TextFxResult {
let raw: HTMLCanvasElement;
if (p.kind === 'western') {
raw = renderWestern(
{
text: p.text, weight: p.weight, family: p.family, size: p.size, space: p.space,
depth: p.depth ?? p.size * 0.06, angle: p.angle ?? (112 * Math.PI) / 180,
slant: p.slant ?? 0.08, grunge: p.grunge ?? 0.8, bevel: p.bevel ?? 0.45, seed: p.seed ?? 7,
cTop: p.cTop, cMid: p.cMid, cBot: p.cBot, cDark: p.cDark,
},
SS,
);
} else {
raw = renderGlossy(
{
text: p.text, weight: p.weight, family: p.family, size: p.size, space: p.space,
plump: p.plump ?? 0.03, edge: p.edge ?? 0.5, outerw: p.outerw ?? 0.45,
gloss: p.gloss ?? 0.85, glossH: p.glossH ?? 0.45, glossI: p.glossI ?? 0.08, innerB: p.innerB ?? 0.7,
cTop: p.cTop, cMid: p.cMid, cBot: p.cBot, cDark: p.cDark, cOuter: p.cOuter ?? '#ced3db',
},
SS,
);
}
const t = trim(raw);
return { url: t.canvas.toDataURL('image/png'), w: t.w / SS, h: t.h / SS };
}