canvas-website/src/automerge/useAutomergeStoreV2.ts

582 lines
24 KiB
TypeScript

import {
TLRecord,
TLStoreWithStatus,
createTLStore,
TLStoreSnapshot,
} 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, sanitizeRecord } 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"
import { TranscriptionShape } from "@/shapes/TranscriptionShapeUtil"
import { ObsNoteShape } from "@/shapes/ObsNoteShapeUtil"
import { FathomTranscriptShape } from "@/shapes/FathomTranscriptShapeUtil"
import { HolonShape } from "@/shapes/HolonShapeUtil"
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
import { LocationShareShape } from "@/shapes/LocationShareShapeUtil"
export function useAutomergeStoreV2({
handle,
userId: _userId,
}: {
handle: DocHandle<any>
userId: string
}): TLStoreWithStatus {
console.log("useAutomergeStoreV2 called with handle:", !!handle)
// Create a custom schema that includes all the custom shapes
const customSchema = createTLSchema({
shapes: {
...defaultShapeSchemas,
ChatBox: {} as any,
VideoChat: {} as any,
Embed: {} as any,
Markdown: {} as any,
MycrozineTemplate: {} as any,
Slide: {} as any,
Prompt: {} as any,
SharedPiano: {} as any,
Transcription: {} as any,
ObsNote: {} as any,
FathomTranscript: {} as any,
Holon: {} as any,
ObsidianBrowser: {} as any,
FathomMeetingsBrowser: {} as any,
LocationShare: {} as any,
},
bindings: defaultBindingSchemas,
})
const [store] = useState(() => {
const store = createTLStore({
schema: customSchema,
shapeUtils: [
ChatBoxShape,
VideoChatShape,
EmbedShape,
MarkdownShape,
MycrozineTemplateShape,
SlideShape,
PromptShape,
SharedPianoShape,
TranscriptionShape,
ObsNoteShape,
FathomTranscriptShape,
HolonShape,
ObsidianBrowserShape,
FathomMeetingsBrowserShape,
LocationShareShape,
],
})
return store
})
const [storeWithStatus, setStoreWithStatus] = useState<TLStoreWithStatus>({
status: "loading",
})
// Debug: Log store status when it changes
useEffect(() => {
if (storeWithStatus.status === "synced-remote" && storeWithStatus.store) {
const allRecords = storeWithStatus.store.allRecords()
const shapes = allRecords.filter(r => r.typeName === 'shape')
const pages = allRecords.filter(r => r.typeName === 'page')
console.log(`📊 useAutomergeStoreV2: Store synced with ${allRecords.length} total records, ${shapes.length} shapes, ${pages.length} pages`)
}
}, [storeWithStatus.status, storeWithStatus.store])
/* -------------------- 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 isLocalChange = false
// Listen for changes from Automerge and apply them to TLDraw
const automergeChangeHandler = (payload: DocHandleChangePayload<any>) => {
if (isLocalChange) {
isLocalChange = false
return
}
try {
// Apply patches from Automerge to TLDraw store
if (payload.patches && payload.patches.length > 0) {
// Debug: Check if patches contain shapes
const shapePatches = payload.patches.filter((p: any) => {
const id = p.path?.[1]
return id && typeof id === 'string' && id.startsWith('shape:')
})
if (shapePatches.length > 0) {
console.log(`🔌 Automerge patches contain ${shapePatches.length} shape patches out of ${payload.patches.length} total patches`)
}
try {
const recordsBefore = store.allRecords()
const shapesBefore = recordsBefore.filter((r: any) => r.typeName === 'shape')
applyAutomergePatchesToTLStore(payload.patches, store)
const recordsAfter = store.allRecords()
const shapesAfter = recordsAfter.filter((r: any) => r.typeName === 'shape')
if (shapesAfter.length !== shapesBefore.length) {
console.log(`✅ Applied ${payload.patches.length} patches: shapes changed from ${shapesBefore.length} to ${shapesAfter.length}`)
}
// Only log if there are many patches or if debugging is needed
if (payload.patches.length > 5) {
console.log(`✅ Successfully applied ${payload.patches.length} patches`)
}
} catch (patchError) {
console.error("Error applying patches batch, attempting individual patch application:", patchError)
// Try applying patches one by one to identify problematic ones
// This is a fallback - ideally we should fix the data at the source
let successCount = 0
let failedPatches: any[] = []
for (const patch of payload.patches) {
try {
applyAutomergePatchesToTLStore([patch], store)
successCount++
} catch (individualPatchError) {
failedPatches.push({ patch, error: individualPatchError })
console.error(`Failed to apply individual patch:`, individualPatchError)
// Log the problematic patch for debugging
const recordId = patch.path[1] as string
console.error("Problematic patch details:", {
action: patch.action,
path: patch.path,
recordId: recordId,
value: 'value' in patch ? patch.value : undefined,
errorMessage: individualPatchError instanceof Error ? individualPatchError.message : String(individualPatchError)
})
// Try to get more context about the failing record
try {
const existingRecord = store.get(recordId as any)
console.error("Existing record that failed:", existingRecord)
// If it's a geo shape missing props.geo, try to fix it
if (existingRecord && (existingRecord as any).typeName === 'shape' && (existingRecord as any).type === 'geo') {
const geoRecord = existingRecord as any
if (!geoRecord.props || !geoRecord.props.geo) {
console.log(`🔧 Attempting to fix geo shape ${recordId} missing props.geo`)
// This won't help with the current patch, but might help future patches
// The real fix should happen in AutomergeToTLStore sanitization
}
}
} catch (e) {
console.error("Could not retrieve existing record:", e)
}
}
}
// Log summary
if (failedPatches.length > 0) {
console.error(`❌ Failed to apply ${failedPatches.length} out of ${payload.patches.length} patches`)
// Most common issue: geo shapes missing props.geo - this should be fixed in sanitization
const geoShapeErrors = failedPatches.filter(p =>
p.error instanceof Error && p.error.message.includes('props.geo')
)
if (geoShapeErrors.length > 0) {
console.error(`⚠️ ${geoShapeErrors.length} failures due to missing props.geo - this should be fixed in AutomergeToTLStore sanitization`)
}
}
if (successCount < payload.patches.length || payload.patches.length > 5) {
console.log(`✅ Successfully applied ${successCount} out of ${payload.patches.length} patches`)
}
}
}
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
})
} catch (error) {
console.error("Error applying Automerge patches to TLDraw:", error)
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "offline",
error: error instanceof Error ? error : new Error("Unknown error") as any,
})
}
}
// Set up handler BEFORE initializeStore to catch patches from initial data load
handle.on("change", automergeChangeHandler)
// Listen for changes from TLDraw and apply them to Automerge
// CRITICAL: Listen to ALL sources, not just "user", to catch richText/text changes
const unsubscribeTLDraw = store.listen(({ changes, source }) => {
// DEBUG: Log all changes to see what's being detected
const totalChanges = Object.keys(changes.added || {}).length + Object.keys(changes.updated || {}).length + Object.keys(changes.removed || {}).length
if (totalChanges > 0) {
console.log(`🔍 TLDraw store changes detected (source: ${source}):`, {
added: Object.keys(changes.added || {}).length,
updated: Object.keys(changes.updated || {}).length,
removed: Object.keys(changes.removed || {}).length,
source: source
})
// DEBUG: Check for richText/text changes in updated records
if (changes.updated) {
Object.values(changes.updated).forEach(([_, record]) => {
if (record.typeName === 'shape') {
if (record.type === 'geo' && (record.props as any)?.richText) {
console.log(`🔍 Geo shape ${record.id} richText change detected:`, {
hasRichText: !!(record.props as any).richText,
richTextType: typeof (record.props as any).richText,
source: source
})
}
if (record.type === 'note' && (record.props as any)?.richText) {
console.log(`🔍 Note shape ${record.id} richText change detected:`, {
hasRichText: !!(record.props as any).richText,
richTextType: typeof (record.props as any).richText,
richTextContentLength: Array.isArray((record.props as any).richText?.content)
? (record.props as any).richText.content.length
: 'not array',
source: source
})
}
if (record.type === 'arrow' && (record.props as any)?.text !== undefined) {
console.log(`🔍 Arrow shape ${record.id} text change detected:`, {
hasText: !!(record.props as any).text,
textValue: (record.props as any).text,
source: source
})
}
if (record.type === 'text' && (record.props as any)?.richText) {
console.log(`🔍 Text shape ${record.id} richText change detected:`, {
hasRichText: !!(record.props as any).richText,
richTextType: typeof (record.props as any).richText,
source: source
})
}
}
})
}
// DEBUG: Log added shapes to track what's being created
if (changes.added) {
Object.values(changes.added).forEach((record) => {
if (record.typeName === 'shape') {
console.log(`🔍 Shape added: ${record.type} (${record.id})`, {
type: record.type,
id: record.id,
hasRichText: !!(record.props as any)?.richText,
hasText: !!(record.props as any)?.text,
source: source
})
}
})
}
}
// CRITICAL: Don't skip changes - always save them to ensure consistency
// The isLocalChange flag is only used to prevent feedback loops from Automerge changes
// We should always save TLDraw changes, even if they came from Automerge sync
// This ensures that all shapes (notes, rectangles, etc.) are consistently persisted
try {
// Set flag to prevent feedback loop when this change comes back from Automerge
isLocalChange = true
handle.change((doc) => {
applyTLStoreChangesToAutomerge(doc, changes)
})
// Reset flag after a short delay to allow Automerge change handler to process
// This prevents feedback loops while ensuring all changes are saved
setTimeout(() => {
isLocalChange = false
}, 100)
// Only log if there are many changes or if debugging is needed
if (totalChanges > 3) {
console.log(`✅ Applied ${totalChanges} TLDraw changes to Automerge document`)
} else if (totalChanges > 0) {
console.log(`✅ Applied ${totalChanges} TLDraw change(s) to Automerge document`)
}
// Check if the document actually changed
const docAfter = handle.doc()
} catch (error) {
console.error("Error applying TLDraw changes to Automerge:", error)
// Reset flag on error to prevent getting stuck
isLocalChange = false
}
}, {
// CRITICAL: Don't filter by source - listen to ALL changes
// This ensures we catch richText/text changes regardless of their source
// (TLDraw might emit these changes with a different source than "user")
scope: "document",
})
unsubs.push(
() => handle.off("change", automergeChangeHandler),
unsubscribeTLDraw
)
// CRITICAL: Use patch-based loading exclusively (same as dev)
// No bulk loading - all data flows through patches via automergeChangeHandler
// This ensures production works exactly like dev
const initializeStore = async () => {
try {
await handle.whenReady()
const doc = handle.doc()
// Check if store is already populated from patches
const existingStoreRecords = store.allRecords()
const existingStoreShapes = existingStoreRecords.filter((r: any) => r.typeName === 'shape')
if (doc.store) {
const storeKeys = Object.keys(doc.store)
const docShapes = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
console.log(`📊 Patch-based initialization: doc has ${storeKeys.length} records (${docShapes} shapes), store has ${existingStoreRecords.length} records (${existingStoreShapes.length} shapes)`)
// If store already has shapes, patches have been applied (dev mode behavior)
if (existingStoreShapes.length > 0) {
console.log(`✅ Store already populated from patches (${existingStoreShapes.length} shapes) - using patch-based loading like dev`)
// REMOVED: Aggressive shape refresh that was causing coordinate loss
// Shapes should be visible through normal patch application
// If shapes aren't visible, it's likely a different issue that refresh won't fix
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
})
return
}
// If doc has data but store doesn't, patches should have been generated when data was written
// The automergeChangeHandler (set up above) should process them automatically
// Just wait a bit for patches to be processed, then set status
if (docShapes > 0 && existingStoreShapes.length === 0) {
console.log(`📊 Doc has ${docShapes} shapes but store is empty. Waiting for patches to be processed by handler...`)
// Wait briefly for patches to be processed by automergeChangeHandler
// The handler is already set up, so it should catch patches from the initial data load
let attempts = 0
const maxAttempts = 10 // Wait up to 2 seconds (10 * 200ms)
await new Promise<void>(resolve => {
const checkForPatches = () => {
attempts++
const currentShapes = store.allRecords().filter((r: any) => r.typeName === 'shape')
if (currentShapes.length > 0) {
console.log(`✅ Patches applied successfully: ${currentShapes.length} shapes loaded via patches`)
// REMOVED: Aggressive shape refresh that was causing coordinate loss
// Shapes loaded via patches should be visible without forced refresh
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
})
resolve()
} else if (attempts < maxAttempts) {
setTimeout(checkForPatches, 200)
} else {
// Patches didn't come through - handler may have missed them if data was written before handler was set up
// In this case, we need to manually apply the data via patches
// We'll trigger patches by making a safe change that doesn't modify existing objects
console.log(`⚠️ Patches didn't populate store. Handler may have missed initial patches. Applying data directly via patches...`)
try {
// Read all records from Automerge doc and apply them directly to store
// This is a fallback when patches are missed (works for both dev and production)
// Use the same sanitization as patches would use to ensure consistency
const allRecords: TLRecord[] = []
Object.entries(doc.store).forEach(([id, record]: [string, any]) => {
// Skip invalid records and custom record types (same as patch processing)
if (!record || !record.typeName || !record.id) {
return
}
// Skip obsidian_vault records - they're not TLDraw records
if (record.typeName === 'obsidian_vault' ||
(typeof record.id === 'string' && record.id.startsWith('obsidian_vault:'))) {
return
}
try {
// Create a clean copy of the record
const cleanRecord = JSON.parse(JSON.stringify(record))
// CRITICAL: Use the same sanitizeRecord function that patches use
// This ensures consistency between dev and production
const sanitized = sanitizeRecord(cleanRecord)
allRecords.push(sanitized)
} catch (e) {
console.warn(`⚠️ Could not serialize/sanitize record ${id}:`, e)
}
})
if (allRecords.length > 0) {
// Apply records directly to store using mergeRemoteChanges
// This bypasses patches but ensures data is loaded (works for both dev and production)
// Use mergeRemoteChanges to mark as remote changes (prevents feedback loop)
store.mergeRemoteChanges(() => {
// Separate pages, shapes, and other records to ensure proper loading order
const pageRecords = allRecords.filter(r => r.typeName === 'page')
const shapeRecords = allRecords.filter(r => r.typeName === 'shape')
const otherRecords = allRecords.filter(r => r.typeName !== 'page' && r.typeName !== 'shape')
// Put pages first, then other records, then shapes (ensures pages exist before shapes reference them)
const recordsToAdd = [...pageRecords, ...otherRecords, ...shapeRecords]
store.put(recordsToAdd)
})
console.log(`✅ Applied ${allRecords.length} records directly to store (fallback for missed patches - works in dev and production)`)
// REMOVED: Aggressive shape refresh that was causing coordinate loss
// Shapes loaded directly should be visible without forced refresh
}
} catch (error) {
console.error(`❌ Error applying records directly:`, error)
}
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
})
resolve()
}
}
// Start checking immediately since handler is already set up
setTimeout(checkForPatches, 100)
})
return
}
// If doc is empty, just set status
if (docShapes === 0) {
console.log(`📊 Empty document - starting fresh (patch-based loading)`)
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
})
return
}
} else {
// No store in doc - empty document
console.log(`📊 No store in Automerge doc - starting fresh (patch-based loading)`)
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
})
return
}
} catch (error) {
console.error("Error in patch-based initialization:", error)
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
})
}
}
initializeStore()
return () => {
unsubs.forEach((unsub) => unsub())
}
}, [handle, store])
/* -------------------- Presence -------------------- */
// Create a safe handle that won't cause null errors
const safeHandle = handle || {
on: () => {},
off: () => {},
removeListener: () => {},
whenReady: () => Promise.resolve(),
doc: () => null,
change: () => {},
broadcast: () => {},
} as any
const [, updateLocalState] = useLocalAwareness({
handle: safeHandle,
userId: _userId,
initialState: {},
})
const [peerStates] = useRemoteAwareness({
handle: safeHandle,
localUserId: _userId,
})
return {
...storeWithStatus,
store,
} as TLStoreWithStatus
}
// Presence hook (simplified version)
export function useAutomergePresence(params: {
handle: DocHandle<any> | null
store: any
userMetadata: {
userId: string
name: string
color: string
}
}) {
const { handle, store, userMetadata } = params
// Simple presence implementation
useEffect(() => {
if (!handle || !store) return
const updatePresence = () => {
// Basic presence update logic
console.log("Updating presence for user:", userMetadata.userId)
}
updatePresence()
}, [handle, store, userMetadata])
return {
updatePresence: () => {},
presence: {},
}
}