i18n support #120 (#124)

* I18n basic framework construction completed

* fossflow-lib i18n done

Thanks to @AmazingRain
This commit is contained in:
mu ziyu
2025-09-04 03:31:56 +08:00
committed by GitHub
parent 5d6cf0e41a
commit 2145981191
17 changed files with 384 additions and 13 deletions

150
package-lock.json generated
View File

@@ -5377,6 +5377,15 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/cross-fetch/-/cross-fetch-4.0.0.tgz",
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.12"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -7771,6 +7780,15 @@
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -7822,6 +7840,55 @@
"node": ">=10.17.0"
}
},
"node_modules/i18next": {
"version": "25.4.2",
"resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.4.2.tgz",
"integrity": "sha512-gD4T25a6ovNXsfXY1TwHXXXLnD/K2t99jyYMCSimSCBnBRJVQr5j+VAaU83RJCPzrTGhVQ6dqIga66xO2rtd5g==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.0",
"resolved": "https://registry.npmmirror.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz",
"integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-http-backend": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz",
"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==",
"license": "MIT",
"dependencies": {
"cross-fetch": "4.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -10102,6 +10169,48 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -11090,6 +11199,32 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-i18next": {
"version": "15.7.3",
"resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-15.7.3.tgz",
"integrity": "sha512-AANws4tOE+QSq/IeMF/ncoHlMNZaVLxpa5uUGW1wjike68elVYr0018L9xYoqBr1OFO7G7boDPrbn0HpMCJxTw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 25.4.1",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz",
@@ -12774,7 +12909,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"devOptional": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13003,6 +13138,15 @@
"d3-timer": "^3.0.1"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
@@ -13521,9 +13665,13 @@
"dependencies": {
"@isoflow/isopacks": "^0.0.10",
"fossflow": "*",
"i18next": "^25.4.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^6.0.0",
"react-i18next": "^15.7.3",
"web-vitals": "^2.1.4"
},
"devDependencies": {

View File

@@ -7,7 +7,7 @@
"packages/*"
],
"scripts": {
"dev": "npm run start --workspace=packages/fossflow-app",
"dev": "NODE_ENV=development npm run start --workspace=packages/fossflow-app",
"dev:lib": "npm run dev --workspace=packages/fossflow-lib",
"dev:backend": "npm run dev --workspace=packages/fossflow-backend",
"build": "npm run build:lib && npm run build:app",

View File

@@ -6,9 +6,13 @@
"dependencies": {
"@isoflow/isopacks": "^0.0.10",
"fossflow": "*",
"i18next": "^25.4.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^6.0.0",
"react-i18next": "^15.7.3",
"web-vitals": "^2.1.4"
},
"scripts": {

View File

@@ -13,5 +13,11 @@ export default defineConfig({
// https://rsbuild.rs/guide/advanced/browser-compatibility
polyfill: 'usage',
assetPrefix: process.env.PUBLIC_URL || '/',
copy: [
{
from: './src/i18n',
to: 'i18n/app',
},
]
}
});

View File

@@ -6,10 +6,13 @@ import awsIsopack from '@isoflow/isopacks/dist/aws';
import gcpIsopack from '@isoflow/isopacks/dist/gcp';
import azureIsopack from '@isoflow/isopacks/dist/azure';
import kubernetesIsopack from '@isoflow/isopacks/dist/kubernetes';
import { useTranslation } from 'react-i18next';
import { DiagramData, mergeDiagramData, extractSavableData } from './diagramUtils';
import { StorageManager } from './StorageManager';
import { DiagramManager } from './components/DiagramManager';
import { storageManager } from './services/storageService';
import ChangeLanguage from './components/ChangeLanguage';
import { allLocales } from 'fossflow';
import './App.css';
const icons = flattenCollections([
@@ -451,6 +454,16 @@ function App() {
setFossflowKey(prev => prev + 1); // Force re-render
setHasUnsavedChanges(false);
};
// i18n
const [canI18n, setCanI18n] = useState(false);
const { t, i18n } = useTranslation('app');
useEffect(() => {
// http://localhost:3000/?canI18n=1
const params = new URLSearchParams(window.location.search);
// show demo
setCanI18n(params.get('canI18n') === '1');
}, [window.location.search]);
// Auto-save functionality
useEffect(() => {
@@ -514,28 +527,29 @@ function App() {
return (
<div className="App">
<div className="toolbar">
<button onClick={newDiagram}>New Diagram</button>
{canI18n && <ChangeLanguage />}
<button onClick={newDiagram}>{t('nav.newDiagram')}</button>
{serverStorageAvailable && (
<button
onClick={() => setShowDiagramManager(true)}
style={{ backgroundColor: '#2196F3', color: 'white' }}
>
🌐 Server Storage
🌐 {t('nav.serverStorage')}
</button>
)}
<button onClick={() => setShowSaveDialog(true)}>Save (Session Only)</button>
<button onClick={() => setShowLoadDialog(true)}>Load (Session Only)</button>
<button onClick={() => setShowSaveDialog(true)}>{t('nav.saveSessionOnly')}</button>
<button onClick={() => setShowLoadDialog(true)}>{t('nav.loadSessionOnly')}</button>
<button
onClick={() => setShowImportDialog(true)}
style={{ backgroundColor: '#28a745' }}
>
📂 Import File
📂 {t('nav.importFile')}
</button>
<button
onClick={() => setShowExportDialog(true)}
style={{ backgroundColor: '#007bff' }}
>
💾 Export File
💾 {t('nav.exportFile')}
</button>
<button
onClick={() => {
@@ -551,7 +565,7 @@ function App() {
}}
title="Save to current session only"
>
Quick Save (Session)
{t('nav.quickSaveSession')}
</button>
<span className="current-diagram">
{currentDiagram ? `Current: ${currentDiagram.name}` : diagramName || 'Untitled Diagram'}
@@ -568,6 +582,7 @@ function App() {
initialData={diagramData}
onModelUpdated={handleModelUpdated}
editorMode="EDITABLE"
locale={allLocales[i18n.language as keyof typeof allLocales]}
/>
</div>

View File

@@ -0,0 +1,57 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import './styles.css';
import { supportedLanguages } from '../../i18n';
const ChangeLanguage = () => {
const { i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [currentLang, setCurrentLang] = useState(i18n.language || 'en');
const dropdownRef = useRef<HTMLDivElement>(null);
const changeLanguage = (lang: string) => {
i18n.changeLanguage(lang);
setCurrentLang(lang);
setIsOpen(false);
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return (
<div className="language-selector" ref={dropdownRef}>
<div
className="language-display"
onMouseEnter={() => setIsOpen(true)}
>
A/
</div>
{isOpen && (
<div className="language-dropdown">
{supportedLanguages.map(item => (
<div
key={item.value}
className={`language-option ${currentLang === item.value ? 'active' : ''}`}
onClick={() => changeLanguage(item.value)}
>
{item.label}
</div>
))
}
</div>
)}
</div>
);
};
export default ChangeLanguage;

View File

@@ -0,0 +1,45 @@
.language-selector {
position: relative;
display: inline-block;
font-size: 14px;
cursor: pointer;
}
.language-display {
padding: 8px 12px;
border-radius: 4px;
background-color: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
min-width: 60px;
text-align: center;
}
.language-dropdown {
position: absolute;
top: 100%;
left: 0;
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
width: 100%;
z-index: 1000;
margin-top: 4px;
overflow: hidden;
}
.language-option {
padding: 8px 12px;
transition: background-color 0.2s;
text-align: center;
}
.language-option:hover {
background-color: #f0f0f0;
}
.language-option.active {
background-color: #e6f7ff;
color: #1890ff;
}

View File

@@ -0,0 +1,34 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-http-backend';
// future: add language detector
// import LanguageDetector from 'i18next-browser-languagedetector';
i18n
.use(Backend)
.use(initReactI18next)
.init({
fallbackLng: 'en-US',
debug: process.env.NODE_ENV === 'development',
interpolation: {
escapeValue: false,
},
ns: ['app'],
backend: {
loadPath: '/i18n/{{ns}}/{{lng}}.json'
},
});
export const supportedLanguages = [
{
label: 'English',
value: 'en-US',
},
{
label: '中文',
value: 'zh-CN',
},
];
export default i18n;

View File

@@ -0,0 +1,11 @@
{
"nav": {
"newDiagram": "New Diagram",
"saveSessionOnly": "Save (Session Only)",
"loadSessionOnly": "Load (Session Only)",
"importFile": "Import File",
"exportFile": "Export File",
"quickSaveSession": "Quick Save(Session)",
"serverStorage": "Server Storage"
}
}

View File

@@ -0,0 +1,11 @@
{
"nav": {
"newDiagram": "新建图表",
"saveSessionOnly": "保存(仅会话)",
"loadSessionOnly": "加载(仅会话)",
"importFile": "导入文件",
"exportFile": "导出文件",
"quickSaveSession": "快速保存(会话)",
"serverStorage": "服务端存储"
}
}

View File

@@ -8,15 +8,19 @@ import reportWebVitals from './reportWebVitals';
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
import { ErrorBoundary } from 'react-error-boundary';
import ErrorBoundaryFallbackUI from './components/ErrorBoundary';
import {I18nextProvider} from 'react-i18next';
import i18n from './i18n';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<ErrorBoundary FallbackComponent={ErrorBoundaryFallbackUI}>
<App />
</ErrorBoundary>
<I18nextProvider i18n={i18n}>
<ErrorBoundary FallbackComponent={ErrorBoundaryFallbackUI}>
<App />
</ErrorBoundary>
</I18nextProvider>
</React.StrictMode>
);

View File

@@ -12,6 +12,7 @@ import { UiOverlay } from 'src/components/UiOverlay/UiOverlay';
import { UiStateProvider, useUiStateStore } from 'src/stores/uiStateStore';
import { INITIAL_DATA, MAIN_MENU_OPTIONS } from 'src/config';
import { useInitialDataManager } from 'src/hooks/useInitialDataManager';
import enUS from 'src/i18n/en-US';
const App = ({
initialData,
@@ -21,7 +22,8 @@ const App = ({
onModelUpdated,
enableDebugTools = false,
editorMode = 'EDITABLE',
renderer
renderer,
locale = enUS,
}: IsoflowProps) => {
const uiStateActions = useUiStateStore((state) => {
return state.actions;

View File

@@ -0,0 +1,7 @@
import { LocaleProps } from '../types/isoflowProps';
const locale: LocaleProps = {
"exampleText": "This is an example text"
};
export default locale;

View File

@@ -0,0 +1,9 @@
import enUS from './en-US';
import zhCN from './zh-CN';
const locales = {
'en-US': enUS,
'zh-CN': zhCN
};
export default locales;

View File

@@ -0,0 +1,7 @@
import { LocaleProps } from '../types/isoflowProps';
const locale: LocaleProps = {
"exampleText": "这是一段示例文本"
};
export default locale;

View File

@@ -6,3 +6,8 @@ export { INITIAL_DATA, INITIAL_SCENE_STATE } from 'src/config';
export * from 'src/schemas';
export type { IsoflowProps, InitialData } from 'src/types';
export * from 'src/types/model';
// Export i18n locales
export { default as enUS } from 'src/i18n/en-US';
export { default as zhCN } from 'src/i18n/zh-CN';
export { default as allLocales } from 'src/i18n';

View File

@@ -7,6 +7,11 @@ export type InitialData = Model & {
view?: string;
};
export interface LocaleProps {
exampleText: string;
// other locale keys
}
export interface IsoflowProps {
initialData?: InitialData;
mainMenuOptions?: MainMenuOptions;
@@ -16,4 +21,5 @@ export interface IsoflowProps {
enableDebugTools?: boolean;
editorMode?: keyof typeof EditorModeEnum;
renderer?: RendererProps;
locale?: LocaleProps;
}