import React, { useState, useRef, useEffect, useCallback } from 'react' import { BaseBoxShapeUtil, TLBaseShape, TLShapeId, createShapeId, IndexKey, TLParentId, HTMLContainer } from '@tldraw/tldraw' import { MDXEditor, headingsPlugin, listsPlugin, quotePlugin, thematicBreakPlugin, markdownShortcutPlugin, linkPlugin, linkDialogPlugin, imagePlugin, tablePlugin, codeBlockPlugin, codeMirrorPlugin, diffSourcePlugin, toolbarPlugin, BoldItalicUnderlineToggles, UndoRedo, BlockTypeSelect, CreateLink, InsertTable, ListsToggle, Separator, DiffSourceToggleWrapper, type MDXEditorMethods, } from '@mdxeditor/editor' import '@mdxeditor/editor/style.css' import { ObsidianObsNote } from '@/lib/obsidianImporter' import { QuartzSync, createQuartzNoteFromShape, QuartzSyncConfig } from '@/lib/quartzSync' import { logGitHubSetupStatus } from '@/lib/githubSetupValidator' import { getClientConfig } from '@/lib/clientConfig' import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper' import { usePinnedToView } from '../hooks/usePinnedToView' import { useMaximize } from '../hooks/useMaximize' // Main ObsNote component with full markdown editing const ObsNoteComponent: React.FC<{ shape: IObsNoteShape shapeUtil: ObsNoteShape }> = ({ shape, shapeUtil }) => { const isSelected = shapeUtil.editor.getSelectedShapeIds().includes(shape.id) const [isEditingTitle, setIsEditingTitle] = useState(false) const [editingTitle, setEditingTitle] = useState(shape.props.title || 'Untitled') const [isSyncing, setIsSyncing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) const [isMinimized, setIsMinimized] = useState(false) const [isCopying, setIsCopying] = useState(false) const editorRef = useRef(null) // Use the pinning hook to keep the shape fixed to viewport when pinned usePinnedToView(shapeUtil.editor, shape.id, shape.props.pinnedToView) // Use the maximize hook for fullscreen functionality const { isMaximized, toggleMaximize } = useMaximize({ editor: shapeUtil.editor, shapeId: shape.id, currentW: shape.props.w, currentH: shape.props.h, shapeType: 'ObsNote', }) // Track content changes for sync button visibility const [hasUnsavedChanges, setHasUnsavedChanges] = useState(shape.props.isModified) // Notification state for in-shape notifications const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error' } | null>(null) // Auto-hide notification after 3 seconds useEffect(() => { if (notification) { const timer = setTimeout(() => { setNotification(null) }, 3000) return () => clearTimeout(timer) } }, [notification]) // Sync external changes to editor (e.g., from vault refresh) useEffect(() => { if (editorRef.current) { const currentMarkdown = editorRef.current.getMarkdown() if (currentMarkdown !== shape.props.content) { editorRef.current.setMarkdown(shape.props.content || '') } } }, [shape.props.content]) // Update hasUnsavedChanges when shape.props.isModified changes useEffect(() => { setHasUnsavedChanges(shape.props.isModified) }, [shape.props.isModified]) const handleContentChange = useCallback((newContent: string) => { const hasChanged = newContent !== shape.props.originalContent setHasUnsavedChanges(hasChanged) const sanitizedProps = ObsNoteShape.sanitizeProps({ ...shape.props, content: newContent, isModified: hasChanged, }) shapeUtil.editor.updateShape({ id: shape.id, type: 'ObsNote', props: sanitizedProps }) }, [shape.id, shape.props, shapeUtil.editor]) const handleStartTitleEdit = () => { setIsEditingTitle(true) setEditingTitle(shape.props.title || 'Untitled') } const handleSaveTitleEdit = () => { if (editingTitle.trim() !== shape.props.title) { const sanitizedProps = ObsNoteShape.sanitizeProps({ ...shape.props, title: editingTitle.trim(), isModified: true }) shapeUtil.editor.updateShape({ id: shape.id, type: 'ObsNote', props: sanitizedProps }) setHasUnsavedChanges(true) } setIsEditingTitle(false) } const handleCancelTitleEdit = () => { setEditingTitle(shape.props.title || 'Untitled') setIsEditingTitle(false) } const handleTitleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault() handleSaveTitleEdit() } else if (e.key === 'Escape') { e.preventDefault() handleCancelTitleEdit() } } const handleRefresh = async () => { if (isRefreshing) return setIsRefreshing(true) try { const success = await shapeUtil.refreshFromVault(shape.id) if (success) { setNotification({ message: '✅ Note restored from vault', type: 'success' }) setHasUnsavedChanges(false) } else { setNotification({ message: '❌ Failed to restore note', type: 'error' }) } } catch (error) { console.error('❌ Refresh failed:', error) setNotification({ message: `Refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}`, type: 'error' }) } finally { setIsRefreshing(false) } } const handleCopy = async () => { if (isCopying) return setIsCopying(true) try { const contentToCopy = shape.props.content || '' if (!contentToCopy.trim()) { setNotification({ message: '⚠️ No content to copy', type: 'error' }) setIsCopying(false) return } await navigator.clipboard.writeText(contentToCopy) setNotification({ message: '✅ Content copied to clipboard', type: 'success' }) } catch (error) { console.error('❌ Copy failed:', error) setNotification({ message: `Copy failed: ${error instanceof Error ? error.message : 'Unknown error'}`, type: 'error' }) } finally { setIsCopying(false) } } const handleSync = async () => { if (isSyncing) return setIsSyncing(true) try { // Get the current content from the editor const contentToSync = editorRef.current?.getMarkdown() || shape.props.content || '' const titleToSync = isEditingTitle ? editingTitle : (shape.props.title || 'Untitled') // If we're editing title, save that first if (isEditingTitle) { const sanitizedProps = ObsNoteShape.sanitizeProps({ ...shape.props, title: editingTitle, }) shapeUtil.editor.updateShape({ id: shape.id, type: 'ObsNote', props: sanitizedProps }) setIsEditingTitle(false) } // Get fresh shape reference for vault info and other properties const currentShape = shapeUtil.editor.getShape(shape.id) as IObsNoteShape if (!currentShape) { throw new Error('Shape not found') } let vaultPath = currentShape.props.vaultPath let vaultName = currentShape.props.vaultName if (!vaultPath || !vaultName) { // Try to get vault info from session if not in shape props const sessionData = localStorage.getItem('canvas_auth_session') if (sessionData) { try { const session = JSON.parse(sessionData) if (session.obsidianVaultPath && session.obsidianVaultName) { // Update the shape with vault info for future syncs const sanitizedProps = ObsNoteShape.sanitizeProps({ ...currentShape.props, vaultPath: session.obsidianVaultPath, vaultName: session.obsidianVaultName, }) shapeUtil.editor.updateShape({ id: currentShape.id, type: 'ObsNote', props: sanitizedProps }) vaultPath = session.obsidianVaultPath vaultName = session.obsidianVaultName } } catch (e) { console.error('Failed to parse session data:', e) } } if (!vaultPath || !vaultName) { throw new Error('No vault configured for sync. Please configure a vault in settings first.') } } // Determine if this is a Quartz URL or local vault const isQuartzVault = vaultPath.startsWith('http') || vaultPath.includes('quartz') || vaultPath.includes('.xyz') || vaultPath.includes('.com') if (isQuartzVault) { // Use the Quartz sync system try { logGitHubSetupStatus() // Create a temporary shape object with the latest content for sync const shapeForSync = { ...currentShape, props: { ...currentShape.props, content: contentToSync, title: titleToSync, } } const quartzNote = createQuartzNoteFromShape(shapeForSync) const config = getClientConfig() const syncConfig: QuartzSyncConfig = { githubToken: config.githubToken, githubRepo: config.quartzRepo, quartzUrl: vaultPath, cloudflareApiKey: config.cloudflareApiKey, cloudflareAccountId: config.cloudflareAccountId } const quartzSync = new QuartzSync(syncConfig) const syncSuccess = await quartzSync.smartSync(quartzNote) if (syncSuccess) { setNotification({ message: '✅ Note synced to Quartz!', type: 'success' }) } else { throw new Error('All sync methods failed') } } catch (error) { console.error('❌ Quartz sync failed:', error) // Fallback to local storage const quartzStorageKey = `quartz_vault_${vaultName}_${currentShape.props.noteId || titleToSync}` const tags = currentShape.props.tags || [] const frontmatter = `--- title: "${titleToSync}" tags: [${tags.map(tag => `"${tag.replace('#', '')}"`).join(', ')}] created: ${new Date().toISOString()} modified: ${new Date().toISOString()} quartz_url: "${vaultPath}" --- ${contentToSync}` localStorage.setItem(quartzStorageKey, frontmatter) setNotification({ message: '⚠️ Stored locally as fallback', type: 'error' }) } } else { // For local vaults, try to write using File System Access API let fileName: string if (currentShape.props.filePath && currentShape.props.filePath.trim() !== '') { const pathParts = currentShape.props.filePath.split('/') fileName = pathParts[pathParts.length - 1] if (!fileName.endsWith('.md')) { fileName = `${fileName}.md` } } else { fileName = `${titleToSync.replace(/[^a-zA-Z0-9]/g, '_')}.md` } const tags = currentShape.props.tags || [] const frontmatter = `--- title: "${titleToSync}" tags: [${tags.map(tag => `"${tag.replace('#', '')}"`).join(', ')}] created: ${new Date().toISOString()} modified: ${new Date().toISOString()} --- ${contentToSync}` try { if ('showSaveFilePicker' in window) { const fileHandle = await (window as any).showSaveFilePicker({ suggestedName: fileName, types: [{ description: 'Markdown files', accept: { 'text/markdown': ['.md'] } }] }) const writable = await fileHandle.createWritable() await writable.write(frontmatter) await writable.close() setNotification({ message: `✅ Saved as ${fileName}`, type: 'success' }) } else { // Fallback: download the file const blob = new Blob([frontmatter], { type: 'text/markdown' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = fileName document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) setNotification({ message: '✅ File downloaded!', type: 'success' }) } } catch (error) { console.error('❌ Failed to write local vault file:', error) const localStorageKey = `local_vault_${vaultName}_${currentShape.props.noteId || titleToSync}` localStorage.setItem(localStorageKey, frontmatter) setNotification({ message: '⚠️ Stored locally as fallback', type: 'error' }) } } // Mark as synced const finalShape = shapeUtil.editor.getShape(shape.id) as IObsNoteShape if (finalShape) { const sanitizedProps = ObsNoteShape.sanitizeProps({ ...finalShape.props, content: contentToSync, isModified: false, originalContent: contentToSync, }) shapeUtil.editor.updateShape({ id: finalShape.id, type: 'ObsNote', props: sanitizedProps }) setHasUnsavedChanges(false) } } catch (error) { console.error('❌ Sync failed:', error) setNotification({ message: `Sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`, type: 'error' }) } finally { setIsSyncing(false) } } const handleMinimize = () => { setIsMinimized(!isMinimized) } const handleClose = () => { shapeUtil.editor.deleteShape(shape.id) } const handlePinToggle = () => { shapeUtil.editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props, pinnedToView: !shape.props.pinnedToView, }, }) } // Custom header content with editable title const headerContent = (
{isEditingTitle ? ( setEditingTitle(e.target.value)} onBlur={handleSaveTitleEdit} onKeyDown={handleTitleKeyDown} onPointerDown={(e) => e.stopPropagation()} style={{ border: '1px solid #007acc', borderRadius: '4px', padding: '4px 8px', backgroundColor: 'white', outline: 'none', fontSize: '14px', fontWeight: 'bold', color: shape.props.textColor, flex: 1, minWidth: 0, }} autoFocus /> ) : (

{ e.stopPropagation() handleStartTitleEdit() }} onPointerDown={(e) => e.stopPropagation()} onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.2)' }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent' }} > {shape.props.title}

)}
) return ( { const sanitizedProps = ObsNoteShape.sanitizeProps({ ...shape.props, tags: newTags, isModified: true, }) shapeUtil.editor.updateShape({ id: shape.id, type: 'ObsNote', props: sanitizedProps }) setHasUnsavedChanges(true) }} tagsEditable={true} > {/* MDXEditor container */}
e.stopPropagation()} onWheel={(e) => e.stopPropagation()} > { return Promise.resolve('https://via.placeholder.com/400x300') }, }), markdownShortcutPlugin(), diffSourcePlugin({ viewMode: 'rich-text', diffMarkdown: shape.props.content || '', }), toolbarPlugin({ toolbarContents: () => ( <> <> ) }), ]} /> {/* Notification display */} {notification && ( <>
{notification.message}
)}
{/* Bottom action buttons */}
{/* Refresh from vault button */} {/* Copy button */} {/* Save changes button - shown when there are modifications */} {hasUnsavedChanges && ( )}
{/* Custom styles for the MDXEditor */}
) } export type IObsNoteShape = TLBaseShape< 'ObsNote', { w: number h: number color: string size: string font: string textAlign: string scale: number noteId: string title: string content: string tags: string[] showPreview: boolean backgroundColor: string textColor: string isEditing: boolean editingContent: string isModified: boolean originalContent: string vaultPath?: string vaultName?: string filePath?: string pinnedToView: boolean primaryColor?: string } > export class ObsNoteShape extends BaseBoxShapeUtil { static override type = 'ObsNote' // Obsidian Note theme color: Purple (matches Obsidian branding) static readonly PRIMARY_COLOR = "#9333ea" /** * Sanitize props to ensure all values are JSON serializable */ public static sanitizeProps(props: Partial): IObsNoteShape['props'] { const tags = Array.isArray(props.tags) ? props.tags.filter(tag => typeof tag === 'string').map(tag => String(tag)) : [] const sanitized: IObsNoteShape['props'] = { w: typeof props.w === 'number' ? props.w : 500, h: typeof props.h === 'number' ? props.h : 400, color: typeof props.color === 'string' ? props.color : 'black', size: typeof props.size === 'string' ? props.size : 'm', font: typeof props.font === 'string' ? props.font : 'sans', textAlign: typeof props.textAlign === 'string' ? props.textAlign : 'start', scale: typeof props.scale === 'number' ? props.scale : 1, noteId: typeof props.noteId === 'string' ? props.noteId : '', title: typeof props.title === 'string' ? props.title : 'Untitled ObsNote', content: typeof props.content === 'string' ? props.content : '', tags: tags.length > 0 ? tags : ['obsidian note', 'markdown'], showPreview: typeof props.showPreview === 'boolean' ? props.showPreview : true, backgroundColor: typeof props.backgroundColor === 'string' ? props.backgroundColor : '#ffffff', textColor: typeof props.textColor === 'string' ? props.textColor : '#000000', isEditing: typeof props.isEditing === 'boolean' ? props.isEditing : false, editingContent: typeof props.editingContent === 'string' ? props.editingContent : '', isModified: typeof props.isModified === 'boolean' ? props.isModified : false, originalContent: typeof props.originalContent === 'string' ? props.originalContent : '', pinnedToView: typeof props.pinnedToView === 'boolean' ? props.pinnedToView : false, } if (props.vaultPath !== undefined && typeof props.vaultPath === 'string') { sanitized.vaultPath = props.vaultPath } if (props.vaultName !== undefined && typeof props.vaultName === 'string') { sanitized.vaultName = props.vaultName } if (props.filePath !== undefined && typeof props.filePath === 'string') { sanitized.filePath = props.filePath } if (props.primaryColor !== undefined && typeof props.primaryColor === 'string') { sanitized.primaryColor = props.primaryColor } return sanitized } getDefaultProps(): IObsNoteShape['props'] { return ObsNoteShape.sanitizeProps({}) } component(shape: IObsNoteShape) { return } indicator(shape: IObsNoteShape) { return } /** * Create an obs_note shape from an ObsidianObsNote */ static createFromObsidianObsNote(obs_note: ObsidianObsNote, x: number = 0, y: number = 0, id?: TLShapeId, vaultPath?: string, vaultName?: string): IObsNoteShape { const obsidianTags = obs_note.tags && obs_note.tags.length > 0 ? obs_note.tags : ['obsidian note', 'markdown'] const props = ObsNoteShape.sanitizeProps({ w: 500, h: 400, color: 'black', size: 'm', font: 'sans', textAlign: 'start', scale: 1, noteId: obs_note.id || '', title: obs_note.title || 'Untitled', content: obs_note.content || '', tags: obsidianTags, showPreview: true, backgroundColor: '#ffffff', textColor: '#000000', isEditing: false, editingContent: '', isModified: false, originalContent: obs_note.content || '', vaultPath: vaultPath, vaultName: vaultName, filePath: obs_note.filePath, }) return { id: id || createShapeId(), type: 'ObsNote', x, y, rotation: 0, index: 'a1' as IndexKey, parentId: 'page:page' as TLParentId, isLocked: false, opacity: 1, meta: {}, typeName: 'shape', props } } /** * Update obs_note content */ updateObsNoteContent(shapeId: string, content: string) { const shape = this.editor.getShape(shapeId as TLShapeId) as IObsNoteShape if (!shape) return const sanitizedProps = ObsNoteShape.sanitizeProps({ ...shape.props, content, }) this.editor.updateShape({ id: shapeId as TLShapeId, type: 'ObsNote', props: sanitizedProps }) } /** * Update obs_note styling */ updateStyling(shapeId: string, backgroundColor: string, textColor: string) { const shape = this.editor.getShape(shapeId as TLShapeId) as IObsNoteShape if (!shape) return const sanitizedProps = ObsNoteShape.sanitizeProps({ ...shape.props, backgroundColor, textColor, }) this.editor.updateShape({ id: shapeId as TLShapeId, type: 'ObsNote', props: sanitizedProps }) } /** * Refresh obs_note content from vault */ async refreshFromVault(shapeId: string): Promise { const shape = this.editor.getShape(shapeId as TLShapeId) as IObsNoteShape if (!shape) return false try { const { ObsidianImporter } = await import('@/lib/obsidianImporter') const importer = new ObsidianImporter() let vaultPath = shape.props.vaultPath let vaultName = shape.props.vaultName if (!vaultPath || !vaultName) { const sessionData = localStorage.getItem('canvas_auth_session') if (sessionData) { const session = JSON.parse(sessionData) vaultPath = session.obsidianVaultPath vaultName = session.obsidianVaultName } } if (!vaultPath || !vaultName) { console.error('No vault configured for refresh') return false } let vault: any if (vaultPath.startsWith('http') || vaultPath.includes('quartz') || vaultPath.includes('.xyz') || vaultPath.includes('.com')) { vault = await importer.importFromQuartzUrl(vaultPath) } else { vault = await importer.importFromDirectory(vaultPath) } const matchingNotes = vault.obs_notes.filter((note: any) => note.id === shape.props.noteId) if (matchingNotes.length === 0) { return false } let updatedNote = matchingNotes[0] if (matchingNotes.length > 1) { const withoutQuotes = matchingNotes.find((note: any) => !note.filePath?.includes('"')) if (withoutQuotes) { updatedNote = withoutQuotes } else { updatedNote = matchingNotes.reduce((best: any, current: any) => current.content?.length > best.content?.length ? current : best ) } } const sanitizedProps = ObsNoteShape.sanitizeProps({ ...shape.props, title: updatedNote.title, content: updatedNote.content, tags: updatedNote.tags, originalContent: updatedNote.content, isModified: false, filePath: updatedNote.filePath || shape.props.filePath, }) this.editor.updateShape({ id: shapeId as TLShapeId, type: 'ObsNote', props: sanitizedProps }) return true } catch (error) { console.error('❌ Failed to refresh ObsNote from vault:', error) return false } } /** * Refresh all ObsNote shapes on the current page */ async refreshAllFromVault(): Promise<{ success: number; failed: number }> { const allShapes = this.editor.getCurrentPageShapes() const obsNoteShapes = allShapes.filter(shape => shape.type === 'ObsNote') let success = 0 let failed = 0 for (const shape of obsNoteShapes) { const result = await this.refreshFromVault(shape.id) if (result) { success++ } else { failed++ } } return { success, failed } } }