修复:插件面板实例级 i18n / undo-redo 滚动保持 / shortcutHelp 卸载残留 / CSS.escape 环境崩溃 / 大纲光标按源码行号定位 / setLocale 即时刷新 / 浮动工具栏 CJK 宽度 / ko 语言包括号 新增:setOutline(isOutline) 运行时大纲 API、getRenderCacheSize 观测接口 测试:841 → 871(补 7 个事件断言、5 个配置项、修 3 处恒真断言) 工程:CI lint 阻断、build.sh npm ci、rollup 死代码清理、覆盖率阈值门禁
280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
/**
|
|
* 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<string, PluralRule> = {
|
|
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<string, any> = {}, 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, any>): 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<string, any> => LOCALES[locale] || {};
|
|
|
|
export const addTranslations = (locale: string, translations: Record<string, any>): void => {
|
|
if (!LOCALES[locale]) LOCALES[locale] = {};
|
|
deepMergeI18n(LOCALES[locale], translations);
|
|
};
|
|
|
|
export const loadRemote = async (url: string, locale: string): Promise<boolean> => {
|
|
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<string, any>, source: Record<string, any>): Record<string, any> => {
|
|
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<string, string> = {
|
|
'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, any>) => 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, any> = {}): 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;
|