import { Component, ReactNode, useEffect, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Tldraw,
DefaultMainMenu,
DefaultMainMenuContent,
TldrawUiMenuItem,
TldrawUiMenuGroup,
TLComponents,
DefaultContextMenu,
DefaultContextMenuContent,
DefaultToolbar,
DefaultToolbarContent,
defaultShapeUtils,
useEditor,
track,
} from 'tldraw'
import { useSync } from '@tldraw/sync'
import { getBookmarkPreview } from '../getBookmarkPreview'
import { multiplayerAssetStore } from '../multiplayerAssetStore'
import { WS_URL, SERVER_URL, apiUrl } from '../config'
import { saveRoom } from './storageUtils'
import { StatePersistence } from '../components/StatePersistence'
import { NavigationDock } from '../components/NavigationDock'
import { getUiOverrides } from '../overrides'
import { shareLink } from '../utils/exportUtils'
import { HatchGeoShapeUtil } from '../HatchGeoShapeUtil'
import { SPenController } from '../components/SPenController'
import { LockStatus, ToggleLockMenuItem } from '../components/LockComponents'
import { SelectOptionsPanel, SelectLockedToggle, SelectLockedLogic } from '../components/SelectLockedComponents'
import { CustomStylePanel, getInitialMetaForOpacity } from '../components/CustomStylePanel'
import { BrushManager } from '../components/BrushManager'
import { EquationRenderer } from '../components/EquationRendererSimple'
import { getEraserSettings } from '../utils/eraserUtils'
import { EquationShapeUtil } from '../shapes/EquationShapeUtil'
import { BackupMenuItem, DownloadMenuItem, RestoreMenuItem, RestoreFileHandler, PdfExportMenuItem, ImportSvgMenuItem } from '../components/BackupControl'
import { AndroidIntentListener } from '../components/AndroidIntentListener'
const customShapeUtils = [
...defaultShapeUtils.filter(u => u.type !== 'geo'),
HatchGeoShapeUtil,
EquationShapeUtil
]
class SyncErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
constructor(props: { children: ReactNode }) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(_error: any) {
return { hasError: true }
}
componentDidCatch(error: any, _errorInfo: any) {
const msg = error?.message || String(error)
console.error("SyncErrorBoundary caught:", msg)
// Check specifically for Tldraw sync/validation errors
if (msg.includes('INVALID_RECORD') || msg.includes('RemoteSyncError') || msg.includes('ValidationError')) {
setTimeout(() => {
alert(`Sync/Validation Error: The room data appears to be invalid.\n\nDetails: ${msg}`)
if (window.confirm('Would you like to clear local data to try and fix this issue? This will not delete server data, but will clear your local cache.')) {
localStorage.clear()
if (window.indexedDB) {
window.indexedDB.databases().then((dbs) => {
dbs.forEach((db) => {
if (db.name && db.name.includes('tldraw')) {
window.indexedDB.deleteDatabase(db.name)
}
})
})
}
setTimeout(() => window.location.reload(), 500)
}
}, 100)
}
}
render() {
if (this.state.hasError) {
// Render a fallback UI or nothing while the alert handles recovery
return
Sync Error Detected. Please check the alert dialog.
}
return this.props.children
}
}
export function RoomPage() {
const { roomId } = useParams<{ roomId: string }>()
const navigate = useNavigate()
console.log(`[Room] Initializing. RoomID: ${roomId}`);
console.log(`[Room] Constants -> SERVER_URL: ${SERVER_URL}, WS_URL: ${WS_URL}`);
if (!roomId) {
navigate('/')
return null
}
// --- ERROR MONITORING ---
useEffect(() => {
const recover = () => {
if (window.confirm('Would you like to clear local data to try and fix this issue? This will not delete server data, but will clear your local cache.')) {
localStorage.clear()
if (window.indexedDB) {
window.indexedDB.databases().then((dbs) => {
dbs.forEach((db) => {
if (db.name && db.name.includes('tldraw')) {
window.indexedDB.deleteDatabase(db.name)
}
})
})
}
setTimeout(() => window.location.reload(), 500)
}
}
const handleError = (event: ErrorEvent) => {
const msg = event.message || ''
if (msg.includes('INVALID_RECORD') || msg.includes('RemoteSyncError') || msg.includes('ValidationError')) {
// Use setTimeout to ensure the alert doesn't block the error propagation immediately if that causes issues,
// but mainly to give UI a chance to render if it can.
setTimeout(() => {
alert(`Sync/Validation Error: The room data appears to be invalid.\n\nDetails: ${msg}`)
recover()
}, 100)
}
}
const handleRejection = (event: PromiseRejectionEvent) => {
const reason = event.reason?.message || String(event.reason || '')
if (reason.includes('INVALID_RECORD') || reason.includes('RemoteSyncError') || reason.includes('ValidationError')) {
setTimeout(() => {
alert(`Sync/Validation Error: The room data appears to be invalid.\n\nDetails: ${reason}`)
recover()
}, 100)
}
}
window.addEventListener('error', handleError)
window.addEventListener('unhandledrejection', handleRejection)
return () => {
window.removeEventListener('error', handleError)
window.removeEventListener('unhandledrejection', handleRejection)
}
}, [])
// ------------------------
useEffect(() => {
saveRoom(roomId)
const metaUrl = apiUrl(`/api/meta/${roomId}`);
console.log(`[Room] Fetching metadata from: ${metaUrl}`);
fetch(metaUrl)
.then(res => res.json())
.then((data: any) => {
if (data.name) {
saveRoom(roomId, data.name)
window.dispatchEvent(new Event('tldraw-room-update'))
}
})
.catch(err => console.error('Failed to fetch room metadata:', err))
}, [roomId])
const syncUri = `${WS_URL}/api/connect/${roomId}`;
console.log(`[Room] Syncing with URI: ${syncUri}`);
const store = useSync({
uri: syncUri,
assets: multiplayerAssetStore,
shapeUtils: customShapeUtils,
})
const overrides = useMemo(() => getUiOverrides(roomId), [roomId])
const EditEquationMenuItem = track(() => {
const editor = useEditor()
const selectedShapes = editor.getSelectedShapes()
const isEquationSelected = selectedShapes.length === 1 && selectedShapes[0].type === 'equation'
if (!isEquationSelected) return null
return (
{
const shape = selectedShapes[0]
// Trigger equation editor with shape data
window.dispatchEvent(new CustomEvent('open-equation-editor', {
detail: { shapeId: shape.id }
}))
}}
/>
)
})
const LockedStylePanel = track(() => {
const editor = useEditor()
const selectedShapes = editor.getSelectedShapes()
if (selectedShapes.length === 0) return null
// Only render manually if ALL shapes are locked
// (Tldraw hides the panel in this case)
const allLocked = selectedShapes.every(s => s.isLocked)
if (!allLocked) return null
return (
{/* Pass the actual shared styles of the selection so pickers show correct state */}
)
})
const components = useMemo(() => ({
MainMenu: () => (
navigate('/')}
/>
shareLink(roomId)}
/>
),
ContextMenu: (props) => (
),
Toolbar: (props) => (
),
StylePanel: (props) => ,
InFrontOfTheCanvas: () => (
<>
>
)
}), [roomId, navigate])
return (
{
// Expose editor to window for debugging
;(window as any).editor = editor
editor.registerExternalAssetHandler('url', getBookmarkPreview)
editor.user.updateUserPreferences({ colorScheme: 'dark' })
// Prevent editing equation shapes by intercepting the editingShapeId change
editor.sideEffects.registerBeforeChangeHandler('instance', (prev: any, next: any) => {
// If trying to set editingShapeId for an equation shape, prevent it
if (next.editingShapeId && next.editingShapeId !== prev.editingShapeId) {
const shape = editor.getShape(next.editingShapeId)
if (shape && shape.type === 'equation') {
// Return the previous state (no editing)
return prev
}
}
return next
})
editor.sideEffects.registerBeforeCreateHandler('shape', (shape) => {
if (shape.type === 'geo') {
return {
...shape,
meta: {
...shape.meta,
...getInitialMetaForOpacity(editor)
}
}
}
return shape
})
editor.sideEffects.registerBeforeChangeHandler('instance_page_state', (_prev: any, next: any) => {
if (next.erasingShapeIds.length === 0) return next
const settings = getEraserSettings()
const filteredIds = next.erasingShapeIds.filter((id: any) => {
const shape = editor.getShape(id)
if (!shape) return false
const { type } = shape
if (['draw', 'highlight'].includes(type)) return settings.scribble
if (type === 'text') return settings.text
if (['geo', 'arrow', 'line', 'note', 'frame', 'group'].includes(type)) return settings.shapes
if (['image', 'video'].includes(type)) return settings.images
return true
})
if (filteredIds.length !== next.erasingShapeIds.length) {
return { ...next, erasingShapeIds: filteredIds }
}
return next
})
editor.sideEffects.registerBeforeDeleteHandler('shape', (shape: any) => {
if (editor.getCurrentToolId() !== 'eraser') return
const settings = getEraserSettings()
const { type } = shape
if (['draw', 'highlight'].includes(type) && !settings.scribble) return false
if (type === 'text' && !settings.text) return false
if (['geo', 'arrow', 'line', 'note', 'frame', 'group'].includes(type) && !settings.shapes) return false
if (['image', 'video'].includes(type) && !settings.images) return false
})
// Restore from backup if data exists in sessionStorage or localStorage
try {
const restoreKey = `restore_data_${roomId}`
let restoreData = sessionStorage.getItem(restoreKey)
let storageType = 'session'
if (!restoreData) {
restoreData = localStorage.getItem(restoreKey)
storageType = 'local'
}
if (restoreData) {
const parsed = JSON.parse(restoreData)
// The backup structure has { snapshot, roomName, ... }
// But fallback to using parsed directly if snapshot prop missing
const snapshot = parsed.snapshot || parsed
console.log(`[Room] Restoring backup for ${roomId}...`)
editor.loadSnapshot(snapshot)
// Clean up
if (storageType === 'session') {
sessionStorage.removeItem(restoreKey)
} else {
localStorage.removeItem(restoreKey)
}
// If name is present, ensure it's saved locally (server meta update happens separately if we implemented it)
if (parsed.roomName) {
saveRoom(roomId, parsed.roomName)
}
}
} catch (e) {
console.error('[Room] Failed to restore backup', e)
}
}}
>
)
}