Procházet zdrojové kódy

Merge pull request #28114 from overleaf/dp-spell-check-link

Fix spell check link in right click menu to always open settings modal in the right place

GitOrigin-RevId: dc5172211e2ed7db52f1f0d51503187aa3d7c178
David před 11 měsíci
rodič
revize
e94ef818b8

+ 0 - 18
services/web/frontend/js/features/ide-redesign/components/settings/appearance-settings/appearance-settings.tsx

@@ -1,18 +0,0 @@
-import SettingsSection from '../settings-section'
-import OverallThemeSetting from '../appearance-settings/overall-theme-setting'
-import EditorThemeSetting from './editor-theme-setting'
-import FontSizeSetting from './font-size-setting'
-import FontFamilySetting from './font-family-setting'
-import LineHeightSetting from './line-height-setting'
-
-export default function AppearanceSettings() {
-  return (
-    <SettingsSection>
-      <OverallThemeSetting />
-      <EditorThemeSetting />
-      <FontSizeSetting />
-      <FontFamilySetting />
-      <LineHeightSetting />
-    </SettingsSection>
-  )
-}

+ 0 - 22
services/web/frontend/js/features/ide-redesign/components/settings/compiler-settings/compiler-settings.tsx

@@ -1,22 +0,0 @@
-import SettingsSection from '../settings-section'
-import AutoCompileSetting from './auto-compile-setting'
-import CompilerSetting from './compiler-setting'
-import DraftSetting from './draft-setting'
-import ImageNameSetting from './image-name-setting'
-import RootDocumentSetting from './root-document-setting'
-import StopOnFirstErrorSetting from './stop-on-first-error-setting'
-
-export default function CompilerSettings() {
-  return (
-    <>
-      <SettingsSection>
-        <RootDocumentSetting />
-        <CompilerSetting />
-        <ImageNameSetting />
-        <DraftSetting />
-        <StopOnFirstErrorSetting />
-        <AutoCompileSetting />
-      </SettingsSection>
-    </>
-  )
-}

+ 0 - 42
services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/editor-settings.tsx

@@ -1,42 +0,0 @@
-import AutoCompleteSetting from './auto-complete-setting'
-import CodeCheckSetting from './code-check-setting'
-import AutoCloseBracketsSetting from './auto-close-brackets-setting'
-import SettingsSection from '../settings-section'
-import MathPreviewSetting from './math-preview-setting'
-import { useTranslation } from 'react-i18next'
-import KeybindingSetting from './keybinding-setting'
-import PDFViewerSetting from './pdf-viewer-setting'
-import SpellCheckSetting from './spell-check-setting'
-import DictionarySetting from './dictionary-setting'
-import importOverleafModules from '../../../../../../macros/import-overleaf-module.macro'
-import BreadcrumbsSetting from './breadcrumbs-setting'
-
-const [referenceSearchSettingModule] = importOverleafModules(
-  'referenceSearchSetting'
-)
-const ReferenceSearchSetting = referenceSearchSettingModule?.import.default
-
-export default function EditorSettings() {
-  const { t } = useTranslation()
-
-  return (
-    <>
-      <SettingsSection>
-        <AutoCompleteSetting />
-        <AutoCloseBracketsSetting />
-        <CodeCheckSetting />
-        <KeybindingSetting />
-        <PDFViewerSetting />
-        {ReferenceSearchSetting && <ReferenceSearchSetting />}
-      </SettingsSection>
-      <SettingsSection title={t('spellcheck')}>
-        <SpellCheckSetting />
-        <DictionarySetting />
-      </SettingsSection>
-      <SettingsSection title={t('tools')}>
-        <BreadcrumbsSetting />
-        <MathPreviewSetting />
-      </SettingsSection>
-    </>
-  )
-}

+ 1 - 1
services/web/frontend/js/features/ide-redesign/components/settings/setting.tsx

@@ -10,7 +10,7 @@ export default function Setting({
   children: React.ReactNode
 }) {
   return (
-    <div className="ide-setting">
+    <div id={`setting-${controlId}`} className="ide-setting">
       <div>
         <label htmlFor={controlId} className="ide-setting-title">
           {label}

+ 9 - 34
services/web/frontend/js/features/ide-redesign/components/settings/settings-modal-body.tsx

@@ -1,31 +1,8 @@
-import MaterialIcon, {
-  AvailableUnfilledIcon,
-} from '@/shared/components/material-icon'
-import { ReactElement } from 'react'
+import MaterialIcon from '@/shared/components/material-icon'
 
-import {
-  Nav,
-  NavLink,
-  TabContainer,
-  TabContent,
-  TabPane,
-} from 'react-bootstrap'
-
-export type SettingsEntry = SettingsLink | SettingsTab
-
-type SettingsTab = {
-  icon: AvailableUnfilledIcon
-  key: string
-  component: ReactElement
-  title: string
-}
-
-type SettingsLink = {
-  key: string
-  icon: AvailableUnfilledIcon
-  href: string
-  title: string
-}
+import { Nav, NavLink, TabContainer, TabContent } from 'react-bootstrap'
+import { SettingsEntry } from '../../contexts/settings-modal-context'
+import SettingsTabPane from './settings-tab-pane'
 
 export const SettingsModalBody = ({
   activeTab,
@@ -40,12 +17,12 @@ export const SettingsModalBody = ({
     <TabContainer
       transition={false}
       onSelect={setActiveTab}
-      defaultActiveKey={activeTab ?? undefined}
+      activeKey={activeTab ?? undefined}
       id="ide-settings-tabs"
     >
       <div className="d-flex flex-row">
         <Nav
-          defaultActiveKey={settingsTabs[0]?.key}
+          activeKey={activeTab ?? undefined}
           className="d-flex flex-column ide-settings-tab-nav"
         >
           {settingsTabs.map(entry => (
@@ -54,11 +31,9 @@ export const SettingsModalBody = ({
         </Nav>
         <TabContent className="ide-settings-tab-content">
           {settingsTabs
-            .filter(t => 'component' in t)
-            .map(({ key, component }) => (
-              <TabPane eventKey={key} key={key}>
-                {component}
-              </TabPane>
+            .filter(t => 'sections' in t)
+            .map(tab => (
+              <SettingsTabPane tab={tab} key={tab.key} />
             ))}
         </TabContent>
       </div>

+ 21 - 50
services/web/frontend/js/features/ide-redesign/components/settings/settings-modal.tsx

@@ -3,62 +3,33 @@ import OLModal, {
   OLModalHeader,
   OLModalTitle,
 } from '@/shared/components/ol/ol-modal'
-import { useLayoutContext } from '@/shared/context/layout-context'
 import { useTranslation } from 'react-i18next'
-import { SettingsEntry, SettingsModalBody } from './settings-modal-body'
+import { SettingsModalBody } from './settings-modal-body'
+import {
+  SettingsModalProvider,
+  useSettingsModalContext,
+} from '../../contexts/settings-modal-context'
+import useFocusOnSetting from '../../hooks/use-focus-on-setting'
 
-import AppearanceSettings from './appearance-settings/appearance-settings'
-import CompilerSettings from './compiler-settings/compiler-settings'
-import EditorSettings from './editor-settings/editor-settings'
-import { useMemo, useState } from 'react'
+const SettingsModalWrapper = () => {
+  return (
+    <SettingsModalProvider>
+      <SettingsModal />
+    </SettingsModalProvider>
+  )
+}
 
 const SettingsModal = () => {
-  // TODO ide-redesign-cleanup: Either rename the field, or introduce a separate
-  // one
-  const { leftMenuShown, setLeftMenuShown } = useLayoutContext()
   const { t } = useTranslation()
-  const settingsTabs: SettingsEntry[] = useMemo(
-    () => [
-      {
-        key: 'editor',
-        title: t('editor'),
-        icon: 'code',
-        component: <EditorSettings />,
-      },
-      {
-        key: 'compiler',
-        title: t('compiler'),
-        icon: 'picture_as_pdf',
-        component: <CompilerSettings />,
-      },
-      {
-        key: 'appearance',
-        title: t('appearance'),
-        icon: 'brush',
-        component: <AppearanceSettings />,
-      },
-      {
-        key: 'account_settings',
-        title: t('account_settings'),
-        icon: 'settings',
-        href: '/user/settings',
-      },
-      {
-        key: 'subscription',
-        title: t('subscription'),
-        icon: 'account_balance',
-        href: '/user/subscription',
-      },
-    ],
-    [t]
-  )
-  const [activeTab, setActiveTab] = useState<string | null | undefined>(
-    settingsTabs[0]?.key
-  )
+  const { show, setShow, settingsTabs, activeTab, setActiveTab } =
+    useSettingsModalContext()
+
+  useFocusOnSetting()
+
   return (
     <OLModal
-      show={leftMenuShown}
-      onHide={() => setLeftMenuShown(false)}
+      show={show}
+      onHide={() => setShow(false)}
       size="lg"
       backdropClassName={
         activeTab === 'appearance'
@@ -80,4 +51,4 @@ const SettingsModal = () => {
   )
 }
 
-export default SettingsModal
+export default SettingsModalWrapper

+ 19 - 0
services/web/frontend/js/features/ide-redesign/components/settings/settings-tab-pane.tsx

@@ -0,0 +1,19 @@
+import { TabPane } from 'react-bootstrap'
+import { SettingsTab } from '../../contexts/settings-modal-context'
+import SettingsSection from './settings-section'
+import { Fragment } from 'react'
+
+export default function SettingsTabPane({ tab }: { tab: SettingsTab }) {
+  const { key, sections } = tab
+  return (
+    <TabPane eventKey={key} key={key}>
+      {sections.map(section => (
+        <SettingsSection key={section.key} title={section.title}>
+          {section.settings.map(({ key, component }) => (
+            <Fragment key={key}>{component}</Fragment>
+          ))}
+        </SettingsSection>
+      ))}
+    </TabPane>
+  )
+}

+ 289 - 0
services/web/frontend/js/features/ide-redesign/contexts/settings-modal-context.tsx

@@ -0,0 +1,289 @@
+import { createContext, FC, useContext, useMemo, useState } from 'react'
+import { useLayoutContext } from '@/shared/context/layout-context'
+import AutoCloseBracketsSetting from '../components/settings/editor-settings/auto-close-brackets-setting'
+import AutoCompleteSetting from '../components/settings/editor-settings/auto-complete-setting'
+import CodeCheckSetting from '../components/settings/editor-settings/code-check-setting'
+import KeybindingSetting from '../components/settings/editor-settings/keybinding-setting'
+import PDFViewerSetting from '../components/settings/editor-settings/pdf-viewer-setting'
+import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
+import SpellCheckSetting from '../components/settings/editor-settings/spell-check-setting'
+import DictionarySetting from '../components/settings/editor-settings/dictionary-setting'
+import { useTranslation } from 'react-i18next'
+import BreadcrumbsSetting from '../components/settings/editor-settings/breadcrumbs-setting'
+import MathPreviewSetting from '../components/settings/editor-settings/math-preview-setting'
+import RootDocumentSetting from '../components/settings/compiler-settings/root-document-setting'
+import CompilerSetting from '../components/settings/compiler-settings/compiler-setting'
+import ImageNameSetting from '../components/settings/compiler-settings/image-name-setting'
+import DraftSetting from '../components/settings/compiler-settings/draft-setting'
+import StopOnFirstErrorSetting from '../components/settings/compiler-settings/stop-on-first-error-setting'
+import AutoCompileSetting from '../components/settings/compiler-settings/auto-compile-setting'
+import OverallThemeSetting from '../components/settings/appearance-settings/overall-theme-setting'
+import EditorThemeSetting from '../components/settings/appearance-settings/editor-theme-setting'
+import FontSizeSetting from '../components/settings/appearance-settings/font-size-setting'
+import LineHeightSetting from '../components/settings/appearance-settings/line-height-setting'
+import FontFamilySetting from '../components/settings/appearance-settings/font-family-setting'
+import { AvailableUnfilledIcon } from '@/shared/components/material-icon'
+import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
+
+const [referenceSearchSettingModule] = importOverleafModules(
+  'referenceSearchSetting'
+)
+const ReferenceSearchSetting = referenceSearchSettingModule?.import.default
+
+type Setting = {
+  key: string
+  component: React.ReactNode
+  hidden?: boolean
+}
+
+type SettingsSection = {
+  title?: string
+  key: string
+  settings: Setting[]
+}
+
+export type SettingsTab = {
+  key: string
+  icon: AvailableUnfilledIcon
+  sections: SettingsSection[]
+  title: string
+}
+
+type SettingsLink = {
+  key: string
+  icon: AvailableUnfilledIcon
+  href: string
+  title: string
+}
+
+export type SettingsEntry = SettingsLink | SettingsTab
+
+type SettingsModalState = {
+  show: boolean
+  setShow: (shown: boolean) => void
+  activeTab: string | null | undefined
+  setActiveTab: (tab: string | null | undefined) => void
+  settingsTabs: SettingsEntry[]
+  settingToTabMap: Map<string, string>
+}
+
+export const SettingsModalContext = createContext<
+  SettingsModalState | undefined
+>(undefined)
+
+export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
+  children,
+}) => {
+  const { t } = useTranslation()
+
+  // TODO ide-redesign-cleanup: Rename this field and move it directly into this context
+  const { leftMenuShown, setLeftMenuShown } = useLayoutContext()
+  const settingsTabs: SettingsEntry[] = useMemo(
+    () => [
+      {
+        key: 'editor',
+        title: t('editor'),
+        icon: 'code',
+        sections: [
+          {
+            key: 'general',
+            settings: [
+              {
+                key: 'autoComplete',
+                component: <AutoCompleteSetting />,
+              },
+              {
+                key: 'autoPairDelimiters',
+                component: <AutoCloseBracketsSetting />,
+              },
+              {
+                key: 'syntaxValidation',
+                component: <CodeCheckSetting />,
+              },
+              {
+                key: 'mode',
+                component: <KeybindingSetting />,
+              },
+              {
+                key: 'pdfViewer',
+                component: <PDFViewerSetting />,
+              },
+              {
+                key: 'write-and-cite-settings',
+                component: <ReferenceSearchSetting />,
+                hidden: !ReferenceSearchSetting,
+              },
+            ],
+          },
+          {
+            key: 'spellcheck',
+            title: t('spellcheck'),
+            settings: [
+              {
+                key: 'spellCheckLanguage',
+                component: <SpellCheckSetting />,
+              },
+              {
+                key: 'dictionary-settings',
+                component: <DictionarySetting />,
+              },
+            ],
+          },
+          {
+            key: 'tools',
+            title: t('tools'),
+            settings: [
+              {
+                key: 'breadcrumbs-setting',
+                component: <BreadcrumbsSetting />,
+              },
+              {
+                key: 'mathPreview',
+                component: <MathPreviewSetting />,
+              },
+            ],
+          },
+        ],
+      },
+      {
+        key: 'compiler',
+        title: t('compiler'),
+        icon: 'picture_as_pdf',
+        sections: [
+          {
+            key: 'general',
+            settings: [
+              {
+                key: 'rootDocId',
+                component: <RootDocumentSetting />,
+              },
+              {
+                key: 'compiler',
+                component: <CompilerSetting />,
+              },
+              {
+                key: 'imageName',
+                component: <ImageNameSetting />,
+              },
+              {
+                key: 'draft',
+                component: <DraftSetting />,
+              },
+              {
+                key: 'stopOnFirstError',
+                component: <StopOnFirstErrorSetting />,
+              },
+              {
+                key: 'autoCompile',
+                component: <AutoCompileSetting />,
+              },
+            ],
+          },
+        ],
+      },
+      {
+        key: 'appearance',
+        title: t('appearance'),
+        icon: 'brush',
+        sections: [
+          {
+            key: 'general',
+            settings: [
+              {
+                key: 'overallTheme',
+                component: <OverallThemeSetting />,
+              },
+              {
+                key: 'editorTheme',
+                component: <EditorThemeSetting />,
+              },
+              {
+                key: 'fontSize',
+                component: <FontSizeSetting />,
+              },
+              {
+                key: 'fontFamily',
+                component: <FontFamilySetting />,
+              },
+              {
+                key: 'lineHeight',
+                component: <LineHeightSetting />,
+              },
+            ],
+          },
+        ],
+      },
+      {
+        key: 'account_settings',
+        title: t('account_settings'),
+        icon: 'settings',
+        href: '/user/settings',
+      },
+      {
+        key: 'subscription',
+        title: t('subscription'),
+        icon: 'account_balance',
+        href: '/user/subscription',
+      },
+    ],
+    [t]
+  )
+
+  const settingToTabMap = useMemo(() => {
+    const map = new Map<string, string>()
+    settingsTabs
+      .filter(t => 'sections' in t)
+      .forEach(tab => {
+        tab.sections.forEach(section => {
+          section.settings.forEach(setting => {
+            map.set(setting.key, tab.key)
+          })
+        })
+      })
+    return map
+  }, [settingsTabs])
+
+  const [activeTab, setActiveTab] = useState<string | null | undefined>(
+    settingsTabs[0]?.key
+  )
+
+  const value = useMemo(
+    () => ({
+      show: leftMenuShown,
+      setShow: setLeftMenuShown,
+      activeTab,
+      setActiveTab,
+      settingsTabs,
+      settingToTabMap,
+    }),
+    [
+      leftMenuShown,
+      setLeftMenuShown,
+      activeTab,
+      setActiveTab,
+      settingsTabs,
+      settingToTabMap,
+    ]
+  )
+
+  return (
+    // TODO ide-redesign-cleanup: Merge <EditorLeftMenuProvider> into <SettingsModalProvider>
+    <EditorLeftMenuProvider>
+      <SettingsModalContext.Provider value={value}>
+        {children}
+      </SettingsModalContext.Provider>
+    </EditorLeftMenuProvider>
+  )
+}
+
+export const useSettingsModalContext = () => {
+  const value = useContext(SettingsModalContext)
+
+  if (!value) {
+    throw new Error(
+      `useSettingsModalContext is only available inside SettingsModalProvider`
+    )
+  }
+
+  return value
+}

+ 56 - 0
services/web/frontend/js/features/ide-redesign/hooks/use-focus-on-setting.tsx

@@ -0,0 +1,56 @@
+import { useEditorLeftMenuContext } from '@/features/editor-left-menu/components/editor-left-menu-context'
+import { useEffect, useState } from 'react'
+import { useSettingsModalContext } from '../contexts/settings-modal-context'
+
+/**
+ *  A hook to scroll to and focus on a specific setting in the settings modal
+ */
+export default function useFocusOnSetting() {
+  const { activeTab, setActiveTab, settingToTabMap } = useSettingsModalContext()
+  const { settingToFocus } = useEditorLeftMenuContext()
+
+  const [eltToScrollTo, setEltToScrollTo] = useState<{
+    tab: string | undefined
+    element: HTMLElement | null
+  } | null>(null)
+
+  useEffect(() => {
+    if (settingToFocus) {
+      const newActiveTab = settingToTabMap.get(settingToFocus)
+
+      const settingElt: HTMLDivElement | null = document.querySelector(
+        `#setting-${settingToFocus}`
+      )
+
+      const settingToFocusElt: HTMLElement | null =
+        settingElt?.querySelector('input, select, button') ?? settingElt
+
+      setActiveTab(newActiveTab)
+      setEltToScrollTo({ tab: newActiveTab, element: settingToFocusElt })
+    }
+
+    // clear the focus setting
+    window.dispatchEvent(
+      new CustomEvent('ui.focus-setting', { detail: undefined })
+    )
+  }, [settingToFocus, activeTab, setActiveTab, settingToTabMap])
+
+  // Scroll to the focused setting, once the correct tab is open
+  useEffect(() => {
+    if (!eltToScrollTo) {
+      return
+    }
+
+    const { tab, element } = eltToScrollTo
+
+    if (tab === activeTab) {
+      element?.scrollIntoView({
+        block: 'center',
+        behavior: 'smooth',
+      })
+      element?.focus()
+
+      setEltToScrollTo(null)
+    }
+  }, [eltToScrollTo, activeTab])
+}