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 = { 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(null); const wrapRef = useRef(null); const fcRef = useRef(null); const history = useRef<{ stack: string[]; index: number; lock: boolean }>({ stack: [], index: -1, lock: false }); const fileRef = useRef(null); const pinch = useRef<{ dist: number; x: number; y: number } | null>(null); const drag = useRef<{ x: number; y: number } | null>(null); const savedTimer = useRef(null); const [unlocked, setUnlocked] = useState(true); const [theme, setTheme] = useState("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(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(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(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 ( { setUser(signed); setUnlocked(true); }} /> ); return (
{ const f = e.target.files?.[0]; if (f) openFile(f); e.target.value = ""; }} />

RealKnot Studio

Редактор схем плетения · сетка {snap} px · правая кнопка — перемещение, колесо — масштаб

{(Object.keys(THEME_CFG) as Theme[]).map((t) => ( ))}
{user.role === "admin" && ( )}
{/* Библиотека */} {/* Холст */}
{showRulers && }
{showRulers && }
{booting && (
)}
{/* мягкий индикатор автосохранения в углу */}
сохранено
{/* Свойства */}
{/* Нижняя строка состояния */}

Узлов: {stats.knots} · расход ≈ {meters.toFixed(2)} м{" "} (запас 15%)

{ loadData(p.data as object); setProjectsOpen(false); }} onImportFile={() => fileRef.current?.click()} onSaveCurrent={saveToGallery} ownerId={user.id} /> { loadData(p.data as object); setProfileOpen(false); }} /> Как пользоваться студией Три коротких шага для создания схемы фриволите.
  1. 1. Добавьте и перетащите Нажмите на элемент в «Библиотеке» — он появится на холсте. Включите круговую симметрию, чтобы сразу получить мандалу или салфетку.
  2. 2. Настройте Меняйте цвет и толщину нити, группируйте элементы, отражайте и меняйте порядок слоёв. Ctrl+Z отменяет действие.
  3. 3. Сохраните «Мои проекты» хранит схемы в браузере, PNG и PDF A4 — для печати, «Скачать .json» — файл проекта.
); } 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 (
{ticks.map((t) => ( {t} ))}
); return (
{ticks.map((t) => ( {t} ))}
); } 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 ( {def.kind === "ring" && ( <> {dots.map((d, i) => ( ))} )} {(def.kind === "arc" || def.kind === "arc-curved") && ( <> {Array.from({ length: def.picots }, (_, i) => { const t = (i + 1) / (def.picots + 1); const a = Math.PI - t * Math.PI; return ; })} )} {def.kind === "picot" && ( <> )} {def.kind === "bead" && } {def.kind === "crystal" && } {def.kind === "text" && ( А1 )} ); }