diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 00000000..5d42e13a --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,16 @@ +--- +title: i18n +slug: i18n +position: 105 +--- + +# i18n + +The component is localized entirely in English by default, but you can override these localizations via the `translation` prop - a function that has a signature compatible with the `t` function of i18next. You only need to localize parts of the UI that you'll actually be using, there is no need to localize the entirety of the editor unless you need to. If you're using i18next, you can use browse the [`locales` directory in GitHub](https://github.com/mdx-editor/editor) for a default set of translations or use a tool like [i18next Parser](https://github.com/i18next/i18next-parser) to extract them from the source code. If you're using another i18n library, you can use the `translation` prop to pass in your own translations + +```tsx + +function LocalizedEditor() { + return { return i18n.t(key, defaultValue, interpolations) }} /> +} +``` diff --git a/locales/en/translation.json b/locales/en/translation.json new file mode 100644 index 00000000..c8f466df --- /dev/null +++ b/locales/en/translation.json @@ -0,0 +1,101 @@ +{ + "frontmatterEditor": { + "title": "Edit document frontmatter", + "key": "Key", + "value": "Value", + "addEntry": "Add entry" + }, + "dialogControls": { + "save": "Save", + "cancel": "Cancel" + }, + "uploadImage": { + "uploadInstructions": "Upload an image from your device:", + "addViaUrlInstructions": "Or add an image from an URL:", + "autoCompletePlaceholder": "Select or paste an image src", + "alt": "Alt:", + "title": "Title:" + }, + "imageEditor": { + "editImage": "Edit image" + }, + "createLink": { + "url": "URL", + "urlPlaceholder": "Select or paste an URL", + "title": "Title", + "saveTooltip": "Set URL", + "cancelTooltip": "Cancel change" + }, + "linkPreview": { + "open": "Open {{url}} in new window", + "edit": "Edit link URL", + "copyToClipboard": "Copy to clipboard", + "copied": "Copied!", + "remove": "Remove link" + }, + "table": { + "deleteTable": "Delete table", + "columnMenu": "Column menu", + "textAlignment": "Text alignment", + "alignLeft": "Align left", + "alignCenter": "Align center", + "alignRight": "Align right", + "insertColumnLeft": "Insert a column to the left of this one", + "insertColumnRight": "Insert a column to the right of this one", + "deleteColumn": "Delete this column", + "rowMenu": "Row menu", + "insertRowAbove": "Insert a row above this one", + "insertRowBelow": "Insert a row below this one", + "deleteRow": "Delete this row" + }, + "toolbar": { + "blockTypes": { + "paragraph": "Paragraph", + "quote": "Quote", + "heading": "Heading {{level}}" + }, + "blockTypeSelect": { + "selectBlockTypeTooltip": "Select block type", + "placeholder": "Block type" + }, + "removeBold": "Remove bold", + "bold": "Bold", + "removeItalic": "Italic", + "italic": "Italic", + "underline": "Remove underline", + "removeUnderline": "Underline", + "removeInlineCode": "Remove code format", + "inlineCode": "Inline code format", + "link": "Create link", + "richText": "Rich text", + "diffMode": "Diff mode", + "source": "Source mode", + "admonition": "Insert Admonition", + "codeBlock": "Insert Code Block", + "editFrontmatter": "Edit frontmatter", + "insertFrontmatter": "Insert frontmatter", + "image": "Insert image", + "insertSandpack": "Insert Sandpack", + "table": "Insert Table", + "thematicBreak": "Insert thematic break", + "bulletedList": "Bulleted list", + "numberedList": "Numbered list", + "checkList": "Check list", + "deleteSandpack": "Delete this code block", + "undo": "Undo {{shortcut}}", + "redo": "Redo {{shortcut}}" + }, + "admonitions": { + "note": "Note", + "tip": "Tip", + "danger": "Danger", + "info": "Info", + "caution": "Caution", + "changeType": "Select admonition type", + "placeholder": "Admonition type" + }, + "codeBlock": { + "language": "Code block language", + "selectLanguage": "Select code block language" + } +} diff --git a/package-lock.json b/package-lock.json index 9391f03a..3cc3f952 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,6 +95,7 @@ "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "express": "^4.18.2", + "i18next": "^23.10.0", "jsdom": "^23.0.1", "mermaid": "^10.6.1", "micromark-util-types": "^2.0.0", @@ -692,11 +693,11 @@ } }, "node_modules/@babel/runtime": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.6.tgz", - "integrity": "sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.0.tgz", + "integrity": "sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw==", "dependencies": { - "regenerator-runtime": "^0.13.11" + "regenerator-runtime": "^0.14.0" }, "engines": { "node": ">=6.9.0" @@ -10870,6 +10871,29 @@ "node": ">=14.18.0" } }, + "node_modules/i18next": { + "version": "23.10.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.10.0.tgz", + "integrity": "sha512-/TgHOqsa7/9abUKJjdPeydoyDc0oTi/7u9F8lMSj6ufg4cbC1Oj3f/Jja7zj7WRIhEQKB7Q4eN6y68I9RDxxGQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -19991,9 +20015,9 @@ } }, "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/regexp.prototype.flags": { "version": "1.5.0", diff --git a/package.json b/package.json index ad23c53b..b7af8bee 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "express": "^4.18.2", + "i18next": "^23.10.0", "jsdom": "^23.0.1", "mermaid": "^10.6.1", "micromark-util-types": "^2.0.0", diff --git a/src/MDXEditor.tsx b/src/MDXEditor.tsx index 0447aefa..066c77da 100644 --- a/src/MDXEditor.tsx +++ b/src/MDXEditor.tsx @@ -2,6 +2,7 @@ import { useCellValues, usePublisher, useRealm } from '@mdxeditor/gurx' import React from 'react' import { RealmPlugin, RealmWithPlugins } from './RealmWithPlugins' import { + Translation, composerChildren$, contentEditableClassName$, corePlugin, @@ -95,6 +96,7 @@ const DefaultIcon = React.lazy(() => import('./plugins/core/Icon')) const IconFallback = () => { return } + const defaultIconComponentFor = (name: IconKey) => { return ( }> @@ -103,6 +105,15 @@ const defaultIconComponentFor = (name: IconKey) => { ) } +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function defaultTranslation(key: string, defaultValue: string, interpolations = {}) { + let value = defaultValue + for (const [k, v] of Object.entries(interpolations)) { + value = value.replaceAll(`{{${k}}}`, String(v)) + } + return value +} + /** * The interface for the {@link MDXEditor} object reference. * @@ -280,6 +291,10 @@ export interface MDXEditorProps { * Set to false if you want to suppress the processing of HTML tags. */ suppressHtmlProcessing?: boolean + /** + * Pass your own translation function if you want to localize the editor. + */ + translation?: Translation } /** @@ -301,7 +316,8 @@ export const MDXEditor = React.forwardRef((pro readOnly: Boolean(props.readOnly), iconComponentFor: props.iconComponentFor ?? defaultIconComponentFor, suppressHtmlProcessing: props.suppressHtmlProcessing ?? false, - onError: props.onError ?? noop + onError: props.onError ?? noop, + translation: props.translation ?? defaultTranslation }), ...(props.plugins || []) ]} diff --git a/src/examples/i18n.tsx b/src/examples/i18n.tsx new file mode 100644 index 00000000..34360cf1 --- /dev/null +++ b/src/examples/i18n.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import i18next from 'i18next' +import markdown from './assets/live-demo-contents.md?raw' +import { MDXEditor } from '..' +import { ALL_PLUGINS } from './_boilerplate' + +const sl = { + toolbar: { + undo: 'Razveljavi {{shortcut}}', + redo: 'Uveljavi {{shortcut}}', + blockTypeSelect: { + selectBlockTypeTooltip: 'Izberi vrsto bloka', + placeholder: 'Vrsta bloka' + }, + + blockTypes: { + paragraph: 'Odstavek', + heading: 'Naslov', + quote: 'Citat' + } + } +} + +void i18next.init({ + lng: 'sl', // if you're using a language detector, do not define the lng option + debug: true, + resources: { + sl: { + translation: sl + } + } +}) + +export const Example = () => { + return ( + { + return i18next.t(key, defaultValue, interpolations) as string + }} + markdown={markdown} + onChange={(md) => console.log('change', { md })} + plugins={ALL_PLUGINS} + /> + ) +} diff --git a/src/plugins/core/index.ts b/src/plugins/core/index.ts index 040bc9f9..a603967e 100644 --- a/src/plugins/core/index.ts +++ b/src/plugins/core/index.ts @@ -4,7 +4,7 @@ import { createEmptyHistoryState } from '@lexical/react/LexicalHistoryPlugin.js' import { $isHeadingNode, HeadingTagType } from '@lexical/rich-text' import { $setBlocksType } from '@lexical/selection' import { $findMatchingParent, $insertNodeToNearestRoot, $wrapNodeInElement } from '@lexical/utils' -import { Cell, NodeRef, Realm, Signal, filter, map, scan, withLatestFrom } from '@mdxeditor/gurx' +import { Cell, NodeRef, Realm, Signal, filter, map, scan, useCellValue, withLatestFrom } from '@mdxeditor/gurx' import { $createParagraphNode, $getRoot, @@ -829,6 +829,12 @@ export const activePlugins$ = Cell([]) */ export const addActivePlugin$ = Appender(activePlugins$) +export type Translation = (key: string, defaultValue: string, interpolations?: Record) => string + +export const translation$ = Cell(() => { + throw new Error('No translation function provided') +}) + /** @internal */ export const corePlugin = realmPlugin<{ initialMarkdown: string @@ -842,6 +848,7 @@ export const corePlugin = realmPlugin<{ readOnly: boolean iconComponentFor: (name: IconKey) => React.ReactElement suppressHtmlProcessing?: boolean + translation: Translation }>({ init(r, params) { r.register(createRootEditorSubscription$) @@ -872,7 +879,8 @@ export const corePlugin = realmPlugin<{ [toMarkdownOptions$]: params?.toMarkdownOptions, [autoFocus$]: params?.autoFocus, [placeholder$]: params?.placeholder, - [readOnly$]: params?.readOnly + [readOnly$]: params?.readOnly, + [translation$]: params?.translation }) // Use the JSX extension to parse HTML @@ -900,3 +908,7 @@ export const corePlugin = realmPlugin<{ realm.singletonSub(markdownErrorSignal$, params?.onError) } }) + +export function useTranslation() { + return useCellValue(translation$) +} diff --git a/src/plugins/frontmatter/FrontmatterEditor.tsx b/src/plugins/frontmatter/FrontmatterEditor.tsx index 02b7dc4f..ca959ac2 100644 --- a/src/plugins/frontmatter/FrontmatterEditor.tsx +++ b/src/plugins/frontmatter/FrontmatterEditor.tsx @@ -5,7 +5,7 @@ import React from 'react' import { useFieldArray, useForm } from 'react-hook-form' import { frontmatterDialogOpen$, removeFrontmatter$ } from '.' import styles from '../../styles/ui.module.css' -import { editorRootElementRef$, iconComponentFor$, readOnly$ } from '../core' +import { editorRootElementRef$, iconComponentFor$, readOnly$, useTranslation } from '../core' import { useCellValues, usePublisher } from '@mdxeditor/gurx' type YamlConfig = { key: string; value: string }[] @@ -22,6 +22,7 @@ export const FrontmatterEditor = ({ yaml, onChange }: FrontmatterEditorProps) => iconComponentFor$, frontmatterDialogOpen$ ) + const t = useTranslation() const setFrontmatterDialogOpen = usePublisher(frontmatterDialogOpen$) const removeFrontmatter = usePublisher(removeFrontmatter$) const yamlConfig = React.useMemo(() => { @@ -70,7 +71,7 @@ export const FrontmatterEditor = ({ yaml, onChange }: FrontmatterEditorProps) => - Edit document frontmatter + {t('frontmatterEditor.title', 'Edit document frontmatter')}
{ void handleSubmit(onSubmit)(e) @@ -89,8 +90,8 @@ export const FrontmatterEditor = ({ yaml, onChange }: FrontmatterEditorProps) => - Key - Value + {t('frontmatterEditor.key', 'Key')} + {t('frontmatterEditor.value', 'Value')} @@ -124,7 +125,7 @@ export const FrontmatterEditor = ({ yaml, onChange }: FrontmatterEditorProps) => append({ key: '', value: '' }) }} > - Add entry + {t('frontmatterEditor.addEntry', 'Add entry')} @@ -132,15 +133,15 @@ export const FrontmatterEditor = ({ yaml, onChange }: FrontmatterEditorProps) =>
- diff --git a/src/plugins/image/ImageDialog.tsx b/src/plugins/image/ImageDialog.tsx index f5ad5978..00c46370 100644 --- a/src/plugins/image/ImageDialog.tsx +++ b/src/plugins/image/ImageDialog.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames' import React from 'react' import { useForm } from 'react-hook-form' import styles from '../../styles/ui.module.css' -import { editorRootElementRef$ } from '../core/index' +import { editorRootElementRef$, useTranslation } from '../core/index' import { closeImageDialog$, imageAutocompleteSuggestions$, imageDialogState$, saveImage$ } from './index' import { DownshiftAutoComplete } from '../core/ui/DownshiftAutoComplete' import { useCellValues, usePublisher } from '@mdxeditor/gurx' @@ -23,6 +23,7 @@ export const ImageDialog: React.FC = () => { ) const saveImage = usePublisher(saveImage$) const closeImageDialog = usePublisher(closeImageDialog$) + const t = useTranslation() const { register, handleSubmit, control, setValue, reset } = useForm({ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -57,12 +58,12 @@ export const ImageDialog: React.FC = () => { className={styles.multiFieldForm} >
- +
- + { suggestions={imageAutocompleteSuggestions} setValue={setValue} control={control} - placeholder="Select or paste an image src" + placeholder={t('uploadImage.autoCompletePlaceholder', 'Select or paste an image src')} />
- +
- +
- -
diff --git a/src/plugins/image/ImageEditor.tsx b/src/plugins/image/ImageEditor.tsx index 125baec5..5031d9e0 100644 --- a/src/plugins/image/ImageEditor.tsx +++ b/src/plugins/image/ImageEditor.tsx @@ -22,7 +22,7 @@ import { } from 'lexical' import { disableImageResize$, imagePreviewHandler$, openEditImageDialog$ } from '.' import styles from '../../styles/ui.module.css' -import { iconComponentFor$, readOnly$ } from '../core' +import { iconComponentFor$, readOnly$, useTranslation } from '../core' import { $isImageNode } from './ImageNode' import ImageResizer from './ImageResizer' import { useCellValues, usePublisher } from '@mdxeditor/gurx' @@ -101,6 +101,7 @@ export function ImageEditor({ src, title, alt, nodeKey, width, height }: ImageEd const [isResizing, setIsResizing] = React.useState(false) const [imageSource, setImageSource] = React.useState(null) const [initialImagePath, setInitialImagePath] = React.useState(null) + const t = useTranslation() const onDelete = React.useCallback( (payload: KeyboardEvent) => { @@ -269,7 +270,7 @@ export function ImageEditor({ src, title, alt, nodeKey, width, height }: ImageEd - @@ -131,6 +142,8 @@ export const LinkDialog: React.FC = () => { const [copyUrlTooltipOpen, setCopyUrlTooltipOpen] = React.useState(false) + const t = useTranslation() + const theRect = linkDialogState?.rectangle const urlIsExternal = linkDialogState.type === 'preview' && linkDialogState.url.startsWith('http') @@ -177,21 +190,27 @@ export const LinkDialog: React.FC = () => { onClickLinkCallback(linkDialogState.url) } }} - title={urlIsExternal ? `Open ${linkDialogState.url} in new window` : linkDialogState.url} + title={ + urlIsExternal ? t('linkPreview.open', `Open {{url}} in new window`, { url: linkDialogState.url }) : linkDialogState.url + } > {linkDialogState.url} {urlIsExternal && iconComponentFor('open_in_new')} - switchFromPreviewToLinkEdit()} title="Edit link URL" aria-label="Edit link URL"> + switchFromPreviewToLinkEdit()} + title={t('linkPreview.edit', 'Edit link URL')} + aria-label={t('linkPreview.edit', 'Edit link URL')} + > {iconComponentFor('edit')} { void window.navigator.clipboard.writeText(linkDialogState.url).then(() => { setCopyUrlTooltipOpen(true) @@ -204,14 +223,18 @@ export const LinkDialog: React.FC = () => { - Copied! + {t('linkPreview.copied', 'Copied!')} - removeLink()}> + removeLink()} + > {iconComponentFor('link_off')} diff --git a/src/plugins/table/TableEditor.tsx b/src/plugins/table/TableEditor.tsx index 82c13a2a..1f7b8c3f 100644 --- a/src/plugins/table/TableEditor.tsx +++ b/src/plugins/table/TableEditor.tsx @@ -41,6 +41,7 @@ import { jsxIsAvailable$, readOnly$, rootEditor$, + useTranslation, usedLexicalNodes$ } from '../core' import { useCellValues } from '@mdxeditor/gurx' @@ -167,6 +168,8 @@ export const TableEditor: React.FC = ({ mdastNode, parentEdito setHighlightedCoordinates([colIndex, rowIndex]) }, []) + const t = useTranslation() + // remove tool cols in readOnly mode return ( setHighlightedCoordinates([-1, -1])}> @@ -190,7 +193,7 @@ export const TableEditor: React.FC = ({ mdastNode, parentEdito