428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
import { TLRecord, RecordId, TLStore } from "@tldraw/tldraw"
|
|
import * as Automerge from "@automerge/automerge"
|
|
|
|
export function applyAutomergePatchesToTLStore(
|
|
patches: Automerge.Patch[],
|
|
store: TLStore
|
|
) {
|
|
const toRemove: TLRecord["id"][] = []
|
|
const updatedObjects: { [id: string]: TLRecord } = {}
|
|
|
|
patches.forEach((patch) => {
|
|
if (!isStorePatch(patch)) return
|
|
|
|
const id = pathToId(patch.path)
|
|
const existingRecord = getRecordFromStore(store, id)
|
|
const record = updatedObjects[id] || (existingRecord ? JSON.parse(JSON.stringify(existingRecord)) : {
|
|
id,
|
|
typeName: 'shape',
|
|
type: 'geo', // Default shape type
|
|
x: 0,
|
|
y: 0,
|
|
rotation: 0,
|
|
isLocked: false,
|
|
opacity: 1,
|
|
meta: {},
|
|
props: {}
|
|
})
|
|
|
|
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)
|
|
}
|
|
}
|
|
})
|
|
|
|
// Sanitize records before putting them in the store
|
|
const toPut: TLRecord[] = []
|
|
const failedRecords: any[] = []
|
|
|
|
Object.values(updatedObjects).forEach(record => {
|
|
try {
|
|
const sanitized = sanitizeRecord(record)
|
|
toPut.push(sanitized)
|
|
} catch (error) {
|
|
console.error("Failed to sanitize record:", error, record)
|
|
failedRecords.push(record)
|
|
}
|
|
})
|
|
|
|
// put / remove the records in the store
|
|
console.log({ patches, toPut: toPut.length, failed: failedRecords.length })
|
|
|
|
if (failedRecords.length > 0) {
|
|
console.error("Failed to sanitize records:", failedRecords)
|
|
}
|
|
|
|
store.mergeRemoteChanges(() => {
|
|
if (toRemove.length) store.remove(toRemove)
|
|
if (toPut.length) store.put(toPut)
|
|
})
|
|
}
|
|
|
|
// Sanitize record to remove invalid properties
|
|
function sanitizeRecord(record: any): TLRecord {
|
|
const sanitized = { ...record }
|
|
|
|
// Ensure required fields exist for all records
|
|
if (!sanitized.id) {
|
|
console.error("Record missing required id field:", record)
|
|
throw new Error("Record missing required id field")
|
|
}
|
|
|
|
if (!sanitized.typeName) {
|
|
console.error("Record missing required typeName field:", record)
|
|
throw new Error("Record missing required typeName field")
|
|
}
|
|
|
|
// Remove invalid properties from shapes
|
|
if (sanitized.typeName === 'shape') {
|
|
// Ensure required shape fields exist
|
|
if (!sanitized.type || typeof sanitized.type !== 'string') {
|
|
console.error("Shape missing or invalid type field:", {
|
|
id: sanitized.id,
|
|
typeName: sanitized.typeName,
|
|
currentType: sanitized.type,
|
|
record: sanitized
|
|
})
|
|
// Try to infer type from other properties or use a default
|
|
if (sanitized.props?.geo) {
|
|
sanitized.type = 'geo'
|
|
} else if (sanitized.props?.text) {
|
|
sanitized.type = 'text'
|
|
} else if (sanitized.props?.roomUrl) {
|
|
sanitized.type = 'VideoChat'
|
|
} else if (sanitized.props?.roomId) {
|
|
sanitized.type = 'ChatBox'
|
|
} else if (sanitized.props?.url) {
|
|
sanitized.type = 'Embed'
|
|
} else if (sanitized.props?.prompt) {
|
|
sanitized.type = 'Prompt'
|
|
} else if (sanitized.props?.isMinimized !== undefined) {
|
|
sanitized.type = 'SharedPiano'
|
|
} else if (sanitized.props?.isTranscribing !== undefined) {
|
|
sanitized.type = 'Transcription'
|
|
} else if (sanitized.props?.noteId) {
|
|
sanitized.type = 'ObsNote'
|
|
} else {
|
|
sanitized.type = 'geo' // Default fallback
|
|
}
|
|
console.log(`🔧 Fixed missing/invalid type field for shape ${sanitized.id}, set to: ${sanitized.type}`)
|
|
}
|
|
|
|
// Ensure type is a valid string
|
|
if (typeof sanitized.type !== 'string') {
|
|
console.error("Shape type is not a string:", sanitized.type, "for shape:", sanitized.id)
|
|
sanitized.type = 'geo' // Force to valid string
|
|
}
|
|
|
|
// Ensure other required shape fields exist
|
|
if (typeof sanitized.x !== 'number') {
|
|
sanitized.x = 0
|
|
}
|
|
if (typeof sanitized.y !== 'number') {
|
|
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
|
|
}
|
|
if (!sanitized.meta || typeof sanitized.meta !== 'object') {
|
|
sanitized.meta = {}
|
|
}
|
|
// Remove top-level properties that should only be in props
|
|
const invalidTopLevelProperties = ['insets', 'scribbles', 'duplicateProps', 'geo', 'w', 'h']
|
|
invalidTopLevelProperties.forEach(prop => {
|
|
if (prop in sanitized) {
|
|
console.log(`Moving ${prop} property from top-level to props for shape during patch application:`, {
|
|
id: sanitized.id,
|
|
type: sanitized.type,
|
|
originalValue: sanitized[prop]
|
|
})
|
|
|
|
// Move to props if props exists, otherwise create props
|
|
if (!sanitized.props) {
|
|
sanitized.props = {}
|
|
}
|
|
sanitized.props[prop] = sanitized[prop]
|
|
delete sanitized[prop]
|
|
}
|
|
})
|
|
|
|
// Ensure props object exists for all shapes
|
|
if (!sanitized.props) {
|
|
sanitized.props = {}
|
|
}
|
|
|
|
// Fix geo shape specific properties
|
|
if (sanitized.type === 'geo') {
|
|
// Ensure geo shape has proper structure
|
|
if (!sanitized.props.geo) {
|
|
sanitized.props.geo = 'rectangle'
|
|
}
|
|
if (!sanitized.props.w) {
|
|
sanitized.props.w = 100
|
|
}
|
|
if (!sanitized.props.h) {
|
|
sanitized.props.h = 100
|
|
}
|
|
|
|
// Remove invalid properties for geo shapes (including insets)
|
|
const invalidGeoProps = ['transcript', 'isTranscribing', 'isPaused', 'isEditing', 'roomUrl', 'roomId', 'prompt', 'value', 'agentBinding', 'isMinimized', 'noteId', 'title', 'content', 'tags', 'showPreview', 'backgroundColor', 'textColor', 'editingContent', 'vaultName', 'insets']
|
|
invalidGeoProps.forEach(prop => {
|
|
if (prop in sanitized.props) {
|
|
console.log(`Removing invalid ${prop} property from geo shape:`, sanitized.id)
|
|
delete sanitized.props[prop]
|
|
}
|
|
})
|
|
}
|
|
|
|
// Fix note shape specific properties
|
|
if (sanitized.type === 'note') {
|
|
// Remove w/h properties from note shapes as they're not valid
|
|
if ('w' in sanitized.props) {
|
|
console.log(`Removing invalid w property from note shape:`, sanitized.id)
|
|
delete sanitized.props.w
|
|
}
|
|
if ('h' in sanitized.props) {
|
|
console.log(`Removing invalid h property from note shape:`, sanitized.id)
|
|
delete sanitized.props.h
|
|
}
|
|
}
|
|
|
|
// Convert custom shape types to valid TLDraw types
|
|
const customShapeTypeMap: { [key: string]: string } = {
|
|
'VideoChat': 'embed',
|
|
'Transcription': 'text',
|
|
'SharedPiano': 'embed',
|
|
'Prompt': 'text',
|
|
'ChatBox': 'embed',
|
|
'Embed': 'embed',
|
|
'Markdown': 'text',
|
|
'MycrozineTemplate': 'embed',
|
|
'Slide': 'embed',
|
|
'ObsNote': 'text'
|
|
}
|
|
|
|
if (customShapeTypeMap[sanitized.type]) {
|
|
console.log(`Converting custom shape type ${sanitized.type} to ${customShapeTypeMap[sanitized.type]} for shape:`, sanitized.id)
|
|
sanitized.type = customShapeTypeMap[sanitized.type]
|
|
}
|
|
|
|
// Ensure proper props for converted shape types
|
|
if (sanitized.type === 'embed') {
|
|
// Ensure embed shapes have required properties
|
|
if (!sanitized.props.url) {
|
|
sanitized.props.url = ''
|
|
}
|
|
if (!sanitized.props.w) {
|
|
sanitized.props.w = 400
|
|
}
|
|
if (!sanitized.props.h) {
|
|
sanitized.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 sanitized.props) {
|
|
console.log(`Removing invalid ${prop} property from embed shape:`, sanitized.id)
|
|
delete sanitized.props[prop]
|
|
}
|
|
})
|
|
}
|
|
|
|
if (sanitized.type === 'text') {
|
|
// Ensure text shapes have required properties
|
|
if (!sanitized.props.text) {
|
|
sanitized.props.text = ''
|
|
}
|
|
if (!sanitized.props.w) {
|
|
sanitized.props.w = 200
|
|
}
|
|
if (!sanitized.props.color) {
|
|
sanitized.props.color = 'black'
|
|
}
|
|
if (!sanitized.props.size) {
|
|
sanitized.props.size = 'm'
|
|
}
|
|
if (!sanitized.props.font) {
|
|
sanitized.props.font = 'draw'
|
|
}
|
|
if (!sanitized.props.textAlign) {
|
|
sanitized.props.textAlign = 'start'
|
|
}
|
|
// Text shapes don't have h property
|
|
if ('h' in sanitized.props) {
|
|
delete sanitized.props.h
|
|
}
|
|
// Remove invalid properties for text shapes
|
|
const invalidTextProps = ['isMinimized', 'roomUrl', 'roomId', 'geo', 'insets', 'scribbles']
|
|
invalidTextProps.forEach(prop => {
|
|
if (prop in sanitized.props) {
|
|
console.log(`Removing invalid ${prop} property from text shape:`, sanitized.id)
|
|
delete sanitized.props[prop]
|
|
}
|
|
})
|
|
}
|
|
|
|
// General cleanup: remove any properties that might cause validation errors
|
|
const validShapeProps: { [key: string]: string[] } = {
|
|
'geo': ['w', 'h', 'geo', 'color', 'fill', 'dash', 'size', 'text', 'font', 'align', 'verticalAlign', 'growY', 'url'],
|
|
'text': ['w', 'text', 'color', 'fill', 'dash', 'size', 'font', 'align', 'verticalAlign', 'growY', 'url'],
|
|
'embed': ['w', 'h', 'url', 'doesResize', 'doesResizeHeight'],
|
|
'note': ['color', 'fill', 'dash', 'size', 'text', 'font', 'align', 'verticalAlign', 'growY', 'url'],
|
|
'arrow': ['start', 'end', 'color', 'fill', 'dash', 'size', 'text', 'font', 'align', 'verticalAlign', 'growY', 'url', 'arrowheadStart', 'arrowheadEnd'],
|
|
'draw': ['points', 'color', 'fill', 'dash', 'size'],
|
|
'bookmark': ['w', 'h', 'url', 'doesResize', 'doesResizeHeight'],
|
|
'image': ['w', 'h', 'assetId', 'crop', 'doesResize', 'doesResizeHeight'],
|
|
'video': ['w', 'h', 'assetId', 'crop', 'doesResize', 'doesResizeHeight'],
|
|
'frame': ['w', 'h', 'name', 'color', 'fill', 'dash', 'size', 'text', 'font', 'align', 'verticalAlign', 'growY', 'url'],
|
|
'group': ['w', 'h'],
|
|
'highlight': ['w', 'h', 'color', 'fill', 'dash', 'size', 'text', 'font', 'align', 'verticalAlign', 'growY', 'url'],
|
|
'line': ['x', 'y', 'color', 'fill', 'dash', 'size', 'text', 'font', 'align', 'verticalAlign', 'growY', 'url']
|
|
}
|
|
|
|
// Remove invalid properties based on shape type
|
|
if (validShapeProps[sanitized.type]) {
|
|
const validProps = validShapeProps[sanitized.type]
|
|
Object.keys(sanitized.props).forEach(prop => {
|
|
if (!validProps.includes(prop)) {
|
|
console.log(`Removing invalid property ${prop} from ${sanitized.type} shape:`, sanitized.id)
|
|
delete sanitized.props[prop]
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
for (const part of parts) {
|
|
if (current[part] === undefined) {
|
|
throw new Error("NO WAY")
|
|
}
|
|
current = current[part]
|
|
}
|
|
// splice is a mutator... yay.
|
|
const clone = 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
|
|
for (const part of parts) {
|
|
current = current[part]
|
|
}
|
|
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)
|
|
for (const part of parts) {
|
|
if (current[part] === undefined) {
|
|
throw new Error("NO WAY")
|
|
}
|
|
current = current[part]
|
|
}
|
|
// 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
|
|
}
|