pin object, fix fathom, and a bunch of other things

This commit is contained in:
Jeff Emmett 2025-11-11 22:32:36 -08:00
parent 356f7b4705
commit de59c4a726
36 changed files with 4723 additions and 1170 deletions

View File

@ -73,7 +73,6 @@ Custom shape types are preserved:
- ObsNote
- Holon
- FathomMeetingsBrowser
- FathomTranscript
- HolonBrowser
- LocationShare
- ObsidianBrowser

View File

@ -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
}

View File

@ -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

View File

@ -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 } = (() => {

View File

@ -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

View File

@ -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>
)

View File

@ -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])
}

View File

@ -0,0 +1,5 @@
// Blockchain integration exports
export * from './ethereum';
export * from './walletIntegration';

114
src/lib/fathomApiKey.ts Normal file
View File

@ -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
}

View File

@ -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>

View File

@ -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}

View File

@ -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) {

View File

@ -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,
}
}
}
}

View File

@ -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} />
}
}

View File

@ -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}

View File

@ -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',

View File

@ -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
}

View File

@ -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}`}

View File

@ -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%",

View File

@ -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}>

View File

@ -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}

View File

@ -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
}
}

View File

@ -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)
}
}
}

View File

@ -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)

View File

@ -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"

View File

@ -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} />

View File

@ -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"

View File

@ -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"]}

View File

@ -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>
)

View File

@ -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",

View File

@ -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(

View File

@ -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)

View File

@ -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
}
}

View File

@ -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) {

View File

@ -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" },