rspace-online/lib/group-manager.ts

294 lines
7.8 KiB
TypeScript

/**
* 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<ShapeData, "id"> {
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<string, CanvasGroup> {
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 };
}
}