浏览代码

Internationalization support (#477)

* add i18next lib
add some translations

* add translations

* fix font-family

* fix pin versions
Fernando Alava Zambrano 5 年之前
父节点
当前提交
ff7a340d2f

+ 68 - 60
package-lock.json

@@ -3054,8 +3054,7 @@
             },
             "ansi-regex": {
               "version": "2.1.1",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "aproba": {
               "version": "1.2.0",
@@ -3073,13 +3072,11 @@
             },
             "balanced-match": {
               "version": "1.0.0",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "brace-expansion": {
               "version": "1.1.11",
               "bundled": true,
-              "optional": true,
               "requires": {
                 "balanced-match": "^1.0.0",
                 "concat-map": "0.0.1"
@@ -3092,18 +3089,15 @@
             },
             "code-point-at": {
               "version": "1.1.0",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "concat-map": {
               "version": "0.0.1",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "console-control-strings": {
               "version": "1.1.0",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "core-util-is": {
               "version": "1.0.2",
@@ -3206,8 +3200,7 @@
             },
             "inherits": {
               "version": "2.0.4",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "ini": {
               "version": "1.3.5",
@@ -3217,7 +3210,6 @@
             "is-fullwidth-code-point": {
               "version": "1.0.0",
               "bundled": true,
-              "optional": true,
               "requires": {
                 "number-is-nan": "^1.0.0"
               }
@@ -3230,20 +3222,17 @@
             "minimatch": {
               "version": "3.0.4",
               "bundled": true,
-              "optional": true,
               "requires": {
                 "brace-expansion": "^1.1.7"
               }
             },
             "minimist": {
               "version": "0.0.8",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "minipass": {
               "version": "2.9.0",
               "bundled": true,
-              "optional": true,
               "requires": {
                 "safe-buffer": "^5.1.2",
                 "yallist": "^3.0.0"
@@ -3260,7 +3249,6 @@
             "mkdirp": {
               "version": "0.5.1",
               "bundled": true,
-              "optional": true,
               "requires": {
                 "minimist": "0.0.8"
               }
@@ -3341,8 +3329,7 @@
             },
             "number-is-nan": {
               "version": "1.0.1",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "object-assign": {
               "version": "4.1.1",
@@ -3352,7 +3339,6 @@
             "once": {
               "version": "1.4.0",
               "bundled": true,
-              "optional": true,
               "requires": {
                 "wrappy": "1"
               }
@@ -3428,8 +3414,7 @@
             },
             "safe-buffer": {
               "version": "5.1.2",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "safer-buffer": {
               "version": "2.1.2",
@@ -3459,7 +3444,6 @@
             "string-width": {
               "version": "1.0.2",
               "bundled": true,
-              "optional": true,
               "requires": {
                 "code-point-at": "^1.0.0",
                 "is-fullwidth-code-point": "^1.0.0",
@@ -3477,7 +3461,6 @@
             "strip-ansi": {
               "version": "3.0.1",
               "bundled": true,
-              "optional": true,
               "requires": {
                 "ansi-regex": "^2.0.0"
               }
@@ -3516,13 +3499,11 @@
             },
             "wrappy": {
               "version": "1.0.2",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "yallist": {
               "version": "3.1.1",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             }
           }
         },
@@ -6639,6 +6620,14 @@
         }
       }
     },
+    "html-parse-stringify2": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/html-parse-stringify2/-/html-parse-stringify2-2.0.1.tgz",
+      "integrity": "sha1-3FZwtyksoVi3vJFsmmc1rIhyg0o=",
+      "requires": {
+        "void-elements": "^2.0.1"
+      }
+    },
     "html-webpack-plugin": {
       "version": "4.0.0-beta.5",
       "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-4.0.0-beta.5.tgz",
@@ -6907,6 +6896,30 @@
         }
       }
     },
+    "i18next": {
+      "version": "19.0.3",
+      "resolved": "https://registry.npmjs.org/i18next/-/i18next-19.0.3.tgz",
+      "integrity": "sha512-Ru4afr++b4cUApsIBifcMYyWG9Nx8wlFdq4DuOF+UuoPoQKfuh0iAVMekTjs6w1CZLUOVb5QZEuoYRLmu17EIA==",
+      "requires": {
+        "@babel/runtime": "^7.3.1"
+      }
+    },
+    "i18next-browser-languagedetector": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-4.0.1.tgz",
+      "integrity": "sha512-RxSoX6mB8cab0CTIQ+klCS764vYRj+Jk621cnFVsINvcdlb/cdi3vQFyrPwmnowB7ReUadjHovgZX+RPIzHVQQ==",
+      "requires": {
+        "@babel/runtime": "^7.5.5"
+      }
+    },
+    "i18next-xhr-backend": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/i18next-xhr-backend/-/i18next-xhr-backend-3.2.2.tgz",
+      "integrity": "sha512-OtRf2Vo3IqAxsttQbpjYnmMML12IMB5e0fc5B7qKJFLScitYaXa1OhMX0n0X/3vrfFlpHL9Ro/H+ps4Ej2j7QQ==",
+      "requires": {
+        "@babel/runtime": "^7.5.5"
+      }
+    },
     "iconv-lite": {
       "version": "0.4.24",
       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -7674,8 +7687,7 @@
             },
             "ansi-regex": {
               "version": "2.1.1",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "aproba": {
               "version": "1.2.0",
@@ -7693,13 +7705,11 @@
             },
             "balanced-match": {
               "version": "1.0.0",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "brace-expansion": {
               "version": "1.1.11",
               "bundled": true,
-              "optional": true,
               "requires": {
                 "balanced-match": "^1.0.0",
                 "concat-map": "0.0.1"
@@ -7712,18 +7722,15 @@
             },
             "code-point-at": {
               "version": "1.1.0",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "concat-map": {
               "version": "0.0.1",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "console-control-strings": {
               "version": "1.1.0",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "core-util-is": {
               "version": "1.0.2",
@@ -7826,8 +7833,7 @@
             },
             "inherits": {
               "version": "2.0.4",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "ini": {
               "version": "1.3.5",
@@ -7837,7 +7843,6 @@
             "is-fullwidth-code-point": {
               "version": "1.0.0",
               "bundled": true,
-              "optional": true,
               "requires": {
                 "number-is-nan": "^1.0.0"
               }
@@ -7850,20 +7855,17 @@
             "minimatch": {
               "version": "3.0.4",
               "bundled": true,
-              "optional": true,
               "requires": {
                 "brace-expansion": "^1.1.7"
               }
             },
             "minimist": {
               "version": "0.0.8",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "minipass": {
               "version": "2.9.0",
               "bundled": true,
-              "optional": true,
               "requires": {
                 "safe-buffer": "^5.1.2",
                 "yallist": "^3.0.0"
@@ -7880,7 +7882,6 @@
             "mkdirp": {
               "version": "0.5.1",
               "bundled": true,
-              "optional": true,
               "requires": {
                 "minimist": "0.0.8"
               }
@@ -7961,8 +7962,7 @@
             },
             "number-is-nan": {
               "version": "1.0.1",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "object-assign": {
               "version": "4.1.1",
@@ -7972,7 +7972,6 @@
             "once": {
               "version": "1.4.0",
               "bundled": true,
-              "optional": true,
               "requires": {
                 "wrappy": "1"
               }
@@ -8048,8 +8047,7 @@
             },
             "safe-buffer": {
               "version": "5.1.2",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "safer-buffer": {
               "version": "2.1.2",
@@ -8079,7 +8077,6 @@
             "string-width": {
               "version": "1.0.2",
               "bundled": true,
-              "optional": true,
               "requires": {
                 "code-point-at": "^1.0.0",
                 "is-fullwidth-code-point": "^1.0.0",
@@ -8097,7 +8094,6 @@
             "strip-ansi": {
               "version": "3.0.1",
               "bundled": true,
-              "optional": true,
               "requires": {
                 "ansi-regex": "^2.0.0"
               }
@@ -8136,13 +8132,11 @@
             },
             "wrappy": {
               "version": "1.0.2",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             },
             "yallist": {
               "version": "3.1.1",
-              "bundled": true,
-              "optional": true
+              "bundled": true
             }
           }
         }
@@ -12324,6 +12318,15 @@
       "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.4.tgz",
       "integrity": "sha512-ueZzLmHltszTshDMwyfELDq8zOA803wQ1ZuzCccXa1m57k1PxSHfflPD5W9YIiTXLs0JTLzoj6o1LuM5N6zzNA=="
     },
+    "react-i18next": {
+      "version": "11.3.1",
+      "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.3.1.tgz",
+      "integrity": "sha512-S/CWHcnew1lXo8HeniGhBU5kTmPhZ4w4rtA4m/gDN07soCtKKYSAcLNm7zhwjI2OSR4Skd0vOtzNp/FzEEjxIw==",
+      "requires": {
+        "@babel/runtime": "^7.3.1",
+        "html-parse-stringify2": "2.0.1"
+      }
+    },
     "react-is": {
       "version": "16.12.0",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz",
@@ -15303,6 +15306,11 @@
       "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz",
       "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ=="
     },
+    "void-elements": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz",
+      "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w="
+    },
     "w3c-hr-time": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz",

+ 4 - 0
package.json

@@ -6,9 +6,13 @@
     "not op_mini all"
   ],
   "dependencies": {
+    "i18next": "19.0.3",
+    "i18next-browser-languagedetector": "4.0.1",
+    "i18next-xhr-backend": "3.2.2",
     "nanoid": "2.1.9",
     "react": "16.12.0",
     "react-dom": "16.12.0",
+    "react-i18next": "11.3.1",
     "react-scripts": "3.3.0",
     "roughjs": "4.0.4"
   },

+ 47 - 0
public/locales/en/translation.json

@@ -0,0 +1,47 @@
+{
+  "labels": {
+    "paste": "Paste",
+    "selectAll": "Select All",
+    "copy": "Copy",
+    "bringForward": "Bring Forward",
+    "sendToBack": "Send To Back",
+    "bringToFront": "Bring To Front",
+    "sendBackward": "Send Backward",
+    "delete": "Delete",
+    "copyStyles": "Copy Styles",
+    "pasteStyles": "Paste Styles",
+    "stroke": "Stroke",
+    "background": "Background",
+    "fill": "Fill",
+    "strokeWidth": "Stroke Width",
+    "sloppiness": "Sloppiness",
+    "oppacity": "Oppacity",
+    "fontSize": "Font Size",
+    "fontFamily": "Font Family",
+    "onlySelected": "Only selected",
+    "withBackground": "With Background",
+    "handDrawn": "Hand-Drawn",
+    "normal": "Normal",
+    "code": "Code"
+  },
+  "buttons": {
+    "clearReset": "Clear the canvas & reset background color",
+    "export": "Export",
+    "exportToPng": "Export to PNG",
+    "copyToClipboard": "Copy to clipboard",
+    "save": "Save",
+    "load": "Load"
+  },
+  "alerts": {
+    "clearReset": "This will clear the whole canvas. Are you sure?"
+  },
+  "toolBar": {
+    "selection": "Selection",
+    "rectangle": "Rectangle",
+    "diamond": "Diamond",
+    "ellipse": "Ellipse",
+    "arrow": "Arrow",
+    "line": "Line",
+    "text": "Text"
+  }
+}

+ 4 - 4
src/actions/actionCanvas.tsx

@@ -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`.

+ 1 - 1
src/actions/actionDeleteSelected.tsx

@@ -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
 };

+ 8 - 8
src/actions/actionExport.tsx

@@ -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 }) => {

+ 32 - 30
src/actions/actionProperties.tsx

@@ -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,

+ 1 - 1
src/actions/actionSelectAll.ts

@@ -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"
 };

+ 2 - 2
src/actions/actionStyles.ts

@@ -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
 };

+ 4 - 4
src/actions/actionZindex.tsx

@@ -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"
 };

+ 10 - 3
src/actions/manager.tsx

@@ -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}
         />
       );
     }

+ 4 - 1
src/actions/types.ts

@@ -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;
 }

+ 14 - 9
src/components/ExportDialog.tsx

@@ -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 - 0
src/i18n.ts

@@ -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;

+ 66 - 39
src/index.tsx

@@ -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);