diff --git a/src/ui/CommandPalette.tsx b/src/ui/CommandPalette.tsx index 6bf59f5..eefc659 100644 --- a/src/ui/CommandPalette.tsx +++ b/src/ui/CommandPalette.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useCallback } from "react" import { useEditor } from "tldraw" -// Command Palette that shows when holding Ctrl+Shift +// Command Palette that shows when holding Ctrl+Shift or via global event // Displays available keyboard shortcuts for custom tools and actions interface ShortcutItem { @@ -13,9 +13,15 @@ interface ShortcutItem { category: 'tool' | 'action' } +// Global function to open the command palette +export function openCommandPalette() { + window.dispatchEvent(new CustomEvent('open-command-palette')) +} + export function CommandPalette() { const editor = useEditor() const [isVisible, setIsVisible] = useState(false) + const [isManuallyOpened, setIsManuallyOpened] = useState(false) const holdTimeoutRef = useRef(null) const keysHeldRef = useRef({ ctrl: false, shift: false }) @@ -52,6 +58,7 @@ export function CommandPalette() { // Handle clicking on a tool/action const handleItemClick = useCallback((item: ShortcutItem) => { setIsVisible(false) + setIsManuallyOpened(false) if (item.category === 'tool') { // Set the current tool @@ -71,6 +78,44 @@ export function CommandPalette() { } }, [editor]) + // Handle manual open via custom event + useEffect(() => { + const handleOpenEvent = () => { + setIsManuallyOpened(true) + setIsVisible(true) + } + + window.addEventListener('open-command-palette', handleOpenEvent) + return () => window.removeEventListener('open-command-palette', handleOpenEvent) + }, []) + + // Handle Escape key and click outside to close when manually opened + useEffect(() => { + if (!isManuallyOpened) return + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setIsVisible(false) + setIsManuallyOpened(false) + } + } + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement + if (target.closest('.command-palette')) return + setIsVisible(false) + setIsManuallyOpened(false) + } + + window.addEventListener('keydown', handleEscape) + window.addEventListener('mousedown', handleClickOutside) + + return () => { + window.removeEventListener('keydown', handleEscape) + window.removeEventListener('mousedown', handleClickOutside) + } + }, [isManuallyOpened]) + // Handle Ctrl+Shift key press/release useEffect(() => { const checkAndShowPalette = () => { @@ -93,8 +138,8 @@ export function CommandPalette() { } else if (e.key === 'Shift') { keysHeldRef.current.shift = true checkAndShowPalette() - } else if (isVisible) { - // Hide on any other key press (they're using a shortcut) + } else if (isVisible && !isManuallyOpened) { + // Hide on any other key press (they're using a shortcut) - only if not manually opened setIsVisible(false) } } @@ -106,8 +151,8 @@ export function CommandPalette() { keysHeldRef.current.shift = false } - // Hide palette if either key is released - if (!keysHeldRef.current.ctrl || !keysHeldRef.current.shift) { + // Hide palette if either key is released - only if not manually opened + if (!isManuallyOpened && (!keysHeldRef.current.ctrl || !keysHeldRef.current.shift)) { if (holdTimeoutRef.current) { clearTimeout(holdTimeoutRef.current) holdTimeoutRef.current = null @@ -126,7 +171,7 @@ export function CommandPalette() { clearTimeout(holdTimeoutRef.current) } } - }, [isVisible]) + }, [isVisible, isManuallyOpened]) if (!isVisible) return null @@ -146,7 +191,7 @@ export function CommandPalette() { alignItems: 'center', justifyContent: 'center', zIndex: 999999, - pointerEvents: 'none', + pointerEvents: isManuallyOpened ? 'auto' : 'none', animation: 'fadeIn 0.15s ease-out', }} > diff --git a/src/ui/components.tsx b/src/ui/components.tsx index c9ffcfd..86e98b5 100644 --- a/src/ui/components.tsx +++ b/src/ui/components.tsx @@ -6,7 +6,7 @@ import { CustomToolbar } from "./CustomToolbar" import { CustomContextMenu } from "./CustomContextMenu" import { FocusLockIndicator } from "./FocusLockIndicator" import { MycelialIntelligenceBar } from "./MycelialIntelligenceBar" -import { CommandPalette } from "./CommandPalette" +import { CommandPalette, openCommandPalette } from "./CommandPalette" import { NetworkGraphPanel } from "../components/networking" import CryptIDDropdown from "../components/auth/CryptIDDropdown" import StarBoardButton from "../components/StarBoardButton" @@ -47,14 +47,11 @@ const PERMISSION_CONFIG: Record 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) @@ -73,9 +70,7 @@ function CustomSharePanel() { // 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' @@ -136,16 +131,6 @@ function CustomSharePanel() { } }, [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) => { @@ -153,15 +138,14 @@ function CustomSharePanel() { e.preventDefault() e.stopPropagation() if (showSettingsDropdown) setShowSettingsDropdown(false) - if (showShortcuts) setShowShortcuts(false) } } - if (showSettingsDropdown || showShortcuts) { + if (showSettingsDropdown) { // Use capture phase to intercept before tldraw document.addEventListener('keydown', handleKeyDown, true) } return () => document.removeEventListener('keydown', handleKeyDown, true) - }, [showSettingsDropdown, showShortcuts]) + }, [showSettingsDropdown]) // Detect dark mode - use state to trigger re-render on change const [isDarkMode, setIsDarkMode] = React.useState( @@ -359,84 +343,6 @@ function CustomSharePanel() { } } - // 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 = () => (
- {/* Help/Keyboard shortcuts button - rightmost */} + {/* Help/Keyboard shortcuts button - rightmost - opens Command Palette */}
- {/* 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(