import { useCallback } from "react";
import {
Tldraw,
Editor,
createShapeId,
toRichText,
TLShapeId,
TLShape,
TLGeoShape,
} from "tldraw";
import "tldraw/tldraw.css";
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
interface ShapeData {
type: "geo" | "text" | "note" | "arrow" | "frame";
x: number;
y: number;
width?: number;
height?: number;
text?: string;
geo?: string;
color?: string;
fill?: string;
name?: string;
}
interface CreateCommand {
type: "create";
shape: ShapeData;
requestId: string;
}
interface UpdateCommand {
type: "update";
id: string;
props: Partial<ShapeData>;
requestId: string;
}
interface DeleteCommand {
type: "delete";
ids: string[];
requestId: string;
}
interface ConnectCommand {
type: "connect";
from: string;
to: string;
label?: string;
requestId: string;
}
interface SnapshotCommand {
type: "snapshot";
requestId: string;
}
interface ClearCommand {
type: "clear";
requestId: string;
}
interface ZoomToFitCommand {
type: "zoom_to_fit";
requestId: string;
}
interface ExportCommand {
type: "export";
format: "png" | "svg" | "json";
requestId: string;
}
type CanvasCommand =
| CreateCommand
| UpdateCommand
| DeleteCommand
| ConnectCommand
| SnapshotCommand
| ClearCommand
| ZoomToFitCommand
| ExportCommand;
interface CommandResponse {
requestId: string;
id?: string;
ids?: string[];
error?: string;
updated?: boolean;
deleted?: number;
cleared?: boolean;
shapes?: ShapeSnapshot[];
bounds?: { x: number; y: number; width: number; height: number };
zoomed?: boolean;
data?: string;
format?: string;
}
interface ShapeSnapshot {
id: string;
type: string;
x: number;
y: number;
geo?: string;
w?: number;
h?: number;
color?: string;
fill?: string;
}
// ─────────────────────────────────────────────────────────────────────────────
// App
// ─────────────────────────────────────────────────────────────────────────────
export function App() {
const handleMount = useCallback((editor: Editor) => {
connectWebSocket(editor);
}, []);
return (
<div style={{ position: "fixed", inset: 0 }}>
<Tldraw onMount={handleMount} />
</div>
);
}
function connectWebSocket(editor: Editor) {
const ws = new WebSocket("ws://localhost:4000");
ws.onopen = () => console.log("[ws] connected");
ws.onclose = () => {
console.log("[ws] disconnected, reconnecting in 2s...");
setTimeout(() => connectWebSocket(editor), 2000);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data) as CanvasCommand;
const response = handleCommand(editor, msg);
ws.send(JSON.stringify(response));
} catch (err) {
console.error("[ws] error:", err);
}
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Command Handler
// ─────────────────────────────────────────────────────────────────────────────
function handleCommand(editor: Editor, cmd: CanvasCommand): CommandResponse {
const base = { requestId: cmd.requestId };
switch (cmd.type) {
case "create": {
const id = createShapeId();
const { shape } = cmd;
if (shape.type === "geo") {
editor.createShape({
id,
type: "geo",
x: shape.x,
y: shape.y,
props: {
geo: (shape.geo || "rectangle") as TLGeoShape["props"]["geo"],
w: shape.width || 200,
h: shape.height || 200,
color: (shape.color || "black") as TLGeoShape["props"]["color"],
fill: (shape.fill || "none") as TLGeoShape["props"]["fill"],
...(shape.text ? { richText: toRichText(shape.text) } : {}),
},
});
} else if (shape.type === "frame") {
editor.createShape({
id,
type: "frame",
x: shape.x,
y: shape.y,
props: {
w: shape.width || 400,
h: shape.height || 300,
name: shape.name || "",
},
});
}
return { ...base, id };
}
case "update": {
const shape = editor.getShape(cmd.id as TLShapeId);
if (!shape) return { ...base, error: "Shape not found" };
const updates: Partial<TLShape> = {};
if (cmd.props.x !== undefined) updates.x = cmd.props.x;
if (cmd.props.y !== undefined) updates.y = cmd.props.y;
const propUpdates: Record<string, unknown> = {};
if (cmd.props.width !== undefined) propUpdates.w = cmd.props.width;
if (cmd.props.height !== undefined) propUpdates.h = cmd.props.height;
if (cmd.props.text !== undefined) propUpdates.text = cmd.props.text;
if (cmd.props.color !== undefined) propUpdates.color = cmd.props.color;
if (cmd.props.fill !== undefined) propUpdates.fill = cmd.props.fill;
editor.updateShape({
id: shape.id,
type: shape.type,
...updates,
props: { ...shape.props, ...propUpdates },
});
return { ...base, updated: true };
}
case "delete": {
editor.deleteShapes(cmd.ids as TLShapeId[]);
return { ...base, deleted: cmd.ids.length };
}
case "connect": {
const fromShape = editor.getShape(cmd.from as TLShapeId);
const toShape = editor.getShape(cmd.to as TLShapeId);
if (!fromShape || !toShape) return { ...base, error: "Shape not found" };
const fromBounds = editor.getShapePageBounds(fromShape.id);
const toBounds = editor.getShapePageBounds(toShape.id);
if (!fromBounds || !toBounds)
return { ...base, error: "Could not get shape bounds" };
const arrowId = createShapeId();
editor.createShape({
id: arrowId,
type: "arrow",
x: Math.min(fromBounds.center.x, toBounds.center.x),
y: Math.min(fromBounds.center.y, toBounds.center.y),
props: {
start: { x: 0, y: 0 },
end: {
x: toBounds.center.x - fromBounds.center.x,
y: toBounds.center.y - fromBounds.center.y,
},
},
});
editor.createBindings([
{
fromId: arrowId,
toId: fromShape.id,
type: "arrow",
props: {
terminal: "start",
normalizedAnchor: { x: 0.5, y: 0.5 },
isExact: false,
isPrecise: false,
},
},
{
fromId: arrowId,
toId: toShape.id,
type: "arrow",
props: {
terminal: "end",
normalizedAnchor: { x: 0.5, y: 0.5 },
isExact: false,
isPrecise: false,
},
},
]);
return { ...base, id: arrowId };
}
case "snapshot": {
const shapes: ShapeSnapshot[] = editor
.getCurrentPageShapes()
.map((s) => {
const props = s.props as Record<string, unknown>;
return {
id: s.id,
type: s.type,
x: s.x,
y: s.y,
geo: props.geo as string | undefined,
w: props.w as number | undefined,
h: props.h as number | undefined,
color: props.color as string | undefined,
fill: props.fill as string | undefined,
};
});
const bounds = editor.getViewportPageBounds();
return { ...base, shapes, bounds };
}
case "clear": {
const allIds = editor.getCurrentPageShapeIds();
editor.deleteShapes([...allIds]);
return { ...base, cleared: true };
}
case "zoom_to_fit": {
editor.zoomToFit({ animation: { duration: 200 } });
return { ...base, zoomed: true };
}
case "export": {
if (cmd.format === "json") {
const shapes = editor.getCurrentPageShapes();
return { ...base, data: JSON.stringify(shapes, null, 2), format: "json" };
}
// PNG/SVG export requires async and blob handling - return error for now
return { ...base, error: `Export format '${cmd.format}' not yet implemented` };
}
default:
return { ...base, error: "Unknown command" };
}
}