210 lines
5.8 KiB
TypeScript
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];
|
|
});
|
|
}
|
|
}
|