From 6bbb7f953cbb05124fb9402291cbe1ac2f023366 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 16 Feb 2026 09:15:57 +1100 Subject: [PATCH 1/2] feat: support bound arrows via skeleton API start/end format --- CLAUDE.md | 18 ++++--- src/mcp-app.tsx | 121 ++++++++++++++++++++++++++++++++++++++++++++---- src/server.ts | 32 ++++++++----- 3 files changed, 140 insertions(+), 31 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 257bb03..7493697 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,18 +23,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 `` React component. diff --git a/src/mcp-app.tsx b/src/mcp-app.tsx index e917761..482aec6 100644 --- a/src/mcp-app.tsx +++ b/src/mcp-app.tsx @@ -43,17 +43,115 @@ 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(); + for (const el of elements) { if (el.id) byId.set(el.id, el); } + + for (const el of elements) { + if (el.type !== "arrow") continue; + // Skip arrows that already have valid coordinates + if (typeof el.x === "number" && !isNaN(el.x) && + typeof el.y === "number" && !isNaN(el.y)) 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 (typeof el.x === "number" && !isNaN(el.x) && el.x !== null) continue; + const container = byId.get(el.containerId); + if (!container || container.type !== "arrow") continue; + if (typeof container.x !== "number" || isNaN(container.x)) 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]; } @@ -346,8 +444,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]; @@ -442,18 +541,19 @@ 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)); @@ -461,8 +561,9 @@ function DiagramView({ toolInput, isFinal, displayMode, onElements, editedElemen } 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; diff --git a/src/server.ts b/src/server.ts index 5a07dab..0d96847 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 @@ -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 }\` @@ -115,9 +122,10 @@ 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 @@ -125,10 +133,12 @@ fixedPoint: top=[0.5,0], bottom=[0.5,1], left=[0,0.5], right=[1,0.5] { "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. From 4c0e8b31b7303daf9bcd4cb2d9b60d8ee0bf70a3 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 16 Feb 2026 09:24:38 +1100 Subject: [PATCH 2/2] feat: support bound arrows via skeleton API start/end format --- src/mcp-app.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/mcp-app.tsx b/src/mcp-app.tsx index 482aec6..272d1f7 100644 --- a/src/mcp-app.tsx +++ b/src/mcp-app.tsx @@ -71,9 +71,11 @@ function resolveArrowPositions(elements: any[]): void { for (const el of elements) { if (el.type !== "arrow") continue; - // Skip arrows that already have valid coordinates - if (typeof el.x === "number" && !isNaN(el.x) && - typeof el.y === "number" && !isNaN(el.y)) 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; @@ -110,10 +112,10 @@ function resolveArrowPositions(elements: any[]): void { // Fix arrow-bound label text with null coordinates for (const el of elements) { if (el.type !== "text" || !el.containerId) continue; - if (typeof el.x === "number" && !isNaN(el.x) && el.x !== null) continue; + if (el.x != null && el.y != null) continue; const container = byId.get(el.containerId); if (!container || container.type !== "arrow") continue; - if (typeof container.x !== "number" || isNaN(container.x)) 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;