Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 8 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,16 @@ Takes `elements` — a JSON string of standard Excalidraw elements. The widget p

## Key Design Decisions

### Standard Excalidraw JSON — no extensions
The input is standard Excalidraw element JSON. No `label` on containers, no `start`/`end` on arrows. These are Excalidraw's internal "skeleton" API (`convertToExcalidrawElements`) — not the standard format.
### Skeleton API via convertToExcalidrawElements
Input uses Excalidraw's skeleton format, processed by `convertToExcalidrawElements` on every render.
Supported skeleton shortcuts:
- `label` on shapes (auto-centred text, auto-resize container)
- `start`/`end` on arrows (binding metadata + position resolution by target element ID)
- Standalone text and manually-positioned arrows with x/y/points still work

**Why:** Standard format means any `.excalidraw` file's elements array works as input.
**Important:** `convertToExcalidrawElements` creates binding metadata but does NOT compute arrow coordinates. `resolveArrowPositions` (in `mcp-app.tsx`) post-processes the output to compute x/y/points from bound shape edges. Without this step, bound arrows render at (0,0).

**Trade-off:** Labels require separate text elements with manually computed centered coordinates. The cheat sheet teaches the formula: `x = shape.x + (shape.width - text.width) / 2`.

### No `convertToExcalidrawElements`
We tried Excalidraw's skeleton API. Problems:
1. Needs font metrics at conversion time (canvas `measureText`)
2. Non-standard format
3. Added complexity for marginal benefit
**Trade-off:** Elements must be ordered so target shapes appear before arrows that reference them.

### SVG-only rendering (no Excalidraw React canvas)
The widget uses `exportToSvg` for ALL rendering — no `<Excalidraw>` React component.
Expand Down
123 changes: 113 additions & 10 deletions src/mcp-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,117 @@ interface ViewportRect {
height: number;
}

/** Find the edge midpoint of a shape closest to a target point.
* Works for rectangles, diamonds, and ellipses (uses bounding-box cardinal points). */
function closestEdgeMidpoint(shape: any, tx: number, ty: number): [number, number] {
const cx = shape.x + shape.width / 2;
const cy = shape.y + shape.height / 2;
const edges: [number, number][] = [
[shape.x + shape.width, cy], // right
[shape.x, cy], // left
[cx, shape.y], // top
[cx, shape.y + shape.height], // bottom
];
let best = edges[0];
let bestDist = Infinity;
for (const e of edges) {
const d = (e[0] - tx) ** 2 + (e[1] - ty) ** 2;
if (d < bestDist) { bestDist = d; best = e; }
}
return best;
}

/** Compute x/y/points for bound arrows that convertToExcalidrawElements left unpositioned.
* Also fixes null-coordinate labels on arrows. Mutates elements in place. */
function resolveArrowPositions(elements: any[]): void {
const byId = new Map<string, any>();
for (const el of elements) { if (el.id) byId.set(el.id, el); }

for (const el of elements) {
if (el.type !== "arrow") continue;
// Skip unbound arrows (manually positioned with x/y/points)
if (!el.startBinding && !el.endBinding) continue;
// Skip bound arrows that already have resolved coordinates
// (el.x == null catches both undefined and null but not 0, which is a valid coordinate)
if (el.x != null && el.y != null) continue;

const startShape = el.startBinding?.elementId ? byId.get(el.startBinding.elementId) : null;
const endShape = el.endBinding?.elementId ? byId.get(el.endBinding.elementId) : null;
if (!startShape && !endShape) continue;

let sx: number, sy: number, ex: number, ey: number;

if (startShape && endShape) {
const sCx = startShape.x + startShape.width / 2;
const sCy = startShape.y + startShape.height / 2;
const eCx = endShape.x + endShape.width / 2;
const eCy = endShape.y + endShape.height / 2;
[sx, sy] = closestEdgeMidpoint(startShape, eCx, eCy);
[ex, ey] = closestEdgeMidpoint(endShape, sCx, sCy);
} else if (startShape) {
sx = startShape.x + startShape.width;
sy = startShape.y + startShape.height / 2;
ex = sx + 100; ey = sy;
} else {
ex = endShape.x;
ey = endShape.y + endShape.height / 2;
sx = ex - 100; sy = ey;
}

el.x = sx;
el.y = sy;
const dx = ex - sx;
const dy = ey - sy;
el.width = Math.abs(dx);
el.height = Math.abs(dy);
el.points = [[0, 0], [dx, dy]];
}

// Fix arrow-bound label text with null coordinates
for (const el of elements) {
if (el.type !== "text" || !el.containerId) continue;
if (el.x != null && el.y != null) continue;
const container = byId.get(el.containerId);
if (!container || container.type !== "arrow") continue;
if (container.x == null) continue;
const pts = container.points;
if (!pts || pts.length < 2) continue;
const midX = container.x + (pts[0][0] + pts[pts.length - 1][0]) / 2;
const midY = container.y + (pts[0][1] + pts[pts.length - 1][1]) / 2;
el.x = midX - (el.width ?? 0) / 2;
el.y = midY - (el.height ?? 0) / 2;
}
}

/** Convert raw shorthand elements → Excalidraw format (labels → bound text, font fix).
* Preserves pseudo-elements like cameraUpdate (not valid Excalidraw types). */
function convertRawElements(els: any[]): any[] {
* Preserves pseudo-elements like cameraUpdate (not valid Excalidraw types).
* Optional contextElements are prepended so arrow start/end can resolve IDs from
* a checkpoint base, then stripped from the output. */
function convertRawElements(els: any[], contextElements?: any[]): any[] {
const pseudoTypes = new Set(["cameraUpdate", "delete", "restoreCheckpoint"]);
const pseudos = els.filter((el: any) => pseudoTypes.has(el.type));
const real = els.filter((el: any) => !pseudoTypes.has(el.type));
const withDefaults = real.map((el: any) =>
el.label ? { ...el, label: { textAlign: "center", verticalAlign: "middle", ...el.label } } : el
);
const converted = convertToExcalidrawElements(withDefaults, { regenerateIds: false })

// Include context elements so arrow start/end can resolve IDs from checkpoint base
const contextReal = (contextElements ?? []).filter((el: any) => !pseudoTypes.has(el.type));
const allForConversion = [...contextReal, ...withDefaults];
const converted = convertToExcalidrawElements(allForConversion, { regenerateIds: false })
.map((el: any) => el.type === "text" ? { ...el, fontFamily: (FONT_FAMILY as any).Excalifont ?? 1 } : el);

// Compute positions for bound arrows left unpositioned by convertToExcalidrawElements
resolveArrowPositions(converted);

// Return only the newly converted elements (strip context prefix)
if (contextReal.length > 0) {
const contextIds = new Set(contextReal.map((el: any) => el.id));
const newConverted = converted.filter((el: any) =>
!contextIds.has(el.id) && !contextIds.has(el.containerId)
);
return [...newConverted, ...pseudos];
}
return [...converted, ...pseudos];
}

Expand Down Expand Up @@ -342,8 +442,9 @@ function DiagramView({ toolInput, isFinal, displayMode, onElements, editedElemen
// Wait for Virgil font to load before computing text metrics
await ensureFontsLoaded();

// Convert new elements (raw → Excalidraw format)
const convertedNew = convertRawElements(els);
// Convert new elements (raw → Excalidraw format), passing base as context
// so arrow start/end bindings can resolve IDs from checkpoint base
const convertedNew = convertRawElements(els, baseElements);
const baseReal = baseElements?.filter((el: any) => el.type !== "cameraUpdate") ?? [];
const excalidrawEls = [...baseReal, ...convertedNew];

Expand Down Expand Up @@ -438,27 +539,29 @@ function DiagramView({ toolInput, isFinal, displayMode, onElements, editedElemen

// Load checkpoint base if restoring (async — from server)
let base: any[] | undefined;
let rawBase: any[] | undefined;
const doFinal = async () => {
if (restoreId && loadCheckpoint) {
const saved = await loadCheckpoint(restoreId);
if (saved) {
base = saved.elements;
rawBase = saved.elements;
// Extract camera from base as fallback
if (!viewport) {
const cam = base.find((el: any) => el.type === "cameraUpdate");
const cam = rawBase.find((el: any) => el.type === "cameraUpdate");
if (cam) viewport = { x: cam.x, y: cam.y, width: cam.width, height: cam.height };
}
// Convert base with convertRawElements (handles both raw and already-converted)
base = convertRawElements(base);
base = convertRawElements(rawBase);
}
if (base && deleteIds.size > 0) {
base = base.filter((el: any) => !deleteIds.has(el.id) && !deleteIds.has(el.containerId));
}
}

latestRef.current = drawElements;
// Convert new elements for fullscreen editor
const convertedNew = convertRawElements(drawElements);
// Convert new elements for fullscreen editor, passing raw base as context
// so arrow start/end bindings can resolve IDs from checkpoint shapes
const convertedNew = convertRawElements(drawElements, rawBase);

// Merge base (converted) + new converted
const allConverted = base ? [...base, ...convertedNew] : convertedNew;
Expand Down
32 changes: 21 additions & 11 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ Thanks for calling read_me! Do NOT call it again in this conversation — you wi

## Excalidraw Elements

### Required Fields (all elements)
\`type\`, \`id\` (unique string), \`x\`, \`y\`, \`width\`, \`height\`
### Required Fields
\`type\`, \`id\` (unique string), \`x\`, \`y\`, \`width\`, \`height\` — except bound arrows (which only need \`type\`, \`id\`, \`start\`, \`end\`)

### Defaults (skip these)
strokeColor="#1e1e1e", backgroundColor="transparent", fillStyle="solid", strokeWidth=2, roughness=1, opacity=100
Expand Down Expand Up @@ -91,13 +91,20 @@ Canvas background is white.
- estimatedWidth ≈ text.length × fontSize × 0.5
- Do NOT rely on textAlign or width for positioning — they only affect multi-line wrapping

**Arrow**: \`{ "type": "arrow", "id": "a1", "x": 300, "y": 150, "width": 200, "height": 0, "points": [[0,0],[200,0]], "endArrowhead": "arrow" }\`
- points: [dx, dy] offsets from element x,y
**Arrow (bound — PREFERRED)**: \`{ "type": "arrow", "id": "a1", "start": { "id": "r1" }, "end": { "id": "r2" }, "endArrowhead": "arrow" }\`
- Positions computed automatically — no x, y, width, height, or points needed
- Both source and target shapes MUST appear BEFORE the arrow in the array
- Add \`"label": { "text": "connects" }\` for labeled arrows
- endArrowhead: null | "arrow" | "bar" | "dot" | "triangle"

### Arrow Bindings
Arrow: \`"startBinding": { "elementId": "r1", "fixedPoint": [1, 0.5] }\`
fixedPoint: top=[0.5,0], bottom=[0.5,1], left=[0,0.5], right=[1,0.5]
**Arrow (unbound — for decorative lines, annotations)**: \`{ "type": "arrow", "id": "a1", "x": 300, "y": 150, "width": 200, "height": 0, "points": [[0,0],[200,0]], "endArrowhead": "arrow" }\`
- points: [dx, dy] offsets from element x,y

### Arrow Bindings (auto-positioned)
Use \`start\` and \`end\` to bind arrows to shapes by id:
\`{ "type": "arrow", "id": "a1", "start": { "id": "r1" }, "end": { "id": "r2" }, "endArrowhead": "arrow" }\`
Both source and target shapes MUST appear in the array BEFORE the arrow.
For one-sided binding, omit \`start\` or \`end\` and use x/y/points for the unbound end.

**cameraUpdate** (pseudo-element — controls the viewport, not drawn):
\`{ "type": "cameraUpdate", "width": 800, "height": 600, "x": 0, "y": 0 }\`
Expand All @@ -115,20 +122,23 @@ fixedPoint: top=[0.5,0], bottom=[0.5,1], left=[0,0.5], right=[1,0.5]

### Drawing Order (CRITICAL for streaming)
- Array order = z-order (first = back, last = front)
- **Emit progressively**: background → shape → its label → its arrows → next shape
- BAD: all rectangles → all texts → all arrows
- GOOD: bg_shape → shape1 → text1 → arrow1 → shape2 → text2 → ...
- **Bound arrows**: emit BOTH source and target shapes BEFORE the arrow connecting them
- For linear flows A->B->C: shape A, shape B, arrow(A->B), shape C, arrow(B->C)
- For hub patterns: emit centre shape, then each spoke shape + arrow pair
- Labels on shapes use \`label\` property (auto-centred), so no separate text elements needed

### Example: Two connected labeled boxes
\`\`\`json
[
{ "type": "cameraUpdate", "width": 800, "height": 600, "x": 50, "y": 50 },
{ "type": "rectangle", "id": "b1", "x": 100, "y": 100, "width": 200, "height": 100, "roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid", "label": { "text": "Start", "fontSize": 20 } },
{ "type": "rectangle", "id": "b2", "x": 450, "y": 100, "width": 200, "height": 100, "roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid", "label": { "text": "End", "fontSize": 20 } },
{ "type": "arrow", "id": "a1", "x": 300, "y": 150, "width": 150, "height": 0, "points": [[0,0],[150,0]], "endArrowhead": "arrow", "startBinding": { "elementId": "b1", "fixedPoint": [1, 0.5] }, "endBinding": { "elementId": "b2", "fixedPoint": [0, 0.5] } }
{ "type": "arrow", "id": "a1", "start": { "id": "b1" }, "end": { "id": "b2" }, "endArrowhead": "arrow" }
]
\`\`\`

Note: The larger examples below use manual arrow positioning (x/y/points) for precise artistic layouts. For most diagrams, prefer bound arrows with \`start\`/\`end\` — they're simpler and always connect correctly.

### Camera & Sizing (CRITICAL for readability)

The diagram displays inline at ~700px width. Design for this constraint.
Expand Down