From 4973dac57a0fe2b50573c13d53ae973942d055b8 Mon Sep 17 00:00:00 2001 From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Wed, 4 Oct 2023 20:28:36 -0700 Subject: [PATCH] Refactor portals/outside clicks in sticker creator --- sticker-creator/package.json | 1 + sticker-creator/src/components/ArtFrame.tsx | 81 ++++++------------- .../src/components/ConfirmModal.tsx | 48 +++-------- .../src/components/PopperRootContext.tsx | 43 ---------- sticker-creator/src/elements/Button.tsx | 2 - .../src/elements/ConfirmDialog.tsx | 19 ++--- .../src/routes/art/AppStage.module.scss | 2 +- sticker-creator/src/routes/art/AppStage.tsx | 3 - sticker-creator/src/routes/art/MetaStage.tsx | 3 - sticker-creator/yarn.lock | 49 ++++++++++- ts/components/PopperRootContext.tsx | 43 ---------- 11 files changed, 96 insertions(+), 198 deletions(-) delete mode 100644 sticker-creator/src/components/PopperRootContext.tsx delete mode 100644 ts/components/PopperRootContext.tsx diff --git a/sticker-creator/package.json b/sticker-creator/package.json index ae5810552..260109de6 100644 --- a/sticker-creator/package.json +++ b/sticker-creator/package.json @@ -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", diff --git a/sticker-creator/src/components/ArtFrame.tsx b/sticker-creator/src/components/ArtFrame.tsx index bd2393f66..e3bc2e1f5 100644 --- a/sticker-creator/src/components/ArtFrame.tsx +++ b/sticker-creator/src/components/ArtFrame.tsx @@ -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(null); + const emojiPickerPopperRef = useRef(null); const [previewActive, setPreviewActive] = React.useState(false); - const [previewPopperRoot, setPreviewPopperRoot] = - React.useState(null); + const previewPopperRef = useRef(null); const timerRef = React.useRef(); 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(false); @@ -246,23 +211,27 @@ export const ArtFrame = React.memo(function ArtFrame({ )} - {emojiPickerOpen && emojiPopperRoot + {emojiPickerOpen ? createPortal( - + {({ ref, style }) => (
)}
, - emojiPopperRoot + document.body ) : null} ) : null} - {mode !== 'pick-emoji' && image && previewActive && previewPopperRoot + {mode !== 'pick-emoji' && image && previewActive ? createPortal( , - previewPopperRoot + document.body ) : null} diff --git a/sticker-creator/src/components/ConfirmModal.tsx b/sticker-creator/src/components/ConfirmModal.tsx index 6348aed2d..3aedc8511 100644 --- a/sticker-creator/src/components/ConfirmModal.tsx +++ b/sticker-creator/src/components/ConfirmModal.tsx @@ -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 } -) { - const { buttonRef, onCancel } = props; - const [popperRoot, setPopperRoot] = React.useState(); - - // 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( -
- -
, - popperRoot - ) - : null; -}); +export function ConfirmModal(props: Props): JSX.Element { + const { onCancel } = props; + const ref = useRef(null); + useInteractOutside({ ref, onInteractOutside: onCancel }); + return createPortal( +
+ +
, + document.body + ); +} diff --git a/sticker-creator/src/components/PopperRootContext.tsx b/sticker-creator/src/components/PopperRootContext.tsx deleted file mode 100644 index aa43a3246..000000000 --- a/sticker-creator/src/components/PopperRootContext.tsx +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright 2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React from 'react'; - -const makeApi = (classes?: Array) => ({ - 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; - children?: React.ReactNode; -}; - -export function ClassyProvider({ - classes, - children, -}: ClassyProviderProps): JSX.Element { - const api = React.useMemo(() => makeApi(classes), [classes]); - - return ( - - {children} - - ); -} diff --git a/sticker-creator/src/elements/Button.tsx b/sticker-creator/src/elements/Button.tsx index b025e509f..dbaa82945 100644 --- a/sticker-creator/src/elements/Button.tsx +++ b/sticker-creator/src/elements/Button.tsx @@ -31,7 +31,6 @@ const getClassName = ({ primary, pill }: Props) => { export function Button({ className, children, - buttonRef, primary, ...otherProps }: React.PropsWithChildren): JSX.Element { @@ -42,7 +41,6 @@ export function Button({ getClassName({ primary, ...otherProps }), className )} - ref={buttonRef} {...otherProps} > {children} diff --git a/sticker-creator/src/elements/ConfirmDialog.tsx b/sticker-creator/src/elements/ConfirmDialog.tsx index 63746d04e..2a7c0d897 100644 --- a/sticker-creator/src/elements/ConfirmDialog.tsx +++ b/sticker-creator/src/elements/ConfirmDialog.tsx @@ -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 +): JSX.Element { const i18n = useI18n(); const cancelText = cancel || i18n('StickerCreator--ConfirmDialog--cancel'); return ( -
+

{title}

{children}

@@ -40,4 +37,4 @@ export function ConfirmDialog({
); -} +}); diff --git a/sticker-creator/src/routes/art/AppStage.module.scss b/sticker-creator/src/routes/art/AppStage.module.scss index 80970be7b..bfc092d62 100644 --- a/sticker-creator/src/routes/art/AppStage.module.scss +++ b/sticker-creator/src/routes/art/AppStage.module.scss @@ -81,6 +81,6 @@ } &:dir(rtl) { // stylelint-disable-next-line declaration-property-value-disallowed-list - transform: translate(50%, 0px) + transform: translate(50%, 0px); } } diff --git a/sticker-creator/src/routes/art/AppStage.tsx b/sticker-creator/src/routes/art/AppStage.tsx index 4ac72d31e..3d6372bee 100644 --- a/sticker-creator/src/routes/art/AppStage.tsx +++ b/sticker-creator/src/routes/art/AppStage.tsx @@ -24,7 +24,6 @@ export type Props = Readonly<{ noScroll?: boolean; onNext?: () => unknown; onPrev?: () => unknown; - nextButtonRef?: React.RefObject; 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 ? (