Files
standardnotes-app-web/packages/web/src/javascripts/Components/CommandPalette/CommandPalette.tsx

427 lines
14 KiB
TypeScript

import { observer } from 'mobx-react-lite'
import { startTransition, useCallback, useEffect, useState } from 'react'
import { useKeyboardService } from '../KeyboardServiceProvider'
import { PlatformedKeyboardShortcut, TOGGLE_COMMAND_PALETTE } from '@standardnotes/ui-services'
import {
Combobox,
ComboboxGroup,
ComboboxGroupLabel,
ComboboxItem,
ComboboxList,
ComboboxProvider,
Dialog,
Tab,
TabList,
TabPanel,
TabProvider,
useDialogStore,
useTabContext,
} from '@ariakit/react'
import {
classNames,
DecryptedItemInterface,
FileItem,
SmartView,
SNNote,
SNTag,
UuidGenerator,
} from '@standardnotes/snjs'
import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/KeyboardShortcutIndicator'
import { useApplication } from '../ApplicationProvider'
import Icon from '../Icon/Icon'
import { getIconForItem } from '../../Utils/Items/Icons/getIconForItem'
import { FileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
import type { CommandService } from './CommandService'
import { requestCloseAllOpenModalsAndPopovers } from '../../Utils/CloseOpenModalsAndPopovers'
type CommandPaletteItem = {
id: string
description: string
icon: JSX.Element
shortcut?: PlatformedKeyboardShortcut
resultRange?: [number, number]
} & ({ section: 'notes' | 'files' | 'tags'; itemUuid: string } | { section: 'commands' })
function ListItemDescription({ item }: { item: CommandPaletteItem }) {
const range = item.resultRange
if (!range) {
return item.description
}
return (
<>
{item.description.slice(0, range[0])}
<span className="rounded-sm bg-[color-mix(in_srgb,var(--sn-stylekit-accessory-tint-color-1),rgba(255,255,255,.1))] p-px">
{item.description.slice(range[0], range[1])}
</span>
{item.description.slice(range[1])}
</>
)
}
const Tabs = ['all', 'commands', 'notes', 'files', 'tags'] as const
type TabId = (typeof Tabs)[number]
function CommandPaletteListItem({
id,
item,
index,
handleClick,
selectedTab,
}: {
id: string
item: CommandPaletteItem
index: number
handleClick: (item: CommandPaletteItem) => void
selectedTab: TabId
}) {
if (selectedTab !== 'all' && selectedTab !== item.section) {
return null
}
return (
<ComboboxItem
id={id}
value={item.id}
hideOnClick={true}
focusOnHover={true}
blurOnHoverEnd={false}
className={classNames(
'flex scroll-m-2 items-center gap-2 whitespace-nowrap rounded-md px-2 py-2.5 text-[0.95rem] data-[active-item]:bg-info data-[active-item]:text-info-contrast [&>svg]:flex-shrink-0',
index === 0 && 'scroll-m-8',
)}
onClick={() => handleClick(item)}
>
{item.icon}
<div className="mr-auto overflow-hidden text-ellipsis whitespace-nowrap leading-none">
<ListItemDescription item={item} />
</div>
{item.shortcut && <KeyboardShortcutIndicator className="ml-auto" shortcut={item.shortcut} small={false} />}
</ComboboxItem>
)
}
function ComboboxInput() {
const tab = useTabContext()
return (
<Combobox
autoSelect="always"
className="h-10 w-full appearance-none bg-transparent px-1 text-base focus:shadow-none focus:outline-none"
placeholder="Search notes, files, commands, etc..."
onKeyDown={(event) => {
if (event.key !== 'Tab') {
return
}
const activeId = tab?.getState().selectedId
const options = { activeId }
const nextId = event.shiftKey ? tab?.previous(options) : tab?.next(options)
if (nextId) {
event.preventDefault()
tab?.select(nextId)
}
}}
/>
)
}
// Future TODO, nice to have: A way to expose items like the current note's options
// directly in the command palette rather than only having a way to open the menu
function CommandPalette() {
const application = useApplication()
const keyboardService = useKeyboardService()
const [isOpen, setIsOpen] = useState(false)
const [query, setQuery] = useState('')
const [recents, setRecents] = useState<CommandPaletteItem[]>([])
const [items, setItems] = useState<CommandPaletteItem[]>([])
// Storing counts as separate state to avoid iterating items multiple times
const [itemCountsPerTab, setItemCounts] = useState({
commands: 0,
notes: 0,
files: 0,
tags: 0,
})
const [selectedTab, setSelectedTab] = useState<TabId>('all')
const dialog = useDialogStore({
open: isOpen,
setOpen: setIsOpen,
})
useEffect(() => {
if (isOpen) {
keyboardService.disableEventHandling()
requestCloseAllOpenModalsAndPopovers()
} else {
keyboardService.enableEventHandling()
}
}, [keyboardService, isOpen])
useEffect(() => {
return keyboardService.addCommandHandler({
command: TOGGLE_COMMAND_PALETTE,
category: 'General',
description: 'Toggle command palette',
onKeyDown: (e) => {
e.preventDefault()
setIsOpen((open) => !open)
setQuery('')
},
})
}, [keyboardService])
const handleItemClick = useCallback(
(item: CommandPaletteItem) => {
if (item.section === 'commands') {
application.commands.triggerCommand(item.id)
application.recents.add(item.id, 'command')
} else {
const decryptedItem = application.items.findItem<DecryptedItemInterface>(item.itemUuid)
if (!decryptedItem) {
return
}
if (decryptedItem instanceof SNNote) {
void application.navigationController.selectHomeNavigationView()
void application.itemListController.selectItemUsingInstance(decryptedItem, true)
} else if (decryptedItem instanceof FileItem) {
void application.filesController.handleFileAction({
type: FileItemActionType.PreviewFile,
payload: { file: decryptedItem },
})
} else if (decryptedItem instanceof SNTag || decryptedItem instanceof SmartView) {
void application.navigationController.setSelectedTag(decryptedItem, 'all', {
userTriggered: true,
})
}
}
},
[
application.commands,
application.filesController,
application.itemListController,
application.items,
application.navigationController,
application.recents,
],
)
const createItemForInteractableItem = useCallback(
(item: DecryptedItemInterface): CommandPaletteItem => {
const icon = getIconForItem(item, application)
let section: 'notes' | 'files' | 'tags'
if (item instanceof SNNote) {
section = 'notes'
} else if (item instanceof FileItem) {
section = 'files'
} else if (item instanceof SNTag || item instanceof SmartView) {
section = 'tags'
} else {
throw new Error('Item is not a note, file or tag')
}
return {
section,
id: UuidGenerator.GenerateUuid(),
itemUuid: item.uuid,
description: item.title || '<no title>',
icon: <Icon type={icon[0]} className={item instanceof SNNote ? icon[1] : ''} />,
}
},
[application],
)
const createItemForCommand = useCallback(
(command: ReturnType<CommandService['getCommandDescriptions']>[0]): CommandPaletteItem => {
const shortcut = command.shortcut_id
? application.keyboardService.keyboardShortcutForCommand(command.shortcut_id)
: undefined
return {
id: command.id,
description: command.description,
section: 'commands',
icon: <Icon type={command.icon} />,
shortcut,
}
},
[application.keyboardService],
)
useEffect(
function updateCommandPaletteItems() {
if (!isOpen) {
setSelectedTab('all')
setItems([])
return
}
const recents: CommandPaletteItem[] = []
const items: CommandPaletteItem[] = []
const itemCounts: typeof itemCountsPerTab = {
commands: 0,
notes: 0,
files: 0,
tags: 0,
}
const searchQuery = query.toLowerCase()
const hasQuery = searchQuery.length > 0
if (hasQuery) {
const commands = application.commands.getCommandDescriptions()
for (let i = 0; i < commands.length; i++) {
const command = commands[i]
if (!command) {
continue
}
if (items.length >= 50) {
break
}
const index = command.description.toLowerCase().indexOf(searchQuery)
if (index === -1) {
continue
}
const item = createItemForCommand(command)
item.resultRange = [index, index + searchQuery.length]
items.push(item)
itemCounts[item.section]++
}
const interactableItems = application.items.getInteractableItems()
for (let i = 0; i < interactableItems.length; i++) {
if (items.length >= 50) {
break
}
const decryptedItem = interactableItems[i]
if (!decryptedItem || !decryptedItem.title) {
continue
}
const index = decryptedItem.title.toLowerCase().indexOf(searchQuery)
if (index === -1) {
continue
}
const item = createItemForInteractableItem(decryptedItem)
item.resultRange = [index, index + searchQuery.length]
items.push(item)
itemCounts[item.section]++
}
} else {
const recentCommands = application.recents.commandUuids
for (let i = 0; i < recentCommands.length; i++) {
const command = application.commands.getCommandDescription(recentCommands[i])
if (!command) {
continue
}
const item = createItemForCommand(command)
recents.push(item)
itemCounts[item.section]++
}
const recentItems = application.recents.itemUuids
for (let i = 0; i < recentItems.length; i++) {
const decryptedItem = application.items.findItem(recentItems[i])
if (!decryptedItem) {
continue
}
const item = createItemForInteractableItem(decryptedItem)
recents.push(item)
itemCounts[item.section]++
}
const commands = application.commands.getCommandDescriptions()
for (let i = 0; i < commands.length; i++) {
const command = commands[i]
if (!command) {
continue
}
const item = createItemForCommand(command)
items.push(item)
itemCounts[item.section]++
}
items.sort((a, b) => (a.description.toLowerCase() < b.description.toLowerCase() ? -1 : 1))
}
setItems(items)
setRecents(recents)
setItemCounts(itemCounts)
},
[application, createItemForCommand, createItemForInteractableItem, isOpen, query],
)
const hasNoItemsAtAll = items.length === 0
const hasNoItemsInSelectedTab = selectedTab !== 'all' && itemCountsPerTab[selectedTab] === 0
return (
<Dialog
store={dialog}
className="fixed inset-3 bottom-[10vh] top-[10vh] z-modal m-auto mt-0 flex h-fit max-h-[70vh] w-[min(45rem,90vw)] flex-col gap-3 overflow-auto rounded-xl border border-[--popover-border-color] bg-[--popover-background-color] px-3 py-3 shadow-main [backdrop-filter:var(--popover-backdrop-filter)]"
backdrop={<div className="bg-passive-5 opacity-50 transition-opacity duration-75 data-[enter]:opacity-85" />}
>
<ComboboxProvider
disclosure={dialog}
includesBaseElement={false}
resetValueOnHide={true}
setValue={(value) => {
startTransition(() => setQuery(value))
}}
>
<TabProvider selectedId={selectedTab} setSelectedId={(id) => setSelectedTab((id as TabId) || 'all')}>
<div className="flex rounded-lg border border-[--popover-border-color] bg-[--popover-background-color] px-2">
<ComboboxInput />
</div>
<TabList className="flex items-center gap-1">
{Tabs.map((id) => (
<Tab
key={id}
id={id}
className="rounded-full px-3 py-1 capitalize disabled:opacity-65 aria-selected:bg-info aria-selected:text-info-contrast data-[active-item]:ring-1 data-[active-item]:ring-info data-[active-item]:ring-offset-1 data-[active-item]:ring-offset-transparent"
disabled={hasNoItemsAtAll || (id !== 'all' && itemCountsPerTab[id] === 0)}
accessibleWhenDisabled={false}
>
{id}
</Tab>
))}
</TabList>
<TabPanel className="flex flex-col gap-1.5 overflow-y-auto" tabId={selectedTab}>
{query.length > 0 && (hasNoItemsAtAll || hasNoItemsInSelectedTab) && (
<div className="mx-auto px-2 text-sm font-semibold opacity-75">No items found</div>
)}
<ComboboxList className="focus:shadow-none focus:outline-none">
{recents.length > 0 && (
<ComboboxGroup>
<ComboboxGroupLabel className="px-2 font-semibold opacity-75">Recent</ComboboxGroupLabel>
{recents.map((item, index) => (
<CommandPaletteListItem
key={item.id}
id={
/* ariakit doesn't like multiple items with the same id in the same combobox list */
item.id + 'recent'
}
index={index}
item={item}
handleClick={handleItemClick}
selectedTab={selectedTab}
/>
))}
</ComboboxGroup>
)}
{!hasNoItemsAtAll && (
<ComboboxGroup>
{recents.length > 0 && (
<ComboboxGroupLabel className="mt-2 px-2 font-semibold opacity-75">All commands</ComboboxGroupLabel>
)}
{items.map((item, index) => (
<CommandPaletteListItem
key={item.id}
id={item.id}
index={index}
item={item}
handleClick={handleItemClick}
selectedTab={selectedTab}
/>
))}
</ComboboxGroup>
)}
</ComboboxList>
</TabPanel>
</TabProvider>
</ComboboxProvider>
</Dialog>
)
}
export default observer(CommandPalette)