canvas-website/src/automerge/AutomergeToTLStore.ts

1074 lines
45 KiB
TypeScript

import { TLRecord, RecordId, TLStore } from "@tldraw/tldraw"
import * as Automerge from "@automerge/automerge"
export function applyAutomergePatchesToTLStore(
patches: Automerge.Patch[],
store: TLStore,
automergeDoc?: any // Optional Automerge document to read full records from
) {
const toRemove: TLRecord["id"][] = []
const updatedObjects: { [id: string]: TLRecord } = {}
patches.forEach((patch) => {
if (!isStorePatch(patch)) return
const id = pathToId(patch.path)
// Skip records with empty or invalid IDs
if (!id || id === '') {
return
}
// CRITICAL: Skip custom record types that aren't TLDraw records
// These should only exist in Automerge, not in TLDraw store
// Components like ObsidianVaultBrowser read directly from Automerge
if (typeof id === 'string' && id.startsWith('obsidian_vault:')) {
return // Skip - not a TLDraw record, don't process
}
const existingRecord = getRecordFromStore(store, id)
// CRITICAL: For shapes, get coordinates from store's current state BEFORE any patch processing
// This ensures we preserve coordinates even if patches don't include them
// This is especially important when patches come back after store.put operations
let storeCoordinates: { x?: number; y?: number } = {}
if (existingRecord && existingRecord.typeName === 'shape') {
const storeX = (existingRecord as any).x
const storeY = (existingRecord as any).y
if (typeof storeX === 'number' && !isNaN(storeX) && storeX !== null && storeX !== undefined) {
storeCoordinates.x = storeX
}
if (typeof storeY === 'number' && !isNaN(storeY) && storeY !== null && storeY !== undefined) {
storeCoordinates.y = storeY
}
}
// CRITICAL: If record doesn't exist in store yet, try to get it from Automerge document
// This prevents coordinates from defaulting to 0,0 when patches create new records
let automergeRecord: any = null
if (!existingRecord && automergeDoc && automergeDoc.store && automergeDoc.store[id]) {
try {
automergeRecord = automergeDoc.store[id]
// Extract coordinates from Automerge record if it's a shape
if (automergeRecord && automergeRecord.typeName === 'shape') {
const docX = automergeRecord.x
const docY = automergeRecord.y
if (typeof docX === 'number' && !isNaN(docX) && docX !== null && docX !== undefined) {
storeCoordinates.x = docX
}
if (typeof docY === 'number' && !isNaN(docY) && docY !== null && docY !== undefined) {
storeCoordinates.y = docY
}
}
} catch (e) {
// If we can't read from Automerge doc, continue without it
console.warn(`Could not read record ${id} from Automerge document:`, e)
}
}
// Infer typeName from ID pattern if record doesn't exist
let defaultTypeName = 'shape'
let defaultRecord: any = {
id,
typeName: 'shape',
type: 'geo', // Default shape type
x: 0,
y: 0,
rotation: 0,
isLocked: false,
opacity: 1,
meta: {},
props: {}
}
// Check if ID pattern indicates a record type
// Note: obsidian_vault records are skipped above, so we don't need to handle them here
if (typeof id === 'string') {
if (id.startsWith('shape:')) {
defaultTypeName = 'shape'
// Keep default shape record structure
} else if (id.startsWith('page:')) {
defaultTypeName = 'page'
defaultRecord = {
id,
typeName: 'page',
name: '',
index: 'a0' as any,
meta: {}
}
} else if (id.startsWith('camera:')) {
defaultTypeName = 'camera'
defaultRecord = {
id,
typeName: 'camera',
x: 0,
y: 0,
z: 1,
meta: {}
}
} else if (id.startsWith('instance:')) {
defaultTypeName = 'instance'
defaultRecord = {
id,
typeName: 'instance',
currentPageId: 'page:page' as any,
meta: {}
}
} else if (id.startsWith('pointer:')) {
defaultTypeName = 'pointer'
defaultRecord = {
id,
typeName: 'pointer',
x: 0,
y: 0,
lastActivityTimestamp: 0,
meta: {}
}
} else if (id.startsWith('document:')) {
defaultTypeName = 'document'
defaultRecord = {
id,
typeName: 'document',
gridSize: 10,
name: '',
meta: {}
}
}
}
// CRITICAL: When creating a new record, prefer using the full record from Automerge document
// This ensures we get all properties including coordinates, not just defaults
let record: any
if (updatedObjects[id]) {
record = updatedObjects[id]
} else if (existingRecord) {
record = JSON.parse(JSON.stringify(existingRecord))
} else if (automergeRecord) {
// Use the full record from Automerge document - this has all properties including coordinates
record = JSON.parse(JSON.stringify(automergeRecord))
} else {
// Fallback to default record only if we can't get it from anywhere else
record = defaultRecord
}
// CRITICAL: For shapes, ensure x and y are always present (even if record came from updatedObjects)
// This prevents coordinates from being lost when records are created from patches
if (record.typeName === 'shape') {
if (typeof record.x !== 'number' || record.x === null || isNaN(record.x)) {
record = { ...record, x: defaultRecord.x || 0 }
}
if (typeof record.y !== 'number' || record.y === null || isNaN(record.y)) {
record = { ...record, y: defaultRecord.y || 0 }
}
}
// CRITICAL: Ensure typeName matches ID pattern (fixes misclassification)
// Note: obsidian_vault records are skipped above, so we don't need to handle them here
if (typeof id === 'string') {
let correctTypeName = record.typeName
if (id.startsWith('shape:') && record.typeName !== 'shape') {
correctTypeName = 'shape'
} else if (id.startsWith('page:') && record.typeName !== 'page') {
correctTypeName = 'page'
} else if (id.startsWith('camera:') && record.typeName !== 'camera') {
correctTypeName = 'camera'
} else if (id.startsWith('instance:') && record.typeName !== 'instance') {
correctTypeName = 'instance'
} else if (id.startsWith('pointer:') && record.typeName !== 'pointer') {
correctTypeName = 'pointer'
} else if (id.startsWith('document:') && record.typeName !== 'document') {
correctTypeName = 'document'
}
// Create new object with correct typeName if it changed
if (correctTypeName !== record.typeName) {
record = { ...record, typeName: correctTypeName } as TLRecord
}
}
// CRITICAL: Store original x and y before patch application to preserve them
// Priority: Use coordinates from store's current state (most reliable), then from record, then undefined
// This ensures we preserve coordinates even when patches come back after store.put operations
const recordX = (record.typeName === 'shape' && typeof record.x === 'number' && !isNaN(record.x)) ? record.x : undefined
const recordY = (record.typeName === 'shape' && typeof record.y === 'number' && !isNaN(record.y)) ? record.y : undefined
const originalX = storeCoordinates.x !== undefined ? storeCoordinates.x : recordX
const originalY = storeCoordinates.y !== undefined ? storeCoordinates.y : recordY
const hadOriginalCoordinates = originalX !== undefined && originalY !== undefined
// CRITICAL: Store original richText and arrow text before patch application to preserve them
// This ensures richText and arrow text aren't lost when patches only update other properties
let originalRichText: any = undefined
let originalArrowText: any = undefined
if (record.typeName === 'shape') {
// Get richText from store's current state (most reliable)
if (existingRecord && (existingRecord as any).props && (existingRecord as any).props.richText) {
originalRichText = (existingRecord as any).props.richText
} else if ((record as any).props && (record as any).props.richText) {
originalRichText = (record as any).props.richText
}
// Get arrow text from store's current state (most reliable)
if ((record as any).type === 'arrow') {
if (existingRecord && (existingRecord as any).props && (existingRecord as any).props.text !== undefined) {
originalArrowText = (existingRecord as any).props.text
} else if ((record as any).props && (record as any).props.text !== undefined) {
originalArrowText = (record as any).props.text
}
}
}
switch (patch.action) {
case "insert": {
updatedObjects[id] = applyInsertToObject(patch, record)
break
}
case "put":
updatedObjects[id] = applyPutToObject(patch, record)
break
case "del": {
const id = pathToId(patch.path)
toRemove.push(id as TLRecord["id"])
break
}
case "splice": {
updatedObjects[id] = applySpliceToObject(patch, record)
break
}
case "inc": {
updatedObjects[id] = applyIncToObject(patch, record)
break
}
case "mark":
case "unmark":
case "conflict": {
// These actions are not currently supported for TLDraw
console.log("Unsupported patch action:", patch.action)
break
}
default: {
console.log("Unsupported patch:", patch)
}
}
// CRITICAL: After patch application, ensure x and y coordinates are preserved for shapes
// This prevents coordinates from being reset to 0,0 when patches don't include them
if (updatedObjects[id] && updatedObjects[id].typeName === 'shape') {
const patchedRecord = updatedObjects[id]
const patchedX = (patchedRecord as any).x
const patchedY = (patchedRecord as any).y
const patchedHasValidX = typeof patchedX === 'number' && !isNaN(patchedX) && patchedX !== null && patchedX !== undefined
const patchedHasValidY = typeof patchedY === 'number' && !isNaN(patchedY) && patchedY !== null && patchedY !== undefined
// CRITICAL: If we had original coordinates, preserve them unless patch explicitly set different valid coordinates
// This prevents coordinates from collapsing to 0,0 after bulk upload
if (hadOriginalCoordinates) {
// Only use patched coordinates if they're explicitly set and different from original
// Otherwise, preserve the original coordinates
if (patchedHasValidX && patchedX !== originalX) {
// Patch explicitly set a different X coordinate - use it
updatedObjects[id] = { ...patchedRecord, x: patchedX }
} else {
// Preserve original X coordinate
updatedObjects[id] = { ...patchedRecord, x: originalX }
}
if (patchedHasValidY && patchedY !== originalY) {
// Patch explicitly set a different Y coordinate - use it
updatedObjects[id] = { ...updatedObjects[id], y: patchedY } as TLRecord
} else {
// Preserve original Y coordinate
updatedObjects[id] = { ...updatedObjects[id], y: originalY } as TLRecord
}
} else {
// No original coordinates - use patched values or defaults
if (!patchedHasValidX) {
updatedObjects[id] = { ...patchedRecord, x: defaultRecord.x || 0 }
}
if (!patchedHasValidY) {
updatedObjects[id] = { ...updatedObjects[id], y: defaultRecord.y || 0 } as TLRecord
}
}
// CRITICAL: Preserve richText and arrow text after patch application
// This prevents richText and arrow text from being lost when patches only update other properties
const currentRecord = updatedObjects[id]
// Preserve richText for geo/note/text shapes
if (originalRichText !== undefined && (currentRecord as any).type !== 'arrow') {
const patchedProps = (currentRecord as any).props || {}
const patchedRichText = patchedProps.richText
// If patch didn't include richText, preserve the original
if (patchedRichText === undefined || patchedRichText === null) {
updatedObjects[id] = {
...currentRecord,
props: {
...patchedProps,
richText: originalRichText
}
} as TLRecord
}
}
// Preserve arrow text for arrow shapes
if (originalArrowText !== undefined && (currentRecord as any).type === 'arrow') {
const patchedProps = (currentRecord as any).props || {}
const patchedText = patchedProps.text
// If patch didn't include text, preserve the original
if (patchedText === undefined || patchedText === null) {
updatedObjects[id] = {
...currentRecord,
props: {
...patchedProps,
text: originalArrowText
}
} as TLRecord
}
}
}
// CRITICAL: Re-check typeName after patch application to ensure it's still correct
// Note: obsidian_vault records are skipped above, so we don't need to handle them here
})
// Sanitize records before putting them in the store
const toPut: TLRecord[] = []
const failedRecords: any[] = []
Object.values(updatedObjects).forEach(record => {
// Skip records with empty or invalid IDs
if (!record || !record.id || record.id === '') {
return
}
// CRITICAL: Skip custom record types that aren't TLDraw records
// These should only exist in Automerge, not in TLDraw store
if (typeof record.id === 'string' && record.id.startsWith('obsidian_vault:')) {
return // Skip - not a TLDraw record
}
// Filter out SharedPiano shapes since they're no longer supported
if (record.typeName === 'shape' && (record as any).type === 'SharedPiano') {
console.log(`⚠️ Filtering out deprecated SharedPiano shape: ${record.id}`)
return // Skip - SharedPiano is deprecated
}
try {
const sanitized = sanitizeRecord(record)
toPut.push(sanitized)
} catch (error) {
// If it's a missing typeName/id error, skip it
if (error instanceof Error &&
(error.message.includes('missing required typeName') ||
error.message.includes('missing required id'))) {
// Skip records with missing required fields
return
}
console.error("Failed to sanitize record:", error, record)
failedRecords.push(record)
}
})
// put / remove the records in the store
// Log patch application for debugging
console.log(`🔧 AutomergeToTLStore: Applying ${patches.length} patches, ${toPut.length} records to put, ${toRemove.length} records to remove`)
if (failedRecords.length > 0) {
console.log({ patches, toPut: toPut.length, failed: failedRecords.length })
}
if (failedRecords.length > 0) {
console.error("Failed to sanitize records:", failedRecords)
}
// CRITICAL: Final safety check - ensure no geo shapes have w/h/geo at top level
// Also ensure text shapes don't have props.text (should use props.richText instead)
const finalSanitized = toPut.map(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
}
// CRITICAL: props.geo is REQUIRED for geo shapes - TLDraw validation will fail without it
// Use geoValue if available, otherwise default to 'rectangle'
if (geoValue !== undefined) {
cleaned.props.geo = geoValue
} else if (!cleaned.props.geo || cleaned.props.geo === undefined || cleaned.props.geo === null) {
// Default to rectangle if geo is missing
cleaned.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 (!cleaned.props.dash || cleaned.props.dash === undefined || cleaned.props.dash === null) {
cleaned.props.dash = 'draw'
}
return cleaned as TLRecord
}
// CRITICAL: Remove props.text from text shapes (TLDraw schema doesn't allow it)
if (record.typeName === 'shape' && record.type === 'text' && (record as any).props && 'text' in (record as any).props) {
const cleaned = { ...record }
if (cleaned.props && 'text' in cleaned.props) {
delete (cleaned.props as any).text
}
return cleaned as TLRecord
}
return record
})
store.mergeRemoteChanges(() => {
if (toRemove.length) store.remove(toRemove)
if (finalSanitized.length) store.put(finalSanitized)
})
}
// Helper function to clean NaN values from richText content
// This prevents SVG export errors when TLDraw tries to render text with invalid coordinates
function cleanRichTextNaN(richText: any): any {
if (!richText || typeof richText !== 'object') {
return richText
}
// Deep clone to avoid mutating the original
const cleaned = JSON.parse(JSON.stringify(richText))
// Recursively clean content array
if (Array.isArray(cleaned.content)) {
cleaned.content = cleaned.content.map((item: any) => {
if (typeof item === 'object' && item !== null) {
// Remove any NaN values from the item
const cleanedItem: any = {}
for (const key in item) {
const value = item[key]
// Skip NaN values - they cause SVG export errors
if (typeof value === 'number' && isNaN(value)) {
// Skip NaN values
continue
}
// Recursively clean nested objects
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
cleanedItem[key] = cleanRichTextNaN(value)
} else if (Array.isArray(value)) {
cleanedItem[key] = value.map((v: any) =>
typeof v === 'object' && v !== null ? cleanRichTextNaN(v) : v
)
} else {
cleanedItem[key] = value
}
}
return cleanedItem
}
return item
})
}
return cleaned
}
// Minimal sanitization - only fix critical issues that break TLDraw
// EXPORTED: Use this same sanitization for patch-based loading (same as dev mode)
export function sanitizeRecord(record: any): TLRecord {
const sanitized = { ...record }
// CRITICAL FIXES ONLY - preserve all other properties
// Only fix critical structural issues
if (!sanitized.id || sanitized.id === '') {
throw new Error("Record missing required id field")
}
if (!sanitized.typeName || sanitized.typeName === '') {
throw new Error("Record missing required typeName field")
}
// For shapes, only ensure basic required fields exist
if (sanitized.typeName === 'shape') {
// CRITICAL: Remove instance-only properties from shapes (these cause validation errors)
// These properties should only exist on instance records, not shape records
const instanceOnlyProperties = ['insets', 'brush', 'zoomBrush', 'scribbles', 'duplicateProps']
instanceOnlyProperties.forEach(prop => {
if (prop in sanitized) {
delete (sanitized as any)[prop]
}
})
// Ensure required shape fields exist
// CRITICAL: Only set defaults if coordinates are truly missing or invalid
// DO NOT overwrite valid coordinates (including 0, which is a valid position)
// Only set to 0 if the value is undefined, null, or NaN
if (sanitized.x === undefined || sanitized.x === null || (typeof sanitized.x === 'number' && isNaN(sanitized.x))) {
sanitized.x = 0
}
if (sanitized.y === undefined || sanitized.y === null || (typeof sanitized.y === 'number' && isNaN(sanitized.y))) {
sanitized.y = 0
}
if (typeof sanitized.rotation !== 'number') sanitized.rotation = 0
if (typeof sanitized.isLocked !== 'boolean') sanitized.isLocked = false
if (typeof sanitized.opacity !== 'number') sanitized.opacity = 1
// CRITICAL: Preserve all existing meta properties - only create empty object if meta doesn't exist
if (!sanitized.meta || typeof sanitized.meta !== 'object') {
sanitized.meta = {}
} else {
// Ensure meta is a mutable copy to preserve all properties (including text for rectangles)
sanitized.meta = { ...sanitized.meta }
}
if (!sanitized.index) sanitized.index = 'a1'
if (!sanitized.parentId) sanitized.parentId = 'page:page'
if (!sanitized.props || typeof sanitized.props !== 'object') sanitized.props = {}
// CRITICAL: Ensure props is a deep mutable copy to preserve all nested properties
// This is essential for custom shapes like ObsNote and for preserving richText in geo shapes
// Use JSON parse/stringify to create a deep copy of nested objects (like richText.content)
try {
sanitized.props = JSON.parse(JSON.stringify(sanitized.props))
} catch (e) {
// If JSON serialization fails (e.g., due to functions or circular references),
// create a shallow copy and recursively clean it
console.warn(`⚠️ Could not deep copy props for shape ${sanitized.id}, using shallow copy:`, e)
const propsCopy: any = {}
for (const key in sanitized.props) {
try {
const value = sanitized.props[key]
// Skip functions
if (typeof value === 'function') {
continue
}
// Try to serialize individual values
try {
propsCopy[key] = JSON.parse(JSON.stringify(value))
} catch (valueError) {
// If individual value can't be serialized, use it as-is if it's a primitive
if (value === null || value === undefined || typeof value !== 'object') {
propsCopy[key] = value
}
// Otherwise skip it
}
} catch (keyError) {
// Skip properties that can't be accessed
continue
}
}
sanitized.props = propsCopy
}
// CRITICAL: Map old shape type names to new ones (migration support)
// This handles renamed shape types from old data
if (sanitized.type === 'Transcribe') {
sanitized.type = 'Transcription'
}
// CRITICAL: Infer type from properties BEFORE defaulting to 'geo'
// This ensures arrows and other shapes are properly recognized
if (!sanitized.type || typeof sanitized.type !== 'string') {
// Check for arrow-specific properties first
if (sanitized.props?.start !== undefined ||
sanitized.props?.end !== undefined ||
sanitized.props?.arrowheadStart !== undefined ||
sanitized.props?.arrowheadEnd !== undefined ||
sanitized.props?.kind === 'line' ||
sanitized.props?.kind === 'curved' ||
sanitized.props?.kind === 'straight') {
sanitized.type = 'arrow'
}
// Check for line-specific properties
else if (sanitized.props?.points !== undefined) {
sanitized.type = 'line'
}
// Check for geo-specific properties (w/h/geo)
else if (sanitized.props?.geo !== undefined ||
('w' in sanitized && 'h' in sanitized) ||
('w' in sanitized.props && 'h' in sanitized.props)) {
sanitized.type = 'geo'
}
// Check for note-specific properties
else if (sanitized.props?.growY !== undefined ||
sanitized.props?.verticalAlign !== undefined) {
sanitized.type = 'note'
}
// Check for text-specific properties
else if (sanitized.props?.textAlign !== undefined ||
sanitized.props?.autoSize !== undefined) {
sanitized.type = 'text'
}
// Check for draw-specific properties
else if (sanitized.props?.segments !== undefined) {
sanitized.type = 'draw'
}
// Default to geo only if no other indicators found
else {
sanitized.type = 'geo'
}
}
// CRITICAL: For geo shapes, move w/h/geo from top level to props (required by TLDraw schema)
if (sanitized.type === 'geo' || ('w' in sanitized && 'h' in sanitized && sanitized.type !== 'arrow')) {
// If type is missing but has w/h, assume it's a geo shape (but only if not already identified as arrow)
if (!sanitized.type || sanitized.type === 'geo') {
sanitized.type = 'geo'
}
// Ensure props exists
if (!sanitized.props) sanitized.props = {}
// Store values before removing from top level
const wValue = 'w' in sanitized ? (sanitized as any).w : undefined
const hValue = 'h' in sanitized ? (sanitized as any).h : undefined
const geoValue = 'geo' in sanitized ? (sanitized as any).geo : undefined
// Move w from top level to props (if present at top level)
if (wValue !== undefined) {
if (!('w' in sanitized.props) || sanitized.props.w === undefined) {
sanitized.props.w = wValue
}
delete (sanitized as any).w
}
// Move h from top level to props (if present at top level)
if (hValue !== undefined) {
if (!('h' in sanitized.props) || sanitized.props.h === undefined) {
sanitized.props.h = hValue
}
delete (sanitized as any).h
}
// Move geo from top level to props (if present at top level)
if (geoValue !== undefined) {
if (!('geo' in sanitized.props) || sanitized.props.geo === undefined) {
sanitized.props.geo = geoValue
}
delete (sanitized 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 (!sanitized.props.geo || sanitized.props.geo === undefined || sanitized.props.geo === null) {
sanitized.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 (!sanitized.props.dash || sanitized.props.dash === undefined || sanitized.props.dash === null) {
sanitized.props.dash = 'draw'
}
}
// Only fix type if completely missing
if (!sanitized.type || typeof sanitized.type !== 'string') {
// Simple type inference - only if absolutely necessary
if (sanitized.props?.geo) {
sanitized.type = 'geo'
} else {
sanitized.type = 'geo' // Safe default
}
}
// CRITICAL: Fix crop structure for image/video shapes if it exists
if (sanitized.type === 'image' || sanitized.type === 'video') {
if (sanitized.props.crop !== null && sanitized.props.crop !== undefined) {
if (!sanitized.props.crop.topLeft || !sanitized.props.crop.bottomRight) {
if (sanitized.props.crop.x !== undefined && sanitized.props.crop.y !== undefined) {
// Convert old format to new format
sanitized.props.crop = {
topLeft: { x: sanitized.props.crop.x || 0, y: sanitized.props.crop.y || 0 },
bottomRight: {
x: (sanitized.props.crop.x || 0) + (sanitized.props.crop.w || 1),
y: (sanitized.props.crop.y || 0) + (sanitized.props.crop.h || 1)
}
}
} else {
sanitized.props.crop = {
topLeft: { x: 0, y: 0 },
bottomRight: { x: 1, y: 1 }
}
}
}
}
}
// CRITICAL: Fix line shapes - ensure valid points structure (required by schema)
if (sanitized.type === 'line') {
// Remove invalid w/h from props (they cause validation errors)
if ('w' in sanitized.props) delete sanitized.props.w
if ('h' in sanitized.props) delete sanitized.props.h
// Line shapes REQUIRE points property
if (!sanitized.props.points || typeof sanitized.props.points !== 'object' || Array.isArray(sanitized.props.points)) {
sanitized.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: Fix group shapes - remove invalid w/h from props
if (sanitized.type === 'group') {
if ('w' in sanitized.props) delete sanitized.props.w
if ('h' in sanitized.props) delete sanitized.props.h
}
// CRITICAL: Fix note shapes - ensure richText structure if it exists
if (sanitized.type === 'note') {
if (sanitized.props.richText) {
if (Array.isArray(sanitized.props.richText)) {
sanitized.props.richText = { content: sanitized.props.richText, type: 'doc' }
} else if (typeof sanitized.props.richText === 'object' && sanitized.props.richText !== null) {
if (!sanitized.props.richText.type) sanitized.props.richText = { ...sanitized.props.richText, type: 'doc' }
if (!sanitized.props.richText.content) sanitized.props.richText = { ...sanitized.props.richText, content: [] }
}
}
// CRITICAL: Clean NaN values from richText content to prevent SVG export errors
if (sanitized.props.richText) {
sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText)
}
}
// CRITICAL: Fix richText structure for geo shapes (preserve content)
if (sanitized.type === 'geo' && sanitized.props.richText) {
if (Array.isArray(sanitized.props.richText)) {
sanitized.props.richText = { content: sanitized.props.richText, type: 'doc' }
} else if (typeof sanitized.props.richText === 'object' && sanitized.props.richText !== null) {
if (!sanitized.props.richText.type) sanitized.props.richText = { ...sanitized.props.richText, type: 'doc' }
if (!sanitized.props.richText.content) sanitized.props.richText = { ...sanitized.props.richText, content: [] }
}
// CRITICAL: Clean NaN values from richText content to prevent SVG export errors
sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText)
}
// CRITICAL: Preserve arrow text property (ensure it's a string)
if (sanitized.type === 'arrow') {
// Ensure text property exists and is a string
if (sanitized.props.text === undefined || sanitized.props.text === null) {
sanitized.props.text = ''
} else if (typeof sanitized.props.text !== 'string') {
// If text is not a string (e.g., RichText object), convert it to string
try {
if (typeof sanitized.props.text === 'object' && sanitized.props.text !== null) {
// Try to extract text from RichText object
const textObj = sanitized.props.text as any
if (Array.isArray(textObj.content)) {
// Extract text from RichText content
const extractText = (content: any[]): string => {
return content.map((item: any) => {
if (item.type === 'text' && item.text) {
return item.text
} else if (item.content && Array.isArray(item.content)) {
return extractText(item.content)
}
return ''
}).join('')
}
sanitized.props.text = extractText(textObj.content)
} else if (textObj.text && typeof textObj.text === 'string') {
sanitized.props.text = textObj.text
} else {
sanitized.props.text = String(sanitized.props.text)
}
} else {
sanitized.props.text = String(sanitized.props.text)
}
} catch (e) {
console.warn(`⚠️ AutomergeToTLStore: Error converting arrow text to string for ${sanitized.id}:`, e)
sanitized.props.text = String(sanitized.props.text)
}
}
// Note: We preserve text even if it's an empty string - that's a valid value
}
// CRITICAL: Fix richText structure for text shapes - REQUIRED field
if (sanitized.type === 'text') {
// Text shapes MUST have props.richText as an object - initialize if missing
if (!sanitized.props.richText || typeof sanitized.props.richText !== 'object' || sanitized.props.richText === null) {
sanitized.props.richText = { content: [], type: 'doc' }
} else if (Array.isArray(sanitized.props.richText)) {
sanitized.props.richText = { content: sanitized.props.richText, type: 'doc' }
} else if (typeof sanitized.props.richText === 'object' && sanitized.props.richText !== null) {
if (!sanitized.props.richText.type) sanitized.props.richText = { ...sanitized.props.richText, type: 'doc' }
if (!sanitized.props.richText.content) sanitized.props.richText = { ...sanitized.props.richText, content: [] }
}
// CRITICAL: Clean NaN values from richText content to prevent SVG export errors
sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText)
// CRITICAL: Ensure required text shape properties exist (TLDraw validation requires these)
// color is REQUIRED and must be one of the valid color values
const validColors = ['black', 'grey', 'light-violet', 'violet', 'blue', 'light-blue', 'yellow', 'orange', 'green', 'light-green', 'light-red', 'red', 'white']
if (!sanitized.props.color || typeof sanitized.props.color !== 'string' || !validColors.includes(sanitized.props.color)) {
sanitized.props.color = 'black'
}
// Ensure other required properties have defaults
if (typeof sanitized.props.w !== 'number') sanitized.props.w = 300
if (!sanitized.props.size || typeof sanitized.props.size !== 'string') sanitized.props.size = 'm'
if (!sanitized.props.font || typeof sanitized.props.font !== 'string') sanitized.props.font = 'draw'
if (!sanitized.props.textAlign || typeof sanitized.props.textAlign !== 'string') sanitized.props.textAlign = 'start'
if (typeof sanitized.props.autoSize !== 'boolean') sanitized.props.autoSize = false
if (typeof sanitized.props.scale !== 'number') sanitized.props.scale = 1
// Remove invalid properties for text shapes (these cause validation errors)
// Remove properties that are only valid for custom shapes, not standard TLDraw text shapes
// CRITICAL: 'text' property is NOT allowed - text shapes must use props.richText instead
const invalidTextProps = ['h', 'geo', 'text', 'isEditing', 'editingContent', 'isTranscribing', 'isPaused', 'fixedHeight', 'pinnedToView', 'isModified', 'originalContent', 'editingName', 'editingDescription', 'isConnected', 'holonId', 'noteId', 'title', 'content', 'tags', 'showPreview', 'backgroundColor', 'textColor']
invalidTextProps.forEach(prop => {
if (prop in sanitized.props) {
delete sanitized.props[prop]
}
})
}
// CRITICAL: Additional safety check - Remove invalid 'text' property from text shapes
// Text shapes should only use props.richText, not props.text
// This is a redundant check to ensure text property is always removed
if (sanitized.type === 'text' && 'text' in sanitized.props) {
delete sanitized.props.text
}
// CRITICAL: Only convert unknown shapes with richText to text if they're truly unknown
// DO NOT convert geo/note shapes - they can legitimately have richText
if (sanitized.props?.richText && sanitized.type !== 'text' && sanitized.type !== 'geo' && sanitized.type !== 'note') {
// This is an unknown shape type with richText - convert to text shape
// But preserve all existing properties first
const existingProps = { ...sanitized.props }
sanitized.type = 'text'
sanitized.props = existingProps
// Fix richText structure if needed
if (Array.isArray(sanitized.props.richText)) {
sanitized.props.richText = { content: sanitized.props.richText, type: 'doc' }
} else if (typeof sanitized.props.richText === 'object' && sanitized.props.richText !== null) {
if (!sanitized.props.richText.type) sanitized.props.richText = { ...sanitized.props.richText, type: 'doc' }
if (!sanitized.props.richText.content) sanitized.props.richText = { ...sanitized.props.richText, content: [] }
}
// CRITICAL: Clean NaN values from richText content to prevent SVG export errors
sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText)
// CRITICAL: Ensure required text shape properties exist (TLDraw validation requires these)
// color is REQUIRED and must be one of the valid color values
const validColors = ['black', 'grey', 'light-violet', 'violet', 'blue', 'light-blue', 'yellow', 'orange', 'green', 'light-green', 'light-red', 'red', 'white']
if (!sanitized.props.color || typeof sanitized.props.color !== 'string' || !validColors.includes(sanitized.props.color)) {
sanitized.props.color = 'black'
}
// Ensure other required properties have defaults
if (typeof sanitized.props.w !== 'number') sanitized.props.w = 300
if (!sanitized.props.size || typeof sanitized.props.size !== 'string') sanitized.props.size = 'm'
if (!sanitized.props.font || typeof sanitized.props.font !== 'string') sanitized.props.font = 'draw'
if (!sanitized.props.textAlign || typeof sanitized.props.textAlign !== 'string') sanitized.props.textAlign = 'start'
if (typeof sanitized.props.autoSize !== 'boolean') sanitized.props.autoSize = false
if (typeof sanitized.props.scale !== 'number') sanitized.props.scale = 1
// Remove invalid properties for text shapes (these cause validation errors)
// Remove properties that are only valid for custom shapes, not standard TLDraw text shapes
const invalidTextProps = ['h', 'geo', 'isEditing', 'editingContent', 'isTranscribing', 'isPaused', 'fixedHeight', 'pinnedToView', 'isModified', 'originalContent', 'editingName', 'editingDescription', 'isConnected', 'holonId', 'noteId', 'title', 'content', 'tags', 'showPreview', 'backgroundColor', 'textColor']
invalidTextProps.forEach(prop => {
if (prop in sanitized.props) {
delete sanitized.props[prop]
}
})
}
} else if (sanitized.typeName === 'instance') {
// CRITICAL: Handle instance records - ensure required fields exist
if (!sanitized.meta || typeof sanitized.meta !== 'object') {
sanitized.meta = {}
} else {
sanitized.meta = { ...sanitized.meta }
}
// Only fix critical instance fields that cause validation errors
if ('brush' in sanitized && (sanitized.brush === null || sanitized.brush === undefined)) {
(sanitized as any).brush = { x: 0, y: 0, w: 0, h: 0 }
}
if ('zoomBrush' in sanitized && (sanitized.zoomBrush === null || sanitized.zoomBrush === undefined)) {
(sanitized as any).zoomBrush = { x: 0, y: 0, w: 0, h: 0 }
}
if ('insets' in sanitized && (sanitized.insets === undefined || !Array.isArray(sanitized.insets))) {
(sanitized as any).insets = [false, false, false, false]
}
if ('scribbles' in sanitized && (sanitized.scribbles === undefined || !Array.isArray(sanitized.scribbles))) {
(sanitized as any).scribbles = []
}
// CRITICAL: duplicateProps is REQUIRED for instance records - TLDraw validation will fail without it
if (!('duplicateProps' in sanitized) || sanitized.duplicateProps === undefined || typeof sanitized.duplicateProps !== 'object') {
(sanitized as any).duplicateProps = {
shapeIds: [],
offset: { x: 0, y: 0 }
}
}
} else if (sanitized.typeName === 'document') {
// CRITICAL: Preserve all existing meta properties
if (!sanitized.meta || typeof sanitized.meta !== 'object') {
sanitized.meta = {}
} else {
sanitized.meta = { ...sanitized.meta }
}
} else if (sanitized.typeName === 'page') {
// CRITICAL: Preserve all existing meta properties
if (!sanitized.meta || typeof sanitized.meta !== 'object') {
sanitized.meta = {}
} else {
sanitized.meta = { ...sanitized.meta }
}
}
// CRITICAL: Final safety check - ensure text shapes never have invalid 'text' property
// This is a last-resort check before returning to catch any edge cases
if (sanitized.typeName === 'shape' && sanitized.type === 'text' && sanitized.props && 'text' in sanitized.props) {
delete sanitized.props.text
}
return sanitized
}
const isStorePatch = (patch: Automerge.Patch): boolean => {
return patch.path[0] === "store" && patch.path.length > 1
}
// Helper function to safely get a record from the store
const getRecordFromStore = (store: TLStore, id: string): TLRecord | null => {
try {
return store.get(id as any) as TLRecord | null
} catch {
return null
}
}
// path: ["store", "camera:page:page", "x"] => "camera:page:page"
const pathToId = (path: Automerge.Prop[]): RecordId<any> => {
return path[1] as RecordId<any>
}
const applyInsertToObject = (patch: Automerge.InsertPatch, object: any): TLRecord => {
const { path, values } = patch
let current = object
const insertionPoint = path[path.length - 1] as number
const pathEnd = path[path.length - 2] as string
const parts = path.slice(2, -2)
// Create missing properties as we navigate
for (const part of parts) {
if (current[part] === undefined || current[part] === null) {
// Create missing property - use array for numeric indices
if (typeof part === 'number' || (typeof part === 'string' && !isNaN(Number(part)))) {
current[part] = []
} else {
current[part] = {}
}
}
current = current[part]
}
// Ensure pathEnd exists and is an array
if (current[pathEnd] === undefined || current[pathEnd] === null) {
current[pathEnd] = []
}
// splice is a mutator... yay.
const clone = Array.isArray(current[pathEnd]) ? current[pathEnd].slice(0) : []
clone.splice(insertionPoint, 0, ...values)
current[pathEnd] = clone
return object
}
const applyPutToObject = (patch: Automerge.PutPatch, object: any): TLRecord => {
const { path, value } = patch
let current = object
// special case
if (path.length === 2) {
// this would be creating the object, but we have done
return object
}
const parts = path.slice(2, -2)
const property = path[path.length - 1] as string
const target = path[path.length - 2] as string
if (path.length === 3) {
return { ...object, [property]: value }
}
// default case - create missing properties as we navigate
for (const part of parts) {
if (current[part] === undefined || current[part] === null) {
// Create missing property - use object for named properties, array for numeric indices
if (typeof part === 'number' || (typeof part === 'string' && !isNaN(Number(part)))) {
current[part] = []
} else {
current[part] = {}
}
}
current = current[part]
}
// Ensure target exists
if (current[target] === undefined || current[target] === null) {
current[target] = {}
}
current[target] = { ...current[target], [property]: value }
return object
}
const applySpliceToObject = (patch: Automerge.SpliceTextPatch, object: any): TLRecord => {
const { path, value } = patch
let current = object
const insertionPoint = path[path.length - 1] as number
const pathEnd = path[path.length - 2] as string
const parts = path.slice(2, -2)
// Create missing properties as we navigate
for (const part of parts) {
if (current[part] === undefined || current[part] === null) {
// Create missing property - use array for numeric indices or when splicing
if (typeof part === 'number' || (typeof part === 'string' && !isNaN(Number(part)))) {
current[part] = []
} else {
current[part] = {}
}
}
current = current[part]
}
// Ensure pathEnd exists and is an array for splicing
if (current[pathEnd] === undefined || current[pathEnd] === null) {
current[pathEnd] = []
}
// TODO: we're not supporting actual splices yet because TLDraw won't generate them natively
if (insertionPoint !== 0) {
throw new Error("Splices are not supported yet")
}
current[pathEnd] = value // .splice(insertionPoint, 0, value)
return object
}
const applyIncToObject = (patch: Automerge.IncPatch, object: any): TLRecord => {
const { path, value } = patch
let current = object
const parts = path.slice(2, -1)
const pathEnd = path[path.length - 1] as string
for (const part of parts) {
if (current[part] === undefined) {
throw new Error("NO WAY")
}
current = current[part]
}
current[pathEnd] = (current[pathEnd] || 0) + value
return object
}