diff --git a/src/client/webapp/elements/components/input-image-edit.tsx b/src/client/webapp/elements/components/input-image-edit.tsx index ec06a60..f5f9a63 100644 --- a/src/client/webapp/elements/components/input-image-edit.tsx +++ b/src/client/webapp/elements/components/input-image-edit.tsx @@ -3,10 +3,11 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import React, { FC, useEffect, useRef, useState } from 'react'; +import React, { FC, useEffect, useRef } from 'react'; import ElementsUtil from '../require/elements-util'; import * as FileType from 'file-type'; +import ReactHelper from '../require/react-helper'; interface ImageEditInputProps { @@ -26,29 +27,33 @@ const ImageEditInput: FC = (props: ImageEditInputProps) => const acceptedExtTypes = [ 'png', 'jpg', 'jpeg' ]; + const isMounted = ReactHelper.useIsMountedRef(); + const imgRef = useRef(null); - const [ imgSrc, setImgSrc ] = useState('./img/loading.svg'); - - useEffect(() => { - (async () => { - if (value) { - try { - const src = await ElementsUtil.getImageBufferSrc(value); - setImgSrc(src); - setValid(true); - } catch (e: unknown) { - LOG.error('unable to get image buffer src', e); - setImgSrc('./img/error.png'); - setValid(false); - setMessage('Unable to get image src'); - } - } else { - setImgSrc('./img/loading.svg'); + const [ imgSrc ] = ReactHelper.useOneTimeAsyncAction( + async () => { + if (!value) { setValid(false); + return './img/loading.svg'; } - })(); - }, [ value ]); + + try { + const src = await ElementsUtil.getImageBufferSrc(value); + if (!isMounted.current) return './img/error.png'; + setValid(true); + return src; + } catch (e: unknown) { + LOG.error('unable to get image buffer src', e); + if (!isMounted.current) return './img/error.png'; + setValid(false); + setMessage('Unable to get image src'); + return './img/error.png'; + } + }, + './img/loading.svg', + [ value ] + ); useEffect(() => { if (imgRef.current) { diff --git a/src/client/webapp/elements/components/overlay.tsx b/src/client/webapp/elements/components/overlay.tsx index 5fc4c75..e9e6ad6 100644 --- a/src/client/webapp/elements/components/overlay.tsx +++ b/src/client/webapp/elements/components/overlay.tsx @@ -3,71 +3,37 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import React, { FC, useMemo, useRef, useState } from "react"; +import React, { FC, RefObject, useCallback, useEffect } from "react"; import ElementsUtil from '../require/elements-util'; +import ReactHelper from '../require/react-helper'; interface OverlayProps { - document: Document; + childRootRef: RefObject; // clicks outside this ref will close the overlay children: React.ReactNode; } const Overlay: FC = (props: OverlayProps) => { - const { document, children } = props; + const { childRootRef, children } = props; - const node = useRef(null); - - const [ mouseDownInChild, setMouseDownInChild ] = useState(false); - const [ mouseUpInChild, setMouseUpInChild ] = useState(false); - - const removeSelf = () => { - if (mouseDownInChild || mouseUpInChild) { - setMouseDownInChild(false); - setMouseUpInChild(false); - return; - } - // TODO: This is pretty messy and should be re-thought when we full-convert to react. - // The window event listener could be re-called if we call closeReactOverlay elsewhere... - if (keyDownHandler) { - window.removeEventListener('keydown', keyDownHandler); - } - if (node.current) { - ElementsUtil.closeReactOverlay(document); - } - // otherwise, this isn't in the DOM anyway - }; - - const checkMouseDown = (e: React.MouseEvent) => { - if (e.target === node.current) { - setMouseDownInChild(false); - } else { - setMouseDownInChild(true); - } - }; - - const checkMouseUp = (e: React.MouseEvent) => { - if (e.target === node.current) { - setMouseUpInChild(false); - } else { - setMouseUpInChild(true); - } - }; - - const keyDownHandler = useMemo(() => { - const eventHandler = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - removeSelf(); - } - }; - window.addEventListener('keydown', eventHandler); - return eventHandler; + const removeSelf = useCallback(() => { + ElementsUtil.closeReactOverlay(document); }, []); - const clickHandler = (e: React.MouseEvent) => { - if (e.target === node.current) { + ReactHelper.useCloseWhenClickedOutsideEffect(childRootRef, () => { removeSelf(); }); + + const keyDownHandler = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') { removeSelf(); } - } + }, [ removeSelf ]); - return
{children}
+ useEffect(() => { + window.addEventListener('keydown', keyDownHandler); + return () => { + window.removeEventListener('keydown', keyDownHandler); + } + }, []); + + return
{children}
}; export default Overlay; diff --git a/src/client/webapp/elements/context-menus/components/context-menu.tsx b/src/client/webapp/elements/context-menus/components/context-menu.tsx index 78d8007..cece528 100644 --- a/src/client/webapp/elements/context-menus/components/context-menu.tsx +++ b/src/client/webapp/elements/context-menus/components/context-menu.tsx @@ -1,16 +1,18 @@ import React, { FC, ReactNode, RefObject, useEffect, useMemo, useRef, useState } from 'react'; +import { ShouldNeverHappenError } from '../../../data-types'; import ElementsUtil, { IAlignment } from '../../require/elements-util'; import ReactHelper from '../../require/react-helper'; export interface ContextMenuProps { - relativeToRef: RefObject; + relativeToRef?: RefObject; + relativeToPos?: { x: number, y: number }; alignment: IAlignment; children: ReactNode; close: () => void; } const ContextMenu: FC = (props: ContextMenuProps) => { - const { relativeToRef, alignment, children, close } = props; + const { relativeToRef, relativeToPos, alignment, children, close } = props; const rootRef = useRef(null); @@ -19,10 +21,12 @@ const ContextMenu: FC = (props: ContextMenuProps) => { ReactHelper.useCloseWhenClickedOutsideEffect(rootRef, close); useEffect(() => { - if (!rootRef.current || !relativeToRef.current) return; - ElementsUtil.alignContextElement(rootRef.current, relativeToRef.current, alignment); + if (!rootRef.current) return; + const relativeTo = (relativeToRef && relativeToRef.current) ?? relativeToPos ?? null; + if (!relativeTo) throw new ShouldNeverHappenError('invalid context menu props'); + ElementsUtil.alignContextElement(rootRef.current, relativeTo, alignment); setAligned(true); - }, [ rootRef, relativeToRef ]); + }, [ rootRef, relativeToRef, relativeToPos ]); const contextClass = useMemo(() => { return 'context react' + (aligned ? ' aligned' : ''); diff --git a/src/client/webapp/elements/context-menus/context-menu-image.tsx b/src/client/webapp/elements/context-menus/context-menu-image.tsx new file mode 100644 index 0000000..4bd70f1 --- /dev/null +++ b/src/client/webapp/elements/context-menus/context-menu-image.tsx @@ -0,0 +1,69 @@ +import React, { FC } from 'react'; +import * as electron from 'electron'; +import ReactHelper from '../require/react-helper'; +import ContextMenu from './components/context-menu'; +import * as FileType from 'file-type'; +import sharp from 'sharp'; +import { IAlignment } from '../require/elements-util'; + +export interface ImageContextMenuProps { + alignment: IAlignment; + relativeToPos: { x: number, y: number }; + close: () => void; + resourceName: string; + resourceBuff: Buffer; + isPreview: boolean; +} + +const ImageContextMenu: FC = (props: ImageContextMenuProps) => { + const { alignment, relativeToPos, close, resourceName, resourceBuff, isPreview } = props; + + const previewText = isPreview ? ' Preview' : ''; + + // TODO: Integrate shaking + + const [ copyCallable, copyText, copyShaking ] = ReactHelper.useAsyncSubmitButton( + async () => { + const type = await FileType.fromBuffer(resourceBuff); + if (!type) { + return { result: null, errorMessage: 'Unable to get file type' }; + } + let nativeImage: electron.NativeImage; + if (type.mime !== 'image/png' && type.mime !== 'image/jpeg') { + // TODO: Copy+Paste GIFs somehow + nativeImage = electron.nativeImage.createFromBuffer(await sharp(resourceBuff).png().toBuffer()); + } else { + nativeImage = electron.nativeImage.createFromBuffer(resourceBuff); + } + electron.clipboard.writeImage(nativeImage); + return { result: null, errorMessage: null }; + }, + [ resourceBuff ], + { + start: 'Copy Image' + previewText, + pending: 'Copying' + previewText, + error: 'Copy Failed. Click to Try Again', + done: 'Copied to Clipboard' + } + ); + + const [ saveCallable, saveText, saveShaking ] = ReactHelper.useDownloadButton( + resourceName, resourceBuff, + { + start: 'Save Image' + previewText, + pendingSave: 'Saving' + previewText + '...', + errorSave: 'Save Failed. Click to Try Again' + } + ); + + return ( + +
+
{copyText}
+
{saveText}
+
+
+ ); +} + +export default ImageContextMenu; diff --git a/src/client/webapp/elements/lists/components/message-element.tsx b/src/client/webapp/elements/lists/components/message-element.tsx index d24b012..0ed9642 100644 --- a/src/client/webapp/elements/lists/components/message-element.tsx +++ b/src/client/webapp/elements/lists/components/message-element.tsx @@ -52,13 +52,13 @@ const PreviewImageElement: FC = (props: PreviewImageEl const [ imgSrc ] = GuildSubscriptions.useSoftImageSrcResourceSubscription(guild, resourcePreviewId); - const clickCallback = useCallback(() => { + const openImageOverlay = useCallback(() => { // Note: document here isn't 100% guaranteed but we should be getting rid of this eventually anyway ElementsUtil.presentReactOverlay(document, ); }, [ guild, resourceId, resourceName ]); return ( -
+
{resourceName}/
); diff --git a/src/client/webapp/elements/overlays/overlay-add-guild.tsx b/src/client/webapp/elements/overlays/overlay-add-guild.tsx index 3bcbb15..c68c393 100644 --- a/src/client/webapp/elements/overlays/overlay-add-guild.tsx +++ b/src/client/webapp/elements/overlays/overlay-add-guild.tsx @@ -3,7 +3,7 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import React, { FC, useEffect, useMemo, useState } from 'react'; +import React, { FC, useEffect, useMemo, useRef, useState } from 'react'; import GuildsManager from '../../guilds-manager'; import moment from 'moment'; import TextInput from '../components/input-text'; @@ -18,6 +18,7 @@ import InvitePreview from '../components/invite-preview'; import ReactHelper from '../require/react-helper'; import * as fs from 'fs/promises'; import Button from '../components/button'; +import Overlay from '../components/overlay'; export interface IAddGuildData { name: string, @@ -60,6 +61,8 @@ export interface AddGuildOverlayProps { const AddGuildOverlay: FC = (props: AddGuildOverlayProps) => { const { document, ui, guildsManager, addGuildData } = props; + const rootRef = useRef(null); + const expired = addGuildData.expires < new Date().getTime(); const exampleDisplayName = useMemo(() => getExampleDisplayName(), []); const exampleAvatarPath = useMemo(() => getExampleAvatarPath(), []); @@ -121,34 +124,36 @@ const AddGuildOverlay: FC = (props: AddGuildOverlayProps) }, [ validationErrorMessage, submitFailMessage ]); return ( -
- -
-
-
- -
-
- + +
+ +
+
+
+ +
+
+ +
+ + +
- - - -
+ ); } diff --git a/src/client/webapp/elements/overlays/overlay-channel.tsx b/src/client/webapp/elements/overlays/overlay-channel.tsx index 47103e3..2c03a1f 100644 --- a/src/client/webapp/elements/overlays/overlay-channel.tsx +++ b/src/client/webapp/elements/overlays/overlay-channel.tsx @@ -3,7 +3,7 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import React, { createRef, FC, useEffect, useMemo, useState } from 'react'; +import React, { FC, useEffect, useMemo, useRef, useState } from 'react'; import CombinedGuild from '../../guild-combined'; import BaseElements from '../require/base-elements'; import TextInput from '../components/input-text'; @@ -13,6 +13,7 @@ import ElementsUtil from '../require/elements-util'; import { Channel } from '../../data-types'; import ReactHelper from '../require/react-helper'; import Button from '../components/button'; +import Overlay from '../components/overlay'; export interface ChannelOverlayProps { guild: CombinedGuild; @@ -21,7 +22,8 @@ export interface ChannelOverlayProps { const ChannelOverlay: FC = (props: ChannelOverlayProps) => { const { guild, channel } = props; - const nameInputRef = createRef(); + const rootRef = useRef(null); + const nameInputRef = useRef(null); const [ edited, setEdited ] = useState(false); @@ -94,38 +96,40 @@ const ChannelOverlay: FC = (props: ChannelOverlayProps) => }, [ validationErrorMessage, submitFailMessage ]); return ( -
-
-
{BaseElements.TEXT_CHANNEL_ICON}
-
{name}
-
-
{flavorText}
+ +
+
+
{BaseElements.TEXT_CHANNEL_ICON}
+
{name}
+
+
{flavorText}
+
+
+ value.toLowerCase().replace(' ', '-').replace(/[^a-z0-9-]/, '')} + onEnterKeyDown={submitFunc} + /> +
+
+ +
+ + +
-
- value.toLowerCase().replace(' ', '-').replace(/[^a-z0-9-]/, '')} - onEnterKeyDown={submitFunc} - /> -
-
- -
- - - -
+ ); } diff --git a/src/client/webapp/elements/overlays/overlay-error-message.tsx b/src/client/webapp/elements/overlays/overlay-error-message.tsx index d019e18..fa483de 100644 --- a/src/client/webapp/elements/overlays/overlay-error-message.tsx +++ b/src/client/webapp/elements/overlays/overlay-error-message.tsx @@ -1,4 +1,5 @@ -import React, { FC } from 'react'; +import React, { FC, useRef } from 'react'; +import Overlay from '../components/overlay'; export interface ErrorMessageOverlayProps { title: string; @@ -7,16 +8,20 @@ export interface ErrorMessageOverlayProps { const ErrorMessageOverlay: FC = (props: ErrorMessageOverlayProps) => { const { title, message } = props; + const rootRef = useRef(null); + return ( -
-
- error + +
+
+ error +
+
+
{title}
+
{message}
+
-
-
{title}
-
{message}
-
-
+ ); } diff --git a/src/client/webapp/elements/overlays/overlay-guild-settings.tsx b/src/client/webapp/elements/overlays/overlay-guild-settings.tsx index 378612f..4e721ef 100644 --- a/src/client/webapp/elements/overlays/overlay-guild-settings.tsx +++ b/src/client/webapp/elements/overlays/overlay-guild-settings.tsx @@ -3,12 +3,13 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import React, { FC, useEffect, useState } from "react"; +import React, { FC, useEffect, useRef, useState } from "react"; import CombinedGuild from "../../guild-combined"; import ChoicesControl from "../components/control-choices"; import GuildInvitesDisplay from "../displays/display-guild-invites"; import GuildOverviewDisplay from "../displays/display-guild-overview"; import { GuildMetadata } from '../../data-types'; +import Overlay from '../components/overlay'; export interface GuildSettingsOverlayProps { guild: CombinedGuild; @@ -17,6 +18,8 @@ export interface GuildSettingsOverlayProps { const GuildSettingsOverlay: FC = (props: GuildSettingsOverlayProps) => { const { guild, guildMeta } = props; + const rootRef = useRef(null); + const [ selectedId, setSelectedId ] = useState('overview'); const [ display, setDisplay ] = useState(); @@ -27,14 +30,16 @@ const GuildSettingsOverlay: FC = (props: GuildSetting }, [ selectedId ]); return ( -
- - {display} -
+ +
+ + {display} +
+
); } diff --git a/src/client/webapp/elements/overlays/overlay-image.tsx b/src/client/webapp/elements/overlays/overlay-image.tsx index 0be64e1..682474e 100644 --- a/src/client/webapp/elements/overlays/overlay-image.tsx +++ b/src/client/webapp/elements/overlays/overlay-image.tsx @@ -3,16 +3,14 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import * as FileType from 'file-type'; - -import React, { FC, useMemo } from 'react'; +import React, { FC, useCallback, useMemo, useState, MouseEvent, useRef } from 'react'; import CombinedGuild from '../../guild-combined'; import ElementsUtil from '../require/elements-util'; import DownloadButton from '../components/button-download'; -import createImageContextMenu from '../context-menu-img'; -import Q from '../../q-module'; import ReactHelper from '../require/react-helper'; import GuildSubscriptions from '../require/guild-subscriptions'; +import ImageContextMenu from '../context-menus/context-menu-image'; +import Overlay from '../components/overlay'; export interface ImageOverlayProps { guild: CombinedGuild @@ -24,50 +22,54 @@ const ImageOverlay: FC = (props: ImageOverlayProps) => { const { guild, resourceId, resourceName } = props; // TODO: Handle errors + const rootRef = useRef(null); const [ resource, resourceError ] = GuildSubscriptions.useResourceSubscription(guild, resourceId); // Note: We want this customization here since we need the resource buffer. - const [ resourceImgSrc, resourceImgSrcError ] = ReactHelper.useOneTimeAsyncAction( + const [ resourceImgSrc ] = ReactHelper.useOneTimeAsyncAction( async () => await ElementsUtil.getImageSrcFromBufferFailSoftly(resource?.data ?? null), './img/loading.svg', [ guild, resource ] - ) - const [ resourceFileTypeInfo, resourceFileTypeInfoError ] = ReactHelper.useOneTimeAsyncAction( - async () => { - if (!resource) return null; - const fileTypeInfo = (await FileType.fromBuffer(resource.data)) ?? null; - if (fileTypeInfo === null) throw new Error('unable to get mime/ext'); - return fileTypeInfo; - }, - null, - [ resource ] ); - const onImageContextMenu = (e: React.MouseEvent) => { - // TODO: This should be in react! - if (!resource) return; - if (!resourceFileTypeInfo) return; - const contextMenu = createImageContextMenu(document, new Q(document), guild, resourceName, resource.data, resourceFileTypeInfo.mime, resourceFileTypeInfo.ext, false); - document.body.appendChild(contextMenu); - const relativeTo = { x: e.pageX, y: e.pageY }; - ElementsUtil.alignContextElement(contextMenu, relativeTo, { top: 'centerY', left: 'right' }); - }; + const [ contextMenuOpen, setContextMenuOpen ] = useState(false); + const alignment = useMemo(() => ({ top: 'centerY', left: 'centerX' }), []); + const [ relativeToPos, setRelativeToPos ] = useState<{ x: number, y: number }>({ x: 0, y: 0 }); + + const contextMenu = useMemo(() => { + if (!resource) return null; + return ( + { setContextMenuOpen(false); }} + resourceName={resourceName} resourceBuff={resource.data} isPreview={false} + /> + ); + }, [ resource, alignment, relativeToPos, resourceName ]); + + const toggleContextMenu = useCallback((e: MouseEvent) => { + LOG.debug('toggle context menu'); + setRelativeToPos({ x: e.clientX, y: e.clientY }); + setContextMenuOpen(oldContextMenuOpen => !!contextMenu && !oldContextMenuOpen); + }, [ contextMenu ]); const sizeText = useMemo(() => resource ? ElementsUtil.humanSize(resource.data.length) : 'Loading Size...', [ resource ]); return ( -
{ e.stopPropagation(); /* prevent overlay click */ }}> - {resourceName} -
-
-
{resourceName}
-
{sizeText}
+ +
+ {resourceName} +
+
+
{resourceName}
+
{sizeText}
+
+
- + {contextMenuOpen ? contextMenu : null}
-
+ ); } diff --git a/src/client/webapp/elements/overlays/overlay-personalize.tsx b/src/client/webapp/elements/overlays/overlay-personalize.tsx index 83116b0..445868a 100644 --- a/src/client/webapp/elements/overlays/overlay-personalize.tsx +++ b/src/client/webapp/elements/overlays/overlay-personalize.tsx @@ -3,8 +3,8 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import React, { createRef, FC, MutableRefObject, useEffect, useMemo, useState } from 'react'; -import { ConnectionInfo, Member } from '../../data-types'; +import React, { createRef, FC, MutableRefObject, useEffect, useMemo, useRef, useState } from 'react'; +import { Member } from '../../data-types'; import Globals from '../../globals'; import CombinedGuild from '../../guild-combined'; import ImageEditInput from '../components/input-image-edit'; @@ -14,18 +14,17 @@ import ElementsUtil from '../require/elements-util'; import GuildSubscriptions from '../require/guild-subscriptions'; import ReactHelper from '../require/react-helper'; import Button from '../components/button'; +import Overlay from '../components/overlay'; export interface PersonalizeOverlayProps { document: Document; guild: CombinedGuild; - selfMember: ConnectionInfo | Member; + selfMember: Member; } const PersonalizeOverlay: FC = (props: PersonalizeOverlayProps) => { const { document, guild, selfMember } = props; - if (selfMember.avatarResourceId === null) { - throw new Error('bad avatar'); - } + const rootRef = useRef(null); const [ avatarResource, avatarResourceError ] = GuildSubscriptions.useResourceSubscription(guild, selfMember.avatarResourceId) @@ -109,30 +108,32 @@ const PersonalizeOverlay: FC = (props: PersonalizeOverl }, [ validationErrorMessage, submitFailMessage ]); return ( -
-
-
- -
-
- + +
+
+
+ +
+
+ +
+ + +
- - - -
+ ); } diff --git a/src/client/webapp/elements/require/elements-util.tsx b/src/client/webapp/elements/require/elements-util.tsx index dae449c..9c3e96c 100644 --- a/src/client/webapp/elements/require/elements-util.tsx +++ b/src/client/webapp/elements/require/elements-util.tsx @@ -17,7 +17,6 @@ import CombinedGuild from '../../guild-combined'; import { ShouldNeverHappenError } from '../../data-types'; import React from 'react'; -import Overlay from '../components/overlay'; import ReactDOM from 'react-dom'; export interface IAlignment { @@ -378,9 +377,14 @@ export default class ElementsUtil { ReactDOM.unmountComponentAtNode(element); } - static presentReactOverlay(document: Document, content: JSX.Element) { - const overlay = {content}; - ReactDOM.render(overlay, document.querySelector('#react-overlays')); + static presentReactOverlay(document: Document, overlay: JSX.Element) { + // for aids reasons, the click event gets sent through to the overlay so we're just adding a sleep + // here to break the event loop. Hopefully this gets better when we don't have to do a seperate render piece. + // and we handle overlays through 100% react + (async () => { + await Util.sleep(0); + ReactDOM.render(overlay, document.querySelector('#react-overlays')); + })(); } static closeReactOverlay(document: Document) { diff --git a/src/client/webapp/elements/require/react-helper.ts b/src/client/webapp/elements/require/react-helper.ts index 290e7f0..d609c69 100644 --- a/src/client/webapp/elements/require/react-helper.ts +++ b/src/client/webapp/elements/require/react-helper.ts @@ -138,7 +138,7 @@ export default class ReactHelper { static useDownloadButton( downloadName: string, downloadSrc: { guild: CombinedGuild, resourceId: string } | Buffer, - stateTextMapping?: { start?: string, pendingFetch: string, errorFetch?: string, pendingSave?: string, errorSave?: string, success?: string } + stateTextMapping?: { start?: string, pendingFetch?: string, errorFetch?: string, pendingSave?: string, errorSave?: string, success?: string } ): [ callable: () => void, text: string, shaking: boolean ] { const textMapping = { ...{ start: 'Download', pendingFetch: 'Downloading...', errorFetch: 'Try Again', pendingSave: 'Saving...', errorSave: 'Try Again', success: 'Open in Explorer' }, ...stateTextMapping }; @@ -338,20 +338,61 @@ export default class ReactHelper { return [ onScrollCallable, onLoadCallable, loadAbove, loadBelow ]; } + // Makes sure to also allow you to fly-out a click starting inside of the ref'd element but was dragged outside static useCloseWhenClickedOutsideEffect(ref: RefObject, close: () => void) { + const [ mouseDownTarget, setMouseDownTarget ] = useState(null); + const [ mouseUpTarget, setMouseUpTarget ] = useState(null); + const handleClickOutside = useCallback((event: MouseEvent) => { + //console.log('current:', ref.current, 'target:', event.target, 'mouseDownTarget:', mouseDownTarget, 'mouseUpTarget:', mouseUpTarget); + if (!ref.current) return; + // Casting here is OK. https://stackoverflow.com/q/61164018 if (ref.current.contains(event.target as Node)) return; + if (mouseDownTarget !== null || mouseUpTarget !== null) return; + close(); - }, [ ref, close ]); + }, [ ref, mouseDownTarget, mouseUpTarget, close ]); + + const handleMouseDown = useCallback((event: MouseEvent) => { + if (!ref.current) return; + if (ref.current.contains(event.target as Node)) { + setMouseDownTarget(event.target); + } else { + setMouseDownTarget(null); + } + }, [ ref ]); + + const handleMouseUp = useCallback((event: MouseEvent) => { + if (!ref.current) return; + if (ref.current.contains(event.target as Node)) { + setMouseUpTarget(event.target); + } else { + setMouseUpTarget(null); + } + }, [ ref ]); useEffect(() => { document.addEventListener('click', handleClickOutside); return () => { document.removeEventListener('click', handleClickOutside); - }; + } }, [ handleClickOutside ]); + + useEffect(() => { + document.addEventListener('mousedown', handleMouseDown); + return () => { + document.removeEventListener('mousedown', handleMouseDown); + } + }, [ handleMouseDown ]); + + useEffect(() => { + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.addEventListener('mouseup', handleMouseUp); + } + }, [ handleMouseUp ]); } -} +} \ No newline at end of file