Skip to content
Closed
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
5 changes: 3 additions & 2 deletions apps/server/src/shared/mcp/whiteboard-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ Text font families: 1=Virgil (handwritten), 3=Cascadia (monospace), 5=Excalifont
}
\`\`\`

**Points:** Array of [x, y] offsets relative to the element's x, y. First point is always [0, 0]. Add intermediate points for bends.
**Points (required):** Array of at least two [x, y] offsets relative to the element's x, y. First point is always [0, 0]. Add intermediate points for bends. Arrows, lines, and freedraw elements must always carry a \`points\` array — omit it and the element cannot be rendered.

**Arrowheads:** \`startArrowhead\` and \`endArrowhead\` can be: null, "arrow", "bar", "dot", "triangle", "diamond".

Expand Down Expand Up @@ -191,8 +191,9 @@ Children are assigned to frames by setting their \`frameId\` to the frame's id.

### Important notes

- The editor auto-heals many issues (null fields, missing indices). Don't over-validate.
- The editor auto-heals many cosmetic issues (null fields, missing indices). Don't over-validate those.
- Always provide \`id\`, \`type\`, \`x\`, \`y\` at minimum. Width and height default to 0 if omitted.
- For \`arrow\`, \`line\`, and \`freedraw\`, \`points\` is mandatory — it is not auto-healed.
- Use readable, descriptive ids — you'll reference them in bindings and future updates.
- Elements are merged by id: sending an element with an existing id replaces it entirely.
`;
Expand Down
12 changes: 10 additions & 2 deletions apps/server/src/shared/whiteboard-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Pool } from "pg";

import { sanitizeElements } from "./whiteboard.js";

export const WHITEBOARD_SNAPSHOT_FILENAME = "whiteboard.png";

export const MAX_ELEMENTS = 20_000;
Expand All @@ -21,7 +23,12 @@ export async function loadWhiteboard(
"SELECT scene, version, updated_by, updated_at FROM whiteboards WHERE agent_id = $1",
[agentId]
);
return result.rows[0] ?? null;
const row = result.rows[0];
if (!row) return null;
// Sanitize on read too, so boards already holding malformed elements render
// instead of crashing, and heal on their next write.
const elements = Array.isArray(row.scene?.elements) ? row.scene.elements : [];
return { ...row, scene: { elements: sanitizeElements(elements) } };
}

export function isValidScene(scene: unknown): scene is { elements: unknown[] } {
Expand All @@ -40,6 +47,7 @@ export async function saveWhiteboard(
baseVersion: number,
updatedBy: "user" | "agent"
): Promise<{ version: number } | null> {
const clean = { elements: sanitizeElements(scene.elements) };
const result = await pool.query<{ version: string }>(
`INSERT INTO whiteboards (agent_id, scene, version, updated_by)
VALUES ($1, $2::jsonb, 1, $3)
Expand All @@ -50,7 +58,7 @@ export async function saveWhiteboard(
updated_at = NOW()
WHERE whiteboards.version = $4
RETURNING version`,
[agentId, JSON.stringify(scene), updatedBy, baseVersion]
[agentId, JSON.stringify(clean), updatedBy, baseVersion]
);
if (result.rows.length === 0) {
return null;
Expand Down
72 changes: 72 additions & 0 deletions apps/server/src/shared/whiteboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,78 @@ export type WhiteboardUpdateResult = {
elements: SimplifiedElement[];
};

// Excalidraw's restoreElements() calls isInvisiblySmallElement() — which reads
// `element.points.length` unguarded — before restoreElement() applies its own
// defaults. A points-less arrow therefore throws and takes down the whole view,
// so we normalize these fields before the data ever reaches the editor.
const POINTS_REQUIRED_TYPES = new Set(["arrow", "line", "draw", "freedraw"]);

function finite(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}

function isPoint(value: unknown): boolean {
return (
Array.isArray(value) &&
value.length >= 2 &&
Number.isFinite(value[0]) &&
Number.isFinite(value[1])
);
}

export function sanitizeElement(raw: unknown): RawElement | null {
if (typeof raw !== "object" || raw === null) return null;
const el = raw as RawElement;
if (typeof el.id !== "string" || typeof el.type !== "string") return null;

const width = finite(el.width);
const height = finite(el.height);
const out: RawElement = {
...el,
x: finite(el.x),
y: finite(el.y),
width,
height,
};

if (POINTS_REQUIRED_TYPES.has(el.type)) {
const points = Array.isArray(el.points) ? el.points.filter(isPoint) : [];
// Same fallback restoreElement() would apply, just early enough to matter.
const usedFallback = points.length < 2;
out.points = usedFallback
? [
[0, 0],
[width, height],
]
: points;

// The renderer indexes pressures[i] per point when simulatePressure is
// falsy, so a missing or short pressures array throws the same way
// missing points did. Keep it aligned with points, defaulting to 0.5 as
// Excalidraw's own restore does.
if (el.type === "freedraw" && el.simulatePressure !== true) {
const pressures = Array.isArray(el.pressures) ? el.pressures : [];
out.pressures = (out.points as unknown[]).map((_, i) => {
const pressure = usedFallback ? undefined : pressures[i];
return typeof pressure === "number" && Number.isFinite(pressure)
? pressure
: 0.5;
});
}
}

return out;
}

export function sanitizeElements(elements: unknown[]): unknown[] {
const out: RawElement[] = [];
for (const raw of elements) {
const el = sanitizeElement(raw);
if (el) out.push(el);
}
return out;
}

function num(value: unknown): number {
return typeof value === "number" && Number.isFinite(value)
? Math.round(value)
Expand Down
Loading
Loading