From 283f55b44887ac4ec9626966e580f5ccdcf949fd Mon Sep 17 00:00:00 2001 From: Mathias Jakobsen Date: Fri, 4 Apr 2025 09:19:26 +0100 Subject: [PATCH] Merge pull request #24427 from overleaf/mj-command-registry [web] Editor redesign: Add command registry GitOrigin-RevId: c3d78d052f7e6e067de3247da8fe04329d8822ff --- services/web/.eslintrc.js | 6 + .../web/frontend/extracted-translations.json | 2 + .../context/command-registry-context.tsx | 59 +++++++++ .../ide-react/context/react-context-root.tsx | 6 +- .../ide-react/hooks/use-command-provider.ts | 21 +++ .../components/file-tree-toolbar.tsx | 34 +++++ .../components/toolbar/command-dropdown.tsx | 124 ++++++++++++++++++ .../components/toolbar/download-project.tsx | 31 +++++ .../components/toolbar/menu-bar.tsx | 51 ++++--- .../components/menu-bar/menu-bar-option.tsx | 3 + services/web/locales/en.json | 2 + 11 files changed, 321 insertions(+), 18 deletions(-) create mode 100644 services/web/frontend/js/features/ide-react/context/command-registry-context.tsx create mode 100644 services/web/frontend/js/features/ide-react/hooks/use-command-provider.ts create mode 100644 services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx diff --git a/services/web/.eslintrc.js b/services/web/.eslintrc.js index 47d15bca87..3c672de7e7 100644 --- a/services/web/.eslintrc.js +++ b/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. diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 6d64ddd77c..85094e14c5 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx b/services/web/frontend/js/features/ide-react/context/command-registry-context.tsx new file mode 100644 index 0000000000..340d4e1b77 --- /dev/null +++ b/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( + undefined +) + +type CommandRegistry = { + registry: Map + register: (...elements: Command[]) => void + unregister: (...id: string[]) => void +} + +export const CommandRegistryProvider: React.FC = ({ children }) => { + const [registry, setRegistry] = useState(new Map()) + 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 ( + + {children} + + ) +} + +export const useCommandRegistry = (): CommandRegistry => { + const context = useContext(CommandRegistryContext) + if (!context) { + throw new Error( + 'useCommandRegistry must be used within a CommandRegistryProvider' + ) + } + return context +} diff --git a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx index 9551777813..72c25b88c9 100644 --- a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx +++ b/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 }> = ({ children, @@ -57,6 +58,7 @@ export const ReactContextRoot: FC<{ providers?: Record }> = ({ UserProvider, UserSettingsProvider, IdeRedesignSwitcherProvider, + CommandRegistryProvider, ...providers, } @@ -87,7 +89,9 @@ export const ReactContextRoot: FC<{ providers?: Record }> = ({ - {children} + + {children} + diff --git a/services/web/frontend/js/features/ide-react/hooks/use-command-provider.ts b/services/web/frontend/js/features/ide-react/hooks/use-command-provider.ts new file mode 100644 index 0000000000..088304b39d --- /dev/null +++ b/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) +} diff --git a/services/web/frontend/js/features/ide-redesign/components/file-tree-toolbar.tsx b/services/web/frontend/js/features/ide-redesign/components/file-tree-toolbar.tsx index cdc28ef4b7..957226c414 100644 --- a/services/web/frontend/js/features/ide-redesign/components/file-tree-toolbar.tsx +++ b/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 diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/command-dropdown.tsx new file mode 100644 index 0000000000..9d0deea0c5 --- /dev/null +++ b/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 | GroupStructure +type GroupStructure = { + id: string + title: string + children: Array> +} +type MenuSectionStructure = { + title?: string + id: string + children: Array> +} +export type MenuStructure = Array> + +const CommandDropdown = ({ + menu, + title, + id, +}: { + menu: MenuStructure + title: string + id: string +}) => { + const { registry } = useCommandRegistry() + const populatedSections = menu + .map(section => populateSectionOrGroup(section, registry)) + .filter(x => x.children.length > 0) + return ( + + {populatedSections.map((section, index) => { + return ( + + {index > 0 && } + {section.title && {section.title}} + {section.children.map(child => ( + + ))} + + ) + })} + + ) +} + +const CommandDropdownChild = ({ item }: { item: Entry }) => { + const onClickHandler = useCallback(() => { + if (isTaggedCommand(item)) { + item.handler?.({ location: 'menu-bar' }) + } + }, [item]) + + if (isTaggedCommand(item)) { + return ( + + ) + } else { + return ( + + {item.children.map(subChild => { + return + })} + + ) + } +} + +export default CommandDropdown + +function populateSectionOrGroup< + T extends { children: Array> }, +>( + section: T, + registry: Map +): Omit & { + children: Array> +} { + 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): item is TaggedCommand { + return 'type' in item && item.type === 'command' +} diff --git a/services/web/frontend/js/features/ide-redesign/components/toolbar/download-project.tsx b/services/web/frontend/js/features/ide-redesign/components/toolbar/download-project.tsx index e40f385fa1..712c874309 100644 --- a/services/web/frontend/js/features/ide-redesign/components/toolbar/download-project.tsx +++ b/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 ( { }) }, [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 = ( { 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 ( - - - - - - - - - - - - + { @@ -24,6 +26,7 @@ export const MenuBarOption = ({ onClick={onClick} disabled={disabled} trailingIcon={trailingIcon} + href={href} > {title} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index e6b66f4d91..d550e59d0a 100644 --- a/services/web/locales/en.json +++ b/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",