diff --git a/src/automerge/useAutomergeStoreV2.ts b/src/automerge/useAutomergeStoreV2.ts index e524b7c..d57b7b7 100644 --- a/src/automerge/useAutomergeStoreV2.ts +++ b/src/automerge/useAutomergeStoreV2.ts @@ -274,12 +274,7 @@ export function useAutomergeStoreV2({ return } - // Broadcasting changes via JSON sync - const shapeRecords = addedOrUpdatedRecords.filter(r => r?.typeName === 'shape') - const deletedShapes = deletedRecordIds.filter(id => id.startsWith('shape:')) - if (shapeRecords.length > 0 || deletedShapes.length > 0) { - console.log(`๐Ÿ“ค Broadcasting ${shapeRecords.length} shape changes and ${deletedShapes.length} deletions via JSON sync`) - } + // Broadcasting changes via JSON sync (logging disabled for performance) if (adapter && typeof (adapter as any).send === 'function') { // Send changes to other clients via the network adapter @@ -303,50 +298,23 @@ export function useAutomergeStoreV2({ // Listen for changes from Automerge and apply them to TLDraw const automergeChangeHandler = (payload: DocHandleChangePayload) => { const patchCount = payload.patches?.length || 0 - const shapePatches = payload.patches?.filter((p: any) => { - const id = p.path?.[1] - return id && typeof id === 'string' && id.startsWith('shape:') - }) || [] - - // Debug logging for sync issues - console.log(`๐Ÿ”„ automergeChangeHandler: ${patchCount} patches (${shapePatches.length} shapes), pendingLocalChanges=${pendingLocalChanges}`) // Skip echoes of our own local changes using a counter. // Each local handle.change() increments the counter, and each echo decrements it. // Only process changes when counter is 0 (those are remote changes from other clients). if (pendingLocalChanges > 0) { - console.log(`โญ๏ธ Skipping echo (pendingLocalChanges was ${pendingLocalChanges}, now ${pendingLocalChanges - 1})`) pendingLocalChanges-- return } - console.log(`โœ… Processing ${patchCount} patches as REMOTE changes (${shapePatches.length} shape patches)`) - try { // Apply patches from Automerge to TLDraw store if (payload.patches && payload.patches.length > 0) { - // Debug: Check if patches contain shapes - if (shapePatches.length > 0) { - console.log(`๐Ÿ“ฅ Applying ${shapePatches.length} shape patches from remote`) - } - try { - const recordsBefore = store.allRecords() - const shapesBefore = recordsBefore.filter((r: any) => r.typeName === 'shape') - // CRITICAL: Pass Automerge document to patch handler so it can read full records // This prevents coordinates from defaulting to 0,0 when patches create new records const automergeDoc = handle.doc() applyAutomergePatchesToTLStore(payload.patches, store, automergeDoc) - - const recordsAfter = store.allRecords() - const shapesAfter = recordsAfter.filter((r: any) => r.typeName === 'shape') - - if (shapesAfter.length !== shapesBefore.length) { - // Patches applied - } - - // Patches processed successfully } catch (patchError) { console.error("Error applying patches batch, attempting individual patch application:", patchError) // Try applying patches one by one to identify problematic ones @@ -580,78 +548,91 @@ export function useAutomergeStoreV2({ // Track recent eraser activity to detect active eraser drags let lastEraserActivity = 0 let eraserToolSelected = false + let lastEraserCheckTime = 0 + let cachedEraserActive = false const ERASER_ACTIVITY_THRESHOLD = 2000 // Increased to 2 seconds to handle longer eraser drags + const ERASER_CHECK_CACHE_MS = 100 // Only refresh eraser state every 100ms to avoid expensive checks let eraserChangeQueue: RecordsDiff | null = null let eraserCheckInterval: NodeJS.Timeout | null = null - + // Helper to check if eraser tool is actively erasing (to prevent saves during eraser drag) + // OPTIMIZED: Uses cached state and only refreshes periodically to avoid expensive store.allRecords() calls const isEraserActive = (): boolean => { + const now = Date.now() + + // Use cached result if checked recently + if (now - lastEraserCheckTime < ERASER_CHECK_CACHE_MS) { + return cachedEraserActive + } + lastEraserCheckTime = now + + // If eraser was selected and recent activity, assume still active + if (eraserToolSelected && now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) { + cachedEraserActive = true + return true + } + + // If no recent eraser activity and not marked as selected, quickly return false + if (!eraserToolSelected && now - lastEraserActivity > ERASER_ACTIVITY_THRESHOLD) { + cachedEraserActive = false + return false + } + + // Only do expensive check if eraser might be transitioning try { - const allRecords = store.allRecords() - + // Use store.get() for specific records instead of allRecords() for better performance + const instancePageState = store.get('instance_page_state:page:page' as any) + // Check instance_page_state for erasingShapeIds (most reliable indicator) - const instancePageState = allRecords.find((r: any) => - r.typeName === 'instance_page_state' && - (r as any).erasingShapeIds && - Array.isArray((r as any).erasingShapeIds) && - (r as any).erasingShapeIds.length > 0 - ) - - if (instancePageState) { - lastEraserActivity = Date.now() + if (instancePageState && + (instancePageState as any).erasingShapeIds && + Array.isArray((instancePageState as any).erasingShapeIds) && + (instancePageState as any).erasingShapeIds.length > 0) { + lastEraserActivity = now eraserToolSelected = true + cachedEraserActive = true return true // Eraser is actively erasing shapes } - + // Check if eraser tool is selected - const instance = allRecords.find((r: any) => r.typeName === 'instance') + const instance = store.get('instance:instance' as any) const currentToolId = instance ? (instance as any).currentToolId : null - + if (currentToolId === 'eraser') { eraserToolSelected = true - const now = Date.now() - // If eraser tool is selected, keep it active for longer to handle drags - // Also check if there was recent activity - if (now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) { - return true - } - // If tool is selected but no recent activity, still consider it active - // (user might be mid-drag) + lastEraserActivity = now + cachedEraserActive = true return true } else { - // Tool switched away - only consider active if very recent activity eraserToolSelected = false - const now = Date.now() - if (now - lastEraserActivity < 300) { - return true // Very recent activity, might still be processing - } } - + + cachedEraserActive = false return false } catch (e) { // If we can't check, use last known state with timeout - const now = Date.now() if (eraserToolSelected && now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) { + cachedEraserActive = true return true } + cachedEraserActive = false return false } } // Track eraser activity from shape deletions + // OPTIMIZED: Only check for eraser tool when shapes are removed, and use cached tool state const checkForEraserActivity = (changes: RecordsDiff) => { // If shapes are being removed and eraser tool might be active, mark activity if (changes.removed) { - const removedShapes = Object.values(changes.removed).filter((r: any) => - r && r.typeName === 'shape' - ) - if (removedShapes.length > 0) { - // Check if eraser tool is currently selected - const allRecords = store.allRecords() - const instance = allRecords.find((r: any) => r.typeName === 'instance') - if (instance && (instance as any).currentToolId === 'eraser') { - lastEraserActivity = Date.now() - eraserToolSelected = true + const removedKeys = Object.keys(changes.removed) + // Quick check: if no shape keys, skip + const hasRemovedShapes = removedKeys.some(key => key.startsWith('shape:')) + if (hasRemovedShapes) { + // Use cached eraserToolSelected state if recent, avoid expensive allRecords() call + const now = Date.now() + if (eraserToolSelected || now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) { + lastEraserActivity = now } } } @@ -688,17 +669,6 @@ export function useAutomergeStoreV2({ id.startsWith('pointer:') ) - // DEBUG: Log why records are being filtered or not - const shouldFilter = (typeName && ephemeralTypes.includes(typeName)) || idMatchesEphemeral - if (shouldFilter) { - console.log(`๐Ÿšซ Filtering out ephemeral record:`, { - id, - typeName, - idMatchesEphemeral, - typeNameMatches: typeName && ephemeralTypes.includes(typeName) - }) - } - // Filter out if typeName matches OR if ID pattern matches ephemeral types if (typeName && ephemeralTypes.includes(typeName)) { // Skip - this is an ephemeral record @@ -721,183 +691,9 @@ export function useAutomergeStoreV2({ removed: filterEphemeral(changes.removed), } - // DEBUG: Log all changes to see what's being detected - const totalChanges = Object.keys(changes.added || {}).length + Object.keys(changes.updated || {}).length + Object.keys(changes.removed || {}).length + // Calculate change counts (minimal, needed for early return) const filteredTotalChanges = Object.keys(filteredChanges.added || {}).length + Object.keys(filteredChanges.updated || {}).length + Object.keys(filteredChanges.removed || {}).length - - // DEBUG: Log ALL changes (before filtering) to see what's actually being updated - if (totalChanges > 0) { - const allChangedRecords: Array<{id: string, typeName: string, changeType: string}> = [] - if (changes.added) { - Object.entries(changes.added).forEach(([id, record]: [string, any]) => { - const recordObj = Array.isArray(record) ? record[1] : record - allChangedRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'added' }) - }) - } - if (changes.updated) { - Object.entries(changes.updated).forEach(([id, [_, record]]: [string, [any, any]]) => { - allChangedRecords.push({ id, typeName: record?.typeName || 'unknown', changeType: 'updated' }) - }) - } - if (changes.removed) { - Object.entries(changes.removed).forEach(([id, record]: [string, any]) => { - const recordObj = Array.isArray(record) ? record[1] : record - allChangedRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'removed' }) - }) - } - console.log(`๐Ÿ” ALL changes detected (before filtering):`, { - total: totalChanges, - records: allChangedRecords, - // Also log the actual record objects to see their structure - recordDetails: allChangedRecords.map(r => { - let record: any = null - if (r.changeType === 'added' && changes.added) { - const rec = (changes.added as any)[r.id] - record = Array.isArray(rec) ? rec[1] : rec - } else if (r.changeType === 'updated' && changes.updated) { - const rec = (changes.updated as any)[r.id] - record = Array.isArray(rec) ? rec[1] : rec - } else if (r.changeType === 'removed' && changes.removed) { - const rec = (changes.removed as any)[r.id] - record = Array.isArray(rec) ? rec[1] : rec - } - return { - id: r.id, - typeName: r.typeName, - changeType: r.changeType, - hasTypeName: !!record?.typeName, - actualTypeName: record?.typeName, - recordKeys: record ? Object.keys(record).slice(0, 10) : [] - } - }) - }) - } - - // Log if we filtered out any ephemeral changes - if (totalChanges > 0 && filteredTotalChanges < totalChanges) { - const filteredCount = totalChanges - filteredTotalChanges - const filteredTypes = new Set() - const filteredIds: string[] = [] - if (changes.added) { - Object.entries(changes.added).forEach(([id, record]: [string, any]) => { - const recordObj = Array.isArray(record) ? record[1] : record - if (recordObj && ephemeralTypes.includes(recordObj.typeName)) { - filteredTypes.add(recordObj.typeName) - filteredIds.push(id) - } - }) - } - if (changes.updated) { - Object.entries(changes.updated).forEach(([id, [_, record]]: [string, [any, any]]) => { - if (ephemeralTypes.includes(record.typeName)) { - filteredTypes.add(record.typeName) - filteredIds.push(id) - } - }) - } - if (changes.removed) { - Object.entries(changes.removed).forEach(([id, record]: [string, any]) => { - const recordObj = Array.isArray(record) ? record[1] : record - if (recordObj && ephemeralTypes.includes(recordObj.typeName)) { - filteredTypes.add(recordObj.typeName) - filteredIds.push(id) - } - }) - } - console.log(`๐Ÿšซ Filtered out ${filteredCount} ephemeral change(s) (${Array.from(filteredTypes).join(', ')}) - not persisting`, { - filteredIds: filteredIds.slice(0, 5), // Show first 5 IDs - totalFiltered: filteredIds.length - }) - } - - if (filteredTotalChanges > 0) { - // Log what records are passing through the filter (shouldn't happen for ephemeral records) - const passingRecords: Array<{id: string, typeName: string, changeType: string}> = [] - if (filteredChanges.added) { - Object.entries(filteredChanges.added).forEach(([id, record]: [string, any]) => { - const recordObj = Array.isArray(record) ? record[1] : record - passingRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'added' }) - }) - } - if (filteredChanges.updated) { - Object.entries(filteredChanges.updated).forEach(([id, recordTuple]: [string, any]) => { - const record = Array.isArray(recordTuple) && recordTuple.length === 2 ? recordTuple[1] : recordTuple - passingRecords.push({ id, typeName: (record as any)?.typeName || 'unknown', changeType: 'updated' }) - }) - } - if (filteredChanges.removed) { - Object.entries(filteredChanges.removed).forEach(([id, record]: [string, any]) => { - const recordObj = Array.isArray(record) ? record[1] : record - passingRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'removed' }) - }) - } - - console.log(`๐Ÿ” TLDraw store changes detected (source: ${source}):`, { - added: Object.keys(filteredChanges.added || {}).length, - updated: Object.keys(filteredChanges.updated || {}).length, - removed: Object.keys(filteredChanges.removed || {}).length, - source: source, - passingRecords: passingRecords // Show what's actually passing through - }) - - // DEBUG: Check for richText/text changes in updated records - if (filteredChanges.updated) { - Object.values(filteredChanges.updated).forEach((recordTuple: any) => { - const record = Array.isArray(recordTuple) && recordTuple.length === 2 ? recordTuple[1] : recordTuple - if ((record as any)?.typeName === 'shape') { - const rec = record as any - if (rec.type === 'geo' && rec.props?.richText) { - console.log(`๐Ÿ” Geo shape ${rec.id} richText change detected:`, { - hasRichText: !!rec.props.richText, - richTextType: typeof rec.props.richText, - source: source - }) - } - if (rec.type === 'note' && rec.props?.richText) { - console.log(`๐Ÿ” Note shape ${rec.id} richText change detected:`, { - hasRichText: !!rec.props.richText, - richTextType: typeof rec.props.richText, - richTextContentLength: Array.isArray(rec.props.richText?.content) - ? rec.props.richText.content.length - : 'not array', - source: source - }) - } - if (rec.type === 'arrow' && rec.props?.text !== undefined) { - console.log(`๐Ÿ” Arrow shape ${rec.id} text change detected:`, { - hasText: !!rec.props.text, - textValue: rec.props.text, - source: source - }) - } - if (rec.type === 'text' && rec.props?.richText) { - console.log(`๐Ÿ” Text shape ${rec.id} richText change detected:`, { - hasRichText: !!rec.props.richText, - richTextType: typeof rec.props.richText, - source: source - }) - } - } - }) - } - - // DEBUG: Log added shapes to track what's being created - if (filteredChanges.added) { - Object.values(filteredChanges.added).forEach((record: any) => { - const rec = Array.isArray(record) ? record[1] : record - if (rec?.typeName === 'shape') { - console.log(`๐Ÿ” Shape added: ${rec.type} (${rec.id})`, { - type: rec.type, - id: rec.id, - hasRichText: !!rec.props?.richText, - hasText: !!rec.props?.text, - source: source - }) - } - }) - } - } - + // Skip if no meaningful changes after filtering ephemeral records if (filteredTotalChanges === 0) { return @@ -906,7 +702,6 @@ export function useAutomergeStoreV2({ // CRITICAL: Skip broadcasting changes that came from remote sources to prevent feedback loops // Only broadcast changes that originated from user interactions (source === 'user') if (source === 'remote') { - console.log('๐Ÿ”„ Skipping broadcast for remote change to prevent feedback loop') return } @@ -1044,38 +839,6 @@ export function useAutomergeStoreV2({ // Check if this is a position-only update that should be throttled const isPositionOnly = isPositionOnlyUpdate(finalFilteredChanges) - // Log what type of change this is for debugging - const changeType = Object.keys(finalFilteredChanges.added || {}).length > 0 ? 'added' : - Object.keys(finalFilteredChanges.removed || {}).length > 0 ? 'removed' : - isPositionOnly ? 'position-only' : 'property-change' - - // DEBUG: Log dimension changes for shapes - if (finalFilteredChanges.updated) { - Object.entries(finalFilteredChanges.updated).forEach(([id, recordTuple]: [string, any]) => { - const isTuple = Array.isArray(recordTuple) && recordTuple.length === 2 - const oldRecord = isTuple ? recordTuple[0] : null - const newRecord = isTuple ? recordTuple[1] : recordTuple - if (newRecord?.typeName === 'shape') { - const oldProps = oldRecord?.props || {} - const newProps = newRecord?.props || {} - if (oldProps.w !== newProps.w || oldProps.h !== newProps.h) { - console.log(`๐Ÿ” Shape dimension change detected for ${newRecord.type} ${id}:`, { - oldDims: { w: oldProps.w, h: oldProps.h }, - newDims: { w: newProps.w, h: newProps.h }, - source - }) - } - } - }) - } - - console.log(`๐Ÿ” Change detected: ${changeType}, will ${isPositionOnly ? 'throttle' : 'broadcast immediately'}`, { - added: Object.keys(finalFilteredChanges.added || {}).length, - updated: Object.keys(finalFilteredChanges.updated || {}).length, - removed: Object.keys(finalFilteredChanges.removed || {}).length, - source - }) - if (isPositionOnly && positionUpdateQueue === null) { // Start a new queue for position updates positionUpdateQueue = finalFilteredChanges @@ -1258,12 +1021,7 @@ export function useAutomergeStoreV2({ broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds) } - // Only log if there are many changes or if debugging is needed - if (filteredTotalChanges > 3) { - console.log(`โœ… Applied ${filteredTotalChanges} TLDraw changes to Automerge document`) - } else if (filteredTotalChanges > 0) { - console.log(`โœ… Applied ${filteredTotalChanges} TLDraw change(s) to Automerge document`) - } + // Logging disabled for performance during continuous drawing // Check if the document actually changed const docAfter = handle.doc() diff --git a/src/automerge/useAutomergeSyncRepo.ts b/src/automerge/useAutomergeSyncRepo.ts index edaa1d5..d4c331b 100644 --- a/src/automerge/useAutomergeSyncRepo.ts +++ b/src/automerge/useAutomergeSyncRepo.ts @@ -203,6 +203,31 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus console.log(`โœ… Applied ${changedRecordCount} records and ${deletedRecordIds.length} deletions to Automerge document`) }, []) + // Presence update batching to prevent "Maximum update depth exceeded" errors + // We batch presence updates and apply them in a single mergeRemoteChanges call + const pendingPresenceUpdates = useRef>(new Map()) + const presenceUpdateTimer = useRef(null) + const PRESENCE_BATCH_INTERVAL_MS = 16 // ~60fps, batch updates every frame + + // Flush pending presence updates to the store + const flushPresenceUpdates = useCallback(() => { + const currentStore = storeRef.current + if (!currentStore || pendingPresenceUpdates.current.size === 0) { + return + } + + const updates = Array.from(pendingPresenceUpdates.current.values()) + pendingPresenceUpdates.current.clear() + + try { + currentStore.mergeRemoteChanges(() => { + currentStore.put(updates) + }) + } catch (error) { + console.error('โŒ Error flushing presence updates:', error) + } + }, []) + // Presence update callback - applies presence from other clients // Presence is ephemeral (cursors, selections) and goes directly to the store // Note: This callback is passed to the adapter but accesses storeRef which updates later @@ -256,16 +281,21 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus lastActivityTimestamp: Date.now() }) - // Apply the instance_presence record using mergeRemoteChanges for atomic updates - currentStore.mergeRemoteChanges(() => { - currentStore.put([instancePresence]) - }) + // Queue the presence update for batched application + pendingPresenceUpdates.current.set(presenceId, instancePresence) + + // Schedule a flush if not already scheduled + if (!presenceUpdateTimer.current) { + presenceUpdateTimer.current = setTimeout(() => { + presenceUpdateTimer.current = null + flushPresenceUpdates() + }, PRESENCE_BATCH_INTERVAL_MS) + } - // Presence applied for remote user } catch (error) { console.error('โŒ Error applying presence:', error) } - }, []) + }, [flushPresenceUpdates]) const { repo, adapter, storageAdapter } = useMemo(() => { const adapter = new CloudflareNetworkAdapter( @@ -541,6 +571,11 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus return () => { mounted = false + // Clear any pending presence update timer + if (presenceUpdateTimer.current) { + clearTimeout(presenceUpdateTimer.current) + presenceUpdateTimer.current = null + } // Disconnect adapter on unmount to clean up WebSocket connection if (adapter) { adapter.disconnect?.() diff --git a/src/components/MiroImportDialog.tsx b/src/components/MiroImportDialog.tsx new file mode 100644 index 0000000..8dcc8cc --- /dev/null +++ b/src/components/MiroImportDialog.tsx @@ -0,0 +1,477 @@ +/** + * Miro Import Dialog + * + * A dialog component for importing Miro boards into the tldraw canvas. + * Supports both JSON file upload and pasting JSON directly. + */ + +import { useState, useCallback, useRef } from 'react' +import { useEditor } from 'tldraw' +import { importMiroJson, isValidMiroUrl, MiroImportResult } from '@/lib/miroImport' + +interface MiroImportDialogProps { + isOpen: boolean + onClose: () => void +} + +type ImportMethod = 'json-file' | 'json-paste' + +export function MiroImportDialog({ isOpen, onClose }: MiroImportDialogProps) { + const editor = useEditor() + const fileInputRef = useRef(null) + + const [importMethod, setImportMethod] = useState('json-file') + const [jsonText, setJsonText] = useState('') + const [isImporting, setIsImporting] = useState(false) + const [progress, setProgress] = useState({ stage: '', percent: 0 }) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + + const resetState = useCallback(() => { + setJsonText('') + setIsImporting(false) + setProgress({ stage: '', percent: 0 }) + setResult(null) + setError(null) + }, []) + + const handleClose = useCallback(() => { + resetState() + onClose() + }, [onClose, resetState]) + + const handleImport = useCallback(async (jsonString: string) => { + setIsImporting(true) + setError(null) + setResult(null) + + try { + // Get current viewport center for import offset + const viewportBounds = editor.getViewportPageBounds() + const offset = { + x: viewportBounds.x + viewportBounds.w / 2, + y: viewportBounds.y + viewportBounds.h / 2, + } + + const importResult = await importMiroJson( + jsonString, + { + migrateAssets: true, + offset, + }, + { + onProgress: (stage, percent) => { + setProgress({ stage, percent: Math.round(percent * 100) }) + }, + } + ) + + setResult(importResult) + + if (importResult.success && importResult.shapes.length > 0) { + // Create assets first + if (importResult.assets.length > 0) { + for (const asset of importResult.assets) { + try { + editor.createAssets([asset]) + } catch (e) { + console.warn('Failed to create asset:', e) + } + } + } + + // Create shapes + editor.createShapes(importResult.shapes) + + // Select and zoom to imported shapes + const shapeIds = importResult.shapes.map((s: any) => s.id) + editor.setSelectedShapes(shapeIds) + editor.zoomToSelection() + } + } catch (e) { + console.error('Import error:', e) + setError(e instanceof Error ? e.message : 'Failed to import Miro board') + } finally { + setIsImporting(false) + } + }, [editor]) + + const handleFileSelect = useCallback(async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + if (!file) return + + try { + const text = await file.text() + await handleImport(text) + } catch (e) { + setError('Failed to read file') + } + + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + }, [handleImport]) + + const handlePasteImport = useCallback(() => { + if (!jsonText.trim()) { + setError('Please paste Miro JSON data') + return + } + handleImport(jsonText) + }, [jsonText, handleImport]) + + if (!isOpen) return null + + return ( +
+
e.stopPropagation()}> +
+

Import from Miro

+ +
+ +
+ {/* Import Method Tabs */} +
+ + +
+ + {/* JSON File Upload */} + {importMethod === 'json-file' && ( +
+

+ Upload a JSON file exported from Miro using the{' '} + + miro-export + {' '} + CLI tool: +

+
+                npx miro-export -b YOUR_BOARD_ID -e json -o board.json
+              
+ + +
+ )} + + {/* JSON Paste */} + {importMethod === 'json-paste' && ( +
+

+ Paste your Miro board JSON data below: +

+