canvas-website/src/automerge/useAutomergeStoreV2.ts

1306 lines
53 KiB
TypeScript

import {
TLRecord,
TLStoreWithStatus,
createTLStore,
TLStoreSnapshot,
RecordsDiff,
} from "@tldraw/tldraw"
import { createTLSchema, defaultBindingSchemas, defaultShapeSchemas } from "@tldraw/tlschema"
import { useEffect, useState, useRef } from "react"
import { DocHandle, DocHandleChangePayload } from "@automerge/automerge-repo"
import {
useLocalAwareness,
useRemoteAwareness,
} from "@automerge/automerge-repo-react-hooks"
import throttle from "lodash.throttle"
import { applyAutomergePatchesToTLStore, sanitizeRecord } from "./AutomergeToTLStore.js"
import { applyTLStoreChangesToAutomerge } from "./TLStoreToAutomerge.js"
// Helper function to safely extract plain objects from Automerge proxies
// This handles cases where JSON.stringify fails due to functions or getters
function safeExtractPlainObject(obj: any, visited = new WeakSet()): any {
// Handle null and undefined
if (obj === null || obj === undefined) {
return obj
}
// Handle primitives
if (typeof obj !== 'object') {
return obj
}
// Handle circular references
if (visited.has(obj)) {
return null
}
visited.add(obj)
// Handle arrays
if (Array.isArray(obj)) {
try {
return obj.map(item => safeExtractPlainObject(item, visited))
} catch (e) {
return []
}
}
// Handle objects
try {
const result: any = {}
// Use Object.keys to get enumerable properties, which is safer than for...in
// for Automerge proxies
const keys = Object.keys(obj)
for (const key of keys) {
try {
// Safely get the property value
// Use Object.getOwnPropertyDescriptor to check if it's a getter
const descriptor = Object.getOwnPropertyDescriptor(obj, key)
if (descriptor) {
// If it's a getter, try to get the value, but catch any errors
if (descriptor.get) {
try {
const value = descriptor.get.call(obj)
// Skip functions
if (typeof value === 'function') {
continue
}
result[key] = safeExtractPlainObject(value, visited)
} catch (e) {
// Skip properties that can't be accessed via getter
continue
}
} else if (descriptor.value !== undefined) {
// Regular property
const value = descriptor.value
// Skip functions
if (typeof value === 'function') {
continue
}
result[key] = safeExtractPlainObject(value, visited)
}
} else {
// Fallback: try direct access
try {
const value = obj[key]
// Skip functions
if (typeof value === 'function') {
continue
}
result[key] = safeExtractPlainObject(value, visited)
} catch (e) {
// Skip properties that can't be accessed
continue
}
}
} catch (e) {
// Skip properties that can't be accessed
continue
}
}
return result
} catch (e) {
// If extraction fails, try JSON.stringify as fallback
try {
return JSON.parse(JSON.stringify(obj))
} catch (jsonError) {
// If that also fails, return empty object
return {}
}
}
}
// 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 { TranscriptionShape } from "@/shapes/TranscriptionShapeUtil"
import { ObsNoteShape } from "@/shapes/ObsNoteShapeUtil"
import { FathomNoteShape } from "@/shapes/FathomNoteShapeUtil"
import { HolonShape } from "@/shapes/HolonShapeUtil"
import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil"
import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil"
import { ImageGenShape } from "@/shapes/ImageGenShapeUtil"
import { VideoGenShape } from "@/shapes/VideoGenShapeUtil"
import { MultmuxShape } from "@/shapes/MultmuxShapeUtil"
// MycelialIntelligence moved to permanent UI bar - shape kept for backwards compatibility
import { MycelialIntelligenceShape } from "@/shapes/MycelialIntelligenceShapeUtil"
// Open Mapping - OSM map shape for geographic visualization
import { MapShape } from "@/shapes/MapShapeUtil"
export function useAutomergeStoreV2({
handle,
userId: _userId,
adapter,
}: {
handle: DocHandle<any>
userId: string
adapter?: any
}): TLStoreWithStatus {
// useAutomergeStoreV2 initializing
// Create store with shape utils and explicit schema for all custom shapes
// Note: Some shapes don't have `static override props`, so we must explicitly list them all
const [store] = useState(() => {
const shapeUtils = [
ChatBoxShape,
VideoChatShape,
EmbedShape,
MarkdownShape,
MycrozineTemplateShape,
SlideShape,
PromptShape,
TranscriptionShape,
ObsNoteShape,
FathomNoteShape,
HolonShape,
ObsidianBrowserShape,
FathomMeetingsBrowserShape,
ImageGenShape,
VideoGenShape,
MultmuxShape,
MycelialIntelligenceShape, // Deprecated - kept for backwards compatibility
MapShape, // Open Mapping - OSM map shape
]
// CRITICAL: Explicitly list ALL custom shape types to ensure they're registered
// This is a fallback in case dynamic extraction from shape utils fails
const knownCustomShapeTypes = [
'ChatBox',
'VideoChat',
'Embed',
'Markdown',
'MycrozineTemplate',
'Slide',
'Prompt',
'Transcription',
'ObsNote',
'FathomNote',
'Holon',
'ObsidianBrowser',
'FathomMeetingsBrowser',
'ImageGen',
'VideoGen',
'Multmux',
'MycelialIntelligence', // Deprecated - kept for backwards compatibility
'Map', // Open Mapping - OSM map shape
]
// Build schema with explicit entries for all custom shapes
const customShapeSchemas: Record<string, any> = {}
// First, register all known custom shape types with empty schemas as fallback
knownCustomShapeTypes.forEach(type => {
customShapeSchemas[type] = {} as any
})
// Then, override with actual props for shapes that have them defined
shapeUtils.forEach((util) => {
const type = (util as any).type
if (type && (util as any).props) {
// Shape has static props - use them for proper validation
customShapeSchemas[type] = {
props: (util as any).props,
migrations: (util as any).migrations,
}
}
})
// Log what shapes were registered for debugging
// Custom shape schemas registered
const customSchema = createTLSchema({
shapes: {
...defaultShapeSchemas,
...customShapeSchemas,
},
bindings: defaultBindingSchemas,
})
const store = createTLStore({
schema: customSchema,
shapeUtils: shapeUtils,
})
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')
// Store synced
}
}, [storeWithStatus.status, storeWithStatus.store])
/* -------------------- TLDraw <--> Automerge -------------------- */
useEffect(() => {
// Early return if handle is not available
if (!handle) {
setStoreWithStatus({ status: "loading" })
return
}
const unsubs: (() => void)[] = []
// Track pending local changes using a COUNTER instead of a boolean.
// The old boolean approach failed because during rapid changes (like dragging),
// multiple echoes could arrive but only the first was skipped.
// With a counter:
// - Increment before each handle.change()
// - Decrement (and skip) for each echo that arrives
// - Process changes only when counter is 0 (those are remote changes)
let pendingLocalChanges = 0
// Helper function to broadcast changes via JSON sync
// DISABLED: This causes last-write-wins conflicts
// Automerge should handle sync automatically via binary protocol
// We're keeping this function but disabling all actual broadcasting
const broadcastJsonSync = (addedOrUpdatedRecords: any[], deletedRecordIds: string[] = []) => {
// TEMPORARY FIX: Manually broadcast changes via WebSocket since Automerge Repo sync isn't working
// This sends the full changed records as JSON to other clients
// TODO: Fix Automerge Repo's binary sync protocol to work properly
if ((!addedOrUpdatedRecords || addedOrUpdatedRecords.length === 0) && deletedRecordIds.length === 0) {
return
}
// Broadcasting changes via JSON sync (logging disabled for performance)
if (adapter && typeof (adapter as any).send === 'function') {
// Send changes to other clients via the network adapter
// CRITICAL: Always include a documentId for the server to process correctly
const docId: string = handle?.documentId || `automerge:${Date.now()}`;
const adapterSend = (adapter as any).send.bind(adapter);
adapterSend({
type: 'sync',
data: {
store: Object.fromEntries(addedOrUpdatedRecords.map(r => [r.id, r])),
deleted: deletedRecordIds // Include list of deleted record IDs
},
documentId: docId,
timestamp: Date.now()
})
} else {
console.warn('⚠️ Cannot broadcast - adapter not available')
}
}
// Listen for changes from Automerge and apply them to TLDraw
const automergeChangeHandler = (payload: DocHandleChangePayload<any>) => {
const patchCount = payload.patches?.length || 0
// Skip echoes of our own local changes using a counter.
// Each local handle.change() increments the counter, and each echo decrements it.
// Only process changes when counter is 0 (those are remote changes from other clients).
if (pendingLocalChanges > 0) {
pendingLocalChanges--
return
}
try {
// Apply patches from Automerge to TLDraw store
if (payload.patches && payload.patches.length > 0) {
try {
// CRITICAL: Pass Automerge document to patch handler so it can read full records
// This prevents coordinates from defaulting to 0,0 when patches create new records
const automergeDoc = handle.doc()
applyAutomergePatchesToTLStore(payload.patches, store, automergeDoc)
} 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[] = []
// CRITICAL: Pass Automerge document to patch handler so it can read full records
const automergeDoc = handle.doc()
for (const patch of payload.patches) {
try {
applyAutomergePatchesToTLStore([patch], store, automergeDoc)
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) {
// Partial patches applied
}
}
}
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)
// CRITICAL: If data was written to Automerge before this handler was set up,
// manually trigger patch processing by reading the current doc state
// This handles the case where useAutomergeSyncRepo writes data before useAutomergeStoreV2 sets up the handler
// We do this synchronously when the handler is set up to catch any missed patches
const currentDoc = handle.doc()
if (currentDoc && currentDoc.store && Object.keys(currentDoc.store).length > 0) {
const docShapeCount = Object.values(currentDoc.store).filter((r: any) => r?.typeName === 'shape').length
const storeShapeCount = store.allRecords().filter((r: any) => r.typeName === 'shape').length
if (docShapeCount > 0 && storeShapeCount === 0) {
console.log(`🔧 Handler set up after data was written. Manually processing ${docShapeCount} shapes that were loaded before handler was ready...`)
// Since patches were already emitted when handle.change() was called in useAutomergeSyncRepo,
// we need to manually process the data that's already in the doc
try {
const allRecords: TLRecord[] = []
Object.entries(currentDoc.store).forEach(([id, record]: [string, any]) => {
if (!record || !record.typeName || !record.id) return
if (record.typeName === 'obsidian_vault' || (typeof record.id === 'string' && record.id.startsWith('obsidian_vault:'))) return
try {
let cleanRecord: any
try {
cleanRecord = JSON.parse(JSON.stringify(record))
} catch {
cleanRecord = safeExtractPlainObject(record)
}
if (cleanRecord && typeof cleanRecord === 'object') {
const sanitized = sanitizeRecord(cleanRecord)
const plainSanitized = JSON.parse(JSON.stringify(sanitized))
allRecords.push(plainSanitized)
}
} catch (e) {
console.warn(`⚠️ Could not process record ${id}:`, e)
}
})
// Filter out SharedPiano shapes since they're no longer supported
const filteredRecords = allRecords.filter((record: any) => {
if (record.typeName === 'shape' && record.type === 'SharedPiano') {
console.log(`⚠️ Filtering out deprecated SharedPiano shape: ${record.id}`)
return false
}
return true
})
if (filteredRecords.length > 0) {
console.log(`🔧 Manually applying ${filteredRecords.length} records to store (patches were missed during initial load, filtered out ${allRecords.length - filteredRecords.length} SharedPiano shapes)`)
store.mergeRemoteChanges(() => {
const pageRecords = filteredRecords.filter(r => r.typeName === 'page')
const shapeRecords = filteredRecords.filter(r => r.typeName === 'shape')
const otherRecords = filteredRecords.filter(r => r.typeName !== 'page' && r.typeName !== 'shape')
const recordsToAdd = [...pageRecords, ...otherRecords, ...shapeRecords]
store.put(recordsToAdd)
})
console.log(`✅ Manually applied ${filteredRecords.length} records to store`)
}
} catch (error) {
console.error(`❌ Error manually processing initial data:`, error)
}
}
}
// Throttle position-only updates (x/y changes) to reduce automerge saves during movement
let positionUpdateQueue: RecordsDiff<TLRecord> | null = null
let positionUpdateTimeout: NodeJS.Timeout | null = null
const POSITION_UPDATE_THROTTLE_MS = 50 // Save position updates every 50ms for near real-time feel
const flushPositionUpdates = () => {
if (positionUpdateQueue && handle) {
const queuedChanges = positionUpdateQueue
positionUpdateQueue = null
// Apply immediately for real-time sync
try {
pendingLocalChanges++
handle.change((doc) => {
applyTLStoreChangesToAutomerge(doc, queuedChanges)
})
// Trigger sync to broadcast position updates
// CRITICAL: updated records are [before, after] tuples - extract the 'after' value
const addedOrUpdatedRecords = [
...Object.values(queuedChanges.added || {}),
...Object.values(queuedChanges.updated || {}).map((tuple: any) => Array.isArray(tuple) ? tuple[1] : tuple)
]
const deletedRecordIds = Object.keys(queuedChanges.removed || {})
broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds)
} catch (error) {
console.error("Error applying throttled position updates to Automerge:", error)
}
}
}
// Helper to check if a change is only a position update (x/y changed, nothing else)
const isPositionOnlyUpdate = (changes: RecordsDiff<TLRecord>): boolean => {
// If there are added or removed records, it's not just a position update
if (changes.added && Object.keys(changes.added).length > 0) return false
if (changes.removed && Object.keys(changes.removed).length > 0) return false
// Check if all updated records are only position changes
if (changes.updated) {
const doc = handle?.doc()
if (!doc?.store) return false
for (const [id, recordTuple] of Object.entries(changes.updated)) {
const isTuple = Array.isArray(recordTuple) && recordTuple.length === 2
const oldRecord = isTuple ? recordTuple[0] : null
const newRecord = isTuple ? recordTuple[1] : recordTuple
if (!oldRecord || !newRecord) return false
// Check if it's a shape record (not a tuple)
const record = newRecord as any
if (!record || typeof record !== 'object' || !('typeName' in record)) return false
if (record.typeName !== 'shape') return false
// Check if only x/y changed
const oldX = (oldRecord as any).x
const oldY = (oldRecord as any).y
const newX = record.x
const newY = record.y
// If x/y didn't change, it's not a position update
if (oldX === newX && oldY === newY) return false
// Check if any other properties changed
for (const key of Object.keys(record)) {
if (key === 'x' || key === 'y') continue
if (key === 'props') {
// Deep compare props - only if both records have props
const oldProps = (oldRecord as any)?.props || {}
const newProps = record?.props || {}
if (JSON.stringify(oldProps) !== JSON.stringify(newProps)) {
return false // Props changed, not just position
}
} else {
if ((oldRecord as any)[key] !== record[key]) {
return false // Other property changed
}
}
}
}
return true // All updates are position-only
}
return false
}
// Track recent eraser activity to detect active eraser drags
let lastEraserActivity = 0
let eraserToolSelected = false
let lastEraserCheckTime = 0
let cachedEraserActive = false
const ERASER_ACTIVITY_THRESHOLD = 2000 // Increased to 2 seconds to handle longer eraser drags
const ERASER_CHECK_CACHE_MS = 100 // Only refresh eraser state every 100ms to avoid expensive checks
let eraserChangeQueue: RecordsDiff<TLRecord> | null = null
let eraserCheckInterval: NodeJS.Timeout | null = null
// Helper to check if eraser tool is actively erasing (to prevent saves during eraser drag)
// OPTIMIZED: Uses cached state and only refreshes periodically to avoid expensive store.allRecords() calls
const isEraserActive = (): boolean => {
const now = Date.now()
// Use cached result if checked recently
if (now - lastEraserCheckTime < ERASER_CHECK_CACHE_MS) {
return cachedEraserActive
}
lastEraserCheckTime = now
// If eraser was selected and recent activity, assume still active
if (eraserToolSelected && now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
cachedEraserActive = true
return true
}
// If no recent eraser activity and not marked as selected, quickly return false
if (!eraserToolSelected && now - lastEraserActivity > ERASER_ACTIVITY_THRESHOLD) {
cachedEraserActive = false
return false
}
// Only do expensive check if eraser might be transitioning
try {
// Use store.get() for specific records instead of allRecords() for better performance
const instancePageState = store.get('instance_page_state:page:page' as any)
// Check instance_page_state for erasingShapeIds (most reliable indicator)
if (instancePageState &&
(instancePageState as any).erasingShapeIds &&
Array.isArray((instancePageState as any).erasingShapeIds) &&
(instancePageState as any).erasingShapeIds.length > 0) {
lastEraserActivity = now
eraserToolSelected = true
cachedEraserActive = true
return true // Eraser is actively erasing shapes
}
// Check if eraser tool is selected
const instance = store.get('instance:instance' as any)
const currentToolId = instance ? (instance as any).currentToolId : null
if (currentToolId === 'eraser') {
eraserToolSelected = true
lastEraserActivity = now
cachedEraserActive = true
return true
} else {
eraserToolSelected = false
}
cachedEraserActive = false
return false
} catch (e) {
// If we can't check, use last known state with timeout
if (eraserToolSelected && now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
cachedEraserActive = true
return true
}
cachedEraserActive = false
return false
}
}
// Track eraser activity from shape deletions
// OPTIMIZED: Only check for eraser tool when shapes are removed, and use cached tool state
const checkForEraserActivity = (changes: RecordsDiff<TLRecord>) => {
// If shapes are being removed and eraser tool might be active, mark activity
if (changes.removed) {
const removedKeys = Object.keys(changes.removed)
// Quick check: if no shape keys, skip
const hasRemovedShapes = removedKeys.some(key => key.startsWith('shape:'))
if (hasRemovedShapes) {
// Use cached eraserToolSelected state if recent, avoid expensive allRecords() call
const now = Date.now()
if (eraserToolSelected || now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
lastEraserActivity = now
}
}
}
}
// 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 }) => {
// Check for eraser activity from shape deletions
checkForEraserActivity(changes)
// Filter out ephemeral records that shouldn't be persisted
// These include:
// - instance: UI state (cursor, screen bounds, etc.)
// - instance_page_state: selection state, editing state, etc.
// - instance_presence: presence/awareness data
// - camera: viewport position (x, y, z) - changes when panning/zooming
// - pointer: pointer position - changes on mouse movement
const ephemeralTypes = ['instance', 'instance_page_state', 'instance_presence', 'camera', 'pointer']
const filterEphemeral = (records: any) => {
if (!records) return {}
const filtered: any = {}
Object.entries(records).forEach(([id, record]: [string, any]) => {
const recordObj = Array.isArray(record) ? record[1] : record
// Check typeName from the record object
const typeName = recordObj?.typeName
// Also check if ID pattern matches ephemeral types (e.g., "camera:page:page")
const idMatchesEphemeral = typeof id === 'string' && (
id.startsWith('instance:') ||
id.startsWith('instance_page_state:') ||
id.startsWith('instance_presence:') ||
id.startsWith('camera:') ||
id.startsWith('pointer:')
)
// Filter out if typeName matches OR if ID pattern matches ephemeral types
if (typeName && ephemeralTypes.includes(typeName)) {
// Skip - this is an ephemeral record
return
}
if (idMatchesEphemeral) {
// Skip - ID pattern indicates ephemeral record (even if typeName is missing)
return
}
// Keep this record - it's not ephemeral
filtered[id] = record
})
return filtered
}
const filteredChanges = {
added: filterEphemeral(changes.added),
updated: filterEphemeral(changes.updated),
removed: filterEphemeral(changes.removed),
}
// Calculate change counts (minimal, needed for early return)
const filteredTotalChanges = Object.keys(filteredChanges.added || {}).length + Object.keys(filteredChanges.updated || {}).length + Object.keys(filteredChanges.removed || {}).length
// Skip if no meaningful changes after filtering ephemeral records
if (filteredTotalChanges === 0) {
return
}
// CRITICAL: Skip broadcasting changes that came from remote sources to prevent feedback loops
// Only broadcast changes that originated from user interactions (source === 'user')
if (source === 'remote') {
return
}
// CRITICAL: Filter out x/y coordinate changes for pinned-to-view shapes
// When a shape is pinned, its x/y coordinates change to stay in the same screen position,
// but we want to keep the original coordinates static in Automerge
const filterPinnedPositionChanges = (changes: any) => {
if (!changes || !handle) return changes
const doc = handle.doc()
if (!doc?.store) return changes
// First, check if there are ANY pinned shapes in the document
// Only filter if there are actually pinned shapes
// Use strict equality check to ensure we only match true (not truthy values)
const hasPinnedShapes = Object.values(doc.store).some((record: any) => {
const isShape = record?.typeName === 'shape'
const isPinned = record?.props?.pinnedToView === true
return isShape && isPinned
})
// Also check the changes being processed to see if any shapes are pinned
let hasPinnedShapesInChanges = false
if (changes.updated) {
hasPinnedShapesInChanges = Object.entries(changes.updated).some(([id, recordTuple]: [string, any]) => {
const isTuple = Array.isArray(recordTuple) && recordTuple.length === 2
const newRecord = isTuple ? recordTuple[1] : recordTuple
const isShape = newRecord?.typeName === 'shape'
const isPinned = (newRecord.props as any)?.pinnedToView === true
// Also verify in the doc that it's actually pinned
const docShape = doc.store[id]
const isPinnedInDoc = docShape?.props?.pinnedToView === true
return isShape && isPinned && isPinnedInDoc
})
}
// If there are no pinned shapes in either the doc or the changes, skip filtering entirely
if (!hasPinnedShapes && !hasPinnedShapesInChanges) {
return changes
}
const filtered: any = { ...changes }
// Check updated shapes for pinned position changes
if (filtered.updated) {
const updatedEntries = Object.entries(filtered.updated)
const filteredUpdated: any = {}
updatedEntries.forEach(([id, recordTuple]: [string, any]) => {
// TLDraw store changes use tuple format [oldRecord, newRecord] for updates
const isTuple = Array.isArray(recordTuple) && recordTuple.length === 2
const oldRecord = isTuple ? recordTuple[0] : null
const newRecord = isTuple ? recordTuple[1] : recordTuple
const record = newRecord
// Get the original shape from Automerge doc to verify it's actually pinned
const originalShape = doc.store[id]
// STRICT CHECK: Must be a shape, must have pinnedToView === true in BOTH the record AND the doc
const isShape = record?.typeName === 'shape'
const isPinnedInRecord = (record.props as any)?.pinnedToView === true
const isPinnedInDoc = originalShape?.props?.pinnedToView === true
// Only filter if the shape is actually pinned in BOTH places
if (isShape && isPinnedInRecord && isPinnedInDoc) {
if (originalShape) {
const originalX = originalShape.x
const originalY = originalShape.y
const newX = (record as any).x
const newY = (record as any).y
// If only x/y coordinates changed, restore original coordinates
// Compare all other properties to see if anything else changed
const otherPropsChanged = Object.keys(record).some(key => {
if (key === 'x' || key === 'y') return false
if (key === 'props') {
// Check if props changed (excluding pinnedToView changes)
const oldProps = oldRecord?.props || originalShape?.props || {}
const newProps = record.props || {}
// Deep compare props (excluding pinnedToView which might change)
const oldPropsCopy = { ...oldProps }
const newPropsCopy = { ...newProps }
delete oldPropsCopy.pinnedToView
delete newPropsCopy.pinnedToView
return JSON.stringify(oldPropsCopy) !== JSON.stringify(newPropsCopy)
}
const oldValue = oldRecord?.[key] ?? originalShape?.[key]
return oldValue !== record[key]
})
// If only position changed (x/y), restore original coordinates
if (!otherPropsChanged && (newX !== originalX || newY !== originalY)) {
console.log(`🚫 Filtering out x/y coordinate change for pinned shape ${id}: (${newX}, ${newY}) -> keeping original (${originalX}, ${originalY})`)
// Restore original coordinates
const recordWithOriginalCoords = {
...record,
x: originalX,
y: originalY
}
filteredUpdated[id] = isTuple
? [oldRecord, recordWithOriginalCoords]
: recordWithOriginalCoords
} else if (otherPropsChanged) {
// Other properties changed, keep the update but restore coordinates
const recordWithOriginalCoords = {
...record,
x: originalX,
y: originalY
}
filteredUpdated[id] = isTuple
? [oldRecord, recordWithOriginalCoords]
: recordWithOriginalCoords
} else {
// No changes or only non-position changes, keep as is
filteredUpdated[id] = recordTuple
}
} else {
// Shape not in doc yet, keep as is
filteredUpdated[id] = recordTuple
}
} else {
// Not a pinned shape (or not pinned in both places), keep as is
filteredUpdated[id] = recordTuple
}
})
filtered.updated = filteredUpdated
}
return filtered
}
const finalFilteredChanges = filterPinnedPositionChanges(filteredChanges)
// Check if this is a position-only update that should be throttled
const isPositionOnly = isPositionOnlyUpdate(finalFilteredChanges)
if (isPositionOnly && positionUpdateQueue === null) {
// Start a new queue for position updates
positionUpdateQueue = finalFilteredChanges
// Clear any existing timeout
if (positionUpdateTimeout) {
clearTimeout(positionUpdateTimeout)
}
// Schedule flush after throttle period
positionUpdateTimeout = setTimeout(() => {
flushPositionUpdates()
positionUpdateTimeout = null
}, POSITION_UPDATE_THROTTLE_MS)
return // Don't save immediately, wait for throttle
} else if (isPositionOnly && positionUpdateQueue !== null) {
// Merge with existing position update queue
// Merge added records
if (finalFilteredChanges.added) {
positionUpdateQueue.added = {
...(positionUpdateQueue.added || {}),
...finalFilteredChanges.added
}
}
// Merge updated records (keep latest)
if (finalFilteredChanges.updated) {
positionUpdateQueue.updated = {
...(positionUpdateQueue.updated || {}),
...finalFilteredChanges.updated
}
}
// Merge removed records
if (finalFilteredChanges.removed) {
positionUpdateQueue.removed = {
...(positionUpdateQueue.removed || {}),
...finalFilteredChanges.removed
}
}
// Reset the timeout
if (positionUpdateTimeout) {
clearTimeout(positionUpdateTimeout)
}
positionUpdateTimeout = setTimeout(() => {
flushPositionUpdates()
positionUpdateTimeout = null
}, POSITION_UPDATE_THROTTLE_MS)
return // Don't save immediately, wait for throttle
} else {
// Not a position-only update, or we have non-position changes
// Flush any queued position updates first
if (positionUpdateQueue) {
flushPositionUpdates()
}
// CRITICAL: Don't skip changes - always save them to ensure consistency
// The local change timestamp is only used to prevent immediate feedback loops
// 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 {
// CRITICAL: Check if eraser is actively erasing - if so, defer the save
const eraserActive = isEraserActive()
if (eraserActive) {
// Eraser is active - queue the changes and apply when eraser becomes inactive
// Merge with existing queued changes
if (eraserChangeQueue) {
// Merge added records
if (finalFilteredChanges.added) {
eraserChangeQueue.added = {
...(eraserChangeQueue.added || {}),
...finalFilteredChanges.added
}
}
// Merge updated records (keep latest)
if (finalFilteredChanges.updated) {
eraserChangeQueue.updated = {
...(eraserChangeQueue.updated || {}),
...finalFilteredChanges.updated
}
}
// Merge removed records
if (finalFilteredChanges.removed) {
eraserChangeQueue.removed = {
...(eraserChangeQueue.removed || {}),
...finalFilteredChanges.removed
}
}
} else {
eraserChangeQueue = finalFilteredChanges
}
// Start checking for when eraser becomes inactive
if (!eraserCheckInterval) {
eraserCheckInterval = setInterval(() => {
const stillActive = isEraserActive()
if (!stillActive && eraserChangeQueue) {
// Eraser is no longer active - flush queued changes
const queuedChanges = eraserChangeQueue
eraserChangeQueue = null
if (eraserCheckInterval) {
clearInterval(eraserCheckInterval)
eraserCheckInterval = null
}
// Apply queued changes immediately
try {
pendingLocalChanges++
handle.change((doc) => {
applyTLStoreChangesToAutomerge(doc, queuedChanges)
})
// Trigger sync to broadcast eraser changes
// CRITICAL: updated records are [before, after] tuples - extract the 'after' value
const addedOrUpdatedRecords = [
...Object.values(queuedChanges.added || {}),
...Object.values(queuedChanges.updated || {}).map((tuple: any) => Array.isArray(tuple) ? tuple[1] : tuple)
]
const deletedRecordIds = Object.keys(queuedChanges.removed || {})
broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds)
} catch (error) {
console.error('❌ Error applying queued eraser changes:', error)
}
}
}, 50) // Check every 50ms for faster response
}
return // Don't save immediately while eraser is active
} else {
// If eraser was active but now isn't, flush any queued changes first
if (eraserChangeQueue) {
const queuedChanges = eraserChangeQueue
eraserChangeQueue = null
if (eraserCheckInterval) {
clearInterval(eraserCheckInterval)
eraserCheckInterval = null
}
// Merge current changes with queued changes
const mergedChanges: RecordsDiff<TLRecord> = {
added: { ...(queuedChanges.added || {}), ...(finalFilteredChanges.added || {}) },
updated: { ...(queuedChanges.updated || {}), ...(finalFilteredChanges.updated || {}) },
removed: { ...(queuedChanges.removed || {}), ...(finalFilteredChanges.removed || {}) }
}
// Apply immediately for real-time sync
pendingLocalChanges++
handle.change((doc) => {
applyTLStoreChangesToAutomerge(doc, mergedChanges)
})
// Trigger sync to broadcast merged changes
// CRITICAL: updated records are [before, after] tuples - extract the 'after' value
const addedOrUpdatedRecords = [
...Object.values(mergedChanges.added || {}),
...Object.values(mergedChanges.updated || {}).map((tuple: any) => Array.isArray(tuple) ? tuple[1] : tuple)
]
const deletedRecordIds = Object.keys(mergedChanges.removed || {})
broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds)
return
}
// Apply changes immediately for real-time sync (no deferral)
// The old requestIdleCallback approach caused multi-second delays
pendingLocalChanges++
handle.change((doc) => {
applyTLStoreChangesToAutomerge(doc, finalFilteredChanges)
})
// CRITICAL: Broadcast immediately for real-time collaboration
// CRITICAL: updated records are [before, after] tuples - extract the 'after' value
const addedOrUpdatedRecords = [
...Object.values(finalFilteredChanges.added || {}),
...Object.values(finalFilteredChanges.updated || {}).map((tuple: any) => Array.isArray(tuple) ? tuple[1] : tuple)
]
const deletedRecordIds = Object.keys(finalFilteredChanges.removed || {})
broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds)
}
// Logging disabled for performance during continuous drawing
// Check if the document actually changed
const docAfter = handle.doc()
} catch (error) {
console.error("Error applying TLDraw changes to Automerge:", error)
}
}
}, {
// 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,
() => {
// Cleanup: flush any pending position updates and clear timeout
if (positionUpdateTimeout) {
clearTimeout(positionUpdateTimeout)
positionUpdateTimeout = null
}
if (positionUpdateQueue) {
flushPositionUpdates()
}
// Cleanup: flush any pending eraser changes and clear interval
if (eraserCheckInterval) {
clearInterval(eraserCheckInterval)
eraserCheckInterval = null
}
if (eraserChangeQueue) {
// Flush queued eraser changes on unmount
const queuedChanges = eraserChangeQueue
eraserChangeQueue = null
if (handle) {
pendingLocalChanges++
handle.change((doc) => {
applyTLStoreChangesToAutomerge(doc, queuedChanges)
})
}
}
}
)
// 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 - this should be rare if handler is set up before data load
// Log a warning but don't show disruptive confirmation dialog
console.warn(`⚠️ No patches received after ${maxAttempts} attempts for room initialization.`)
console.warn(`⚠️ This may happen if Automerge doc was initialized with server data before handler was ready.`)
console.warn(`⚠️ Store will remain empty - patches should handle data loading in normal operation.`)
// Simplified fallback: Just log and continue with empty store
// Patches should handle data loading, so if they don't come through,
// it's likely the document is actually empty or there's a timing issue
// that will resolve on next sync
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
}
adapter?: any
}) {
const { handle, store, userMetadata, adapter } = params
const presenceRef = useRef<Map<string, any>>(new Map())
// Broadcast local presence to other clients
useEffect(() => {
if (!handle || !store || !adapter) {
return
}
// Listen for changes to instance_presence records in the store
// These represent user cursors, selections, etc.
const handleStoreChange = () => {
if (!store) return
const allRecords = store.allRecords()
// Filter for ALL presence-related records
// instance_presence: Contains user cursor, name, color - THIS IS WHAT WE NEED!
// instance_page_state: Contains selections, editing state
// pointer: Contains pointer position
const presenceRecords = allRecords.filter((r: any) => {
const isPresenceType = r.typeName === 'instance_presence' ||
r.typeName === 'instance_page_state' ||
r.typeName === 'pointer'
const hasPresenceId = r.id?.startsWith('instance_presence:') ||
r.id?.startsWith('instance_page_state:') ||
r.id?.startsWith('pointer:')
return isPresenceType || hasPresenceId
})
if (presenceRecords.length > 0) {
// Send presence update via WebSocket
try {
const presenceData: any = {}
presenceRecords.forEach((record: any) => {
presenceData[record.id] = record
})
adapter.send({
type: 'presence',
userId: userMetadata.userId,
userName: userMetadata.name,
userColor: userMetadata.color,
data: presenceData
})
} catch (error) {
console.error('Error broadcasting presence:', error)
}
}
}
// Throttle presence updates to avoid overwhelming the network
const throttledUpdate = throttle(handleStoreChange, 100)
const unsubscribe = store.listen(throttledUpdate, { scope: 'all' })
return () => {
unsubscribe()
}
}, [handle, store, userMetadata, adapter])
return {
updatePresence: () => {},
presence: presenceRef.current,
}
}