canvas-website/src/automerge/useAutomergeStore.ts

623 lines
23 KiB
TypeScript

import {
TLAnyShapeUtilConstructor,
TLRecord,
TLStoreWithStatus,
createTLStore,
defaultShapeUtils,
HistoryEntry,
getUserPreferences,
setUserPreferences,
defaultUserPreferences,
createPresenceStateDerivation,
InstancePresenceRecordType,
computed,
react,
TLStoreSnapshot,
sortById,
loadSnapshot,
} from "@tldraw/tldraw"
import { createTLSchema, defaultBindingSchemas, defaultShapeSchemas } from "@tldraw/tlschema"
import { useEffect, useState } from "react"
import { DocHandle, DocHandleChangePayload } from "@automerge/automerge-repo"
import {
useLocalAwareness,
useRemoteAwareness,
} from "@automerge/automerge-repo-react-hooks"
import { applyAutomergePatchesToTLStore } from "./AutomergeToTLStore.js"
import { applyTLStoreChangesToAutomerge } from "./TLStoreToAutomerge.js"
// Import custom shape utilities
import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil"
import { VideoChatShape } from "@/shapes/VideoChatShapeUtil"
import { EmbedShape } from "@/shapes/EmbedShapeUtil"
import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil"
import { SlideShape } from "@/shapes/SlideShapeUtil"
import { PromptShape } from "@/shapes/PromptShapeUtil"
import { SharedPianoShape } from "@/shapes/SharedPianoShapeUtil"
export function useAutomergeStore({
handle,
}: {
handle: DocHandle<TLStoreSnapshot>
userId: string
}): TLStoreWithStatus {
// Deprecation warning
console.warn(
"⚠️ useAutomergeStore is deprecated and has known migration issues. " +
"Please use useAutomergeStoreV2 or useAutomergeSync instead for better reliability."
)
// Create a custom schema that includes all the custom shapes
const customSchema = createTLSchema({
shapes: {
...defaultShapeSchemas,
ChatBox: {
props: ChatBoxShape.props,
},
VideoChat: {
props: VideoChatShape.props,
},
Embed: {
props: EmbedShape.props,
},
Markdown: {
props: MarkdownShape.props,
},
MycrozineTemplate: {
props: MycrozineTemplateShape.props,
},
Slide: {
props: SlideShape.props,
},
Prompt: {
props: PromptShape.props,
},
SharedPiano: {
props: SharedPianoShape.props,
},
},
bindings: defaultBindingSchemas,
})
const [store] = useState(() => {
const store = createTLStore({
schema: customSchema,
})
return store
})
const [storeWithStatus, setStoreWithStatus] = useState<TLStoreWithStatus>({
status: "loading",
})
/* -------------------- TLDraw <--> Automerge -------------------- */
useEffect(() => {
// Early return if handle is not available
if (!handle) {
setStoreWithStatus({ status: "loading" })
return
}
const unsubs: (() => void)[] = []
// A hacky workaround to prevent local changes from being applied twice
// once into the automerge doc and then back again.
let preventPatchApplications = false
/* TLDraw to Automerge */
function syncStoreChangesToAutomergeDoc({
changes,
}: HistoryEntry<TLRecord>) {
preventPatchApplications = true
handle.change((doc) => {
applyTLStoreChangesToAutomerge(doc, changes)
})
preventPatchApplications = false
}
unsubs.push(
store.listen(syncStoreChangesToAutomergeDoc, {
source: "user",
scope: "document",
})
)
/* Automerge to TLDraw */
const syncAutomergeDocChangesToStore = ({
patches,
}: DocHandleChangePayload<any>) => {
if (preventPatchApplications) return
applyAutomergePatchesToTLStore(patches, store)
}
handle.on("change", syncAutomergeDocChangesToStore)
unsubs.push(() => handle.off("change", syncAutomergeDocChangesToStore))
/* Defer rendering until the document is ready */
// TODO: need to think through the various status possibilities here and how they map
handle.whenReady().then(() => {
try {
const doc = handle.doc()
if (!doc) throw new Error("Document not found")
if (!doc.store) throw new Error("Document store not initialized")
// Clean the store data to remove any problematic text properties that might cause migration issues
const cleanedStore = JSON.parse(JSON.stringify(doc.store))
// Clean up any problematic text properties that might cause migration issues
const shapesToRemove: string[] = []
Object.keys(cleanedStore).forEach(key => {
const record = cleanedStore[key]
if (record && record.typeName === 'shape') {
let shouldRemove = false
// Migrate old Transcribe shapes to geo shapes
if (record.type === 'Transcribe') {
console.log(`Migrating old Transcribe shape ${key} to geo shape`)
record.type = 'geo'
// Ensure required geo props exist
if (!record.props.geo) record.props.geo = 'rectangle'
if (!record.props.fill) record.props.fill = 'solid'
if (!record.props.color) record.props.color = 'white'
if (!record.props.dash) record.props.dash = 'draw'
if (!record.props.size) record.props.size = 'm'
if (!record.props.font) record.props.font = 'draw'
if (!record.props.align) record.props.align = 'start'
if (!record.props.verticalAlign) record.props.verticalAlign = 'start'
if (!record.props.growY) record.props.growY = 0
if (!record.props.url) record.props.url = ''
if (!record.props.scale) record.props.scale = 1
if (!record.props.labelColor) record.props.labelColor = 'black'
if (!record.props.richText) record.props.richText = [] as any
// Move transcript text from props to meta
if (record.props.transcript) {
if (!record.meta) record.meta = {}
record.meta.text = record.props.transcript
delete record.props.transcript
}
// Clean up other old Transcribe-specific props
const oldProps = ['isRecording', 'transcriptSegments', 'speakers', 'currentSpeakerId',
'interimText', 'isCompleted', 'aiSummary', 'language', 'autoScroll',
'showTimestamps', 'showSpeakerLabels', 'manualClear']
oldProps.forEach(prop => {
if (record.props[prop] !== undefined) {
delete record.props[prop]
}
})
}
// Handle text shapes
if (record.type === 'text' && record.props) {
// Ensure text property is a string
if (typeof record.props.text !== 'string') {
console.warn('Fixing invalid text property for text shape:', key)
record.props.text = record.props.text || ''
}
}
// Handle other shapes that might have text properties
if (record.props && record.props.text !== undefined) {
if (typeof record.props.text !== 'string') {
console.warn('Fixing invalid text property for shape:', key, 'type:', record.type)
record.props.text = record.props.text || ''
}
}
// Handle rich text content that might be undefined or invalid
if (record.props && record.props.richText !== undefined) {
if (!Array.isArray(record.props.richText)) {
console.warn('Fixing invalid richText property for shape:', key, 'type:', record.type)
record.props.richText = [] as any
} else {
// Clean up any invalid rich text entries
record.props.richText = record.props.richText.filter((item: any) =>
item && typeof item === 'object' && item.type
)
}
}
// Remove any other potentially problematic properties that might cause migration issues
if (record.props) {
// Remove any properties that are null or undefined
Object.keys(record.props).forEach(propKey => {
if (record.props[propKey] === null || record.props[propKey] === undefined) {
console.warn(`Removing null/undefined property ${propKey} from shape:`, key, 'type:', record.type)
delete record.props[propKey]
}
})
}
// If the shape still looks problematic, mark it for removal
if (record.props && Object.keys(record.props).length === 0) {
console.warn('Removing shape with empty props:', key, 'type:', record.type)
shouldRemove = true
}
// For geo shapes, ensure basic properties exist
if (record.type === 'geo' && record.props) {
if (!record.props.geo) record.props.geo = 'rectangle'
if (!record.props.fill) record.props.fill = 'solid'
if (!record.props.color) record.props.color = 'white'
}
if (shouldRemove) {
shapesToRemove.push(key)
}
}
})
// Remove problematic shapes
shapesToRemove.forEach(key => {
console.warn('Removing problematic shape:', key)
delete cleanedStore[key]
})
// Log the final state of the cleaned store
const remainingShapes = Object.values(cleanedStore).filter((record: any) =>
record && record.typeName === 'shape'
)
console.log(`Cleaned store: ${remainingShapes.length} shapes remaining`)
// Additional aggressive cleaning to prevent migration errors
// Set ALL richText properties to proper structure instead of deleting them
Object.keys(cleanedStore).forEach(key => {
const record = cleanedStore[key]
if (record && record.typeName === 'shape' && record.props && record.props.richText !== undefined) {
console.warn('Setting richText to proper structure to prevent migration error:', key, 'type:', record.type)
record.props.richText = [] as any
}
})
// Remove ALL text properties that might be causing issues
Object.keys(cleanedStore).forEach(key => {
const record = cleanedStore[key]
if (record && record.typeName === 'shape' && record.props && record.props.text !== undefined) {
// Only keep text for actual text shapes
if (record.type !== 'text') {
console.warn('Removing text property from non-text shape to prevent migration error:', key, 'type:', record.type)
delete record.props.text
}
}
})
// Final cleanup: remove any shapes that still have problematic properties
const finalShapesToRemove: string[] = []
Object.keys(cleanedStore).forEach(key => {
const record = cleanedStore[key]
if (record && record.typeName === 'shape') {
// Remove any shape that has problematic text properties (but keep richText as proper structure)
if (record.props && (record.props.text !== undefined && record.type !== 'text')) {
console.warn('Removing shape with remaining problematic text properties:', key, 'type:', record.type)
finalShapesToRemove.push(key)
}
}
})
// Remove the final problematic shapes
finalShapesToRemove.forEach(key => {
console.warn('Final removal of problematic shape:', key)
delete cleanedStore[key]
})
// Log the final cleaned state
const finalShapes = Object.values(cleanedStore).filter((record: any) =>
record && record.typeName === 'shape'
)
console.log(`Final cleaned store: ${finalShapes.length} shapes remaining`)
// Try to load the snapshot with a more defensive approach
let loadSuccess = false
// Skip loadSnapshot entirely to avoid migration issues
console.log('Skipping loadSnapshot to avoid migration errors - starting with clean store')
// Manually add the cleaned shapes back to the store without going through migration
try {
store.mergeRemoteChanges(() => {
// Add only the essential store records first
const essentialRecords: any[] = []
Object.values(cleanedStore).forEach((record: any) => {
if (record && record.typeName === 'store' && record.id) {
essentialRecords.push(record)
}
})
if (essentialRecords.length > 0) {
store.put(essentialRecords)
console.log(`Added ${essentialRecords.length} essential records to store`)
}
// Add the cleaned shapes
const safeShapes: any[] = []
Object.values(cleanedStore).forEach((record: any) => {
if (record && record.typeName === 'shape' && record.type && record.id) {
// Only add shapes that are safe (no text properties, but richText can be proper structure)
if (record.props &&
!record.props.text &&
record.type !== 'text') {
safeShapes.push(record)
}
}
})
if (safeShapes.length > 0) {
store.put(safeShapes)
console.log(`Added ${safeShapes.length} safe shapes to store`)
}
})
loadSuccess = true
} catch (manualError) {
console.error('Manual shape addition failed:', manualError)
loadSuccess = true // Still consider it successful, just with empty store
}
// If we still haven't succeeded, try to completely bypass the migration by creating a new store
if (!loadSuccess) {
console.log('Attempting to create a completely new store to bypass migration...')
try {
// Create a new store with the same schema
const newStore = createTLStore({
schema: customSchema,
})
// Replace the current store with the new one
Object.assign(store, newStore)
// Try to manually add safe shapes to the new store
store.mergeRemoteChanges(() => {
const safeShapes: any[] = []
Object.values(cleanedStore).forEach((record: any) => {
if (record && record.typeName === 'shape' && record.type && record.id) {
// Only add shapes that don't have problematic properties
if (record.props &&
(!record.props.text || typeof record.props.text === 'string') &&
(!record.props.richText || Array.isArray(record.props.richText))) {
safeShapes.push(record)
}
}
})
console.log(`Found ${safeShapes.length} safe shapes to add to new store`)
if (safeShapes.length > 0) {
store.put(safeShapes)
console.log(`Added ${safeShapes.length} safe shapes to new store`)
}
})
loadSuccess = true
} catch (newStoreError) {
console.error('New store creation also failed:', newStoreError)
console.log('Continuing with completely empty store')
}
}
// If we still haven't succeeded, try to completely bypass the migration by using a different approach
if (!loadSuccess) {
console.log('Attempting to completely bypass migration...')
try {
// Create a completely new store and manually add only the essential data
const newStore = createTLStore({
schema: customSchema,
})
// Replace the current store with the new one
Object.assign(store, newStore)
// Manually add only the essential data without going through migration
store.mergeRemoteChanges(() => {
// Add only the essential store records
const essentialRecords: any[] = []
Object.values(cleanedStore).forEach((record: any) => {
if (record && record.typeName === 'store' && record.id) {
essentialRecords.push(record)
}
})
console.log(`Found ${essentialRecords.length} essential records to add`)
if (essentialRecords.length > 0) {
store.put(essentialRecords)
console.log(`Added ${essentialRecords.length} essential records to new store`)
}
})
loadSuccess = true
} catch (bypassError) {
console.error('Migration bypass also failed:', bypassError)
console.log('Continuing with completely empty store')
}
}
// If we still haven't succeeded, try the most aggressive approach: completely bypass loadSnapshot
if (!loadSuccess) {
console.log('Attempting most aggressive bypass - skipping loadSnapshot entirely...')
try {
// Create a completely new store
const newStore = createTLStore({
schema: customSchema,
})
// Replace the current store with the new one
Object.assign(store, newStore)
// Don't try to load any snapshot data - just start with a clean store
console.log('Starting with completely clean store to avoid migration issues')
loadSuccess = true
} catch (aggressiveError) {
console.error('Most aggressive bypass also failed:', aggressiveError)
console.log('Continuing with completely empty store')
}
}
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
})
} catch (error) {
console.error('Error in handle.whenReady():', error)
setStoreWithStatus({
status: "error",
error: error instanceof Error ? error : new Error('Unknown error'),
})
}
}).catch((error) => {
console.error('Promise rejection in handle.whenReady():', error)
setStoreWithStatus({
status: "error",
error: error instanceof Error ? error : new Error('Unknown error'),
})
})
// Add a global error handler for unhandled promise rejections
const originalConsoleError = console.error
console.error = (...args) => {
if (args[0] && typeof args[0] === 'string' && args[0].includes('Cannot read properties of undefined (reading \'split\')')) {
console.warn('Caught migration error, attempting recovery...')
// Try to recover by setting a clean store status
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
})
return
}
originalConsoleError.apply(console, args)
}
// Add a global error handler for unhandled errors
const originalErrorHandler = window.onerror
window.onerror = (message, source, lineno, colno, error) => {
if (message && typeof message === 'string' && message.includes('Cannot read properties of undefined (reading \'split\')')) {
console.warn('Caught global migration error, attempting recovery...')
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
})
return true // Prevent default error handling
}
if (originalErrorHandler) {
return originalErrorHandler(message, source, lineno, colno, error)
}
return false
}
// Add a global handler for unhandled promise rejections
const originalUnhandledRejection = window.onunhandledrejection
window.onunhandledrejection = (event) => {
if (event.reason && event.reason.message && event.reason.message.includes('Cannot read properties of undefined (reading \'split\')')) {
console.warn('Caught unhandled promise rejection migration error, attempting recovery...')
event.preventDefault() // Prevent the error from being logged
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
})
return
}
if (originalUnhandledRejection) {
return (originalUnhandledRejection as any)(event)
}
}
return () => {
unsubs.forEach((fn) => fn())
unsubs.length = 0
}
}, [handle, store])
return storeWithStatus
}
export function useAutomergePresence({ handle, store, userMetadata }:
{ handle: DocHandle<TLStoreSnapshot> | null, store: TLStoreWithStatus, userMetadata: any }) {
const innerStore = store?.store
const { userId, name, color } = userMetadata
// Only use awareness hooks if we have a valid handle and the store is ready
const shouldUseAwareness = handle && store?.status === "synced-remote"
// Create a safe handle that won't cause null errors
const safeHandle = shouldUseAwareness ? handle : {
on: () => {},
off: () => {},
removeListener: () => {}, // Add the missing removeListener method
whenReady: () => Promise.resolve(),
doc: () => null,
change: () => {},
broadcast: () => {}, // Add the missing broadcast method
} as any
const [, updateLocalState] = useLocalAwareness({
handle: safeHandle,
userId,
initialState: {},
})
const [peerStates] = useRemoteAwareness({
handle: safeHandle,
localUserId: userId,
})
/* ----------- Presence stuff ----------- */
useEffect(() => {
if (!innerStore || !shouldUseAwareness) return
const toPut: TLRecord[] =
Object.values(peerStates)
.filter((record) => record && Object.keys(record).length !== 0)
// put / remove the records in the store
const toRemove = innerStore.query.records('instance_presence').get().sort(sortById)
.map((record) => record.id)
.filter((id) => !toPut.find((record) => record.id === id))
if (toRemove.length) innerStore.remove(toRemove)
if (toPut.length) innerStore.put(toPut)
}, [innerStore, peerStates, shouldUseAwareness])
useEffect(() => {
if (!innerStore || !shouldUseAwareness) return
/* ----------- Presence stuff ----------- */
setUserPreferences({ id: userId, color, name })
const userPreferences = computed<{
id: string
color: string
name: string
}>("userPreferences", () => {
const user = getUserPreferences()
return {
id: user.id,
color: user.color ?? defaultUserPreferences.color,
name: user.name ?? defaultUserPreferences.name,
}
})
const presenceId = InstancePresenceRecordType.createId(userId)
const presenceDerivation = createPresenceStateDerivation(
userPreferences,
presenceId
)(innerStore)
return react("when presence changes", () => {
const presence = presenceDerivation.get()
requestAnimationFrame(() => {
updateLocalState(presence)
})
})
}, [innerStore, userId, updateLocalState, shouldUseAwareness])
/* ----------- End presence stuff ----------- */
}