diff --git a/package-lock.json b/package-lock.json index 67cc81c7e..2759a52cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "timeout-signal": "^2.0.0", "tmp": "^0.2.1", "typed-redux-saga": "^1.5.0", + "use-sync-external-store": "^1.2.0", "uuid": "^9.0.0", "validator": "^13.11.0", "xml-js": "^1.6.11", @@ -93,6 +94,7 @@ "@types/remote-redux-devtools": "^0.5.5", "@types/tmp": "^0.2.3", "@types/urijs": "^1.19.19", + "@types/use-sync-external-store": "^0.0.4", "@types/uuid": "^9.0.2", "@types/validator": "^13.11.1", "@types/xmldom": "^0.1.31", @@ -4473,6 +4475,12 @@ "integrity": "sha512-FDJNkyhmKLw7uEvTxx5tSXfPeQpO0iy73Ry+PmYZJvQy0QIWX8a7kJ4kLWRf+EbTPJEPDSgPXHaM7pzr5lmvCg==", "dev": true }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.4.tgz", + "integrity": "sha512-DMBc2WDEfaGsWXqH/Sk2oBaUkvlUwqgt/YEygpqX0MaiEjqR7afd1QgE4Pq2zBr/TRz0Mpu92eBBo5UQjtTD5Q==", + "dev": true + }, "node_modules/@types/uuid": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.2.tgz", @@ -26371,6 +26379,14 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/utf8-byte-length": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz", diff --git a/package.json b/package.json index a016b6248..cedf778f4 100644 --- a/package.json +++ b/package.json @@ -297,6 +297,7 @@ "timeout-signal": "^2.0.0", "tmp": "^0.2.1", "typed-redux-saga": "^1.5.0", + "use-sync-external-store": "^1.2.0", "uuid": "^9.0.0", "validator": "^13.11.0", "xml-js": "^1.6.11", @@ -334,6 +335,7 @@ "@types/remote-redux-devtools": "^0.5.5", "@types/tmp": "^0.2.3", "@types/urijs": "^1.19.19", + "@types/use-sync-external-store": "^0.0.4", "@types/uuid": "^9.0.2", "@types/validator": "^13.11.1", "@types/xmldom": "^0.1.31", diff --git a/src/common/services/translator.ts b/src/common/services/translator.ts index fbdd2d6cd..9e96f8fbd 100644 --- a/src/common/services/translator.ts +++ b/src/common/services/translator.ts @@ -191,7 +191,23 @@ export type I18nTyped = TFunction; @injectable() export class Translator { public translate = this._translate as I18nTyped; + public subscribe = this._subscribe.bind(this); private locale = "en"; + private listeners: Set<() => void>; + + constructor() { + this.listeners = new Set(); + } + + private _subscribe(fn: () => void) { + if (fn) { + this.listeners.add(fn); + return () => { + this.listeners.delete(fn); + }; + } + return () => {}; + } public getLocale(): string { return this.locale; @@ -215,6 +231,10 @@ export class Translator { } else { resolve(); } + }).finally(() => { + for (const listener of this.listeners) { + listener(); + } }); } diff --git a/src/renderer/common/hooks/useApi.ts b/src/renderer/common/hooks/useApi.ts new file mode 100644 index 000000000..c312b4dc4 --- /dev/null +++ b/src/renderer/common/hooks/useApi.ts @@ -0,0 +1,28 @@ +import * as React from "react"; +import { ReactReduxContext } from "react-redux"; +import { v4 as uuidv4 } from "uuid"; +import { TApiMethod, TApiMethodName } from "readium-desktop/common/api/api.type"; +import { TModuleApi } from "readium-desktop/common/api/moduleApi.type"; +import { TMethodApi } from "readium-desktop/common/api/methodApi.type"; +import { apiActions } from "readium-desktop/common/redux/actions"; +import { ApiResponse } from "readium-desktop/common/redux/states/api"; +import { TReturnPromiseOrGeneratorType } from "readium-desktop/typings/api"; + +export function useApi(_requestId: string, apiPath: T, ...requestData: Parameters): ApiResponse> { + + const requestId = _requestId || React.useMemo(() => uuidv4(), []); + const { store } = React.useContext(ReactReduxContext); + React.useEffect(() => { + const splitPath = apiPath.split("/"); + const moduleId = splitPath[0] as TModuleApi; + const methodId = splitPath[1] as TMethodApi; + store.dispatch(apiActions.request.build(requestId, moduleId, methodId, requestData)); + + return () => { + store.dispatch(apiActions.clean.build(requestId)); + }; + }, []); // componentDidMount + + const apiResult = React.useSyncExternalStore(store.subscribe, () => store.getState().api[requestId]); + return apiResult; +}; diff --git a/src/renderer/common/hooks/useDispatch.ts b/src/renderer/common/hooks/useDispatch.ts new file mode 100644 index 000000000..255ca7664 --- /dev/null +++ b/src/renderer/common/hooks/useDispatch.ts @@ -0,0 +1,10 @@ +import * as React from "react"; +import { ReactReduxContext} from "react-redux"; +import { Action } from "../../../common/models/redux"; + +export function useDispatch(action: Action) { + + const {store} = React.useContext(ReactReduxContext); + const actionReturned = store.dispatch(action); + return actionReturned; +} diff --git a/src/renderer/common/hooks/useKeyboardShortcut.ts b/src/renderer/common/hooks/useKeyboardShortcut.ts new file mode 100644 index 000000000..4a172a9a6 --- /dev/null +++ b/src/renderer/common/hooks/useKeyboardShortcut.ts @@ -0,0 +1,16 @@ +import * as React from "react"; +import { TKeyboardShortcutReadOnly } from "readium-desktop/common/keyboard"; +import { registerKeyboardListener, unregisterKeyboardListener } from "../keyboard"; +import { useSelector } from "./useSelector"; +import { ICommonRootState } from "readium-desktop/common/redux/states/commonRootState"; + +export function useKeyboardShortcut(ListenForKeyUP: boolean, keyboardShortcut: (s: ICommonRootState["keyboard"]["shortcuts"]) => TKeyboardShortcutReadOnly, callback: () => void) { + + const keyboardShortcutState = useSelector((state: ICommonRootState) => state.keyboard.shortcuts); + React.useEffect(() => { + registerKeyboardListener(ListenForKeyUP, keyboardShortcut(keyboardShortcutState), callback); + return () => unregisterKeyboardListener(callback); + }, [keyboardShortcutState]); + + return ; +} diff --git a/src/renderer/common/hooks/useSelector.ts b/src/renderer/common/hooks/useSelector.ts new file mode 100644 index 000000000..9f51fcc4b --- /dev/null +++ b/src/renderer/common/hooks/useSelector.ts @@ -0,0 +1,9 @@ +import * as React from "react"; +import { ReactReduxContext, ReactReduxContextValue } from "react-redux"; + +export function useSelector(selector: (state: State) => Selected): Selected { + + const {store} = React.useContext>(ReactReduxContext); + const selected = React.useSyncExternalStore(store.subscribe, () => selector(store.getState())); + return selected; +} diff --git a/src/renderer/common/hooks/useTranslator.ts b/src/renderer/common/hooks/useTranslator.ts new file mode 100644 index 000000000..3e069cbe4 --- /dev/null +++ b/src/renderer/common/hooks/useTranslator.ts @@ -0,0 +1,19 @@ +import * as React from "react"; +import { Translator } from "readium-desktop/common/services/translator"; +import { TranslatorContext } from "readium-desktop/renderer/common/translator.context"; + +export function useTranslator(): [typeof Translator.prototype.translate, Translator] { + + const translator = React.useContext(TranslatorContext); + const { translate: __ } = translator; + + const [, forceUpdate] = React.useReducer(x => x + 1, 0); + React.useEffect(() => { + const handleLocaleChange = () => { + forceUpdate(); + }; + return translator.subscribe(handleLocaleChange); + }, [translator.subscribe]); + + return [__, translator]; +} diff --git a/src/renderer/library/components/HooksTest.tsx b/src/renderer/library/components/HooksTest.tsx new file mode 100644 index 000000000..b50096f6a --- /dev/null +++ b/src/renderer/library/components/HooksTest.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; +import { log } from "console"; +import { ILibraryRootState } from "../redux/states"; +import { useTranslator } from "readium-desktop/renderer/common/hooks/useTranslator"; +import { useSelector } from "readium-desktop/renderer/common/hooks/useSelector"; +import { useApi } from "readium-desktop/renderer/common/hooks/useApi"; +// import { v4 as uuidv4 } from "uuid"; +// import { TApiMethod, TApiMethodName } from "readium-desktop/common/api/api.type"; +// import { ReactReduxContext} from 'react-redux' +// import { TModuleApi } from "readium-desktop/common/api/moduleApi.type"; +// import { TMethodApi } from "readium-desktop/common/api/methodApi.type"; +// import { apiActions } from "readium-desktop/common/redux/actions"; +// import { useSyncExternalStore } from "readium-desktop/renderer/common/hooks/useSyncExternalStore"; + +export function HooksTest(props: { name: string }) { + + const name = props.name; + // useSelector from react-redux + // const {locale} = useSelector((state: ILibraryRootState) => { + + // log(Date.now().toString(), "useSelector Locale"); + // return state.i18n; + + // } + // , + // (a, b) => { + + // log("cond: ", a, b, a === b); + // return a === b + // } + // ); + + // const translator = React.useContext(TranslatorContext); + // const { translate: _ } = translator; + // // useSyncExternalStore(translator.subscribe, () => {console.log("a"); return translator.getLocale();}); + + // const [, forceUpdate] = React.useReducer(x => x + 1, 0); + // React.useEffect(() => { + // const handleLocaleChange = () => { + // forceUpdate(); + // } + // return translator.subscribe(handleLocaleChange); + // }, [translator.subscribe]); + const [__] = useTranslator(); + // let _ = (_a: any) => {}; + + // const {store} = React.useContext>(ReactReduxContext); + // const id = useSyncExternalStore(store.subscribe, () => store.getState().win.identifier); + // log(id); + + // useSelector custom with just useSyncExternalStore + const id = useSelector((state: ILibraryRootState) => state.win.identifier); + // let id = ""; + log(id); + + // const requestId = React.useMemo(() => uuidv4(), []); + const requestId: string = null; + log(requestId); + // useApi + // const [requestId] = React.useState('test'); + // useDispatchApi(requestId, "publication/findAll"); + + // const { store } = React.useContext(ReactReduxContext); + // React.useEffect(() => { + // const apiPath = "publication/findAll"; + // const requestData: any[] = []; + // const splitPath = apiPath.split("/"); + // const moduleId = splitPath[0] as TModuleApi; + // const methodId = splitPath[1] as TMethodApi; + // store.dispatch(apiActions.request.build(requestId, moduleId, methodId, requestData)) + // }, []); + + // const allPubRes = useSyncExternalStore(store.subscribe, () => store.getState().api[requestId]); + const allPubRes = useApi(requestId, "publication/findAll"); + log(allPubRes); + log(Date.now().toString(), "rendered"); + return

Hello : {name} : id {id} : translate {__("header.books")}

; +} diff --git a/src/renderer/library/components/SecondaryHeader.tsx b/src/renderer/library/components/SecondaryHeader.tsx index 5ee3130b6..53b7c0890 100644 --- a/src/renderer/library/components/SecondaryHeader.tsx +++ b/src/renderer/library/components/SecondaryHeader.tsx @@ -11,7 +11,7 @@ import * as stylesHeader from "readium-desktop/renderer/assets/styles/header.css import { TranslatorProps, withTranslator } from "../../common/components/hoc/translator"; // eslint-disable-next-line @typescript-eslint/no-empty-interface -interface IBaseProps extends TranslatorProps, React.PropsWithChildren { +interface IBaseProps extends TranslatorProps { style?: React.CSSProperties; id?: string; } diff --git a/src/renderer/library/components/layout/LibraryHeader.tsx b/src/renderer/library/components/layout/LibraryHeader.tsx index 873d8de31..5060292f9 100644 --- a/src/renderer/library/components/layout/LibraryHeader.tsx +++ b/src/renderer/library/components/layout/LibraryHeader.tsx @@ -7,15 +7,13 @@ import classNames from "classnames"; import * as React from "react"; -import { connect } from "react-redux"; import { Link } from "react-router-dom"; import * as stylesHeader from "readium-desktop/renderer/assets/styles/header.css"; -import { - TranslatorProps, withTranslator, -} from "readium-desktop/renderer/common/components/hoc/translator"; import SkipLink from "readium-desktop/renderer/common/components/SkipLink"; import { ILibraryRootState } from "readium-desktop/renderer/library/redux/states"; import { DisplayType, IRouterLocationState } from "../../routing"; +import { useTranslator } from "readium-desktop/renderer/common/hooks/useTranslator"; +import { useSelector } from "readium-desktop/renderer/common/hooks/useSelector"; interface NavigationHeader { route: string; @@ -45,111 +43,91 @@ const headerNav: NavigationHeader[] = [ }, ]; -// eslint-disable-next-line @typescript-eslint/no-empty-interface -interface IBaseProps extends TranslatorProps { -} -// IProps may typically extend: -// RouteComponentProps -// ReturnType -// ReturnType -// eslint-disable-next-line @typescript-eslint/no-empty-interface -interface IProps extends IBaseProps, ReturnType { -} - -class Header extends React.Component { - constructor(props: IProps) { - super(props); +const Header = () => { + const [__] = useTranslator(); + return (<> + + + ); +}; + +const NavItem = (props: {item: NavigationHeader, index: number}) => { + const location = useSelector((state: ILibraryRootState) => state.router.location); + const history = useSelector((state: ILibraryRootState) => state.history); + const {item, index} = props; + + if (!location) { + return (<>); } - public render(): React.ReactElement<{}> { - const { __ } = this.props; - - return (<> - - - ); - } - - private buildNavItem(item: NavigationHeader, index: number) { - - if (!this.props.location) { - return (<>); - } - - // because dynamic label does not pass typed i18n compilation - const translate = this.props.__ as (str: string) => string; - - let styleClasses = []; - const pathname = this.props.location.pathname; - - let active = false; - for (const matchRoute of item.matchRoutes) { - if ( - pathname.startsWith(matchRoute) - && ( - (pathname === "/" && matchRoute === pathname) - || matchRoute !== "/" - ) - ) { - active = true; - styleClasses.push(stylesHeader.active); - break; - } + let styleClasses = []; + const pathname = location.pathname; + + let active = false; + for (const matchRoute of item.matchRoutes) { + if ( + pathname.startsWith(matchRoute) + && ( + (pathname === "/" && matchRoute === pathname) + || matchRoute !== "/" + ) + ) { + active = true; + styleClasses.push(stylesHeader.active); + break; } - styleClasses = styleClasses.concat(item.styles); - - const nextLocation = this.props.history.reduce( - (pv, cv) => - cv?.pathname?.startsWith(item.route) - ? { - ...this.props.location, - pathname: cv.pathname, - } - : pv, - { - ...this.props.location, - pathname: item.route, - }, - ); - - return ( -
  • - - { - translate("header." + item.label) - } - -
  • - ); } -} - -const mapStateToProps = (state: ILibraryRootState) => ({ - location: state.router.location, - history: state.history, - locale: state.i18n.locale, // used for automatic refresh to force the rendering of header -}); - -export default connect(mapStateToProps)(withTranslator(Header)); + styleClasses = styleClasses.concat(item.styles); + + const nextLocation = history.reduce( + (pv, cv) => + cv?.pathname?.startsWith(item.route) + ? { + ...location, + pathname: cv.pathname, + } + : pv, + { + ...location, + pathname: item.route, + }, + ); + const [__] = useTranslator(); + const translate = __ as (str: string) => string; + return ( +
  • + + { + translate(("header." + item.label) as any) + } + +
  • + ); +}; + +export default Header;