pi-tldraw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/bridge/app-bridge-entry.js +6 -0
  4. package/mcp-app/LICENSE.md +9 -0
  5. package/mcp-app/PI_TLDRAW_PROVENANCE.json +32 -0
  6. package/mcp-app/README.md +129 -0
  7. package/mcp-app/dev-tunnel.sh +51 -0
  8. package/mcp-app/dist/editor-api.json +8493 -0
  9. package/mcp-app/dist/mcp-app.html +643 -0
  10. package/mcp-app/dist/method-map.json +915 -0
  11. package/mcp-app/package.json +42 -0
  12. package/mcp-app/plugins/tldraw-mcp/.cursor-plugin/plugin.json +10 -0
  13. package/mcp-app/plugins/tldraw-mcp/assets/logo.svg +3 -0
  14. package/mcp-app/plugins/tldraw-mcp/mcp.json +8 -0
  15. package/mcp-app/scripts/extract-editor-api.ts +1374 -0
  16. package/mcp-app/server.json +21 -0
  17. package/mcp-app/src/logger.ts +45 -0
  18. package/mcp-app/src/register-tools.ts +368 -0
  19. package/mcp-app/src/shared/generated-data.ts +160 -0
  20. package/mcp-app/src/shared/pending-requests.ts +69 -0
  21. package/mcp-app/src/shared/types.ts +76 -0
  22. package/mcp-app/src/shared/utils.ts +132 -0
  23. package/mcp-app/src/tools/exec.ts +120 -0
  24. package/mcp-app/src/tools/loadCachedCanvasWidgetHtml.ts +16 -0
  25. package/mcp-app/src/tools/search.ts +150 -0
  26. package/mcp-app/src/widget/app-context.tsx +29 -0
  27. package/mcp-app/src/widget/dev-log.tsx +70 -0
  28. package/mcp-app/src/widget/exec-helpers.ts +232 -0
  29. package/mcp-app/src/widget/export-tldr.ts +35 -0
  30. package/mcp-app/src/widget/focused/defaults.ts +141 -0
  31. package/mcp-app/src/widget/focused/focused-editor-proxy.ts +434 -0
  32. package/mcp-app/src/widget/focused/format.ts +366 -0
  33. package/mcp-app/src/widget/focused/to-focused.ts +258 -0
  34. package/mcp-app/src/widget/focused/to-tldraw.ts +570 -0
  35. package/mcp-app/src/widget/image-guard.tsx +106 -0
  36. package/mcp-app/src/widget/index.html +33 -0
  37. package/mcp-app/src/widget/mcp-app.css +113 -0
  38. package/mcp-app/src/widget/mcp-app.tsx +857 -0
  39. package/mcp-app/src/widget/persistence.ts +337 -0
  40. package/mcp-app/src/widget/snapshot.ts +157 -0
  41. package/mcp-app/src/worker.ts +305 -0
  42. package/mcp-app/tsconfig.json +23 -0
  43. package/mcp-app/vite.config.ts +13 -0
  44. package/mcp-app/wrangler.toml +45 -0
  45. package/mcp-app-source.json +36 -0
  46. package/package.json +90 -0
  47. package/patches/tldraw-mcp-app/001-pi-runtime.patch +35 -0
  48. package/scripts/assemble-mcp-app.mjs +193 -0
  49. package/scripts/build-bridge.mjs +74 -0
  50. package/scripts/e2e-mcp.mjs +69 -0
  51. package/scripts/e2e-packaged-mcp-app.mjs +79 -0
  52. package/scripts/run-mcp-app-dev.mjs +44 -0
  53. package/scripts/verify-bundle.mjs +41 -0
  54. package/scripts/verify-mcp-app-source.mjs +51 -0
  55. package/scripts/verify-mcp-app.mjs +38 -0
  56. package/scripts/verify-package-files.mjs +50 -0
  57. package/src/canvas/export.ts +164 -0
  58. package/src/canvas/state.ts +117 -0
  59. package/src/canvas/workflow.ts +105 -0
  60. package/src/commands/tldraw-command.ts +48 -0
  61. package/src/diagram/guidance.ts +44 -0
  62. package/src/host/local-host.ts +289 -0
  63. package/src/index.ts +762 -0
  64. package/src/mcp/client.ts +126 -0
  65. package/src/mcp/response.ts +74 -0
  66. package/src/semantic/layer.ts +309 -0
  67. package/src/server/server-manager.ts +153 -0
  68. package/src/store/export-store.ts +33 -0
  69. package/src/store/project-store.ts +251 -0
  70. package/src/ui/tldraw-status.ts +88 -0
  71. package/static/app-bridge-bundle.js +18114 -0
  72. package/static/app-bridge-bundle.meta.json +164 -0
  73. package/static/host.html +390 -0
  74. package/tsconfig.json +13 -0
@@ -0,0 +1,570 @@
1
+ /**
2
+ * Convert FocusedShape → tldraw TLShape.
3
+ * Ported from tldraw/tldraw templates/agent/shared/format/convertFocusedShapeToTldrawShape.ts
4
+ */
5
+ import {
6
+ Box,
7
+ createShapeId,
8
+ Editor,
9
+ IndexKey,
10
+ reverseRecordsDiff,
11
+ TLArrowShape,
12
+ TLBindingCreate,
13
+ TLDefaultSizeStyle,
14
+ TLDrawShape,
15
+ TLGeoShape,
16
+ TLLineShape,
17
+ TLNoteShape,
18
+ TLShape,
19
+ TLTextShape,
20
+ toRichText,
21
+ Vec,
22
+ } from 'tldraw'
23
+ import {
24
+ asColor,
25
+ convertFocusedFillToTldrawFill,
26
+ convertFocusedFontSizeToTldrawFontSizeAndScale,
27
+ convertSimpleIdToTldrawId,
28
+ FOCUSED_TO_GEO_TYPES,
29
+ type FocusedArrowShape,
30
+ type FocusedDrawShape,
31
+ type FocusedGeoShape,
32
+ type FocusedGeoShapeType,
33
+ type FocusedLineShape,
34
+ type FocusedNoteShape,
35
+ type FocusedShape,
36
+ type FocusedTextShape,
37
+ type FocusedUnknownShape,
38
+ } from './format'
39
+
40
+ export function convertFocusedShapeToTldrawShape(
41
+ editor: Editor,
42
+ focusedShape: FocusedShape,
43
+ { defaultShape }: { defaultShape: Partial<TLShape> }
44
+ ): { shape: TLShape; bindings?: TLBindingCreate[] } {
45
+ switch (focusedShape._type) {
46
+ case 'text':
47
+ return convertTextShapeToTldrawShape(editor, focusedShape, { defaultShape })
48
+ case 'line':
49
+ return convertLineShapeToTldrawShape(editor, focusedShape, { defaultShape })
50
+ case 'arrow':
51
+ return convertArrowShapeToTldrawShape(editor, focusedShape, { defaultShape })
52
+ case 'note':
53
+ return convertNoteShapeToTldrawShape(editor, focusedShape, { defaultShape })
54
+ case 'draw':
55
+ return convertDrawShapeToTldrawShape(editor, focusedShape, { defaultShape })
56
+ case 'unknown':
57
+ return convertUnknownShapeToTldrawShape(editor, focusedShape, { defaultShape })
58
+ default:
59
+ // Geo types (rectangle, ellipse, etc.)
60
+ return convertGeoShapeToTldrawShape(editor, focusedShape as FocusedGeoShape, {
61
+ defaultShape,
62
+ })
63
+ }
64
+ }
65
+
66
+ export function convertFocusedGeoTypeToTldrawGeoGeoType(type: FocusedGeoShapeType) {
67
+ return FOCUSED_TO_GEO_TYPES[type]
68
+ }
69
+
70
+ function convertTextShapeToTldrawShape(
71
+ editor: Editor,
72
+ focusedShape: FocusedTextShape,
73
+ { defaultShape }: { defaultShape: Partial<TLShape> }
74
+ ): { shape: TLTextShape } {
75
+ const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
76
+ const defaultTextShape = defaultShape as TLTextShape
77
+ const baseFontSize = editor.getTheme('default')?.fontSize ?? 16
78
+
79
+ let textSize: TLDefaultSizeStyle = 's'
80
+ let scale = 1
81
+
82
+ if (focusedShape.fontSize) {
83
+ const result = convertFocusedFontSizeToTldrawFontSizeAndScale(
84
+ focusedShape.fontSize,
85
+ baseFontSize
86
+ )
87
+ textSize = result.textSize
88
+ scale = result.scale
89
+ } else if (defaultTextShape.props?.size) {
90
+ textSize = defaultTextShape.props.size
91
+ scale = defaultTextShape.props.scale ?? 1
92
+ }
93
+
94
+ const autoSize =
95
+ focusedShape.maxWidth !== undefined && focusedShape.maxWidth !== null
96
+ ? false
97
+ : (defaultTextShape.props?.autoSize ?? true)
98
+ const font = defaultTextShape.props?.font ?? 'draw'
99
+
100
+ let richText
101
+ if (focusedShape.text !== undefined) {
102
+ richText = toRichText(focusedShape.text)
103
+ } else if (defaultTextShape.props?.richText) {
104
+ richText = defaultTextShape.props.richText
105
+ } else {
106
+ richText = toRichText('')
107
+ }
108
+
109
+ let textAlign: TLTextShape['props']['textAlign'] = defaultTextShape.props?.textAlign ?? 'start'
110
+ switch (focusedShape.anchor) {
111
+ case 'top-left':
112
+ case 'bottom-left':
113
+ case 'center-left':
114
+ textAlign = 'start'
115
+ break
116
+ case 'top-center':
117
+ case 'bottom-center':
118
+ case 'center':
119
+ textAlign = 'middle'
120
+ break
121
+ case 'top-right':
122
+ case 'bottom-right':
123
+ case 'center-right':
124
+ textAlign = 'end'
125
+ break
126
+ }
127
+
128
+ const unpositionedShape: TLTextShape = {
129
+ id: shapeId,
130
+ type: 'text',
131
+ typeName: 'shape',
132
+ x: 0,
133
+ y: 0,
134
+ rotation: defaultTextShape.rotation ?? 0,
135
+ index: defaultTextShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
136
+ parentId: defaultTextShape.parentId ?? editor.getCurrentPageId(),
137
+ isLocked: defaultTextShape.isLocked ?? false,
138
+ opacity: defaultTextShape.opacity ?? 1,
139
+ props: {
140
+ size: textSize,
141
+ scale,
142
+ richText,
143
+ color: asColor(focusedShape.color ?? defaultTextShape.props?.color ?? 'black'),
144
+ textAlign,
145
+ autoSize,
146
+ w:
147
+ focusedShape.maxWidth !== undefined && focusedShape.maxWidth !== null
148
+ ? focusedShape.maxWidth
149
+ : (defaultTextShape.props?.w ?? 100),
150
+ font,
151
+ },
152
+ meta: {
153
+ note: focusedShape.note ?? defaultTextShape.meta?.note ?? '',
154
+ },
155
+ }
156
+
157
+ const unpositionedBounds = getDummyBounds(editor, unpositionedShape)
158
+
159
+ const position = new Vec(defaultTextShape.x ?? 0, defaultTextShape.y ?? 0)
160
+ const x = focusedShape.x ?? defaultTextShape.x ?? 0
161
+ const y = focusedShape.y ?? defaultTextShape.y ?? 0
162
+ switch (focusedShape.anchor) {
163
+ case 'top-center': {
164
+ position.x = x - unpositionedBounds.w / 2
165
+ position.y = y
166
+ break
167
+ }
168
+ case 'top-right': {
169
+ position.x = x - unpositionedBounds.w
170
+ position.y = y
171
+ break
172
+ }
173
+ case 'bottom-left': {
174
+ position.x = x
175
+ position.y = y - unpositionedBounds.h
176
+ break
177
+ }
178
+ case 'bottom-center': {
179
+ position.x = x - unpositionedBounds.w / 2
180
+ position.y = y - unpositionedBounds.h
181
+ break
182
+ }
183
+ case 'bottom-right': {
184
+ position.x = x - unpositionedBounds.w
185
+ position.y = y - unpositionedBounds.h
186
+ break
187
+ }
188
+ case 'center-left': {
189
+ position.x = x
190
+ position.y = y - unpositionedBounds.h / 2
191
+ break
192
+ }
193
+ case 'center-right': {
194
+ position.x = x - unpositionedBounds.w
195
+ position.y = y - unpositionedBounds.h / 2
196
+ break
197
+ }
198
+ case 'center': {
199
+ position.x = focusedShape.x - unpositionedBounds.w / 2
200
+ position.y = focusedShape.y - unpositionedBounds.h / 2
201
+ break
202
+ }
203
+ case 'top-left':
204
+ default: {
205
+ position.x = x
206
+ position.y = y
207
+ break
208
+ }
209
+ }
210
+
211
+ return {
212
+ shape: { ...unpositionedShape, x: position.x, y: position.y },
213
+ }
214
+ }
215
+
216
+ function convertLineShapeToTldrawShape(
217
+ editor: Editor,
218
+ focusedShape: FocusedLineShape,
219
+ { defaultShape }: { defaultShape: Partial<TLShape> }
220
+ ): { shape: TLShape } {
221
+ const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
222
+ const defaultLineShape = defaultShape as TLLineShape
223
+
224
+ const x1 = focusedShape.x1 ?? 0
225
+ const y1 = focusedShape.y1 ?? 0
226
+ const x2 = focusedShape.x2 ?? 0
227
+ const y2 = focusedShape.y2 ?? 0
228
+ const minX = Math.min(x1, x2)
229
+ const minY = Math.min(y1, y2)
230
+
231
+ return {
232
+ shape: {
233
+ id: shapeId,
234
+ type: 'line',
235
+ typeName: 'shape',
236
+ x: minX,
237
+ y: minY,
238
+ rotation: defaultLineShape.rotation ?? 0,
239
+ index: defaultLineShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
240
+ parentId: defaultLineShape.parentId ?? editor.getCurrentPageId(),
241
+ isLocked: defaultLineShape.isLocked ?? false,
242
+ opacity: defaultLineShape.opacity ?? 1,
243
+ props: {
244
+ size: defaultLineShape.props?.size ?? 's',
245
+ points: {
246
+ a1: { id: 'a1', index: 'a1' as IndexKey, x: x1 - minX, y: y1 - minY },
247
+ a2: { id: 'a2', index: 'a2' as IndexKey, x: x2 - minX, y: y2 - minY },
248
+ },
249
+ color: asColor(focusedShape.color ?? defaultLineShape.props?.color ?? 'black'),
250
+ dash: defaultLineShape.props?.dash ?? 'draw',
251
+ scale: defaultLineShape.props?.scale ?? 1,
252
+ spline: defaultLineShape.props?.spline ?? 'line',
253
+ },
254
+ meta: {
255
+ note: focusedShape.note ?? defaultLineShape.meta?.note ?? '',
256
+ },
257
+ },
258
+ }
259
+ }
260
+
261
+ function convertArrowShapeToTldrawShape(
262
+ editor: Editor,
263
+ focusedShape: FocusedArrowShape,
264
+ { defaultShape }: { defaultShape: Partial<TLShape> }
265
+ ): { shape: TLShape; bindings?: TLBindingCreate[] } {
266
+ const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
267
+ const defaultArrowShape = defaultShape as TLArrowShape
268
+
269
+ const x1 = focusedShape.x1 ?? defaultArrowShape.props?.start?.x ?? 0
270
+ const y1 = focusedShape.y1 ?? defaultArrowShape.props?.start?.y ?? 0
271
+ const x2 = focusedShape.x2 ?? defaultArrowShape.props?.end?.x ?? 0
272
+ const y2 = focusedShape.y2 ?? defaultArrowShape.props?.end?.y ?? 0
273
+ const minX = Math.min(x1, x2)
274
+ const minY = Math.min(y1, y2)
275
+
276
+ let richText
277
+ if (focusedShape.text !== undefined) {
278
+ richText = toRichText(focusedShape.text)
279
+ } else if (defaultArrowShape.props?.richText) {
280
+ richText = defaultArrowShape.props.richText
281
+ } else {
282
+ richText = toRichText('')
283
+ }
284
+
285
+ const shape = {
286
+ id: shapeId,
287
+ type: 'arrow' as const,
288
+ typeName: 'shape' as const,
289
+ x: minX,
290
+ y: minY,
291
+ rotation: defaultArrowShape.rotation ?? 0,
292
+ index: defaultArrowShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
293
+ parentId: defaultArrowShape.parentId ?? editor.getCurrentPageId(),
294
+ isLocked: defaultArrowShape.isLocked ?? false,
295
+ opacity: defaultArrowShape.opacity ?? 1,
296
+ props: {
297
+ arrowheadEnd: defaultArrowShape.props?.arrowheadEnd ?? 'arrow',
298
+ arrowheadStart: defaultArrowShape.props?.arrowheadStart ?? 'none',
299
+ bend: (focusedShape.bend ?? (defaultArrowShape.props?.bend ?? 0) * -1) * -1,
300
+ color: asColor(focusedShape.color ?? defaultArrowShape.props?.color ?? 'black'),
301
+ dash: defaultArrowShape.props?.dash ?? 'draw',
302
+ elbowMidPoint: defaultArrowShape.props?.elbowMidPoint ?? 0.5,
303
+ end: { x: x2 - minX, y: y2 - minY },
304
+ fill: defaultArrowShape.props?.fill ?? 'none',
305
+ font: defaultArrowShape.props?.font ?? 'draw',
306
+ kind: focusedShape.kind ?? defaultArrowShape.props?.kind ?? 'arc',
307
+ labelColor: defaultArrowShape.props?.labelColor ?? 'black',
308
+ labelPosition: defaultArrowShape.props?.labelPosition ?? 0.5,
309
+ richText,
310
+ scale: defaultArrowShape.props?.scale ?? 1,
311
+ size: defaultArrowShape.props?.size ?? 's',
312
+ start: { x: x1 - minX, y: y1 - minY },
313
+ },
314
+ meta: {
315
+ note: focusedShape.note ?? defaultArrowShape.meta?.note ?? '',
316
+ },
317
+ }
318
+
319
+ const bindings: TLBindingCreate[] = []
320
+
321
+ if (focusedShape.fromId) {
322
+ const fromId = convertSimpleIdToTldrawId(focusedShape.fromId)
323
+ const startShape = editor.getShape(fromId)
324
+ if (startShape) {
325
+ bindings.push({
326
+ type: 'arrow',
327
+ typeName: 'binding',
328
+ fromId: shapeId,
329
+ toId: startShape.id,
330
+ props: {
331
+ normalizedAnchor: { x: 0.5, y: 0.5 },
332
+ isExact: false,
333
+ isPrecise: false,
334
+ terminal: 'start',
335
+ },
336
+ meta: {},
337
+ })
338
+ }
339
+ }
340
+
341
+ if (focusedShape.toId) {
342
+ const toId = convertSimpleIdToTldrawId(focusedShape.toId)
343
+ const endShape = editor.getShape(toId)
344
+ if (endShape) {
345
+ bindings.push({
346
+ type: 'arrow',
347
+ typeName: 'binding',
348
+ fromId: shapeId,
349
+ toId: endShape.id,
350
+ props: {
351
+ normalizedAnchor: { x: 0.5, y: 0.5 },
352
+ isExact: false,
353
+ isPrecise: false,
354
+ terminal: 'end',
355
+ },
356
+ meta: {},
357
+ })
358
+ }
359
+ }
360
+
361
+ return {
362
+ shape,
363
+ bindings: bindings.length > 0 ? bindings : undefined,
364
+ }
365
+ }
366
+
367
+ function convertGeoShapeToTldrawShape(
368
+ editor: Editor,
369
+ focusedShape: FocusedGeoShape,
370
+ { defaultShape }: { defaultShape: Partial<TLShape> }
371
+ ): { shape: TLShape } {
372
+ const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
373
+ const shapeType = convertFocusedGeoTypeToTldrawGeoGeoType(focusedShape._type)
374
+ const defaultGeoShape = defaultShape as TLGeoShape
375
+
376
+ let richText
377
+ if (focusedShape.text !== undefined) {
378
+ richText = toRichText(focusedShape.text)
379
+ } else if (defaultGeoShape.props?.richText) {
380
+ richText = defaultGeoShape.props.richText
381
+ } else {
382
+ richText = toRichText('')
383
+ }
384
+
385
+ let fill
386
+ if (focusedShape.fill !== undefined) {
387
+ fill = convertFocusedFillToTldrawFill(focusedShape.fill) ?? 'none'
388
+ } else if (defaultGeoShape.props?.fill) {
389
+ fill = defaultGeoShape.props.fill
390
+ } else {
391
+ fill = convertFocusedFillToTldrawFill('none')
392
+ }
393
+
394
+ return {
395
+ shape: {
396
+ id: shapeId,
397
+ type: 'geo',
398
+ typeName: 'shape',
399
+ x: focusedShape.x ?? defaultGeoShape.x ?? 0,
400
+ y: focusedShape.y ?? defaultGeoShape.y ?? 0,
401
+ rotation: defaultGeoShape.rotation ?? 0,
402
+ index: defaultGeoShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
403
+ parentId: defaultGeoShape.parentId ?? editor.getCurrentPageId(),
404
+ isLocked: defaultGeoShape.isLocked ?? false,
405
+ opacity: defaultGeoShape.opacity ?? 1,
406
+ props: {
407
+ align: focusedShape.textAlign ?? defaultGeoShape.props?.align ?? 'start',
408
+ color: asColor(focusedShape.color ?? defaultGeoShape.props?.color ?? 'black'),
409
+ dash: defaultGeoShape.props?.dash ?? 'draw',
410
+ fill,
411
+ font: defaultGeoShape.props?.font ?? 'draw',
412
+ geo: shapeType,
413
+ growY: defaultGeoShape.props?.growY ?? 0,
414
+ h: focusedShape.h ?? defaultGeoShape.props?.h ?? 100,
415
+ labelColor: defaultGeoShape.props?.labelColor ?? 'black',
416
+ richText,
417
+ scale: defaultGeoShape.props?.scale ?? 1,
418
+ size: defaultGeoShape.props?.size ?? 's',
419
+ url: defaultGeoShape.props?.url ?? '',
420
+ verticalAlign: defaultGeoShape.props?.verticalAlign ?? 'start',
421
+ w: focusedShape.w ?? defaultGeoShape.props?.w ?? 100,
422
+ },
423
+ meta: {
424
+ note: focusedShape.note ?? defaultGeoShape.meta?.note ?? '',
425
+ },
426
+ },
427
+ }
428
+ }
429
+
430
+ function convertNoteShapeToTldrawShape(
431
+ editor: Editor,
432
+ focusedShape: FocusedNoteShape,
433
+ { defaultShape }: { defaultShape: Partial<TLShape> }
434
+ ): { shape: TLShape } {
435
+ const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
436
+ const defaultNoteShape = defaultShape as TLNoteShape
437
+
438
+ let richText
439
+ if (focusedShape.text !== undefined) {
440
+ richText = toRichText(focusedShape.text)
441
+ } else if (defaultNoteShape.props?.richText) {
442
+ richText = defaultNoteShape.props.richText
443
+ } else {
444
+ richText = toRichText('')
445
+ }
446
+
447
+ return {
448
+ shape: {
449
+ id: shapeId,
450
+ type: 'note',
451
+ typeName: 'shape',
452
+ x: focusedShape.x ?? defaultNoteShape.x ?? 0,
453
+ y: focusedShape.y ?? defaultNoteShape.y ?? 0,
454
+ rotation: defaultNoteShape.rotation ?? 0,
455
+ index: defaultNoteShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
456
+ parentId: defaultNoteShape.parentId ?? editor.getCurrentPageId(),
457
+ isLocked: defaultNoteShape.isLocked ?? false,
458
+ opacity: defaultNoteShape.opacity ?? 1,
459
+ props: {
460
+ color: asColor(focusedShape.color ?? defaultNoteShape.props?.color ?? 'black'),
461
+ richText,
462
+ size: defaultNoteShape.props?.size ?? 's',
463
+ align: defaultNoteShape.props?.align ?? 'middle',
464
+ font: defaultNoteShape.props?.font ?? 'draw',
465
+ fontSizeAdjustment: defaultNoteShape.props?.fontSizeAdjustment ?? 0,
466
+ growY: defaultNoteShape.props?.growY ?? 0,
467
+ labelColor: defaultNoteShape.props?.labelColor ?? 'black',
468
+ scale: defaultNoteShape.props?.scale ?? 1,
469
+ url: defaultNoteShape.props?.url ?? '',
470
+ verticalAlign: defaultNoteShape.props?.verticalAlign ?? 'middle',
471
+ textFirstEditedBy: defaultNoteShape.props?.textFirstEditedBy ?? null,
472
+ },
473
+ meta: {
474
+ note: focusedShape.note ?? defaultNoteShape.meta?.note ?? '',
475
+ },
476
+ },
477
+ }
478
+ }
479
+
480
+ function convertDrawShapeToTldrawShape(
481
+ editor: Editor,
482
+ focusedShape: FocusedDrawShape,
483
+ { defaultShape }: { defaultShape: Partial<TLShape> }
484
+ ): { shape: TLShape } {
485
+ const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
486
+ const defaultDrawShape = defaultShape as TLDrawShape
487
+
488
+ let fill
489
+ if (focusedShape.fill !== undefined) {
490
+ fill = convertFocusedFillToTldrawFill(focusedShape.fill)
491
+ } else if (defaultDrawShape.props?.fill) {
492
+ fill = defaultDrawShape.props.fill
493
+ } else {
494
+ fill = convertFocusedFillToTldrawFill('none')
495
+ }
496
+
497
+ return {
498
+ shape: {
499
+ id: shapeId,
500
+ type: 'draw',
501
+ typeName: 'shape',
502
+ x: defaultDrawShape.x ?? 0,
503
+ y: defaultDrawShape.y ?? 0,
504
+ rotation: defaultDrawShape.rotation ?? 0,
505
+ index: defaultDrawShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
506
+ parentId: defaultDrawShape.parentId ?? editor.getCurrentPageId(),
507
+ isLocked: defaultDrawShape.isLocked ?? false,
508
+ opacity: defaultDrawShape.opacity ?? 1,
509
+ props: {
510
+ ...editor.getShapeUtil('draw').getDefaultProps(),
511
+ color: asColor(focusedShape.color ?? defaultDrawShape.props?.color ?? 'black'),
512
+ fill,
513
+ },
514
+ meta: {
515
+ note: focusedShape.note ?? defaultDrawShape.meta?.note ?? '',
516
+ },
517
+ },
518
+ }
519
+ }
520
+
521
+ function convertUnknownShapeToTldrawShape(
522
+ editor: Editor,
523
+ focusedShape: FocusedUnknownShape,
524
+ { defaultShape }: { defaultShape: Partial<TLShape> }
525
+ ): { shape: TLShape } {
526
+ const shapeId = convertSimpleIdToTldrawId(focusedShape.shapeId)
527
+
528
+ return {
529
+ shape: {
530
+ id: shapeId,
531
+ type: defaultShape.type ?? 'geo',
532
+ typeName: 'shape',
533
+ x: focusedShape.x ?? defaultShape.x ?? 0,
534
+ y: focusedShape.y ?? defaultShape.y ?? 0,
535
+ rotation: defaultShape.rotation ?? 0,
536
+ index: defaultShape.index ?? editor.getHighestIndexForParent(editor.getCurrentPageId()),
537
+ parentId: defaultShape.parentId ?? editor.getCurrentPageId(),
538
+ isLocked: defaultShape.isLocked ?? false,
539
+ opacity: defaultShape.opacity ?? 1,
540
+ props: defaultShape.props ?? ({} as any),
541
+ meta: {
542
+ note: focusedShape.note ?? defaultShape.meta?.note ?? '',
543
+ },
544
+ },
545
+ }
546
+ }
547
+
548
+ export function getDummyBounds(editor: Editor, shape: TLShape): Box {
549
+ const bounds = editor.getShapePageBounds(shape)
550
+ if (bounds) return bounds
551
+
552
+ let dummyBounds: Box | undefined
553
+ const diff = editor.store.extractingChanges(() => {
554
+ editor.run(
555
+ () => {
556
+ const dummyId = createShapeId()
557
+ editor.createShape({ ...shape, id: dummyId })
558
+ dummyBounds = editor.getShapePageBounds(dummyId)
559
+ },
560
+ { ignoreShapeLock: false, history: 'ignore' }
561
+ )
562
+ })
563
+ const reverseDiff = reverseRecordsDiff(diff)
564
+ editor.store.applyDiff(reverseDiff)
565
+
566
+ if (!dummyBounds) {
567
+ throw new Error('Failed to get bounds for shape')
568
+ }
569
+ return dummyBounds
570
+ }
@@ -0,0 +1,106 @@
1
+ import { useEffect } from 'react'
2
+ import { type TLUiOverrides, useEditor, useToasts } from 'tldraw'
3
+
4
+ function extractImageFiles(data: DataTransfer | null): File[] {
5
+ if (!data) return []
6
+ const result: File[] = []
7
+ for (const item of data.items) {
8
+ if (item.kind === 'file' && item.type.startsWith('image/')) {
9
+ const file = item.getAsFile()
10
+ if (file) result.push(file)
11
+ }
12
+ }
13
+ if (result.length > 0) return result
14
+ return [...data.files].filter((f) => f.type.startsWith('image/'))
15
+ }
16
+
17
+ const COMING_SOON_TOAST = {
18
+ id: 'feature-coming-soon',
19
+ title: 'Coming soon',
20
+ description: 'This feature is coming soon!',
21
+ severity: 'info' as const,
22
+ }
23
+
24
+ /** Intercepts image drop/paste and shows a "coming soon" toast. */
25
+ export function ImageDropGuard() {
26
+ const editor = useEditor()
27
+ const { addToast } = useToasts()
28
+
29
+ useEffect(() => {
30
+ const container = editor.getContainer()
31
+
32
+ const showBlockedToast = (type: string) => {
33
+ addToast({
34
+ id: `blocked-${type}`,
35
+ title: 'Coming soon!',
36
+ description: `${type} are not yet supported in the tldraw MCP app.`,
37
+ severity: 'info',
38
+ })
39
+ }
40
+
41
+ const onDrop = (e: DragEvent) => {
42
+ const imageFiles = extractImageFiles(e.dataTransfer)
43
+ if (imageFiles.length > 0) {
44
+ e.preventDefault()
45
+ e.stopPropagation()
46
+ showBlockedToast('Images')
47
+ }
48
+ }
49
+
50
+ const onPaste = (e: ClipboardEvent) => {
51
+ const imageFiles = extractImageFiles(e.clipboardData)
52
+ if (imageFiles.length > 0) {
53
+ e.preventDefault()
54
+ e.stopPropagation()
55
+ showBlockedToast('Images')
56
+ }
57
+ }
58
+
59
+ container.addEventListener('drop', onDrop, { capture: true })
60
+ document.addEventListener('paste', onPaste, { capture: true })
61
+
62
+ // Override external content handlers to block images, embeds, and URLs.
63
+ // The context menu paste uses navigator.clipboard.read() directly,
64
+ // bypassing DOM paste events, so we need to intercept at this level too.
65
+ editor.registerExternalContentHandler('files', async ({ files }) => {
66
+ const hasImages = files.some((f) => f.type.startsWith('image/'))
67
+ if (hasImages) {
68
+ showBlockedToast('Images')
69
+ }
70
+ })
71
+ editor.registerExternalContentHandler('embed', async () => {
72
+ showBlockedToast('Embeds')
73
+ })
74
+ editor.registerExternalContentHandler('url', async () => {
75
+ showBlockedToast('Links')
76
+ })
77
+
78
+ return () => {
79
+ container.removeEventListener('drop', onDrop, { capture: true })
80
+ document.removeEventListener('paste', onPaste, { capture: true })
81
+ }
82
+ }, [editor, addToast])
83
+
84
+ return null
85
+ }
86
+
87
+ /** Override actions/tools to block media, embeds, and flatten. */
88
+ export const uiOverrides: TLUiOverrides = {
89
+ actions(_editor, actions, helpers) {
90
+ const { 'insert-media': _media, 'insert-embed': _embed, ...rest } = actions
91
+ return {
92
+ ...rest,
93
+ 'flatten-to-image': {
94
+ ...actions['flatten-to-image'],
95
+ onSelect() {
96
+ helpers.addToast(COMING_SOON_TOAST)
97
+ },
98
+ },
99
+ }
100
+ },
101
+ tools(_editor, tools) {
102
+ // Remove the asset tool (image/media picker from toolbar)
103
+ const { asset: _asset, ...rest } = tools
104
+ return rest
105
+ },
106
+ }
@@ -0,0 +1,33 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>tldraw MCP</title>
7
+ <style>
8
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@500;700&display=swap');
9
+
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ }
15
+ html,
16
+ body {
17
+ width: 100%;
18
+ height: 100%;
19
+ overflow: hidden;
20
+ font-family: 'Inter', sans-serif;
21
+ }
22
+ #root {
23
+ width: 100%;
24
+ height: 100%;
25
+ position: relative;
26
+ }
27
+ </style>
28
+ </head>
29
+ <body>
30
+ <div id="root"></div>
31
+ <script type="module" src="./mcp-app.tsx"></script>
32
+ </body>
33
+ </html>