Refactor portals/outside clicks in sticker creator

This commit is contained in:
Jamie Kyle 2023-10-04 20:28:36 -07:00 committed by GitHub
parent 8172840535
commit 4973dac57a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 96 additions and 198 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -81,6 +81,6 @@
}
&:dir(rtl) {
// stylelint-disable-next-line declaration-property-value-disallowed-list
transform: translate(50%, 0px)
transform: translate(50%, 0px);
}
}

View file

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

View file

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

View file

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

View file

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