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,337 @@
|
|
|
1
|
+
import type { App } from '@modelcontextprotocol/ext-apps/react'
|
|
2
|
+
import { Editor, structuredClone } from 'tldraw'
|
|
3
|
+
import type { TLAsset, TLBindingCreate, TLShape } from 'tldraw'
|
|
4
|
+
import { isPlainObject } from '../shared/utils'
|
|
5
|
+
import { convertTldrawShapeToFocusedShape } from './focused/to-focused'
|
|
6
|
+
|
|
7
|
+
export interface CanvasSnapshot {
|
|
8
|
+
shapes: TLShape[]
|
|
9
|
+
assets: TLAsset[]
|
|
10
|
+
bindings?: TLBindingCreate[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// --- Canvas-scoped localStorage persistence ---
|
|
14
|
+
|
|
15
|
+
let currentSessionId: string | null = null
|
|
16
|
+
let currentCanvasId: string | null = null
|
|
17
|
+
|
|
18
|
+
export function setCurrentSessionId(id: string): void {
|
|
19
|
+
currentSessionId = id
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function setCurrentCanvasId(id: string): void {
|
|
23
|
+
currentCanvasId = id
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getCurrentCanvasId(): string | null {
|
|
27
|
+
return currentCanvasId
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function localStorageKey(checkpointId: string): string {
|
|
31
|
+
if (currentCanvasId) return `tldraw:canvas:${currentCanvasId}:${checkpointId}`
|
|
32
|
+
if (currentSessionId) return `tldraw:${currentSessionId}:${checkpointId}`
|
|
33
|
+
return `tldraw:${checkpointId}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseSnapshotData(
|
|
37
|
+
raw: string
|
|
38
|
+
): { shapes: TLShape[]; assets: TLAsset[]; bindings: TLBindingCreate[] } | null {
|
|
39
|
+
const parsed = JSON.parse(raw)
|
|
40
|
+
// Backwards compat: old format was a plain array of shapes
|
|
41
|
+
if (Array.isArray(parsed)) {
|
|
42
|
+
const shapes = parsed.filter(
|
|
43
|
+
(s: unknown): s is TLShape =>
|
|
44
|
+
isPlainObject(s) && typeof s.id === 'string' && typeof s.type === 'string'
|
|
45
|
+
)
|
|
46
|
+
if (parsed.length > 0 && shapes.length === 0) return null
|
|
47
|
+
return { shapes, assets: [], bindings: [] }
|
|
48
|
+
}
|
|
49
|
+
if (!isPlainObject(parsed) || !Array.isArray(parsed.shapes)) return null
|
|
50
|
+
const shapes = parsed.shapes.filter(
|
|
51
|
+
(s: unknown): s is TLShape =>
|
|
52
|
+
isPlainObject(s) && typeof s.id === 'string' && typeof s.type === 'string'
|
|
53
|
+
)
|
|
54
|
+
if (parsed.shapes.length > 0 && shapes.length === 0) return null
|
|
55
|
+
const assets = (Array.isArray(parsed.assets) ? parsed.assets : []).filter(
|
|
56
|
+
(a: unknown): a is TLAsset => isPlainObject(a) && typeof a.id === 'string'
|
|
57
|
+
)
|
|
58
|
+
const bindings = (Array.isArray(parsed.bindings) ? parsed.bindings : []) as TLBindingCreate[]
|
|
59
|
+
return { shapes, assets, bindings }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function loadLocalSnapshot(
|
|
63
|
+
checkpointId: string
|
|
64
|
+
): { shapes: TLShape[]; assets: TLAsset[]; bindings: TLBindingCreate[] } | null {
|
|
65
|
+
try {
|
|
66
|
+
// eslint-disable-next-line tldraw/no-direct-storage
|
|
67
|
+
const raw = localStorage.getItem(localStorageKey(checkpointId))
|
|
68
|
+
if (!raw) return null
|
|
69
|
+
return parseSnapshotData(raw)
|
|
70
|
+
} catch {
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function saveLocalSnapshot(
|
|
76
|
+
checkpointId: string,
|
|
77
|
+
shapes: TLShape[],
|
|
78
|
+
assets: TLAsset[] = [],
|
|
79
|
+
bindings: TLBindingCreate[] = []
|
|
80
|
+
): void {
|
|
81
|
+
const scopeId = currentCanvasId ?? currentSessionId
|
|
82
|
+
if (!scopeId) return
|
|
83
|
+
try {
|
|
84
|
+
// eslint-disable-next-line tldraw/no-direct-storage
|
|
85
|
+
localStorage.setItem(
|
|
86
|
+
localStorageKey(checkpointId),
|
|
87
|
+
JSON.stringify({ shapes, assets, bindings })
|
|
88
|
+
)
|
|
89
|
+
const latestKey = currentCanvasId
|
|
90
|
+
? `tldraw:canvas:${currentCanvasId}:latest`
|
|
91
|
+
: `tldraw:${currentSessionId}:latest`
|
|
92
|
+
// eslint-disable-next-line tldraw/no-direct-storage
|
|
93
|
+
localStorage.setItem(latestKey, checkpointId)
|
|
94
|
+
} catch {
|
|
95
|
+
// localStorage may be full or unavailable.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getLatestCheckpointSnapshot(): {
|
|
100
|
+
shapes: TLShape[]
|
|
101
|
+
assets: TLAsset[]
|
|
102
|
+
bindings: TLBindingCreate[]
|
|
103
|
+
} | null {
|
|
104
|
+
const scopeId = currentCanvasId ?? currentSessionId
|
|
105
|
+
if (!scopeId) return null
|
|
106
|
+
try {
|
|
107
|
+
const latestKey = currentCanvasId
|
|
108
|
+
? `tldraw:canvas:${currentCanvasId}:latest`
|
|
109
|
+
: `tldraw:${currentSessionId}:latest`
|
|
110
|
+
// eslint-disable-next-line tldraw/no-direct-storage
|
|
111
|
+
const latestId = localStorage.getItem(latestKey)
|
|
112
|
+
if (!latestId) return null
|
|
113
|
+
return loadLocalSnapshot(latestId)
|
|
114
|
+
} catch {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- Embedded bootstrap ---
|
|
120
|
+
|
|
121
|
+
declare global {
|
|
122
|
+
interface Window {
|
|
123
|
+
__TLDRAW_BOOTSTRAP__?: unknown
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getEmbeddedBootstrap(): {
|
|
128
|
+
sessionId: string
|
|
129
|
+
canvasId?: string
|
|
130
|
+
checkpointId?: string
|
|
131
|
+
isDev: boolean
|
|
132
|
+
workerOrigin?: string
|
|
133
|
+
mcpSessionId?: string
|
|
134
|
+
snapshot?: CanvasSnapshot
|
|
135
|
+
} | null {
|
|
136
|
+
const data = window.__TLDRAW_BOOTSTRAP__
|
|
137
|
+
if (!isPlainObject(data)) return null
|
|
138
|
+
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : null
|
|
139
|
+
if (!sessionId) return null
|
|
140
|
+
|
|
141
|
+
const canvasId = typeof data.canvasId === 'string' ? data.canvasId : undefined
|
|
142
|
+
const checkpointId = typeof data.checkpointId === 'string' ? data.checkpointId : undefined
|
|
143
|
+
const isDev = data.isDev === true
|
|
144
|
+
const workerOrigin = typeof data.workerOrigin === 'string' ? data.workerOrigin : undefined
|
|
145
|
+
const mcpSessionId = typeof data.mcpSessionId === 'string' ? data.mcpSessionId : undefined
|
|
146
|
+
|
|
147
|
+
let snapshot: CanvasSnapshot | undefined
|
|
148
|
+
if (Array.isArray(data.shapes)) {
|
|
149
|
+
const shapes = data.shapes.filter(
|
|
150
|
+
(s: unknown): s is TLShape =>
|
|
151
|
+
isPlainObject(s) && typeof s.id === 'string' && typeof s.type === 'string'
|
|
152
|
+
)
|
|
153
|
+
const assets = (Array.isArray(data.assets) ? data.assets : []).filter(
|
|
154
|
+
(a: unknown): a is TLAsset => isPlainObject(a) && typeof a.id === 'string'
|
|
155
|
+
)
|
|
156
|
+
const bindings = (Array.isArray(data.bindings) ? data.bindings : []) as TLBindingCreate[]
|
|
157
|
+
if (data.shapes.length === 0 || shapes.length > 0) {
|
|
158
|
+
snapshot = { shapes, assets, bindings }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { sessionId, canvasId, checkpointId, isDev, workerOrigin, mcpSessionId, snapshot }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- Tool result parsing ---
|
|
166
|
+
|
|
167
|
+
function toSnapshotShapesFromRecords(value: unknown): TLShape[] | null {
|
|
168
|
+
if (!Array.isArray(value)) return null
|
|
169
|
+
return value.filter(
|
|
170
|
+
(shape): shape is TLShape =>
|
|
171
|
+
isPlainObject(shape) && typeof shape.id === 'string' && typeof shape.type === 'string'
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function toAssetRecords(value: unknown): TLAsset[] {
|
|
176
|
+
if (!Array.isArray(value)) return []
|
|
177
|
+
return value.filter((a): a is TLAsset => isPlainObject(a) && typeof a.id === 'string')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
interface CheckpointResult {
|
|
181
|
+
checkpointId: string
|
|
182
|
+
sessionId: string | null
|
|
183
|
+
shapes: TLShape[]
|
|
184
|
+
assets: TLAsset[]
|
|
185
|
+
bindings: TLBindingCreate[]
|
|
186
|
+
action: string | null
|
|
187
|
+
/** True if the server found base shapes to merge with (for create action). */
|
|
188
|
+
hadBaseShapes: boolean
|
|
189
|
+
/** True if the server started from a blank canvas (for create action). */
|
|
190
|
+
newBlankCanvas: boolean
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function toBindingRecords(value: unknown): TLBindingCreate[] {
|
|
194
|
+
if (!Array.isArray(value)) return []
|
|
195
|
+
return value.filter(
|
|
196
|
+
(b): b is TLBindingCreate =>
|
|
197
|
+
isPlainObject(b) && typeof b.type === 'string' && typeof b.fromId === 'string'
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function parseCheckpointFromToolResult(result: unknown): CheckpointResult | null {
|
|
202
|
+
if (!isPlainObject(result)) return null
|
|
203
|
+
const sc = result.structuredContent
|
|
204
|
+
if (!isPlainObject(sc)) return null
|
|
205
|
+
const checkpointId = typeof sc.checkpointId === 'string' ? sc.checkpointId : null
|
|
206
|
+
if (!checkpointId) return null
|
|
207
|
+
const shapes = toSnapshotShapesFromRecords(sc.tldrawRecords)
|
|
208
|
+
if (!shapes) return null
|
|
209
|
+
|
|
210
|
+
const assets = toAssetRecords(sc.assets ?? sc.assetRecords)
|
|
211
|
+
const bindings = toBindingRecords(sc.bindings)
|
|
212
|
+
return {
|
|
213
|
+
checkpointId,
|
|
214
|
+
sessionId: typeof sc.sessionId === 'string' ? sc.sessionId : null,
|
|
215
|
+
shapes,
|
|
216
|
+
assets,
|
|
217
|
+
bindings,
|
|
218
|
+
action: typeof sc.action === 'string' ? sc.action : null,
|
|
219
|
+
hadBaseShapes: sc.hadBaseShapes === true,
|
|
220
|
+
newBlankCanvas: sc.newBlankCanvas === true,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// --- Snapshot capture + sync ---
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Capture the current editor state as a cloned snapshot.
|
|
228
|
+
*/
|
|
229
|
+
export function captureEditorSnapshot(editor: Editor): CanvasSnapshot {
|
|
230
|
+
return {
|
|
231
|
+
shapes: [...editor.getCurrentPageShapes()].map((s) => structuredClone(s)),
|
|
232
|
+
assets: [...editor.getAssets()].map((a) => structuredClone(a)),
|
|
233
|
+
bindings: getEditorBindings(editor),
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Push model context and persist the editor state to localStorage + server.
|
|
239
|
+
* Returns the captured snapshot so the caller can store it.
|
|
240
|
+
*/
|
|
241
|
+
export function syncEditorState(
|
|
242
|
+
app: App,
|
|
243
|
+
editor: Editor,
|
|
244
|
+
checkpointId: string | null,
|
|
245
|
+
opts?: { message?: string }
|
|
246
|
+
): CanvasSnapshot {
|
|
247
|
+
const snapshot = captureEditorSnapshot(editor)
|
|
248
|
+
pushCanvasContext(app, editor, opts)
|
|
249
|
+
if (checkpointId) {
|
|
250
|
+
saveLocalSnapshot(checkpointId, snapshot.shapes, snapshot.assets, snapshot.bindings ?? [])
|
|
251
|
+
void saveCheckpointToServer(app, checkpointId, editor)
|
|
252
|
+
}
|
|
253
|
+
return snapshot
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- Canvas context push ---
|
|
257
|
+
|
|
258
|
+
export function pushCanvasContext(app: App, editor: Editor, opts?: { message?: string }) {
|
|
259
|
+
const shapes = [...editor.getCurrentPageShapes()].map((shape) =>
|
|
260
|
+
convertTldrawShapeToFocusedShape(editor, shape)
|
|
261
|
+
)
|
|
262
|
+
const assets = [...editor.getAssets()].map((asset) => structuredClone(asset))
|
|
263
|
+
const bindings = getEditorBindings(editor)
|
|
264
|
+
|
|
265
|
+
const canvasStatus = shapes.length > 0 ? `Current canvas state is attached.` : 'Canvas is empty.'
|
|
266
|
+
|
|
267
|
+
const text = opts?.message ? `${opts.message}\n\n${canvasStatus}` : canvasStatus
|
|
268
|
+
|
|
269
|
+
void app.updateModelContext({
|
|
270
|
+
content: [{ type: 'text', text }],
|
|
271
|
+
structuredContent: {
|
|
272
|
+
shapes,
|
|
273
|
+
assets,
|
|
274
|
+
bindings,
|
|
275
|
+
},
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function clearCanvasContext(app: App, opts?: { message?: string }) {
|
|
280
|
+
const text = opts?.message ?? 'Canvas context was cleared.'
|
|
281
|
+
void app.updateModelContext({
|
|
282
|
+
content: [{ type: 'text', text }],
|
|
283
|
+
structuredContent: {
|
|
284
|
+
shapes: [],
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function getEditorBindings(editor: Editor): TLBindingCreate[] {
|
|
290
|
+
const bindings: TLBindingCreate[] = []
|
|
291
|
+
for (const record of editor.getCurrentPageShapes()) {
|
|
292
|
+
if (record.type !== 'arrow') continue
|
|
293
|
+
const arrowBindings = editor.getBindingsFromShape(record.id, 'arrow')
|
|
294
|
+
for (const binding of arrowBindings) {
|
|
295
|
+
bindings.push({
|
|
296
|
+
type: binding.type,
|
|
297
|
+
fromId: binding.fromId,
|
|
298
|
+
toId: binding.toId,
|
|
299
|
+
props: structuredClone(binding.props),
|
|
300
|
+
meta: structuredClone(binding.meta),
|
|
301
|
+
} as TLBindingCreate)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return bindings
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function saveCheckpointToServer(
|
|
308
|
+
app: App,
|
|
309
|
+
checkpointId: string,
|
|
310
|
+
editor: Editor
|
|
311
|
+
): Promise<boolean> {
|
|
312
|
+
const shapes = [...editor.getCurrentPageShapes()].map((s) => structuredClone(s))
|
|
313
|
+
const assets = [...editor.getAssets()].map((a) => structuredClone(a))
|
|
314
|
+
const bindings = getEditorBindings(editor)
|
|
315
|
+
const args: Record<string, string> = {
|
|
316
|
+
checkpointId,
|
|
317
|
+
shapesJson: JSON.stringify(shapes),
|
|
318
|
+
}
|
|
319
|
+
if (assets.length > 0) {
|
|
320
|
+
args.assetsJson = JSON.stringify(assets)
|
|
321
|
+
}
|
|
322
|
+
if (bindings.length > 0) {
|
|
323
|
+
args.bindingsJson = JSON.stringify(bindings)
|
|
324
|
+
}
|
|
325
|
+
if (currentCanvasId) {
|
|
326
|
+
args.canvasId = currentCanvasId
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
const result = await app.callServerTool({
|
|
330
|
+
name: 'save_checkpoint',
|
|
331
|
+
arguments: args,
|
|
332
|
+
})
|
|
333
|
+
return result.isError !== true
|
|
334
|
+
} catch {
|
|
335
|
+
return false
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Box, Editor, structuredClone } from 'tldraw'
|
|
2
|
+
import type { TLBindingCreate, TLShape, TLShapeId } from 'tldraw'
|
|
3
|
+
import type { CanvasSnapshot } from './persistence'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Re-run each ShapeUtil's onBeforeCreate to recalculate auto-sizing dimensions
|
|
7
|
+
* (e.g. growY for geo/note shapes). This compensates for shapes arriving from
|
|
8
|
+
* the server with static dimensions (growY: 0) that get applied via updateShapes,
|
|
9
|
+
* which doesn't trigger the sizing recalculation in onBeforeUpdate unless
|
|
10
|
+
* text/font/size actually changed.
|
|
11
|
+
*/
|
|
12
|
+
function forceAutoSize(editor: Editor) {
|
|
13
|
+
const updates: TLShape[] = []
|
|
14
|
+
for (const shape of editor.getCurrentPageShapes()) {
|
|
15
|
+
const util = editor.getShapeUtil(shape)
|
|
16
|
+
const adjusted = util.onBeforeCreate?.(shape as any)
|
|
17
|
+
if (adjusted) {
|
|
18
|
+
updates.push(adjusted as TLShape)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (updates.length > 0) {
|
|
22
|
+
editor.updateShapes(updates)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create bindings on the editor from a list of TLBindingCreate objects.
|
|
28
|
+
* Only creates bindings where the target shape exists on the page.
|
|
29
|
+
* Removes existing arrow bindings for affected arrows to prevent duplicates.
|
|
30
|
+
*/
|
|
31
|
+
function applyBindings(editor: Editor, bindings: TLBindingCreate[]) {
|
|
32
|
+
if (bindings.length === 0) return
|
|
33
|
+
|
|
34
|
+
// Collect arrow shape IDs that have bindings to dedup
|
|
35
|
+
const arrowIds = new Set(bindings.map((b) => b.fromId))
|
|
36
|
+
for (const arrowId of arrowIds) {
|
|
37
|
+
const existing = editor.getBindingsFromShape(arrowId, 'arrow')
|
|
38
|
+
if (existing.length > 0) {
|
|
39
|
+
editor.deleteBindings(existing)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const binding of bindings) {
|
|
44
|
+
if (editor.getShape(binding.toId)) {
|
|
45
|
+
editor.createBinding(binding)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const CAMERA_ANIM_MS = 300
|
|
51
|
+
let cameraAnimEndTime = 0
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* If any of the given shape IDs extend outside the current viewport,
|
|
55
|
+
* pan (and only zoom out if necessary) to keep them visible.
|
|
56
|
+
* Never zooms in beyond the current zoom level.
|
|
57
|
+
* Skips the call if a previous animation is still playing.
|
|
58
|
+
*/
|
|
59
|
+
export function zoomToFitRequestShapes(editor: Editor, shapeIds: Set<TLShapeId>) {
|
|
60
|
+
if (shapeIds.size === 0) return
|
|
61
|
+
|
|
62
|
+
// Don't interrupt an in-progress animation — wait for it to finish
|
|
63
|
+
if (Date.now() < cameraAnimEndTime) return
|
|
64
|
+
|
|
65
|
+
const shapeBounds: Box[] = []
|
|
66
|
+
for (const id of shapeIds) {
|
|
67
|
+
const bounds = editor.getShapePageBounds(id)
|
|
68
|
+
if (bounds) shapeBounds.push(bounds)
|
|
69
|
+
}
|
|
70
|
+
if (shapeBounds.length === 0) return
|
|
71
|
+
|
|
72
|
+
const commonBounds = Box.Common(shapeBounds)
|
|
73
|
+
const viewportBounds = editor.getViewportPageBounds()
|
|
74
|
+
|
|
75
|
+
// All request shapes already visible — nothing to do
|
|
76
|
+
const contained =
|
|
77
|
+
commonBounds.x >= viewportBounds.x &&
|
|
78
|
+
commonBounds.y >= viewportBounds.y &&
|
|
79
|
+
commonBounds.x + commonBounds.w <= viewportBounds.x + viewportBounds.w &&
|
|
80
|
+
commonBounds.y + commonBounds.h <= viewportBounds.y + viewportBounds.h
|
|
81
|
+
if (contained) return
|
|
82
|
+
|
|
83
|
+
const currentZoom = editor.getZoomLevel()
|
|
84
|
+
const screenBounds = editor.getViewportScreenBounds()
|
|
85
|
+
const inset = 100
|
|
86
|
+
|
|
87
|
+
// The zoom level needed to fit the shapes with padding
|
|
88
|
+
const fitZoomX =
|
|
89
|
+
commonBounds.w > 0 ? (screenBounds.w - inset) / commonBounds.w : Number.POSITIVE_INFINITY
|
|
90
|
+
const fitZoomY =
|
|
91
|
+
commonBounds.h > 0 ? (screenBounds.h - inset) / commonBounds.h : Number.POSITIVE_INFINITY
|
|
92
|
+
const fitZoom = Math.min(fitZoomX, fitZoomY)
|
|
93
|
+
|
|
94
|
+
// Never zoom in past the current level — only zoom out if shapes don't fit
|
|
95
|
+
const zoom = Math.min(currentZoom, fitZoom)
|
|
96
|
+
if (!Number.isFinite(zoom) || zoom <= 0) return
|
|
97
|
+
|
|
98
|
+
// Center the shapes in the viewport at the chosen zoom
|
|
99
|
+
const cx = commonBounds.x + commonBounds.w / 2
|
|
100
|
+
const cy = commonBounds.y + commonBounds.h / 2
|
|
101
|
+
const cameraX = -cx + screenBounds.w / zoom / 2
|
|
102
|
+
const cameraY = -cy + screenBounds.h / zoom / 2
|
|
103
|
+
if (!Number.isFinite(cameraX) || !Number.isFinite(cameraY)) return
|
|
104
|
+
|
|
105
|
+
cameraAnimEndTime = Date.now() + CAMERA_ANIM_MS
|
|
106
|
+
|
|
107
|
+
editor.setCamera(
|
|
108
|
+
{
|
|
109
|
+
x: cameraX,
|
|
110
|
+
y: cameraY,
|
|
111
|
+
z: zoom,
|
|
112
|
+
},
|
|
113
|
+
{ animation: { duration: CAMERA_ANIM_MS } }
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function applySnapshot(editor: Editor, snapshot: CanvasSnapshot) {
|
|
118
|
+
const nextShapes = snapshot.shapes.map((shape) => structuredClone(shape))
|
|
119
|
+
const nextAssets = (snapshot.assets ?? []).map((asset) => structuredClone(asset))
|
|
120
|
+
const nextBindings = (snapshot.bindings ?? []) as TLBindingCreate[]
|
|
121
|
+
|
|
122
|
+
editor.store.mergeRemoteChanges(() => {
|
|
123
|
+
editor.run(
|
|
124
|
+
() => {
|
|
125
|
+
// Restore asset records first so image shapes can resolve them
|
|
126
|
+
if (nextAssets.length > 0) {
|
|
127
|
+
const existingAssetIds = new Set(editor.getAssets().map((a) => a.id))
|
|
128
|
+
for (const asset of nextAssets) {
|
|
129
|
+
if (!existingAssetIds.has(asset.id)) {
|
|
130
|
+
editor.createAssets([asset])
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const existingIds = [...editor.getCurrentPageShapeIds()]
|
|
136
|
+
if (existingIds.length > 0) {
|
|
137
|
+
editor.deleteShapes(existingIds)
|
|
138
|
+
}
|
|
139
|
+
if (nextShapes.length <= 0) return
|
|
140
|
+
|
|
141
|
+
// Preserve parent relationships while allowing the editor to assign fresh indices.
|
|
142
|
+
const createInputs = nextShapes.map((shape) => {
|
|
143
|
+
const { index: _index, ...partial } = shape
|
|
144
|
+
return partial
|
|
145
|
+
})
|
|
146
|
+
editor.createShapes(createInputs)
|
|
147
|
+
|
|
148
|
+
// Re-run auto-sizing so growY / fontSizeAdjustment are correct
|
|
149
|
+
forceAutoSize(editor)
|
|
150
|
+
|
|
151
|
+
// Create bindings after all shapes are on the page
|
|
152
|
+
applyBindings(editor, nextBindings)
|
|
153
|
+
},
|
|
154
|
+
{ history: 'ignore' }
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
}
|