فهرست منبع

fix: rerender i18n in host components on lang change (#6224)

David Luzar 2 سال پیش
والد
کامیت
04a8c22f39

+ 11 - 22
src/components/main-menu/DefaultItems.tsx

@@ -1,5 +1,5 @@
 import { getShortcutFromShortcutName } from "../../actions/shortcuts";
-import { t } from "../../i18n";
+import { useI18n } from "../../i18n";
 import {
   useExcalidrawAppState,
   useExcalidrawSetAppState,
@@ -33,9 +33,7 @@ import { useSetAtom } from "jotai";
 import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
 
 export const LoadScene = () => {
-  // FIXME Hack until we tie "t" to lang state
-  // eslint-disable-next-line
-  const appState = useExcalidrawAppState();
+  const { t } = useI18n();
   const actionManager = useExcalidrawActionManager();
 
   if (!actionManager.isActionEnabled(actionLoadScene)) {
@@ -57,9 +55,7 @@ export const LoadScene = () => {
 LoadScene.displayName = "LoadScene";
 
 export const SaveToActiveFile = () => {
-  // FIXME Hack until we tie "t" to lang state
-  // eslint-disable-next-line
-  const appState = useExcalidrawAppState();
+  const { t } = useI18n();
   const actionManager = useExcalidrawActionManager();
 
   if (!actionManager.isActionEnabled(actionSaveToActiveFile)) {
@@ -80,9 +76,7 @@ SaveToActiveFile.displayName = "SaveToActiveFile";
 
 export const SaveAsImage = () => {
   const setAppState = useExcalidrawSetAppState();
-  // FIXME Hack until we tie "t" to lang state
-  // eslint-disable-next-line
-  const appState = useExcalidrawAppState();
+  const { t } = useI18n();
   return (
     <DropdownMenuItem
       icon={ExportImageIcon}
@@ -98,9 +92,7 @@ export const SaveAsImage = () => {
 SaveAsImage.displayName = "SaveAsImage";
 
 export const Help = () => {
-  // FIXME Hack until we tie "t" to lang state
-  // eslint-disable-next-line
-  const appState = useExcalidrawAppState();
+  const { t } = useI18n();
 
   const actionManager = useExcalidrawActionManager();
 
@@ -119,9 +111,8 @@ export const Help = () => {
 Help.displayName = "Help";
 
 export const ClearCanvas = () => {
-  // FIXME Hack until we tie "t" to lang state
-  // eslint-disable-next-line
-  const appState = useExcalidrawAppState();
+  const { t } = useI18n();
+
   const setActiveConfirmDialog = useSetAtom(activeConfirmDialogAtom);
   const actionManager = useExcalidrawActionManager();
 
@@ -143,6 +134,7 @@ export const ClearCanvas = () => {
 ClearCanvas.displayName = "ClearCanvas";
 
 export const ToggleTheme = () => {
+  const { t } = useI18n();
   const appState = useExcalidrawAppState();
   const actionManager = useExcalidrawActionManager();
 
@@ -175,6 +167,7 @@ export const ToggleTheme = () => {
 ToggleTheme.displayName = "ToggleTheme";
 
 export const ChangeCanvasBackground = () => {
+  const { t } = useI18n();
   const appState = useExcalidrawAppState();
   const actionManager = useExcalidrawActionManager();
 
@@ -195,9 +188,7 @@ export const ChangeCanvasBackground = () => {
 ChangeCanvasBackground.displayName = "ChangeCanvasBackground";
 
 export const Export = () => {
-  // FIXME Hack until we tie "t" to lang state
-  // eslint-disable-next-line
-  const appState = useExcalidrawAppState();
+  const { t } = useI18n();
   const setAppState = useExcalidrawSetAppState();
   return (
     <DropdownMenuItem
@@ -248,9 +239,7 @@ export const LiveCollaborationTrigger = ({
   onSelect: () => void;
   isCollaborating: boolean;
 }) => {
-  // FIXME Hack until we tie "t" to lang state
-  // eslint-disable-next-line
-  const appState = useExcalidrawAppState();
+  const { t } = useI18n();
   return (
     <DropdownMenuItem
       data-testid="collab-button"

+ 2 - 5
src/components/welcome-screen/WelcomeScreen.Center.tsx

@@ -1,6 +1,6 @@
 import { actionLoadScene, actionShortcuts } from "../../actions";
 import { getShortcutFromShortcutName } from "../../actions/shortcuts";
-import { t } from "../../i18n";
+import { t, useI18n } from "../../i18n";
 import {
   useDevice,
   useExcalidrawActionManager,
@@ -172,10 +172,7 @@ const MenuItemLiveCollaborationTrigger = ({
 }: {
   onSelect: () => any;
 }) => {
-  // FIXME when we tie t() to lang state
-  // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  const appState = useExcalidrawAppState();
-
+  const { t } = useI18n();
   return (
     <WelcomeScreenMenuItem shortcut={null} onSelect={onSelect} icon={usersIcon}>
       {t("labels.liveCollaboration")}

+ 3 - 0
src/excalidraw-app/app-jotai.ts

@@ -0,0 +1,3 @@
+import { unstable_createStore } from "jotai";
+
+export const appJotaiStore = unstable_createStore();

+ 6 - 6
src/excalidraw-app/collab/Collab.tsx

@@ -70,7 +70,7 @@ import { decryptData } from "../../data/encryption";
 import { resetBrowserStateVersions } from "../data/tabSync";
 import { LocalData } from "../data/LocalData";
 import { atom, useAtom } from "jotai";
-import { jotaiStore } from "../../jotai";
+import { appJotaiStore } from "../app-jotai";
 
 export const collabAPIAtom = atom<CollabAPI | null>(null);
 export const collabDialogShownAtom = atom(false);
@@ -167,7 +167,7 @@ class Collab extends PureComponent<Props, CollabState> {
       setUsername: this.setUsername,
     };
 
-    jotaiStore.set(collabAPIAtom, collabAPI);
+    appJotaiStore.set(collabAPIAtom, collabAPI);
     this.onOfflineStatusToggle();
 
     if (
@@ -185,7 +185,7 @@ class Collab extends PureComponent<Props, CollabState> {
   }
 
   onOfflineStatusToggle = () => {
-    jotaiStore.set(isOfflineAtom, !window.navigator.onLine);
+    appJotaiStore.set(isOfflineAtom, !window.navigator.onLine);
   };
 
   componentWillUnmount() {
@@ -208,10 +208,10 @@ class Collab extends PureComponent<Props, CollabState> {
     }
   }
 
-  isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!;
+  isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
 
   private setIsCollaborating = (isCollaborating: boolean) => {
-    jotaiStore.set(isCollaboratingAtom, isCollaborating);
+    appJotaiStore.set(isCollaboratingAtom, isCollaborating);
   };
 
   private onUnload = () => {
@@ -804,7 +804,7 @@ class Collab extends PureComponent<Props, CollabState> {
   );
 
   handleClose = () => {
-    jotaiStore.set(collabDialogShownAtom, false);
+    appJotaiStore.set(collabDialogShownAtom, false);
   };
 
   setUsername = (username: string) => {

+ 2 - 1
src/excalidraw-app/collab/RoomDialog.tsx

@@ -10,13 +10,13 @@ import {
   shareWindows,
 } from "../../components/icons";
 import { ToolButton } from "../../components/ToolButton";
-import { t } from "../../i18n";
 import "./RoomDialog.scss";
 import Stack from "../../components/Stack";
 import { AppState } from "../../types";
 import { trackEvent } from "../../analytics";
 import { getFrame } from "../../utils";
 import DialogActionButton from "../../components/DialogActionButton";
+import { useI18n } from "../../i18n";
 
 const getShareIcon = () => {
   const navigator = window.navigator as any;
@@ -51,6 +51,7 @@ const RoomDialog = ({
   setErrorMessage: (message: string) => void;
   theme: AppState["theme"];
 }) => {
+  const { t } = useI18n();
   const roomLinkInput = useRef<HTMLInputElement>(null);
 
   const copyRoomLink = async () => {

+ 2 - 1
src/excalidraw-app/components/AppWelcomeScreen.tsx

@@ -1,12 +1,13 @@
 import React from "react";
 import { PlusPromoIcon } from "../../components/icons";
-import { t } from "../../i18n";
+import { useI18n } from "../../i18n";
 import { WelcomeScreen } from "../../packages/excalidraw/index";
 import { isExcalidrawPlusSignedUser } from "../app_constants";
 
 export const AppWelcomeScreen: React.FC<{
   setCollabDialogShown: (toggle: boolean) => any;
 }> = React.memo((props) => {
+  const { t } = useI18n();
   let headingContent;
 
   if (isExcalidrawPlusSignedUser) {

+ 18 - 14
src/excalidraw-app/components/EncryptedIcon.tsx

@@ -1,17 +1,21 @@
 import { shield } from "../../components/icons";
 import { Tooltip } from "../../components/Tooltip";
-import { t } from "../../i18n";
+import { useI18n } from "../../i18n";
 
-export const EncryptedIcon = () => (
-  <a
-    className="encrypted-icon tooltip"
-    href="https://blog.excalidraw.com/end-to-end-encryption/"
-    target="_blank"
-    rel="noopener noreferrer"
-    aria-label={t("encrypted.link")}
-  >
-    <Tooltip label={t("encrypted.tooltip")} long={true}>
-      {shield}
-    </Tooltip>
-  </a>
-);
+export const EncryptedIcon = () => {
+  const { t } = useI18n();
+
+  return (
+    <a
+      className="encrypted-icon tooltip"
+      href="https://blog.excalidraw.com/end-to-end-encryption/"
+      target="_blank"
+      rel="noopener noreferrer"
+      aria-label={t("encrypted.link")}
+    >
+      <Tooltip label={t("encrypted.tooltip")} long={true}>
+        {shield}
+      </Tooltip>
+    </a>
+  );
+};

+ 2 - 1
src/excalidraw-app/components/ExportToExcalidrawPlus.tsx

@@ -6,7 +6,7 @@ import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
 import { FileId, NonDeletedExcalidrawElement } from "../../element/types";
 import { AppState, BinaryFileData, BinaryFiles } from "../../types";
 import { nanoid } from "nanoid";
-import { t } from "../../i18n";
+import { useI18n } from "../../i18n";
 import { excalidrawPlusIcon } from "./icons";
 import { encryptData, generateEncryptionKey } from "../../data/encryption";
 import { isInitializedImageElement } from "../../element/typeChecks";
@@ -79,6 +79,7 @@ export const ExportToExcalidrawPlus: React.FC<{
   files: BinaryFiles;
   onError: (error: Error) => void;
 }> = ({ elements, appState, files, onError }) => {
+  const { t } = useI18n();
   return (
     <Card color="primary">
       <div className="Card-icon">{excalidrawPlusIcon}</div>

+ 8 - 7
src/excalidraw-app/components/LanguageList.tsx

@@ -1,22 +1,23 @@
-import { useAtom } from "jotai";
+import { useSetAtom } from "jotai";
 import React from "react";
-import { langCodeAtom } from "..";
-import * as i18n from "../../i18n";
+import { appLangCodeAtom } from "..";
+import { defaultLang, useI18n } from "../../i18n";
 import { languages } from "../../i18n";
 
 export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
-  const [langCode, setLangCode] = useAtom(langCodeAtom);
+  const { t, langCode } = useI18n();
+  const setLangCode = useSetAtom(appLangCodeAtom);
 
   return (
     <select
       className="dropdown-select dropdown-select__language"
       onChange={({ target }) => setLangCode(target.value)}
       value={langCode}
-      aria-label={i18n.t("buttons.selectLanguage")}
+      aria-label={t("buttons.selectLanguage")}
       style={style}
     >
-      <option key={i18n.defaultLang.code} value={i18n.defaultLang.code}>
-        {i18n.defaultLang.label}
+      <option key={defaultLang.code} value={defaultLang.code}>
+        {defaultLang.label}
       </option>
       {languages.map((lang) => (
         <option key={lang.code} value={lang.code}>

+ 9 - 8
src/excalidraw-app/index.tsx

@@ -75,13 +75,14 @@ import { loadFilesFromFirebase } from "./data/firebase";
 import { LocalData } from "./data/LocalData";
 import { isBrowserStorageStateNewer } from "./data/tabSync";
 import clsx from "clsx";
-import { atom, Provider, useAtom, useAtomValue } from "jotai";
-import { jotaiStore, useAtomWithInitialValue } from "../jotai";
 import { reconcileElements } from "./collab/reconciliation";
 import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
 import { AppMainMenu } from "./components/AppMainMenu";
 import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
 import { AppFooter } from "./components/AppFooter";
+import { atom, Provider, useAtom, useAtomValue } from "jotai";
+import { useAtomWithInitialValue } from "../jotai";
+import { appJotaiStore } from "./app-jotai";
 
 import "./index.scss";
 
@@ -226,15 +227,15 @@ const initializeScene = async (opts: {
   return { scene: null, isExternalScene: false };
 };
 
-const currentLangCode = languageDetector.detect() || defaultLang.code;
-
-export const langCodeAtom = atom(
-  Array.isArray(currentLangCode) ? currentLangCode[0] : currentLangCode,
+const detectedLangCode = languageDetector.detect() || defaultLang.code;
+export const appLangCodeAtom = atom(
+  Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
 );
 
 const ExcalidrawWrapper = () => {
   const [errorMessage, setErrorMessage] = useState("");
-  const [langCode, setLangCode] = useAtom(langCodeAtom);
+  const [langCode, setLangCode] = useAtom(appLangCodeAtom);
+
   // initial state
   // ---------------------------------------------------------------------------
 
@@ -683,7 +684,7 @@ const ExcalidrawWrapper = () => {
 const ExcalidrawApp = () => {
   return (
     <TopErrorBoundary>
-      <Provider unstable_createStore={() => jotaiStore}>
+      <Provider unstable_createStore={() => appJotaiStore}>
         <ExcalidrawWrapper />
       </Provider>
     </TopErrorBoundary>

+ 16 - 0
src/i18n.ts

@@ -1,6 +1,8 @@
 import fallbackLangData from "./locales/en.json";
 import percentages from "./locales/percentages.json";
 import { ENV } from "./constants";
+import { jotaiScope, jotaiStore } from "./jotai";
+import { atom, useAtomValue } from "jotai";
 
 const COMPLETION_THRESHOLD = 85;
 
@@ -99,6 +101,8 @@ export const setLanguage = async (lang: Language) => {
       currentLangData = fallbackLangData;
     }
   }
+
+  jotaiStore.set(editorLangCodeAtom, lang.code);
 };
 
 export const getLanguage = () => currentLang;
@@ -143,3 +147,15 @@ export const t = (
   }
   return translation;
 };
+
+/** @private atom used solely to rerender components using `useI18n` hook */
+const editorLangCodeAtom = atom(defaultLang.code);
+
+// Should be used in components that fall under these cases:
+// - component is rendered as an <Excalidraw> child
+// - component is rendered internally by <Excalidraw>, but the component
+//   is memoized w/o being updated on `langCode`, `AppState`, or `UIAppState`
+export const useI18n = () => {
+  const langCode = useAtomValue(editorLangCodeAtom, jotaiScope);
+  return { t, langCode };
+};

+ 2 - 2
src/jotai.ts

@@ -1,4 +1,4 @@
-import { unstable_createStore, useAtom, WritableAtom } from "jotai";
+import { PrimitiveAtom, unstable_createStore, useAtom } from "jotai";
 import { useLayoutEffect } from "react";
 
 export const jotaiScope = Symbol();
@@ -6,7 +6,7 @@ export const jotaiStore = unstable_createStore();
 
 export const useAtomWithInitialValue = <
   T extends unknown,
-  A extends WritableAtom<T, T>,
+  A extends PrimitiveAtom<T>,
 >(
   atom: A,
   initialValue: T | (() => T),

+ 2 - 0
src/packages/excalidraw/CHANGELOG.md

@@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section.
 
 ### Features
 
+- Expose `useI18n()` hook return an object containing `t()` i18n helper and current `langCode`. You can use this in components you render as `<Excalidraw>` children to render any of our i18n locale strings. [#6224](https://github.com/excalidraw/excalidraw/pull/6224)
+
 - [`restoreElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils/restore#restoreelements) API now takes an optional parameter `opts` which currently supports the below attributes
 
 ```js

+ 5 - 5
src/packages/excalidraw/index.tsx

@@ -87,8 +87,8 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
   }, []);
 
   return (
-    <InitializeApp langCode={langCode} theme={theme}>
-      <Provider unstable_createStore={() => jotaiStore} scope={jotaiScope}>
+    <Provider unstable_createStore={() => jotaiStore} scope={jotaiScope}>
+      <InitializeApp langCode={langCode} theme={theme}>
         <App
           onChange={onChange}
           initialData={initialData}
@@ -118,8 +118,8 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
         >
           {children}
         </App>
-      </Provider>
-    </InitializeApp>
+      </InitializeApp>
+    </Provider>
   );
 };
 
@@ -198,7 +198,7 @@ export {
   isInvisiblySmallElement,
   getNonDeletedElements,
 } from "../../element";
-export { defaultLang, languages } from "../../i18n";
+export { defaultLang, useI18n, languages } from "../../i18n";
 export {
   restore,
   restoreAppState,