Просмотр исходного кода

Merge pull request #15707 from overleaf/td-user-settings-context

Move user settings to a context

GitOrigin-RevId: 9a9d55dfee9f71cee323fe64d1442303ac7cfeb2
Tim Down 2 лет назад
Родитель
Сommit
38efea39f2
25 измененных файлов с 291 добавлено и 202 удалено
  1. 3 3
      services/web/frontend/js/features/editor-left-menu/components/left-menu-mask.tsx
  2. 2 2
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-size.tsx
  3. 9 7
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-menu-select.tsx
  4. 2 1
      services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx
  5. 3 6
      services/web/frontend/js/features/editor-left-menu/hooks/use-save-user-settings.tsx
  6. 13 4
      services/web/frontend/js/features/editor-left-menu/hooks/use-set-overall-theme.tsx
  7. 25 17
      services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx
  8. 2 23
      services/web/frontend/js/features/editor-left-menu/utils/api.ts
  9. 4 11
      services/web/frontend/js/features/history/components/diff-view/document-diff-viewer.tsx
  10. 0 2
      services/web/frontend/js/features/ide-react/context/ide-react-context.tsx
  11. 34 31
      services/web/frontend/js/features/ide-react/context/react-context-root.tsx
  12. 0 5
      services/web/frontend/js/features/ide-react/scope-adapters/settings-adapter.ts
  13. 3 3
      services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx
  14. 13 14
      services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
  15. 3 6
      services/web/frontend/js/ide.js
  16. 4 5
      services/web/frontend/js/shared/context/local-compile-context.jsx
  17. 22 19
      services/web/frontend/js/shared/context/root-context.jsx
  18. 74 0
      services/web/frontend/js/shared/context/user-settings-context.tsx
  19. 2 2
      services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx
  20. 4 4
      services/web/test/frontend/features/source-editor/components/codemirror-editor-shortcuts.spec.tsx
  21. 7 5
      services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx
  22. 0 12
      services/web/test/frontend/features/source-editor/helpers/mock-scope.ts
  23. 42 19
      services/web/test/frontend/helpers/editor-providers.jsx
  24. 19 0
      services/web/types/user-settings.ts
  25. 1 1
      services/web/types/window.ts

+ 3 - 3
services/web/frontend/js/features/editor-left-menu/components/left-menu-mask.tsx

@@ -1,11 +1,11 @@
 import { memo, useEffect, useRef, useState } from 'react'
 import { useLayoutContext } from '../../../shared/context/layout-context'
-import useScopeValue from '../../../shared/hooks/use-scope-value'
+import { useUserSettingsContext } from '@/shared/context/user-settings-context'
 
 export default memo(function LeftMenuMask() {
   const { setLeftMenuShown } = useLayoutContext()
-  const [editorTheme] = useScopeValue('settings.editorTheme')
-  const [overallTheme] = useScopeValue('settings.overallTheme')
+  const { userSettings } = useUserSettingsContext()
+  const { editorTheme, overallTheme } = userSettings
   const [original] = useState({ editorTheme, overallTheme })
   const maskRef = useRef<HTMLDivElement | null>(null)
 

+ 2 - 2
services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-size.tsx

@@ -3,8 +3,8 @@ import { useProjectSettingsContext } from '../../context/project-settings-contex
 import SettingsMenuSelect from './settings-menu-select'
 import type { Option } from './settings-menu-select'
 
-const sizes = ['10', '11', '12', '13', '14', '16', '18', '20', '22', '24']
-const options: Array<Option> = sizes.map(size => ({
+const sizes = [10, 11, 12, 13, 14, 16, 18, 20, 22, 24]
+const options: Option<number>[] = sizes.map(size => ({
   value: size,
   label: `${size}px`,
 }))

+ 9 - 7
services/web/frontend/js/features/editor-left-menu/components/settings/settings-menu-select.tsx

@@ -1,6 +1,6 @@
 import { ChangeEventHandler, useCallback } from 'react'
 
-type PossibleValue = string | boolean
+type PossibleValue = string | number | boolean
 
 export type Option<T extends PossibleValue = string> = {
   value: T
@@ -35,14 +35,16 @@ export default function SettingsMenuSelect<T extends PossibleValue = string>({
 }: SettingsMenuSelectProps<T>) {
   const handleChange: ChangeEventHandler<HTMLSelectElement> = useCallback(
     event => {
-      let value: PossibleValue = event.target.value
-      if (value === 'true' || value === 'false') {
-        value = value === 'true'
+      const selectedValue = event.target.value
+      let onChangeValue: PossibleValue = selectedValue
+      if (typeof value === 'boolean') {
+        onChangeValue = selectedValue === 'true'
+      } else if (typeof value === 'number') {
+        onChangeValue = parseInt(selectedValue, 10)
       }
-
-      onChange(value as T)
+      onChange(onChangeValue as T)
     },
-    [onChange]
+    [onChange, value]
   )
 
   return (

+ 2 - 1
services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx

@@ -2,7 +2,8 @@ import { createContext, FC, useContext, useMemo } from 'react'
 import useProjectWideSettings from '../hooks/use-project-wide-settings'
 import useUserWideSettings from '../hooks/use-user-wide-settings'
 import useProjectWideSettingsSocketListener from '../hooks/use-project-wide-settings-socket-listener'
-import type { ProjectSettings, UserSettings } from '../utils/api'
+import type { ProjectSettings } from '../utils/api'
+import { UserSettings } from '../../../../../types/user-settings'
 
 type ProjectSettingsSetterContextValue = {
   setCompiler: (compiler: ProjectSettings['compiler']) => void

+ 3 - 6
services/web/frontend/js/features/editor-left-menu/hooks/use-save-user-settings.tsx

@@ -1,12 +1,9 @@
-import useScopeValue from '../../../shared/hooks/use-scope-value'
+import { useUserSettingsContext } from '@/shared/context/user-settings-context'
 import { saveUserSettings } from '../utils/api'
-import type { UserSettings } from '../utils/api'
+import { UserSettings } from '../../../../../types/user-settings'
 
 export default function useSaveUserSettings() {
-  const [userSettings, setUserSettings] = useScopeValue<UserSettings>(
-    'settings',
-    true
-  )
+  const { userSettings, setUserSettings } = useUserSettingsContext()
 
   return (
     key: keyof UserSettings,

+ 13 - 4
services/web/frontend/js/features/editor-left-menu/hooks/use-set-overall-theme.tsx

@@ -2,16 +2,25 @@ import { useCallback, useEffect, useState } from 'react'
 import _ from 'lodash'
 import useScopeValue from '../../../shared/hooks/use-scope-value'
 import type { OverallThemeMeta } from '../../../../../types/project-settings'
-import { saveUserSettings, type UserSettings } from '../utils/api'
+import { saveUserSettings } from '../utils/api'
+import { UserSettings } from '../../../../../types/user-settings'
+import { useUserSettingsContext } from '@/shared/context/user-settings-context'
 
 export default function useSetOverallTheme() {
   const [chosenTheme, setChosenTheme] = useState<OverallThemeMeta | null>(null)
   const [loadingStyleSheet, setLoadingStyleSheet] = useScopeValue<boolean>(
     'ui.loadingStyleSheet'
   )
-  const [overallTheme, setOverallTheme] = useScopeValue<
-    UserSettings['overallTheme']
-  >('settings.overallTheme')
+
+  const { userSettings, setUserSettings } = useUserSettingsContext()
+  const { overallTheme } = userSettings
+
+  const setOverallTheme = useCallback(
+    (overallTheme: UserSettings['overallTheme']) => {
+      setUserSettings(settings => ({ ...settings, overallTheme }))
+    },
+    [setUserSettings]
+  )
 
   useEffect(() => {
     const docHeadEl = document.querySelector('head')

+ 25 - 17
services/web/frontend/js/features/editor-left-menu/hooks/use-user-wide-settings.tsx

@@ -1,17 +1,25 @@
 import { useCallback } from 'react'
-import useScopeValue from '../../../shared/hooks/use-scope-value'
+import { useUserSettingsContext } from '@/shared/context/user-settings-context'
 import useSetOverallTheme from './use-set-overall-theme'
 import useSaveUserSettings from './use-save-user-settings'
-import type { UserSettings } from '../utils/api'
+import { UserSettings } from '../../../../../types/user-settings'
 
 export default function useUserWideSettings() {
   const saveUserSettings = useSaveUserSettings()
 
-  // this may be undefined on test environments
-  const [userSettings] = useScopeValue<UserSettings | undefined>(
-    'settings',
-    true
-  )
+  const { userSettings } = useUserSettingsContext()
+  const {
+    overallTheme,
+    autoComplete,
+    autoPairDelimiters,
+    syntaxValidation,
+    editorTheme,
+    mode,
+    fontSize,
+    fontFamily,
+    lineHeight,
+    pdfViewer,
+  } = userSettings
 
   const setOverallTheme = useSetOverallTheme()
   const setAutoComplete = useCallback(
@@ -78,25 +86,25 @@ export default function useUserWideSettings() {
   )
 
   return {
-    autoComplete: userSettings?.autoComplete,
+    autoComplete,
     setAutoComplete,
-    autoPairDelimiters: userSettings?.autoPairDelimiters,
+    autoPairDelimiters,
     setAutoPairDelimiters,
-    syntaxValidation: userSettings?.syntaxValidation,
+    syntaxValidation,
     setSyntaxValidation,
-    editorTheme: userSettings?.editorTheme,
+    editorTheme,
     setEditorTheme,
-    overallTheme: userSettings?.overallTheme,
+    overallTheme,
     setOverallTheme,
-    mode: userSettings?.mode,
+    mode,
     setMode,
-    fontSize: userSettings?.fontSize,
+    fontSize,
     setFontSize,
-    fontFamily: userSettings?.fontFamily,
+    fontFamily,
     setFontFamily,
-    lineHeight: userSettings?.lineHeight,
+    lineHeight,
     setLineHeight,
-    pdfViewer: userSettings?.pdfViewer,
+    pdfViewer,
     setPdfViewer,
   }
 }

+ 2 - 23
services/web/frontend/js/features/editor-left-menu/utils/api.ts

@@ -1,29 +1,8 @@
-import type {
-  FontFamily,
-  LineHeight,
-  OverallTheme,
-} from '../../source-editor/extensions/theme'
-import type {
-  Keybindings,
-  PdfViewer,
-  ProjectCompiler,
-} from '../../../../../types/project-settings'
+import type { ProjectCompiler } from '../../../../../types/project-settings'
 import { sendMB } from '../../../infrastructure/event-tracking'
 import { postJSON } from '../../../infrastructure/fetch-json'
 import { debugConsole } from '@/utils/debugging'
-
-export type UserSettings = {
-  pdfViewer: PdfViewer
-  autoComplete: boolean
-  autoPairDelimiters: boolean
-  syntaxValidation: boolean
-  editorTheme: string
-  overallTheme: OverallTheme
-  mode: Keybindings
-  fontSize: string
-  fontFamily: FontFamily
-  lineHeight: LineHeight
-}
+import { UserSettings } from '../../../../../types/user-settings'
 
 export type ProjectSettings = {
   compiler: ProjectCompiler

+ 4 - 11
services/web/frontend/js/features/history/components/diff-view/document-diff-viewer.tsx

@@ -8,14 +8,8 @@ import {
 import { EditorView, lineNumbers } from '@codemirror/view'
 import { indentationMarkers } from '@replit/codemirror-indentation-markers'
 import { highlights, setHighlightsEffect } from '../../extensions/highlights'
-import useScopeValue from '../../../../shared/hooks/use-scope-value'
-import {
-  theme,
-  Options,
-  setOptionsTheme,
-  FontFamily,
-  LineHeight,
-} from '../../extensions/theme'
+import { useUserSettingsContext } from '@/shared/context/user-settings-context'
+import { theme, Options, setOptionsTheme } from '../../extensions/theme'
 import { indentUnit } from '@codemirror/language'
 import { Highlight } from '../../services/types/doc'
 import useIsMounted from '../../../../shared/hooks/use-is-mounted'
@@ -50,9 +44,8 @@ function DocumentDiffViewer({
   doc: string
   highlights: Highlight[]
 }) {
-  const [fontFamily] = useScopeValue<FontFamily>('settings.fontFamily')
-  const [fontSize] = useScopeValue<number>('settings.fontSize')
-  const [lineHeight] = useScopeValue<LineHeight>('settings.lineHeight')
+  const { userSettings } = useUserSettingsContext()
+  const { fontFamily, fontSize, lineHeight } = userSettings
   const isMounted = useIsMounted()
   const { t } = useTranslation()
 

+ 0 - 2
services/web/frontend/js/features/ide-react/context/ide-react-context.tsx

@@ -21,7 +21,6 @@ import { getMockIde } from '@/shared/context/mock/mock-ide'
 import { populateEditorScope } from '@/features/ide-react/scope-adapters/editor-manager-context-adapter'
 import { postJSON } from '@/infrastructure/fetch-json'
 import { EventLog } from '@/features/ide-react/editor/event-log'
-import { populateSettingsScope } from '@/features/ide-react/scope-adapters/settings-adapter'
 import { populateOnlineUsersScope } from '@/features/ide-react/context/online-users-context'
 import { populateReferenceScope } from '@/features/ide-react/context/references-context'
 import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter'
@@ -69,7 +68,6 @@ function createReactScopeValueStore(projectId: string) {
   populateLayoutScope(scopeStore)
   populateProjectScope(scopeStore)
   populatePdfScope(scopeStore)
-  populateSettingsScope(scopeStore)
   populateOnlineUsersScope(scopeStore)
   populateReferenceScope(scopeStore)
   populateReviewPanelScope(scopeStore)

+ 34 - 31
services/web/frontend/js/features/ide-react/context/react-context-root.tsx

@@ -18,6 +18,7 @@ import { ReferencesProvider } from '@/features/ide-react/context/references-cont
 import { SplitTestProvider } from '@/shared/context/split-test-context'
 import { ModalsContextProvider } from '@/features/ide-react/context/modals-context'
 import { FileTreePathProvider } from '@/features/file-tree/contexts/file-tree-path'
+import { UserSettingsProvider } from '@/shared/context/user-settings-context'
 
 export const ReactContextRoot: FC = ({ children }) => {
   return (
@@ -25,37 +26,39 @@ export const ReactContextRoot: FC = ({ children }) => {
       <ConnectionProvider>
         <IdeReactProvider>
           <UserProvider>
-            <ProjectProvider>
-              <FileTreeDataProvider>
-                <FileTreePathProvider>
-                  <ReferencesProvider>
-                    <DetachProvider>
-                      <EditorProvider>
-                        <ProjectSettingsProvider>
-                          <LayoutProvider>
-                            <LocalCompileProvider>
-                              <DetachCompileProvider>
-                                <ChatProvider>
-                                  <ModalsContextProvider>
-                                    <EditorManagerProvider>
-                                      <OnlineUsersProvider>
-                                        <MetadataProvider>
-                                          {children}
-                                        </MetadataProvider>
-                                      </OnlineUsersProvider>
-                                    </EditorManagerProvider>
-                                  </ModalsContextProvider>
-                                </ChatProvider>
-                              </DetachCompileProvider>
-                            </LocalCompileProvider>
-                          </LayoutProvider>
-                        </ProjectSettingsProvider>
-                      </EditorProvider>
-                    </DetachProvider>
-                  </ReferencesProvider>
-                </FileTreePathProvider>
-              </FileTreeDataProvider>
-            </ProjectProvider>
+            <UserSettingsProvider>
+              <ProjectProvider>
+                <FileTreeDataProvider>
+                  <FileTreePathProvider>
+                    <ReferencesProvider>
+                      <DetachProvider>
+                        <EditorProvider>
+                          <ProjectSettingsProvider>
+                            <LayoutProvider>
+                              <LocalCompileProvider>
+                                <DetachCompileProvider>
+                                  <ChatProvider>
+                                    <ModalsContextProvider>
+                                      <EditorManagerProvider>
+                                        <OnlineUsersProvider>
+                                          <MetadataProvider>
+                                            {children}
+                                          </MetadataProvider>
+                                        </OnlineUsersProvider>
+                                      </EditorManagerProvider>
+                                    </ModalsContextProvider>
+                                  </ChatProvider>
+                                </DetachCompileProvider>
+                              </LocalCompileProvider>
+                            </LayoutProvider>
+                          </ProjectSettingsProvider>
+                        </EditorProvider>
+                      </DetachProvider>
+                    </ReferencesProvider>
+                  </FileTreePathProvider>
+                </FileTreeDataProvider>
+              </ProjectProvider>
+            </UserSettingsProvider>
           </UserProvider>
         </IdeReactProvider>
       </ConnectionProvider>

+ 0 - 5
services/web/frontend/js/features/ide-react/scope-adapters/settings-adapter.ts

@@ -1,5 +0,0 @@
-import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
-
-export function populateSettingsScope(store: ReactScopeValueStore) {
-  store.set('settings', window.userSettings)
-}

+ 3 - 3
services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx

@@ -20,7 +20,7 @@ import { useTranslation } from 'react-i18next'
 import Tooltip from '../../../shared/components/tooltip'
 import Icon from '../../../shared/components/icon'
 import classnames from 'classnames'
-import useScopeValue from '../../../shared/hooks/use-scope-value'
+import { useUserSettingsContext } from '@/shared/context/user-settings-context'
 import { getStoredSelection, setStoredSelection } from '../extensions/search'
 import { debounce } from 'lodash'
 import { EditorSelection, EditorState } from '@codemirror/state'
@@ -46,8 +46,8 @@ const CodeMirrorSearchForm: FC = () => {
   const view = useCodeMirrorViewContext()
   const state = useCodeMirrorStateContext()
 
-  const [keybindings] = useScopeValue<string>('settings.mode')
-  const emacsKeybindingsActive = keybindings === 'emacs'
+  const { userSettings } = useUserSettingsContext()
+  const emacsKeybindingsActive = userSettings.mode === 'emacs'
   const [activeSearchOption, setActiveSearchOption] =
     useState<ActiveSearchOption>(null)
 

+ 13 - 14
services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts

@@ -6,10 +6,7 @@ import useEventListener from '../../../shared/hooks/use-event-listener'
 import useScopeEventListener from '../../../shared/hooks/use-scope-event-listener'
 import { createExtensions } from '../extensions'
 import {
-  FontFamily,
-  LineHeight,
   lineHeights,
-  OverallTheme,
   setEditorTheme,
   setOptionsTheme,
 } from '../extensions/theme'
@@ -49,6 +46,7 @@ import { useErrorHandler } from 'react-error-boundary'
 import { setVisual } from '../extensions/visual/visual'
 import getMeta from '../../../utils/meta'
 import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
+import { useUserSettingsContext } from '@/shared/context/user-settings-context'
 
 function useCodeMirrorScope(view: EditorView) {
   const ide = useIdeContext()
@@ -67,17 +65,18 @@ function useCodeMirrorScope(view: EditorView) {
   const [docName] = useScopeValue<string>('editor.open_doc_name')
   const [trackChanges] = useScopeValue<boolean>('editor.trackChanges')
 
-  const [fontFamily] = useScopeValue<FontFamily>('settings.fontFamily')
-  const [fontSize] = useScopeValue<number>('settings.fontSize')
-  const [lineHeight] = useScopeValue<LineHeight>('settings.lineHeight')
-  const [overallTheme] = useScopeValue<OverallTheme>('settings.overallTheme')
-  const [autoComplete] = useScopeValue<boolean>('settings.autoComplete')
-  const [editorTheme] = useScopeValue<string>('settings.editorTheme')
-  const [autoPairDelimiters] = useScopeValue<boolean>(
-    'settings.autoPairDelimiters'
-  )
-  const [mode] = useScopeValue<string>('settings.mode')
-  const [syntaxValidation] = useScopeValue<boolean>('settings.syntaxValidation')
+  const { userSettings } = useUserSettingsContext()
+  const {
+    fontFamily,
+    fontSize,
+    lineHeight,
+    overallTheme,
+    autoComplete,
+    editorTheme,
+    autoPairDelimiters,
+    mode,
+    syntaxValidation,
+  } = userSettings
 
   const [cursorHighlights] = useScopeValue<Record<string, Highlight[]>>(
     'onlineUserCursorHighlights'

+ 3 - 6
services/web/frontend/js/ide.js

@@ -367,12 +367,9 @@ If the project has been renamed please look in your project list for a new proje
       'vibrant_ink',
     ]
     $scope.darkTheme = false
-    $scope.$watch('settings.editorTheme', function (theme) {
-      if (Array.from(DARK_THEMES).includes(theme)) {
-        return ($scope.darkTheme = true)
-      } else {
-        return ($scope.darkTheme = false)
-      }
+    // Listen for settings change from React
+    window.addEventListener('settings:change', event => {
+      $scope.darkTheme = DARK_THEMES.includes(event.detail.editorTheme)
     })
 
     ide.localStorage = localStorage

+ 4 - 5
services/web/frontend/js/shared/context/local-compile-context.jsx

@@ -31,6 +31,7 @@ import { useLayoutContext } from './layout-context'
 import { useUserContext } from './user-context'
 import { useFileTreeData } from '@/shared/context/file-tree-data-context'
 import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
+import { useUserSettingsContext } from '@/shared/context/user-settings-context'
 
 export const LocalCompileContext = createContext()
 
@@ -114,8 +115,9 @@ export function LocalCompileProvider({ children }) {
     'pdf.logEntryAnnotations'
   )
 
-  // the PDF viewer
-  const [pdfViewer] = useScopeValue('settings.pdfViewer')
+  // the PDF viewer and whether syntax validation is enabled globally
+  const { userSettings } = useUserSettingsContext()
+  const { pdfViewer, syntaxValidation } = userSettings
 
   // the URL for downloading the PDF
   const [, setPdfDownloadUrl] = useScopeValueSetterOnly('pdf.downloadUrl')
@@ -232,9 +234,6 @@ export function LocalCompileProvider({ children }) {
   // whether the editor linter found errors
   const [hasLintingError, setHasLintingError] = useScopeValue('hasLintingError')
 
-  // whether syntax validation is enabled globally
-  const [syntaxValidation] = useScopeValue('settings.syntaxValidation')
-
   // the timestamp that a doc was last changed
   const [changedAt, setChangedAt] = useState(0)
 

+ 22 - 19
services/web/frontend/js/shared/context/root-context.jsx

@@ -14,31 +14,34 @@ import { SplitTestProvider } from './split-test-context'
 import { FileTreeDataProvider } from './file-tree-data-context'
 import { ProjectSettingsProvider } from '@/features/editor-left-menu/context/project-settings-context'
 import { FileTreePathProvider } from '@/features/file-tree/contexts/file-tree-path'
+import { UserSettingsProvider } from '@/shared/context/user-settings-context'
 
 export function ContextRoot({ children, ide }) {
   return (
     <SplitTestProvider>
       <IdeAngularProvider ide={ide}>
         <UserProvider>
-          <ProjectProvider>
-            <FileTreeDataProvider>
-              <FileTreePathProvider>
-                <DetachProvider>
-                  <EditorProvider>
-                    <ProjectSettingsProvider>
-                      <LayoutProvider>
-                        <LocalCompileProvider>
-                          <DetachCompileProvider>
-                            <ChatProvider>{children}</ChatProvider>
-                          </DetachCompileProvider>
-                        </LocalCompileProvider>
-                      </LayoutProvider>
-                    </ProjectSettingsProvider>
-                  </EditorProvider>
-                </DetachProvider>
-              </FileTreePathProvider>
-            </FileTreeDataProvider>
-          </ProjectProvider>
+          <UserSettingsProvider>
+            <ProjectProvider>
+              <FileTreeDataProvider>
+                <FileTreePathProvider>
+                  <DetachProvider>
+                    <EditorProvider>
+                      <ProjectSettingsProvider>
+                        <LayoutProvider>
+                          <LocalCompileProvider>
+                            <DetachCompileProvider>
+                              <ChatProvider>{children}</ChatProvider>
+                            </DetachCompileProvider>
+                          </LocalCompileProvider>
+                        </LayoutProvider>
+                      </ProjectSettingsProvider>
+                    </EditorProvider>
+                  </DetachProvider>
+                </FileTreePathProvider>
+              </FileTreeDataProvider>
+            </ProjectProvider>
+          </UserSettingsProvider>
         </UserProvider>
       </IdeAngularProvider>
     </SplitTestProvider>

+ 74 - 0
services/web/frontend/js/shared/context/user-settings-context.tsx

@@ -0,0 +1,74 @@
+import {
+  createContext,
+  useContext,
+  useMemo,
+  Dispatch,
+  SetStateAction,
+  FC,
+  useState,
+  useEffect,
+} from 'react'
+
+import { UserSettings } from '../../../../types/user-settings'
+import getMeta from '@/utils/meta'
+
+const defaultSettings: UserSettings = {
+  pdfViewer: 'pdfjs',
+  autoComplete: true,
+  autoPairDelimiters: true,
+  syntaxValidation: false,
+  editorTheme: 'textmate',
+  overallTheme: '',
+  mode: 'default',
+  fontSize: 12,
+  fontFamily: 'monaco',
+  lineHeight: 'normal',
+}
+
+type UserSettingsContextValue = {
+  userSettings: UserSettings
+  setUserSettings: Dispatch<
+    SetStateAction<UserSettingsContextValue['userSettings']>
+  >
+}
+
+export const UserSettingsContext = createContext<
+  UserSettingsContextValue | undefined
+>(undefined)
+
+export const UserSettingsProvider: FC = ({ children }) => {
+  const [userSettings, setUserSettings] = useState<
+    UserSettingsContextValue['userSettings']
+  >(() => getMeta('ol-userSettings') || defaultSettings)
+
+  const value = useMemo<UserSettingsContextValue>(
+    () => ({
+      userSettings,
+      setUserSettings,
+    }),
+    [userSettings, setUserSettings]
+  )
+
+  // Fire an event to inform non-React code of settings changes
+  useEffect(() => {
+    window.dispatchEvent(
+      new CustomEvent('settings:change', { detail: userSettings })
+    )
+  }, [userSettings])
+
+  return (
+    <UserSettingsContext.Provider value={value}>
+      {children}
+    </UserSettingsContext.Provider>
+  )
+}
+
+export function useUserSettingsContext() {
+  const context = useContext(UserSettingsContext)
+  if (!context) {
+    throw new Error(
+      'useUserSettingsContext is only available inside UserSettingsProvider'
+    )
+  }
+  return context
+}

+ 2 - 2
services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx

@@ -231,12 +231,12 @@ describe('<PdfPreview/>', function () {
 
     const scope = mockScope()
     // enable linting in the editor
-    scope.settings.syntaxValidation = true
+    const userSettings = { syntaxValidation: true }
     // mock a linting error
     scope.hasLintingError = true
 
     cy.mount(
-      <EditorProviders scope={scope}>
+      <EditorProviders scope={scope} userSettings={userSettings}>
         <div className="pdf-viewer">
           <PdfPreview />
         </div>

+ 4 - 4
services/web/test/frontend/features/source-editor/components/codemirror-editor-shortcuts.spec.tsx

@@ -140,11 +140,11 @@ contentLine3
 \\end{document}`
 
     const scope = mockScope(shortDoc)
-    scope.settings.mode = 'emacs'
+    const userSettings = { mode: 'emacs' }
 
     cy.mount(
       <Container>
-        <EditorProviders scope={scope}>
+        <EditorProviders scope={scope} userSettings={userSettings}>
           <CodeMirrorEditor />
         </EditorProviders>
       </Container>
@@ -245,11 +245,11 @@ contentLine3
 `
 
     const scope = mockScope(shortDoc)
-    scope.settings.mode = 'vim'
+    const userSettings = { mode: 'vim' }
 
     cy.mount(
       <Container>
-        <EditorProviders scope={scope}>
+        <EditorProviders scope={scope} userSettings={userSettings}>
           <CodeMirrorEditor />
         </EditorProviders>
       </Container>

+ 7 - 5
services/web/test/frontend/features/source-editor/components/codemirror-editor.spec.tsx

@@ -41,13 +41,13 @@ describe('<CodeMirrorEditor/>', { scrollBehavior: false }, function () {
 
   it('renders client-side lint annotations in the gutter', function () {
     const scope = mockScope()
-    scope.settings.syntaxValidation = true
+    const userSettings = { syntaxValidation: true }
 
     cy.clock()
 
     cy.mount(
       <Container>
-        <EditorProviders scope={scope}>
+        <EditorProviders scope={scope} userSettings={userSettings}>
           <CodeMirrorEditor />
         </EditorProviders>
       </Container>
@@ -89,11 +89,13 @@ describe('<CodeMirrorEditor/>', { scrollBehavior: false }, function () {
       ],
     }
 
+    const userSettings = { syntaxValidation: false }
+
     cy.clock()
 
     cy.mount(
       <Container>
-        <EditorProviders scope={scope}>
+        <EditorProviders scope={scope} userSettings={userSettings}>
           <CodeMirrorEditor />
         </EditorProviders>
       </Container>
@@ -318,11 +320,11 @@ describe('<CodeMirrorEditor/>', { scrollBehavior: false }, function () {
     cy.interceptCompile()
 
     const scope = mockScope()
-    scope.settings.mode = 'vim'
+    const userSettings = { mode: 'vim' }
 
     cy.mount(
       <Container>
-        <EditorProviders scope={scope}>
+        <EditorProviders scope={scope} userSettings={userSettings}>
           <CodeMirrorEditor />
         </EditorProviders>
       </Container>

+ 0 - 12
services/web/test/frontend/features/source-editor/helpers/mock-scope.ts

@@ -7,18 +7,6 @@ export const figuresFolderId = '123456789012345678901234'
 export const figureId = '234567890123456789012345'
 export const mockScope = (content?: string) => {
   return {
-    settings: {
-      fontSize: 12,
-      fontFamily: 'monaco',
-      lineHeight: 'normal',
-      editorTheme: 'textmate',
-      overallTheme: '',
-      mode: 'default',
-      autoComplete: true,
-      autoPairDelimiters: true,
-      trackChanges: true,
-      syntaxValidation: false,
-    },
     editor: {
       sharejs_doc: mockDoc(content),
       open_doc_name: 'test.tex',

+ 42 - 19
services/web/test/frontend/helpers/editor-providers.jsx

@@ -14,6 +14,7 @@ import { LocalCompileProvider } from '@/shared/context/local-compile-context'
 import { DetachCompileProvider } from '@/shared/context/detach-compile-context'
 import { ProjectSettingsProvider } from '@/features/editor-left-menu/context/project-settings-context'
 import { FileTreePathProvider } from '@/features/file-tree/contexts/file-tree-path'
+import { UserSettingsProvider } from '@/shared/context/user-settings-context'
 
 // these constants can be imported in tests instead of
 // using magic strings
@@ -22,6 +23,20 @@ export const PROJECT_NAME = 'project-name'
 export const USER_ID = '123abd'
 export const USER_EMAIL = 'testuser@example.com'
 
+const defaultUserSettings = {
+  pdfViewer: 'pdfjs',
+  fontSize: 12,
+  fontFamily: 'monaco',
+  lineHeight: 'normal',
+  editorTheme: 'textmate',
+  overallTheme: '',
+  mode: 'default',
+  autoComplete: true,
+  autoPairDelimiters: true,
+  trackChanges: true,
+  syntaxValidation: false,
+}
+
 export function EditorProviders({
   user = { id: USER_ID, email: USER_EMAIL },
   projectId = PROJECT_ID,
@@ -71,12 +86,17 @@ export function EditorProviders({
       },
     },
   },
+  userSettings = {},
   providers = {},
 }) {
   window.user = user || window.user
   window.gitBridgePublicBaseUrl = 'https://git.overleaf.test'
   window.project_id = projectId != null ? projectId : window.project_id
   window.isRestrictedTokenMember = isRestrictedTokenMember
+  window.metaAttributesCache.set(
+    'ol-userSettings',
+    merge({}, defaultUserSettings, userSettings)
+  )
 
   const $scope = merge(
     {
@@ -126,6 +146,7 @@ export function EditorProviders({
     ProjectSettingsProvider,
     SplitTestProvider,
     UserProvider,
+    UserSettingsProvider,
     ...providers,
   }
 
@@ -133,25 +154,27 @@ export function EditorProviders({
     <Providers.SplitTestProvider>
       <Providers.IdeAngularProvider ide={window._ide}>
         <Providers.UserProvider>
-          <Providers.ProjectProvider>
-            <Providers.FileTreeDataProvider>
-              <Providers.FileTreePathProvider>
-                <Providers.DetachProvider>
-                  <Providers.EditorProvider>
-                    <Providers.ProjectSettingsProvider>
-                      <Providers.LayoutProvider>
-                        <Providers.LocalCompileProvider>
-                          <Providers.DetachCompileProvider>
-                            {children}
-                          </Providers.DetachCompileProvider>
-                        </Providers.LocalCompileProvider>
-                      </Providers.LayoutProvider>
-                    </Providers.ProjectSettingsProvider>
-                  </Providers.EditorProvider>
-                </Providers.DetachProvider>
-              </Providers.FileTreePathProvider>
-            </Providers.FileTreeDataProvider>
-          </Providers.ProjectProvider>
+          <Providers.UserSettingsProvider>
+            <Providers.ProjectProvider>
+              <Providers.FileTreeDataProvider>
+                <Providers.FileTreePathProvider>
+                  <Providers.DetachProvider>
+                    <Providers.EditorProvider>
+                      <Providers.ProjectSettingsProvider>
+                        <Providers.LayoutProvider>
+                          <Providers.LocalCompileProvider>
+                            <Providers.DetachCompileProvider>
+                              {children}
+                            </Providers.DetachCompileProvider>
+                          </Providers.LocalCompileProvider>
+                        </Providers.LayoutProvider>
+                      </Providers.ProjectSettingsProvider>
+                    </Providers.EditorProvider>
+                  </Providers.DetachProvider>
+                </Providers.FileTreePathProvider>
+              </Providers.FileTreeDataProvider>
+            </Providers.ProjectProvider>
+          </Providers.UserSettingsProvider>
         </Providers.UserProvider>
       </Providers.IdeAngularProvider>
     </Providers.SplitTestProvider>

+ 19 - 0
services/web/types/user-settings.ts

@@ -0,0 +1,19 @@
+import { Keybindings, PdfViewer } from './project-settings'
+import {
+  FontFamily,
+  LineHeight,
+  OverallTheme,
+} from '@/features/source-editor/extensions/theme'
+
+export type UserSettings = {
+  pdfViewer: PdfViewer
+  autoComplete: boolean
+  autoPairDelimiters: boolean
+  syntaxValidation: boolean
+  editorTheme: string
+  overallTheme: OverallTheme
+  mode: Keybindings
+  fontSize: number
+  fontFamily: FontFamily
+  lineHeight: LineHeight
+}

+ 1 - 1
services/web/types/window.ts

@@ -3,7 +3,7 @@ import { OAuthProviders } from './oauth-providers'
 import { OverallThemeMeta } from './project-settings'
 import { User } from './user'
 import 'recurly__recurly-js'
-import { UserSettings } from '@/features/editor-left-menu/utils/api'
+import { UserSettings } from './user-settings'
 
 declare global {
   // eslint-disable-next-line no-unused-vars