Refactor portals/outside clicks in sticker creator
This commit is contained in:
parent
8172840535
commit
4973dac57a
11 changed files with 96 additions and 198 deletions
|
@ -25,6 +25,7 @@
|
|||
"@formatjs/fast-memoize": "1.2.8",
|
||||
"@indutny/emoji-picker-react": "4.4.9",
|
||||
"@popperjs/core": "2.11.7",
|
||||
"@react-aria/interactions": "3.19.0",
|
||||
"@reduxjs/toolkit": "1.9.5",
|
||||
"@stablelib/x25519": "1.0.3",
|
||||
"base64-js": "1.5.1",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
|
@ -11,6 +11,7 @@ import {
|
|||
} from 'react-popper';
|
||||
import type { EmojiClickData } from '@indutny/emoji-picker-react';
|
||||
|
||||
import { useInteractOutside } from '@react-aria/interactions';
|
||||
import { AddEmoji } from '../elements/icons';
|
||||
import type { Props as DropZoneProps } from '../elements/DropZone';
|
||||
import { DropZone } from '../elements/DropZone';
|
||||
|
@ -19,11 +20,9 @@ import { Spinner } from '../elements/Spinner';
|
|||
import styles from './ArtFrame.module.scss';
|
||||
import { useI18n } from '../contexts/I18n';
|
||||
import { assert } from '../util/assert';
|
||||
import { noop } from '../util/noop';
|
||||
import { ArtType } from '../constants';
|
||||
import type { EmojiData } from '../types.d';
|
||||
import EMOJI_SHEET from '../assets/emoji.webp';
|
||||
import { PopperRootContext } from './PopperRootContext';
|
||||
import EmojiPicker from './EmojiPicker';
|
||||
|
||||
export type Mode = 'removable' | 'pick-emoji' | 'add';
|
||||
|
@ -71,11 +70,9 @@ export const ArtFrame = React.memo(function ArtFrame({
|
|||
}: Props) {
|
||||
const i18n = useI18n();
|
||||
const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false);
|
||||
const [emojiPopperRoot, setEmojiPopperRoot] =
|
||||
React.useState<HTMLElement | null>(null);
|
||||
const emojiPickerPopperRef = useRef<HTMLElement>(null);
|
||||
const [previewActive, setPreviewActive] = React.useState(false);
|
||||
const [previewPopperRoot, setPreviewPopperRoot] =
|
||||
React.useState<HTMLElement | null>(null);
|
||||
const previewPopperRef = useRef<HTMLElement>(null);
|
||||
const timerRef = React.useRef<number>();
|
||||
|
||||
const handleToggleEmojiPicker = React.useCallback(() => {
|
||||
|
@ -138,51 +135,19 @@ export const ArtFrame = React.memo(function ArtFrame({
|
|||
[timerRef]
|
||||
);
|
||||
|
||||
const { createRoot, removeRoot } = React.useContext(PopperRootContext);
|
||||
useInteractOutside({
|
||||
ref: emojiPickerPopperRef,
|
||||
onInteractOutside() {
|
||||
setEmojiPickerOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
// Create popper root and handle outside clicks
|
||||
React.useEffect(() => {
|
||||
if (emojiPickerOpen) {
|
||||
const root = createRoot();
|
||||
setEmojiPopperRoot(root);
|
||||
const handleOutsideClick = ({ target }: MouseEvent) => {
|
||||
const targetNode = target as HTMLElement;
|
||||
const button = targetNode.closest(`button.${styles.emojiButton}`);
|
||||
if (!root.contains(targetNode) && !button) {
|
||||
setEmojiPickerOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleOutsideClick);
|
||||
|
||||
return () => {
|
||||
removeRoot(root);
|
||||
setEmojiPopperRoot(null);
|
||||
document.removeEventListener('click', handleOutsideClick);
|
||||
};
|
||||
}
|
||||
|
||||
return noop;
|
||||
}, [createRoot, emojiPickerOpen, removeRoot]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mode !== 'pick-emoji' && image && previewActive) {
|
||||
const root = createRoot();
|
||||
setPreviewPopperRoot(root);
|
||||
|
||||
return () => {
|
||||
removeRoot(root);
|
||||
};
|
||||
}
|
||||
|
||||
return noop;
|
||||
}, [
|
||||
createRoot,
|
||||
image,
|
||||
mode,
|
||||
previewActive,
|
||||
removeRoot,
|
||||
setPreviewPopperRoot,
|
||||
]);
|
||||
useInteractOutside({
|
||||
ref: previewPopperRef,
|
||||
onInteractOutside() {
|
||||
setPreviewActive(false);
|
||||
},
|
||||
});
|
||||
|
||||
const [dragActive, setDragActive] = React.useState<boolean>(false);
|
||||
|
||||
|
@ -246,23 +211,27 @@ export const ArtFrame = React.memo(function ArtFrame({
|
|||
</button>
|
||||
)}
|
||||
</PopperReference>
|
||||
{emojiPickerOpen && emojiPopperRoot
|
||||
{emojiPickerOpen
|
||||
? createPortal(
|
||||
<Popper placement="bottom-start">
|
||||
<Popper
|
||||
innerRef={emojiPickerPopperRef}
|
||||
placement="bottom-start"
|
||||
>
|
||||
{({ ref, style }) => (
|
||||
<div ref={ref} style={{ ...style, marginTop: '8px' }}>
|
||||
<EmojiPicker onEmojiClick={handlePickEmoji} />
|
||||
</div>
|
||||
)}
|
||||
</Popper>,
|
||||
emojiPopperRoot
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</PopperManager>
|
||||
) : null}
|
||||
{mode !== 'pick-emoji' && image && previewActive && previewPopperRoot
|
||||
{mode !== 'pick-emoji' && image && previewActive
|
||||
? createPortal(
|
||||
<Popper
|
||||
innerRef={previewPopperRef}
|
||||
placement="bottom"
|
||||
modifiers={[
|
||||
{ name: 'offset', options: { offset: [undefined, 8] } },
|
||||
|
@ -281,7 +250,7 @@ export const ArtFrame = React.memo(function ArtFrame({
|
|||
);
|
||||
}}
|
||||
</Popper>,
|
||||
previewPopperRoot
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
|
|
|
@ -1,45 +1,23 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useInteractOutside } from '@react-aria/interactions';
|
||||
import styles from './ConfirmModal.module.scss';
|
||||
import type { Props } from '../elements/ConfirmDialog';
|
||||
import { ConfirmDialog } from '../elements/ConfirmDialog';
|
||||
|
||||
export type Mode = 'removable' | 'pick-emoji' | 'add';
|
||||
|
||||
export const ConfirmModal = React.memo(function ConfirmModalInner(
|
||||
props: Props & { buttonRef: React.RefObject<HTMLElement> }
|
||||
) {
|
||||
const { buttonRef, onCancel } = props;
|
||||
const [popperRoot, setPopperRoot] = React.useState<HTMLDivElement>();
|
||||
|
||||
// Create popper root and handle outside clicks
|
||||
React.useEffect(() => {
|
||||
const root = document.createElement('div');
|
||||
setPopperRoot(root);
|
||||
document.body.appendChild(root);
|
||||
const handleOutsideClick = ({ target }: MouseEvent) => {
|
||||
const node = target as Node;
|
||||
if (!root.contains(node) && !buttonRef.current?.contains(node)) {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleOutsideClick);
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(root);
|
||||
document.removeEventListener('click', handleOutsideClick);
|
||||
};
|
||||
}, [onCancel, buttonRef]);
|
||||
|
||||
return popperRoot
|
||||
? createPortal(
|
||||
<div className={styles.facade}>
|
||||
<ConfirmDialog {...props} />
|
||||
</div>,
|
||||
popperRoot
|
||||
)
|
||||
: null;
|
||||
});
|
||||
export function ConfirmModal(props: Props): JSX.Element {
|
||||
const { onCancel } = props;
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useInteractOutside({ ref, onInteractOutside: onCancel });
|
||||
return createPortal(
|
||||
<div className={styles.facade}>
|
||||
<ConfirmDialog ref={ref} {...props} />
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const makeApi = (classes?: Array<string>) => ({
|
||||
createRoot: () => {
|
||||
const div = document.createElement('div');
|
||||
|
||||
if (classes) {
|
||||
classes.forEach(theme => {
|
||||
div.classList.add(theme);
|
||||
});
|
||||
}
|
||||
|
||||
document.body.appendChild(div);
|
||||
|
||||
return div;
|
||||
},
|
||||
removeRoot: (root: HTMLElement) => {
|
||||
document.body.removeChild(root);
|
||||
},
|
||||
});
|
||||
|
||||
export const PopperRootContext = React.createContext(makeApi());
|
||||
|
||||
export type ClassyProviderProps = {
|
||||
classes?: Array<string>;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ClassyProvider({
|
||||
classes,
|
||||
children,
|
||||
}: ClassyProviderProps): JSX.Element {
|
||||
const api = React.useMemo(() => makeApi(classes), [classes]);
|
||||
|
||||
return (
|
||||
<PopperRootContext.Provider value={api}>
|
||||
{children}
|
||||
</PopperRootContext.Provider>
|
||||
);
|
||||
}
|
|
@ -31,7 +31,6 @@ const getClassName = ({ primary, pill }: Props) => {
|
|||
export function Button({
|
||||
className,
|
||||
children,
|
||||
buttonRef,
|
||||
primary,
|
||||
...otherProps
|
||||
}: React.PropsWithChildren<Props>): JSX.Element {
|
||||
|
@ -42,7 +41,6 @@ export function Button({
|
|||
getClassName({ primary, ...otherProps }),
|
||||
className
|
||||
)}
|
||||
ref={buttonRef}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import type { Ref } from 'react';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import { useI18n } from '../contexts/I18n';
|
||||
import styles from './ConfirmDialog.module.scss';
|
||||
|
@ -16,19 +17,15 @@ export type Props = Readonly<{
|
|||
onCancel: () => unknown;
|
||||
}>;
|
||||
|
||||
export function ConfirmDialog({
|
||||
title,
|
||||
children,
|
||||
confirm,
|
||||
cancel,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Props): JSX.Element {
|
||||
export const ConfirmDialog = forwardRef(function ConfirmDialog(
|
||||
{ title, children, confirm, cancel, onConfirm, onCancel }: Props,
|
||||
ref: Ref<HTMLDivElement>
|
||||
): JSX.Element {
|
||||
const i18n = useI18n();
|
||||
const cancelText = cancel || i18n('StickerCreator--ConfirmDialog--cancel');
|
||||
|
||||
return (
|
||||
<div className={styles.base}>
|
||||
<div ref={ref} className={styles.base}>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
<p className={styles.text}>{children}</p>
|
||||
<div className={styles.grow} />
|
||||
|
@ -40,4 +37,4 @@ export function ConfirmDialog({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -81,6 +81,6 @@
|
|||
}
|
||||
&:dir(rtl) {
|
||||
// stylelint-disable-next-line declaration-property-value-disallowed-list
|
||||
transform: translate(50%, 0px)
|
||||
transform: translate(50%, 0px);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,6 @@ export type Props = Readonly<{
|
|||
noScroll?: boolean;
|
||||
onNext?: () => unknown;
|
||||
onPrev?: () => unknown;
|
||||
nextButtonRef?: React.RefObject<HTMLButtonElement>;
|
||||
nextText?: string;
|
||||
showGuide?: boolean;
|
||||
setShowGuide?: (value: boolean) => unknown;
|
||||
|
@ -48,7 +47,6 @@ export function AppStage(props: Props): JSX.Element {
|
|||
next,
|
||||
nextActive,
|
||||
nextText,
|
||||
nextButtonRef,
|
||||
noScroll,
|
||||
onNext,
|
||||
onPrev,
|
||||
|
@ -99,7 +97,6 @@ export function AppStage(props: Props): JSX.Element {
|
|||
) : null}
|
||||
{next || onNext ? (
|
||||
<Button
|
||||
buttonRef={nextButtonRef}
|
||||
className={styles.button}
|
||||
onClick={onNext || handleNext}
|
||||
primary
|
||||
|
|
|
@ -35,7 +35,6 @@ export function MetaStage(): JSX.Element {
|
|||
const title = useTitle();
|
||||
const author = useAuthor();
|
||||
const [confirming, setConfirming] = React.useState(false);
|
||||
const buttonRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const onDrop = React.useCallback(
|
||||
async ([file]: Array<FileWithPath>) => {
|
||||
|
@ -69,7 +68,6 @@ export function MetaStage(): JSX.Element {
|
|||
|
||||
return (
|
||||
<AppStage
|
||||
nextButtonRef={buttonRef}
|
||||
onNext={onNext}
|
||||
nextActive={valid}
|
||||
noMessage
|
||||
|
@ -83,7 +81,6 @@ export function MetaStage(): JSX.Element {
|
|||
confirm={i18n('StickerCreator--MetaStage--ConfirmDialog--confirm')}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
buttonRef={buttonRef}
|
||||
>
|
||||
{i18n(`StickerCreator--MetaStage--ConfirmDialog--text--${artType}`)}
|
||||
</ConfirmModal>
|
||||
|
|
|
@ -585,6 +585,46 @@
|
|||
resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
|
||||
integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==
|
||||
|
||||
"@react-aria/interactions@3.19.0":
|
||||
version "3.19.0"
|
||||
resolved "https://registry.yarnpkg.com/@react-aria/interactions/-/interactions-3.19.0.tgz#37c14668e43f5c1fc737ddfa3cdf77311ce9c858"
|
||||
integrity sha512-nJ8VTmEOYJAAvV7wzeQVnamxWd3j16hGAzG++onjhluSWWKO1jMRN6WG9LDwvT5mBI0VYwf7JdVB3QBaCa9fsQ==
|
||||
dependencies:
|
||||
"@react-aria/ssr" "^3.8.0"
|
||||
"@react-aria/utils" "^3.21.0"
|
||||
"@react-types/shared" "^3.21.0"
|
||||
"@swc/helpers" "^0.5.0"
|
||||
|
||||
"@react-aria/ssr@^3.8.0":
|
||||
version "3.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@react-aria/ssr/-/ssr-3.8.0.tgz#e7f467ac42f72504682724304ce221f785d70d49"
|
||||
integrity sha512-Y54xs483rglN5DxbwfCPHxnkvZ+gZ0LbSYmR72LyWPGft8hN/lrl1VRS1EW2SMjnkEWlj+Km2mwvA3kEHDUA0A==
|
||||
dependencies:
|
||||
"@swc/helpers" "^0.5.0"
|
||||
|
||||
"@react-aria/utils@^3.21.0":
|
||||
version "3.21.0"
|
||||
resolved "https://registry.yarnpkg.com/@react-aria/utils/-/utils-3.21.0.tgz#147a91188d74f1aa284ad6d3d7db24b0de069fca"
|
||||
integrity sha512-0ZNaXgvbWnqqiG7FB0qhAIENN7CmBU30AnyTzz5ZZgvJexUJkhd2GMjvTqrBZ6zSjeMpUEIKg5PUA1eptGRPww==
|
||||
dependencies:
|
||||
"@react-aria/ssr" "^3.8.0"
|
||||
"@react-stately/utils" "^3.8.0"
|
||||
"@react-types/shared" "^3.21.0"
|
||||
"@swc/helpers" "^0.5.0"
|
||||
clsx "^1.1.1"
|
||||
|
||||
"@react-stately/utils@^3.8.0":
|
||||
version "3.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@react-stately/utils/-/utils-3.8.0.tgz#88a45742c58bde804f6cbecb20ea3833915cfdf0"
|
||||
integrity sha512-wCIoFDbt/uwNkWIBF+xV+21k8Z8Sj5qGO3uptTcVmjYcZngOaGGyB4NkiuZhmhG70Pkv+yVrRwoC1+4oav9cCg==
|
||||
dependencies:
|
||||
"@swc/helpers" "^0.5.0"
|
||||
|
||||
"@react-types/shared@^3.21.0":
|
||||
version "3.21.0"
|
||||
resolved "https://registry.yarnpkg.com/@react-types/shared/-/shared-3.21.0.tgz#1af41fdf7dfbdbd33bbc1210617c43ed0d4ef20c"
|
||||
integrity sha512-wJA2cUF8dP4LkuNUt9Vh2kkfiQb2NLnV2pPXxVnKJZ7d4x2/7VPccN+LYPnH8m0X3+rt50cxWuPKQmjxSsCFOg==
|
||||
|
||||
"@reduxjs/toolkit@1.9.5":
|
||||
version "1.9.5"
|
||||
resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.5.tgz#d3987849c24189ca483baa7aa59386c8e52077c4"
|
||||
|
@ -646,6 +686,13 @@
|
|||
"@stablelib/random" "^1.0.2"
|
||||
"@stablelib/wipe" "^1.0.1"
|
||||
|
||||
"@swc/helpers@^0.5.0":
|
||||
version "0.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.2.tgz#85ea0c76450b61ad7d10a37050289eded783c27d"
|
||||
integrity sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==
|
||||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@types/chai-subset@^1.3.3":
|
||||
version "1.3.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.3.tgz#97893814e92abd2c534de422cb377e0e0bdaac94"
|
||||
|
@ -1213,7 +1260,7 @@ cliui@^8.0.1:
|
|||
strip-ansi "^6.0.1"
|
||||
wrap-ansi "^7.0.0"
|
||||
|
||||
clsx@^1.2.1:
|
||||
clsx@^1.1.1, clsx@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
|
||||
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
const makeApi = (classes?: Array<string>) => ({
|
||||
createRoot: () => {
|
||||
const div = document.createElement('div');
|
||||
|
||||
if (classes) {
|
||||
classes.forEach(theme => {
|
||||
div.classList.add(theme);
|
||||
});
|
||||
}
|
||||
|
||||
document.body.appendChild(div);
|
||||
|
||||
return div;
|
||||
},
|
||||
removeRoot: (root: HTMLElement) => {
|
||||
document.body.removeChild(root);
|
||||
},
|
||||
});
|
||||
|
||||
export const PopperRootContext = React.createContext(makeApi());
|
||||
|
||||
export type ClassyProviderProps = {
|
||||
classes?: Array<string>;
|
||||
children?: React.ReactChildren;
|
||||
};
|
||||
|
||||
export function ClassyProvider({
|
||||
classes,
|
||||
children,
|
||||
}: ClassyProviderProps): JSX.Element {
|
||||
const api = React.useMemo(() => makeApi(classes), [classes]);
|
||||
|
||||
return (
|
||||
<PopperRootContext.Provider value={api}>
|
||||
{children}
|
||||
</PopperRootContext.Provider>
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue