From 2ea9de67c1ba84226a05749279efbd73d4e25441 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 11:57:46 +0000 Subject: [PATCH] kasuti: multiple custom motifs, rename, click-to-edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit State now carries an array of custom patterns instead of one slot. Each gets a unique id ("custom-") and a user-editable title; they render as full tiles in the reel between the presets and the "+ Make your own" button. Reel behaviour: - Clicking a preset tile still selects it for play. - Clicking a custom tile opens the editor pre-seeded with that motif (rename, tweak geometry, re-download, or just press "Use this motif" to re-select for play). - "+ Make your own" always opens an empty editor so a second motif can be drawn without overwriting the first. Editor gains a Name input at the top. Saving writes the trimmed title back onto the motif; downloads use it in the filename; parsed uploads restore whatever title was baked into the JSON. Shared replay URLs for custom motifs hydrate into a new "Shared motif" tile on the viewer's reel rather than clobbering an existing custom. Intro blurb split: the "editor only supports connected designs" and "browser will not save your designs — download them separately" sentences are now a single paragraph in foreground colour so the two caveats read as a highlighted note. https://claude.ai/code/session_01KNdXjQczMCRGMK7K3Qderw --- .../interactives/KasutiEmbroidery.tsx | 141 ++++++++++++++---- 1 file changed, 112 insertions(+), 29 deletions(-) diff --git a/src/components/interactives/KasutiEmbroidery.tsx b/src/components/interactives/KasutiEmbroidery.tsx index 8a7ff00..f1b3437 100644 --- a/src/components/interactives/KasutiEmbroidery.tsx +++ b/src/components/interactives/KasutiEmbroidery.tsx @@ -14,6 +14,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Toaster as SonnerToaster } from '@/components/ui/sonner'; @@ -220,19 +221,19 @@ function b64urlDecode(s: string): string { } function encodeReplay( - patternId: string, - customPattern: Pattern | null, + pattern: Pattern, startVertex: V, stitches: Stitch[] ): string { + const isCustom = !PATTERNS.some((p) => p.id === pattern.id); const payload: ReplayPayload = { v: 1, - p: patternId, + p: isCustom ? 'custom' : pattern.id, s: [startVertex[0], startVertex[1]], t: stitches.map((st) => [st.to[0], st.to[1]]), }; - if (patternId === 'custom' && customPattern) { - payload.e = customPattern.edges.map(([a, b]) => [ + if (isCustom) { + payload.e = pattern.edges.map(([a, b]) => [ [a[0], a[1]], [b[0], b[1]], ]); @@ -677,12 +678,22 @@ const MiniPreview: React.FC<{ pattern: Pattern; active: boolean }> = ({ pattern, interface MotifReelProps { patterns: Pattern[]; + customPatternIds: string[]; activeId: string; onSelect: (id: string) => void; + onEditCustom: (id: string) => void; onOpenEditor: () => void; } -const MotifReel: React.FC = ({ patterns, activeId, onSelect, onOpenEditor }) => { +const MotifReel: React.FC = ({ + patterns, + customPatternIds, + activeId, + onSelect, + onEditCustom, + onOpenEditor, +}) => { + const customIdSet = useMemo(() => new Set(customPatternIds), [customPatternIds]); const trackRef = useRef(null); const [atStart, setAtStart] = useState(true); const [atEnd, setAtEnd] = useState(false); @@ -726,12 +737,14 @@ const MotifReel: React.FC = ({ patterns, activeId, onSelect, onO > {patterns.map((p) => { const active = p.id === activeId; + const isCustom = customIdSet.has(p.id); return ( ); })} @@ -796,8 +811,10 @@ const MotifReel: React.FC = ({ patterns, activeId, onSelect, onO const KasutiEmbroidery: React.FC = () => { const [patternId, setPatternId] = useState(PATTERNS[0].id); - const [customPattern, setCustomPattern] = useState(null); + const [customPatterns, setCustomPatterns] = useState([]); const [editorOpen, setEditorOpen] = useState(false); + const [editingId, setEditingId] = useState(null); // null = new motif + const customCounterRef = useRef(0); const [currentVertex, setCurrentVertex] = useState(null); const [startVertex, setStartVertex] = useState(null); const [activeSide, setActiveSide] = useState('front'); @@ -811,10 +828,20 @@ const KasutiEmbroidery: React.FC = () => { const celebratedRef = useRef(false); const visiblePatterns = useMemo( - () => (customPattern ? [...PATTERNS, customPattern] : PATTERNS), - [customPattern] + () => [...PATTERNS, ...customPatterns], + [customPatterns] ); + const editingPattern = useMemo( + () => (editingId ? customPatterns.find((p) => p.id === editingId) ?? null : null), + [editingId, customPatterns] + ); + + const nextCustomId = useCallback(() => { + customCounterRef.current += 1; + return `custom-${customCounterRef.current}`; + }, []); + const placed = useMemo(() => { const p = visiblePatterns.find((x) => x.id === patternId) ?? PATTERNS[0]; return placePattern(p); @@ -931,12 +958,14 @@ const KasutiEmbroidery: React.FC = () => { // Build a shareable URL that hydrates to this tour on arrival. const buildReplayUrl = useCallback((): string | null => { if (!completed || !startVertex || typeof window === 'undefined') return null; - const encoded = encodeReplay(patternId, customPattern, startVertex, stitches); + const pattern = visiblePatterns.find((p) => p.id === patternId); + if (!pattern) return null; + const encoded = encodeReplay(pattern, startVertex, stitches); const url = new URL(window.location.href); url.search = ''; url.searchParams.set('replay', encoded); return url.toString(); - }, [completed, startVertex, patternId, customPattern, stitches]); + }, [completed, startVertex, patternId, visiblePatterns, stitches]); const copyReplayLink = useCallback(async () => { const url = buildReplayUrl(); @@ -1054,6 +1083,7 @@ const KasutiEmbroidery: React.FC = () => { if (!payload) return; let targetPattern: Pattern | null = null; + let targetPatternId = payload.p; if (payload.p === 'custom' && payload.e) { const customEdges: Array = payload.e.map(([a, b]) => [ [a[0], a[1]] as V, @@ -1066,20 +1096,22 @@ const KasutiEmbroidery: React.FC = () => { maxR = Math.max(maxR, a[0], b[0]); maxC = Math.max(maxC, a[1], b[1]); } + const hydratedId = nextCustomId(); targetPattern = { - id: 'custom', - title: 'Custom', + id: hydratedId, + title: 'Shared motif', description: `${customEdges.length} stitches`, bbox: [maxR, maxC], edges: customEdges, }; - setCustomPattern(targetPattern); + targetPatternId = hydratedId; + setCustomPatterns((prev) => [...prev, targetPattern!]); } else { targetPattern = PATTERNS.find((p) => p.id === payload.p) ?? null; } if (!targetPattern) return; - setPatternId(payload.p); + setPatternId(targetPatternId); // Reconstruct stitches: start at payload.s, alternate sides, each entry's // `to` is the corresponding tour point. @@ -1181,8 +1213,11 @@ const KasutiEmbroidery: React.FC = () => { with making some designs or just get a feel for the pathways the thread will take when you don't have your materials handy, you can try emulating kasuti embroidery below. Pick one of the premade examples or make, save, and share your own designs! +

+

The editor does not currently support disjoint designs so make sure your patterns - are connected. + are connected. The browser will not save your designs, so be sure to download them + separately if you want to come back to them later!

@@ -1200,21 +1235,48 @@ const KasutiEmbroidery: React.FC = () => { p.id)} activeId={patternId} onSelect={selectPattern} - onOpenEditor={() => setEditorOpen(true)} + onEditCustom={(id) => { + setEditingId(id); + setEditorOpen(true); + }} + onOpenEditor={() => { + setEditingId(null); + setEditorOpen(true); + }} /> { + setEditorOpen(open); + if (!open) setEditingId(null); + }} + initial={editingPattern} onSave={(p) => { - setCustomPattern(p); - setPatternId(p.id); + const title = p.title?.trim() || (editingPattern?.title ?? 'Custom'); + if (editingId) { + // Update existing motif in place. + const updated: Pattern = { ...p, id: editingId, title }; + setCustomPatterns((prev) => + prev.map((m) => (m.id === editingId ? updated : m)) + ); + setPatternId(editingId); + } else { + // Brand new motif. + const id = nextCustomId(); + const defaultTitle = + title && title !== 'Custom' ? title : `Custom ${customCounterRef.current}`; + const created: Pattern = { ...p, id, title: defaultTitle }; + setCustomPatterns((prev) => [...prev, created]); + setPatternId(id); + } resetTrace(); + setEditingId(null); setEditorOpen(false); }} /> @@ -1985,6 +2047,7 @@ function parsePatternFile(raw: string): Pattern | null { const PatternEditor: React.FC = ({ open, onOpenChange, initial, onSave }) => { const [edges, setEdges] = useState>(new Set()); + const [title, setTitle] = useState(''); const [loadError, setLoadError] = useState(null); const fileInputRef = useRef(null); @@ -1995,9 +2058,11 @@ const PatternEditor: React.FC = ({ open, onOpenChange, initi setLoadError(null); if (!initial) { setEdges(new Set()); + setTitle(''); return; } setEdges(centeredEdgeSetFromPattern(initial)); + setTitle(initial.title ?? ''); }, [open, initial]); const toggleEdge = useCallback((a: V, b: V) => { @@ -2050,18 +2115,20 @@ const PatternEditor: React.FC = ({ open, onOpenChange, initi // Main grid has GRID_SIZE cells; patterns larger than that will not fit. const [br, bc] = pattern.bbox; if (br > GRID_SIZE || bc > GRID_SIZE) return; - onSave(pattern); - }, [connected, edgeList, onSave]); + const trimmed = title.trim(); + onSave({ ...pattern, title: trimmed || 'Custom' }); + }, [connected, edgeList, title, onSave]); const handleClear = useCallback(() => setEdges(new Set()), []); const handleDownload = useCallback(() => { const pattern = normalizePattern(edgeList); if (!pattern) return; + const trimmed = title.trim() || 'Custom'; const payload: KasutiPatternFile = { kind: 'kasuti-pattern', version: 1, - title: 'Custom', + title: trimmed, edges: pattern.edges.map(([a, b]) => [ [a[0], a[1]], [b[0], b[1]], @@ -2070,13 +2137,14 @@ const PatternEditor: React.FC = ({ open, onOpenChange, initi const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); + const safeName = trimmed.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'motif'; link.href = url; - link.download = `kasuti-motif-${pattern.edges.length}.json`; + link.download = `kasuti-${safeName}-${pattern.edges.length}.json`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); - }, [edgeList]); + }, [edgeList, title]); const handleUpload = useCallback(() => { fileInputRef.current?.click(); @@ -2103,6 +2171,7 @@ const PatternEditor: React.FC = ({ open, onOpenChange, initi } setLoadError(null); setEdges(centeredEdgeSetFromPattern(parsed)); + setTitle(parsed.title ?? ''); }; reader.onerror = () => setLoadError('Could not read that file.'); reader.readAsText(file); @@ -2112,13 +2181,27 @@ const PatternEditor: React.FC = ({ open, onOpenChange, initi - Design your own motif + {initial ? 'Edit motif' : 'Design your own motif'} Click between two dots to add or remove a stitch line. As long as your motif is{' '} connected, an alternating kasuti tour exists on it. +
+ + setTitle(e.target.value)} + placeholder="Custom" + maxLength={40} + className="h-8 text-sm" + /> +
+