Skip to content

Commit 7c051c1

Browse files
authored
Add fill functionality to whiteboard (#313)
1. Allow selecting fill color for shapes 2. Improve the Azure deploy steps in README
1 parent 7a26c94 commit 7c051c1

File tree

7 files changed

+151
-67
lines changed

7 files changed

+151
-67
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ asrs.log.txt
99
**/global.json
1010
PublishProfiles/
1111
**.log.txt
12-
**.log
12+
**.log
13+
.env

samples/Whiteboard/Diagram.cs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
// Copyright (c) Microsoft. All rights reserved.
22
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
33

4+
using System;
45
using System.Collections.Concurrent;
56
using System.Collections.Generic;
7+
using System.Linq;
68
using System.Threading;
79

810
namespace Microsoft.Azure.SignalR.Samples.Whiteboard;
@@ -18,6 +20,8 @@ public abstract class Shape
1820
public string Color { get; set; }
1921

2022
public int Width { get; set; }
23+
24+
public string Fill { get; set; }
2125
}
2226

2327
public class Polyline : Shape
@@ -57,13 +61,41 @@ public class Diagram
5761
{
5862
private int totalUsers = 0;
5963

64+
private int currentZIndex = -1;
65+
66+
private readonly ConcurrentDictionary<string, Tuple<int, Shape>> shapes = new();
67+
6068
public byte[] Background { get; set; }
6169

6270
public string BackgroundContentType { get; set; }
6371

6472
public string BackgroundId { get; set; }
6573

66-
public ConcurrentDictionary<string, Shape> Shapes { get; } = new ConcurrentDictionary<string, Shape>();
74+
public int AddOrUpdateShape(string id, Shape shape)
75+
{
76+
var s = shapes.AddOrUpdate(id, _ => Tuple.Create(Interlocked.Increment(ref currentZIndex), shape), (_, v) => Tuple.Create(v.Item1, shape));
77+
return s.Item1;
78+
}
79+
80+
public void RemoveShape(string id)
81+
{
82+
shapes.TryRemove(id, out _);
83+
}
84+
85+
public Shape GetShape(string id)
86+
{
87+
return shapes[id].Item2;
88+
}
89+
90+
public void ClearShapes()
91+
{
92+
shapes.Clear();
93+
}
94+
95+
public IEnumerable<Tuple<string, int, Shape>> GetShapes()
96+
{
97+
return shapes.AsEnumerable().Select(l => Tuple.Create(l.Key, l.Value.Item1, l.Value.Item2));
98+
}
6799

68100
public int UserEnter()
69101
{

samples/Whiteboard/Hub/DrawHub.cs

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
33

44
using Microsoft.AspNetCore.SignalR;
5-
using System.Collections.Generic;
65
using System.Threading.Tasks;
7-
using System.Linq;
86
using System;
97

108
namespace Microsoft.Azure.SignalR.Samples.Whiteboard;
@@ -13,17 +11,19 @@ public class DrawHub(Diagram diagram) : Hub
1311
{
1412
private readonly Diagram diagram = diagram;
1513

16-
private async Task UpdateShape(string id, Shape shape)
14+
private async Task<int> UpdateShape(string id, Shape shape)
1715
{
18-
diagram.Shapes[id] = shape;
19-
await Clients.Others.SendAsync("ShapeUpdated", id, shape.GetType().Name, shape);
16+
var z = diagram.AddOrUpdateShape(id, shape);
17+
await Clients.Others.SendAsync("ShapeUpdated", id, shape.GetType().Name, shape, z);
18+
return z;
2019
}
2120

22-
public override Task OnConnectedAsync()
21+
public override async Task OnConnectedAsync()
2322
{
24-
var t = Task.WhenAll(diagram.Shapes.AsEnumerable().Select(l => Clients.Client(Context.ConnectionId).SendAsync("ShapeUpdated", l.Key, l.Value.GetType().Name, l.Value)));
25-
if (diagram.Background != null) t = t.ContinueWith(_ => Clients.Client(Context.ConnectionId).SendAsync("BackgroundUpdated", diagram.BackgroundId));
26-
return t.ContinueWith(_ => Clients.All.SendAsync("UserUpdated", diagram.UserEnter()));
23+
await Clients.All.SendAsync("UserUpdated", diagram.UserEnter());
24+
if (diagram.Background != null) await Clients.Client(Context.ConnectionId).SendAsync("BackgroundUpdated", diagram.BackgroundId);
25+
foreach (var s in diagram.GetShapes())
26+
await Clients.Caller.SendAsync("ShapeUpdated", s.Item1, s.Item3.GetType().Name, s.Item3, s.Item2);
2727
}
2828

2929
public override Task OnDisconnectedAsync(Exception exception)
@@ -33,47 +33,47 @@ public override Task OnDisconnectedAsync(Exception exception)
3333

3434
public async Task RemoveShape(string id)
3535
{
36-
diagram.Shapes.Remove(id, out _);
36+
diagram.RemoveShape(id);
3737
await Clients.Others.SendAsync("ShapeRemoved", id);
3838
}
3939

40-
public async Task AddOrUpdatePolyline(string id, Polyline polyline)
40+
public async Task<int> AddOrUpdatePolyline(string id, Polyline polyline)
4141
{
42-
await this.UpdateShape(id, polyline);
42+
return await this.UpdateShape(id, polyline);
4343
}
4444

4545
public async Task PatchPolyline(string id, Polyline polyline)
4646
{
47-
if (diagram.Shapes[id] is not Polyline p) throw new InvalidOperationException($"Shape {id} does not exist or is not a polyline.");
47+
if (diagram.GetShape(id) is not Polyline p) throw new InvalidOperationException($"Shape {id} does not exist or is not a polyline.");
4848
if (polyline.Color != null) p.Color = polyline.Color;
4949
if (polyline.Width != 0) p.Width = polyline.Width;
5050
p.Points.AddRange(polyline.Points);
5151
await Clients.Others.SendAsync("ShapePatched", id, polyline);
5252
}
5353

54-
public async Task AddOrUpdateLine(string id, Line line)
54+
public async Task<int> AddOrUpdateLine(string id, Line line)
5555
{
56-
await this.UpdateShape(id, line);
56+
return await this.UpdateShape(id, line);
5757
}
5858

59-
public async Task AddOrUpdateCircle(string id, Circle circle)
59+
public async Task<int> AddOrUpdateCircle(string id, Circle circle)
6060
{
61-
await this.UpdateShape(id, circle);
61+
return await this.UpdateShape(id, circle);
6262
}
6363

64-
public async Task AddOrUpdateRect(string id, Rect rect)
64+
public async Task<int> AddOrUpdateRect(string id, Rect rect)
6565
{
66-
await this.UpdateShape(id, rect);
66+
return await this.UpdateShape(id, rect);
6767
}
6868

69-
public async Task AddOrUpdateEllipse(string id, Ellipse ellipse)
69+
public async Task<int> AddOrUpdateEllipse(string id, Ellipse ellipse)
7070
{
71-
await this.UpdateShape(id, ellipse);
71+
return await this.UpdateShape(id, ellipse);
7272
}
7373

7474
public async Task Clear()
7575
{
76-
diagram.Shapes.Clear();
76+
diagram.ClearShapes();
7777
diagram.Background = null;
7878
await Clients.Others.SendAsync("Clear");
7979
}

samples/Whiteboard/MCPServer/index.js

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,18 @@ const logger = new class {
1010
log = (level, message) => level > 1 && console.error(`[${level}] ${message}`);
1111
};
1212

13-
const connection = new HubConnectionBuilder().withUrl(`${process.env['WHITEBOARD_ENDPOINT'] || 'http://localhost:5000'}/draw`).withAutomaticReconnect().configureLogging(logger).build();
13+
const endpoint = process.env['WHITEBOARD_ENDPOINT'] || 'http://localhost:5000';
14+
15+
const connection = new HubConnectionBuilder().withUrl(`${endpoint}/draw`).withAutomaticReconnect().configureLogging(logger).build();
1416

1517
const server = new McpServer({
1618
name: 'Whiteboard',
1719
version: '1.0.0'
1820
});
1921

20-
let color = z.string().describe('color of the shape, valid values are: black, grey, darkred, red, orange, yellow, green, deepskyblue, indigo, purple');
21-
let width = z.number().describe('width of the shape, valid values are: 1, 2, 4, 8');
22+
let color = z.string().describe('line color of the shape, value should be a valid CSS color');
23+
let fill = z.string().describe('fill color of the shape, value should be a valid CSS color');
24+
let width = z.number().describe('width of the shape in pixels');
2225
let point = z.object({
2326
x: z.number().describe('x coordinate of the point, 0 denotes the left edge of the whiteboard'),
2427
y: z.number().describe('y coordinate of the point, 0 denotes the top edge of the whiteboard')
@@ -34,7 +37,7 @@ server.tool(
3437
'add_or_update_polyline', 'add or update a polyline on whiteboard',
3538
{
3639
id, polyline: z.object({
37-
color, width,
40+
color, width, fill,
3841
points: z.array(point).describe('array of points that define the polyline')
3942
})
4043
},
@@ -47,7 +50,7 @@ server.tool(
4750
'add_or_update_line', 'add or update a line on whiteboard',
4851
{
4952
id, line: z.object({
50-
color, width,
53+
color, width, fill,
5154
start: point.describe('start point of the line'),
5255
end: point.describe('end point of the line')
5356
})
@@ -61,7 +64,7 @@ server.tool(
6164
'add_or_update_circle', 'add or update a circle on whiteboard',
6265
{
6366
id, circle: z.object({
64-
color, width,
67+
color, width, fill,
6568
center: point.describe('center point of the circle'),
6669
radius: z.number().describe('radius of the circle')
6770
})
@@ -75,9 +78,9 @@ server.tool(
7578
'add_or_update_rect', 'add or update a rectangle on whiteboard',
7679
{
7780
id, rect: z.object({
78-
color, width,
81+
color, width, fill,
7982
topLeft: point.describe('top left corner of the rectangle'),
80-
bottomRight: point.describe('bottom right of the rectangle')
83+
bottomRight: point.describe('bottom right corner of the rectangle')
8184
})
8285
},
8386
async ({ id, rect }) => {
@@ -89,9 +92,9 @@ server.tool(
8992
'add_or_update_ellipse', 'add or update an ellipse on whiteboard',
9093
{
9194
id, ellipse: z.object({
92-
color, width,
95+
color, width, fill,
9396
topLeft: point.describe('top left corner of the bounding rectangle of the ellipse'),
94-
bottomRight: point.describe('bottom right of the bounding rectangle of the ellipse')
97+
bottomRight: point.describe('bottom right corner of the bounding rectangle of the ellipse')
9598
})
9699
},
97100
async ({ id, ellipse }) => {
@@ -120,7 +123,7 @@ const transport = new StdioServerTransport();
120123
await server.connect(transport);
121124

122125
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
123-
for (;;) {
126+
for (; ;) {
124127
try {
125128
await connection.start();
126129
break;

samples/Whiteboard/README.md

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Open multiple windows on http://localhost:5000/, when you paint in one window, o
5555
2. Then use the following command to deploy it to Azure Web App:
5656
5757
```
58-
az webapp deployment source config-zip --src <path_to_zip_file> -n <app_name> -g <resource_group_name>
58+
az webapp deploy --src-path <path_to_zip_file> -n <app_name> -g <resource_group_name>
5959
```
6060
6161
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:
8585
npm install
8686
```
8787
88-
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
89-
90-
3. Configure the MCP server in your LLM app (like Claude Desktop or GitHub Copilot in VS Code):
88+
2. Configure the MCP server in your LLM app (like Claude Desktop or GitHub Copilot in VS Code):
9189
9290
```json
9391
"mcpServers": {
9492
"Whiteboard": {
9593
"command": "node",
96-
"args": [
97-
"<path-to-MCPServer-project>/index.js"
98-
]
99-
}
100-
}
94+
"args": [
95+
"<path_to_MCPServer_project>/index.js"
96+
]
97+
}
98+
}
10199
```
102100

101+
> 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
102+
> ```
103+
> WHITEBOARD_ENDPOINT=<endpoint-of-whiteboard>
104+
> ```
105+
>
106+
> Change the config to the following:
107+
> ```json
108+
> "mcpServers": {
109+
> "Whiteboard": {
110+
> "command": "node",
111+
> "args": [
112+
> "--env-file",
113+
> "<path_to_MCPServer_project>/.env",
114+
> "<path_to_MCPServer_project>/index.js"
115+
> ]
116+
> }
117+
> }
118+
> ```
119+
103120
> Change `mcpServers` to `mcp` if you're using VS Code
104121
105122
4. Start the server if it's not automatically started (like in VS Code)

samples/Whiteboard/wwwroot/index.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
</a>
6565
</div>
6666
</li>
67+
<span class="navbar-text">Color</span>
6768
<li class="nav-item dropdown">
6869
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
6970
aria-expanded="false">
@@ -76,6 +77,19 @@
7677
</a>
7778
</div>
7879
</li>
80+
<span class="navbar-text">Fill</span>
81+
<li class="nav-item dropdown">
82+
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
83+
aria-expanded="false">
84+
<span class="toolbox selected" v-bind:style="{ 'background-color': fill }"></span>
85+
</a>
86+
<div class="dropdown-menu">
87+
<a v-for="c in colors" v-bind:class="{ active: c === fill, 'dropdown-item': true }"
88+
v-on:click.prevent="fill = c" href="#">
89+
<span class="toolbox" v-bind:style="{ 'background-color': c }"></span>
90+
</a>
91+
</div>
92+
</li>
7993
<li class="nav-item dropdown">
8094
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
8195
aria-expanded="false">

0 commit comments

Comments
 (0)