canvas-website/src/automerge/useAutomergeStoreV2.ts

2276 lines
120 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) {
// Debug: Check if patches contain shapes
const shapePatches = payload.patches.filter((p: any) => {
const id = p.path?.[1]
return id && typeof id === 'string' && id.startsWith('shape:')
})
if (shapePatches.length > 0) {
console.log(`🔌 Automerge patches contain ${shapePatches.length} shape patches out of ${payload.patches.length} total patches`)
}
try {
const recordsBefore = store.allRecords()
const shapesBefore = recordsBefore.filter((r: any) => r.typeName === 'shape')
applyAutomergePatchesToTLStore(payload.patches, store)
const recordsAfter = store.allRecords()
const shapesAfter = recordsAfter.filter((r: any) => r.typeName === 'shape')
if (shapesAfter.length !== shapesBefore.length) {
console.log(`✅ Applied ${payload.patches.length} patches: shapes changed from ${shapesBefore.length} to ${shapesAfter.length}`)
}
// Only log if there are many patches or if debugging is needed
if (payload.patches.length > 5) {
console.log(`✅ Successfully applied ${payload.patches.length} patches`)
}
} catch (patchError) {
console.error("Error applying patches batch, attempting individual patch application:", patchError)
// Try applying patches one by one to identify problematic ones
// This is a fallback - ideally we should fix the data at the source
let successCount = 0
let failedPatches: any[] = []
for (const patch of payload.patches) {
try {
applyAutomergePatchesToTLStore([patch], store)
successCount++
} catch (individualPatchError) {
failedPatches.push({ patch, error: individualPatchError })
console.error(`Failed to apply individual patch:`, individualPatchError)
// Log the problematic patch for debugging
const recordId = patch.path[1] as string
console.error("Problematic patch details:", {
action: patch.action,
path: patch.path,
recordId: recordId,
value: 'value' in patch ? patch.value : undefined,
errorMessage: individualPatchError instanceof Error ? individualPatchError.message : String(individualPatchError)
})
// Try to get more context about the failing record
try {
const existingRecord = store.get(recordId as any)
console.error("Existing record that failed:", existingRecord)
// If it's a geo shape missing props.geo, try to fix it
if (existingRecord && (existingRecord as any).typeName === 'shape' && (existingRecord as any).type === 'geo') {
const geoRecord = existingRecord as any
if (!geoRecord.props || !geoRecord.props.geo) {
console.log(`🔧 Attempting to fix geo shape ${recordId} missing props.geo`)
// This won't help with the current patch, but might help future patches
// The real fix should happen in AutomergeToTLStore sanitization
}
}
} catch (e) {
console.error("Could not retrieve existing record:", e)
}
}
}
// Log summary
if (failedPatches.length > 0) {
console.error(`❌ Failed to apply ${failedPatches.length} out of ${payload.patches.length} patches`)
// Most common issue: geo shapes missing props.geo - this should be fixed in sanitization
const geoShapeErrors = failedPatches.filter(p =>
p.error instanceof Error && p.error.message.includes('props.geo')
)
if (geoShapeErrors.length > 0) {
console.error(`⚠️ ${geoShapeErrors.length} failures due to missing props.geo - this should be fixed in AutomergeToTLStore sanitization`)
}
}
if (successCount < payload.patches.length || payload.patches.length > 5) {
console.log(`✅ Successfully applied ${successCount} out of ${payload.patches.length} patches`)
}
}
}
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
})
} catch (error) {
console.error("Error applying Automerge patches to TLDraw:", error)
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "offline",
error: error instanceof Error ? error : new Error("Unknown error") as any,
})
}
}
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
// NOTE: JSON sync might have already loaded data into the store
// Check if store is already populated before loading from Automerge
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(`📊 Automerge store initialization: doc has ${storeKeys.length} records (${docShapes} shapes), store already has ${existingStoreRecords.length} records (${existingStoreShapes.length} shapes)`)
// If store already has shapes (from JSON sync), skip Automerge initialization
// JSON sync happened first and loaded the data
if (existingStoreShapes.length > 0 && docShapes === 0) {
console.log(` Store already populated from JSON sync (${existingStoreShapes.length} shapes). Skipping Automerge initialization to prevent overwriting.`)
setStoreWithStatus({
store,
status: "synced-remote",
connectionStatus: "online",
})
return // Skip Automerge initialization
}
console.log(`📊 Store keys count: ${storeKeys.length}`, storeKeys.slice(0, 10))
// Get all store values - Automerge should handle this correctly
const allStoreValues = Object.values(doc.store)
// Debug: Log first few records in detail to see their structure
console.log("📊 Sample store values (first 3):", allStoreValues.slice(0, 3).map((v: any) => {
try {
return {
hasTypeName: !!v?.typeName,
hasId: !!v?.id,
typeName: v?.typeName,
id: v?.id,
type: v?.type,
keys: v ? Object.keys(v).slice(0, 10) : [],
// Try to stringify a sample to see structure
sample: JSON.stringify(v).substring(0, 200)
}
} catch (e) {
return { error: String(e), value: v }
}
}))
// Debug: Count record types before filtering
const typeCountBefore = allStoreValues.reduce((acc: any, v: any) => {
const type = v?.typeName || 'unknown'
acc[type] = (acc[type] || 0) + 1
return acc
}, {})
console.log(`📊 Store values before filtering:`, {
total: allStoreValues.length,
typeCounts: typeCountBefore
})
// 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) {
console.log(`⚠️ Filtering out invalid record:`, { hasRecord: !!record, hasTypeName: !!record?.typeName, hasId: !!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
})
// Track shape types before processing to ensure all are loaded
const shapeRecordsBefore = records.filter((r: any) => r.typeName === 'shape')
const shapeTypeCountsBefore = shapeRecordsBefore.reduce((acc: any, r: any) => {
const type = r.type || 'unknown'
acc[type] = (acc[type] || 0) + 1
return acc
}, {})
console.log(`📊 After filtering: ${records.length} valid records from ${allStoreValues.length} total store values`)
console.log(`📊 Shape type breakdown before processing (${shapeRecordsBefore.length} shapes):`, shapeTypeCountsBefore)
// 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
// Use a more robust serialization that handles Automerge proxies
let processedRecord: any
try {
// First try JSON serialization (works for most cases)
processedRecord = JSON.parse(JSON.stringify(record))
// Verify the record has essential properties
if (!processedRecord.typeName || !processedRecord.id) {
// If serialization lost properties, try accessing them directly
processedRecord = {
...record,
typeName: record.typeName,
id: record.id,
type: record.type,
props: record.props ? { ...record.props } : {},
}
// Copy all enumerable properties
for (const key in record) {
if (!(key in processedRecord)) {
try {
processedRecord[key] = record[key]
} catch (e) {
// Skip properties that can't be accessed
}
}
}
}
} catch (e) {
// Fallback: manual copy if JSON serialization fails
console.warn(`⚠️ JSON serialization failed for record ${record?.id}, using manual copy:`, e)
processedRecord = {
typeName: record.typeName,
id: record.id,
type: record.type,
props: record.props ? { ...record.props } : {},
}
// Copy all enumerable properties
for (const key in record) {
try {
processedRecord[key] = record[key]
} catch (err) {
// Skip properties that can't be accessed
}
}
}
// 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) {
// Find all page records
const pageRecords = records.filter((r: any) => r.typeName === 'page') as any[]
// Prefer 'page:page' if it exists, otherwise use the first page found
const pageRecord = pageRecords.find((p: any) => p.id === 'page:page') || pageRecords[0]
if (pageRecord && pageRecord.id) {
processedRecord.parentId = pageRecord.id
} else {
// Default to 'page:page' - TLDraw will create it if needed
processedRecord.parentId = 'page:page'
}
} else {
// Validate that the parentId points to an existing page
const parentPage = records.find((r: any) => r.typeName === 'page' && r.id === processedRecord.parentId)
if (!parentPage) {
// Parent page doesn't exist, assign to first available page or default
const pageRecords = records.filter((r: any) => r.typeName === 'page') as any[]
const pageRecord = pageRecords.find((p: any) => p.id === 'page:page') || pageRecords[0]
if (pageRecord && pageRecord.id) {
console.log(`🔧 Shape ${processedRecord.id} has invalid parentId ${processedRecord.parentId}, reassigning to ${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
// CRITICAL: Include ALL original tldraw shapes to ensure they're preserved
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]
// Normalize shape type to handle case variations and known aliases
const normalizedType = processedRecord.type?.toLowerCase()
const isDefaultShape = validDefaultShapes.includes(normalizedType)
const isCustomShape = validCustomShapes.includes(processedRecord.type)
// Handle known shape type aliases/variations
const shapeTypeAliases: Record<string, string> = {
'transcribe': 'Transcription', // "Transcribe" -> "Transcription"
'transcription': 'Transcription', // lowercase -> proper case
}
const aliasType = shapeTypeAliases[normalizedType] || shapeTypeAliases[processedRecord.type]
if (aliasType) {
console.log(`🔧 Normalizing shape type from "${processedRecord.type}" to "${aliasType}" for shape:`, processedRecord.id)
processedRecord.type = aliasType
} else if (isDefaultShape && processedRecord.type !== normalizedType) {
// If it's a valid default shape but with wrong casing, normalize it
console.log(`🔧 Normalizing shape type from "${processedRecord.type}" to "${normalizedType}" for shape:`, processedRecord.id)
processedRecord.type = normalizedType
} else if (!isDefaultShape && !isCustomShape) {
// Only convert to text if it's truly unknown
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 what record types we have
const recordTypes = processedRecords.reduce((acc: any, r: any) => {
const type = r.typeName || 'unknown'
acc[type] = (acc[type] || 0) + 1
return acc
}, {})
console.log(`📊 Record types breakdown:`, recordTypes)
console.log(`📊 All processed records:`, processedRecords.map((r: any) => ({
id: r.id,
typeName: r.typeName,
type: r.type,
hasProps: !!r.props
})))
// Debug: Log shape structures before loading - track ALL shape types
const shapesToLoad = processedRecords.filter(r => r.typeName === 'shape')
const shapeTypeCountsToLoad = shapesToLoad.reduce((acc: any, r: any) => {
const type = r.type || 'unknown'
acc[type] = (acc[type] || 0) + 1
return acc
}, {})
console.log(`📊 About to load ${shapesToLoad.length} shapes into store`)
console.log(`📊 Shape type breakdown to load:`, shapeTypeCountsToLoad)
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 (first 20)
const shapesToLog = shapesToLoad.slice(0, 20)
console.log("📊 Processed shapes (first 20):", shapesToLog.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
})))
if (shapesToLoad.length > 20) {
console.log(`📊 ... and ${shapesToLoad.length - 20} more shapes`)
}
}
// 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
}
// CRITICAL: props.geo is REQUIRED for geo shapes - TLDraw validation will fail without it
// Ensure it's always set, defaulting to 'rectangle' if missing
if (!record.props) record.props = {}
if (!record.props.geo || record.props.geo === undefined || record.props.geo === null) {
record.props.geo = 'rectangle'
}
// CRITICAL: props.dash is REQUIRED for geo shapes - TLDraw validation will fail without it
// Ensure it's always set, defaulting to 'draw' if missing
if (!record.props.dash || record.props.dash === undefined || record.props.dash === null) {
record.props.dash = 'draw'
}
}
// 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 - track ALL shape types that were successfully loaded
const storeRecords = store.allRecords()
const shapes = storeRecords.filter(r => r.typeName === 'shape')
const shapeTypeCountsAfter = shapes.reduce((acc: any, r: any) => {
const type = (r as any).type || 'unknown'
acc[type] = (acc[type] || 0) + 1
return acc
}, {})
console.log(`📊 Store verification: ${processedRecords.length} processed records, ${storeRecords.length} total store records, ${shapes.length} shapes`)
console.log(`📊 Shape type breakdown after loading:`, shapeTypeCountsAfter)
// 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: {},
}
}