From ffef04df501379860b82030e2e68168d866a9786 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 11 Nov 2025 22:32:36 -0800 Subject: [PATCH] pin object, fix fathom, and a bunch of other things --- DATA_CONVERSION_GUIDE.md | 1 - src/automerge/AutomergeToTLStore.ts | 105 +- src/automerge/MinimalSanitization.ts | 5 +- src/automerge/useAutomergeStoreV2.ts | 991 +++++++++++++++--- src/automerge/useAutomergeSyncRepo.ts | 350 ++++++- src/components/FathomMeetingsPanel.tsx | 445 ++++++-- src/components/StandardizedToolWrapper.tsx | 239 ++++- src/hooks/usePinnedToView.ts | 414 ++++++++ src/lib/blockchain/index.ts | 5 + src/lib/fathomApiKey.ts | 114 ++ src/routes/Board.tsx | 141 ++- src/shapes/ChatBoxShapeUtil.tsx | 33 + src/shapes/FathomMeetingsBrowserShapeUtil.tsx | 529 +++++++++- src/shapes/FathomNoteShapeUtil.tsx | 654 ++++++++++++ src/shapes/FathomTranscriptShapeUtil.tsx | 369 ------- src/shapes/HolonBrowserShapeUtil.tsx | 52 +- src/shapes/HolonShapeUtil.tsx | 42 +- src/shapes/ObsNoteShapeUtil.tsx | 104 +- src/shapes/ObsidianBrowserShapeUtil.tsx | 39 +- src/shapes/SharedPianoShapeUtil.tsx | 62 +- src/shapes/TranscriptionShapeUtil.tsx | 33 + src/shapes/VideoChatShapeUtil.tsx | 115 +- src/tools/FathomMeetingsTool.ts | 80 +- src/tools/FathomTranscriptTool.ts | 69 -- src/tools/HolonTool.ts | 45 +- src/tools/ObsNoteTool.ts | 1 - src/ui/CustomContextMenu.tsx | 1 - src/ui/CustomMainMenu.tsx | 110 +- src/ui/CustomToolbar.tsx | 195 +++- src/ui/components.tsx | 44 +- src/ui/overrides.tsx | 22 - src/utils/shapeCollisionUtils.ts | 8 +- worker/AutomergeDurableObject.ts | 334 ++++-- worker/shapes/VideoChatShapeUtil.ts | 4 +- worker/worker.ts | 133 ++- wrangler.dev.toml | 5 + 36 files changed, 4723 insertions(+), 1170 deletions(-) create mode 100644 src/hooks/usePinnedToView.ts create mode 100644 src/lib/blockchain/index.ts create mode 100644 src/lib/fathomApiKey.ts create mode 100644 src/shapes/FathomNoteShapeUtil.tsx delete mode 100644 src/shapes/FathomTranscriptShapeUtil.tsx delete mode 100644 src/tools/FathomTranscriptTool.ts diff --git a/DATA_CONVERSION_GUIDE.md b/DATA_CONVERSION_GUIDE.md index 43fc62f..9ce790c 100644 --- a/DATA_CONVERSION_GUIDE.md +++ b/DATA_CONVERSION_GUIDE.md @@ -73,7 +73,6 @@ Custom shape types are preserved: - ObsNote - Holon - FathomMeetingsBrowser -- FathomTranscript - HolonBrowser - LocationShare - ObsidianBrowser diff --git a/src/automerge/AutomergeToTLStore.ts b/src/automerge/AutomergeToTLStore.ts index b3267c0..0cedb10 100644 --- a/src/automerge/AutomergeToTLStore.ts +++ b/src/automerge/AutomergeToTLStore.ts @@ -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 } diff --git a/src/automerge/MinimalSanitization.ts b/src/automerge/MinimalSanitization.ts index 047620d..600f589 100644 --- a/src/automerge/MinimalSanitization.ts +++ b/src/automerge/MinimalSanitization.ts @@ -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 diff --git a/src/automerge/useAutomergeStoreV2.ts b/src/automerge/useAutomergeStoreV2.ts index 080cd15..6d12d82 100644 --- a/src/automerge/useAutomergeStoreV2.ts +++ b/src/automerge/useAutomergeStoreV2.ts @@ -15,6 +15,99 @@ import { import { applyAutomergePatchesToTLStore, sanitizeRecord } from "./AutomergeToTLStore.js" import { applyTLStoreChangesToAutomerge } from "./TLStoreToAutomerge.js" +// Helper function to safely extract plain objects from Automerge proxies +// This handles cases where JSON.stringify fails due to functions or getters +function safeExtractPlainObject(obj: any, visited = new WeakSet()): any { + // Handle null and undefined + if (obj === null || obj === undefined) { + return obj + } + + // Handle primitives + if (typeof obj !== 'object') { + return obj + } + + // Handle circular references + if (visited.has(obj)) { + return null + } + visited.add(obj) + + // Handle arrays + if (Array.isArray(obj)) { + try { + return obj.map(item => safeExtractPlainObject(item, visited)) + } catch (e) { + return [] + } + } + + // Handle objects + try { + const result: any = {} + // Use Object.keys to get enumerable properties, which is safer than for...in + // for Automerge proxies + const keys = Object.keys(obj) + for (const key of keys) { + try { + // Safely get the property value + // Use Object.getOwnPropertyDescriptor to check if it's a getter + const descriptor = Object.getOwnPropertyDescriptor(obj, key) + if (descriptor) { + // If it's a getter, try to get the value, but catch any errors + if (descriptor.get) { + try { + const value = descriptor.get.call(obj) + // Skip functions + if (typeof value === 'function') { + continue + } + result[key] = safeExtractPlainObject(value, visited) + } catch (e) { + // Skip properties that can't be accessed via getter + continue + } + } else if (descriptor.value !== undefined) { + // Regular property + const value = descriptor.value + // Skip functions + if (typeof value === 'function') { + continue + } + result[key] = safeExtractPlainObject(value, visited) + } + } else { + // Fallback: try direct access + try { + const value = obj[key] + // Skip functions + if (typeof value === 'function') { + continue + } + result[key] = safeExtractPlainObject(value, visited) + } catch (e) { + // Skip properties that can't be accessed + continue + } + } + } catch (e) { + // Skip properties that can't be accessed + continue + } + } + return result + } catch (e) { + // If extraction fails, try JSON.stringify as fallback + try { + return JSON.parse(JSON.stringify(obj)) + } catch (jsonError) { + // If that also fails, return empty object + return {} + } + } +} + // Import custom shape utilities import { ChatBoxShape } from "@/shapes/ChatBoxShapeUtil" import { VideoChatShape } from "@/shapes/VideoChatShapeUtil" @@ -23,10 +116,9 @@ import { MarkdownShape } from "@/shapes/MarkdownShapeUtil" import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil" import { SlideShape } from "@/shapes/SlideShapeUtil" import { PromptShape } from "@/shapes/PromptShapeUtil" -import { SharedPianoShape } from "@/shapes/SharedPianoShapeUtil" import { TranscriptionShape } from "@/shapes/TranscriptionShapeUtil" import { ObsNoteShape } from "@/shapes/ObsNoteShapeUtil" -import { FathomTranscriptShape } from "@/shapes/FathomTranscriptShapeUtil" +import { FathomNoteShape } from "@/shapes/FathomNoteShapeUtil" import { HolonShape } from "@/shapes/HolonShapeUtil" import { ObsidianBrowserShape } from "@/shapes/ObsidianBrowserShapeUtil" import { FathomMeetingsBrowserShape } from "@/shapes/FathomMeetingsBrowserShapeUtil" @@ -52,10 +144,9 @@ export function useAutomergeStoreV2({ MycrozineTemplate: {} as any, Slide: {} as any, Prompt: {} as any, - SharedPiano: {} as any, Transcription: {} as any, ObsNote: {} as any, - FathomTranscript: {} as any, + FathomNote: {} as any, Holon: {} as any, ObsidianBrowser: {} as any, FathomMeetingsBrowser: {} as any, @@ -75,10 +166,9 @@ export function useAutomergeStoreV2({ MycrozineTemplateShape, SlideShape, PromptShape, - SharedPianoShape, TranscriptionShape, ObsNoteShape, - FathomTranscriptShape, + FathomNoteShape, HolonShape, ObsidianBrowserShape, FathomMeetingsBrowserShape, @@ -232,24 +322,414 @@ export function useAutomergeStoreV2({ // Set up handler BEFORE initializeStore to catch patches from initial data load handle.on("change", automergeChangeHandler) + + // CRITICAL: If data was written to Automerge before this handler was set up, + // manually trigger patch processing by reading the current doc state + // This handles the case where useAutomergeSyncRepo writes data before useAutomergeStoreV2 sets up the handler + // We do this synchronously when the handler is set up to catch any missed patches + const currentDoc = handle.doc() + if (currentDoc && currentDoc.store && Object.keys(currentDoc.store).length > 0) { + const docShapeCount = Object.values(currentDoc.store).filter((r: any) => r?.typeName === 'shape').length + const storeShapeCount = store.allRecords().filter((r: any) => r.typeName === 'shape').length + + if (docShapeCount > 0 && storeShapeCount === 0) { + console.log(`🔧 Handler set up after data was written. Manually processing ${docShapeCount} shapes that were loaded before handler was ready...`) + // Since patches were already emitted when handle.change() was called in useAutomergeSyncRepo, + // we need to manually process the data that's already in the doc + try { + const allRecords: TLRecord[] = [] + Object.entries(currentDoc.store).forEach(([id, record]: [string, any]) => { + if (!record || !record.typeName || !record.id) return + if (record.typeName === 'obsidian_vault' || (typeof record.id === 'string' && record.id.startsWith('obsidian_vault:'))) return + + try { + let cleanRecord: any + try { + cleanRecord = JSON.parse(JSON.stringify(record)) + } catch { + cleanRecord = safeExtractPlainObject(record) + } + + if (cleanRecord && typeof cleanRecord === 'object') { + const sanitized = sanitizeRecord(cleanRecord) + const plainSanitized = JSON.parse(JSON.stringify(sanitized)) + allRecords.push(plainSanitized) + } + } catch (e) { + console.warn(`âš ī¸ Could not process record ${id}:`, e) + } + }) + + // Filter out SharedPiano shapes since they're no longer supported + const filteredRecords = allRecords.filter((record: any) => { + if (record.typeName === 'shape' && record.type === 'SharedPiano') { + console.log(`âš ī¸ Filtering out deprecated SharedPiano shape: ${record.id}`) + return false + } + return true + }) + + if (filteredRecords.length > 0) { + console.log(`🔧 Manually applying ${filteredRecords.length} records to store (patches were missed during initial load, filtered out ${allRecords.length - filteredRecords.length} SharedPiano shapes)`) + store.mergeRemoteChanges(() => { + const pageRecords = filteredRecords.filter(r => r.typeName === 'page') + const shapeRecords = filteredRecords.filter(r => r.typeName === 'shape') + const otherRecords = filteredRecords.filter(r => r.typeName !== 'page' && r.typeName !== 'shape') + const recordsToAdd = [...pageRecords, ...otherRecords, ...shapeRecords] + store.put(recordsToAdd) + }) + console.log(`✅ Manually applied ${filteredRecords.length} records to store`) + } + } catch (error) { + console.error(`❌ Error manually processing initial data:`, error) + } + } + } + // Throttle position-only updates (x/y changes) to reduce automerge saves during movement + let positionUpdateQueue: RecordsDiff | null = null + let positionUpdateTimeout: NodeJS.Timeout | null = null + const POSITION_UPDATE_THROTTLE_MS = 1000 // Save position updates every 1 second + + const flushPositionUpdates = () => { + if (positionUpdateQueue && handle) { + const queuedChanges = positionUpdateQueue + positionUpdateQueue = null + + // CRITICAL: Defer position update saves to prevent interrupting active interactions + requestAnimationFrame(() => { + try { + isLocalChange = true + handle.change((doc) => { + applyTLStoreChangesToAutomerge(doc, queuedChanges) + }) + setTimeout(() => { + isLocalChange = false + }, 100) + } catch (error) { + console.error("Error applying throttled position updates to Automerge:", error) + isLocalChange = false + } + }) + } + } + + // Helper to check if a change is only a position update (x/y changed, nothing else) + const isPositionOnlyUpdate = (changes: RecordsDiff): boolean => { + // If there are added or removed records, it's not just a position update + if (changes.added && Object.keys(changes.added).length > 0) return false + if (changes.removed && Object.keys(changes.removed).length > 0) return false + + // Check if all updated records are only position changes + if (changes.updated) { + const doc = handle?.doc() + if (!doc?.store) return false + + for (const [id, recordTuple] of Object.entries(changes.updated)) { + const isTuple = Array.isArray(recordTuple) && recordTuple.length === 2 + const oldRecord = isTuple ? recordTuple[0] : null + const newRecord = isTuple ? recordTuple[1] : recordTuple + + if (!oldRecord || !newRecord) return false + if (newRecord.typeName !== 'shape') return false + + // Check if only x/y changed + const oldX = (oldRecord as any).x + const oldY = (oldRecord as any).y + const newX = (newRecord as any).x + const newY = (newRecord as any).y + + // If x/y didn't change, it's not a position update + if (oldX === newX && oldY === newY) return false + + // Check if any other properties changed + for (const key of Object.keys(newRecord)) { + if (key === 'x' || key === 'y') continue + if (key === 'props') { + // Deep compare props + const oldProps = oldRecord.props || {} + const newProps = newRecord.props || {} + if (JSON.stringify(oldProps) !== JSON.stringify(newProps)) { + return false // Props changed, not just position + } + } else { + if ((oldRecord as any)[key] !== (newRecord as any)[key]) { + return false // Other property changed + } + } + } + } + return true // All updates are position-only + } + + return false + } + + // Track recent eraser activity to detect active eraser drags + let lastEraserActivity = 0 + let eraserToolSelected = false + const ERASER_ACTIVITY_THRESHOLD = 2000 // Increased to 2 seconds to handle longer eraser drags + let eraserChangeQueue: RecordsDiff | null = null + let eraserCheckInterval: NodeJS.Timeout | null = null + + // Helper to check if eraser tool is actively erasing (to prevent saves during eraser drag) + const isEraserActive = (): boolean => { + try { + const allRecords = store.allRecords() + + // Check instance_page_state for erasingShapeIds (most reliable indicator) + const instancePageState = allRecords.find((r: any) => + r.typeName === 'instance_page_state' && + (r as any).erasingShapeIds && + Array.isArray((r as any).erasingShapeIds) && + (r as any).erasingShapeIds.length > 0 + ) + + if (instancePageState) { + lastEraserActivity = Date.now() + eraserToolSelected = true + return true // Eraser is actively erasing shapes + } + + // Check if eraser tool is selected + const instance = allRecords.find((r: any) => r.typeName === 'instance') + const currentToolId = instance ? (instance as any).currentToolId : null + + if (currentToolId === 'eraser') { + eraserToolSelected = true + const now = Date.now() + // If eraser tool is selected, keep it active for longer to handle drags + // Also check if there was recent activity + if (now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) { + return true + } + // If tool is selected but no recent activity, still consider it active + // (user might be mid-drag) + return true + } else { + // Tool switched away - only consider active if very recent activity + eraserToolSelected = false + const now = Date.now() + if (now - lastEraserActivity < 300) { + return true // Very recent activity, might still be processing + } + } + + return false + } catch (e) { + // If we can't check, use last known state with timeout + const now = Date.now() + if (eraserToolSelected && now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) { + return true + } + return false + } + } + + // Track eraser activity from shape deletions + const checkForEraserActivity = (changes: RecordsDiff) => { + // If shapes are being removed and eraser tool might be active, mark activity + if (changes.removed) { + const removedShapes = Object.values(changes.removed).filter((r: any) => + r && r.typeName === 'shape' + ) + if (removedShapes.length > 0) { + // Check if eraser tool is currently selected + const allRecords = store.allRecords() + const instance = allRecords.find((r: any) => r.typeName === 'instance') + if (instance && (instance as any).currentToolId === 'eraser') { + lastEraserActivity = Date.now() + eraserToolSelected = true + } + } + } + } + // Listen for changes from TLDraw and apply them to Automerge // CRITICAL: Listen to ALL sources, not just "user", to catch richText/text changes const unsubscribeTLDraw = store.listen(({ changes, source }) => { + // Check for eraser activity from shape deletions + checkForEraserActivity(changes) + + // Filter out ephemeral records that shouldn't be persisted + // These include: + // - instance: UI state (cursor, screen bounds, etc.) + // - instance_page_state: selection state, editing state, etc. + // - instance_presence: presence/awareness data + // - camera: viewport position (x, y, z) - changes when panning/zooming + // - pointer: pointer position - changes on mouse movement + const ephemeralTypes = ['instance', 'instance_page_state', 'instance_presence', 'camera', 'pointer'] + + const filterEphemeral = (records: any) => { + if (!records) return {} + const filtered: any = {} + Object.entries(records).forEach(([id, record]: [string, any]) => { + const recordObj = Array.isArray(record) ? record[1] : record + // Check typeName from the record object + const typeName = recordObj?.typeName + // Also check if ID pattern matches ephemeral types (e.g., "camera:page:page") + const idMatchesEphemeral = typeof id === 'string' && ( + id.startsWith('instance:') || + id.startsWith('instance_page_state:') || + id.startsWith('instance_presence:') || + id.startsWith('camera:') || + id.startsWith('pointer:') + ) + + // DEBUG: Log why records are being filtered or not + const shouldFilter = (typeName && ephemeralTypes.includes(typeName)) || idMatchesEphemeral + if (shouldFilter) { + console.log(`đŸšĢ Filtering out ephemeral record:`, { + id, + typeName, + idMatchesEphemeral, + typeNameMatches: typeName && ephemeralTypes.includes(typeName) + }) + } + + // Filter out if typeName matches OR if ID pattern matches ephemeral types + if (typeName && ephemeralTypes.includes(typeName)) { + // Skip - this is an ephemeral record + return + } + if (idMatchesEphemeral) { + // Skip - ID pattern indicates ephemeral record (even if typeName is missing) + return + } + + // Keep this record - it's not ephemeral + filtered[id] = record + }) + return filtered + } + + const filteredChanges = { + added: filterEphemeral(changes.added), + updated: filterEphemeral(changes.updated), + removed: filterEphemeral(changes.removed), + } + // DEBUG: Log all changes to see what's being detected const totalChanges = Object.keys(changes.added || {}).length + Object.keys(changes.updated || {}).length + Object.keys(changes.removed || {}).length + const filteredTotalChanges = Object.keys(filteredChanges.added || {}).length + Object.keys(filteredChanges.updated || {}).length + Object.keys(filteredChanges.removed || {}).length + // DEBUG: Log ALL changes (before filtering) to see what's actually being updated if (totalChanges > 0) { + const allChangedRecords: Array<{id: string, typeName: string, changeType: string}> = [] + if (changes.added) { + Object.entries(changes.added).forEach(([id, record]: [string, any]) => { + const recordObj = Array.isArray(record) ? record[1] : record + allChangedRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'added' }) + }) + } + if (changes.updated) { + Object.entries(changes.updated).forEach(([id, [_, record]]: [string, [any, any]]) => { + allChangedRecords.push({ id, typeName: record?.typeName || 'unknown', changeType: 'updated' }) + }) + } + if (changes.removed) { + Object.entries(changes.removed).forEach(([id, record]: [string, any]) => { + const recordObj = Array.isArray(record) ? record[1] : record + allChangedRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'removed' }) + }) + } + console.log(`🔍 ALL changes detected (before filtering):`, { + total: totalChanges, + records: allChangedRecords, + // Also log the actual record objects to see their structure + recordDetails: allChangedRecords.map(r => { + let record: any = null + if (r.changeType === 'added' && changes.added) { + const rec = changes.added[r.id] + record = Array.isArray(rec) ? rec[1] : rec + } else if (r.changeType === 'updated' && changes.updated) { + const rec = changes.updated[r.id] + record = Array.isArray(rec) ? rec[1] : rec + } else if (r.changeType === 'removed' && changes.removed) { + const rec = changes.removed[r.id] + record = Array.isArray(rec) ? rec[1] : rec + } + return { + id: r.id, + typeName: r.typeName, + changeType: r.changeType, + hasTypeName: !!record?.typeName, + actualTypeName: record?.typeName, + recordKeys: record ? Object.keys(record).slice(0, 10) : [] + } + }) + }) + } + + // Log if we filtered out any ephemeral changes + if (totalChanges > 0 && filteredTotalChanges < totalChanges) { + const filteredCount = totalChanges - filteredTotalChanges + const filteredTypes = new Set() + const filteredIds: string[] = [] + if (changes.added) { + Object.entries(changes.added).forEach(([id, record]: [string, any]) => { + const recordObj = Array.isArray(record) ? record[1] : record + if (recordObj && ephemeralTypes.includes(recordObj.typeName)) { + filteredTypes.add(recordObj.typeName) + filteredIds.push(id) + } + }) + } + if (changes.updated) { + Object.entries(changes.updated).forEach(([id, [_, record]]: [string, [any, any]]) => { + if (ephemeralTypes.includes(record.typeName)) { + filteredTypes.add(record.typeName) + filteredIds.push(id) + } + }) + } + if (changes.removed) { + Object.entries(changes.removed).forEach(([id, record]: [string, any]) => { + const recordObj = Array.isArray(record) ? record[1] : record + if (recordObj && ephemeralTypes.includes(recordObj.typeName)) { + filteredTypes.add(recordObj.typeName) + filteredIds.push(id) + } + }) + } + console.log(`đŸšĢ Filtered out ${filteredCount} ephemeral change(s) (${Array.from(filteredTypes).join(', ')}) - not persisting`, { + filteredIds: filteredIds.slice(0, 5), // Show first 5 IDs + totalFiltered: filteredIds.length + }) + } + + if (filteredTotalChanges > 0) { + // Log what records are passing through the filter (shouldn't happen for ephemeral records) + const passingRecords: Array<{id: string, typeName: string, changeType: string}> = [] + if (filteredChanges.added) { + Object.entries(filteredChanges.added).forEach(([id, record]: [string, any]) => { + const recordObj = Array.isArray(record) ? record[1] : record + passingRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'added' }) + }) + } + if (filteredChanges.updated) { + Object.entries(filteredChanges.updated).forEach(([id, [_, record]]: [string, [any, any]]) => { + passingRecords.push({ id, typeName: record?.typeName || 'unknown', changeType: 'updated' }) + }) + } + if (filteredChanges.removed) { + Object.entries(filteredChanges.removed).forEach(([id, record]: [string, any]) => { + const recordObj = Array.isArray(record) ? record[1] : record + passingRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'removed' }) + }) + } + console.log(`🔍 TLDraw store changes detected (source: ${source}):`, { - added: Object.keys(changes.added || {}).length, - updated: Object.keys(changes.updated || {}).length, - removed: Object.keys(changes.removed || {}).length, - source: source + added: Object.keys(filteredChanges.added || {}).length, + updated: Object.keys(filteredChanges.updated || {}).length, + removed: Object.keys(filteredChanges.removed || {}).length, + source: source, + passingRecords: passingRecords // Show what's actually passing through }) // DEBUG: Check for richText/text changes in updated records - if (changes.updated) { - Object.values(changes.updated).forEach(([_, record]) => { + if (filteredChanges.updated) { + Object.values(filteredChanges.updated).forEach(([_, record]) => { if (record.typeName === 'shape') { if (record.type === 'geo' && (record.props as any)?.richText) { console.log(`🔍 Geo shape ${record.id} richText change detected:`, { @@ -287,8 +767,8 @@ export function useAutomergeStoreV2({ } // DEBUG: Log added shapes to track what's being created - if (changes.added) { - Object.values(changes.added).forEach((record) => { + if (filteredChanges.added) { + Object.values(filteredChanges.added).forEach((record) => { if (record.typeName === 'shape') { console.log(`🔍 Shape added: ${record.type} (${record.id})`, { type: record.type, @@ -302,38 +782,342 @@ export function useAutomergeStoreV2({ } } - // CRITICAL: Don't skip changes - always save them to ensure consistency - // The isLocalChange flag is only used to prevent feedback loops from Automerge changes - // We should always save TLDraw changes, even if they came from Automerge sync - // This ensures that all shapes (notes, rectangles, etc.) are consistently persisted + // Skip if no meaningful changes after filtering ephemeral records + if (filteredTotalChanges === 0) { + return + } - try { - // Set flag to prevent feedback loop when this change comes back from Automerge - isLocalChange = true + // CRITICAL: Filter out x/y coordinate changes for pinned-to-view shapes + // When a shape is pinned, its x/y coordinates change to stay in the same screen position, + // but we want to keep the original coordinates static in Automerge + const filterPinnedPositionChanges = (changes: any) => { + if (!changes || !handle) return changes - handle.change((doc) => { - applyTLStoreChangesToAutomerge(doc, changes) + const doc = handle.doc() + if (!doc?.store) return changes + + // First, check if there are ANY pinned shapes in the document + // Only filter if there are actually pinned shapes + // Use strict equality check to ensure we only match true (not truthy values) + const hasPinnedShapes = Object.values(doc.store).some((record: any) => { + const isShape = record?.typeName === 'shape' + const isPinned = record?.props?.pinnedToView === true + return isShape && isPinned }) - // Reset flag after a short delay to allow Automerge change handler to process - // This prevents feedback loops while ensuring all changes are saved - setTimeout(() => { - isLocalChange = false - }, 100) - - // Only log if there are many changes or if debugging is needed - if (totalChanges > 3) { - console.log(`✅ Applied ${totalChanges} TLDraw changes to Automerge document`) - } else if (totalChanges > 0) { - console.log(`✅ Applied ${totalChanges} TLDraw change(s) to Automerge document`) + // Also check the changes being processed to see if any shapes are pinned + let hasPinnedShapesInChanges = false + if (changes.updated) { + hasPinnedShapesInChanges = Object.entries(changes.updated).some(([id, recordTuple]: [string, any]) => { + const isTuple = Array.isArray(recordTuple) && recordTuple.length === 2 + const newRecord = isTuple ? recordTuple[1] : recordTuple + const isShape = newRecord?.typeName === 'shape' + const isPinned = (newRecord.props as any)?.pinnedToView === true + // Also verify in the doc that it's actually pinned + const docShape = doc.store[id] + const isPinnedInDoc = docShape?.props?.pinnedToView === true + return isShape && isPinned && isPinnedInDoc + }) } - // Check if the document actually changed - const docAfter = handle.doc() - } catch (error) { - console.error("Error applying TLDraw changes to Automerge:", error) - // Reset flag on error to prevent getting stuck - isLocalChange = false + // If there are no pinned shapes in either the doc or the changes, skip filtering entirely + if (!hasPinnedShapes && !hasPinnedShapesInChanges) { + return changes + } + + const filtered: any = { ...changes } + + // Check updated shapes for pinned position changes + if (filtered.updated) { + const updatedEntries = Object.entries(filtered.updated) + const filteredUpdated: any = {} + + updatedEntries.forEach(([id, recordTuple]: [string, any]) => { + // TLDraw store changes use tuple format [oldRecord, newRecord] for updates + const isTuple = Array.isArray(recordTuple) && recordTuple.length === 2 + const oldRecord = isTuple ? recordTuple[0] : null + const newRecord = isTuple ? recordTuple[1] : recordTuple + const record = newRecord + + // Get the original shape from Automerge doc to verify it's actually pinned + const originalShape = doc.store[id] + + // STRICT CHECK: Must be a shape, must have pinnedToView === true in BOTH the record AND the doc + const isShape = record?.typeName === 'shape' + const isPinnedInRecord = (record.props as any)?.pinnedToView === true + const isPinnedInDoc = originalShape?.props?.pinnedToView === true + + // Only filter if the shape is actually pinned in BOTH places + if (isShape && isPinnedInRecord && isPinnedInDoc) { + if (originalShape) { + const originalX = originalShape.x + const originalY = originalShape.y + const newX = (record as any).x + const newY = (record as any).y + + // If only x/y coordinates changed, restore original coordinates + // Compare all other properties to see if anything else changed + const otherPropsChanged = Object.keys(record).some(key => { + if (key === 'x' || key === 'y') return false + if (key === 'props') { + // Check if props changed (excluding pinnedToView changes) + const oldProps = oldRecord?.props || originalShape?.props || {} + const newProps = record.props || {} + // Deep compare props (excluding pinnedToView which might change) + const oldPropsCopy = { ...oldProps } + const newPropsCopy = { ...newProps } + delete oldPropsCopy.pinnedToView + delete newPropsCopy.pinnedToView + return JSON.stringify(oldPropsCopy) !== JSON.stringify(newPropsCopy) + } + const oldValue = oldRecord?.[key] ?? originalShape?.[key] + return oldValue !== record[key] + }) + + // If only position changed (x/y), restore original coordinates + if (!otherPropsChanged && (newX !== originalX || newY !== originalY)) { + console.log(`đŸšĢ Filtering out x/y coordinate change for pinned shape ${id}: (${newX}, ${newY}) -> keeping original (${originalX}, ${originalY})`) + // Restore original coordinates + const recordWithOriginalCoords = { + ...record, + x: originalX, + y: originalY + } + filteredUpdated[id] = isTuple + ? [oldRecord, recordWithOriginalCoords] + : recordWithOriginalCoords + } else if (otherPropsChanged) { + // Other properties changed, keep the update but restore coordinates + const recordWithOriginalCoords = { + ...record, + x: originalX, + y: originalY + } + filteredUpdated[id] = isTuple + ? [oldRecord, recordWithOriginalCoords] + : recordWithOriginalCoords + } else { + // No changes or only non-position changes, keep as is + filteredUpdated[id] = recordTuple + } + } else { + // Shape not in doc yet, keep as is + filteredUpdated[id] = recordTuple + } + } else { + // Not a pinned shape (or not pinned in both places), keep as is + filteredUpdated[id] = recordTuple + } + }) + + filtered.updated = filteredUpdated + } + + return filtered + } + + const finalFilteredChanges = filterPinnedPositionChanges(filteredChanges) + + // Check if this is a position-only update that should be throttled + const isPositionOnly = isPositionOnlyUpdate(finalFilteredChanges) + + if (isPositionOnly && positionUpdateQueue === null) { + // Start a new queue for position updates + positionUpdateQueue = finalFilteredChanges + + // Clear any existing timeout + if (positionUpdateTimeout) { + clearTimeout(positionUpdateTimeout) + } + + // Schedule flush after throttle period + positionUpdateTimeout = setTimeout(() => { + flushPositionUpdates() + positionUpdateTimeout = null + }, POSITION_UPDATE_THROTTLE_MS) + + return // Don't save immediately, wait for throttle + } else if (isPositionOnly && positionUpdateQueue !== null) { + // Merge with existing position update queue + // Merge added records + if (finalFilteredChanges.added) { + positionUpdateQueue.added = { + ...(positionUpdateQueue.added || {}), + ...finalFilteredChanges.added + } + } + // Merge updated records (keep latest) + if (finalFilteredChanges.updated) { + positionUpdateQueue.updated = { + ...(positionUpdateQueue.updated || {}), + ...finalFilteredChanges.updated + } + } + // Merge removed records + if (finalFilteredChanges.removed) { + positionUpdateQueue.removed = { + ...(positionUpdateQueue.removed || {}), + ...finalFilteredChanges.removed + } + } + + // Reset the timeout + if (positionUpdateTimeout) { + clearTimeout(positionUpdateTimeout) + } + positionUpdateTimeout = setTimeout(() => { + flushPositionUpdates() + positionUpdateTimeout = null + }, POSITION_UPDATE_THROTTLE_MS) + + return // Don't save immediately, wait for throttle + } else { + // Not a position-only update, or we have non-position changes + // Flush any queued position updates first + if (positionUpdateQueue) { + flushPositionUpdates() + } + + // CRITICAL: Don't skip changes - always save them to ensure consistency + // The isLocalChange flag is only used to prevent feedback loops from Automerge changes + // We should always save TLDraw changes, even if they came from Automerge sync + // This ensures that all shapes (notes, rectangles, etc.) are consistently persisted + + try { + // CRITICAL: Check if eraser is actively erasing - if so, defer the save + const eraserActive = isEraserActive() + + if (eraserActive) { + // Eraser is active - queue the changes and apply when eraser becomes inactive + // Merge with existing queued changes + if (eraserChangeQueue) { + // Merge added records + if (finalFilteredChanges.added) { + eraserChangeQueue.added = { + ...(eraserChangeQueue.added || {}), + ...finalFilteredChanges.added + } + } + // Merge updated records (keep latest) + if (finalFilteredChanges.updated) { + eraserChangeQueue.updated = { + ...(eraserChangeQueue.updated || {}), + ...finalFilteredChanges.updated + } + } + // Merge removed records + if (finalFilteredChanges.removed) { + eraserChangeQueue.removed = { + ...(eraserChangeQueue.removed || {}), + ...finalFilteredChanges.removed + } + } + } else { + eraserChangeQueue = finalFilteredChanges + } + + // Start checking for when eraser becomes inactive + if (!eraserCheckInterval) { + eraserCheckInterval = setInterval(() => { + const stillActive = isEraserActive() + if (!stillActive && eraserChangeQueue) { + // Eraser is no longer active - flush queued changes + const queuedChanges = eraserChangeQueue + eraserChangeQueue = null + + if (eraserCheckInterval) { + clearInterval(eraserCheckInterval) + eraserCheckInterval = null + } + + // Apply queued changes immediately + try { + isLocalChange = true + handle.change((doc) => { + applyTLStoreChangesToAutomerge(doc, queuedChanges) + }) + setTimeout(() => { + isLocalChange = false + }, 100) + } catch (error) { + console.error('❌ Error applying queued eraser changes:', error) + isLocalChange = false + } + } + }, 50) // Check every 50ms for faster response + } + + return // Don't save immediately while eraser is active + } else { + // If eraser was active but now isn't, flush any queued changes first + if (eraserChangeQueue) { + const queuedChanges = eraserChangeQueue + eraserChangeQueue = null + + if (eraserCheckInterval) { + clearInterval(eraserCheckInterval) + eraserCheckInterval = null + } + + // Merge current changes with queued changes + const mergedChanges: RecordsDiff = { + added: { ...(queuedChanges.added || {}), ...(finalFilteredChanges.added || {}) }, + updated: { ...(queuedChanges.updated || {}), ...(finalFilteredChanges.updated || {}) }, + removed: { ...(queuedChanges.removed || {}), ...(finalFilteredChanges.removed || {}) } + } + + requestAnimationFrame(() => { + isLocalChange = true + handle.change((doc) => { + applyTLStoreChangesToAutomerge(doc, mergedChanges) + }) + setTimeout(() => { + isLocalChange = false + }, 100) + }) + + return + } + // OPTIMIZED: Use requestIdleCallback to defer Automerge changes when browser is idle + // This prevents blocking mouse interactions without queuing changes + const applyChanges = () => { + // Set flag to prevent feedback loop when this change comes back from Automerge + isLocalChange = true + + handle.change((doc) => { + applyTLStoreChangesToAutomerge(doc, finalFilteredChanges) + }) + + // Reset flag after a short delay to allow Automerge change handler to process + // This prevents feedback loops while ensuring all changes are saved + setTimeout(() => { + isLocalChange = false + }, 100) + } + + // Use requestIdleCallback if available to apply changes when browser is idle + if (typeof requestIdleCallback !== 'undefined') { + requestIdleCallback(applyChanges, { timeout: 100 }) + } else { + // Fallback: use requestAnimationFrame for next frame + requestAnimationFrame(applyChanges) + } + } + + // Only log if there are many changes or if debugging is needed + if (filteredTotalChanges > 3) { + console.log(`✅ Applied ${filteredTotalChanges} TLDraw changes to Automerge document`) + } else if (filteredTotalChanges > 0) { + console.log(`✅ Applied ${filteredTotalChanges} TLDraw change(s) to Automerge document`) + } + + // Check if the document actually changed + const docAfter = handle.doc() + } catch (error) { + console.error("Error applying TLDraw changes to Automerge:", error) + // Reset flag on error to prevent getting stuck + isLocalChange = false + } } }, { // CRITICAL: Don't filter by source - listen to ALL changes @@ -344,7 +1128,36 @@ export function useAutomergeStoreV2({ unsubs.push( () => handle.off("change", automergeChangeHandler), - unsubscribeTLDraw + unsubscribeTLDraw, + () => { + // Cleanup: flush any pending position updates and clear timeout + if (positionUpdateTimeout) { + clearTimeout(positionUpdateTimeout) + positionUpdateTimeout = null + } + if (positionUpdateQueue) { + flushPositionUpdates() + } + // Cleanup: flush any pending eraser changes and clear interval + if (eraserCheckInterval) { + clearInterval(eraserCheckInterval) + eraserCheckInterval = null + } + if (eraserChangeQueue) { + // Flush queued eraser changes on unmount + const queuedChanges = eraserChangeQueue + eraserChangeQueue = null + if (handle) { + isLocalChange = true + handle.change((doc) => { + applyTLStoreChangesToAutomerge(doc, queuedChanges) + }) + setTimeout(() => { + isLocalChange = false + }, 100) + } + } + } ) // CRITICAL: Use patch-based loading exclusively (same as dev) @@ -411,94 +1224,16 @@ export function useAutomergeStoreV2({ } else if (attempts < maxAttempts) { setTimeout(checkForPatches, 200) } else { - // Patches didn't come through - handler may have missed them if data was written before handler was set up - // This happens when Automerge doc is initialized with server data before the change handler is ready - console.warn(`âš ī¸ No patches received after ${maxAttempts} attempts. Using fallback: loading records directly from Automerge doc.`) - console.warn(`âš ī¸ This is expected when Automerge doc is initialized with server data before handler is ready.`) + // Patches didn't come through - this should be rare if handler is set up before data load + // Log a warning but don't show disruptive confirmation dialog + console.warn(`âš ī¸ No patches received after ${maxAttempts} attempts for room initialization.`) + console.warn(`âš ī¸ This may happen if Automerge doc was initialized with server data before handler was ready.`) + console.warn(`âš ī¸ Store will remain empty - patches should handle data loading in normal operation.`) - try { - // Read all records from Automerge doc and apply them directly to store - // CRITICAL: This fallback preserves coordinates properly - const allRecords: TLRecord[] = [] - Object.entries(doc.store).forEach(([id, record]: [string, any]) => { - // Skip invalid records and custom record types (same as patch processing) - if (!record || !record.typeName || !record.id) { - return - } - - // Skip obsidian_vault records - they're not TLDraw records - if (record.typeName === 'obsidian_vault' || - (typeof record.id === 'string' && record.id.startsWith('obsidian_vault:'))) { - return - } - - try { - // Create a clean copy of the record - const cleanRecord = JSON.parse(JSON.stringify(record)) - - // CRITICAL: For shapes, preserve x and y coordinates - // We MUST preserve coordinates - they should never be reset to 0,0 unless truly missing - if (cleanRecord.typeName === 'shape') { - // Store original coordinates BEFORE any processing - const originalX = cleanRecord.x - const originalY = cleanRecord.y - const hadValidX = typeof originalX === 'number' && !isNaN(originalX) && originalX !== null && originalX !== undefined - const hadValidY = typeof originalY === 'number' && !isNaN(originalY) && originalY !== null && originalY !== undefined - - // Use the same sanitizeRecord function that patches use - // This ensures consistency between dev and production - const sanitized = sanitizeRecord(cleanRecord) - - // CRITICAL: ALWAYS restore original coordinates if they were valid - // Even if sanitizeRecord preserved them, we ensure they're correct - // This prevents any possibility of coordinates being reset - if (hadValidX) { - const beforeX = (sanitized as any).x - (sanitized as any).x = originalX - // Log if coordinates were changed during sanitization (for debugging) - if (beforeX !== originalX) { - console.warn(`âš ī¸ Coordinate X was changed during sanitization for shape ${cleanRecord.id}: ${originalX} -> ${beforeX}. Restored to ${originalX}.`) - } - } - if (hadValidY) { - const beforeY = (sanitized as any).y - (sanitized as any).y = originalY - // Log if coordinates were changed during sanitization (for debugging) - if (beforeY !== originalY) { - console.warn(`âš ī¸ Coordinate Y was changed during sanitization for shape ${cleanRecord.id}: ${originalY} -> ${beforeY}. Restored to ${originalY}.`) - } - } - - allRecords.push(sanitized) - } else { - // For non-shapes, just sanitize normally - const sanitized = sanitizeRecord(cleanRecord) - allRecords.push(sanitized) - } - } catch (e) { - console.warn(`âš ī¸ Could not serialize/sanitize record ${id}:`, e) - } - }) - - if (allRecords.length > 0) { - // Apply records directly to store using mergeRemoteChanges - // This bypasses patches but ensures data is loaded (works for both dev and production) - // Use mergeRemoteChanges to mark as remote changes (prevents feedback loop) - store.mergeRemoteChanges(() => { - // Separate pages, shapes, and other records to ensure proper loading order - const pageRecords = allRecords.filter(r => r.typeName === 'page') - const shapeRecords = allRecords.filter(r => r.typeName === 'shape') - const otherRecords = allRecords.filter(r => r.typeName !== 'page' && r.typeName !== 'shape') - - // Put pages first, then other records, then shapes (ensures pages exist before shapes reference them) - const recordsToAdd = [...pageRecords, ...otherRecords, ...shapeRecords] - store.put(recordsToAdd) - }) - console.log(`✅ Applied ${allRecords.length} records directly to store (fallback for missed patches - coordinates preserved)`) - } - } catch (error) { - console.error(`❌ Error applying records directly:`, error) - } + // Simplified fallback: Just log and continue with empty store + // Patches should handle data loading, so if they don't come through, + // it's likely the document is actually empty or there's a timing issue + // that will resolve on next sync setStoreWithStatus({ store, diff --git a/src/automerge/useAutomergeSyncRepo.ts b/src/automerge/useAutomergeSyncRepo.ts index e04c1a1..267cac8 100644 --- a/src/automerge/useAutomergeSyncRepo.ts +++ b/src/automerge/useAutomergeSyncRepo.ts @@ -35,6 +35,56 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus const [isLoading, setIsLoading] = useState(true) const handleRef = useRef(null) const storeRef = useRef(null) + const lastSentHashRef = useRef(null) + const isMouseActiveRef = useRef(false) + const pendingSaveRef = useRef(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((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 } = (() => { diff --git a/src/components/FathomMeetingsPanel.tsx b/src/components/FathomMeetingsPanel.tsx index 24ca7ea..d53de1e 100644 --- a/src/components/FathomMeetingsPanel.tsx +++ b/src/components/FathomMeetingsPanel.tsx @@ -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([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(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(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 = ( -
shapeMode ? undefined : e.stopPropagation()}> -
-

- đŸŽĨ Fathom Meetings -

- -
- +
{ + // 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 ? (

- +
+ + + + +
)) @@ -477,3 +709,4 @@ export function FathomMeetingsPanel({ onClose, shapeMode = false }: FathomMeetin + diff --git a/src/components/StandardizedToolWrapper.tsx b/src/components/StandardizedToolWrapper.tsx index bcecd50..c6ac974 100644 --- a/src/components/StandardizedToolWrapper.tsx +++ b/src/components/StandardizedToolWrapper.tsx @@ -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 = ( 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(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 = ( 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 = ( 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) => { + 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 = ( 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 = ( {headerContent || title}
+ {onPinToggle && ( + + )} + )} +
+ )} + )} ) diff --git a/src/hooks/usePinnedToView.ts b/src/hooks/usePinnedToView.ts new file mode 100644 index 0000000..dd6f519 --- /dev/null +++ b/src/hooks/usePinnedToView.ts @@ -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(null) + const wasPinnedRef = useRef(false) + const isUpdatingRef = useRef(false) + const animationFrameRef = useRef(null) + const lastCameraRef = useRef<{ x: number; y: number; z: number } | null>(null) + const pendingUpdateRef = useRef<{ x: number; y: number } | null>(null) + const lastUpdateTimeRef = useRef(0) + const driftAnimationRef = useRef(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]) +} + diff --git a/src/lib/blockchain/index.ts b/src/lib/blockchain/index.ts new file mode 100644 index 0000000..df9f7ec --- /dev/null +++ b/src/lib/blockchain/index.ts @@ -0,0 +1,5 @@ +// Blockchain integration exports + +export * from './ethereum'; +export * from './walletIntegration'; + diff --git a/src/lib/fathomApiKey.ts b/src/lib/fathomApiKey.ts new file mode 100644 index 0000000..08b9509 --- /dev/null +++ b/src/lib/fathomApiKey.ts @@ -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 +} + diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx index 9e8b62d..55aaebd 100644 --- a/src/routes/Board.tsx +++ b/src/routes/Board.tsx @@ -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 }} - > + > diff --git a/src/shapes/ChatBoxShapeUtil.tsx b/src/shapes/ChatBoxShapeUtil.tsx index 3f5903e..09ccbae 100644 --- a/src/shapes/ChatBoxShapeUtil.tsx +++ b/src/shapes/ChatBoxShapeUtil.tsx @@ -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 { w: 400, h: 500, userName: "", + pinnedToView: false, + tags: ['chat'], } } @@ -35,6 +40,9 @@ export class ChatBoxShape extends BaseBoxShapeUtil { 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 { setIsMinimized(!isMinimized) } + const handlePinToggle = () => { + this.editor.updateShape({ + id: shape.id, + type: shape.type, + props: { + ...shape.props, + pinnedToView: !shape.props.pinnedToView, + }, + }) + } + return ( { isMinimized={isMinimized} editor={this.editor} shapeId={shape.id} + isPinnedToView={shape.props.pinnedToView} + onPinToggle={handlePinToggle} + tags={shape.props.tags} + onTagsChange={(newTags) => { + this.editor.updateShape({ + id: shape.id, + type: 'ChatBox', + props: { + ...shape.props, + tags: newTags, + } + }) + }} + tagsEditable={true} > @@ -22,6 +31,8 @@ export class FathomMeetingsBrowserShape extends BaseBoxShapeUtil { 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({ + id: shape.id, + type: shape.type, + props: { + ...shape.props, + pinnedToView: !shape.props.pinnedToView, + }, + }) } - return ( - - - { + 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() + 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 + ? `
+

${meetingName}: Fathom Summary

+ ${meetingDate} +
\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 + ? `
+

${meetingName}: Fathom Transcript

+ ${meetingDate} +
\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 + ? `
+

${meetingName}: Fathom Action Items

+ ${meetingDate} +
\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 ( + + - - - ) + 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({ + id: shape.id, + type: 'FathomMeetingsBrowser', + props: { + ...shape.props, + tags: newTags, + } + }) + }} + tagsEditable={true} + > + +
+
+ ) + } + + return } indicator(shape: IFathomMeetingsBrowser) { diff --git a/src/shapes/FathomNoteShapeUtil.tsx b/src/shapes/FathomNoteShapeUtil.tsx new file mode 100644 index 0000000..17e8bca --- /dev/null +++ b/src/shapes/FathomNoteShapeUtil.tsx @@ -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 { + 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({ + 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('') + if (divEndIndex !== -1) { + const htmlHeader = content.substring(0, divEndIndex + 6) // Include + const markdownContent = content.substring(divEndIndex + 6).trim() + + return ( + <> +
+ {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} + ) + }, + { + regex: /\[([^\]]+)\]\(([^)]+)\)/g, + groupCount: 2, + render: (linkText: string, url: string, key: number) => ( + {linkText} + ) + }, + { + regex: /\*\*([^*]+)\*\*/g, + groupCount: 1, + render: (boldText: string, key: number) => ( + {boldText} + ) + }, + ] + + // 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 {text.substring(matchIndex, matchIndex + matchLength)} + } + 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() + 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: () => ( + {italicText} + ) + }) + } + } + + // 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( + + {listItems.map((item, idx) => ( +
  • + {processInlineMarkdown(item)} +
  • + ))} +
    + ) + listItems = [] + listType = null + } + } + + const flushCodeBlock = () => { + if (codeBlockLines.length > 0) { + elements.push( +
    +              {codeBlockLines.join('\n')}
    +            
    + ) + 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( +

    + {processInlineMarkdown(trimmed.substring(2))} +

    + ) + i++ + continue + } + if (trimmed.startsWith('## ')) { + flushList() + flushCodeBlock() + elements.push( +

    + {processInlineMarkdown(trimmed.substring(3))} +

    + ) + i++ + continue + } + if (trimmed.startsWith('### ')) { + flushList() + flushCodeBlock() + elements.push( +

    + {processInlineMarkdown(trimmed.substring(4))} +

    + ) + i++ + continue + } + if (trimmed.startsWith('#### ')) { + flushList() + flushCodeBlock() + elements.push( +

    + {processInlineMarkdown(trimmed.substring(5))} +

    + ) + i++ + continue + } + + // Horizontal rule + if (trimmed === '---' || trimmed === '***' || trimmed === '___') { + flushList() + flushCodeBlock() + elements.push( +
    + ) + i++ + continue + } + + // Blockquote + if (trimmed.startsWith('> ')) { + flushList() + flushCodeBlock() + elements.push( +
    + {processInlineMarkdown(trimmed.substring(2))} +
    + ) + 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(
    ) + i++ + continue + } + + // Regular paragraph + flushList() + flushCodeBlock() + const processed = processInlineMarkdown(trimmed) + elements.push( +

    + {processed} +

    + ) + i++ + } + + // Flush any remaining lists or code blocks + flushList() + flushCodeBlock() + + return elements + } + + return ( + + { + this.editor.updateShape({ + id: shape.id, + type: 'FathomNote', + props: { + ...shape.props, + tags: newTags, + } + }) + }} + tagsEditable={true} + > +
    { + // 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)} +
    + {/* Copy button at bottom right */} + +
    +
    + ) + } + + indicator(shape: IFathomNoteShape) { + return + } + + /** + * 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, + } + } + } +} + diff --git a/src/shapes/FathomTranscriptShapeUtil.tsx b/src/shapes/FathomTranscriptShapeUtil.tsx deleted file mode 100644 index 9401176..0000000 --- a/src/shapes/FathomTranscriptShapeUtil.tsx +++ /dev/null @@ -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 { - 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({ - id: shape.id, - type: 'FathomTranscript', - props: { - ...shape.props, - isExpanded: !isExpanded - } - }) - }, [shape.id, shape.props, isExpanded]) - - const toggleTranscript = useCallback(() => { - this.editor.updateShape({ - id: shape.id, - type: 'FathomTranscript', - props: { - ...shape.props, - showTranscript: !showTranscript - } - }) - }, [shape.id, shape.props, showTranscript]) - - const toggleActionItems = useCallback(() => { - this.editor.updateShape({ - 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 = ( -
    -
    - đŸŽĨ Fathom Meeting - {meetingId && #{meetingId}} -
    -
    - - - -
    -
    - ) - - 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 ( - - - -
    - {/* Meeting Title */} -
    -

    - {meetingTitle || 'Untitled Meeting'} -

    - {meetingUrl && ( - e.stopPropagation()} - > - View in Fathom → - - )} -
    - - {/* Summary */} - {summary && ( -
    -

    - 📋 Summary -

    -
    - {summary} -
    -
    - )} - - {/* Action Items */} - {showActionItems && actionItems.length > 0 && ( -
    -

    - ✅ Action Items ({actionItems.length}) -

    -
    - {actionItems.map((item, index) => ( -
    -
    - {item.text} -
    - {item.assignee && ( -
    - 👤 {item.assignee} -
    - )} - {item.dueDate && ( -
    - 📅 {item.dueDate} -
    - )} -
    - ))} -
    -
    - )} - - {/* Transcript */} - {showTranscript && transcript.length > 0 && ( -
    -

    - đŸ’Ŧ Transcript ({transcript.length} entries) -

    -
    - {transcript.map((entry, index) => ( -
    -
    - - {entry.speaker} - - - {formatTimestamp(entry.timestamp)} - -
    -
    - {entry.text} -
    -
    - ))} -
    -
    - )} - - {/* Empty state */} - {!summary && transcript.length === 0 && actionItems.length === 0 && ( -
    - No meeting data available -
    - )} -
    -
    -
    - ) - } - - indicator(shape: IFathomTranscript) { - return - } -} - - - - - - - - - - - - - - - - - diff --git a/src/shapes/HolonBrowserShapeUtil.tsx b/src/shapes/HolonBrowserShapeUtil.tsx index 48d918a..92d078c 100644 --- a/src/shapes/HolonBrowserShapeUtil.tsx +++ b/src/shapes/HolonBrowserShapeUtil.tsx @@ -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 { return { w: 800, h: 600, + pinnedToView: false, + tags: ['holon', 'browser'], } } @@ -35,6 +40,9 @@ export class HolonBrowserShape extends BaseBoxShapeUtil { 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 { 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 { 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({ + 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 { isMinimized={isMinimized} editor={this.editor} shapeId={shape.id} + isPinnedToView={shape.props.pinnedToView} + onPinToggle={handlePinToggle} + tags={shape.props.tags} + onTagsChange={(newTags) => { + this.editor.updateShape({ + id: shape.id, + type: 'HolonBrowser', + props: { + ...shape.props, + tags: newTags, + } + }) + }} + tagsEditable={true} > 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(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 (