Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion packages/core/editor/src/YooptaEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { FakeSelectionMark } from './marks/FakeSelectionMark';
import { generateId } from './utils/generateId';
import { YooptaOperation } from './editor/core/applyTransforms';
import { validateYooptaValue } from './utils/validateYooptaValue';
import {Translations} from './i18n/types';
import {useAddTranslations} from './i18n/hooks/useAddTranslations';

export type YooptaOnChangeOptions = {
operations: YooptaOperation[];
Expand All @@ -40,6 +42,8 @@ export type YooptaEditorProps = {
readOnly?: boolean;
width?: number | string;
style?: CSSProperties;

translations?: Translations;
};

type EditorState = {
Expand All @@ -64,14 +68,25 @@ const YooptaEditor = ({
style,
onChange,
onPathChange,
translations: userTranslations = {},
}: YooptaEditorProps) => {
const {addTranslations} = useAddTranslations();
const marks = useMemo(() => {
if (marksProps) return [FakeSelectionMark, ...marksProps];
return [FakeSelectionMark];
}, [marksProps]);

const plugins = useMemo(() => {
return pluginsProps.map((plugin) => plugin.getPlugin as Plugin<Record<string, SlateElement>>);
return pluginsProps.map((plugin) => {
const pluginInstance = plugin.getPlugin as Plugin<Record<string, SlateElement>>;

// Merge plugin translations into the global translation registry
Object.entries(pluginInstance.translations || {}).forEach(([language, pluginTranslations]) => {
addTranslations(language, pluginInstance.type.toLowerCase(), pluginTranslations);
});

return pluginInstance;
});
}, [pluginsProps]);

const [editorState, setEditorState] = useState<EditorState>(() => {
Expand All @@ -88,6 +103,12 @@ const YooptaEditor = ({
);
}

Object.entries(userTranslations).forEach(([language, translations]) => {
Object.entries(translations).forEach(([namespace, translation]) => {
addTranslations(language, namespace, translation);
});
});

editor.children = (isValueValid ? value : {}) as YooptaContentValue;
editor.blockEditorsMap = buildBlockSlateEditors(editor);
editor.shortcuts = buildBlockShortcuts(editor);
Expand Down
4 changes: 3 additions & 1 deletion packages/core/editor/src/components/TextLeaf/TextLeaf.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import { HTMLAttributes } from 'react';
import { RenderLeafProps, useSelected } from 'slate-react';
import { ExtendedLeafProps } from '../../plugins/types';
import {useTranslation} from '../../i18n/hooks/useTranslation';

type Props = Pick<ExtendedLeafProps<any, any>, 'attributes' | 'children'> & {
placeholder?: string;
};

const TextLeaf = ({ children, attributes, placeholder }: Props) => {
const selected = useSelected();
const {t} = useTranslation();

const attrs: HTMLAttributes<HTMLSpanElement> & RenderLeafProps['attributes'] = {
...attributes,
};

if (selected && placeholder) {
attrs['data-placeholder'] = placeholder;
attrs['data-placeholder'] = t(placeholder);
attrs.className = `yoopta-placeholder`;
}

Expand Down
125 changes: 125 additions & 0 deletions packages/core/editor/src/i18n/TranslationManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import {defaultLocales} from './locales';
import {Translations} from './types';

type Listener = () => void;

class TranslationManager {
private static instance: TranslationManager;
private translations: Translations = defaultLocales;
private currentLanguage: string = 'en';
private userOverrides: Translations = {};
private listeners: Listener[] = [];

static getInstance(): TranslationManager {
if (!TranslationManager.instance) {
this.instance = new TranslationManager();
}
return this.instance;
}

/**
* Add translations for a specific namespace and language.
*/
addTranslations(language: string, namespace: string, newTranslations: Record<string, string>): void {
if (!this.translations[language]) {
this.translations[language] = {};
}

if (!this.translations[language][namespace]) {
this.translations[language][namespace] = {};
}

this.translations[language][namespace] = {
...this.translations[language][namespace],
...newTranslations,
};
}

/**
* Override translations provided by the user.
*/
overrideTranslations(overrides: Translations): void {
Object.entries(overrides).forEach(([language, namespaces]) => {
if (!this.userOverrides[language]) {
this.userOverrides[language] = {};
}

Object.entries(namespaces).forEach(([namespace, translations]) => {
if (!this.userOverrides[language][namespace]) {
this.userOverrides[language][namespace] = {};
}

this.userOverrides[language][namespace] = {
...this.userOverrides[language][namespace],
...translations,
};
});
});
}

/**
* Fetch a translation for a specific key.
*/
translate(key: string): string {
const [namespace, ...rest] = key.split('.');
const finalKey = rest.join('.');

return (
this.userOverrides?.[this.currentLanguage]?.[namespace]?.[finalKey] ??
this.translations?.[this.currentLanguage]?.[namespace]?.[finalKey] ??
key
);
}

/**
* Set the current language and notify listeners.
*/
setLanguage(language: string): void {
if (this.translations[language]) {
this.currentLanguage = language;
this.notifyListeners();
} else {
console.warn(`Language ${language} not found. Falling back to ${this.currentLanguage}.`);
}
}

/**
* Get the current language.
*/
getCurrentLanguage(): string {
return this.currentLanguage;
}

/**
* Fetch all available keys for the current language.
*/
getAvailableKeys(): Record<string, string[]> {
const availableKeys: Record<string, string[]> = {};
const langTranslations = this.translations[this.currentLanguage] || {};

Object.entries(langTranslations).forEach(([namespace, keys]) => {
availableKeys[namespace] = Object.keys(keys);
});

return availableKeys;
}

/**
* Subscribe to language changes.
*/
subscribe(listener: Listener): () => void {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
}

/**
* Notify all listeners about language changes.
*/
private notifyListeners(): void {
this.listeners.forEach((listener) => listener());
}
}

export const YooptaTranslationManager = TranslationManager.getInstance();
52 changes: 52 additions & 0 deletions packages/core/editor/src/i18n/hooks/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
export interface UseTranslationReturn {
/**
* Translates a key into the current language.
* The key format is `namespace.key`, e.g., `core.save`.
* If the translation for the key is not found, it returns the key provided.
*
* @param key - The key to translate, formatted as `namespace.key`.
* @returns The translated string or a fallback message.
*/
t: (key: string) => string;

/**
* The current language set in the translation manager.
* This value updates reactively when the language is changed.
*/
currentLanguage: string;

/**
* Changes the current language to the specified value.
* Notifies all subscribers of the language change.
*
* @param lang - The language code to set, e.g., 'en' or 'es' or 'fr'...
*/
setLanguage: (lang: string) => void;

/**
* Retrieves all available keys for the current language.
* This is provided as a utility function to know all available keys at runtime.
* The keys are grouped by namespace and provide introspection into all registered translations.
*
* @returns A record where the keys are namespaces and the values are arrays of available keys.
*/
getAvailableKeys: () => Record<string, string[]>;
}

export interface UseAddTranslationsReturn {
/**
* Enables adding new translations at runtime for specific languages and namespaces.
*
* Example Usage:
* ```
* const { addTranslations } = useAddTranslations();
* addTranslations('es', 'core', { save: 'Guardar', cancel: 'Cancelar' });
* addTranslations('en', 'paragraph', { placeholder: 'Type a paragraph...' });
* ```
*
* @param language - The language code to add translations for (e.g., 'en' or 'es').
* @param namespace - The namespace grouping the translations (e.g., 'core', 'plugin').
* @param translations - A record of key-value pairs representing the translations.
*/
addTranslations: (language: string, namespace: string, translations: Record<string, string>) => void;
}
14 changes: 14 additions & 0 deletions packages/core/editor/src/i18n/hooks/useAddTranslations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {UseAddTranslationsReturn} from './types';
import {useCallback} from 'react';
import {YooptaTranslationManager} from '../TranslationManager';

export const useAddTranslations = (): UseAddTranslationsReturn => {
const addTranslations = useCallback(
(language: string, namespace: string, translations: Record<string, string>) => {
YooptaTranslationManager.addTranslations(language, namespace, translations);
},
[]
);

return { addTranslations }
}
35 changes: 35 additions & 0 deletions packages/core/editor/src/i18n/hooks/useTranslation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react';
import { YooptaTranslationManager } from '../TranslationManager';
import { UseTranslationReturn } from './types';

export const useTranslation = (): UseTranslationReturn => {
const [language, setLanguage] = useState(() => YooptaTranslationManager.getCurrentLanguage());
const [t, setT] = useState(() => (key: string) => YooptaTranslationManager.translate(key));

useEffect(() => {
const handleUpdate = () => {
setLanguage(YooptaTranslationManager.getCurrentLanguage());
setT(() => (key: string) => YooptaTranslationManager.translate(key));
};

const unsubscribe = YooptaTranslationManager.subscribe(handleUpdate);

// Initialize the state in case it changes before mounting
handleUpdate();

return () => unsubscribe();
}, []);

const changeLanguage = (lang: string) => {
YooptaTranslationManager.setLanguage(lang);
};

const getAvailableKeys = () => YooptaTranslationManager.getAvailableKeys();

return {
t,
currentLanguage: language,
setLanguage: changeLanguage,
getAvailableKeys,
};
};
9 changes: 9 additions & 0 deletions packages/core/editor/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {Translations} from '../types';

export const enLocale: Translations = {
en: {
core: {
editor_placeholder: "Type '/' for commands"
}
}
}
9 changes: 9 additions & 0 deletions packages/core/editor/src/i18n/locales/es.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {Translations} from '../types';

export const esLocale: Translations = {
es: {
core: {
editor_placeholder: "Escribe / para abrir el menú"
}
}
}
8 changes: 8 additions & 0 deletions packages/core/editor/src/i18n/locales/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {enLocale} from './en';
import {esLocale} from './es';
import {Translations} from '../types';

export const defaultLocales: Translations = {
...enLocale,
...esLocale,
}
42 changes: 42 additions & 0 deletions packages/core/editor/src/i18n/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Interface representing the structure of the translations used in YooptaTranslationManager.
*
* This structure organizes translations by language and namespace, making it easy to
* group related keys and support multiple languages.
*
* Example Usage:
* ```
* const translations: Translations = {
* en: {
* core: {
* save: 'Save',
* cancel: 'Cancel',
* },
* paragraph: {
* placeholder: 'Type a paragraph...',
* },
* },
* es: {
* core: {
* save: 'Guardar',
* cancel: 'Cancelar',
* },
* paragraph: {
* placeholder: 'Escribe un párrafo...',
* },
* },
* };
* ```
*
* Structure:
* - `language` (string): Represents the language code, e.g., 'en', 'es'.
* - `namespace` (string): Groups related translations, such as 'core' or 'paragraph'.
* - `Record<string, string>`: Contains key-value pairs where:
* - The key (string) is the identifier for a specific translation.
* - The value (string) is the translated text for that key.
*/
export interface Translations {
[language: string]: {
[namespace: string]: Record<string, string>;
};
}
1 change: 1 addition & 0 deletions packages/core/editor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export {
useYooptaReadOnly,
useYooptaPluginOptions,
} from './contexts/YooptaContext/YooptaContext';
export { useTranslation } from './i18n/hooks/useTranslation';
import { YooptaEditor, type YooptaEditorProps, type YooptaOnChangeOptions } from './YooptaEditor';
export { deserializeHTML } from './parsers/deserializeHTML';
export { type EmailTemplateOptions } from './parsers/getEmail';
Expand Down
Loading