/** * MetonaEditor i18n — internationalization * @module i18n * @version 0.2.0 */ import { LOCALES } from './constants'; import type { EditorOptions } from './constants'; let currentLocale = 'zh-CN'; let localeListeners = new Set<(locale: string) => void>(); let fallbackLocale = 'zh-CN'; type PluralRule = (n: number) => string; const pluralRules: Record = { zh: () => 'other', en: (n) => n === 1 ? 'one' : 'other', ru: (n) => { const m = n % 10, h = n % 100; if (m === 1 && h !== 11) return 'one'; if (m >= 2 && m <= 4 && !(h >= 12 && h <= 14)) return 'few'; if (m === 0 || (m >= 5 && m <= 9) || (h >= 11 && h <= 14)) return 'many'; return 'other'; }, }; const getPluralForm = (locale: string, count: number): string => { const lang = locale.split('-')[0].toLowerCase(); const rule = pluralRules[lang] || pluralRules.en; return rule(Math.abs(count)); }; export const getCurrentLocale = (): string => currentLocale; export const setCurrentLocale = (locale: string): void => { let target = locale; if (!LOCALES[target]) { console.warn(`Locale "${target}" not found, falling back to "${fallbackLocale}"`); target = fallbackLocale; } currentLocale = target; notifyLocaleListeners(target); saveLocale(target); }; export const t = (key: string, params: Record = {}, locale?: string): string => { const loc = locale || currentLocale; let translation: any = getTranslation(loc, key); if (translation && typeof translation === 'object' && params.count !== undefined) { const form = getPluralForm(loc, params.count); translation = translation[form] || translation.other || key; } if (typeof translation === 'string') return interpolate(translation, params); if (loc !== fallbackLocale) { let fb: any = getTranslation(fallbackLocale, key); if (fb && typeof fb === 'object' && params.count !== undefined) { const form = getPluralForm(fallbackLocale, params.count); fb = fb[form] || fb.other; } if (typeof fb === 'string') return interpolate(fb, params); } console.warn(`Translation missing for key "${key}" in locale "${loc}"`); return key; }; const getTranslation = (locale: string, key: string): any => { const localeData: any = LOCALES[locale]; if (!localeData) return undefined; const keys = key.split('.'); let result: any = localeData; for (const k of keys) { if (result && typeof result === 'object' && k in result) { result = result[k]; } else return undefined; } return result; }; const interpolate = (str: string, params: Record): string => { return str.replace(/\{(\w+)\}/g, (_match, key) => params[key] !== undefined ? String(params[key]) : _match); }; export const hasTranslation = (key: string): boolean => { return getTranslation(currentLocale, key) !== undefined || getTranslation(fallbackLocale, key) !== undefined; }; export const getTranslations = (locale: string): Record => LOCALES[locale] || {}; export const addTranslations = (locale: string, translations: Record): void => { if (!LOCALES[locale]) LOCALES[locale] = {}; deepMergeI18n(LOCALES[locale], translations); }; export const loadRemote = async (url: string, locale: string): Promise => { try { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); addTranslations(locale, data); return true; } catch (e) { console.error(`MeEditor: failed to load locale "${locale}" from ${url}`, e); return false; } }; const deepMergeI18n = (target: Record, source: Record): Record => { for (const key in source) { if (source[key] instanceof Object && key in target && target[key] instanceof Object && !Array.isArray(source[key])) { deepMergeI18n(target[key], source[key]); } else { target[key] = source[key]; } } return target; }; export const getSupportedLocales = (): string[] => Object.keys(LOCALES); export const isLocaleSupported = (locale: string): boolean => locale in LOCALES; export const getLocaleName = (locale: string): string => { const names: Record = { 'zh-CN': '简体中文', 'zh-TW': '繁體中文', 'en-US': 'English (US)', 'en-GB': 'English (UK)', ja: '日本語', ko: '한국어', fr: 'Français', de: 'Deutsch', es: 'Español', pt: 'Português', ru: 'Русский', ar: 'العربية', hi: 'हिन्दी', th: 'ไทย', vi: 'Tiếng Việt', id: 'Bahasa Indonesia', tr: 'Türkçe', it: 'Italiano', nl: 'Nederlands', pl: 'Polski', }; return names[locale] || locale; }; export const getLocaleDirection = (locale: string): 'ltr' | 'rtl' => { const rtlLocales = ['ar', 'he', 'fa', 'ur', 'yi', 'ps', 'sd', 'ug', 'dv', 'ku', 'syr']; const short = locale.split('-')[0].toLowerCase(); return rtlLocales.includes(short) ? 'rtl' : 'ltr'; }; export const formatNumber = (number: number, options: Intl.NumberFormatOptions = {}): string => { try { return new Intl.NumberFormat(currentLocale, options).format(number); } catch (_) { return String(number); } }; export const formatCurrency = (amount: number, currency: string = 'USD', options: Intl.NumberFormatOptions = {}): string => { try { return new Intl.NumberFormat(currentLocale, { style: 'currency', currency, ...options }).format(amount); } catch (_) { return String(amount); } }; export const formatDate = (date: Date | string, options: Intl.DateTimeFormatOptions = {}): string => { try { const d = date instanceof Date ? date : new Date(date); return new Intl.DateTimeFormat(currentLocale, options).format(d); } catch (_) { return String(date); } }; export const addLocaleListener = (fn: (locale: string) => void): () => void => { localeListeners.add(fn); return () => { localeListeners.delete(fn); }; }; export const removeLocaleListener = (fn: (locale: string) => void): void => { localeListeners.delete(fn); }; export const clearLocaleListeners = (): void => { localeListeners.clear(); }; const notifyLocaleListeners = (locale: string): void => { localeListeners.forEach((fn) => { try { fn(locale); } catch (e) { console.error('Locale listener error:', e); } }); }; export const saveLocale = (locale: string): void => { if (typeof localStorage !== 'undefined') { try { localStorage.setItem('metona-editor-locale', locale); } catch (_) {} } }; export const loadLocale = (): string => { if (typeof localStorage !== 'undefined') { try { return localStorage.getItem('metona-editor-locale') || getDefaultLocale(); } catch (_) {} } return getDefaultLocale(); }; export const getDefaultLocale = (): string => { if (typeof navigator !== 'undefined') { const browserLocale: string = (navigator as any).language || (navigator as any).userLanguage; if (browserLocale && isLocaleSupported(browserLocale)) return browserLocale; const short = browserLocale?.split('-')[0]; if (short && isLocaleSupported(short)) return short; } return fallbackLocale; }; export const getFallbackLocale = (): string => fallbackLocale; export const setFallbackLocale = (locale: string): void => { fallbackLocale = locale; }; export const initI18n = (): void => { const saved = loadLocale(); setCurrentLocale(saved); }; export const switchLocale = (locale: string): void => { setCurrentLocale(locale); }; export interface InstanceI18n { set: (locale: string) => string; get: () => string; getDirection: () => 'ltr' | 'rtl'; t: (key: string, params?: Record) => string; formatNumber: (n: number, opts?: Intl.NumberFormatOptions) => string; formatDate: (d: Date | string, opts?: Intl.DateTimeFormatOptions) => string; } export const createInstanceI18n = (editor: any): InstanceI18n => { let instanceLocale = editor.config?.locale || currentLocale || 'zh-CN'; const instanceT = (key: string, params: Record = {}): string => t(key, params, instanceLocale); const set = (locale: string): string => { if (!locale || instanceLocale === locale) return instanceLocale; instanceLocale = locale; if (editor.el) { editor.el.setAttribute('lang', locale); editor.el.setAttribute('dir', getLocaleDirection(locale)); } if (editor.textarea) { editor.textarea.placeholder = instanceT('placeholder') || ''; editor.textarea.setAttribute('aria-label', instanceT('edit') || ''); editor.textarea.setAttribute('dir', getLocaleDirection(locale)); } if (typeof editor._emit === 'function') { editor._emit('localeChange', { locale, direction: getLocaleDirection(locale) }); } if (editor.toolbarEl) { editor.toolbarEl.querySelectorAll('.me-btn').forEach((btn: HTMLElement) => { const action = btn.dataset.action || btn.dataset.mode; if (action) { const label = instanceT(action); if (label && label !== action) { btn.title = label; btn.setAttribute('aria-label', label); } } }); } // 语言切换即时刷新已渲染的 UI(状态栏统计标签、大纲标题),无需等待下一次输入 if (typeof editor._updateWordCount === 'function') { try { editor._updateWordCount(); } catch (_) {} } if (editor.el && typeof editor.el.querySelector === 'function') { const outlineTitle = editor.el.querySelector('.me-outline-title'); if (outlineTitle) outlineTitle.textContent = instanceT('outline') || 'Outline'; } return instanceLocale; }; return { set, get: () => instanceLocale, getDirection: () => getLocaleDirection(instanceLocale), t: instanceT, formatNumber: (n, opts) => { try { return new Intl.NumberFormat(instanceLocale, opts).format(n); } catch (_) { return String(n); } }, formatDate: (d, opts) => { try { return new Intl.DateTimeFormat(instanceLocale, opts).format(d instanceof Date ? d : new Date(d)); } catch (_) { return String(d); } }, }; }; export const createI18nManager = () => ({ t, getCurrentLocale, setCurrentLocale, switchLocale, getFallbackLocale: () => fallbackLocale, setFallbackLocale: (locale: string) => { fallbackLocale = locale; }, hasTranslation, getTranslations, addTranslations, loadRemote, getSupportedLocales, isLocaleSupported, getLocaleName, getLocaleDirection, formatNumber, formatCurrency, formatDate, addLocaleListener, removeLocaleListener, clearLocaleListeners, initI18n, saveLocale, loadLocale, getDefaultLocale, createInstanceI18n, }); export const presetLocales = { 'zh-CN': { name: '简体中文', nativeName: '简体中文', direction: 'ltr', translations: LOCALES['zh-CN'] }, 'en-US': { name: 'English (US)', nativeName: 'English (US)', direction: 'ltr', translations: LOCALES['en-US'] }, ja: { name: '日本語', nativeName: '日本語', direction: 'ltr', translations: (LOCALES as any)['ja'] }, ko: { name: '한국어', nativeName: '한국어', direction: 'ltr', translations: (LOCALES as any)['ko'] }, fr: { name: 'Français', nativeName: 'Français', direction: 'ltr', translations: (LOCALES as any)['fr'] }, de: { name: 'Deutsch', nativeName: 'Deutsch', direction: 'ltr', translations: (LOCALES as any)['de'] }, }; export const i18nUtils = createI18nManager(); export default i18nUtils;