Skip to content

Commit

Permalink
Add translation button to rte
Browse files Browse the repository at this point in the history
  • Loading branch information
VP-DS committed Jan 3, 2024
1 parent 4b2946f commit 74cd1be
Show file tree
Hide file tree
Showing 9 changed files with 540 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/super-car-drifto.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@comet/admin-rte": minor
---

Add translation button to rte if translations are enabled. The content is translated via extracting the the text from the editor state, translating it and then creating a new state while perserving the rte formating.
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
14 changes: 14 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,14 @@
import { useContentTranslationServiceProvider } 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 = useContentTranslationServiceProvider();

return <ButtonGroup>{translationContext && <TranslationToolbarButton {...props} />}</ButtonGroup>;
}

export default TranslationControls;
45 changes: 45 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,45 @@
import { useContentTranslationServiceProvider } from "@comet/admin";
import TranslateIcon from "@mui/icons-material/Translate";
import Tooltip from "@mui/material/Tooltip";
import { convertToRaw } from "draft-js";
import * as React from "react";
import { FormattedMessage } from "react-intl";

import ControlButton from "../Controls/ControlButton";
import { IControlProps } from "../types";
import { transformStateToXml } from "./xml/transformStateToXml";
import { translateAndTransformXmlToState } from "./xml/translateAndTransformToState";

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

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

const contentState = editorState.getCurrentContent();

const xml = transformStateToXml(contentState);

const translationPromises = xml.map(async (item) => ({
original: item,
replaceWith: (await translationContext.translate(item)) ?? item,
}));
const translations = await Promise.all(translationPromises);

const translatedState = translateAndTransformXmlToState(contentState, convertToRaw(contentState), translations);

setEditorState(translatedState);
}
}

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

export default ToolbarButton;
100 changes: 100 additions & 0 deletions packages/admin/admin-rte/src/core/translation/xml/getEntityRanges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { CharacterMetadata } from "draft-js";
import type { List } from "immutable";
import { is, OrderedSet } from "immutable";

type EntityKey = string | undefined | null;
type Style = OrderedSet<string>;
type StyleRangeWithId = [string, { style: string; id: number }[]];
type StyleRange = [string, Style];
type EntityRange = [EntityKey, Array<StyleRangeWithId>];
export type CharacterMetaList = List<CharacterMetadata>;

export const EMPTY_SET: Style = OrderedSet();

/*
This implementation is inspired by https://github.com/jpuri/draftjs-to-html.
*/
export default function getEntityRanges(text: string, charMetaList: CharacterMetaList): EntityRange[] {
let charEntity: EntityKey = null;
let prevCharEntity: EntityKey = null;
const ranges: Array<EntityRange> = [];
let rangeStart = 0;
let lastStyle = null;
// the id is used for the pseudotags
let styleId = 0;

for (let i = 0, len = text.length; i < len; i++) {
prevCharEntity = charEntity;
const meta: CharacterMetadata = charMetaList.get(i);
charEntity = meta ? meta.getEntity() : null;

if (i > 0 && charEntity !== prevCharEntity) {
/* Styles are always within entities */
const styleRanges = getStyleRanges(text.slice(rangeStart, i), charMetaList.slice(rangeStart, i), lastStyle, styleId);
styleId = styleRanges.styleId;
ranges.push([prevCharEntity, styleRanges.styleRanges]);
rangeStart = i;
lastStyle = ranges[ranges.length - 1];
}
}

ranges.push([charEntity, getStyleRanges(text.slice(rangeStart), charMetaList.slice(rangeStart), lastStyle, styleId).styleRanges]);

return ranges;
}

function getStyleRanges(
text: string,
charMetaList: Immutable.Iterable<number, CharacterMetadata>,
lastStyle: EntityRange | null,
styleId: number,
): { styleRanges: StyleRangeWithId[]; styleId: number } {
let charStyle = EMPTY_SET;
let prevCharStyle = charStyle;
const ranges: StyleRange[] = [];
let rangeStart = 0;

/* The start and end of an entity always mark a single range.
If a style range starts before an entity range and extends into it, the last style must be used here, otherwise it will be interpreted as a new style range. */
const lastPreviousStyleRange = lastStyle ? lastStyle[1][lastStyle[1].length - 1][1] : [];

for (let i = 0, len = text.length; i < len; i++) {
prevCharStyle = charStyle;
const meta = charMetaList.get(i);
charStyle = meta ? meta.getStyle() : EMPTY_SET;

if (i > 0 && !is(charStyle, prevCharStyle)) {
ranges.push([text.slice(rangeStart, i), prevCharStyle]);
rangeStart = i;
}
}
ranges.push([text.slice(rangeStart), charStyle]);

const styleRangesWithIds: [string, { style: string; id: number }[]][] = [];

// This adds ids to the styles to identify related styling tags in export
for (let i = 0; i < ranges.length; i++) {
const stylesArray = ranges[i][1].toArray();

const styles = stylesArray.map((style) => {
// when entity ranges are in the text, the text is split up at their positions, therefore it's needed to look at the previous style
const enduringStyle = lastPreviousStyleRange.find((item) => item.style === style);

if (enduringStyle && ranges[i - 1]?.[1].toArray().length !== 0) {
return { style, id: enduringStyle.id };
} else if (i > 0 && ranges[i - 1][1].toArray().includes(style)) {
const previousStyle = styleRangesWithIds[i - 1][1].find((previousStyle) => previousStyle.style === style);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return { style, id: previousStyle!.id };
}

styleId += 1;

return { style, id: styleId };
});

styleRangesWithIds.push([ranges[i][0], styles]);
}

return { styleRanges: styleRangesWithIds, styleId };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { CharacterMetadata, ContentBlock, ContentState } from "draft-js";
import type { List } from "immutable";

/*
This is a first, very basic implementation, inspired by https://github.com/sstur/draft-js-utils/blob/master/packages/draft-js-export-html/src/stateToHTML.js.
Images, code snippets, custom options as attributes, elements or styles are not taken into account.
*/
import getEntityRanges from "./getEntityRanges";

type CharacterMetaList = List<CharacterMetadata>;

export const INLINE_STYLE = {
BOLD: "BOLD",
ITALIC: "ITALIC",
STRIKETHROUGH: "STRIKETHROUGH",
UNDERLINE: "UNDERLINE",
SUB: "SUB",
SUP: "SUP",
};

export const ENTITY_TYPE = {
LINK: "LINK",
};

// Order: inner-most style to outer-most.
// Examle: <em><strong>foo</strong></em>
const DEFAULT_STYLE_ORDER = [
INLINE_STYLE.BOLD,
INLINE_STYLE.ITALIC,
INLINE_STYLE.UNDERLINE,
INLINE_STYLE.STRIKETHROUGH,
INLINE_STYLE.SUB,
INLINE_STYLE.SUP,
];

const DEFAULT_STYLE_MAP = {
[INLINE_STYLE.BOLD]: { element: "inline" },
[INLINE_STYLE.ITALIC]: { element: "inline" },
[INLINE_STYLE.STRIKETHROUGH]: { element: "inline" },
[INLINE_STYLE.UNDERLINE]: { element: "inline" },
[INLINE_STYLE.SUB]: { element: "inline" },
[INLINE_STYLE.SUP]: { element: "inline" },
};

/*
escapes special characters in the text content to their HTML/XML entities
*/
function encodeContent(text: string): string {
return text.split("&").join("&amp;").split("<").join("&lt;").split(">").join("&gt;").split("\xA0").join("&nbsp;").split("\n").join(`<br>\n`);
}

class MarkupGenerator {
blocks: ContentBlock[] = [];
contentState: ContentState | undefined;
output: string[] = [];
currentBlock = 0;
indentLevel = 0;
totalBlocks = 0;
inlineStyles = DEFAULT_STYLE_MAP;
styleOrder: string[] = DEFAULT_STYLE_ORDER;
indent = " ";
counter = 1;

constructor(contentState?: ContentState) {
this.contentState = contentState;
}

generate(): string[] {
if (!this.contentState) {
return [];
}

this.output = [];
this.blocks = this.contentState.getBlocksAsArray();
this.totalBlocks = this.blocks.length;
this.currentBlock = 0;

while (this.currentBlock < this.totalBlocks) {
this.processBlock(this.contentState);
}

return this.output.filter((content) => content !== "" && content !== "\n");
}

processBlock(contentState: ContentState) {
const block = this.blocks[this.currentBlock];

const content = this.renderBlockContent(block, contentState);

this.output.push(content);

this.currentBlock += 1;
this.output.push(`\n`);
}

renderBlockContent(block: ContentBlock, contentState: ContentState): string {
let text = block.getText();

let currentLinkId = 0;

if (text === "") {
return "";
}

text = this.preserveWhitespace(text);

// getting a list including all styles and entites for every single character
const charMetaList: CharacterMetaList = block.getCharacterList();

// divides the information about style and entities of each character into ranges
const entityPieces = getEntityRanges(text, charMetaList);

return entityPieces
.map(([entityKey, stylePieces]) => {
const content = stylePieces
.map(([text, styleSet]) => {
let content = encodeContent(text);
for (const styleName of this.styleOrder) {
const currentStyle = styleSet.find((style) => style.style === styleName);

if (currentStyle) {
let { element } = this.inlineStyles[styleName];
if (element == null) {
element = "span";
}
content = `<${element} id="${currentStyle.id}">${content}</${element}>`;
}
}

return content;
})
.join("");

const entity = entityKey ? contentState.getEntity(entityKey) : null;
// Note: The `toUpperCase` below is for compatability with some libraries that use lower-case for image blocks.
const entityType = entity == null ? null : entity.getType().toUpperCase();

if (entityType != null && entityType === ENTITY_TYPE.LINK) {
currentLinkId += 1;
return `<entity id="${currentLinkId}">${content}</entity>`;
} else {
return content;
}
})
.join("");
}

/*
preserves leading/trailing/consecutive whitespace in the text content
*/
preserveWhitespace(text: string): string {
const length = text.length;
const newText = new Array(length);
for (let i = 0; i < length; i++) {
if (text[i] === " " && (i === 0 || i === length - 1 || text[i - 1] === " ")) {
newText[i] = "\xA0";
} else {
newText[i] = text[i];
}
}
return newText.join("");
}
}

export function transformStateToXml(content: ContentState): string[] {
return new MarkupGenerator(content).generate();
}

export function blockToXml(contentBlock: ContentBlock, contentState: ContentState): string {
return new MarkupGenerator().renderBlockContent(contentBlock, contentState);
}
Loading

0 comments on commit 74cd1be

Please sign in to comment.