-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
9 changed files
with
540 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
14 changes: 14 additions & 0 deletions
14
packages/admin/admin-rte/src/core/Controls/TranslationControls.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
45
packages/admin/admin-rte/src/core/translation/ToolbarButton.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
100
packages/admin/admin-rte/src/core/translation/xml/getEntityRanges.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
} |
171 changes: 171 additions & 0 deletions
171
packages/admin/admin-rte/src/core/translation/xml/transformStateToXml.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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("&").split("<").join("<").split(">").join(">").split("\xA0").join(" ").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); | ||
} |
Oops, something went wrong.