Skip to content

Commit 6cbea53

Browse files
committed
Add arrange_grid tool for normalizing and arranging shapes in a grid
1 parent e5a4738 commit 6cbea53

3 files changed

Lines changed: 267 additions & 0 deletions

File tree

‎client/webmcp/gridTools.ts‎

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { createShapeId, Editor, TLShape, TLShapeId } from 'tldraw'
2+
3+
const SIZE_TOLERANCE = 0.75
4+
const RATIO_TOLERANCE = 0.01
5+
const MAX_NORMALIZATION_PASSES = 3
6+
7+
export function arrangeGrid(
8+
editor: Editor,
9+
input: {
10+
shapeIds: string[]
11+
columns?: number
12+
gap?: number
13+
targetWidth?: number
14+
fitCamera?: boolean
15+
}
16+
) {
17+
if (editor.getIsReadonly()) throw new Error('The canvas is read-only.')
18+
19+
const currentPageId = editor.getCurrentPageId()
20+
const shapes = input.shapeIds.map((simpleId) => {
21+
const shapeId = createShapeId(simpleId)
22+
const shape = editor.getShape(shapeId)
23+
if (!shape) throw new Error(`Shape ${simpleId} does not exist.`)
24+
if (shape.parentId !== currentPageId) {
25+
throw new Error(`Shape ${simpleId} must be a top-level shape on the current page.`)
26+
}
27+
28+
const util = editor.getShapeUtil(shape)
29+
if (!util.canResize(shape)) throw new Error(`Shape ${simpleId} cannot be resized.`)
30+
const geometryBounds = editor.getShapeGeometry(shape).bounds
31+
return {
32+
simpleId,
33+
shapeId,
34+
shape,
35+
locked: util.isAspectRatioLocked(shape),
36+
ratio: geometryBounds.w / geometryBounds.h,
37+
}
38+
})
39+
40+
const initialBounds = shapes.map(({ shapeId, simpleId }) => ({
41+
simpleId,
42+
shapeId,
43+
bounds: requireBounds(editor, shapeId, simpleId),
44+
}))
45+
const lockedRatios = shapes.filter(({ locked }) => locked).map(({ ratio }) => ratio)
46+
const lockedRatio = compatibleLockedRatio(lockedRatios)
47+
const ratios = shapes.map(({ ratio }) => ratio).sort((a, b) => a - b)
48+
const representativeRatio = lockedRatio ?? ratios[Math.floor(ratios.length / 2)]
49+
let targetWidth = input.targetWidth ?? 600
50+
let targetHeight = targetWidth / representativeRatio
51+
let passes = 0
52+
53+
const readingOrder = initialBounds
54+
.slice()
55+
.sort((a, b) => a.bounds.y - b.bounds.y || a.bounds.x - b.bounds.x)
56+
const anchorX = Math.min(...initialBounds.map(({ bounds }) => bounds.x))
57+
const anchorY = Math.min(...initialBounds.map(({ bounds }) => bounds.y))
58+
const columns = Math.min(input.columns ?? Math.ceil(Math.sqrt(shapes.length)), shapes.length)
59+
const rows = Math.ceil(shapes.length / columns)
60+
const gap = input.gap ?? 50
61+
62+
editor.markHistoryStoppingPoint('arranging equal-size grid')
63+
editor.run(() => {
64+
for (const { shape } of shapes) {
65+
if (shape.rotation !== 0) {
66+
editor.updateShape({ id: shape.id, type: shape.type, rotation: 0 })
67+
}
68+
}
69+
70+
for (passes = 1; passes <= MAX_NORMALIZATION_PASSES; passes += 1) {
71+
resizeAll(editor, shapes, targetWidth, targetHeight)
72+
const actualBounds = shapes.map(({ shapeId, simpleId }) =>
73+
requireBounds(editor, shapeId, simpleId)
74+
)
75+
if (allBoundsMatch(actualBounds, targetWidth, targetHeight)) break
76+
77+
if (lockedRatio) {
78+
targetWidth = Math.max(
79+
targetWidth,
80+
...actualBounds.map((bounds) => bounds.w),
81+
...actualBounds.map((bounds) => bounds.h * lockedRatio)
82+
)
83+
targetHeight = targetWidth / lockedRatio
84+
} else {
85+
targetWidth = Math.max(targetWidth, ...actualBounds.map((bounds) => bounds.w))
86+
targetHeight = Math.max(targetHeight, ...actualBounds.map((bounds) => bounds.h))
87+
}
88+
}
89+
90+
const normalizedBounds = shapes.map(({ shapeId, simpleId }) =>
91+
requireBounds(editor, shapeId, simpleId)
92+
)
93+
if (!allBoundsMatch(normalizedBounds, targetWidth, targetHeight)) {
94+
throw new Error('The selected shapes could not converge on one common size.')
95+
}
96+
97+
for (const [index, item] of readingOrder.entries()) {
98+
const row = Math.floor(index / columns)
99+
const column = index % columns
100+
const bounds = requireBounds(editor, item.shapeId, item.simpleId)
101+
moveTopLevelShape(
102+
editor,
103+
editor.getShape(item.shapeId)!,
104+
anchorX + column * (targetWidth + gap) - bounds.x,
105+
anchorY + row * (targetHeight + gap) - bounds.y
106+
)
107+
}
108+
})
109+
110+
if (input.fitCamera !== false) editor.zoomToFit()
111+
112+
return {
113+
shapeIds: readingOrder.map(({ simpleId }) => simpleId),
114+
columns,
115+
rows,
116+
gap,
117+
width: targetWidth,
118+
height: targetHeight,
119+
normalizationPasses: passes,
120+
fitCamera: input.fitCamera !== false,
121+
rotationsNormalized: true,
122+
positions: readingOrder.map(({ simpleId, shapeId }) => {
123+
const bounds = requireBounds(editor, shapeId, simpleId)
124+
return { shapeId: simpleId, x: bounds.x, y: bounds.y, w: bounds.w, h: bounds.h }
125+
}),
126+
}
127+
}
128+
129+
function resizeAll(
130+
editor: Editor,
131+
shapes: Array<{ shapeId: TLShapeId; simpleId: string }>,
132+
targetWidth: number,
133+
targetHeight: number
134+
) {
135+
for (const { shapeId, simpleId } of shapes) {
136+
const bounds = requireBounds(editor, shapeId, simpleId)
137+
editor.resizeShape(
138+
shapeId,
139+
{ x: targetWidth / bounds.w, y: targetHeight / bounds.h },
140+
{ scaleOrigin: { x: bounds.x, y: bounds.y } }
141+
)
142+
}
143+
}
144+
145+
function moveTopLevelShape(editor: Editor, shape: TLShape, dx: number, dy: number) {
146+
editor.updateShape({ id: shape.id, type: shape.type, x: shape.x + dx, y: shape.y + dy })
147+
}
148+
149+
function requireBounds(editor: Editor, shapeId: TLShapeId, simpleId: string) {
150+
const bounds = editor.getShapePageBounds(shapeId)
151+
if (!bounds || bounds.w <= 0 || bounds.h <= 0) {
152+
throw new Error(`Shape ${simpleId} does not have measurable bounds.`)
153+
}
154+
return bounds
155+
}
156+
157+
function compatibleLockedRatio(ratios: number[]) {
158+
if (ratios.length === 0) return null
159+
const reference = ratios[0]
160+
if (ratios.some((ratio) => Math.abs(ratio / reference - 1) > RATIO_TOLERANCE)) {
161+
throw new Error('Selected aspect-ratio-locked shapes have incompatible proportions.')
162+
}
163+
return ratios.reduce((total, ratio) => total + ratio, 0) / ratios.length
164+
}
165+
166+
function allBoundsMatch(
167+
bounds: Array<{ w: number; h: number }>,
168+
targetWidth: number,
169+
targetHeight: number
170+
) {
171+
return bounds.every(
172+
(item) =>
173+
Math.abs(item.w - targetWidth) <= SIZE_TOLERANCE &&
174+
Math.abs(item.h - targetHeight) <= SIZE_TOLERANCE
175+
)
176+
}

‎client/webmcp/registerWebMcpTools.ts‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { searchCommonsImages } from '../commons/commonsImages'
1010
import { insertCommonsImage } from '../commons/insertCommonsImage'
1111
import { previewMermaidDiagram } from '../mermaid/mermaidDiagram'
1212
import { inspectEmbeds } from './embedTools'
13+
import { arrangeGrid } from './gridTools'
1314
import { createCanvasPage, inspectCanvasPages } from './pageTools'
1415

1516
type JsonSchema = {
@@ -65,6 +66,7 @@ const MERMAID_TOOL_NAMES = ['create_mermaid_diagram'] as const
6566
const PAGE_TOOL_NAMES = ['create_page', 'inspect_pages'] as const
6667
const EMBED_TOOL_NAMES = ['inspect_embeds'] as const
6768
const CAMERA_TOOL_NAMES = ['zoom_out'] as const
69+
const GRID_TOOL_NAMES = ['arrange_grid'] as const
6870
const DISCOVERY_TOOL_NAMES = ['list_tools', 'describe_tools'] as const
6971
const ALL_TOOL_NAMES = [
7072
...ACTION_TOOL_NAMES,
@@ -73,6 +75,7 @@ const ALL_TOOL_NAMES = [
7375
...PAGE_TOOL_NAMES,
7476
...EMBED_TOOL_NAMES,
7577
...CAMERA_TOOL_NAMES,
78+
...GRID_TOOL_NAMES,
7679
...DISCOVERY_TOOL_NAMES,
7780
] as const
7881

@@ -82,6 +85,7 @@ type MermaidToolName = (typeof MERMAID_TOOL_NAMES)[number]
8285
type PageToolName = (typeof PAGE_TOOL_NAMES)[number]
8386
type EmbedToolName = (typeof EMBED_TOOL_NAMES)[number]
8487
type CameraToolName = (typeof CAMERA_TOOL_NAMES)[number]
88+
type GridToolName = (typeof GRID_TOOL_NAMES)[number]
8589
type ToolName = (typeof ALL_TOOL_NAMES)[number]
8690

8791
const ACTION_PURPOSES: Record<ActionToolName, string> = {
@@ -136,6 +140,11 @@ const CAMERA_PURPOSES: Record<CameraToolName, string> = {
136140
'Zoom the current page camera so more of the canvas is visible. With no arguments, fit all shapes in the viewport. With steps, zoom out that many navigation-panel increments.',
137141
}
138142

143+
const GRID_PURPOSES: Record<GridToolName, string> = {
144+
arrange_grid:
145+
'Normalize rotation and size for selected top-level shapes, arrange them in a row-major grid, and optionally fit the camera. Automatically adapts to aspect locks and provider minimum sizes.',
146+
}
147+
139148
const PARAMETER_DESCRIPTIONS: Record<ActionToolName, Record<string, string>> = {
140149
review: {
141150
intent: 'Why this region should be reviewed and what the follow-up should check.',
@@ -382,6 +391,44 @@ const ZOOM_OUT_INPUT_SCHEMA: JsonSchema = Object.freeze({
382391
}),
383392
})
384393

394+
const ARRANGE_GRID_INPUT_SCHEMA: JsonSchema = Object.freeze({
395+
type: 'object',
396+
additionalProperties: false,
397+
required: Object.freeze(['shapeIds']),
398+
properties: Object.freeze({
399+
shapeIds: Object.freeze({
400+
type: 'array',
401+
minItems: 2,
402+
maxItems: 100,
403+
uniqueItems: true,
404+
description: 'Unique simple IDs of top-level resizable shapes on the current page.',
405+
items: Object.freeze({ type: 'string', minLength: 1, maxLength: 128 }),
406+
}),
407+
columns: Object.freeze({
408+
type: 'integer',
409+
minimum: 1,
410+
maximum: 20,
411+
description: 'Optional column count. Default ceil(sqrt(shape count)).',
412+
}),
413+
gap: Object.freeze({
414+
type: 'number',
415+
minimum: 0,
416+
maximum: 500,
417+
description: 'Optional horizontal and vertical gap. Default 50.',
418+
}),
419+
targetWidth: Object.freeze({
420+
type: 'number',
421+
minimum: 64,
422+
maximum: 2000,
423+
description: 'Optional starting common width. Default 600; may grow for provider minimums.',
424+
}),
425+
fitCamera: Object.freeze({
426+
type: 'boolean',
427+
description: 'Whether to fit the arranged grid in the viewport. Default true.',
428+
}),
429+
}),
430+
}) as JsonSchema
431+
385432
const LIST_TOOLS_INPUT_SCHEMA: JsonSchema = Object.freeze({
386433
type: 'object',
387434
additionalProperties: false,
@@ -459,6 +506,20 @@ const ZoomOutInput = z
459506
})
460507
.strict()
461508

509+
const ArrangeGridInput = z
510+
.object({
511+
shapeIds: z
512+
.array(z.string().trim().min(1).max(128))
513+
.min(2)
514+
.max(100)
515+
.refine((shapeIds) => new Set(shapeIds).size === shapeIds.length, 'Shape IDs must be unique.'),
516+
columns: z.number().int().min(1).max(20).optional(),
517+
gap: z.number().finite().min(0).max(500).optional(),
518+
targetWidth: z.number().finite().min(64).max(2000).optional(),
519+
fitCamera: z.boolean().optional(),
520+
})
521+
.strict()
522+
462523
type CatalogEntry = {
463524
name: ToolName
464525
purpose: string
@@ -700,6 +761,26 @@ function createCatalog(agent: TldrawAgent) {
700761
untrustedContent: false,
701762
})
702763

764+
catalog.set('arrange_grid', {
765+
name: 'arrange_grid',
766+
purpose: GRID_PURPOSES.arrange_grid,
767+
inputSchema: ARRANGE_GRID_INPUT_SCHEMA,
768+
execute: (input, context) => {
769+
if (context?.signal?.aborted) {
770+
return { ok: false, tool: 'arrange_grid', error: 'Tool call was aborted.' }
771+
}
772+
const parsed = ArrangeGridInput.safeParse(input)
773+
if (!parsed.success) return invalidArgumentsResult('arrange_grid', parsed.error)
774+
try {
775+
return { ok: true, tool: 'arrange_grid', ...arrangeGrid(agent.editor, parsed.data) }
776+
} catch (error) {
777+
return toolErrorResult('arrange_grid', error)
778+
}
779+
},
780+
readOnly: false,
781+
untrustedContent: false,
782+
})
783+
703784
catalog.set('list_tools', {
704785
name: 'list_tools',
705786
purpose:

‎tools.txt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,16 @@ params:
205205

206206
- steps (integer, optional): Optional number of navigation-panel zoom-out increments from 1 to 50. Omit to fit all shapes in the viewport.
207207

208+
name: arrange\_grid
209+
purpose: Normalize rotation and size for selected top-level shapes, arrange them in a row-major grid, and optionally fit the camera. Automatically adapts to aspect locks and provider minimum sizes.
210+
params:
211+
212+
- shapeIds (string[], required): Unique simple IDs of top-level resizable shapes on the current page.
213+
- columns (integer, optional): Optional column count from 1 to 20. Default ceil(sqrt(shape count)).
214+
- gap (number, optional): Optional horizontal and vertical gap from 0 to 500. Default 50.
215+
- targetWidth (number, optional): Optional starting common width from 64 to 2000. Default 600; may grow for provider minimums.
216+
- fitCamera (boolean, optional): Whether to fit the arranged grid in the viewport. Default true.
217+
208218
name: list\_tools
209219
purpose: List every registered draw-app tool with a one-line summary and the required new-drawing page workflow; call this first to choose tools.
210220
params: none

0 commit comments

Comments
 (0)