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,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
+ }