mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-25 10:10:08 +02:00
Merge pull request #24427 from overleaf/mj-command-registry
[web] Editor redesign: Add command registry GitOrigin-RevId: c3d78d052f7e6e067de3247da8fe04329d8822ff
This commit is contained in:
committed by
Copybot
parent
cd8609e89c
commit
283f55b448
@@ -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.
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user