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

View File

@ -1,6 +1,6 @@
import { actionLoadScene, actionShortcuts } from "../../actions"; import { actionLoadScene, actionShortcuts } from "../../actions";
import { getShortcutFromShortcutName } from "../../actions/shortcuts"; import { getShortcutFromShortcutName } from "../../actions/shortcuts";
import { t } from "../../i18n"; import { t, useI18n } from "../../i18n";
import { import {
useDevice, useDevice,
useExcalidrawActionManager, useExcalidrawActionManager,
@ -172,10 +172,7 @@ const MenuItemLiveCollaborationTrigger = ({
}: { }: {
onSelect: () => any; onSelect: () => any;
}) => { }) => {
// FIXME when we tie t() to lang state const { t } = useI18n();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const appState = useExcalidrawAppState();
return ( return (
<WelcomeScreenMenuItem shortcut={null} onSelect={onSelect} icon={usersIcon}> <WelcomeScreenMenuItem shortcut={null} onSelect={onSelect} icon={usersIcon}>
{t("labels.liveCollaboration")} {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 { resetBrowserStateVersions } from "../data/tabSync";
import { LocalData } from "../data/LocalData"; import { LocalData } from "../data/LocalData";
import { atom, useAtom } from "jotai"; import { atom, useAtom } from "jotai";
import { jotaiStore } from "../../jotai"; import { appJotaiStore } from "../app-jotai";
export const collabAPIAtom = atom<CollabAPI | null>(null); export const collabAPIAtom = atom<CollabAPI | null>(null);
export const collabDialogShownAtom = atom(false); export const collabDialogShownAtom = atom(false);
@ -167,7 +167,7 @@ class Collab extends PureComponent<Props, CollabState> {
setUsername: this.setUsername, setUsername: this.setUsername,
}; };
jotaiStore.set(collabAPIAtom, collabAPI); appJotaiStore.set(collabAPIAtom, collabAPI);
this.onOfflineStatusToggle(); this.onOfflineStatusToggle();
if ( if (
@ -185,7 +185,7 @@ class Collab extends PureComponent<Props, CollabState> {
} }
onOfflineStatusToggle = () => { onOfflineStatusToggle = () => {
jotaiStore.set(isOfflineAtom, !window.navigator.onLine); appJotaiStore.set(isOfflineAtom, !window.navigator.onLine);
}; };
componentWillUnmount() { componentWillUnmount() {
@ -208,10 +208,10 @@ class Collab extends PureComponent<Props, CollabState> {
} }
} }
isCollaborating = () => jotaiStore.get(isCollaboratingAtom)!; isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
private setIsCollaborating = (isCollaborating: boolean) => { private setIsCollaborating = (isCollaborating: boolean) => {
jotaiStore.set(isCollaboratingAtom, isCollaborating); appJotaiStore.set(isCollaboratingAtom, isCollaborating);
}; };
private onUnload = () => { private onUnload = () => {
@ -804,7 +804,7 @@ class Collab extends PureComponent<Props, CollabState> {
); );
handleClose = () => { handleClose = () => {
jotaiStore.set(collabDialogShownAtom, false); appJotaiStore.set(collabDialogShownAtom, false);
}; };
setUsername = (username: string) => { setUsername = (username: string) => {

View File

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

View File

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

View File

@ -1,17 +1,21 @@
import { shield } from "../../components/icons"; import { shield } from "../../components/icons";
import { Tooltip } from "../../components/Tooltip"; import { Tooltip } from "../../components/Tooltip";
import { t } from "../../i18n"; import { useI18n } from "../../i18n";
export const EncryptedIcon = () => ( export const EncryptedIcon = () => {
<a const { t } = useI18n();
className="encrypted-icon tooltip"
href="https://blog.excalidraw.com/end-to-end-encryption/" return (
target="_blank" <a
rel="noopener noreferrer" className="encrypted-icon tooltip"
aria-label={t("encrypted.link")} href="https://blog.excalidraw.com/end-to-end-encryption/"
> target="_blank"
<Tooltip label={t("encrypted.tooltip")} long={true}> rel="noopener noreferrer"
{shield} aria-label={t("encrypted.link")}
</Tooltip> >
</a> <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 { FileId, NonDeletedExcalidrawElement } from "../../element/types";
import { AppState, BinaryFileData, BinaryFiles } from "../../types"; import { AppState, BinaryFileData, BinaryFiles } from "../../types";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { t } from "../../i18n"; import { useI18n } from "../../i18n";
import { excalidrawPlusIcon } from "./icons"; import { excalidrawPlusIcon } from "./icons";
import { encryptData, generateEncryptionKey } from "../../data/encryption"; import { encryptData, generateEncryptionKey } from "../../data/encryption";
import { isInitializedImageElement } from "../../element/typeChecks"; import { isInitializedImageElement } from "../../element/typeChecks";
@ -79,6 +79,7 @@ export const ExportToExcalidrawPlus: React.FC<{
files: BinaryFiles; files: BinaryFiles;
onError: (error: Error) => void; onError: (error: Error) => void;
}> = ({ elements, appState, files, onError }) => { }> = ({ elements, appState, files, onError }) => {
const { t } = useI18n();
return ( return (
<Card color="primary"> <Card color="primary">
<div className="Card-icon">{excalidrawPlusIcon}</div> <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 React from "react";
import { langCodeAtom } from ".."; import { appLangCodeAtom } from "..";
import * as i18n from "../../i18n"; import { defaultLang, useI18n } from "../../i18n";
import { languages } from "../../i18n"; import { languages } from "../../i18n";
export const LanguageList = ({ style }: { style?: React.CSSProperties }) => { export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
const [langCode, setLangCode] = useAtom(langCodeAtom); const { t, langCode } = useI18n();
const setLangCode = useSetAtom(appLangCodeAtom);
return ( return (
<select <select
className="dropdown-select dropdown-select__language" className="dropdown-select dropdown-select__language"
onChange={({ target }) => setLangCode(target.value)} onChange={({ target }) => setLangCode(target.value)}
value={langCode} value={langCode}
aria-label={i18n.t("buttons.selectLanguage")} aria-label={t("buttons.selectLanguage")}
style={style} style={style}
> >
<option key={i18n.defaultLang.code} value={i18n.defaultLang.code}> <option key={defaultLang.code} value={defaultLang.code}>
{i18n.defaultLang.label} {defaultLang.label}
</option> </option>
{languages.map((lang) => ( {languages.map((lang) => (
<option key={lang.code} value={lang.code}> <option key={lang.code} value={lang.code}>

View File

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

View File

@ -1,6 +1,8 @@
import fallbackLangData from "./locales/en.json"; import fallbackLangData from "./locales/en.json";
import percentages from "./locales/percentages.json"; import percentages from "./locales/percentages.json";
import { ENV } from "./constants"; import { ENV } from "./constants";
import { jotaiScope, jotaiStore } from "./jotai";
import { atom, useAtomValue } from "jotai";
const COMPLETION_THRESHOLD = 85; const COMPLETION_THRESHOLD = 85;
@ -99,6 +101,8 @@ export const setLanguage = async (lang: Language) => {
currentLangData = fallbackLangData; currentLangData = fallbackLangData;
} }
} }
jotaiStore.set(editorLangCodeAtom, lang.code);
}; };
export const getLanguage = () => currentLang; export const getLanguage = () => currentLang;
@ -143,3 +147,15 @@ export const t = (
} }
return translation; 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"; import { useLayoutEffect } from "react";
export const jotaiScope = Symbol(); export const jotaiScope = Symbol();
@ -6,7 +6,7 @@ export const jotaiStore = unstable_createStore();
export const useAtomWithInitialValue = < export const useAtomWithInitialValue = <
T extends unknown, T extends unknown,
A extends WritableAtom<T, T>, A extends PrimitiveAtom<T>,
>( >(
atom: A, atom: A,
initialValue: T | (() => T), initialValue: T | (() => T),

View File

@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section.
### Features ### 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 - [`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 ```js

View File

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