From f5582fc7d1322c49581a62023615b599bca953f1 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 16 Nov 2025 03:03:39 -0700 Subject: [PATCH] prevent coordinate collapse on reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/automerge/AutomergeToTLStore.ts | 139 +++++++++++++++++++++++++++- src/automerge/TLStoreToAutomerge.ts | 107 +++++++++++++++++++-- 2 files changed, 234 insertions(+), 12 deletions(-) diff --git a/src/automerge/AutomergeToTLStore.ts b/src/automerge/AutomergeToTLStore.ts index 0cedb10..a2fb253 100644 --- a/src/automerge/AutomergeToTLStore.ts +++ b/src/automerge/AutomergeToTLStore.ts @@ -3,7 +3,8 @@ import * as Automerge from "@automerge/automerge" export function applyAutomergePatchesToTLStore( patches: Automerge.Patch[], - store: TLStore + store: TLStore, + automergeDoc?: any // Optional Automerge document to read full records from ) { const toRemove: TLRecord["id"][] = [] const updatedObjects: { [id: string]: TLRecord } = {} @@ -42,6 +43,29 @@ export function applyAutomergePatchesToTLStore( } } + // 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 = { @@ -112,7 +136,20 @@ export function applyAutomergePatchesToTLStore( } } - let record = updatedObjects[id] || (existingRecord ? JSON.parse(JSON.stringify(existingRecord)) : defaultRecord) + // 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 @@ -157,6 +194,28 @@ export function applyAutomergePatchesToTLStore( 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": { @@ -229,6 +288,42 @@ export function applyAutomergePatchesToTLStore( 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 @@ -660,6 +755,46 @@ export function sanitizeRecord(record: any): TLRecord { 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 diff --git a/src/automerge/TLStoreToAutomerge.ts b/src/automerge/TLStoreToAutomerge.ts index 311a4a4..098d8df 100644 --- a/src/automerge/TLStoreToAutomerge.ts +++ b/src/automerge/TLStoreToAutomerge.ts @@ -144,19 +144,97 @@ function sanitizeRecord(record: TLRecord): TLRecord { console.warn(`🔧 TLStoreToAutomerge: Error checking richText for shape ${sanitized.id}:`, e) } + // CRITICAL: Extract arrow text BEFORE deep copy to handle RichText instances properly + // Arrow text should be a string, but might be a RichText object in edge cases + let arrowTextValue: any = undefined + if (sanitized.type === 'arrow') { + try { + const props = sanitized.props || {} + if ('text' in props) { + try { + // Use Object.getOwnPropertyDescriptor to safely check if it's a getter + const descriptor = Object.getOwnPropertyDescriptor(props, 'text') + let textValue: any = undefined + + if (descriptor && descriptor.get) { + // It's a getter - try to call it safely + try { + textValue = descriptor.get.call(props) + } catch (getterError) { + console.warn(`🔧 TLStoreToAutomerge: Error calling text getter for arrow ${sanitized.id}:`, getterError) + textValue = undefined + } + } else { + // It's a regular property - access it directly + textValue = (props as any).text + } + + // Now process the value + if (textValue !== undefined && textValue !== null) { + // If it's a string, use it directly + if (typeof textValue === 'string') { + arrowTextValue = textValue + } + // If it's a RichText object, extract the text content + else if (typeof textValue === 'object' && textValue !== null) { + // Try to extract text from RichText object + try { + const serialized = JSON.parse(JSON.stringify(textValue)) + // If it has content array, extract text from it + if (Array.isArray(serialized.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('') + } + arrowTextValue = extractText(serialized.content) + } else { + // Fallback: try to get text property + arrowTextValue = serialized.text || '' + } + } catch (serializeError) { + // If serialization fails, try to extract manually + if ((textValue as any).text && typeof (textValue as any).text === 'string') { + arrowTextValue = (textValue as any).text + } else { + arrowTextValue = String(textValue) + } + } + } + // For other types, convert to string + else { + arrowTextValue = String(textValue) + } + } + } catch (e) { + console.warn(`🔧 TLStoreToAutomerge: Error extracting text for arrow ${sanitized.id}:`, e) + arrowTextValue = undefined + } + } + } catch (e) { + console.warn(`🔧 TLStoreToAutomerge: Error checking text for arrow ${sanitized.id}:`, e) + } + } + // CRITICAL: For all shapes, ensure props is a deep mutable copy to preserve all 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) - // Remove richText temporarily to avoid serialization issues + // Remove richText and arrow text temporarily to avoid serialization issues try { - const propsWithoutRichText: any = {} - // Copy all props except richText + const propsWithoutSpecial: any = {} + // Copy all props except richText and arrow text (if extracted) for (const key in sanitized.props) { - if (key !== 'richText') { - propsWithoutRichText[key] = (sanitized.props as any)[key] + if (key !== 'richText' && !(sanitized.type === 'arrow' && key === 'text' && arrowTextValue !== undefined)) { + propsWithoutSpecial[key] = (sanitized.props as any)[key] } } - sanitized.props = JSON.parse(JSON.stringify(propsWithoutRichText)) + sanitized.props = JSON.parse(JSON.stringify(propsWithoutSpecial)) } catch (e) { console.warn(`🔧 TLStoreToAutomerge: Error deep copying props for shape ${sanitized.id}:`, e) // Fallback: just copy props without deep copy @@ -164,6 +242,9 @@ function sanitizeRecord(record: TLRecord): TLRecord { if (richTextValue !== undefined) { delete (sanitized.props as any).richText } + if (arrowTextValue !== undefined) { + delete (sanitized.props as any).text + } } // CRITICAL: For geo shapes, move w/h/geo from top-level to props (required by TLDraw schema) @@ -210,11 +291,17 @@ function sanitizeRecord(record: TLRecord): TLRecord { // CRITICAL: For arrow shapes, preserve text property if (sanitized.type === 'arrow') { - // CRITICAL: Preserve text property - only set default if truly missing (preserve empty strings and all other values) - if ((sanitized.props as any).text === undefined || (sanitized.props as any).text === null) { - (sanitized.props as any).text = '' + // CRITICAL: Restore extracted text value if available, otherwise preserve existing text + if (arrowTextValue !== undefined) { + // Use the extracted text value (handles RichText objects by extracting text content) + (sanitized.props as any).text = arrowTextValue + } else { + // CRITICAL: Preserve text property - only set default if truly missing (preserve empty strings and all other values) + if ((sanitized.props as any).text === undefined || (sanitized.props as any).text === null) { + (sanitized.props as any).text = '' + } + // Note: We preserve text even if it's an empty string - that's a valid value } - // Note: We preserve text even if it's an empty string - that's a valid value } // CRITICAL: For note shapes, preserve richText property (required for note shapes)