Implement offline storage with IndexedDB for canvas documents

- Add @automerge/automerge-repo-storage-indexeddb for local persistence
- Create documentIdMapping utility to track roomId → documentId in IndexedDB
- Update useAutomergeSyncRepo with offline-first loading strategy:
  - Load from IndexedDB first for instant access
  - Sync with server in background when online
  - Track connection status (online/offline/syncing)
- Add OfflineIndicator component to show connection state
- Integrate offline indicator into Board component

Documents are now cached locally and available offline. Automerge CRDT
handles conflict resolution when syncing back online.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-11-26 03:03:37 -08:00
parent e4743c6ff6
commit b502a08c62
6 changed files with 543 additions and 68 deletions

26
package-lock.json generated
View File

@ -13,6 +13,7 @@
"@automerge/automerge": "^3.1.1", "@automerge/automerge": "^3.1.1",
"@automerge/automerge-repo": "^2.2.0", "@automerge/automerge-repo": "^2.2.0",
"@automerge/automerge-repo-react-hooks": "^2.2.0", "@automerge/automerge-repo-react-hooks": "^2.2.0",
"@automerge/automerge-repo-storage-indexeddb": "^2.5.0",
"@chengsokdara/use-whisper": "^0.2.0", "@chengsokdara/use-whisper": "^0.2.0",
"@daily-co/daily-js": "^0.60.0", "@daily-co/daily-js": "^0.60.0",
"@daily-co/daily-react": "^0.20.0", "@daily-co/daily-react": "^0.20.0",
@ -211,6 +212,31 @@
"react-dom": "^18.0.0 || ^19.0.0" "react-dom": "^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/@automerge/automerge-repo-storage-indexeddb": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@automerge/automerge-repo-storage-indexeddb/-/automerge-repo-storage-indexeddb-2.5.0.tgz",
"integrity": "sha512-7MJYJ5S6K7dHlbvs5/u/v9iexqOeprU/qQonup28r2IoVqwzjuN5ezaoVk6JRBMDI/ZxWfU4rNrqVrVlB49yXA==",
"license": "MIT",
"dependencies": {
"@automerge/automerge-repo": "2.5.0"
}
},
"node_modules/@automerge/automerge-repo-storage-indexeddb/node_modules/@automerge/automerge-repo": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@automerge/automerge-repo/-/automerge-repo-2.5.0.tgz",
"integrity": "sha512-bdxuMuKmxw0ZjwQXecrIX1VrHXf445bYCftNJJ5vqgGWVvINB5ZKFYAbtgPIyu1Y0TXQKvc6eqESaDeL+g8MmA==",
"license": "MIT",
"dependencies": {
"@automerge/automerge": "2.2.8 - 3",
"bs58check": "^3.0.1",
"cbor-x": "^1.3.0",
"debug": "^4.3.4",
"eventemitter3": "^5.0.1",
"fast-sha256": "^1.3.0",
"uuid": "^9.0.0",
"xstate": "^5.9.1"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",

View File

@ -25,6 +25,7 @@
"@automerge/automerge": "^3.1.1", "@automerge/automerge": "^3.1.1",
"@automerge/automerge-repo": "^2.2.0", "@automerge/automerge-repo": "^2.2.0",
"@automerge/automerge-repo-react-hooks": "^2.2.0", "@automerge/automerge-repo-react-hooks": "^2.2.0",
"@automerge/automerge-repo-storage-indexeddb": "^2.5.0",
"@chengsokdara/use-whisper": "^0.2.0", "@chengsokdara/use-whisper": "^0.2.0",
"@daily-co/daily-js": "^0.60.0", "@daily-co/daily-js": "^0.60.0",
"@daily-co/daily-react": "^0.20.0", "@daily-co/daily-react": "^0.20.0",

View File

@ -0,0 +1,250 @@
/**
* Document ID Mapping Utility
*
* Manages the mapping between room IDs (human-readable slugs) and
* Automerge document IDs (automerge:xxxx format).
*
* This is necessary because:
* - Automerge requires specific document ID formats
* - We want to persist documents in IndexedDB with consistent IDs
* - Room IDs are user-friendly slugs that may not match Automerge's format
*/
const DB_NAME = 'canvas-document-mappings'
const STORE_NAME = 'mappings'
const DB_VERSION = 1
interface DocumentMapping {
roomId: string
documentId: string
createdAt: number
lastAccessedAt: number
}
let dbInstance: IDBDatabase | null = null
/**
* Open the IndexedDB database for document ID mappings
*/
async function openDatabase(): Promise<IDBDatabase> {
if (dbInstance) return dbInstance
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => {
console.error('Failed to open document mapping database:', request.error)
reject(request.error)
}
request.onsuccess = () => {
dbInstance = request.result
resolve(request.result)
}
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'roomId' })
store.createIndex('documentId', 'documentId', { unique: true })
store.createIndex('lastAccessedAt', 'lastAccessedAt', { unique: false })
}
}
})
}
/**
* Get the Automerge document ID for a given room ID
* Returns null if no mapping exists
*/
export async function getDocumentId(roomId: string): Promise<string | null> {
try {
const db = await openDatabase()
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly')
const store = transaction.objectStore(STORE_NAME)
const request = store.get(roomId)
request.onerror = () => {
console.error('Failed to get document mapping:', request.error)
reject(request.error)
}
request.onsuccess = () => {
const mapping = request.result as DocumentMapping | undefined
if (mapping) {
// Update last accessed time in background
updateLastAccessed(roomId).catch(console.error)
resolve(mapping.documentId)
} else {
resolve(null)
}
}
})
} catch (error) {
console.error('Error getting document ID:', error)
return null
}
}
/**
* Save a mapping between room ID and Automerge document ID
*/
export async function saveDocumentId(roomId: string, documentId: string): Promise<void> {
try {
const db = await openDatabase()
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite')
const store = transaction.objectStore(STORE_NAME)
const mapping: DocumentMapping = {
roomId,
documentId,
createdAt: Date.now(),
lastAccessedAt: Date.now()
}
const request = store.put(mapping)
request.onerror = () => {
console.error('Failed to save document mapping:', request.error)
reject(request.error)
}
request.onsuccess = () => {
console.log(`📝 Saved document mapping: ${roomId}${documentId}`)
resolve()
}
})
} catch (error) {
console.error('Error saving document ID:', error)
throw error
}
}
/**
* Update the last accessed timestamp for a room
*/
async function updateLastAccessed(roomId: string): Promise<void> {
try {
const db = await openDatabase()
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite')
const store = transaction.objectStore(STORE_NAME)
const getRequest = store.get(roomId)
getRequest.onerror = () => reject(getRequest.error)
getRequest.onsuccess = () => {
const mapping = getRequest.result as DocumentMapping | undefined
if (mapping) {
mapping.lastAccessedAt = Date.now()
store.put(mapping)
}
resolve()
}
})
} catch (error) {
// Silent fail for background update
}
}
/**
* Delete a document mapping (useful for cleanup)
*/
export async function deleteDocumentMapping(roomId: string): Promise<void> {
try {
const db = await openDatabase()
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite')
const store = transaction.objectStore(STORE_NAME)
const request = store.delete(roomId)
request.onerror = () => {
console.error('Failed to delete document mapping:', request.error)
reject(request.error)
}
request.onsuccess = () => {
console.log(`🗑️ Deleted document mapping for: ${roomId}`)
resolve()
}
})
} catch (error) {
console.error('Error deleting document mapping:', error)
throw error
}
}
/**
* Get all document mappings (useful for debugging/management)
*/
export async function getAllMappings(): Promise<DocumentMapping[]> {
try {
const db = await openDatabase()
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly')
const store = transaction.objectStore(STORE_NAME)
const request = store.getAll()
request.onerror = () => {
console.error('Failed to get all document mappings:', request.error)
reject(request.error)
}
request.onsuccess = () => {
resolve(request.result as DocumentMapping[])
}
})
} catch (error) {
console.error('Error getting all mappings:', error)
return []
}
}
/**
* Clean up old document mappings (documents not accessed in X days)
* This helps manage storage quota
*/
export async function cleanupOldMappings(maxAgeDays: number = 30): Promise<number> {
try {
const db = await openDatabase()
const cutoffTime = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000)
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite')
const store = transaction.objectStore(STORE_NAME)
const index = store.index('lastAccessedAt')
const range = IDBKeyRange.upperBound(cutoffTime)
const request = index.openCursor(range)
let deletedCount = 0
request.onerror = () => {
console.error('Failed to cleanup old mappings:', request.error)
reject(request.error)
}
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result
if (cursor) {
cursor.delete()
deletedCount++
cursor.continue()
} else {
console.log(`🧹 Cleaned up ${deletedCount} old document mappings`)
resolve(deletedCount)
}
}
})
} catch (error) {
console.error('Error cleaning up old mappings:', error)
return 0
}
}

View File

@ -3,8 +3,9 @@ import { TLStoreSnapshot } from "@tldraw/tldraw"
import { CloudflareNetworkAdapter } from "./CloudflareAdapter" import { CloudflareNetworkAdapter } from "./CloudflareAdapter"
import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2" import { useAutomergeStoreV2, useAutomergePresence } from "./useAutomergeStoreV2"
import { TLStoreWithStatus } from "@tldraw/tldraw" import { TLStoreWithStatus } from "@tldraw/tldraw"
import { Repo } from "@automerge/automerge-repo" import { Repo, DocHandle, DocumentId } from "@automerge/automerge-repo"
import { DocHandle } from "@automerge/automerge-repo" import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb"
import { getDocumentId, saveDocumentId } from "./documentIdMapping"
interface AutomergeSyncConfig { interface AutomergeSyncConfig {
uri: string uri: string
@ -17,9 +18,23 @@ interface AutomergeSyncConfig {
} }
} }
export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus & { handle: DocHandle<any> | null; presence: ReturnType<typeof useAutomergePresence> } { // Track online/offline status
export type ConnectionStatus = 'online' | 'offline' | 'syncing'
// Return type for useAutomergeSync - extends TLStoreWithStatus with offline capabilities
export interface AutomergeSyncResult {
store?: TLStoreWithStatus['store']
status: TLStoreWithStatus['status']
error?: TLStoreWithStatus['error']
handle: DocHandle<any> | null
presence: ReturnType<typeof useAutomergePresence>
connectionStatus: ConnectionStatus
isOfflineReady: boolean
}
export function useAutomergeSync(config: AutomergeSyncConfig): AutomergeSyncResult {
const { uri, user } = config const { uri, user } = config
// Extract roomId from URI (e.g., "https://worker.com/connect/room123" -> "room123") // Extract roomId from URI (e.g., "https://worker.com/connect/room123" -> "room123")
const roomId = useMemo(() => { const roomId = useMemo(() => {
const match = uri.match(/\/connect\/([^\/]+)$/) const match = uri.match(/\/connect\/([^\/]+)$/)
@ -33,14 +48,18 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
const [handle, setHandle] = useState<any>(null) const [handle, setHandle] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>(
typeof navigator !== 'undefined' && navigator.onLine ? 'online' : 'offline'
)
const [isOfflineReady, setIsOfflineReady] = useState(false)
const handleRef = useRef<any>(null) const handleRef = useRef<any>(null)
const storeRef = useRef<any>(null) const storeRef = useRef<any>(null)
// Update refs when handle/store changes // Update refs when handle/store changes
useEffect(() => { useEffect(() => {
handleRef.current = handle handleRef.current = handle
}, [handle]) }, [handle])
// JSON sync is deprecated - all data now flows through Automerge sync protocol // JSON sync is deprecated - all data now flows through Automerge sync protocol
// Old format content is converted server-side and saved to R2 in Automerge format // Old format content is converted server-side and saved to R2 in Automerge format
// This callback is kept for backwards compatibility but should not be used // This callback is kept for backwards compatibility but should not be used
@ -49,92 +68,199 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
// Don't apply JSON sync - let Automerge sync handle everything // Don't apply JSON sync - let Automerge sync handle everything
return return
}, []) }, [])
// Create Repo with both network AND storage adapters for offline support
const [repo] = useState(() => { const [repo] = useState(() => {
const adapter = new CloudflareNetworkAdapter(workerUrl, roomId, applyJsonSyncData) const networkAdapter = new CloudflareNetworkAdapter(workerUrl, roomId, applyJsonSyncData)
const storageAdapter = new IndexedDBStorageAdapter()
console.log('🗄️ Creating Automerge Repo with IndexedDB storage adapter for offline support')
return new Repo({ return new Repo({
network: [adapter] network: [networkAdapter],
storage: storageAdapter
}) })
}) })
// Initialize Automerge document handle // Listen for online/offline events
useEffect(() => {
const handleOnline = () => {
console.log('🌐 Network: Back online')
setConnectionStatus('syncing')
// The network adapter will automatically reconnect and sync
// After a short delay, assume we're synced if no errors
setTimeout(() => {
setConnectionStatus('online')
}, 2000)
}
const handleOffline = () => {
console.log('📴 Network: Gone offline')
setConnectionStatus('offline')
}
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
// Initialize Automerge document handle with offline-first approach
useEffect(() => { useEffect(() => {
let mounted = true let mounted = true
const initializeHandle = async () => { const initializeHandle = async () => {
try { try {
console.log("🔌 Initializing Automerge Repo with NetworkAdapter for room:", roomId) console.log("🔌 Initializing Automerge Repo with offline support for room:", roomId)
if (mounted) { if (!mounted) return
// CRITICAL: Create a new Automerge document (repo.create() generates a proper document ID)
// We can't use repo.find() with a custom ID because Automerge requires specific document ID formats let handle: DocHandle<any>
// Instead, we'll create a new document and load initial data from the server let existingDocId: string | null = null
const handle = repo.create() let loadedFromLocal = false
console.log("Created Automerge handle via Repo:", { // Step 1: Check if we have a stored document ID for this room
handleId: handle.documentId, try {
isReady: handle.isReady() existingDocId = await getDocumentId(roomId)
}) if (existingDocId) {
console.log(`📦 Found existing document ID in IndexedDB: ${existingDocId}`)
// Wait for the handle to be ready }
} catch (error) {
console.warn('⚠️ Could not check IndexedDB for existing document:', error)
}
// Step 2: Try to load from local storage first (offline-first approach)
if (existingDocId) {
try {
console.log(`🔍 Attempting to load document from IndexedDB: ${existingDocId}`)
// Use repo.find() which will check IndexedDB storage adapter first
// In automerge-repo v2.x, find() can return a Promise
const foundHandle = await Promise.resolve(repo.find(existingDocId as DocumentId))
handle = foundHandle as DocHandle<any>
// Wait for the handle to be ready (will load from IndexedDB if available)
await handle.whenReady()
const localDoc = handle.doc() as any
const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
if (localRecordCount > 0) {
console.log(`✅ Loaded ${localRecordCount} records from IndexedDB (offline-first)`)
loadedFromLocal = true
setIsOfflineReady(true)
} else {
console.log('📦 Document exists in IndexedDB but is empty')
}
} catch (error) {
console.warn('⚠️ Could not load from IndexedDB, will create new document:', error)
existingDocId = null
}
}
// Step 3: If no local document, create a new one
if (!existingDocId || !handle!) {
console.log('📝 Creating new Automerge document')
handle = repo.create()
// Save the mapping for future offline access
await saveDocumentId(roomId, handle.documentId)
console.log(`📝 Saved new document mapping: ${roomId}${handle.documentId}`)
await handle.whenReady() await handle.whenReady()
}
// CRITICAL: Always load initial data from the server
// The server stores documents in R2 as JSON, so we need to load and initialize the Automerge document // Step 4: Sync with server if online (background sync)
console.log("📥 Loading initial data from server...") if (navigator.onLine) {
setConnectionStatus('syncing')
console.log("📥 Syncing with server...")
try { try {
const response = await fetch(`${workerUrl}/room/${roomId}`) const response = await fetch(`${workerUrl}/room/${roomId}`)
if (response.ok) { if (response.ok) {
const serverDoc = await response.json() as TLStoreSnapshot const serverDoc = await response.json() as TLStoreSnapshot
const serverShapeCount = serverDoc.store ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
const serverRecordCount = Object.keys(serverDoc.store || {}).length const serverRecordCount = Object.keys(serverDoc.store || {}).length
const serverShapeCount = serverDoc.store
console.log(`📥 Loaded document from server: ${serverRecordCount} records, ${serverShapeCount} shapes`) ? Object.values(serverDoc.store).filter((r: any) => r?.typeName === 'shape').length
: 0
// Initialize the Automerge document with server data
console.log(`📥 Server has: ${serverRecordCount} records, ${serverShapeCount} shapes`)
// Merge server data into local document
if (serverDoc.store && serverRecordCount > 0) { if (serverDoc.store && serverRecordCount > 0) {
handle.change((doc: any) => { const localDoc = handle.doc() as any
// Initialize store if it doesn't exist const localRecordCount = localDoc?.store ? Object.keys(localDoc.store).length : 0
if (!doc.store) {
doc.store = {} // If server has more data or local is empty, merge server data
} if (serverRecordCount > 0) {
// Copy all records from server document handle.change((doc: any) => {
Object.entries(serverDoc.store).forEach(([id, record]) => { if (!doc.store) {
doc.store[id] = record doc.store = {}
}
// Merge server records (Automerge will handle conflicts)
Object.entries(serverDoc.store).forEach(([id, record]) => {
// Only add if not already present locally, or if this is first load
if (!doc.store[id] || !loadedFromLocal) {
doc.store[id] = record
}
})
}) })
})
const mergedDoc = handle.doc() as any
console.log(`✅ Initialized Automerge document with ${serverRecordCount} records from server`) const mergedCount = mergedDoc?.store ? Object.keys(mergedDoc.store).length : 0
} else { console.log(`✅ Merged server data. Total records: ${mergedCount}`)
console.log("📥 Server document is empty - starting with empty Automerge document") }
} else if (response.status !== 404) {
console.log("📥 Server document is empty")
} }
setConnectionStatus('online')
} else if (response.status === 404) { } else if (response.status === 404) {
console.log("📥 No document found on server (404) - starting with empty document") console.log("📥 No document on server yet - local document will be synced when saved")
setConnectionStatus('online')
} else { } else {
console.warn(`⚠️ Failed to load document from server: ${response.status} ${response.statusText}`) console.warn(`⚠️ Server sync failed: ${response.status}`)
setConnectionStatus(loadedFromLocal ? 'offline' : 'online')
} }
} catch (error) { } catch (error) {
console.error("❌ Error loading initial document from server:", error) console.error("❌ Error syncing with server:", error)
// Continue anyway - user can still create new content // If we loaded from local, we're still functional in offline mode
setConnectionStatus(loadedFromLocal ? 'offline' : 'online')
} }
} else {
const finalDoc = handle.doc() as any console.log("📴 Offline - using local data only")
const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0 setConnectionStatus('offline')
const finalShapeCount = finalDoc?.store ? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0 }
console.log("Automerge handle initialized:", { // Mark as offline-ready once we have any document loaded
hasDoc: !!finalDoc, setIsOfflineReady(true)
storeKeys: finalStoreKeys,
shapeCount: finalShapeCount const finalDoc = handle.doc() as any
}) const finalStoreKeys = finalDoc?.store ? Object.keys(finalDoc.store).length : 0
const finalShapeCount = finalDoc?.store
? Object.values(finalDoc.store).filter((r: any) => r?.typeName === 'shape').length
: 0
console.log("✅ Automerge handle initialized:", {
documentId: handle.documentId,
hasDoc: !!finalDoc,
storeKeys: finalStoreKeys,
shapeCount: finalShapeCount,
loadedFromLocal,
isOnline: navigator.onLine
})
if (mounted) {
setHandle(handle) setHandle(handle)
setIsLoading(false) setIsLoading(false)
} }
} catch (error) { } catch (error) {
console.error("Error initializing Automerge handle:", error) console.error("Error initializing Automerge handle:", error)
if (mounted) { if (mounted) {
setIsLoading(false) setIsLoading(false)
setConnectionStatus('offline')
} }
} }
} }
@ -144,7 +270,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
return () => { return () => {
mounted = false mounted = false
} }
}, [repo, roomId]) }, [repo, roomId, workerUrl])
// Auto-save to Cloudflare on every change (with debouncing to prevent excessive calls) // Auto-save to Cloudflare on every change (with debouncing to prevent excessive calls)
// CRITICAL: This ensures new shapes are persisted to R2 // CRITICAL: This ensures new shapes are persisted to R2
@ -279,6 +405,8 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
return { return {
...storeWithStatus, ...storeWithStatus,
handle, handle,
presence presence,
connectionStatus,
isOfflineReady
} }
} }

View File

@ -0,0 +1,66 @@
import { ConnectionStatus } from '@/automerge/useAutomergeSyncRepo'
interface OfflineIndicatorProps {
connectionStatus: ConnectionStatus
isOfflineReady: boolean
}
export function OfflineIndicator({ connectionStatus, isOfflineReady }: OfflineIndicatorProps) {
// Don't show indicator when online and everything is working normally
if (connectionStatus === 'online') {
return null
}
const getStatusConfig = () => {
switch (connectionStatus) {
case 'offline':
return {
icon: '📴',
text: isOfflineReady ? 'Offline (changes saved locally)' : 'Offline',
bgColor: '#fef3c7', // warm yellow
textColor: '#92400e',
borderColor: '#f59e0b'
}
case 'syncing':
return {
icon: '🔄',
text: 'Syncing...',
bgColor: '#dbeafe', // light blue
textColor: '#1e40af',
borderColor: '#3b82f6'
}
default:
return null
}
}
const config = getStatusConfig()
if (!config) return null
return (
<div
style={{
position: 'fixed',
bottom: '16px',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: config.bgColor,
color: config.textColor,
padding: '8px 16px',
borderRadius: '8px',
border: `1px solid ${config.borderColor}`,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
display: 'flex',
alignItems: 'center',
gap: '8px',
zIndex: 9999,
fontSize: '14px',
fontFamily: 'system-ui, -apple-system, sans-serif',
pointerEvents: 'none'
}}
>
<span style={{ fontSize: '16px' }}>{config.icon}</span>
<span>{config.text}</span>
</div>
)
}

View File

@ -56,6 +56,7 @@ import { Collection, initializeGlobalCollections } from "@/collections"
import { GraphLayoutCollection } from "@/graph/GraphLayoutCollection" import { GraphLayoutCollection } from "@/graph/GraphLayoutCollection"
import { GestureTool } from "@/GestureTool" import { GestureTool } from "@/GestureTool"
import { CmdK } from "@/CmdK" import { CmdK } from "@/CmdK"
import { OfflineIndicator } from "@/components/OfflineIndicator"
import "react-cmdk/dist/cmdk.css" import "react-cmdk/dist/cmdk.css"
@ -203,13 +204,14 @@ export function Board() {
// Use Automerge sync for all environments // Use Automerge sync for all environments
const storeWithHandle = useAutomergeSync(storeConfig) const storeWithHandle = useAutomergeSync(storeConfig)
const store = { const store = {
store: storeWithHandle.store, store: storeWithHandle.store,
status: storeWithHandle.status, status: storeWithHandle.status,
...('connectionStatus' in storeWithHandle ? { connectionStatus: storeWithHandle.connectionStatus } : {}),
error: storeWithHandle.error error: storeWithHandle.error
} }
const automergeHandle = (storeWithHandle as any).handle const automergeHandle = storeWithHandle.handle
const connectionStatus = storeWithHandle.connectionStatus
const isOfflineReady = storeWithHandle.isOfflineReady
const [editor, setEditor] = useState<Editor | null>(null) const [editor, setEditor] = useState<Editor | null>(null)
useEffect(() => { useEffect(() => {
@ -658,6 +660,7 @@ export function Board() {
<div style={{ position: "fixed", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}> <div style={{ position: "fixed", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
<div>Loading canvas...</div> <div>Loading canvas...</div>
</div> </div>
<OfflineIndicator connectionStatus={connectionStatus} isOfflineReady={isOfflineReady} />
</AutomergeHandleProvider> </AutomergeHandleProvider>
) )
} }
@ -740,6 +743,7 @@ export function Board() {
> >
<CmdK /> <CmdK />
</Tldraw> </Tldraw>
<OfflineIndicator connectionStatus={connectionStatus} isOfflineReady={isOfflineReady} />
</div> </div>
</AutomergeHandleProvider> </AutomergeHandleProvider>
) )