diff --git a/src/bootstrap/agGrid.ts b/src/bootstrap/agGrid.ts index 6c83b1153f..d215008b19 100644 --- a/src/bootstrap/agGrid.ts +++ b/src/bootstrap/agGrid.ts @@ -26,8 +26,7 @@ const productionModules: readonly Module[] = [ PaginationModule, RowDragModule, TextEditorModule, - TextFilterModule, - ValidationModule + TextFilterModule ]; export const initializeAgGridModules = () => { diff --git a/src/features/sicp/utils/SicpUtils.ts b/src/features/sicp/utils/SicpUtils.ts index cc347256b1..d33487a795 100644 --- a/src/features/sicp/utils/SicpUtils.ts +++ b/src/features/sicp/utils/SicpUtils.ts @@ -11,3 +11,22 @@ export const readSicpSectionLocalStorage = () => { const data = readLocalStorage(SICP_CACHE_KEY, SICP_INDEX); return data; }; + +const SICP_SUPPORTED_LANGUAGES = ['en', 'zh_CN'] as const satisfies readonly string[]; +export type SicpSupportedLanguage = (typeof SICP_SUPPORTED_LANGUAGES)[number]; +export const SICP_DEFAULT_LANGUAGE: SicpSupportedLanguage = 'en'; + +const sicplanguageKey = 'sicp-textbook-lang'; + +export const persistSicpLanguageToLocalStorage = (value: string) => { + setLocalStorage(sicplanguageKey, value); + window.dispatchEvent(new Event('sicp-tb-lang-change')); +}; + +export const getSicpLanguageFromLocalStorage = (): SicpSupportedLanguage | null => { + const value = readLocalStorage(sicplanguageKey, null); + if (!SICP_SUPPORTED_LANGUAGES.includes(value)) { + return null; + } + return value as SicpSupportedLanguage; +}; diff --git a/src/index.tsx b/src/index.tsx index 203f2e55dc..6831f5d802 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -15,6 +15,7 @@ import { initializeAgGridModules } from './bootstrap/agGrid'; import { initializeSentryLogging } from './bootstrap/sentry'; import ApplicationWrapper from './commons/application/ApplicationWrapper'; import { createInBrowserFileSystem } from './pages/fileSystem/createInBrowserFileSystem'; +import { SicpLanguageContextProvider } from './pages/sicp/subcomponents/SicpLanguageProvider'; initializeSentryLogging(); initializeAgGridModules(); @@ -38,7 +39,9 @@ createInBrowserFileSystem(store) root.render( - + + + ); diff --git a/src/pages/sicp/Sicp.tsx b/src/pages/sicp/Sicp.tsx index 431e2a94f4..1b98267d2a 100644 --- a/src/pages/sicp/Sicp.tsx +++ b/src/pages/sicp/Sicp.tsx @@ -23,6 +23,7 @@ import SicpErrorBoundary from '../../features/sicp/errors/SicpErrorBoundary'; import getSicpError, { SicpErrorType } from '../../features/sicp/errors/SicpErrors'; import Chatbot from './subcomponents/chatbot/Chatbot'; import SicpIndexPage from './subcomponents/SicpIndexPage'; +import { useSicpLanguageContext } from './subcomponents/SicpLanguageProvider'; const baseUrl = Constants.sicpBackendUrl + 'json/'; const extension = '.json'; @@ -40,6 +41,7 @@ const Sicp: React.FC = () => { const [loading, setLoading] = useState(false); const [active, setActive] = useState('0'); const { section } = useParams<{ section: string }>(); + const { sicpLanguage, setSicpLanguage } = useSicpLanguageContext(); const parentRef = useRef(null); const refs = useRef>({}); const navigate = useNavigate(); @@ -105,7 +107,7 @@ const Sicp: React.FC = () => { setLoading(true); - fetch(baseUrl + section + extension) + fetch(`${baseUrl}${sicpLanguage}/${section}${extension}`) .then(response => { if (!response.ok) { throw Error(response.statusText); @@ -121,6 +123,7 @@ const Sicp: React.FC = () => { throw new ParseJsonError(error.message); } }) + .catch(error => { console.error(error); @@ -138,7 +141,7 @@ const Sicp: React.FC = () => { .finally(() => { setLoading(false); }); - }, [section, navigate]); + }, [section, sicpLanguage, navigate]); // Scroll to correct position React.useEffect(() => { @@ -163,10 +166,31 @@ const Sicp: React.FC = () => { dispatch(WorkspaceActions.resetWorkspace('sicp')); dispatch(WorkspaceActions.toggleUsingSubst(false, 'sicp')); }; + + const toggleSicpLanguage = () => { + setSicpLanguage(sicpLanguage === 'en' ? 'zh_CN' : 'en'); + }; + const handleNavigation = (sect: string) => { navigate('/sicpjs/' + sect); }; + // Language toggle button with fixed position + const languageToggle = ( +
+ +
+ ); + // `section` is defined due to the navigate logic in the useEffect above const navigationButtons = (
@@ -186,6 +210,7 @@ const Sicp: React.FC = () => { > + {languageToggle} {loading ? (
{loadingComponent}
) : section === 'index' ? ( diff --git a/src/pages/sicp/subcomponents/SicpLanguageProvider.tsx b/src/pages/sicp/subcomponents/SicpLanguageProvider.tsx new file mode 100644 index 0000000000..ad72a3926e --- /dev/null +++ b/src/pages/sicp/subcomponents/SicpLanguageProvider.tsx @@ -0,0 +1,45 @@ +import { createContext, useCallback, useContext, useState } from 'react'; +import { + getSicpLanguageFromLocalStorage, + SICP_DEFAULT_LANGUAGE, + type SicpSupportedLanguage +} from 'src/features/sicp/utils/SicpUtils'; + +type SicpLanguageContext = { + sicpLanguage: SicpSupportedLanguage; + setSicpLanguage: (lang: SicpSupportedLanguage) => void; +}; + +const sicpLanguageContext = createContext(undefined); + +export const useSicpLanguageContext = (): SicpLanguageContext => { + const context = useContext(sicpLanguageContext); + if (!context) { + throw new Error('useSicpLanguageContext must be used inside an SicpLanguageContextProvider'); + } + + return context; +}; + +export const SicpLanguageContextProvider: React.FC<{ children: React.ReactNode }> = ({ + children +}) => { + const [lang, setLang] = useState( + getSicpLanguageFromLocalStorage() ?? SICP_DEFAULT_LANGUAGE + ); + + const handleLangChange = useCallback((newLang: SicpSupportedLanguage) => { + setLang(newLang); + }, []); + + return ( + + {children} + + ); +}; diff --git a/src/pages/sicp/subcomponents/SicpToc.tsx b/src/pages/sicp/subcomponents/SicpToc.tsx index 84fbced81f..407e5ab15c 100644 --- a/src/pages/sicp/subcomponents/SicpToc.tsx +++ b/src/pages/sicp/subcomponents/SicpToc.tsx @@ -1,9 +1,11 @@ import { Tree, TreeNodeInfo } from '@blueprintjs/core'; import { cloneDeep } from 'lodash'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; +import Constants from 'src/commons/utils/Constants'; -import toc from '../../../features/sicp/data/toc.json'; +import fallbackToc from '../../../features/sicp/data/toc.json'; +import { useSicpLanguageContext } from './SicpLanguageProvider'; type TocProps = OwnProps; @@ -15,8 +17,17 @@ type OwnProps = { * Table of contents of SICP. */ const SicpToc: React.FC = props => { - const [sidebarContent, setSidebarContent] = useState(toc as TreeNodeInfo[]); + const [sidebarContent, setSidebarContent] = useState(fallbackToc as TreeNodeInfo[]); const navigate = useNavigate(); + const { sicpLanguage } = useSicpLanguageContext(); + + useEffect(() => { + const loadLocalizedToc = async () => { + const resp = await fetch(`${Constants.sicpBackendUrl}json/${sicpLanguage}/toc.json`); + return (await resp.json()) as TreeNodeInfo[]; + }; + loadLocalizedToc().then(setSidebarContent).catch(console.error); + }, [sicpLanguage]); const handleNodeExpand = (_node: TreeNodeInfo, path: integer[]) => { const newState = cloneDeep(sidebarContent); diff --git a/src/pages/sicp/subcomponents/__tests__/SicpIndexPage.test.tsx b/src/pages/sicp/subcomponents/__tests__/SicpIndexPage.test.tsx index ae300b4173..4f2fa6bff3 100644 --- a/src/pages/sicp/subcomponents/__tests__/SicpIndexPage.test.tsx +++ b/src/pages/sicp/subcomponents/__tests__/SicpIndexPage.test.tsx @@ -2,11 +2,14 @@ import { MemoryRouter } from 'react-router'; import { renderTreeJson } from 'src/commons/utils/TestUtils'; import SicpIndexPage from '../../subcomponents/SicpIndexPage'; +import { SicpLanguageContextProvider } from '../SicpLanguageProvider'; test('Sicp index page', async () => { const tree = await renderTreeJson( - + + + ); expect(tree).toMatchSnapshot(); diff --git a/src/pages/sicp/subcomponents/__tests__/SicpToc.test.tsx b/src/pages/sicp/subcomponents/__tests__/SicpToc.test.tsx index 73c8bf41a9..791e9d2d6d 100644 --- a/src/pages/sicp/subcomponents/__tests__/SicpToc.test.tsx +++ b/src/pages/sicp/subcomponents/__tests__/SicpToc.test.tsx @@ -1,6 +1,7 @@ import { MemoryRouter } from 'react-router'; import { renderTreeJson } from 'src/commons/utils/TestUtils'; +import { SicpLanguageContextProvider } from '../SicpLanguageProvider'; import SicpToc from '../SicpToc'; test('Sicp toc renders correctly', async () => { @@ -10,7 +11,9 @@ test('Sicp toc renders correctly', async () => { const tree = await renderTreeJson( - + + + ); expect(tree).toMatchSnapshot();