Explorar o código

Data handling for settings in editor left menu react migration (#10470)

- Importing SettingsController from the angular code, which enables post requests for every select menu and socket handler for compiler, texlive version, and main document select option
 - New context for the data handling infrastructure between react and angular. The data is still located in the angular version, and I use the context only as a proxy to fetch/post new data.

GitOrigin-RevId: 59009bceb128d82969a2318e90036aacf79f9887
M Fahru %!s(int64=3) %!d(string=hai) anos
pai
achega
f27562eb12
Modificáronse 34 ficheiros con 753 adicións e 83 borrados
  1. 2 1
      services/web/app/views/project/editor/left-menu-react.pug
  2. 7 2
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-auto-close-brackets.tsx
  3. 6 2
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-auto-complete.tsx
  4. 6 1
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-compiler.tsx
  5. 6 15
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx
  6. 5 1
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-editor-theme.tsx
  7. 6 1
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx
  8. 4 0
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-size.tsx
  9. 6 7
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-image-name.tsx
  10. 6 1
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-keybindings.tsx
  11. 6 1
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-line-height.tsx
  12. 41 13
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-menu-select.tsx
  13. 10 11
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-overall-theme.tsx
  14. 6 1
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-pdf-viewer.tsx
  15. 8 6
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-spell-check-language.tsx
  16. 7 3
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-syntax-validation.tsx
  17. 166 0
      services/web/frontend/js/features/editor-left-menu/context/project-settings-context.tsx
  18. 5 5
      services/web/frontend/js/main/is-valid-tex-file.js
  19. 10 7
      services/web/frontend/js/shared/context/root-context.js
  20. 23 0
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-auto-close-brackets.test.tsx
  21. 23 0
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-auto-complete.test.tsx
  22. 47 0
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-document.test.tsx
  23. 37 0
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-editor-theme.test.tsx
  24. 23 0
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-font-family.test.tsx
  25. 24 0
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-font-size.test.tsx
  26. 39 0
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-image-name.test.tsx
  27. 26 0
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-keybindings.test.tsx
  28. 26 0
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-line-height.test.tsx
  29. 41 0
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-overall-theme.test.tsx
  30. 23 0
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-pdf-viewer.test.tsx
  31. 42 0
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-spell-check-language.test.tsx
  32. 23 0
      services/web/test/frontend/features/editor-left-menu/components/settings/settings-syntax-validation.test.tsx
  33. 10 5
      services/web/test/frontend/helpers/editor-providers.js
  34. 33 0
      services/web/types/project-settings.ts

+ 2 - 1
services/web/app/views/project/editor/left-menu-react.pug

@@ -1 +1,2 @@
-editor-left-menu()
+div(ng-controller="SettingsController")
+	editor-left-menu()

+ 7 - 2
services/web/frontend/js/features/editor-left-menu/components/settings/settings-auto-close-brackets.tsx

@@ -1,18 +1,23 @@
 import { useTranslation } from 'react-i18next'
+import { useProjectSettingsContext } from '../../context/project-settings-context'
 import SettingsMenuSelect from './settings-menu-select'
 
 export default function SettingsAutoCloseBrackets() {
   const { t } = useTranslation()
+  const { autoPairDelimiters, setAutoPairDelimiters } =
+    useProjectSettingsContext()
 
   return (
     <SettingsMenuSelect
+      onChange={setAutoPairDelimiters}
+      value={autoPairDelimiters}
       options={[
         {
-          value: 'true',
+          value: true,
           label: t('on'),
         },
         {
-          value: 'false',
+          value: false,
           label: t('off'),
         },
       ]}

+ 6 - 2
services/web/frontend/js/features/editor-left-menu/components/settings/settings-auto-complete.tsx

@@ -1,18 +1,22 @@
 import { useTranslation } from 'react-i18next'
+import { useProjectSettingsContext } from '../../context/project-settings-context'
 import SettingsMenuSelect from './settings-menu-select'
 
 export default function SettingsAutoComplete() {
   const { t } = useTranslation()
+  const { autoComplete, setAutoComplete } = useProjectSettingsContext()
 
   return (
     <SettingsMenuSelect
+      onChange={setAutoComplete}
+      value={autoComplete}
       options={[
         {
-          value: 'true',
+          value: true,
           label: t('on'),
         },
         {
-          value: 'false',
+          value: false,
           label: t('off'),
         },
       ]}

+ 6 - 1
services/web/frontend/js/features/editor-left-menu/components/settings/settings-compiler.tsx

@@ -1,17 +1,22 @@
 import { useTranslation } from 'react-i18next'
+import type { ProjectCompiler } from '../../../../../../types/project-settings'
 import { useEditorContext } from '../../../../shared/context/editor-context'
+import { useProjectSettingsContext } from '../../context/project-settings-context'
 import SettingsMenuSelect from './settings-menu-select'
 
 export default function SettingsCompiler() {
   const { t } = useTranslation()
   const { permissionsLevel } = useEditorContext()
+  const { compiler, setCompiler } = useProjectSettingsContext()
 
   if (permissionsLevel === 'readOnly') {
     return null
   }
 
   return (
-    <SettingsMenuSelect
+    <SettingsMenuSelect<ProjectCompiler>
+      onChange={setCompiler}
+      value={compiler}
       options={[
         {
           value: 'pdflatex',

+ 6 - 15
services/web/frontend/js/features/editor-left-menu/components/settings/settings-document.tsx

@@ -2,28 +2,17 @@ import { useMemo } from 'react'
 import { useTranslation } from 'react-i18next'
 import isValidTeXFile from '../../../../main/is-valid-tex-file'
 import { useEditorContext } from '../../../../shared/context/editor-context'
-import { useProjectContext } from '../../../../shared/context/project-context'
 import useScopeValue from '../../../../shared/hooks/use-scope-value'
+import { useProjectSettingsContext } from '../../context/project-settings-context'
 import SettingsMenuSelect from './settings-menu-select'
 import type { Option } from './settings-menu-select'
-
-type Doc = {
-  doc: {
-    name: string
-    id: string
-    type: string
-    selected: boolean
-  }
-  path: string
-}
+import type { MainDocument } from '../../../../../../types/project-settings'
 
 export default function SettingsDocument() {
   const { t } = useTranslation()
-
   const { permissionsLevel } = useEditorContext()
-
-  const { rootDocId } = useProjectContext()
-  const [docs] = useScopeValue<Doc[] | undefined>('docs')
+  const [docs] = useScopeValue<MainDocument[] | undefined>('docs')
+  const { rootDocId, setRootDocId } = useProjectSettingsContext()
 
   const validDocsOptions = useMemo(() => {
     const filteredDocs =
@@ -45,6 +34,8 @@ export default function SettingsDocument() {
 
   return (
     <SettingsMenuSelect
+      onChange={setRootDocId}
+      value={rootDocId}
       options={validDocsOptions}
       label={t('main_document')}
       name="rootDoc_id"

+ 5 - 1
services/web/frontend/js/features/editor-left-menu/components/settings/settings-editor-theme.tsx

@@ -1,6 +1,7 @@
 import { useMemo } from 'react'
 import { useTranslation } from 'react-i18next'
 import getMeta from '../../../../utils/meta'
+import { useProjectSettingsContext } from '../../context/project-settings-context'
 import SettingsMenuSelect from './settings-menu-select'
 import type { Option } from './settings-menu-select'
 
@@ -10,6 +11,7 @@ export default function SettingsEditorTheme() {
   const legacyEditorThemes = getMeta('ol-legacyEditorThemes') as
     | string[]
     | undefined
+  const { editorTheme, setEditorTheme } = useProjectSettingsContext()
 
   const options = useMemo(() => {
     const editorThemeOptions: Array<Option> =
@@ -36,9 +38,11 @@ export default function SettingsEditorTheme() {
 
   return (
     <SettingsMenuSelect
+      onChange={setEditorTheme}
+      value={editorTheme}
+      options={options}
       label={t('editor_theme')}
       name="editorTheme"
-      options={options}
     />
   )
 }

+ 6 - 1
services/web/frontend/js/features/editor-left-menu/components/settings/settings-font-family.tsx

@@ -1,11 +1,16 @@
 import { useTranslation } from 'react-i18next'
+import { FontFamily } from '../../../../../../modules/source-editor/frontend/js/extensions/theme'
+import { useProjectSettingsContext } from '../../context/project-settings-context'
 import SettingsMenuSelect from './settings-menu-select'
 
 export default function SettingsFontFamily() {
   const { t } = useTranslation()
+  const { fontFamily, setFontFamily } = useProjectSettingsContext()
 
   return (
-    <SettingsMenuSelect
+    <SettingsMenuSelect<FontFamily>
+      onChange={setFontFamily}
+      value={fontFamily}
       options={[
         {
           value: 'monaco',

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

@@ -1,4 +1,5 @@
 import { useTranslation } from 'react-i18next'
+import { useProjectSettingsContext } from '../../context/project-settings-context'
 import SettingsMenuSelect from './settings-menu-select'
 import type { Option } from './settings-menu-select'
 
@@ -10,9 +11,12 @@ const options: Array<Option> = sizes.map(size => ({
 
 export default function SettingsFontSize() {
   const { t } = useTranslation()
+  const { fontSize, setFontSize } = useProjectSettingsContext()
 
   return (
     <SettingsMenuSelect
+      onChange={setFontSize}
+      value={fontSize}
       options={options}
       label={t('font_size')}
       name="fontSize"

+ 6 - 7
services/web/frontend/js/features/editor-left-menu/components/settings/settings-image-name.tsx

@@ -3,16 +3,13 @@ import { useTranslation } from 'react-i18next'
 import getMeta from '../../../../utils/meta'
 import SettingsMenuSelect from './settings-menu-select'
 import type { Option } from './settings-menu-select'
-
-/* eslint-disable react/no-unused-prop-types */
-type AllowedImageName = {
-  imageDesc: string
-  imageName: string
-}
-/* eslint-enable */
+import { useProjectSettingsContext } from '../../context/project-settings-context'
+import type { AllowedImageName } from '../../../../../../types/project-settings'
 
 export default function SettingsImageName() {
   const { t } = useTranslation()
+  const { imageName, setImageName } = useProjectSettingsContext()
+
   const allowedImageNames = getMeta('ol-allowedImageNames') as
     | AllowedImageName[]
     | undefined
@@ -32,6 +29,8 @@ export default function SettingsImageName() {
 
   return (
     <SettingsMenuSelect
+      onChange={setImageName}
+      value={imageName}
       options={options}
       label={t('tex_live_version')}
       name="imageName"

+ 6 - 1
services/web/frontend/js/features/editor-left-menu/components/settings/settings-keybindings.tsx

@@ -1,11 +1,16 @@
 import { useTranslation } from 'react-i18next'
+import type { Keybindings } from '../../../../../../types/project-settings'
+import { useProjectSettingsContext } from '../../context/project-settings-context'
 import SettingsMenuSelect from './settings-menu-select'
 
 export default function SettingsKeybindings() {
   const { t } = useTranslation()
+  const { mode, setMode } = useProjectSettingsContext()
 
   return (
-    <SettingsMenuSelect
+    <SettingsMenuSelect<Keybindings>
+      onChange={setMode}
+      value={mode}
       options={[
         {
           value: 'default',

+ 6 - 1
services/web/frontend/js/features/editor-left-menu/components/settings/settings-line-height.tsx

@@ -1,11 +1,16 @@
 import { useTranslation } from 'react-i18next'
+import type { LineHeight } from '../../../../../../modules/source-editor/frontend/js/extensions/theme'
+import { useProjectSettingsContext } from '../../context/project-settings-context'
 import SettingsMenuSelect from './settings-menu-select'
 
 export default function SettingsLineHeight() {
   const { t } = useTranslation()
+  const { lineHeight, setLineHeight } = useProjectSettingsContext()
 
   return (
-    <SettingsMenuSelect
+    <SettingsMenuSelect<LineHeight>
+      onChange={setLineHeight}
+      value={lineHeight}
       options={[
         {
           value: 'compact',

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

@@ -1,43 +1,68 @@
-export type Option = {
-  value: string
+import { ChangeEventHandler, useCallback } from 'react'
+
+type PossibleValue = string | boolean
+
+export type Option<T extends PossibleValue = string> = {
+  value: T
   label: string
   ariaHidden?: 'true' | 'false'
   disabled?: boolean
 }
 
-export type Optgroup = {
+export type Optgroup<T extends PossibleValue = string> = {
   label: string
-  options: Array<Option>
+  options: Array<Option<T>>
 }
 
-type SettingsMenuSelectProps = {
+type SettingsMenuSelectProps<T extends PossibleValue = string> = {
   label: string
   name: string
-  options: Array<Option>
-  optgroup?: Optgroup
+  options: Array<Option<T>>
+  optgroup?: Optgroup<T>
   loading?: boolean
+  onChange: (val: T) => void
+  value?: T
 }
 
-export default function SettingsMenuSelect({
+export default function SettingsMenuSelect<T extends PossibleValue = string>({
   label,
   name,
   options,
   optgroup,
   loading,
-}: SettingsMenuSelectProps) {
+  onChange,
+  value,
+}: SettingsMenuSelectProps<T>) {
+  const handleChange: ChangeEventHandler<HTMLSelectElement> = useCallback(
+    event => {
+      let value: PossibleValue = event.target.value
+      if (value === 'true' || value === 'false') {
+        value = value === 'true'
+      }
+
+      onChange(value as T)
+    },
+    [onChange]
+  )
+
   return (
     <div className="form-group left-menu-setting">
-      <label htmlFor={name}>{label}</label>
+      <label htmlFor={`settings-menu-${name}`}>{label}</label>
       {loading ? (
         <p className="loading pull-right">
           <i className="fa fa-fw fa-spin fa-refresh" />
         </p>
       ) : (
-        <select name={name} className="form-control">
+        <select
+          id={`settings-menu-${name}`}
+          className="form-control"
+          onChange={handleChange}
+          value={value?.toString()}
+        >
           {options.map(option => (
             <option
               key={`${name}-${option.value}`}
-              value={option.value}
+              value={option.value.toString()}
               aria-hidden={option.ariaHidden}
               disabled={option.disabled}
             >
@@ -47,7 +72,10 @@ export default function SettingsMenuSelect({
           {optgroup ? (
             <optgroup label={optgroup.label}>
               {optgroup.options.map(option => (
-                <option value={option.value} key={option.value}>
+                <option
+                  value={option.value.toString()}
+                  key={option.value.toString()}
+                >
                   {option.label}
                 </option>
               ))}

+ 10 - 11
services/web/frontend/js/features/editor-left-menu/components/settings/settings-overall-theme.tsx

@@ -2,25 +2,22 @@ import { useMemo } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useLayoutContext } from '../../../../shared/context/layout-context'
 import getMeta from '../../../../utils/meta'
-import SettingsMenuSelect from './settings-menu-select'
-import type { Option } from './settings-menu-select'
-
-type OverallTheme = {
-  name: string
-  path: string
-  val: string
-}
+import SettingsMenuSelect, { Option } from './settings-menu-select'
+import { useProjectSettingsContext } from '../../context/project-settings-context'
+import type { OverallThemeMeta } from '../../../../../../types/project-settings'
+import type { OverallTheme } from '../../../../../../modules/source-editor/frontend/js/extensions/theme'
 
 export default function SettingsOverallTheme() {
   const { t } = useTranslation()
   const overallThemes = getMeta('ol-overallThemes') as
-    | OverallTheme[]
+    | OverallThemeMeta[]
     | undefined
   const { loadingStyleSheet } = useLayoutContext() as {
     loadingStyleSheet: boolean
   }
+  const { overallTheme, setOverallTheme } = useProjectSettingsContext()
 
-  const options: Array<Option> = useMemo(
+  const options: Array<Option<OverallTheme>> = useMemo(
     () =>
       overallThemes?.map(({ name, val }) => ({
         value: val,
@@ -37,7 +34,9 @@ export default function SettingsOverallTheme() {
   }
 
   return (
-    <SettingsMenuSelect
+    <SettingsMenuSelect<OverallTheme>
+      onChange={setOverallTheme}
+      value={overallTheme}
       options={options}
       loading={loadingStyleSheet}
       label={t('overall_theme')}

+ 6 - 1
services/web/frontend/js/features/editor-left-menu/components/settings/settings-pdf-viewer.tsx

@@ -1,11 +1,16 @@
 import { useTranslation } from 'react-i18next'
+import type { PdfViewer } from '../../../../../../types/project-settings'
+import { useProjectSettingsContext } from '../../context/project-settings-context'
 import SettingsMenuSelect from './settings-menu-select'
 
 export default function SettingsPdfViewer() {
   const { t } = useTranslation()
+  const { pdfViewer, setPdfViewer } = useProjectSettingsContext()
 
   return (
-    <SettingsMenuSelect
+    <SettingsMenuSelect<PdfViewer>
+      onChange={setPdfViewer}
+      value={pdfViewer}
       options={[
         {
           value: 'pdfjs',

+ 8 - 6
services/web/frontend/js/features/editor-left-menu/components/settings/settings-spell-check-language.tsx

@@ -1,17 +1,17 @@
 import { useMemo } from 'react'
 import { useTranslation } from 'react-i18next'
 import getMeta from '../../../../utils/meta'
+import { useProjectSettingsContext } from '../../context/project-settings-context'
 import SettingsMenuSelect from './settings-menu-select'
 import type { Optgroup } from './settings-menu-select'
-
-type Language = {
-  name: string
-  code: string
-}
+import type { SpellCheckLanguage } from '../../../../../../types/project-settings'
 
 export default function SettingsSpellCheckLanguage() {
   const { t } = useTranslation()
-  const languages = getMeta('ol-languages') as Language[] | undefined
+  const languages = getMeta('ol-languages') as SpellCheckLanguage[] | undefined
+
+  const { spellCheckLanguage, setSpellCheckLanguage } =
+    useProjectSettingsContext()
 
   const optgroup: Optgroup = useMemo(
     () => ({
@@ -27,6 +27,8 @@ export default function SettingsSpellCheckLanguage() {
 
   return (
     <SettingsMenuSelect
+      onChange={setSpellCheckLanguage}
+      value={spellCheckLanguage}
       options={[{ value: '', label: t('off') }]}
       optgroup={optgroup}
       label={t('spell_check')}

+ 7 - 3
services/web/frontend/js/features/editor-left-menu/components/settings/settings-syntax-validation.tsx

@@ -1,18 +1,22 @@
 import { useTranslation } from 'react-i18next'
+import { useProjectSettingsContext } from '../../context/project-settings-context'
 import SettingsMenuSelect from './settings-menu-select'
 
 export default function SettingsSyntaxValidation() {
   const { t } = useTranslation()
+  const { syntaxValidation, setSyntaxValidation } = useProjectSettingsContext()
 
   return (
-    <SettingsMenuSelect
+    <SettingsMenuSelect<boolean>
+      onChange={setSyntaxValidation}
+      value={syntaxValidation}
       options={[
         {
-          value: 'true',
+          value: true,
           label: t('on'),
         },
         {
-          value: 'false',
+          value: false,
           label: t('off'),
         },
       ]}

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

@@ -0,0 +1,166 @@
+import { createContext, useContext, useMemo } from 'react'
+import type { PropsWithChildren } from 'react'
+import type {
+  FontFamily,
+  LineHeight,
+  OverallTheme,
+} from '../../../../../modules/source-editor/frontend/js/extensions/theme'
+import type {
+  Keybindings,
+  PdfViewer,
+  ProjectCompiler,
+} from '../../../../../types/project-settings'
+import useScopeValue from '../../../shared/hooks/use-scope-value'
+
+type ProjectSettingsContextValue = {
+  compiler: ProjectCompiler
+  setCompiler: (compiler: ProjectCompiler) => void
+  imageName: string
+  setImageName: (imageName: string) => void
+  rootDocId: string
+  setRootDocId: (rootDocId: string) => void
+  spellCheckLanguage: string
+  setSpellCheckLanguage: (spellCheckLanguage: string) => void
+  autoComplete: boolean
+  setAutoComplete: (autoComplete: boolean) => void
+  autoPairDelimiters: boolean
+  setAutoPairDelimiters: (autoPairDelimiters: boolean) => void
+  syntaxValidation: boolean
+  setSyntaxValidation: (syntaxValidation: boolean) => void
+  mode: Keybindings
+  setMode: (mode: Keybindings) => void
+  editorTheme: string
+  setEditorTheme: (editorTheme: string) => void
+  overallTheme: OverallTheme
+  setOverallTheme: (overallTheme: OverallTheme) => void
+  fontSize: string
+  setFontSize: (fontSize: string) => void
+  fontFamily: FontFamily
+  setFontFamily: (fontFamily: FontFamily) => void
+  lineHeight: LineHeight
+  setLineHeight: (lineHeight: LineHeight) => void
+  pdfViewer: PdfViewer
+  setPdfViewer: (pdfViewer: PdfViewer) => void
+}
+
+export const ProjectSettingsContext = createContext<
+  ProjectSettingsContextValue | undefined
+>(undefined)
+
+export function ProjectSettingsProvider({
+  children,
+}: PropsWithChildren<Record<string, never>>) {
+  const [compiler, setCompiler] =
+    useScopeValue<ProjectCompiler>('project.compiler')
+  const [imageName, setImageName] = useScopeValue<string>('project.imageName')
+  const [rootDocId, setRootDocId] = useScopeValue<string>('project.rootDoc_id')
+  const [spellCheckLanguage, setSpellCheckLanguage] = useScopeValue<string>(
+    'project.spellCheckLanguage'
+  )
+  const [autoComplete, setAutoComplete] = useScopeValue<boolean>(
+    'settings.autoComplete'
+  )
+  const [autoPairDelimiters, setAutoPairDelimiters] = useScopeValue<boolean>(
+    'settings.autoPairDelimiters'
+  )
+  const [syntaxValidation, setSyntaxValidation] = useScopeValue<boolean>(
+    'settings.syntaxValidation'
+  )
+  const [editorTheme, setEditorTheme] = useScopeValue<string>(
+    'settings.editorTheme'
+  )
+  const [overallTheme, setOverallTheme] = useScopeValue<OverallTheme>(
+    'settings.overallTheme'
+  )
+  const [mode, setMode] = useScopeValue<Keybindings>('settings.mode')
+  const [fontSize, setFontSize] = useScopeValue<string>('settings.fontSize')
+  const [fontFamily, setFontFamily] = useScopeValue<FontFamily>(
+    'settings.fontFamily'
+  )
+  const [lineHeight, setLineHeight] = useScopeValue<LineHeight>(
+    'settings.lineHeight'
+  )
+  const [pdfViewer, setPdfViewer] =
+    useScopeValue<PdfViewer>('settings.pdfViewer')
+
+  const value: ProjectSettingsContextValue = useMemo(
+    () => ({
+      compiler,
+      setCompiler,
+      imageName,
+      setImageName,
+      rootDocId,
+      setRootDocId,
+      spellCheckLanguage,
+      setSpellCheckLanguage,
+      autoComplete,
+      setAutoComplete,
+      autoPairDelimiters,
+      setAutoPairDelimiters,
+      syntaxValidation,
+      setSyntaxValidation,
+      editorTheme,
+      setEditorTheme,
+      overallTheme,
+      setOverallTheme,
+      mode,
+      setMode,
+      fontSize,
+      setFontSize,
+      fontFamily,
+      setFontFamily,
+      lineHeight,
+      setLineHeight,
+      pdfViewer,
+      setPdfViewer,
+    }),
+    [
+      compiler,
+      setCompiler,
+      imageName,
+      setImageName,
+      rootDocId,
+      setRootDocId,
+      spellCheckLanguage,
+      setSpellCheckLanguage,
+      autoComplete,
+      setAutoComplete,
+      autoPairDelimiters,
+      setAutoPairDelimiters,
+      syntaxValidation,
+      setSyntaxValidation,
+      editorTheme,
+      setEditorTheme,
+      overallTheme,
+      setOverallTheme,
+      mode,
+      setMode,
+      fontSize,
+      setFontSize,
+      fontFamily,
+      setFontFamily,
+      lineHeight,
+      setLineHeight,
+      pdfViewer,
+      setPdfViewer,
+    ]
+  )
+
+  return (
+    <ProjectSettingsContext.Provider value={value}>
+      {children}
+    </ProjectSettingsContext.Provider>
+  )
+}
+
+export function useProjectSettingsContext() {
+  const context = useContext(ProjectSettingsContext)
+
+  if (!context) {
+    throw new Error(
+      'useProjectSettingsContext is only available inside ProjectSettingsProvider'
+    )
+  }
+
+  return context
+}

+ 5 - 5
services/web/frontend/js/main/is-valid-tex-file.js

@@ -1,9 +1,9 @@
-const validTeXFileRegExp = new RegExp(
-  `\\.(${window.ExposedSettings.validRootDocExtensions.join('|')})$`,
-  'i'
-)
-
 function isValidTeXFile(filename) {
+  const validTeXFileRegExp = new RegExp(
+    `\\.(${window.ExposedSettings.validRootDocExtensions.join('|')})$`,
+    'i'
+  )
+
   return validTeXFileRegExp.test(filename)
 }
 

+ 10 - 7
services/web/frontend/js/shared/context/root-context.js

@@ -12,6 +12,7 @@ import { ChatProvider } from '../../features/chat/context/chat-context'
 import { ProjectProvider } from './project-context'
 import { SplitTestProvider } from './split-test-context'
 import { FileTreeDataProvider } from './file-tree-data-context'
+import { ProjectSettingsProvider } from '../../features/editor-left-menu/context/project-settings-context'
 
 export function ContextRoot({ children, ide, settings }) {
   return (
@@ -22,13 +23,15 @@ export function ContextRoot({ children, ide, settings }) {
             <FileTreeDataProvider>
               <DetachProvider>
                 <EditorProvider settings={settings}>
-                  <LayoutProvider>
-                    <LocalCompileProvider>
-                      <DetachCompileProvider>
-                        <ChatProvider>{children}</ChatProvider>
-                      </DetachCompileProvider>
-                    </LocalCompileProvider>
-                  </LayoutProvider>
+                  <ProjectSettingsProvider>
+                    <LayoutProvider>
+                      <LocalCompileProvider>
+                        <DetachCompileProvider>
+                          <ChatProvider>{children}</ChatProvider>
+                        </DetachCompileProvider>
+                      </LocalCompileProvider>
+                    </LayoutProvider>
+                  </ProjectSettingsProvider>
                 </EditorProvider>
               </DetachProvider>
             </FileTreeDataProvider>

+ 23 - 0
services/web/test/frontend/features/editor-left-menu/components/settings/settings-auto-close-brackets.test.tsx

@@ -0,0 +1,23 @@
+import { screen, within } from '@testing-library/dom'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import SettingsAutoCloseBrackets from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-auto-close-brackets'
+import { renderWithEditorContext } from '../../../../helpers/render-with-context'
+
+describe('<SettingsAutoCloseBrackets />', function () {
+  afterEach(function () {
+    fetchMock.reset()
+  })
+
+  it('shows correct menu', async function () {
+    renderWithEditorContext(<SettingsAutoCloseBrackets />)
+
+    const select = screen.getByLabelText('Auto-close Brackets')
+
+    const optionOn = within(select).getByText('On')
+    expect(optionOn.getAttribute('value')).to.equal('true')
+
+    const optionOff = within(select).getByText('Off')
+    expect(optionOff.getAttribute('value')).to.equal('false')
+  })
+})

+ 23 - 0
services/web/test/frontend/features/editor-left-menu/components/settings/settings-auto-complete.test.tsx

@@ -0,0 +1,23 @@
+import { screen, within } from '@testing-library/dom'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import SettingsAutoComplete from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-auto-complete'
+import { renderWithEditorContext } from '../../../../helpers/render-with-context'
+
+describe('<SettingsAutoComplete />', function () {
+  afterEach(function () {
+    fetchMock.reset()
+  })
+
+  it('shows correct menu', async function () {
+    renderWithEditorContext(<SettingsAutoComplete />)
+
+    const select = screen.getByLabelText('Auto-complete')
+
+    const optionOn = within(select).getByText('On')
+    expect(optionOn.getAttribute('value')).to.equal('true')
+
+    const optionOff = within(select).getByText('Off')
+    expect(optionOff.getAttribute('value')).to.equal('false')
+  })
+})

+ 47 - 0
services/web/test/frontend/features/editor-left-menu/components/settings/settings-document.test.tsx

@@ -0,0 +1,47 @@
+import { screen, within } from '@testing-library/dom'
+import { expect } from 'chai'
+import sinon from 'sinon'
+import fetchMock from 'fetch-mock'
+import SettingsDocument from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-document'
+import * as isValidTeXFileModule from '../../../../../../frontend/js/main/is-valid-tex-file'
+import { renderWithEditorContext } from '../../../../helpers/render-with-context'
+import type { MainDocument } from '../../../../../../types/project-settings'
+
+describe('<SettingsDocument />', function () {
+  let isValidTeXFileStub: sinon.SinonStub
+  const docs: MainDocument[] = [
+    {
+      path: 'main.tex',
+      doc: {
+        name: 'main.tex',
+        id: '123abc',
+        type: 'doc',
+        selected: false,
+      },
+    },
+  ]
+
+  beforeEach(function () {
+    isValidTeXFileStub = sinon
+      .stub(isValidTeXFileModule, 'default')
+      .returns(true)
+  })
+
+  afterEach(function () {
+    fetchMock.reset()
+    isValidTeXFileStub.restore()
+  })
+
+  it('shows correct menu', async function () {
+    renderWithEditorContext(<SettingsDocument />, {
+      scope: {
+        docs,
+      },
+    })
+
+    const select = screen.getByLabelText('Main document')
+
+    const optionOn = within(select).getByText('main.tex')
+    expect(optionOn.getAttribute('value')).to.equal('123abc')
+  })
+})

+ 37 - 0
services/web/test/frontend/features/editor-left-menu/components/settings/settings-editor-theme.test.tsx

@@ -0,0 +1,37 @@
+import { screen, within } from '@testing-library/dom'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import SettingsEditorTheme from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-editor-theme'
+import { renderWithEditorContext } from '../../../../helpers/render-with-context'
+
+describe('<SettingsEditorTheme />', function () {
+  const editorThemes = ['editortheme-1', 'editortheme-2', 'editortheme-3']
+
+  const legacyEditorThemes = ['legacytheme-1', 'legacytheme-2', 'legacytheme-3']
+
+  beforeEach(function () {
+    window.metaAttributesCache.set('ol-editorThemes', editorThemes)
+    window.metaAttributesCache.set('ol-legacyEditorThemes', legacyEditorThemes)
+  })
+
+  afterEach(function () {
+    fetchMock.reset()
+    window.metaAttributesCache = new Map()
+  })
+
+  it('shows correct menu', async function () {
+    renderWithEditorContext(<SettingsEditorTheme />)
+
+    const select = screen.getByLabelText('Editor theme')
+
+    for (const theme of editorThemes) {
+      const option = within(select).getByText(theme.replace(/_/g, ' '))
+      expect(option.getAttribute('value')).to.equal(theme)
+    }
+
+    for (const theme of legacyEditorThemes) {
+      const option = within(select).getByText(theme.replace(/_/g, ' '))
+      expect(option.getAttribute('value')).to.equal(theme)
+    }
+  })
+})

+ 23 - 0
services/web/test/frontend/features/editor-left-menu/components/settings/settings-font-family.test.tsx

@@ -0,0 +1,23 @@
+import { screen, within } from '@testing-library/dom'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import SettingsFontFamily from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-font-family'
+import { renderWithEditorContext } from '../../../../helpers/render-with-context'
+
+describe('<SettingsFontFamily />', function () {
+  afterEach(function () {
+    fetchMock.reset()
+  })
+
+  it('shows correct menu', async function () {
+    renderWithEditorContext(<SettingsFontFamily />)
+
+    const select = screen.getByLabelText('Font Family')
+
+    const optionMonaco = within(select).getByText('Monaco / Menlo / Consolas')
+    expect(optionMonaco.getAttribute('value')).to.equal('monaco')
+
+    const optionLucida = within(select).getByText('Lucida / Source Code Pro')
+    expect(optionLucida.getAttribute('value')).to.equal('lucida')
+  })
+})

+ 24 - 0
services/web/test/frontend/features/editor-left-menu/components/settings/settings-font-size.test.tsx

@@ -0,0 +1,24 @@
+import { screen, within } from '@testing-library/dom'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import SettingsFontSize from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-font-size'
+import { renderWithEditorContext } from '../../../../helpers/render-with-context'
+
+describe('<SettingsFontSize />', function () {
+  const sizes = ['10', '11', '12', '13', '14', '16', '18', '20', '22', '24']
+
+  afterEach(function () {
+    fetchMock.reset()
+  })
+
+  it('shows correct menu', async function () {
+    renderWithEditorContext(<SettingsFontSize />)
+
+    const select = screen.getByLabelText('Font Size')
+
+    for (const size of sizes) {
+      const option = within(select).getByText(`${size}px`)
+      expect(option.getAttribute('value')).to.equal(size)
+    }
+  })
+})

+ 39 - 0
services/web/test/frontend/features/editor-left-menu/components/settings/settings-image-name.test.tsx

@@ -0,0 +1,39 @@
+import { screen, within } from '@testing-library/dom'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import SettingsImageName from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-image-name'
+import type { AllowedImageName } from '../../../../../../types/project-settings'
+import { renderWithEditorContext } from '../../../../helpers/render-with-context'
+
+describe('<SettingsImageName />', function () {
+  const allowedImageNames: AllowedImageName[] = [
+    {
+      imageDesc: 'Image 1',
+      imageName: 'img-1',
+    },
+    {
+      imageDesc: 'Image 2',
+      imageName: 'img-2',
+    },
+  ]
+
+  beforeEach(function () {
+    window.metaAttributesCache.set('ol-allowedImageNames', allowedImageNames)
+  })
+
+  afterEach(function () {
+    fetchMock.reset()
+    window.metaAttributesCache = new Map()
+  })
+
+  it('shows correct menu', async function () {
+    renderWithEditorContext(<SettingsImageName />)
+
+    const select = screen.getByLabelText('TeX Live version')
+
+    for (const { imageName, imageDesc } of allowedImageNames) {
+      const option = within(select).getByText(imageDesc)
+      expect(option.getAttribute('value')).to.equal(imageName)
+    }
+  })
+})

+ 26 - 0
services/web/test/frontend/features/editor-left-menu/components/settings/settings-keybindings.test.tsx

@@ -0,0 +1,26 @@
+import { screen, within } from '@testing-library/dom'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import SettingsKeybindings from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-keybindings'
+import { renderWithEditorContext } from '../../../../helpers/render-with-context'
+
+describe('<SettingsKeybindings />', function () {
+  afterEach(function () {
+    fetchMock.reset()
+  })
+
+  it('shows correct menu', async function () {
+    renderWithEditorContext(<SettingsKeybindings />)
+
+    const select = screen.getByLabelText('Keybindings')
+
+    const optionNone = within(select).getByText('None')
+    expect(optionNone.getAttribute('value')).to.equal('default')
+
+    const optionVim = within(select).getByText('Vim')
+    expect(optionVim.getAttribute('value')).to.equal('vim')
+
+    const optionEmacs = within(select).getByText('Emacs')
+    expect(optionEmacs.getAttribute('value')).to.equal('emacs')
+  })
+})

+ 26 - 0
services/web/test/frontend/features/editor-left-menu/components/settings/settings-line-height.test.tsx

@@ -0,0 +1,26 @@
+import { screen, within } from '@testing-library/dom'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import SettingsLineHeight from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-line-height'
+import { renderWithEditorContext } from '../../../../helpers/render-with-context'
+
+describe('<SettingsLineHeight />', function () {
+  afterEach(function () {
+    fetchMock.reset()
+  })
+
+  it('shows correct menu', async function () {
+    renderWithEditorContext(<SettingsLineHeight />)
+
+    const select = screen.getByLabelText('Line Height')
+
+    const optionCompact = within(select).getByText('Compact')
+    expect(optionCompact.getAttribute('value')).to.equal('compact')
+
+    const optionNormal = within(select).getByText('Normal')
+    expect(optionNormal.getAttribute('value')).to.equal('normal')
+
+    const optionWide = within(select).getByText('Wide')
+    expect(optionWide.getAttribute('value')).to.equal('wide')
+  })
+})

+ 41 - 0
services/web/test/frontend/features/editor-left-menu/components/settings/settings-overall-theme.test.tsx

@@ -0,0 +1,41 @@
+import { screen, within } from '@testing-library/dom'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import SettingsOverallTheme from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-overall-theme'
+import type { OverallThemeMeta } from '../../../../../../types/project-settings'
+import { renderWithEditorContext } from '../../../../helpers/render-with-context'
+
+describe('<SettingsOverallTheme />', function () {
+  const overallThemes: OverallThemeMeta[] = [
+    {
+      name: 'Overall Theme 1',
+      val: '',
+      path: 'https://overleaf.com/overalltheme-1.css',
+    },
+    {
+      name: 'Overall Theme 2',
+      val: 'light-',
+      path: 'https://overleaf.com/overalltheme-2.css',
+    },
+  ]
+
+  beforeEach(function () {
+    window.metaAttributesCache.set('ol-overallThemes', overallThemes)
+  })
+
+  afterEach(function () {
+    fetchMock.reset()
+    window.metaAttributesCache = new Map()
+  })
+
+  it('shows correct menu', async function () {
+    renderWithEditorContext(<SettingsOverallTheme />)
+
+    const select = screen.getByLabelText('Overall theme')
+
+    for (const theme of overallThemes) {
+      const option = within(select).getByText(theme.name)
+      expect(option.getAttribute('value')).to.equal(theme.val)
+    }
+  })
+})

+ 23 - 0
services/web/test/frontend/features/editor-left-menu/components/settings/settings-pdf-viewer.test.tsx

@@ -0,0 +1,23 @@
+import { screen, within } from '@testing-library/dom'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import SettingsPdfViewer from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-pdf-viewer'
+import { renderWithEditorContext } from '../../../../helpers/render-with-context'
+
+describe('<SettingsPdfViewer />', function () {
+  afterEach(function () {
+    fetchMock.reset()
+  })
+
+  it('shows correct menu', async function () {
+    renderWithEditorContext(<SettingsPdfViewer />)
+
+    const select = screen.getByLabelText('PDF Viewer')
+
+    const optionOverleaf = within(select).getByText('Overleaf')
+    expect(optionOverleaf.getAttribute('value')).to.equal('pdfjs')
+
+    const optionBrowser = within(select).getByText('Browser')
+    expect(optionBrowser.getAttribute('value')).to.equal('native')
+  })
+})

+ 42 - 0
services/web/test/frontend/features/editor-left-menu/components/settings/settings-spell-check-language.test.tsx

@@ -0,0 +1,42 @@
+import { screen, within } from '@testing-library/dom'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import SettingsSpellCheckLanguage from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-spell-check-language'
+import type { SpellCheckLanguage } from '../../../../../../types/project-settings'
+import { renderWithEditorContext } from '../../../../helpers/render-with-context'
+
+describe('<SettingsSpellCheckLanguage />', function () {
+  const languages: SpellCheckLanguage[] = [
+    {
+      name: 'Lang 1',
+      code: 'lang-1',
+    },
+    {
+      name: 'Lang 2',
+      code: 'lang-2',
+    },
+  ]
+
+  beforeEach(function () {
+    window.metaAttributesCache.set('ol-languages', languages)
+  })
+
+  afterEach(function () {
+    fetchMock.reset()
+    window.metaAttributesCache = new Map()
+  })
+
+  it('shows correct menu', async function () {
+    renderWithEditorContext(<SettingsSpellCheckLanguage />)
+
+    const select = screen.getByLabelText('Spell check')
+
+    const optionEmpty = within(select).getByText('Off')
+    expect(optionEmpty.getAttribute('value')).to.equal('')
+
+    for (const language of languages) {
+      const option = within(select).getByText(language.name)
+      expect(option.getAttribute('value')).to.equal(language.code)
+    }
+  })
+})

+ 23 - 0
services/web/test/frontend/features/editor-left-menu/components/settings/settings-syntax-validation.test.tsx

@@ -0,0 +1,23 @@
+import { screen, within } from '@testing-library/dom'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import SettingsSyntaxValidation from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-syntax-validation'
+import { renderWithEditorContext } from '../../../../helpers/render-with-context'
+
+describe('<SettingsSyntaxValidation />', function () {
+  afterEach(function () {
+    fetchMock.reset()
+  })
+
+  it('shows correct menu', async function () {
+    renderWithEditorContext(<SettingsSyntaxValidation />)
+
+    const select = screen.getByLabelText('Code check')
+
+    const optionOn = within(select).getByText('On')
+    expect(optionOn.getAttribute('value')).to.equal('true')
+
+    const optionOff = within(select).getByText('Off')
+    expect(optionOff.getAttribute('value')).to.equal('false')
+  })
+})

+ 10 - 5
services/web/test/frontend/helpers/editor-providers.js

@@ -12,6 +12,7 @@ import { DetachProvider } from '../../../frontend/js/shared/context/detach-conte
 import { LayoutProvider } from '../../../frontend/js/shared/context/layout-context'
 import { LocalCompileProvider } from '../../../frontend/js/shared/context/local-compile-context'
 import { DetachCompileProvider } from '../../../frontend/js/shared/context/detach-compile-context'
+import { ProjectSettingsProvider } from '../../../frontend/js/features/editor-left-menu/context/project-settings-context'
 
 // these constants can be imported in tests instead of
 // using magic strings
@@ -112,11 +113,15 @@ export function EditorProviders({
             <FileTreeDataProvider>
               <DetachProvider>
                 <EditorProvider settings={{}}>
-                  <LayoutProvider>
-                    <LocalCompileProvider>
-                      <DetachCompileProvider>{children}</DetachCompileProvider>
-                    </LocalCompileProvider>
-                  </LayoutProvider>
+                  <ProjectSettingsProvider>
+                    <LayoutProvider>
+                      <LocalCompileProvider>
+                        <DetachCompileProvider>
+                          {children}
+                        </DetachCompileProvider>
+                      </LocalCompileProvider>
+                    </LayoutProvider>
+                  </ProjectSettingsProvider>
                 </EditorProvider>
               </DetachProvider>
             </FileTreeDataProvider>

+ 33 - 0
services/web/types/project-settings.ts

@@ -0,0 +1,33 @@
+import { OverallTheme } from '../modules/source-editor/frontend/js/extensions/theme'
+
+export type AllowedImageName = {
+  imageDesc: string
+  imageName: string
+}
+
+export type MainDocument = {
+  doc: {
+    name: string
+    id: string
+    type: string
+    selected: boolean
+  }
+  path: string
+}
+
+export type ProjectCompiler = 'pdflatex' | 'latex' | 'xelatex' | 'lualatex'
+
+export type Keybindings = 'default' | 'vim' | 'emacs'
+
+export type OverallThemeMeta = {
+  name: string
+  path: string
+  val: OverallTheme
+}
+
+export type PdfViewer = 'pdfjs' | 'native'
+
+export type SpellCheckLanguage = {
+  name: string
+  code: string
+}