kasuti: multiple custom motifs, rename, click-to-edit
State now carries an array of custom patterns instead of one slot.
Each gets a unique id ("custom-<n>") 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
This commit is contained in:
parent
39d173ab75
commit
2ea9de67c1
1 changed files with 112 additions and 29 deletions
|
|
@ -14,6 +14,7 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Toaster as SonnerToaster } from '@/components/ui/sonner';
|
import { Toaster as SonnerToaster } from '@/components/ui/sonner';
|
||||||
|
|
||||||
|
|
@ -220,19 +221,19 @@ function b64urlDecode(s: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodeReplay(
|
function encodeReplay(
|
||||||
patternId: string,
|
pattern: Pattern,
|
||||||
customPattern: Pattern | null,
|
|
||||||
startVertex: V,
|
startVertex: V,
|
||||||
stitches: Stitch[]
|
stitches: Stitch[]
|
||||||
): string {
|
): string {
|
||||||
|
const isCustom = !PATTERNS.some((p) => p.id === pattern.id);
|
||||||
const payload: ReplayPayload = {
|
const payload: ReplayPayload = {
|
||||||
v: 1,
|
v: 1,
|
||||||
p: patternId,
|
p: isCustom ? 'custom' : pattern.id,
|
||||||
s: [startVertex[0], startVertex[1]],
|
s: [startVertex[0], startVertex[1]],
|
||||||
t: stitches.map((st) => [st.to[0], st.to[1]]),
|
t: stitches.map((st) => [st.to[0], st.to[1]]),
|
||||||
};
|
};
|
||||||
if (patternId === 'custom' && customPattern) {
|
if (isCustom) {
|
||||||
payload.e = customPattern.edges.map(([a, b]) => [
|
payload.e = pattern.edges.map(([a, b]) => [
|
||||||
[a[0], a[1]],
|
[a[0], a[1]],
|
||||||
[b[0], b[1]],
|
[b[0], b[1]],
|
||||||
]);
|
]);
|
||||||
|
|
@ -677,12 +678,22 @@ const MiniPreview: React.FC<{ pattern: Pattern; active: boolean }> = ({ pattern,
|
||||||
|
|
||||||
interface MotifReelProps {
|
interface MotifReelProps {
|
||||||
patterns: Pattern[];
|
patterns: Pattern[];
|
||||||
|
customPatternIds: string[];
|
||||||
activeId: string;
|
activeId: string;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
|
onEditCustom: (id: string) => void;
|
||||||
onOpenEditor: () => void;
|
onOpenEditor: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MotifReel: React.FC<MotifReelProps> = ({ patterns, activeId, onSelect, onOpenEditor }) => {
|
const MotifReel: React.FC<MotifReelProps> = ({
|
||||||
|
patterns,
|
||||||
|
customPatternIds,
|
||||||
|
activeId,
|
||||||
|
onSelect,
|
||||||
|
onEditCustom,
|
||||||
|
onOpenEditor,
|
||||||
|
}) => {
|
||||||
|
const customIdSet = useMemo(() => new Set(customPatternIds), [customPatternIds]);
|
||||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [atStart, setAtStart] = useState(true);
|
const [atStart, setAtStart] = useState(true);
|
||||||
const [atEnd, setAtEnd] = useState(false);
|
const [atEnd, setAtEnd] = useState(false);
|
||||||
|
|
@ -726,12 +737,14 @@ const MotifReel: React.FC<MotifReelProps> = ({ patterns, activeId, onSelect, onO
|
||||||
>
|
>
|
||||||
{patterns.map((p) => {
|
{patterns.map((p) => {
|
||||||
const active = p.id === activeId;
|
const active = p.id === activeId;
|
||||||
|
const isCustom = customIdSet.has(p.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={active}
|
aria-checked={active}
|
||||||
onClick={() => onSelect(p.id)}
|
onClick={() => (isCustom ? onEditCustom(p.id) : onSelect(p.id))}
|
||||||
|
title={isCustom ? 'Click to edit or rename this motif' : p.title}
|
||||||
className={`shrink-0 flex flex-col items-center gap-1 rounded-lg border p-2 transition-colors min-w-[92px] ${
|
className={`shrink-0 flex flex-col items-center gap-1 rounded-lg border p-2 transition-colors min-w-[92px] ${
|
||||||
active
|
active
|
||||||
? 'border-primary bg-primary/5 text-foreground'
|
? 'border-primary bg-primary/5 text-foreground'
|
||||||
|
|
@ -742,7 +755,9 @@ const MotifReel: React.FC<MotifReelProps> = ({ patterns, activeId, onSelect, onO
|
||||||
<MiniPreview pattern={p} active={active} />
|
<MiniPreview pattern={p} active={active} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium">{p.title}</span>
|
<span className="text-xs font-medium">{p.title}</span>
|
||||||
<span className="text-[10px] text-muted-foreground">{p.description}</span>
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{isCustom ? 'click to edit' : p.description}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -796,8 +811,10 @@ const MotifReel: React.FC<MotifReelProps> = ({ patterns, activeId, onSelect, onO
|
||||||
|
|
||||||
const KasutiEmbroidery: React.FC = () => {
|
const KasutiEmbroidery: React.FC = () => {
|
||||||
const [patternId, setPatternId] = useState<string>(PATTERNS[0].id);
|
const [patternId, setPatternId] = useState<string>(PATTERNS[0].id);
|
||||||
const [customPattern, setCustomPattern] = useState<Pattern | null>(null);
|
const [customPatterns, setCustomPatterns] = useState<Pattern[]>([]);
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null); // null = new motif
|
||||||
|
const customCounterRef = useRef(0);
|
||||||
const [currentVertex, setCurrentVertex] = useState<V | null>(null);
|
const [currentVertex, setCurrentVertex] = useState<V | null>(null);
|
||||||
const [startVertex, setStartVertex] = useState<V | null>(null);
|
const [startVertex, setStartVertex] = useState<V | null>(null);
|
||||||
const [activeSide, setActiveSide] = useState<Side>('front');
|
const [activeSide, setActiveSide] = useState<Side>('front');
|
||||||
|
|
@ -811,10 +828,20 @@ const KasutiEmbroidery: React.FC = () => {
|
||||||
const celebratedRef = useRef(false);
|
const celebratedRef = useRef(false);
|
||||||
|
|
||||||
const visiblePatterns = useMemo(
|
const visiblePatterns = useMemo(
|
||||||
() => (customPattern ? [...PATTERNS, customPattern] : PATTERNS),
|
() => [...PATTERNS, ...customPatterns],
|
||||||
[customPattern]
|
[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 placed = useMemo(() => {
|
||||||
const p = visiblePatterns.find((x) => x.id === patternId) ?? PATTERNS[0];
|
const p = visiblePatterns.find((x) => x.id === patternId) ?? PATTERNS[0];
|
||||||
return placePattern(p);
|
return placePattern(p);
|
||||||
|
|
@ -931,12 +958,14 @@ const KasutiEmbroidery: React.FC = () => {
|
||||||
// Build a shareable URL that hydrates to this tour on arrival.
|
// Build a shareable URL that hydrates to this tour on arrival.
|
||||||
const buildReplayUrl = useCallback((): string | null => {
|
const buildReplayUrl = useCallback((): string | null => {
|
||||||
if (!completed || !startVertex || typeof window === 'undefined') return 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);
|
const url = new URL(window.location.href);
|
||||||
url.search = '';
|
url.search = '';
|
||||||
url.searchParams.set('replay', encoded);
|
url.searchParams.set('replay', encoded);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}, [completed, startVertex, patternId, customPattern, stitches]);
|
}, [completed, startVertex, patternId, visiblePatterns, stitches]);
|
||||||
|
|
||||||
const copyReplayLink = useCallback(async () => {
|
const copyReplayLink = useCallback(async () => {
|
||||||
const url = buildReplayUrl();
|
const url = buildReplayUrl();
|
||||||
|
|
@ -1054,6 +1083,7 @@ const KasutiEmbroidery: React.FC = () => {
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
|
|
||||||
let targetPattern: Pattern | null = null;
|
let targetPattern: Pattern | null = null;
|
||||||
|
let targetPatternId = payload.p;
|
||||||
if (payload.p === 'custom' && payload.e) {
|
if (payload.p === 'custom' && payload.e) {
|
||||||
const customEdges: Array<readonly [V, V]> = payload.e.map(([a, b]) => [
|
const customEdges: Array<readonly [V, V]> = payload.e.map(([a, b]) => [
|
||||||
[a[0], a[1]] as V,
|
[a[0], a[1]] as V,
|
||||||
|
|
@ -1066,20 +1096,22 @@ const KasutiEmbroidery: React.FC = () => {
|
||||||
maxR = Math.max(maxR, a[0], b[0]);
|
maxR = Math.max(maxR, a[0], b[0]);
|
||||||
maxC = Math.max(maxC, a[1], b[1]);
|
maxC = Math.max(maxC, a[1], b[1]);
|
||||||
}
|
}
|
||||||
|
const hydratedId = nextCustomId();
|
||||||
targetPattern = {
|
targetPattern = {
|
||||||
id: 'custom',
|
id: hydratedId,
|
||||||
title: 'Custom',
|
title: 'Shared motif',
|
||||||
description: `${customEdges.length} stitches`,
|
description: `${customEdges.length} stitches`,
|
||||||
bbox: [maxR, maxC],
|
bbox: [maxR, maxC],
|
||||||
edges: customEdges,
|
edges: customEdges,
|
||||||
};
|
};
|
||||||
setCustomPattern(targetPattern);
|
targetPatternId = hydratedId;
|
||||||
|
setCustomPatterns((prev) => [...prev, targetPattern!]);
|
||||||
} else {
|
} else {
|
||||||
targetPattern = PATTERNS.find((p) => p.id === payload.p) ?? null;
|
targetPattern = PATTERNS.find((p) => p.id === payload.p) ?? null;
|
||||||
}
|
}
|
||||||
if (!targetPattern) return;
|
if (!targetPattern) return;
|
||||||
|
|
||||||
setPatternId(payload.p);
|
setPatternId(targetPatternId);
|
||||||
|
|
||||||
// Reconstruct stitches: start at payload.s, alternate sides, each entry's
|
// Reconstruct stitches: start at payload.s, alternate sides, each entry's
|
||||||
// `to` is the corresponding tour point.
|
// `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
|
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
|
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!
|
below. Pick one of the premade examples or make, save, and share your own designs!
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium text-foreground max-w-3xl mx-auto">
|
||||||
The editor does not currently support disjoint designs so make sure your patterns
|
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!
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -1200,21 +1235,48 @@ const KasutiEmbroidery: React.FC = () => {
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<MotifReel
|
<MotifReel
|
||||||
patterns={visiblePatterns}
|
patterns={visiblePatterns}
|
||||||
|
customPatternIds={customPatterns.map((p) => p.id)}
|
||||||
activeId={patternId}
|
activeId={patternId}
|
||||||
onSelect={selectPattern}
|
onSelect={selectPattern}
|
||||||
onOpenEditor={() => setEditorOpen(true)}
|
onEditCustom={(id) => {
|
||||||
|
setEditingId(id);
|
||||||
|
setEditorOpen(true);
|
||||||
|
}}
|
||||||
|
onOpenEditor={() => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditorOpen(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<PatternEditor
|
<PatternEditor
|
||||||
open={editorOpen}
|
open={editorOpen}
|
||||||
onOpenChange={setEditorOpen}
|
onOpenChange={(open) => {
|
||||||
initial={customPattern}
|
setEditorOpen(open);
|
||||||
|
if (!open) setEditingId(null);
|
||||||
|
}}
|
||||||
|
initial={editingPattern}
|
||||||
onSave={(p) => {
|
onSave={(p) => {
|
||||||
setCustomPattern(p);
|
const title = p.title?.trim() || (editingPattern?.title ?? 'Custom');
|
||||||
setPatternId(p.id);
|
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();
|
resetTrace();
|
||||||
|
setEditingId(null);
|
||||||
setEditorOpen(false);
|
setEditorOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1985,6 +2047,7 @@ function parsePatternFile(raw: string): Pattern | null {
|
||||||
|
|
||||||
const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initial, onSave }) => {
|
const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initial, onSave }) => {
|
||||||
const [edges, setEdges] = useState<Set<EdgeKey>>(new Set());
|
const [edges, setEdges] = useState<Set<EdgeKey>>(new Set());
|
||||||
|
const [title, setTitle] = useState<string>('');
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
|
@ -1995,9 +2058,11 @@ const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initi
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
if (!initial) {
|
if (!initial) {
|
||||||
setEdges(new Set());
|
setEdges(new Set());
|
||||||
|
setTitle('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setEdges(centeredEdgeSetFromPattern(initial));
|
setEdges(centeredEdgeSetFromPattern(initial));
|
||||||
|
setTitle(initial.title ?? '');
|
||||||
}, [open, initial]);
|
}, [open, initial]);
|
||||||
|
|
||||||
const toggleEdge = useCallback((a: V, b: V) => {
|
const toggleEdge = useCallback((a: V, b: V) => {
|
||||||
|
|
@ -2050,18 +2115,20 @@ const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initi
|
||||||
// Main grid has GRID_SIZE cells; patterns larger than that will not fit.
|
// Main grid has GRID_SIZE cells; patterns larger than that will not fit.
|
||||||
const [br, bc] = pattern.bbox;
|
const [br, bc] = pattern.bbox;
|
||||||
if (br > GRID_SIZE || bc > GRID_SIZE) return;
|
if (br > GRID_SIZE || bc > GRID_SIZE) return;
|
||||||
onSave(pattern);
|
const trimmed = title.trim();
|
||||||
}, [connected, edgeList, onSave]);
|
onSave({ ...pattern, title: trimmed || 'Custom' });
|
||||||
|
}, [connected, edgeList, title, onSave]);
|
||||||
|
|
||||||
const handleClear = useCallback(() => setEdges(new Set()), []);
|
const handleClear = useCallback(() => setEdges(new Set()), []);
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const handleDownload = useCallback(() => {
|
||||||
const pattern = normalizePattern(edgeList);
|
const pattern = normalizePattern(edgeList);
|
||||||
if (!pattern) return;
|
if (!pattern) return;
|
||||||
|
const trimmed = title.trim() || 'Custom';
|
||||||
const payload: KasutiPatternFile = {
|
const payload: KasutiPatternFile = {
|
||||||
kind: 'kasuti-pattern',
|
kind: 'kasuti-pattern',
|
||||||
version: 1,
|
version: 1,
|
||||||
title: 'Custom',
|
title: trimmed,
|
||||||
edges: pattern.edges.map(([a, b]) => [
|
edges: pattern.edges.map(([a, b]) => [
|
||||||
[a[0], a[1]],
|
[a[0], a[1]],
|
||||||
[b[0], b[1]],
|
[b[0], b[1]],
|
||||||
|
|
@ -2070,13 +2137,14 @@ const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initi
|
||||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
|
const safeName = trimmed.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'motif';
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = `kasuti-motif-${pattern.edges.length}.json`;
|
link.download = `kasuti-${safeName}-${pattern.edges.length}.json`;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}, [edgeList]);
|
}, [edgeList, title]);
|
||||||
|
|
||||||
const handleUpload = useCallback(() => {
|
const handleUpload = useCallback(() => {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
|
|
@ -2103,6 +2171,7 @@ const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initi
|
||||||
}
|
}
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
setEdges(centeredEdgeSetFromPattern(parsed));
|
setEdges(centeredEdgeSetFromPattern(parsed));
|
||||||
|
setTitle(parsed.title ?? '');
|
||||||
};
|
};
|
||||||
reader.onerror = () => setLoadError('Could not read that file.');
|
reader.onerror = () => setLoadError('Could not read that file.');
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
|
|
@ -2112,13 +2181,27 @@ const PatternEditor: React.FC<PatternEditorProps> = ({ open, onOpenChange, initi
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-lg md:max-w-2xl">
|
<DialogContent className="sm:max-w-lg md:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Design your own motif</DialogTitle>
|
<DialogTitle>{initial ? 'Edit motif' : 'Design your own motif'}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Click between two dots to add or remove a stitch line. As long as your motif is{' '}
|
Click between two dots to add or remove a stitch line. As long as your motif is{' '}
|
||||||
<em>connected</em>, an alternating kasuti tour exists on it.
|
<em>connected</em>, an alternating kasuti tour exists on it.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="kasuti-motif-title" className="text-xs text-muted-foreground shrink-0">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="kasuti-motif-title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Custom"
|
||||||
|
maxLength={40}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center overflow-auto">
|
<div className="flex justify-center overflow-auto">
|
||||||
<svg
|
<svg
|
||||||
width={EDITOR_DIM}
|
width={EDITOR_DIM}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue