Implement UI animations

using Framer Motion
This commit is contained in:
IRBorisov 2023-12-25 16:53:27 +03:00
parent e024900dfa
commit 1df94572b7
31 changed files with 452 additions and 164 deletions

View File

@ -24,6 +24,7 @@ This readme file is used mostly to document project dependencies
- react-pdf - react-pdf
- react-tooltip - react-tooltip
- js-file-download - js-file-download
- framer-motion
- reagraph - reagraph
- @tanstack/react-table - @tanstack/react-table
- @uiw/react-codemirror - @uiw/react-codemirror

View File

@ -14,6 +14,7 @@
"@uiw/react-codemirror": "^4.21.21", "@uiw/react-codemirror": "^4.21.21",
"axios": "^1.6.2", "axios": "^1.6.2",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"framer-motion": "^10.16.16",
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@ -4985,6 +4986,44 @@
"url": "https://github.com/sponsors/rawify" "url": "https://github.com/sponsors/rawify"
} }
}, },
"node_modules/framer-motion": {
"version": "10.16.16",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-10.16.16.tgz",
"integrity": "sha512-je6j91rd7NmUX7L1XHouwJ4v3R+SO4umso2LUcgOct3rHZ0PajZ80ETYZTajzEXEl9DlKyzjyt4AvGQ+lrebOw==",
"dependencies": {
"tslib": "^2.4.0"
},
"optionalDependencies": {
"@emotion/is-prop-valid": "^0.8.2"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/framer-motion/node_modules/@emotion/is-prop-valid": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
"integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==",
"optional": true,
"dependencies": {
"@emotion/memoize": "0.7.4"
}
},
"node_modules/framer-motion/node_modules/@emotion/memoize": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz",
"integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
"optional": true
},
"node_modules/fs-minipass": { "node_modules/fs-minipass": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",

View File

@ -18,6 +18,7 @@
"@uiw/react-codemirror": "^4.21.21", "@uiw/react-codemirror": "^4.21.21",
"axios": "^1.6.2", "axios": "^1.6.2",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"framer-motion": "^10.16.16",
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",

View File

@ -38,7 +38,7 @@ function Button({
className, className,
colors colors
)} )}
data-tooltip-id={title ? (globalIDs.tooltip) : undefined} data-tooltip-id={title ? globalIDs.tooltip : undefined}
data-tooltip-content={title} data-tooltip-content={title}
{...restProps} {...restProps}
> >

View File

@ -22,7 +22,6 @@ function ConceptTooltip({
...restProps ...restProps
}: ConceptTooltipProps) { }: ConceptTooltipProps) {
const { darkMode } = useConceptTheme(); const { darkMode } = useConceptTheme();
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return null; return null;
} }

View File

@ -1,25 +1,29 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { motion } from 'framer-motion';
import { animateDropdown } from '@/utils/animations';
import { CProps } from '../props'; import { CProps } from '../props';
import Overlay from './Overlay';
interface DropdownProps interface DropdownProps
extends CProps.Styling { extends CProps.Styling {
stretchLeft?: boolean stretchLeft?: boolean
isOpen: boolean
children: React.ReactNode children: React.ReactNode
} }
function Dropdown({ function Dropdown({
isOpen, stretchLeft,
className, className,
stretchLeft,
children, children,
...restProps ...restProps
}: DropdownProps) { }: DropdownProps) {
return ( return (
<Overlay <div className='relative'>
layer='z-modal-tooltip' <motion.div
position='mt-3'
className={clsx( className={clsx(
'z-modal-tooltip',
'absolute mt-3',
'flex flex-col', 'flex flex-col',
'border rounded-md shadow-lg', 'border rounded-md shadow-lg',
'text-sm', 'text-sm',
@ -30,10 +34,14 @@ function Dropdown({
}, },
className className
)} )}
initial={false}
animate={isOpen ? 'open' : 'closed'}
variants={animateDropdown}
{...restProps} {...restProps}
> >
{children} {children}
</Overlay>); </motion.div>
</div>);
} }
export default Dropdown; export default Dropdown;

View File

@ -1,11 +1,13 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { motion } from 'framer-motion';
import { animateDropdownItem } from '@/utils/animations';
import { globalIDs } from '@/utils/constants'; import { globalIDs } from '@/utils/constants';
import { CProps } from '../props'; import { CProps } from '../props';
interface DropdownButtonProps interface DropdownButtonProps
extends CProps.Button { extends CProps.AnimatedButton {
text?: string text?: string
icon?: React.ReactNode icon?: React.ReactNode
@ -20,7 +22,7 @@ function DropdownButton({
...restProps ...restProps
}: DropdownButtonProps) { }: DropdownButtonProps) {
return ( return (
<button type='button' <motion.button type='button'
onClick={onClick} onClick={onClick}
className={clsx( className={clsx(
'px-3 py-1 inline-flex items-center gap-2', 'px-3 py-1 inline-flex items-center gap-2',
@ -33,6 +35,7 @@ function DropdownButton({
}, },
className className
)} )}
variants={animateDropdownItem}
data-tooltip-id={title ? (globalIDs.tooltip) : undefined} data-tooltip-id={title ? (globalIDs.tooltip) : undefined}
data-tooltip-content={title} data-tooltip-content={title}
{...restProps} {...restProps}
@ -40,7 +43,7 @@ function DropdownButton({
{children ? children : null} {children ? children : null}
{!children && icon ? icon : null} {!children && icon ? icon : null}
{!children && text ? <span>{text}</span> : null} {!children && text ? <span>{text}</span> : null}
</button>); </motion.button>);
} }
export default DropdownButton; export default DropdownButton;

View File

@ -1,4 +1,7 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { motion } from 'framer-motion';
import { animateDropdownItem } from '@/utils/animations';
import Checkbox from './Checkbox'; import Checkbox from './Checkbox';
@ -12,7 +15,8 @@ interface DropdownCheckboxProps {
function DropdownCheckbox({ title, setValue, disabled, ...restProps }: DropdownCheckboxProps) { function DropdownCheckbox({ title, setValue, disabled, ...restProps }: DropdownCheckboxProps) {
return ( return (
<div <motion.div
variants={animateDropdownItem}
title={title} title={title}
className={clsx( className={clsx(
'px-3 py-1', 'px-3 py-1',
@ -26,7 +30,7 @@ function DropdownCheckbox({ title, setValue, disabled, ...restProps }: DropdownC
setValue={setValue} setValue={setValue}
{...restProps} {...restProps}
/> />
</div>); </motion.div>);
} }
export default DropdownCheckbox; export default DropdownCheckbox;

View File

@ -1,10 +1,12 @@
'use client'; 'use client';
import clsx from 'clsx'; import clsx from 'clsx';
import { motion } from 'framer-motion';
import { useRef } from 'react'; import { useRef } from 'react';
import { BiX } from 'react-icons/bi'; import { BiX } from 'react-icons/bi';
import useEscapeKey from '@/hooks/useEscapeKey'; import useEscapeKey from '@/hooks/useEscapeKey';
import { animateModal } from '@/utils/animations';
import { CProps } from '../props'; import { CProps } from '../props';
import Button from './Button'; import Button from './Button';
@ -56,13 +58,16 @@ function Modal({
'w-full h-full', 'w-full h-full',
'clr-modal-backdrop' 'clr-modal-backdrop'
)}/> )}/>
<div ref={ref} <motion.div ref={ref}
className={clsx( className={clsx(
'z-modal', 'z-modal',
'fixed bottom-1/2 left-1/2 -translate-x-1/2 translate-y-1/2', 'fixed bottom-1/2 left-1/2 -translate-x-1/2 translate-y-1/2',
'border shadow-md', 'border shadow-md',
'clr-app' 'clr-app'
)} )}
initial={{...animateModal.initial}}
animate={{...animateModal.animate}}
exit={{...animateModal.exit}}
{...restProps} {...restProps}
> >
<Overlay position='right-[0.3rem] top-2'> <Overlay position='right-[0.3rem] top-2'>
@ -107,7 +112,7 @@ function Modal({
onClick={handleCancel} onClick={handleCancel}
/> />
</div> </div>
</div> </motion.div>
</>); </>);
} }

View File

@ -1,10 +1,12 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { motion } from 'framer-motion';
import { FaSquarePlus } from 'react-icons/fa6'; import { FaSquarePlus } from 'react-icons/fa6';
import { IoLibrary } from 'react-icons/io5'; import { IoLibrary } from 'react-icons/io5';
import { EducationIcon } from '@/components/Icons'; import { EducationIcon } from '@/components/Icons';
import { useConceptNavigation } from '@/context/NagivationContext'; import { useConceptNavigation } from '@/context/NagivationContext';
import { useConceptTheme } from '@/context/ThemeContext'; import { useConceptTheme } from '@/context/ThemeContext';
import { animateNavigation } from '@/utils/animations';
import Logo from './Logo'; import Logo from './Logo';
import NavigationButton from './NavigationButton'; import NavigationButton from './NavigationButton';
@ -13,7 +15,7 @@ import UserMenu from './UserMenu';
function Navigation () { function Navigation () {
const router = useConceptNavigation(); const router = useConceptNavigation();
const { noNavigation } = useConceptTheme(); const { noNavigationAnimation } = useConceptTheme();
const navigateHome = () => router.push('/'); const navigateHome = () => router.push('/');
const navigateLibrary = () => router.push('/library'); const navigateLibrary = () => router.push('/library');
@ -28,13 +30,15 @@ function Navigation () {
'select-none' 'select-none'
)}> )}>
<ToggleNavigationButton /> <ToggleNavigationButton />
{!noNavigation ? <motion.div
<div
className={clsx( className={clsx(
'pl-2 pr-[0.9rem] h-[3rem]', 'pl-2 pr-[0.9rem] h-[3rem]',
'flex justify-between', 'flex justify-between',
'border-b-2 rounded-none' 'shadow-border'
)} )}
initial={false}
animate={!noNavigationAnimation ? 'open' : 'closed'}
variants={animateNavigation}
> >
<div className='flex items-center mr-2 cursor-pointer' onClick={navigateHome} tabIndex={-1}> <div className='flex items-center mr-2 cursor-pointer' onClick={navigateHome} tabIndex={-1}>
<Logo /> <Logo />
@ -60,7 +64,7 @@ function Navigation () {
/> />
<UserMenu /> <UserMenu />
</div> </div>
</div> : null} </motion.div>
</nav>); </nav>);
} }

View File

@ -1,36 +1,28 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { useMemo } from 'react'; import { motion } from 'framer-motion';
import { RiPushpinFill, RiUnpinLine } from 'react-icons/ri';
import { useConceptTheme } from '@/context/ThemeContext'; import { useConceptTheme } from '@/context/ThemeContext';
import { animateNavigationToggle } from '@/utils/animations';
function ToggleNavigationButton() { function ToggleNavigationButton() {
const { noNavigation, toggleNoNavigation } = useConceptTheme(); const { noNavigationAnimation, toggleNoNavigation } = useConceptTheme();
const text = useMemo(() => (
noNavigation ?
''
:
<>
<p>{'>'}</p>
<p>{'>'}</p>
</>
), [noNavigation]
);
return ( return (
<button type='button' tabIndex={-1} <motion.button type='button' tabIndex={-1}
title={noNavigation ? 'Показать навигацию' : 'Скрыть навигацию'} title={noNavigationAnimation ? 'Показать навигацию' : 'Скрыть навигацию'}
className={clsx( className={clsx(
'absolute top-0 right-0 z-navigation', 'absolute top-0 right-0 z-navigation flex items-center justify-center',
'border-b-2 border-l-2 rounded-none',
'clr-btn-nav', 'clr-btn-nav',
{ 'select-none disabled:cursor-not-allowed'
'px-1 h-[1.6rem]': noNavigation,
'w-[1.2rem] h-[3rem]': !noNavigation
}
)} )}
onClick={toggleNoNavigation} onClick={toggleNoNavigation}
initial={false}
animate={noNavigationAnimation ? 'off' : 'on'}
variants={animateNavigationToggle}
> >
{text} {!noNavigationAnimation ? <RiPushpinFill /> : null}
</button>); {noNavigationAnimation ? <RiUnpinLine /> : null}
</motion.button>);
} }
export default ToggleNavigationButton; export default ToggleNavigationButton;

View File

@ -5,10 +5,11 @@ import { useConceptNavigation } from '@/context/NagivationContext';
import { useConceptTheme } from '@/context/ThemeContext'; import { useConceptTheme } from '@/context/ThemeContext';
interface UserDropdownProps { interface UserDropdownProps {
isOpen: boolean
hideDropdown: () => void hideDropdown: () => void
} }
function UserDropdown({ hideDropdown }: UserDropdownProps) { function UserDropdown({ isOpen, hideDropdown }: UserDropdownProps) {
const { darkMode, toggleDarkMode } = useConceptTheme(); const { darkMode, toggleDarkMode } = useConceptTheme();
const router = useConceptNavigation(); const router = useConceptNavigation();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
@ -25,7 +26,7 @@ function UserDropdown({ hideDropdown }: UserDropdownProps) {
}; };
return ( return (
<Dropdown className='w-36' stretchLeft> <Dropdown className='w-36' stretchLeft isOpen={isOpen}>
<DropdownButton <DropdownButton
text={user?.username} text={user?.username}
title='Профиль пользователя' title='Профиль пользователя'

View File

@ -27,10 +27,10 @@ function UserMenu() {
icon={<FaCircleUser size='1.5rem' />} icon={<FaCircleUser size='1.5rem' />}
onClick={menu.toggle} onClick={menu.toggle}
/> : null} /> : null}
{(user && menu.isActive) ?
<UserDropdown <UserDropdown
isOpen={!!user && menu.isOpen}
hideDropdown={() => menu.hide()} hideDropdown={() => menu.hide()}
/> : null} />
</div>); </div>);
} }

View File

@ -6,6 +6,7 @@ import { createTheme } from '@uiw/codemirror-themes';
import CodeMirror, { BasicSetupOptions, ReactCodeMirrorProps, ReactCodeMirrorRef } from '@uiw/react-codemirror'; import CodeMirror, { BasicSetupOptions, ReactCodeMirrorProps, ReactCodeMirrorRef } from '@uiw/react-codemirror';
import clsx from 'clsx'; import clsx from 'clsx';
import { EditorView } from 'codemirror'; import { EditorView } from 'codemirror';
import { AnimatePresence } from 'framer-motion';
import { RefObject, useCallback, useMemo, useRef, useState } from 'react'; import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
import Label from '@/components/Common/Label'; import Label from '@/components/Common/Label';
@ -164,6 +165,7 @@ function RefsInput({
}, [thisRef]); }, [thisRef]);
return (<> return (<>
<AnimatePresence>
{showEditor ? {showEditor ?
<DlgEditReference <DlgEditReference
hideWindow={() => setShowEditor(false)} hideWindow={() => setShowEditor(false)}
@ -177,6 +179,8 @@ function RefsInput({
}} }}
onSave={handleInputReference} onSave={handleInputReference}
/> : null} /> : null}
</AnimatePresence>
<div className={clsx( <div className={clsx(
'flex flex-col gap-2', 'flex flex-col gap-2',
cursor cursor

View File

@ -1,4 +1,6 @@
// =========== Module contains interfaces for common UI elements. ========== // =========== Module contains interfaces for common UI elements. ==========
import { HTMLMotionProps } from 'framer-motion';
export namespace CProps { export namespace CProps {
export type Control = { export type Control = {
@ -33,4 +35,6 @@ export type Label = Omit<
export type TextArea = React.DetailedHTMLProps<React.TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>; export type TextArea = React.DetailedHTMLProps<React.TextareaHTMLAttributes<HTMLTextAreaElement>, HTMLTextAreaElement>;
export type Input = React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>; export type Input = React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>;
export type AnimatedButton = Omit<HTMLMotionProps<'button'>, 'type'>;
} }

View File

@ -1,10 +1,11 @@
'use client'; 'use client';
import clsx from 'clsx'; import clsx from 'clsx';
import { createContext, useContext, useLayoutEffect, useMemo, useState } from 'react'; import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react';
import ConceptTooltip from '@/components/Common/ConceptTooltip'; import ConceptTooltip from '@/components/Common/ConceptTooltip';
import useLocalStorage from '@/hooks/useLocalStorage'; import useLocalStorage from '@/hooks/useLocalStorage';
import { animationDuration } from '@/utils/animations';
import { darkT, IColorTheme, lightT } from '@/utils/color'; import { darkT, IColorTheme, lightT } from '@/utils/color';
import { globalIDs } from '@/utils/constants'; import { globalIDs } from '@/utils/constants';
@ -17,6 +18,7 @@ interface IThemeContext {
darkMode: boolean darkMode: boolean
toggleDarkMode: () => void toggleDarkMode: () => void
noNavigationAnimation: boolean
noNavigation: boolean noNavigation: boolean
toggleNoNavigation: () => void toggleNoNavigation: () => void
@ -44,6 +46,7 @@ export const ThemeState = ({ children }: ThemeStateProps) => {
const [darkMode, setDarkMode] = useLocalStorage('darkMode', false); const [darkMode, setDarkMode] = useLocalStorage('darkMode', false);
const [colors, setColors] = useState<IColorTheme>(lightT); const [colors, setColors] = useState<IColorTheme>(lightT);
const [noNavigation, setNoNavigation] = useState(false); const [noNavigation, setNoNavigation] = useState(false);
const [noNavigationAnimation, setNoNavigationAnimation] = useState(false);
const [noFooter, setNoFooter] = useState(false); const [noFooter, setNoFooter] = useState(false);
const [showScroll, setShowScroll] = useState(false); const [showScroll, setShowScroll] = useState(false);
@ -65,6 +68,17 @@ export const ThemeState = ({ children }: ThemeStateProps) => {
setColors(darkMode ? darkT : lightT) setColors(darkMode ? darkT : lightT)
}, [darkMode, setColors]); }, [darkMode, setColors]);
const toggleNoNavigation = useCallback(
() => {
if (noNavigation) {
setNoNavigationAnimation(false);
setNoNavigation(false);
} else {
setNoNavigationAnimation(true);
setTimeout(() => setNoNavigation(true), animationDuration.navigationToggle);
}
}, [noNavigation]);
const mainHeight = useMemo( const mainHeight = useMemo(
() => { () => {
return !noNavigation ? return !noNavigation ?
@ -82,9 +96,9 @@ export const ThemeState = ({ children }: ThemeStateProps) => {
return ( return (
<ThemeContext.Provider value={{ <ThemeContext.Provider value={{
darkMode, colors, darkMode, colors,
noNavigation, noFooter, showScroll, noNavigationAnimation, noNavigation, noFooter, showScroll,
toggleDarkMode: () => setDarkMode(prev => !prev), toggleDarkMode: () => setDarkMode(prev => !prev),
toggleNoNavigation: () => setNoNavigation(prev => !prev), toggleNoNavigation: toggleNoNavigation,
setNoFooter, setShowScroll, setNoFooter, setShowScroll,
viewportHeight, mainHeight viewportHeight, mainHeight
}}> }}>

View File

@ -5,17 +5,17 @@ import { useRef, useState } from 'react';
import useClickedOutside from './useClickedOutside'; import useClickedOutside from './useClickedOutside';
function useDropdown() { function useDropdown() {
const [isActive, setIsActive] = useState(false); const [isOpen, setIsOpen] = useState(false);
const ref = useRef(null); const ref = useRef(null);
useClickedOutside({ ref, callback: () => setIsActive(false) }); useClickedOutside({ ref, callback: () => setIsOpen(false) });
return { return {
ref, ref,
isActive, isOpen,
setIsActive, setIsOpen,
toggle: () => setIsActive(!isActive), toggle: () => setIsOpen(!isOpen),
hide: () => setIsActive(false) hide: () => setIsOpen(false)
}; };
} }

View File

@ -48,8 +48,7 @@ function PickerStrategy({ value, onChange }: PickerStrategyProps) {
text={labelLibraryFilter(value)} text={labelLibraryFilter(value)}
onClick={strategyMenu.toggle} onClick={strategyMenu.toggle}
/> />
{strategyMenu.isActive ? <Dropdown isOpen={strategyMenu.isOpen}>
<Dropdown>
{Object.values(LibraryFilterStrategy).map( {Object.values(LibraryFilterStrategy).map(
(enumValue, index) => { (enumValue, index) => {
const strategy = enumValue as LibraryFilterStrategy; const strategy = enumValue as LibraryFilterStrategy;
@ -63,7 +62,7 @@ function PickerStrategy({ value, onChange }: PickerStrategyProps) {
disabled={isStrategyDisabled(strategy)} disabled={isStrategyDisabled(strategy)}
/>); />);
})} })}
</Dropdown> : null} </Dropdown>
</div> </div>
); );
} }

View File

@ -1,5 +1,6 @@
'use client'; 'use client';
import { AnimatePresence } from 'framer-motion';
import { Dispatch, SetStateAction, useMemo, useState } from 'react'; import { Dispatch, SetStateAction, useMemo, useState } from 'react';
import { useRSForm } from '@/context/RSFormContext'; import { useRSForm } from '@/context/RSFormContext';
@ -149,6 +150,7 @@ function EditorConstituenta({
onEditTerm={onEditTerm} onEditTerm={onEditTerm}
onRenameCst={onRenameCst} onRenameCst={onRenameCst}
/> />
<AnimatePresence>
{(showList && windowSize.width && windowSize.width >= SIDELIST_HIDE_THRESHOLD) ? {(showList && windowSize.width && windowSize.width >= SIDELIST_HIDE_THRESHOLD) ?
<ViewConstituents <ViewConstituents
schema={schema} schema={schema}
@ -157,6 +159,7 @@ function EditorConstituenta({
activeID={activeID} activeID={activeID}
onOpenEdit={onOpenEdit} onOpenEdit={onOpenEdit}
/>: null} />: null}
</AnimatePresence>
</div> </div>
</>); </>);
} }

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { ReactCodeMirrorRef } from '@uiw/react-codemirror'; import { ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { AnimatePresence } from 'framer-motion';
import { useCallback, useLayoutEffect, useRef, useState } from 'react'; import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { BiListUl } from 'react-icons/bi'; import { BiListUl } from 'react-icons/bi';
import { FaRegKeyboard } from 'react-icons/fa6'; import { FaRegKeyboard } from 'react-icons/fa6';
@ -134,12 +135,14 @@ function EditorRSExpression({
} }
return (<> return (<>
<AnimatePresence>
{showAST ? {showAST ?
<DlgShowAST <DlgShowAST
expression={expression} expression={expression}
syntaxTree={syntaxTree} syntaxTree={syntaxTree}
hideWindow={() => setShowAST(false)} hideWindow={() => setShowAST(false)}
/> : null} /> : null}
</AnimatePresence>
<div> <div>
<Overlay position='top-0 right-0 flex'> <Overlay position='top-0 right-0 flex'>
@ -179,18 +182,18 @@ function EditorRSExpression({
{...restProps} {...restProps}
/> />
{showControls ?
<RSEditorControls <RSEditorControls
isOpen={showControls}
disabled={disabled} disabled={disabled}
onEdit={handleEdit} onEdit={handleEdit}
/> : null} />
{(parseData && parseData.errors.length > 0) ?
<ParsingResult <ParsingResult
isOpen={!!parseData && parseData.errors.length > 0}
data={parseData} data={parseData}
disabled={disabled} disabled={disabled}
onShowError={onShowError} onShowError={onShowError}
/>: null} />
</div> </div>
</>); </>);
} }

View File

@ -1,23 +1,39 @@
'use client'; 'use client';
import clsx from 'clsx';
import { motion } from 'framer-motion';
import { IExpressionParse, IRSErrorDescription } from '@/models/rslang'; import { IExpressionParse, IRSErrorDescription } from '@/models/rslang';
import { animateRSControl } from '@/utils/animations';
import { describeRSError } from '@/utils/labels'; import { describeRSError } from '@/utils/labels';
import { getRSErrorPrefix } from '@/utils/misc'; import { getRSErrorPrefix } from '@/utils/misc';
interface ParsingResultProps { interface ParsingResultProps {
data: IExpressionParse data: IExpressionParse | undefined
disabled?: boolean disabled?: boolean
isOpen: boolean
onShowError: (error: IRSErrorDescription) => void onShowError: (error: IRSErrorDescription) => void
} }
function ParsingResult({ data, disabled, onShowError }: ParsingResultProps) { function ParsingResult({ isOpen, data, disabled, onShowError }: ParsingResultProps) {
const errorCount = data.errors.reduce((total, error) => (error.isCritical ? total + 1 : total), 0); const errorCount = data ? data.errors.reduce((total, error) => (error.isCritical ? total + 1 : total), 0) : 0;
const warningsCount = data.errors.length - errorCount; const warningsCount = data ? data.errors.length - errorCount : 0;
return ( return (
<div className='px-2 pt-1 text-sm border overflow-y-auto h-[4.5rem]'> <motion.div
className={clsx(
'px-2 pt-1',
'h-[4.5rem] mt-3',
'text-sm',
'border',
'overflow-y-auto'
)}
initial={false}
animate={isOpen ? 'open' : 'closed'}
variants={animateRSControl}
>
<p>Ошибок: <b>{errorCount}</b> | Предупреждений: <b>{warningsCount}</b></p> <p>Ошибок: <b>{errorCount}</b> | Предупреждений: <b>{warningsCount}</b></p>
{data.errors.map( {data?.errors.map(
(error, index) => { (error, index) => {
return ( return (
<p <p
@ -31,7 +47,7 @@ function ParsingResult({ data, disabled, onShowError }: ParsingResultProps) {
<span>{` ${describeRSError(error)}`}</span> <span>{` ${describeRSError(error)}`}</span>
</p>); </p>);
})} })}
</div>); </motion.div>);
} }
export default ParsingResult; export default ParsingResult;

View File

@ -1,4 +1,7 @@
import { motion } from 'framer-motion';
import { TokenID } from '@/models/rslang'; import { TokenID } from '@/models/rslang';
import { animateRSControl } from '@/utils/animations';
import { prefixes } from '@/utils/constants'; import { prefixes } from '@/utils/constants';
import RSLocalButton from './RSLocalButton'; import RSLocalButton from './RSLocalButton';
@ -79,13 +82,19 @@ const SECONDARY_THIRD_ROW = [
]; ];
interface RSEditorControlsProps { interface RSEditorControlsProps {
onEdit: (id: TokenID, key?: string) => void isOpen: boolean
disabled?: boolean disabled?: boolean
onEdit: (id: TokenID, key?: string) => void
} }
function RSEditorControls({ onEdit, disabled }: RSEditorControlsProps) { function RSEditorControls({ isOpen, disabled, onEdit }: RSEditorControlsProps) {
return ( return (
<div className='flex-wrap text-sm divide-solid'> <motion.div
className='flex-wrap text-sm divide-solid'
initial={false}
animate={isOpen ? 'open' : 'closed'}
variants={animateRSControl}
>
{MAIN_FIRST_ROW.map( {MAIN_FIRST_ROW.map(
(token) => (token) =>
<RSTokenButton key={`${prefixes.rsedit_btn}${token}`} <RSTokenButton key={`${prefixes.rsedit_btn}${token}`}
@ -118,7 +127,7 @@ function RSEditorControls({ onEdit, disabled }: RSEditorControlsProps) {
<RSLocalButton key={`${prefixes.rsedit_btn}${title}`} <RSLocalButton key={`${prefixes.rsedit_btn}${title}`}
text={text} title={title} onInsert={onEdit} disabled={disabled} text={text} title={title} onInsert={onEdit} disabled={disabled}
/>)} />)}
</div>); </motion.div>);
} }
export default RSEditorControls; export default RSEditorControls;

View File

@ -70,8 +70,7 @@ function RSListToolbar({
disabled={!isMutable} disabled={!isMutable}
onClick={insertMenu.toggle} onClick={insertMenu.toggle}
/> />
{insertMenu.isActive ? <Dropdown isOpen={insertMenu.isOpen}>
<Dropdown>
{(Object.values(CstType)).map( {(Object.values(CstType)).map(
(typeStr) => (typeStr) =>
<DropdownButton <DropdownButton
@ -81,7 +80,7 @@ function RSListToolbar({
title={getCstTypeShortcut(typeStr as CstType)} title={getCstTypeShortcut(typeStr as CstType)}
/> />
)} )}
</Dropdown> : null} </Dropdown>
</div> </div>
<MiniButton <MiniButton
title='Удалить выбранные [Delete]' title='Удалить выбранные [Delete]'

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import clsx from 'clsx'; import clsx from 'clsx';
import { AnimatePresence } from 'framer-motion';
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { GraphEdge, GraphNode, LayoutTypes } from 'reagraph'; import { GraphEdge, GraphNode, LayoutTypes } from 'reagraph';
@ -196,12 +197,14 @@ function EditorTermGraph({ isMutable, onOpenEdit, onCreateCst, onDeleteCst }: Ed
return ( return (
<div tabIndex={-1} onKeyDown={handleKeyDown}> <div tabIndex={-1} onKeyDown={handleKeyDown}>
<AnimatePresence>
{showParamsDialog ? {showParamsDialog ?
<DlgGraphParams <DlgGraphParams
hideWindow={() => setShowParamsDialog(false)} hideWindow={() => setShowParamsDialog(false)}
initial={filterParams} initial={filterParams}
onConfirm={handleChangeParams} onConfirm={handleChangeParams}
/> : null} /> : null}
</AnimatePresence>
<SelectedCounter hideZero <SelectedCounter hideZero
total={schema?.stats?.count_all ?? 0} total={schema?.stats?.count_all ?? 0}

View File

@ -2,6 +2,7 @@
import axios from 'axios'; import axios from 'axios';
import clsx from 'clsx'; import clsx from 'clsx';
import { AnimatePresence } from 'framer-motion';
import fileDownload from 'js-file-download'; import fileDownload from 'js-file-download';
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { TabList, TabPanel, Tabs } from 'react-tabs'; import { TabList, TabPanel, Tabs } from 'react-tabs';
@ -346,7 +347,7 @@ function RSTabs() {
return (<> return (<>
{loading ? <ConceptLoader /> : null} {loading ? <ConceptLoader /> : null}
{error ? <ProcessError error={error} /> : null} {error ? <ProcessError error={error} /> : null}
<AnimatePresence>
{showUpload ? {showUpload ?
<DlgUploadRSForm <DlgUploadRSForm
hideWindow={() => setShowUpload(false)} hideWindow={() => setShowUpload(false)}
@ -389,6 +390,7 @@ function RSTabs() {
insertAfter={insertCstID} insertAfter={insertCstID}
onCreate={handleCreateCst} onCreate={handleCreateCst}
/> : null} /> : null}
</AnimatePresence>
{(schema && !loading) ? {(schema && !loading) ?
<Tabs <Tabs

View File

@ -104,12 +104,10 @@ function RSTabsMenu({
style={{outlineColor: 'transparent'}} style={{outlineColor: 'transparent'}}
onClick={schemaMenu.toggle} onClick={schemaMenu.toggle}
/> />
{schemaMenu.isActive ? <Dropdown isOpen={schemaMenu.isOpen}>
<Dropdown> <DropdownButton disabled={(!user || !isClaimable) && !isOwned}
<DropdownButton
text={isOwned ? 'Вы — владелец' : 'Стать владельцем'} text={isOwned ? 'Вы — владелец' : 'Стать владельцем'}
title={!user || !isClaimable ? 'Взять во владение можно общую изменяемую схему' : ''} icon={<LuCrown size='1rem' className={isOwned ? 'clr-text-success' : ''} />}
icon={<LuCrown size='1rem' className={isOwned ? 'clr-text-success' : 'clr-text-controls'} />}
onClick={(!isOwned && user && isClaimable) ? handleClaimOwner : undefined} onClick={(!isOwned && user && isClaimable) ? handleClaimOwner : undefined}
/> />
<DropdownButton <DropdownButton
@ -142,7 +140,7 @@ function RSTabsMenu({
icon={<BiPlusCircle size='1rem' className='clr-text-url' />} icon={<BiPlusCircle size='1rem' className='clr-text-url' />}
onClick={handleCreateNew} onClick={handleCreateNew}
/> />
</Dropdown> : null} </Dropdown>
</div> </div>
<div ref={editMenu.ref}> <div ref={editMenu.ref}>
@ -153,8 +151,7 @@ function RSTabsMenu({
icon={<FiEdit size='1.25rem' className={isMutable ? 'clr-text-success' : 'clr-text-warning'}/>} icon={<FiEdit size='1.25rem' className={isMutable ? 'clr-text-success' : 'clr-text-warning'}/>}
onClick={editMenu.toggle} onClick={editMenu.toggle}
/> />
{editMenu.isActive ? <Dropdown isOpen={editMenu.isOpen}>
<Dropdown>
<DropdownButton disabled={!isMutable} <DropdownButton disabled={!isMutable}
text='Сброс имён' text='Сброс имён'
title='Присвоить порядковые имена и обновить выражения' title='Присвоить порядковые имена и обновить выражения'
@ -167,7 +164,7 @@ function RSTabsMenu({
icon={<BiDiamond size='1rem' className={isMutable ? 'clr-text-success': ''} />} icon={<BiDiamond size='1rem' className={isMutable ? 'clr-text-success': ''} />}
onClick={handleTemplates} onClick={handleTemplates}
/> />
</Dropdown>: null} </Dropdown>
</div> </div>
<div ref={accessMenu.ref}> <div ref={accessMenu.ref}>
@ -182,8 +179,7 @@ function RSTabsMenu({
} }
onClick={accessMenu.toggle} onClick={accessMenu.toggle}
/> />
{accessMenu.isActive ? <Dropdown isOpen={accessMenu.isOpen}>
<Dropdown>
<DropdownButton <DropdownButton
text={labelAccessMode(UserAccessMode.READER)} text={labelAccessMode(UserAccessMode.READER)}
title={describeAccessMode(UserAccessMode.READER)} title={describeAccessMode(UserAccessMode.READER)}
@ -202,7 +198,7 @@ function RSTabsMenu({
icon={<BiMeteor size='1rem' className={user?.is_staff ? 'clr-text-primary': ''} />} icon={<BiMeteor size='1rem' className={user?.is_staff ? 'clr-text-primary': ''} />}
onClick={() => handleChangeMode(UserAccessMode.ADMIN)} onClick={() => handleChangeMode(UserAccessMode.ADMIN)}
/> />
</Dropdown>: null} </Dropdown>
</div> </div>
</div>); </div>);
} }

View File

@ -90,8 +90,7 @@ function ConstituentsSearch({ schema, activeID, activeExpression, setFiltered }:
text={labelCstMathchMode(filterMatch)} text={labelCstMathchMode(filterMatch)}
onClick={matchModeMenu.toggle} onClick={matchModeMenu.toggle}
/> />
{matchModeMenu.isActive ? <Dropdown stretchLeft isOpen={matchModeMenu.isOpen}>
<Dropdown stretchLeft>
{Object.values(CstMatchMode).filter(value => !isNaN(Number(value))).map( {Object.values(CstMatchMode).filter(value => !isNaN(Number(value))).map(
(value, index) => { (value, index) => {
const matchMode = value as CstMatchMode; const matchMode = value as CstMatchMode;
@ -103,7 +102,7 @@ function ConstituentsSearch({ schema, activeID, activeExpression, setFiltered }:
<p><b>{labelCstMathchMode(matchMode)}:</b> {describeCstMathchMode(matchMode)}</p> <p><b>{labelCstMathchMode(matchMode)}:</b> {describeCstMathchMode(matchMode)}</p>
</DropdownButton>); </DropdownButton>);
})} })}
</Dropdown> : null} </Dropdown>
</div> </div>
<div ref={sourceMenu.ref}> <div ref={sourceMenu.ref}>
@ -114,8 +113,7 @@ function ConstituentsSearch({ schema, activeID, activeExpression, setFiltered }:
text={labelCstSource(filterSource)} text={labelCstSource(filterSource)}
onClick={sourceMenu.toggle} onClick={sourceMenu.toggle}
/> />
{sourceMenu.isActive ? <Dropdown stretchLeft isOpen={sourceMenu.isOpen}>
<Dropdown stretchLeft>
{Object.values(DependencyMode).filter(value => !isNaN(Number(value))).map( {Object.values(DependencyMode).filter(value => !isNaN(Number(value))).map(
(value, index) => { (value, index) => {
const source = value as DependencyMode; const source = value as DependencyMode;
@ -127,7 +125,7 @@ function ConstituentsSearch({ schema, activeID, activeExpression, setFiltered }:
<p><b>{labelCstSource(source)}:</b> {describeCstSource(source)}</p> <p><b>{labelCstSource(source)}:</b> {describeCstSource(source)}</p>
</DropdownButton>); </DropdownButton>);
})} })}
</Dropdown> : null} </Dropdown>
</div> </div>
</div>); </div>);
} }

View File

@ -1,9 +1,11 @@
'use client'; 'use client';
import { motion } from 'framer-motion';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useConceptTheme } from '@/context/ThemeContext'; import { useConceptTheme } from '@/context/ThemeContext';
import { IConstituenta, IRSForm } from '@/models/rsform'; import { IConstituenta, IRSForm } from '@/models/rsform';
import { animateSideView } from '@/utils/animations';
import ConstituentsSearch from './ConstituentsSearch'; import ConstituentsSearch from './ConstituentsSearch';
import ConstituentsTable from './ConstituentsTable'; import ConstituentsTable from './ConstituentsTable';
@ -36,7 +38,12 @@ function ViewConstituents({ expression, baseHeight, schema, activeID, onOpenEdit
}, [noNavigation, baseHeight]); }, [noNavigation, baseHeight]);
return ( return (
<div className='mt-[2.25rem] border'> <motion.div
className='mt-[2.25rem] border'
initial={{...animateSideView.initial}}
animate={{...animateSideView.animate}}
exit={{...animateSideView.exit}}
>
<ConstituentsSearch <ConstituentsSearch
schema={schema} schema={schema}
activeID={activeID} activeID={activeID}
@ -49,7 +56,7 @@ function ViewConstituents({ expression, baseHeight, schema, activeID, onOpenEdit
onOpenEdit={onOpenEdit} onOpenEdit={onOpenEdit}
denseThreshold={COLUMN_EXPRESSION_HIDE_THRESHOLD} denseThreshold={COLUMN_EXPRESSION_HIDE_THRESHOLD}
/> />
</div>); </motion.div>);
} }
export default ViewConstituents; export default ViewConstituents;

View File

@ -1,5 +1,6 @@
'use client'; 'use client';
import { AnimatePresence } from 'framer-motion';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { FiBell, FiBellOff } from 'react-icons/fi'; import { FiBell, FiBellOff } from 'react-icons/fi';
@ -50,11 +51,12 @@ function UserTabs() {
<EditorPassword /> <EditorPassword />
</div> </div>
</div> </div>
<AnimatePresence>
{(subscriptions.length > 0 && showSubs) ? {(subscriptions.length > 0 && showSubs) ?
<div> <ViewSubscriptions
<h1 className='mb-6'>Отслеживаемые схемы</h1> items={subscriptions}
<ViewSubscriptions items={subscriptions} /> /> : null}
</div> : null} </AnimatePresence>
</div> : null} </div> : null}
</>); </>);
} }

View File

@ -1,11 +1,13 @@
'use client'; 'use client';
import { motion } from 'framer-motion';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import DataTable, { createColumnHelper } from '@/components/DataTable'; import DataTable, { createColumnHelper } from '@/components/DataTable';
import { useConceptNavigation } from '@/context/NagivationContext'; import { useConceptNavigation } from '@/context/NagivationContext';
import { ILibraryItem } from '@/models/library'; import { ILibraryItem } from '@/models/library';
import { animateSideView } from '@/utils/animations';
interface ViewSubscriptionsProps { interface ViewSubscriptionsProps {
items: ILibraryItem[] items: ILibraryItem[]
@ -52,6 +54,12 @@ function ViewSubscriptions({items}: ViewSubscriptionsProps) {
], [intl]); ], [intl]);
return ( return (
<motion.div
initial={{...animateSideView.initial}}
animate={{...animateSideView.animate}}
exit={{...animateSideView.exit}}
>
<h1 className='mb-6'>Отслеживаемые схемы</h1>
<DataTable dense noFooter <DataTable dense noFooter
className='max-h-[23.8rem] overflow-y-auto text-sm border' className='max-h-[23.8rem] overflow-y-auto text-sm border'
columns={columns} columns={columns}
@ -70,7 +78,8 @@ function ViewSubscriptions({items}: ViewSubscriptionsProps) {
} }
onRowClicked={openRSForm} onRowClicked={openRSForm}
/>); />
</motion.div>);
} }
export default ViewSubscriptions; export default ViewSubscriptions;

View File

@ -0,0 +1,163 @@
/**
* Module: animations parameters.
*/
import { Variants } from 'framer-motion';
/**
* Duration constants in ms.
*/
export const animationDuration = {
navigationToggle: 500
};
export const animateNavigation: Variants = {
open: {
height: '3rem',
translateY: 0,
transition: {
type: 'spring',
bounce: 0,
duration: animationDuration.navigationToggle / 1000
}
},
closed: {
height: 0,
translateY: '-1.5rem',
transition: {
type: 'spring',
bounce: 0,
duration: animationDuration.navigationToggle / 1000
}
}
};
export const animateNavigationToggle: Variants = {
on: {
height: '3rem',
width: '1.2rem',
transition: {
type: 'spring',
bounce: 0,
duration: animationDuration.navigationToggle / 1000
}
},
off: {
height: '1.2rem',
width: '3rem',
transition: {
type: 'spring',
bounce: 0,
duration: animationDuration.navigationToggle / 1000
}
}
};
export const animateDropdown: Variants = {
open: {
clipPath: 'inset(0% 0% 0% 0%)',
transition: {
type: 'spring',
bounce: 0,
duration: 0.4,
delayChildren: 0.2,
staggerChildren: 0.05
}
},
closed: {
clipPath: 'inset(10% 0% 90% 0%)',
transition: {
type: 'spring',
bounce: 0,
duration: 0.3
}
}
};
export const animateDropdownItem: Variants = {
open: {
opacity: 1,
y: 0,
transition: {
type: 'spring',
duration: 0.1,
stiffness: 300,
damping: 24
}
},
closed: {
opacity: 0,
y: 10,
transition: {
duration: 0.1
}
}
};
export const animateRSControl: Variants = {
open: {
clipPath: 'inset(0% 0% 0% 0%)',
height: 'max-content',
transition: {
type: 'spring',
bounce: 0,
duration: 0.4
}
},
closed: {
clipPath: 'inset(0% 0% 100% 0%)',
height: 0,
transition: {
type: 'spring',
bounce: 0,
duration: 0.3
}
}
};
export const animateSideView = {
initial: {
clipPath: 'inset(0% 100% 0% 0%)',
},
animate: {
clipPath: 'inset(0% 0% 0% 0%)',
transition: {
type: 'spring',
bounce: 0,
duration: 1
}
},
exit: {
clipPath: 'inset(0% 100% 0% 0%)',
transition: {
type: 'spring',
bounce: 0,
duration: 1
}
}
};
export const animateModal = {
initial: {
clipPath: 'inset(50% 50% 50% 50%)',
opacity: 0
},
animate: {
clipPath: 'inset(0% 0% 0% 0%)',
opacity: 1,
transition: {
type: 'spring',
bounce: 0,
duration: 0.3
}
},
exit: {
opacity: 0,
clipPath: 'inset(50% 50% 50% 50%)',
transition: {
type: 'spring',
bounce: 0,
duration: 0.2
}
}
};