diff --git a/.gitignore b/.gitignore index ab103c71..18de97e3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ /docs/website/build npm-debug.log* .history +src/lib/forms/locales diff --git a/package-lock.json b/package-lock.json index 161037d8..131a494d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "devDependencies": { "@babel/cli": "^7.5.0", + "@hugerte/hugerte-react": "^2.0.2", "@inveniosoftware/eslint-config-invenio": "^2.0.0", "@rollup/plugin-babel": "^5.0.0", "@rollup/plugin-commonjs": "^11.1.0", @@ -18,7 +19,6 @@ "@testing-library/jest-dom": "^4.2.0", "@testing-library/react": "^9.5.0", "@testing-library/user-event": "^7.2.0", - "@tinymce/tinymce-react": "^4.3.0", "ajv": "^8.0.0", "ajv-keywords": "^5.0.0", "axios": "^1.7.7", @@ -27,6 +27,7 @@ "enzyme-adapter-react-16": "^1.15.0", "expect": "^26.0.0", "formik": "^2.1.0", + "hugerte": "^1.0.10", "json": "^10.0.0", "lodash": "^4.17.0", "luxon": "^1.23.0", @@ -42,7 +43,7 @@ "rollup-plugin-url": "^3.0.0", "semantic-ui-css": "^2.4.0", "semantic-ui-react": "^2.1.0", - "tinymce": "^6.7.2", + "tinymce-i18n": "^26.5.18", "typescript": "^4.9.5", "yup": "^0.32.11" }, @@ -51,10 +52,11 @@ }, "peerDependencies": { "@babel/runtime": "^7.26.10", + "@hugerte/hugerte-react": "^2.0.2", "@semantic-ui-react/css-patch": "^1.0.0", - "@tinymce/tinymce-react": "^4.3.0", "axios": "^1.8.2", "formik": "^2.1.0", + "hugerte": "^1.0.10", "lodash": "^4.17.0", "luxon": "^1.23.0", "query-string": "^7.0.0", @@ -63,7 +65,6 @@ "react-overridable": "^1.0.0", "semantic-ui-css": "^2.4.0", "semantic-ui-react": "^2.1.0", - "tinymce": "^6.7.2", "yup": "^0.32.11" } }, @@ -2689,6 +2690,34 @@ "react-dom": "^16.8.0 || ^17 || ^18" } }, + "node_modules/@hugerte/framework-integration-shared": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@hugerte/framework-integration-shared/-/framework-integration-shared-1.2.0.tgz", + "integrity": "sha512-ogcvVO0dFcb/HQAY7itj8xnmeMcHZmtMaOj9uC34ZYopnvj12TGWnfRQwbt8jZMlmoZk2z1TTLmJgvQBuH013A==", + "dev": true, + "peerDependencies": { + "hugerte": "^1.0.4" + } + }, + "node_modules/@hugerte/hugerte-react": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@hugerte/hugerte-react/-/hugerte-react-2.0.2.tgz", + "integrity": "sha512-vIVEX2mpxX/Vl9pvzhOd4bKEktS2sSOrd8smS6lIqgqETsuWTfLbjvXWni99i2a+VEMfWvK+nLqP8AmYhcELBQ==", + "dev": true, + "dependencies": { + "@hugerte/framework-integration-shared": "^1.1.0" + }, + "peerDependencies": { + "hugerte": "^1.0.6", + "react": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0", + "react-dom": "^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0" + }, + "peerDependenciesMeta": { + "hugerte": { + "optional": true + } + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -4896,21 +4925,6 @@ "@testing-library/dom": ">=5" } }, - "node_modules/@tinymce/tinymce-react": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tinymce/tinymce-react/-/tinymce-react-4.3.2.tgz", - "integrity": "sha512-wJHZhPf2Mk3yTtdVC/uIGh+kvDgKuTw/qV13uzdChTNo68JI1l7jYMrSQOpyimDyn5LHAw0E1zFByrm1WHAVeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "prop-types": "^15.6.2", - "tinymce": "^6.0.0 || ^5.5.1" - }, - "peerDependencies": { - "react": "^18.0.0 || ^17.0.1 || ^16.7.0", - "react-dom": "^18.0.0 || ^17.0.1 || ^16.7.0" - } - }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -12201,6 +12215,16 @@ "node": ">= 6" } }, + "node_modules/hugerte": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/hugerte/-/hugerte-1.0.10.tgz", + "integrity": "sha512-Uk7drnB4mqJUuXCtgDLDmBNSHNp7CH5juM3MGXE8HVJZwySW77xcdRaPss23vzneXY+dDoRABQcuFsBoa6s4CQ==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/hugerte" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -22787,12 +22811,11 @@ "dev": true, "license": "MIT" }, - "node_modules/tinymce": { - "version": "6.8.6", - "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-6.8.6.tgz", - "integrity": "sha512-++XYEs8lKWvZxDCjrr8Baiw7KiikraZ5JkLMg6EdnUVNKJui0IsrAADj5MsyUeFkcEryfn2jd3p09H7REvewyg==", - "dev": true, - "license": "MIT" + "node_modules/tinymce-i18n": { + "version": "26.5.18", + "resolved": "https://registry.npmjs.org/tinymce-i18n/-/tinymce-i18n-26.5.18.tgz", + "integrity": "sha512-FVoXajqDbh1CC6fGbS7zlYtHKCA0Tv2+QugvTHOdAtgnAUVP0LEEEZIIyIf3qpy3PckuynYPnRrYnKw6IQxO5g==", + "dev": true }, "node_modules/tmpl": { "version": "1.0.5", diff --git a/package.json b/package.json index b869ca4b..1d79c223 100644 --- a/package.json +++ b/package.json @@ -9,14 +9,15 @@ "dist" ], "scripts": { - "start": "react-scripts start", - "build": "rimraf dist && NODE_ENV=production rollup -c --failAfterWarnings --inlineDynamicImports", + "prepare": "node scripts/patch-hugerte-langs.js", + "start": "npm run prepare && react-scripts start", + "build": "npm run prepare && rimraf dist && NODE_ENV=production rollup -c --failAfterWarnings --inlineDynamicImports && node scripts/copy-hugerte-langs.js", "edit-linked-package": "json -I -f ./dist/package.json -e 'this.module=\"esm/index.js\", this.main=\"cjs/index.js\", this.browser=\"cjs/index.js\"' ", "prelink-dist": "cp package.json ./dist && npm run edit-linked-package", "link-dist": "cd dist && npm link", "postlink-dist": "cd dist && rm -rf node_modules", "unlink-dist": "cd dist && npm unlink && rm package*", - "watch": "NODE_ENV=development rollup --watch --inlineDynamicImports -c", + "watch": "npm run prepare && NODE_ENV=development rollup --watch --inlineDynamicImports -c", "test": "react-scripts test", "eject": "react-scripts eject", "lint": "eslint src/ --ext .js", @@ -24,23 +25,24 @@ }, "peerDependencies": { "@babel/runtime": "^7.26.10", + "@hugerte/hugerte-react": "^2.0.2", "@semantic-ui-react/css-patch": "^1.0.0", - "@tinymce/tinymce-react": "^4.3.0", "axios": "^1.8.2", "formik": "^2.1.0", + "hugerte": "^1.0.10", "lodash": "^4.17.0", "luxon": "^1.23.0", "query-string": "^7.0.0", "react": "^16.13.0", "react-dom": "^16.13.0", + "react-overridable": "^1.0.0", "semantic-ui-css": "^2.4.0", "semantic-ui-react": "^2.1.0", - "tinymce": "^6.7.2", - "react-overridable": "^1.0.0", "yup": "^0.32.11" }, "devDependencies": { "@babel/cli": "^7.5.0", + "@hugerte/hugerte-react": "^2.0.2", "@inveniosoftware/eslint-config-invenio": "^2.0.0", "@rollup/plugin-babel": "^5.0.0", "@rollup/plugin-commonjs": "^11.1.0", @@ -49,7 +51,6 @@ "@testing-library/jest-dom": "^4.2.0", "@testing-library/react": "^9.5.0", "@testing-library/user-event": "^7.2.0", - "@tinymce/tinymce-react": "^4.3.0", "ajv": "^8.0.0", "ajv-keywords": "^5.0.0", "axios": "^1.7.7", @@ -58,6 +59,7 @@ "enzyme-adapter-react-16": "^1.15.0", "expect": "^26.0.0", "formik": "^2.1.0", + "hugerte": "^1.0.10", "json": "^10.0.0", "lodash": "^4.17.0", "luxon": "^1.23.0", @@ -73,7 +75,7 @@ "rollup-plugin-url": "^3.0.0", "semantic-ui-css": "^2.4.0", "semantic-ui-react": "^2.1.0", - "tinymce": "^6.7.2", + "tinymce-i18n": "^26.5.18", "typescript": "^4.9.5", "yup": "^0.32.11" }, diff --git a/scripts/copy-hugerte-langs.js b/scripts/copy-hugerte-langs.js new file mode 100644 index 00000000..a89344c4 --- /dev/null +++ b/scripts/copy-hugerte-langs.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +/** + * Build script to copy patched HugeRTE language packs into dist/hugerte-langs/. + * + * Invenio-supported locales (exact list from invenio-i18n/translations): + * ar, bg, ca, cs, da, de, el, en, es, et, fa, fi, fr, hr, hu, it, + * ja, ka, ko, lt, no, pl, pt, ro, ru, sk, sv, tr, uk, zh_CN, zh_TW + * + * Files are copied with their TinyMCE 6 filenames so RichEditor's mapping works. + * + * Source locale files are the ones already patched by `patch-hugerte-langs.js` + * (tinymce.addI18n -> window.hugerte.addI18n) so they work in bundled strict-mode. + */ + +const fs = require("fs"); +const path = require("path"); + +const LOCALES_DIR = path.resolve(__dirname, "../src/lib/forms/locales"); +const OUT_DIR = path.resolve(__dirname, "../dist/hugerte-langs"); + +const invenioLocaleMap = { + ar: "ar", + bg: "bg_BG", + ca: "ca", + cs: "cs", + da: "da", + de: "de", + el: "el", + en: null, // English is the default; no pack needed. + es: "es", + et: "et", + fa: "fa", + fi: "fi", + fr: "fr_FR", + hr: "hr", + hu: "hu_HU", + it: "it", + ja: "ja", + ka: "ka_GE", + ko: "ko_KR", + lt: "lt", + no: "nb_NO", + pl: "pl", + pt: "pt_BR", + ro: "ro", + ru: "ru", + sk: "sk", + sv: "sv_SE", + tr: "tr", + uk: "uk", + zh_CN: "zh-Hans", + zh_TW: "zh-Hant", +}; + +function main() { + if (!fs.existsSync(LOCALES_DIR)) { + console.error(`patched locales not found at ${LOCALES_DIR}; run 'npm run prepare' first`); + process.exit(1); + } + + fs.mkdirSync(OUT_DIR, { recursive: true }); + + for (const [invenioCode, tinymceCode] of Object.entries(invenioLocaleMap)) { + if (!tinymceCode) continue; + + const src = path.join(LOCALES_DIR, `${tinymceCode}.js`); + const dest = path.join(OUT_DIR, `${tinymceCode}.js`); + + if (!fs.existsSync(src)) { + console.warn(`Missing language pack: ${src} (Invenio: ${invenioCode}, TinyMCE: ${tinymceCode})`); + continue; + } + + fs.copyFileSync(src, dest); + console.log(`Copied ${tinymceCode}.js`); + } + + console.log("Done."); +} + +main(); diff --git a/scripts/patch-hugerte-langs.js b/scripts/patch-hugerte-langs.js new file mode 100644 index 00000000..73119235 --- /dev/null +++ b/scripts/patch-hugerte-langs.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +const fs = require("fs"); +const path = require("path"); + +const SRC_DIR = path.resolve(__dirname, "../node_modules/tinymce-i18n/langs6"); +const OUT_DIR = path.resolve(__dirname, "../src/lib/forms/locales"); + +const locales = [ + "ar", "bg_BG", "ca", "cs", "da", "de", "el", "es", "et", "fa", "fi", + "fr_FR", "hr", "hu_HU", "it", "ja", "ka_GE", "ko_KR", "lt", "nb_NO", + "pl", "pt_BR", "ro", "ru", "sk", "sv_SE", "tr", "uk", "zh-Hans", "zh-Hant", +]; + +fs.mkdirSync(OUT_DIR, { recursive: true }); + +for (const code of locales) { + const src = path.join(SRC_DIR, `${code}.js`); + const dest = path.join(OUT_DIR, `${code}.js`); + if (!fs.existsSync(src)) { + console.warn(`missing ${src}`); + continue; + } + let text = fs.readFileSync(src, "utf-8"); + // HugeRTE docs: replace `tinymce` variable by `hugerte`. + // Use `window.hugerte` so it works in bundled strict-mode output. + text = text.replace(/\btinymce\.addI18n\(/g, "window.hugerte.addI18n("); + fs.writeFileSync(dest, text); + console.log(`patched ${code}.js`); +} + +console.log("done."); diff --git a/src/lib/forms/RichEditor.js b/src/lib/forms/RichEditor.js index 64d7d459..80919d98 100644 --- a/src/lib/forms/RichEditor.js +++ b/src/lib/forms/RichEditor.js @@ -6,30 +6,81 @@ // React-Invenio-Forms is free software; you can redistribute it and/or modify it // under the terms of the MIT License; see LICENSE file for more details. import React, { Component } from "react"; -import { Editor } from "@tinymce/tinymce-react"; -import "tinymce/tinymce"; -import "tinymce/models/dom/model"; -import "tinymce/themes/silver"; -import "tinymce/icons/default"; -import "tinymce/plugins/table"; -import "tinymce/plugins/autoresize"; -import "tinymce/plugins/code"; -import "tinymce/plugins/codesample"; -import "tinymce/plugins/image"; -import "tinymce/plugins/link"; -import "tinymce/plugins/lists"; -import "tinymce/plugins/wordcount"; -import "tinymce/plugins/preview"; +import { Editor } from "@hugerte/hugerte-react"; +import "hugerte/hugerte"; +import "hugerte/models/dom/model"; +import "hugerte/themes/silver"; +import "hugerte/icons/default"; +import "hugerte/plugins/table"; +import "hugerte/plugins/autoresize"; +import "hugerte/plugins/code"; +import "hugerte/plugins/codesample"; +import "hugerte/plugins/image"; +import "hugerte/plugins/link"; +import "hugerte/plugins/lists"; +import "hugerte/plugins/wordcount"; +import "hugerte/plugins/preview"; import PropTypes from "prop-types"; import { Button, Message } from "semantic-ui-react"; import { FilesList } from "./FilesList"; // Make content inside the editor look identical to how we will render it across the site. -// TinyMCE runs within an iframe, so we cannot style it with page-wide CSS styles as normal. +// HugeRTE runs within an iframe, so we cannot style it with page-wide CSS styles as normal. // -// TinyMCE overrides blockquotes with custom styles, so we need to use !important to override +// Maps Invenio locale codes to TinyMCE 6 language pack codes. +// Source: tinymce-i18n/langs6, verified against invenio-i18n/translations. +const localeMap = { + bg: "bg_BG", + fr: "fr_FR", + hu: "hu_HU", + ka: "ka_GE", + ko: "ko_KR", + no: "nb_NO", + pt: "pt_BR", + sv: "sv_SE", + zh_CN: "zh-Hans", + zh_TW: "zh-Hant", +}; + +const mapInvenioLocale = (locale) => { + return localeMap[locale] || locale; +}; + +const localeImports = { + ar: () => import("./locales/ar"), + bg_BG: () => import("./locales/bg_BG"), + ca: () => import("./locales/ca"), + cs: () => import("./locales/cs"), + da: () => import("./locales/da"), + de: () => import("./locales/de"), + el: () => import("./locales/el"), + es: () => import("./locales/es"), + et: () => import("./locales/et"), + fa: () => import("./locales/fa"), + fi: () => import("./locales/fi"), + fr_FR: () => import("./locales/fr_FR"), + hr: () => import("./locales/hr"), + hu_HU: () => import("./locales/hu_HU"), + it: () => import("./locales/it"), + ja: () => import("./locales/ja"), + ka_GE: () => import("./locales/ka_GE"), + ko_KR: () => import("./locales/ko_KR"), + lt: () => import("./locales/lt"), + nb_NO: () => import("./locales/nb_NO"), + pl: () => import("./locales/pl"), + pt_BR: () => import("./locales/pt_BR"), + ro: () => import("./locales/ro"), + ru: () => import("./locales/ru"), + sk: () => import("./locales/sk"), + sv_SE: () => import("./locales/sv_SE"), + tr: () => import("./locales/tr"), + uk: () => import("./locales/uk"), + "zh-Hans": () => import("./locales/zh-Hans"), + "zh-Hant": () => import("./locales/zh-Hant"), +}; + +// HugeRTE overrides blockquotes with custom styles, so we need to use !important to override // the overrides in a consistent and reliable way. -// https://github.com/tinymce/tinymce-dist/blob/8d7491f2ee341c201b68cc7c3701d54703edd474/skins/content/tinymce-5/content.css#L61-L70 const editorContentStyle = (disabled) => ` body { font-size: 14px; @@ -62,14 +113,68 @@ export class RichEditor extends Component { constructor(props) { super(props); + const htmlLang = + typeof document !== "undefined" ? document.documentElement?.getAttribute("lang") : undefined; + const effectiveLocale = props.locale || htmlLang; + const mapped = mapInvenioLocale(effectiveLocale); this.state = { fileErrors: [], + detectedLocale: htmlLang || undefined, + localeLoaded: !mapped || mapped === "en", }; this.editorRef = React.createRef(); this.editorDialogRef = React.createRef(); } + loadLocale = (locale) => { + const mapped = mapInvenioLocale(locale); + if (!mapped || mapped === "en") { + this.setState({ localeLoaded: true }); + return; + } + const importFn = localeImports[mapped]; + if (!importFn) { + this.setState({ localeLoaded: true }); + return; + } + this.setState({ localeLoaded: false }); + importFn() + .then(() => this.setState({ localeLoaded: true })) + .catch(() => this.setState({ localeLoaded: true })); + }; + + componentDidUpdate(prevProps, prevState) { + const prevLocale = mapInvenioLocale(prevProps.locale || prevState.detectedLocale); + const nextLocale = mapInvenioLocale(this.props.locale || this.state.detectedLocale); + if (prevLocale !== nextLocale) { + this.loadLocale(this.props.locale || this.state.detectedLocale); + } + } + + componentDidMount() { + if (typeof document === "undefined") return; + const htmlEl = document.documentElement; + this._langObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.attributeName === "lang") { + const newLang = htmlEl.getAttribute("lang"); + if (newLang !== this.state.detectedLocale) { + this.setState({ detectedLocale: newLang || undefined }); + } + } + } + }); + this._langObserver.observe(htmlEl, { attributes: true }); + this.loadLocale(this.props.locale || this.state.detectedLocale); + } + + componentWillUnmount() { + if (this._langObserver) { + this._langObserver.disconnect(); + } + } + addToFileErrors = (filename, error) => { this.setState((prevState) => ({ fileErrors: [ @@ -304,8 +409,11 @@ export class RichEditor extends Component { onEditorChange, files, onInit, + locale, } = this.props; - const { fileErrors } = this.state; + const { fileErrors, detectedLocale, localeLoaded } = this.state; + const effectiveLocale = locale || detectedLocale; + const mappedLocale = mapInvenioLocale(effectiveLocale); const attachFilesEnabled = files !== undefined; let config = { branding: false, @@ -332,6 +440,8 @@ export class RichEditor extends Component { block_formats: "Paragraph=p; Header 1=h1; Header 2=h2; Header 3=h3", table_advtab: false, convert_urls: false, + base_url: "/static/dist/js/", + language: mappedLocale, setup: (editor) => { this.registerCustomPreviewButton(editor); if (attachFilesEnabled) { @@ -373,6 +483,7 @@ export class RichEditor extends Component { return ( <> value} // () => To avoid re-rendering + inputValue={value} optimized={optimized} editorConfig={editorConfig} + locale={locale} onBlur={(event, editor) => { formikBag.form.setFieldValue(fieldPath, editor.getContent()); formikBag.form.setFieldTouched(fieldPath, true); @@ -89,6 +91,7 @@ RichInputField.propTypes = { editorConfig: PropTypes.object, disabled: PropTypes.bool, helpText: PropTypes.string, + locale: PropTypes.string, }; RichInputField.defaultProps = { @@ -100,4 +103,5 @@ RichInputField.defaultProps = { editorConfig: undefined, disabled: false, helpText: undefined, + locale: undefined, }; diff --git a/src/lib/forms/widgets/text/RichInput.js b/src/lib/forms/widgets/text/RichInput.js index 83bcbc7d..239bc2a4 100644 --- a/src/lib/forms/widgets/text/RichInput.js +++ b/src/lib/forms/widgets/text/RichInput.js @@ -20,6 +20,7 @@ class RichInputComponent extends Component { helpText: helpTextProp, labelIcon: labelIconProp, optimized, + locale, } = this.props; const helpText = helpTextProp ?? description; @@ -33,6 +34,7 @@ class RichInputComponent extends Component { required={required} disabled={disabled} editorConfig={editorConfig} + locale={locale} label={} optimized={optimized} /> @@ -53,6 +55,7 @@ RichInputComponent.propTypes = { */ description: PropTypes.string.isRequired, optimized: PropTypes.bool, + locale: PropTypes.string, ...fieldCommonProps, }; @@ -60,6 +63,7 @@ RichInputComponent.defaultProps = { icon: undefined, editorConfig: {}, optimized: true, + locale: undefined, }; export const RichInput = showHideOverridableWithDynamicId(RichInputComponent);