playcanvas
Version:
PlayCanvas WebGL game engine
386 lines (383 loc) • 14.3 kB
JavaScript
import { EventHandler } from '../../core/event-handler.js';
import { Asset } from '../asset/asset.js';
import { DEFAULT_LOCALE, DEFAULT_LOCALE_FALLBACKS } from './constants.js';
import { getLang, replaceLang, getPluralFn, findAvailableLocale } from './utils.js';
import { I18nParser } from './i18n-parser.js';
/**
* @import { AppBase } from '../app-base.js'
*/ /**
* Handles localization. Responsible for loading localization assets and returning translations for
* a certain key. Can also handle plural forms. To override its default behavior define a different
* implementation for {@link I18n#getText} and {@link I18n#getPluralText}.
*/ class I18n extends EventHandler {
static{
/**
* Fired when when the locale is changed.
*
* @event
* @example
* app.i18n.on('change', (newLocale, oldLocale) => {
* console.log(`Locale changed from ${oldLocale} to ${newLocale}`);
* });
*/ this.EVENT_CHANGE = 'change';
}
/**
* Create a new I18n instance.
*
* @param {AppBase} app - The application.
*/ constructor(app){
super();
this.locale = DEFAULT_LOCALE;
this._translations = {};
this._availableLangs = {};
this._app = app;
this._assets = [];
this._parser = new I18nParser();
}
/**
* Sets the array of asset ids or assets that contain localization data in the expected format.
* I18n will automatically load translations from these assets as the assets are loaded and it
* will also automatically unload translations if the assets get removed or unloaded at runtime.
*
* @type {number[]|Asset[]}
*/ set assets(value) {
const index = {};
// convert array to dict
for(let i = 0, len = value.length; i < len; i++){
const id = value[i] instanceof Asset ? value[i].id : value[i];
index[id] = true;
}
// remove assets not in value
let i = this._assets.length;
while(i--){
const id = this._assets[i];
if (!index[id]) {
this._app.assets.off(`add:${id}`, this._onAssetAdd, this);
const asset = this._app.assets.get(id);
if (asset) {
this._onAssetRemove(asset);
}
this._assets.splice(i, 1);
}
}
// add assets in value that do not already exist here
for(const id in index){
const idNum = parseInt(id, 10);
if (this._assets.indexOf(idNum) !== -1) continue;
this._assets.push(idNum);
const asset = this._app.assets.get(idNum);
if (!asset) {
this._app.assets.once(`add:${idNum}`, this._onAssetAdd, this);
} else {
this._onAssetAdd(asset);
}
}
}
/**
* Gets the array of asset ids that contain localization data in the expected format.
*
* @type {number[]|Asset[]}
*/ get assets() {
return this._assets;
}
/**
* Sets the current locale. For example, "en-US". Changing the locale will raise an event which
* will cause localized Text Elements to change language to the new locale.
*
* @type {string}
*/ set locale(value) {
if (this._locale === value) {
return;
}
// replace 'in' language with 'id'
// for Indonesian because both codes are valid
// so that users only need to use the 'id' code
let lang = getLang(value);
if (lang === 'in') {
lang = 'id';
value = replaceLang(value, lang);
if (this._locale === value) {
return;
}
}
const old = this._locale;
// cache locale, lang and plural function
this._locale = value;
this._lang = lang;
this._pluralFn = getPluralFn(this._lang);
// raise event
this.fire(I18n.EVENT_CHANGE, value, old);
}
/**
* Gets the current locale.
*
* @type {string}
*/ get locale() {
return this._locale;
}
/**
* Returns the first available locale based on the desired locale specified. First tries to
* find the desired locale and then tries to find an alternative locale based on the language.
*
* @param {string} desiredLocale - The desired locale e.g. en-US.
* @param {object} availableLocales - A dictionary where each key is an available locale.
* @returns {string} The locale found or if no locale is available returns the default en-US
* locale.
* @example
* // With a defined dictionary of locales
* const availableLocales = { en: 'en-US', fr: 'fr-FR' };
* const locale = pc.I18n.getText('en-US', availableLocales);
* // returns 'en'
* @ignore
*/ static findAvailableLocale(desiredLocale, availableLocales) {
return findAvailableLocale(desiredLocale, availableLocales);
}
/**
* Returns the first available locale based on the desired locale specified. First tries to
* find the desired locale in the loaded translations and then tries to find an alternative
* locale based on the language.
*
* @param {string} desiredLocale - The desired locale e.g. en-US.
* @returns {string} The locale found or if no locale is available returns the default en-US
* locale.
* @example
* const locale = this.app.i18n.getText('en-US');
*/ findAvailableLocale(desiredLocale) {
if (this._translations[desiredLocale]) {
return desiredLocale;
}
const lang = getLang(desiredLocale);
return this._findFallbackLocale(desiredLocale, lang);
}
/**
* Returns the translation for the specified key and locale. If the locale is not specified it
* will use the current locale.
*
* @param {string} key - The localization key.
* @param {string} [locale] - The desired locale.
* @returns {string} The translated text. If no translations are found at all for the locale
* then it will return the en-US translation. If no translation exists for that key then it will
* return the localization key.
* @example
* const localized = this.app.i18n.getText('localization-key');
* const localizedFrench = this.app.i18n.getText('localization-key', 'fr-FR');
*/ getText(key, locale) {
// default translation is the key
let result = key;
let lang;
if (!locale) {
locale = this._locale;
lang = this._lang;
}
let translations = this._translations[locale];
if (!translations) {
if (!lang) {
lang = getLang(locale);
}
locale = this._findFallbackLocale(locale, lang);
translations = this._translations[locale];
}
if (translations && translations.hasOwnProperty(key)) {
result = translations[key];
// if this is a plural key then return the first entry in the array
if (Array.isArray(result)) {
result = result[0];
}
// if null or undefined switch back to the key (empty string is allowed)
if (result === null || result === undefined) {
result = key;
}
}
return result;
}
/**
* Returns the pluralized translation for the specified key, number n and locale. If the locale
* is not specified it will use the current locale.
*
* @param {string} key - The localization key.
* @param {number} n - The number used to determine which plural form to use. E.g. For the
* phrase "5 Apples" n equals 5.
* @param {string} [locale] - The desired locale.
* @returns {string} The translated text. If no translations are found at all for the locale
* then it will return the en-US translation. If no translation exists for that key then it
* will return the localization key.
* @example
* // manually replace {number} in the resulting translation with our number
* const localized = this.app.i18n.getPluralText('{number} apples', number).replace("{number}", number);
*/ getPluralText(key, n, locale) {
// default translation is the key
let result = key;
let lang;
let pluralFn;
if (!locale) {
locale = this._locale;
lang = this._lang;
pluralFn = this._pluralFn;
} else {
lang = getLang(locale);
pluralFn = getPluralFn(lang);
}
let translations = this._translations[locale];
if (!translations) {
locale = this._findFallbackLocale(locale, lang);
lang = getLang(locale);
pluralFn = getPluralFn(lang);
translations = this._translations[locale];
}
if (translations && translations[key] && pluralFn) {
const index = pluralFn(n);
result = translations[key][index];
// if null or undefined switch back to the key (empty string is allowed)
if (result === null || result === undefined) {
result = key;
}
}
return result;
}
/**
* Adds localization data. If the locale and key for a translation already exists it will be
* overwritten.
*
* @param {object} data - The localization data. See example for the expected format of the
* data.
* @example
* this.app.i18n.addData({
* header: {
* version: 1
* },
* data: [{
* info: {
* locale: 'en-US'
* },
* messages: {
* "key": "translation",
* // The number of plural forms depends on the locale. See the manual for more information.
* "plural_key": ["one item", "more than one items"]
* }
* }, {
* info: {
* locale: 'fr-FR'
* },
* messages: {
* // ...
* }
* }]
* });
*/ addData(data) {
let parsed;
try {
parsed = this._parser.parse(data);
} catch (err) {
console.error(err);
return;
}
for(let i = 0, len = parsed.length; i < len; i++){
const entry = parsed[i];
const locale = entry.info.locale;
const messages = entry.messages;
if (!this._translations[locale]) {
this._translations[locale] = {};
const lang = getLang(locale);
// remember the first locale we've found for that language
// in case we need to fall back to it
if (!this._availableLangs[lang]) {
this._availableLangs[lang] = locale;
}
}
Object.assign(this._translations[locale], messages);
this.fire('data:add', locale, messages);
}
}
/**
* Removes localization data.
*
* @param {object} data - The localization data. The data is expected to be in the same format
* as {@link I18n#addData}.
*/ removeData(data) {
let parsed;
try {
parsed = this._parser.parse(data);
} catch (err) {
console.error(err);
return;
}
for(let i = 0, len = parsed.length; i < len; i++){
const entry = parsed[i];
const locale = entry.info.locale;
const translations = this._translations[locale];
if (!translations) continue;
const messages = entry.messages;
for(const key in messages){
delete translations[key];
}
// if no more entries for that locale then
// delete the locale
if (Object.keys(translations).length === 0) {
delete this._translations[locale];
delete this._availableLangs[getLang(locale)];
}
this.fire('data:remove', locale, messages);
}
}
/**
* Frees up memory.
*/ destroy() {
this._translations = null;
this._availableLangs = null;
this._assets = null;
this._parser = null;
this.off();
}
// Finds a fallback locale for the specified locale and language.
// 1) First tries DEFAULT_LOCALE_FALLBACKS
// 2) If no translation exists for that locale return the first locale available for that language.
// 3) If no translation exists for that either then return the DEFAULT_LOCALE
_findFallbackLocale(locale, lang) {
let result = DEFAULT_LOCALE_FALLBACKS[locale];
if (result && this._translations[result]) {
return result;
}
result = DEFAULT_LOCALE_FALLBACKS[lang];
if (result && this._translations[result]) {
return result;
}
result = this._availableLangs[lang];
if (result && this._translations[result]) {
return result;
}
return DEFAULT_LOCALE;
}
_onAssetAdd(asset) {
asset.on('load', this._onAssetLoad, this);
asset.on('change', this._onAssetChange, this);
asset.on('remove', this._onAssetRemove, this);
asset.on('unload', this._onAssetUnload, this);
if (asset.resource) {
this._onAssetLoad(asset);
}
}
_onAssetLoad(asset) {
this.addData(asset.resource);
}
_onAssetChange(asset) {
if (asset.resource) {
this.addData(asset.resource);
}
}
_onAssetRemove(asset) {
asset.off('load', this._onAssetLoad, this);
asset.off('change', this._onAssetChange, this);
asset.off('remove', this._onAssetRemove, this);
asset.off('unload', this._onAssetUnload, this);
if (asset.resource) {
this.removeData(asset.resource);
}
this._app.assets.once(`add:${asset.id}`, this._onAssetAdd, this);
}
_onAssetUnload(asset) {
if (asset.resource) {
this.removeData(asset.resource);
}
}
}
export { I18n };