Browse Source

Merge pull request #24427 from overleaf/mj-command-registry

[web] Editor redesign: Add command registry

GitOrigin-RevId: c3d78d052f7e6e067de3247da8fe04329d8822ff
Mathias Jakobsen 1 year ago
parent
commit
767ac1632e

+ 6 - 0
services/web/.eslintrc.js

@@ -39,6 +39,12 @@ module.exports = {
       'error',
       { functions: false, classes: false, variables: false },
     ],
+    'react-hooks/exhaustive-deps': [
+      'warn',
+      {
+        additionalHooks: '(useCommandProvider)',
+      },
+    ],
   },
   overrides: [
     // NOTE: changing paths may require updating them in the Makefile too.

+ 2 - 0
services/web/frontend/extracted-translations.json

@@ -1495,6 +1495,7 @@
   "show_local_file_contents": "",
   "show_more": "",
   "show_outline": "",
+  "show_version_history": "",
   "show_x_more_projects": "",
   "showing_1_result": "",
   "showing_1_result_of_total": "",
@@ -1893,6 +1894,7 @@
   "upgrade_to_unlock_more_time": "",
   "upgrade_your_subscription": "",
   "upload": "",
+  "upload_file": "",
   "upload_from_computer": "",
   "upload_project": "",
   "upload_zipped_project": "",

+ 59 - 0
services/web/frontend/js/features/ide-react/context/command-registry-context.tsx

@@ -0,0 +1,59 @@
+import { createContext, useCallback, useContext, useState } from 'react'
+
+type CommandInvocationContext = {
+  location?: string
+}
+
+export type Command = {
+  label: string
+  id: string
+  handler?: (context: CommandInvocationContext) => void
+  href?: string
+  disabled?: boolean
+  // TODO: Keybinding?
+}
+
+const CommandRegistryContext = createContext<CommandRegistry | undefined>(
+  undefined
+)
+
+type CommandRegistry = {
+  registry: Map<string, Command>
+  register: (...elements: Command[]) => void
+  unregister: (...id: string[]) => void
+}
+
+export const CommandRegistryProvider: React.FC = ({ children }) => {
+  const [registry, setRegistry] = useState(new Map<string, Command>())
+  const register = useCallback((...elements: Command[]) => {
+    setRegistry(
+      registry =>
+        new Map([
+          ...registry,
+          ...elements.map(element => [element.id, element] as const),
+        ])
+    )
+  }, [])
+
+  const unregister = useCallback((...ids: string[]) => {
+    setRegistry(
+      registry => new Map([...registry].filter(([key]) => !ids.includes(key)))
+    )
+  }, [])
+
+  return (
+    <CommandRegistryContext.Provider value={{ registry, register, unregister }}>
+      {children}
+    </CommandRegistryContext.Provider>
+  )
+}
+
+export const useCommandRegistry = (): CommandRegistry => {
+  const context = useContext(CommandRegistryContext)
+  if (!context) {
+    throw new Error(
+      'useCommandRegistry must be used within a CommandRegistryProvider'
+    )
+  }
+  return context
+}

+ 5 - 1
services/web/frontend/js/features/ide-react/context/react-context-root.tsx

@@ -25,6 +25,7 @@ import { SplitTestProvider } from '@/shared/context/split-test-context'
 import { UserProvider } from '@/shared/context/user-context'
 import { UserSettingsProvider } from '@/shared/context/user-settings-context'
 import { IdeRedesignSwitcherProvider } from './ide-redesign-switcher-context'
+import { CommandRegistryProvider } from './command-registry-context'
 
 export const ReactContextRoot: FC<{ providers?: Record<string, FC> }> = ({
   children,
@@ -57,6 +58,7 @@ export const ReactContextRoot: FC<{ providers?: Record<string, FC> }> = ({
     UserProvider,
     UserSettingsProvider,
     IdeRedesignSwitcherProvider,
+    CommandRegistryProvider,
     ...providers,
   }
 
@@ -87,7 +89,9 @@ export const ReactContextRoot: FC<{ providers?: Record<string, FC> }> = ({
                                                   <Providers.OutlineProvider>
                                                     <Providers.RailProvider>
                                                       <Providers.IdeRedesignSwitcherProvider>
-                                                        {children}
+                                                        <Providers.CommandRegistryProvider>
+                                                          {children}
+                                                        </Providers.CommandRegistryProvider>
                                                       </Providers.IdeRedesignSwitcherProvider>
                                                     </Providers.RailProvider>
                                                   </Providers.OutlineProvider>

+ 21 - 0
services/web/frontend/js/features/ide-react/hooks/use-command-provider.ts

@@ -0,0 +1,21 @@
+import { DependencyList, useEffect } from 'react'
+import {
+  Command,
+  useCommandRegistry,
+} from '../context/command-registry-context'
+
+export const useCommandProvider = (
+  generateElements: () => Command[] | undefined,
+  dependencies: DependencyList
+) => {
+  const { register, unregister } = useCommandRegistry()
+  useEffect(() => {
+    const elements = generateElements()
+    if (!elements) return
+    register(...elements)
+    return () => {
+      unregister(...elements.map(element => element.id))
+    }
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, dependencies)
+}

+ 34 - 0
services/web/frontend/js/features/ide-redesign/components/file-tree-toolbar.tsx

@@ -8,6 +8,7 @@ import MaterialIcon, {
 } from '@/shared/components/material-icon'
 import React from 'react'
 import useCollapsibleFileTree from '../hooks/use-collapsible-file-tree'
+import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
 
 function FileTreeToolbar() {
   const { t } = useTranslation()
@@ -44,6 +45,39 @@ function FileTreeActionButtons() {
     startCreatingDocOrFile,
     startUploadingDocOrFile,
   } = useFileTreeActionable()
+  useCommandProvider(() => {
+    if (!canCreate || fileTreeReadOnly) return
+    return [
+      {
+        label: t('new_file'),
+        id: 'new_file',
+        handler: ({ location }) => {
+          eventTracking.sendMB('new-file-click', { location })
+          startCreatingDocOrFile()
+        },
+      },
+      {
+        label: t('new_folder'),
+        id: 'new_folder',
+        handler: startCreatingFolder,
+      },
+      {
+        label: t('upload_file'),
+        id: 'upload_file',
+        handler: ({ location }) => {
+          eventTracking.sendMB('upload-click', { location })
+          startUploadingDocOrFile()
+        },
+      },
+    ]
+  }, [
+    canCreate,
+    fileTreeReadOnly,
+    startCreatingDocOrFile,
+    t,
+    startCreatingFolder,
+    startUploadingDocOrFile,
+  ])
 
   if (!canCreate || fileTreeReadOnly) return null
 

+ 124 - 0
services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx

@@ -0,0 +1,124 @@
+import {
+  Command,
+  useCommandRegistry,
+} from '@/features/ide-react/context/command-registry-context'
+import {
+  DropdownDivider,
+  DropdownHeader,
+} from '@/features/ui/components/bootstrap-5/dropdown-menu'
+import {
+  MenuBarDropdown,
+  NestedMenuBarDropdown,
+} from '@/shared/components/menu-bar/menu-bar-dropdown'
+import { MenuBarOption } from '@/shared/components/menu-bar/menu-bar-option'
+import { Fragment, useCallback } from 'react'
+
+type CommandId = string
+type TaggedCommand = Command & { type: 'command' }
+type Entry<T> = T | GroupStructure<T>
+type GroupStructure<T> = {
+  id: string
+  title: string
+  children: Array<Entry<T>>
+}
+type MenuSectionStructure<T> = {
+  title?: string
+  id: string
+  children: Array<Entry<T>>
+}
+export type MenuStructure<T = CommandId> = Array<MenuSectionStructure<T>>
+
+const CommandDropdown = ({
+  menu,
+  title,
+  id,
+}: {
+  menu: MenuStructure<CommandId>
+  title: string
+  id: string
+}) => {
+  const { registry } = useCommandRegistry()
+  const populatedSections = menu
+    .map(section => populateSectionOrGroup(section, registry))
+    .filter(x => x.children.length > 0)
+  return (
+    <MenuBarDropdown
+      title={title}
+      id={id}
+      className="ide-redesign-toolbar-dropdown-toggle-subdued ide-redesign-toolbar-button-subdued"
+    >
+      {populatedSections.map((section, index) => {
+        return (
+          <Fragment key={section.id}>
+            {index > 0 && <DropdownDivider />}
+            {section.title && <DropdownHeader>{section.title}</DropdownHeader>}
+            {section.children.map(child => (
+              <CommandDropdownChild item={child} key={child.id} />
+            ))}
+          </Fragment>
+        )
+      })}
+    </MenuBarDropdown>
+  )
+}
+
+const CommandDropdownChild = ({ item }: { item: Entry<TaggedCommand> }) => {
+  const onClickHandler = useCallback(() => {
+    if (isTaggedCommand(item)) {
+      item.handler?.({ location: 'menu-bar' })
+    }
+  }, [item])
+
+  if (isTaggedCommand(item)) {
+    return (
+      <MenuBarOption
+        key={item.id}
+        title={item.label}
+        // eslint-disable-next-line react/jsx-handler-names
+        onClick={onClickHandler}
+        href={item.href}
+        disabled={item.disabled}
+      />
+    )
+  } else {
+    return (
+      <NestedMenuBarDropdown title={item.title} id={item.id} key={item.id}>
+        {item.children.map(subChild => {
+          return <CommandDropdownChild item={subChild} key={subChild.id} />
+        })}
+      </NestedMenuBarDropdown>
+    )
+  }
+}
+
+export default CommandDropdown
+
+function populateSectionOrGroup<
+  T extends { children: Array<Entry<CommandId>> },
+>(
+  section: T,
+  registry: Map<string, Command>
+): Omit<T, 'children'> & {
+  children: Array<Entry<TaggedCommand>>
+} {
+  const { children, ...rest } = section
+  return {
+    ...rest,
+    children: children
+      .map(child => {
+        if (typeof child !== 'string') {
+          return populateSectionOrGroup(child, registry)
+        }
+        const command = registry.get(child)
+        if (command) {
+          return { ...command, type: 'command' as const }
+        }
+        return undefined
+      })
+      .filter(x => x !== undefined),
+  }
+}
+
+function isTaggedCommand(item: Entry<TaggedCommand>): item is TaggedCommand {
+  return 'type' in item && item.type === 'command'
+}

+ 31 - 0
services/web/frontend/js/features/ide-redesign/components/toolbar/download-project.tsx

@@ -1,3 +1,4 @@
+import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
 import OLDropdownMenuItem from '@/features/ui/components/ol/ol-dropdown-menu-item'
 import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
 import { isSmallDevice, sendMB } from '@/infrastructure/event-tracking'
@@ -17,6 +18,17 @@ export const DownloadProjectZip = () => {
     })
   }, [projectId])
 
+  useCommandProvider(
+    () => [
+      {
+        id: 'download-as-source-zip',
+        href: `/project/${projectId}/download/zip`,
+        label: t('download_as_source_zip'),
+      },
+    ],
+    [t, projectId]
+  )
+
   return (
     <OLDropdownMenuItem
       href={`/project/${projectId}/download/zip`}
@@ -41,6 +53,25 @@ export const DownloadProjectPDF = () => {
     })
   }, [projectId])
 
+  useCommandProvider(
+    () => [
+      {
+        id: 'download-pdf',
+        disabled: !pdfUrl,
+        href: pdfDownloadUrl || pdfUrl,
+        handler: ({ location }) => {
+          sendMB('download-pdf-button-click', {
+            projectId,
+            location,
+            isSmallDevice,
+          })
+        },
+        label: t('download_as_pdf'),
+      },
+    ],
+    [t, pdfUrl, projectId, pdfDownloadUrl]
+  )
+
   const button = (
     <OLDropdownMenuItem
       href={pdfDownloadUrl || pdfUrl}

+ 34 - 17
services/web/frontend/js/features/ide-redesign/components/toolbar/menu-bar.tsx

@@ -10,11 +10,14 @@ import {
 import { MenuBarOption } from '@/shared/components/menu-bar/menu-bar-option'
 import { useTranslation } from 'react-i18next'
 import ChangeLayoutOptions from './change-layout-options'
-import { MouseEventHandler, useCallback } from 'react'
+import { MouseEventHandler, useCallback, useMemo } from 'react'
 import { useIdeRedesignSwitcherContext } from '@/features/ide-react/context/ide-redesign-switcher-context'
 import { useSwitchEnableNewEditorState } from '../../hooks/use-switch-enable-new-editor-state'
 import MaterialIcon from '@/shared/components/material-icon'
 import OLSpinner from '@/features/ui/components/ol/ol-spinner'
+import { useLayoutContext } from '@/shared/context/layout-context'
+import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
+import CommandDropdown, { MenuStructure } from './command-dropdown'
 
 export const ToolbarMenuBar = () => {
   const { t } = useTranslation()
@@ -22,27 +25,41 @@ export const ToolbarMenuBar = () => {
   const openEditorRedesignSwitcherModal = useCallback(() => {
     setShowSwitcherModal(true)
   }, [setShowSwitcherModal])
+  const { setView, view } = useLayoutContext()
+
+  useCommandProvider(
+    () => [
+      {
+        label: t('show_version_history'),
+        handler: () => {
+          setView(view === 'history' ? 'editor' : 'history')
+        },
+        id: 'show_version_history',
+      },
+    ],
+    [t, setView, view]
+  )
+  const fileMenuStructure: MenuStructure = useMemo(
+    () => [
+      {
+        id: 'file-file-tree',
+        children: ['new_file', 'new_folder', 'upload_file'],
+      },
+      { id: 'file-history', children: ['show_version_history'] },
+      {
+        id: 'file-download',
+        children: ['download-as-source-zip', 'download-pdf'],
+      },
+    ],
+    []
+  )
+
   return (
     <MenuBar
       className="ide-redesign-toolbar-menu-bar"
       id="toolbar-menu-bar-item"
     >
-      <MenuBarDropdown
-        title={t('file')}
-        id="file"
-        className="ide-redesign-toolbar-dropdown-toggle-subdued ide-redesign-toolbar-button-subdued"
-      >
-        <MenuBarOption title="New file" />
-        <MenuBarOption title="New folder" />
-        <MenuBarOption title="Upload file" />
-        <DropdownDivider />
-        <MenuBarOption title="Show version history" />
-        <DropdownDivider />
-        <MenuBarOption title="Download as source (.zip)" />
-        <MenuBarOption title="Download as PDF" />
-        <DropdownDivider />
-        <MenuBarOption title="New project" />
-      </MenuBarDropdown>
+      <CommandDropdown menu={fileMenuStructure} title={t('file')} id="file" />
       <MenuBarDropdown
         title={t('edit')}
         id="edit"

+ 3 - 0
services/web/frontend/js/shared/components/menu-bar/menu-bar-option.tsx

@@ -8,11 +8,13 @@ type MenuBarOptionProps = {
   onClick?: MouseEventHandler
   disabled?: boolean
   trailingIcon?: ReactNode
+  href?: string
 }
 
 export const MenuBarOption = ({
   title,
   onClick,
+  href,
   disabled,
   trailingIcon,
 }: MenuBarOptionProps) => {
@@ -24,6 +26,7 @@ export const MenuBarOption = ({
         onClick={onClick}
         disabled={disabled}
         trailingIcon={trailingIcon}
+        href={href}
       >
         {title}
       </DropdownItem>

+ 2 - 0
services/web/locales/en.json

@@ -1968,6 +1968,7 @@
   "show_local_file_contents": "Show Local File Contents",
   "show_more": "show more",
   "show_outline": "Show File outline",
+  "show_version_history": "Show version history",
   "show_x_more_projects": "Show __x__ more projects",
   "showing_1_result": "Showing 1 result",
   "showing_1_result_of_total": "Showing 1 result of __total__",
@@ -2432,6 +2433,7 @@
   "upgrade_your_subscription": "Upgrade your subscription",
   "upload": "Upload",
   "upload_failed": "Upload failed",
+  "upload_file": "Upload file",
   "upload_from_computer": "Upload from computer",
   "upload_project": "Upload Project",
   "upload_zipped_project": "Upload Zipped Project",