diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordOnClickOutsideEffect.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordOnClickOutsideEffect.tsx index ee8652fb3e..a3cb699c77 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordOnClickOutsideEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordOnClickOutsideEffect.tsx @@ -1,4 +1,8 @@ -import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import { useEffect } from 'react'; + +import { MULTI_OBJECT_RECORD_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/relation-picker/constants/MultiObjectRecordClickOutsideListenerId'; +import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener'; +import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener'; export const MultipleObjectRecordOnClickOutsideEffect = ({ containerRef, @@ -7,6 +11,21 @@ export const MultipleObjectRecordOnClickOutsideEffect = ({ containerRef: React.RefObject; onClickOutside: () => void; }) => { + const { useListenClickOutside } = useClickOutsideListener( + MULTI_OBJECT_RECORD_CLICK_OUTSIDE_LISTENER_ID, + ); + + const { toggleClickOutsideListener: toggleRightDrawerClickOustideListener } = + useClickOutsideListener(RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID); + + useEffect(() => { + toggleRightDrawerClickOustideListener(false); + + return () => { + toggleRightDrawerClickOustideListener(true); + }; + }, [toggleRightDrawerClickOustideListener]); + useListenClickOutside({ refs: [containerRef], callback: (event) => { diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/constants/MultiObjectRecordClickOutsideListenerId.ts b/packages/twenty-front/src/modules/object-record/relation-picker/constants/MultiObjectRecordClickOutsideListenerId.ts new file mode 100644 index 0000000000..a57b630e15 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/relation-picker/constants/MultiObjectRecordClickOutsideListenerId.ts @@ -0,0 +1,2 @@ +export const MULTI_OBJECT_RECORD_CLICK_OUTSIDE_LISTENER_ID = + 'multi-object-record-click-outside-listener'; diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx index 9b748c3d81..a8c70dfdd5 100644 --- a/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/components/RightDrawer.tsx @@ -5,11 +5,10 @@ import { motion } from 'framer-motion'; import { useRecoilState } from 'recoil'; import { Key } from 'ts-key-enum'; +import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; -import { - ClickOutsideMode, - useListenClickOutside, -} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener'; +import { ClickOutsideMode } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { isDefined } from '~/utils/isDefined'; @@ -53,6 +52,10 @@ export const RightDrawer = () => { const rightDrawerRef = useRef(null); + const { useListenClickOutside } = useClickOutsideListener( + RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID, + ); + useListenClickOutside({ refs: [rightDrawerRef], callback: () => closeRightDrawer(), diff --git a/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener.ts b/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener.ts new file mode 100644 index 0000000000..79ab542b23 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener.ts @@ -0,0 +1,2 @@ +export const RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID = + 'right-drawer-click-outside-listener'; diff --git a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOustideListenerStates.ts b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOustideListenerStates.ts new file mode 100644 index 0000000000..238df64659 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOustideListenerStates.ts @@ -0,0 +1,23 @@ +import { clickOutsideListenerIsActivatedStateScopeMap } from '@/ui/utilities/pointer-event/states/clickOutsideListenerIsActivatedStateScopeMap'; +import { clickOutsideListenerIsMouseDownInsideStateScopeMap } from '@/ui/utilities/pointer-event/states/clickOutsideListenerIsMouseDownInsideStateScopeMap'; +import { lockedListenerIdState } from '@/ui/utilities/pointer-event/states/lockedListenerIdState'; +import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; +import { getState } from '@/ui/utilities/recoil-scope/utils/getState'; + +export const useClickOustideListenerStates = (componentId: string) => { + // TODO: improve typing + const scopeId = getScopeIdFromComponentId(componentId) ?? ''; + + return { + scopeId, + getClickOutsideListenerIsMouseDownInsideState: getState( + clickOutsideListenerIsMouseDownInsideStateScopeMap, + scopeId, + ), + getClickOutsideListenerIsActivatedState: getState( + clickOutsideListenerIsActivatedStateScopeMap, + scopeId, + ), + lockedListenerIdState, + }; +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOutsideListener.ts b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOutsideListener.ts new file mode 100644 index 0000000000..9abfee1536 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOutsideListener.ts @@ -0,0 +1,45 @@ +import { useRecoilCallback } from 'recoil'; + +import { useClickOustideListenerStates } from '@/ui/utilities/pointer-event/hooks/useClickOustideListenerStates'; +import { + ClickOutsideListenerProps, + useListenClickOutsideV2, +} from '@/ui/utilities/pointer-event/hooks/useListenClickOutsideV2'; +import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; + +export const useClickOutsideListener = (componentId: string) => { + // TODO: improve typing + const scopeId = getScopeIdFromComponentId(componentId) ?? ''; + + const { getClickOutsideListenerIsActivatedState } = + useClickOustideListenerStates(componentId); + + const useListenClickOutside = ({ + callback, + refs, + enabled, + mode, + }: Omit, 'listenerId'>) => { + return useListenClickOutsideV2({ + listenerId: componentId, + refs, + callback, + enabled, + mode, + }); + }; + + const toggleClickOutsideListener = useRecoilCallback( + ({ set }) => + (activated: boolean) => { + set(getClickOutsideListenerIsActivatedState(), activated); + }, + [getClickOutsideListenerIsActivatedState], + ); + + return { + scopeId, + useListenClickOutside, + toggleClickOutsideListener, + }; +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useListenClickOutsideV2.ts b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useListenClickOutsideV2.ts new file mode 100644 index 0000000000..e7f178e7d7 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useListenClickOutsideV2.ts @@ -0,0 +1,184 @@ +import React, { useEffect } from 'react'; +import { useRecoilCallback } from 'recoil'; + +import { useClickOustideListenerStates } from '@/ui/utilities/pointer-event/hooks/useClickOustideListenerStates'; + +export enum ClickOutsideMode { + comparePixels = 'comparePixels', + compareHTMLRef = 'compareHTMLRef', +} + +export type ClickOutsideListenerProps = { + refs: Array>; + callback: (event: MouseEvent | TouchEvent) => void; + mode?: ClickOutsideMode; + listenerId: string; + enabled?: boolean; +}; + +export const useListenClickOutsideV2 = ({ + refs, + callback, + mode = ClickOutsideMode.compareHTMLRef, + listenerId, + enabled = true, +}: ClickOutsideListenerProps) => { + const { + getClickOutsideListenerIsMouseDownInsideState, + getClickOutsideListenerIsActivatedState, + } = useClickOustideListenerStates(listenerId); + + const handleMouseDown = useRecoilCallback( + ({ snapshot, set }) => + (event: MouseEvent | TouchEvent) => { + const clickOutsideListenerIsActivated = snapshot + .getLoadable(getClickOutsideListenerIsActivatedState()) + .getValue(); + + const isListening = clickOutsideListenerIsActivated && enabled; + + if (!isListening) { + return; + } + + if (mode === ClickOutsideMode.compareHTMLRef) { + const clickedOnAtLeastOneRef = refs + .filter((ref) => !!ref.current) + .some((ref) => ref.current?.contains(event.target as Node)); + + set( + getClickOutsideListenerIsMouseDownInsideState(), + clickedOnAtLeastOneRef, + ); + } + + if (mode === ClickOutsideMode.comparePixels) { + const clickedOnAtLeastOneRef = refs + .filter((ref) => !!ref.current) + .some((ref) => { + if (!ref.current) { + return false; + } + + const { x, y, width, height } = + ref.current.getBoundingClientRect(); + + const clientX = + 'clientX' in event + ? event.clientX + : event.changedTouches[0].clientX; + const clientY = + 'clientY' in event + ? event.clientY + : event.changedTouches[0].clientY; + + if ( + clientX < x || + clientX > x + width || + clientY < y || + clientY > y + height + ) { + return false; + } + return true; + }); + + set( + getClickOutsideListenerIsMouseDownInsideState(), + clickedOnAtLeastOneRef, + ); + } + }, + [ + mode, + refs, + getClickOutsideListenerIsMouseDownInsideState, + enabled, + getClickOutsideListenerIsActivatedState, + ], + ); + + const handleClickOutside = useRecoilCallback( + ({ snapshot }) => + (event: MouseEvent | TouchEvent) => { + const isMouseDownInside = snapshot + .getLoadable(getClickOutsideListenerIsMouseDownInsideState()) + .getValue(); + + if (mode === ClickOutsideMode.compareHTMLRef) { + const clickedOnAtLeastOneRef = refs + .filter((ref) => !!ref.current) + .some((ref) => ref.current?.contains(event.target as Node)); + + if (!clickedOnAtLeastOneRef && !isMouseDownInside) { + callback(event); + } + } + + if (mode === ClickOutsideMode.comparePixels) { + const clickedOnAtLeastOneRef = refs + .filter((ref) => !!ref.current) + .some((ref) => { + if (!ref.current) { + return false; + } + + const { x, y, width, height } = + ref.current.getBoundingClientRect(); + + const clientX = + 'clientX' in event + ? event.clientX + : event.changedTouches[0].clientX; + const clientY = + 'clientY' in event + ? event.clientY + : event.changedTouches[0].clientY; + + if ( + clientX < x || + clientX > x + width || + clientY < y || + clientY > y + height + ) { + return false; + } + return true; + }); + + if (!clickedOnAtLeastOneRef && !isMouseDownInside) { + callback(event); + } + } + }, + [mode, refs, callback, getClickOutsideListenerIsMouseDownInsideState], + ); + + useEffect(() => { + document.addEventListener('mousedown', handleMouseDown, { + capture: true, + }); + document.addEventListener('click', handleClickOutside, { capture: true }); + document.addEventListener('touchstart', handleMouseDown, { + capture: true, + }); + document.addEventListener('touchend', handleClickOutside, { + capture: true, + }); + + return () => { + document.removeEventListener('mousedown', handleMouseDown, { + capture: true, + }); + document.removeEventListener('click', handleClickOutside, { + capture: true, + }); + document.removeEventListener('touchstart', handleMouseDown, { + capture: true, + }); + document.removeEventListener('touchend', handleClickOutside, { + capture: true, + }); + }; + }, [refs, callback, mode, handleClickOutside, handleMouseDown]); +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/pointer-event/states/clickOutsideListenerIsActivatedStateScopeMap.ts b/packages/twenty-front/src/modules/ui/utilities/pointer-event/states/clickOutsideListenerIsActivatedStateScopeMap.ts new file mode 100644 index 0000000000..61367a9aee --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/pointer-event/states/clickOutsideListenerIsActivatedStateScopeMap.ts @@ -0,0 +1,7 @@ +import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; + +export const clickOutsideListenerIsActivatedStateScopeMap = + createStateScopeMap({ + key: 'clickOutsideListenerIsActivatedStateScopeMap', + defaultValue: true, + }); diff --git a/packages/twenty-front/src/modules/ui/utilities/pointer-event/states/clickOutsideListenerIsMouseDownInsideStateScopeMap.ts b/packages/twenty-front/src/modules/ui/utilities/pointer-event/states/clickOutsideListenerIsMouseDownInsideStateScopeMap.ts new file mode 100644 index 0000000000..cf42531397 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/pointer-event/states/clickOutsideListenerIsMouseDownInsideStateScopeMap.ts @@ -0,0 +1,7 @@ +import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; + +export const clickOutsideListenerIsMouseDownInsideStateScopeMap = + createStateScopeMap({ + key: 'clickOutsideListenerIsMouseDownInsideStateScopeMap', + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/ui/utilities/pointer-event/states/lockedListenerIdState.ts b/packages/twenty-front/src/modules/ui/utilities/pointer-event/states/lockedListenerIdState.ts new file mode 100644 index 0000000000..1349627fce --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/pointer-event/states/lockedListenerIdState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const lockedListenerIdState = atom({ + key: 'lockedListenerIdState', + default: null, +});