Spaces:
Sleeping
Sleeping
| import * as fabric from "fabric"; | |
| import { jsPDF } from "jspdf"; | |
| import { useCallback, useEffect, useRef, useState } from "react"; | |
| import { | |
| ArrowDownToLine, | |
| ArrowUpToLine, | |
| Cloud, | |
| Copy, | |
| Download, | |
| Eraser, | |
| FileDown, | |
| FlipHorizontal, | |
| FlipVertical, | |
| FolderOpen, | |
| Grid3x3, | |
| Group, | |
| HelpCircle, | |
| Maximize, | |
| MousePointer2, | |
| Redo2, | |
| RotateCw, | |
| Ruler, | |
| Save, | |
| Scissors, | |
| Trash2, | |
| Undo2, | |
| Ungroup, | |
| ZoomIn, | |
| ZoomOut, | |
| LogOut, | |
| Users, | |
| UserRound, | |
| MessageSquarePlus, | |
| } from "lucide-react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogDescription, | |
| DialogHeader, | |
| DialogTitle, | |
| } from "@/components/ui/dialog"; | |
| import { Slider } from "@/components/ui/slider"; | |
| import { | |
| CATEGORIES, | |
| LIBRARY, | |
| SNAP_STEPS, | |
| SYMMETRY_AXES, | |
| THREADS, | |
| threadMeters, | |
| type ElDef, | |
| type ThreadColor, | |
| } from "@/lib/tatting-ru"; | |
| import { applyThread, applyWidth, buildElement, CUSTOM_PROPS, type TattingObject } from "@/lib/fabric-elements"; | |
| import { AUTOSAVE_KEY, newId, upsertProject, type StoredProject } from "@/lib/studio-storage"; | |
| import { currentUser, logout, roleLabel, type StoredUser } from "@/lib/auth-service"; | |
| import LoginDialog from "@/components/studio/LoginDialog"; | |
| import AdminPanel from "@/components/studio/AdminPanel"; | |
| import ProjectsDialog from "@/components/studio/ProjectsDialog"; | |
| import ProfileDialog from "@/components/studio/ProfileDialog"; | |
| import FeedbackDialog from "@/components/studio/FeedbackDialog"; | |
| const W = 1200; | |
| const H = 850; | |
| type Theme = "light" | "dark" | "grid"; | |
| const THEME_CFG: Record<Theme, { bg: string; line: string; bold: string; label: string; hint: string }> = { | |
| light: { bg: "#fdfaf3", line: "#e6dfd0", bold: "#d6cbb4", label: "Светлая", hint: "Светлая тема" }, | |
| dark: { bg: "#14161b", line: "#2a2f3a", bold: "#3f4757", label: "Тёмная", hint: "Тёмная тема с контрастной сеткой" }, | |
| grid: { bg: "#eef5fb", line: "#c3d9ee", bold: "#8fb6dc", label: "Миллиметровка", hint: "Чертёжная миллиметровка" }, | |
| }; | |
| export default function TattingStudio() { | |
| const elRef = useRef<HTMLCanvasElement | null>(null); | |
| const wrapRef = useRef<HTMLDivElement | null>(null); | |
| const fcRef = useRef<fabric.Canvas | null>(null); | |
| const history = useRef<{ stack: string[]; index: number; lock: boolean }>({ stack: [], index: -1, lock: false }); | |
| const fileRef = useRef<HTMLInputElement | null>(null); | |
| const pinch = useRef<{ dist: number; x: number; y: number } | null>(null); | |
| const drag = useRef<{ x: number; y: number } | null>(null); | |
| const savedTimer = useRef<number | null>(null); | |
| const [unlocked, setUnlocked] = useState(true); | |
| const [theme, setTheme] = useState<Theme>("light"); | |
| const [snap, setSnap] = useState(10); | |
| const [showGrid, setShowGrid] = useState(true); | |
| const [showRulers, setShowRulers] = useState(true); | |
| const [zoom, setZoom] = useState(1); | |
| const [pan, setPan] = useState({ x: 0, y: 0 }); | |
| const [panning, setPanning] = useState(false); | |
| const [booting, setBooting] = useState(true); | |
| const [symmetry, setSymmetry] = useState(1); | |
| const [thread, setThread] = useState<ThreadColor>(THREADS[0]!); | |
| const [width, setWidth] = useState(3); | |
| const [selected, setSelected] = useState<{ name: string; knots: number; thread: string } | null>(null); | |
| const [stats, setStats] = useState({ knots: 0, pathPx: 0, count: 0 }); | |
| const [canUndo, setCanUndo] = useState(false); | |
| const [canRedo, setCanRedo] = useState(false); | |
| const [category, setCategory] = useState<string>(CATEGORIES[0]!); | |
| const [helpOpen, setHelpOpen] = useState(false); | |
| const [projectsOpen, setProjectsOpen] = useState(false); | |
| const [profileOpen, setProfileOpen] = useState(false); | |
| const [feedbackOpen, setFeedbackOpen] = useState(false); | |
| const [saved, setSaved] = useState(false); | |
| const [user, setUser] = useState<StoredUser | null>(null); | |
| const [adminOpen, setAdminOpen] = useState(false); | |
| useEffect(() => { | |
| const session = currentUser(); | |
| setUser(session); | |
| setUnlocked(!!session && !session.temporary); | |
| }, []); | |
| const drawGrid = useCallback((canvas: fabric.Canvas, step: number, th: Theme, visible: boolean) => { | |
| const cfg = THEME_CFG[th]; | |
| canvas.backgroundColor = cfg.bg; | |
| canvas.getObjects().forEach((o) => { | |
| if ((o as TattingObject).isGrid) canvas.remove(o); | |
| }); | |
| if (visible) { | |
| const lines: fabric.FabricObject[] = []; | |
| const mk = (coords: [number, number, number, number], bold: boolean) => { | |
| const l = new fabric.Line(coords, { | |
| stroke: bold ? cfg.bold : cfg.line, | |
| strokeWidth: bold ? 1 : 0.5, | |
| selectable: false, | |
| evented: false, | |
| }) as unknown as TattingObject; | |
| l.isGrid = true; | |
| lines.push(l as unknown as fabric.FabricObject); | |
| }; | |
| for (let x = 0; x <= W; x += step) mk([x, 0, x, H], x % (step * 5) === 0); | |
| for (let y = 0; y <= H; y += step) mk([0, y, W, y], y % (step * 5) === 0); | |
| lines.forEach((l) => { | |
| canvas.add(l); | |
| canvas.sendObjectToBack(l); | |
| }); | |
| } | |
| canvas.requestRenderAll(); | |
| }, []); | |
| const recalc = useCallback((canvas: fabric.Canvas) => { | |
| const objs = canvas.getObjects().filter((o) => !(o as TattingObject).isGrid); | |
| const knots = objs.reduce((s, o) => s + ((o as TattingObject).knots ?? 0), 0); | |
| const pathPx = objs.reduce((s, o) => s + ((o as TattingObject).pathPx ?? 0) * (o.scaleX ?? 1), 0); | |
| setStats({ knots, pathPx, count: objs.length }); | |
| }, []); | |
| /** автосохранение текущей схемы в память браузера */ | |
| const persist = useCallback((canvas: fabric.Canvas) => { | |
| try { | |
| const data = canvas.toObject(CUSTOM_PROPS) as { objects: { isGrid?: boolean }[] }; | |
| data.objects = data.objects.filter((o) => !o.isGrid); | |
| localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data)); | |
| setSaved(true); | |
| if (savedTimer.current) window.clearTimeout(savedTimer.current); | |
| savedTimer.current = window.setTimeout(() => setSaved(false), 1400); | |
| } catch { | |
| /* приватный режим — просто пропускаем */ | |
| } | |
| }, []); | |
| const snapshot = useCallback( | |
| (canvas: fabric.Canvas) => { | |
| if (history.current.lock) return; | |
| const json = JSON.stringify(canvas.toObject(CUSTOM_PROPS)); | |
| const h = history.current; | |
| h.stack = h.stack.slice(0, h.index + 1); | |
| h.stack.push(json); | |
| if (h.stack.length > 40) h.stack.shift(); | |
| h.index = h.stack.length - 1; | |
| setCanUndo(h.index > 0); | |
| setCanRedo(false); | |
| recalc(canvas); | |
| persist(canvas); | |
| }, | |
| [recalc, persist], | |
| ); | |
| // init | |
| useEffect(() => { | |
| if (!unlocked || !elRef.current) return; | |
| const canvas = new fabric.Canvas(elRef.current, { | |
| width: W, | |
| height: H, | |
| backgroundColor: THEME_CFG.light.bg, | |
| preserveObjectStacking: true, | |
| }); | |
| fcRef.current = canvas; | |
| drawGrid(canvas, 10, "light", true); | |
| const onSelect = () => { | |
| const a = canvas.getActiveObject() as TattingObject | undefined; | |
| setSelected( | |
| a && a.elName | |
| ? { name: a.elName, knots: a.knots ?? 0, thread: a.threadName ?? "—" } | |
| : a | |
| ? { name: "Несколько элементов", knots: 0, thread: "—" } | |
| : null, | |
| ); | |
| }; | |
| canvas.on("selection:created", onSelect); | |
| canvas.on("selection:updated", onSelect); | |
| canvas.on("selection:cleared", () => setSelected(null)); | |
| canvas.on("object:added", () => snapshot(canvas)); | |
| canvas.on("object:modified", () => snapshot(canvas)); | |
| canvas.on("object:removed", () => snapshot(canvas)); | |
| const stored = typeof localStorage !== "undefined" ? localStorage.getItem(AUTOSAVE_KEY) : null; | |
| if (stored) { | |
| history.current.lock = true; | |
| void canvas | |
| .loadFromJSON(stored) | |
| .then(() => { | |
| drawGrid(canvas, 10, "light", true); | |
| canvas.requestRenderAll(); | |
| history.current.lock = false; | |
| snapshot(canvas); | |
| }) | |
| .catch(() => { | |
| history.current.lock = false; | |
| }) | |
| .finally(() => window.setTimeout(() => setBooting(false), 220)); | |
| } else { | |
| window.setTimeout(() => setBooting(false), 220); | |
| } | |
| return () => { | |
| void canvas.dispose(); | |
| fcRef.current = null; | |
| }; | |
| }, [unlocked, drawGrid, snapshot]); | |
| // привязка к сетке | |
| useEffect(() => { | |
| const canvas = fcRef.current; | |
| if (!canvas) return; | |
| const handler = (e: { target: fabric.FabricObject }) => { | |
| const t = e.target; | |
| t.set({ left: Math.round(t.left / snap) * snap, top: Math.round(t.top / snap) * snap }); | |
| }; | |
| canvas.on("object:moving", handler as never); | |
| return () => canvas.off("object:moving", handler as never); | |
| }, [snap]); | |
| // тема / шаг сетки | |
| useEffect(() => { | |
| const canvas = fcRef.current; | |
| if (!canvas) return; | |
| history.current.lock = true; | |
| drawGrid(canvas, snap, theme, showGrid); | |
| history.current.lock = false; | |
| document.documentElement.classList.toggle("dark", theme === "dark"); | |
| }, [theme, snap, showGrid, drawGrid]); | |
| /** не даём уехать схеме за пределы окна: сцена всегда перекрывает вид */ | |
| const clampVpt = useCallback((canvas: fabric.Canvas) => { | |
| const z = canvas.getZoom(); | |
| const vpt = canvas.viewportTransform; | |
| const limX = Math.max(0, W * z - W); | |
| const limY = Math.max(0, H * z - H); | |
| vpt[4] = Math.min(W * 0.25, Math.max(-limX - W * 0.25, vpt[4])); | |
| vpt[5] = Math.min(H * 0.25, Math.max(-limY - H * 0.25, vpt[5])); | |
| canvas.setViewportTransform(vpt); | |
| setZoom(z); | |
| setPan({ x: vpt[4], y: vpt[5] }); | |
| }, []); | |
| const applyZoom = useCallback( | |
| (next: number, px?: number, py?: number) => { | |
| const canvas = fcRef.current; | |
| if (!canvas) return; | |
| const z = Math.min(6, Math.max(0.3, next)); | |
| canvas.zoomToPoint(new fabric.Point(px ?? W / 2, py ?? H / 2), z); | |
| clampVpt(canvas); | |
| canvas.requestRenderAll(); | |
| }, | |
| [clampVpt], | |
| ); | |
| const panBy = useCallback( | |
| (dx: number, dy: number) => { | |
| const canvas = fcRef.current; | |
| if (!canvas) return; | |
| const vpt = canvas.viewportTransform; | |
| vpt[4] += dx; | |
| vpt[5] += dy; | |
| clampVpt(canvas); | |
| canvas.requestRenderAll(); | |
| }, | |
| [clampVpt], | |
| ); | |
| const fitToScreen = useCallback(() => { | |
| const canvas = fcRef.current; | |
| if (!canvas) return; | |
| canvas.setViewportTransform([1, 0, 0, 1, 0, 0]); | |
| setZoom(1); | |
| setPan({ x: 0, y: 0 }); | |
| canvas.requestRenderAll(); | |
| }, []); | |
| // колесо мыши, панорама правой кнопкой и жесты касания | |
| useEffect(() => { | |
| const wrap = wrapRef.current; | |
| if (!wrap || !unlocked) return; | |
| const scale = () => { | |
| const r = wrap.getBoundingClientRect(); | |
| return { kx: W / (r.width || 1), ky: H / (r.height || 1), r }; | |
| }; | |
| const toScene = (cx: number, cy: number) => { | |
| const { kx, ky, r } = scale(); | |
| return { x: (cx - r.left) * kx, y: (cy - r.top) * ky }; | |
| }; | |
| const onWheel = (e: WheelEvent) => { | |
| const canvas = fcRef.current; | |
| if (!canvas) return; | |
| e.preventDefault(); | |
| const dy = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 100 : 1); | |
| const p = toScene(e.clientX, e.clientY); | |
| applyZoom(canvas.getZoom() * Math.exp(-dy * 0.0015), p.x, p.y); | |
| }; | |
| // правая кнопка мыши — перетаскивание холста | |
| const onDown = (e: PointerEvent) => { | |
| if (e.button !== 2 && e.button !== 1) return; | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| drag.current = { x: e.clientX, y: e.clientY }; | |
| setPanning(true); | |
| wrap.setPointerCapture(e.pointerId); | |
| }; | |
| const onPointerMove = (e: PointerEvent) => { | |
| if (!drag.current) return; | |
| e.preventDefault(); | |
| const { kx, ky } = scale(); | |
| panBy((e.clientX - drag.current.x) * kx, (e.clientY - drag.current.y) * ky); | |
| drag.current = { x: e.clientX, y: e.clientY }; | |
| }; | |
| const onUp = () => { | |
| drag.current = null; | |
| setPanning(false); | |
| }; | |
| const onContext = (e: MouseEvent) => e.preventDefault(); | |
| const dist = (t: TouchList) => Math.hypot(t[0]!.clientX - t[1]!.clientX, t[0]!.clientY - t[1]!.clientY); | |
| const mid = (t: TouchList) => ({ | |
| x: (t[0]!.clientX + t[1]!.clientX) / 2, | |
| y: (t[0]!.clientY + t[1]!.clientY) / 2, | |
| }); | |
| const onStart = (e: TouchEvent) => { | |
| if (e.touches.length === 2) { | |
| const m = mid(e.touches); | |
| const c = toScene(m.x, m.y); | |
| pinch.current = { dist: dist(e.touches), x: c.x, y: c.y }; | |
| drag.current = { x: m.x, y: m.y }; | |
| } | |
| }; | |
| const onTouchMove = (e: TouchEvent) => { | |
| const canvas = fcRef.current; | |
| if (!canvas || e.touches.length !== 2 || !pinch.current || !drag.current) return; | |
| e.preventDefault(); | |
| const d = dist(e.touches); | |
| const m = mid(e.touches); | |
| const c = toScene(m.x, m.y); | |
| const { kx, ky } = scale(); | |
| applyZoom(canvas.getZoom() * (d / pinch.current.dist), c.x, c.y); | |
| panBy((m.x - drag.current.x) * kx, (m.y - drag.current.y) * ky); | |
| pinch.current = { dist: d, x: c.x, y: c.y }; | |
| drag.current = { x: m.x, y: m.y }; | |
| }; | |
| const onEnd = () => { | |
| pinch.current = null; | |
| drag.current = null; | |
| }; | |
| wrap.addEventListener("wheel", onWheel, { passive: false }); | |
| wrap.addEventListener("pointerdown", onDown, true); | |
| wrap.addEventListener("pointermove", onPointerMove, true); | |
| wrap.addEventListener("pointerup", onUp, true); | |
| wrap.addEventListener("pointercancel", onUp, true); | |
| wrap.addEventListener("contextmenu", onContext); | |
| wrap.addEventListener("touchstart", onStart, { passive: false }); | |
| wrap.addEventListener("touchmove", onTouchMove, { passive: false }); | |
| wrap.addEventListener("touchend", onEnd); | |
| return () => { | |
| wrap.removeEventListener("wheel", onWheel); | |
| wrap.removeEventListener("pointerdown", onDown, true); | |
| wrap.removeEventListener("pointermove", onPointerMove, true); | |
| wrap.removeEventListener("pointerup", onUp, true); | |
| wrap.removeEventListener("pointercancel", onUp, true); | |
| wrap.removeEventListener("contextmenu", onContext); | |
| wrap.removeEventListener("touchstart", onStart); | |
| wrap.removeEventListener("touchmove", onTouchMove); | |
| wrap.removeEventListener("touchend", onEnd); | |
| }; | |
| }, [unlocked, applyZoom, panBy]); | |
| const addElement = (def: ElDef) => { | |
| const canvas = fcRef.current; | |
| if (!canvas) return; | |
| const cx = W / 2; | |
| const cy = H / 2; | |
| const axes = def.kind === "text" ? 1 : symmetry; | |
| if (axes <= 1) { | |
| const obj = buildElement(def, thread, width); | |
| obj.set({ left: cx, top: cy }); | |
| canvas.add(obj); | |
| canvas.setActiveObject(obj); | |
| } else { | |
| const dist = 160; | |
| for (let i = 0; i < axes; i++) { | |
| const a = (i / axes) * Math.PI * 2; | |
| const obj = buildElement(def, thread, width); | |
| obj.set({ | |
| left: Math.round((cx + Math.cos(a) * dist) / snap) * snap, | |
| top: Math.round((cy + Math.sin(a) * dist) / snap) * snap, | |
| angle: (a * 180) / Math.PI + 90, | |
| }); | |
| canvas.add(obj); | |
| } | |
| } | |
| canvas.requestRenderAll(); | |
| }; | |
| const withActive = (fn: (obj: TattingObject, canvas: fabric.Canvas) => void) => { | |
| const canvas = fcRef.current; | |
| const a = canvas?.getActiveObject() as TattingObject | undefined; | |
| if (!canvas || !a) return; | |
| fn(a, canvas); | |
| canvas.requestRenderAll(); | |
| }; | |
| const duplicate = () => | |
| withActive(async (a, canvas) => { | |
| const clone = (await a.clone(CUSTOM_PROPS as never)) as TattingObject; | |
| clone.set({ left: a.left + snap * 3, top: a.top + snap * 3 }); | |
| canvas.add(clone); | |
| canvas.setActiveObject(clone); | |
| canvas.requestRenderAll(); | |
| }); | |
| const groupSelected = () => { | |
| const canvas = fcRef.current; | |
| const a = canvas?.getActiveObject(); | |
| if (!canvas || !a || a.type !== "activeselection") return; | |
| const sel = a as fabric.ActiveSelection; | |
| const g = new fabric.Group(sel.removeAll()) as TattingObject; | |
| g.elName = "Группа элементов"; | |
| g.knots = 0; | |
| canvas.add(g); | |
| canvas.setActiveObject(g); | |
| canvas.requestRenderAll(); | |
| snapshot(canvas); | |
| }; | |
| const ungroupSelected = () => { | |
| const canvas = fcRef.current; | |
| const a = canvas?.getActiveObject() as fabric.Group | undefined; | |
| if (!canvas || !a || a.type !== "group") return; | |
| const items = a.removeAll(); | |
| canvas.remove(a); | |
| items.forEach((o) => canvas.add(o)); | |
| canvas.discardActiveObject(); | |
| canvas.requestRenderAll(); | |
| snapshot(canvas); | |
| }; | |
| const restore = (dir: -1 | 1) => { | |
| const canvas = fcRef.current; | |
| const h = history.current; | |
| const next = h.index + dir; | |
| if (!canvas || next < 0 || next >= h.stack.length) return; | |
| h.index = next; | |
| h.lock = true; | |
| void canvas.loadFromJSON(h.stack[next]!).then(() => { | |
| drawGrid(canvas, snap, theme, showGrid); | |
| canvas.requestRenderAll(); | |
| h.lock = false; | |
| setCanUndo(h.index > 0); | |
| setCanRedo(h.index < h.stack.length - 1); | |
| recalc(canvas); | |
| }); | |
| }; | |
| useEffect(() => { | |
| const onKey = (e: KeyboardEvent) => { | |
| if (!(e.ctrlKey || e.metaKey)) return; | |
| if (e.key.toLowerCase() === "z") { | |
| e.preventDefault(); | |
| restore(e.shiftKey ? 1 : -1); | |
| } | |
| if (e.key.toLowerCase() === "y") { | |
| e.preventDefault(); | |
| restore(1); | |
| } | |
| }; | |
| window.addEventListener("keydown", onKey); | |
| return () => window.removeEventListener("keydown", onKey); | |
| }); | |
| const exportPng = () => { | |
| const canvas = fcRef.current; | |
| if (!canvas) return; | |
| const url = canvas.toDataURL({ format: "png", multiplier: 3 }); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = "frivolite-uzor.png"; | |
| a.click(); | |
| }; | |
| const exportPdf = () => { | |
| const canvas = fcRef.current; | |
| if (!canvas) return; | |
| const url = canvas.toDataURL({ format: "png", multiplier: 3 }); | |
| const pdf = new jsPDF({ orientation: "landscape", unit: "mm", format: "a4" }); | |
| const ratio = Math.min((297 - 20) / W, (210 - 24) / H); | |
| pdf.setFontSize(12); | |
| pdf.text("Shema frivolite / RealKnot Studio", 10, 12); | |
| pdf.addImage(url, "PNG", 10, 16, W * ratio, H * ratio); | |
| pdf.save("frivolite-uzor.pdf"); | |
| }; | |
| const currentData = () => { | |
| const canvas = fcRef.current; | |
| if (!canvas) return null; | |
| const data = canvas.toObject(CUSTOM_PROPS) as { objects: { isGrid?: boolean }[] }; | |
| data.objects = data.objects.filter((o) => !o.isGrid); | |
| return data; | |
| }; | |
| const saveProjectFile = () => { | |
| const data = currentData(); | |
| if (!data) return; | |
| const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); | |
| const a = document.createElement("a"); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = "frivolite-proekt.json"; | |
| a.click(); | |
| URL.revokeObjectURL(a.href); | |
| }; | |
| const saveToGallery = () => { | |
| const canvas = fcRef.current; | |
| const data = currentData(); | |
| if (!canvas || !data) return; | |
| const title = window.prompt("Название проекта", `Схема ${new Date().toLocaleDateString("ru-RU")}`); | |
| if (!title) return; | |
| if (!user) return; | |
| const project: StoredProject = { | |
| id: newId(), | |
| ownerId: user.id, | |
| title: title.trim(), | |
| updatedAt: Date.now(), | |
| elements: stats.count, | |
| thumb: canvas.toDataURL({ format: "png", multiplier: 0.25 }), | |
| data, | |
| }; | |
| upsertProject(project); | |
| setProjectsOpen(false); | |
| window.setTimeout(() => setProjectsOpen(true), 120); | |
| }; | |
| const loadData = (payload: string | object) => { | |
| const canvas = fcRef.current; | |
| if (!canvas) return; | |
| history.current.lock = true; | |
| void canvas.loadFromJSON(payload as never).then(() => { | |
| drawGrid(canvas, snap, theme, showGrid); | |
| canvas.requestRenderAll(); | |
| history.current.lock = false; | |
| snapshot(canvas); | |
| }); | |
| }; | |
| const openFile = (file: File) => void file.text().then((t) => loadData(t)); | |
| const deleteSelected = () => | |
| withActive((_a, c) => { | |
| c.getActiveObjects().forEach((o) => c.remove(o)); | |
| c.discardActiveObject(); | |
| c.requestRenderAll(); | |
| }); | |
| const clearCanvas = () => { | |
| const canvas = fcRef.current; | |
| if (!canvas) return; | |
| if (!window.confirm("Очистить холст? Все элементы схемы будут удалены.")) return; | |
| canvas.discardActiveObject(); | |
| canvas | |
| .getObjects() | |
| .filter((o) => !(o as TattingObject).isGrid) | |
| .forEach((o) => canvas.remove(o)); | |
| canvas.requestRenderAll(); | |
| snapshot(canvas); | |
| }; | |
| const meters = threadMeters(stats.pathPx); | |
| if (!unlocked || !user) | |
| return ( | |
| <LoginDialog | |
| onSignedIn={(signed) => { | |
| setUser(signed); | |
| setUnlocked(true); | |
| }} | |
| /> | |
| ); | |
| return ( | |
| <div className="flex h-[100dvh] flex-col overflow-hidden bg-background font-sans text-foreground"> | |
| <input | |
| ref={fileRef} | |
| type="file" | |
| accept="application/json" | |
| className="hidden" | |
| onChange={(e) => { | |
| const f = e.target.files?.[0]; | |
| if (f) openFile(f); | |
| e.target.value = ""; | |
| }} | |
| /> | |
| <header className="flex shrink-0 items-center gap-2 border-b border-border bg-card px-3 py-2"> | |
| <div className="min-w-0 flex-1"> | |
| <h1 className="truncate font-display text-xl leading-none lg:text-2xl">RealKnot Studio</h1> | |
| <p className="truncate text-[11px] text-muted-foreground"> | |
| Редактор схем плетения · сетка {snap} px · правая кнопка — перемещение, колесо — масштаб | |
| </p> | |
| </div> | |
| <div className="hidden rounded-lg border border-border p-0.5 md:flex"> | |
| {(Object.keys(THEME_CFG) as Theme[]).map((t) => ( | |
| <button | |
| key={t} | |
| onClick={() => setTheme(t)} | |
| title={THEME_CFG[t].hint} | |
| className={`h-8 rounded-md px-2 text-xs font-medium transition-colors ${ | |
| theme === t ? "bg-primary text-primary-foreground" : "text-muted-foreground" | |
| }`} | |
| > | |
| {THEME_CFG[t].label} | |
| </button> | |
| ))} | |
| </div> | |
| <button | |
| onClick={() => setProfileOpen(true)} | |
| title={`Профиль: ${user.username} · ${roleLabel(user.role)}`} | |
| className="hidden h-9 items-center gap-2 rounded-lg border border-border bg-secondary px-2.5 text-xs font-medium transition-colors hover:border-accent lg:flex" | |
| > | |
| {user.avatar ? ( | |
| <img src={user.avatar} alt="" className="h-6 w-6 rounded-full object-cover" /> | |
| ) : ( | |
| <UserRound className="h-4 w-4 text-accent" /> | |
| )} | |
| {user.displayName?.trim() || user.username} | |
| </button> | |
| <div className="flex items-center gap-1"> | |
| <Button | |
| variant="outline" | |
| size="icon" | |
| className="h-9 w-9 lg:hidden" | |
| title="Профиль и мои работы" | |
| onClick={() => setProfileOpen(true)} | |
| > | |
| <UserRound className="h-4 w-4" /> | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="icon" | |
| className="h-9 w-9" | |
| title="Написать: чего не хватает" | |
| onClick={() => setFeedbackOpen(true)} | |
| > | |
| <MessageSquarePlus className="h-4 w-4" /> | |
| </Button> | |
| {user.role === "admin" && ( | |
| <Button | |
| variant="outline" | |
| size="icon" | |
| className="h-9 w-9" | |
| title="Панель админа: пользователи и пожелания" | |
| onClick={() => setAdminOpen(true)} | |
| > | |
| <Users className="h-4 w-4" /> | |
| </Button> | |
| )} | |
| <Button variant="outline" size="icon" className="h-9 w-9" title="Помощь" onClick={() => setHelpOpen(true)}> | |
| <HelpCircle className="h-4 w-4" /> | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="icon" | |
| className="h-9 w-9" | |
| title="Мои проекты" | |
| onClick={() => setProjectsOpen(true)} | |
| > | |
| <FolderOpen className="h-4 w-4" /> | |
| </Button> | |
| <Button variant="outline" size="icon" className="h-9 w-9" title="Скачать .json" onClick={saveProjectFile}> | |
| <Save className="h-4 w-4" /> | |
| </Button> | |
| <Button variant="outline" size="icon" className="h-9 w-9" title="Экспорт в PDF A4" onClick={exportPdf}> | |
| <FileDown className="h-4 w-4" /> | |
| </Button> | |
| <Button size="sm" className="h-9 gap-1.5" title="Экспорт изображения PNG" onClick={exportPng}> | |
| <Download className="h-4 w-4" /> PNG | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="icon" | |
| className="h-9 w-9" | |
| title="Выйти из аккаунта" | |
| onClick={() => { | |
| logout(); | |
| setUser(null); | |
| setUnlocked(false); | |
| }} | |
| > | |
| <LogOut className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| </header> | |
| <div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-2 lg:flex-row lg:overflow-hidden lg:gap-3 lg:p-3"> | |
| {/* Библиотека */} | |
| <aside className="w-full shrink-0 overflow-y-auto rounded-xl border border-border bg-card p-3 lg:w-56 xl:w-64"> | |
| <h2 className="font-display text-base">Библиотека элементов</h2> | |
| <div className="mt-2 flex flex-wrap gap-1"> | |
| {CATEGORIES.map((c) => ( | |
| <button | |
| key={c} | |
| onClick={() => setCategory(c)} | |
| title={`Раздел «${c}»`} | |
| className={`h-8 rounded-md border px-2 text-xs font-medium transition-colors ${ | |
| category === c ? "border-accent bg-secondary" : "border-border text-muted-foreground" | |
| }`} | |
| > | |
| {c} | |
| </button> | |
| ))} | |
| </div> | |
| <div className="mt-2 grid grid-cols-2 gap-1.5 lg:grid-cols-1"> | |
| {LIBRARY.filter((e) => e.category === category).map((def) => ( | |
| <button | |
| key={def.id} | |
| onClick={() => addElement(def)} | |
| title={`Добавить: ${def.name} · ${def.kind === "text" ? "подпись" : `${def.knots} узлов`}`} | |
| className="flex w-full items-center gap-2 rounded-lg border border-border bg-background p-1.5 text-left transition-colors hover:border-accent hover:bg-secondary active:scale-[0.98]" | |
| > | |
| <ElementIcon def={def} color={thread.value} /> | |
| <span className="min-w-0"> | |
| <span className="block truncate text-xs font-medium">{def.name}</span> | |
| <span className="block text-[10px] text-muted-foreground"> | |
| {def.kind === "text" ? "подпись" : `${def.knots} узлов`} | |
| </span> | |
| </span> | |
| </button> | |
| ))} | |
| </div> | |
| </aside> | |
| {/* Холст */} | |
| <main className="flex min-h-0 min-w-0 flex-1 flex-col"> | |
| <div className="flex shrink-0 flex-wrap items-center gap-1 rounded-xl border border-border bg-card p-1.5"> | |
| <Button variant="outline" size="icon" className="h-9 w-9" title="Отменить (Ctrl+Z)" disabled={!canUndo} onClick={() => restore(-1)}> | |
| <Undo2 className="h-4 w-4" /> | |
| </Button> | |
| <Button variant="outline" size="icon" className="h-9 w-9" title="Повторить (Ctrl+Shift+Z)" disabled={!canRedo} onClick={() => restore(1)}> | |
| <Redo2 className="h-4 w-4" /> | |
| </Button> | |
| <Button variant="outline" size="icon" className="h-9 w-9" title="Дублировать выбранный элемент" onClick={duplicate}> | |
| <Copy className="h-4 w-4" /> | |
| </Button> | |
| <Button variant="outline" size="icon" className="h-9 w-9" title="Сгруппировать выделенные элементы" onClick={groupSelected}> | |
| <Group className="h-4 w-4" /> | |
| </Button> | |
| <Button variant="outline" size="icon" className="h-9 w-9" title="Разгруппировать элементы" onClick={ungroupSelected}> | |
| <Ungroup className="h-4 w-4" /> | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="icon" | |
| className="h-9 w-9" | |
| title="Отразить по горизонтали" | |
| onClick={() => withActive((a) => a.set("flipX", !a.flipX))} | |
| > | |
| <FlipHorizontal className="h-4 w-4" /> | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="icon" | |
| className="h-9 w-9" | |
| title="Отразить по вертикали" | |
| onClick={() => withActive((a) => a.set("flipY", !a.flipY))} | |
| > | |
| <FlipVertical className="h-4 w-4" /> | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="icon" | |
| className="h-9 w-9" | |
| title="На передний план" | |
| onClick={() => withActive((a, c) => c.bringObjectToFront(a))} | |
| > | |
| <ArrowUpToLine className="h-4 w-4" /> | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="icon" | |
| className="h-9 w-9" | |
| title="На задний план" | |
| onClick={() => | |
| withActive((a, c) => { | |
| c.sendObjectToBack(a); | |
| c.getObjects() | |
| .filter((o) => (o as TattingObject).isGrid) | |
| .forEach((g) => c.sendObjectToBack(g)); | |
| }) | |
| } | |
| > | |
| <ArrowDownToLine className="h-4 w-4" /> | |
| </Button> | |
| <span className="mx-1 hidden h-6 w-px bg-border sm:block" /> | |
| <Button variant="outline" size="icon" className="h-9 w-9" title="Приблизить" onClick={() => applyZoom(zoom * 1.2)}> | |
| <ZoomIn className="h-4 w-4" /> | |
| </Button> | |
| <Button variant="outline" size="icon" className="h-9 w-9" title="Отдалить" onClick={() => applyZoom(zoom / 1.2)}> | |
| <ZoomOut className="h-4 w-4" /> | |
| </Button> | |
| <Button variant="outline" size="sm" className="h-9 gap-1.5" title="Вписать схему в экран" onClick={fitToScreen}> | |
| <Maximize className="h-4 w-4" /> | |
| <span className="tabular-nums">{Math.round(zoom * 100)}%</span> | |
| </Button> | |
| <Button | |
| variant={showGrid ? "default" : "outline"} | |
| size="icon" | |
| className="h-9 w-9" | |
| title="Показать или скрыть сетку" | |
| onClick={() => setShowGrid((v) => !v)} | |
| > | |
| <Grid3x3 className="h-4 w-4" /> | |
| </Button> | |
| <Button | |
| variant={showRulers ? "default" : "outline"} | |
| size="icon" | |
| className="h-9 w-9" | |
| title="Показать или скрыть линейки" | |
| onClick={() => setShowRulers((v) => !v)} | |
| > | |
| <Ruler className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| <div className="tatting-frame relative mt-2 min-h-0 flex-1 rounded-xl border border-border bg-card p-1.5 shadow-sm"> | |
| <div className={`tatting-box ${showRulers ? "with-rulers" : ""}`}> | |
| {showRulers && <RulerBar axis="x" zoom={zoom} pan={pan.x} />} | |
| <div className="flex min-h-0 min-w-0 gap-1"> | |
| {showRulers && <RulerBar axis="y" zoom={zoom} pan={pan.y} />} | |
| <div | |
| ref={wrapRef} | |
| className={`tatting-wrap relative min-w-0 flex-1 overflow-hidden rounded-lg ${ | |
| panning ? "cursor-grabbing" : "" | |
| }`} | |
| > | |
| <canvas ref={elRef} className="touch-none" /> | |
| {booting && ( | |
| <div className="absolute inset-0 animate-pulse backdrop-blur-md"> | |
| <div className="h-full w-full bg-gradient-to-br from-secondary/70 via-card/60 to-secondary/70" /> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* мягкий индикатор автосохранения в углу */} | |
| <div | |
| aria-live="polite" | |
| className={`pointer-events-none absolute bottom-3 right-3 flex items-center gap-1.5 rounded-full border border-border bg-card/90 px-2.5 py-1 text-[11px] text-muted-foreground shadow-sm transition-all duration-300 ${ | |
| saved ? "translate-y-0 opacity-100" : "translate-y-1 opacity-0" | |
| }`} | |
| > | |
| <Cloud className="h-3.5 w-3.5 animate-pulse text-accent" /> сохранено | |
| </div> | |
| </div> | |
| </main> | |
| {/* Свойства */} | |
| <aside className="w-full shrink-0 space-y-3 overflow-y-auto rounded-xl border border-border bg-card p-3 lg:w-60 xl:w-72"> | |
| <div> | |
| <h2 className="font-display text-base">Сетка и симметрия</h2> | |
| <div className="mt-1 text-xs text-muted-foreground">Шаг привязки</div> | |
| <div className="mt-1 flex gap-1"> | |
| {SNAP_STEPS.map((s) => ( | |
| <button | |
| key={s} | |
| onClick={() => setSnap(s)} | |
| title={`Привязка к сетке ${s} px`} | |
| className={`h-8 flex-1 rounded-lg border text-xs font-medium ${ | |
| snap === s ? "border-accent bg-secondary" : "border-border text-muted-foreground" | |
| }`} | |
| > | |
| {s} px | |
| </button> | |
| ))} | |
| </div> | |
| <div className="mt-2 text-xs text-muted-foreground">Круговая симметрия (осей)</div> | |
| <div className="mt-1 flex gap-1"> | |
| {SYMMETRY_AXES.map((s) => ( | |
| <button | |
| key={s} | |
| onClick={() => setSymmetry(s)} | |
| title={s === 1 ? "Без симметрии" : `Радиальная симметрия: ${s} осей`} | |
| className={`h-8 flex-1 rounded-lg border text-xs font-medium ${ | |
| symmetry === s ? "border-accent bg-secondary" : "border-border text-muted-foreground" | |
| }`} | |
| > | |
| {s === 1 ? "нет" : s} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| <div className="border-t border-border pt-3"> | |
| <h2 className="font-display text-base">Нити и цвет</h2> | |
| {(["Металлик", "Пастель", "Градиент"] as const).map((g) => ( | |
| <div key={g} className="mt-2"> | |
| <div className="text-[10px] uppercase tracking-wide text-muted-foreground">{g}</div> | |
| <div className="mt-1 flex flex-wrap gap-1.5"> | |
| {THREADS.filter((t) => t.group === g).map((t) => ( | |
| <button | |
| key={t.name} | |
| title={`Нить: ${t.name}`} | |
| aria-label={t.name} | |
| onClick={() => { | |
| setThread(t); | |
| withActive((a) => applyThread(a, t)); | |
| setSelected((s) => (s ? { ...s, thread: t.name } : s)); | |
| }} | |
| style={{ background: t.to ? `linear-gradient(135deg, ${t.value}, ${t.to})` : t.value }} | |
| className={`h-8 w-8 rounded-full border-2 transition-transform active:scale-90 ${ | |
| thread.name === t.name ? "scale-110 border-accent" : "border-border" | |
| }`} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| ))} | |
| <div className="mt-3"> | |
| <div className="mb-1.5 flex items-center justify-between text-xs"> | |
| <span className="flex items-center gap-1.5"> | |
| <Scissors className="h-3.5 w-3.5" /> Толщина нити | |
| </span> | |
| <span className="tabular-nums text-muted-foreground">{width.toFixed(1)}</span> | |
| </div> | |
| <Slider | |
| value={[width]} | |
| min={1} | |
| max={10} | |
| step={0.5} | |
| onValueChange={([v]) => { | |
| const nv = v ?? 3; | |
| setWidth(nv); | |
| withActive((a) => applyWidth(a, nv)); | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| <div className="border-t border-border pt-3"> | |
| <h2 className="font-display text-base">Выбранный элемент</h2> | |
| {selected ? ( | |
| <dl className="mt-1.5 space-y-1 text-xs"> | |
| <div className="flex justify-between gap-2"> | |
| <dt className="text-muted-foreground">Название</dt> | |
| <dd className="text-right font-medium">{selected.name}</dd> | |
| </div> | |
| <div className="flex justify-between gap-2"> | |
| <dt className="text-muted-foreground">Узлов</dt> | |
| <dd className="font-medium tabular-nums">{selected.knots}</dd> | |
| </div> | |
| <div className="flex justify-between gap-2"> | |
| <dt className="text-muted-foreground">Нить</dt> | |
| <dd className="font-medium">{selected.thread}</dd> | |
| </div> | |
| </dl> | |
| ) : ( | |
| <p className="mt-1.5 text-xs text-muted-foreground"> | |
| Выберите элемент на холсте, чтобы изменить цвет, толщину и положение. | |
| </p> | |
| )} | |
| <Button | |
| variant="destructive" | |
| size="sm" | |
| className="mt-2 h-9 w-full gap-2" | |
| title="Удалить выбранный элемент" | |
| disabled={!selected} | |
| onClick={deleteSelected} | |
| > | |
| <Trash2 className="h-4 w-4" /> Удалить элемент | |
| </Button> | |
| </div> | |
| </aside> | |
| </div> | |
| {/* Нижняя строка состояния */} | |
| <footer className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-t border-border bg-card px-3 py-1.5"> | |
| <p className="text-xs tabular-nums"> | |
| Узлов: <strong>{stats.knots}</strong> · расход <strong>≈ {meters.toFixed(2)} м</strong>{" "} | |
| <span className="text-muted-foreground">(запас 15%)</span> | |
| </p> | |
| <div className="flex items-center gap-2"> | |
| <Button | |
| variant="destructive" | |
| size="sm" | |
| className="h-10 gap-2" | |
| title="Удалить выбранный элемент" | |
| disabled={!selected} | |
| onClick={deleteSelected} | |
| > | |
| <Trash2 className="h-4 w-4" /> Удалить | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className="h-10 gap-2 border-destructive/40 text-destructive" | |
| title="Очистить холст полностью" | |
| onClick={clearCanvas} | |
| > | |
| <Eraser className="h-4 w-4" /> Очистить холст | |
| </Button> | |
| </div> | |
| </footer> | |
| <ProjectsDialog | |
| open={projectsOpen} | |
| onOpenChange={setProjectsOpen} | |
| onOpenProject={(p) => { | |
| loadData(p.data as object); | |
| setProjectsOpen(false); | |
| }} | |
| onImportFile={() => fileRef.current?.click()} | |
| onSaveCurrent={saveToGallery} | |
| ownerId={user.id} | |
| /> | |
| <AdminPanel open={adminOpen} onOpenChange={setAdminOpen} /> | |
| <ProfileDialog | |
| open={profileOpen} | |
| onOpenChange={setProfileOpen} | |
| user={user} | |
| onUserChange={setUser} | |
| onOpenProject={(p) => { | |
| loadData(p.data as object); | |
| setProfileOpen(false); | |
| }} | |
| /> | |
| <FeedbackDialog open={feedbackOpen} onOpenChange={setFeedbackOpen} user={user} /> | |
| <Dialog open={helpOpen} onOpenChange={setHelpOpen}> | |
| <DialogContent className="max-w-lg"> | |
| <DialogHeader> | |
| <DialogTitle className="font-display text-2xl">Как пользоваться студией</DialogTitle> | |
| <DialogDescription>Три коротких шага для создания схемы фриволите.</DialogDescription> | |
| </DialogHeader> | |
| <ol className="space-y-4 text-sm"> | |
| <li className="flex gap-3"> | |
| <MousePointer2 className="mt-0.5 h-6 w-6 shrink-0 text-primary" /> | |
| <span> | |
| <strong className="block">1. Добавьте и перетащите</strong> | |
| Нажмите на элемент в «Библиотеке» — он появится на холсте. Включите круговую симметрию, чтобы сразу | |
| получить мандалу или салфетку. | |
| </span> | |
| </li> | |
| <li className="flex gap-3"> | |
| <RotateCw className="mt-0.5 h-6 w-6 shrink-0 text-primary" /> | |
| <span> | |
| <strong className="block">2. Настройте</strong> | |
| Меняйте цвет и толщину нити, группируйте элементы, отражайте и меняйте порядок слоёв. Ctrl+Z отменяет | |
| действие. | |
| </span> | |
| </li> | |
| <li className="flex gap-3"> | |
| <Download className="mt-0.5 h-6 w-6 shrink-0 text-primary" /> | |
| <span> | |
| <strong className="block">3. Сохраните</strong> | |
| «Мои проекты» хранит схемы в браузере, PNG и PDF A4 — для печати, «Скачать .json» — файл проекта. | |
| </span> | |
| </li> | |
| </ol> | |
| </DialogContent> | |
| </Dialog> | |
| </div> | |
| ); | |
| } | |
| function RulerBar({ axis, zoom, pan }: { axis: "x" | "y"; zoom: number; pan: number }) { | |
| const total = axis === "x" ? W : H; | |
| const step = zoom >= 2.5 ? 25 : zoom >= 1.4 ? 50 : zoom >= 0.7 ? 100 : 200; | |
| const from = Math.floor(-pan / zoom / step) * step; | |
| const to = from + Math.ceil(total / zoom / step) * step + step; | |
| const ticks: number[] = []; | |
| for (let t = from; t <= to; t += step) ticks.push(t); | |
| const pos = (t: number) => ((t * zoom + pan) / total) * 100; | |
| if (axis === "x") | |
| return ( | |
| <div className="relative mb-1 ml-7 h-5 shrink-0 overflow-hidden rounded-md bg-secondary text-[10px] text-muted-foreground"> | |
| {ticks.map((t) => ( | |
| <span | |
| key={t} | |
| className="absolute top-0 flex h-full items-center border-l border-border pl-1" | |
| style={{ left: `${pos(t)}%` }} | |
| > | |
| {t} | |
| </span> | |
| ))} | |
| </div> | |
| ); | |
| return ( | |
| <div className="relative w-6 shrink-0 overflow-hidden rounded-md bg-secondary text-[10px] text-muted-foreground"> | |
| {ticks.map((t) => ( | |
| <span | |
| key={t} | |
| className="absolute left-0 flex w-full justify-center border-t border-border pt-0.5" | |
| style={{ top: `${pos(t)}%` }} | |
| > | |
| {t} | |
| </span> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| function ElementIcon({ def, color }: { def: ElDef; color: string }) { | |
| const r = 30; | |
| const picotR = 5; | |
| const dots = Array.from({ length: def.picots }, (_, i) => { | |
| const a = (i / Math.max(def.picots, 1)) * Math.PI * 2 - Math.PI / 2; | |
| return { cx: Math.cos(a) * (r + 4), cy: Math.sin(a) * (r + 4) }; | |
| }); | |
| return ( | |
| <svg viewBox="-45 -45 90 90" className="h-8 w-8 shrink-0" aria-hidden> | |
| <g fill="none" stroke={color} strokeWidth={3}> | |
| {def.kind === "ring" && ( | |
| <> | |
| <circle r={r} /> | |
| {dots.map((d, i) => ( | |
| <circle key={i} cx={d.cx} cy={d.cy} r={picotR} /> | |
| ))} | |
| </> | |
| )} | |
| {(def.kind === "arc" || def.kind === "arc-curved") && ( | |
| <> | |
| <path d="M -34 12 A 36 36 0 0 1 34 12" /> | |
| {Array.from({ length: def.picots }, (_, i) => { | |
| const t = (i + 1) / (def.picots + 1); | |
| const a = Math.PI - t * Math.PI; | |
| return <circle key={i} cx={Math.cos(a) * 40} cy={-Math.sin(a) * 40 + 12} r={4} />; | |
| })} | |
| </> | |
| )} | |
| {def.kind === "picot" && ( | |
| <> | |
| <path d="M -26 8 Q 0 -34 26 8" /> | |
| <circle cy={-14} r={7} /> | |
| </> | |
| )} | |
| {def.kind === "bead" && <circle r={16} fill={color} />} | |
| {def.kind === "crystal" && <polygon points="0,-28 20,0 0,28 -20,0" fill={color} />} | |
| {def.kind === "text" && ( | |
| <text x="0" y="10" textAnchor="middle" fontSize="34" fill={color} stroke="none"> | |
| А1 | |
| </text> | |
| )} | |
| </g> | |
| </svg> | |
| ); | |
| } | |