From 79bb48c99dfcac01fe82c439a721a68708c014a7 Mon Sep 17 00:00:00 2001 From: Ken Chen Date: Fri, 9 May 2025 20:13:55 +0800 Subject: [PATCH] Add fill functionality to whiteboard 1. Allow selecting fill color for shapes 2. Improve the Azure deploy steps in README --- .gitignore | 3 +- samples/Whiteboard/Diagram.cs | 34 ++++++++++++++- samples/Whiteboard/Hub/DrawHub.cs | 44 +++++++++---------- samples/Whiteboard/MCPServer/index.js | 25 ++++++----- samples/Whiteboard/README.md | 35 +++++++++++---- samples/Whiteboard/wwwroot/index.html | 14 ++++++ samples/Whiteboard/wwwroot/script.js | 63 +++++++++++++++++---------- 7 files changed, 151 insertions(+), 67 deletions(-) diff --git a/.gitignore b/.gitignore index 2b8e5dfd..9d05ffb6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ asrs.log.txt **/global.json PublishProfiles/ **.log.txt -**.log \ No newline at end of file +**.log +.env \ No newline at end of file diff --git a/samples/Whiteboard/Diagram.cs b/samples/Whiteboard/Diagram.cs index fc733c16..c761c49d 100644 --- a/samples/Whiteboard/Diagram.cs +++ b/samples/Whiteboard/Diagram.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Linq; using System.Threading; namespace Microsoft.Azure.SignalR.Samples.Whiteboard; @@ -18,6 +20,8 @@ public abstract class Shape public string Color { get; set; } public int Width { get; set; } + + public string Fill { get; set; } } public class Polyline : Shape @@ -57,13 +61,41 @@ public class Diagram { private int totalUsers = 0; + private int currentZIndex = -1; + + private readonly ConcurrentDictionary> shapes = new(); + public byte[] Background { get; set; } public string BackgroundContentType { get; set; } public string BackgroundId { get; set; } - public ConcurrentDictionary Shapes { get; } = new ConcurrentDictionary(); + public int AddOrUpdateShape(string id, Shape shape) + { + var s = shapes.AddOrUpdate(id, _ => Tuple.Create(Interlocked.Increment(ref currentZIndex), shape), (_, v) => Tuple.Create(v.Item1, shape)); + return s.Item1; + } + + public void RemoveShape(string id) + { + shapes.TryRemove(id, out _); + } + + public Shape GetShape(string id) + { + return shapes[id].Item2; + } + + public void ClearShapes() + { + shapes.Clear(); + } + + public IEnumerable> GetShapes() + { + return shapes.AsEnumerable().Select(l => Tuple.Create(l.Key, l.Value.Item1, l.Value.Item2)); + } public int UserEnter() { diff --git a/samples/Whiteboard/Hub/DrawHub.cs b/samples/Whiteboard/Hub/DrawHub.cs index ab7de76d..954358fb 100644 --- a/samples/Whiteboard/Hub/DrawHub.cs +++ b/samples/Whiteboard/Hub/DrawHub.cs @@ -2,9 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Microsoft.AspNetCore.SignalR; -using System.Collections.Generic; using System.Threading.Tasks; -using System.Linq; using System; namespace Microsoft.Azure.SignalR.Samples.Whiteboard; @@ -13,17 +11,19 @@ public class DrawHub(Diagram diagram) : Hub { private readonly Diagram diagram = diagram; - private async Task UpdateShape(string id, Shape shape) + private async Task UpdateShape(string id, Shape shape) { - diagram.Shapes[id] = shape; - await Clients.Others.SendAsync("ShapeUpdated", id, shape.GetType().Name, shape); + var z = diagram.AddOrUpdateShape(id, shape); + await Clients.Others.SendAsync("ShapeUpdated", id, shape.GetType().Name, shape, z); + return z; } - public override Task OnConnectedAsync() + public override async Task OnConnectedAsync() { - var t = Task.WhenAll(diagram.Shapes.AsEnumerable().Select(l => Clients.Client(Context.ConnectionId).SendAsync("ShapeUpdated", l.Key, l.Value.GetType().Name, l.Value))); - if (diagram.Background != null) t = t.ContinueWith(_ => Clients.Client(Context.ConnectionId).SendAsync("BackgroundUpdated", diagram.BackgroundId)); - return t.ContinueWith(_ => Clients.All.SendAsync("UserUpdated", diagram.UserEnter())); + await Clients.All.SendAsync("UserUpdated", diagram.UserEnter()); + if (diagram.Background != null) await Clients.Client(Context.ConnectionId).SendAsync("BackgroundUpdated", diagram.BackgroundId); + foreach (var s in diagram.GetShapes()) + await Clients.Caller.SendAsync("ShapeUpdated", s.Item1, s.Item3.GetType().Name, s.Item3, s.Item2); } public override Task OnDisconnectedAsync(Exception exception) @@ -33,47 +33,47 @@ public override Task OnDisconnectedAsync(Exception exception) public async Task RemoveShape(string id) { - diagram.Shapes.Remove(id, out _); + diagram.RemoveShape(id); await Clients.Others.SendAsync("ShapeRemoved", id); } - public async Task AddOrUpdatePolyline(string id, Polyline polyline) + public async Task AddOrUpdatePolyline(string id, Polyline polyline) { - await this.UpdateShape(id, polyline); + return await this.UpdateShape(id, polyline); } public async Task PatchPolyline(string id, Polyline polyline) { - if (diagram.Shapes[id] is not Polyline p) throw new InvalidOperationException($"Shape {id} does not exist or is not a polyline."); + if (diagram.GetShape(id) is not Polyline p) throw new InvalidOperationException($"Shape {id} does not exist or is not a polyline."); if (polyline.Color != null) p.Color = polyline.Color; if (polyline.Width != 0) p.Width = polyline.Width; p.Points.AddRange(polyline.Points); await Clients.Others.SendAsync("ShapePatched", id, polyline); } - public async Task AddOrUpdateLine(string id, Line line) + public async Task AddOrUpdateLine(string id, Line line) { - await this.UpdateShape(id, line); + return await this.UpdateShape(id, line); } - public async Task AddOrUpdateCircle(string id, Circle circle) + public async Task AddOrUpdateCircle(string id, Circle circle) { - await this.UpdateShape(id, circle); + return await this.UpdateShape(id, circle); } - public async Task AddOrUpdateRect(string id, Rect rect) + public async Task AddOrUpdateRect(string id, Rect rect) { - await this.UpdateShape(id, rect); + return await this.UpdateShape(id, rect); } - public async Task AddOrUpdateEllipse(string id, Ellipse ellipse) + public async Task AddOrUpdateEllipse(string id, Ellipse ellipse) { - await this.UpdateShape(id, ellipse); + return await this.UpdateShape(id, ellipse); } public async Task Clear() { - diagram.Shapes.Clear(); + diagram.ClearShapes(); diagram.Background = null; await Clients.Others.SendAsync("Clear"); } diff --git a/samples/Whiteboard/MCPServer/index.js b/samples/Whiteboard/MCPServer/index.js index 37643a95..7fd16318 100644 --- a/samples/Whiteboard/MCPServer/index.js +++ b/samples/Whiteboard/MCPServer/index.js @@ -10,15 +10,18 @@ const logger = new class { log = (level, message) => level > 1 && console.error(`[${level}] ${message}`); }; -const connection = new HubConnectionBuilder().withUrl(`${process.env['WHITEBOARD_ENDPOINT'] || 'http://localhost:5000'}/draw`).withAutomaticReconnect().configureLogging(logger).build(); +const endpoint = process.env['WHITEBOARD_ENDPOINT'] || 'http://localhost:5000'; + +const connection = new HubConnectionBuilder().withUrl(`${endpoint}/draw`).withAutomaticReconnect().configureLogging(logger).build(); const server = new McpServer({ name: 'Whiteboard', version: '1.0.0' }); -let color = z.string().describe('color of the shape, valid values are: black, grey, darkred, red, orange, yellow, green, deepskyblue, indigo, purple'); -let width = z.number().describe('width of the shape, valid values are: 1, 2, 4, 8'); +let color = z.string().describe('line color of the shape, value should be a valid CSS color'); +let fill = z.string().describe('fill color of the shape, value should be a valid CSS color'); +let width = z.number().describe('width of the shape in pixels'); let point = z.object({ x: z.number().describe('x coordinate of the point, 0 denotes the left edge of the whiteboard'), y: z.number().describe('y coordinate of the point, 0 denotes the top edge of the whiteboard') @@ -34,7 +37,7 @@ server.tool( 'add_or_update_polyline', 'add or update a polyline on whiteboard', { id, polyline: z.object({ - color, width, + color, width, fill, points: z.array(point).describe('array of points that define the polyline') }) }, @@ -47,7 +50,7 @@ server.tool( 'add_or_update_line', 'add or update a line on whiteboard', { id, line: z.object({ - color, width, + color, width, fill, start: point.describe('start point of the line'), end: point.describe('end point of the line') }) @@ -61,7 +64,7 @@ server.tool( 'add_or_update_circle', 'add or update a circle on whiteboard', { id, circle: z.object({ - color, width, + color, width, fill, center: point.describe('center point of the circle'), radius: z.number().describe('radius of the circle') }) @@ -75,9 +78,9 @@ server.tool( 'add_or_update_rect', 'add or update a rectangle on whiteboard', { id, rect: z.object({ - color, width, + color, width, fill, topLeft: point.describe('top left corner of the rectangle'), - bottomRight: point.describe('bottom right of the rectangle') + bottomRight: point.describe('bottom right corner of the rectangle') }) }, async ({ id, rect }) => { @@ -89,9 +92,9 @@ server.tool( 'add_or_update_ellipse', 'add or update an ellipse on whiteboard', { id, ellipse: z.object({ - color, width, + color, width, fill, topLeft: point.describe('top left corner of the bounding rectangle of the ellipse'), - bottomRight: point.describe('bottom right of the bounding rectangle of the ellipse') + bottomRight: point.describe('bottom right corner of the bounding rectangle of the ellipse') }) }, async ({ id, ellipse }) => { @@ -120,7 +123,7 @@ const transport = new StdioServerTransport(); await server.connect(transport); const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); -for (;;) { +for (; ;) { try { await connection.start(); break; diff --git a/samples/Whiteboard/README.md b/samples/Whiteboard/README.md index a56bd7af..f4598689 100644 --- a/samples/Whiteboard/README.md +++ b/samples/Whiteboard/README.md @@ -55,7 +55,7 @@ Open multiple windows on http://localhost:5000/, when you paint in one window, o 2. Then use the following command to deploy it to Azure Web App: ``` - az webapp deployment source config-zip --src -n -g + az webapp deploy --src-path -n -g ``` 3. Set Azure SignalR Service connection string in the application settings. You can do it through portal or using Azure CLI: @@ -85,21 +85,38 @@ To install the MCP server: npm install ``` -2. The MCP server will by default connect to local server (http://localhost:5000). If your whiteboard is not running locally, set the endpoint in `WHITEBOARD_ENDPOINT` environment variable or `.env` file - -3. Configure the MCP server in your LLM app (like Claude Desktop or GitHub Copilot in VS Code): +2. Configure the MCP server in your LLM app (like Claude Desktop or GitHub Copilot in VS Code): ```json "mcpServers": { "Whiteboard": { "command": "node", - "args": [ - "/index.js" - ] - } - } + "args": [ + "/index.js" + ] + } + } ``` + > This will by default connect to local server (http://localhost:5000). If your whiteboard is not running locally, save the endpoint to a `.env` file + > ``` + > WHITEBOARD_ENDPOINT= + > ``` + > + > Change the config to the following: + > ```json + > "mcpServers": { + > "Whiteboard": { + > "command": "node", + > "args": [ + > "--env-file", + > "/.env", + > "/index.js" + > ] + > } + > } + > ``` + > Change `mcpServers` to `mcp` if you're using VS Code 4. Start the server if it's not automatically started (like in VS Code) diff --git a/samples/Whiteboard/wwwroot/index.html b/samples/Whiteboard/wwwroot/index.html index 14356792..d900ea1b 100644 --- a/samples/Whiteboard/wwwroot/index.html +++ b/samples/Whiteboard/wwwroot/index.html @@ -64,6 +64,7 @@ + Color + Fill +