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

[web] Editor redesign: Add command registry

GitOrigin-RevId: c3d78d052f7e6e067de3247da8fe04329d8822ff
This commit is contained in:
Mathias Jakobsen
2025-04-04 09:19:26 +01:00
committed by Copybot
parent cd8609e89c
commit 283f55b448
11 changed files with 321 additions and 18 deletions

View File

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

View File

@@ -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": "",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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