canvas-website/src/automerge/useAutomergeSync.ts

288 lines
12 KiB
TypeScript

import { useMemo, useEffect, useState, useCallback } from "react"
import { TLStoreSnapshot } from "@tldraw/tldraw"
import { CloudflareAdapter } from "./CloudflareAdapter"
import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2"
import { TLStoreWithStatus } from "@tldraw/tldraw"
interface AutomergeSyncConfig {
uri: string
assets?: any
shapeUtils?: any[]
bindingUtils?: any[]
user?: {
id: string
name: string
}
}
export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus & { handle: any | null } {
const { uri, user } = config
// Extract roomId from URI (e.g., "https://worker.com/connect/room123" -> "room123")
const roomId = useMemo(() => {
const match = uri.match(/\/connect\/([^\/]+)$/)
return match ? match[1] : "default-room"
}, [uri])
// Extract worker URL from URI (remove /connect/roomId part)
const workerUrl = useMemo(() => {
return uri.replace(/\/connect\/.*$/, '')
}, [uri])
const [adapter] = useState(() => new CloudflareAdapter(workerUrl, roomId))
const [handle, setHandle] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true)
// Initialize Automerge document handle
useEffect(() => {
let mounted = true
const initializeHandle = async () => {
// Add a small delay to ensure the server is ready
await new Promise(resolve => setTimeout(resolve, 500));
try {
// Try to load existing document from Cloudflare
const existingDoc = await adapter.loadFromCloudflare(roomId)
if (mounted) {
const handle = await adapter.getHandle(roomId)
// If we loaded an existing document, properly initialize it
if (existingDoc) {
console.log("Initializing Automerge document with existing data:", {
hasStore: !!existingDoc.store,
storeKeys: existingDoc.store ? Object.keys(existingDoc.store).length : 0,
sampleKeys: existingDoc.store ? Object.keys(existingDoc.store).slice(0, 5) : []
})
handle.change((doc) => {
// Always load R2 data if it exists and has content
const r2StoreKeys = existingDoc.store ? Object.keys(existingDoc.store).length : 0
console.log("Loading R2 data:", {
r2StoreKeys,
hasR2Data: r2StoreKeys > 0,
sampleStoreKeys: existingDoc.store ? Object.keys(existingDoc.store).slice(0, 5) : []
})
if (r2StoreKeys > 0) {
console.log("Loading R2 data into Automerge document")
if (existingDoc.store) {
// Debug: Log what we're about to load
const storeEntries = Object.entries(existingDoc.store)
const shapeCount = storeEntries.filter(([_, v]: [string, any]) => v?.typeName === 'shape').length
console.log("📊 R2 data to load:", {
totalRecords: storeEntries.length,
shapeCount,
recordTypes: storeEntries.reduce((acc: any, [_, v]: [string, any]) => {
const type = v?.typeName || 'unknown'
acc[type] = (acc[type] || 0) + 1
return acc
}, {}),
sampleRecords: storeEntries.slice(0, 5).map(([k, v]: [string, any]) => ({
key: k,
id: v?.id,
typeName: v?.typeName,
type: v?.type
}))
})
// Initialize store if it doesn't exist
if (!doc.store) {
doc.store = {}
}
// Assign each record individually with deep copy to ensure Automerge properly handles nested objects
// This matches how records are saved in TLStoreToAutomerge.ts
// Cast to any to allow string indexing (Automerge handles the typing internally)
const store = doc.store as any
let assignedCount = 0
for (const [key, record] of Object.entries(existingDoc.store)) {
try {
// Create a deep copy to ensure Automerge properly handles nested objects
// This is critical for preserving nested structures like props, richText, etc.
const recordToSave = JSON.parse(JSON.stringify(record))
store[key] = recordToSave
assignedCount++
} catch (e) {
console.error(`❌ Error deep copying record ${key}:`, e)
// Fallback: assign directly (might not work for nested objects)
store[key] = record
}
}
console.log("Loaded store data into Automerge document:", {
loadedStoreKeys: Object.keys(doc.store).length,
assignedCount,
sampleLoadedKeys: Object.keys(doc.store).slice(0, 5)
})
// Verify what was actually loaded
const loadedValues = Object.values(doc.store)
const loadedShapeCount = loadedValues.filter((v: any) => v?.typeName === 'shape').length
console.log("📊 Verification after loading:", {
totalLoaded: loadedValues.length,
loadedShapeCount,
loadedRecordTypes: loadedValues.reduce((acc: any, v: any) => {
const type = v?.typeName || 'unknown'
acc[type] = (acc[type] || 0) + 1
return acc
}, {})
})
}
if (existingDoc.schema) {
doc.schema = existingDoc.schema
}
} else {
console.log("No R2 data to load")
}
})
} else {
console.log("No existing document found, loading snapshot data")
// Load snapshot data for new rooms
try {
const snapshotResponse = await fetch('/src/snapshot.json')
if (snapshotResponse.ok) {
const snapshotData = await snapshotResponse.json() as TLStoreSnapshot
console.log("Loaded snapshot data:", {
hasStore: !!snapshotData.store,
storeKeys: snapshotData.store ? Object.keys(snapshotData.store).length : 0,
shapeCount: snapshotData.store ? Object.values(snapshotData.store).filter((r: any) => r.typeName === 'shape').length : 0
})
handle.change((doc) => {
if (snapshotData.store) {
// Pre-sanitize snapshot data to remove invalid properties
const sanitizedStore = { ...snapshotData.store }
let sanitizedCount = 0
Object.keys(sanitizedStore).forEach(key => {
const record = (sanitizedStore as any)[key]
if (record && record.typeName === 'shape') {
// Remove invalid properties from embed shapes (both custom Embed and default embed)
if ((record.type === 'Embed' || record.type === 'embed') && record.props) {
const invalidEmbedProps = ['doesResize', 'doesResizeHeight', 'richText']
invalidEmbedProps.forEach(prop => {
if (prop in record.props) {
console.log(`🔧 Pre-sanitizing snapshot: Removing invalid prop '${prop}' from embed shape ${record.id}`)
delete record.props[prop]
sanitizedCount++
}
})
}
// Remove invalid properties from text shapes
if (record.type === 'text' && record.props) {
const invalidTextProps = ['text', 'richText']
invalidTextProps.forEach(prop => {
if (prop in record.props) {
console.log(`🔧 Pre-sanitizing snapshot: Removing invalid prop '${prop}' from text shape ${record.id}`)
delete record.props[prop]
sanitizedCount++
}
})
}
}
})
if (sanitizedCount > 0) {
console.log(`🔧 Pre-sanitized ${sanitizedCount} invalid properties from snapshot data`)
}
doc.store = sanitizedStore
console.log("Loaded snapshot store data into Automerge document:", {
storeKeys: Object.keys(doc.store).length,
shapeCount: Object.values(doc.store).filter((r: any) => r.typeName === 'shape').length,
sampleKeys: Object.keys(doc.store).slice(0, 5)
})
}
if (snapshotData.schema) {
doc.schema = snapshotData.schema
}
})
}
} catch (error) {
console.error('Error loading snapshot data:', error)
}
}
// Wait a bit more to ensure the handle is fully ready with data
await new Promise(resolve => setTimeout(resolve, 500))
setHandle(handle)
setIsLoading(false)
console.log("Automerge handle initialized and loading completed")
}
} catch (error) {
console.error('Error initializing Automerge handle:', error)
if (mounted) {
setIsLoading(false)
}
}
}
initializeHandle()
return () => {
mounted = false
}
}, [adapter, roomId])
// Auto-save to Cloudflare on every change (with debouncing to prevent excessive calls)
useEffect(() => {
if (!handle) return
let saveTimeout: NodeJS.Timeout
const scheduleSave = () => {
// Clear existing timeout
if (saveTimeout) clearTimeout(saveTimeout)
// Schedule save with a short debounce (500ms) to batch rapid changes
saveTimeout = setTimeout(async () => {
try {
await adapter.saveToCloudflare(roomId)
} catch (error) {
console.error('Error in change-triggered save:', error)
}
}, 500)
}
// Listen for changes to the Automerge document
const changeHandler = (_payload: any) => {
scheduleSave()
}
handle.on('change', changeHandler)
return () => {
handle.off('change', changeHandler)
if (saveTimeout) clearTimeout(saveTimeout)
}
}, [handle, adapter, roomId])
// Use the Automerge store (only when handle is ready and not loading)
const store = useAutomergeStoreV2({
handle: !isLoading && handle ? handle : null,
userId: user?.id || 'anonymous',
})
// Set up presence if user is provided (always call hooks, but handle null internally)
useAutomergePresence({
handle,
store,
userMetadata: {
userId: user?.id || 'anonymous',
name: user?.name || 'Anonymous',
color: '#000000', // Default color
},
})
// Return loading state while initializing
if (isLoading || !handle) {
return { ...store, handle: null }
}
return { ...store, handle }
}