kasuti: download custom motif as PNG

Adds a "PNG" button beside "JSON" in the pattern editor footer. It
rasterises the normalised motif (solid black strokes on a white
background, line width scaled to the cell size, ~800 px on the
longer axis) to a canvas, pulls a PNG blob via toBlob, and triggers
a download with the same safe-name slug as the JSON export.

The existing "Download" button is relabelled "JSON" so the two
outputs are distinguishable.

https://claude.ai/code/session_01KNdXjQczMCRGMK7K3Qderw
This commit is contained in:
Claude 2026-04-22 12:19:57 +00:00
parent 2ea9de67c1
commit 52615a5086
No known key found for this signature in database

View file

@ -2146,6 +2146,48 @@ const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initi
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [edgeList, title]); }, [edgeList, title]);
const handleDownloadPng = useCallback(() => {
const pattern = normalizePattern(edgeList);
if (!pattern) return;
const trimmed = title.trim() || 'Custom';
const safeName =
trimmed.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'motif';
const [rows, cols] = pattern.bbox;
const span = Math.max(rows, cols, 1);
const cellPx = 800 / span;
const pad = 40;
const W = Math.round(cols * cellPx + pad * 2);
const H = Math.round(rows * cellPx + pad * 2);
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = '#171717';
ctx.lineWidth = Math.max(4, cellPx * 0.16);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (const [a, b] of pattern.edges) {
ctx.beginPath();
ctx.moveTo(pad + a[1] * cellPx, pad + a[0] * cellPx);
ctx.lineTo(pad + b[1] * cellPx, pad + b[0] * cellPx);
ctx.stroke();
}
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `kasuti-${safeName}-${pattern.edges.length}.png`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}, 'image/png');
}, [edgeList, title]);
const handleUpload = useCallback(() => { const handleUpload = useCallback(() => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}, []); }, []);
@ -2312,7 +2354,7 @@ const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initi
/> />
<DialogFooter className="flex-col-reverse sm:flex-row gap-2 sm:justify-between"> <DialogFooter className="flex-col-reverse sm:flex-row gap-2 sm:justify-between">
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={handleUpload}> <Button variant="outline" size="sm" onClick={handleUpload}>
<Upload className="w-4 h-4 mr-1" /> <Upload className="w-4 h-4 mr-1" />
Upload Upload
@ -2322,9 +2364,20 @@ const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initi
size="sm" size="sm"
onClick={handleDownload} onClick={handleDownload}
disabled={count === 0} disabled={count === 0}
title="Download as JSON (re-openable in the editor)"
> >
<Download className="w-4 h-4 mr-1" /> <Download className="w-4 h-4 mr-1" />
Download JSON
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDownloadPng}
disabled={count === 0}
title="Download the motif as a PNG image"
>
<Download className="w-4 h-4 mr-1" />
PNG
</Button> </Button>
<Button variant="outline" size="sm" onClick={handleClear} disabled={count === 0}> <Button variant="outline" size="sm" onClick={handleClear} disabled={count === 0}>
Clear Clear