Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rte translate button + translation via xml transformation #1484

Closed
Show file tree
Hide file tree
Changes from all 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
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.
VP-DS marked this conversation as resolved.
Show resolved Hide resolved
5 changes: 3 additions & 2 deletions packages/admin/admin-rte/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@
"devDependencies": {
"@babel/cli": "^7.17.6",
"@babel/core": "^7.20.12",
"@comet/admin-babel-preset": "workspace:^5.1.0",
"@comet/eslint-config": "workspace:^5.1.0",
"@comet/admin": "workspace:^5.3.0",
VP-DS marked this conversation as resolved.
Show resolved Hide resolved
"@comet/admin-babel-preset": "workspace:^5.3.0",
"@comet/eslint-config": "workspace:^5.3.0",
"@mui/icons-material": "^5.0.0",
"@mui/material": "^5.0.0",
"@mui/styles": "^5.0.0",
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
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 { 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();

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 { useContentTranslationService } from "@comet/admin";
import { Translate } from "@comet/admin-icons";
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 = useContentTranslationService();

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={Translate} 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" },
thomasdax98 marked this conversation as resolved.
Show resolved Hide resolved
};

/*
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