import React from "react" import { createPortal } from "react-dom" import { useParams } from "react-router-dom" import { CustomMainMenu } from "./CustomMainMenu" import { CustomToolbar } from "./CustomToolbar" import { CustomContextMenu } from "./CustomContextMenu" import { FocusLockIndicator } from "./FocusLockIndicator" import { MycelialIntelligenceBar } from "./MycelialIntelligenceBar" import { CommandPalette } from "./CommandPalette" import { NetworkGraphPanel } from "../components/networking" import CryptIDDropdown from "../components/auth/CryptIDDropdown" import StarBoardButton from "../components/StarBoardButton" import ShareBoardButton from "../components/ShareBoardButton" import { SettingsDialog } from "./SettingsDialog" // import { VersionHistoryPanel } from "../components/history" // TODO: Re-enable when version reversion is ready import { useAuth } from "../context/AuthContext" import { PermissionLevel } from "../lib/auth/types" import { WORKER_URL } from "../constants/workerUrl" import { DefaultKeyboardShortcutsDialog, DefaultKeyboardShortcutsDialogContent, TLComponents, TldrawUiMenuItem, useTools, useActions, useDialogs, } from "tldraw" import { SlidesPanel } from "@/slides/SlidesPanel" // AI tool model configurations const AI_TOOLS = [ { id: 'chat', name: 'Chat', icon: '💬', model: 'llama3.1:8b', provider: 'Ollama', type: 'local' }, { id: 'make-real', name: 'Make Real', icon: '🔧', model: 'claude-sonnet-4-5', provider: 'Anthropic', type: 'cloud' }, { id: 'image-gen', name: 'Image Gen', icon: '🎨', model: 'SDXL', provider: 'RunPod', type: 'gpu' }, { id: 'video-gen', name: 'Video Gen', icon: '🎬', model: 'Wan2.1', provider: 'RunPod', type: 'gpu' }, { id: 'transcription', name: 'Transcribe', icon: '🎤', model: 'Web Speech', provider: 'Browser', type: 'local' }, { id: 'mycelial', name: 'Mycelial', icon: '🍄', model: 'llama3.1:70b', provider: 'Ollama', type: 'local' }, ]; // Permission labels and colors const PERMISSION_CONFIG: Record = { view: { label: 'View Only', color: '#6b7280', icon: '👁️' }, edit: { label: 'Edit', color: '#3b82f6', icon: '✏️' }, admin: { label: 'Admin', color: '#10b981', icon: '👑' }, } // Custom SharePanel with layout: CryptID -> Star -> Gear -> Question mark function CustomSharePanel() { const tools = useTools() const actions = useActions() const { addDialog, removeDialog } = useDialogs() const { session } = useAuth() const { slug } = useParams<{ slug: string }>() const boardId = slug || 'mycofi33' const [showShortcuts, setShowShortcuts] = React.useState(false) const [showSettingsDropdown, setShowSettingsDropdown] = React.useState(false) // const [showVersionHistory, setShowVersionHistory] = React.useState(false) // TODO: Re-enable when version reversion is ready const [showAISection, setShowAISection] = React.useState(false) const [hasApiKey, setHasApiKey] = React.useState(false) const [permissionRequestStatus, setPermissionRequestStatus] = React.useState<'idle' | 'sending' | 'sent' | 'error'>('idle') const [requestMessage, setRequestMessage] = React.useState('') // Refs for dropdown positioning const settingsButtonRef = React.useRef(null) const shortcutsButtonRef = React.useRef(null) const [settingsDropdownPos, setSettingsDropdownPos] = React.useState<{ top: number; right: number } | null>(null) const [shortcutsDropdownPos, setShortcutsDropdownPos] = React.useState<{ top: number; right: number } | null>(null) // Get current permission from session // Authenticated users default to 'edit', unauthenticated to 'view' const currentPermission: PermissionLevel = session.currentBoardPermission || (session.authed ? 'edit' : 'view') // Request permission upgrade const handleRequestPermission = async (requestedLevel: PermissionLevel) => { if (!session.authed || !session.username) { setRequestMessage('Please sign in to request permissions') return } setPermissionRequestStatus('sending') try { const response = await fetch(`${WORKER_URL}/boards/${boardId}/permission-request`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ username: session.username, email: session.email, requestedPermission: requestedLevel, currentPermission, boardId, }), }) if (response.ok) { setPermissionRequestStatus('sent') setRequestMessage(`Request for ${PERMISSION_CONFIG[requestedLevel].label} access sent to board admins`) setTimeout(() => { setPermissionRequestStatus('idle') setRequestMessage('') }, 5000) } else { throw new Error('Failed to send request') } } catch (error) { console.error('Permission request error:', error) setPermissionRequestStatus('error') setRequestMessage('Failed to send request. Please try again.') setTimeout(() => { setPermissionRequestStatus('idle') setRequestMessage('') }, 3000) } } // Update dropdown positions when they open React.useEffect(() => { if (showSettingsDropdown && settingsButtonRef.current) { const rect = settingsButtonRef.current.getBoundingClientRect() setSettingsDropdownPos({ top: rect.bottom + 8, right: window.innerWidth - rect.right, }) } }, [showSettingsDropdown]) React.useEffect(() => { if (showShortcuts && shortcutsButtonRef.current) { const rect = shortcutsButtonRef.current.getBoundingClientRect() setShortcutsDropdownPos({ top: rect.bottom + 8, right: window.innerWidth - rect.right, }) } }, [showShortcuts]) // ESC key handler for closing dropdowns React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() e.stopPropagation() if (showSettingsDropdown) setShowSettingsDropdown(false) if (showShortcuts) setShowShortcuts(false) } } if (showSettingsDropdown || showShortcuts) { // Use capture phase to intercept before tldraw document.addEventListener('keydown', handleKeyDown, true) } return () => document.removeEventListener('keydown', handleKeyDown, true) }, [showSettingsDropdown, showShortcuts]) // Detect dark mode - use state to trigger re-render on change const [isDarkMode, setIsDarkMode] = React.useState( typeof document !== 'undefined' && document.documentElement.classList.contains('dark') ) // Check for API keys on mount React.useEffect(() => { const checkApiKeys = () => { const keys = localStorage.getItem('apiKeys') if (keys) { try { const parsed = JSON.parse(keys) setHasApiKey(!!(parsed.openai || parsed.anthropic || parsed.google)) } catch { setHasApiKey(false) } } } checkApiKeys() }, []) const handleToggleDarkMode = () => { const newIsDark = !document.documentElement.classList.contains('dark') document.documentElement.classList.toggle('dark') localStorage.setItem('theme', newIsDark ? 'dark' : 'light') setIsDarkMode(newIsDark) } const handleManageApiKeys = () => { setShowSettingsDropdown(false) addDialog({ id: "api-keys", component: ({ onClose: dialogClose }: { onClose: () => void }) => ( { dialogClose() removeDialog("api-keys") // Recheck API keys after dialog closes const keys = localStorage.getItem('apiKeys') if (keys) { try { const parsed = JSON.parse(keys) setHasApiKey(!!(parsed.openai || parsed.anthropic || parsed.google)) } catch { setHasApiKey(false) } } }} /> ), }) } // Helper to extract label string from tldraw label (can be string or {default, menu} object) const getLabelString = (label: any, fallback: string): string => { if (typeof label === 'string') return label if (label && typeof label === 'object' && 'default' in label) return label.default return fallback } // Collect all tools and actions with keyboard shortcuts const allShortcuts = React.useMemo(() => { const shortcuts: { name: string; kbd: string; category: string }[] = [] // Built-in tools const builtInTools = ['select', 'hand', 'draw', 'eraser', 'arrow', 'text', 'note', 'frame', 'geo', 'line', 'highlight', 'laser'] builtInTools.forEach(toolId => { const tool = tools[toolId] if (tool?.kbd) { shortcuts.push({ name: getLabelString(tool.label, toolId), kbd: tool.kbd, category: 'Tools' }) } }) // Custom tools (VideoGen and Map temporarily hidden) const customToolIds = ['VideoChat', 'ChatBox', 'Embed', 'Slide', 'Markdown', 'MycrozineTemplate', 'Prompt', 'ObsidianNote', 'Transcription', 'Holon', 'FathomMeetings', 'ImageGen', 'Multmux'] customToolIds.forEach(toolId => { const tool = tools[toolId] if (tool?.kbd) { shortcuts.push({ name: getLabelString(tool.label, toolId), kbd: tool.kbd, category: 'Custom Tools' }) } }) // Built-in actions const builtInActionIds = ['undo', 'redo', 'cut', 'copy', 'paste', 'delete', 'select-all', 'duplicate', 'group', 'ungroup', 'bring-to-front', 'send-to-back', 'zoom-in', 'zoom-out', 'zoom-to-fit', 'zoom-to-100', 'toggle-grid'] builtInActionIds.forEach(actionId => { const action = actions[actionId] if (action?.kbd) { shortcuts.push({ name: getLabelString(action.label, actionId), kbd: action.kbd, category: 'Actions' }) } }) // Custom actions const customActionIds = ['copy-link-to-current-view', 'copy-focus-link', 'unlock-camera-focus', 'revert-camera', 'lock-element', 'save-to-pdf', 'search-shapes', 'llm', 'open-obsidian-browser'] customActionIds.forEach(actionId => { const action = actions[actionId] if (action?.kbd) { shortcuts.push({ name: getLabelString(action.label, actionId), kbd: action.kbd, category: 'Custom Actions' }) } }) return shortcuts }, [tools, actions]) // Group shortcuts by category const groupedShortcuts = React.useMemo(() => { const groups: Record = {} allShortcuts.forEach(shortcut => { if (!groups[shortcut.category]) { groups[shortcut.category] = [] } groups[shortcut.category].push(shortcut) }) return groups }, [allShortcuts]) // Separator component for unified menu const Separator = () => (
) return (
{/* Unified menu container - grey oval */}
{/* CryptID dropdown - leftmost */}
{/* Share board button */}
{/* Star board button */}
{/* Settings gear button with dropdown */}
{/* Settings dropdown - rendered via portal to break out of parent container */} {showSettingsDropdown && settingsDropdownPos && createPortal( <> {/* Backdrop - only uses onClick, not onPointerDown */}
setShowSettingsDropdown(false)} /> {/* Dropdown menu */}
e.stopPropagation()} onClick={(e) => e.stopPropagation()} > {/* Board Permission Section */}
{/* Section Header */}
🔐 Board Permission {PERMISSION_CONFIG[currentPermission].label}
{/* Permission levels - indented to show hierarchy */}
Access Levels {(['view', 'edit', 'admin'] as PermissionLevel[]).map((level) => { const config = PERMISSION_CONFIG[level] const isCurrent = currentPermission === level const canRequest = session.authed && !isCurrent && ( (level === 'edit' && currentPermission === 'view') || (level === 'admin' && currentPermission !== 'admin') ) return (
{config.icon} {config.label} {isCurrent && ( Current )} {canRequest && ( )}
) })}
{/* Request status message */} {requestMessage && (

{requestMessage}

)} {!session.authed && (

Sign in to request higher permissions

)}
{/* Appearance Toggle */}
🎨 Appearance {/* Toggle Switch */}
{/* AI Models Accordion */}
{showAISection && (

💡 Local models are free. Cloud models require API keys.

{AI_TOOLS.map((tool) => (
{tool.icon} {tool.name} {tool.model}
))}
)}
{/* Version Reversion - Coming Soon */}
{/* Section Header - matches other headers */}
🕐 Version Reversion
{/* Coming Soon Button */}
, document.body )}
{/* Help/Keyboard shortcuts button - rightmost */}
{/* Keyboard shortcuts panel - rendered via portal to break out of parent container */} {showShortcuts && shortcutsDropdownPos && createPortal( <> {/* Backdrop - only uses onClick, not onPointerDown */}
setShowShortcuts(false)} /> {/* Shortcuts menu */}
e.stopPropagation()} onClick={(e) => e.stopPropagation()} >
Keyboard Shortcuts
{Object.entries(groupedShortcuts).map(([category, shortcuts]) => (
{category}
{shortcuts.map((shortcut, idx) => (
{shortcut.name.replace('tool.', '').replace('action.', '')} {shortcut.kbd.toUpperCase()}
))}
))}
, document.body )} {/* Version Reversion Panel - Coming Soon */} {/* TODO: Re-enable when version history backend is fully tested {showVersionHistory && createPortal( setShowVersionHistory(false)} onRevert={(hash) => { console.log('Reverted to version:', hash) window.location.reload() }} isDarkMode={isDarkMode} />, document.body )} */}
) } // Combined InFrontOfCanvas component for floating UI elements function CustomInFrontOfCanvas() { return ( <> ) } export const components: TLComponents = { Toolbar: CustomToolbar, MainMenu: CustomMainMenu, ContextMenu: CustomContextMenu, HelperButtons: SlidesPanel, SharePanel: CustomSharePanel, InFrontOfTheCanvas: CustomInFrontOfCanvas, KeyboardShortcutsDialog: (props: any) => { const tools = useTools() const actions = useActions() // Get all custom tools with keyboard shortcuts const customTools = [ tools["VideoChat"], tools["ChatBox"], tools["Embed"], tools["Slide"], tools["Markdown"], tools["MycrozineTemplate"], tools["Prompt"], tools["ObsidianNote"], tools["Transcription"], tools["Holon"], tools["FathomMeetings"], tools["ImageGen"], // tools["VideoGen"], // Temporarily hidden tools["Multmux"], // MycelialIntelligence moved to permanent floating bar ].filter(tool => tool && tool.kbd) // Get all custom actions with keyboard shortcuts const customActions = [ actions["zoom-in"], actions["zoom-out"], actions["zoom-to-selection"], actions["copy-link-to-current-view"], actions["copy-focus-link"], actions["unlock-camera-focus"], actions["revert-camera"], actions["lock-element"], actions["save-to-pdf"], actions["search-shapes"], actions["llm"], actions["open-obsidian-browser"], ].filter(action => action && action.kbd) return ( {/* Custom Tools */} {customTools.map(tool => ( ))} {/* Custom Actions */} {customActions.map(action => ( ))} {/* Default content (includes standard TLDraw shortcuts) */} ) }, }