From c8a3bdd13ff3030a97c99619978d93f8dfd3a20e Mon Sep 17 00:00:00 2001 From: Alexandre Bourdin Date: Fri, 5 Jun 2026 11:57:08 +0200 Subject: [PATCH] [web] Add a focus mode to the editor (#32245) * Basic focus mode implementation * Exit button * Toggle between editor pdf mode * Keep the rail and file tree mounted for editor tabs to work properly * Hide Switch to editor button in focus mode * Show shortcuts in layout menu * Gate the feature behind a focus-mode feature flag * Add split test badges in the focus mode dropdown item and top bar * Add analytics tracking for focusMode changes * Cleanup leftover IdeView value * Allow passing focusMode value in layout test provider * Remove redundant listener for focus mode shortcut * Update focus mode and full project search listeners to use the event key * Post-rebase cleanup --------- Co-authored-by: Anna Fields GitOrigin-RevId: 207f8d136fa6662558c474cca4a867a9842191e7 --- .../Features/Project/ProjectController.mjs | 1 + .../web/frontend/extracted-translations.json | 2 + .../ide-react/components/rail/rail-panel.tsx | 4 +- .../components/rail/rail-resize-handle.tsx | 4 +- .../ide-react/components/rail/rail.tsx | 21 +++-- .../toolbar/change-layout-options.tsx | 42 +++++++++- .../ide-react/components/toolbar/toolbar.tsx | 72 ++++++++++++++++- .../components/pdf-preview-hybrid-toolbar.tsx | 4 +- .../components/review-panel-container.tsx | 4 +- .../hooks/use-review-panel-layout.ts | 4 +- .../components/codemirror-toolbar.tsx | 6 ++ .../js/shared/context/layout-context.tsx | 78 ++++++++++++++++--- services/web/locales/en.json | 2 + .../components/full-project-search.spec.tsx | 2 + .../frontend/helpers/editor-providers.tsx | 2 + 15 files changed, 223 insertions(+), 25 deletions(-) diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 9eddf0bef7..d9fa175b51 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -487,6 +487,7 @@ const _ProjectController = { 'command-palette', 'overleaf-library', 'compile-timeout-cta', + 'focus-mode', 'editor-upgrade-button-relocation', 'markdown-visual', ].filter(Boolean) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 61afd0e4b3..24c8e0a40b 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -645,6 +645,7 @@ "event_type": "", "example_project": "", "existing_plan_active_until_term_end": "", + "exit_focus_mode": "", "expand": "", "experiment_enabled_refresh_page": "", "experiment_full_check_back_soon": "", @@ -705,6 +706,7 @@ "fix_errors": "", "fixed_width": "", "fixed_width_wrap_text": "", + "focus_mode": "", "fold_line": "", "folder_location": "", "folder_name": "", diff --git a/services/web/frontend/js/features/ide-react/components/rail/rail-panel.tsx b/services/web/frontend/js/features/ide-react/components/rail/rail-panel.tsx index b203d56fcf..4e0bf02d81 100644 --- a/services/web/frontend/js/features/ide-react/components/rail/rail-panel.tsx +++ b/services/web/frontend/js/features/ide-react/components/rail/rail-panel.tsx @@ -12,10 +12,12 @@ export default function RailPanel({ isReviewPanelOpen, isHistoryView, railTabs, + focusMode, }: { isReviewPanelOpen: boolean isHistoryView: boolean railTabs: RailElement[] + focusMode: boolean }) { const { selectedTab, panelRef, handlePaneExpand, handlePaneCollapse } = useRailContext() @@ -35,7 +37,7 @@ export default function RailPanel({ return ( { const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled') const { isOverleaf } = getMeta('ol-ExposedSettings') - const { view, setLeftMenuShown } = useLayoutContext() + const { view, setLeftMenuShown, focusMode } = useLayoutContext() const { markMessagesAsRead } = useChatContext() @@ -271,7 +271,9 @@ export const RailLayout = () => { But it should be identified as a navigation landmark. Therefore, we nest them: the parent - {moduleRailPopovers - .filter(shouldIncludeElement) - .map(({ key, Component, ref }) => ( - - ))} + {!focusMode && + moduleRailPopovers + .filter(shouldIncludeElement) + .map(({ key, Component, ref }) => )} + - ) diff --git a/services/web/frontend/js/features/ide-react/components/toolbar/change-layout-options.tsx b/services/web/frontend/js/features/ide-react/components/toolbar/change-layout-options.tsx index ef1548ff32..8216401dfe 100644 --- a/services/web/frontend/js/features/ide-react/components/toolbar/change-layout-options.tsx +++ b/services/web/frontend/js/features/ide-react/components/toolbar/change-layout-options.tsx @@ -1,6 +1,7 @@ import { DropdownItem, DropdownHeader, + DropdownDivider, } from '@/shared/components/dropdown/dropdown-menu' import { IdeLayout, @@ -14,8 +15,16 @@ import OLSpinner from '@/shared/components/ol/ol-spinner' import { isMac } from '@/shared/utils/os' import { Shortcut } from '@/shared/components/shortcut' import classNames from 'classnames' +import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' +import { useFeatureFlag } from '@/shared/context/split-test-context' +import SplitTestBadge from '@/shared/components/split-test-badge' -type LayoutOption = 'sideBySide' | 'editorOnly' | 'pdfOnly' | 'detachedPdf' +type LayoutOption = + | 'sideBySide' + | 'editorOnly' + | 'pdfOnly' + | 'detachedPdf' + | 'focusMode' const getActiveLayoutOption = ({ pdfLayout, @@ -45,7 +54,6 @@ const getActiveLayoutOption = ({ if (pdfLayout === 'sideBySide') { return 'sideBySide' } - return null } @@ -93,12 +101,14 @@ const shortcuts: Record = isMac pdfOnly: ['⌃', '⌘', '→'], sideBySide: ['⌃', '⌘', '↓'], detachedPdf: ['⌃', '⌘', '↑'], + focusMode: ['⌘', '⇧', 'M'], } : { editorOnly: null, pdfOnly: null, sideBySide: null, detachedPdf: null, + focusMode: ['⌃', '⇧', 'M'], } export default function ChangeLayoutOptions() { @@ -109,9 +119,13 @@ export default function ChangeLayoutOptions() { pdfLayout, handleChangeLayout, handleDetach, + focusMode, + setFocusMode, } = useLayoutContext() const { t } = useTranslation() + const { sendEvent } = useEditorAnalytics() + const focusModeEnabled = useFeatureFlag('focus-mode') const detachable = 'BroadcastChannel' in window @@ -168,6 +182,30 @@ export default function ChangeLayoutOptions() { > {t('open_pdf_in_separate_tab')} + {focusModeEnabled && ( + <> + + { + setFocusMode(!focusMode) + sendEvent('focus-mode-toggle', { focusMode: !focusMode }) + }} + active={focusMode} + leadingIcon="self_improvement" + trailingIcon={ + + + {shortcuts.focusMode && } + + } + > + {t('focus_mode')} + + + )} ) } diff --git a/services/web/frontend/js/features/ide-react/components/toolbar/toolbar.tsx b/services/web/frontend/js/features/ide-react/components/toolbar/toolbar.tsx index ba65fb21ca..c525dc35a3 100644 --- a/services/web/frontend/js/features/ide-react/components/toolbar/toolbar.tsx +++ b/services/web/frontend/js/features/ide-react/components/toolbar/toolbar.tsx @@ -16,12 +16,16 @@ import UpgradeButton from './upgrade-button' import getMeta from '@/utils/meta' import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context' import { useFeatureFlag } from '@/shared/context/split-test-context' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import SplitTestBadge from '@/shared/components/split-test-badge' const [publishModalModules] = importOverleafModules('publishModal') const SubmitProjectButton = publishModalModules?.import.NewPublishToolbarButton export const Toolbar = () => { - const { view, restoreView } = useLayoutContext() + const { view, restoreView, focusMode, setFocusMode, pdfLayout, setView } = + useLayoutContext() const { cobranding, isRestrictedTokenMember } = useEditorContext() const { permissionsLevel } = useIdeReactContext() const showUpgradePrompt = getMeta('ol-showUpgradePrompt') @@ -38,6 +42,72 @@ export const Toolbar = () => { restoreView() }, [restoreView]) + const handleExitFocusMode = useCallback(() => { + setFocusMode(false) + eventTracking.sendMB('focus-mode-exit') + }, [setFocusMode]) + + const handleSwitchView = useCallback(() => { + const newView = view === 'pdf' ? 'editor' : 'pdf' + setView(newView) + eventTracking.sendMB('focus-mode-switch-view', { view: newView }) + }, [view, setView]) + + if (focusMode) { + const showViewSwitcher = pdfLayout === 'flat' + const switchTooltip = + view === 'pdf' ? t('switch_to_editor') : t('switch_to_pdf') + const switchIcon = view === 'pdf' ? 'edit' : 'picture_as_pdf' + + return ( + + ) + } + if (view === 'history') { return (