Skip to content

Commit

Permalink
Merge pull request #1725 from vivid-planet/feature/translation-module
Browse files Browse the repository at this point in the history
[Feature] Content translation
  • Loading branch information
thomasdax98 authored Feb 20, 2024
2 parents 693cbdb + 74e0fc2 commit 45295a5
Show file tree
Hide file tree
Showing 17 changed files with 1,274 additions and 493 deletions.
31 changes: 31 additions & 0 deletions .changeset/rare-shiny-bulbasaur.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@comet/admin": minor
---

Add basis for content translation

Wrap a component with a `ContentTranslationServiceProvider` to add support for content translation to all underlying `FinalFormInput` inputs.

```tsx
<ContentTranslationServiceProvider
enabled={true}
translate={async function (text: string): Promise<string> {
return yourTranslationFnc(text);
}}
>
...
</ContentTranslationServiceProvider>
```

You can disable translation for a specific `FinalFormInput` by using the `disableContentTranslation` prop.

```diff
<Field
required
fullWidth
name="myField"
component={FinalFormInput}
label={<FormattedMessage id="myField" defaultMessage="My Field" />}
+ disableContentTranslation
/>
```
5 changes: 5 additions & 0 deletions .changeset/seven-sailor-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@comet/admin-rte": minor
---

Add support for content translation
7 changes: 7 additions & 0 deletions packages/admin/admin-rte/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
reporters: ["default", "jest-junit"],
rootDir: "./src",
};
15 changes: 14 additions & 1 deletion packages/admin/admin-rte/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,16 @@
"lint:tsc": "tsc --noEmit",
"start": "run-p start:babel start:types",
"start:babel": "npx babel ./src -x \".ts,.tsx\" -d lib -w",
"start:types": "tsc --project ./tsconfig.json --emitDeclarationOnly --watch --preserveWatchOutput"
"start:types": "tsc --project ./tsconfig.json --emitDeclarationOnly --watch --preserveWatchOutput",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@comet/admin": "workspace:^6.0.0",
"@comet/admin-icons": "workspace:^6.0.0",
"detect-browser": "^5.2.1",
"draft-js-export-html": "^1.4.1",
"draft-js-import-html": "^1.4.1",
"draftjs-conductor": "^3.0.0",
"immutable": "~3.7.4"
},
Expand All @@ -38,20 +43,28 @@
"@mui/icons-material": "^5.0.0",
"@mui/material": "^5.0.0",
"@mui/styles": "^5.0.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^12.0.0",
"@types/draft-js": "^0.11.10",
"@types/immutable": "^3.8.7",
"@types/jest": "^29.5.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/uuid": "^9.0.2",
"draft-js": "^0.11.4",
"eslint": "^8.0.0",
"final-form": "^4.16.1",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"jest-junit": "^15.0.0",
"npm-run-all": "^4.1.5",
"prettier": "^2.0.0",
"react": "^17.0",
"react-dom": "^17.0",
"react-final-form": "^6.3.1",
"react-intl": "^5.10.0",
"rimraf": "^3.0.2",
"ts-jest": "^29.0.0",
"typescript": "^4.0.0"
},
"peerDependencies": {
Expand Down
2 changes: 2 additions & 0 deletions packages/admin/admin-rte/src/core/Controls/Controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import ListsControls from "./ListsControls";
import ListsIndentControls from "./ListsIndentControls";
import SpecialCharactersControls from "./SpecialCharactersControls";
import Toolbar from "./Toolbar";
import TranslationControls from "./TranslationControls";

export default function Controls(p: IControlProps) {
const {
Expand All @@ -22,6 +23,7 @@ export default function Controls(p: IControlProps) {
{[
HistoryControls,
BlockTypesControls,
TranslationControls,
InlineStyleTypeControls,
ListsControls,
ListsIndentControls,
Expand Down
21 changes: 21 additions & 0 deletions packages/admin/admin-rte/src/core/Controls/TranslationControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useContentTranslationService } from "@comet/admin";
import { ButtonGroup } from "@mui/material";
import * as React from "react";

import TranslationToolbarButton from "../translation/ToolbarButton";
import { IControlProps } from "../types";

function TranslationControls(props: IControlProps) {
const translationContext = useContentTranslationService();

if (translationContext.enabled) {
return (
<ButtonGroup>
<TranslationToolbarButton {...props} />
</ButtonGroup>
);
}
return null;
}

export default TranslationControls;
37 changes: 37 additions & 0 deletions packages/admin/admin-rte/src/core/translation/ToolbarButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Tooltip, useContentTranslationService } from "@comet/admin";
import { Translate } from "@comet/admin-icons";
import * as React from "react";
import { FormattedMessage } from "react-intl";

import ControlButton from "../Controls/ControlButton";
import { IControlProps } from "../types";
import { htmlToState } from "./htmlToState";
import { stateToHtml } from "./stateToHtml";

function ToolbarButton({ editorState, setEditorState, options }: IControlProps): React.ReactElement {
const translationContext = useContentTranslationService();

async function handleClick(event: React.MouseEvent) {
if (!translationContext) return;

event.preventDefault();

const { html, entities } = stateToHtml({ editorState, options });

const translation = await translationContext.translate(html);

const translatedEditorState = htmlToState({ html: translation, entities });

setEditorState(translatedEditorState);
}

return (
<Tooltip title={<FormattedMessage id="comet.rte.translation.buttonTooltip" defaultMessage="Translate" />} placement="top">
<span>
<ControlButton icon={Translate} onButtonClick={handleClick} />
</span>
</Tooltip>
);
}

export default ToolbarButton;
176 changes: 176 additions & 0 deletions packages/admin/admin-rte/src/core/translation/htmlToState.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* @jest-environment jsdom
*/

import { convertFromRaw, EditorState, RawDraftContentState } from "draft-js";

import { IRteOptions } from "../Rte";
import { htmlToState } from "./htmlToState";
import { stateToHtml } from "./stateToHtml";

describe("htmlToState", () => {
const options = { customInlineStyles: { HIGHLIGHT: { label: "Highlight!", style: { backgroundColor: "yellow" } } } } as unknown as IRteOptions;

it("should convert html to state to html with the html staying the same", () => {
const blocks = [
// Basic stylings
{ key: "52cmg", text: "Normal Text", type: "unstyled", depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} },
{
key: "8psic",
text: "Bold Text",
type: "unstyled",
depth: 0,
inlineStyleRanges: [{ offset: 0, length: 9, style: "BOLD" }],
entityRanges: [],
data: {},
},
{
key: "4m6ou",
text: "Italic Text",
type: "unstyled",
depth: 0,
inlineStyleRanges: [{ offset: 0, length: 11, style: "ITALIC" }],
entityRanges: [],
data: {},
},
{
key: "fask6",
text: "Bold Italic Text",
type: "unstyled",
depth: 0,
inlineStyleRanges: [
{ offset: 0, length: 16, style: "ITALIC" },
{ offset: 0, length: 16, style: "BOLD" },
],
entityRanges: [],
data: {},
},
{
key: "fm23u",
text: "Strikethrough Text",
type: "unstyled",
depth: 0,
inlineStyleRanges: [{ offset: 0, length: 18, style: "STRIKETHROUGH" }],
entityRanges: [],
data: {},
},
{
key: "9q8m5",
text: "A Subscript Text",
type: "unstyled",
depth: 0,
inlineStyleRanges: [{ offset: 2, length: 14, style: "SUB" }],
entityRanges: [],
data: {},
},
{
key: "t3nk",
text: "B Superscript Text",
type: "unstyled",
depth: 0,
inlineStyleRanges: [{ offset: 2, length: 16, style: "SUP" }],
entityRanges: [],
data: {},
},
{ key: "e6k04", text: "Headline 1", type: "header-one", depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} },
{ key: "ect4f", text: "Headline 2", type: "header-two", depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} },
{ key: "e038j", text: "Headline 3", type: "header-three", depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} },
{ key: "4bha8", text: "Headline 4", type: "header-four", depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} },
{ key: "aje6k", text: "Headline 5", type: "header-five", depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} },
{ key: "7u6on", text: "Headline 6", type: "header-six", depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} },
// Unordered List
{ key: "a9t3", text: "Unordered List", type: "unordered-list-item", depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} },
{
key: "f4o2c",
text: "123456",
type: "unordered-list-item",
depth: 1,
inlineStyleRanges: [{ offset: 3, length: 3, style: "SUB" }],
entityRanges: [],
data: {},
},
{
key: "7v61p",
text: "234",
type: "unordered-list-item",
depth: 2,
inlineStyleRanges: [{ offset: 0, length: 3, style: "ITALIC" }],
entityRanges: [],
data: {},
},
{
key: "1duir",
text: "345",
type: "unordered-list-item",
depth: 2,
inlineStyleRanges: [{ offset: 0, length: 3, style: "BOLD" }],
entityRanges: [],
data: {},
},
// Ordered List
{ key: "1iahs", text: "List", type: "ordered-list-item", depth: 0, inlineStyleRanges: [], entityRanges: [], data: {} },
{
key: "aqjhb",
text: "123456",
type: "ordered-list-item",
depth: 1,
inlineStyleRanges: [{ offset: 3, length: 3, style: "SUP" }],
entityRanges: [],
data: {},
},
{
key: "c4js6",
text: "234",
type: "ordered-list-item",
depth: 2,
inlineStyleRanges: [{ offset: 0, length: 3, style: "ITALIC" }],
entityRanges: [],
data: {},
},
{
key: "3qjfc",
text: "345",
type: "ordered-list-item",
depth: 2,
inlineStyleRanges: [{ offset: 0, length: 3, style: "BOLD" }],
entityRanges: [],
data: {},
},
// Custom Style
{
key: "7l333",
text: "A rte text with custom styling",
type: "unstyled",
depth: 0,
inlineStyleRanges: [{ offset: 0, length: 30, style: "HIGHLIGHT" }],
entityRanges: [],
data: {},
},
];
const rawContent = {
entityMap: {},
blocks,
} as RawDraftContentState;

const content = convertFromRaw(rawContent);
const editorState = EditorState.createWithContent(content);

const { html, entities } = stateToHtml({
editorState,
options,
});

const state = htmlToState({
html: html,
entities,
});

const { html: html2, entities: linkDataList2 } = stateToHtml({
editorState: state,
options,
});

expect(html).toEqual(html2);
expect(entities).toEqual(linkDataList2);
});
});
42 changes: 42 additions & 0 deletions packages/admin/admin-rte/src/core/translation/htmlToState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { convertFromRaw, convertToRaw, EditorState } from "draft-js";
import { stateFromHTML } from "draft-js-import-html";

export function htmlToState({
html,
entities,
}: {
html: string;
entities: {
id: string;
data: any;
}[];
}) {
const translatedContentState = stateFromHTML(html, {
customInlineFn: (element, { Style, Entity }) => {
if (element.tagName === "SUB") {
return Style("SUB");
}
if (element.tagName === "SUP") {
return Style("SUP");
}
if (element.tagName == "SPAN") {
return Style((element.attributes as any).class.value);
}
if (element.tagName === "A") {
return Entity("LINK", { id: (element.attributes as any).id.value });
}
},
});

const { entityMap, blocks } = convertToRaw(translatedContentState);

for (const key of Object.keys(entityMap)) {
if ("id" in entityMap[key].data) {
entityMap[key].data = entities.find((item) => item.id == entityMap[key].data.id)?.data;
}
}

const translatedContentStateWithLinkData = convertFromRaw({ entityMap, blocks });

return EditorState.createWithContent(translatedContentStateWithLinkData);
}
Loading

0 comments on commit 45295a5

Please sign in to comment.