Test / components /studio /TattingStudio.tsx
TrikozikGames's picture
Add source files
eddc354
Raw
History Blame Contribute Delete
46.1 kB
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>
);
}