Migrate to a monorepo structure (#2909)
This commit is contained in:
@ -0,0 +1,30 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { AnimationDuration } from '@/ui/theme/constants/animation';
|
||||
|
||||
type AnimatedEaseInProps = Omit<
|
||||
React.ComponentProps<typeof motion.div>,
|
||||
'initial' | 'animated' | 'transition'
|
||||
> & {
|
||||
duration?: AnimationDuration;
|
||||
};
|
||||
|
||||
export const AnimatedEaseIn = ({
|
||||
children,
|
||||
duration = 'normal',
|
||||
}: AnimatedEaseInProps) => {
|
||||
const theme = useTheme();
|
||||
const initial = { opacity: 0 };
|
||||
const animate = { opacity: 1 };
|
||||
const transition = {
|
||||
ease: 'linear',
|
||||
duration: theme.animation.duration[duration],
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div initial={initial} animate={animate} transition={transition}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,47 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import { AnimationDuration } from '@/ui/theme/constants/animation';
|
||||
|
||||
type AnimatedEaseInOutProps = {
|
||||
isOpen: boolean;
|
||||
children: React.ReactNode;
|
||||
duration?: AnimationDuration;
|
||||
marginBottom?: string;
|
||||
marginTop?: string;
|
||||
initial?: boolean;
|
||||
};
|
||||
|
||||
export const AnimatedEaseInOut = ({
|
||||
children,
|
||||
isOpen,
|
||||
marginBottom,
|
||||
marginTop,
|
||||
duration = 'normal',
|
||||
initial = true,
|
||||
}: AnimatedEaseInOutProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={initial}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{
|
||||
marginBottom: marginBottom ?? 0,
|
||||
marginTop: marginTop ?? 0,
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{ height: 'fit-content', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0, marginBottom: 0, marginTop: 0 }}
|
||||
transition={{
|
||||
duration: theme.animation.duration[duration],
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,42 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
|
||||
import { AnimationDuration } from '@/ui/theme/constants/animation';
|
||||
|
||||
type AnimatedFadeOutProps = {
|
||||
isOpen: boolean;
|
||||
children: React.ReactNode;
|
||||
duration?: AnimationDuration;
|
||||
marginBottom?: string;
|
||||
marginTop?: string;
|
||||
};
|
||||
|
||||
export const AnimatedFadeOut = ({
|
||||
isOpen,
|
||||
children,
|
||||
duration = 'normal',
|
||||
marginBottom,
|
||||
marginTop,
|
||||
}: AnimatedFadeOutProps) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 1,
|
||||
marginBottom: marginBottom ?? 0,
|
||||
marginTop: marginTop ?? 0,
|
||||
}}
|
||||
exit={{ opacity: 0, height: 0, marginBottom: 0, marginTop: 0 }}
|
||||
transition={{
|
||||
duration: theme.animation.duration[duration],
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,72 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const StyledContainer = styled(motion.div)`
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledWord = styled(motion.span)`
|
||||
white-space: pre;
|
||||
`;
|
||||
|
||||
type AnimatedTextWordProps = Omit<
|
||||
React.ComponentProps<typeof motion.div>,
|
||||
'children'
|
||||
> & {
|
||||
text: string;
|
||||
};
|
||||
|
||||
const containerAnimation = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: (i = 1) => ({
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.12, delayChildren: 0.04 * i },
|
||||
}),
|
||||
};
|
||||
|
||||
const childAnimation = {
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
damping: 12,
|
||||
stiffness: 100,
|
||||
},
|
||||
},
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
x: 20,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
damping: 12,
|
||||
stiffness: 100,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const AnimatedTextWord = ({ text = '' }: AnimatedTextWordProps) => {
|
||||
const words = useMemo(() => {
|
||||
const words = text.split(' ');
|
||||
|
||||
return words.map((value, index) =>
|
||||
index === words.length - 1 ? value : value + ' ',
|
||||
);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<StyledContainer
|
||||
variants={containerAnimation}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{words.map((word, index) => (
|
||||
<StyledWord variants={childAnimation} key={index}>
|
||||
{word}
|
||||
</StyledWord>
|
||||
))}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,44 @@
|
||||
import { Profiler } from 'react';
|
||||
import { Interaction } from 'scheduler/tracing';
|
||||
|
||||
import { logDebug } from '~/utils/logDebug';
|
||||
|
||||
type TimingProfilerProps = {
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const TimingProfiler = ({ id, children }: TimingProfilerProps) => {
|
||||
const handleRender = (
|
||||
id: string,
|
||||
phase: 'mount' | 'update' | 'nested-update',
|
||||
actualDuration: number,
|
||||
baseDuration: number,
|
||||
startTime: number,
|
||||
commitTime: number,
|
||||
interactions: Set<Interaction>,
|
||||
) => {
|
||||
logDebug(
|
||||
'TimingProfiler',
|
||||
JSON.stringify(
|
||||
{
|
||||
id,
|
||||
phase,
|
||||
actualDuration,
|
||||
baseDuration,
|
||||
startTime,
|
||||
commitTime,
|
||||
interactions,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Profiler id={id} onRender={handleRender}>
|
||||
{children}
|
||||
</Profiler>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,52 @@
|
||||
import { ReactNode, useLayoutEffect, useRef, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
type ComputeNodeDimensionsProps = {
|
||||
children: (
|
||||
dimensions: { height: number; width: number } | undefined,
|
||||
) => ReactNode;
|
||||
node?: ReactNode;
|
||||
};
|
||||
|
||||
const StyledNodeWrapper = styled.span`
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
visibility: hidden;
|
||||
`;
|
||||
|
||||
export const ComputeNodeDimensions = ({
|
||||
children,
|
||||
node = children(undefined),
|
||||
}: ComputeNodeDimensionsProps) => {
|
||||
const nodeWrapperRef = useRef<HTMLSpanElement>(null);
|
||||
const [nodeDimensions, setNodeDimensions] = useState<
|
||||
| {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!nodeWrapperRef.current) {
|
||||
return;
|
||||
}
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (nodeWrapperRef.current) {
|
||||
setNodeDimensions({
|
||||
width: nodeWrapperRef.current.offsetWidth,
|
||||
height: nodeWrapperRef.current.offsetHeight,
|
||||
});
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(nodeWrapperRef.current);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [nodeWrapperRef]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledNodeWrapper ref={nodeWrapperRef}>{node}</StyledNodeWrapper>
|
||||
{nodeDimensions && children(nodeDimensions)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,72 @@
|
||||
import { RefObject } from 'react';
|
||||
import {
|
||||
boxesIntersect,
|
||||
useSelectionContainer,
|
||||
} from '@air/react-drag-to-select';
|
||||
import { useTheme } from '@emotion/react';
|
||||
|
||||
import { rgba } from '@/ui/theme/constants/colors';
|
||||
|
||||
import { useDragSelect } from '../hooks/useDragSelect';
|
||||
|
||||
type DragSelectProps = {
|
||||
dragSelectable: RefObject<HTMLElement>;
|
||||
onDragSelectionChange: (id: string, selected: boolean) => void;
|
||||
onDragSelectionStart?: () => void;
|
||||
};
|
||||
|
||||
export const DragSelect = ({
|
||||
dragSelectable,
|
||||
onDragSelectionChange,
|
||||
onDragSelectionStart,
|
||||
}: DragSelectProps) => {
|
||||
const theme = useTheme();
|
||||
const { isDragSelectionStartEnabled } = useDragSelect();
|
||||
const { DragSelection } = useSelectionContainer({
|
||||
shouldStartSelecting: (target) => {
|
||||
if (!isDragSelectionStartEnabled()) {
|
||||
return false;
|
||||
}
|
||||
if (target instanceof HTMLElement || target instanceof SVGElement) {
|
||||
let el = target;
|
||||
while (el.parentElement && !el.dataset.selectDisable) {
|
||||
el = el.parentElement;
|
||||
}
|
||||
|
||||
return el.dataset.selectDisable !== 'true';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
onSelectionStart: onDragSelectionStart,
|
||||
onSelectionChange: (box) => {
|
||||
const scrollAwareBox = {
|
||||
...box,
|
||||
top: box.top + window.scrollY,
|
||||
left: box.left + window.scrollX,
|
||||
};
|
||||
Array.from(
|
||||
dragSelectable.current?.querySelectorAll('[data-selectable-id]') ?? [],
|
||||
).forEach((item) => {
|
||||
const id = item.getAttribute('data-selectable-id');
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
if (boxesIntersect(scrollAwareBox, item.getBoundingClientRect())) {
|
||||
onDragSelectionChange(id, true);
|
||||
} else {
|
||||
onDragSelectionChange(id, false);
|
||||
}
|
||||
});
|
||||
},
|
||||
selectionProps: {
|
||||
style: {
|
||||
border: `1px solid ${theme.color.blue10}`,
|
||||
background: rgba(theme.color.blue30, 0.4),
|
||||
position: `absolute`,
|
||||
zIndex: 99,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return <DragSelection />;
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
import { useRecoilCallback, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { isDragSelectionStartEnabledState } from '../states/internal/isDragSelectionStartEnabledState';
|
||||
|
||||
export const useDragSelect = () => {
|
||||
const setIsDragSelectionStartEnabled = useSetRecoilState(
|
||||
isDragSelectionStartEnabledState,
|
||||
);
|
||||
|
||||
const setDragSelectionStartEnabled = (isEnabled: boolean) => {
|
||||
setIsDragSelectionStartEnabled(isEnabled);
|
||||
};
|
||||
|
||||
const isDragSelectionStartEnabled = useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
() => {
|
||||
return snapshot
|
||||
.getLoadable(isDragSelectionStartEnabledState)
|
||||
.getValue();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
isDragSelectionStartEnabled,
|
||||
setDragSelectionStartEnabled,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const isDragSelectionStartEnabledState = atom({
|
||||
key: 'drag-select/isDragSelectionStartEnabledState',
|
||||
default: true,
|
||||
});
|
||||
@ -0,0 +1,22 @@
|
||||
import { Keys } from 'react-hotkeys-hook';
|
||||
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
|
||||
type HotkeyEffectProps = {
|
||||
hotkey: {
|
||||
key: Keys;
|
||||
scope: string;
|
||||
};
|
||||
onHotkeyTriggered: () => void;
|
||||
};
|
||||
|
||||
export const HotkeyEffect = ({
|
||||
hotkey,
|
||||
onHotkeyTriggered,
|
||||
}: HotkeyEffectProps) => {
|
||||
useScopedHotkeys(hotkey.key, () => onHotkeyTriggered(), hotkey.scope, [
|
||||
onHotkeyTriggered,
|
||||
]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
@ -0,0 +1,18 @@
|
||||
import { AppHotkeyScope } from '../types/AppHotkeyScope';
|
||||
import { CustomHotkeyScopes } from '../types/CustomHotkeyScope';
|
||||
import { HotkeyScope } from '../types/HotkeyScope';
|
||||
|
||||
export const DEFAULT_HOTKEYS_SCOPE_CUSTOM_SCOPES: CustomHotkeyScopes = {
|
||||
commandMenu: true,
|
||||
goto: false,
|
||||
keyboardShortcutMenu: false,
|
||||
};
|
||||
|
||||
export const INITIAL_HOTKEYS_SCOPE: HotkeyScope = {
|
||||
scope: AppHotkeyScope.App,
|
||||
customScopes: {
|
||||
commandMenu: true,
|
||||
goto: true,
|
||||
keyboardShortcutMenu: true,
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,25 @@
|
||||
import { Keys } from 'react-hotkeys-hook/dist/types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { AppHotkeyScope } from '../types/AppHotkeyScope';
|
||||
|
||||
import { useSequenceHotkeys } from './useSequenceScopedHotkeys';
|
||||
|
||||
export const useGoToHotkeys = (key: Keys, location: string) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useSequenceHotkeys(
|
||||
'g',
|
||||
key,
|
||||
() => {
|
||||
navigate(location);
|
||||
},
|
||||
AppHotkeyScope.Goto,
|
||||
{
|
||||
enableOnContentEditable: true,
|
||||
enableOnFormTags: true,
|
||||
preventDefault: true,
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,50 @@
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
import { currentHotkeyScopeState } from '../states/internal/currentHotkeyScopeState';
|
||||
import { previousHotkeyScopeState } from '../states/internal/previousHotkeyScopeState';
|
||||
import { CustomHotkeyScopes } from '../types/CustomHotkeyScope';
|
||||
|
||||
import { useSetHotkeyScope } from './useSetHotkeyScope';
|
||||
|
||||
export const usePreviousHotkeyScope = () => {
|
||||
const setHotkeyScope = useSetHotkeyScope();
|
||||
|
||||
const goBackToPreviousHotkeyScope = useRecoilCallback(
|
||||
({ snapshot, set }) =>
|
||||
() => {
|
||||
const previousHotkeyScope = snapshot
|
||||
.getLoadable(previousHotkeyScopeState)
|
||||
.valueOrThrow();
|
||||
|
||||
if (!previousHotkeyScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHotkeyScope(
|
||||
previousHotkeyScope.scope,
|
||||
previousHotkeyScope.customScopes,
|
||||
);
|
||||
|
||||
set(previousHotkeyScopeState, null);
|
||||
},
|
||||
[setHotkeyScope],
|
||||
);
|
||||
|
||||
const setHotkeyScopeAndMemorizePreviousScope = useRecoilCallback(
|
||||
({ snapshot, set }) =>
|
||||
(scope: string, customScopes?: CustomHotkeyScopes) => {
|
||||
const currentHotkeyScope = snapshot
|
||||
.getLoadable(currentHotkeyScopeState)
|
||||
.valueOrThrow();
|
||||
|
||||
setHotkeyScope(scope, customScopes);
|
||||
set(previousHotkeyScopeState, currentHotkeyScope);
|
||||
},
|
||||
[setHotkeyScope],
|
||||
);
|
||||
|
||||
return {
|
||||
setHotkeyScopeAndMemorizePreviousScope,
|
||||
goBackToPreviousHotkeyScope,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,65 @@
|
||||
import { Hotkey } from 'react-hotkeys-hook/dist/types';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
import { logDebug } from '~/utils/logDebug';
|
||||
|
||||
import { internalHotkeysEnabledScopesState } from '../states/internal/internalHotkeysEnabledScopesState';
|
||||
|
||||
const DEBUG_HOTKEY_SCOPE = true;
|
||||
|
||||
export const useScopedHotkeyCallback = () =>
|
||||
useRecoilCallback(
|
||||
({ snapshot }) =>
|
||||
({
|
||||
callback,
|
||||
hotkeysEvent,
|
||||
keyboardEvent,
|
||||
scope,
|
||||
preventDefault = true,
|
||||
}: {
|
||||
keyboardEvent: KeyboardEvent;
|
||||
hotkeysEvent: Hotkey;
|
||||
callback: (keyboardEvent: KeyboardEvent, hotkeysEvent: Hotkey) => void;
|
||||
scope: string;
|
||||
preventDefault?: boolean;
|
||||
}) => {
|
||||
const currentHotkeyScopes = snapshot
|
||||
.getLoadable(internalHotkeysEnabledScopesState)
|
||||
.valueOrThrow();
|
||||
|
||||
if (!currentHotkeyScopes.includes(scope)) {
|
||||
if (DEBUG_HOTKEY_SCOPE) {
|
||||
logDebug(
|
||||
`%cI can't call hotkey (${
|
||||
hotkeysEvent.keys
|
||||
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
|
||||
', ',
|
||||
)}]`,
|
||||
'color: gray; ',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (DEBUG_HOTKEY_SCOPE) {
|
||||
logDebug(
|
||||
`%cI can call hotkey (${
|
||||
hotkeysEvent.keys
|
||||
}) because I'm in scope [${scope}] and the active scopes are : [${currentHotkeyScopes.join(
|
||||
', ',
|
||||
)}]`,
|
||||
'color: green;',
|
||||
);
|
||||
}
|
||||
|
||||
if (preventDefault) {
|
||||
keyboardEvent.stopPropagation();
|
||||
keyboardEvent.preventDefault();
|
||||
keyboardEvent.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
return callback(keyboardEvent, hotkeysEvent);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@ -0,0 +1,52 @@
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import {
|
||||
HotkeyCallback,
|
||||
Keys,
|
||||
Options,
|
||||
OptionsOrDependencyArray,
|
||||
} from 'react-hotkeys-hook/dist/types';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { pendingHotkeyState } from '../states/internal/pendingHotkeysState';
|
||||
|
||||
import { useScopedHotkeyCallback } from './useScopedHotkeyCallback';
|
||||
|
||||
export const useScopedHotkeys = (
|
||||
keys: Keys,
|
||||
callback: HotkeyCallback,
|
||||
scope: string,
|
||||
dependencies?: OptionsOrDependencyArray,
|
||||
options: Options = {
|
||||
enableOnContentEditable: true,
|
||||
enableOnFormTags: true,
|
||||
preventDefault: true,
|
||||
},
|
||||
) => {
|
||||
const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState);
|
||||
|
||||
const callScopedHotkeyCallback = useScopedHotkeyCallback();
|
||||
|
||||
return useHotkeys(
|
||||
keys,
|
||||
(keyboardEvent, hotkeysEvent) => {
|
||||
callScopedHotkeyCallback({
|
||||
keyboardEvent,
|
||||
hotkeysEvent,
|
||||
callback: () => {
|
||||
if (!pendingHotkey) {
|
||||
callback(keyboardEvent, hotkeysEvent);
|
||||
return;
|
||||
}
|
||||
setPendingHotkey(null);
|
||||
},
|
||||
scope,
|
||||
preventDefault: !!options.preventDefault,
|
||||
});
|
||||
},
|
||||
{
|
||||
enableOnContentEditable: options.enableOnContentEditable,
|
||||
enableOnFormTags: options.enableOnFormTags,
|
||||
},
|
||||
dependencies,
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,76 @@
|
||||
import { Options, useHotkeys } from 'react-hotkeys-hook';
|
||||
import { Keys } from 'react-hotkeys-hook/dist/types';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
import { pendingHotkeyState } from '../states/internal/pendingHotkeysState';
|
||||
|
||||
import { useScopedHotkeyCallback } from './useScopedHotkeyCallback';
|
||||
|
||||
export const useSequenceHotkeys = (
|
||||
firstKey: Keys,
|
||||
secondKey: Keys,
|
||||
sequenceCallback: () => void,
|
||||
scope: string,
|
||||
options: Options = {
|
||||
enableOnContentEditable: true,
|
||||
enableOnFormTags: true,
|
||||
preventDefault: true,
|
||||
},
|
||||
deps: any[] = [],
|
||||
) => {
|
||||
const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState);
|
||||
|
||||
const callScopedHotkeyCallback = useScopedHotkeyCallback();
|
||||
|
||||
useHotkeys(
|
||||
firstKey,
|
||||
(keyboardEvent, hotkeysEvent) => {
|
||||
callScopedHotkeyCallback({
|
||||
keyboardEvent,
|
||||
hotkeysEvent,
|
||||
callback: () => {
|
||||
setPendingHotkey(firstKey);
|
||||
},
|
||||
scope,
|
||||
preventDefault: !!options.preventDefault,
|
||||
});
|
||||
},
|
||||
{
|
||||
enableOnContentEditable: options.enableOnContentEditable,
|
||||
enableOnFormTags: options.enableOnFormTags,
|
||||
},
|
||||
[setPendingHotkey, scope],
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
secondKey,
|
||||
(keyboardEvent, hotkeysEvent) => {
|
||||
callScopedHotkeyCallback({
|
||||
keyboardEvent,
|
||||
hotkeysEvent,
|
||||
callback: () => {
|
||||
if (pendingHotkey !== firstKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingHotkey(null);
|
||||
|
||||
if (options.preventDefault) {
|
||||
keyboardEvent.stopImmediatePropagation();
|
||||
keyboardEvent.stopPropagation();
|
||||
keyboardEvent.preventDefault();
|
||||
}
|
||||
|
||||
sequenceCallback();
|
||||
},
|
||||
scope,
|
||||
preventDefault: false,
|
||||
});
|
||||
},
|
||||
{
|
||||
enableOnContentEditable: options.enableOnContentEditable,
|
||||
enableOnFormTags: options.enableOnFormTags,
|
||||
},
|
||||
[pendingHotkey, setPendingHotkey, scope, ...deps],
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,81 @@
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
import { DEFAULT_HOTKEYS_SCOPE_CUSTOM_SCOPES } from '../constants';
|
||||
import { currentHotkeyScopeState } from '../states/internal/currentHotkeyScopeState';
|
||||
import { internalHotkeysEnabledScopesState } from '../states/internal/internalHotkeysEnabledScopesState';
|
||||
import { AppHotkeyScope } from '../types/AppHotkeyScope';
|
||||
import { CustomHotkeyScopes } from '../types/CustomHotkeyScope';
|
||||
import { HotkeyScope } from '../types/HotkeyScope';
|
||||
|
||||
const isCustomScopesEqual = (
|
||||
customScopesA: CustomHotkeyScopes | undefined,
|
||||
customScopesB: CustomHotkeyScopes | undefined,
|
||||
) => {
|
||||
return (
|
||||
customScopesA?.commandMenu === customScopesB?.commandMenu &&
|
||||
customScopesA?.goto === customScopesB?.goto &&
|
||||
customScopesA?.keyboardShortcutMenu === customScopesB?.keyboardShortcutMenu
|
||||
);
|
||||
};
|
||||
|
||||
export const useSetHotkeyScope = () =>
|
||||
useRecoilCallback(
|
||||
({ snapshot, set }) =>
|
||||
async (hotkeyScopeToSet: string, customScopes?: CustomHotkeyScopes) => {
|
||||
const currentHotkeyScope = snapshot
|
||||
.getLoadable(currentHotkeyScopeState)
|
||||
.valueOrThrow();
|
||||
|
||||
if (currentHotkeyScope.scope === hotkeyScopeToSet) {
|
||||
if (!isDefined(customScopes)) {
|
||||
if (
|
||||
isCustomScopesEqual(
|
||||
currentHotkeyScope?.customScopes,
|
||||
DEFAULT_HOTKEYS_SCOPE_CUSTOM_SCOPES,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
isCustomScopesEqual(
|
||||
currentHotkeyScope?.customScopes,
|
||||
customScopes,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newHotkeyScope: HotkeyScope = {
|
||||
scope: hotkeyScopeToSet,
|
||||
customScopes: {
|
||||
commandMenu: customScopes?.commandMenu ?? true,
|
||||
goto: customScopes?.goto ?? false,
|
||||
keyboardShortcutMenu: customScopes?.keyboardShortcutMenu ?? false,
|
||||
},
|
||||
};
|
||||
|
||||
const scopesToSet: string[] = [];
|
||||
|
||||
if (newHotkeyScope.customScopes?.commandMenu) {
|
||||
scopesToSet.push(AppHotkeyScope.CommandMenu);
|
||||
}
|
||||
|
||||
if (newHotkeyScope?.customScopes?.goto) {
|
||||
scopesToSet.push(AppHotkeyScope.Goto);
|
||||
}
|
||||
|
||||
if (newHotkeyScope?.customScopes?.keyboardShortcutMenu) {
|
||||
scopesToSet.push(AppHotkeyScope.KeyboardShortcutMenu);
|
||||
}
|
||||
|
||||
scopesToSet.push(newHotkeyScope.scope);
|
||||
set(internalHotkeysEnabledScopesState, scopesToSet);
|
||||
set(currentHotkeyScopeState, newHotkeyScope);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@ -0,0 +1,9 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
import { INITIAL_HOTKEYS_SCOPE } from '../../constants';
|
||||
import { HotkeyScope } from '../../types/HotkeyScope';
|
||||
|
||||
export const currentHotkeyScopeState = atom<HotkeyScope>({
|
||||
key: 'currentHotkeyScopeState',
|
||||
default: INITIAL_HOTKEYS_SCOPE,
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const internalHotkeysEnabledScopesState = atom<string[]>({
|
||||
key: 'internalHotkeysEnabledScopesState',
|
||||
default: [],
|
||||
});
|
||||
@ -0,0 +1,7 @@
|
||||
import { Keys } from 'react-hotkeys-hook/dist/types';
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const pendingHotkeyState = atom<Keys | null>({
|
||||
key: 'pendingHotkeyState',
|
||||
default: null,
|
||||
});
|
||||
@ -0,0 +1,8 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
import { HotkeyScope } from '../../types/HotkeyScope';
|
||||
|
||||
export const previousHotkeyScopeState = atom<HotkeyScope | null>({
|
||||
key: 'previousHotkeyScopeState',
|
||||
default: null,
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
export enum AppHotkeyScope {
|
||||
App = 'app',
|
||||
Goto = 'goto',
|
||||
CommandMenu = 'command-menu',
|
||||
KeyboardShortcutMenu = 'keyboard-shortcut-menu',
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
export type CustomHotkeyScopes = {
|
||||
goto?: boolean;
|
||||
commandMenu?: boolean;
|
||||
keyboardShortcutMenu?: boolean;
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import { CustomHotkeyScopes } from './CustomHotkeyScope';
|
||||
|
||||
export type HotkeyScope = {
|
||||
scope: string;
|
||||
customScopes?: CustomHotkeyScopes;
|
||||
};
|
||||
@ -0,0 +1,57 @@
|
||||
export const isNonTextWritingKey = (key: string) => {
|
||||
const nonTextWritingKeys = [
|
||||
'Enter',
|
||||
'Tab',
|
||||
'Shift',
|
||||
'Escape',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'Delete',
|
||||
'Backspace',
|
||||
'F1',
|
||||
'F2',
|
||||
'F3',
|
||||
'F4',
|
||||
'F5',
|
||||
'F6',
|
||||
'F7',
|
||||
'F8',
|
||||
'F9',
|
||||
'F10',
|
||||
'F11',
|
||||
'F12',
|
||||
'Meta',
|
||||
'Alt',
|
||||
'Control',
|
||||
'CapsLock',
|
||||
'NumLock',
|
||||
'ScrollLock',
|
||||
'Pause',
|
||||
'Insert',
|
||||
'Home',
|
||||
'PageUp',
|
||||
'Delete',
|
||||
'End',
|
||||
'PageDown',
|
||||
'ContextMenu',
|
||||
'PrintScreen',
|
||||
'BrowserBack',
|
||||
'BrowserForward',
|
||||
'BrowserRefresh',
|
||||
'BrowserStop',
|
||||
'BrowserSearch',
|
||||
'BrowserFavorites',
|
||||
'BrowserHome',
|
||||
'VolumeMute',
|
||||
'VolumeDown',
|
||||
'VolumeUp',
|
||||
'MediaTrackNext',
|
||||
'MediaTrackPrevious',
|
||||
'MediaStop',
|
||||
'MediaPlayPause',
|
||||
];
|
||||
|
||||
return nonTextWritingKeys.includes(key);
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const currentPageLocationState = atom<string>({
|
||||
key: 'currentPageLocationState',
|
||||
default: '',
|
||||
});
|
||||
@ -0,0 +1,13 @@
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
|
||||
type PageTitleProps = {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const PageTitle = (props: PageTitleProps) => {
|
||||
return (
|
||||
<Helmet>
|
||||
<title>{props.title}</title>
|
||||
</Helmet>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,39 @@
|
||||
import { useRef } from 'react';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
|
||||
import { useListenClickOutside } from '../useListenClickOutside';
|
||||
|
||||
const onOutsideClick = jest.fn();
|
||||
|
||||
const TestComponentDomMode = () => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const buttonRef2 = useRef<HTMLButtonElement>(null);
|
||||
useListenClickOutside({
|
||||
refs: [buttonRef, buttonRef2],
|
||||
callback: onOutsideClick,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span>Outside</span>
|
||||
<button ref={buttonRef}>Inside</button>
|
||||
<button ref={buttonRef2}>Inside 2</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
test('useListenClickOutside web-hook works in dom mode', async () => {
|
||||
const { getByText } = render(<TestComponentDomMode />);
|
||||
const inside = getByText('Inside');
|
||||
const inside2 = getByText('Inside 2');
|
||||
const outside = getByText('Outside');
|
||||
|
||||
fireEvent.click(inside);
|
||||
expect(onOutsideClick).toHaveBeenCalledTimes(0);
|
||||
|
||||
fireEvent.click(inside2);
|
||||
expect(onOutsideClick).toHaveBeenCalledTimes(0);
|
||||
|
||||
fireEvent.click(outside);
|
||||
expect(onOutsideClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@ -0,0 +1,195 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export enum ClickOutsideMode {
|
||||
comparePixels = 'comparePixels',
|
||||
compareHTMLRef = 'compareHTMLRef',
|
||||
}
|
||||
|
||||
export const useListenClickOutside = <T extends Element>({
|
||||
refs,
|
||||
callback,
|
||||
mode = ClickOutsideMode.compareHTMLRef,
|
||||
enabled = true,
|
||||
}: {
|
||||
refs: Array<React.RefObject<T>>;
|
||||
callback: (event: MouseEvent | TouchEvent) => void;
|
||||
mode?: ClickOutsideMode;
|
||||
enabled?: boolean;
|
||||
}) => {
|
||||
const [isMouseDownInside, setIsMouseDownInside] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseDown = (event: MouseEvent | TouchEvent) => {
|
||||
if (mode === ClickOutsideMode.compareHTMLRef) {
|
||||
const clickedOnAtLeastOneRef = refs
|
||||
.filter((ref) => !!ref.current)
|
||||
.some((ref) => ref.current?.contains(event.target as Node));
|
||||
|
||||
setIsMouseDownInside(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;
|
||||
});
|
||||
|
||||
setIsMouseDownInside(clickedOnAtLeastOneRef);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (enabled) {
|
||||
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, enabled, isMouseDownInside]);
|
||||
};
|
||||
|
||||
export const useListenClickOutsideByClassName = ({
|
||||
classNames,
|
||||
excludeClassNames,
|
||||
callback,
|
||||
}: {
|
||||
classNames: string[];
|
||||
excludeClassNames?: string[];
|
||||
callback: () => void;
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
if (!(event.target instanceof Node)) return;
|
||||
|
||||
const clickedElement = event.target as HTMLElement;
|
||||
let isClickedInside = false;
|
||||
let isClickedOnExcluded = false;
|
||||
let currentElement: HTMLElement | null = clickedElement;
|
||||
|
||||
while (currentElement) {
|
||||
const currentClassList = currentElement.classList;
|
||||
|
||||
isClickedInside = classNames.some((className) =>
|
||||
currentClassList.contains(className),
|
||||
);
|
||||
isClickedOnExcluded =
|
||||
excludeClassNames?.some((className) =>
|
||||
currentClassList.contains(className),
|
||||
) ?? false;
|
||||
|
||||
if (isClickedInside || isClickedOnExcluded) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
|
||||
if (!isClickedInside && !isClickedOnExcluded) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('touchend', handleClickOutside, {
|
||||
capture: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('touchend', handleClickOutside, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}, [callback, classNames, excludeClassNames]);
|
||||
};
|
||||
@ -0,0 +1,67 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
type MouseListener = (positionX: number, positionY: number) => void;
|
||||
|
||||
export const useTrackPointer = ({
|
||||
shouldTrackPointer = true,
|
||||
onMouseMove,
|
||||
onMouseDown,
|
||||
onMouseUp,
|
||||
}: {
|
||||
shouldTrackPointer?: boolean;
|
||||
onMouseMove?: MouseListener;
|
||||
onMouseDown?: MouseListener;
|
||||
onMouseUp?: MouseListener;
|
||||
}) => {
|
||||
const extractPosition = useCallback((event: MouseEvent | TouchEvent) => {
|
||||
const clientX =
|
||||
'clientX' in event ? event.clientX : event.changedTouches[0].clientX;
|
||||
const clientY =
|
||||
'clientY' in event ? event.clientY : event.changedTouches[0].clientY;
|
||||
|
||||
return { clientX, clientY };
|
||||
}, []);
|
||||
|
||||
const onInternalMouseMove = useCallback(
|
||||
(event: MouseEvent | TouchEvent) => {
|
||||
const { clientX, clientY } = extractPosition(event);
|
||||
onMouseMove?.(clientX, clientY);
|
||||
},
|
||||
[onMouseMove, extractPosition],
|
||||
);
|
||||
|
||||
const onInternalMouseDown = useCallback(
|
||||
(event: MouseEvent | TouchEvent) => {
|
||||
const { clientX, clientY } = extractPosition(event);
|
||||
onMouseDown?.(clientX, clientY);
|
||||
},
|
||||
[onMouseDown, extractPosition],
|
||||
);
|
||||
|
||||
const onInternalMouseUp = useCallback(
|
||||
(event: MouseEvent | TouchEvent) => {
|
||||
const { clientX, clientY } = extractPosition(event);
|
||||
onMouseUp?.(clientX, clientY);
|
||||
},
|
||||
[onMouseUp, extractPosition],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldTrackPointer) {
|
||||
document.addEventListener('mousemove', onInternalMouseMove);
|
||||
document.addEventListener('mousedown', onInternalMouseDown);
|
||||
document.addEventListener('mouseup', onInternalMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onInternalMouseMove);
|
||||
document.removeEventListener('mousedown', onInternalMouseDown);
|
||||
document.removeEventListener('mouseup', onInternalMouseUp);
|
||||
};
|
||||
}
|
||||
}, [
|
||||
shouldTrackPointer,
|
||||
onInternalMouseMove,
|
||||
onInternalMouseDown,
|
||||
onInternalMouseUp,
|
||||
]);
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
import { useRef } from 'react';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { RecoilScopeContext as RecoilScopeContextType } from '@/types/RecoilScopeContext';
|
||||
|
||||
import { RecoilScopeContext } from '../states/RecoilScopeContext';
|
||||
|
||||
/**
|
||||
*
|
||||
* @deprecated Use a custom scope context instead, see example with DropdownScope
|
||||
*/
|
||||
export const RecoilScope = ({
|
||||
children,
|
||||
scopeId,
|
||||
CustomRecoilScopeContext,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
scopeId?: string;
|
||||
CustomRecoilScopeContext?: RecoilScopeContextType;
|
||||
}) => {
|
||||
// eslint-disable-next-line twenty/no-state-useref
|
||||
const currentScopeId = useRef(scopeId ?? v4());
|
||||
|
||||
return CustomRecoilScopeContext ? (
|
||||
<CustomRecoilScopeContext.Provider value={currentScopeId.current}>
|
||||
{children}
|
||||
</CustomRecoilScopeContext.Provider>
|
||||
) : (
|
||||
<RecoilScopeContext.Provider value={currentScopeId.current}>
|
||||
{children}
|
||||
</RecoilScopeContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,16 @@
|
||||
import { Context, useContext } from 'react';
|
||||
|
||||
/**
|
||||
* @deprecated use a custom scope instead and desctructure the scope id from the scope context
|
||||
* Get the scope context with useScopeInternalContext
|
||||
*/
|
||||
export const useContextScopeId = (SpecificContext: Context<string | null>) => {
|
||||
const recoilScopeId = useContext(SpecificContext);
|
||||
|
||||
if (!recoilScopeId)
|
||||
throw new Error(
|
||||
`Using useContextScopedId outside of the specified context : ${SpecificContext.displayName}, verify that you are using a RecoilScope with the specific context you want to use.`,
|
||||
);
|
||||
|
||||
return recoilScopeId;
|
||||
};
|
||||
@ -0,0 +1,17 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { RecoilScopeContext } from '@/types/RecoilScopeContext';
|
||||
|
||||
/**
|
||||
* @deprecated Use a custom scope instead and desctructure the scope id from the scope context
|
||||
*/
|
||||
export const useRecoilScopeId = (RecoilScopeContext: RecoilScopeContext) => {
|
||||
const recoilScopeId = useContext(RecoilScopeContext);
|
||||
|
||||
if (!recoilScopeId)
|
||||
throw new Error(
|
||||
`Using useRecoilScopeId outside of the specified context : ${RecoilScopeContext.displayName}, verify that you are using a RecoilScope with the specific context you want to use.`,
|
||||
);
|
||||
|
||||
return recoilScopeId;
|
||||
};
|
||||
@ -0,0 +1,27 @@
|
||||
import { RecoilState, SerializableParam, useRecoilState } from 'recoil';
|
||||
|
||||
import { ScopedFamilyStateKey } from '../scopes-internal/types/ScopedFamilyStateKey';
|
||||
|
||||
export const useRecoilScopedFamilyState = <
|
||||
StateType,
|
||||
FamilyKey extends SerializableParam,
|
||||
>(
|
||||
recoilState: (
|
||||
scopedFamilyKey: ScopedFamilyStateKey<FamilyKey>,
|
||||
) => RecoilState<StateType>,
|
||||
scopeId: string,
|
||||
familyKey?: FamilyKey,
|
||||
) => {
|
||||
const familyState = useRecoilState<StateType>(
|
||||
recoilState({
|
||||
scopeId,
|
||||
familyKey: familyKey || ('' as FamilyKey),
|
||||
}),
|
||||
);
|
||||
|
||||
if (!familyKey) {
|
||||
return [undefined, undefined];
|
||||
}
|
||||
|
||||
return familyState;
|
||||
};
|
||||
@ -0,0 +1,22 @@
|
||||
import { Context, useContext } from 'react';
|
||||
import { RecoilState, useRecoilState } from 'recoil';
|
||||
|
||||
import { RecoilScopeContext } from '../states/RecoilScopeContext';
|
||||
|
||||
export const useRecoilScopedState = <StateType>(
|
||||
recoilState: (param: string) => RecoilState<StateType>,
|
||||
CustomRecoilScopeContext?: Context<string | null>,
|
||||
) => {
|
||||
const recoilScopeId = useContext(
|
||||
CustomRecoilScopeContext ?? RecoilScopeContext,
|
||||
);
|
||||
|
||||
if (!recoilScopeId)
|
||||
throw new Error(
|
||||
`Using a scoped atom without a RecoilScope : ${
|
||||
recoilState('').key
|
||||
}, verify that you are using a RecoilScope with a specific context if you intended to do so.`,
|
||||
);
|
||||
|
||||
return useRecoilState<StateType>(recoilState(recoilScopeId));
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
import { RecoilState, useRecoilState } from 'recoil';
|
||||
|
||||
import { ScopedStateKey } from '../scopes-internal/types/ScopedStateKey';
|
||||
|
||||
export const useRecoilScopedStateV2 = <StateType>(
|
||||
recoilState: (scopedKey: ScopedStateKey) => RecoilState<StateType>,
|
||||
scopeId: string,
|
||||
) => {
|
||||
return useRecoilState<StateType>(
|
||||
recoilState({
|
||||
scopeId,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,25 @@
|
||||
import { Context, useContext } from 'react';
|
||||
import { RecoilState, RecoilValueReadOnly, useRecoilValue } from 'recoil';
|
||||
|
||||
import { RecoilScopeContext } from '../states/RecoilScopeContext';
|
||||
|
||||
/**
|
||||
* @deprecated use useRecoilScopedStateV2 instead
|
||||
*/
|
||||
export const useRecoilScopedValue = <T>(
|
||||
recoilState: (param: string) => RecoilState<T> | RecoilValueReadOnly<T>,
|
||||
CustomRecoilScopeContext?: Context<string | null>,
|
||||
) => {
|
||||
const recoilScopeId = useContext(
|
||||
CustomRecoilScopeContext ?? RecoilScopeContext,
|
||||
);
|
||||
|
||||
if (!recoilScopeId)
|
||||
throw new Error(
|
||||
`Using a scoped atom without a RecoilScope : ${
|
||||
recoilState('').key
|
||||
}, verify that you are using a RecoilScope with a specific context if you intended to do so.`,
|
||||
);
|
||||
|
||||
return useRecoilValue<T>(recoilState(recoilScopeId));
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
import { RecoilState, useRecoilValue } from 'recoil';
|
||||
|
||||
import { ScopedStateKey } from '../scopes-internal/types/ScopedStateKey';
|
||||
|
||||
export const useRecoilScopedValueV2 = <StateType>(
|
||||
recoilState: (scopedKey: ScopedStateKey) => RecoilState<StateType>,
|
||||
scopeId: string,
|
||||
) => {
|
||||
return useRecoilValue<StateType>(
|
||||
recoilState({
|
||||
scopeId,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,27 @@
|
||||
import { RecoilState, SerializableParam, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { ScopedFamilyStateKey } from '../scopes-internal/types/ScopedFamilyStateKey';
|
||||
|
||||
export const useSetRecoilScopedFamilyState = <
|
||||
StateType,
|
||||
FamilyKey extends SerializableParam,
|
||||
>(
|
||||
recoilState: (
|
||||
scopedFamilyKey: ScopedFamilyStateKey<FamilyKey>,
|
||||
) => RecoilState<StateType>,
|
||||
scopeId: string,
|
||||
familyKey?: FamilyKey,
|
||||
) => {
|
||||
const familyState = useSetRecoilState<StateType>(
|
||||
recoilState({
|
||||
scopeId,
|
||||
familyKey: familyKey || ('' as FamilyKey),
|
||||
}),
|
||||
);
|
||||
|
||||
if (!familyKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
return familyState;
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
import { RecoilState, useSetRecoilState } from 'recoil';
|
||||
|
||||
import { ScopedStateKey } from '../scopes-internal/types/ScopedStateKey';
|
||||
|
||||
export const useSetRecoilScopedStateV2 = <StateType>(
|
||||
recoilState: (scopedKey: ScopedStateKey) => RecoilState<StateType>,
|
||||
scopeId: string,
|
||||
) => {
|
||||
return useSetRecoilState<StateType>(
|
||||
recoilState({
|
||||
scopeId,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,20 @@
|
||||
import { ScopeInternalContext } from '../types/ScopeInternalContext';
|
||||
|
||||
import { useScopeInternalContext } from './useScopeInternalContext';
|
||||
|
||||
export const useAvailableScopeIdOrThrow = <T extends { scopeId: string }>(
|
||||
Context: ScopeInternalContext<T>,
|
||||
scopeIdFromProps?: string,
|
||||
): string => {
|
||||
const scopeInternalContext = useScopeInternalContext(Context);
|
||||
|
||||
const scopeIdFromContext = scopeInternalContext?.scopeId;
|
||||
|
||||
if (scopeIdFromProps) {
|
||||
return scopeIdFromProps;
|
||||
} else if (scopeIdFromContext) {
|
||||
return scopeIdFromContext;
|
||||
} else {
|
||||
throw new Error('Scope id is not provided and cannot be found in context.');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,11 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { ScopeInternalContext } from '../types/ScopeInternalContext';
|
||||
|
||||
export const useScopeInternalContext = <T extends { scopeId: string }>(
|
||||
Context: ScopeInternalContext<T>,
|
||||
) => {
|
||||
const context = useContext(Context);
|
||||
|
||||
return context;
|
||||
};
|
||||
@ -0,0 +1,19 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
import { ScopeInternalContext } from '../types/ScopeInternalContext';
|
||||
|
||||
export const useScopeInternalContextOrThrow = <T extends { scopeId: string }>(
|
||||
Context: ScopeInternalContext<T>,
|
||||
) => {
|
||||
const context = useContext(Context);
|
||||
|
||||
if (!isDefined(context)) {
|
||||
throw new Error(
|
||||
`Using a scope context without a ScopeInternalContext.Provider wrapper for context : ${Context.displayName}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
@ -0,0 +1,4 @@
|
||||
import { Context } from 'react';
|
||||
|
||||
export type ScopeInternalContext<T extends { scopeId: string }> =
|
||||
Context<T | null>;
|
||||
@ -0,0 +1,6 @@
|
||||
import { SerializableParam } from 'recoil';
|
||||
|
||||
export type ScopedFamilyStateKey<FamilyKey extends SerializableParam> = {
|
||||
scopeId: string;
|
||||
familyKey: FamilyKey;
|
||||
};
|
||||
@ -0,0 +1,3 @@
|
||||
export type ScopedStateKey = {
|
||||
scopeId: string;
|
||||
};
|
||||
@ -0,0 +1,13 @@
|
||||
import { Context, createContext } from 'react';
|
||||
|
||||
import { ScopedStateKey } from '../types/ScopedStateKey';
|
||||
|
||||
type ScopeInternalContext<T extends ScopedStateKey> = Context<T | null>;
|
||||
|
||||
export const createScopeInternalContext = <T extends ScopedStateKey>(
|
||||
initialValue?: T,
|
||||
) => {
|
||||
return createContext<T | null>(
|
||||
initialValue ?? null,
|
||||
) as ScopeInternalContext<T>;
|
||||
};
|
||||
@ -0,0 +1,3 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const RecoilScopeContext = createContext<string | null>(null);
|
||||
@ -0,0 +1,7 @@
|
||||
import { RecoilValueReadOnly } from 'recoil';
|
||||
|
||||
import { ScopedStateKey } from '../scopes-internal/types/ScopedStateKey';
|
||||
|
||||
export type RecoilScopedSelector<StateType> = (
|
||||
scopedKey: ScopedStateKey,
|
||||
) => RecoilValueReadOnly<StateType>;
|
||||
@ -0,0 +1,7 @@
|
||||
import { RecoilState } from 'recoil';
|
||||
|
||||
import { ScopedStateKey } from '../scopes-internal/types/ScopedStateKey';
|
||||
|
||||
export type RecoilScopedState<StateType> = (
|
||||
scopedKey: ScopedStateKey,
|
||||
) => RecoilState<StateType>;
|
||||
@ -0,0 +1,19 @@
|
||||
import { atomFamily, SerializableParam } from 'recoil';
|
||||
|
||||
import { ScopedFamilyStateKey } from '../scopes-internal/types/ScopedFamilyStateKey';
|
||||
|
||||
export const createScopedFamilyState = <
|
||||
ValueType,
|
||||
FamilyKey extends SerializableParam,
|
||||
>({
|
||||
key,
|
||||
defaultValue,
|
||||
}: {
|
||||
key: string;
|
||||
defaultValue: ValueType;
|
||||
}) => {
|
||||
return atomFamily<ValueType, ScopedFamilyStateKey<FamilyKey>>({
|
||||
key,
|
||||
default: defaultValue,
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
import {
|
||||
GetCallback,
|
||||
GetRecoilValue,
|
||||
Loadable,
|
||||
RecoilValue,
|
||||
selectorFamily,
|
||||
WrappedValue,
|
||||
} from 'recoil';
|
||||
|
||||
import { ScopedStateKey } from '../scopes-internal/types/ScopedStateKey';
|
||||
|
||||
type SelectorGetter<T, P> = (
|
||||
param: P,
|
||||
) => (opts: {
|
||||
get: GetRecoilValue;
|
||||
getCallback: GetCallback;
|
||||
}) => Promise<T> | RecoilValue<T> | Loadable<T> | WrappedValue<T> | T;
|
||||
|
||||
export const createScopedSelector = <ValueType>({
|
||||
key,
|
||||
get,
|
||||
}: {
|
||||
key: string;
|
||||
get: SelectorGetter<ValueType, ScopedStateKey>;
|
||||
}) => {
|
||||
return selectorFamily<ValueType, ScopedStateKey>({
|
||||
key,
|
||||
get,
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
|
||||
export const createScopedState = <ValueType>({
|
||||
key,
|
||||
defaultValue,
|
||||
}: {
|
||||
key: string;
|
||||
defaultValue: ValueType;
|
||||
}) => {
|
||||
return atomFamily<ValueType, { scopeId: string }>({
|
||||
key,
|
||||
default: defaultValue,
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,19 @@
|
||||
import { RecoilState, SerializableParam } from 'recoil';
|
||||
|
||||
import { ScopedFamilyStateKey } from '../scopes-internal/types/ScopedFamilyStateKey';
|
||||
|
||||
export const getScopedFamilyState = <
|
||||
StateType,
|
||||
FamilyKey extends SerializableParam,
|
||||
>(
|
||||
recoilState: (
|
||||
scopedFamilyKey: ScopedFamilyStateKey<FamilyKey>,
|
||||
) => RecoilState<StateType>,
|
||||
scopeId: string,
|
||||
familyKey: FamilyKey,
|
||||
) => {
|
||||
return recoilState({
|
||||
scopeId,
|
||||
familyKey: familyKey || ('' as FamilyKey),
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import { RecoilScopedSelector } from '../types/RecoilScopedSelector';
|
||||
|
||||
export const getScopedSelector = <StateType>(
|
||||
recoilScopedState: RecoilScopedSelector<StateType>,
|
||||
scopeId: string,
|
||||
) => {
|
||||
return recoilScopedState({
|
||||
scopeId,
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import { RecoilScopedState } from '../types/RecoilScopedState';
|
||||
|
||||
export const getScopedState = <StateType>(
|
||||
recoilScopedState: RecoilScopedState<StateType>,
|
||||
scopeId: string,
|
||||
) => {
|
||||
return recoilScopedState({
|
||||
scopeId,
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,15 @@
|
||||
import { Snapshot } from 'recoil';
|
||||
|
||||
import { RecoilScopedSelector } from '../types/RecoilScopedSelector';
|
||||
|
||||
import { getScopedSelector } from './getScopedSelector';
|
||||
|
||||
export const getSnapshotScopedSelector = <StateType>(
|
||||
snapshot: Snapshot,
|
||||
scopedState: RecoilScopedSelector<StateType>,
|
||||
scopeId: string,
|
||||
) => {
|
||||
return snapshot
|
||||
.getLoadable(getScopedSelector(scopedState, scopeId))
|
||||
.getValue();
|
||||
};
|
||||
@ -0,0 +1,13 @@
|
||||
import { Snapshot } from 'recoil';
|
||||
|
||||
import { RecoilScopedState } from '../types/RecoilScopedState';
|
||||
|
||||
import { getScopedState } from './getScopedState';
|
||||
|
||||
export const getSnapshotScopedValue = <StateType>(
|
||||
snapshot: Snapshot,
|
||||
scopedState: RecoilScopedState<StateType>,
|
||||
scopeId: string,
|
||||
) => {
|
||||
return snapshot.getLoadable(getScopedState(scopedState, scopeId)).getValue();
|
||||
};
|
||||
@ -0,0 +1,8 @@
|
||||
import { RecoilState, RecoilValueReadOnly, Snapshot } from 'recoil';
|
||||
|
||||
export const getSnapshotValue = <StateType>(
|
||||
snapshot: Snapshot,
|
||||
state: RecoilState<StateType> | RecoilValueReadOnly<StateType>,
|
||||
) => {
|
||||
return snapshot.getLoadable(state).getValue();
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import { MOBILE_VIEWPORT } from '@/ui/theme/constants/theme';
|
||||
|
||||
export const useIsMobile = () =>
|
||||
useMediaQuery({ query: `(max-width: ${MOBILE_VIEWPORT}px)` });
|
||||
@ -0,0 +1,41 @@
|
||||
import { createContext, RefObject, useRef } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { useListenScroll } from '../hooks/useListenScroll';
|
||||
|
||||
export const ScrollWrapperContext = createContext<RefObject<HTMLDivElement>>({
|
||||
current: null,
|
||||
});
|
||||
|
||||
const StyledScrollWrapper = styled.div`
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable;
|
||||
width: 100%;
|
||||
|
||||
&.scrolling::-webkit-scrollbar-thumb {
|
||||
background-color: ${({ theme }) => theme.border.color.medium};
|
||||
}
|
||||
`;
|
||||
|
||||
export type ScrollWrapperProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ScrollWrapper = ({ children, className }: ScrollWrapperProps) => {
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useListenScroll({
|
||||
scrollableRef,
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollWrapperContext.Provider value={scrollableRef}>
|
||||
<StyledScrollWrapper ref={scrollableRef} className={className}>
|
||||
{children}
|
||||
</StyledScrollWrapper>
|
||||
</ScrollWrapperContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,45 @@
|
||||
import { useEffect } from 'react';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
import { isScrollingState } from '../states/isScrollingState';
|
||||
|
||||
export const useListenScroll = <T extends Element>({
|
||||
scrollableRef,
|
||||
}: {
|
||||
scrollableRef: React.RefObject<T>;
|
||||
}) => {
|
||||
const hideScrollBarsCallback = useRecoilCallback(({ snapshot }) => () => {
|
||||
const isScrolling = snapshot.getLoadable(isScrollingState).getValue();
|
||||
if (!isScrolling) {
|
||||
scrollableRef.current?.classList.remove('scrolling');
|
||||
}
|
||||
});
|
||||
|
||||
const handleScrollStart = useRecoilCallback(({ set }) => () => {
|
||||
set(isScrollingState, true);
|
||||
scrollableRef.current?.classList.add('scrolling');
|
||||
});
|
||||
|
||||
const handleScrollEnd = useRecoilCallback(({ set }) => () => {
|
||||
set(isScrollingState, false);
|
||||
debounce(hideScrollBarsCallback, 1000)();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const refTarget = scrollableRef.current;
|
||||
|
||||
refTarget?.addEventListener('scrollend', handleScrollEnd);
|
||||
refTarget?.addEventListener('scroll', handleScrollStart);
|
||||
|
||||
return () => {
|
||||
refTarget?.removeEventListener('scrollend', handleScrollEnd);
|
||||
refTarget?.removeEventListener('scroll', handleScrollStart);
|
||||
};
|
||||
}, [
|
||||
hideScrollBarsCallback,
|
||||
handleScrollStart,
|
||||
handleScrollEnd,
|
||||
scrollableRef,
|
||||
]);
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { ScrollWrapperContext } from '../components/ScrollWrapper';
|
||||
|
||||
export const useScrollWrapperScopedRef = () => {
|
||||
const scrollWrapperRef = useContext(ScrollWrapperContext);
|
||||
|
||||
if (!scrollWrapperRef)
|
||||
throw new Error(
|
||||
`Using a scoped ref without a ScrollWrapper : verify that you are using a ScrollWrapper if you intended to do so.`,
|
||||
);
|
||||
|
||||
return scrollWrapperRef;
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const isScrollingState = atom({
|
||||
key: 'scroll/isScollingState',
|
||||
default: false,
|
||||
});
|
||||
Reference in New Issue
Block a user