canvas-website/src/ui/CustomMainMenu.tsx

755 lines
44 KiB
TypeScript

import {
DefaultMainMenu,
TldrawUiMenuItem,
Editor,
TLContent,
DefaultMainMenuContent,
useEditor,
} from "tldraw";
export function CustomMainMenu() {
const editor = useEditor()
const importJSON = (editor: Editor) => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = (event) => {
const file = (event.target as HTMLInputElement).files?.[0];
const reader = new FileReader();
reader.onload = (event) => {
if (typeof event.target?.result !== 'string') {
return
}
try {
const jsonData = JSON.parse(event.target.result)
console.log('Parsed JSON data:', jsonData)
// Helper function to validate and normalize shape types
const validateAndNormalizeShapeType = (shape: any): string => {
if (!shape || !shape.type) return 'text'
const validCustomShapes = ['ObsNote', 'VideoChat', 'Transcription', 'Prompt', 'ChatBox', 'Embed', 'Markdown', 'MycrozineTemplate', 'Slide', 'Holon', 'ObsidianBrowser', 'HolonBrowser', 'FathomMeetingsBrowser', 'LocationShare']
const validDefaultShapes = ['arrow', 'bookmark', 'draw', 'embed', 'frame', 'geo', 'group', 'highlight', 'image', 'line', 'note', 'text', 'video']
const allValidShapes = [...validCustomShapes, ...validDefaultShapes]
// Check if original type is valid (preserves lowercase default shapes like 'embed', 'geo', etc.)
if (allValidShapes.includes(shape.type)) {
return shape.type
}
// Normalize case: chatBox -> ChatBox, videoChat -> VideoChat, etc.
const normalizedType = shape.type.charAt(0).toUpperCase() + shape.type.slice(1)
// Check if normalized version is valid (for custom shapes like ChatBox, VideoChat, etc.)
if (allValidShapes.includes(normalizedType)) {
return normalizedType
}
// If not valid, convert to text shape
console.warn(`⚠️ Unknown or unsupported shape type "${shape.type}", converting to text shape for shape:`, shape.id)
return 'text'
}
// Helper function to validate and fix invalid numeric values (NaN, Infinity)
const validateNumericValue = (value: any, defaultValue: number, name: string): number => {
if (typeof value !== 'number' || isNaN(value) || !isFinite(value)) {
console.warn(`⚠️ Invalid ${name} value (${value}), using default: ${defaultValue}`)
return defaultValue
}
return value
}
// Helper function to validate shape geometry data
const validateShapeGeometry = (shape: any): boolean => {
if (!shape || !shape.id) return false
// Validate basic numeric properties
shape.x = validateNumericValue(shape.x, 0, 'x')
shape.y = validateNumericValue(shape.y, 0, 'y')
shape.rotation = validateNumericValue(shape.rotation, 0, 'rotation')
shape.opacity = validateNumericValue(shape.opacity, 1, 'opacity')
// Validate shape-specific geometry based on type
if (shape.type === 'line' && shape.props?.points) {
// Validate line points
if (Array.isArray(shape.props.points)) {
shape.props.points = shape.props.points.filter((point: any) => {
if (!point || typeof point !== 'object') return false
const x = validateNumericValue(point.x, 0, 'point.x')
const y = validateNumericValue(point.y, 0, 'point.y')
return true
}).map((point: any) => ({
x: validateNumericValue(point.x, 0, 'point.x'),
y: validateNumericValue(point.y, 0, 'point.y'),
z: point.z !== undefined ? validateNumericValue(point.z, 0.5, 'point.z') : 0.5
}))
// Line must have at least 2 points
if (shape.props.points.length < 2) {
console.warn(`⚠️ Line shape has insufficient points (${shape.props.points.length}), skipping shape:`, shape.id)
return false
}
}
}
if (shape.type === 'draw' && shape.props?.segments) {
// Validate draw segments
if (Array.isArray(shape.props.segments)) {
shape.props.segments = shape.props.segments.filter((segment: any) => {
if (!segment || typeof segment !== 'object') return false
if (segment.points && Array.isArray(segment.points)) {
segment.points = segment.points.filter((point: any) => {
if (!point || typeof point !== 'object') return false
const x = validateNumericValue(point.x, 0, 'segment.point.x')
const y = validateNumericValue(point.y, 0, 'segment.point.y')
return true
}).map((point: any) => ({
x: validateNumericValue(point.x, 0, 'segment.point.x'),
y: validateNumericValue(point.y, 0, 'segment.point.y')
}))
return segment.points.length > 0
}
return false
})
// Draw must have at least 1 segment with points
if (shape.props.segments.length === 0 ||
!shape.props.segments.some((s: any) => s.points && s.points.length > 0)) {
console.warn(`⚠️ Draw shape has no valid segments, skipping shape:`, shape.id)
return false
}
}
}
if (shape.type === 'arrow' && shape.props?.points) {
// Validate arrow points
if (Array.isArray(shape.props.points)) {
shape.props.points = shape.props.points.filter((point: any) => {
if (!point || typeof point !== 'object') return false
return true
}).map((point: any) => ({
x: validateNumericValue(point.x, 0, 'arrow.point.x'),
y: validateNumericValue(point.y, 0, 'arrow.point.y'),
z: point.z !== undefined ? validateNumericValue(point.z, 0.5, 'arrow.point.z') : 0.5
}))
// Arrow must have at least 2 points
if (shape.props.points.length < 2) {
console.warn(`⚠️ Arrow shape has insufficient points (${shape.props.points.length}), skipping shape:`, shape.id)
return false
}
}
}
// Validate props numeric values
if (shape.props) {
if ('w' in shape.props) {
shape.props.w = validateNumericValue(shape.props.w, 100, 'props.w')
}
if ('h' in shape.props) {
shape.props.h = validateNumericValue(shape.props.h, 100, 'props.h')
}
if ('scale' in shape.props) {
shape.props.scale = validateNumericValue(shape.props.scale, 1, 'props.scale')
}
}
return true
}
// Handle different JSON formats
let contentToImport: TLContent
// Function to fix incomplete shape data for proper rendering
const fixIncompleteShape = (shape: any, pageId: string): any => {
const fixedShape = { ...shape }
// CRITICAL: Validate geometry first (fixes NaN/Infinity values)
if (!validateShapeGeometry(fixedShape)) {
console.warn(`⚠️ Shape failed geometry validation, skipping:`, fixedShape.id)
return null // Return null to indicate shape should be skipped
}
// CRITICAL: Validate and normalize shape type
const normalizedType = validateAndNormalizeShapeType(fixedShape)
if (normalizedType !== fixedShape.type) {
console.log(`🔧 Normalizing shape type "${fixedShape.type}" to "${normalizedType}" for shape:`, fixedShape.id)
fixedShape.type = normalizedType
// If converted to text, set up proper text shape props
if (normalizedType === 'text') {
if (!fixedShape.props) fixedShape.props = {}
fixedShape.props = {
...fixedShape.props,
w: fixedShape.props.w || 300,
color: fixedShape.props.color || 'black',
size: fixedShape.props.size || 'm',
font: fixedShape.props.font || 'draw',
textAlign: fixedShape.props.textAlign || 'start',
autoSize: fixedShape.props.autoSize !== undefined ? fixedShape.props.autoSize : false,
scale: fixedShape.props.scale || 1,
richText: fixedShape.props.richText || { content: [], type: 'doc' }
}
// Remove invalid properties for text shapes
const invalidTextProps = ['h', 'geo', 'insets', 'scribbles', 'isMinimized', 'roomUrl', 'text', 'align', 'verticalAlign', 'growY', 'url']
invalidTextProps.forEach(prop => {
if (prop in fixedShape.props) {
delete (fixedShape.props as any)[prop]
}
})
}
}
// CRITICAL: Preserve existing coordinates - only set defaults if truly missing
// x/y can be 0, which is a valid coordinate, so check for undefined/null/NaN
// Note: validateShapeGeometry already ensures x/y are valid numbers, but we need to
// handle the case where they might be NaN or Infinity after validation
if (fixedShape.x === undefined || fixedShape.x === null || isNaN(fixedShape.x) || !isFinite(fixedShape.x)) {
fixedShape.x = Math.random() * 400 + 50 // Random position only if missing or invalid
}
if (fixedShape.y === undefined || fixedShape.y === null || isNaN(fixedShape.y) || !isFinite(fixedShape.y)) {
fixedShape.y = Math.random() * 300 + 50 // Random position only if missing or invalid
}
// Preserve rotation, isLocked, opacity - only set defaults if missing
if (fixedShape.rotation === undefined || fixedShape.rotation === null) {
fixedShape.rotation = 0
}
if (fixedShape.isLocked === undefined || fixedShape.isLocked === null) {
fixedShape.isLocked = false
}
if (fixedShape.opacity === undefined || fixedShape.opacity === null) {
fixedShape.opacity = 1
}
if (!fixedShape.meta || typeof fixedShape.meta !== 'object') {
fixedShape.meta = {}
}
// CRITICAL: Preserve parentId relationships (frames, groups, etc.)
// Only set to pageId if parentId is truly missing
// This preserves frame relationships and prevents content collapse
if (!fixedShape.parentId || fixedShape.parentId === '') {
fixedShape.parentId = pageId
}
// CRITICAL: For geo shapes, w/h/geo MUST be in props, NOT at top level
if (fixedShape.type === 'geo') {
// Store w/h/geo values if they exist at top level
const wValue = fixedShape.w !== undefined ? fixedShape.w : 100
const hValue = fixedShape.h !== undefined ? fixedShape.h : 100
const geoValue = fixedShape.geo !== undefined ? fixedShape.geo : 'rectangle'
// Remove w/h/geo from top level (TLDraw validation requires they be in props only)
delete fixedShape.w
delete fixedShape.h
delete fixedShape.geo
// Ensure props exists and has the correct values
if (!fixedShape.props) fixedShape.props = {}
if (fixedShape.props.w === undefined) fixedShape.props.w = wValue
if (fixedShape.props.h === undefined) fixedShape.props.h = hValue
if (fixedShape.props.geo === undefined) fixedShape.props.geo = geoValue
// Set default props if missing
if (!fixedShape.props.color) fixedShape.props.color = 'black'
if (!fixedShape.props.fill) fixedShape.props.fill = 'none'
if (!fixedShape.props.dash) fixedShape.props.dash = 'draw'
if (!fixedShape.props.size) fixedShape.props.size = 'm'
if (!fixedShape.props.font) fixedShape.props.font = 'draw'
} else if (fixedShape.type === 'VideoChat') {
// VideoChat shapes also need w/h in props, not top level
const wValue = fixedShape.w !== undefined ? fixedShape.w : 200
const hValue = fixedShape.h !== undefined ? fixedShape.h : 150
delete fixedShape.w
delete fixedShape.h
if (!fixedShape.props) fixedShape.props = {}
if (fixedShape.props.w === undefined) fixedShape.props.w = wValue
if (fixedShape.props.h === undefined) fixedShape.props.h = hValue
if (!fixedShape.props.color) fixedShape.props.color = 'black'
if (!fixedShape.props.fill) fixedShape.props.fill = 'none'
if (!fixedShape.props.dash) fixedShape.props.dash = 'draw'
if (!fixedShape.props.size) fixedShape.props.size = 'm'
if (!fixedShape.props.font) fixedShape.props.font = 'draw'
}
return fixedShape
}
// Check if it's a worker export format (has documents array)
if (jsonData.documents && Array.isArray(jsonData.documents)) {
console.log('Detected worker export format with', jsonData.documents.length, 'documents')
// Convert worker export format to TLContent format
const pageId = jsonData.documents.find((doc: any) => doc.state?.typeName === 'page')?.state?.id || 'page:default'
const shapes = jsonData.documents
.filter((doc: any) => doc.state?.typeName === 'shape')
.map((doc: any) => fixIncompleteShape(doc.state, pageId))
const bindings = jsonData.documents
.filter((doc: any) => doc.state?.typeName === 'binding')
.map((doc: any) => doc.state)
const assets = jsonData.documents
.filter((doc: any) => doc.state?.typeName === 'asset')
.map((doc: any) => doc.state)
console.log('Extracted:', { shapes: shapes.length, bindings: bindings.length, assets: assets.length })
// CRITICAL: rootShapeIds should only include shapes that are direct children of the page
// Shapes inside frames should NOT be in rootShapeIds (they're children of frames)
const rootShapeIds = shapes
.filter((shape: any) => !shape.parentId || shape.parentId === pageId)
.map((shape: any) => shape.id)
.filter(Boolean)
contentToImport = {
rootShapeIds: rootShapeIds,
schema: jsonData.schema || { schemaVersion: 1, storeVersion: 4, recordVersions: {} },
shapes: shapes,
bindings: bindings,
assets: assets,
}
} else if (jsonData.store && jsonData.schema) {
console.log('Detected Automerge format')
// Convert Automerge format to TLContent format
const store = jsonData.store
const shapes: any[] = []
const bindings: any[] = []
const assets: any[] = []
// Find the page ID first
const pageRecord = Object.values(store).find((record: any) =>
record && typeof record === 'object' && record.typeName === 'page'
) as any
const pageId = pageRecord?.id || 'page:default'
// Extract shapes, bindings, and assets from the store
Object.values(store).forEach((record: any) => {
if (record && typeof record === 'object') {
if (record.typeName === 'shape') {
const fixedShape = fixIncompleteShape(record, pageId)
if (fixedShape !== null) {
shapes.push(fixedShape)
}
} else if (record.typeName === 'binding') {
bindings.push(record)
} else if (record.typeName === 'asset') {
assets.push(record)
}
}
})
console.log('Extracted from Automerge format:', { shapes: shapes.length, bindings: bindings.length, assets: assets.length })
// CRITICAL: rootShapeIds should only include shapes that are direct children of the page
// Shapes inside frames should NOT be in rootShapeIds (they're children of frames)
const rootShapeIds = shapes
.filter((shape: any) => !shape.parentId || shape.parentId === pageId)
.map((shape: any) => shape.id)
.filter(Boolean)
contentToImport = {
rootShapeIds: rootShapeIds,
schema: jsonData.schema,
shapes: shapes,
bindings: bindings,
assets: assets,
}
} else if (jsonData.shapes && Array.isArray(jsonData.shapes)) {
console.log('Detected standard TLContent format with', jsonData.shapes.length, 'shapes')
// Find page ID from imported data or use current page
const importedPageId = jsonData.pages?.[0]?.id || 'page:default'
const currentPageId = editor.getCurrentPageId()
const pageId = importedPageId // Use imported page ID, putContentOntoCurrentPage will handle mapping
// Fix shapes to ensure they have required properties
// Filter out null shapes (shapes that failed validation)
const fixedShapes = jsonData.shapes
.map((shape: any) => fixIncompleteShape(shape, pageId))
.filter((shape: any) => shape !== null)
// CRITICAL: rootShapeIds should only include shapes that are direct children of the page
// Always recompute from fixed shapes to ensure correctness (shapes within frames should be excluded)
const rootShapeIds = fixedShapes
.filter((shape: any) => !shape.parentId || shape.parentId === pageId)
.map((shape: any) => shape.id)
.filter(Boolean)
contentToImport = {
rootShapeIds: rootShapeIds,
schema: jsonData.schema || { schemaVersion: 1, storeVersion: 4, recordVersions: {} },
shapes: fixedShapes,
bindings: jsonData.bindings || [],
assets: jsonData.assets || [],
}
} else {
console.log('Detected unknown format, attempting fallback')
// Try to extract shapes from any other format
const pageId = 'page:default'
// Filter out null shapes (shapes that failed validation)
const fixedShapes = (jsonData.shapes || [])
.map((shape: any) => fixIncompleteShape(shape, pageId))
.filter((shape: any) => shape !== null)
// CRITICAL: rootShapeIds should only include shapes that are direct children of the page
// Always recompute from fixed shapes to ensure correctness (shapes within frames should be excluded)
const rootShapeIds = fixedShapes
.filter((shape: any) => !shape.parentId || shape.parentId === pageId)
.map((shape: any) => shape.id)
.filter(Boolean)
contentToImport = {
rootShapeIds: rootShapeIds,
schema: jsonData.schema || { schemaVersion: 1, storeVersion: 4, recordVersions: {} },
shapes: fixedShapes,
bindings: jsonData.bindings || [],
assets: jsonData.assets || [],
}
}
// Validate all required properties
console.log('Final contentToImport:', contentToImport)
if (!contentToImport.shapes || !Array.isArray(contentToImport.shapes)) {
console.error('Invalid JSON format: missing or invalid shapes array')
alert('Invalid JSON format. Please ensure the file contains valid TLDraw content.')
return
}
if (!contentToImport.rootShapeIds || !Array.isArray(contentToImport.rootShapeIds)) {
console.error('Invalid JSON format: missing or invalid rootShapeIds array')
alert('Invalid JSON format. Please ensure the file contains valid TLDraw content.')
return
}
if (!contentToImport.schema) {
console.error('Invalid JSON format: missing schema')
alert('Invalid JSON format. Please ensure the file contains valid TLDraw content.')
return
}
if (!contentToImport.bindings || !Array.isArray(contentToImport.bindings)) {
contentToImport.bindings = []
}
if (!contentToImport.assets || !Array.isArray(contentToImport.assets)) {
contentToImport.assets = []
}
// CRITICAL: Final sanitization - validate geometry, validate shape types, ensure all geo shapes have w/h/geo in props, not top level
// Also ensure text shapes don't have props.text (should use props.richText instead)
if (contentToImport.shapes) {
contentToImport.shapes = contentToImport.shapes
.map((shape: any) => {
if (!shape || !shape.type) return null
// CRITICAL: Validate geometry first (fixes NaN/Infinity values)
if (!validateShapeGeometry(shape)) {
console.warn(`⚠️ Shape failed geometry validation in final sanitization, skipping:`, shape.id)
return null
}
return shape
})
.filter((shape: any) => shape !== null)
.map((shape: any) => {
if (!shape || !shape.type) return shape
// Validate and normalize shape type
const normalizedType = validateAndNormalizeShapeType(shape)
if (normalizedType !== shape.type) {
console.log(`🔧 Normalizing shape type "${shape.type}" to "${normalizedType}" for shape:`, shape.id)
shape.type = normalizedType
// If converted to text, set up proper text shape props
if (normalizedType === 'text') {
if (!shape.props) shape.props = {}
shape.props = {
...shape.props,
w: shape.props.w || 300,
color: shape.props.color || 'black',
size: shape.props.size || 'm',
font: shape.props.font || 'draw',
textAlign: shape.props.textAlign || 'start',
autoSize: shape.props.autoSize !== undefined ? shape.props.autoSize : false,
scale: shape.props.scale || 1,
richText: shape.props.richText || { content: [], type: 'doc' }
}
// Remove invalid properties for text shapes
const invalidTextProps = ['h', 'geo', 'insets', 'scribbles', 'isMinimized', 'roomUrl', 'text', 'align', 'verticalAlign', 'growY', 'url']
invalidTextProps.forEach(prop => {
if (prop in shape.props) {
delete (shape.props as any)[prop]
}
})
}
}
if (shape.type === 'geo') {
const wValue = 'w' in shape ? shape.w : undefined
const hValue = 'h' in shape ? shape.h : undefined
const geoValue = 'geo' in shape ? shape.geo : undefined
// Remove from top level
delete shape.w
delete shape.h
delete shape.geo
// Ensure props exists and move values there
if (!shape.props) shape.props = {}
if (wValue !== undefined && !shape.props.w) shape.props.w = wValue
if (hValue !== undefined && !shape.props.h) shape.props.h = hValue
if (geoValue !== undefined && !shape.props.geo) shape.props.geo = geoValue
}
// CRITICAL: Remove invalid 'text' property from text shapes (TLDraw schema doesn't allow props.text)
// Text shapes should only use props.richText, not props.text
if (shape && shape.type === 'text' && shape.props && 'text' in shape.props) {
delete shape.props.text
}
return shape
})
}
console.log('About to call putContentOntoCurrentPage with:', contentToImport)
try {
editor.putContentOntoCurrentPage(contentToImport, { select: true })
} catch (putContentError) {
console.error('putContentOntoCurrentPage failed, trying alternative approach:', putContentError)
// Fallback: Create shapes individually
if (contentToImport.shapes && contentToImport.shapes.length > 0) {
console.log('Attempting to create shapes individually...')
// Clear current page first
const currentShapes = editor.getCurrentPageShapes()
if (currentShapes.length > 0) {
editor.deleteShapes(currentShapes.map(shape => shape.id))
}
// Create shapes one by one
contentToImport.shapes.forEach((shape: any) => {
try {
if (shape && shape.id && shape.type) {
// CRITICAL: Validate geometry first (fixes NaN/Infinity values)
if (!validateShapeGeometry(shape)) {
console.warn(`⚠️ Shape failed geometry validation in fallback, skipping:`, shape.id)
return
}
// CRITICAL: Validate and normalize shape type
const normalizedType = validateAndNormalizeShapeType(shape)
if (normalizedType !== shape.type) {
console.log(`🔧 Normalizing shape type "${shape.type}" to "${normalizedType}" for shape:`, shape.id)
shape.type = normalizedType
// If converted to text, set up proper text shape props
if (normalizedType === 'text') {
if (!shape.props) shape.props = {}
shape.props = {
...shape.props,
w: shape.props.w || 300,
color: shape.props.color || 'black',
size: shape.props.size || 'm',
font: shape.props.font || 'draw',
textAlign: shape.props.textAlign || 'start',
autoSize: shape.props.autoSize !== undefined ? shape.props.autoSize : false,
scale: shape.props.scale || 1,
richText: shape.props.richText || { content: [], type: 'doc' }
}
// Remove invalid properties for text shapes
const invalidTextProps = ['h', 'geo', 'insets', 'scribbles', 'isMinimized', 'roomUrl', 'text', 'align', 'verticalAlign', 'growY', 'url']
invalidTextProps.forEach(prop => {
if (prop in shape.props) {
delete (shape.props as any)[prop]
}
})
}
}
// Ensure isLocked property is set
if (shape.isLocked === undefined) {
shape.isLocked = false
}
// CRITICAL: Final sanitization - ensure geo shapes don't have w/h/geo at top level
if (shape.type === 'geo') {
const wValue = 'w' in shape ? shape.w : undefined
const hValue = 'h' in shape ? shape.h : undefined
const geoValue = 'geo' in shape ? shape.geo : undefined
// Remove from top level
delete shape.w
delete shape.h
delete shape.geo
// Ensure props exists and move values there
if (!shape.props) shape.props = {}
if (wValue !== undefined && !shape.props.w) shape.props.w = wValue
if (hValue !== undefined && !shape.props.h) shape.props.h = hValue
if (geoValue !== undefined && !shape.props.geo) shape.props.geo = geoValue
}
// CRITICAL: Remove invalid 'text' property from text shapes (TLDraw schema doesn't allow props.text)
// Text shapes should only use props.richText, not props.text
if (shape.type === 'text' && shape.props && 'text' in shape.props) {
delete shape.props.text
}
editor.createShape(shape)
}
} catch (shapeError) {
console.error('Failed to create shape:', shape, shapeError)
}
})
// Create bindings if any
if (contentToImport.bindings && contentToImport.bindings.length > 0) {
contentToImport.bindings.forEach((binding: any) => {
try {
if (binding && binding.id) {
editor.createBinding(binding)
}
} catch (bindingError) {
console.error('Failed to create binding:', binding, bindingError)
}
})
}
console.log('Individual shape creation completed')
} else {
alert('No valid shapes found in the JSON file.')
}
}
} catch (error) {
console.error('Error parsing JSON:', error)
alert('Error parsing JSON file. Please ensure the file is valid JSON.')
}
};
if (file) {
reader.readAsText(file);
}
};
input.click();
};
const exportJSON = (editor: Editor) => {
try {
// Get all shapes from the current page
const shapes = editor.getCurrentPageShapes()
if (shapes.length === 0) {
alert('No shapes to export')
return
}
// Get the current page ID
const currentPageId = editor.getCurrentPageId()
// Get root shape IDs (shapes without a parent or with page as parent)
const rootShapeIds = shapes
.filter(shape => !shape.parentId || shape.parentId === currentPageId)
.map(shape => shape.id)
// Get all bindings from the store
const store = editor.store
const bindings = store.allRecords()
.filter(record => record.typeName === 'binding')
.map(record => record as any)
// Get all assets from the store
const assets = store.allRecords()
.filter(record => record.typeName === 'asset')
.map(record => record as any)
// Get schema from the store
const schema = editor.store.schema.serialize()
// Construct the content object matching the import format
const content: TLContent = {
rootShapeIds: rootShapeIds,
schema: schema,
shapes: shapes.map(shape => shape as any),
bindings: bindings,
assets: assets,
}
// Convert to JSON string
const jsonString = JSON.stringify(content, null, 2)
// Create a blob and download it
const blob = new Blob([jsonString], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `canvas-export-${Date.now()}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (error) {
console.error('Error exporting JSON:', error)
alert('Failed to export JSON. Please try again.')
}
};
const fitToContent = (editor: Editor) => {
// Get all shapes on the current page
const shapes = editor.getCurrentPageShapes()
if (shapes.length === 0) {
console.log("No shapes to fit to")
return
}
// Calculate bounds
const bounds = {
minX: Math.min(...shapes.map(s => s.x)),
maxX: Math.max(...shapes.map(s => s.x)),
minY: Math.min(...shapes.map(s => s.y)),
maxY: Math.max(...shapes.map(s => s.y))
}
const centerX = (bounds.minX + bounds.maxX) / 2
const centerY = (bounds.minY + bounds.maxY) / 2
const width = bounds.maxX - bounds.minX
const height = bounds.maxY - bounds.minY
const maxDimension = Math.max(width, height)
const zoom = Math.min(1, 800 / maxDimension) // Fit in 800px viewport
console.log("Fitting to content:", { bounds, centerX, centerY, zoom })
// Set camera to show all shapes
editor.setCamera({ x: centerX, y: centerY, z: zoom })
};
return (
<DefaultMainMenu>
<DefaultMainMenuContent />
<TldrawUiMenuItem
id="export"
label="Export JSON"
icon="external-link"
readonlyOk
onSelect={() => exportJSON(editor)}
/>
<TldrawUiMenuItem
id="import"
label="Import JSON"
icon="external-link"
readonlyOk
onSelect={() => importJSON(editor)}
/>
<TldrawUiMenuItem
id="fit-to-content"
label="Fit to Content"
icon="external-link"
readonlyOk
onSelect={() => fitToContent(editor)}
/>
</DefaultMainMenu>
)
}