first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import EditorCloneProjectModalWrapper from '../../clone-project-modal/components/editor-clone-project-modal-wrapper'
import LeftMenuButton from './left-menu-button'
import { useLocation } from '../../../shared/hooks/use-location'
import * as eventTracking from '../../../infrastructure/event-tracking'
type ProjectCopyResponse = {
project_id: string
}
export default function ActionsCopyProject() {
const [showModal, setShowModal] = useState(false)
const { t } = useTranslation()
const location = useLocation()
const openProject = useCallback(
({ project_id: projectId }: ProjectCopyResponse) => {
location.assign(`/project/${projectId}`)
},
[location]
)
const handleShowModal = useCallback(() => {
eventTracking.sendMB('left-menu-copy')
setShowModal(true)
}, [])
return (
<>
<LeftMenuButton onClick={handleShowModal} icon="file_copy">
{t('copy_project')}
</LeftMenuButton>
<EditorCloneProjectModalWrapper
show={showModal}
handleHide={() => setShowModal(false)}
openProject={openProject}
/>
</>
)
}

View File

@@ -0,0 +1,39 @@
import { ElementType } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '../../../utils/meta'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import ActionsCopyProject from './actions-copy-project'
import ActionsWordCount from './actions-word-count'
const components = importOverleafModules('editorLeftMenuManageTemplate') as {
import: { default: ElementType }
path: string
}[]
export default function ActionsMenu() {
const { t } = useTranslation()
const anonymous = getMeta('ol-anonymous')
if (anonymous) {
return null
}
return (
<>
<h4>{t('actions')}</h4>
<ul className="list-unstyled nav">
<li>
<ActionsCopyProject />
</li>
{components.map(({ import: { default: Component }, path }) => (
<li key={path}>
<Component />
</li>
))}
<li>
<ActionsWordCount />
</li>
</ul>
</>
)
}

View File

@@ -0,0 +1,50 @@
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import WordCountModal from '../../word-count-modal/components/word-count-modal'
import LeftMenuButton from './left-menu-button'
import * as eventTracking from '../../../infrastructure/event-tracking'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
export default function ActionsWordCount() {
const [showModal, setShowModal] = useState(false)
const { pdfUrl } = useCompileContext()
const { t } = useTranslation()
const handleShowModal = useCallback(() => {
eventTracking.sendMB('left-menu-count')
setShowModal(true)
}, [])
return (
<>
{pdfUrl ? (
<LeftMenuButton onClick={handleShowModal} icon="match_case">
{t('word_count')}
</LeftMenuButton>
) : (
<OLTooltip
id="disabled-word-count"
description={t('please_compile_pdf_before_word_count')}
overlayProps={{
placement: 'top',
}}
>
{/* OverlayTrigger won't fire unless the child is a non-react html element (e.g div, span) */}
<div>
<LeftMenuButton
icon="match_case"
disabled
disabledAccesibilityText={t(
'please_compile_pdf_before_word_count'
)}
>
{t('word_count')}
</LeftMenuButton>
</div>
</OLTooltip>
)}
<WordCountModal show={showModal} handleHide={() => setShowModal(false)} />
</>
)
}

View File

@@ -0,0 +1,21 @@
import { useTranslation } from 'react-i18next'
import DownloadPDF from './download-pdf'
import DownloadSource from './download-source'
export default function DownloadMenu() {
const { t } = useTranslation()
return (
<>
<h4 className="mt-0">{t('download')}</h4>
<ul className="list-unstyled nav nav-downloads text-center">
<li>
<DownloadSource />
</li>
<li>
<DownloadPDF />
</li>
</ul>
</>
)
}

View File

@@ -0,0 +1,50 @@
import { useTranslation } from 'react-i18next'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { useProjectContext } from '../../../shared/context/project-context'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { isSmallDevice } from '../../../infrastructure/event-tracking'
import MaterialIcon from '@/shared/components/material-icon'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
export default function DownloadPDF() {
const { t } = useTranslation()
const { pdfDownloadUrl, pdfUrl } = useCompileContext()
const { _id: projectId } = useProjectContext()
function sendDownloadEvent() {
eventTracking.sendMB('download-pdf-button-click', {
projectId,
location: 'left-menu',
isSmallDevice,
})
}
if (pdfUrl) {
return (
<a
href={pdfDownloadUrl || pdfUrl}
target="_blank"
rel="noreferrer"
onClick={sendDownloadEvent}
>
<MaterialIcon type="picture_as_pdf" size="2x" />
<br />
PDF
</a>
)
} else {
return (
<OLTooltip
id="disabled-pdf-download"
description={t('please_compile_pdf_before_download')}
overlayProps={{ placement: 'bottom' }}
>
<div className="link-disabled">
<MaterialIcon type="picture_as_pdf" size="2x" />
<br />
PDF
</div>
</OLTooltip>
)
}
}

View File

@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '../../../shared/context/project-context'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { isSmallDevice } from '../../../infrastructure/event-tracking'
import MaterialIcon from '@/shared/components/material-icon'
export default function DownloadSource() {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
function sendDownloadEvent() {
eventTracking.sendMB('download-zip-button-click', {
projectId,
location: 'left-menu',
isSmallDevice,
})
}
return (
<a
href={`/project/${projectId}/download/zip`}
target="_blank"
rel="noreferrer"
onClick={sendDownloadEvent}
>
<MaterialIcon type="folder_zip" size="2x" />
<br />
{t('source')}
</a>
)
}

View File

@@ -0,0 +1,17 @@
import DownloadMenu from './download-menu'
import ActionsMenu from './actions-menu'
import HelpMenu from './help-menu'
import SyncMenu from './sync-menu'
import SettingsMenu from './settings-menu'
export default function EditorLeftMenuBody() {
return (
<>
<DownloadMenu />
<ActionsMenu />
<SyncMenu />
<SettingsMenu />
<HelpMenu />
</>
)
}

View File

@@ -0,0 +1,44 @@
import { createContext, FC, useCallback, useContext, useState } from 'react'
import useEventListener from '@/shared/hooks/use-event-listener'
type EditorLeftMenuState = {
settingToFocus?: string
}
export const EditorLeftMenuContext = createContext<
EditorLeftMenuState | undefined
>(undefined)
export const EditorLeftMenuProvider: FC = ({ children }) => {
const [value, setValue] = useState<EditorLeftMenuState>(() => ({
settingToFocus: undefined,
}))
useEventListener(
'ui.focus-setting',
useCallback(event => {
setValue(value => ({
...value,
settingToFocus: (event as CustomEvent<string>).detail,
}))
}, [])
)
return (
<EditorLeftMenuContext.Provider value={value}>
{children}
</EditorLeftMenuContext.Provider>
)
}
export const useEditorLeftMenuContext = () => {
const value = useContext(EditorLeftMenuContext)
if (!value) {
throw new Error(
`useEditorLeftMenuContext is only available inside EditorLeftMenuProvider`
)
}
return value
}

View File

@@ -0,0 +1,55 @@
import { useLayoutContext } from '../../../shared/context/layout-context'
import LeftMenuMask from './left-menu-mask'
import classNames from 'classnames'
import { lazy, memo, Suspense } from 'react'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
import { Offcanvas } from 'react-bootstrap-5'
import { EditorLeftMenuProvider } from './editor-left-menu-context'
import withErrorBoundary from '@/infrastructure/error-boundary'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import { useTranslation } from 'react-i18next'
const EditorLeftMenuBody = lazy(() => import('./editor-left-menu-body'))
const LazyEditorLeftMenuWithErrorBoundary = withErrorBoundary(
() => (
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<EditorLeftMenuBody />
</Suspense>
),
() => {
const { t } = useTranslation()
return <OLNotification type="error" content={t('something_went_wrong')} />
}
)
function EditorLeftMenu() {
const { leftMenuShown, setLeftMenuShown } = useLayoutContext()
const closeLeftMenu = () => {
setLeftMenuShown(false)
}
return (
<EditorLeftMenuProvider>
<Offcanvas
show={leftMenuShown}
onHide={closeLeftMenu}
backdropClassName="left-menu-modal-backdrop"
id="left-menu-offcanvas"
>
<Offcanvas.Body
className={classNames('full-size', 'left-menu', {
shown: leftMenuShown,
})}
id="left-menu"
>
<LazyEditorLeftMenuWithErrorBoundary />
</Offcanvas.Body>
</Offcanvas>
{leftMenuShown && <LeftMenuMask />}
</EditorLeftMenuProvider>
)
}
export default memo(EditorLeftMenu)

View File

@@ -0,0 +1,24 @@
import { useTranslation } from 'react-i18next'
import { useCallback } from 'react'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { useContactUsModal } from '../../../shared/hooks/use-contact-us-modal'
import LeftMenuButton from './left-menu-button'
export default function HelpContactUs() {
const { modal, showModal } = useContactUsModal()
const { t } = useTranslation()
const showModalWithAnalytics = useCallback(() => {
eventTracking.sendMB('left-menu-contact')
showModal()
}, [showModal])
return (
<>
<LeftMenuButton onClick={showModalWithAnalytics} icon="contact_support">
{t('contact_us')}
</LeftMenuButton>
{modal}
</>
)
}

View File

@@ -0,0 +1,14 @@
import { useTranslation } from 'react-i18next'
import LeftMenuButton from './left-menu-button'
export default function HelpDocumentation() {
const { t } = useTranslation()
return (
<>
<LeftMenuButton type="link" href="/learn" icon="book_4">
{t('documentation')}
</LeftMenuButton>
</>
)
}

View File

@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'
import getMeta from '../../../utils/meta'
import HelpContactUs from './help-contact-us'
import HelpDocumentation from './help-documentation'
import HelpShowHotkeys from './help-show-hotkeys'
export default function HelpMenu() {
const { t } = useTranslation()
const showSupport = getMeta('ol-showSupport')
return (
<>
<h4>{t('help')}</h4>
<ul className="list-unstyled nav">
<li>
<HelpShowHotkeys />
</li>
{showSupport ? (
<>
<li>
<HelpDocumentation />
</li>
<li>
<HelpContactUs />
</li>
</>
) : null}
</ul>
</>
)
}

View File

@@ -0,0 +1,32 @@
import { useState, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { useProjectContext } from '../../../shared/context/project-context'
import HotkeysModal from '../../hotkeys-modal/components/hotkeys-modal'
import LeftMenuButton from './left-menu-button'
import { isMac } from '@/shared/utils/os'
export default function HelpShowHotkeys() {
const [showModal, setShowModal] = useState(false)
const { t } = useTranslation()
const { features } = useProjectContext()
const showModalWithAnalytics = useCallback(() => {
eventTracking.sendMB('left-menu-hotkeys')
setShowModal(true)
}, [])
return (
<>
<LeftMenuButton onClick={showModalWithAnalytics} icon="keyboard">
{t('show_hotkeys')}
</LeftMenuButton>
<HotkeysModal
show={showModal}
handleHide={() => setShowModal(false)}
isMac={isMac}
trackChangesVisible={features?.trackChangesVisible}
/>
</>
)
}

View File

@@ -0,0 +1,70 @@
import { PropsWithChildren } from 'react'
import MaterialIcon from '@/shared/components/material-icon'
type Props = {
onClick?: () => void
icon?: string
svgIcon?: React.ReactElement | null
disabled?: boolean
disabledAccesibilityText?: string
type?: 'button' | 'link'
href?: string
}
function LeftMenuButtonIcon({
svgIcon,
icon,
}: {
svgIcon?: React.ReactElement | null
icon?: string
}) {
if (svgIcon) {
return <div className="material-symbols">{svgIcon}</div>
} else if (icon) {
return <MaterialIcon type={icon} />
} else return null
}
export default function LeftMenuButton({
children,
svgIcon,
onClick,
icon,
disabled = false,
disabledAccesibilityText,
type = 'button',
href,
}: PropsWithChildren<Props>) {
if (disabled) {
return (
<div className="left-menu-button link-disabled">
<LeftMenuButtonIcon svgIcon={svgIcon} icon={icon} />
<span>{children}</span>
{disabledAccesibilityText ? (
<span className="sr-only">{disabledAccesibilityText}</span>
) : null}
</div>
)
}
if (type === 'button') {
return (
<button onClick={onClick} className="left-menu-button">
<LeftMenuButtonIcon svgIcon={svgIcon} icon={icon} />
<span>{children}</span>
</button>
)
} else {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="left-menu-button"
>
<LeftMenuButtonIcon svgIcon={svgIcon} icon={icon} />
<span>{children}</span>
</a>
)
}
}

View File

@@ -0,0 +1,31 @@
import { memo, useEffect, useRef, useState } from 'react'
import { useLayoutContext } from '../../../shared/context/layout-context'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
export default memo(function LeftMenuMask() {
const { setLeftMenuShown } = useLayoutContext()
const { userSettings } = useUserSettingsContext()
const { editorTheme, overallTheme } = userSettings
const [original] = useState({ editorTheme, overallTheme })
const maskRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (maskRef.current) {
if (
editorTheme !== original.editorTheme ||
overallTheme !== original.overallTheme
) {
maskRef.current.style.opacity = '0'
}
}
}, [editorTheme, overallTheme, original])
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
id="left-menu-mask"
ref={maskRef}
onClick={() => setLeftMenuShown(false)}
/>
)
})

View File

@@ -0,0 +1,62 @@
import { useTranslation } from 'react-i18next'
import getMeta from '../../../utils/meta'
import SettingsAutoCloseBrackets from './settings/settings-auto-close-brackets'
import SettingsAutoComplete from './settings/settings-auto-complete'
import SettingsCompiler from './settings/settings-compiler'
import SettingsDictionary from './settings/settings-dictionary'
import SettingsDocument from './settings/settings-document'
import SettingsEditorTheme from './settings/settings-editor-theme'
import SettingsFontFamily from './settings/settings-font-family'
import SettingsFontSize from './settings/settings-font-size'
import SettingsImageName from './settings/settings-image-name'
import SettingsKeybindings from './settings/settings-keybindings'
import SettingsLineHeight from './settings/settings-line-height'
import SettingsOverallTheme from './settings/settings-overall-theme'
import SettingsPdfViewer from './settings/settings-pdf-viewer'
import SettingsSpellCheckLanguage from './settings/settings-spell-check-language'
import SettingsSyntaxValidation from './settings/settings-syntax-validation'
import SettingsMathPreview from './settings/settings-math-preview'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import { ElementType } from 'react'
import OLForm from '@/features/ui/components/ol/ol-form'
const moduleSettings: Array<{
import: { default: ElementType }
path: string
}> = importOverleafModules('settingsEntries')
export default function SettingsMenu() {
const { t } = useTranslation()
const anonymous = getMeta('ol-anonymous')
if (anonymous) {
return null
}
return (
<>
<h4>{t('settings')}</h4>
<OLForm id="left-menu-setting" className="settings">
<SettingsCompiler />
<SettingsImageName />
<SettingsDocument />
<SettingsSpellCheckLanguage />
<SettingsDictionary />
{moduleSettings.map(({ import: { default: Component }, path }) => (
<Component key={path} />
))}
<SettingsAutoComplete />
<SettingsAutoCloseBrackets />
<SettingsSyntaxValidation />
<SettingsMathPreview />
<SettingsEditorTheme />
<SettingsOverallTheme />
<SettingsKeybindings />
<SettingsFontSize />
<SettingsFontFamily />
<SettingsLineHeight />
<SettingsPdfViewer />
</OLForm>
</>
)
}

View File

@@ -0,0 +1,28 @@
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,
label: t('on'),
},
{
value: false,
label: t('off'),
},
]}
label={t('auto_close_brackets')}
name="autoPairDelimiters"
/>
)
}

View File

@@ -0,0 +1,27 @@
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,
label: t('on'),
},
{
value: false,
label: t('off'),
},
]}
label={t('auto_complete')}
name="autoComplete"
/>
)
}

View File

@@ -0,0 +1,39 @@
import { useTranslation } from 'react-i18next'
import type { ProjectCompiler } from '../../../../../../types/project-settings'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
export default function SettingsCompiler() {
const { t } = useTranslation()
const { write } = usePermissionsContext()
const { compiler, setCompiler } = useProjectSettingsContext()
return (
<SettingsMenuSelect<ProjectCompiler>
onChange={setCompiler}
value={compiler}
disabled={!write}
options={[
{
value: 'pdflatex',
label: 'pdfLaTeX',
},
{
value: 'latex',
label: 'LaTeX',
},
{
value: 'xelatex',
label: 'XeLaTeX',
},
{
value: 'lualatex',
label: 'LuaLaTeX',
},
]}
label={t('compiler')}
name="compiler"
/>
)
}

View File

@@ -0,0 +1,30 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import DictionaryModal from '../../../dictionary/components/dictionary-modal'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
export default function SettingsDictionary() {
const { t } = useTranslation()
const [showModal, setShowModal] = useState(false)
return (
<OLFormGroup className="left-menu-setting">
<OLFormLabel htmlFor="dictionary-settings">{t('dictionary')}</OLFormLabel>
<OLButton
id="dictionary-settings"
variant="secondary"
size="sm"
onClick={() => setShowModal(true)}
>
{t('edit')}
</OLButton>
<DictionaryModal
show={showModal}
handleHide={() => setShowModal(false)}
/>
</OLFormGroup>
)
}

View File

@@ -0,0 +1,48 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { isValidTeXFile } from '../../../../main/is-valid-tex-file'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
import type { Option } from './settings-menu-select'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
export default function SettingsDocument() {
const { t } = useTranslation()
const { write } = usePermissionsContext()
const { docs } = useFileTreeData()
const { rootDocId, setRootDocId } = useProjectSettingsContext()
const validDocsOptions = useMemo(() => {
const filteredDocs =
docs?.filter(
doc => isValidTeXFile(doc.doc.name) || rootDocId === doc.doc.id
) ?? []
const mappedDocs: Array<Option> = filteredDocs.map(doc => ({
value: doc.doc.id,
label: doc.path,
}))
if (!rootDocId) {
mappedDocs.unshift({
value: '',
label: 'None',
disabled: true,
})
}
return mappedDocs
}, [docs, rootDocId])
return (
<SettingsMenuSelect
onChange={setRootDocId}
value={rootDocId ?? ''}
disabled={!write}
options={validDocsOptions}
label={t('main_document')}
name="rootDocId"
/>
)
}

View File

@@ -0,0 +1,45 @@
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'
export default function SettingsEditorTheme() {
const { t } = useTranslation()
const editorThemes = getMeta('ol-editorThemes')
const legacyEditorThemes = getMeta('ol-legacyEditorThemes')
const { editorTheme, setEditorTheme } = useProjectSettingsContext()
const options = useMemo(() => {
const editorThemeOptions: Array<Option> =
editorThemes?.map(theme => ({
value: theme,
label: theme.replace(/_/g, ' '),
})) ?? []
const dividerOption: Option = {
value: '-',
label: '—————————————————',
disabled: true,
}
const legacyEditorThemeOptions: Array<Option> =
legacyEditorThemes?.map(theme => ({
value: theme,
label: theme.replace(/_/g, ' ') + ' (Legacy)',
})) ?? []
return [...editorThemeOptions, dividerOption, ...legacyEditorThemeOptions]
}, [editorThemes, legacyEditorThemes])
return (
<SettingsMenuSelect
onChange={setEditorTheme}
value={editorTheme}
options={options}
label={t('editor_theme')}
name="editorTheme"
/>
)
}

View File

@@ -0,0 +1,47 @@
import { useTranslation } from 'react-i18next'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
import BetaBadge from '@/shared/components/beta-badge'
import { FontFamily } from '@/shared/utils/styles'
export default function SettingsFontFamily() {
const { t } = useTranslation()
const { fontFamily, setFontFamily } = useProjectSettingsContext()
return (
<div className="left-menu-setting-position">
<SettingsMenuSelect<FontFamily>
onChange={setFontFamily}
value={fontFamily}
options={[
{
value: 'monaco',
label: 'Monaco / Menlo / Consolas',
},
{
value: 'lucida',
label: 'Lucida / Source Code Pro',
},
{
value: 'opendyslexicmono',
label: 'OpenDyslexic Mono',
},
]}
label={t('font_family')}
name="fontFamily"
/>
<BetaBadge
phase="release"
link={{
href: 'https://docs.google.com/forms/d/e/1FAIpQLScOt_IHTrcaM_uitP9dgCo_r4dl4cy9Ry6LhYYcwTN4qDTDUg/viewform',
className: 'left-menu-setting-icon',
}}
tooltip={{
id: 'font-family-tooltip',
text: `${t('new_font_open_dyslexic')} ${t('click_to_give_feedback')}`,
placement: 'right',
}}
/>
</div>
)
}

View File

@@ -0,0 +1,25 @@
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'
const sizes = [10, 11, 12, 13, 14, 16, 18, 20, 22, 24]
const options: Option<number>[] = sizes.map(size => ({
value: size,
label: `${size}px`,
}))
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"
/>
)
}

View File

@@ -0,0 +1,42 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '../../../../utils/meta'
import SettingsMenuSelect from './settings-menu-select'
import type { Option } from './settings-menu-select'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
export default function SettingsImageName() {
const { t } = useTranslation()
const { imageName, setImageName } = useProjectSettingsContext()
const { write } = usePermissionsContext()
const allowedImageNames = useMemo(
() => getMeta('ol-allowedImageNames') || [],
[]
)
const options: Array<Option> = useMemo(
() =>
allowedImageNames.map(({ imageName, imageDesc }) => ({
value: imageName,
label: imageDesc,
})),
[allowedImageNames]
)
if (allowedImageNames.length === 0) {
return null
}
return (
<SettingsMenuSelect
onChange={setImageName}
value={imageName}
disabled={!write}
options={options}
label={t('tex_live_version')}
name="imageName"
/>
)
}

View File

@@ -0,0 +1,32 @@
import { useTranslation } from 'react-i18next'
import type { Keybindings } from '../../../../../../types/user-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<Keybindings>
onChange={setMode}
value={mode}
options={[
{
value: 'default',
label: 'None',
},
{
value: 'vim',
label: 'Vim',
},
{
value: 'emacs',
label: 'Emacs',
},
]}
label={t('keybindings')}
name="mode"
/>
)
}

View File

@@ -0,0 +1,32 @@
import { useTranslation } from 'react-i18next'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
import { LineHeight } from '@/shared/utils/styles'
export default function SettingsLineHeight() {
const { t } = useTranslation()
const { lineHeight, setLineHeight } = useProjectSettingsContext()
return (
<SettingsMenuSelect<LineHeight>
onChange={setLineHeight}
value={lineHeight}
options={[
{
value: 'compact',
label: t('compact'),
},
{
value: 'normal',
label: t('normal'),
},
{
value: 'wide',
label: t('wide'),
},
]}
label={t('line_height')}
name="lineHeight"
/>
)
}

View File

@@ -0,0 +1,27 @@
import { useTranslation } from 'react-i18next'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import SettingsMenuSelect from './settings-menu-select'
export default function SettingsMathPreview() {
const { t } = useTranslation()
const { mathPreview, setMathPreview } = useProjectSettingsContext()
return (
<SettingsMenuSelect
onChange={setMathPreview}
value={mathPreview}
options={[
{
value: true,
label: t('on'),
},
{
value: false,
label: t('off'),
},
]}
label={t('equation_preview')}
name="mathPreview"
/>
)
}

View File

@@ -0,0 +1,125 @@
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
import { ChangeEventHandler, useCallback, useEffect, useRef } from 'react'
import { Spinner } from 'react-bootstrap-5'
import { useEditorLeftMenuContext } from '@/features/editor-left-menu/components/editor-left-menu-context'
type PossibleValue = string | number | boolean
export type Option<T extends PossibleValue = string> = {
value: T
label: string
ariaHidden?: 'true' | 'false'
disabled?: boolean
}
export type Optgroup<T extends PossibleValue = string> = {
label: string
options: Array<Option<T>>
}
type SettingsMenuSelectProps<T extends PossibleValue = string> = {
label: string
name: string
options: Array<Option<T>>
optgroup?: Optgroup<T>
loading?: boolean
onChange: (val: T) => void
value?: T
disabled?: boolean
}
export default function SettingsMenuSelect<T extends PossibleValue = string>({
label,
name,
options,
optgroup,
loading,
onChange,
value,
disabled = false,
}: SettingsMenuSelectProps<T>) {
const handleChange: ChangeEventHandler<HTMLSelectElement> = useCallback(
event => {
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(onChangeValue as T)
},
[onChange, value]
)
const { settingToFocus } = useEditorLeftMenuContext()
const selectRef = useRef<HTMLSelectElement | null>(null)
useEffect(() => {
if (settingToFocus === name && selectRef.current) {
selectRef.current.scrollIntoView({
block: 'center',
behavior: 'smooth',
})
selectRef.current.focus()
}
// clear the focus setting
window.dispatchEvent(
new CustomEvent('ui.focus-setting', { detail: undefined })
)
}, [name, settingToFocus])
return (
<OLFormGroup
controlId={`settings-menu-${name}`}
className="left-menu-setting"
>
<OLFormLabel>{label}</OLFormLabel>
{loading ? (
<p className="mb-0">
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
</p>
) : (
<OLFormSelect
size="sm"
onChange={handleChange}
value={value?.toString()}
disabled={disabled}
ref={selectRef}
>
{options.map(option => (
<option
key={`${name}-${option.value}`}
value={option.value.toString()}
aria-hidden={option.ariaHidden}
disabled={option.disabled}
>
{option.label}
</option>
))}
{optgroup ? (
<optgroup label={optgroup.label}>
{optgroup.options.map(option => (
<option
value={option.value.toString()}
key={option.value.toString()}
>
{option.label}
</option>
))}
</optgroup>
) : null}
</OLFormSelect>
)}
</OLFormGroup>
)
}

View File

@@ -0,0 +1,42 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useLayoutContext } from '../../../../shared/context/layout-context'
import getMeta from '../../../../utils/meta'
import SettingsMenuSelect, { Option } from './settings-menu-select'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import type { OverallThemeMeta } from '../../../../../../types/project-settings'
import { isIEEEBranded } from '@/utils/is-ieee-branded'
import { OverallTheme } from '@/shared/utils/styles'
export default function SettingsOverallTheme() {
const { t } = useTranslation()
const overallThemes = getMeta('ol-overallThemes') as
| OverallThemeMeta[]
| undefined
const { loadingStyleSheet } = useLayoutContext()
const { overallTheme, setOverallTheme } = useProjectSettingsContext()
const options: Array<Option<OverallTheme>> = useMemo(
() =>
overallThemes?.map(({ name, val }) => ({
value: val,
label: name,
})) ?? [],
[overallThemes]
)
if (!overallThemes || isIEEEBranded()) {
return null
}
return (
<SettingsMenuSelect<OverallTheme>
onChange={setOverallTheme}
value={overallTheme}
options={options}
loading={loadingStyleSheet}
label={t('overall_theme')}
name="overallTheme"
/>
)
}

View File

@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next'
import type { PdfViewer } from '../../../../../../types/user-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<PdfViewer>
onChange={setPdfViewer}
value={pdfViewer}
options={[
{
value: 'pdfjs',
label: t('overleaf'),
},
{
value: 'native',
label: t('browser'),
},
]}
label={t('pdf_viewer')}
name="pdfViewer"
/>
)
}

View File

@@ -0,0 +1,42 @@
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'
import { useEditorContext } from '@/shared/context/editor-context'
import { supportsWebAssembly } from '@/utils/wasm'
export default function SettingsSpellCheckLanguage() {
const { t } = useTranslation()
const { spellCheckLanguage, setSpellCheckLanguage } =
useProjectSettingsContext()
const { permissionsLevel } = useEditorContext()
const optgroup: Optgroup = useMemo(() => {
const options = (getMeta('ol-languages') ?? [])
// only include spell-check languages that are available in the client
.filter(language => language.dic !== undefined)
return {
label: 'Language',
options: options.map(language => ({
value: language.code,
label: language.name,
})),
}
}, [])
return (
<SettingsMenuSelect
onChange={setSpellCheckLanguage}
value={supportsWebAssembly() ? spellCheckLanguage : ''}
options={[{ value: '', label: t('off') }]}
optgroup={optgroup}
label={t('spell_check')}
name="spellCheckLanguage"
disabled={permissionsLevel === 'readOnly' || !supportsWebAssembly()}
/>
)
}

View File

@@ -0,0 +1,27 @@
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<boolean>
onChange={setSyntaxValidation}
value={syntaxValidation}
options={[
{
value: true,
label: t('on'),
},
{
value: false,
label: t('off'),
},
]}
label={t('syntax_validation')}
name="syntaxValidation"
/>
)
}

View File

@@ -0,0 +1,42 @@
import { ElementType } from 'react'
import { useTranslation } from 'react-i18next'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
import getMeta from '../../../utils/meta'
const components = importOverleafModules('editorLeftMenuSync') as {
import: { default: ElementType }
path: string
}[]
export default function SyncMenu() {
const { t } = useTranslation()
const anonymous = getMeta('ol-anonymous')
const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled')
if (anonymous) {
return null
}
if (components.length === 0) {
return null
}
// This flag can only be false in CE and Server Pro. In this case we skip rendering the
// entire sync section, since Dropbox and GitHub are never available in SP
if (!gitBridgeEnabled) {
return null
}
return (
<>
<h4>{t('sync')}</h4>
<ul className="list-unstyled nav">
{components.map(({ import: { default: Component }, path }) => (
<li key={path}>
<Component />
</li>
))}
</ul>
</>
)
}

View File

@@ -0,0 +1,163 @@
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 } from '../utils/api'
import { UserSettings } from '../../../../../types/user-settings'
type ProjectSettingsSetterContextValue = {
setCompiler: (compiler: ProjectSettings['compiler']) => void
setImageName: (imageName: ProjectSettings['imageName']) => void
setRootDocId: (rootDocId: ProjectSettings['rootDocId']) => void
setSpellCheckLanguage: (
spellCheckLanguage: ProjectSettings['spellCheckLanguage']
) => void
setAutoComplete: (autoComplete: UserSettings['autoComplete']) => void
setAutoPairDelimiters: (
autoPairDelimiters: UserSettings['autoPairDelimiters']
) => void
setSyntaxValidation: (
syntaxValidation: UserSettings['syntaxValidation']
) => void
setMode: (mode: UserSettings['mode']) => void
setEditorTheme: (editorTheme: UserSettings['editorTheme']) => void
setOverallTheme: (overallTheme: UserSettings['overallTheme']) => void
setFontSize: (fontSize: UserSettings['fontSize']) => void
setFontFamily: (fontFamily: UserSettings['fontFamily']) => void
setLineHeight: (lineHeight: UserSettings['lineHeight']) => void
setPdfViewer: (pdfViewer: UserSettings['pdfViewer']) => void
setMathPreview: (mathPreview: UserSettings['mathPreview']) => void
}
type ProjectSettingsContextValue = Partial<ProjectSettings> &
Partial<UserSettings> &
ProjectSettingsSetterContextValue
export const ProjectSettingsContext = createContext<
ProjectSettingsContextValue | undefined
>(undefined)
export const ProjectSettingsProvider: FC = ({ children }) => {
const {
compiler,
setCompiler,
imageName,
setImageName,
rootDocId,
setRootDocId,
spellCheckLanguage,
setSpellCheckLanguage,
} = useProjectWideSettings()
const {
autoComplete,
setAutoComplete,
autoPairDelimiters,
setAutoPairDelimiters,
syntaxValidation,
setSyntaxValidation,
editorTheme,
setEditorTheme,
overallTheme,
setOverallTheme,
mode,
setMode,
fontSize,
setFontSize,
fontFamily,
setFontFamily,
lineHeight,
setLineHeight,
pdfViewer,
setPdfViewer,
mathPreview,
setMathPreview,
} = useUserWideSettings()
useProjectWideSettingsSocketListener()
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,
mathPreview,
setMathPreview,
}),
[
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,
mathPreview,
setMathPreview,
]
)
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
}

View File

@@ -0,0 +1,58 @@
import { useCallback, useEffect } from 'react'
import { useIdeContext } from '../../../shared/context/ide-context'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import type { ProjectSettings } from '../utils/api'
export default function useProjectWideSettingsSocketListener() {
const { socket } = useIdeContext()
const [project, setProject] = useScopeValue<ProjectSettings | undefined>(
'project'
)
const setCompiler = useCallback(
(compiler: ProjectSettings['compiler']) => {
if (project) {
setProject({ ...project, compiler })
}
},
[project, setProject]
)
const setImageName = useCallback(
(imageName: ProjectSettings['imageName']) => {
if (project) {
setProject({ ...project, imageName })
}
},
[project, setProject]
)
const setSpellCheckLanguage = useCallback(
(spellCheckLanguage: ProjectSettings['spellCheckLanguage']) => {
if (project) {
setProject({ ...project, spellCheckLanguage })
}
},
[project, setProject]
)
useEffect(() => {
// data is not available on initial mounting
const dataAvailable = !!project
if (dataAvailable && socket) {
socket.on('compilerUpdated', setCompiler)
socket.on('imageNameUpdated', setImageName)
socket.on('spellCheckLanguageUpdated', setSpellCheckLanguage)
return () => {
socket.removeListener('compilerUpdated', setCompiler)
socket.removeListener('imageNameUpdated', setImageName)
socket.removeListener(
'spellCheckLanguageUpdated',
setSpellCheckLanguage
)
}
}
}, [socket, project, setCompiler, setImageName, setSpellCheckLanguage])
}

View File

@@ -0,0 +1,41 @@
import { useCallback } from 'react'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import type { ProjectSettings } from '../utils/api'
import useRootDocId from './use-root-doc-id'
import useSaveProjectSettings from './use-save-project-settings'
import useSetSpellCheckLanguage from './use-set-spell-check-language'
import { debugConsole } from '@/utils/debugging'
export default function useProjectWideSettings() {
// The value will be undefined on mount
const [project] = useScopeValue<ProjectSettings | undefined>('project')
const saveProjectSettings = useSaveProjectSettings()
const setCompiler = useCallback(
(newCompiler: ProjectSettings['compiler']) => {
saveProjectSettings('compiler', newCompiler).catch(debugConsole.error)
},
[saveProjectSettings]
)
const setImageName = useCallback(
(newImageName: ProjectSettings['imageName']) => {
saveProjectSettings('imageName', newImageName).catch(debugConsole.error)
},
[saveProjectSettings]
)
const { setRootDocId, rootDocId } = useRootDocId()
const setSpellCheckLanguage = useSetSpellCheckLanguage()
return {
compiler: project?.compiler,
setCompiler,
imageName: project?.imageName,
setImageName,
rootDocId,
setRootDocId,
spellCheckLanguage: project?.spellCheckLanguage,
setSpellCheckLanguage,
}
}

View File

@@ -0,0 +1,34 @@
import { useCallback } from 'react'
import { useEditorContext } from '../../../shared/context/editor-context'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import type { ProjectSettings } from '../utils/api'
import useSaveProjectSettings from './use-save-project-settings'
export default function useRootDocId() {
const [rootDocId] =
useScopeValue<ProjectSettings['rootDocId']>('project.rootDoc_id')
const { permissionsLevel } = useEditorContext()
const saveProjectSettings = useSaveProjectSettings()
const setRootDocIdFunc = useCallback(
async (newRootDocId: ProjectSettings['rootDocId']) => {
// rootDocId will be undefined on angular scope on initialisation
const allowUpdate =
typeof rootDocId !== 'undefined' && permissionsLevel !== 'readOnly'
if (allowUpdate) {
try {
await saveProjectSettings('rootDocId', newRootDocId)
} catch (err) {
// TODO: retry mechanism (max 10x before failed completely and rollback the old value)
}
}
},
[permissionsLevel, rootDocId, saveProjectSettings]
)
return {
rootDocId,
setRootDocId: setRootDocIdFunc,
}
}

View File

@@ -0,0 +1,32 @@
import { type ProjectSettings, saveProjectSettings } from '../utils/api'
import { useProjectContext } from '../../../shared/context/project-context'
import useScopeValue from '../../../shared/hooks/use-scope-value'
export default function useSaveProjectSettings() {
// projectSettings value will be undefined on mount
const [projectSettings, setProjectSettings] = useScopeValue<
ProjectSettings | undefined
>('project')
const { _id: projectId } = useProjectContext()
return async (
key: keyof ProjectSettings,
newSetting: ProjectSettings[keyof ProjectSettings]
) => {
if (projectSettings) {
const currentSetting = projectSettings[key]
if (currentSetting !== newSetting) {
await saveProjectSettings(projectId, {
[key]: newSetting,
})
// rootDocId is used in our tsx and our endpoint, but rootDoc_id is used in our project $scope, etc
// as we use both namings in many files, and convert back and forth,
// its complicated to seperate and choose one name for all usages
// todo: make rootDocId or rootDoc_id consistent, and remove need for this/ other conversions
const settingsKey = key === 'rootDocId' ? 'rootDoc_id' : key
setProjectSettings({ ...projectSettings, [settingsKey]: newSetting })
}
}
}
}

View File

@@ -0,0 +1,19 @@
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import { saveUserSettings } from '../utils/api'
import { UserSettings } from '../../../../../types/user-settings'
export default function useSaveUserSettings() {
const { userSettings, setUserSettings } = useUserSettingsContext()
return (
key: keyof UserSettings,
newSetting: UserSettings[keyof UserSettings]
) => {
const currentSetting = userSettings[key]
if (currentSetting !== newSetting) {
setUserSettings({ ...userSettings, [key]: newSetting })
saveUserSettings(key, newSetting)
}
}
}

View File

@@ -0,0 +1,43 @@
import { useCallback, useEffect } from 'react'
import _ from 'lodash'
import { saveUserSettings } from '../utils/api'
import { UserSettings } from '../../../../../types/user-settings'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import getMeta from '@/utils/meta'
import { isIEEEBranded } from '@/utils/is-ieee-branded'
export default function useSetOverallTheme() {
const { userSettings, setUserSettings } = useUserSettingsContext()
const { overallTheme } = userSettings
const setOverallTheme = useCallback(
(overallTheme: UserSettings['overallTheme']) => {
setUserSettings(settings => ({ ...settings, overallTheme }))
},
[setUserSettings]
)
useEffect(() => {
// Sets the body's data-theme attribute for theming
const theme =
overallTheme === 'light-' && !isIEEEBranded() ? 'light' : 'default'
document.body.dataset.theme = theme
}, [overallTheme])
return useCallback(
(newOverallTheme: UserSettings['overallTheme']) => {
if (overallTheme !== newOverallTheme) {
const chosenTheme = _.find(
getMeta('ol-overallThemes'),
theme => theme.val === newOverallTheme
)
if (chosenTheme) {
setOverallTheme(newOverallTheme)
saveUserSettings('overallTheme', newOverallTheme)
}
}
},
[overallTheme, setOverallTheme]
)
}

View File

@@ -0,0 +1,32 @@
import { useCallback } from 'react'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import { type ProjectSettings, saveUserSettings } from '../utils/api'
import useSaveProjectSettings from './use-save-project-settings'
export default function useSetSpellCheckLanguage() {
const [spellCheckLanguage, setSpellCheckLanguage] = useScopeValue<
ProjectSettings['spellCheckLanguage']
>('project.spellCheckLanguage')
const saveProjectSettings = useSaveProjectSettings()
return useCallback(
(newSpellCheckLanguage: ProjectSettings['spellCheckLanguage']) => {
const allowUpdate =
spellCheckLanguage != null &&
newSpellCheckLanguage !== spellCheckLanguage
if (allowUpdate) {
setSpellCheckLanguage(newSpellCheckLanguage)
// Save project settings is created from hooks because it will save the value on
// both server-side and client-side (angular scope)
saveProjectSettings('spellCheckLanguage', newSpellCheckLanguage)
// For user settings, we only need to save it on server-side,
// so we import the function directly without hooks
saveUserSettings('spellCheckLanguage', newSpellCheckLanguage)
}
},
[setSpellCheckLanguage, spellCheckLanguage, saveProjectSettings]
)
}

View File

@@ -0,0 +1,120 @@
import { useCallback } from 'react'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import useSetOverallTheme from './use-set-overall-theme'
import useSaveUserSettings from './use-save-user-settings'
import { UserSettings } from '../../../../../types/user-settings'
export default function useUserWideSettings() {
const saveUserSettings = useSaveUserSettings()
const { userSettings } = useUserSettingsContext()
const {
overallTheme,
autoComplete,
autoPairDelimiters,
syntaxValidation,
editorTheme,
mode,
fontSize,
fontFamily,
lineHeight,
pdfViewer,
mathPreview,
} = userSettings
const setOverallTheme = useSetOverallTheme()
const setAutoComplete = useCallback(
(autoComplete: UserSettings['autoComplete']) => {
saveUserSettings('autoComplete', autoComplete)
},
[saveUserSettings]
)
const setAutoPairDelimiters = useCallback(
(autoPairDelimiters: UserSettings['autoPairDelimiters']) => {
saveUserSettings('autoPairDelimiters', autoPairDelimiters)
},
[saveUserSettings]
)
const setSyntaxValidation = useCallback(
(syntaxValidation: UserSettings['syntaxValidation']) => {
saveUserSettings('syntaxValidation', syntaxValidation)
},
[saveUserSettings]
)
const setEditorTheme = useCallback(
(editorTheme: UserSettings['editorTheme']) => {
saveUserSettings('editorTheme', editorTheme)
},
[saveUserSettings]
)
const setMode = useCallback(
(mode: UserSettings['mode']) => {
saveUserSettings('mode', mode)
},
[saveUserSettings]
)
const setFontSize = useCallback(
(fontSize: UserSettings['fontSize']) => {
saveUserSettings('fontSize', fontSize)
},
[saveUserSettings]
)
const setFontFamily = useCallback(
(fontFamily: UserSettings['fontFamily']) => {
saveUserSettings('fontFamily', fontFamily)
},
[saveUserSettings]
)
const setLineHeight = useCallback(
(lineHeight: UserSettings['lineHeight']) => {
saveUserSettings('lineHeight', lineHeight)
},
[saveUserSettings]
)
const setPdfViewer = useCallback(
(pdfViewer: UserSettings['pdfViewer']) => {
saveUserSettings('pdfViewer', pdfViewer)
},
[saveUserSettings]
)
const setMathPreview = useCallback(
(mathPreview: UserSettings['mathPreview']) => {
saveUserSettings('mathPreview', mathPreview)
},
[saveUserSettings]
)
return {
autoComplete,
setAutoComplete,
autoPairDelimiters,
setAutoPairDelimiters,
syntaxValidation,
setSyntaxValidation,
editorTheme,
setEditorTheme,
overallTheme,
setOverallTheme,
mode,
setMode,
fontSize,
setFontSize,
fontFamily,
setFontFamily,
lineHeight,
setLineHeight,
pdfViewer,
setPdfViewer,
mathPreview,
setMathPreview,
}
}

View File

@@ -0,0 +1,46 @@
import type { ProjectCompiler } from '../../../../../types/project-settings'
import { sendMB } from '../../../infrastructure/event-tracking'
import { postJSON } from '../../../infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import { UserSettings } from '../../../../../types/user-settings'
export type ProjectSettings = {
compiler: ProjectCompiler
imageName: string
rootDocId: string
spellCheckLanguage: string
name: string
}
type SaveUserSettings = Partial<
UserSettings & {
spellCheckLanguage: ProjectSettings['spellCheckLanguage']
}
>
export function saveUserSettings(
key: keyof SaveUserSettings,
value: SaveUserSettings[keyof SaveUserSettings]
) {
sendMB('setting-changed', {
changedSetting: key,
changedSettingVal: value,
})
postJSON('/user/settings', {
body: {
[key]: value,
},
}).catch(debugConsole.error)
}
export const saveProjectSettings = async (
projectId: string,
data: Partial<ProjectSettings>
) => {
await postJSON<never>(`/project/${projectId}/settings`, {
body: {
...data,
},
})
}