/** * AppletTemplateManager — save/instantiate/list/delete applet templates. * * Templates capture a selection of shapes + their inter-connecting arrows, * storing relative positions in CommunityDoc.templates. Instantiation * generates new IDs, remaps arrow refs, and places at cursor position. */ import type { CommunitySync, CommunityDoc, ShapeData } from "./community-sync"; import type { AppletTemplateRecord, AppletTemplateShape, AppletTemplateArrow } from "../shared/applet-types"; import * as Automerge from "@automerge/automerge"; export class AppletTemplateManager { #sync: CommunitySync; constructor(sync: CommunitySync) { this.#sync = sync; } /** Get templates map from doc. */ #getTemplates(): Record { return (this.#sync.doc as any).templates || {}; } /** Batch-mutate the Automerge doc. */ #change(msg: string, fn: (doc: any) => void): void { const oldDoc = this.#sync.doc; const newDoc = Automerge.change(oldDoc, msg, (d: any) => { if (!d.templates) d.templates = {}; fn(d); }); (this.#sync as any)._applyDocChange(newDoc); } // ── Save ── /** * Save selected shapes + their internal arrows as a template. * Only captures arrows where both source AND target are in the selection. */ saveTemplate( selectedIds: string[], meta: { name: string; description?: string; icon?: string; color?: string; createdBy?: string }, ): AppletTemplateRecord | null { const shapes = this.#sync.doc.shapes || {}; const selectedSet = new Set(selectedIds); // Filter to existing shapes const validIds = selectedIds.filter(id => shapes[id]); if (validIds.length === 0) return null; // Compute bounding box let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const id of validIds) { const s = shapes[id]; 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); } // Build relative-ID map: shapeId → relativeId const idMap = new Map(); let relIdx = 0; // Separate non-arrow shapes and arrows const templateShapes: AppletTemplateShape[] = []; const templateArrows: AppletTemplateArrow[] = []; for (const id of validIds) { const s = shapes[id]; if (s.type === "folk-arrow") continue; // handle arrows separately const relId = `rel-${relIdx++}`; idMap.set(id, relId); const { id: _id, x, y, width, height, rotation, type, ...rest } = s; templateShapes.push({ relativeId: relId, type, relX: x - minX, relY: y - minY, width, height, rotation: rotation || 0, props: rest as Record, }); } // Find arrows connecting shapes within the selection for (const [id, s] of Object.entries(shapes)) { if (s.type !== "folk-arrow") continue; if (!s.sourceId || !s.targetId) continue; if (!selectedSet.has(s.sourceId) || !selectedSet.has(s.targetId)) continue; const sourceRelId = idMap.get(s.sourceId); const targetRelId = idMap.get(s.targetId); if (!sourceRelId || !targetRelId) continue; const relId = `rel-${relIdx++}`; templateArrows.push({ relativeId: relId, sourceRelId, targetRelId, sourcePort: s.sourcePort, targetPort: s.targetPort, }); } const template: AppletTemplateRecord = { id: `tpl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, name: meta.name, description: meta.description || "", icon: meta.icon || "📋", color: meta.color || "#6366f1", createdAt: Date.now(), createdBy: meta.createdBy || "unknown", shapes: templateShapes, arrows: templateArrows, boundingWidth: maxX - minX, boundingHeight: maxY - minY, }; this.#change(`Save template "${meta.name}"`, (d) => { d.templates[template.id] = template; }); return template; } // ── Instantiate ── /** * Create new shapes + arrows from a template at the given position. * Returns array of new shape IDs (for optional group creation). */ instantiateTemplate(templateId: string, x: number, y: number): string[] { const template = this.#getTemplates()[templateId]; if (!template) return []; // Map relativeId → new real ID const relToNew = new Map(); const newShapeIds: string[] = []; // Create shapes for (const tplShape of template.shapes) { const newId = `shape-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; relToNew.set(tplShape.relativeId, newId); const shapeData: ShapeData = { type: tplShape.type, id: newId, x: x + tplShape.relX, y: y + tplShape.relY, width: tplShape.width, height: tplShape.height, rotation: tplShape.rotation, ...tplShape.props, }; this.#sync.addShapeData(shapeData); newShapeIds.push(newId); } // Create arrows with remapped source/target for (const tplArrow of template.arrows) { const sourceId = relToNew.get(tplArrow.sourceRelId); const targetId = relToNew.get(tplArrow.targetRelId); if (!sourceId || !targetId) continue; const arrowId = `arrow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const arrowData: ShapeData = { type: "folk-arrow", id: arrowId, x: 0, y: 0, width: 0, height: 0, rotation: 0, sourceId, targetId, sourcePort: tplArrow.sourcePort, targetPort: tplArrow.targetPort, }; this.#sync.addShapeData(arrowData); newShapeIds.push(arrowId); } return newShapeIds; } // ── List / Get / Delete ── listTemplates(): AppletTemplateRecord[] { return Object.values(this.#getTemplates()) .sort((a, b) => b.createdAt - a.createdAt); } getTemplate(id: string): AppletTemplateRecord | undefined { return this.#getTemplates()[id]; } deleteTemplate(id: string): void { this.#change(`Delete template "${id}"`, (d) => { delete d.templates[id]; }); } }