rspace-online/lib/applet-template-manager.ts

210 lines
5.8 KiB
TypeScript

/**
* 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<string, AppletTemplateRecord> {
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<string, string>();
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<string, unknown>,
});
}
// 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<string, string>();
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];
});
}
}