/** * MiCanvasBridge — Singleton that connects canvas state to the MI assistant. * * Listens to selection, viewport, and connection changes on the canvas * and exposes a structured context snapshot for the MI system prompt. */ export interface ShapeInfo { id: string; type: string; x: number; y: number; width: number; height: number; content?: string; title?: string; } export interface Connection { arrowId: string; sourceId: string; targetId: string; } export interface ShapeGroup { shapeIds: string[]; } export interface CanvasContext { selectedShapes: ShapeInfo[]; allShapes: ShapeInfo[]; connections: Connection[]; viewport: { x: number; y: number; scale: number }; shapeGroups: ShapeGroup[]; shapeCountByType: Record; } export class MiCanvasBridge { static instance: MiCanvasBridge | null = null; selectedShapeIds: string[] = []; viewport = { x: 0, y: 0, scale: 1 }; private canvasContent: HTMLElement | null = null; constructor(canvasContent?: HTMLElement) { if (MiCanvasBridge.instance) return MiCanvasBridge.instance; this.canvasContent = canvasContent || document.getElementById("canvas-content"); MiCanvasBridge.instance = this; } setSelection(ids: string[]) { this.selectedShapeIds = ids; } setViewport(x: number, y: number, scale: number) { this.viewport = { x, y, scale }; } /** Build full context snapshot for the MI system prompt. */ getCanvasContext(): CanvasContext { const allShapes = this.#collectShapes(); const connections = this.#collectConnections(); const selectedShapes = allShapes.filter((s) => this.selectedShapeIds.includes(s.id)); const shapeGroups = this.#buildShapeGroups(allShapes, connections); const shapeCountByType: Record = {}; for (const s of allShapes) { shapeCountByType[s.type] = (shapeCountByType[s.type] || 0) + 1; } return { selectedShapes, allShapes, connections, viewport: { ...this.viewport }, shapeGroups, shapeCountByType, }; } #collectShapes(): ShapeInfo[] { if (!this.canvasContent) return []; return [...this.canvasContent.children] .filter( (el) => el.tagName?.includes("-") && el.id && !el.tagName.toLowerCase().includes("arrow"), ) .map((el: any) => ({ id: el.id, type: el.tagName.toLowerCase(), x: el.x ?? 0, y: el.y ?? 0, width: el.width ?? 0, height: el.height ?? 0, ...(el.content ? { content: String(el.content).slice(0, 120) } : {}), ...(el.title ? { title: String(el.title).slice(0, 80) } : {}), })); } #collectConnections(): Connection[] { if (!this.canvasContent) return []; return [...this.canvasContent.querySelectorAll("folk-arrow")] .filter((el: any) => el.id && el.sourceId && el.targetId) .map((el: any) => ({ arrowId: el.id, sourceId: el.sourceId, targetId: el.targetId, })); } /** BFS on the arrow graph to find clusters of connected shapes. */ #buildShapeGroups(shapes: ShapeInfo[], connections: Connection[]): ShapeGroup[] { const adj = new Map>(); for (const c of connections) { if (!adj.has(c.sourceId)) adj.set(c.sourceId, new Set()); if (!adj.has(c.targetId)) adj.set(c.targetId, new Set()); adj.get(c.sourceId)!.add(c.targetId); adj.get(c.targetId)!.add(c.sourceId); } const visited = new Set(); const groups: ShapeGroup[] = []; for (const shape of shapes) { if (visited.has(shape.id) || !adj.has(shape.id)) continue; const group: string[] = []; const queue = [shape.id]; while (queue.length) { const id = queue.shift()!; if (visited.has(id)) continue; visited.add(id); group.push(id); for (const neighbor of adj.get(id) || []) { if (!visited.has(neighbor)) queue.push(neighbor); } } if (group.length > 1) groups.push({ shapeIds: group }); } return groups; } }