canvas-website/src/automerge/useAutomergeStoreV2.ts

2051 lines
107 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 } 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) {
try {
applyAutomergePatchesToTLStore(payload.patches, store)
// 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, attempting individual patch application:", patchError)
// Try applying patches one by one to identify problematic ones
let successCount = 0
for (const patch of payload.patches) {
try {
applyAutomergePatchesToTLStore([patch], store)
successCount++
} catch (individualPatchError) {
console.error(`Failed to apply individual patch:`, individualPatchError)
// Log the problematic patch for debugging
console.error("Problematic patch details:", {
action: patch.action,
path: patch.path,
value: 'value' in patch ? patch.value : undefined,
patchId: patch.path[1],
errorMessage: individualPatchError instanceof Error ? individualPatchError.message : String(individualPatchError)
})
// Try to get more context about the failing record
const recordId = patch.path[1] as string
try {
const existingRecord = store.get(recordId as any)
console.error("Existing record that failed:", existingRecord)
} catch (e) {
console.error("Could not retrieve existing record:", e)
}
}
}
// Only log if there are failures or many patches
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,
})
}
}
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
)
// Initial load - populate TLDraw store from Automerge document
const initializeStore = async () => {
try {
// Only log if debugging is needed
// console.log("Starting TLDraw store initialization...")
await handle.whenReady()
// console.log("Automerge handle is ready")
const doc = handle.doc()
// Only log if debugging is needed
// console.log("Got Automerge document (FIXED VERSION):", {
// hasStore: !!doc.store,
// storeKeys: doc.store ? Object.keys(doc.store).length : 0,
// })
// Skip pre-sanitization to avoid Automerge reference errors
// We'll handle validation issues in the record processing loop instead
// Force cache refresh - pre-sanitization code has been removed
// Initialize store with existing records from Automerge
if (doc.store) {
const allStoreValues = Object.values(doc.store)
console.log("All store values from Automerge:", allStoreValues.map((v: any) => ({
hasTypeName: !!v?.typeName,
hasId: !!v?.id,
typeName: v?.typeName,
id: v?.id
})))
// Simple filtering - only keep valid TLDraw records
// Skip custom record types like obsidian_vault - they're not TLDraw records
// Components should read them directly from Automerge (like ObsidianVaultBrowser does)
const records = allStoreValues.filter((record: any) => {
if (!record || !record.typeName || !record.id) return false
// Skip obsidian_vault records - they're not TLDraw records
if (record.typeName === 'obsidian_vault' ||
(typeof record.id === 'string' && record.id.startsWith('obsidian_vault:'))) {
return false
}
return true
})
// Only log if there are many records or if debugging is needed
if (records.length > 50) {
console.log(`Found ${records.length} valid records in Automerge document`)
}
// CRITICAL FIXES ONLY - preserve all other properties
// Note: obsidian_vault records are filtered out above - they're not TLDraw records
const processedRecords = records.map((record: any) => {
// Create a deep copy to avoid modifying immutable Automerge objects
const processedRecord = JSON.parse(JSON.stringify(record))
// CRITICAL FIXES ONLY - preserve all other properties
if (processedRecord.typeName === 'shape') {
// Ensure basic required properties exist
if (typeof processedRecord.x !== 'number') processedRecord.x = 0
if (typeof processedRecord.y !== 'number') processedRecord.y = 0
if (typeof processedRecord.rotation !== 'number') processedRecord.rotation = 0
if (typeof processedRecord.isLocked !== 'boolean') processedRecord.isLocked = false
if (typeof processedRecord.opacity !== 'number') processedRecord.opacity = 1
if (!processedRecord.meta || typeof processedRecord.meta !== 'object') processedRecord.meta = {}
if (!processedRecord.index) processedRecord.index = 'a1'
if (!processedRecord.parentId) {
const pageRecord = records.find((r: any) => r.typeName === 'page') as any
if (pageRecord && pageRecord.id) {
processedRecord.parentId = pageRecord.id
} else {
processedRecord.parentId = 'page:page'
}
}
if (!processedRecord.props || typeof processedRecord.props !== 'object') processedRecord.props = {}
// CRITICAL: Infer type from properties BEFORE defaulting to 'geo'
// This ensures arrows and other shapes are properly recognized
if (!processedRecord.type || typeof processedRecord.type !== 'string') {
// Check for arrow-specific properties first
if (processedRecord.props?.start !== undefined ||
processedRecord.props?.end !== undefined ||
processedRecord.props?.arrowheadStart !== undefined ||
processedRecord.props?.arrowheadEnd !== undefined ||
processedRecord.props?.kind === 'line' ||
processedRecord.props?.kind === 'curved' ||
processedRecord.props?.kind === 'straight') {
processedRecord.type = 'arrow'
}
// Check for line-specific properties
else if (processedRecord.props?.points !== undefined) {
processedRecord.type = 'line'
}
// Check for geo-specific properties (w/h/geo)
else if (processedRecord.props?.geo !== undefined ||
('w' in processedRecord && 'h' in processedRecord) ||
('w' in processedRecord.props && 'h' in processedRecord.props)) {
processedRecord.type = 'geo'
}
// Check for note-specific properties
else if (processedRecord.props?.growY !== undefined ||
processedRecord.props?.verticalAlign !== undefined) {
processedRecord.type = 'note'
}
// Check for text-specific properties
else if (processedRecord.props?.textAlign !== undefined ||
processedRecord.props?.autoSize !== undefined) {
processedRecord.type = 'text'
}
// Check for draw-specific properties
else if (processedRecord.props?.segments !== undefined) {
processedRecord.type = 'draw'
}
// Default to geo only if no other indicators found
else {
processedRecord.type = 'geo'
}
}
// CRITICAL: For geo shapes, move w/h/geo from top-level to props (required by TLDraw schema)
if (processedRecord.type === 'geo' || ('w' in processedRecord && 'h' in processedRecord && processedRecord.type !== 'arrow')) {
if (!processedRecord.type || processedRecord.type === 'geo') {
processedRecord.type = 'geo'
}
// Move w from top-level to props
if ('w' in processedRecord && processedRecord.w !== undefined) {
if (!('w' in processedRecord.props) || processedRecord.props.w === undefined) {
processedRecord.props.w = processedRecord.w
}
delete (processedRecord as any).w
}
// Move h from top-level to props
if ('h' in processedRecord && processedRecord.h !== undefined) {
if (!('h' in processedRecord.props) || processedRecord.props.h === undefined) {
processedRecord.props.h = processedRecord.h
}
delete (processedRecord as any).h
}
// Move geo from top-level to props
if ('geo' in processedRecord && processedRecord.geo !== undefined) {
if (!('geo' in processedRecord.props) || processedRecord.props.geo === undefined) {
processedRecord.props.geo = processedRecord.geo
}
delete (processedRecord as any).geo
}
// Fix richText structure if it exists (preserve content)
if (processedRecord.props.richText) {
if (Array.isArray(processedRecord.props.richText)) {
processedRecord.props.richText = { content: processedRecord.props.richText, type: 'doc' }
} else if (typeof processedRecord.props.richText === 'object' && processedRecord.props.richText !== null) {
if (!processedRecord.props.richText.type) {
processedRecord.props.richText = { ...processedRecord.props.richText, type: 'doc' }
}
if (!processedRecord.props.richText.content) {
processedRecord.props.richText = { ...processedRecord.props.richText, content: [] }
}
}
}
}
// CRITICAL: For arrow shapes, preserve text property
if (processedRecord.type === 'arrow') {
if ((processedRecord.props as any).text === undefined || (processedRecord.props as any).text === null) {
(processedRecord.props as any).text = ''
}
}
// CRITICAL: For line shapes, ensure points structure exists (required by schema)
if (processedRecord.type === 'line') {
if ('w' in processedRecord.props) delete (processedRecord.props as any).w
if ('h' in processedRecord.props) delete (processedRecord.props as any).h
if (!processedRecord.props.points || typeof processedRecord.props.points !== 'object' || Array.isArray(processedRecord.props.points)) {
processedRecord.props.points = {
'a1': { id: 'a1', index: 'a1' as any, x: 0, y: 0 },
'a2': { id: 'a2', index: 'a2' as any, x: 100, y: 0 }
}
}
}
// CRITICAL: For group shapes, remove w/h from props (they cause validation errors)
if (processedRecord.type === 'group') {
if ('w' in processedRecord.props) delete (processedRecord.props as any).w
if ('h' in processedRecord.props) delete (processedRecord.props as any).h
}
// CRITICAL: For image/video shapes, fix crop structure if it exists
if (processedRecord.type === 'image' || processedRecord.type === 'video') {
if (processedRecord.props.crop !== null && processedRecord.props.crop !== undefined) {
if (!processedRecord.props.crop.topLeft || !processedRecord.props.crop.bottomRight) {
if (processedRecord.props.crop.x !== undefined && processedRecord.props.crop.y !== undefined) {
processedRecord.props.crop = {
topLeft: { x: processedRecord.props.crop.x || 0, y: processedRecord.props.crop.y || 0 },
bottomRight: {
x: (processedRecord.props.crop.x || 0) + (processedRecord.props.crop.w || 1),
y: (processedRecord.props.crop.y || 0) + (processedRecord.props.crop.h || 1)
}
}
} else {
processedRecord.props.crop = {
topLeft: { x: 0, y: 0 },
bottomRight: { x: 1, y: 1 }
}
}
}
}
}
// CRITICAL: Fix richText structure for note shapes if it exists
if (processedRecord.type === 'note' && processedRecord.props.richText) {
if (Array.isArray(processedRecord.props.richText)) {
processedRecord.props.richText = { content: processedRecord.props.richText, type: 'doc' }
} else if (typeof processedRecord.props.richText === 'object' && processedRecord.props.richText !== null) {
if (!processedRecord.props.richText.type) {
processedRecord.props.richText = { ...processedRecord.props.richText, type: 'doc' }
}
if (!processedRecord.props.richText.content) {
processedRecord.props.richText = { ...processedRecord.props.richText, content: [] }
}
}
}
// Ensure props object exists for all shapes
if (!processedRecord.props) processedRecord.props = {}
// Preserve original data structure - only move properties when TLDraw validation requires it
// Arrow shapes don't have w/h properties, so remove them if present
if (processedRecord.type === 'arrow') {
if ('w' in processedRecord) {
console.log(`Removing invalid w property from arrow shape ${processedRecord.id}`)
delete (processedRecord as any).w
}
if ('h' in processedRecord) {
console.log(`Removing invalid h property from arrow shape ${processedRecord.id}`)
delete (processedRecord as any).h
}
}
// For other shapes, preserve the original structure - don't move w/h unless validation fails
// Handle arrow shapes specially - ensure they have required properties
if (processedRecord.type === 'arrow') {
// Ensure required arrow properties exist
if (!processedRecord.props.kind) processedRecord.props.kind = 'line'
if (!processedRecord.props.labelColor) processedRecord.props.labelColor = 'black'
if (!processedRecord.props.color) processedRecord.props.color = 'black'
if (!processedRecord.props.fill) processedRecord.props.fill = 'none'
if (!processedRecord.props.dash) processedRecord.props.dash = 'draw'
if (!processedRecord.props.size) processedRecord.props.size = 'm'
if (!processedRecord.props.arrowheadStart) processedRecord.props.arrowheadStart = 'none'
if (!processedRecord.props.arrowheadEnd) processedRecord.props.arrowheadEnd = 'arrow'
if (!processedRecord.props.font) processedRecord.props.font = 'draw'
if (!processedRecord.props.start) processedRecord.props.start = { x: 0, y: 0 }
if (!processedRecord.props.end) processedRecord.props.end = { x: 100, y: 0 }
if (processedRecord.props.bend === undefined) processedRecord.props.bend = 0
if (!processedRecord.props.text) processedRecord.props.text = ''
if (processedRecord.props.labelPosition === undefined) processedRecord.props.labelPosition = 0.5
if (processedRecord.props.scale === undefined) processedRecord.props.scale = 1
if (processedRecord.props.elbowMidPoint === undefined) processedRecord.props.elbowMidPoint = 0.5
// Remove any invalid properties
const invalidArrowProps = ['w', 'h', 'geo', 'insets', 'scribbles']
invalidArrowProps.forEach(prop => {
if (prop in processedRecord.props) {
console.log(`Removing invalid prop '${prop}' from arrow shape ${processedRecord.id}`)
delete (processedRecord.props as any)[prop]
}
})
}
// Handle note shapes specially - ensure they have required properties
if (processedRecord.type === 'note') {
// Ensure required note properties exist
if (!processedRecord.props.color) processedRecord.props.color = 'black'
if (!processedRecord.props.labelColor) processedRecord.props.labelColor = 'black'
if (!processedRecord.props.size) processedRecord.props.size = 'm'
if (!processedRecord.props.font) processedRecord.props.font = 'draw'
if (processedRecord.props.fontSizeAdjustment === undefined) processedRecord.props.fontSizeAdjustment = 0
if (!processedRecord.props.align) processedRecord.props.align = 'start'
if (!processedRecord.props.verticalAlign) processedRecord.props.verticalAlign = 'start'
if (processedRecord.props.growY === undefined) processedRecord.props.growY = 0
if (!processedRecord.props.url) processedRecord.props.url = ''
// Note: richText is not required for note shapes
if (processedRecord.props.scale === undefined) processedRecord.props.scale = 1
// Remove any invalid properties
const invalidNoteProps = ['w', 'h', 'geo', 'insets', 'scribbles']
invalidNoteProps.forEach(prop => {
if (prop in processedRecord.props) {
console.log(`Removing invalid prop '${prop}' from note shape ${processedRecord.id}`)
delete (processedRecord.props as any)[prop]
}
})
}
// Handle text shapes specially - ensure they have required properties
if (processedRecord.type === 'text') {
// Ensure required text properties exist (matching default tldraw text shape schema)
if (!processedRecord.props.color) processedRecord.props.color = 'black'
if (!processedRecord.props.size) processedRecord.props.size = 'm'
if (!processedRecord.props.font) processedRecord.props.font = 'draw'
if (!processedRecord.props.textAlign) processedRecord.props.textAlign = 'start'
if (processedRecord.props.w === undefined || processedRecord.props.w === null) {
processedRecord.props.w = 100
}
if (processedRecord.props.scale === undefined) processedRecord.props.scale = 1
if (processedRecord.props.autoSize === undefined) processedRecord.props.autoSize = false
// Ensure richText property exists for text shapes
if (!processedRecord.props.richText) {
console.log(`🔧 Creating default richText object for text shape ${processedRecord.id}`)
processedRecord.props.richText = { content: [], type: 'doc' }
}
// Remove any invalid properties (including 'text' property which is not in default schema)
// Note: richText is actually required for text shapes, so don't remove it
const invalidTextProps = ['text', 'h', 'geo', 'insets', 'scribbles', 'isMinimized', 'roomUrl', 'roomId', 'align', 'verticalAlign', 'growY', 'url']
invalidTextProps.forEach(prop => {
if (prop in processedRecord.props) {
console.log(`Removing invalid prop '${prop}' from text shape ${processedRecord.id}`)
delete (processedRecord.props as any)[prop]
}
})
}
// Handle draw shapes specially - ensure they have required properties
if (processedRecord.type === 'draw') {
// Ensure required draw properties exist
if (!processedRecord.props.color) processedRecord.props.color = 'black'
if (!processedRecord.props.fill) processedRecord.props.fill = 'none'
if (!processedRecord.props.dash) processedRecord.props.dash = 'draw'
if (!processedRecord.props.size) processedRecord.props.size = 'm'
// Validate and fix segments array - this is critical for preventing Polyline2d errors
if (!processedRecord.props.segments || !Array.isArray(processedRecord.props.segments)) {
console.log(`🔧 Fixing missing/invalid segments for draw shape ${processedRecord.id}`)
processedRecord.props.segments = [
{
type: "free",
points: [
{ x: 0, y: 0, z: 0.5 },
{ x: 10, y: 10, z: 0.5 }
]
}
]
} else {
// Validate each segment in the array
// Polyline2d requires at least 2 points per segment
const validSegments = []
for (let i = 0; i < processedRecord.props.segments.length; i++) {
const segment = processedRecord.props.segments[i]
if (segment && typeof segment === 'object' &&
segment.type &&
Array.isArray(segment.points) &&
segment.points.length >= 2) {
// Validate points in the segment
const validPoints = segment.points.filter((point: any) =>
point &&
typeof point === 'object' &&
typeof point.x === 'number' &&
typeof point.y === 'number' &&
!isNaN(point.x) && !isNaN(point.y)
)
// Polyline2d requires at least 2 points
if (validPoints.length >= 2) {
validSegments.push({
type: segment.type,
points: validPoints
})
} else if (validPoints.length === 1) {
// If only 1 point, duplicate it to create a valid 2-point segment
console.log(`🔧 Draw shape ${processedRecord.id} segment ${i} has only 1 point, duplicating to create valid segment`)
validSegments.push({
type: segment.type,
points: [validPoints[0], { ...validPoints[0] }]
})
}
}
}
if (validSegments.length === 0) {
console.log(`🔧 All segments invalid for draw shape ${processedRecord.id}, creating default segment`)
processedRecord.props.segments = [
{
type: "free",
points: [
{ x: 0, y: 0, z: 0.5 },
{ x: 10, y: 10, z: 0.5 }
]
}
]
} else {
processedRecord.props.segments = validSegments
}
}
if (processedRecord.props.isComplete === undefined) processedRecord.props.isComplete = true
if (processedRecord.props.isClosed === undefined) processedRecord.props.isClosed = false
if (processedRecord.props.isPen === undefined) processedRecord.props.isPen = false
if (processedRecord.props.scale === undefined) processedRecord.props.scale = 1
// Remove any invalid properties
const invalidDrawProps = ['w', 'h', 'geo', 'insets', 'scribbles', 'richText']
invalidDrawProps.forEach(prop => {
if (prop in processedRecord.props) {
console.log(`Removing invalid prop '${prop}' from draw shape ${processedRecord.id}`)
delete (processedRecord.props as any)[prop]
}
})
}
// Handle geo shapes specially - ensure geo property is in props where TLDraw expects it
if (processedRecord.type === 'geo') {
// Ensure props exists
if (!processedRecord.props) processedRecord.props = {}
// CRITICAL: ALWAYS remove w/h/geo from top level (TLDraw validation fails if they exist at top level)
// Move w from top level to props (preserve value if not already in props)
if ('w' in processedRecord) {
console.log(`🔧 Geo shape fix: Removing w from top level for shape ${processedRecord.id}`)
if (!('w' in processedRecord.props) || processedRecord.props.w === undefined) {
processedRecord.props.w = (processedRecord as any).w
}
delete (processedRecord as any).w
}
// Move h from top level to props (preserve value if not already in props)
if ('h' in processedRecord) {
console.log(`🔧 Geo shape fix: Removing h from top level for shape ${processedRecord.id}`)
if (!('h' in processedRecord.props) || processedRecord.props.h === undefined) {
processedRecord.props.h = (processedRecord as any).h
}
delete (processedRecord as any).h
}
// Move geo from top level to props (preserve value if not already in props)
if ('geo' in processedRecord) {
console.log(`🔧 Geo shape fix: Removing geo from top level for shape ${processedRecord.id}`)
if (!('geo' in processedRecord.props) || processedRecord.props.geo === undefined) {
processedRecord.props.geo = (processedRecord as any).geo
}
delete (processedRecord as any).geo
}
// Ensure geo property exists in props with a default value
if (!processedRecord.props.geo) {
processedRecord.props.geo = 'rectangle'
}
// Ensure w/h exist in props with defaults if missing
if (!processedRecord.props) processedRecord.props = {}
if (processedRecord.props.w === undefined || processedRecord.props.w === null) {
processedRecord.props.w = 100
}
if (processedRecord.props.h === undefined || processedRecord.props.h === null) {
processedRecord.props.h = 100
}
if (processedRecord.props.geo === undefined || processedRecord.props.geo === null) {
processedRecord.props.geo = 'rectangle'
}
if (!processedRecord.props.dash) processedRecord.props.dash = 'draw'
if (!processedRecord.props.growY) processedRecord.props.growY = 0
if (!processedRecord.props.url) processedRecord.props.url = ''
if (!processedRecord.props.scale) processedRecord.props.scale = 1
if (!processedRecord.props.color) processedRecord.props.color = 'black'
if (!processedRecord.props.labelColor) processedRecord.props.labelColor = 'black'
if (!processedRecord.props.fill) processedRecord.props.fill = 'none'
if (!processedRecord.props.size) processedRecord.props.size = 'm'
if (!processedRecord.props.font) processedRecord.props.font = 'draw'
if (!processedRecord.props.align) processedRecord.props.align = 'middle'
if (!processedRecord.props.verticalAlign) processedRecord.props.verticalAlign = 'middle'
// Note: richText IS required for geo shapes in TLDraw
if (!processedRecord.props.richText) processedRecord.props.richText = { content: [], type: 'doc' }
// Ensure basic geo properties exist
if (!processedRecord.props.geo) processedRecord.props.geo = 'rectangle'
if (!processedRecord.props.fill) processedRecord.props.fill = 'solid'
if (!processedRecord.props.color) processedRecord.props.color = 'white'
// Validate geo property
const validGeoTypes = [
'cloud', 'rectangle', 'ellipse', 'triangle', 'diamond', 'pentagon',
'hexagon', 'octagon', 'star', 'rhombus', 'rhombus-2', 'oval',
'trapezoid', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down',
'x-box', 'check-box', 'heart'
]
if (!validGeoTypes.includes(processedRecord.props.geo)) {
console.log(`Setting valid geo property for shape ${processedRecord.id} (was: ${processedRecord.props.geo})`)
processedRecord.props.geo = 'rectangle'
}
// Remove invalid properties from props (only log if actually removing)
const invalidProps = ['insets', 'scribbles']
invalidProps.forEach(prop => {
if (prop in processedRecord.props) {
delete (processedRecord.props as any)[prop]
}
})
}
// Handle rich text content that might be undefined or invalid
// Only process richText for shapes that actually use it (text, note, geo, etc.)
// CRITICAL: geo shapes (rectangles) can legitimately have richText in TLDraw
if (processedRecord.type === 'text' || processedRecord.type === 'note' || processedRecord.type === 'geo') {
if (processedRecord.props && processedRecord.props.richText !== undefined) {
if (!Array.isArray(processedRecord.props.richText) && typeof processedRecord.props.richText !== 'object') {
console.warn('Fixing invalid richText property for shape:', processedRecord.id, 'type:', processedRecord.type, 'was:', typeof processedRecord.props.richText)
processedRecord.props.richText = { content: [], type: 'doc' }
} else if (Array.isArray(processedRecord.props.richText)) {
// If it's an array, convert to proper richText object structure
console.log(`🔧 Converting richText array to object for shape ${processedRecord.id}`)
processedRecord.props.richText = { content: processedRecord.props.richText, type: 'doc' }
}
} else {
// Create default empty richText object for text shapes (but not for geo/note unless they already have it)
if (processedRecord.type === 'text') {
if (!processedRecord.props) processedRecord.props = {}
processedRecord.props.richText = { content: [], type: 'doc' }
}
}
} else if (processedRecord.props && processedRecord.props.richText !== undefined) {
// Remove richText from shapes that don't use it (but preserve for geo/note which are handled above)
delete (processedRecord.props as any).richText
}
// Remove invalid properties that cause validation errors (after moving geo properties)
const invalidProperties = [
'insets', 'scribbles', 'duplicateProps', 'isAspectRatioLocked',
'isFlippedHorizontal', 'isFlippedVertical', 'isFrozen', 'isSnappable',
'isTransparent', 'isVisible', 'isZIndexLocked', 'isHidden'
]
invalidProperties.forEach(prop => {
if (prop in processedRecord) {
delete (processedRecord as any)[prop]
}
})
// Custom shapes are supported natively by our custom schema - no conversion needed!
// Just ensure they have the required properties for their type
if (processedRecord.type === 'VideoChat' || processedRecord.type === 'ChatBox' ||
processedRecord.type === 'Embed' || processedRecord.type === 'SharedPiano' ||
processedRecord.type === 'MycrozineTemplate' || processedRecord.type === 'Slide') {
// These are embed-like shapes - ensure they have basic properties
if (!processedRecord.props) processedRecord.props = {}
if (processedRecord.props.w === undefined || processedRecord.props.w === null) {
processedRecord.props.w = 300
}
if (processedRecord.props.h === undefined || processedRecord.props.h === null) {
processedRecord.props.h = 200
}
console.log(`🔧 Ensured embed-like shape ${processedRecord.type} has required properties:`, processedRecord.props)
} else if (processedRecord.type === 'Prompt' || processedRecord.type === 'Transcription' ||
processedRecord.type === 'Markdown') {
// These are text-like shapes - ensure they have text properties
if (!processedRecord.props) processedRecord.props = {}
if (processedRecord.props.w === undefined || processedRecord.props.w === null) {
processedRecord.props.w = 300
}
// Convert value property to richText if it exists (for Prompt shapes)
if (processedRecord.props.value && !processedRecord.props.richText) {
processedRecord.props.richText = {
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: processedRecord.props.value
}
]
}
],
type: 'doc'
}
console.log(`🔧 Converted value to richText for ${processedRecord.type} shape ${processedRecord.id}`)
}
if (!processedRecord.props.richText) {
processedRecord.props.richText = { content: [], type: 'doc' }
}
console.log(`🔧 Ensured text-like shape ${processedRecord.type} has required properties:`, processedRecord.props)
}
// Validate that the shape type is supported by our schema
const validCustomShapes = ['ObsNote', 'VideoChat', 'Transcription', 'SharedPiano', 'Prompt', 'ChatBox', 'Embed', 'Markdown', 'MycrozineTemplate', 'Slide', 'FathomTranscript', 'Holon', 'ObsidianBrowser', 'HolonBrowser', 'FathomMeetingsBrowser', 'LocationShare']
const validDefaultShapes = ['arrow', 'bookmark', 'draw', 'embed', 'frame', 'geo', 'group', 'highlight', 'image', 'line', 'note', 'text', 'video']
const allValidShapes = [...validCustomShapes, ...validDefaultShapes]
if (!allValidShapes.includes(processedRecord.type)) {
console.log(`🔧 Unknown shape type ${processedRecord.type}, converting to text shape for shape:`, processedRecord.id)
processedRecord.type = 'text'
if (!processedRecord.props) processedRecord.props = {}
// Preserve existing props and only set defaults for missing required text shape properties
// This prevents losing metadata or other valid properties
processedRecord.props = {
...processedRecord.props, // Preserve existing props
w: processedRecord.props.w || 300,
color: processedRecord.props.color || 'black',
size: processedRecord.props.size || 'm',
font: processedRecord.props.font || 'draw',
textAlign: processedRecord.props.textAlign || 'start',
autoSize: processedRecord.props.autoSize !== undefined ? processedRecord.props.autoSize : false,
scale: processedRecord.props.scale || 1,
richText: processedRecord.props.richText || { content: [], type: 'doc' }
}
// Remove invalid properties for text shapes (but preserve meta and other valid top-level properties)
const invalidTextProps = ['h', 'geo', 'insets', 'scribbles', 'isMinimized', 'roomUrl', 'text', 'align', 'verticalAlign', 'growY', 'url']
invalidTextProps.forEach(prop => {
if (prop in processedRecord.props) {
delete (processedRecord.props as any)[prop]
}
})
console.log(`🔧 Converted unknown shape to text:`, processedRecord.props)
}
// Universal shape validation - ensure any shape type can be imported
// CRITICAL: Fix image and video shapes FIRST - ensure crop has correct structure
// Tldraw expects crop to be { topLeft: { x, y }, bottomRight: { x, y } } or null
if (processedRecord.type === 'image' || processedRecord.type === 'video') {
// Ensure props exists for image/video shapes
if (!processedRecord.props) {
processedRecord.props = {}
}
// Fix crop structure
if (processedRecord.props.crop !== null && processedRecord.props.crop !== undefined) {
// If crop exists but has wrong structure, fix it
if (!processedRecord.props.crop.topLeft || !processedRecord.props.crop.bottomRight) {
// Convert old format { x, y, w, h } to new format, or set default
if (processedRecord.props.crop.x !== undefined && processedRecord.props.crop.y !== undefined) {
// Old format: convert to new format
processedRecord.props.crop = {
topLeft: { x: processedRecord.props.crop.x || 0, y: processedRecord.props.crop.y || 0 },
bottomRight: {
x: (processedRecord.props.crop.x || 0) + (processedRecord.props.crop.w || 1),
y: (processedRecord.props.crop.y || 0) + (processedRecord.props.crop.h || 1)
}
}
} else {
// Invalid structure: set to default (full crop)
processedRecord.props.crop = {
topLeft: { x: 0, y: 0 },
bottomRight: { x: 1, y: 1 }
}
}
} else {
// Ensure topLeft and bottomRight are proper objects
if (!processedRecord.props.crop.topLeft || typeof processedRecord.props.crop.topLeft !== 'object') {
processedRecord.props.crop.topLeft = { x: 0, y: 0 }
}
if (!processedRecord.props.crop.bottomRight || typeof processedRecord.props.crop.bottomRight !== 'object') {
processedRecord.props.crop.bottomRight = { x: 1, y: 1 }
}
}
} else {
// Crop is null/undefined: set to null (no crop)
processedRecord.props.crop = null
}
}
// CRITICAL: Fix line shapes - ensure valid points and remove invalid w/h properties
if (processedRecord.type === 'line') {
if (!processedRecord.props) {
processedRecord.props = {}
}
// Line shapes should NOT have w or h properties
if ('w' in processedRecord.props) {
console.log(`🔧 Universal fix: Removing invalid w property from line shape ${processedRecord.id}`)
delete processedRecord.props.w
}
if ('h' in processedRecord.props) {
console.log(`🔧 Universal fix: Removing invalid h property from line shape ${processedRecord.id}`)
delete processedRecord.props.h
}
// Line shapes REQUIRE points property: Record<string, { id: string, index: IndexKey, x: number, y: number }>
if (!processedRecord.props.points || typeof processedRecord.props.points !== 'object' || Array.isArray(processedRecord.props.points)) {
console.log(`🔧 Universal fix: Creating default points for line shape ${processedRecord.id}`)
// Create default points with at least 2 points
const point1 = { id: 'a1', index: 'a1' as any, x: 0, y: 0 }
const point2 = { id: 'a2', index: 'a2' as any, x: 100, y: 0 }
processedRecord.props.points = {
'a1': point1,
'a2': point2
}
} else {
// Validate and fix existing points
const validPoints: Record<string, { id: string, index: any, x: number, y: number }> = {}
let pointIndex = 0
const indices = ['a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9', 'a10']
for (const [key, point] of Object.entries(processedRecord.props.points)) {
if (point && typeof point === 'object' &&
typeof (point as any).x === 'number' &&
typeof (point as any).y === 'number' &&
!isNaN((point as any).x) && !isNaN((point as any).y)) {
const index = indices[pointIndex] || `a${pointIndex + 1}`
validPoints[index] = {
id: index,
index: index as any,
x: (point as any).x,
y: (point as any).y
}
pointIndex++
}
}
if (Object.keys(validPoints).length === 0) {
// No valid points, create default
console.log(`🔧 Universal fix: No valid points found for line shape ${processedRecord.id}, creating default points`)
processedRecord.props.points = {
'a1': { id: 'a1', index: 'a1' as any, x: 0, y: 0 },
'a2': { id: 'a2', index: 'a2' as any, x: 100, y: 0 }
}
} else if (Object.keys(validPoints).length === 1) {
// Only one point, add a second one
const firstPoint = Object.values(validPoints)[0]
const secondIndex = indices[1] || 'a2'
validPoints[secondIndex] = {
id: secondIndex,
index: secondIndex as any,
x: firstPoint.x + 100,
y: firstPoint.y
}
processedRecord.props.points = validPoints
} else {
processedRecord.props.points = validPoints
}
}
// Ensure other required line shape properties exist
if (!processedRecord.props.color) processedRecord.props.color = 'black'
if (!processedRecord.props.dash) processedRecord.props.dash = 'draw'
if (!processedRecord.props.size) processedRecord.props.size = 'm'
if (!processedRecord.props.spline) processedRecord.props.spline = 'line'
if (processedRecord.props.scale === undefined || processedRecord.props.scale === null) {
processedRecord.props.scale = 1
}
}
// CRITICAL: Fix group shapes - remove invalid w/h properties
if (processedRecord.type === 'group') {
if (!processedRecord.props) {
processedRecord.props = {}
}
// Group shapes should NOT have w or h properties
if ('w' in processedRecord.props) {
console.log(`🔧 Universal fix: Removing invalid w property from group shape ${processedRecord.id}`)
delete processedRecord.props.w
}
if ('h' in processedRecord.props) {
console.log(`🔧 Universal fix: Removing invalid h property from group shape ${processedRecord.id}`)
delete processedRecord.props.h
}
}
if (processedRecord.props) {
// Fix any richText issues for text shapes only
if (processedRecord.type === 'text' && processedRecord.props.richText !== undefined) {
if (!Array.isArray(processedRecord.props.richText)) {
console.log(`🔧 Universal fix: Converting richText to proper object for text shape ${processedRecord.id}`)
processedRecord.props.richText = { content: [], type: 'doc' }
} else {
// Convert array to proper object structure
console.log(`🔧 Universal fix: Converting richText array to object for text shape ${processedRecord.id}`)
processedRecord.props.richText = { content: processedRecord.props.richText, type: 'doc' }
}
}
// Special handling for geo shapes
if (processedRecord.type === 'geo') {
// Geo shapes should have richText property but not text property
if ('text' in processedRecord.props) {
console.log(`🔧 Removing invalid text property from geo shape ${processedRecord.id}`)
delete processedRecord.props.text
}
// Ensure richText property exists and is properly structured for geo shapes
if (!processedRecord.props.richText) {
console.log(`🔧 Adding missing richText property for geo shape ${processedRecord.id}`)
processedRecord.props.richText = { content: [], type: 'doc' }
} else if (Array.isArray(processedRecord.props.richText)) {
console.log(`🔧 Converting richText array to object for geo shape ${processedRecord.id}`)
processedRecord.props.richText = { content: processedRecord.props.richText, type: 'doc' }
} else if (typeof processedRecord.props.richText !== 'object' || processedRecord.props.richText === null) {
console.log(`🔧 Fixing invalid richText structure for geo shape ${processedRecord.id}`)
processedRecord.props.richText = { content: [], type: 'doc' }
} else if (!processedRecord.props.richText.content) {
// If richText exists but content is missing, preserve the rest and add empty content
console.log(`🔧 Adding missing content to richText for geo shape ${processedRecord.id}`)
processedRecord.props.richText = {
...processedRecord.props.richText,
content: processedRecord.props.richText.content || [],
type: processedRecord.props.richText.type || 'doc'
}
}
// Ensure geo shape has proper structure
if (!processedRecord.props.geo) {
processedRecord.props.geo = 'rectangle'
}
if (processedRecord.props.w === undefined || processedRecord.props.w === null) {
processedRecord.props.w = 100
}
if (processedRecord.props.h === undefined || processedRecord.props.h === null) {
processedRecord.props.h = 100
}
// Fix dash property - ensure it's a valid value
if (processedRecord.props.dash === '' || processedRecord.props.dash === undefined) {
processedRecord.props.dash = 'solid'
} else if (!['draw', 'solid', 'dashed', 'dotted'].includes(processedRecord.props.dash)) {
console.log(`🔧 Fixing invalid dash value '${processedRecord.props.dash}' for geo shape:`, processedRecord.id)
processedRecord.props.dash = 'solid'
}
// Fix scale property - ensure it's a number
if (processedRecord.props.scale === undefined || processedRecord.props.scale === null) {
processedRecord.props.scale = 1
} else if (typeof processedRecord.props.scale !== 'number') {
console.log(`🔧 Fixing invalid scale value '${processedRecord.props.scale}' for geo shape:`, processedRecord.id)
processedRecord.props.scale = 1
}
// Remove invalid properties for geo shapes (including insets) - but NOT richText as it's required
const invalidGeoOtherProps = ['transcript', 'isTranscribing', 'isPaused', 'isEditing', 'roomUrl', 'roomId', 'prompt', 'value', 'agentBinding', 'isMinimized', 'noteId', 'title', 'content', 'tags', 'showPreview', 'backgroundColor', 'textColor', 'editingContent', 'vaultName', 'insets']
invalidGeoOtherProps.forEach(prop => {
if (prop in processedRecord.props) {
console.log(`🔧 Removing invalid ${prop} property from geo shape:`, processedRecord.id)
delete processedRecord.props[prop]
}
})
}
// Fix note shapes - ensure richText exists and remove invalid w/h properties
if (processedRecord.type === 'note') {
// Note shapes REQUIRE richText property (it's part of the schema)
if (!processedRecord.props.richText || typeof processedRecord.props.richText !== 'object') {
console.log(`🔧 Adding missing richText property for note shape ${processedRecord.id}`)
processedRecord.props.richText = { content: [], type: 'doc' }
}
if ('w' in processedRecord.props) {
console.log(`🔧 Removing invalid w property from note shape:`, processedRecord.id)
delete processedRecord.props.w
}
if ('h' in processedRecord.props) {
console.log(`🔧 Removing invalid h property from note shape:`, processedRecord.id)
delete processedRecord.props.h
}
}
// Fix text shapes - remove h property
if (processedRecord.type === 'text') {
if ('h' in processedRecord.props) {
console.log(`🔧 Removing invalid h property from text shape:`, processedRecord.id)
delete processedRecord.props.h
}
}
// Fix embed shapes - ensure required properties and remove invalid ones
if (processedRecord.type === 'embed') {
if (!processedRecord.props.url) {
console.log(`🔧 Adding missing url property for embed shape:`, processedRecord.id)
processedRecord.props.url = ''
}
if (!processedRecord.props.w) {
processedRecord.props.w = 400
}
if (!processedRecord.props.h) {
processedRecord.props.h = 300
}
// Remove invalid properties for embed shapes
const invalidEmbedProps = ['isMinimized', 'roomUrl', 'roomId', 'color', 'fill', 'dash', 'size', 'text', 'font', 'align', 'verticalAlign', 'growY', 'richText']
invalidEmbedProps.forEach(prop => {
if (prop in processedRecord.props) {
console.log(`🔧 Removing invalid prop '${prop}' from embed shape ${processedRecord.id}`)
delete (processedRecord.props as any)[prop]
}
})
}
// Ensure all required properties exist for any shape type (except arrow, draw, line, text, note, and group)
if (processedRecord.type !== 'arrow' && processedRecord.type !== 'draw' && processedRecord.type !== 'line' && processedRecord.type !== 'text' && processedRecord.type !== 'note' && processedRecord.type !== 'group') {
const requiredProps = ['w', 'h']
requiredProps.forEach(prop => {
if (processedRecord.props[prop] === undefined) {
console.log(`🔧 Universal fix: Adding missing ${prop} for shape ${processedRecord.id} (type: ${processedRecord.type})`)
if (prop === 'w' && processedRecord.props.w === undefined) processedRecord.props.w = 100
if (prop === 'h' && processedRecord.props.h === undefined) processedRecord.props.h = 100
}
})
} else if (processedRecord.type === 'text') {
// Text shapes only need w, not h
if (processedRecord.props.w === undefined || processedRecord.props.w === null) {
console.log(`🔧 Universal fix: Adding missing w for text shape ${processedRecord.id}`)
processedRecord.props.w = 100
}
}
// Clean up any null/undefined values in props (but preserve required objects like crop for images/videos)
// IMPORTANT: crop is already set above for image/video shapes, so we must skip it here
Object.keys(processedRecord.props).forEach(propKey => {
// Skip crop for image/video shapes - it must be an object, not undefined
if ((processedRecord.type === 'image' || processedRecord.type === 'video') && propKey === 'crop') {
return // crop is required and already set above
}
if (processedRecord.props[propKey] === null || processedRecord.props[propKey] === undefined) {
console.log(`🔧 Universal fix: Removing null/undefined prop ${propKey} from shape ${processedRecord.id}`)
delete processedRecord.props[propKey]
}
})
}
}
// Fix instance records
if (processedRecord.typeName === 'instance') {
if (!processedRecord.meta) processedRecord.meta = {}
if ('insets' in processedRecord && !Array.isArray(processedRecord.insets)) {
processedRecord.insets = [false, false, false, false]
}
// Always ensure scribbles is an array, even if undefined
if (!Array.isArray(processedRecord.scribbles)) {
processedRecord.scribbles = []
}
// Always ensure duplicateProps is an object with required properties
if (typeof processedRecord.duplicateProps !== 'object' || processedRecord.duplicateProps === null) {
processedRecord.duplicateProps = {}
}
// Ensure duplicateProps has the required shapeIds array
if (!Array.isArray(processedRecord.duplicateProps.shapeIds)) {
processedRecord.duplicateProps.shapeIds = []
}
// Ensure duplicateProps has the required offset object
if (typeof processedRecord.duplicateProps.offset !== 'object' || processedRecord.duplicateProps.offset === null) {
processedRecord.duplicateProps.offset = { x: 0, y: 0 }
}
}
return processedRecord
})
console.log(`Processed ${processedRecords.length} records for loading`)
// Debug: Log shape structures before loading
const shapesToLoad = processedRecords.filter(r => r.typeName === 'shape')
console.log(`📊 About to load ${shapesToLoad.length} shapes into store`)
if (shapesToLoad.length > 0) {
console.log("📊 Sample processed shape structure:", {
id: shapesToLoad[0].id,
type: shapesToLoad[0].type,
x: shapesToLoad[0].x,
y: shapesToLoad[0].y,
props: shapesToLoad[0].props,
parentId: shapesToLoad[0].parentId,
allKeys: Object.keys(shapesToLoad[0])
})
// Log all shapes with their positions
console.log("📊 All processed shapes:", shapesToLoad.map(s => ({
id: s.id,
type: s.type,
x: s.x,
y: s.y,
hasProps: !!s.props,
propsW: s.props?.w,
propsH: s.props?.h,
parentId: s.parentId
})))
}
// Load records into store
if (processedRecords.length > 0) {
console.log("Attempting to load records into store...")
// Final validation: ensure all shapes are properly structured
processedRecords.forEach(record => {
if (record.typeName === 'shape') {
// Final check for geo shapes - ALWAYS remove w/h/geo from top level (even if in props)
if (record.type === 'geo') {
// ALWAYS delete w from top level (TLDraw validation fails if it exists at top level)
if ('w' in record) {
console.log(`🔧 FINAL PRE-STORE FIX: Removing w from top level for geo shape ${record.id}`)
if (!record.props) record.props = {}
if (!('w' in record.props) || record.props.w === undefined) {
record.props.w = (record as any).w
}
delete (record as any).w
}
// ALWAYS delete h from top level
if ('h' in record) {
console.log(`🔧 FINAL PRE-STORE FIX: Removing h from top level for geo shape ${record.id}`)
if (!record.props) record.props = {}
if (!('h' in record.props) || record.props.h === undefined) {
record.props.h = (record as any).h
}
delete (record as any).h
}
// ALWAYS delete geo from top level
if ('geo' in record) {
console.log(`🔧 FINAL PRE-STORE FIX: Removing geo from top level for geo shape ${record.id}`)
if (!record.props) record.props = {}
if (!('geo' in record.props) || record.props.geo === undefined) {
record.props.geo = (record as any).geo
}
delete (record as any).geo
}
}
// Ensure text shapes have richText
if (record.type === 'text') {
if (!record.props) {
record.props = {}
}
if (!record.props.richText) {
console.log(`🔧 Final fix: Adding richText to text shape ${record.id}`)
record.props.richText = { content: [], type: 'doc' }
}
}
}
})
try {
store.mergeRemoteChanges(() => {
// CRITICAL: Final safety check - ensure no geo shapes have w/h/geo at top level
// Note: obsidian_vault records are already filtered out above
const sanitizedRecords = processedRecords.map(record => {
if (record.typeName === 'shape' && record.type === 'geo') {
const sanitized = { ...record }
// ALWAYS remove from top level if present
if ('w' in sanitized) {
console.log(`🔧 LAST-CHANCE FIX: Removing w from top level for geo shape ${sanitized.id}`)
if (!sanitized.props) sanitized.props = {}
if (!('w' in sanitized.props) || sanitized.props.w === undefined) {
sanitized.props.w = (sanitized as any).w
}
delete (sanitized as any).w
}
if ('h' in sanitized) {
console.log(`🔧 LAST-CHANCE FIX: Removing h from top level for geo shape ${sanitized.id}`)
if (!sanitized.props) sanitized.props = {}
if (!('h' in sanitized.props) || sanitized.props.h === undefined) {
sanitized.props.h = (sanitized as any).h
}
delete (sanitized as any).h
}
if ('geo' in sanitized) {
console.log(`🔧 LAST-CHANCE FIX: Removing geo from top level for geo shape ${sanitized.id}`)
if (!sanitized.props) sanitized.props = {}
if (!('geo' in sanitized.props) || sanitized.props.geo === undefined) {
sanitized.props.geo = (sanitized as any).geo
}
delete (sanitized as any).geo
}
return sanitized
}
return record
})
// Put TLDraw records into store
if (sanitizedRecords.length > 0) {
store.put(sanitizedRecords)
}
})
console.log("Successfully loaded all records into store")
} catch (error) {
console.error("Error loading records into store:", error)
// Try loading records one by one to identify problematic ones
console.log("Attempting to load records one by one...")
let successCount = 0
const failedRecords = []
for (const record of processedRecords) {
// Final validation for individual record: ensure text shapes have richText
if (record.type === 'text') {
if (!record.props) {
record.props = {}
}
if (!record.props.richText) {
console.log(`🔧 Individual fix: Adding richText to text shape ${record.id}`)
record.props.richText = { content: [], type: 'doc' }
}
}
try {
// CRITICAL: Final validation before putting record into store
if (record.typeName === 'shape' && record.type === 'geo') {
// ALWAYS remove w/h/geo from top level (TLDraw validation fails if they exist at top level)
if ('w' in record) {
console.log(`🔧 INDIVIDUAL PRE-STORE FIX: Removing w from top level for geo shape ${record.id}`)
if (!record.props) record.props = {}
if (!('w' in record.props) || record.props.w === undefined) {
record.props.w = (record as any).w
}
delete (record as any).w
}
if ('h' in record) {
console.log(`🔧 INDIVIDUAL PRE-STORE FIX: Removing h from top level for geo shape ${record.id}`)
if (!record.props) record.props = {}
if (!('h' in record.props) || record.props.h === undefined) {
record.props.h = (record as any).h
}
delete (record as any).h
}
if ('geo' in record) {
console.log(`🔧 INDIVIDUAL PRE-STORE FIX: Removing geo from top level for geo shape ${record.id}`)
if (!record.props) record.props = {}
if (!('geo' in record.props) || record.props.geo === undefined) {
record.props.geo = (record as any).geo
}
delete (record as any).geo
}
// Ensure geo property exists in props
if (!record.props) record.props = {}
if (!record.props.geo) {
record.props.geo = 'rectangle'
}
}
// CRITICAL: Final safety check - ensure no geo shapes have w/h/geo at top level
let recordToPut = record
if (record.typeName === 'shape' && record.type === 'geo') {
// Store values before removing from top level
const wValue = 'w' in record ? (record as any).w : undefined
const hValue = 'h' in record ? (record as any).h : undefined
const geoValue = 'geo' in record ? (record as any).geo : undefined
// Create cleaned record without w/h/geo at top level
const cleaned: any = {}
for (const key in record) {
if (key !== 'w' && key !== 'h' && key !== 'geo') {
cleaned[key] = (record as any)[key]
}
}
// Ensure props exists and move values there if needed
if (!cleaned.props) cleaned.props = {}
if (wValue !== undefined && (!('w' in cleaned.props) || cleaned.props.w === undefined)) {
cleaned.props.w = wValue
}
if (hValue !== undefined && (!('h' in cleaned.props) || cleaned.props.h === undefined)) {
cleaned.props.h = hValue
}
if (geoValue !== undefined && (!('geo' in cleaned.props) || cleaned.props.geo === undefined)) {
cleaned.props.geo = geoValue
}
recordToPut = cleaned as any
}
store.mergeRemoteChanges(() => {
store.put([recordToPut])
})
successCount++
console.log(`✅ Successfully loaded record ${record.id} (${record.typeName})`)
} catch (individualError) {
console.error(`❌ Failed to load record ${record.id} (${record.typeName}):`, individualError)
console.log("Problematic record structure:", {
id: record.id,
typeName: record.typeName,
type: record.type,
hasW: 'w' in record,
hasH: 'h' in record,
w: record.w,
h: record.h,
propsW: record.props?.w,
propsH: record.props?.h,
allKeys: Object.keys(record)
})
failedRecords.push(record)
}
}
// Only log if there are failures or many records
if (successCount < processedRecords.length || processedRecords.length > 50) {
console.log(`Successfully loaded ${successCount} out of ${processedRecords.length} records`)
}
// Only log if debugging is needed
// console.log(`Failed records: ${failedRecords.length}`, failedRecords.map(r => r.id))
// Try to fix and reload failed records
if (failedRecords.length > 0) {
// Only log if debugging is needed
// console.log("Attempting to fix and reload failed records...")
for (const record of failedRecords) {
try {
// Additional cleanup for failed records - create deep copy
let fixedRecord = JSON.parse(JSON.stringify(record))
// Fix instance records specifically
if (fixedRecord.typeName === 'instance') {
if (!fixedRecord.meta) fixedRecord.meta = {}
if (!Array.isArray(fixedRecord.insets)) {
fixedRecord.insets = [false, false, false, false]
}
if (!Array.isArray(fixedRecord.scribbles)) {
fixedRecord.scribbles = []
}
if (typeof fixedRecord.duplicateProps !== 'object' || fixedRecord.duplicateProps === null) {
fixedRecord.duplicateProps = {}
}
if (!Array.isArray(fixedRecord.duplicateProps.shapeIds)) {
fixedRecord.duplicateProps.shapeIds = []
}
if (typeof fixedRecord.duplicateProps.offset !== 'object' || fixedRecord.duplicateProps.offset === null) {
fixedRecord.duplicateProps.offset = { x: 0, y: 0 }
}
}
// Remove any remaining top-level w/h properties for shapes (except arrow, draw, and text)
if (fixedRecord.typeName === 'shape') {
if (fixedRecord.type !== 'arrow' && fixedRecord.type !== 'draw' && fixedRecord.type !== 'text') {
if ('w' in fixedRecord) {
if (!fixedRecord.props) fixedRecord.props = {}
fixedRecord.props.w = fixedRecord.w
delete (fixedRecord as any).w
}
if ('h' in fixedRecord) {
if (!fixedRecord.props) fixedRecord.props = {}
fixedRecord.props.h = fixedRecord.h
delete (fixedRecord as any).h
}
} else if (fixedRecord.type === 'text') {
// Text shapes only need w, not h
if ('w' in fixedRecord) {
if (!fixedRecord.props) fixedRecord.props = {}
fixedRecord.props.w = fixedRecord.w
delete (fixedRecord as any).w
}
if ('h' in fixedRecord) {
delete (fixedRecord as any).h
}
} else {
// For arrow and draw shapes, remove w/h entirely
if ('w' in fixedRecord) {
delete (fixedRecord as any).w
}
if ('h' in fixedRecord) {
delete (fixedRecord as any).h
}
}
}
// Comprehensive richText validation - ensure it's always an object with content and type for text shapes
if (fixedRecord.type === 'text' && fixedRecord.props) {
if (fixedRecord.props.richText !== undefined) {
if (!Array.isArray(fixedRecord.props.richText)) {
console.log(`🔧 Fixing richText for text shape ${fixedRecord.id}: was ${typeof fixedRecord.props.richText}, setting to proper object`)
fixedRecord.props.richText = { content: [], type: 'doc' }
} else {
// If it's an array, convert to proper richText object structure
console.log(`🔧 Converting richText array to object for text shape ${fixedRecord.id}`)
fixedRecord.props.richText = { content: fixedRecord.props.richText, type: 'doc' }
}
} else {
// Text shapes must have richText as an object
console.log(`🔧 Creating default richText object for text shape ${fixedRecord.id}`)
fixedRecord.props.richText = { content: [], type: 'doc' }
}
} else if (fixedRecord.type === 'text' && !fixedRecord.props) {
// Ensure props object exists for text shapes
fixedRecord.props = { richText: { content: [], type: 'doc' } }
}
// Fix text shapes - ensure they have required properties including color
if (fixedRecord.type === 'text') {
if (!fixedRecord.props.color) {
console.log(`🔧 Adding missing color property for text shape ${fixedRecord.id}`)
fixedRecord.props.color = 'black'
}
if (!fixedRecord.props.size) {
fixedRecord.props.size = 'm'
}
if (!fixedRecord.props.font) {
fixedRecord.props.font = 'draw'
}
if (!fixedRecord.props.textAlign) {
fixedRecord.props.textAlign = 'start'
}
if (!fixedRecord.props.w) {
fixedRecord.props.w = 100
}
if (fixedRecord.props.scale === undefined) {
fixedRecord.props.scale = 1
}
if (fixedRecord.props.autoSize === undefined) {
fixedRecord.props.autoSize = false
}
if (!fixedRecord.props.richText) {
console.log(`🔧 Creating default richText object for text shape ${fixedRecord.id}`)
fixedRecord.props.richText = { content: [], type: 'doc' }
}
// Remove invalid properties for text shapes (matching default text shape schema)
// Note: richText is actually required for text shapes, so don't remove it
const invalidTextProps = ['h', 'geo', 'insets', 'scribbles', 'isMinimized', 'roomUrl', 'text', 'align', 'verticalAlign', 'growY', 'url']
invalidTextProps.forEach(prop => {
if (prop in fixedRecord.props) {
console.log(`🔧 Removing invalid prop '${prop}' from text shape ${fixedRecord.id}`)
delete (fixedRecord.props as any)[prop]
}
})
}
// Fix embed shapes - ensure they have required properties and remove invalid ones
if (fixedRecord.type === 'Embed' || fixedRecord.type === 'embed') {
if (!fixedRecord.props.url) {
console.log(`🔧 Adding missing url property for embed shape ${fixedRecord.id}`)
fixedRecord.props.url = ''
}
if (!fixedRecord.props.w) {
fixedRecord.props.w = 400
}
if (!fixedRecord.props.h) {
fixedRecord.props.h = 300
}
if (fixedRecord.props.isMinimized === undefined) {
fixedRecord.props.isMinimized = false
}
// Remove invalid properties for embed shapes (matching custom EmbedShape schema)
const invalidEmbedProps = ['doesResize', 'doesResizeHeight', 'roomUrl', 'roomId', 'color', 'fill', 'dash', 'size', 'text', 'font', 'align', 'verticalAlign', 'growY', 'richText']
invalidEmbedProps.forEach(prop => {
if (prop in fixedRecord.props) {
console.log(`🔧 Removing invalid prop '${prop}' from embed shape ${fixedRecord.id}`)
delete (fixedRecord.props as any)[prop]
}
})
}
// Remove any other problematic properties from shapes
const invalidProps = ['insets', 'scribbles', 'geo']
invalidProps.forEach(prop => {
if (prop in fixedRecord) {
delete (fixedRecord as any)[prop]
}
})
// Final validation - ensure all required properties exist
if (fixedRecord.typeName === 'shape') {
// Ensure basic required properties
if (fixedRecord.x === undefined) fixedRecord.x = 0
if (fixedRecord.y === undefined) fixedRecord.y = 0
if (fixedRecord.rotation === undefined) fixedRecord.rotation = 0
if (fixedRecord.isLocked === undefined) fixedRecord.isLocked = false
if (fixedRecord.opacity === undefined) fixedRecord.opacity = 1
if (!fixedRecord.meta) fixedRecord.meta = {}
// CRITICAL: Final geo shape validation - ALWAYS remove w/h/geo from top level
if (fixedRecord.type === 'geo') {
// Store values before removing from top level
const wValue = 'w' in fixedRecord ? (fixedRecord as any).w : undefined
const hValue = 'h' in fixedRecord ? (fixedRecord as any).h : undefined
const geoValue = 'geo' in fixedRecord ? (fixedRecord as any).geo : undefined
// Ensure props exists
if (!fixedRecord.props) fixedRecord.props = {}
// ALWAYS remove w from top level (even if value is 0 or undefined)
if ('w' in fixedRecord) {
if (!('w' in fixedRecord.props) || fixedRecord.props.w === undefined) {
fixedRecord.props.w = wValue !== undefined ? wValue : 100
}
delete (fixedRecord as any).w
}
// ALWAYS remove h from top level (even if value is 0 or undefined)
if ('h' in fixedRecord) {
if (!('h' in fixedRecord.props) || fixedRecord.props.h === undefined) {
fixedRecord.props.h = hValue !== undefined ? hValue : 100
}
delete (fixedRecord as any).h
}
// ALWAYS remove geo from top level (even if value is undefined)
if ('geo' in fixedRecord) {
if (!('geo' in fixedRecord.props) || fixedRecord.props.geo === undefined) {
fixedRecord.props.geo = geoValue !== undefined ? geoValue : 'rectangle'
}
delete (fixedRecord as any).geo
}
// Ensure geo property exists in props
if (!fixedRecord.props.geo) {
fixedRecord.props.geo = 'rectangle'
}
// Ensure w and h are in props
if (fixedRecord.props.w === undefined) fixedRecord.props.w = 100
if (fixedRecord.props.h === undefined) fixedRecord.props.h = 100
}
// Ensure parentId exists
if (!fixedRecord.parentId) {
const pageRecord = records.find((r: any) => r.typeName === 'page') as any
if (pageRecord && pageRecord.id) {
fixedRecord.parentId = pageRecord.id
}
}
// Ensure props object exists
if (!fixedRecord.props) fixedRecord.props = {}
// Ensure w and h exist in props (except for arrow, draw, line, text, note, and group shapes)
if (fixedRecord.type !== 'arrow' && fixedRecord.type !== 'draw' && fixedRecord.type !== 'line' && fixedRecord.type !== 'text' && fixedRecord.type !== 'note' && fixedRecord.type !== 'group') {
if (fixedRecord.props.w === undefined) fixedRecord.props.w = 100
if (fixedRecord.props.h === undefined) fixedRecord.props.h = 100
} else if (fixedRecord.type === 'text') {
// Text shapes only need w, not h
if (fixedRecord.props.w === undefined) fixedRecord.props.w = 100
} else if (fixedRecord.type === 'line') {
// Line shapes should NOT have w or h properties
if ('w' in fixedRecord.props) {
console.log(`🔧 FINAL FIX: Removing invalid w property from line shape ${fixedRecord.id}`)
delete fixedRecord.props.w
}
if ('h' in fixedRecord.props) {
console.log(`🔧 FINAL FIX: Removing invalid h property from line shape ${fixedRecord.id}`)
delete fixedRecord.props.h
}
// Ensure line shapes have valid points
if (!fixedRecord.props.points || typeof fixedRecord.props.points !== 'object' || Array.isArray(fixedRecord.props.points)) {
console.log(`🔧 FINAL FIX: Creating default points for line shape ${fixedRecord.id}`)
fixedRecord.props.points = {
'a1': { id: 'a1', index: 'a1' as any, x: 0, y: 0 },
'a2': { id: 'a2', index: 'a2' as any, x: 100, y: 0 }
}
} else {
// Validate points
const validPoints: Record<string, { id: string, index: any, x: number, y: number }> = {}
let pointIndex = 0
const indices = ['a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9', 'a10']
for (const [key, point] of Object.entries(fixedRecord.props.points)) {
if (point && typeof point === 'object' &&
typeof (point as any).x === 'number' &&
typeof (point as any).y === 'number' &&
!isNaN((point as any).x) && !isNaN((point as any).y)) {
const index = indices[pointIndex] || `a${pointIndex + 1}`
validPoints[index] = {
id: index,
index: index as any,
x: (point as any).x,
y: (point as any).y
}
pointIndex++
}
}
if (Object.keys(validPoints).length === 0) {
fixedRecord.props.points = {
'a1': { id: 'a1', index: 'a1' as any, x: 0, y: 0 },
'a2': { id: 'a2', index: 'a2' as any, x: 100, y: 0 }
}
} else if (Object.keys(validPoints).length === 1) {
const firstPoint = Object.values(validPoints)[0]
const secondIndex = indices[1] || 'a2'
validPoints[secondIndex] = {
id: secondIndex,
index: secondIndex as any,
x: firstPoint.x + 100,
y: firstPoint.y
}
fixedRecord.props.points = validPoints
} else {
fixedRecord.props.points = validPoints
}
}
// Ensure other required line shape properties
if (!fixedRecord.props.color) fixedRecord.props.color = 'black'
if (!fixedRecord.props.dash) fixedRecord.props.dash = 'draw'
if (!fixedRecord.props.size) fixedRecord.props.size = 'm'
if (!fixedRecord.props.spline) fixedRecord.props.spline = 'line'
if (fixedRecord.props.scale === undefined || fixedRecord.props.scale === null) {
fixedRecord.props.scale = 1
}
} else if (fixedRecord.type === 'note') {
// Note shapes should NOT have w or h properties, but DO need richText
if ('w' in fixedRecord.props) {
console.log(`🔧 FINAL FIX: Removing invalid w property from note shape ${fixedRecord.id}`)
delete fixedRecord.props.w
}
if ('h' in fixedRecord.props) {
console.log(`🔧 FINAL FIX: Removing invalid h property from note shape ${fixedRecord.id}`)
delete fixedRecord.props.h
}
// Note shapes REQUIRE richText property
if (!fixedRecord.props.richText || typeof fixedRecord.props.richText !== 'object') {
console.log(`🔧 FINAL FIX: Adding missing richText property for note shape ${fixedRecord.id}`)
fixedRecord.props.richText = { content: [], type: 'doc' }
}
} else if (fixedRecord.type === 'group') {
// Group shapes should NOT have w or h properties
if ('w' in fixedRecord.props) {
console.log(`🔧 FINAL FIX: Removing invalid w property from group shape ${fixedRecord.id}`)
delete fixedRecord.props.w
}
if ('h' in fixedRecord.props) {
console.log(`🔧 FINAL FIX: Removing invalid h property from group shape ${fixedRecord.id}`)
delete fixedRecord.props.h
}
}
}
// CRITICAL: Final safety check - ensure no geo shapes have w/h/geo at top level
if (fixedRecord.typeName === 'shape' && fixedRecord.type === 'geo') {
// Store values before removing from top level
const wValue = 'w' in fixedRecord ? (fixedRecord as any).w : undefined
const hValue = 'h' in fixedRecord ? (fixedRecord as any).h : undefined
const geoValue = 'geo' in fixedRecord ? (fixedRecord as any).geo : undefined
// Create cleaned record without w/h/geo at top level
const cleaned: any = {}
for (const key in fixedRecord) {
if (key !== 'w' && key !== 'h' && key !== 'geo') {
cleaned[key] = (fixedRecord as any)[key]
}
}
// Ensure props exists and move values there if needed
if (!cleaned.props) cleaned.props = {}
if (wValue !== undefined && (!('w' in cleaned.props) || cleaned.props.w === undefined)) {
cleaned.props.w = wValue
}
if (hValue !== undefined && (!('h' in cleaned.props) || cleaned.props.h === undefined)) {
cleaned.props.h = hValue
}
if (geoValue !== undefined && (!('geo' in cleaned.props) || cleaned.props.geo === undefined)) {
cleaned.props.geo = geoValue
}
fixedRecord = cleaned as any
}
// CRITICAL: Final safety check - ensure text shapes don't have props.text (TLDraw schema doesn't allow it)
// Text shapes should only use props.richText, not props.text
if (fixedRecord.typeName === 'shape' && fixedRecord.type === 'text' && fixedRecord.props && 'text' in fixedRecord.props) {
delete (fixedRecord.props as any).text
}
store.mergeRemoteChanges(() => {
store.put([fixedRecord])
})
console.log(`✅ Successfully loaded fixed record ${fixedRecord.id}`)
successCount++
} catch (retryError) {
console.error(`❌ Still failed to load record ${record.id} after fix attempt:`, retryError)
}
}
}
}
}
// Verify loading
const storeRecords = store.allRecords()
const shapes = storeRecords.filter(r => r.typeName === 'shape')
console.log(`📊 Store verification: ${processedRecords.length} processed records, ${storeRecords.length} total store records, ${shapes.length} shapes`)
// Debug: Check if shapes have the right structure
if (shapes.length > 0) {
console.log("📊 Sample loaded shape:", {
id: shapes[0].id,
type: shapes[0].type,
x: shapes[0].x,
y: shapes[0].y,
hasProps: !!shapes[0].props,
propsKeys: shapes[0].props ? Object.keys(shapes[0].props) : [],
allKeys: Object.keys(shapes[0])
})
// Validate all shapes have proper structure
const invalidShapes = shapes.filter(shape => {
const issues = []
if (!shape.props) issues.push('missing props')
// Only check w/h for shapes that actually need them
const shapesWithoutWH = ['arrow', 'draw', 'text', 'note', 'line']
if (!shapesWithoutWH.includes(shape.type) && (!(shape.props as any)?.w || !(shape.props as any)?.h)) {
issues.push('missing w/h in props')
}
if ('w' in shape || 'h' in shape) {
issues.push('w/h at top level instead of props')
}
return issues.length > 0
})
if (invalidShapes.length > 0) {
console.warn(`⚠️ Found ${invalidShapes.length} shapes with structural issues:`, invalidShapes.map(s => ({
id: s.id,
type: s.type,
issues: {
missingProps: !s.props,
missingWH: s.type !== 'arrow' && s.type !== 'draw' && (!(s.props as any)?.w || !(s.props as any)?.h),
topLevelWH: 'w' in s || 'h' in s
}
})))
}
}
// Debug: Check for any shapes that might have validation issues
const shapesWithTopLevelW = shapes.filter(s => 'w' in s)
const shapesWithTopLevelH = shapes.filter(s => 'h' in s)
if (shapesWithTopLevelW.length > 0 || shapesWithTopLevelH.length > 0) {
console.warn(`📊 Found ${shapesWithTopLevelW.length} shapes with top-level w, ${shapesWithTopLevelH.length} with top-level h`)
// Fix shapes with top-level w/h properties
shapesWithTopLevelW.forEach(shape => {
console.log(`🔧 Fixing shape ${shape.id} with top-level w property`)
if (!shape.props) shape.props = {}
;(shape.props as any).w = (shape as any).w
delete (shape as any).w
})
shapesWithTopLevelH.forEach(shape => {
console.log(`🔧 Fixing shape ${shape.id} with top-level h property`)
if (!shape.props) shape.props = {}
;(shape.props as any).h = (shape as any).h
delete (shape as any).h
})
}
if (shapes.length === 0) {
// Only log if debugging is needed
// console.log("No store data found in Automerge document")
}
}
// Only log if debugging is needed
// console.log("Setting store status to synced-remote")
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
})
} catch (error) {
console.error("Error initializing store from Automerge:", error)
// Try to recover by creating a minimal valid store
try {
console.log("Attempting to recover with minimal store...")
const minimalStore = createTLStore({
schema: customSchema,
})
// Add basic page and camera records
minimalStore.mergeRemoteChanges(() => {
minimalStore.put([
{
id: 'page:page' as any,
typeName: 'page',
name: 'Page',
index: 'a0' as any,
meta: {}
},
{
id: 'camera:page:page' as any,
typeName: 'camera',
x: 0,
y: 0,
z: 1,
meta: {}
}
])
})
setStoreWithStatus({
store: minimalStore,
status: "synced-remote",
connectionStatus: "offline",
error: error instanceof Error ? error : new Error("Store initialization failed, using minimal store") as any,
})
} catch (recoveryError) {
console.error("Failed to recover with minimal store:", recoveryError)
setStoreWithStatus({
store,
status: "not-synced",
error: error instanceof Error ? error : new Error("Unknown error") as any,
})
}
}
}
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: {},
}
}