import { TLI18nMessage, getDeclaredMessages, useValue, warnOnce } from '@tldraw/editor' import * as React from 'react' import { IntlContext, IntlProvider, ReactIntlErrorCode, createIntl, createIntlCache, type IntlConfig, } from 'react-intl' // Nothing renders these, so nothing else imports them, and an app looks them up by key. Imported // so their English is declared — see `getDeclaredMessages`. import '../../consumerMessages' import { useAssetUrls } from '../../context/asset-urls' import { TLUiTranslationKey } from './TLUiTranslationKey' import { EN_TRANSLATION, TLUiTranslation, fetchTranslation } from './translations' /** @public */ export interface TLUiTranslationProviderProps { children: React.ReactNode locale: string /** * A collection of overrides different locales. * * @example * * ```ts * * ``` */ overrides?: Record> } // What a nested `TldrawUiTranslationProvider` needs to tell its parent `IntlProvider` apart from // an app's: the catalog this one handed react-intl, which passes it through as `intl.messages`. const OuterCatalogContext = React.createContext<{ messages: IntlConfig['messages'] appMessages: IntlConfig['messages'] | undefined } | null>(null) /** @public */ export type TLUiTranslationContextType = TLUiTranslation /** @internal */ export const TranslationsContext = React.createContext(null) /** @public */ export function useCurrentTranslation() { const translations = React.useContext(TranslationsContext) if (!translations) { throw new Error('useCurrentTranslation must be used inside of ') } return translations } /** * Like {@link useCurrentTranslation}, but returns `null` instead of throwing when used outside * of a `` / ``. * * @public */ export function useMaybeCurrentTranslation() { return React.useContext(TranslationsContext) } /** * Provides a translation context to the editor. Wrap this around components that use * `useTranslation` (such as `TldrawSelectionForeground`) when you don't want to use the * full `TldrawUiContextProvider`. Must be rendered inside an `AssetUrlsProvider`. * * @public @react */ export function TldrawUiTranslationProvider({ overrides, locale, children, }: TLUiTranslationProviderProps) { const getAssetUrl = useAssetUrls() // Only the fetch belongs in an effect; merging overrides is derived from data already in hand. // Inside one, a changed override took another round trip to apply, and an app assembling its // overrides as it loads its own strings refetched this catalog on every identity change. const [loaded, setLoaded] = React.useState(EN_TRANSLATION) React.useEffect(() => { let isCancelled = false async function loadTranslation() { const translation = await fetchTranslation(locale, getAssetUrl) if (isCancelled) return setLoaded(translation) } loadTranslation() return () => { isCancelled = true } }, [getAssetUrl, locale]) // Keyed on the requested locale, not the loaded one. They differ when the SDK ships no catalogue // for it and `fetchTranslation` falls back to English — which is the case for a locale that // exists only in an app's own strings, so that's exactly when its overrides have to apply. // // Lowest to highest: the declared English, the English overrides, the locale's catalog, its // overrides. The English overrides go underneath the catalog, so they reach only ids it lacks: // an app's own key not yet translated for this locale falls back to its English, not its id. const declared = useValue('declared messages', getDeclaredMessages, []) const translated = React.useMemo(() => { const enOverrides = locale === 'en' ? undefined : overrides?.en return { ...enOverrides, ...loaded.messages, ...overrides?.[locale] } }, [loaded, overrides, locale]) const currentTranslation = React.useMemo( () => ({ ...loaded, messages: { ...declared, ...translated } as TLUiTranslation['messages'], }), [loaded, declared, translated] ) // react-intl builds each provider's catalog from its own props alone, so without merging this // one shadows an app's own `IntlProvider` for everything inside it — every tldraw.com dialog // and share menu — leaving them on `defaultMessage`: English, and silently untranslated // everywhere else. Ours wins a collision, since `overrides.translations` is the documented way // to change an SDK string. The declared English goes underneath the app's catalog, not over it: // it holds the app's own declarations too, whose English would otherwise shadow their // translations. // // Only the app's catalog is merged, never another of ours: `` renders one of these // above the UI's, with the locale it mounted with, and merging that put its language under // every other — French under English, French filling German's gaps. // // The cast is because the type is a union of two uniform records and this one is mixed on // purpose: `formatMessage` dispatches per message, so the SDK's ICU strings and an app's // precompiled AST can share a catalog. That holds only while the app keeps react-intl's ICU // parser — an app that aliases it away, as tldraw.com used to, formats every string message // with a placeholder to its own source. const parentIntl = React.useContext(IntlContext) const outer = React.useContext(OuterCatalogContext) const appMessages = outer && parentIntl?.messages === outer.messages ? outer.appMessages : parentIntl?.messages const messages = React.useMemo( () => appMessages ? ({ ...declared, ...appMessages, ...translated, } as IntlConfig['messages']) : currentTranslation.messages, [appMessages, declared, translated, currentTranslation.messages] ) const outerCatalog = React.useMemo(() => ({ messages, appMessages }), [messages, appMessages]) return ( {/* One IntlShape behind both APIs: `msg()` by key and `` by descriptor read the same merged catalog, so an app's `overrides.translations` reaches both. */} {children} ) } /** * Values substituted into a message's ICU placeholders. * * @public */ export type TLUiTranslationValues = Record< string, string | number | bigint | boolean | Date | null | undefined > // `fallbackOnEmptyString` is off on both intl instances because an override of `''` is how an app // hides a label; react-intl's default renders the id in its place. // // Returning the key verbatim for an unknown id is documented behaviour: apps pass their own keys // and plain text through `msg()`. Only surface errors that mean a message is actually malformed. const onTranslationError: IntlConfig['onError'] = (error) => { if (error.code === ReactIntlErrorCode.MISSING_TRANSLATION) return if (process.env.NODE_ENV !== 'production') console.error(error) } // One cache for the intl instances built below, as formatjs recommends, so that repeated mounts // don't leak a formatter cache each. const intlCache = createIntlCache() /** * Returns a function to translate a message into a string based on the current translation. * * Pass a message declared with `defineMessages`, or a translation key. Messages are * {@link https://unicode-org.github.io/icu/userguide/format_parse/messages/ | ICU MessageFormat}, * so a message can pluralise or select on the values you pass it. A declared message missing from * the catalog falls back to its `defaultMessage`; an unknown key is returned as-is. * * @example * * ```ts * const messages = defineMessages({ * selected: { id: 'my-app.selected', defaultMessage: '{count, plural, one {# shape} other {# shapes}}' }, * }) * * const msg = useTranslation() * const label = msg(messages.selected, { count: 5 }) * const title = msg('my-app.title') * ``` * * @public */ export function useTranslation() { const translation = React.useContext(TranslationsContext) React.useEffect(() => { if (!translation?.messages) { warnOnce( 'No translation messages found, falling back to default translation. Wrap your app in , or in both and , to provide translations.' ) } }, [translation?.messages]) // Not `useIntl()`: that throws outside a provider, where this hook still has to work. const providedIntl = React.useContext(IntlContext) // `TranslationsContext` on its own has always been enough to translate — tests and hand-rolled // setups provide it without the rest. Keep that working by formatting against its messages when // there's no `IntlProvider` above us, rather than silently falling back to English. const standaloneIntl = React.useMemo( () => providedIntl ? null : createIntl( { locale: translation?.locale ?? 'en', defaultLocale: 'en', messages: translation?.messages ?? getDeclaredMessages(), onError: onTranslationError, fallbackOnEmptyString: false, }, intlCache ), [providedIntl, translation?.locale, translation?.messages] ) const intl = providedIntl ?? standaloneIntl! return React.useCallback( function msg( message?: Exclude | string | TLI18nMessage, values?: TLUiTranslationValues ): string { // `formatMessage` needs an id. Callers pass optional labels straight through, so keep the // nullish passthrough this has always had rather than substituting an empty string. if (!message) return message as string // A bare key carries no default message, by design: this is the path for labels that // arrive as data and for an app's own keys. return intl.formatMessage(typeof message === 'string' ? { id: message } : message, values) }, [intl] ) } /** * Returns the current text direction ('ltr' or 'rtl') based on the current translation. * * @public */ export function useDirection() { const translation = useMaybeCurrentTranslation() return translation?.dir ?? 'ltr' } export function untranslated(string: string) { return string as TLUiTranslationKey }