582 lines
24 KiB
TypeScript
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: {},
|
|
}
|
|
}
|