294 lines
7.8 KiB
TypeScript
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 };
|
|
}
|
|
}
|