Kaynağa Gözat

[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 <acfields11@gmail.com>
GitOrigin-RevId: 207f8d136fa6662558c474cca4a867a9842191e7
Alexandre Bourdin 4 ay önce
ebeveyn
işleme
c8a3bdd13f

+ 1 - 0
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)

+ 2 - 0
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": "",

+ 3 - 1
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 (
     <Panel
       id={`ide-redesign-sidebar-panel-${isHistoryView ? 'file-tree' : selectedTab}`}
-      className={classNames({ hidden: isReviewPanelOpen })}
+      className={classNames({ hidden: isReviewPanelOpen || focusMode })}
       order={1}
       defaultSize={selectedTab === 'workbench' ? 20 : 15}
       minSize={5}

+ 3 - 1
services/web/frontend/js/features/ide-react/components/rail/rail-resize-handle.tsx

@@ -6,15 +6,17 @@ import { useTranslation } from 'react-i18next'
 
 export default function RailResizeHandle({
   isReviewPanelOpen,
+  focusMode,
 }: {
   isReviewPanelOpen: boolean
+  focusMode: boolean
 }) {
   const { isOpen, setIsOpen, togglePane, setResizing } = useRailContext()
   const { t } = useTranslation()
 
   return (
     <HorizontalResizeHandle
-      className={classNames({ hidden: isReviewPanelOpen })}
+      className={classNames({ hidden: isReviewPanelOpen || focusMode })}
       resizable
       hitAreaMargins={{ coarse: 0, fine: 0 }}
       onDoubleClick={togglePane}

+ 13 - 8
services/web/frontend/js/features/ide-react/components/rail/rail.tsx

@@ -73,7 +73,7 @@ export const RailLayout = () => {
   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 <nav> is the landmark, and its child gets the "role="tablist"". */}
       <nav
-        className={classNames('ide-rail', { hidden: isHistoryView })}
+        className={classNames('ide-rail', {
+          hidden: isHistoryView || focusMode,
+        })}
         aria-label={t('sidebar')}
       >
         <Nav activeKey={selectedTab} className="ide-rail-tabs-nav">
@@ -306,17 +308,20 @@ export const RailLayout = () => {
           </nav>
         </Nav>
       </nav>
-      {moduleRailPopovers
-        .filter(shouldIncludeElement)
-        .map(({ key, Component, ref }) => (
-          <Component key={key} ref={ref} />
-        ))}
+      {!focusMode &&
+        moduleRailPopovers
+          .filter(shouldIncludeElement)
+          .map(({ key, Component, ref }) => <Component key={key} ref={ref} />)}
       <RailPanel
         isReviewPanelOpen={isReviewPanelOpen}
         isHistoryView={isHistoryView}
         railTabs={railTabs}
+        focusMode={focusMode}
+      />
+      <RailResizeHandle
+        isReviewPanelOpen={isReviewPanelOpen}
+        focusMode={focusMode}
       />
-      <RailResizeHandle isReviewPanelOpen={isReviewPanelOpen} />
       <RailModals />
     </TabContainer>
   )

+ 40 - 2
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<LayoutOption, string[] | null> = 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')}
       </LayoutDropdownItem>
+      {focusModeEnabled && (
+        <>
+          <DropdownDivider />
+          <LayoutDropdownItem
+            onClick={() => {
+              setFocusMode(!focusMode)
+              sendEvent('focus-mode-toggle', { focusMode: !focusMode })
+            }}
+            active={focusMode}
+            leadingIcon="self_improvement"
+            trailingIcon={
+              <span className="d-flex align-items-center gap-2">
+                <SplitTestBadge
+                  splitTestName="focus-mode"
+                  displayOnVariants={['enabled']}
+                />
+                {shortcuts.focusMode && <Shortcut keys={shortcuts.focusMode} />}
+              </span>
+            }
+          >
+            {t('focus_mode')}
+          </LayoutDropdownItem>
+        </>
+      )}
     </>
   )
 }

+ 71 - 1
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 (
+      <nav className="ide-redesign-toolbar" aria-label={t('project_actions')}>
+        <div className="ide-redesign-toolbar-menu">
+          <ToolbarLogos cobranding={cobranding} />
+        </div>
+        <ToolbarProjectTitle />
+        <div className="ide-redesign-toolbar-actions">
+          <div className="ide-redesign-toolbar-button-container">
+            <SplitTestBadge
+              splitTestName="focus-mode"
+              displayOnVariants={['enabled']}
+            />
+          </div>
+          {showViewSwitcher && (
+            <div className="ide-redesign-toolbar-button-container">
+              <OLTooltip
+                id="tooltip-switch-view"
+                description={switchTooltip}
+                overlayProps={{ delay: 0, placement: 'bottom' }}
+              >
+                <OLIconButton
+                  icon={switchIcon}
+                  className="ide-redesign-toolbar-button-subdued ide-redesign-toolbar-button-icon"
+                  onClick={handleSwitchView}
+                  accessibilityLabel={switchTooltip}
+                />
+              </OLTooltip>
+            </div>
+          )}
+          <ChangeLayoutButton />
+          <div className="ide-redesign-toolbar-button-container">
+            <OLTooltip
+              id="tooltip-exit-focus-mode"
+              description={t('exit_focus_mode')}
+              overlayProps={{ delay: 0, placement: 'bottom' }}
+            >
+              <OLIconButton
+                icon="close"
+                className="ide-redesign-toolbar-button-subdued ide-redesign-toolbar-button-icon"
+                onClick={handleExitFocusMode}
+                accessibilityLabel={t('exit_focus_mode')}
+              />
+            </OLTooltip>
+          </div>
+        </div>
+      </nav>
+    )
+  }
+
   if (view === 'history') {
     return (
       <nav className="ide-redesign-toolbar" aria-label={t('project_actions')}>

+ 3 - 1
services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.tsx

@@ -1,3 +1,4 @@
+import { useLayoutContext } from '@/shared/context/layout-context'
 import { memo } from 'react'
 import { useTranslation } from 'react-i18next'
 import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar'
@@ -34,6 +35,7 @@ function PdfPreviewHybridToolbar() {
 }
 
 function PdfPreviewHybridToolbarInner() {
+  const { focusMode } = useLayoutContext()
   return (
     <>
       <div className="toolbar-pdf-left">
@@ -43,7 +45,7 @@ function PdfPreviewHybridToolbarInner() {
       </div>
       <div className="toolbar-pdf-right">
         <div className="toolbar-pdf-controls" id="toolbar-pdf-controls" />
-        <SwitchToEditorButton />
+        {!focusMode && <SwitchToEditorButton />}
         <DetachedSynctexControl />
         {/* TODO: should we have code check? */}
       </div>

+ 3 - 1
services/web/frontend/js/features/review-panel/components/review-panel-container.tsx

@@ -4,10 +4,12 @@ import { memo } from 'react'
 import ReviewPanel from './review-panel'
 import ReviewModeSwitcher from './review-mode-switcher'
 import useReviewPanelLayout from '../hooks/use-review-panel-layout'
+import { useLayoutContext } from '@/shared/context/layout-context'
 
 function ReviewPanelContainer() {
   const view = useCodeMirrorViewContext()
   const { showPanel, mini } = useReviewPanelLayout()
+  const { focusMode } = useLayoutContext()
 
   if (!view) {
     return null
@@ -15,7 +17,7 @@ function ReviewPanelContainer() {
 
   return ReactDOM.createPortal(
     <>
-      <ReviewModeSwitcher />
+      {!focusMode && <ReviewModeSwitcher />}
       {showPanel && <ReviewPanel mini={mini} />}
     </>,
     view.scrollDOM

+ 3 - 1
services/web/frontend/js/features/review-panel/hooks/use-review-panel-layout.ts

@@ -1,6 +1,7 @@
 import { useRangesContext } from '../context/ranges-context'
 import { useThreadsContext } from '@/features/review-panel/context/threads-context'
 import { hasActiveRange } from '@/features/review-panel/utils/has-active-range'
+import { useLayoutContext } from '@/shared/context/layout-context'
 import { useRailContext } from '@/features/ide-react/context/rail-context'
 import { useCallback } from 'react'
 
@@ -19,6 +20,7 @@ export default function useReviewPanelLayout(): {
     openTab: openRailTab,
     setIsOpen: setRailIsOpen,
   } = useRailContext()
+  const { focusMode } = useLayoutContext()
 
   const reviewPanelOpen = selectedRailTab === 'review-panel' && railIsOpen
 
@@ -31,7 +33,7 @@ export default function useReviewPanelLayout(): {
   }, [setRailIsOpen])
 
   const hasCommentOrChange = hasActiveRange(ranges, threads)
-  const showPanel = reviewPanelOpen || !!hasCommentOrChange
+  const showPanel = focusMode ? false : reviewPanelOpen || !!hasCommentOrChange
   const mini = !reviewPanelOpen
   const showHeader = showPanel && !mini
 

+ 6 - 0
services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx

@@ -34,6 +34,7 @@ import classNames from 'classnames'
 import { useUserSettingsContext } from '@/shared/context/user-settings-context'
 import { useFeatureFlag } from '@/shared/context/split-test-context'
 import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
+import { useLayoutContext } from '@/shared/context/layout-context'
 
 const sourceEditorToolbarComponents = importOverleafModules(
   'sourceEditorToolbarComponents'
@@ -62,6 +63,7 @@ const Toolbar = memo(function Toolbar() {
     userSettings: { breadcrumbs },
   } = useUserSettingsContext()
   const visualPreviewEnabled = useFeatureFlag('visual-preview')
+  const { focusMode } = useLayoutContext()
 
   const [overflowed, setOverflowed] = useState(false)
 
@@ -158,6 +160,10 @@ const Toolbar = memo(function Toolbar() {
 
   const showActions = !state.readOnly && !insideTable
 
+  if (focusMode) {
+    return null
+  }
+
   return (
     <>
       {showReviewPanelHeader && <ReviewPanelHeader />}

+ 69 - 9
services/web/frontend/js/shared/context/layout-context.tsx

@@ -24,6 +24,7 @@ import { useRailContext } from '@/features/ide-react/context/rail-context'
 import usePersistedState from '@/shared/hooks/use-persisted-state'
 import { repositionAllTooltips } from '@/features/source-editor/extensions/tooltips-reposition'
 import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
+import { useFeatureFlag } from './split-test-context'
 
 export type IdeLayout = 'sideBySide' | 'flat'
 export type IdeView = 'editor' | 'file' | 'pdf' | 'history'
@@ -38,6 +39,7 @@ export type LayoutContextOwnStates = {
   pdfLayout: IdeLayout
   projectSearchIsOpen: boolean
   openFile: BinaryFile | null
+  focusMode: boolean
 }
 
 export type LayoutContextValue = LayoutContextOwnStates & {
@@ -66,6 +68,7 @@ export type LayoutContextValue = LayoutContextOwnStates & {
   restoreView: () => void
   handleChangeLayout: (newLayout: IdeLayout, newView?: IdeView) => void
   handleDetach: () => void
+  setFocusMode: Dispatch<SetStateAction<LayoutContextValue['focusMode']>>
 }
 
 const debugPdfDetach = getMeta('ol-debugPdfDetach')
@@ -154,6 +157,37 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
   // whether the project search is open
   const [projectSearchIsOpen, setProjectSearchIsOpen] = useState(false)
 
+  // whether to display the editor and preview side-by-side or full-width ("flat")
+  const [pdfLayout, setPdfLayout] = useState<IdeLayout>('sideBySide')
+
+  const [persistedFocusMode, setPersistedFocusMode] =
+    usePersistedState<boolean>('ui.focus-mode', false)
+
+  const focusModeEnabled = useFeatureFlag('focus-mode')
+
+  const focusMode = focusModeEnabled && persistedFocusMode
+  const setFocusMode = useCallback(
+    (value: SetStateAction<boolean>) => {
+      if (focusModeEnabled) {
+        const newValue = typeof value === 'function' ? value(focusMode) : value
+        setPersistedFocusMode(newValue)
+        sendEvent('project-layout-change', {
+          layout: pdfLayout,
+          view: view ?? undefined,
+          focusMode: newValue,
+        })
+      }
+    },
+    [
+      focusMode,
+      focusModeEnabled,
+      pdfLayout,
+      sendEvent,
+      setPersistedFocusMode,
+      view,
+    ]
+  )
+
   useEventListener(
     'ui.toggle-left-menu',
     useCallback(
@@ -172,14 +206,21 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
     }, [setReviewPanelOpen])
   )
 
-  // TODO ide-redesign-cleanup: remove this listener as we have an equivalent in rail-context
   useEventListener(
-    'keydown',
-    useCallback((event: KeyboardEvent) => {
+    'ui.toggle-focus-mode',
+    useCallback(() => {
+      setFocusMode(mode => !mode)
+    }, [setFocusMode])
+  )
+
+  // Global keyboard shortcut handlers
+  useEffect(() => {
+    const handleKeyDown = (event: KeyboardEvent) => {
+      // Cmd/Ctrl+Shift+F for full project search
       if (
         (isMac ? event.metaKey : event.ctrlKey) &&
         event.shiftKey &&
-        event.code === 'KeyF'
+        event.key === 'F'
       ) {
         event.preventDefault()
         sendSearchEvent('search-open', {
@@ -188,11 +229,25 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
         })
         setProjectSearchIsOpen(true)
       }
-    }, [])
-  )
+      // Cmd/Ctrl+Shift+M for focus mode
+      if (
+        focusModeEnabled &&
+        (isMac ? event.metaKey : event.ctrlKey) &&
+        event.shiftKey &&
+        event.key === 'M'
+      ) {
+        event.preventDefault()
+        setFocusMode(mode => !mode)
+      }
+    }
 
-  // whether to display the editor and preview side-by-side or full-width ("flat")
-  const [pdfLayout, setPdfLayout] = useState<IdeLayout>('sideBySide')
+    // Use capture phase to ensure we get the event even if something else stops propagation
+    window.addEventListener('keydown', handleKeyDown, true)
+
+    return () => {
+      window.removeEventListener('keydown', handleKeyDown, true)
+    }
+  }, [focusModeEnabled, setFocusMode, setProjectSearchIsOpen])
 
   // whether stylesheet on theme is loading
   const [loadingStyleSheet, setLoadingStyleSheet] = useState(false)
@@ -282,9 +337,10 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
       sendEvent('project-layout-change', {
         layout: newLayout,
         view: newView,
+        focusMode,
       })
     },
-    [changeLayout, handleReattach, sendEvent]
+    [changeLayout, focusMode, handleReattach, sendEvent]
   )
 
   useEventListener(
@@ -353,6 +409,8 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
       restoreView,
       handleChangeLayout,
       handleDetach,
+      focusMode,
+      setFocusMode,
     }),
     [
       reattach,
@@ -382,6 +440,8 @@ export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
       restoreView,
       handleChangeLayout,
       handleDetach,
+      focusMode,
+      setFocusMode,
     ]
   )
 

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

@@ -849,6 +849,7 @@
   "examples_to_help_you_learn": "Examples to help you learn how to use powerful LaTeX packages and techniques.",
   "exclusive_access_with_labs": "Exclusive access to early-stage experiments",
   "existing_plan_active_until_term_end": "Your existing plan and its features will remain active until the end of the current billing period.",
+  "exit_focus_mode": "Exit focus mode",
   "expand": "Expand",
   "experiment_enabled_refresh_page": "Experiment is enabled! You need to refresh the page to see the changes.",
   "experiment_full_check_back_soon": "Sorry, this experiment is full. Spaces may become available, so check back soon.",
@@ -927,6 +928,7 @@
   "fixed_width_wrap_text": "Fixed width, wrap text",
   "flexible_plans_for_everyone": "Flexible plans for everyone—from individual students and researchers, to large businesses and universities.",
   "flexible_plans_for_everyone_from_individual_students_and_researchers_to_large_businesses_and_universities": "Flexible plans for everyone, from individual students and researchers to large businesses and universities.",
+  "focus_mode": "Focus mode",
   "fold_line": "Fold line",
   "folder_location": "Folder location",
   "folder_name": "Folder name",

+ 2 - 0
services/web/test/frontend/features/full-project-search/components/full-project-search.spec.tsx

@@ -98,6 +98,8 @@ const createInitialValue = () =>
     restoreView: cy.stub(),
     handleChangeLayout: cy.stub(),
     handleDetach: cy.stub(),
+    focusMode: false,
+    setFocusMode: cy.stub(),
   }) satisfies LayoutContextValue
 
 const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {

+ 2 - 0
services/web/test/frontend/helpers/editor-providers.tsx

@@ -577,6 +577,8 @@ const makeLayoutProvider = (
         restoreView,
         handleChangeLayout,
         handleDetach,
+        focusMode: layout.focusMode ?? false,
+        setFocusMode: layout.setFocusMode ?? (() => {}),
       }),
       [
         reattach,