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.
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/bridge/app-bridge-entry.js +6 -0
- package/mcp-app/LICENSE.md +9 -0
- package/mcp-app/PI_TLDRAW_PROVENANCE.json +32 -0
- package/mcp-app/README.md +129 -0
- package/mcp-app/dev-tunnel.sh +51 -0
- package/mcp-app/dist/editor-api.json +8493 -0
- package/mcp-app/dist/mcp-app.html +643 -0
- package/mcp-app/dist/method-map.json +915 -0
- package/mcp-app/package.json +42 -0
- package/mcp-app/plugins/tldraw-mcp/.cursor-plugin/plugin.json +10 -0
- package/mcp-app/plugins/tldraw-mcp/assets/logo.svg +3 -0
- package/mcp-app/plugins/tldraw-mcp/mcp.json +8 -0
- package/mcp-app/scripts/extract-editor-api.ts +1374 -0
- package/mcp-app/server.json +21 -0
- package/mcp-app/src/logger.ts +45 -0
- package/mcp-app/src/register-tools.ts +368 -0
- package/mcp-app/src/shared/generated-data.ts +160 -0
- package/mcp-app/src/shared/pending-requests.ts +69 -0
- package/mcp-app/src/shared/types.ts +76 -0
- package/mcp-app/src/shared/utils.ts +132 -0
- package/mcp-app/src/tools/exec.ts +120 -0
- package/mcp-app/src/tools/loadCachedCanvasWidgetHtml.ts +16 -0
- package/mcp-app/src/tools/search.ts +150 -0
- package/mcp-app/src/widget/app-context.tsx +29 -0
- package/mcp-app/src/widget/dev-log.tsx +70 -0
- package/mcp-app/src/widget/exec-helpers.ts +232 -0
- package/mcp-app/src/widget/export-tldr.ts +35 -0
- package/mcp-app/src/widget/focused/defaults.ts +141 -0
- package/mcp-app/src/widget/focused/focused-editor-proxy.ts +434 -0
- package/mcp-app/src/widget/focused/format.ts +366 -0
- package/mcp-app/src/widget/focused/to-focused.ts +258 -0
- package/mcp-app/src/widget/focused/to-tldraw.ts +570 -0
- package/mcp-app/src/widget/image-guard.tsx +106 -0
- package/mcp-app/src/widget/index.html +33 -0
- package/mcp-app/src/widget/mcp-app.css +113 -0
- package/mcp-app/src/widget/mcp-app.tsx +857 -0
- package/mcp-app/src/widget/persistence.ts +337 -0
- package/mcp-app/src/widget/snapshot.ts +157 -0
- package/mcp-app/src/worker.ts +305 -0
- package/mcp-app/tsconfig.json +23 -0
- package/mcp-app/vite.config.ts +13 -0
- package/mcp-app/wrangler.toml +45 -0
- package/mcp-app-source.json +36 -0
- package/package.json +90 -0
- package/patches/tldraw-mcp-app/001-pi-runtime.patch +35 -0
- package/scripts/assemble-mcp-app.mjs +193 -0
- package/scripts/build-bridge.mjs +74 -0
- package/scripts/e2e-mcp.mjs +69 -0
- package/scripts/e2e-packaged-mcp-app.mjs +79 -0
- package/scripts/run-mcp-app-dev.mjs +44 -0
- package/scripts/verify-bundle.mjs +41 -0
- package/scripts/verify-mcp-app-source.mjs +51 -0
- package/scripts/verify-mcp-app.mjs +38 -0
- package/scripts/verify-package-files.mjs +50 -0
- package/src/canvas/export.ts +164 -0
- package/src/canvas/state.ts +117 -0
- package/src/canvas/workflow.ts +105 -0
- package/src/commands/tldraw-command.ts +48 -0
- package/src/diagram/guidance.ts +44 -0
- package/src/host/local-host.ts +289 -0
- package/src/index.ts +762 -0
- package/src/mcp/client.ts +126 -0
- package/src/mcp/response.ts +74 -0
- package/src/semantic/layer.ts +309 -0
- package/src/server/server-manager.ts +153 -0
- package/src/store/export-store.ts +33 -0
- package/src/store/project-store.ts +251 -0
- package/src/ui/tldraw-status.ts +88 -0
- package/static/app-bridge-bundle.js +18114 -0
- package/static/app-bridge-bundle.meta.json +164 -0
- package/static/host.html +390 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
export type CanvasExportFormat = 'png' | 'svg'
|
|
2
|
+
export type CanvasExportScope = 'selected' | 'all'
|
|
3
|
+
|
|
4
|
+
export interface CanvasExportCodeOptions {
|
|
5
|
+
format?: string
|
|
6
|
+
scope?: string
|
|
7
|
+
background?: boolean
|
|
8
|
+
padding?: number
|
|
9
|
+
scale?: number
|
|
10
|
+
pixelRatio?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CanvasExportPayload {
|
|
14
|
+
dataUrl: string
|
|
15
|
+
width: number
|
|
16
|
+
height: number
|
|
17
|
+
format: CanvasExportFormat
|
|
18
|
+
scope: CanvasExportScope
|
|
19
|
+
exportedShapeIds: string[]
|
|
20
|
+
selectedCount: number
|
|
21
|
+
fallbackToAll: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ParsedDataUrl {
|
|
25
|
+
mimeType: string
|
|
26
|
+
data: string
|
|
27
|
+
bytes: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeCanvasExportFormat(format?: string): CanvasExportFormat {
|
|
31
|
+
const normalized = format?.toLowerCase().trim()
|
|
32
|
+
return normalized === 'svg' ? 'svg' : 'png'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function normalizeCanvasExportScope(scope?: string): CanvasExportScope {
|
|
36
|
+
const normalized = scope?.toLowerCase().trim()
|
|
37
|
+
return normalized === 'all' ? 'all' : 'selected'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildCanvasExportCode(options: CanvasExportCodeOptions = {}) {
|
|
41
|
+
const format = normalizeCanvasExportFormat(options.format)
|
|
42
|
+
const scope = normalizeCanvasExportScope(options.scope)
|
|
43
|
+
const exportOptions: Record<string, unknown> = {
|
|
44
|
+
format,
|
|
45
|
+
background: options.background ?? true,
|
|
46
|
+
}
|
|
47
|
+
if (typeof options.pixelRatio === 'number' && Number.isFinite(options.pixelRatio)) {
|
|
48
|
+
exportOptions.pixelRatio = options.pixelRatio
|
|
49
|
+
} else if (format === 'png') {
|
|
50
|
+
exportOptions.pixelRatio = 1
|
|
51
|
+
}
|
|
52
|
+
if (typeof options.padding === 'number' && Number.isFinite(options.padding)) exportOptions.padding = options.padding
|
|
53
|
+
if (typeof options.scale === 'number' && Number.isFinite(options.scale)) exportOptions.scale = options.scale
|
|
54
|
+
|
|
55
|
+
return `
|
|
56
|
+
const __piExportScope = ${JSON.stringify(scope)}
|
|
57
|
+
const __piExportFormat = ${JSON.stringify(format)}
|
|
58
|
+
const __piExportOptions = ${JSON.stringify(exportOptions)}
|
|
59
|
+
const __piToTldrawId = (id) => {
|
|
60
|
+
const value = String(id)
|
|
61
|
+
return value.startsWith('shape:') ? value : 'shape:' + value
|
|
62
|
+
}
|
|
63
|
+
const __piAllShapeIds = editor.getCurrentPageShapes()
|
|
64
|
+
.map((shape) => shape.shapeId ?? shape.id)
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.map(String)
|
|
67
|
+
let __piSelectedShapeIds = []
|
|
68
|
+
try { __piSelectedShapeIds = (editor.getSelectedShapeIds() || []).map(String) } catch { __piSelectedShapeIds = [] }
|
|
69
|
+
const __piFallbackToAll = __piExportScope === 'selected' && __piSelectedShapeIds.length === 0
|
|
70
|
+
const __piFocusedIds = __piExportScope === 'selected' && __piSelectedShapeIds.length > 0 ? __piSelectedShapeIds : __piAllShapeIds
|
|
71
|
+
if (__piFocusedIds.length === 0) throw new Error('No shapes to export.')
|
|
72
|
+
const __piInternalIds = __piFocusedIds.map(__piToTldrawId)
|
|
73
|
+
|
|
74
|
+
// The mcp-app exec sandbox disables window.setTimeout/setInterval/fetch/XMLHttpRequest
|
|
75
|
+
// while user code runs. tldraw's export pipeline (exportToSvg + getSvgAsImage) needs
|
|
76
|
+
// setTimeout (to unmount the react root on the next tick) and may need fetch (to embed
|
|
77
|
+
// fonts/images). Borrow working globals from a hidden same-origin about:blank iframe and
|
|
78
|
+
// install them on window only for the duration of the export call.
|
|
79
|
+
const __piSandboxFrame = document.createElement('iframe')
|
|
80
|
+
__piSandboxFrame.setAttribute('aria-hidden', 'true')
|
|
81
|
+
__piSandboxFrame.style.cssText = 'width:0;height:0;border:0;position:absolute;left:-9999px;'
|
|
82
|
+
document.body.appendChild(__piSandboxFrame)
|
|
83
|
+
const __piBorrowedWindow = __piSandboxFrame.contentWindow
|
|
84
|
+
const __piPriorSetTimeout = window.setTimeout
|
|
85
|
+
const __piPriorSetInterval = window.setInterval
|
|
86
|
+
const __piPriorFetch = window.fetch
|
|
87
|
+
const __piPriorXHR = window.XMLHttpRequest
|
|
88
|
+
try {
|
|
89
|
+
window.setTimeout = __piBorrowedWindow.setTimeout.bind(__piBorrowedWindow)
|
|
90
|
+
window.setInterval = __piBorrowedWindow.setInterval.bind(__piBorrowedWindow)
|
|
91
|
+
window.fetch = __piBorrowedWindow.fetch.bind(__piBorrowedWindow)
|
|
92
|
+
window.XMLHttpRequest = __piBorrowedWindow.XMLHttpRequest
|
|
93
|
+
|
|
94
|
+
// The focused editor proxy marks toImageDataUrl with ret:'this', so a direct call
|
|
95
|
+
// returns the proxy instead of the image result. Install a temp method on the real
|
|
96
|
+
// editor; the proxy invokes it via value.apply(target, args) with this === the real
|
|
97
|
+
// editor, so this.toImageDataUrl reaches the genuine implementation and its return
|
|
98
|
+
// value flows back untouched.
|
|
99
|
+
const __piMethodName = '__piExportImage_' + Math.random().toString(36).slice(2)
|
|
100
|
+
editor[__piMethodName] = async function(ids, opts) {
|
|
101
|
+
return await this.toImageDataUrl(ids, opts)
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const __piImage = await editor[__piMethodName](__piInternalIds, __piExportOptions)
|
|
105
|
+
return {
|
|
106
|
+
dataUrl: __piImage.url,
|
|
107
|
+
width: __piImage.width,
|
|
108
|
+
height: __piImage.height,
|
|
109
|
+
format: __piExportFormat,
|
|
110
|
+
scope: __piExportScope,
|
|
111
|
+
exportedShapeIds: __piFocusedIds,
|
|
112
|
+
selectedCount: __piSelectedShapeIds.length,
|
|
113
|
+
fallbackToAll: __piFallbackToAll,
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
try { delete editor[__piMethodName] } catch {}
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
window.setTimeout = __piPriorSetTimeout
|
|
120
|
+
window.setInterval = __piPriorSetInterval
|
|
121
|
+
window.fetch = __piPriorFetch
|
|
122
|
+
window.XMLHttpRequest = __piPriorXHR
|
|
123
|
+
__piSandboxFrame.remove()
|
|
124
|
+
}
|
|
125
|
+
`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function parseCanvasExportPayload(value: unknown): CanvasExportPayload {
|
|
129
|
+
if (!value || typeof value !== 'object') throw new Error('Canvas export did not return an object.')
|
|
130
|
+
const record = value as Record<string, unknown>
|
|
131
|
+
if (typeof record.dataUrl !== 'string') throw new Error('Canvas export did not return a data URL.')
|
|
132
|
+
if (typeof record.width !== 'number' || typeof record.height !== 'number') {
|
|
133
|
+
throw new Error('Canvas export did not return image dimensions.')
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
dataUrl: record.dataUrl,
|
|
137
|
+
width: record.width,
|
|
138
|
+
height: record.height,
|
|
139
|
+
format: normalizeCanvasExportFormat(typeof record.format === 'string' ? record.format : undefined),
|
|
140
|
+
scope: normalizeCanvasExportScope(typeof record.scope === 'string' ? record.scope : undefined),
|
|
141
|
+
exportedShapeIds: Array.isArray(record.exportedShapeIds)
|
|
142
|
+
? record.exportedShapeIds.map(String)
|
|
143
|
+
: [],
|
|
144
|
+
selectedCount: typeof record.selectedCount === 'number' ? record.selectedCount : 0,
|
|
145
|
+
fallbackToAll: record.fallbackToAll === true,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function parseDataUrl(dataUrl: string): ParsedDataUrl {
|
|
150
|
+
const match = dataUrl.match(/^data:([^;,]+)(;base64)?,(.*)$/s)
|
|
151
|
+
if (!match) throw new Error('Invalid image data URL returned by canvas export.')
|
|
152
|
+
const mimeType = match[1]
|
|
153
|
+
const isBase64 = Boolean(match[2])
|
|
154
|
+
const payload = match[3]
|
|
155
|
+
const data = isBase64 ? payload : Buffer.from(decodeURIComponent(payload), 'utf8').toString('base64')
|
|
156
|
+
return { mimeType, data, bytes: Buffer.byteLength(data, 'base64') }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function formatBytes(bytes: number) {
|
|
160
|
+
if (bytes < 1024) return `${bytes} B`
|
|
161
|
+
const kib = bytes / 1024
|
|
162
|
+
if (kib < 1024) return `${kib.toFixed(1)} KiB`
|
|
163
|
+
return `${(kib / 1024).toFixed(1)} MiB`
|
|
164
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { CanvasBundle } from '../semantic/layer'
|
|
2
|
+
|
|
3
|
+
export type CanvasStateBundle = {
|
|
4
|
+
shapes: unknown[]
|
|
5
|
+
assets?: unknown[]
|
|
6
|
+
bindings?: unknown[]
|
|
7
|
+
selectedIds?: unknown[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type SceneLens = 'all' | 'selected'
|
|
11
|
+
|
|
12
|
+
export const INITIALIZE_CANVAS_CODE = 'return { initialized: true }'
|
|
13
|
+
|
|
14
|
+
export const READ_CANVAS_STATE_CODE = `
|
|
15
|
+
const shapes = editor.getCurrentPageShapes()
|
|
16
|
+
const assets = typeof editor.getAssets === 'function' ? editor.getAssets() : []
|
|
17
|
+
let bindings = []
|
|
18
|
+
try {
|
|
19
|
+
for (const shape of shapes) {
|
|
20
|
+
const shapeId = shape.shapeId ?? shape.id
|
|
21
|
+
if (!shapeId || typeof editor.getBindingsFromShape !== 'function') continue
|
|
22
|
+
const shapeBindings = editor.getBindingsFromShape(shapeId, 'arrow') ?? []
|
|
23
|
+
bindings.push(...shapeBindings)
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
bindings = []
|
|
27
|
+
}
|
|
28
|
+
let selectedIds = []
|
|
29
|
+
try { selectedIds = (editor.getSelectedShapeIds() || []).map((id) => String(id)) } catch { selectedIds = [] }
|
|
30
|
+
return { shapes, assets, bindings, selectedIds }
|
|
31
|
+
`
|
|
32
|
+
|
|
33
|
+
export function summarizeSnapshot(snapshot: { shapes?: unknown[]; assets?: unknown[]; bindings?: unknown[] }) {
|
|
34
|
+
const shapeCount = Array.isArray(snapshot.shapes) ? snapshot.shapes.length : 0
|
|
35
|
+
const assetCount = Array.isArray(snapshot.assets) ? snapshot.assets.length : 0
|
|
36
|
+
const bindingCount = Array.isArray(snapshot.bindings) ? snapshot.bindings.length : 0
|
|
37
|
+
return `${shapeCount} shape(s), ${assetCount} asset(s), ${bindingCount} binding(s)`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const normalizeShapeId = (id: unknown): string | null =>
|
|
41
|
+
id == null ? null : String(id).replace(/^shape:/, '')
|
|
42
|
+
|
|
43
|
+
export function shapeIdOf(shape: unknown): string | null {
|
|
44
|
+
if (!shape || typeof shape !== 'object') return null
|
|
45
|
+
const record = shape as Record<string, unknown>
|
|
46
|
+
return normalizeShapeId(record.shapeId ?? record.id)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function bindingTouchesSelection(binding: unknown, selectedIds: ReadonlySet<string>): boolean {
|
|
50
|
+
if (!binding || typeof binding !== 'object') return false
|
|
51
|
+
const record = binding as Record<string, unknown>
|
|
52
|
+
return [record.fromId, record.toId].some((id) => {
|
|
53
|
+
const normalized = normalizeShapeId(id)
|
|
54
|
+
return normalized != null && selectedIds.has(normalized)
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function applySceneLens(bundle: CanvasStateBundle, lens: SceneLens): CanvasStateBundle {
|
|
59
|
+
if (lens === 'all') return bundle
|
|
60
|
+
|
|
61
|
+
const selectedIds = selectedIdSet(bundle.selectedIds)
|
|
62
|
+
return {
|
|
63
|
+
...bundle,
|
|
64
|
+
shapes: bundle.shapes.filter((shape) => {
|
|
65
|
+
const id = shapeIdOf(shape)
|
|
66
|
+
return id != null && selectedIds.has(id)
|
|
67
|
+
}),
|
|
68
|
+
bindings: Array.isArray(bundle.bindings)
|
|
69
|
+
? bundle.bindings.filter((binding) => bindingTouchesSelection(binding, selectedIds))
|
|
70
|
+
: bundle.bindings,
|
|
71
|
+
selectedIds: [...selectedIds],
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const selectedIdSet = (selectedIds: unknown): ReadonlySet<string> =>
|
|
76
|
+
new Set(
|
|
77
|
+
(Array.isArray(selectedIds) ? selectedIds : [])
|
|
78
|
+
.map((id) => normalizeShapeId(id))
|
|
79
|
+
.filter((id): id is string => id != null)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
export function asCanvasBundle(bundle: CanvasStateBundle): CanvasBundle {
|
|
83
|
+
return {
|
|
84
|
+
shapes: bundle.shapes as CanvasBundle['shapes'],
|
|
85
|
+
assets: bundle.assets,
|
|
86
|
+
bindings: bundle.bindings,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function toRawCanvasState(bundle: CanvasStateBundle) {
|
|
91
|
+
return {
|
|
92
|
+
shapes: bundle.shapes,
|
|
93
|
+
assets: bundle.assets ?? [],
|
|
94
|
+
bindings: bundle.bindings ?? [],
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function restoreCanvasCode(snapshot: { shapes?: unknown[] }) {
|
|
99
|
+
const shapes = Array.isArray(snapshot.shapes) ? snapshot.shapes : []
|
|
100
|
+
return `
|
|
101
|
+
const snapshotShapes = ${JSON.stringify(shapes)}
|
|
102
|
+
const currentShapes = editor.getCurrentPageShapes()
|
|
103
|
+
const currentIds = currentShapes.map((shape) => shape.shapeId ?? shape.id).filter(Boolean)
|
|
104
|
+
if (currentIds.length) editor.deleteShapes(currentIds)
|
|
105
|
+
const nonArrows = snapshotShapes.filter((shape) => (shape._type ?? shape.type) !== 'arrow')
|
|
106
|
+
const arrows = snapshotShapes.filter((shape) => (shape._type ?? shape.type) === 'arrow')
|
|
107
|
+
for (const shape of nonArrows) editor.createShape(shape)
|
|
108
|
+
for (const shape of arrows) editor.createShape(shape)
|
|
109
|
+
const restoredIds = snapshotShapes.map((shape) => shape.shapeId ?? shape.id).filter(Boolean)
|
|
110
|
+
if (restoredIds.length) {
|
|
111
|
+
editor.select(...restoredIds)
|
|
112
|
+
editor.zoomToSelection()
|
|
113
|
+
editor.selectNone()
|
|
114
|
+
}
|
|
115
|
+
return { restored: snapshotShapes.length, shapeIds: restoredIds }
|
|
116
|
+
`
|
|
117
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
INITIALIZE_CANVAS_CODE,
|
|
3
|
+
READ_CANVAS_STATE_CODE,
|
|
4
|
+
restoreCanvasCode,
|
|
5
|
+
summarizeSnapshot,
|
|
6
|
+
type CanvasStateBundle,
|
|
7
|
+
} from './state'
|
|
8
|
+
import { extractReturnValue, extractTextContent, parseCanvasIdFromText, type McpToolResult } from '../mcp/response'
|
|
9
|
+
import type { StoredCanvasSnapshot } from '../store/project-store'
|
|
10
|
+
|
|
11
|
+
export type CanvasHostPort = {
|
|
12
|
+
open(signal?: AbortSignal): Promise<{ url: string; spawned: boolean }>
|
|
13
|
+
execOnCanvas(
|
|
14
|
+
input: { code: string; canvasId?: string; timeoutMs?: number },
|
|
15
|
+
signal?: AbortSignal
|
|
16
|
+
): Promise<unknown>
|
|
17
|
+
getStatus(): { url: string | null; browserConnected: boolean }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type CanvasWorkflowDeps = {
|
|
21
|
+
canvasHost: CanvasHostPort
|
|
22
|
+
loadCanvasSnapshot(cwd: string, canvasId?: string): Promise<StoredCanvasSnapshot | null>
|
|
23
|
+
saveCanvasSnapshot(
|
|
24
|
+
cwd: string,
|
|
25
|
+
canvasId: string,
|
|
26
|
+
state: { shapes?: unknown[]; assets?: unknown[]; bindings?: unknown[] },
|
|
27
|
+
opts?: { allowEmptyOverwrite?: boolean }
|
|
28
|
+
): Promise<StoredCanvasSnapshot>
|
|
29
|
+
resolveCanvasId(cwd: string, explicitCanvasId?: string): Promise<string | undefined>
|
|
30
|
+
rememberCanvasId(cwd: string, canvasId: string): Promise<void>
|
|
31
|
+
createProjectCanvasId(): string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createCanvasWorkflows(deps: CanvasWorkflowDeps) {
|
|
35
|
+
return {
|
|
36
|
+
async ensureBrowserAndRestore(
|
|
37
|
+
cwd: string,
|
|
38
|
+
canvasId: string | undefined,
|
|
39
|
+
signal?: AbortSignal,
|
|
40
|
+
opts: { restore?: boolean } = {}
|
|
41
|
+
) {
|
|
42
|
+
const { url, spawned } = await deps.canvasHost.open(signal)
|
|
43
|
+
let resolvedId = await deps.resolveCanvasId(cwd, canvasId)
|
|
44
|
+
let restoreText = spawned
|
|
45
|
+
? 'No project canvas restored.'
|
|
46
|
+
: 'Browser already open; live canvas unchanged.'
|
|
47
|
+
|
|
48
|
+
if (spawned && opts.restore !== false) {
|
|
49
|
+
if (!resolvedId) resolvedId = deps.createProjectCanvasId()
|
|
50
|
+
const snapshot = await deps.loadCanvasSnapshot(cwd, resolvedId)
|
|
51
|
+
if (snapshot) {
|
|
52
|
+
await deps.canvasHost.execOnCanvas(
|
|
53
|
+
{ code: restoreCanvasCode(snapshot), canvasId: snapshot.canvasId },
|
|
54
|
+
signal
|
|
55
|
+
)
|
|
56
|
+
await deps.rememberCanvasId(cwd, snapshot.canvasId)
|
|
57
|
+
restoreText = `Restored project canvas ${snapshot.canvasId} (${summarizeSnapshot(snapshot)}). Autosave is on.`
|
|
58
|
+
} else {
|
|
59
|
+
await deps.canvasHost.execOnCanvas(
|
|
60
|
+
{ code: INITIALIZE_CANVAS_CODE, canvasId: resolvedId },
|
|
61
|
+
signal
|
|
62
|
+
)
|
|
63
|
+
await deps.rememberCanvasId(cwd, resolvedId)
|
|
64
|
+
restoreText = `Started new project canvas ${resolvedId}. Autosave is on.`
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { url, spawned, resolvedId, restoreText }
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async snapshotLiveCanvas(
|
|
72
|
+
cwd: string,
|
|
73
|
+
canvasId: string | undefined,
|
|
74
|
+
signal?: AbortSignal,
|
|
75
|
+
opts?: { allowEmptyOverwrite?: boolean }
|
|
76
|
+
) {
|
|
77
|
+
const hostStatus = deps.canvasHost.getStatus()
|
|
78
|
+
if (!hostStatus.browserConnected) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`No live browser canvas is connected. Open the canvas tab with /tldraw open, wait for "Canvas ready", then save again. Host: ${hostStatus.url ?? 'not started'}`
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
const result = (await deps.canvasHost.execOnCanvas(
|
|
84
|
+
{ code: READ_CANVAS_STATE_CODE, canvasId, timeoutMs: 60_000 },
|
|
85
|
+
signal
|
|
86
|
+
)) as McpToolResult
|
|
87
|
+
const text = extractTextContent(result)
|
|
88
|
+
const returnedCanvasId = parseCanvasIdFromText(text) ?? canvasId
|
|
89
|
+
if (!returnedCanvasId) throw new Error('Could not determine canvasId from live canvas read.')
|
|
90
|
+
const state = (result.structuredContent ?? extractReturnValue(result) ?? {}) as Partial<CanvasStateBundle>
|
|
91
|
+
const saved = await deps.saveCanvasSnapshot(
|
|
92
|
+
cwd,
|
|
93
|
+
returnedCanvasId,
|
|
94
|
+
{
|
|
95
|
+
shapes: Array.isArray(state.shapes) ? state.shapes : [],
|
|
96
|
+
assets: Array.isArray(state.assets) ? state.assets : [],
|
|
97
|
+
bindings: Array.isArray(state.bindings) ? state.bindings : [],
|
|
98
|
+
},
|
|
99
|
+
{ allowEmptyOverwrite: opts?.allowEmptyOverwrite }
|
|
100
|
+
)
|
|
101
|
+
await deps.rememberCanvasId(cwd, returnedCanvasId)
|
|
102
|
+
return saved
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const TLDRAW_COMMAND_TYPES = [
|
|
2
|
+
'reset',
|
|
3
|
+
'current',
|
|
4
|
+
'canvases',
|
|
5
|
+
'save',
|
|
6
|
+
'start',
|
|
7
|
+
'restart',
|
|
8
|
+
'tools',
|
|
9
|
+
'open',
|
|
10
|
+
'host',
|
|
11
|
+
'resource',
|
|
12
|
+
'status',
|
|
13
|
+
] as const
|
|
14
|
+
|
|
15
|
+
export type TldrawCommandType = (typeof TLDRAW_COMMAND_TYPES)[number]
|
|
16
|
+
|
|
17
|
+
export type ParsedTldrawCommand = {
|
|
18
|
+
type: TldrawCommandType
|
|
19
|
+
force: boolean
|
|
20
|
+
canvasId?: string
|
|
21
|
+
requestedAction?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const COMMANDS: ReadonlySet<string> = new Set(TLDRAW_COMMAND_TYPES)
|
|
25
|
+
|
|
26
|
+
export function parseTldrawCommand(args: string): ParsedTldrawCommand {
|
|
27
|
+
const parts = args.trim().split(/\s+/).filter(Boolean)
|
|
28
|
+
const rawAction = parts[0] ?? 'status'
|
|
29
|
+
const force = rawAction.endsWith('!') || parts.includes('--force')
|
|
30
|
+
const normalizedAction = rawAction.endsWith('!') ? rawAction.slice(0, -1) : rawAction
|
|
31
|
+
const canvasId = parts.slice(1).find((part) => !part.startsWith('--'))
|
|
32
|
+
|
|
33
|
+
return isTldrawCommandType(normalizedAction)
|
|
34
|
+
? withOptionalCanvasId({ type: normalizedAction, force }, canvasId)
|
|
35
|
+
: withOptionalCanvasId(
|
|
36
|
+
{ type: 'status', force, requestedAction: normalizedAction },
|
|
37
|
+
canvasId
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isTldrawCommandType = (value: string): value is TldrawCommandType => COMMANDS.has(value)
|
|
42
|
+
|
|
43
|
+
function withOptionalCanvasId<T extends Omit<ParsedTldrawCommand, 'canvasId'>>(
|
|
44
|
+
command: T,
|
|
45
|
+
canvasId: string | undefined
|
|
46
|
+
): T | (T & { canvasId: string }) {
|
|
47
|
+
return canvasId ? { ...command, canvasId } : command
|
|
48
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface DiagramGuidanceOptions {
|
|
2
|
+
focus?: string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface DiagramGuidance {
|
|
6
|
+
focus?: string
|
|
7
|
+
text: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function normalizeFocus(focus?: string) {
|
|
11
|
+
const value = focus?.trim()
|
|
12
|
+
return value ? value : undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildDiagramGuidance(options: DiagramGuidanceOptions = {}): DiagramGuidance {
|
|
16
|
+
const focus = normalizeFocus(options.focus)
|
|
17
|
+
const lines = ['# tldraw drawing guidance', '']
|
|
18
|
+
|
|
19
|
+
if (focus) lines.push(`Focus: ${focus}`, '')
|
|
20
|
+
|
|
21
|
+
lines.push(
|
|
22
|
+
'## One rule',
|
|
23
|
+
'',
|
|
24
|
+
'Draw for legibility: make space do the explanatory work.',
|
|
25
|
+
'',
|
|
26
|
+
'## Ideas while drawing',
|
|
27
|
+
'',
|
|
28
|
+
'- Begin with the relationship the viewer should understand, then place shapes so that relationship is visible before any label is read.',
|
|
29
|
+
'- Use negative space deliberately. Leave breathing room around shapes, groups, arrow paths, and labels.',
|
|
30
|
+
'- Prefer simple alignment over clever geometry. Order and rhythm make diagrams easier to scan.',
|
|
31
|
+
'- Let grouping emerge from spacing and containment, not from crowding.',
|
|
32
|
+
'- Route connections through open space. If a connector competes with another mark, move the shapes rather than forcing the line.',
|
|
33
|
+
'- Keep labels short and readable. Put explanatory text where it does not interrupt the drawing.',
|
|
34
|
+
'- Use visual weight sparingly. Color, fill, and shape should clarify attention, not decorate every element.',
|
|
35
|
+
'',
|
|
36
|
+
'## Finish by looking, not explaining',
|
|
37
|
+
'',
|
|
38
|
+
'- Step back and inspect the canvas as a reader.',
|
|
39
|
+
'- Ask what feels cramped, ambiguous, or noisy.',
|
|
40
|
+
'- Add space, simplify marks, or reposition elements until the relationships can breathe.'
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return { focus, text: lines.join('\n') }
|
|
44
|
+
}
|