Merge pull request #33649 from overleaf/mj-command-palette

[web] Add command palette

GitOrigin-RevId: 5bf1903836810ca5f0e2bc7f6c00a4b1da797ea2
This commit is contained in:
Mathias Jakobsen
2026-05-20 15:35:59 +01:00
committed by Copybot
parent 5cfd7b6c6a
commit eddec90cb1
14 changed files with 573 additions and 3 deletions
@@ -484,6 +484,7 @@ const _ProjectController = {
'export-docx',
'sharing-updates',
'export-markdown',
'command-palette',
].filter(Boolean)
const getUserValues = async userId =>
@@ -0,0 +1,133 @@
import {
FC,
KeyboardEvent,
memo,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { CommandPaletteSearchResult } from '../types'
import { OLModal, OLModalBody } from '@/shared/components/ol/ol-modal'
import classNames from 'classnames'
import useEventListener from '@/shared/hooks/use-event-listener'
import useCommandPaletteResults from '../hooks/use-command-palette-results'
import { debugConsole } from '@/utils/debugging'
type CommandPaletteBodyProps = {
show: boolean
onHide(): void
}
const CommandPaletteBody: FC<CommandPaletteBodyProps> = ({ show, onHide }) => {
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const resultsRef = useRef<HTMLUListElement>(null)
const results = useCommandPaletteResults(query)
useEffect(() => {
setSelectedIndex(0)
}, [results])
useEffect(() => {
const el = resultsRef.current?.children[selectedIndex] as
| HTMLElement
| undefined
el?.scrollIntoView({ block: 'nearest' })
}, [selectedIndex])
const runResult = (result: CommandPaletteSearchResult) => {
try {
const res = result.onSelect(result)
if (res instanceof Promise) {
res.catch(err => {
debugConsole.error('Command palette command error', err)
})
}
} catch (err) {
debugConsole.error('Command palette command error', err)
} finally {
onHide()
}
}
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (results.length === 0) return
if (event.key === 'ArrowDown') {
event.preventDefault()
setSelectedIndex(i => (i + 1) % results.length)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setSelectedIndex(i => (i - 1 + results.length) % results.length)
} else if (event.key === 'Enter') {
event.preventDefault()
const selected = results[selectedIndex]
if (selected) runResult(selected)
}
}
useEventListener(
'mousedown',
useCallback(
(event: MouseEvent) => {
if (!show) return
const target = event.target as Element | null
if (target?.closest('.command-palette .modal-dialog')) return
onHide()
},
[show, onHide]
)
)
return (
<OLModal
show={show}
onHide={onHide}
className="command-palette"
returnFocusOnDeactivate
animation={false}
backdrop={false}
clickOutsideDeactivates
>
<OLModalBody>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Type a command..."
aria-label="Command palette search"
className="command-palette-input"
/>
<ul ref={resultsRef} className="command-palette-results">
{results.map((result, index) => (
<li
role="none"
key={index}
className={classNames('command-palette-result', {
'command-palette-result-selected': index === selectedIndex,
})}
>
<button
role="menuitem"
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => runResult(result)}
>
<div className="command-palette-result-title">
{result.title}
</div>
{result.description && (
<div className="command-palette-result-description">
{result.description}
</div>
)}
</button>
</li>
))}
</ul>
</OLModalBody>
</OLModal>
)
}
export default memo(CommandPaletteBody)
@@ -0,0 +1,39 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useState,
} from 'react'
import useCommandPaletteTriggers from '../hooks/use-command-palette-triggers'
import CommandPaletteBody from './command-palette-body'
import { useLayoutContext } from '@/shared/context/layout-context'
const CommandPaletteRoot = () => {
const [show, _setShow] = useState(false)
const { view } = useLayoutContext()
const setShow: Dispatch<SetStateAction<boolean>> = useCallback(
value => {
_setShow(view === 'history' ? false : value)
},
[view]
)
useEffect(() => {
if (view === 'history') {
_setShow(false)
}
}, [view])
useCommandPaletteTriggers(setShow)
const onHide = useCallback(() => setShow(false), [setShow])
if (!show) {
return null
}
return <CommandPaletteBody show={show} onHide={onHide} />
}
export default CommandPaletteRoot
@@ -0,0 +1,11 @@
import { lazy, Suspense } from 'react'
const CommandPaletteRoot = lazy(() => import('./command-palette-root'))
export default function CommandPalette() {
return (
<Suspense fallback={null}>
<CommandPaletteRoot />
</Suspense>
)
}
@@ -0,0 +1,25 @@
import useDebounce from '@/shared/hooks/use-debounce'
import useCommandPaletteSources from './use-command-palette-sources'
import { useEffect, useState } from 'react'
import { CommandPaletteSearchResult } from '../types'
const useCommandPaletteResults = (query: string) => {
const debouncedQuery = useDebounce(query, 25)
const sources = useCommandPaletteSources()
const [results, setResults] = useState<CommandPaletteSearchResult[]>([])
useEffect(() => {
if (debouncedQuery) {
const res = sources.map(source => source.search(debouncedQuery))
setResults(res.flat().sort((a, b) => b.score - a.score))
} else {
const res = sources
.filter(s => 'defaults' in s)
.map(source => source.defaults!())
setResults(res.flat().sort((a, b) => b.score - a.score))
}
}, [debouncedQuery, sources])
return results
}
export default useCommandPaletteResults
@@ -0,0 +1,16 @@
import { useMemo } from 'react'
import { CommandPaletteSource } from '../types'
import useCommandRegistrySource from './use-command-registry-source'
import useFileTreeCommandSource from './use-file-tree-command-source'
const useCommandPaletteSources = (): CommandPaletteSource[] => {
const fileTreeSource = useFileTreeCommandSource()
const commandRegistrySource = useCommandRegistrySource()
const sources = useMemo(
() => [fileTreeSource, commandRegistrySource],
[fileTreeSource, commandRegistrySource]
)
return sources
}
export default useCommandPaletteSources
@@ -0,0 +1,18 @@
import useEventListener from '@/shared/hooks/use-event-listener'
import { Dispatch, SetStateAction, useCallback } from 'react'
const useCommandPaletteTriggers = (show: Dispatch<SetStateAction<boolean>>) => {
const onKeyDown = useCallback(
(event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyP') {
event.preventDefault()
show(prev => !prev)
}
},
[show]
)
useEventListener('keydown', onKeyDown)
}
export default useCommandPaletteTriggers
@@ -0,0 +1,77 @@
import { useCallback, useMemo } from 'react'
import MiniSearch from 'minisearch'
import { CommandPaletteSearchResult, CommandPaletteSource } from '../types'
import {
Command,
useCommandRegistry,
} from '@/features/ide-react/context/command-registry-context'
const ENABLED_COMMANDS: string[] = [
'new_file',
'new_folder',
'upload_file',
'open-settings',
'show_version_history',
'word_count',
'view-pdf-presentation-mode',
'comment',
'compile',
'stop-compile',
'recompile-from-scratch',
]
const useCommandRegistrySource = (): CommandPaletteSource => {
const { registry } = useCommandRegistry()
const commands = useMemo(() => {
const enabled = new Set(ENABLED_COMMANDS)
return [...registry.values()].filter(
c => enabled.has(c.id) && !c.disabled && c.handler
)
}, [registry])
const defaults = useCallback((): CommandPaletteSearchResult[] => {
return commands.map(command => ({
title: command.label,
onSelect: () => command.handler!({ location: 'command-palette' }),
score: 1,
}))
}, [commands])
const index = useMemo(() => {
const miniSearch = new MiniSearch<Command>({
fields: ['label'],
storeFields: ['id'],
idField: 'id',
})
miniSearch.addAll(commands)
return miniSearch
}, [commands])
return useMemo<CommandPaletteSource>(
() => ({
id: 'command-registry',
search(query) {
const results = index.search(query, {
prefix: true,
fuzzy: term => (term.length > 3 ? 0.2 : false),
})
return results.flatMap(({ id, score }) => {
const command = registry.get(id)
if (!command?.handler) return []
return [
{
title: command.label,
onSelect: () => command.handler!({ location: 'command-palette' }),
score,
},
]
})
},
defaults,
}),
[index, registry, defaults]
)
}
export default useCommandRegistrySource
@@ -0,0 +1,127 @@
import { useCallback, useMemo } from 'react'
import { CommandPaletteSearchResult, CommandPaletteSource } from '../types'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { Folder } from '@ol-types/folder'
import MiniSearch from 'minisearch'
import { findInTree } from '@/features/file-tree/util/find-in-tree'
import { debugConsole } from '@/utils/debugging'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
type FlatFileTree = { path: string; name: string; id: string }[]
const useFileTreeCommandSource = (): CommandPaletteSource => {
const { fileTreeData } = useFileTreeData()
const { openDocWithId, openFileWithId } = useEditorManagerContext()
const flatFileTree = useMemo(
() => flattenFileTree(fileTreeData),
[fileTreeData]
)
const index = useMemo(
() => flatFileTree && indexFromFlatFileTree(flatFileTree),
[flatFileTree]
)
const onSelect = useCallback(
async (id: string) => {
if (!fileTreeData) {
return
}
const file = findInTree(fileTreeData, id)
if (!file) {
return
}
if (file.type === 'doc') {
await openDocWithId(file.entity._id)
} else if (file.type === 'fileRef') {
openFileWithId(file.entity._id)
} else {
debugConsole.error('Attempting to open invalid entity type')
}
},
[fileTreeData, openDocWithId, openFileWithId]
)
const defaults = useCallback((): CommandPaletteSearchResult[] => {
if (!flatFileTree) {
return []
}
const files: CommandPaletteSearchResult[] = flatFileTree
.slice(0, 10)
.map(({ path, name, id }) => ({
title: name,
description: path,
onSelect: () => onSelect(id),
score: 1,
}))
return files
}, [flatFileTree, onSelect])
const source: CommandPaletteSource = useMemo(
() => ({
id: 'file-tree',
search(query) {
if (!index) {
return []
}
const result = index.search(query, {
prefix: true,
fuzzy: term => (term.length > 3 ? 0.2 : false),
})
return result.map(({ path, name, id, score }) => ({
title: name,
description: path,
onSelect: () => onSelect(id),
score,
}))
},
defaults,
}),
[index, onSelect, defaults]
)
return source
}
export default useFileTreeCommandSource
const flattenFileTree = (
folder: Folder,
parentPath = ''
): FlatFileTree | null => {
if (!folder) {
return null
}
const currentPath = `${parentPath}/${folder.name}`
const files = folder.fileRefs.map(file => ({
path: `${currentPath}/${file.name}`.replace(/^\/rootFolder\//, ''),
name: file.name,
id: file._id,
}))
const docs = folder.docs.map(doc => ({
path: `${currentPath}/${doc.name}`.replace(/^\/rootFolder\//, ''),
name: doc.name,
id: doc._id,
}))
const subFolders = folder.folders
.flatMap(subFolder => flattenFileTree(subFolder, currentPath))
.filter(x => x !== null)
return [...docs, ...files, ...subFolders]
}
function indexFromFlatFileTree(flatFileTree: FlatFileTree): MiniSearch {
const miniSearch = new MiniSearch({
fields: ['path', 'name'],
storeFields: ['path', 'name'],
})
if (!flatFileTree) {
return miniSearch
}
miniSearch.addAll(flatFileTree)
return miniSearch
}
@@ -0,0 +1,12 @@
export type CommandPaletteSearchResult = {
title: string
description?: string
onSelect(self: CommandPaletteSearchResult): void | Promise<void>
score: number
}
export type CommandPaletteSource = {
id: string
search(query: string): CommandPaletteSearchResult[]
defaults?(): CommandPaletteSearchResult[]
}
@@ -14,6 +14,7 @@ import useThemedPage from '@/shared/hooks/use-themed-page'
import MainLayout from '@/features/ide-react/components/layout/main-layout'
import SettingsModalNew from '@/features/settings/components/settings-modal'
import CommandPalette from '@/features/command-palette/components/command-palette'
export default function IdePage() {
useLayoutEventTracking() // sent event when the layout changes
@@ -25,6 +26,7 @@ export default function IdePage() {
useThemedPage() // set the page theme based on user settings
const showEditorSurvey = useFeatureFlag('editor-popup-ux-survey-03-2026')
const showCommandPalette = useFeatureFlag('command-palette')
return (
<GlobalAlertsProvider>
@@ -33,6 +35,7 @@ export default function IdePage() {
<SettingsModalNew />
<MainLayout />
<GlobalToasts />
{showCommandPalette && <CommandPalette />}
{showEditorSurvey && <EditorSurvey />}
</GlobalAlertsProvider>
)
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import { memo, useCallback } from 'react'
import classNames from 'classnames'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { useStopOnFirstError } from '../../../shared/hooks/use-stop-on-first-error'
@@ -17,6 +17,7 @@ import {
import OLButton from '@/shared/components/ol/ol-button'
import OLButtonGroup from '@/shared/components/ol/ol-button-group'
import { useLayoutContext } from '@/shared/context/layout-context'
import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
const modifierKey = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl'
@@ -55,12 +56,12 @@ function PdfCompileButton() {
const { detachRole } = useLayoutContext()
const fromScratchWithEvent = () => {
const fromScratchWithEvent = useCallback(() => {
eventTracking.sendMB('recompile-setting-changed', {
setting: 'from-scratch',
})
recompileFromScratch()
}
}, [recompileFromScratch])
const tooltipElement = (
<>
@@ -87,6 +88,30 @@ function PdfCompileButton() {
}
)
useCommandProvider(
() => [
{
id: 'compile',
handler: () => startCompile(),
label: t('recompile'),
disabled: compiling,
},
{
id: 'stop-compile',
handler: () => stopCompile(),
label: t('stop_compile'),
disabled: !compiling,
},
{
id: 'recompile-from-scratch',
handler: fromScratchWithEvent,
label: t('recompile_from_scratch'),
disabled: compiling,
},
],
[startCompile, t, compiling, stopCompile, fromScratchWithEvent]
)
return (
<Dropdown as={OLButtonGroup} className="compile-button-group">
<OLTooltip
@@ -38,6 +38,7 @@
@import 'editor/editor-survey';
@import 'editor/themed-tooltip';
@import 'editor/new-editor-promo-modal';
@import 'editor/command-palette';
@import 'error-pages';
@import 'website-redesign';
@import 'group-settings';
@@ -0,0 +1,82 @@
.command-palette {
pointer-events: none;
.modal-dialog {
pointer-events: auto;
}
.modal-body {
padding: 0;
}
.command-palette-input {
width: 100%;
padding: var(--spacing-05);
border: 0;
border-bottom: 1px solid var(--border-divider);
background: transparent;
color: var(--content-primary);
font-size: var(--font-size-04);
outline: none;
&::placeholder {
color: var(--content-secondary);
}
}
.command-palette-results {
list-style: none;
margin: 0;
padding: var(--spacing-02);
max-height: 50vh;
overflow-y: auto;
&:empty {
display: none;
}
}
.command-palette-result {
margin: 0;
button {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-01);
width: 100%;
padding: var(--spacing-03) var(--spacing-04);
border: 0;
border-radius: var(--border-radius-base);
background: transparent;
color: var(--content-primary);
text-align: left;
cursor: pointer;
&:focus-visible {
outline: none;
}
}
&.command-palette-result-selected button {
background-color: var(--bg-accent-01);
color: var(--content-primary-dark);
}
&.command-palette-result-selected .command-palette-result-description {
color: var(--content-secondary-dark);
}
}
.command-palette-result-title {
font-size: var(--font-size-03);
font-weight: 500;
line-height: 1.2;
}
.command-palette-result-description {
font-size: var(--font-size-02);
color: var(--content-secondary);
line-height: 1.2;
}
}