Merge pull request #33649 from overleaf/mj-command-palette
[web] Add command palette GitOrigin-RevId: 5bf1903836810ca5f0e2bc7f6c00a4b1da797ea2
This commit is contained in:
committed by
Copybot
parent
5cfd7b6c6a
commit
eddec90cb1
@@ -484,6 +484,7 @@ const _ProjectController = {
|
||||
'export-docx',
|
||||
'sharing-updates',
|
||||
'export-markdown',
|
||||
'command-palette',
|
||||
].filter(Boolean)
|
||||
|
||||
const getUserValues = async userId =>
|
||||
|
||||
+133
@@ -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>
|
||||
)
|
||||
}
|
||||
+25
@@ -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
|
||||
+16
@@ -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
|
||||
+18
@@ -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
|
||||
+77
@@ -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
|
||||
+127
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user