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

This commit is contained in:
David Luzar 2023-02-22 15:01:23 +01:00 committed by GitHub
parent e4506be3e8
commit 04a8c22f39
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 88 additions and 72 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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