Răsfoiți Sursa

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

[web] Add command palette

GitOrigin-RevId: 5bf1903836810ca5f0e2bc7f6c00a4b1da797ea2
Mathias Jakobsen 2 luni în urmă
părinte
comite
eddec90cb1

+ 1 - 0
services/web/app/src/Features/Project/ProjectController.mjs

@@ -484,6 +484,7 @@ const _ProjectController = {
       'export-docx',
       'sharing-updates',
       'export-markdown',
+      'command-palette',
     ].filter(Boolean)
 
     const getUserValues = async userId =>

+ 133 - 0
services/web/frontend/js/features/command-palette/components/command-palette-body.tsx

@@ -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)

+ 39 - 0
services/web/frontend/js/features/command-palette/components/command-palette-root.tsx

@@ -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

+ 11 - 0
services/web/frontend/js/features/command-palette/components/command-palette.tsx

@@ -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
services/web/frontend/js/features/command-palette/hooks/use-command-palette-results.ts

@@ -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
services/web/frontend/js/features/command-palette/hooks/use-command-palette-sources.ts

@@ -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
services/web/frontend/js/features/command-palette/hooks/use-command-palette-triggers.ts

@@ -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
services/web/frontend/js/features/command-palette/hooks/use-command-registry-source.tsx

@@ -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
services/web/frontend/js/features/command-palette/hooks/use-file-tree-command-source.ts

@@ -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
+}

+ 12 - 0
services/web/frontend/js/features/command-palette/types.ts

@@ -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[]
+}

+ 3 - 0
services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx

@@ -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>
   )

+ 28 - 3
services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.tsx

@@ -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

+ 1 - 0
services/web/frontend/stylesheets/pages/all.scss

@@ -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';

+ 82 - 0
services/web/frontend/stylesheets/pages/editor/command-palette.scss

@@ -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;
+  }
+}