/** * GroupManager — named shape clusters on the canvas. * Manages CRUD, collapse/expand, group movement, and template instantiation. * All state persists via CommunitySync's Automerge doc. */ import type { CommunitySync, CommunityDoc, ShapeData } from "./community-sync"; import * as Automerge from "@automerge/automerge"; export interface CanvasGroup { id: string; name: string; color: string; icon: string; memberIds: string[]; collapsed: boolean; isTemplate: boolean; templateName?: string; createdAt: number; updatedAt: number; } export interface GroupTemplateMember extends Omit { relX: number; relY: number; } export interface GroupTemplate { name: string; icon: string; color: string; /** Shapes with positions relative to group origin (0,0 = top-left of bounding box) */ members: GroupTemplateMember[]; } const GROUP_COLORS = [ "#14b8a6", "#8b5cf6", "#f59e0b", "#ef4444", "#3b82f6", "#ec4899", "#10b981", "#f97316", ]; let colorIndex = 0; function nextColor(): string { return GROUP_COLORS[colorIndex++ % GROUP_COLORS.length]; } export class GroupManager extends EventTarget { #sync: CommunitySync; constructor(sync: CommunitySync) { super(); this.#sync = sync; } /** Access the groups map from the doc. */ #getGroups(): Record { return (this.#sync.doc as any).groups || {}; } /** Batch-mutate the Automerge doc. */ #change(msg: string, fn: (doc: any) => void): void { // Use changeDoc via the public accessor pattern const oldDoc = this.#sync.doc; const newDoc = Automerge.change(oldDoc, msg, (d: any) => { if (!d.groups) d.groups = {}; fn(d); }); // Apply via internal doc setter — we need to go through sync's methods // Since CommunitySync doesn't expose a raw setter, we use addShapeData pattern // Actually we'll use the changeDoc method we'll add (this.#sync as any)._applyDocChange(newDoc); } // ── CRUD ── createGroup(name: string, shapeIds: string[], opts?: { color?: string; icon?: string }): string { const id = `group-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const now = Date.now(); const group: CanvasGroup = { id, name, color: opts?.color || nextColor(), icon: opts?.icon || "📦", memberIds: [...shapeIds], collapsed: false, isTemplate: false, createdAt: now, updatedAt: now, }; this.#change(`Create group "${name}"`, (d) => { d.groups[id] = group; // Tag each member shape with groupId for (const sid of shapeIds) { if (d.shapes?.[sid]) { d.shapes[sid].groupId = id; } } }); this.dispatchEvent(new CustomEvent("group-created", { detail: group })); return id; } dissolveGroup(groupId: string): void { const group = this.#getGroups()[groupId]; if (!group) return; this.#change(`Dissolve group "${group.name}"`, (d) => { // Clear groupId from members for (const sid of (group.memberIds || [])) { if (d.shapes?.[sid]) { delete d.shapes[sid].groupId; } } delete d.groups[groupId]; }); this.dispatchEvent(new CustomEvent("group-dissolved", { detail: { groupId } })); } addToGroup(groupId: string, shapeId: string): void { this.#change(`Add shape to group`, (d) => { const g = d.groups[groupId]; if (!g) return; if (!g.memberIds.includes(shapeId)) { g.memberIds.push(shapeId); } if (d.shapes?.[shapeId]) { d.shapes[shapeId].groupId = groupId; } g.updatedAt = Date.now(); }); } removeFromGroup(groupId: string, shapeId: string): void { this.#change(`Remove shape from group`, (d) => { const g = d.groups[groupId]; if (!g) return; const idx = g.memberIds.indexOf(shapeId); if (idx >= 0) g.memberIds.splice(idx, 1); if (d.shapes?.[shapeId]) { delete d.shapes[shapeId].groupId; } g.updatedAt = Date.now(); // Auto-dissolve if empty if (g.memberIds.length === 0) { delete d.groups[groupId]; } }); } collapseGroup(groupId: string): void { const group = this.#getGroups()[groupId]; if (!group || group.collapsed) return; this.#change(`Collapse group "${group.name}"`, (d) => { d.groups[groupId].collapsed = true; d.groups[groupId].updatedAt = Date.now(); // Minimize member shapes for (const sid of (group.memberIds || [])) { if (d.shapes?.[sid]) { d.shapes[sid].isMinimized = true; } } }); this.dispatchEvent(new CustomEvent("group-collapsed", { detail: { groupId } })); } expandGroup(groupId: string): void { const group = this.#getGroups()[groupId]; if (!group || !group.collapsed) return; this.#change(`Expand group "${group.name}"`, (d) => { d.groups[groupId].collapsed = false; d.groups[groupId].updatedAt = Date.now(); // Restore member shapes for (const sid of (group.memberIds || [])) { if (d.shapes?.[sid]) { d.shapes[sid].isMinimized = false; } } }); this.dispatchEvent(new CustomEvent("group-expanded", { detail: { groupId } })); } moveGroup(groupId: string, dx: number, dy: number): void { const group = this.#getGroups()[groupId]; if (!group) return; this.#change(`Move group "${group.name}"`, (d) => { for (const sid of (group.memberIds || [])) { if (d.shapes?.[sid]) { d.shapes[sid].x = (d.shapes[sid].x || 0) + dx; d.shapes[sid].y = (d.shapes[sid].y || 0) + dy; } } d.groups[groupId].updatedAt = Date.now(); }); this.dispatchEvent(new CustomEvent("group-moved", { detail: { groupId, dx, dy } })); } // ── Templates ── saveAsTemplate(groupId: string, templateName: string): GroupTemplate | null { const group = this.#getGroups()[groupId]; if (!group) return null; const shapes = this.#sync.doc.shapes || {}; const bounds = this.getGroupBounds(groupId); if (!bounds) return null; const members: GroupTemplateMember[] = []; for (const sid of group.memberIds) { const s = shapes[sid]; if (!s) continue; const clone = JSON.parse(JSON.stringify(s)) as ShapeData & { relX: number; relY: number }; delete (clone as any).id; clone.relX = s.x - bounds.x; clone.relY = s.y - bounds.y; members.push(clone as GroupTemplateMember); } return { name: templateName, icon: group.icon, color: group.color, members, }; } instantiateTemplate(template: GroupTemplate, x: number, y: number): string { const shapeIds: string[] = []; // Create shapes at offset position for (const member of template.members) { const id = `shape-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const { relX, relY, ...rest } = member; const shapeData = { ...rest, id, x: x + relX, y: y + relY, } as ShapeData; this.#sync.addShapeData(shapeData); shapeIds.push(id); } return this.createGroup(template.name, shapeIds, { color: template.color, icon: template.icon, }); } // ── Queries ── getGroup(groupId: string): CanvasGroup | undefined { return this.#getGroups()[groupId]; } getAllGroups(): CanvasGroup[] { return Object.values(this.#getGroups()); } getGroupForShape(shapeId: string): CanvasGroup | undefined { const shapes = this.#sync.doc.shapes || {}; const gid = (shapes[shapeId] as any)?.groupId; if (!gid) return undefined; return this.#getGroups()[gid]; } getGroupBounds(groupId: string): { x: number; y: number; width: number; height: number } | null { const group = this.#getGroups()[groupId]; if (!group || group.memberIds.length === 0) return null; const shapes = this.#sync.doc.shapes || {}; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const sid of group.memberIds) { const s = shapes[sid]; if (!s) continue; minX = Math.min(minX, s.x); minY = Math.min(minY, s.y); maxX = Math.max(maxX, s.x + s.width); maxY = Math.max(maxY, s.y + s.height); } if (!isFinite(minX)) return null; return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; } }