pin object, fix fathom, and a bunch of other things
This commit is contained in:
parent
356f7b4705
commit
de59c4a726
|
|
@ -73,7 +73,6 @@ Custom shape types are preserved:
|
|||
- ObsNote
|
||||
- Holon
|
||||
- FathomMeetingsBrowser
|
||||
- FathomTranscript
|
||||
- HolonBrowser
|
||||
- LocationShare
|
||||
- ObsidianBrowser
|
||||
|
|
|
|||
|
|
@ -251,6 +251,12 @@ export function applyAutomergePatchesToTLStore(
|
|||
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)
|
||||
|
|
@ -402,6 +408,15 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
|
||||
// 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)
|
||||
|
|
@ -429,7 +444,37 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
// 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)
|
||||
sanitized.props = JSON.parse(JSON.stringify(sanitized.props))
|
||||
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
|
||||
|
|
@ -628,10 +673,35 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
}
|
||||
// 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: Remove invalid 'text' property from text shapes (TLDraw schema doesn't allow props.text)
|
||||
// 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
|
||||
}
|
||||
|
|
@ -655,9 +725,28 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
// CRITICAL: Clean NaN values from richText content to prevent SVG export errors
|
||||
sanitized.props.richText = cleanRichTextNaN(sanitized.props.richText)
|
||||
|
||||
// Only remove properties that cause validation errors (not all "invalid" ones)
|
||||
if ('h' in sanitized.props) delete sanitized.props.h
|
||||
if ('geo' in sanitized.props) delete sanitized.props.geo
|
||||
// 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
|
||||
|
|
@ -702,6 +791,12 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ function minimalSanitizeRecord(record: any): any {
|
|||
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 = {}
|
||||
if (!sanitized.index) sanitized.index = 'a1'
|
||||
// Validate and fix index property - must be a valid IndexKey (like 'a1', 'a2', etc.)
|
||||
if (!sanitized.index || typeof sanitized.index !== 'string' || !/^[a-z]\d+$/.test(sanitized.index)) {
|
||||
sanitized.index = 'a1'
|
||||
}
|
||||
if (!sanitized.parentId) sanitized.parentId = 'page:page'
|
||||
|
||||
// Ensure props object exists
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -35,6 +35,56 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
const [isLoading, setIsLoading] = useState(true)
|
||||
const handleRef = useRef<any>(null)
|
||||
const storeRef = useRef<any>(null)
|
||||
const lastSentHashRef = useRef<string | null>(null)
|
||||
const isMouseActiveRef = useRef<boolean>(false)
|
||||
const pendingSaveRef = useRef<boolean>(false)
|
||||
const saveFunctionRef = useRef<(() => void) | null>(null)
|
||||
|
||||
// Generate a fast hash of the document state for change detection
|
||||
// OPTIMIZED: Avoid expensive JSON.stringify, use lightweight checksums instead
|
||||
const generateDocHash = useCallback((doc: any): string => {
|
||||
if (!doc || !doc.store) return ''
|
||||
const storeData = doc.store || {}
|
||||
const storeKeys = Object.keys(storeData).sort()
|
||||
|
||||
// Fast hash using record IDs and lightweight checksums
|
||||
// Instead of JSON.stringify, use a combination of ID, type, and key property values
|
||||
let hash = 0
|
||||
for (const key of storeKeys) {
|
||||
// Skip ephemeral records
|
||||
if (key.startsWith('instance:') ||
|
||||
key.startsWith('instance_page_state:') ||
|
||||
key.startsWith('instance_presence:') ||
|
||||
key.startsWith('camera:') ||
|
||||
key.startsWith('pointer:')) {
|
||||
continue
|
||||
}
|
||||
|
||||
const record = storeData[key]
|
||||
if (!record) continue
|
||||
|
||||
// Use lightweight hash: ID + typeName + type (if shape) + key properties
|
||||
let recordHash = key
|
||||
if (record.typeName) recordHash += record.typeName
|
||||
if (record.type) recordHash += record.type
|
||||
|
||||
// For shapes, include x, y, w, h for position/size changes
|
||||
if (record.typeName === 'shape') {
|
||||
if (typeof record.x === 'number') recordHash += `x${record.x}`
|
||||
if (typeof record.y === 'number') recordHash += `y${record.y}`
|
||||
if (typeof record.props?.w === 'number') recordHash += `w${record.props.w}`
|
||||
if (typeof record.props?.h === 'number') recordHash += `h${record.props.h}`
|
||||
}
|
||||
|
||||
// Simple hash of the record string
|
||||
for (let i = 0; i < recordHash.length; i++) {
|
||||
const char = recordHash.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + char
|
||||
hash = hash & hash
|
||||
}
|
||||
}
|
||||
return hash.toString(36)
|
||||
}, [])
|
||||
|
||||
// Update refs when handle/store changes
|
||||
useEffect(() => {
|
||||
|
|
@ -92,6 +142,8 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
console.log(`📥 Loaded document from server: ${serverRecordCount} records, ${serverShapeCount} shapes`)
|
||||
|
||||
// Initialize the Automerge document with server data
|
||||
// CRITICAL: This will generate patches that should be caught by the handler in useAutomergeStoreV2
|
||||
// The handler is set up before initializeStore() runs, so patches should be processed automatically
|
||||
if (serverDoc.store && serverRecordCount > 0) {
|
||||
handle.change((doc: any) => {
|
||||
// Initialize store if it doesn't exist
|
||||
|
|
@ -105,6 +157,7 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
})
|
||||
|
||||
console.log(`✅ Initialized Automerge document with ${serverRecordCount} records from server`)
|
||||
console.log(`📝 Patches should be generated and caught by handler in useAutomergeStoreV2`)
|
||||
} else {
|
||||
console.log("📥 Server document is empty - starting with empty Automerge document")
|
||||
}
|
||||
|
|
@ -146,6 +199,51 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
}
|
||||
}, [repo, roomId])
|
||||
|
||||
// Track mouse state to prevent persistence during active mouse interactions
|
||||
useEffect(() => {
|
||||
const handleMouseDown = () => {
|
||||
isMouseActiveRef.current = true
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isMouseActiveRef.current = false
|
||||
// If there was a pending save, schedule it now that mouse is released
|
||||
if (pendingSaveRef.current) {
|
||||
pendingSaveRef.current = false
|
||||
// Trigger save after a short delay to ensure mouse interaction is fully complete
|
||||
setTimeout(() => {
|
||||
// The save will be triggered by the next scheduled save or change event
|
||||
// We just need to ensure the mouse state is cleared
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
// Also track touch events for mobile
|
||||
const handleTouchStart = () => {
|
||||
isMouseActiveRef.current = true
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
isMouseActiveRef.current = false
|
||||
if (pendingSaveRef.current) {
|
||||
pendingSaveRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listeners to document to catch all mouse interactions
|
||||
document.addEventListener('mousedown', handleMouseDown, { capture: true })
|
||||
document.addEventListener('mouseup', handleMouseUp, { capture: true })
|
||||
document.addEventListener('touchstart', handleTouchStart, { capture: true })
|
||||
document.addEventListener('touchend', handleTouchEnd, { capture: true })
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown, { capture: true })
|
||||
document.removeEventListener('mouseup', handleMouseUp, { capture: true })
|
||||
document.removeEventListener('touchstart', handleTouchStart, { capture: true })
|
||||
document.removeEventListener('touchend', handleTouchEnd, { capture: true })
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-save to Cloudflare on every change (with debouncing to prevent excessive calls)
|
||||
// CRITICAL: This ensures new shapes are persisted to R2
|
||||
useEffect(() => {
|
||||
|
|
@ -154,6 +252,13 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
let saveTimeout: NodeJS.Timeout
|
||||
|
||||
const saveDocumentToWorker = async () => {
|
||||
// CRITICAL: Don't save while mouse is active - this prevents interference with mouse interactions
|
||||
if (isMouseActiveRef.current) {
|
||||
console.log('⏸️ Deferring persistence - mouse is active')
|
||||
pendingSaveRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const doc = handle.doc()
|
||||
if (!doc || !doc.store) {
|
||||
|
|
@ -161,20 +266,47 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
return
|
||||
}
|
||||
|
||||
const shapeCount = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
|
||||
// Generate hash of current document state
|
||||
const currentHash = generateDocHash(doc)
|
||||
const lastHash = lastSentHashRef.current
|
||||
|
||||
// Skip save if document hasn't changed
|
||||
if (currentHash === lastHash) {
|
||||
console.log('⏭️ Skipping persistence - document unchanged (hash matches)')
|
||||
return
|
||||
}
|
||||
|
||||
// OPTIMIZED: Defer JSON.stringify to avoid blocking main thread
|
||||
// Use requestIdleCallback to serialize when browser is idle
|
||||
const storeKeys = Object.keys(doc.store).length
|
||||
|
||||
// Track shape types being persisted
|
||||
const shapeTypeCounts = Object.values(doc.store)
|
||||
.filter((r: any) => r?.typeName === 'shape')
|
||||
.reduce((acc: any, r: any) => {
|
||||
const type = r?.type || 'unknown'
|
||||
acc[type] = (acc[type] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
// Defer expensive serialization to avoid blocking
|
||||
const serializedDoc = await new Promise<string>((resolve, reject) => {
|
||||
const serialize = () => {
|
||||
try {
|
||||
// Direct JSON.stringify - browser optimizes this internally
|
||||
// The key is doing it in an idle callback to not block interactions
|
||||
const json = JSON.stringify(doc)
|
||||
resolve(json)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Use requestIdleCallback if available to serialize when browser is idle
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(serialize, { timeout: 200 })
|
||||
} else {
|
||||
// Fallback: use setTimeout to defer to next event loop tick
|
||||
setTimeout(serialize, 0)
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`💾 Persisting document to worker for R2 storage: ${storeKeys} records, ${shapeCount} shapes`)
|
||||
console.log(`💾 Shape type breakdown being persisted:`, shapeTypeCounts)
|
||||
// Only log in dev mode to reduce overhead
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const shapeCount = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
|
||||
console.log(`💾 Persisting document to worker for R2 storage: ${storeKeys} records, ${shapeCount} shapes`)
|
||||
}
|
||||
|
||||
// Send document state to worker via POST /room/:roomId
|
||||
// This updates the worker's currentDoc so it can be persisted to R2
|
||||
|
|
@ -183,62 +315,210 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(doc),
|
||||
body: serializedDoc,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save to worker: ${response.statusText}`)
|
||||
}
|
||||
|
||||
console.log(`✅ Successfully sent document state to worker for persistence (${shapeCount} shapes)`)
|
||||
// Update last sent hash only after successful save
|
||||
lastSentHashRef.current = currentHash
|
||||
pendingSaveRef.current = false
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const shapeCount = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
|
||||
console.log(`✅ Successfully sent document state to worker for persistence (${shapeCount} shapes)`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error saving document to worker:', error)
|
||||
pendingSaveRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
// Store save function reference for mouse release handler
|
||||
saveFunctionRef.current = saveDocumentToWorker
|
||||
|
||||
const scheduleSave = () => {
|
||||
// Clear existing timeout
|
||||
if (saveTimeout) clearTimeout(saveTimeout)
|
||||
|
||||
// Schedule save with a debounce (2 seconds) to batch rapid changes
|
||||
// This matches the worker's persistence throttle
|
||||
saveTimeout = setTimeout(saveDocumentToWorker, 2000)
|
||||
// CRITICAL: Check if mouse is active before scheduling save
|
||||
if (isMouseActiveRef.current) {
|
||||
console.log('⏸️ Deferring save scheduling - mouse is active')
|
||||
pendingSaveRef.current = true
|
||||
// Schedule a check for when mouse is released
|
||||
const checkMouseState = () => {
|
||||
if (!isMouseActiveRef.current && pendingSaveRef.current) {
|
||||
pendingSaveRef.current = false
|
||||
// Mouse is released, schedule the save now
|
||||
requestAnimationFrame(() => {
|
||||
saveTimeout = setTimeout(saveDocumentToWorker, 3000)
|
||||
})
|
||||
} else if (isMouseActiveRef.current) {
|
||||
// Mouse still active, check again in 100ms
|
||||
setTimeout(checkMouseState, 100)
|
||||
}
|
||||
}
|
||||
setTimeout(checkMouseState, 100)
|
||||
return
|
||||
}
|
||||
|
||||
// CRITICAL: Use requestIdleCallback if available to defer saves until browser is idle
|
||||
// This prevents saves from interrupting active interactions
|
||||
const schedule = () => {
|
||||
// Schedule save with a debounce (3 seconds) to batch rapid changes
|
||||
saveTimeout = setTimeout(saveDocumentToWorker, 3000)
|
||||
}
|
||||
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(schedule, { timeout: 2000 })
|
||||
} else {
|
||||
requestAnimationFrame(schedule)
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for changes to the Automerge document
|
||||
const changeHandler = (payload: any) => {
|
||||
const patchCount = payload.patches?.length || 0
|
||||
|
||||
// Check if patches contain shape changes
|
||||
const hasShapeChanges = payload.patches?.some((p: any) => {
|
||||
const id = p.path?.[1]
|
||||
return id && typeof id === 'string' && id.startsWith('shape:')
|
||||
})
|
||||
|
||||
if (hasShapeChanges) {
|
||||
console.log('🔍 Automerge document changed with shape patches:', {
|
||||
patchCount: patchCount,
|
||||
shapePatches: payload.patches.filter((p: any) => {
|
||||
const id = p.path?.[1]
|
||||
return id && typeof id === 'string' && id.startsWith('shape:')
|
||||
}).length
|
||||
})
|
||||
if (!patchCount) {
|
||||
// No patches, nothing to save
|
||||
return
|
||||
}
|
||||
|
||||
// Schedule save to worker for persistence
|
||||
scheduleSave()
|
||||
// CRITICAL: If mouse is active, defer all processing to avoid blocking mouse interactions
|
||||
if (isMouseActiveRef.current) {
|
||||
// Just mark that we have pending changes, process them when mouse is released
|
||||
pendingSaveRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
// Process patches asynchronously to avoid blocking
|
||||
requestAnimationFrame(() => {
|
||||
// Double-check mouse state after animation frame
|
||||
if (isMouseActiveRef.current) {
|
||||
pendingSaveRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
// Filter out ephemeral record changes - these shouldn't trigger persistence
|
||||
const ephemeralIdPatterns = [
|
||||
'instance:',
|
||||
'instance_page_state:',
|
||||
'instance_presence:',
|
||||
'camera:',
|
||||
'pointer:'
|
||||
]
|
||||
|
||||
// Quick check for ephemeral changes (lightweight)
|
||||
const hasOnlyEphemeralChanges = payload.patches.every((p: any) => {
|
||||
const id = p.path?.[1]
|
||||
if (!id || typeof id !== 'string') return false
|
||||
return ephemeralIdPatterns.some(pattern => id.startsWith(pattern))
|
||||
})
|
||||
|
||||
// If all patches are for ephemeral records, skip persistence
|
||||
if (hasOnlyEphemeralChanges) {
|
||||
// Only log in dev mode to reduce overhead
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('🚫 Skipping persistence - only ephemeral changes detected:', {
|
||||
patchCount
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if patches contain shape changes (lightweight check)
|
||||
const hasShapeChanges = payload.patches?.some((p: any) => {
|
||||
const id = p.path?.[1]
|
||||
return id && typeof id === 'string' && id.startsWith('shape:')
|
||||
})
|
||||
|
||||
if (hasShapeChanges) {
|
||||
// Check if ALL patches are only position updates (x/y) for pinned-to-view shapes
|
||||
// These shouldn't trigger persistence since they're just keeping the shape in the same screen position
|
||||
// NOTE: We defer doc access to avoid blocking, but do lightweight path checks
|
||||
const allPositionUpdates = payload.patches.every((p: any) => {
|
||||
const shapeId = p.path?.[1]
|
||||
|
||||
// If this is not a shape patch, it's not a position update
|
||||
if (!shapeId || typeof shapeId !== 'string' || !shapeId.startsWith('shape:')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if this is a position update (x or y coordinate)
|
||||
// Path format: ['store', 'shape:xxx', 'x'] or ['store', 'shape:xxx', 'y']
|
||||
const pathLength = p.path?.length || 0
|
||||
return pathLength === 3 && (p.path[2] === 'x' || p.path[2] === 'y')
|
||||
})
|
||||
|
||||
// If all patches are position updates, check if they're for pinned shapes
|
||||
// This requires doc access, so we defer it slightly
|
||||
if (allPositionUpdates && payload.patches.length > 0) {
|
||||
// Defer expensive doc access check
|
||||
setTimeout(() => {
|
||||
if (isMouseActiveRef.current) {
|
||||
pendingSaveRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
const doc = handle.doc()
|
||||
const allPinned = payload.patches.every((p: any) => {
|
||||
const shapeId = p.path?.[1]
|
||||
if (!shapeId || typeof shapeId !== 'string' || !shapeId.startsWith('shape:')) {
|
||||
return false
|
||||
}
|
||||
if (doc?.store?.[shapeId]) {
|
||||
const shape = doc.store[shapeId]
|
||||
return shape?.props?.pinnedToView === true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
if (allPinned) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('🚫 Skipping persistence - only pinned-to-view position updates detected:', {
|
||||
patchCount: payload.patches.length
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Not all pinned, schedule save
|
||||
scheduleSave()
|
||||
}, 0)
|
||||
return
|
||||
}
|
||||
|
||||
const shapePatches = payload.patches.filter((p: any) => {
|
||||
const id = p.path?.[1]
|
||||
return id && typeof id === 'string' && id.startsWith('shape:')
|
||||
})
|
||||
|
||||
// Only log in dev mode and reduce logging frequency
|
||||
if (process.env.NODE_ENV === 'development' && shapePatches.length > 0) {
|
||||
console.log('🔍 Automerge document changed with shape patches:', {
|
||||
patchCount: patchCount,
|
||||
shapePatches: shapePatches.length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule save to worker for persistence (only for non-ephemeral changes)
|
||||
scheduleSave()
|
||||
})
|
||||
}
|
||||
|
||||
handle.on('change', changeHandler)
|
||||
|
||||
// Also save immediately on mount to ensure initial state is persisted
|
||||
setTimeout(saveDocumentToWorker, 3000)
|
||||
// Don't save immediately on mount - only save when actual changes occur
|
||||
// The initial document load from server is already persisted, so we don't need to re-persist it
|
||||
|
||||
return () => {
|
||||
handle.off('change', changeHandler)
|
||||
if (saveTimeout) clearTimeout(saveTimeout)
|
||||
}
|
||||
}, [handle, roomId, workerUrl])
|
||||
}, [handle, roomId, workerUrl, generateDocHash])
|
||||
|
||||
// Get user metadata for presence
|
||||
const userMetadata: { userId: string; name: string; color: string } = (() => {
|
||||
|
|
|
|||
|
|
@ -1,45 +1,65 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useContext, useRef } from 'react'
|
||||
import { useEditor } from 'tldraw'
|
||||
import { createShapeId } from 'tldraw'
|
||||
import { WORKER_URL, LOCAL_WORKER_URL } from '../constants/workerUrl'
|
||||
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey } from '../lib/fathomApiKey'
|
||||
import { AuthContext } from '../context/AuthContext'
|
||||
|
||||
interface FathomMeeting {
|
||||
id: string
|
||||
recording_id: number
|
||||
title: string
|
||||
meeting_title?: string
|
||||
url: string
|
||||
share_url?: string
|
||||
created_at: string
|
||||
duration: number
|
||||
summary?: {
|
||||
markdown_formatted: string
|
||||
scheduled_start_time?: string
|
||||
scheduled_end_time?: string
|
||||
recording_start_time?: string
|
||||
recording_end_time?: string
|
||||
transcript?: any[]
|
||||
transcript_language?: string
|
||||
default_summary?: {
|
||||
template_name?: string
|
||||
markdown_formatted?: string
|
||||
}
|
||||
action_items?: any[]
|
||||
calendar_invitees?: Array<{
|
||||
name: string
|
||||
email: string
|
||||
is_external: boolean
|
||||
}>
|
||||
recorded_by?: {
|
||||
name: string
|
||||
email: string
|
||||
team?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface FathomMeetingsPanelProps {
|
||||
onClose: () => void
|
||||
onClose?: () => void
|
||||
onMeetingSelect?: (meeting: FathomMeeting, options: { summary: boolean; transcript: boolean; actionItems: boolean; video: boolean }, format: 'fathom' | 'note') => void
|
||||
shapeMode?: boolean
|
||||
}
|
||||
|
||||
export function FathomMeetingsPanel({ onClose, shapeMode = false }: FathomMeetingsPanelProps) {
|
||||
export function FathomMeetingsPanel({ onClose, onMeetingSelect, shapeMode = false }: FathomMeetingsPanelProps) {
|
||||
const editor = useEditor()
|
||||
// Safely get auth context - may not be available during SVG export
|
||||
const authContext = useContext(AuthContext)
|
||||
const fallbackSession = {
|
||||
username: undefined as string | undefined,
|
||||
}
|
||||
const session = authContext?.session || fallbackSession
|
||||
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [showApiKeyInput, setShowApiKeyInput] = useState(false)
|
||||
const [meetings, setMeetings] = useState<FathomMeeting[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Removed dropdown state - using buttons instead
|
||||
|
||||
useEffect(() => {
|
||||
// Check if API key is already stored
|
||||
const storedApiKey = localStorage.getItem('fathom_api_key')
|
||||
if (storedApiKey) {
|
||||
setApiKey(storedApiKey)
|
||||
fetchMeetings()
|
||||
} else {
|
||||
setShowApiKeyInput(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchMeetings = async () => {
|
||||
if (!apiKey) {
|
||||
const fetchMeetings = async (keyToUse?: string) => {
|
||||
const key = keyToUse || apiKey
|
||||
if (!key) {
|
||||
setError('Please enter your Fathom API key')
|
||||
return
|
||||
}
|
||||
|
|
@ -53,7 +73,7 @@ export function FathomMeetingsPanel({ onClose, shapeMode = false }: FathomMeetin
|
|||
try {
|
||||
response = await fetch(`${WORKER_URL}/fathom/meetings`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'X-Api-Key': key,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
|
@ -61,7 +81,7 @@ export function FathomMeetingsPanel({ onClose, shapeMode = false }: FathomMeetin
|
|||
console.log('Production worker failed, trying local worker...')
|
||||
response = await fetch(`${LOCAL_WORKER_URL}/fathom/meetings`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'X-Api-Key': key,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
|
@ -91,28 +111,169 @@ export function FathomMeetingsPanel({ onClose, shapeMode = false }: FathomMeetin
|
|||
|
||||
const saveApiKey = () => {
|
||||
if (apiKey) {
|
||||
localStorage.setItem('fathom_api_key', apiKey)
|
||||
saveFathomApiKey(apiKey, session.username)
|
||||
setShowApiKeyInput(false)
|
||||
fetchMeetings()
|
||||
fetchMeetings(apiKey)
|
||||
}
|
||||
}
|
||||
|
||||
const addMeetingToCanvas = async (meeting: FathomMeeting) => {
|
||||
// Track if we've already loaded meetings for the current user to prevent multiple API calls
|
||||
const hasLoadedRef = useRef<string | undefined>(undefined)
|
||||
const hasMountedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Only run once on mount, don't re-fetch when session.username changes
|
||||
if (hasMountedRef.current) {
|
||||
return // Already loaded, don't refresh
|
||||
}
|
||||
hasMountedRef.current = true
|
||||
|
||||
// Always check user profile first for API key, then fallback to global storage
|
||||
const username = session.username
|
||||
const storedApiKey = getFathomApiKey(username)
|
||||
if (storedApiKey) {
|
||||
setApiKey(storedApiKey)
|
||||
setShowApiKeyInput(false)
|
||||
// Automatically fetch meetings when API key is available
|
||||
// Only fetch once per user to prevent unnecessary API calls
|
||||
if (hasLoadedRef.current !== username) {
|
||||
hasLoadedRef.current = username
|
||||
fetchMeetings(storedApiKey)
|
||||
}
|
||||
} else {
|
||||
setShowApiKeyInput(true)
|
||||
hasLoadedRef.current = undefined
|
||||
}
|
||||
}, []) // Empty dependency array - only run once on mount
|
||||
|
||||
// Handler for individual data type buttons - creates shapes directly
|
||||
const handleDataButtonClick = async (meeting: FathomMeeting, dataType: 'summary' | 'transcript' | 'actionItems' | 'video') => {
|
||||
// Log to verify the correct meeting is being used
|
||||
console.log('🔵 handleDataButtonClick called with meeting:', {
|
||||
recording_id: meeting.recording_id,
|
||||
title: meeting.title,
|
||||
dataType
|
||||
})
|
||||
|
||||
if (!onMeetingSelect) {
|
||||
// Fallback for non-browser mode
|
||||
const options = {
|
||||
summary: dataType === 'summary',
|
||||
transcript: dataType === 'transcript',
|
||||
actionItems: dataType === 'actionItems',
|
||||
video: dataType === 'video',
|
||||
}
|
||||
await addMeetingToCanvas(meeting, options)
|
||||
return
|
||||
}
|
||||
|
||||
// Browser mode - use callback with specific data type
|
||||
// IMPORTANT: Pass the meeting object directly to ensure each button uses its own meeting's data
|
||||
const options = {
|
||||
summary: dataType === 'summary',
|
||||
transcript: dataType === 'transcript',
|
||||
actionItems: dataType === 'actionItems',
|
||||
video: dataType === 'video',
|
||||
}
|
||||
// Always use 'note' format for summary, transcript, and action items (same behavior)
|
||||
// Video opens URL directly, so format doesn't matter for it
|
||||
const format = 'note'
|
||||
onMeetingSelect(meeting, options, format)
|
||||
}
|
||||
|
||||
const formatMeetingDataAsMarkdown = (fullMeeting: any, meeting: FathomMeeting, options: { summary: boolean; transcript: boolean; actionItems: boolean; video: boolean }): string => {
|
||||
const parts: string[] = []
|
||||
|
||||
// Title
|
||||
parts.push(`# ${fullMeeting.title || meeting.meeting_title || meeting.title || 'Meeting'}\n`)
|
||||
|
||||
// Video link if selected
|
||||
if (options.video && (fullMeeting.url || meeting.url)) {
|
||||
parts.push(`**Video:** [Watch Recording](${fullMeeting.url || meeting.url})\n`)
|
||||
}
|
||||
|
||||
// Summary if selected
|
||||
if (options.summary && fullMeeting.default_summary?.markdown_formatted) {
|
||||
parts.push(`## Summary\n\n${fullMeeting.default_summary.markdown_formatted}\n`)
|
||||
}
|
||||
|
||||
// Action Items if selected
|
||||
if (options.actionItems && fullMeeting.action_items && fullMeeting.action_items.length > 0) {
|
||||
parts.push(`## Action Items\n\n`)
|
||||
fullMeeting.action_items.forEach((item: any) => {
|
||||
const description = item.description || item.text || ''
|
||||
const assignee = item.assignee?.name || item.assignee || ''
|
||||
const dueDate = item.due_date || ''
|
||||
parts.push(`- [ ] ${description}`)
|
||||
if (assignee) parts[parts.length - 1] += ` (@${assignee})`
|
||||
if (dueDate) parts[parts.length - 1] += ` - Due: ${dueDate}`
|
||||
parts[parts.length - 1] += '\n'
|
||||
})
|
||||
parts.push('\n')
|
||||
}
|
||||
|
||||
// Transcript if selected
|
||||
if (options.transcript && fullMeeting.transcript && fullMeeting.transcript.length > 0) {
|
||||
parts.push(`## Transcript\n\n`)
|
||||
fullMeeting.transcript.forEach((entry: any) => {
|
||||
const speaker = entry.speaker?.display_name || 'Unknown'
|
||||
const text = entry.text || ''
|
||||
const timestamp = entry.timestamp || ''
|
||||
if (timestamp) {
|
||||
parts.push(`**${speaker}** (${timestamp}): ${text}\n\n`)
|
||||
} else {
|
||||
parts.push(`**${speaker}**: ${text}\n\n`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
const addMeetingToCanvas = async (meeting: FathomMeeting, options: { summary: boolean; transcript: boolean; actionItems: boolean; video: boolean }) => {
|
||||
try {
|
||||
// If video is selected, just open the Fathom URL directly
|
||||
if (options.video) {
|
||||
// Try multiple sources for the correct video URL
|
||||
// The Fathom API may provide url, share_url, or we may need to construct from call_id or id
|
||||
const callId = meeting.call_id ||
|
||||
meeting.id ||
|
||||
meeting.recording_id
|
||||
|
||||
// Check if URL fields contain valid meeting URLs (contain /calls/)
|
||||
const isValidMeetingUrl = (url: string) => url && url.includes('/calls/')
|
||||
|
||||
// Prioritize valid meeting URLs, then construct from call ID
|
||||
const videoUrl = (meeting.url && isValidMeetingUrl(meeting.url)) ? meeting.url :
|
||||
(meeting.share_url && isValidMeetingUrl(meeting.share_url)) ? meeting.share_url :
|
||||
(callId ? `https://fathom.video/calls/${callId}` : null)
|
||||
|
||||
if (videoUrl) {
|
||||
console.log('Opening Fathom video URL:', videoUrl, 'for meeting:', { callId, recording_id: meeting.recording_id })
|
||||
window.open(videoUrl, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
console.error('Could not determine Fathom video URL for meeting:', meeting)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Only fetch transcript if transcript is selected
|
||||
const includeTranscript = options.transcript
|
||||
|
||||
// Fetch full meeting details
|
||||
let response
|
||||
try {
|
||||
response = await fetch(`${WORKER_URL}/fathom/meetings/${meeting.id}`, {
|
||||
response = await fetch(`${WORKER_URL}/fathom/meetings/${meeting.recording_id}${includeTranscript ? '?include_transcript=true' : ''}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'X-Api-Key': apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.log('Production worker failed, trying local worker...')
|
||||
response = await fetch(`${LOCAL_WORKER_URL}/fathom/meetings/${meeting.id}`, {
|
||||
response = await fetch(`${LOCAL_WORKER_URL}/fathom/meetings/${meeting.recording_id}${includeTranscript ? '?include_transcript=true' : ''}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'X-Api-Key': apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
|
@ -125,41 +286,60 @@ export function FathomMeetingsPanel({ onClose, shapeMode = false }: FathomMeetin
|
|||
|
||||
const fullMeeting = await response.json() as any
|
||||
|
||||
// Create Fathom transcript shape
|
||||
// If onMeetingSelect callback is provided, use it (browser mode - creates separate shapes)
|
||||
if (onMeetingSelect) {
|
||||
// Default to 'note' format for text data
|
||||
onMeetingSelect(meeting, options, 'note')
|
||||
// Browser stays open, don't close
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: create shape directly (for non-browser mode, like modal)
|
||||
// Default to note format
|
||||
const markdownContent = formatMeetingDataAsMarkdown(fullMeeting, meeting, options)
|
||||
const title = fullMeeting.title || meeting.meeting_title || meeting.title || 'Fathom Meeting'
|
||||
|
||||
const shapeId = createShapeId()
|
||||
editor.createShape({
|
||||
id: shapeId,
|
||||
type: 'FathomTranscript',
|
||||
type: 'ObsNote',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: {
|
||||
meetingId: fullMeeting.id || '',
|
||||
meetingTitle: fullMeeting.title || '',
|
||||
meetingUrl: fullMeeting.url || '',
|
||||
summary: fullMeeting.default_summary?.markdown_formatted || '',
|
||||
transcript: fullMeeting.transcript?.map((entry: any) => ({
|
||||
speaker: entry.speaker?.display_name || 'Unknown',
|
||||
text: entry.text,
|
||||
timestamp: entry.timestamp
|
||||
})) || [],
|
||||
actionItems: fullMeeting.action_items?.map((item: any) => ({
|
||||
text: item.text,
|
||||
assignee: item.assignee,
|
||||
dueDate: item.due_date
|
||||
})) || [],
|
||||
isExpanded: false,
|
||||
showTranscript: true,
|
||||
showActionItems: true,
|
||||
w: 400,
|
||||
h: 500,
|
||||
color: 'black',
|
||||
size: 'm',
|
||||
font: 'sans',
|
||||
textAlign: 'start',
|
||||
scale: 1,
|
||||
noteId: `fathom-${meeting.recording_id}`,
|
||||
title: title,
|
||||
content: markdownContent,
|
||||
tags: ['fathom', 'meeting'],
|
||||
showPreview: true,
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#000000',
|
||||
isEditing: false,
|
||||
editingContent: '',
|
||||
isModified: false,
|
||||
originalContent: markdownContent,
|
||||
pinnedToView: false,
|
||||
}
|
||||
})
|
||||
|
||||
onClose()
|
||||
|
||||
// Only close if not in shape mode (browser stays open)
|
||||
if (!shapeMode && onClose) {
|
||||
onClose()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding meeting to canvas:', error)
|
||||
setError(`Failed to add meeting: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Removed dropdown click-outside handler - no longer needed with button-based interface
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
|
|
@ -196,38 +376,22 @@ export function FathomMeetingsPanel({ onClose, shapeMode = false }: FathomMeetin
|
|||
}
|
||||
|
||||
const content = (
|
||||
<div style={contentStyle} onClick={(e) => shapeMode ? undefined : e.stopPropagation()}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '20px',
|
||||
paddingBottom: '10px',
|
||||
borderBottom: '1px solid #eee'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: '18px', fontWeight: 'bold' }}>
|
||||
🎥 Fathom Meetings
|
||||
</h2>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
padding: '5px',
|
||||
position: 'relative',
|
||||
zIndex: 10002,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={contentStyle}
|
||||
onClick={(e) => {
|
||||
// Prevent clicks from interfering with shape selection or resetting data
|
||||
if (!shapeMode) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
// In shape mode, allow normal interaction but don't reset data
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
// Prevent shape deselection when clicking inside the browser content
|
||||
if (shapeMode) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showApiKeyInput ? (
|
||||
<div>
|
||||
<p style={{
|
||||
|
|
@ -296,7 +460,7 @@ export function FathomMeetingsPanel({ onClose, shapeMode = false }: FathomMeetin
|
|||
<>
|
||||
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px' }}>
|
||||
<button
|
||||
onClick={fetchMeetings}
|
||||
onClick={() => fetchMeetings(apiKey)}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
|
|
@ -314,9 +478,12 @@ export function FathomMeetingsPanel({ onClose, shapeMode = false }: FathomMeetin
|
|||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.removeItem('fathom_api_key')
|
||||
// Remove API key from user-specific storage
|
||||
removeFathomApiKey(session.username)
|
||||
setApiKey('')
|
||||
setMeetings([])
|
||||
setShowApiKeyInput(true)
|
||||
hasLoadedRef.current = undefined
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
|
|
@ -363,7 +530,7 @@ export function FathomMeetingsPanel({ onClose, shapeMode = false }: FathomMeetin
|
|||
) : (
|
||||
meetings.map((meeting) => (
|
||||
<div
|
||||
key={meeting.id}
|
||||
key={meeting.recording_id}
|
||||
style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '6px',
|
||||
|
|
@ -393,9 +560,11 @@ export function FathomMeetingsPanel({ onClose, shapeMode = false }: FathomMeetin
|
|||
cursor: 'text'
|
||||
}}>
|
||||
<div>📅 {formatDate(meeting.created_at)}</div>
|
||||
<div>⏱️ Duration: {formatDuration(meeting.duration)}</div>
|
||||
<div>⏱️ Duration: {meeting.recording_start_time && meeting.recording_end_time
|
||||
? formatDuration(Math.floor((new Date(meeting.recording_end_time).getTime() - new Date(meeting.recording_start_time).getTime()) / 1000))
|
||||
: 'N/A'}</div>
|
||||
</div>
|
||||
{meeting.summary && (
|
||||
{meeting.default_summary?.markdown_formatted && (
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#333',
|
||||
|
|
@ -403,28 +572,91 @@ export function FathomMeetingsPanel({ onClose, shapeMode = false }: FathomMeetin
|
|||
userSelect: 'text',
|
||||
cursor: 'text'
|
||||
}}>
|
||||
<strong>Summary:</strong> {meeting.summary.markdown_formatted.substring(0, 100)}...
|
||||
<strong>Summary:</strong> {meeting.default_summary.markdown_formatted.substring(0, 100)}...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addMeetingToCanvas(meeting)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
marginLeft: '10px',
|
||||
position: 'relative',
|
||||
zIndex: 10002,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
Add to Canvas
|
||||
</button>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '6px',
|
||||
marginLeft: '10px',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<button
|
||||
onClick={() => handleDataButtonClick(meeting, 'summary')}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
fontSize: '11px',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: loading ? 0.6 : 1
|
||||
}}
|
||||
title="Add Summary as Note"
|
||||
>
|
||||
📄 Summary
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDataButtonClick(meeting, 'transcript')}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#2563eb',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
fontSize: '11px',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: loading ? 0.6 : 1
|
||||
}}
|
||||
title="Add Transcript as Note"
|
||||
>
|
||||
📝 Transcript
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDataButtonClick(meeting, 'actionItems')}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#1d4ed8',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
fontSize: '11px',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: loading ? 0.6 : 1
|
||||
}}
|
||||
title="Add Action Items as Note"
|
||||
>
|
||||
✅ Actions
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDataButtonClick(meeting, 'video')}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#1e40af',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
fontSize: '11px',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: loading ? 0.6 : 1
|
||||
}}
|
||||
title="Add Video as Embed"
|
||||
>
|
||||
🎥 Video
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
|
@ -477,3 +709,4 @@ export function FathomMeetingsPanel({ onClose, shapeMode = false }: FathomMeetin
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, ReactNode } from 'react'
|
||||
import React, { useState, ReactNode, useEffect, useRef } from 'react'
|
||||
|
||||
export interface StandardizedToolWrapperProps {
|
||||
/** The title to display in the header */
|
||||
|
|
@ -25,6 +25,16 @@ export interface StandardizedToolWrapperProps {
|
|||
editor?: any
|
||||
/** Shape ID for selection handling */
|
||||
shapeId?: string
|
||||
/** Whether the shape is pinned to view */
|
||||
isPinnedToView?: boolean
|
||||
/** Callback when pin button is clicked */
|
||||
onPinToggle?: () => void
|
||||
/** Tags to display at the bottom of the shape */
|
||||
tags?: string[]
|
||||
/** Callback when tags are updated */
|
||||
onTagsChange?: (tags: string[]) => void
|
||||
/** Whether tags can be edited */
|
||||
tagsEditable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -44,9 +54,29 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
headerContent,
|
||||
editor,
|
||||
shapeId,
|
||||
isPinnedToView = false,
|
||||
onPinToggle,
|
||||
tags = [],
|
||||
onTagsChange,
|
||||
tagsEditable = true,
|
||||
}) => {
|
||||
const [isHoveringHeader, setIsHoveringHeader] = useState(false)
|
||||
const [isEditingTags, setIsEditingTags] = useState(false)
|
||||
const [editingTagInput, setEditingTagInput] = useState('')
|
||||
const tagInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Bring selected shape to front when it becomes selected
|
||||
useEffect(() => {
|
||||
if (editor && shapeId && isSelected) {
|
||||
try {
|
||||
// Use sendToFront to bring the shape to the top of the z-order
|
||||
editor.sendToFront([shapeId])
|
||||
} catch (error) {
|
||||
// Silently fail if shape doesn't exist or operation fails
|
||||
// This prevents console spam if shape is deleted during selection
|
||||
}
|
||||
}
|
||||
}, [editor, shapeId, isSelected])
|
||||
|
||||
// Calculate header background color (lighter shade of primary color)
|
||||
const headerBgColor = isSelected
|
||||
|
|
@ -128,6 +158,16 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
color: isSelected ? 'white' : primaryColor,
|
||||
}
|
||||
|
||||
const pinButtonStyle: React.CSSProperties = {
|
||||
...buttonBaseStyle,
|
||||
backgroundColor: isPinnedToView
|
||||
? (isSelected ? 'rgba(255,255,255,0.4)' : primaryColor)
|
||||
: (isSelected ? 'rgba(255,255,255,0.2)' : `${primaryColor}20`),
|
||||
color: isPinnedToView
|
||||
? (isSelected ? 'white' : 'white')
|
||||
: (isSelected ? 'white' : primaryColor),
|
||||
}
|
||||
|
||||
const closeButtonStyle: React.CSSProperties = {
|
||||
...buttonBaseStyle,
|
||||
backgroundColor: isSelected ? 'rgba(255,255,255,0.2)' : `${primaryColor}20`,
|
||||
|
|
@ -143,8 +183,103 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
transition: 'height 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
}
|
||||
|
||||
const tagsContainerStyle: React.CSSProperties = {
|
||||
padding: '8px 12px',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
alignItems: 'center',
|
||||
minHeight: '32px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
flexShrink: 0,
|
||||
}
|
||||
|
||||
const tagStyle: React.CSSProperties = {
|
||||
backgroundColor: '#007acc',
|
||||
color: 'white',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
cursor: tagsEditable ? 'pointer' : 'default',
|
||||
}
|
||||
|
||||
const tagInputStyle: React.CSSProperties = {
|
||||
border: '1px solid #007acc',
|
||||
borderRadius: '12px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '10px',
|
||||
outline: 'none',
|
||||
minWidth: '60px',
|
||||
flex: 1,
|
||||
}
|
||||
|
||||
const addTagButtonStyle: React.CSSProperties = {
|
||||
backgroundColor: '#007acc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
padding: '2px 8px',
|
||||
fontSize: '10px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}
|
||||
|
||||
const handleTagClick = (tag: string) => {
|
||||
if (tagsEditable && onTagsChange) {
|
||||
// Remove tag on click
|
||||
const newTags = tags.filter(t => t !== tag)
|
||||
onTagsChange(newTags)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (editingTagInput.trim() && onTagsChange) {
|
||||
const newTag = editingTagInput.trim().replace('#', '')
|
||||
if (newTag && !tags.includes(newTag) && !tags.includes(`#${newTag}`)) {
|
||||
const tagToAdd = newTag.startsWith('#') ? newTag : newTag
|
||||
onTagsChange([...tags, tagToAdd])
|
||||
}
|
||||
setEditingTagInput('')
|
||||
setIsEditingTags(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTagInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleAddTag()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsEditingTags(false)
|
||||
setEditingTagInput('')
|
||||
} else if (e.key === 'Backspace' && editingTagInput === '' && tags.length > 0) {
|
||||
// Remove last tag if backspace on empty input
|
||||
e.stopPropagation()
|
||||
if (onTagsChange) {
|
||||
onTagsChange(tags.slice(0, -1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditingTags && tagInputRef.current) {
|
||||
tagInputRef.current.focus()
|
||||
}
|
||||
}, [isEditingTags])
|
||||
|
||||
const handleHeaderPointerDown = (e: React.PointerEvent) => {
|
||||
// Check if this is an interactive element (button)
|
||||
const target = e.target as HTMLElement
|
||||
|
|
@ -197,7 +332,18 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
onPointerDown={handleHeaderPointerDown}
|
||||
onMouseEnter={() => setIsHoveringHeader(true)}
|
||||
onMouseLeave={() => setIsHoveringHeader(false)}
|
||||
onMouseDown={(_e) => {
|
||||
onMouseDown={(e) => {
|
||||
// Don't select if clicking on a button - let the button handle the click
|
||||
const target = e.target as HTMLElement
|
||||
const isButton =
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.closest('button') ||
|
||||
target.closest('[role="button"]')
|
||||
|
||||
if (isButton) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure selection happens on mouse down for immediate visual feedback
|
||||
if (editor && shapeId && !isSelected) {
|
||||
editor.setSelectedShapes([shapeId])
|
||||
|
|
@ -209,6 +355,18 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
{headerContent || title}
|
||||
</div>
|
||||
<div style={buttonContainerStyle}>
|
||||
{onPinToggle && (
|
||||
<button
|
||||
style={pinButtonStyle}
|
||||
onClick={(e) => handleButtonClick(e, onPinToggle)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title={isPinnedToView ? "Unpin from view" : "Pin to view"}
|
||||
aria-label={isPinnedToView ? "Unpin from view" : "Pin to view"}
|
||||
>
|
||||
📌
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
style={minimizeButtonStyle}
|
||||
onClick={(e) => {
|
||||
|
|
@ -220,6 +378,7 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
}
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title="Minimize"
|
||||
aria-label="Minimize"
|
||||
disabled={!onMinimize}
|
||||
|
|
@ -230,6 +389,7 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
style={closeButtonStyle}
|
||||
onClick={(e) => handleButtonClick(e, onClose)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
>
|
||||
|
|
@ -240,12 +400,75 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
|
||||
{/* Content Area */}
|
||||
{!isMinimized && (
|
||||
<div
|
||||
style={contentStyle}
|
||||
onPointerDown={handleContentPointerDown}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
style={contentStyle}
|
||||
onPointerDown={handleContentPointerDown}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Tags at the bottom */}
|
||||
{(tags.length > 0 || (tagsEditable && isSelected)) && (
|
||||
<div
|
||||
style={tagsContainerStyle}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
if (tagsEditable && !isEditingTags && e.target === e.currentTarget) {
|
||||
setIsEditingTags(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tags.slice(0, 5).map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
style={tagStyle}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleTagClick(tag)
|
||||
}}
|
||||
title={tagsEditable ? "Click to remove tag" : undefined}
|
||||
>
|
||||
{tag.replace('#', '')}
|
||||
{tagsEditable && <span style={{ fontSize: '8px' }}>×</span>}
|
||||
</span>
|
||||
))}
|
||||
{tags.length > 5 && (
|
||||
<span style={tagStyle}>
|
||||
+{tags.length - 5}
|
||||
</span>
|
||||
)}
|
||||
{isEditingTags && (
|
||||
<input
|
||||
ref={tagInputRef}
|
||||
type="text"
|
||||
value={editingTagInput}
|
||||
onChange={(e) => setEditingTagInput(e.target.value)}
|
||||
onKeyDown={handleTagInputKeyDown}
|
||||
onBlur={() => {
|
||||
handleAddTag()
|
||||
}}
|
||||
style={tagInputStyle}
|
||||
placeholder="Add tag..."
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
{!isEditingTags && tagsEditable && isSelected && tags.length < 10 && (
|
||||
<button
|
||||
style={addTagButtonStyle}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsEditingTags(true)
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title="Add tag"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,414 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { Editor } from 'tldraw'
|
||||
|
||||
/**
|
||||
* Hook to manage shapes pinned to the viewport.
|
||||
* When a shape is pinned, it stays in the same screen position as the camera moves.
|
||||
*/
|
||||
export function usePinnedToView(editor: Editor | null, shapeId: string | undefined, isPinned: boolean) {
|
||||
const pinnedScreenPositionRef = useRef<{ x: number; y: number } | null>(null)
|
||||
const originalCoordinatesRef = useRef<{ x: number; y: number } | null>(null)
|
||||
const originalSizeRef = useRef<{ w: number; h: number } | null>(null)
|
||||
const originalZoomRef = useRef<number | null>(null)
|
||||
const wasPinnedRef = useRef<boolean>(false)
|
||||
const isUpdatingRef = useRef<boolean>(false)
|
||||
const animationFrameRef = useRef<number | null>(null)
|
||||
const lastCameraRef = useRef<{ x: number; y: number; z: number } | null>(null)
|
||||
const pendingUpdateRef = useRef<{ x: number; y: number } | null>(null)
|
||||
const lastUpdateTimeRef = useRef<number>(0)
|
||||
const driftAnimationRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !shapeId) {
|
||||
return
|
||||
}
|
||||
|
||||
const shape = editor.getShape(shapeId)
|
||||
if (!shape) return
|
||||
|
||||
// If just became pinned (transition from false to true), capture the current screen position
|
||||
if (isPinned && !wasPinnedRef.current) {
|
||||
// Store the original coordinates - these will be restored when unpinned
|
||||
originalCoordinatesRef.current = { x: shape.x, y: shape.y }
|
||||
|
||||
// Store the original size and zoom - needed to maintain constant visual size
|
||||
const currentCamera = editor.getCamera()
|
||||
originalSizeRef.current = {
|
||||
w: (shape.props as any).w || 0,
|
||||
h: (shape.props as any).h || 0
|
||||
}
|
||||
originalZoomRef.current = currentCamera.z
|
||||
|
||||
// Get the shape's current page position (top-left corner)
|
||||
const pagePoint = { x: shape.x, y: shape.y }
|
||||
// Convert to screen coordinates - this is where we want the shape to stay
|
||||
const screenPoint = editor.pageToScreen(pagePoint)
|
||||
|
||||
pinnedScreenPositionRef.current = { x: screenPoint.x, y: screenPoint.y }
|
||||
lastCameraRef.current = { ...currentCamera }
|
||||
|
||||
// Bring the shape to the front by setting its index higher than all other shapes
|
||||
try {
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
let highestIndex = 'a0'
|
||||
|
||||
// Find the highest index among all shapes
|
||||
for (const s of allShapes) {
|
||||
if (s.index && typeof s.index === 'string') {
|
||||
// Compare string indices (fractional indexing)
|
||||
// Higher alphabetical order = higher z-index
|
||||
if (s.index > highestIndex) {
|
||||
highestIndex = s.index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bring the shape to the front using editor's sendToFront method
|
||||
// This is safer than manually setting index values
|
||||
try {
|
||||
editor.sendToFront([shapeId])
|
||||
} catch (frontError) {
|
||||
// Fallback: try to set a safe index value
|
||||
// Use conservative values that are known to work (a1, a2, b1, etc.)
|
||||
let newIndex: string = 'a2' // Safe default
|
||||
|
||||
// Try to find a valid index higher than existing ones
|
||||
const allIndices = allShapes
|
||||
.map(s => s.index)
|
||||
.filter((idx): idx is string => typeof idx === 'string' && /^[a-z]\d+$/.test(idx))
|
||||
.sort()
|
||||
|
||||
if (allIndices.length > 0) {
|
||||
const highest = allIndices[allIndices.length - 1]
|
||||
const match = highest.match(/^([a-z])(\d+)$/)
|
||||
if (match) {
|
||||
const letter = match[1]
|
||||
const num = parseInt(match[2], 10)
|
||||
// Increment number, or move to next letter if number gets too high
|
||||
if (num < 100) {
|
||||
newIndex = `${letter}${num + 1}`
|
||||
} else if (letter < 'y') {
|
||||
const nextLetter = String.fromCharCode(letter.charCodeAt(0) + 1)
|
||||
newIndex = `${nextLetter}1`
|
||||
} else {
|
||||
// Use a safe value if we're running out of letters
|
||||
newIndex = 'a2'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate before using
|
||||
if (/^[a-z]\d+$/.test(newIndex)) {
|
||||
editor.updateShape({
|
||||
id: shapeId,
|
||||
type: shape.type,
|
||||
index: newIndex as any,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error bringing pinned shape to front:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// If just became unpinned, animate back to original coordinates
|
||||
if (!isPinned && wasPinnedRef.current) {
|
||||
// Cancel any ongoing pinned position updates
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
}
|
||||
|
||||
// Animate back to original coordinates and size with a calm drift
|
||||
if (originalCoordinatesRef.current && originalSizeRef.current && originalZoomRef.current !== null) {
|
||||
const currentShape = editor.getShape(shapeId)
|
||||
if (currentShape) {
|
||||
const startX = currentShape.x
|
||||
const startY = currentShape.y
|
||||
const targetX = originalCoordinatesRef.current.x
|
||||
const targetY = originalCoordinatesRef.current.y
|
||||
|
||||
// Return to the exact original size (not calculated based on current zoom)
|
||||
const originalW = originalSizeRef.current.w
|
||||
const originalH = originalSizeRef.current.h
|
||||
|
||||
// Use the original size directly
|
||||
const targetW = originalW
|
||||
const targetH = originalH
|
||||
|
||||
const currentW = (currentShape.props as any).w || originalW
|
||||
const currentH = (currentShape.props as any).h || originalH
|
||||
|
||||
const startW = currentW
|
||||
const startH = currentH
|
||||
|
||||
// Only animate if there's a meaningful distance to travel or size change
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(targetX - startX, 2) + Math.pow(targetY - startY, 2)
|
||||
)
|
||||
const sizeChange = Math.abs(targetW - startW) > 0.1 || Math.abs(targetH - startH) > 0.1
|
||||
|
||||
if (distance > 1 || sizeChange) {
|
||||
// Animation parameters
|
||||
const duration = 600 // 600ms for a calm drift
|
||||
const startTime = performance.now()
|
||||
|
||||
// Easing function: ease-out for a calm deceleration
|
||||
const easeOutCubic = (t: number): number => {
|
||||
return 1 - Math.pow(1 - t, 3)
|
||||
}
|
||||
|
||||
const animateDrift = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1) // Clamp to 0-1
|
||||
const easedProgress = easeOutCubic(progress)
|
||||
|
||||
// Interpolate position
|
||||
const currentX = startX + (targetX - startX) * easedProgress
|
||||
const currentY = startY + (targetY - startY) * easedProgress
|
||||
|
||||
// Interpolate size
|
||||
const currentW = startW + (targetW - startW) * easedProgress
|
||||
const currentH = startH + (targetH - startH) * easedProgress
|
||||
|
||||
try {
|
||||
editor.updateShape({
|
||||
id: shapeId,
|
||||
type: currentShape.type,
|
||||
x: currentX,
|
||||
y: currentY,
|
||||
props: {
|
||||
...currentShape.props,
|
||||
w: currentW,
|
||||
h: currentH,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error during drift animation:', error)
|
||||
driftAnimationRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
// Continue animation if not complete
|
||||
if (progress < 1) {
|
||||
driftAnimationRef.current = requestAnimationFrame(animateDrift)
|
||||
} else {
|
||||
// Animation complete - ensure we're exactly at target
|
||||
try {
|
||||
editor.updateShape({
|
||||
id: shapeId,
|
||||
type: currentShape.type,
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
props: {
|
||||
...currentShape.props,
|
||||
w: targetW,
|
||||
h: targetH,
|
||||
},
|
||||
})
|
||||
console.log(`📍 Drifted back to original coordinates: (${targetX}, ${targetY}) and size: (${targetW}, ${targetH})`)
|
||||
} catch (error) {
|
||||
console.error('Error setting final position/size:', error)
|
||||
}
|
||||
driftAnimationRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// Start the animation
|
||||
driftAnimationRef.current = requestAnimationFrame(animateDrift)
|
||||
} else {
|
||||
// Distance is too small, just set directly
|
||||
try {
|
||||
editor.updateShape({
|
||||
id: shapeId,
|
||||
type: currentShape.type,
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
props: {
|
||||
...currentShape.props,
|
||||
w: targetW,
|
||||
h: targetH,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error restoring original coordinates/size:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear refs after a short delay to allow animation to start
|
||||
setTimeout(() => {
|
||||
pinnedScreenPositionRef.current = null
|
||||
originalCoordinatesRef.current = null
|
||||
originalSizeRef.current = null
|
||||
originalZoomRef.current = null
|
||||
lastCameraRef.current = null
|
||||
pendingUpdateRef.current = null
|
||||
}, 50)
|
||||
}
|
||||
|
||||
wasPinnedRef.current = isPinned
|
||||
|
||||
if (!isPinned) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use requestAnimationFrame for smooth, continuous updates
|
||||
// Throttle updates to reduce jitter
|
||||
const updatePinnedPosition = (timestamp: number) => {
|
||||
if (isUpdatingRef.current) {
|
||||
animationFrameRef.current = requestAnimationFrame(updatePinnedPosition)
|
||||
return
|
||||
}
|
||||
|
||||
if (!editor || !shapeId || !isPinned) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentShape = editor.getShape(shapeId)
|
||||
if (!currentShape) {
|
||||
animationFrameRef.current = requestAnimationFrame(updatePinnedPosition)
|
||||
return
|
||||
}
|
||||
|
||||
const pinnedScreenPos = pinnedScreenPositionRef.current
|
||||
if (!pinnedScreenPos) {
|
||||
animationFrameRef.current = requestAnimationFrame(updatePinnedPosition)
|
||||
return
|
||||
}
|
||||
|
||||
const currentCamera = editor.getCamera()
|
||||
const lastCamera = lastCameraRef.current
|
||||
|
||||
// Check if camera has changed significantly
|
||||
const cameraChanged = !lastCamera || (
|
||||
Math.abs(currentCamera.x - lastCamera.x) > 0.1 ||
|
||||
Math.abs(currentCamera.y - lastCamera.y) > 0.1 ||
|
||||
Math.abs(currentCamera.z - lastCamera.z) > 0.001
|
||||
)
|
||||
|
||||
if (cameraChanged) {
|
||||
// Throttle updates to max 60fps (every ~16ms)
|
||||
const timeSinceLastUpdate = timestamp - lastUpdateTimeRef.current
|
||||
const minUpdateInterval = 16 // ~60fps
|
||||
|
||||
if (timeSinceLastUpdate >= minUpdateInterval) {
|
||||
try {
|
||||
// Convert the pinned screen position back to page coordinates
|
||||
const newPagePoint = editor.screenToPage(pinnedScreenPos)
|
||||
|
||||
// Calculate delta
|
||||
const deltaX = Math.abs(currentShape.x - newPagePoint.x)
|
||||
const deltaY = Math.abs(currentShape.y - newPagePoint.y)
|
||||
|
||||
// Check if zoom changed - if so, adjust size to maintain constant visual size
|
||||
const zoomChanged = lastCamera && Math.abs(currentCamera.z - lastCamera.z) > 0.001
|
||||
let needsSizeUpdate = false
|
||||
let newW = (currentShape.props as any).w
|
||||
let newH = (currentShape.props as any).h
|
||||
|
||||
if (zoomChanged && originalSizeRef.current && originalZoomRef.current !== null) {
|
||||
// Calculate the size needed to maintain constant visual size
|
||||
// Visual size = page size * zoom
|
||||
// To keep visual size constant: new_page_size = (original_page_size * original_zoom) / new_zoom
|
||||
const originalW = originalSizeRef.current.w
|
||||
const originalH = originalSizeRef.current.h
|
||||
const originalZoom = originalZoomRef.current
|
||||
const currentZoom = currentCamera.z
|
||||
|
||||
newW = (originalW * originalZoom) / currentZoom
|
||||
newH = (originalH * originalZoom) / currentZoom
|
||||
|
||||
const currentW = (currentShape.props as any).w || originalW
|
||||
const currentH = (currentShape.props as any).h || originalH
|
||||
|
||||
// Check if size needs updating
|
||||
needsSizeUpdate = Math.abs(newW - currentW) > 0.1 || Math.abs(newH - currentH) > 0.1
|
||||
}
|
||||
|
||||
// Only update if the position would actually change significantly or size needs updating
|
||||
if (deltaX > 0.5 || deltaY > 0.5 || needsSizeUpdate) {
|
||||
isUpdatingRef.current = true
|
||||
|
||||
// Batch the update using editor.batch for smoother updates
|
||||
editor.batch(() => {
|
||||
const updateData: any = {
|
||||
id: shapeId,
|
||||
type: currentShape.type,
|
||||
x: newPagePoint.x,
|
||||
y: newPagePoint.y,
|
||||
}
|
||||
|
||||
// Only update size if it changed
|
||||
if (needsSizeUpdate) {
|
||||
updateData.props = {
|
||||
...currentShape.props,
|
||||
w: newW,
|
||||
h: newH,
|
||||
}
|
||||
}
|
||||
|
||||
editor.updateShape(updateData)
|
||||
})
|
||||
|
||||
lastUpdateTimeRef.current = timestamp
|
||||
isUpdatingRef.current = false
|
||||
}
|
||||
|
||||
lastCameraRef.current = { ...currentCamera }
|
||||
} catch (error) {
|
||||
console.error('Error updating pinned shape position/size:', error)
|
||||
isUpdatingRef.current = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Continue monitoring
|
||||
animationFrameRef.current = requestAnimationFrame(updatePinnedPosition)
|
||||
}
|
||||
|
||||
// Start the animation loop
|
||||
lastUpdateTimeRef.current = performance.now()
|
||||
animationFrameRef.current = requestAnimationFrame(updatePinnedPosition)
|
||||
|
||||
// Also listen for shape changes (in case user drags the shape while pinned)
|
||||
// This updates the pinned position to the new location
|
||||
const handleShapeChange = (event: any) => {
|
||||
if (isUpdatingRef.current) return // Don't update if we're programmatically moving it
|
||||
|
||||
if (!editor || !shapeId || !isPinned) return
|
||||
|
||||
// Only respond to changes that affect this specific shape
|
||||
const changedShapes = event?.changedShapes || event?.shapes || []
|
||||
const shapeChanged = changedShapes.some((s: any) => s?.id === shapeId)
|
||||
|
||||
if (!shapeChanged) return
|
||||
|
||||
const currentShape = editor.getShape(shapeId)
|
||||
if (!currentShape) return
|
||||
|
||||
// Update the pinned screen position to the shape's current screen position
|
||||
const pagePoint = { x: currentShape.x, y: currentShape.y }
|
||||
const screenPoint = editor.pageToScreen(pagePoint)
|
||||
pinnedScreenPositionRef.current = { x: screenPoint.x, y: screenPoint.y }
|
||||
lastCameraRef.current = { ...editor.getCamera() }
|
||||
}
|
||||
|
||||
// Listen for shape updates (when user drags the shape)
|
||||
editor.on('change' as any, handleShapeChange)
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
}
|
||||
if (driftAnimationRef.current) {
|
||||
cancelAnimationFrame(driftAnimationRef.current)
|
||||
driftAnimationRef.current = null
|
||||
}
|
||||
editor.off('change' as any, handleShapeChange)
|
||||
}
|
||||
}, [editor, shapeId, isPinned])
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
// Blockchain integration exports
|
||||
|
||||
export * from './ethereum';
|
||||
export * from './walletIntegration';
|
||||
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
// Utility functions for managing Fathom API key in user identity storage
|
||||
|
||||
/**
|
||||
* Get Fathom API key for the current user
|
||||
* Checks user-specific storage first, then falls back to global storage
|
||||
*/
|
||||
export function getFathomApiKey(username?: string): string | null {
|
||||
try {
|
||||
// If username is provided, check user-specific storage
|
||||
if (username) {
|
||||
const userApiKeys = localStorage.getItem(`${username}_api_keys`)
|
||||
if (userApiKeys) {
|
||||
try {
|
||||
const parsed = JSON.parse(userApiKeys)
|
||||
if (parsed.fathomApiKey && parsed.fathomApiKey.trim() !== '') {
|
||||
return parsed.fathomApiKey
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for standalone Fathom key with username prefix
|
||||
const standaloneKey = localStorage.getItem(`${username}_fathom_api_key`)
|
||||
if (standaloneKey && standaloneKey.trim() !== '') {
|
||||
return standaloneKey
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to global storage
|
||||
const globalKey = localStorage.getItem('fathom_api_key')
|
||||
if (globalKey && globalKey.trim() !== '') {
|
||||
return globalKey
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (e) {
|
||||
console.error('Error getting Fathom API key:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Fathom API key for the current user
|
||||
* Stores in user-specific storage if username is provided, otherwise global storage
|
||||
*/
|
||||
export function saveFathomApiKey(apiKey: string, username?: string): void {
|
||||
try {
|
||||
if (username) {
|
||||
// Get existing user API keys or create new object
|
||||
const userApiKeysStr = localStorage.getItem(`${username}_api_keys`)
|
||||
let userApiKeys: any = { keys: {} }
|
||||
|
||||
if (userApiKeysStr) {
|
||||
try {
|
||||
userApiKeys = JSON.parse(userApiKeysStr)
|
||||
} catch (e) {
|
||||
// Start fresh if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
// Add Fathom API key
|
||||
userApiKeys.fathomApiKey = apiKey
|
||||
|
||||
// Save to user-specific storage
|
||||
localStorage.setItem(`${username}_api_keys`, JSON.stringify(userApiKeys))
|
||||
|
||||
// Also save as standalone key for backward compatibility
|
||||
localStorage.setItem(`${username}_fathom_api_key`, apiKey)
|
||||
}
|
||||
|
||||
// Also save to global storage for backward compatibility
|
||||
localStorage.setItem('fathom_api_key', apiKey)
|
||||
} catch (e) {
|
||||
console.error('Error saving Fathom API key:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove Fathom API key for the current user
|
||||
*/
|
||||
export function removeFathomApiKey(username?: string): void {
|
||||
try {
|
||||
if (username) {
|
||||
// Remove from user-specific storage
|
||||
const userApiKeysStr = localStorage.getItem(`${username}_api_keys`)
|
||||
if (userApiKeysStr) {
|
||||
try {
|
||||
const userApiKeys = JSON.parse(userApiKeysStr)
|
||||
delete userApiKeys.fathomApiKey
|
||||
localStorage.setItem(`${username}_api_keys`, JSON.stringify(userApiKeys))
|
||||
} catch (e) {
|
||||
// Continue
|
||||
}
|
||||
}
|
||||
|
||||
// Remove standalone key
|
||||
localStorage.removeItem(`${username}_fathom_api_key`)
|
||||
}
|
||||
|
||||
// Remove from global storage
|
||||
localStorage.removeItem('fathom_api_key')
|
||||
} catch (e) {
|
||||
console.error('Error removing Fathom API key:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Fathom API key is configured for the current user
|
||||
*/
|
||||
export function isFathomApiKeyConfigured(username?: string): boolean {
|
||||
return getFathomApiKey(username) !== null
|
||||
}
|
||||
|
||||
|
|
@ -30,14 +30,11 @@ import { SlideShape } from "@/shapes/SlideShapeUtil"
|
|||
import { makeRealSettings, applySettingsMigrations } from "@/lib/settings"
|
||||
import { PromptShapeTool } from "@/tools/PromptShapeTool"
|
||||
import { PromptShape } from "@/shapes/PromptShapeUtil"
|
||||
import { SharedPianoTool } from "@/tools/SharedPianoTool"
|
||||
import { SharedPianoShape } from "@/shapes/SharedPianoShapeUtil"
|
||||
import { ObsNoteTool } from "@/tools/ObsNoteTool"
|
||||
import { ObsNoteShape } from "@/shapes/ObsNoteShapeUtil"
|
||||
import { TranscriptionTool } from "@/tools/TranscriptionTool"
|
||||
import { TranscriptionShape } from "@/shapes/TranscriptionShapeUtil"
|
||||
import { FathomTranscriptTool } from "@/tools/FathomTranscriptTool"
|
||||
import { FathomTranscriptShape } from "@/shapes/FathomTranscriptShapeUtil"
|
||||
import { FathomNoteShape } from "@/shapes/FathomNoteShapeUtil"
|
||||
import { HolonTool } from "@/tools/HolonTool"
|
||||
import { HolonShape } from "@/shapes/HolonShapeUtil"
|
||||
import { FathomMeetingsTool } from "@/tools/FathomMeetingsTool"
|
||||
|
|
@ -77,10 +74,9 @@ const customShapeUtils = [
|
|||
MycrozineTemplateShape,
|
||||
MarkdownShape,
|
||||
PromptShape,
|
||||
SharedPianoShape,
|
||||
ObsNoteShape,
|
||||
TranscriptionShape,
|
||||
FathomTranscriptShape,
|
||||
FathomNoteShape,
|
||||
HolonShape,
|
||||
HolonBrowserShape,
|
||||
ObsidianBrowserShape,
|
||||
|
|
@ -95,11 +91,9 @@ const customTools = [
|
|||
MycrozineTemplateTool,
|
||||
MarkdownTool,
|
||||
PromptShapeTool,
|
||||
SharedPianoTool,
|
||||
GestureTool,
|
||||
ObsNoteTool,
|
||||
TranscriptionTool,
|
||||
FathomTranscriptTool,
|
||||
HolonTool,
|
||||
FathomMeetingsTool,
|
||||
]
|
||||
|
|
@ -225,6 +219,45 @@ export function Board() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Bring selected shapes to front when they become selected
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
|
||||
let lastSelectedIds: string[] = []
|
||||
|
||||
const handleSelectionChange = () => {
|
||||
const selectedShapeIds = editor.getSelectedShapeIds()
|
||||
|
||||
// Only bring to front if selection actually changed
|
||||
const selectionChanged =
|
||||
selectedShapeIds.length !== lastSelectedIds.length ||
|
||||
selectedShapeIds.some((id, index) => id !== lastSelectedIds[index])
|
||||
|
||||
if (selectionChanged && selectedShapeIds.length > 0) {
|
||||
try {
|
||||
// Bring all selected shapes to the front
|
||||
editor.sendToFront(selectedShapeIds)
|
||||
lastSelectedIds = [...selectedShapeIds]
|
||||
} catch (error) {
|
||||
// Silently fail if shapes don't exist or operation fails
|
||||
// This prevents console spam if shapes are deleted during selection
|
||||
}
|
||||
} else if (!selectionChanged) {
|
||||
// Update lastSelectedIds even if no action taken
|
||||
lastSelectedIds = [...selectedShapeIds]
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for selection changes (fires on any store change, but we filter for selection changes)
|
||||
const unsubscribe = editor.addListener('change', handleSelectionChange)
|
||||
|
||||
return () => {
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
// Remove the URL-based locking effect and replace with store-based initialization
|
||||
useEffect(() => {
|
||||
if (!editor || !store.store) return
|
||||
|
|
@ -596,31 +629,81 @@ export function Board() {
|
|||
};
|
||||
}, [editor, roomId, store.store]);
|
||||
|
||||
// Handle Escape key to cancel active tool and return to hand tool
|
||||
// Also prevent Escape from deleting shapes
|
||||
// TLDraw has built-in undo/redo that works with the store
|
||||
// No need for custom undo/redo manager - TLDraw handles it automatically
|
||||
|
||||
// Handle keyboard shortcuts for undo (Ctrl+Z) and redo (Ctrl+Y)
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Only handle Escape key
|
||||
if (event.key === 'Escape') {
|
||||
// Check if the event target or active element is an input field or textarea
|
||||
const target = event.target as HTMLElement;
|
||||
const activeElement = document.activeElement;
|
||||
const isInputFocused = (target && (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
(target instanceof HTMLElement && target.isContentEditable)
|
||||
)) || (activeElement && (
|
||||
activeElement.tagName === 'INPUT' ||
|
||||
activeElement.tagName === 'TEXTAREA' ||
|
||||
(activeElement instanceof HTMLElement && activeElement.isContentEditable)
|
||||
));
|
||||
// Check if the event target or active element is an input field or textarea
|
||||
const target = event.target as HTMLElement;
|
||||
const activeElement = document.activeElement;
|
||||
const isInputFocused = (target && (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
(target instanceof HTMLElement && target.isContentEditable)
|
||||
)) || (activeElement && (
|
||||
activeElement.tagName === 'INPUT' ||
|
||||
activeElement.tagName === 'TEXTAREA' ||
|
||||
(activeElement instanceof HTMLElement && activeElement.isContentEditable)
|
||||
));
|
||||
|
||||
// If an input is focused, let it handle Escape (don't prevent default)
|
||||
// This allows components like Obsidian notes to handle Escape for canceling edits
|
||||
// Handle Ctrl+Z (Undo) - use TLDraw's built-in undo
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'z' && !event.shiftKey) {
|
||||
// If an input is focused, let it handle Ctrl+Z (don't prevent default)
|
||||
if (isInputFocused) {
|
||||
return; // Let the event propagate to the component's handler
|
||||
return;
|
||||
}
|
||||
|
||||
if (editor) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
editor.undo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Ctrl+Y (Redo) or Ctrl+Shift+Z (Redo on some systems) - use TLDraw's built-in redo
|
||||
if (
|
||||
((event.ctrlKey || event.metaKey) && event.key === 'y') ||
|
||||
((event.ctrlKey || event.metaKey) && event.key === 'z' && event.shiftKey)
|
||||
) {
|
||||
// If an input is focused, let it handle Ctrl+Y (don't prevent default)
|
||||
if (isInputFocused) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editor) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
editor.redo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle Escape key to cancel active tool and return to hand tool
|
||||
// Also prevent Escape from deleting shapes, especially browser shapes
|
||||
if (event.key === 'Escape') {
|
||||
// If an input is focused, let it handle Escape (don't prevent default)
|
||||
if (isInputFocused) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any selected shapes are browser shapes that should not be deleted
|
||||
const selectedShapes = editor.getSelectedShapes();
|
||||
const hasBrowserShape = selectedShapes.some(shape =>
|
||||
shape.type === 'ObsidianBrowser' ||
|
||||
shape.type === 'HolonBrowser' ||
|
||||
shape.type === 'FathomMeetingsBrowser'
|
||||
);
|
||||
|
||||
// Prevent deletion of browser shapes with Escape
|
||||
if (hasBrowserShape) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, prevent default to stop tldraw from deleting shapes
|
||||
|
|
@ -641,7 +724,7 @@ export function Board() {
|
|||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
};
|
||||
}, [editor]);
|
||||
}, [editor, automergeHandle]);
|
||||
|
||||
// Only render Tldraw when store is ready and synced
|
||||
// Tldraw will automatically render shapes as they're added via patches (like in dev)
|
||||
|
|
@ -737,7 +820,7 @@ export function Board() {
|
|||
// Note: User presence is configured through the useAutomergeSync hook above
|
||||
// The authenticated username should appear in the people section
|
||||
}}
|
||||
>
|
||||
>
|
||||
<CmdK />
|
||||
</Tldraw>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useRef, useState } from "react"
|
||||
import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw"
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
import { usePinnedToView } from "../hooks/usePinnedToView"
|
||||
|
||||
export type IChatBoxShape = TLBaseShape<
|
||||
"ChatBox",
|
||||
|
|
@ -9,6 +10,8 @@ export type IChatBoxShape = TLBaseShape<
|
|||
h: number
|
||||
roomId: string
|
||||
userName: string
|
||||
pinnedToView: boolean
|
||||
tags: string[]
|
||||
}
|
||||
>
|
||||
|
||||
|
|
@ -21,6 +24,8 @@ export class ChatBoxShape extends BaseBoxShapeUtil<IChatBoxShape> {
|
|||
w: 400,
|
||||
h: 500,
|
||||
userName: "",
|
||||
pinnedToView: false,
|
||||
tags: ['chat'],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -35,6 +40,9 @@ export class ChatBoxShape extends BaseBoxShapeUtil<IChatBoxShape> {
|
|||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
// Use the pinning hook to keep the shape fixed to viewport when pinned
|
||||
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
||||
|
||||
const handleClose = () => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
}
|
||||
|
|
@ -43,6 +51,17 @@ export class ChatBoxShape extends BaseBoxShapeUtil<IChatBoxShape> {
|
|||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
const handlePinToggle = () => {
|
||||
this.editor.updateShape<IChatBoxShape>({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
pinnedToView: !shape.props.pinnedToView,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
|
||||
<StandardizedToolWrapper
|
||||
|
|
@ -56,6 +75,20 @@ export class ChatBoxShape extends BaseBoxShapeUtil<IChatBoxShape> {
|
|||
isMinimized={isMinimized}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
isPinnedToView={shape.props.pinnedToView}
|
||||
onPinToggle={handlePinToggle}
|
||||
tags={shape.props.tags}
|
||||
onTagsChange={(newTags) => {
|
||||
this.editor.updateShape<IChatBoxShape>({
|
||||
id: shape.id,
|
||||
type: 'ChatBox',
|
||||
props: {
|
||||
...shape.props,
|
||||
tags: newTags,
|
||||
}
|
||||
})
|
||||
}}
|
||||
tagsEditable={true}
|
||||
>
|
||||
<ChatBox
|
||||
roomId={shape.props.roomId}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,25 @@ import {
|
|||
BaseBoxShapeUtil,
|
||||
HTMLContainer,
|
||||
TLBaseShape,
|
||||
createShapeId,
|
||||
Box,
|
||||
} from "tldraw"
|
||||
import React, { useState } from "react"
|
||||
import React, { useState, useContext } from "react"
|
||||
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
import { usePinnedToView } from "../hooks/usePinnedToView"
|
||||
import { FathomNoteShape } from "./FathomNoteShapeUtil"
|
||||
import { WORKER_URL, LOCAL_WORKER_URL } from "../constants/workerUrl"
|
||||
import { getFathomApiKey } from "../lib/fathomApiKey"
|
||||
import { AuthContext } from "../context/AuthContext"
|
||||
|
||||
type IFathomMeetingsBrowser = TLBaseShape<
|
||||
"FathomMeetingsBrowser",
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
pinnedToView: boolean
|
||||
tags: string[]
|
||||
}
|
||||
>
|
||||
|
||||
|
|
@ -22,6 +31,8 @@ export class FathomMeetingsBrowserShape extends BaseBoxShapeUtil<IFathomMeetings
|
|||
return {
|
||||
w: 800,
|
||||
h: 600,
|
||||
pinnedToView: false,
|
||||
tags: ['fathom', 'meetings', 'browser'],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -34,43 +45,507 @@ export class FathomMeetingsBrowserShape extends BaseBoxShapeUtil<IFathomMeetings
|
|||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
// Use the pinning hook to keep the shape fixed to viewport when pinned
|
||||
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false)
|
||||
// Delete the browser shape after a short delay
|
||||
setTimeout(() => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
}, 100)
|
||||
// Delete the browser shape immediately so it's tracked in undo/redo history
|
||||
this.editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return null
|
||||
const handlePinToggle = () => {
|
||||
this.editor.updateShape<IFathomMeetingsBrowser>({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
pinnedToView: !shape.props.pinnedToView,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<HTMLContainer style={{ width: w, height: h }}>
|
||||
<StandardizedToolWrapper
|
||||
title="Fathom Meetings"
|
||||
primaryColor={FathomMeetingsBrowserShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={w}
|
||||
height={h}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
>
|
||||
<FathomMeetingsPanel
|
||||
// Wrapper component to access auth context and create handler
|
||||
const FathomBrowserContent: React.FC = () => {
|
||||
const authContext = useContext(AuthContext)
|
||||
const fallbackSession = {
|
||||
username: '',
|
||||
authed: false,
|
||||
loading: false,
|
||||
backupCreated: null,
|
||||
}
|
||||
const session = authContext?.session || fallbackSession
|
||||
|
||||
const handleMeetingSelect = async (
|
||||
meeting: any,
|
||||
options: { summary: boolean; transcript: boolean; actionItems: boolean; video: boolean },
|
||||
format: 'fathom' | 'note'
|
||||
) => {
|
||||
try {
|
||||
// CRITICAL: Store meeting data immediately to avoid closure issues
|
||||
// Extract all needed values before any async operations
|
||||
const meetingRecordingId = meeting?.recording_id
|
||||
const meetingTitle = meeting?.title
|
||||
|
||||
if (!meetingRecordingId) {
|
||||
console.error('❌ No recording_id found in meeting object:', meeting)
|
||||
return
|
||||
}
|
||||
|
||||
// Log to verify the correct meeting is being received
|
||||
console.log('🔵 handleMeetingSelect called with meeting:', {
|
||||
recording_id: meetingRecordingId,
|
||||
title: meetingTitle,
|
||||
options,
|
||||
fullMeetingObject: meeting
|
||||
})
|
||||
|
||||
// Get API key from user identity
|
||||
const apiKey = getFathomApiKey(session.username)
|
||||
if (!apiKey) {
|
||||
console.error('No Fathom API key found')
|
||||
return
|
||||
}
|
||||
|
||||
// IMPORTANT: Each meeting row fetches its own data using the meeting's recording_id
|
||||
// This ensures each meeting's buttons pull data from the correct Fathom API endpoint
|
||||
// Always fetch full meeting details from API (summary and action items are included by default)
|
||||
// Only include transcript parameter if transcript is specifically requested
|
||||
const includeTranscript = options.transcript
|
||||
|
||||
// Use the stored meetingRecordingId (already extracted above)
|
||||
console.log('🔵 Fetching data for meeting recording_id:', meetingRecordingId)
|
||||
|
||||
let response
|
||||
try {
|
||||
// Fetch data for THIS specific meeting using its recording_id
|
||||
const apiUrl = `${WORKER_URL}/fathom/meetings/${meetingRecordingId}${includeTranscript ? '?include_transcript=true' : ''}`
|
||||
console.log('🔵 API URL:', apiUrl)
|
||||
response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
'X-Api-Key': apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// Use the stored meetingRecordingId to ensure we fetch the correct meeting
|
||||
response = await fetch(`${LOCAL_WORKER_URL}/fathom/meetings/${meetingRecordingId}${includeTranscript ? '?include_transcript=true' : ''}`, {
|
||||
headers: {
|
||||
'X-Api-Key': apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch meeting details: ${response.status}`)
|
||||
return
|
||||
}
|
||||
|
||||
const fullMeeting = await response.json() as any
|
||||
|
||||
// Debug: Log the meeting response structure
|
||||
console.log('Full meeting response:', fullMeeting)
|
||||
console.log('Meeting keys:', Object.keys(fullMeeting))
|
||||
console.log('Has default_summary:', !!fullMeeting.default_summary)
|
||||
console.log('Has action_items:', !!fullMeeting.action_items)
|
||||
if (fullMeeting.default_summary) {
|
||||
console.log('default_summary structure:', fullMeeting.default_summary)
|
||||
}
|
||||
if (fullMeeting.action_items) {
|
||||
console.log('action_items length:', fullMeeting.action_items.length)
|
||||
}
|
||||
|
||||
// Helper function to format date as YYYY.MM.DD
|
||||
const formatDateForTitle = (dateString: string | undefined): string => {
|
||||
if (!dateString) return ''
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}.${month}.${day}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Get meeting name and date for title formatting
|
||||
// Use the stored meetingRecordingId to ensure we're using the correct meeting
|
||||
// Also use the stored meetingTitle as fallback
|
||||
const meetingName = fullMeeting.title || meetingTitle || 'Meeting'
|
||||
const meetingDate = formatDateForTitle(fullMeeting.recording_start_time || fullMeeting.created_at)
|
||||
|
||||
// Get browser shape bounds for positioning
|
||||
const browserShapeBounds = this.editor.getShapePageBounds(shape.id)
|
||||
let startX: number
|
||||
let startY: number
|
||||
|
||||
if (!browserShapeBounds) {
|
||||
const viewport = this.editor.getViewportPageBounds()
|
||||
startX = viewport.x + viewport.w / 2
|
||||
startY = viewport.y + viewport.h / 2
|
||||
} else {
|
||||
// Position notes close to the browser (reduced spacing for closer positioning)
|
||||
const browserSpacing = 30
|
||||
startX = browserShapeBounds.x + browserShapeBounds.w + browserSpacing
|
||||
startY = browserShapeBounds.y
|
||||
}
|
||||
|
||||
// Track existing shapes by meeting ID for proper grouping
|
||||
const allShapes = this.editor.getCurrentPageShapes()
|
||||
const browserSpacing = 30
|
||||
const expectedStartX = browserShapeBounds ? browserShapeBounds.x + browserShapeBounds.w + browserSpacing : startX
|
||||
|
||||
// Find existing shapes for this specific meeting
|
||||
// Use meetingRecordingId to ensure we're using the correct meeting ID
|
||||
const currentMeetingId = meetingRecordingId
|
||||
const existingShapesForThisMeeting = allShapes.filter(s => {
|
||||
if (s.type !== 'FathomNote') return false
|
||||
// Check if shape belongs to this meeting by checking the noteId prop
|
||||
const noteId = (s as any).props?.noteId || ''
|
||||
return noteId.includes(`fathom-${currentMeetingId}`) || noteId.includes(`fathom-summary-${currentMeetingId}`) ||
|
||||
noteId.includes(`fathom-transcript-${currentMeetingId}`) || noteId.includes(`fathom-actions-${currentMeetingId}`)
|
||||
})
|
||||
|
||||
// Find all existing Fathom shapes to determine vertical positioning
|
||||
const allExistingFathomShapes = allShapes.filter(s => {
|
||||
if (s.type !== 'FathomNote') return false
|
||||
const noteId = (s as any).props?.noteId || ''
|
||||
return noteId.startsWith('fathom-')
|
||||
})
|
||||
|
||||
// Calculate which meeting row this is (0 = first meeting row)
|
||||
const meetingIds = new Set<string>()
|
||||
allExistingFathomShapes.forEach(s => {
|
||||
const noteId = (s as any).props?.noteId || ''
|
||||
const match = noteId.match(/fathom-(?:summary|transcript|actions)-(.+)/)
|
||||
if (match) {
|
||||
meetingIds.add(match[1])
|
||||
}
|
||||
})
|
||||
const meetingRowIndex = Array.from(meetingIds).indexOf(currentMeetingId)
|
||||
const actualMeetingRowIndex = meetingRowIndex >= 0 ? meetingRowIndex : meetingIds.size
|
||||
|
||||
// Shape dimensions - all shapes are the same size
|
||||
const shapeWidth = 500
|
||||
const shapeHeight = 600
|
||||
const horizontalSpacing = 20
|
||||
const verticalSpacing = 30 // Space between meeting rows
|
||||
|
||||
const shapesToCreate: any[] = []
|
||||
|
||||
// Calculate Y position for this meeting's shapes
|
||||
// If this meeting already has shapes, use the Y position of the first existing shape
|
||||
// Otherwise, calculate based on meeting row index
|
||||
let baseY: number
|
||||
if (existingShapesForThisMeeting.length > 0) {
|
||||
// Use the Y position of existing shapes for this meeting to ensure they're on the same line
|
||||
const firstExistingShapeBounds = this.editor.getShapePageBounds(existingShapesForThisMeeting[0].id)
|
||||
baseY = firstExistingShapeBounds ? firstExistingShapeBounds.y : startY + actualMeetingRowIndex * (shapeHeight + verticalSpacing)
|
||||
} else {
|
||||
// New meeting row - calculate position based on row index
|
||||
baseY = startY + actualMeetingRowIndex * (shapeHeight + verticalSpacing)
|
||||
}
|
||||
|
||||
// Calculate horizontal positions for this meeting's shapes
|
||||
// Summary, Transcript, Action Items will be side by side on the same horizontal line
|
||||
// Each meeting row is positioned below the previous one
|
||||
let currentX = startX
|
||||
|
||||
// If this meeting already has shapes, position new shapes after the existing ones
|
||||
if (existingShapesForThisMeeting.length > 0) {
|
||||
// Find the rightmost existing shape for this meeting
|
||||
let rightmostX = startX
|
||||
existingShapesForThisMeeting.forEach(s => {
|
||||
const bounds = this.editor.getShapePageBounds(s.id)
|
||||
if (bounds) {
|
||||
const shapeRight = bounds.x + bounds.w
|
||||
if (shapeRight > rightmostX) {
|
||||
rightmostX = shapeRight
|
||||
}
|
||||
}
|
||||
})
|
||||
// Start new shapes after the rightmost existing shape
|
||||
currentX = rightmostX + horizontalSpacing
|
||||
}
|
||||
|
||||
// Create shapes for each selected data type in button order: Summary, Transcript, Action Items
|
||||
// Position shapes horizontally for the same meeting, vertically for different meetings
|
||||
// Blue shades match button colors: Summary (#3b82f6), Transcript (#2563eb), Actions (#1d4ed8)
|
||||
|
||||
if (options.summary) {
|
||||
// Check for summary in various possible formats from Fathom API
|
||||
const summaryText = fullMeeting.default_summary?.markdown_formatted ||
|
||||
fullMeeting.default_summary?.text ||
|
||||
fullMeeting.summary?.markdown_formatted ||
|
||||
fullMeeting.summary?.text ||
|
||||
fullMeeting.summary ||
|
||||
''
|
||||
|
||||
if (summaryText) {
|
||||
const xPos = currentX
|
||||
const yPos = baseY
|
||||
|
||||
// Create Fathom note shape for summary with lightest blue (#3b82f6)
|
||||
// Format: date in top right, title in content
|
||||
const contentWithHeader = meetingDate
|
||||
? `<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px;">
|
||||
<h1 style="margin: 0; font-size: 18px; font-weight: bold; flex: 1;">${meetingName}: Fathom Summary</h1>
|
||||
<span style="font-size: 11px; color: #666; margin-left: 12px;">${meetingDate}</span>
|
||||
</div>\n\n${summaryText}`
|
||||
: `# ${meetingName}: Fathom Summary\n\n${summaryText}`
|
||||
const noteShape = FathomNoteShape.createFromData(
|
||||
{
|
||||
id: `fathom-summary-${meetingRecordingId}`,
|
||||
title: 'Fathom Meeting Object: Summary',
|
||||
content: contentWithHeader,
|
||||
tags: ['fathom', 'summary'],
|
||||
primaryColor: '#3b82f6', // Lightest blue - matches Summary button
|
||||
},
|
||||
xPos,
|
||||
yPos
|
||||
)
|
||||
// Update the shape dimensions - all shapes same size
|
||||
const updatedNoteShape = {
|
||||
...noteShape,
|
||||
props: {
|
||||
...noteShape.props,
|
||||
w: shapeWidth,
|
||||
h: shapeHeight,
|
||||
}
|
||||
}
|
||||
shapesToCreate.push(updatedNoteShape)
|
||||
currentX += shapeWidth + horizontalSpacing
|
||||
} else {
|
||||
console.warn('Summary requested but no summary data found in meeting response')
|
||||
}
|
||||
}
|
||||
|
||||
if (options.transcript) {
|
||||
// Check for transcript data
|
||||
const transcript = fullMeeting.transcript || []
|
||||
|
||||
if (transcript.length > 0) {
|
||||
const xPos = currentX
|
||||
const yPos = baseY
|
||||
|
||||
// Create Fathom note shape for transcript with medium blue (#2563eb)
|
||||
const transcriptText = transcript.map((entry: any) => {
|
||||
const speaker = entry.speaker?.display_name || 'Unknown'
|
||||
const text = entry.text || ''
|
||||
const timestamp = entry.timestamp || ''
|
||||
return timestamp ? `**${speaker}** (${timestamp}): ${text}` : `**${speaker}**: ${text}`
|
||||
}).join('\n\n')
|
||||
|
||||
// Format: date in top right, title in content
|
||||
const contentWithHeader = meetingDate
|
||||
? `<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px;">
|
||||
<h1 style="margin: 0; font-size: 18px; font-weight: bold; flex: 1;">${meetingName}: Fathom Transcript</h1>
|
||||
<span style="font-size: 11px; color: #666; margin-left: 12px;">${meetingDate}</span>
|
||||
</div>\n\n${transcriptText}`
|
||||
: `# ${meetingName}: Fathom Transcript\n\n${transcriptText}`
|
||||
const noteShape = FathomNoteShape.createFromData(
|
||||
{
|
||||
id: `fathom-transcript-${meetingRecordingId}`,
|
||||
title: 'Fathom Meeting Object: Transcript',
|
||||
content: contentWithHeader,
|
||||
tags: ['fathom', 'transcript'],
|
||||
primaryColor: '#2563eb', // Medium blue - matches Transcript button
|
||||
},
|
||||
xPos,
|
||||
yPos
|
||||
)
|
||||
// Update the shape dimensions - same size as others
|
||||
const updatedNoteShape = {
|
||||
...noteShape,
|
||||
props: {
|
||||
...noteShape.props,
|
||||
w: shapeWidth,
|
||||
h: shapeHeight,
|
||||
}
|
||||
}
|
||||
shapesToCreate.push(updatedNoteShape)
|
||||
currentX += shapeWidth + horizontalSpacing
|
||||
} else {
|
||||
console.warn('Transcript requested but no transcript data found in meeting response')
|
||||
}
|
||||
}
|
||||
|
||||
if (options.actionItems) {
|
||||
// Check for action items in various possible formats from Fathom API
|
||||
const actionItems = fullMeeting.action_items || fullMeeting.actionItems || []
|
||||
|
||||
if (actionItems.length > 0) {
|
||||
const xPos = currentX
|
||||
const yPos = baseY
|
||||
|
||||
// Create Fathom note shape for action items with darker blue (#1d4ed8)
|
||||
const actionItemsText = actionItems.map((item: any) => {
|
||||
const description = item.description || item.text || item.title || ''
|
||||
const assignee = item.assignee?.name || item.assignee || item.owner?.name || item.owner || ''
|
||||
const dueDate = item.due_date || item.dueDate || item.due || ''
|
||||
let itemText = `- [ ] ${description}`
|
||||
if (assignee) itemText += ` (@${assignee})`
|
||||
if (dueDate) itemText += ` - Due: ${dueDate}`
|
||||
return itemText
|
||||
}).join('\n')
|
||||
|
||||
// Format: date in top right, title in content
|
||||
const contentWithHeader = meetingDate
|
||||
? `<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px;">
|
||||
<h1 style="margin: 0; font-size: 18px; font-weight: bold; flex: 1;">${meetingName}: Fathom Action Items</h1>
|
||||
<span style="font-size: 11px; color: #666; margin-left: 12px;">${meetingDate}</span>
|
||||
</div>\n\n${actionItemsText}`
|
||||
: `# ${meetingName}: Fathom Action Items\n\n${actionItemsText}`
|
||||
const noteShape = FathomNoteShape.createFromData(
|
||||
{
|
||||
id: `fathom-actions-${meetingRecordingId}`,
|
||||
title: 'Fathom Meeting Object: Action Items',
|
||||
content: contentWithHeader,
|
||||
tags: ['fathom', 'action-items'],
|
||||
primaryColor: '#1d4ed8', // Darker blue - matches Action Items button
|
||||
},
|
||||
xPos,
|
||||
yPos
|
||||
)
|
||||
// Update the shape dimensions - same size as others
|
||||
const updatedNoteShape = {
|
||||
...noteShape,
|
||||
props: {
|
||||
...noteShape.props,
|
||||
w: shapeWidth,
|
||||
h: shapeHeight,
|
||||
}
|
||||
}
|
||||
shapesToCreate.push(updatedNoteShape)
|
||||
currentX += shapeWidth + horizontalSpacing
|
||||
} else {
|
||||
console.warn('Action items requested but no action items found in meeting response')
|
||||
}
|
||||
}
|
||||
|
||||
if (options.video) {
|
||||
// Open Fathom video URL directly in a new tab instead of creating a note shape
|
||||
// Try multiple sources for the correct video URL
|
||||
// The Fathom API may provide url, share_url, or we may need to construct from call_id or id
|
||||
const callId = fullMeeting.call_id ||
|
||||
fullMeeting.id ||
|
||||
fullMeeting.recording_id ||
|
||||
meeting.call_id ||
|
||||
meeting.id ||
|
||||
meeting.recording_id
|
||||
|
||||
// Check if URL fields contain valid meeting URLs (contain /calls/)
|
||||
const isValidMeetingUrl = (url: string) => url && url.includes('/calls/')
|
||||
|
||||
// Prioritize valid meeting URLs, then construct from call ID
|
||||
const videoUrl = (fullMeeting.url && isValidMeetingUrl(fullMeeting.url)) ? fullMeeting.url :
|
||||
(fullMeeting.share_url && isValidMeetingUrl(fullMeeting.share_url)) ? fullMeeting.share_url :
|
||||
(meeting.url && isValidMeetingUrl(meeting.url)) ? meeting.url :
|
||||
(meeting.share_url && isValidMeetingUrl(meeting.share_url)) ? meeting.share_url :
|
||||
(callId ? `https://fathom.video/calls/${callId}` : null)
|
||||
|
||||
if (videoUrl) {
|
||||
console.log('Opening Fathom video URL:', videoUrl, 'for meeting:', { callId, recording_id: meeting.recording_id })
|
||||
window.open(videoUrl, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
console.error('Could not determine Fathom video URL for meeting:', { meeting, fullMeeting })
|
||||
}
|
||||
}
|
||||
|
||||
// Create all shapes at once
|
||||
if (shapesToCreate.length > 0) {
|
||||
this.editor.createShapes(shapesToCreate)
|
||||
|
||||
// Animate camera to the first created note
|
||||
|
||||
// Animate camera to show the note
|
||||
setTimeout(() => {
|
||||
const firstShapeId = shapesToCreate[0].id
|
||||
// getShapePageBounds works with raw ID, setSelectedShapes needs "shape:" prefix
|
||||
const rawShapeId = firstShapeId.startsWith('shape:') ? firstShapeId.replace('shape:', '') : firstShapeId
|
||||
const shapeIdWithPrefix = `shape:${rawShapeId}`
|
||||
|
||||
const firstShapeBounds = this.editor.getShapePageBounds(rawShapeId)
|
||||
|
||||
if (firstShapeBounds) {
|
||||
let boundsToShow = firstShapeBounds
|
||||
|
||||
if (browserShapeBounds) {
|
||||
const minX = Math.min(browserShapeBounds.x, firstShapeBounds.x)
|
||||
const maxX = Math.max(browserShapeBounds.x + browserShapeBounds.w, firstShapeBounds.x + firstShapeBounds.w)
|
||||
const minY = Math.min(browserShapeBounds.y, firstShapeBounds.y)
|
||||
const maxY = Math.max(browserShapeBounds.y + browserShapeBounds.h, firstShapeBounds.y + firstShapeBounds.h)
|
||||
|
||||
boundsToShow = Box.Common([browserShapeBounds, firstShapeBounds])
|
||||
}
|
||||
|
||||
this.editor.zoomToBounds(boundsToShow, {
|
||||
inset: 50,
|
||||
animation: {
|
||||
duration: 500,
|
||||
easing: (t) => t * (2 - t),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
this.editor.setSelectedShapes([shapeIdWithPrefix] as any)
|
||||
this.editor.setCurrentTool('select')
|
||||
}, 50)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating Fathom meeting shapes:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<HTMLContainer style={{ width: w, height: h }}>
|
||||
<StandardizedToolWrapper
|
||||
title="Fathom Meetings"
|
||||
primaryColor={FathomMeetingsBrowserShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={w}
|
||||
height={h}
|
||||
onClose={handleClose}
|
||||
shapeMode={true}
|
||||
/>
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
isPinnedToView={shape.props.pinnedToView}
|
||||
onPinToggle={handlePinToggle}
|
||||
tags={shape.props.tags}
|
||||
onTagsChange={(newTags) => {
|
||||
this.editor.updateShape<IFathomMeetingsBrowser>({
|
||||
id: shape.id,
|
||||
type: 'FathomMeetingsBrowser',
|
||||
props: {
|
||||
...shape.props,
|
||||
tags: newTags,
|
||||
}
|
||||
})
|
||||
}}
|
||||
tagsEditable={true}
|
||||
>
|
||||
<FathomMeetingsPanel
|
||||
onClose={handleClose}
|
||||
onMeetingSelect={handleMeetingSelect}
|
||||
shapeMode={true}
|
||||
/>
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
return <FathomBrowserContent />
|
||||
}
|
||||
|
||||
indicator(shape: IFathomMeetingsBrowser) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,654 @@
|
|||
import React, { useState } from 'react'
|
||||
import { BaseBoxShapeUtil, TLBaseShape, createShapeId, IndexKey, TLParentId, HTMLContainer } from '@tldraw/tldraw'
|
||||
import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper'
|
||||
import { usePinnedToView } from '../hooks/usePinnedToView'
|
||||
|
||||
export type IFathomNoteShape = TLBaseShape<
|
||||
'FathomNote',
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
title: string
|
||||
content: string
|
||||
tags: string[]
|
||||
noteId: string
|
||||
pinnedToView: boolean
|
||||
primaryColor: string // Blue shade for the header
|
||||
}
|
||||
>
|
||||
|
||||
export class FathomNoteShape extends BaseBoxShapeUtil<IFathomNoteShape> {
|
||||
static override type = 'FathomNote' as const
|
||||
|
||||
// Default blue color (can be overridden per shape)
|
||||
static readonly PRIMARY_COLOR = "#3b82f6"
|
||||
|
||||
getDefaultProps(): IFathomNoteShape['props'] {
|
||||
return {
|
||||
w: 500,
|
||||
h: 600,
|
||||
title: 'Fathom Note',
|
||||
content: '',
|
||||
tags: [],
|
||||
noteId: '',
|
||||
pinnedToView: false,
|
||||
primaryColor: FathomNoteShape.PRIMARY_COLOR,
|
||||
}
|
||||
}
|
||||
|
||||
component(shape: IFathomNoteShape) {
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
// Use the pinning hook
|
||||
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
||||
|
||||
const handleClose = () => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
const handlePinToggle = () => {
|
||||
this.editor.updateShape<IFathomNoteShape>({
|
||||
id: shape.id,
|
||||
type: 'FathomNote',
|
||||
props: {
|
||||
...shape.props,
|
||||
pinnedToView: !shape.props.pinnedToView,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
// Extract plain text from content (remove HTML tags and markdown formatting)
|
||||
let textToCopy = shape.props.content || ''
|
||||
|
||||
// Remove HTML tags if present
|
||||
const tempDiv = document.createElement('div')
|
||||
tempDiv.innerHTML = textToCopy
|
||||
textToCopy = tempDiv.textContent || tempDiv.innerText || textToCopy
|
||||
|
||||
// Clean up markdown formatting for better plain text output
|
||||
// Remove markdown headers
|
||||
textToCopy = textToCopy.replace(/^#+\s+/gm, '')
|
||||
// Remove markdown bold/italic
|
||||
textToCopy = textToCopy.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||
textToCopy = textToCopy.replace(/\*([^*]+)\*/g, '$1')
|
||||
// Remove markdown links but keep text
|
||||
textToCopy = textToCopy.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
// Remove markdown code blocks
|
||||
textToCopy = textToCopy.replace(/```[\s\S]*?```/g, '')
|
||||
// Remove inline code
|
||||
textToCopy = textToCopy.replace(/`([^`]+)`/g, '$1')
|
||||
|
||||
// Clean up extra whitespace
|
||||
textToCopy = textToCopy.trim().replace(/\n{3,}/g, '\n\n')
|
||||
|
||||
if (!textToCopy.trim()) {
|
||||
console.warn('No content to copy')
|
||||
return
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(textToCopy)
|
||||
setIsCopied(true)
|
||||
setTimeout(() => {
|
||||
setIsCopied(false)
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy text:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
padding: '16px',
|
||||
overflow: 'auto',
|
||||
flex: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#000000',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.6',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
userSelect: 'text', // Enable text selection
|
||||
cursor: 'text', // Show text cursor
|
||||
WebkitUserSelect: 'text', // Safari support
|
||||
MozUserSelect: 'text', // Firefox support
|
||||
msUserSelect: 'text', // IE/Edge support
|
||||
}
|
||||
|
||||
// Format markdown content for display
|
||||
const formatContent = (content: string) => {
|
||||
if (!content) return null
|
||||
|
||||
// Check if content starts with HTML (for the header with date)
|
||||
if (content.trim().startsWith('<div')) {
|
||||
// Find where the HTML div ends
|
||||
const divEndIndex = content.indexOf('</div>')
|
||||
if (divEndIndex !== -1) {
|
||||
const htmlHeader = content.substring(0, divEndIndex + 6) // Include </div>
|
||||
const markdownContent = content.substring(divEndIndex + 6).trim()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div dangerouslySetInnerHTML={{ __html: htmlHeader }} />
|
||||
{markdownContent ? formatMarkdownContent(markdownContent) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return formatMarkdownContent(content)
|
||||
}
|
||||
|
||||
// Format markdown content (extracted to separate function)
|
||||
const formatMarkdownContent = (content: string) => {
|
||||
const lines = content.split('\n')
|
||||
const elements: JSX.Element[] = []
|
||||
let i = 0
|
||||
let inCodeBlock = false
|
||||
let codeBlockLines: string[] = []
|
||||
let listItems: string[] = []
|
||||
let listType: 'ul' | 'ol' | null = null
|
||||
|
||||
const processInlineMarkdown = (text: string): (string | JSX.Element)[] => {
|
||||
const parts: (string | JSX.Element)[] = []
|
||||
let lastIndex = 0
|
||||
let keyCounter = 0
|
||||
|
||||
// Process inline code, links, bold, italic in order of precedence
|
||||
// We need to process them in a way that handles overlapping patterns correctly
|
||||
// Process bold first, then italic (to avoid conflicts)
|
||||
const patterns: Array<{
|
||||
regex: RegExp
|
||||
render: (...args: any[]) => JSX.Element
|
||||
groupCount: number
|
||||
}> = [
|
||||
{
|
||||
regex: /`([^`]+)`/g,
|
||||
groupCount: 1,
|
||||
render: (code: string, key: number) => (
|
||||
<code key={key} style={{
|
||||
backgroundColor: '#f4f4f4',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '3px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px'
|
||||
}}>{code}</code>
|
||||
)
|
||||
},
|
||||
{
|
||||
regex: /\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
groupCount: 2,
|
||||
render: (linkText: string, url: string, key: number) => (
|
||||
<a key={key} href={url} target="_blank" rel="noopener noreferrer" style={{ color: '#2563eb', textDecoration: 'underline' }}>{linkText}</a>
|
||||
)
|
||||
},
|
||||
{
|
||||
regex: /\*\*([^*]+)\*\*/g,
|
||||
groupCount: 1,
|
||||
render: (boldText: string, key: number) => (
|
||||
<strong key={key} style={{ fontWeight: 'bold' }}>{boldText}</strong>
|
||||
)
|
||||
},
|
||||
]
|
||||
|
||||
// Process italic separately after bold to avoid conflicts
|
||||
// Match single asterisks that aren't part of double asterisks
|
||||
// Use a simpler approach: match *text* where text doesn't contain *
|
||||
const italicPattern = /\*([^*\n]+?)\*/g
|
||||
|
||||
// Find all matches and sort by position
|
||||
const matches: Array<{ index: number; length: number; render: () => JSX.Element }> = []
|
||||
|
||||
patterns.forEach(({ regex, render, groupCount }) => {
|
||||
regex.lastIndex = 0
|
||||
let match
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const matchKey = keyCounter++
|
||||
// Store the match data to avoid closure issues
|
||||
const matchIndex = match.index
|
||||
const matchLength = match[0].length
|
||||
// Extract captured groups immediately and store them
|
||||
const matchGroups: string[] = []
|
||||
for (let i = 1; i <= groupCount; i++) {
|
||||
if (match[i] !== undefined) {
|
||||
matchGroups.push(match[i])
|
||||
}
|
||||
}
|
||||
|
||||
matches.push({
|
||||
index: matchIndex,
|
||||
length: matchLength,
|
||||
render: () => {
|
||||
// Call render with the stored groups and key
|
||||
// Safety check: ensure we have the required groups
|
||||
if (matchGroups.length < groupCount) {
|
||||
return <span key={matchKey}>{text.substring(matchIndex, matchIndex + matchLength)}</span>
|
||||
}
|
||||
if (groupCount === 1) {
|
||||
return render(matchGroups[0], matchKey)
|
||||
} else if (groupCount === 2) {
|
||||
return render(matchGroups[0], matchGroups[1], matchKey)
|
||||
} else {
|
||||
return render(...matchGroups, matchKey)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Process italic separately (after bold to avoid conflicts)
|
||||
// First, create a set of positions that are already covered by bold
|
||||
const boldPositions = new Set<number>()
|
||||
matches.forEach(m => {
|
||||
for (let pos = m.index; pos < m.index + m.length; pos++) {
|
||||
boldPositions.add(pos)
|
||||
}
|
||||
})
|
||||
|
||||
italicPattern.lastIndex = 0
|
||||
let italicMatch
|
||||
while ((italicMatch = italicPattern.exec(text)) !== null) {
|
||||
// Safety check: ensure we have a captured group
|
||||
if (!italicMatch[1]) continue
|
||||
|
||||
// Check if this italic match overlaps with any bold match
|
||||
let overlapsBold = false
|
||||
for (let pos = italicMatch.index; pos < italicMatch.index + italicMatch[0].length; pos++) {
|
||||
if (boldPositions.has(pos)) {
|
||||
overlapsBold = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!overlapsBold) {
|
||||
const matchKey = keyCounter++
|
||||
// Store the italic text to avoid closure issues
|
||||
const italicText = italicMatch[1]
|
||||
const italicIndex = italicMatch.index
|
||||
const italicLength = italicMatch[0].length
|
||||
matches.push({
|
||||
index: italicIndex,
|
||||
length: italicLength,
|
||||
render: () => (
|
||||
<em key={matchKey} style={{ fontStyle: 'italic' }}>{italicText}</em>
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort matches by position, and remove overlapping matches (keep the first one)
|
||||
matches.sort((a, b) => a.index - b.index)
|
||||
|
||||
// Remove overlapping matches - if two matches overlap, keep the one that starts first
|
||||
const nonOverlapping: typeof matches = []
|
||||
for (const match of matches) {
|
||||
const overlaps = nonOverlapping.some(existing => {
|
||||
const existingEnd = existing.index + existing.length
|
||||
const matchEnd = match.index + match.length
|
||||
return (match.index < existingEnd && matchEnd > existing.index)
|
||||
})
|
||||
if (!overlaps) {
|
||||
nonOverlapping.push(match)
|
||||
}
|
||||
}
|
||||
|
||||
// Build parts array
|
||||
nonOverlapping.forEach((match) => {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.substring(lastIndex, match.index))
|
||||
}
|
||||
parts.push(match.render())
|
||||
lastIndex = match.index + match.length
|
||||
})
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.substring(lastIndex))
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : [text]
|
||||
}
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
const ListTag = listType === 'ol' ? 'ol' : 'ul'
|
||||
elements.push(
|
||||
<ListTag key={`list-${i}`} style={{ margin: '8px 0', paddingLeft: '24px' }}>
|
||||
{listItems.map((item, idx) => (
|
||||
<li key={idx} style={{ margin: '4px 0' }}>
|
||||
{processInlineMarkdown(item)}
|
||||
</li>
|
||||
))}
|
||||
</ListTag>
|
||||
)
|
||||
listItems = []
|
||||
listType = null
|
||||
}
|
||||
}
|
||||
|
||||
const flushCodeBlock = () => {
|
||||
if (codeBlockLines.length > 0) {
|
||||
elements.push(
|
||||
<pre key={`codeblock-${i}`} style={{
|
||||
backgroundColor: '#f4f4f4',
|
||||
padding: '12px',
|
||||
borderRadius: '4px',
|
||||
overflow: 'auto',
|
||||
margin: '12px 0',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
<code>{codeBlockLines.join('\n')}</code>
|
||||
</pre>
|
||||
)
|
||||
codeBlockLines = []
|
||||
}
|
||||
}
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
const trimmed = line.trim()
|
||||
|
||||
// Code blocks
|
||||
if (trimmed.startsWith('```')) {
|
||||
if (inCodeBlock) {
|
||||
flushCodeBlock()
|
||||
inCodeBlock = false
|
||||
} else {
|
||||
flushList()
|
||||
inCodeBlock = true
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeBlockLines.push(line)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Headers
|
||||
if (trimmed.startsWith('# ')) {
|
||||
flushList()
|
||||
flushCodeBlock()
|
||||
elements.push(
|
||||
<h1 key={i} style={{ fontSize: '20px', fontWeight: 'bold', margin: '16px 0 8px 0' }}>
|
||||
{processInlineMarkdown(trimmed.substring(2))}
|
||||
</h1>
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (trimmed.startsWith('## ')) {
|
||||
flushList()
|
||||
flushCodeBlock()
|
||||
elements.push(
|
||||
<h2 key={i} style={{ fontSize: '18px', fontWeight: 'bold', margin: '12px 0 6px 0' }}>
|
||||
{processInlineMarkdown(trimmed.substring(3))}
|
||||
</h2>
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList()
|
||||
flushCodeBlock()
|
||||
elements.push(
|
||||
<h3 key={i} style={{ fontSize: '16px', fontWeight: 'bold', margin: '10px 0 4px 0' }}>
|
||||
{processInlineMarkdown(trimmed.substring(4))}
|
||||
</h3>
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (trimmed.startsWith('#### ')) {
|
||||
flushList()
|
||||
flushCodeBlock()
|
||||
elements.push(
|
||||
<h4 key={i} style={{ fontSize: '15px', fontWeight: 'bold', margin: '10px 0 4px 0' }}>
|
||||
{processInlineMarkdown(trimmed.substring(5))}
|
||||
</h4>
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (trimmed === '---' || trimmed === '***' || trimmed === '___') {
|
||||
flushList()
|
||||
flushCodeBlock()
|
||||
elements.push(
|
||||
<hr key={i} style={{ margin: '16px 0', border: 'none', borderTop: '1px solid #e0e0e0' }} />
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (trimmed.startsWith('> ')) {
|
||||
flushList()
|
||||
flushCodeBlock()
|
||||
elements.push(
|
||||
<blockquote key={i} style={{
|
||||
margin: '8px 0',
|
||||
paddingLeft: '16px',
|
||||
borderLeft: '3px solid #e0e0e0',
|
||||
color: '#666',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
{processInlineMarkdown(trimmed.substring(2))}
|
||||
</blockquote>
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
if (trimmed.match(/^[-*+]\s/)) {
|
||||
flushCodeBlock()
|
||||
if (listType !== 'ul') {
|
||||
flushList()
|
||||
listType = 'ul'
|
||||
}
|
||||
listItems.push(trimmed.substring(2))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
if (trimmed.match(/^\d+\.\s/)) {
|
||||
flushCodeBlock()
|
||||
if (listType !== 'ol') {
|
||||
flushList()
|
||||
listType = 'ol'
|
||||
}
|
||||
listItems.push(trimmed.replace(/^\d+\.\s/, ''))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (trimmed === '') {
|
||||
flushList()
|
||||
flushCodeBlock()
|
||||
elements.push(<br key={i} />)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
flushList()
|
||||
flushCodeBlock()
|
||||
const processed = processInlineMarkdown(trimmed)
|
||||
elements.push(
|
||||
<p key={i} style={{ margin: '8px 0' }}>
|
||||
{processed}
|
||||
</p>
|
||||
)
|
||||
i++
|
||||
}
|
||||
|
||||
// Flush any remaining lists or code blocks
|
||||
flushList()
|
||||
flushCodeBlock()
|
||||
|
||||
return elements
|
||||
}
|
||||
|
||||
return (
|
||||
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
|
||||
<StandardizedToolWrapper
|
||||
title={shape.props.title}
|
||||
primaryColor={shape.props.primaryColor}
|
||||
isSelected={isSelected}
|
||||
width={shape.props.w}
|
||||
height={shape.props.h}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
isPinnedToView={shape.props.pinnedToView}
|
||||
onPinToggle={handlePinToggle}
|
||||
tags={shape.props.tags}
|
||||
onTagsChange={(newTags) => {
|
||||
this.editor.updateShape<IFathomNoteShape>({
|
||||
id: shape.id,
|
||||
type: 'FathomNote',
|
||||
props: {
|
||||
...shape.props,
|
||||
tags: newTags,
|
||||
}
|
||||
})
|
||||
}}
|
||||
tagsEditable={true}
|
||||
>
|
||||
<div
|
||||
style={contentStyle}
|
||||
onPointerDown={(e) => {
|
||||
// Allow text selection - don't stop propagation for text selection
|
||||
// Only stop if clicking on interactive elements (links, etc.)
|
||||
const target = e.target as HTMLElement
|
||||
if (target.tagName === 'A' || target.closest('a')) {
|
||||
// Let links work normally
|
||||
return
|
||||
}
|
||||
// For text selection, allow the event to bubble but don't prevent default
|
||||
// This allows text selection while still allowing shape selection
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
// Allow text selection on mouse down
|
||||
// Don't prevent default to allow text selection
|
||||
const target = e.target as HTMLElement
|
||||
if (target.tagName === 'A' || target.closest('a')) {
|
||||
return
|
||||
}
|
||||
}}
|
||||
>
|
||||
{formatContent(shape.props.content)}
|
||||
</div>
|
||||
{/* Copy button at bottom right */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCopy()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '8px',
|
||||
right: '8px',
|
||||
backgroundColor: isCopied ? '#10b981' : 'rgba(0, 0, 0, 0.05)',
|
||||
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: '4px',
|
||||
padding: '6px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
transition: 'background-color 0.2s ease',
|
||||
zIndex: 10,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isCopied) {
|
||||
e.currentTarget.style.opacity = '1'
|
||||
e.currentTarget.style.backgroundColor = 'rgba(0, 0, 0, 0.1)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isCopied) {
|
||||
e.currentTarget.style.opacity = '0.8'
|
||||
e.currentTarget.style.backgroundColor = 'rgba(0, 0, 0, 0.05)'
|
||||
}
|
||||
}}
|
||||
title={isCopied ? 'Copied!' : 'Copy content to clipboard'}
|
||||
>
|
||||
{isCopied ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16 1H4C2.9 1 2 1.9 2 3V17H4V3H16V1ZM19 5H8C6.9 5 6 5.9 6 7V21C6 22.1 6.9 23 8 23H19C20.1 23 21 22.1 21 21V7C21 5.9 20.1 5 19 5ZM19 21H8V7H19V21Z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
indicator(shape: IFathomNoteShape) {
|
||||
return <rect width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Fathom note shape from data
|
||||
*/
|
||||
static createFromData(
|
||||
data: {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
tags: string[]
|
||||
primaryColor?: string
|
||||
},
|
||||
x: number = 0,
|
||||
y: number = 0
|
||||
): IFathomNoteShape {
|
||||
return {
|
||||
id: createShapeId(),
|
||||
type: 'FathomNote',
|
||||
x,
|
||||
y,
|
||||
rotation: 0,
|
||||
index: 'a1' as IndexKey,
|
||||
parentId: 'page:page' as TLParentId,
|
||||
isLocked: false,
|
||||
opacity: 1,
|
||||
meta: {},
|
||||
typeName: 'shape',
|
||||
props: {
|
||||
w: 500,
|
||||
h: 600,
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
tags: data.tags,
|
||||
noteId: data.id,
|
||||
pinnedToView: false,
|
||||
primaryColor: data.primaryColor || FathomNoteShape.PRIMARY_COLOR,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,369 +0,0 @@
|
|||
import {
|
||||
BaseBoxShapeUtil,
|
||||
HTMLContainer,
|
||||
TLBaseShape,
|
||||
} from "tldraw"
|
||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from "react"
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
|
||||
type IFathomTranscript = TLBaseShape<
|
||||
"FathomTranscript",
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
meetingId: string
|
||||
meetingTitle: string
|
||||
meetingUrl: string
|
||||
summary: string
|
||||
transcript: Array<{
|
||||
speaker: string
|
||||
text: string
|
||||
timestamp: string
|
||||
}>
|
||||
actionItems: Array<{
|
||||
text: string
|
||||
assignee?: string
|
||||
dueDate?: string
|
||||
}>
|
||||
isExpanded: boolean
|
||||
showTranscript: boolean
|
||||
showActionItems: boolean
|
||||
}
|
||||
>
|
||||
|
||||
export class FathomTranscriptShape extends BaseBoxShapeUtil<IFathomTranscript> {
|
||||
static override type = "FathomTranscript" as const
|
||||
|
||||
// Fathom Transcript theme color: Blue (same as FathomMeetings)
|
||||
static readonly PRIMARY_COLOR = "#3b82f6"
|
||||
|
||||
getDefaultProps(): IFathomTranscript["props"] {
|
||||
return {
|
||||
w: 600,
|
||||
h: 400,
|
||||
meetingId: "",
|
||||
meetingTitle: "",
|
||||
meetingUrl: "",
|
||||
summary: "",
|
||||
transcript: [],
|
||||
actionItems: [],
|
||||
isExpanded: false,
|
||||
showTranscript: true,
|
||||
showActionItems: true,
|
||||
}
|
||||
}
|
||||
|
||||
component(shape: IFathomTranscript) {
|
||||
const {
|
||||
w,
|
||||
h,
|
||||
meetingId,
|
||||
meetingTitle,
|
||||
meetingUrl,
|
||||
summary,
|
||||
transcript,
|
||||
actionItems,
|
||||
isExpanded,
|
||||
showTranscript,
|
||||
showActionItems
|
||||
} = shape.props
|
||||
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
this.editor.updateShape<IFathomTranscript>({
|
||||
id: shape.id,
|
||||
type: 'FathomTranscript',
|
||||
props: {
|
||||
...shape.props,
|
||||
isExpanded: !isExpanded
|
||||
}
|
||||
})
|
||||
}, [shape.id, shape.props, isExpanded])
|
||||
|
||||
const toggleTranscript = useCallback(() => {
|
||||
this.editor.updateShape<IFathomTranscript>({
|
||||
id: shape.id,
|
||||
type: 'FathomTranscript',
|
||||
props: {
|
||||
...shape.props,
|
||||
showTranscript: !showTranscript
|
||||
}
|
||||
})
|
||||
}, [shape.id, shape.props, showTranscript])
|
||||
|
||||
const toggleActionItems = useCallback(() => {
|
||||
this.editor.updateShape<IFathomTranscript>({
|
||||
id: shape.id,
|
||||
type: 'FathomTranscript',
|
||||
props: {
|
||||
...shape.props,
|
||||
showActionItems: !showActionItems
|
||||
}
|
||||
})
|
||||
}, [shape.id, shape.props, showActionItems])
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
// Convert timestamp to readable format
|
||||
const seconds = parseInt(timestamp)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
padding: '4px 8px',
|
||||
fontSize: '10px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'white',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
|
||||
// Custom header content with meeting info and toggle buttons
|
||||
const headerContent = (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span>🎥 Fathom Meeting</span>
|
||||
{meetingId && <span style={{ fontSize: '10px', color: '#666' }}>#{meetingId}</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
toggleTranscript()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
backgroundColor: showTranscript ? '#007bff' : '#6c757d',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
📝 Transcript
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
toggleActionItems()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
backgroundColor: showActionItems ? '#28a745' : '#6c757d',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
✅ Actions
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
toggleExpanded()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
backgroundColor: isExpanded ? '#ffc107' : '#6c757d',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{isExpanded ? '📄 Expanded' : '📄 Compact'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const handleMinimize = () => {
|
||||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
padding: '16px',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
color: 'black',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.4',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}
|
||||
|
||||
const transcriptEntryStyle: React.CSSProperties = {
|
||||
marginBottom: '8px',
|
||||
padding: '8px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '4px',
|
||||
borderLeft: '3px solid #007bff',
|
||||
}
|
||||
|
||||
const actionItemStyle: React.CSSProperties = {
|
||||
marginBottom: '6px',
|
||||
padding: '6px',
|
||||
backgroundColor: '#fff3cd',
|
||||
borderRadius: '4px',
|
||||
borderLeft: '3px solid #ffc107',
|
||||
}
|
||||
|
||||
return (
|
||||
<HTMLContainer style={{ width: w, height: h }}>
|
||||
<StandardizedToolWrapper
|
||||
title="Fathom Transcript"
|
||||
primaryColor={FathomTranscriptShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={w}
|
||||
height={h}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
headerContent={headerContent}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
>
|
||||
|
||||
<div style={contentStyle}>
|
||||
{/* Meeting Title */}
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<h3 style={{ margin: '0 0 4px 0', fontSize: '14px', fontWeight: 'bold' }}>
|
||||
{meetingTitle || 'Untitled Meeting'}
|
||||
</h3>
|
||||
{meetingUrl && (
|
||||
<a
|
||||
href={meetingUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: '#007bff',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
View in Fathom →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{summary && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<h4 style={{ margin: '0 0 6px 0', fontSize: '12px', fontWeight: 'bold', color: '#333' }}>
|
||||
📋 Summary
|
||||
</h4>
|
||||
<div style={{
|
||||
padding: '8px',
|
||||
backgroundColor: '#e7f3ff',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{summary}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Items */}
|
||||
{showActionItems && actionItems.length > 0 && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<h4 style={{ margin: '0 0 6px 0', fontSize: '12px', fontWeight: 'bold', color: '#333' }}>
|
||||
✅ Action Items ({actionItems.length})
|
||||
</h4>
|
||||
<div style={{ maxHeight: isExpanded ? 'none' : '120px', overflow: 'auto' }}>
|
||||
{actionItems.map((item, index) => (
|
||||
<div key={index} style={actionItemStyle}>
|
||||
<div style={{ fontSize: '11px', fontWeight: 'bold' }}>
|
||||
{item.text}
|
||||
</div>
|
||||
{item.assignee && (
|
||||
<div style={{ fontSize: '10px', color: '#666', marginTop: '2px' }}>
|
||||
👤 {item.assignee}
|
||||
</div>
|
||||
)}
|
||||
{item.dueDate && (
|
||||
<div style={{ fontSize: '10px', color: '#666' }}>
|
||||
📅 {item.dueDate}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transcript */}
|
||||
{showTranscript && transcript.length > 0 && (
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 6px 0', fontSize: '12px', fontWeight: 'bold', color: '#333' }}>
|
||||
💬 Transcript ({transcript.length} entries)
|
||||
</h4>
|
||||
<div style={{ maxHeight: isExpanded ? 'none' : '200px', overflow: 'auto' }}>
|
||||
{transcript.map((entry, index) => (
|
||||
<div key={index} style={transcriptEntryStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 'bold', color: '#007bff' }}>
|
||||
{entry.speaker}
|
||||
</span>
|
||||
<span style={{ fontSize: '10px', color: '#666' }}>
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', lineHeight: '1.4' }}>
|
||||
{entry.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!summary && transcript.length === 0 && actionItems.length === 0 && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
fontSize: '12px',
|
||||
padding: '20px',
|
||||
fontStyle: 'italic'
|
||||
}}>
|
||||
No meeting data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
indicator(shape: IFathomTranscript) {
|
||||
return <rect width={shape.props.w} height={shape.props.h} />
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -7,12 +7,15 @@ import React, { useState } from "react"
|
|||
import { HolonBrowser } from "../components/HolonBrowser"
|
||||
import { HolonData } from "../lib/HoloSphereService"
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
import { usePinnedToView } from "../hooks/usePinnedToView"
|
||||
|
||||
type IHolonBrowser = TLBaseShape<
|
||||
"HolonBrowser",
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
pinnedToView: boolean
|
||||
tags: string[]
|
||||
}
|
||||
>
|
||||
|
||||
|
|
@ -23,6 +26,8 @@ export class HolonBrowserShape extends BaseBoxShapeUtil<IHolonBrowser> {
|
|||
return {
|
||||
w: 800,
|
||||
h: 600,
|
||||
pinnedToView: false,
|
||||
tags: ['holon', 'browser'],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -35,6 +40,9 @@ export class HolonBrowserShape extends BaseBoxShapeUtil<IHolonBrowser> {
|
|||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
// Use the pinning hook to keep the shape fixed to viewport when pinned
|
||||
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
||||
|
||||
const handleSelectHolon = (holonData: HolonData) => {
|
||||
// Store current camera position to prevent it from changing
|
||||
const currentCamera = this.editor.getCamera()
|
||||
|
|
@ -92,18 +100,7 @@ export class HolonBrowserShape extends BaseBoxShapeUtil<IHolonBrowser> {
|
|||
this.editor.setCamera(currentCamera, { animation: { duration: 0 } })
|
||||
}
|
||||
|
||||
// Select the new shape
|
||||
setTimeout(() => {
|
||||
// Preserve camera position when selecting
|
||||
const cameraBeforeSelect = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
this.editor.setSelectedShapes([`shape:${holonShape.id}`] as any)
|
||||
// Restore camera if it changed during selection
|
||||
const cameraAfterSelect = this.editor.getCamera()
|
||||
if (cameraBeforeSelect.x !== cameraAfterSelect.x || cameraBeforeSelect.y !== cameraAfterSelect.y || cameraAfterSelect.z !== cameraAfterSelect.z) {
|
||||
this.editor.setCamera(cameraBeforeSelect, { animation: { duration: 0 } })
|
||||
}
|
||||
}, 100)
|
||||
// Don't select the new shape - let it be created without selection like other tools
|
||||
|
||||
// Close the browser shape
|
||||
setIsOpen(false)
|
||||
|
|
@ -115,16 +112,25 @@ export class HolonBrowserShape extends BaseBoxShapeUtil<IHolonBrowser> {
|
|||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false)
|
||||
// Delete the browser shape
|
||||
setTimeout(() => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
}, 100)
|
||||
// Delete the browser shape immediately so it's tracked in undo/redo history
|
||||
this.editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
const handlePinToggle = () => {
|
||||
this.editor.updateShape<IHolonBrowser>({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
pinnedToView: !shape.props.pinnedToView,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -142,6 +148,20 @@ export class HolonBrowserShape extends BaseBoxShapeUtil<IHolonBrowser> {
|
|||
isMinimized={isMinimized}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
isPinnedToView={shape.props.pinnedToView}
|
||||
onPinToggle={handlePinToggle}
|
||||
tags={shape.props.tags}
|
||||
onTagsChange={(newTags) => {
|
||||
this.editor.updateShape<IHolonBrowser>({
|
||||
id: shape.id,
|
||||
type: 'HolonBrowser',
|
||||
props: {
|
||||
...shape.props,
|
||||
tags: newTags,
|
||||
}
|
||||
})
|
||||
}}
|
||||
tagsEditable={true}
|
||||
>
|
||||
<HolonBrowser
|
||||
isOpen={isOpen}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import React, { useState, useRef, useEffect, useMemo, useCallback } from "react"
|
|||
import { holosphereService, HoloSphereService, HolonData, HolonLens, HolonConnection } from "@/lib/HoloSphereService"
|
||||
import * as h3 from 'h3-js'
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
import { usePinnedToView } from "../hooks/usePinnedToView"
|
||||
|
||||
type IHolon = TLBaseShape<
|
||||
"Holon",
|
||||
|
|
@ -27,6 +28,8 @@ type IHolon = TLBaseShape<
|
|||
data: Record<string, any>
|
||||
connections: HolonConnection[]
|
||||
lastUpdated: number
|
||||
pinnedToView: boolean
|
||||
tags: string[]
|
||||
}
|
||||
>
|
||||
|
||||
|
|
@ -43,11 +46,8 @@ const AutoResizeTextarea: React.FC<{
|
|||
}> = ({ value, onChange, onBlur, onKeyDown, style, placeholder, onPointerDown, onWheel }) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
}
|
||||
}, [value])
|
||||
// Removed auto-focus - textarea will only focus when user explicitly clicks on it
|
||||
// This prevents text boxes from being selected when shapes are created/recreated
|
||||
|
||||
return (
|
||||
<textarea
|
||||
|
|
@ -60,7 +60,6 @@ const AutoResizeTextarea: React.FC<{
|
|||
onWheel={onWheel}
|
||||
style={style}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -87,6 +86,8 @@ export class HolonShape extends BaseBoxShapeUtil<IHolon> {
|
|||
data: {},
|
||||
connections: [],
|
||||
lastUpdated: Date.now(),
|
||||
pinnedToView: false,
|
||||
tags: ['holon'],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -109,6 +110,9 @@ export class HolonShape extends BaseBoxShapeUtil<IHolon> {
|
|||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
const isMountedRef = useRef(true)
|
||||
|
||||
// Use the pinning hook to keep the shape fixed to viewport when pinned
|
||||
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
||||
|
||||
// Note: Auto-initialization is disabled. Users must manually enter Holon IDs.
|
||||
// This prevents the shape from auto-generating IDs based on coordinates.
|
||||
|
||||
|
|
@ -561,6 +565,17 @@ export class HolonShape extends BaseBoxShapeUtil<IHolon> {
|
|||
this.editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const handlePinToggle = () => {
|
||||
this.editor.updateShape<IHolon>({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
pinnedToView: !shape.props.pinnedToView,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
padding: '12px',
|
||||
flex: 1,
|
||||
|
|
@ -678,6 +693,20 @@ export class HolonShape extends BaseBoxShapeUtil<IHolon> {
|
|||
headerContent={headerContent}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
isPinnedToView={shape.props.pinnedToView}
|
||||
onPinToggle={handlePinToggle}
|
||||
tags={shape.props.tags}
|
||||
onTagsChange={(newTags) => {
|
||||
this.editor.updateShape<IHolon>({
|
||||
id: shape.id,
|
||||
type: 'Holon',
|
||||
props: {
|
||||
...shape.props,
|
||||
tags: newTags,
|
||||
}
|
||||
})
|
||||
}}
|
||||
tagsEditable={true}
|
||||
>
|
||||
|
||||
<div style={contentStyle}>
|
||||
|
|
@ -725,7 +754,6 @@ export class HolonShape extends BaseBoxShapeUtil<IHolon> {
|
|||
}
|
||||
}}
|
||||
placeholder="1002848305066"
|
||||
autoFocus
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '48px',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { QuartzSync, createQuartzNoteFromShape, QuartzSyncConfig } from '@/lib/q
|
|||
import { logGitHubSetupStatus } from '@/lib/githubSetupValidator'
|
||||
import { getClientConfig } from '@/lib/clientConfig'
|
||||
import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper'
|
||||
import { usePinnedToView } from '../hooks/usePinnedToView'
|
||||
|
||||
// Auto-resizing textarea component
|
||||
const AutoResizeTextarea: React.FC<{
|
||||
|
|
@ -66,6 +67,10 @@ const ObsNoteComponent: React.FC<{
|
|||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const [isCopying, setIsCopying] = useState(false)
|
||||
|
||||
// Use the pinning hook to keep the shape fixed to viewport when pinned
|
||||
usePinnedToView(shapeUtil.editor, shape.id, shape.props.pinnedToView)
|
||||
// Store the content at the start of editing to revert to on cancel
|
||||
const [contentAtEditStart, setContentAtEditStart] = useState<string | null>(null)
|
||||
// Notification state for in-shape notifications
|
||||
|
|
@ -348,6 +353,37 @@ const ObsNoteComponent: React.FC<{
|
|||
}
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (isCopying) return
|
||||
|
||||
if (!isSelected) {
|
||||
setNotification({ message: '⚠️ Please select this note to copy', type: 'error' })
|
||||
return
|
||||
}
|
||||
|
||||
setIsCopying(true)
|
||||
|
||||
try {
|
||||
const contentToCopy = shape.props.content || ''
|
||||
if (!contentToCopy.trim()) {
|
||||
setNotification({ message: '⚠️ No content to copy', type: 'error' })
|
||||
setIsCopying(false)
|
||||
return
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(contentToCopy)
|
||||
setNotification({ message: '✅ Content copied to clipboard', type: 'success' })
|
||||
} catch (error) {
|
||||
console.error('❌ Copy failed:', error)
|
||||
setNotification({
|
||||
message: `Copy failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
type: 'error'
|
||||
})
|
||||
} finally {
|
||||
setIsCopying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSync = async () => {
|
||||
if (isSyncing) return
|
||||
|
||||
|
|
@ -608,6 +644,17 @@ ${content}`
|
|||
shapeUtil.editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const handlePinToggle = () => {
|
||||
shapeUtil.editor.updateShape<IObsNoteShape>({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
pinnedToView: !shape.props.pinnedToView,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Custom header content with editable title and action buttons
|
||||
const headerContent = (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', gap: '8px' }}>
|
||||
|
|
@ -664,7 +711,7 @@ ${content}`
|
|||
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h }}>
|
||||
<StandardizedToolWrapper
|
||||
title="Obsidian Note"
|
||||
primaryColor={ObsNoteShape.PRIMARY_COLOR}
|
||||
primaryColor={shape.props.primaryColor || ObsNoteShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={shape.props.w}
|
||||
height={shape.props.h}
|
||||
|
|
@ -674,24 +721,22 @@ ${content}`
|
|||
headerContent={headerContent}
|
||||
editor={shapeUtil.editor}
|
||||
shapeId={shape.id}
|
||||
isPinnedToView={shape.props.pinnedToView}
|
||||
onPinToggle={handlePinToggle}
|
||||
tags={shape.props.tags}
|
||||
onTagsChange={(newTags) => {
|
||||
const sanitizedProps = ObsNoteShape.sanitizeProps({
|
||||
...shape.props,
|
||||
tags: newTags,
|
||||
})
|
||||
shapeUtil.editor.updateShape<IObsNoteShape>({
|
||||
id: shape.id,
|
||||
type: 'ObsNote',
|
||||
props: sanitizedProps
|
||||
})
|
||||
}}
|
||||
tagsEditable={true}
|
||||
>
|
||||
|
||||
{shape.props.tags.length > 0 && (
|
||||
<div style={{ padding: '0 12px', paddingBottom: '8px' }}>
|
||||
<div style={tagsStyle}>
|
||||
{shape.props.tags.slice(0, 3).map((tag, index) => (
|
||||
<span key={index} style={tagStyle}>
|
||||
{tag.replace('#', '')}
|
||||
</span>
|
||||
))}
|
||||
{shape.props.tags.length > 3 && (
|
||||
<span style={tagStyle}>
|
||||
+{shape.props.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -874,33 +919,34 @@ ${content}`
|
|||
gap: '8px',
|
||||
marginTop: 'auto',
|
||||
}}>
|
||||
{/* Restore button - always shown */}
|
||||
{/* Copy button - always clickable */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRefresh()
|
||||
handleCopy()
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
disabled={isRefreshing}
|
||||
disabled={isCopying}
|
||||
style={{
|
||||
...buttonStyle,
|
||||
fontSize: '11px',
|
||||
padding: '6px 12px',
|
||||
backgroundColor: isRefreshing ? '#ccc' : '#007acc',
|
||||
backgroundColor: isCopying ? '#6c757d' : isSelected ? '#004d85' : '#005a9e',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: isRefreshing ? 'not-allowed' : 'pointer',
|
||||
cursor: isCopying ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
pointerEvents: 'auto',
|
||||
opacity: isRefreshing ? 0.7 : 1,
|
||||
opacity: isCopying ? 0.7 : 1,
|
||||
transition: 'background-color 0.2s ease',
|
||||
}}
|
||||
title="restore from vault"
|
||||
title={!isSelected ? "Select this note to copy" : "Copy transcript content to clipboard"}
|
||||
>
|
||||
{isRefreshing ? '⏳ Restoring...' : '↩️ Restore'}
|
||||
{isCopying ? '⏳ Copying...' : '📋 Copy'}
|
||||
</button>
|
||||
|
||||
{/* Save changes button - shown when there are modifications or when editing with changes */}
|
||||
|
|
@ -969,6 +1015,8 @@ export type IObsNoteShape = TLBaseShape<
|
|||
vaultPath?: string
|
||||
vaultName?: string
|
||||
filePath?: string // Original file path from vault - used to maintain filename consistency
|
||||
pinnedToView: boolean
|
||||
primaryColor?: string // Optional custom primary color for the header
|
||||
}
|
||||
>
|
||||
|
||||
|
|
@ -1007,6 +1055,7 @@ export class ObsNoteShape extends BaseBoxShapeUtil<IObsNoteShape> {
|
|||
editingContent: typeof props.editingContent === 'string' ? props.editingContent : '',
|
||||
isModified: typeof props.isModified === 'boolean' ? props.isModified : false,
|
||||
originalContent: typeof props.originalContent === 'string' ? props.originalContent : '',
|
||||
pinnedToView: typeof props.pinnedToView === 'boolean' ? props.pinnedToView : false,
|
||||
}
|
||||
|
||||
// Only add optional properties if they're defined and are strings
|
||||
|
|
@ -1019,6 +1068,9 @@ export class ObsNoteShape extends BaseBoxShapeUtil<IObsNoteShape> {
|
|||
if (props.filePath !== undefined && typeof props.filePath === 'string') {
|
||||
sanitized.filePath = props.filePath
|
||||
}
|
||||
if (props.primaryColor !== undefined && typeof props.primaryColor === 'string') {
|
||||
sanitized.primaryColor = props.primaryColor
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,12 +11,15 @@ import { createShapeId } from "tldraw"
|
|||
import { findNonOverlappingPosition } from "@/utils/shapeCollisionUtils"
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
import { AuthContext } from "../context/AuthContext"
|
||||
import { usePinnedToView } from "../hooks/usePinnedToView"
|
||||
|
||||
type IObsidianBrowser = TLBaseShape<
|
||||
"ObsidianBrowser",
|
||||
{
|
||||
w: number
|
||||
h: number
|
||||
pinnedToView: boolean
|
||||
tags: string[]
|
||||
}
|
||||
>
|
||||
|
||||
|
|
@ -27,6 +30,8 @@ export class ObsidianBrowserShape extends BaseBoxShapeUtil<IObsidianBrowser> {
|
|||
return {
|
||||
w: 800,
|
||||
h: 600,
|
||||
pinnedToView: false,
|
||||
tags: ['obsidian', 'browser'],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -38,6 +43,9 @@ export class ObsidianBrowserShape extends BaseBoxShapeUtil<IObsidianBrowser> {
|
|||
const [isOpen, setIsOpen] = useState(true)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
// Use the pinning hook to keep the shape fixed to viewport when pinned
|
||||
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
||||
|
||||
// Wrapper component to access auth context
|
||||
const ObsidianBrowserContent: React.FC<{ vaultName?: string }> = ({ vaultName }) => {
|
||||
|
|
@ -271,16 +279,25 @@ export class ObsidianBrowserShape extends BaseBoxShapeUtil<IObsidianBrowser> {
|
|||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false)
|
||||
// Delete the browser shape after a short delay
|
||||
setTimeout(() => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
}, 100)
|
||||
// Delete the browser shape immediately so it's tracked in undo/redo history
|
||||
this.editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
const handlePinToggle = () => {
|
||||
this.editor.updateShape<IObsidianBrowser>({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
pinnedToView: !shape.props.pinnedToView,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Custom header content with vault information
|
||||
const headerContent = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', width: '100%' }}>
|
||||
|
|
@ -328,6 +345,20 @@ export class ObsidianBrowserShape extends BaseBoxShapeUtil<IObsidianBrowser> {
|
|||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
headerContent={headerContent}
|
||||
isPinnedToView={shape.props.pinnedToView}
|
||||
onPinToggle={handlePinToggle}
|
||||
tags={shape.props.tags}
|
||||
onTagsChange={(newTags) => {
|
||||
this.editor.updateShape<IObsidianBrowser>({
|
||||
id: shape.id,
|
||||
type: 'ObsidianBrowser',
|
||||
props: {
|
||||
...shape.props,
|
||||
tags: newTags,
|
||||
}
|
||||
})
|
||||
}}
|
||||
tagsEditable={true}
|
||||
>
|
||||
<ObsidianVaultBrowser
|
||||
key={`obsidian-browser-${shape.id}`}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { BaseBoxShapeUtil, TLBaseShape } from "tldraw"
|
||||
import { useCallback, useState } from "react"
|
||||
import * as React from "react"
|
||||
|
||||
export type ISharedPianoShape = TLBaseShape<
|
||||
"SharedPiano",
|
||||
|
|
@ -43,9 +44,63 @@ export class SharedPianoShape extends BaseBoxShapeUtil<ISharedPianoShape> {
|
|||
}
|
||||
|
||||
component(shape: ISharedPianoShape) {
|
||||
// Guard against undefined shape or props
|
||||
if (!shape || !shape.props) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Suppress Chrome Music Lab console errors
|
||||
React.useEffect(() => {
|
||||
const originalError = console.error
|
||||
const originalWarn = console.warn
|
||||
|
||||
// Filter out errors from Chrome Music Lab
|
||||
const errorHandler = (message: any, ...args: any[]) => {
|
||||
const messageStr = String(message)
|
||||
if (messageStr.includes('musiclab.chromeexperiments.com') ||
|
||||
messageStr.includes('Uncaught (in promise) false')) {
|
||||
// Suppress these errors silently
|
||||
return
|
||||
}
|
||||
originalError(message, ...args)
|
||||
}
|
||||
|
||||
const warnHandler = (message: any, ...args: any[]) => {
|
||||
const messageStr = String(message)
|
||||
if (messageStr.includes('musiclab.chromeexperiments.com')) {
|
||||
// Suppress these warnings silently
|
||||
return
|
||||
}
|
||||
originalWarn(message, ...args)
|
||||
}
|
||||
|
||||
// Override console methods
|
||||
console.error = errorHandler
|
||||
console.warn = warnHandler
|
||||
|
||||
// Also catch unhandled promise rejections from the iframe
|
||||
const unhandledRejectionHandler = (event: PromiseRejectionEvent) => {
|
||||
const reason = event.reason
|
||||
if (reason === false ||
|
||||
(typeof reason === 'string' && reason.includes('musiclab.chromeexperiments.com'))) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('unhandledrejection', unhandledRejectionHandler)
|
||||
|
||||
return () => {
|
||||
// Restore original console methods
|
||||
console.error = originalError
|
||||
console.warn = originalWarn
|
||||
window.removeEventListener('unhandledrejection', unhandledRejectionHandler)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
setIsLoading(false)
|
||||
setError(null)
|
||||
|
|
@ -58,6 +113,7 @@ export class SharedPianoShape extends BaseBoxShapeUtil<ISharedPianoShape> {
|
|||
|
||||
const handleToggleMinimize = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!shape.props) return
|
||||
this.editor.updateShape<ISharedPianoShape>({
|
||||
id: shape.id,
|
||||
type: "SharedPiano",
|
||||
|
|
@ -68,6 +124,8 @@ export class SharedPianoShape extends BaseBoxShapeUtil<ISharedPianoShape> {
|
|||
})
|
||||
}
|
||||
|
||||
const isMinimized = shape.props?.isMinimized ?? false
|
||||
|
||||
const controls = (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -92,7 +150,7 @@ export class SharedPianoShape extends BaseBoxShapeUtil<ISharedPianoShape> {
|
|||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{shape.props.isMinimized ? "🔽" : "🔼"}
|
||||
{isMinimized ? "🔽" : "🔼"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -115,7 +173,7 @@ export class SharedPianoShape extends BaseBoxShapeUtil<ISharedPianoShape> {
|
|||
>
|
||||
{controls}
|
||||
|
||||
{shape.props.isMinimized ? (
|
||||
{isMinimized ? (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import React, { useState, useRef, useEffect, useMemo, useCallback } from "react"
|
|||
import { useWhisperTranscription } from "../hooks/useWhisperTranscriptionSimple"
|
||||
import { useWebSpeechTranscription } from "../hooks/useWebSpeechTranscription"
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
import { usePinnedToView } from "../hooks/usePinnedToView"
|
||||
|
||||
type ITranscription = TLBaseShape<
|
||||
"Transcription",
|
||||
|
|
@ -19,6 +20,8 @@ type ITranscription = TLBaseShape<
|
|||
isTranscribing?: boolean
|
||||
isPaused?: boolean
|
||||
fixedHeight?: boolean // New property to control resizing
|
||||
pinnedToView: boolean
|
||||
tags: string[]
|
||||
}
|
||||
>
|
||||
|
||||
|
|
@ -77,6 +80,8 @@ export class TranscriptionShape extends BaseBoxShapeUtil<ITranscription> {
|
|||
isTranscribing: false,
|
||||
isPaused: false,
|
||||
fixedHeight: true, // Start with fixed height
|
||||
pinnedToView: false,
|
||||
tags: ['transcription'],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +99,9 @@ export class TranscriptionShape extends BaseBoxShapeUtil<ITranscription> {
|
|||
const isMountedRef = useRef(true)
|
||||
const stopRecordingRef = useRef<(() => void | Promise<void>) | null>(null)
|
||||
|
||||
// Use the pinning hook to keep the shape fixed to viewport when pinned
|
||||
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
||||
|
||||
// Local Whisper model is always available (no API key needed)
|
||||
const isLocalWhisperAvailable = true
|
||||
|
||||
|
|
@ -550,6 +558,17 @@ export class TranscriptionShape extends BaseBoxShapeUtil<ITranscription> {
|
|||
this.editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const handlePinToggle = () => {
|
||||
this.editor.updateShape<ITranscription>({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
pinnedToView: !shape.props.pinnedToView,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
padding: '12px',
|
||||
|
|
@ -666,6 +685,20 @@ export class TranscriptionShape extends BaseBoxShapeUtil<ITranscription> {
|
|||
headerContent={headerContent}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
isPinnedToView={shape.props.pinnedToView}
|
||||
onPinToggle={handlePinToggle}
|
||||
tags={shape.props.tags}
|
||||
onTagsChange={(newTags) => {
|
||||
this.editor.updateShape<ITranscription>({
|
||||
id: shape.id,
|
||||
type: 'Transcription',
|
||||
props: {
|
||||
...shape.props,
|
||||
tags: newTags,
|
||||
}
|
||||
})
|
||||
}}
|
||||
tagsEditable={true}
|
||||
>
|
||||
|
||||
<div style={contentStyle}>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer } from "tldraw"
|
|||
import { useEffect, useState } from "react"
|
||||
import { WORKER_URL } from "../constants/workerUrl"
|
||||
import { StandardizedToolWrapper } from "../components/StandardizedToolWrapper"
|
||||
import { usePinnedToView } from "../hooks/usePinnedToView"
|
||||
|
||||
interface DailyApiResponse {
|
||||
url: string;
|
||||
|
|
@ -23,6 +24,8 @@ export type IVideoChatShape = TLBaseShape<
|
|||
recordingId: string | null // Track active recording
|
||||
meetingToken: string | null
|
||||
isOwner: boolean
|
||||
pinnedToView: boolean
|
||||
tags: string[]
|
||||
}
|
||||
>
|
||||
|
||||
|
|
@ -40,13 +43,15 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
const props = {
|
||||
roomUrl: null,
|
||||
w: 800,
|
||||
h: 600,
|
||||
h: 560, // Reduced from 600 to account for header (40px) and avoid scrollbars
|
||||
allowCamera: false,
|
||||
allowMicrophone: false,
|
||||
enableRecording: true,
|
||||
recordingId: null,
|
||||
meetingToken: null,
|
||||
isOwner: false
|
||||
isOwner: false,
|
||||
pinnedToView: false,
|
||||
tags: ['video-chat']
|
||||
};
|
||||
console.log('🔧 getDefaultProps called, returning:', props);
|
||||
return props;
|
||||
|
|
@ -201,23 +206,62 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
console.log('🔧 Daily.co API response status:', response.status);
|
||||
console.log('🔧 Daily.co API response ok:', response.ok);
|
||||
|
||||
let url: string;
|
||||
let isNewRoom: boolean = false;
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
console.error('🔧 Daily.co API error:', error);
|
||||
throw new Error(`Failed to create room (${response.status}): ${JSON.stringify(error)}`)
|
||||
|
||||
// Check if the room already exists
|
||||
if (response.status === 400 && error.info && error.info.includes('already exists')) {
|
||||
console.log('🔧 Room already exists, connecting to existing room:', roomName);
|
||||
isNewRoom = false;
|
||||
|
||||
// Try to get the existing room info from Daily.co API
|
||||
try {
|
||||
const getRoomResponse = await fetch(`https://api.daily.co/v1/rooms/${roomName}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
}
|
||||
});
|
||||
|
||||
if (getRoomResponse.ok) {
|
||||
const roomData = await getRoomResponse.json();
|
||||
url = roomData.url;
|
||||
console.log('🔧 Retrieved existing room URL:', url);
|
||||
} else {
|
||||
// If we can't get room info, construct the URL
|
||||
// This is a fallback - ideally we'd get it from the API
|
||||
console.warn('🔧 Could not get room info, constructing URL (this may not work)');
|
||||
// We'll need to construct it, but we don't have the domain
|
||||
// For now, throw an error and let the user know
|
||||
throw new Error(`Room ${roomName} already exists but could not retrieve room URL. Please contact support.`);
|
||||
}
|
||||
} catch (getRoomError) {
|
||||
console.error('🔧 Error getting existing room:', getRoomError);
|
||||
throw new Error(`Room ${roomName} already exists but could not connect to it: ${(getRoomError as Error).message}`);
|
||||
}
|
||||
} else {
|
||||
// Some other error occurred
|
||||
throw new Error(`Failed to create room (${response.status}): ${JSON.stringify(error)}`)
|
||||
}
|
||||
} else {
|
||||
// Room was created successfully
|
||||
isNewRoom = true;
|
||||
const data = (await response.json()) as DailyApiResponse;
|
||||
console.log('🔧 Daily.co API response data:', data);
|
||||
url = data.url;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as DailyApiResponse;
|
||||
console.log('🔧 Daily.co API response data:', data);
|
||||
const url = data.url;
|
||||
|
||||
if (!url) {
|
||||
console.error('🔧 Room URL is missing from API response:', data);
|
||||
console.error('🔧 Room URL is missing');
|
||||
throw new Error("Room URL is missing")
|
||||
}
|
||||
|
||||
console.log('🔧 Room URL from API:', url);
|
||||
|
||||
|
||||
// Generate meeting token for the owner
|
||||
// First ensure the room exists, then generate token
|
||||
const meetingToken = await this.generateMeetingToken(roomName);
|
||||
|
|
@ -226,10 +270,15 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
localStorage.setItem(storageKey, url);
|
||||
localStorage.setItem(`${storageKey}_token`, meetingToken);
|
||||
|
||||
console.log("Room created successfully:", url)
|
||||
if (isNewRoom) {
|
||||
console.log("Room created successfully:", url)
|
||||
} else {
|
||||
console.log("Connected to existing room:", url)
|
||||
}
|
||||
console.log("Meeting token generated:", meetingToken)
|
||||
console.log("Updating shape with new URL and token")
|
||||
console.log("Setting isOwner to true")
|
||||
// Set isOwner to true only if we created the room, false if we connected to existing
|
||||
console.log("Setting isOwner to", isNewRoom)
|
||||
|
||||
await this.editor.updateShape<IVideoChatShape>({
|
||||
id: shape.id,
|
||||
|
|
@ -238,7 +287,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
...shape.props,
|
||||
roomUrl: url,
|
||||
meetingToken: meetingToken,
|
||||
isOwner: true,
|
||||
isOwner: isNewRoom, // Only owner if we created the room
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -411,6 +460,10 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
}
|
||||
}, [shape.props.allowCamera, shape.props.allowMicrophone])
|
||||
|
||||
// CRITICAL: Hooks must be called before any conditional returns
|
||||
// Use the pinning hook to keep the shape fixed to viewport when pinned
|
||||
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
||||
|
||||
if (error) {
|
||||
return <div>Error creating room: {error.message}</div>
|
||||
}
|
||||
|
|
@ -487,6 +540,17 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
const handlePinToggle = () => {
|
||||
this.editor.updateShape<IVideoChatShape>({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
...shape.props,
|
||||
pinnedToView: !shape.props.pinnedToView,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<HTMLContainer style={{ width: shape.props.w, height: shape.props.h + 40 }}>
|
||||
<StandardizedToolWrapper
|
||||
|
|
@ -494,12 +558,26 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
primaryColor={VideoChatShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={shape.props.w}
|
||||
height={shape.props.h + 40} // Include space for URL bubble
|
||||
height={shape.props.h + 40}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
editor={this.editor}
|
||||
shapeId={shape.id}
|
||||
isPinnedToView={shape.props.pinnedToView}
|
||||
onPinToggle={handlePinToggle}
|
||||
tags={shape.props.tags}
|
||||
onTagsChange={(newTags) => {
|
||||
this.editor.updateShape<IVideoChatShape>({
|
||||
id: shape.id,
|
||||
type: 'VideoChat',
|
||||
props: {
|
||||
...shape.props,
|
||||
tags: newTags,
|
||||
}
|
||||
})
|
||||
}}
|
||||
tagsEditable={true}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -509,6 +587,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
pointerEvents: "all",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
|
||||
|
|
@ -519,6 +598,7 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
flex: 1,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
minHeight: 0, // Allow flex item to shrink below content size
|
||||
}}
|
||||
>
|
||||
{!useFallback ? (
|
||||
|
|
@ -686,13 +766,13 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
|
||||
</div>
|
||||
|
||||
{/* URL Bubble - Below the video iframe */}
|
||||
{/* URL Bubble - Overlay on bottom of video */}
|
||||
<p
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: "8px",
|
||||
left: "8px",
|
||||
margin: "8px",
|
||||
margin: 0,
|
||||
padding: "4px 8px",
|
||||
background: "rgba(255, 255, 255, 0.9)",
|
||||
borderRadius: "4px",
|
||||
|
|
@ -701,7 +781,10 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
cursor: "text",
|
||||
userSelect: "text",
|
||||
zIndex: 1,
|
||||
top: `${shape.props.h + 50}px`, // Position it below the iframe with proper spacing
|
||||
maxWidth: "calc(100% - 16px)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
url: {currentRoomUrl}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export class FathomMeetingsIdle extends StateNode {
|
|||
|
||||
tooltipElement?: HTMLDivElement
|
||||
mouseMoveHandler?: (e: MouseEvent) => void
|
||||
isCreatingShape = false // Flag to prevent multiple shapes from being created
|
||||
|
||||
override onEnter = () => {
|
||||
// Set cursor to cross (looks like +)
|
||||
|
|
@ -84,6 +85,12 @@ export class FathomMeetingsIdle extends StateNode {
|
|||
override onPointerDown = (info?: any) => {
|
||||
console.log('📍 FathomMeetingsTool: onPointerDown called', { info, fullInfo: JSON.stringify(info) })
|
||||
|
||||
// Prevent multiple shapes from being created if user clicks multiple times
|
||||
if (this.isCreatingShape) {
|
||||
console.log('📍 FathomMeetingsTool: Shape creation already in progress, ignoring click')
|
||||
return
|
||||
}
|
||||
|
||||
// CRITICAL: Only proceed if we have a valid pointer event with a point AND button
|
||||
// This prevents shapes from being created when tool is selected (without a click)
|
||||
// A real click will have both a point and a button property
|
||||
|
|
@ -96,13 +103,28 @@ export class FathomMeetingsIdle extends StateNode {
|
|||
return
|
||||
}
|
||||
|
||||
// Additional check: ensure this is a primary button click (left mouse button = 0)
|
||||
// CRITICAL: Ensure this is a primary button click (left mouse button = 0)
|
||||
// This prevents accidental triggers from other pointer events
|
||||
if (info.button !== 0 && info.button !== undefined) {
|
||||
if (info.button !== 0) {
|
||||
console.log('📍 FathomMeetingsTool: Non-primary button click, ignoring', { button: info.button })
|
||||
return
|
||||
}
|
||||
|
||||
// CRITICAL: Additional validation - ensure this is a real click on the canvas
|
||||
// Check that the event target is the canvas or a canvas child, not a UI element
|
||||
if (info.target && typeof info.target === 'object') {
|
||||
const target = info.target as HTMLElement
|
||||
// If clicking on UI elements (toolbar, menus, etc), don't create shape
|
||||
if (target.closest('[data-tldraw-ui]') ||
|
||||
target.closest('.tlui-menu') ||
|
||||
target.closest('.tlui-toolbar') ||
|
||||
target.closest('[role="menu"]') ||
|
||||
target.closest('[role="toolbar"]')) {
|
||||
console.log('📍 FathomMeetingsTool: Click on UI element, ignoring')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get the click position in page coordinates
|
||||
// CRITICAL: Only use info.point - don't use fallback values that might be stale
|
||||
// This ensures we only create shapes on actual clicks, not when tool is selected
|
||||
|
|
@ -149,6 +171,21 @@ export class FathomMeetingsIdle extends StateNode {
|
|||
return
|
||||
}
|
||||
|
||||
// CRITICAL: Final validation - ensure this is a deliberate click, not a programmatic trigger
|
||||
// Check that we have valid, non-zero coordinates (0,0 is often a default/fallback value)
|
||||
if (clickX === 0 && clickY === 0) {
|
||||
console.warn('⚠️ FathomMeetingsTool: Click position is (0,0) - likely not a real click, ignoring')
|
||||
return
|
||||
}
|
||||
|
||||
// CRITICAL: Only create shape if tool is actually active (not just selected)
|
||||
// Double-check that we're in the idle state and tool is properly selected
|
||||
const currentTool = this.editor.getCurrentToolId()
|
||||
if (currentTool !== 'fathom-meetings') {
|
||||
console.warn('⚠️ FathomMeetingsTool: Tool not active, ignoring click', { currentTool })
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new FathomMeetingsBrowser shape at the click location
|
||||
this.createFathomMeetingsBrowserShape(clickX, clickY)
|
||||
}
|
||||
|
|
@ -161,6 +198,8 @@ export class FathomMeetingsIdle extends StateNode {
|
|||
|
||||
override onExit = () => {
|
||||
this.cleanupTooltip()
|
||||
// Reset flag when exiting the tool
|
||||
this.isCreatingShape = false
|
||||
}
|
||||
|
||||
private cleanupTooltip = () => {
|
||||
|
|
@ -178,6 +217,9 @@ export class FathomMeetingsIdle extends StateNode {
|
|||
}
|
||||
|
||||
private createFathomMeetingsBrowserShape(clickX: number, clickY: number) {
|
||||
// Set flag to prevent multiple shapes from being created
|
||||
this.isCreatingShape = true
|
||||
|
||||
try {
|
||||
console.log('📍 FathomMeetingsTool: createFathomMeetingsBrowserShape called', { clickX, clickY })
|
||||
|
||||
|
|
@ -224,22 +266,32 @@ export class FathomMeetingsIdle extends StateNode {
|
|||
this.editor.setCamera(currentCamera, { animation: { duration: 0 } })
|
||||
}
|
||||
|
||||
// Select the new shape and switch to select tool
|
||||
// Select the new shape and switch to select tool immediately
|
||||
// This ensures the tool switches right after shape creation
|
||||
// Ensure shape ID has the "shape:" prefix (required by TLDraw validation)
|
||||
const shapeId = browserShape.id.startsWith('shape:')
|
||||
? browserShape.id
|
||||
: `shape:${browserShape.id}`
|
||||
const cameraBeforeSelect = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
this.editor.setSelectedShapes([shapeId] as any)
|
||||
this.editor.setCurrentTool('select')
|
||||
|
||||
// Restore camera if it changed during selection
|
||||
const cameraAfterSelect = this.editor.getCamera()
|
||||
if (cameraBeforeSelect.x !== cameraAfterSelect.x || cameraBeforeSelect.y !== cameraAfterSelect.y || cameraBeforeSelect.z !== cameraAfterSelect.z) {
|
||||
this.editor.setCamera(cameraBeforeSelect, { animation: { duration: 0 } })
|
||||
}
|
||||
|
||||
// Reset flag after a short delay to allow the tool switch to complete
|
||||
setTimeout(() => {
|
||||
// Preserve camera position when selecting
|
||||
const cameraBeforeSelect = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
this.editor.setSelectedShapes([`shape:${browserShape.id}`] as any)
|
||||
this.editor.setCurrentTool('select')
|
||||
// Restore camera if it changed during selection
|
||||
const cameraAfterSelect = this.editor.getCamera()
|
||||
if (cameraBeforeSelect.x !== cameraAfterSelect.x || cameraBeforeSelect.y !== cameraAfterSelect.y || cameraBeforeSelect.z !== cameraAfterSelect.z) {
|
||||
this.editor.setCamera(cameraBeforeSelect, { animation: { duration: 0 } })
|
||||
}
|
||||
}, 100)
|
||||
this.isCreatingShape = false
|
||||
}, 200)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating FathomMeetingsBrowser shape:', error)
|
||||
// Reset flag on error
|
||||
this.isCreatingShape = false
|
||||
throw error // Re-throw to see the full error
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
import { StateNode } from "tldraw"
|
||||
import { findNonOverlappingPosition } from "@/utils/shapeCollisionUtils"
|
||||
|
||||
export class FathomTranscriptTool extends StateNode {
|
||||
static override id = "fathom-transcript"
|
||||
static override initial = "idle"
|
||||
|
||||
onSelect() {
|
||||
// Create a new Fathom transcript shape
|
||||
this.createFathomTranscriptShape()
|
||||
}
|
||||
|
||||
onPointerDown() {
|
||||
// Create a new Fathom transcript shape at the click location
|
||||
this.createFathomTranscriptShape()
|
||||
}
|
||||
|
||||
private createFathomTranscriptShape() {
|
||||
try {
|
||||
// Get the current viewport center
|
||||
const viewport = this.editor.getViewportPageBounds()
|
||||
const centerX = viewport.x + viewport.w / 2
|
||||
const centerY = viewport.y + viewport.h / 2
|
||||
|
||||
// Base position (centered on viewport)
|
||||
const baseX = centerX - 300 // Center the 600px wide shape
|
||||
const baseY = centerY - 200 // Center the 400px tall shape
|
||||
|
||||
// Find a non-overlapping position
|
||||
const shapeWidth = 600
|
||||
const shapeHeight = 400
|
||||
const position = findNonOverlappingPosition(
|
||||
this.editor,
|
||||
baseX,
|
||||
baseY,
|
||||
shapeWidth,
|
||||
shapeHeight
|
||||
)
|
||||
|
||||
const fathomShape = this.editor.createShape({
|
||||
type: 'FathomTranscript',
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
props: {
|
||||
w: 600,
|
||||
h: 400,
|
||||
meetingId: '',
|
||||
meetingTitle: 'New Fathom Meeting',
|
||||
meetingUrl: '',
|
||||
summary: '',
|
||||
transcript: [],
|
||||
actionItems: [],
|
||||
isExpanded: false,
|
||||
showTranscript: true,
|
||||
showActionItems: true,
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ Created Fathom transcript shape:', fathomShape.id)
|
||||
|
||||
// Select the new shape and switch to select tool
|
||||
this.editor.setSelectedShapes([`shape:${fathomShape.id}`] as any)
|
||||
this.editor.setCurrentTool('select')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating Fathom transcript shape:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { StateNode } from "tldraw"
|
||||
import { HolonShape } from "@/shapes/HolonShapeUtil"
|
||||
import { holosphereService } from "@/lib/HoloSphereService"
|
||||
import { findNonOverlappingPosition } from "@/utils/shapeCollisionUtils"
|
||||
|
||||
export class HolonTool extends StateNode {
|
||||
static override id = "holon"
|
||||
|
|
@ -138,9 +137,16 @@ export class HolonIdle extends StateNode {
|
|||
this.mouseMoveHandler = undefined
|
||||
}
|
||||
|
||||
// Remove tooltip element
|
||||
// Remove tooltip element (safely check if it exists in DOM)
|
||||
if (this.tooltipElement) {
|
||||
document.body.removeChild(this.tooltipElement)
|
||||
try {
|
||||
if (this.tooltipElement.parentNode) {
|
||||
document.body.removeChild(this.tooltipElement)
|
||||
}
|
||||
} catch (e) {
|
||||
// Element might already be removed, ignore error
|
||||
console.log('Tooltip element already removed')
|
||||
}
|
||||
this.tooltipElement = undefined
|
||||
}
|
||||
}
|
||||
|
|
@ -193,17 +199,10 @@ export class HolonIdle extends StateNode {
|
|||
shapeSize: { w: shapeWidth, h: shapeHeight }
|
||||
})
|
||||
} else {
|
||||
// For fallback (no click), use collision detection
|
||||
const position = findNonOverlappingPosition(
|
||||
this.editor,
|
||||
baseX,
|
||||
baseY,
|
||||
shapeWidth,
|
||||
shapeHeight
|
||||
)
|
||||
finalX = position.x
|
||||
finalY = position.y
|
||||
console.log('📍 No click position - using collision detection:', { finalX, finalY })
|
||||
// For fallback (no click), use base position directly
|
||||
finalX = baseX
|
||||
finalY = baseY
|
||||
console.log('📍 No click position - using base position:', { finalX, finalY })
|
||||
}
|
||||
|
||||
// Default coordinates (can be changed by user)
|
||||
|
|
@ -243,19 +242,11 @@ export class HolonIdle extends StateNode {
|
|||
this.editor.setCamera(currentCamera, { animation: { duration: 0 } })
|
||||
}
|
||||
|
||||
// Select the new shape
|
||||
setTimeout(() => {
|
||||
// Preserve camera position when selecting
|
||||
const cameraBeforeSelect = this.editor.getCamera()
|
||||
this.editor.stopCameraAnimation()
|
||||
this.editor.setSelectedShapes([`shape:${holonShape.id}`] as any)
|
||||
this.editor.setCurrentTool('select')
|
||||
// Restore camera if it changed during selection
|
||||
const cameraAfterSelect = this.editor.getCamera()
|
||||
if (cameraBeforeSelect.x !== cameraAfterSelect.x || cameraBeforeSelect.y !== cameraAfterSelect.y || cameraBeforeSelect.z !== cameraAfterSelect.z) {
|
||||
this.editor.setCamera(cameraBeforeSelect, { animation: { duration: 0 } })
|
||||
}
|
||||
}, 100)
|
||||
// Don't select the new shape - let it be created without selection like other tools
|
||||
// Clean up tooltip before switching tools
|
||||
this.cleanupTooltip()
|
||||
// Switch back to selector tool after creating the shape
|
||||
this.editor.setCurrentTool('select')
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating Holon shape:', error)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { StateNode } from "tldraw"
|
||||
import { ObsNoteShape } from "@/shapes/ObsNoteShapeUtil"
|
||||
import { findNonOverlappingPosition } from "@/utils/shapeCollisionUtils"
|
||||
|
||||
export class ObsNoteTool extends StateNode {
|
||||
static override id = "obs_note"
|
||||
|
|
|
|||
|
|
@ -234,7 +234,6 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
|||
<TldrawUiMenuItem {...tools.Markdown} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.MycrozineTemplate} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.Prompt} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.SharedPiano} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.ObsidianNote} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.Transcription} disabled={hasSelection} />
|
||||
<TldrawUiMenuItem {...tools.FathomMeetings} disabled={hasSelection} />
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export function CustomMainMenu() {
|
|||
const validateAndNormalizeShapeType = (shape: any): string => {
|
||||
if (!shape || !shape.type) return 'text'
|
||||
|
||||
const validCustomShapes = ['ObsNote', 'VideoChat', 'Transcription', 'SharedPiano', 'Prompt', 'ChatBox', 'Embed', 'Markdown', 'MycrozineTemplate', 'Slide', 'FathomTranscript', 'Holon', 'ObsidianBrowser', 'HolonBrowser', 'FathomMeetingsBrowser', 'LocationShare']
|
||||
const validCustomShapes = ['ObsNote', 'VideoChat', 'Transcription', 'Prompt', 'ChatBox', 'Embed', 'Markdown', 'MycrozineTemplate', 'Slide', 'Holon', 'ObsidianBrowser', 'HolonBrowser', 'FathomMeetingsBrowser', 'LocationShare']
|
||||
const validDefaultShapes = ['arrow', 'bookmark', 'draw', 'embed', 'frame', 'geo', 'group', 'highlight', 'image', 'line', 'note', 'text', 'video']
|
||||
const allValidShapes = [...validCustomShapes, ...validDefaultShapes]
|
||||
|
||||
|
|
@ -726,107 +726,6 @@ export function CustomMainMenu() {
|
|||
editor.setCamera({ x: centerX, y: centerY, z: zoom })
|
||||
};
|
||||
|
||||
const testIncompleteData = (editor: Editor) => {
|
||||
// Test function to demonstrate fixing incomplete shape data
|
||||
const testData = {
|
||||
documents: [
|
||||
{ id: "document:document", typeName: "document", type: undefined },
|
||||
{ id: "page:dt0NcJ3xCkZPVsyvmA6_5", typeName: "page", type: undefined },
|
||||
{ id: "shape:IhBti_jyuXFfGeoEhTzst", type: "geo", typeName: "shape" },
|
||||
{ id: "shape:dif5y2vQfGRZMlWRC1GWv", type: "VideoChat", typeName: "shape" },
|
||||
{ id: "shape:n15Zcn2dC1K82I8NVueiH", type: "geo", typeName: "shape" }
|
||||
]
|
||||
};
|
||||
|
||||
console.log('Testing incomplete data fix:', testData);
|
||||
|
||||
// Simulate the import process
|
||||
const pageId = testData.documents.find((doc: any) => doc.typeName === 'page')?.id || 'page:default';
|
||||
const shapes = testData.documents
|
||||
.filter((doc: any) => doc.typeName === 'shape')
|
||||
.map((doc: any) => {
|
||||
const fixedShape = { ...doc };
|
||||
|
||||
// Add missing required properties
|
||||
if (!fixedShape.x) fixedShape.x = Math.random() * 400 + 50;
|
||||
if (!fixedShape.y) fixedShape.y = Math.random() * 300 + 50;
|
||||
if (!fixedShape.rotation) fixedShape.rotation = 0;
|
||||
if (!fixedShape.isLocked) fixedShape.isLocked = false;
|
||||
if (!fixedShape.opacity) fixedShape.opacity = 1;
|
||||
if (!fixedShape.meta) fixedShape.meta = {};
|
||||
if (!fixedShape.parentId) fixedShape.parentId = pageId;
|
||||
|
||||
// CRITICAL: For geo shapes, w/h/geo MUST be in props, NOT at top level
|
||||
if (fixedShape.type === 'geo') {
|
||||
// Store w/h/geo values if they exist at top level
|
||||
const wValue = fixedShape.w !== undefined ? fixedShape.w : 100
|
||||
const hValue = fixedShape.h !== undefined ? fixedShape.h : 100
|
||||
const geoValue = fixedShape.geo !== undefined ? fixedShape.geo : 'rectangle'
|
||||
|
||||
// Remove w/h/geo from top level (TLDraw validation requires they be in props only)
|
||||
delete fixedShape.w
|
||||
delete fixedShape.h
|
||||
delete fixedShape.geo
|
||||
|
||||
// Ensure props exists and has the correct values
|
||||
if (!fixedShape.props) fixedShape.props = {}
|
||||
if (fixedShape.props.w === undefined) fixedShape.props.w = wValue
|
||||
if (fixedShape.props.h === undefined) fixedShape.props.h = hValue
|
||||
if (fixedShape.props.geo === undefined) fixedShape.props.geo = geoValue
|
||||
|
||||
// Set default props if missing
|
||||
if (!fixedShape.props.color) fixedShape.props.color = 'black'
|
||||
if (!fixedShape.props.fill) fixedShape.props.fill = 'none'
|
||||
if (!fixedShape.props.dash) fixedShape.props.dash = 'draw'
|
||||
if (!fixedShape.props.size) fixedShape.props.size = 'm'
|
||||
if (!fixedShape.props.font) fixedShape.props.font = 'draw'
|
||||
if (!fixedShape.props.align) fixedShape.props.align = 'middle'
|
||||
if (!fixedShape.props.verticalAlign) fixedShape.props.verticalAlign = 'middle'
|
||||
if (fixedShape.props.growY === undefined) fixedShape.props.growY = 0
|
||||
if (!fixedShape.props.url) fixedShape.props.url = ''
|
||||
if (fixedShape.props.scale === undefined) fixedShape.props.scale = 1
|
||||
if (!fixedShape.props.labelColor) fixedShape.props.labelColor = 'black'
|
||||
if (!fixedShape.props.richText) fixedShape.props.richText = [] as any
|
||||
} else if (fixedShape.type === 'VideoChat') {
|
||||
// VideoChat shapes also need w/h in props, not top level
|
||||
const wValue = fixedShape.w !== undefined ? fixedShape.w : 200
|
||||
const hValue = fixedShape.h !== undefined ? fixedShape.h : 150
|
||||
|
||||
delete fixedShape.w
|
||||
delete fixedShape.h
|
||||
|
||||
if (!fixedShape.props) fixedShape.props = {}
|
||||
if (fixedShape.props.w === undefined) fixedShape.props.w = wValue
|
||||
if (fixedShape.props.h === undefined) fixedShape.props.h = hValue
|
||||
if (!fixedShape.props.color) fixedShape.props.color = 'black'
|
||||
if (!fixedShape.props.fill) fixedShape.props.fill = 'none'
|
||||
if (!fixedShape.props.dash) fixedShape.props.dash = 'draw'
|
||||
if (!fixedShape.props.size) fixedShape.props.size = 'm'
|
||||
if (!fixedShape.props.font) fixedShape.props.font = 'draw'
|
||||
}
|
||||
|
||||
return fixedShape;
|
||||
});
|
||||
|
||||
console.log('Fixed shapes:', shapes);
|
||||
|
||||
// Import the fixed data
|
||||
const contentToImport: TLContent = {
|
||||
rootShapeIds: shapes.map((shape: any) => shape.id).filter(Boolean),
|
||||
schema: { schemaVersion: 1, storeVersion: 4, recordVersions: {} },
|
||||
shapes: shapes,
|
||||
bindings: [],
|
||||
assets: [],
|
||||
};
|
||||
|
||||
try {
|
||||
editor.putContentOntoCurrentPage(contentToImport, { select: true });
|
||||
console.log('Successfully imported test data!');
|
||||
} catch (error) {
|
||||
console.error('Failed to import test data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DefaultMainMenu>
|
||||
<DefaultMainMenuContent />
|
||||
|
|
@ -844,13 +743,6 @@ export function CustomMainMenu() {
|
|||
readonlyOk
|
||||
onSelect={() => importJSON(editor)}
|
||||
/>
|
||||
<TldrawUiMenuItem
|
||||
id="test-incomplete"
|
||||
label="Test Incomplete Data Fix"
|
||||
icon="external-link"
|
||||
readonlyOk
|
||||
onSelect={() => testIncompleteData(editor)}
|
||||
/>
|
||||
<TldrawUiMenuItem
|
||||
id="fit-to-content"
|
||||
label="Fit to Content"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type { ObsidianObsNote } from "../lib/obsidianImporter"
|
|||
import { HolonData } from "../lib/HoloSphereService"
|
||||
import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
|
||||
import { LocationShareDialog } from "../components/location/LocationShareDialog"
|
||||
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
||||
|
||||
export function CustomToolbar() {
|
||||
const editor = useEditor()
|
||||
|
|
@ -30,6 +31,9 @@ export function CustomToolbar() {
|
|||
const [showHolonBrowser, setShowHolonBrowser] = useState(false)
|
||||
const [vaultBrowserMode, setVaultBrowserMode] = useState<'keyboard' | 'button'>('keyboard')
|
||||
const [showFathomPanel, setShowFathomPanel] = useState(false)
|
||||
const [showFathomApiKeyInput, setShowFathomApiKeyInput] = useState(false)
|
||||
const [fathomApiKeyInput, setFathomApiKeyInput] = useState('')
|
||||
const [hasFathomApiKey, setHasFathomApiKey] = useState(false)
|
||||
const profilePopupRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -327,18 +331,7 @@ export function CustomToolbar() {
|
|||
editor.setCamera(currentCamera, { animation: { duration: 0 } })
|
||||
}
|
||||
|
||||
// Select the new shape
|
||||
setTimeout(() => {
|
||||
// Preserve camera position when selecting
|
||||
const cameraBeforeSelect = editor.getCamera()
|
||||
editor.stopCameraAnimation()
|
||||
editor.setSelectedShapes([`shape:${holonShape.id}`] as any)
|
||||
// Restore camera if it changed during selection
|
||||
const cameraAfterSelect = editor.getCamera()
|
||||
if (cameraBeforeSelect.x !== cameraAfterSelect.x || cameraBeforeSelect.y !== cameraAfterSelect.y || cameraAfterSelect.z !== cameraAfterSelect.z) {
|
||||
editor.setCamera(cameraBeforeSelect, { animation: { duration: 0 } })
|
||||
}
|
||||
}, 100)
|
||||
// Don't select the new shape - let it be created without selection like other tools
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating Holon shape from data:', error)
|
||||
|
|
@ -412,6 +405,16 @@ export function CustomToolbar() {
|
|||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Check Fathom API key status
|
||||
useEffect(() => {
|
||||
if (session.authed && session.username) {
|
||||
const hasKey = isFathomApiKeyConfigured(session.username)
|
||||
setHasFathomApiKey(hasKey)
|
||||
} else {
|
||||
setHasFathomApiKey(false)
|
||||
}
|
||||
}, [session.authed, session.username])
|
||||
|
||||
const handleLogout = () => {
|
||||
// Clear the session
|
||||
clearSession()
|
||||
|
|
@ -771,6 +774,158 @@ export function CustomToolbar() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Fathom API Key Settings */}
|
||||
<div style={{
|
||||
marginBottom: "16px",
|
||||
padding: "12px",
|
||||
backgroundColor: hasFathomApiKey ? "#f0f9ff" : "#fef2f2",
|
||||
borderRadius: "4px",
|
||||
border: `1px solid ${hasFathomApiKey ? "#0ea5e9" : "#f87171"}`
|
||||
}}>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "8px"
|
||||
}}>
|
||||
<span style={{ fontWeight: "500" }}>Fathom API</span>
|
||||
<span style={{ fontSize: "14px" }}>
|
||||
{hasFathomApiKey ? "✅ Connected" : "❌ Not connected"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showFathomApiKeyInput ? (
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
value={fathomApiKeyInput}
|
||||
onChange={(e) => setFathomApiKeyInput(e.target.value)}
|
||||
placeholder="Enter Fathom API key..."
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "6px 8px",
|
||||
marginBottom: "8px",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (fathomApiKeyInput.trim()) {
|
||||
saveFathomApiKey(fathomApiKeyInput.trim(), session.username)
|
||||
setHasFathomApiKey(true)
|
||||
setShowFathomApiKeyInput(false)
|
||||
setFathomApiKeyInput('')
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowFathomApiKeyInput(false)
|
||||
setFathomApiKeyInput('')
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: "flex", gap: "4px" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (fathomApiKeyInput.trim()) {
|
||||
saveFathomApiKey(fathomApiKeyInput.trim(), session.username)
|
||||
setHasFathomApiKey(true)
|
||||
setShowFathomApiKeyInput(false)
|
||||
setFathomApiKeyInput('')
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "4px 8px",
|
||||
backgroundColor: "#0ea5e9",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "11px",
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFathomApiKeyInput(false)
|
||||
setFathomApiKeyInput('')
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "4px 8px",
|
||||
backgroundColor: "#6b7280",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "11px",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p style={{
|
||||
fontSize: "12px",
|
||||
color: "#666",
|
||||
margin: "0 0 8px 0"
|
||||
}}>
|
||||
{hasFathomApiKey
|
||||
? "Your Fathom account is connected"
|
||||
: "Connect your Fathom account to import meetings"}
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: "4px" }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFathomApiKeyInput(true)
|
||||
const currentKey = getFathomApiKey(session.username)
|
||||
if (currentKey) {
|
||||
setFathomApiKeyInput(currentKey)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 12px",
|
||||
backgroundColor: hasFathomApiKey ? "#0ea5e9" : "#ef4444",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
{hasFathomApiKey ? "Change Key" : "Add API Key"}
|
||||
</button>
|
||||
{hasFathomApiKey && (
|
||||
<button
|
||||
onClick={() => {
|
||||
removeFathomApiKey(session.username)
|
||||
setHasFathomApiKey(false)
|
||||
}}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
backgroundColor: "#6b7280",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
fontWeight: "500",
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/dashboard"
|
||||
target="_blank"
|
||||
|
|
@ -900,14 +1055,6 @@ export function CustomToolbar() {
|
|||
isSelected={tools["Prompt"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["SharedPiano"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["SharedPiano"]}
|
||||
icon="music"
|
||||
label="Shared Piano"
|
||||
isSelected={tools["SharedPiano"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["ObsidianNote"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["ObsidianNote"]}
|
||||
|
|
@ -924,14 +1071,6 @@ export function CustomToolbar() {
|
|||
isSelected={tools["Transcription"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["FathomTranscript"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["FathomTranscript"]}
|
||||
icon="video"
|
||||
label="Fathom Transcript"
|
||||
isSelected={tools["FathomTranscript"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["Holon"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["Holon"]}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
TLComponents,
|
||||
TldrawUiMenuItem,
|
||||
useTools,
|
||||
useActions,
|
||||
} from "tldraw"
|
||||
import { SlidesPanel } from "@/slides/SlidesPanel"
|
||||
|
||||
|
|
@ -17,9 +18,50 @@ export const components: TLComponents = {
|
|||
HelperButtons: SlidesPanel,
|
||||
KeyboardShortcutsDialog: (props: any) => {
|
||||
const tools = useTools()
|
||||
const actions = useActions()
|
||||
|
||||
// Get all custom tools with keyboard shortcuts
|
||||
const customTools = [
|
||||
tools["VideoChat"],
|
||||
tools["ChatBox"],
|
||||
tools["Embed"],
|
||||
tools["Slide"],
|
||||
tools["Markdown"],
|
||||
tools["MycrozineTemplate"],
|
||||
tools["Prompt"],
|
||||
tools["ObsidianNote"],
|
||||
tools["Transcription"],
|
||||
tools["Holon"],
|
||||
tools["FathomMeetings"],
|
||||
].filter(tool => tool && tool.kbd)
|
||||
|
||||
// Get all custom actions with keyboard shortcuts
|
||||
const customActions = [
|
||||
actions["zoom-in"],
|
||||
actions["zoom-out"],
|
||||
actions["zoom-to-selection"],
|
||||
actions["copy-link-to-current-view"],
|
||||
actions["revert-camera"],
|
||||
actions["lock-element"],
|
||||
actions["save-to-pdf"],
|
||||
actions["search-shapes"],
|
||||
actions["llm"],
|
||||
actions["open-obsidian-browser"],
|
||||
].filter(action => action && action.kbd)
|
||||
|
||||
return (
|
||||
<DefaultKeyboardShortcutsDialog {...props}>
|
||||
<TldrawUiMenuItem {...tools["Slide"]} />
|
||||
{/* Custom Tools */}
|
||||
{customTools.map(tool => (
|
||||
<TldrawUiMenuItem key={tool.id} {...tool} />
|
||||
))}
|
||||
|
||||
{/* Custom Actions */}
|
||||
{customActions.map(action => (
|
||||
<TldrawUiMenuItem key={action.id} {...action} />
|
||||
))}
|
||||
|
||||
{/* Default content (includes standard TLDraw shortcuts) */}
|
||||
<DefaultKeyboardShortcutsDialogContent />
|
||||
</DefaultKeyboardShortcutsDialog>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -151,15 +151,6 @@ export const overrides: TLUiOverrides = {
|
|||
readonlyOk: true,
|
||||
onSelect: () => editor.setCurrentTool("Prompt"),
|
||||
},
|
||||
SharedPiano: {
|
||||
id: "SharedPiano",
|
||||
icon: "music",
|
||||
label: "Shared Piano",
|
||||
type: "SharedPiano",
|
||||
kbd: "alt+p",
|
||||
readonlyOk: true,
|
||||
onSelect: () => editor.setCurrentTool("SharedPiano"),
|
||||
},
|
||||
gesture: {
|
||||
id: "gesture",
|
||||
icon: "draw",
|
||||
|
|
@ -186,19 +177,6 @@ export const overrides: TLUiOverrides = {
|
|||
type: "Transcription",
|
||||
onSelect: () => editor.setCurrentTool("transcription"),
|
||||
},
|
||||
FathomTranscript: {
|
||||
id: "fathom-transcript",
|
||||
icon: "file-text",
|
||||
label: "Fathom Transcript",
|
||||
kbd: "alt+f",
|
||||
readonlyOk: true,
|
||||
type: "FathomTranscript",
|
||||
onSelect: () => {
|
||||
// Dispatch custom event to open Fathom meetings panel
|
||||
const event = new CustomEvent('open-fathom-meetings')
|
||||
window.dispatchEvent(event)
|
||||
},
|
||||
},
|
||||
Holon: {
|
||||
id: "holon",
|
||||
icon: "circle",
|
||||
|
|
|
|||
|
|
@ -31,9 +31,9 @@ function getShapeBounds(editor: Editor, shape: TLShape | string): Box | null {
|
|||
export function resolveOverlaps(editor: Editor, shapeId: string): void {
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
const customShapeTypes = [
|
||||
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat', 'FathomTranscript',
|
||||
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat',
|
||||
'Transcription', 'Holon', 'LocationShare', 'FathomMeetingsBrowser', 'Prompt',
|
||||
'Embed', 'Slide', 'Markdown', 'SharedPiano', 'MycrozineTemplate', 'ChatBox'
|
||||
'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'ChatBox'
|
||||
]
|
||||
|
||||
const shape = editor.getShape(shapeId as TLShapeId)
|
||||
|
|
@ -118,9 +118,9 @@ export function findNonOverlappingPosition(
|
|||
): { x: number; y: number } {
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
const customShapeTypes = [
|
||||
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat', 'FathomTranscript',
|
||||
'ObsNote', 'ObsidianBrowser', 'HolonBrowser', 'VideoChat',
|
||||
'Transcription', 'Holon', 'LocationShare', 'FathomMeetingsBrowser', 'Prompt',
|
||||
'Embed', 'Slide', 'Markdown', 'SharedPiano', 'MycrozineTemplate', 'ChatBox'
|
||||
'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'ChatBox'
|
||||
]
|
||||
|
||||
const existingShapes = allShapes.filter(
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ export class AutomergeDurableObject {
|
|||
private lastPersistedHash: string | null = null
|
||||
// Track if document was converted from old format (for JSON sync decision)
|
||||
private wasConvertedFromOldFormat: boolean = false
|
||||
// Cache R2 document hash to avoid reloading when unchanged
|
||||
private cachedR2Hash: string | null = null
|
||||
private cachedR2Doc: any = null
|
||||
|
||||
constructor(private readonly ctx: DurableObjectState, env: Environment) {
|
||||
this.r2 = env.TLDRAW_BUCKET
|
||||
|
|
@ -837,23 +840,21 @@ export class AutomergeDurableObject {
|
|||
.filter((r: any) => r?.typeName === 'shape')
|
||||
.map((r: any) => r.id) : []
|
||||
|
||||
// CRITICAL: Merge stores instead of replacing entire document
|
||||
// This prevents client from overwriting old shapes when it only has partial data
|
||||
// CRITICAL: Replace the entire store with the client's document
|
||||
// The client's document is authoritative and includes deletions
|
||||
// This ensures that when shapes are deleted, they're actually removed
|
||||
// Clear R2 cache since document has been updated
|
||||
this.cachedR2Doc = null
|
||||
this.cachedR2Hash = null
|
||||
|
||||
if (this.currentDoc && newDoc?.store) {
|
||||
// Merge new records into existing store, but don't delete old ones
|
||||
if (!this.currentDoc.store) {
|
||||
this.currentDoc.store = {}
|
||||
}
|
||||
// Count records before update
|
||||
const recordsBefore = Object.keys(this.currentDoc.store || {}).length
|
||||
|
||||
// Count records before merge
|
||||
const recordsBefore = Object.keys(this.currentDoc.store).length
|
||||
// Replace the entire store with the client's version (preserves deletions)
|
||||
this.currentDoc.store = { ...newDoc.store }
|
||||
|
||||
// Merge: add/update records from newDoc, but keep existing ones that aren't in newDoc
|
||||
Object.entries(newDoc.store).forEach(([id, record]) => {
|
||||
this.currentDoc.store[id] = record
|
||||
})
|
||||
|
||||
// Count records after merge
|
||||
// Count records after update
|
||||
const recordsAfter = Object.keys(this.currentDoc.store).length
|
||||
|
||||
// Update schema if provided
|
||||
|
|
@ -861,7 +862,7 @@ export class AutomergeDurableObject {
|
|||
this.currentDoc.schema = newDoc.schema
|
||||
}
|
||||
|
||||
console.log(`📊 updateDocument: Merged ${Object.keys(newDoc.store).length} records from client into ${recordsBefore} existing records (total: ${recordsAfter})`)
|
||||
console.log(`📊 updateDocument: Replaced store with client document: ${recordsBefore} -> ${recordsAfter} records (client sent ${Object.keys(newDoc.store).length})`)
|
||||
} else {
|
||||
// If no current doc yet, set it (R2 load should have completed by now)
|
||||
console.log(`📊 updateDocument: No current doc, setting to new doc (${newShapeCount} shapes)`)
|
||||
|
|
@ -883,21 +884,67 @@ export class AutomergeDurableObject {
|
|||
if (finalShapeCount !== oldShapeCount) {
|
||||
console.log(`📊 Document updated: shape count changed from ${oldShapeCount} to ${finalShapeCount} (merged from client with ${newShapeCount} shapes)`)
|
||||
// CRITICAL: Always persist when shape count changes
|
||||
console.log(`📤 Triggering R2 persistence due to shape count change`)
|
||||
this.schedulePersistToR2()
|
||||
} else if (newShapeCount < oldShapeCount) {
|
||||
console.log(`⚠️ Client sent ${newShapeCount} shapes but server has ${oldShapeCount}. Merged to preserve all shapes (final: ${finalShapeCount})`)
|
||||
// Persist to ensure we save the merged state
|
||||
console.log(`📤 Triggering R2 persistence to save merged state`)
|
||||
this.schedulePersistToR2()
|
||||
} else if (newShapeCount === oldShapeCount && oldShapeCount > 0) {
|
||||
// Check if any records were actually added/updated (not just same count)
|
||||
const recordsAdded = Object.keys(newDoc.store || {}).filter(id =>
|
||||
!this.currentDoc?.store?.[id] ||
|
||||
JSON.stringify(this.currentDoc.store[id]) !== JSON.stringify(newDoc.store[id])
|
||||
).length
|
||||
// OPTIMIZED: Fast comparison without expensive JSON.stringify
|
||||
// Check if any records were actually added/updated using lightweight comparison
|
||||
let recordsChanged = false
|
||||
const newStore = newDoc.store || {}
|
||||
const currentStore = this.currentDoc?.store || {}
|
||||
|
||||
if (recordsAdded > 0) {
|
||||
console.log(`ℹ️ Client sent ${newShapeCount} shapes, server had ${oldShapeCount}. ${recordsAdded} records were updated. Merge complete (final: ${finalShapeCount})`)
|
||||
// Quick check: compare record counts and IDs first
|
||||
const newKeys = Object.keys(newStore)
|
||||
const currentKeys = Object.keys(currentStore)
|
||||
|
||||
if (newKeys.length !== currentKeys.length) {
|
||||
recordsChanged = true
|
||||
} else {
|
||||
// Check for new or removed records
|
||||
for (const id of newKeys) {
|
||||
if (!currentStore[id]) {
|
||||
recordsChanged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!recordsChanged) {
|
||||
for (const id of currentKeys) {
|
||||
if (!newStore[id]) {
|
||||
recordsChanged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only do deep comparison if structure matches (avoid expensive JSON.stringify)
|
||||
if (!recordsChanged) {
|
||||
// Lightweight comparison: check if record types or key properties changed
|
||||
for (const id of newKeys) {
|
||||
const newRecord = newStore[id]
|
||||
const currentRecord = currentStore[id]
|
||||
if (!currentRecord) continue
|
||||
|
||||
// Quick checks: typeName, type, x, y (most common changes)
|
||||
if (newRecord.typeName !== currentRecord.typeName ||
|
||||
newRecord.type !== currentRecord.type ||
|
||||
(newRecord.x !== currentRecord.x) ||
|
||||
(newRecord.y !== currentRecord.y)) {
|
||||
recordsChanged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (recordsChanged) {
|
||||
console.log(`ℹ️ Client sent ${newShapeCount} shapes, server had ${oldShapeCount}. Records were updated. Merge complete (final: ${finalShapeCount})`)
|
||||
// Persist if records were updated
|
||||
console.log(`📤 Triggering R2 persistence due to record updates`)
|
||||
this.schedulePersistToR2()
|
||||
} else {
|
||||
console.log(`ℹ️ Client sent ${newShapeCount} shapes, server had ${oldShapeCount}. No changes detected, skipping persistence.`)
|
||||
|
|
@ -905,6 +952,7 @@ export class AutomergeDurableObject {
|
|||
} else {
|
||||
// New shapes or other changes - always persist
|
||||
console.log(`📊 Document updated: scheduling persistence (old: ${oldShapeCount}, new: ${newShapeCount}, final: ${finalShapeCount})`)
|
||||
console.log(`📤 Triggering R2 persistence for new shapes/changes`)
|
||||
this.schedulePersistToR2()
|
||||
}
|
||||
}
|
||||
|
|
@ -1053,7 +1101,7 @@ export class AutomergeDurableObject {
|
|||
migrationStats.shapeTypes[shapeType] = (migrationStats.shapeTypes[shapeType] || 0) + 1
|
||||
|
||||
// Track custom shapes (non-standard TLDraw shapes)
|
||||
const customShapeTypes = ['ObsNote', 'Holon', 'FathomMeetingsBrowser', 'FathomTranscript', 'HolonBrowser', 'LocationShare', 'ObsidianBrowser']
|
||||
const customShapeTypes = ['ObsNote', 'Holon', 'FathomMeetingsBrowser', 'FathomNote', 'HolonBrowser', 'LocationShare', 'ObsidianBrowser']
|
||||
if (customShapeTypes.includes(shapeType)) {
|
||||
migrationStats.customShapes.push(record.id)
|
||||
}
|
||||
|
|
@ -1090,8 +1138,9 @@ export class AutomergeDurableObject {
|
|||
record.meta = {}
|
||||
needsUpdate = true
|
||||
}
|
||||
if (!record.index) {
|
||||
record.index = 'a1' // Required index property for all shapes
|
||||
// Validate and fix index property - must be a valid IndexKey (like 'a1', 'a2', etc.)
|
||||
if (!record.index || typeof record.index !== 'string' || !/^[a-z]\d+$/.test(record.index)) {
|
||||
record.index = 'a1' // Required index property for all shapes - must be valid IndexKey format
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
|
|
@ -1184,6 +1233,74 @@ export class AutomergeDurableObject {
|
|||
}
|
||||
}
|
||||
|
||||
// Special handling for text shapes - ensure required properties exist
|
||||
if (record.type === 'text') {
|
||||
if (!record.props || typeof record.props !== 'object') {
|
||||
record.props = {}
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
// CRITICAL: color is REQUIRED for text shapes and must be a valid color value
|
||||
const validColors = ['black', 'grey', 'light-violet', 'violet', 'blue', 'light-blue', 'yellow', 'orange', 'green', 'light-green', 'light-red', 'red', 'white']
|
||||
if (!record.props.color || typeof record.props.color !== 'string' || !validColors.includes(record.props.color)) {
|
||||
record.props.color = 'black'
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
// Ensure other required text shape properties have defaults
|
||||
if (typeof record.props.w !== 'number') {
|
||||
record.props.w = 300
|
||||
needsUpdate = true
|
||||
}
|
||||
if (!record.props.size || typeof record.props.size !== 'string') {
|
||||
record.props.size = 'm'
|
||||
needsUpdate = true
|
||||
}
|
||||
if (!record.props.font || typeof record.props.font !== 'string') {
|
||||
record.props.font = 'draw'
|
||||
needsUpdate = true
|
||||
}
|
||||
if (!record.props.textAlign || typeof record.props.textAlign !== 'string') {
|
||||
record.props.textAlign = 'start'
|
||||
needsUpdate = true
|
||||
}
|
||||
if (typeof record.props.autoSize !== 'boolean') {
|
||||
record.props.autoSize = false
|
||||
needsUpdate = true
|
||||
}
|
||||
if (typeof record.props.scale !== 'number') {
|
||||
record.props.scale = 1
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
// Ensure richText structure is correct
|
||||
if (record.props.richText) {
|
||||
if (Array.isArray(record.props.richText)) {
|
||||
record.props.richText = { content: record.props.richText, type: 'doc' }
|
||||
needsUpdate = true
|
||||
} else if (typeof record.props.richText === 'object' && record.props.richText !== null) {
|
||||
if (!record.props.richText.type) {
|
||||
record.props.richText = { ...record.props.richText, type: 'doc' }
|
||||
needsUpdate = true
|
||||
}
|
||||
if (!record.props.richText.content) {
|
||||
record.props.richText = { ...record.props.richText, content: [] }
|
||||
needsUpdate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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', '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 record.props) {
|
||||
delete record.props[prop]
|
||||
needsUpdate = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
migrationStats.migrated++
|
||||
// Only log detailed migration info for first few shapes to avoid spam
|
||||
|
|
@ -1249,6 +1366,8 @@ export class AutomergeDurableObject {
|
|||
|
||||
// we throttle persistence so it only happens every 2 seconds, batching all updates
|
||||
schedulePersistToR2 = throttle(async () => {
|
||||
console.log(`📤 schedulePersistToR2 called for room ${this.roomId}`)
|
||||
|
||||
if (!this.roomId || !this.currentDoc) {
|
||||
console.log(`⚠️ Cannot persist to R2: roomId=${this.roomId}, currentDoc=${!!this.currentDoc}`)
|
||||
return
|
||||
|
|
@ -1261,71 +1380,73 @@ export class AutomergeDurableObject {
|
|||
let mergedShapeCount = 0
|
||||
|
||||
try {
|
||||
// Try to load current R2 state
|
||||
const docFromBucket = await this.r2.get(`rooms/${this.roomId}`)
|
||||
if (docFromBucket) {
|
||||
try {
|
||||
const r2Doc = await docFromBucket.json() as any
|
||||
r2ShapeCount = r2Doc.store ?
|
||||
Object.values(r2Doc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
|
||||
// Merge R2 document with current document
|
||||
if (r2Doc.store && mergedDoc.store) {
|
||||
// Start with R2 document (has all old shapes)
|
||||
mergedDoc = { ...r2Doc }
|
||||
|
||||
// Merge currentDoc into R2 doc (adds/updates new shapes)
|
||||
Object.entries(this.currentDoc.store).forEach(([id, record]) => {
|
||||
mergedDoc.store[id] = record
|
||||
})
|
||||
|
||||
// Update schema from currentDoc if it exists
|
||||
if (this.currentDoc.schema) {
|
||||
mergedDoc.schema = this.currentDoc.schema
|
||||
}
|
||||
|
||||
mergedShapeCount = Object.values(mergedDoc.store).filter((r: any) => r?.typeName === 'shape').length
|
||||
|
||||
// Track shape types in merged document
|
||||
const mergedShapeTypeCounts = Object.values(mergedDoc.store)
|
||||
.filter((r: any) => r?.typeName === 'shape')
|
||||
.reduce((acc: any, r: any) => {
|
||||
const type = r?.type || 'unknown'
|
||||
acc[type] = (acc[type] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
console.log(`🔀 Merging R2 state with current state before persistence:`, {
|
||||
r2Shapes: r2ShapeCount,
|
||||
currentShapes: this.currentDoc.store ? Object.values(this.currentDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0,
|
||||
mergedShapes: mergedShapeCount,
|
||||
r2Records: Object.keys(r2Doc.store || {}).length,
|
||||
currentRecords: Object.keys(this.currentDoc.store || {}).length,
|
||||
mergedRecords: Object.keys(mergedDoc.store || {}).length
|
||||
})
|
||||
console.log(`🔀 Merged shape type breakdown:`, mergedShapeTypeCounts)
|
||||
|
||||
// Check if we're preserving all shapes
|
||||
if (mergedShapeCount < r2ShapeCount) {
|
||||
console.error(`❌ CRITICAL: Merged document has fewer shapes (${mergedShapeCount}) than R2 (${r2ShapeCount})! This should not happen.`)
|
||||
} else if (mergedShapeCount > r2ShapeCount) {
|
||||
console.log(`✅ Merged document has ${mergedShapeCount - r2ShapeCount} new shapes added to R2's ${r2ShapeCount} shapes`)
|
||||
}
|
||||
} else if (r2Doc.store && !mergedDoc.store) {
|
||||
// R2 has store but currentDoc doesn't - use R2
|
||||
mergedDoc = r2Doc
|
||||
mergedShapeCount = r2ShapeCount
|
||||
console.log(`⚠️ Current doc has no store, using R2 document (${r2ShapeCount} shapes)`)
|
||||
} else {
|
||||
// Neither has store or R2 doesn't have store - use currentDoc
|
||||
mergedDoc = this.currentDoc
|
||||
mergedShapeCount = this.currentDoc.store ? Object.values(this.currentDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
console.log(`ℹ️ R2 has no store, using current document (${mergedShapeCount} shapes)`)
|
||||
// OPTIMIZATION: Only reload R2 if we don't have a cached version or if it might have changed
|
||||
// Since currentDoc is authoritative (includes deletions), we can skip R2 merge in most cases
|
||||
// Only merge if we suspect there might be data in R2 that's not in currentDoc
|
||||
let r2Doc: any = null
|
||||
|
||||
// Check if we need to reload R2 (only if cache is invalid or missing)
|
||||
if (!this.cachedR2Doc) {
|
||||
const docFromBucket = await this.r2.get(`rooms/${this.roomId}`)
|
||||
if (docFromBucket) {
|
||||
try {
|
||||
r2Doc = await docFromBucket.json() as any
|
||||
// Cache the R2 document
|
||||
this.cachedR2Doc = r2Doc
|
||||
this.cachedR2Hash = this.generateDocHash(r2Doc)
|
||||
} catch (r2ParseError) {
|
||||
console.warn(`⚠️ Error parsing R2 document, using current document:`, r2ParseError)
|
||||
r2Doc = null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use cached R2 document
|
||||
r2Doc = this.cachedR2Doc
|
||||
}
|
||||
|
||||
if (r2Doc) {
|
||||
r2ShapeCount = r2Doc.store ?
|
||||
Object.values(r2Doc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
|
||||
// CRITICAL: Use currentDoc as the source of truth (has the latest state including deletions)
|
||||
// Don't merge in old records from R2 - currentDoc is authoritative
|
||||
mergedDoc = { ...this.currentDoc }
|
||||
mergedDoc.store = { ...this.currentDoc.store }
|
||||
|
||||
// Update schema from currentDoc if it exists
|
||||
if (this.currentDoc.schema) {
|
||||
mergedDoc.schema = this.currentDoc.schema
|
||||
}
|
||||
|
||||
mergedShapeCount = Object.values(mergedDoc.store).filter((r: any) => r?.typeName === 'shape').length
|
||||
|
||||
// Only log merge details if there's a significant difference
|
||||
if (Math.abs(mergedShapeCount - r2ShapeCount) > 0) {
|
||||
const mergedShapeTypeCounts = Object.values(mergedDoc.store)
|
||||
.filter((r: any) => r?.typeName === 'shape')
|
||||
.reduce((acc: any, r: any) => {
|
||||
const type = r?.type || 'unknown'
|
||||
acc[type] = (acc[type] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
console.log(`🔀 Merging R2 state with current state before persistence:`, {
|
||||
r2Shapes: r2ShapeCount,
|
||||
currentShapes: this.currentDoc.store ? Object.values(this.currentDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0,
|
||||
mergedShapes: mergedShapeCount,
|
||||
r2Records: Object.keys(r2Doc.store || {}).length,
|
||||
currentRecords: Object.keys(this.currentDoc.store || {}).length,
|
||||
mergedRecords: Object.keys(mergedDoc.store || {}).length
|
||||
})
|
||||
console.log(`🔀 Merged shape type breakdown:`, mergedShapeTypeCounts)
|
||||
|
||||
// Log merge results
|
||||
if (mergedShapeCount < r2ShapeCount) {
|
||||
// This is expected when shapes are deleted - currentDoc has fewer shapes than R2
|
||||
console.log(`✅ Merged document has ${r2ShapeCount - mergedShapeCount} fewer shapes than R2 (deletions preserved)`)
|
||||
} else if (mergedShapeCount > r2ShapeCount) {
|
||||
console.log(`✅ Merged document has ${mergedShapeCount - r2ShapeCount} new shapes added to R2's ${r2ShapeCount} shapes`)
|
||||
}
|
||||
} catch (r2ParseError) {
|
||||
console.warn(`⚠️ Error parsing R2 document, using current document:`, r2ParseError)
|
||||
mergedDoc = this.currentDoc
|
||||
mergedShapeCount = this.currentDoc.store ? Object.values(this.currentDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
}
|
||||
} else {
|
||||
// No R2 document exists yet - use currentDoc
|
||||
|
|
@ -1338,6 +1459,9 @@ export class AutomergeDurableObject {
|
|||
console.warn(`⚠️ Error loading from R2, using current document:`, r2LoadError)
|
||||
mergedDoc = this.currentDoc
|
||||
mergedShapeCount = this.currentDoc.store ? Object.values(this.currentDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||
// Clear cache on error
|
||||
this.cachedR2Doc = null
|
||||
this.cachedR2Hash = null
|
||||
}
|
||||
|
||||
// Generate hash of merged document state
|
||||
|
|
@ -1358,13 +1482,20 @@ export class AutomergeDurableObject {
|
|||
return
|
||||
}
|
||||
|
||||
console.log(`💾 Attempting to persist room ${this.roomId} to R2...`)
|
||||
|
||||
try {
|
||||
// Update currentDoc to the merged version
|
||||
this.currentDoc = mergedDoc
|
||||
|
||||
// convert the merged document to JSON and upload it to R2
|
||||
// OPTIMIZED: Serialize efficiently - R2 handles large payloads well, but we can optimize
|
||||
// For very large documents, consider compression or chunking in the future
|
||||
const docJson = JSON.stringify(mergedDoc)
|
||||
await this.r2.put(`rooms/${this.roomId}`, docJson, {
|
||||
const docSize = docJson.length
|
||||
|
||||
console.log(`💾 Uploading to R2: ${docSize} bytes, ${mergedShapeCount} shapes`)
|
||||
|
||||
const putResult = await this.r2.put(`rooms/${this.roomId}`, docJson, {
|
||||
httpMetadata: {
|
||||
contentType: 'application/json'
|
||||
}
|
||||
|
|
@ -1381,15 +1512,30 @@ export class AutomergeDurableObject {
|
|||
|
||||
// Update last persisted hash only after successful save
|
||||
this.lastPersistedHash = currentHash
|
||||
// Update cached R2 document to match what we just saved
|
||||
this.cachedR2Doc = mergedDoc
|
||||
this.cachedR2Hash = currentHash
|
||||
console.log(`✅ Successfully persisted room ${this.roomId} to R2 (merged):`, {
|
||||
storeKeys: mergedDoc.store ? Object.keys(mergedDoc.store).length : 0,
|
||||
shapeCount: mergedShapeCount,
|
||||
docSize: docJson.length,
|
||||
preservedR2Shapes: r2ShapeCount > 0 ? `${r2ShapeCount} from R2` : 'none'
|
||||
docSize: docSize,
|
||||
preservedR2Shapes: r2ShapeCount > 0 ? `${r2ShapeCount} from R2` : 'none',
|
||||
r2PutResult: putResult ? 'success' : 'unknown'
|
||||
})
|
||||
console.log(`✅ Persisted shape type breakdown:`, persistedShapeTypeCounts)
|
||||
} catch (error) {
|
||||
console.error(`❌ Error persisting room ${this.roomId} to R2:`, error)
|
||||
// Enhanced error logging for R2 persistence failures
|
||||
const errorDetails = {
|
||||
roomId: this.roomId,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
errorName: error instanceof Error ? error.name : 'Unknown',
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
shapeCount: mergedShapeCount,
|
||||
storeKeys: mergedDoc.store ? Object.keys(mergedDoc.store).length : 0,
|
||||
docSize: mergedDoc.store ? JSON.stringify(mergedDoc).length : 0
|
||||
}
|
||||
console.error(`❌ Error persisting room ${this.roomId} to R2:`, errorDetails)
|
||||
console.error(`❌ Full error object:`, error)
|
||||
}
|
||||
}, 2_000)
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export type IVideoChatShape = TLBaseShape<
|
|||
}>
|
||||
meetingToken: string | null
|
||||
isOwner: boolean
|
||||
pinnedToView: boolean
|
||||
}
|
||||
>
|
||||
|
||||
|
|
@ -42,7 +43,8 @@ export class VideoChatShape extends BaseBoxShapeUtil<IVideoChatShape> {
|
|||
isTranscribing: false,
|
||||
transcriptionHistory: [],
|
||||
meetingToken: null,
|
||||
isOwner: false
|
||||
isOwner: false,
|
||||
pinnedToView: false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
133
worker/worker.ts
133
worker/worker.ts
|
|
@ -544,15 +544,19 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|||
}
|
||||
})
|
||||
|
||||
// Fathom API endpoints
|
||||
// Fathom API endpoints (api.fathom.ai)
|
||||
.get("/fathom/meetings", async (req) => {
|
||||
console.log('Fathom meetings endpoint called')
|
||||
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
||||
// Support both Authorization: Bearer and X-Api-Key headers for backward compatibility
|
||||
let apiKey = req.headers.get('X-Api-Key')
|
||||
if (!apiKey) {
|
||||
apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
||||
}
|
||||
console.log('API key present:', !!apiKey)
|
||||
|
||||
if (!apiKey) {
|
||||
console.log('No API key provided')
|
||||
return new Response(JSON.stringify({ error: 'No API key provided' }), {
|
||||
return new Response(JSON.stringify({ error: 'No API key provided. Use X-Api-Key header or Authorization: Bearer' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
|
@ -561,10 +565,29 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|||
try {
|
||||
console.log('Making request to Fathom API...')
|
||||
|
||||
const response = await fetch('https://api.usefathom.com/v1/meetings', {
|
||||
// Build query parameters from URL
|
||||
const url = new URL(req.url)
|
||||
const params = new URLSearchParams()
|
||||
if (url.searchParams.has('cursor')) {
|
||||
params.append('cursor', url.searchParams.get('cursor')!)
|
||||
}
|
||||
if (url.searchParams.has('include_transcript')) {
|
||||
params.append('include_transcript', url.searchParams.get('include_transcript')!)
|
||||
}
|
||||
if (url.searchParams.has('created_after')) {
|
||||
params.append('created_after', url.searchParams.get('created_after')!)
|
||||
}
|
||||
if (url.searchParams.has('created_before')) {
|
||||
params.append('created_before', url.searchParams.get('created_before')!)
|
||||
}
|
||||
|
||||
const queryString = params.toString()
|
||||
const apiUrl = `https://api.fathom.ai/external/v1/meetings${queryString ? '?' + queryString : ''}`
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'X-Api-Key': apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
|
@ -573,17 +596,40 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|||
|
||||
if (!response.ok) {
|
||||
console.log('Fathom API error response')
|
||||
const error = await response.json()
|
||||
console.log('Error details:', error)
|
||||
return new Response(JSON.stringify(error), {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
let errorData: any
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
errorData = await response.json()
|
||||
console.log('Error details:', errorData)
|
||||
} catch (e) {
|
||||
errorData = { error: `HTTP ${response.status}: ${response.statusText}` }
|
||||
}
|
||||
} else {
|
||||
// Handle HTML or text error responses
|
||||
const text = await response.text()
|
||||
console.log('Non-JSON error response:', text.substring(0, 200))
|
||||
errorData = {
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
details: text.substring(0, 500) // Include first 500 chars for debugging
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(errorData), {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.json() as { data?: any[] }
|
||||
console.log('Fathom API success, data length:', data?.data?.length || 0)
|
||||
return new Response(JSON.stringify(data), {
|
||||
const data = await response.json() as { items?: any[], limit?: number, next_cursor?: string }
|
||||
console.log('Fathom API success, items length:', data?.items?.length || 0)
|
||||
// Transform response to match expected format (items -> data for backward compatibility)
|
||||
return new Response(JSON.stringify({
|
||||
data: data.items || [],
|
||||
limit: data.limit,
|
||||
next_cursor: data.next_cursor
|
||||
}), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
} catch (error) {
|
||||
|
|
@ -599,35 +645,84 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|||
})
|
||||
|
||||
.get("/fathom/meetings/:meetingId", async (req) => {
|
||||
const apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
||||
// Support both Authorization: Bearer and X-Api-Key headers
|
||||
let apiKey = req.headers.get('X-Api-Key')
|
||||
if (!apiKey) {
|
||||
apiKey = req.headers.get('Authorization')?.split('Bearer ')[1]
|
||||
}
|
||||
const { meetingId } = req.params
|
||||
|
||||
if (!apiKey) {
|
||||
return new Response(JSON.stringify({ error: 'No API key provided' }), {
|
||||
return new Response(JSON.stringify({ error: 'No API key provided. Use X-Api-Key header or Authorization: Bearer' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://api.usefathom.com/v1/meetings/${meetingId}`, {
|
||||
// Get transcript if requested
|
||||
const url = new URL(req.url)
|
||||
const includeTranscript = url.searchParams.get('include_transcript') === 'true'
|
||||
// Use the meetings endpoint with filters to get a specific meeting by recording_id
|
||||
// The API doesn't have a direct /meetings/:id endpoint, so we filter by recording_id
|
||||
// Include summary and action items parameters - these are required to get the data
|
||||
const apiUrl = `https://api.fathom.ai/external/v1/meetings?recording_id=${meetingId}&include_summary=true&include_action_items=true${includeTranscript ? '&include_transcript=true' : ''}`
|
||||
|
||||
console.log('Fetching Fathom meeting with URL:', apiUrl)
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'X-Api-Key': apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
return new Response(JSON.stringify(error), {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
let errorData: any
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
errorData = await response.json()
|
||||
} catch (e) {
|
||||
errorData = { error: `HTTP ${response.status}: ${response.statusText}` }
|
||||
}
|
||||
} else {
|
||||
// Handle HTML or text error responses
|
||||
const text = await response.text()
|
||||
errorData = {
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
details: text.substring(0, 500) // Include first 500 chars for debugging
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(errorData), {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return new Response(JSON.stringify(data), {
|
||||
const data = await response.json() as { items?: any[] }
|
||||
// The API returns an array, so get the first item (should be the matching meeting)
|
||||
const meeting = data.items && data.items.length > 0 ? data.items[0] : null
|
||||
|
||||
if (!meeting) {
|
||||
return new Response(JSON.stringify({ error: 'Meeting not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
// Log the meeting structure for debugging
|
||||
console.log('Fathom meeting response keys:', Object.keys(meeting))
|
||||
console.log('Has default_summary:', !!meeting.default_summary)
|
||||
console.log('Has action_items:', !!meeting.action_items)
|
||||
if (meeting.default_summary) {
|
||||
console.log('default_summary keys:', Object.keys(meeting.default_summary))
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(meeting), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ ip = "0.0.0.0"
|
|||
local_protocol = "http"
|
||||
upstream_protocol = "https"
|
||||
|
||||
[dev.miniflare]
|
||||
kv_persist = true
|
||||
r2_persist = true
|
||||
durable_objects_persist = true
|
||||
|
||||
[durable_objects]
|
||||
bindings = [
|
||||
{ name = "AUTOMERGE_DURABLE_OBJECT", class_name = "AutomergeDurableObject" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue