Internationalization support (#477)

* add i18next lib
add some translations

* add translations

* fix font-family

* fix pin versions
This commit is contained in:
Fernando Alava Zambrano 2020-01-21 01:14:10 +02:00 committed by Christopher Chedeau
parent 1a03a29025
commit ff7a340d2f
15 changed files with 286 additions and 162 deletions

View file

@ -31,14 +31,14 @@ export const actionClearCanvas: Action = {
appState: getDefaultAppState()
};
},
PanelComponent: ({ updateData }) => (
PanelComponent: ({ updateData, t }) => (
<ToolIcon
type="button"
icon={trash}
title="Clear the canvas & reset background color"
aria-label="Clear the canvas & reset background color"
title={t("buttons.clearReset")}
aria-label={t("buttons.clearReset")}
onClick={() => {
if (window.confirm("This will clear the whole canvas. Are you sure?")) {
if (window.confirm(t("alerts.clearReset"))) {
// TODO: Defined globally, since file handles aren't yet serializable.
// Once `FileSystemFileHandle` can be serialized, make this
// part of `AppState`.

View file

@ -9,7 +9,7 @@ export const actionDeleteSelected: Action = {
elements: deleteSelectedElements(elements)
};
},
contextItemLabel: "Delete",
contextItemLabel: "labels.delete",
contextMenuOrder: 3,
keyTest: event => event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE
};

View file

@ -23,7 +23,7 @@ export const actionChangeExportBackground: Action = {
perform: (elements, appState, value) => {
return { appState: { ...appState, exportBackground: value } };
},
PanelComponent: ({ appState, updateData }) => (
PanelComponent: ({ appState, updateData, t }) => (
<label>
<input
type="checkbox"
@ -32,7 +32,7 @@ export const actionChangeExportBackground: Action = {
updateData(e.target.checked);
}}
/>{" "}
With background
{t("labels.withBackground")}
</label>
)
};
@ -43,12 +43,12 @@ export const actionSaveScene: Action = {
saveAsJSON(elements, appState).catch(err => console.error(err));
return {};
},
PanelComponent: ({ updateData }) => (
PanelComponent: ({ updateData, t }) => (
<ToolIcon
type="button"
icon={save}
title="Save"
aria-label="Save"
title={t("buttons.save")}
aria-label={t("buttons.save")}
onClick={() => updateData(null)}
/>
)
@ -63,12 +63,12 @@ export const actionLoadScene: Action = {
) => {
return { elements: loadedElements, appState: loadedAppState };
},
PanelComponent: ({ updateData }) => (
PanelComponent: ({ updateData, t }) => (
<ToolIcon
type="button"
icon={load}
title="Load"
aria-label="Load"
title={t("buttons.load")}
aria-label={t("buttons.load")}
onClick={() => {
loadFromJSON()
.then(({ elements, appState }) => {

View file

@ -30,19 +30,21 @@ export const actionChangeStrokeColor: Action = {
appState: { ...appState, currentItemStrokeColor: value }
};
},
PanelComponent: ({ elements, appState, updateData }) => (
<>
<h5>Stroke</h5>
<ColorPicker
type="elementStroke"
color={
getSelectedAttribute(elements, element => element.strokeColor) ||
appState.currentItemStrokeColor
}
onChange={updateData}
/>
</>
)
PanelComponent: ({ elements, appState, updateData, t }) => {
return (
<>
<h5>{t("labels.stroke")}</h5>
<ColorPicker
type="elementStroke"
color={
getSelectedAttribute(elements, element => element.strokeColor) ||
appState.currentItemStrokeColor
}
onChange={updateData}
/>
</>
);
}
};
export const actionChangeBackgroundColor: Action = {
@ -57,9 +59,9 @@ export const actionChangeBackgroundColor: Action = {
appState: { ...appState, currentItemBackgroundColor: value }
};
},
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, t }) => (
<>
<h5>Background</h5>
<h5>{t("labels.background")}</h5>
<ColorPicker
type="elementBackground"
color={
@ -83,9 +85,9 @@ export const actionChangeFillStyle: Action = {
}))
};
},
PanelComponent: ({ elements, updateData }) => (
PanelComponent: ({ elements, updateData, t }) => (
<>
<h5>Fill</h5>
<h5>{t("labels.fill")}</h5>
<ButtonSelect
options={[
{ value: "solid", text: "Solid" },
@ -112,9 +114,9 @@ export const actionChangeStrokeWidth: Action = {
}))
};
},
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, t }) => (
<>
<h5>Stroke Width</h5>
<h5>{t("labels.strokeWidth")}</h5>
<ButtonSelect
options={[
{ value: 1, text: "Thin" },
@ -139,9 +141,9 @@ export const actionChangeSloppiness: Action = {
}))
};
},
PanelComponent: ({ elements, appState, updateData }) => (
PanelComponent: ({ elements, appState, updateData, t }) => (
<>
<h5>Sloppiness</h5>
<h5>{t("labels.sloppiness")}</h5>
<ButtonSelect
options={[
{ value: 0, text: "Architect" },
@ -166,9 +168,9 @@ export const actionChangeOpacity: Action = {
}))
};
},
PanelComponent: ({ elements, updateData }) => (
PanelComponent: ({ elements, updateData, t }) => (
<>
<h5>Opacity</h5>
<h5>{t("labels.oppacity")}</h5>
<input
type="range"
min="0"
@ -202,9 +204,9 @@ export const actionChangeFontSize: Action = {
})
};
},
PanelComponent: ({ elements, updateData }) => (
PanelComponent: ({ elements, updateData, t }) => (
<>
<h5>Font size</h5>
<h5>{t("labels.fontSize")}</h5>
<ButtonSelect
options={[
{ value: 16, text: "Small" },
@ -241,14 +243,14 @@ export const actionChangeFontFamily: Action = {
})
};
},
PanelComponent: ({ elements, updateData }) => (
PanelComponent: ({ elements, updateData, t }) => (
<>
<h5>Font family</h5>
<h5>{t("labels.fontFamily")}</h5>
<ButtonSelect
options={[
{ value: "Virgil", text: "Hand-drawn" },
{ value: "Helvetica", text: "Normal" },
{ value: "Cascadia", text: "Code" }
{ value: "Virgil", text: t("labels.handDrawn") },
{ value: "Helvetica", text: t("labels.normal") },
{ value: "Cascadia", text: t("labels.code") }
]}
value={getSelectedAttribute(
elements,

View file

@ -8,6 +8,6 @@ export const actionSelectAll: Action = {
elements: elements.map(elem => ({ ...elem, isSelected: true }))
};
},
contextItemLabel: "Select All",
contextItemLabel: "labels.selectAll",
keyTest: event => event[KEYS.META] && event.code === "KeyA"
};

View file

@ -13,7 +13,7 @@ export const actionCopyStyles: Action = {
}
return {};
},
contextItemLabel: "Copy Styles",
contextItemLabel: "labels.copyStyles",
keyTest: event => event[KEYS.META] && event.shiftKey && event.code === "KeyC",
contextMenuOrder: 0
};
@ -45,7 +45,7 @@ export const actionPasteStyles: Action = {
})
};
},
contextItemLabel: "Paste Styles",
contextItemLabel: "labels.pasteStyles",
keyTest: event => event[KEYS.META] && event.shiftKey && event.code === "KeyV",
contextMenuOrder: 1
};

View file

@ -16,7 +16,7 @@ export const actionSendBackward: Action = {
appState
};
},
contextItemLabel: "Send Backward",
contextItemLabel: "labels.sendBackward",
keyPriority: 40,
keyTest: event =>
event[KEYS.META] && event.shiftKey && event.altKey && event.code === "KeyB"
@ -30,7 +30,7 @@ export const actionBringForward: Action = {
appState
};
},
contextItemLabel: "Bring Forward",
contextItemLabel: "labels.bringForward",
keyPriority: 40,
keyTest: event =>
event[KEYS.META] && event.shiftKey && event.altKey && event.code === "KeyF"
@ -44,7 +44,7 @@ export const actionSendToBack: Action = {
appState
};
},
contextItemLabel: "Send to Back",
contextItemLabel: "labels.sendToBack",
keyTest: event => event[KEYS.META] && event.shiftKey && event.code === "KeyB"
};
@ -56,6 +56,6 @@ export const actionBringToFront: Action = {
appState
};
},
contextItemLabel: "Bring to Front",
contextItemLabel: "labels.bringToFront",
keyTest: event => event[KEYS.META] && event.shiftKey && event.code === "KeyF"
};

View file

@ -7,6 +7,7 @@ import {
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { TFunction } from "i18next";
export class ActionManager implements ActionsManagerInterface {
actions: { [keyProp: string]: Action } = {};
@ -46,7 +47,8 @@ export class ActionManager implements ActionsManagerInterface {
elements: readonly ExcalidrawElement[],
appState: AppState,
updater: UpdaterFn,
actionFilter: ActionFilterFn = action => action
actionFilter: ActionFilterFn = action => action,
t?: TFunction
) {
return Object.values(this.actions)
.filter(actionFilter)
@ -57,7 +59,10 @@ export class ActionManager implements ActionsManagerInterface {
(b.contextMenuOrder !== undefined ? b.contextMenuOrder : 999)
)
.map(action => ({
label: action.contextItemLabel!,
label:
t && action.contextItemLabel
? t(action.contextItemLabel)
: action.contextItemLabel!,
action: () => {
updater(action.perform(elements, appState, null));
}
@ -68,7 +73,8 @@ export class ActionManager implements ActionsManagerInterface {
name: string,
elements: readonly ExcalidrawElement[],
appState: AppState,
updater: UpdaterFn
updater: UpdaterFn,
t: TFunction
) {
if (this.actions[name] && "PanelComponent" in this.actions[name]) {
const action = this.actions[name];
@ -82,6 +88,7 @@ export class ActionManager implements ActionsManagerInterface {
elements={elements}
appState={appState}
updateData={updateData}
t={t}
/>
);
}

View file

@ -1,6 +1,7 @@
import React from "react";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { TFunction } from "i18next";
export type ActionResult = {
elements?: ExcalidrawElement[];
@ -22,6 +23,7 @@ export interface Action {
elements: readonly ExcalidrawElement[];
appState: AppState;
updateData: (formData: any) => void;
t: TFunction;
}>;
perform: ActionFn;
keyPriority?: number;
@ -54,6 +56,7 @@ export interface ActionsManagerInterface {
name: string,
elements: readonly ExcalidrawElement[],
appState: AppState,
updater: UpdaterFn
updater: UpdaterFn,
t: TFunction
) => React.ReactElement | null;
}

View file

@ -12,6 +12,8 @@ import { getExportCanvasPreview } from "../scene/getExportCanvasPreview";
import { ActionsManagerInterface, UpdaterFn } from "../actions/types";
import Stack from "./Stack";
import { useTranslation } from "react-i18next";
const probablySupportsClipboard =
"toBlob" in HTMLCanvasElement.prototype &&
"clipboard" in navigator &&
@ -42,6 +44,7 @@ export function ExportDialog({
onExportToClipboard: ExportCB;
onExportToBackend: ExportCB;
}) {
const { t } = useTranslation();
const someElementIsSelected = elements.some(element => element.isSelected);
const [modalIsShown, setModalIsShown] = useState(false);
const [scale, setScale] = useState(defaultScale);
@ -90,7 +93,7 @@ export function ExportDialog({
icon={exportFile}
type="button"
aria-label="Show export dialog"
title="Export"
title={t("buttons.export")}
/>
{modalIsShown && (
<Modal maxWidth={640} onCloseRequest={handleClose}>
@ -99,23 +102,23 @@ export function ExportDialog({
<button className="ExportDialog__close" onClick={handleClose}>
</button>
<h2>Export</h2>
<h2>{t("buttons.export")}</h2>
<div className="ExportDialog__preview" ref={previeRef}></div>
<div className="ExportDialog__actions">
<Stack.Row gap={2}>
<ToolIcon
type="button"
icon={downloadFile}
title="Export to PNG"
aria-label="Export to PNG"
title={t("buttons.exportToPng")}
aria-label={t("buttons.exportToPng")}
onClick={() => onExportToPng(exportedElements, scale)}
/>
{probablySupportsClipboard && (
<ToolIcon
type="button"
icon={clipboard}
title="Copy to clipboard"
aria-label="Copy to clipboard"
title={t("buttons.copyToClipboard")}
aria-label={t("buttons.copyToClipboard")}
onClick={() =>
onExportToClipboard(exportedElements, scale)
}
@ -134,7 +137,8 @@ export function ExportDialog({
"changeProjectName",
elements,
appState,
syncActionResult
syncActionResult,
t
)}
<Stack.Col gap={1}>
<div className="ExportDialog__scales">
@ -157,7 +161,8 @@ export function ExportDialog({
"changeExportBackground",
elements,
appState,
syncActionResult
syncActionResult,
t
)}
{someElementIsSelected && (
<div>
@ -169,7 +174,7 @@ export function ExportDialog({
setExportSelected(e.currentTarget.checked)
}
/>{" "}
Only selected
{t("labels.onlySelected")}
</label>
</div>
)}

21
src/i18n.ts Normal file
View file

@ -0,0 +1,21 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import Backend from "i18next-xhr-backend";
import LanguageDetector from "i18next-browser-languagedetector";
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
backend: {
loadPath: "./locales/{{lng}}/translation.json"
},
lng: "en",
fallbackLng: "en",
debug: false,
react: { useSuspense: false }
});
export default i18n;

View file

@ -77,6 +77,8 @@ import Stack from "./components/Stack";
import { FixedSideContainer } from "./components/FixedSideContainer";
import { ToolIcon } from "./components/ToolIcon";
import { ExportDialog } from "./components/ExportDialog";
import { withTranslation } from "react-i18next";
import "./i18n";
let { elements } = createScene();
const { history } = createHistory();
@ -129,7 +131,7 @@ export function viewportCoordsToSceneCoords(
return { x, y };
}
export class App extends React.Component<{}, AppState> {
export class App extends React.Component<any, AppState> {
canvas: HTMLCanvasElement | null = null;
rc: RoughCanvas | null = null;
@ -359,6 +361,7 @@ export class App extends React.Component<{}, AppState> {
};
private renderSelectedShapeActions(elements: readonly ExcalidrawElement[]) {
const { t } = this.props;
const { elementType, editingElement } = this.state;
const selectedElements = elements.filter(el => el.isSelected);
const hasSelectedElements = selectedElements.length > 0;
@ -381,7 +384,8 @@ export class App extends React.Component<{}, AppState> {
"changeStrokeColor",
elements,
this.state,
this.syncActionResult
this.syncActionResult,
t
)}
{(hasBackground(elements) ||
@ -391,14 +395,16 @@ export class App extends React.Component<{}, AppState> {
"changeBackgroundColor",
elements,
this.state,
this.syncActionResult
this.syncActionResult,
t
)}
{this.actionManager.renderAction(
"changeFillStyle",
elements,
this.state,
this.syncActionResult
this.syncActionResult,
t
)}
<hr />
</>
@ -411,14 +417,16 @@ export class App extends React.Component<{}, AppState> {
"changeStrokeWidth",
elements,
this.state,
this.syncActionResult
this.syncActionResult,
t
)}
{this.actionManager.renderAction(
"changeSloppiness",
elements,
this.state,
this.syncActionResult
this.syncActionResult,
t
)}
<hr />
</>
@ -430,14 +438,16 @@ export class App extends React.Component<{}, AppState> {
"changeFontSize",
elements,
this.state,
this.syncActionResult
this.syncActionResult,
t
)}
{this.actionManager.renderAction(
"changeFontFamily",
elements,
this.state,
this.syncActionResult
this.syncActionResult,
t
)}
<hr />
</>
@ -447,14 +457,16 @@ export class App extends React.Component<{}, AppState> {
"changeOpacity",
elements,
this.state,
this.syncActionResult
this.syncActionResult,
t
)}
{this.actionManager.renderAction(
"deleteSelectedElements",
elements,
this.state,
this.syncActionResult
this.syncActionResult,
t
)}
</div>
</Island>
@ -462,32 +474,38 @@ export class App extends React.Component<{}, AppState> {
}
private renderShapesSwitcher() {
const { t } = this.props;
return (
<>
{SHAPES.map(({ value, icon }, index) => (
<ToolIcon
key={value}
type="radio"
icon={icon}
checked={this.state.elementType === value}
name="editor-current-shape"
title={`${capitalizeString(value)}${
capitalizeString(value)[0]
}, ${index + 1}`}
onChange={() => {
this.setState({ elementType: value });
elements = clearSelection(elements);
document.documentElement.style.cursor =
value === "text" ? "text" : "crosshair";
this.forceUpdate();
}}
></ToolIcon>
))}
{SHAPES.map(({ value, icon }, index) => {
const label = t(`toolBar.${value}`);
return (
<ToolIcon
key={value}
type="radio"
icon={icon}
checked={this.state.elementType === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${
capitalizeString(label)[0]
}, ${index + 1}`}
onChange={() => {
this.setState({ elementType: value });
elements = clearSelection(elements);
document.documentElement.style.cursor =
value === "text" ? "text" : "crosshair";
this.forceUpdate();
}}
></ToolIcon>
);
})}
</>
);
}
private renderCanvasActions() {
const { t } = this.props;
return (
<Stack.Col gap={4}>
<Stack.Row justifyContent={"space-between"}>
@ -495,13 +513,15 @@ export class App extends React.Component<{}, AppState> {
"loadScene",
elements,
this.state,
this.syncActionResult
this.syncActionResult,
t
)}
{this.actionManager.renderAction(
"saveScene",
elements,
this.state,
this.syncActionResult
this.syncActionResult,
t
)}
<ExportDialog
elements={elements}
@ -540,14 +560,16 @@ export class App extends React.Component<{}, AppState> {
"clearCanvas",
elements,
this.state,
this.syncActionResult
this.syncActionResult,
t
)}
</Stack.Row>
{this.actionManager.renderAction(
"changeViewBackgroundColor",
elements,
this.state,
this.syncActionResult
this.syncActionResult,
t
)}
</Stack.Col>
);
@ -556,6 +578,7 @@ export class App extends React.Component<{}, AppState> {
public render() {
const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT;
const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP;
const { t } = this.props;
return (
<div className="container">
@ -624,14 +647,15 @@ export class App extends React.Component<{}, AppState> {
ContextMenu.push({
options: [
navigator.clipboard && {
label: "Paste",
label: t("labels.paste"),
action: () => this.pasteFromClipboard()
},
...this.actionManager.getContextMenuItems(
elements,
this.state,
this.syncActionResult,
action => this.canvasOnlyActions.includes(action)
action => this.canvasOnlyActions.includes(action),
t
)
],
top: e.clientY,
@ -649,18 +673,19 @@ export class App extends React.Component<{}, AppState> {
ContextMenu.push({
options: [
navigator.clipboard && {
label: "Copy",
label: t("labels.copy"),
action: this.copyToClipboard
},
navigator.clipboard && {
label: "Paste",
label: t("labels.paste"),
action: () => this.pasteFromClipboard()
},
...this.actionManager.getContextMenuItems(
elements,
this.state,
this.syncActionResult,
action => !this.canvasOnlyActions.includes(action)
action => !this.canvasOnlyActions.includes(action),
t
)
],
top: e.clientY,
@ -1333,5 +1358,7 @@ export class App extends React.Component<{}, AppState> {
}
}
const AppWithTrans = withTranslation()(App);
const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
ReactDOM.render(<AppWithTrans />, rootElement);