Skip to content

Commit 584a482

Browse files
feat: implement full roadmap — grouping, sticky notes, PNG/JPEG export (27 tools)
Grouping: - group_elements: groups 2+ elements into a tldraw native group, computes bounding box, sets parentId on children, broadcasts WS events - ungroup_elements: dissolves group, clears parentId on children - REST: POST /api/elements/groups, DELETE /api/elements/groups/:groupId - Frontend: toTldrawType maps 'group' → 'group', applyElement calls editor.groupShapes() for new groups Advanced Export (server-side, no browser required): - export_png: Playwright headless PNG via exportPng() - export_jpg: Playwright headless JPEG via exportJpg() - Refactored exportPng/exportJpg to share exportRasterImage() helper - Error message in get_canvas_screenshot updated to reference Playwright Sticky Notes (ergonomic wrappers over create/update_element): - create_sticky: defaults yellow, solid fill, 200×200, draw font - update_sticky: partial update for note elements - list_sticky_templates: 9 pre-built presets (action item, risk, decision, etc.) Types: - CanvasElement gains childIds?, parentId? fields - ApiResponse gains childIds?, groupId? fields - ElementType and ELEMENT_TYPES include 'group' Docs: - README: 27 tools, grouped by category (Canvas, Grouping, Sticky, Layout, Viewport, Export) - AGENTS.md: updated tool table with 7 new entries - Architecture diagram: regenerated (cleaner spacing, icon-rich cards) Co-Authored-By: AdaL <adal@sylph.ai>
1 parent 80db98a commit 584a482

9 files changed

Lines changed: 514 additions & 25 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,14 @@ tldraw-mcp-server/
6565
| `clear_canvas` | Wipe all elements (`confirm: true` required) |
6666
| `read_diagram_guide` | Return color names, fill/dash/size presets, layout rules |
6767
| `auto_layout` | Automatic layout using dagre (hierarchical), force-directed, or grid |
68+
| `group_elements` | Group 2+ elements so they move as a unit |
69+
| `ungroup_elements` | Dissolve a group, release children as independent shapes |
70+
| `create_sticky` | Create a sticky note with sensible defaults |
71+
| `update_sticky` | Update content/styling of an existing sticky note |
72+
| `list_sticky_templates` | Return pre-built sticky note presets |
6873
| `export_svg` | Export canvas as SVG string |
74+
| `export_png` | Export canvas as PNG (server-side Playwright) |
75+
| `export_jpg` | Export canvas as JPEG (server-side Playwright) |
6976
| `export_pdf` | Export canvas as PDF (Playwright for full fidelity, pdf-lib fallback) |
7077

7178
## REST API (Canvas Server)

README.md

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,9 @@ No migration is required for existing AdaL users — stdio remains the default.
261261

262262
---
263263

264-
## MCP Tools (20 tools)
264+
## MCP Tools (27 tools)
265+
266+
### Canvas Operations
265267

266268
| Tool | Description |
267269
|------|-------------|
@@ -272,27 +274,51 @@ No migration is required for existing AdaL users — stdio remains the default.
272274
| `query_elements` | List/filter elements by type and bounding box |
273275
| `batch_create_elements` | Create multiple elements atomically |
274276
| `clear_canvas` | Remove all elements (requires `confirm: true`) |
275-
| `read_diagram_guide` | Return tldraw color names, presets, and layout best practices |
276-
| `describe_scene` | Summarize the current canvas elements, positions, labels, and connections |
277-
| `get_canvas_screenshot` | Capture a PNG screenshot from the live browser canvas |
277+
278+
### Grouping
279+
280+
| Tool | Description |
281+
|------|-------------|
282+
| `group_elements` | Group 2+ elements — they move and transform as a unit |
283+
| `ungroup_elements` | Dissolve a group, releasing all children as independent shapes |
284+
285+
### Sticky Notes
286+
287+
| Tool | Description |
288+
|------|-------------|
289+
| `create_sticky` | Create a sticky note with sensible defaults (yellow, solid fill) |
290+
| `update_sticky` | Update the content or styling of an existing sticky note |
291+
| `list_sticky_templates` | Return pre-built sticky templates with recommended colors and use cases |
292+
293+
### Layout & Alignment
294+
295+
| Tool | Description |
296+
|------|-------------|
297+
| `align_elements` | Align multiple elements left/center/right/top/middle/bottom |
298+
| `distribute_elements` | Distribute elements evenly along horizontal or vertical axis |
299+
| `auto_layout` | Automatically arrange using dagre, force-directed, or grid layout |
300+
301+
### Viewport & Scene
302+
303+
| Tool | Description |
304+
|------|-------------|
305+
| `set_viewport` | Zoom, pan, zoom-to-fit, or center on a specific element |
306+
| `read_diagram_guide` | Return color names, presets, and layout best practices |
307+
| `describe_scene` | Summarize all canvas elements, positions, labels, and connections |
278308
| `export_scene` | Export all elements as a JSON snapshot |
279309
| `import_scene` | Import a JSON scene in replace or merge mode |
280310
| `snapshot_scene` | Save the current canvas as a named in-memory snapshot |
281311
| `restore_snapshot` | Restore a previously saved named snapshot |
282-
| `set_viewport` | Zoom, pan, zoom-to-fit, or center on a specific element |
283-
| `align_elements` | Align multiple elements using an atomic batch update |
284-
| `distribute_elements` | Distribute multiple elements evenly using an atomic batch update |
285-
| `auto_layout` | Automatically arrange elements using dagre, force-directed, or grid layout |
286-
| `export_svg` | Export the current canvas as an SVG string |
287-
| `export_pdf` | Export the current canvas as a PDF file |
288-
289-
### Roadmap
290-
291-
| Category | Tools | Status |
292-
|----------|-------|--------|
293-
| **Grouping** | `group_elements`, `ungroup_elements` | Planned |
294-
| **Advanced Export** | `export_png` (via Playwright), `export_jpg` | Planned |
295-
| **Sticky Notes** | `create_sticky`, `update_sticky`, `list_sticky_templates` | Planned |
312+
313+
### Export
314+
315+
| Tool | Description |
316+
|------|-------------|
317+
| `get_canvas_screenshot` | Capture PNG from the canvas (server-side via Playwright, no browser needed) |
318+
| `export_svg` | Export canvas as an SVG string |
319+
| `export_png` | Export canvas as a PNG image (server-side Playwright) |
320+
| `export_jpg` | Export canvas as a JPEG image (server-side Playwright) |
321+
| `export_pdf` | Export canvas as a PDF (Playwright for full fidelity, pdf-lib fallback) |
296322

297323
---
298324

assets/architecture-diagram.png

-25.5 KB
Loading

frontend/src/App.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,16 +189,37 @@ function toTldrawType(type: string): TLShapeType {
189189
if (type === 'arrow') return 'arrow'
190190
if (type === 'line') return 'line'
191191
if (type === 'frame') return 'frame'
192+
if (type === 'group') return 'group'
192193
// rectangle, ellipse, diamond, triangle, star, cloud, hexagon → all use type "geo"
193194
return 'geo'
194195
}
195196

197+
/** Build tldraw group shape props */
198+
function buildGroupProps(el: CanvasElement): Record<string, unknown> {
199+
return {
200+
w: el.width ?? 160,
201+
h: el.height ?? 80,
202+
}
203+
}
204+
196205
/** Apply a single element to the editor (create or update) */
197206
function applyElement(editor: Editor, el: CanvasElement): void {
198207
const shapeId = createShapeId(el.id)
199208
const existing = editor.getShape(shapeId)
200209
const shapeType = toTldrawType(el.type)
201-
const props = buildShapeProps(el)
210+
211+
// Groups use tldraw's native grouping — create shapes first then group them
212+
if (el.type === 'group' && el.childIds && el.childIds.length >= 2) {
213+
const childShapeIds = el.childIds
214+
.map((id) => createShapeId(id))
215+
.filter((id) => editor.getShape(id) !== undefined)
216+
if (childShapeIds.length >= 2 && !existing) {
217+
editor.groupShapes(childShapeIds, { groupId: shapeId })
218+
}
219+
return
220+
}
221+
222+
const props = el.type === 'group' ? buildGroupProps(el) : buildShapeProps(el)
202223

203224
if (existing) {
204225
if (existing.type !== shapeType) {

src/canvas-server.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,116 @@ app.delete('/api/elements/:id', (req: Request, res: Response) => {
415415
res.json({ success: true, message: `Element ${id} deleted` } satisfies ApiResponse)
416416
})
417417

418+
// ─── REST: Grouping ──────────────────────────────────────────────────────────
419+
420+
/** POST /api/elements/groups — group elements together */
421+
app.post('/api/elements/groups', (req: Request, res: Response) => {
422+
const { groupId, elementIds } = req.body as { groupId?: string; elementIds?: string[] }
423+
424+
if (!groupId) {
425+
res.status(400).json({ success: false, error: '`groupId` is required' } satisfies ApiResponse)
426+
return
427+
}
428+
if (!Array.isArray(elementIds) || elementIds.length < 2) {
429+
res.status(400).json({ success: false, error: '`elementIds` must be an array of at least 2 IDs' } satisfies ApiResponse)
430+
return
431+
}
432+
433+
const missing = elementIds.find((id) => !elements.has(id))
434+
if (missing) {
435+
res.status(404).json({ success: false, error: `Element ${missing} not found` } satisfies ApiResponse)
436+
return
437+
}
438+
439+
if (elements.has(groupId)) {
440+
res.status(409).json({ success: false, error: `Element ${groupId} already exists` } satisfies ApiResponse)
441+
return
442+
}
443+
444+
const now = new Date().toISOString()
445+
446+
// Compute bounding box of members
447+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
448+
for (const id of elementIds) {
449+
const el = elements.get(id)!
450+
minX = Math.min(minX, el.x)
451+
minY = Math.min(minY, el.y)
452+
maxX = Math.max(maxX, el.x + (el.width ?? 160))
453+
maxY = Math.max(maxY, el.y + (el.height ?? 80))
454+
}
455+
456+
// Create a group element to represent the container
457+
const groupElement: CanvasElement = {
458+
id: groupId,
459+
type: 'group',
460+
x: minX,
461+
y: minY,
462+
width: maxX - minX,
463+
height: maxY - minY,
464+
childIds: elementIds,
465+
color: 'black',
466+
fill: 'none',
467+
dash: 'draw',
468+
size: 'm',
469+
font: 'draw',
470+
opacity: 100,
471+
createdAt: now,
472+
updatedAt: now,
473+
version: 1,
474+
} as CanvasElement
475+
476+
elements.set(groupId, groupElement)
477+
478+
// Update each child with parentId
479+
const updatedChildren: CanvasElement[] = elementIds.map((id) => {
480+
const el = elements.get(id)!
481+
const updated: CanvasElement = { ...el, parentId: groupId, updatedAt: now, version: el.version + 1 }
482+
elements.set(id, updated)
483+
return updated
484+
})
485+
486+
broadcast({ type: 'element_created', element: groupElement })
487+
broadcast({ type: 'elements_batch_updated', elements: updatedChildren })
488+
res.status(201).json({ success: true, element: groupElement, childIds: elementIds } satisfies ApiResponse)
489+
})
490+
491+
/** DELETE /api/elements/groups/:groupId — dissolve a group */
492+
app.delete('/api/elements/groups/:groupId', (req: Request, res: Response) => {
493+
const groupId = req.params.groupId as string
494+
const groupEl = elements.get(groupId)
495+
496+
if (!groupEl) {
497+
res.status(404).json({ success: false, error: `Group ${groupId} not found` } satisfies ApiResponse)
498+
return
499+
}
500+
if (groupEl.type !== 'group') {
501+
res.status(400).json({ success: false, error: `Element ${groupId} is not a group` } satisfies ApiResponse)
502+
return
503+
}
504+
505+
const childIds = groupEl.childIds ?? []
506+
const now = new Date().toISOString()
507+
508+
// Clear parentId from all children
509+
const releasedChildren: CanvasElement[] = childIds.map((id) => {
510+
const el = elements.get(id)
511+
if (!el) return null
512+
const updated: CanvasElement = { ...el, parentId: undefined, updatedAt: now, version: el.version + 1 }
513+
elements.set(id, updated)
514+
return updated
515+
}).filter(Boolean) as CanvasElement[]
516+
517+
// Remove the group element
518+
elements.delete(groupId)
519+
520+
broadcast({ type: 'element_deleted', id: groupId })
521+
if (releasedChildren.length > 0) {
522+
broadcast({ type: 'elements_batch_updated', elements: releasedChildren })
523+
}
524+
525+
res.json({ success: true, message: `Group ${groupId} dissolved`, childIds } satisfies ApiResponse)
526+
})
527+
418528
// ─── REST: Viewport ───────────────────────────────────────────────────────────
419529

420530
app.post('/api/viewport', (req: Request, res: Response) => {

src/export/svg.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,22 @@ export function generateSvg(elements: CanvasElement[], background = true): strin
237237
* Works without a browser open — uses the SVG generator + headless Chromium.
238238
*/
239239
export async function exportPng(elements: CanvasElement[], background = true): Promise<{ data: string; format: string }> {
240+
return exportRasterImage(elements, 'png', background)
241+
}
242+
243+
/**
244+
* Server-side JPEG export using Playwright.
245+
* Works without a browser open — uses the SVG generator + headless Chromium.
246+
*/
247+
export async function exportJpg(elements: CanvasElement[], background = true): Promise<{ data: string; format: string }> {
248+
return exportRasterImage(elements, 'jpeg', background)
249+
}
250+
251+
async function exportRasterImage(
252+
elements: CanvasElement[],
253+
type: 'png' | 'jpeg',
254+
background: boolean
255+
): Promise<{ data: string; format: string }> {
240256
const svg = generateSvg(elements, background)
241257

242258
let playwright: typeof import('playwright') | null = null
@@ -267,8 +283,8 @@ export async function exportPng(elements: CanvasElement[], background = true): P
267283
await page.setContent(html, { waitUntil: 'networkidle' })
268284
await page.evaluate(() => document.fonts.ready)
269285

270-
const data = await page.screenshot({ type: 'png' })
271-
return { data: Buffer.from(data).toString('base64'), format: 'png' }
286+
const data = await page.screenshot({ type, omitBackground: !background })
287+
return { data: Buffer.from(data).toString('base64'), format: type === 'jpeg' ? 'jpg' : 'png' }
272288
} finally {
273289
await browser.close()
274290
}

0 commit comments

Comments
 (0)