mirror of https://github.com/msgbyte/tailchat
feat: 模态框与模态框动画
parent
fc3d9ae06f
commit
85dbc95459
@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import type { PortalMethods } from './context';
|
||||
|
||||
export type PortalConsumerProps = {
|
||||
hostName: string;
|
||||
manager: PortalMethods;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export class PortalConsumer extends React.Component<PortalConsumerProps> {
|
||||
_key: any;
|
||||
componentDidMount() {
|
||||
if (!this.props.manager) {
|
||||
throw new Error(
|
||||
'Looks like you forgot to wrap your root component with `PortalHost` component.\n'
|
||||
);
|
||||
}
|
||||
|
||||
this._key = this.props.manager.mount(
|
||||
this.props.hostName,
|
||||
this.props.children
|
||||
);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.props.manager.update(
|
||||
this.props.hostName,
|
||||
this._key,
|
||||
this.props.children
|
||||
);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.manager.unmount(this.props.hostName, this._key);
|
||||
}
|
||||
|
||||
render() {
|
||||
return null;
|
||||
}
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
export type State = {
|
||||
portals: {
|
||||
key: number;
|
||||
children: React.ReactNode;
|
||||
}[];
|
||||
};
|
||||
interface PortalManagerProps {
|
||||
renderManagerView: (children: React.ReactNode) => React.ReactElement;
|
||||
}
|
||||
export interface PortalManagerState {
|
||||
portals: any[];
|
||||
}
|
||||
/**
|
||||
* Portal host is the component which actually renders all Portals.
|
||||
*/
|
||||
export class PortalManager extends React.PureComponent<
|
||||
PortalManagerProps,
|
||||
PortalManagerState
|
||||
> {
|
||||
state: State = {
|
||||
portals: [],
|
||||
};
|
||||
|
||||
mount = (key: number, children: React.ReactNode) => {
|
||||
this.setState((state) => ({
|
||||
portals: [...state.portals, { key, children }],
|
||||
}));
|
||||
};
|
||||
|
||||
update = (key: number, children: React.ReactNode) => {
|
||||
this.setState((state) => ({
|
||||
portals: state.portals.map((item) => {
|
||||
if (item.key === key) {
|
||||
return { ...item, children };
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
unmount = (key: number) => {
|
||||
this.setState((state) => ({
|
||||
portals: state.portals.filter((item) => item.key !== key),
|
||||
}));
|
||||
};
|
||||
|
||||
render() {
|
||||
const { renderManagerView } = this.props;
|
||||
return this.state.portals.map(({ key, children }, i) => (
|
||||
<React.Fragment key={key}>{renderManagerView(children)}</React.Fragment>
|
||||
// <View
|
||||
// key={key}
|
||||
// collapsable={
|
||||
// false /* Need collapsable=false here to clip the elevations, otherwise they appear above sibling components */
|
||||
// }
|
||||
// pointerEvents="box-none"
|
||||
// style={[StyleSheet.absoluteFill, { zIndex: 1000 + i }]}
|
||||
// >
|
||||
// {children}
|
||||
// </View>
|
||||
));
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
## Portal
|
||||
|
||||
**暂时没有使用**
|
||||
|
||||
这是一组用于使用命令式方法将一个React DOM映射到相关的位置的方法
|
||||
可以指定不同的portal位置
|
||||
|
||||
用于处理对于每个节点都需要创建一个相关的Modal的情况
|
||||
|
||||
> 参考 https://github.com/ant-design/ant-design-mobile-rn/blob/master/components/portal/ 的实现
|
@ -0,0 +1,200 @@
|
||||
import React, { useCallback, useRef, Fragment, useContext } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { PortalManager } from './Manager';
|
||||
import { createPortalContext } from './context';
|
||||
import { PortalConsumer } from './Consumer';
|
||||
import _isNil from 'lodash/isNil';
|
||||
import { DefaultEventEmitter } from './defaultEventEmitter';
|
||||
|
||||
type Operation =
|
||||
| { type: 'mount'; key: number; children: React.ReactNode }
|
||||
| { type: 'update'; key: number; children: React.ReactNode }
|
||||
| { type: 'unmount'; key: number };
|
||||
|
||||
// Events const
|
||||
const addType = 'ADD_PORTAL';
|
||||
const removeType = 'REMOVE_PORTAL';
|
||||
|
||||
// For react-native
|
||||
// const TopViewEventEmitter = DeviceEventEmitter || new NativeEventEmitter();
|
||||
|
||||
const defaultRenderManagerViewFn = (children: React.ReactNode) => (
|
||||
<>{children}</>
|
||||
);
|
||||
|
||||
interface EventEmitterFunc {
|
||||
emit: (...args: any[]) => any;
|
||||
addListener: (...args: any[]) => any;
|
||||
removeListener: (...args: any[]) => any;
|
||||
}
|
||||
export interface BuildPortalOptions {
|
||||
/**
|
||||
* 唯一标识名
|
||||
* 用于多实例的情况
|
||||
*/
|
||||
hostName?: string;
|
||||
|
||||
/**
|
||||
* 事件监听函数
|
||||
*/
|
||||
eventEmitter?: EventEmitterFunc;
|
||||
|
||||
/**
|
||||
* 负责Portal Manager如何生成函数的逻辑
|
||||
*/
|
||||
renderManagerView?: (children: React.ReactNode) => React.ReactElement;
|
||||
}
|
||||
export function buildPortal(options: BuildPortalOptions) {
|
||||
const {
|
||||
hostName = 'default',
|
||||
eventEmitter = new DefaultEventEmitter(),
|
||||
renderManagerView = defaultRenderManagerViewFn,
|
||||
} = options;
|
||||
let nextKey = 10000;
|
||||
|
||||
const add = (el: React.ReactNode): number => {
|
||||
const key = nextKey++;
|
||||
eventEmitter.emit(addType, hostName, el, key);
|
||||
return key;
|
||||
};
|
||||
|
||||
const remove = (key: number): void => {
|
||||
eventEmitter.emit(removeType, hostName, key);
|
||||
};
|
||||
|
||||
const PortalContext = createPortalContext(hostName);
|
||||
|
||||
const PortalHost = React.memo((props) => {
|
||||
const managerRef = useRef<PortalManager>();
|
||||
const nextKeyRef = useRef<number>(0);
|
||||
const queueRef = useRef<Operation[]>([]);
|
||||
const hostNameRef = useRef(hostName);
|
||||
useEffect(() => {
|
||||
hostNameRef.current = hostName;
|
||||
}, [hostName]);
|
||||
|
||||
const mount: any = useCallback(
|
||||
(name: string, children: React.ReactNode, _key?: number) => {
|
||||
if (name !== hostNameRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = _key || nextKeyRef.current++;
|
||||
if (managerRef.current) {
|
||||
managerRef.current.mount(key, children);
|
||||
} else {
|
||||
queueRef.current.push({ type: 'mount', key, children });
|
||||
}
|
||||
|
||||
return key;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
(name: string, key: number, children: React.ReactNode) => {
|
||||
if (name !== hostNameRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (managerRef.current) {
|
||||
managerRef.current.update(key, children);
|
||||
} else {
|
||||
const op: Operation = { type: 'mount', key, children };
|
||||
const index = queueRef.current.findIndex(
|
||||
(o) => o.type === 'mount' || (o.type === 'update' && o.key === key)
|
||||
);
|
||||
|
||||
if (index > -1) {
|
||||
queueRef.current[index] = op;
|
||||
} else {
|
||||
queueRef.current.push(op);
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const unmount = useCallback((name: string, key: number) => {
|
||||
if (name !== hostNameRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (managerRef.current) {
|
||||
managerRef.current.unmount(key);
|
||||
} else {
|
||||
queueRef.current.push({ type: 'unmount', key });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
eventEmitter.addListener(addType, mount);
|
||||
eventEmitter.addListener(removeType, unmount);
|
||||
|
||||
return () => {
|
||||
eventEmitter.removeListener(addType, mount);
|
||||
eventEmitter.removeListener(removeType, unmount);
|
||||
};
|
||||
}, [mount, unmount]);
|
||||
|
||||
useEffect(() => {
|
||||
// 处理队列
|
||||
const queue = queueRef.current;
|
||||
const manager = managerRef.current;
|
||||
|
||||
while (queue.length && manager) {
|
||||
const action = queue.pop();
|
||||
if (!action) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case 'mount':
|
||||
manager.mount(action.key, action.children);
|
||||
break;
|
||||
case 'update':
|
||||
manager.update(action.key, action.children);
|
||||
break;
|
||||
case 'unmount':
|
||||
manager.unmount(action.key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PortalContext.Provider
|
||||
value={{
|
||||
mount,
|
||||
update,
|
||||
unmount,
|
||||
}}
|
||||
>
|
||||
<Fragment>{props.children}</Fragment>
|
||||
<PortalManager
|
||||
ref={managerRef as any}
|
||||
renderManagerView={renderManagerView}
|
||||
/>
|
||||
</PortalContext.Provider>
|
||||
);
|
||||
});
|
||||
PortalHost.displayName = 'PortalHost-' + hostName;
|
||||
|
||||
const PortalRender = React.memo((props) => {
|
||||
const manager = useContext(PortalContext);
|
||||
|
||||
if (_isNil(manager)) {
|
||||
console.error('Not find PortalContext');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalConsumer hostName={hostName} manager={manager}>
|
||||
{props.children}
|
||||
</PortalConsumer>
|
||||
);
|
||||
});
|
||||
PortalRender.displayName = 'PortalRender-' + hostName;
|
||||
|
||||
return { add, remove, PortalHost, PortalRender };
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
export type PortalMethods = {
|
||||
mount: (name: string, children: React.ReactNode) => number;
|
||||
update: (name: string, key: number, children: React.ReactNode) => void;
|
||||
unmount: (name: string, key: number) => void;
|
||||
};
|
||||
|
||||
export function createPortalContext(name: string) {
|
||||
const PortalContext = React.createContext<PortalMethods | null>(null);
|
||||
PortalContext.displayName = 'PortalContext-' + name;
|
||||
|
||||
return PortalContext;
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
export class DefaultEventEmitter {
|
||||
// 参考: https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget
|
||||
listeners = {};
|
||||
|
||||
emit(type: string, ...args: any[]) {
|
||||
if (!(type in this.listeners)) {
|
||||
return;
|
||||
}
|
||||
const stack = this.listeners[type];
|
||||
for (let i = 0, l = stack.length; i < l; i++) {
|
||||
stack[i].call(this, event);
|
||||
const func = stack[i];
|
||||
|
||||
if (typeof func === 'function') {
|
||||
func(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addListener(type: string, callback: (...args: any[]) => any) {
|
||||
if (!(type in this.listeners)) {
|
||||
this.listeners[type] = [];
|
||||
}
|
||||
|
||||
this.listeners[type].push(callback);
|
||||
}
|
||||
|
||||
removeListener(type: string, callback: (...args: any[]) => any) {
|
||||
if (!(type in this.listeners)) {
|
||||
return;
|
||||
}
|
||||
const stack = this.listeners[type];
|
||||
for (let i = 0, l = stack.length; i < l; i++) {
|
||||
if (stack[i] === callback) {
|
||||
stack.splice(i, 1);
|
||||
return this.removeListener(type, callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export { buildPortal } from './buildPortal';
|
||||
export { DefaultEventEmitter } from './defaultEventEmitter';
|
@ -0,0 +1,37 @@
|
||||
.modal-anim {
|
||||
&-appear {
|
||||
opacity: 0;
|
||||
|
||||
.modal-inner {
|
||||
margin-top: -40px;
|
||||
}
|
||||
}
|
||||
&-appear-active,
|
||||
&-appear-done {
|
||||
opacity: 1;
|
||||
transition: opacity 200ms;
|
||||
|
||||
.modal-inner {
|
||||
transition: margin-top 200ms;
|
||||
margin-top: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
&-exit {
|
||||
opacity: 1;
|
||||
|
||||
.modal-inner {
|
||||
margin-top: 0px;
|
||||
}
|
||||
}
|
||||
&-exit-active,
|
||||
&-exit-done {
|
||||
opacity: 0;
|
||||
transition: opacity 200ms;
|
||||
|
||||
.modal-inner {
|
||||
transition: margin-top 200ms;
|
||||
margin-top: -40px;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,190 @@
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
import _isFunction from 'lodash/isFunction';
|
||||
import _isNil from 'lodash/isNil';
|
||||
import _last from 'lodash/last';
|
||||
import _pull from 'lodash/pull';
|
||||
import _isString from 'lodash/isString';
|
||||
import _noop from 'lodash/noop';
|
||||
import { PortalAdd, PortalRemove } from './Portal';
|
||||
import { Typography } from 'antd';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
// import { animated, useSpring } from 'react-spring';
|
||||
// import { easeQuadInOut } from 'd3-ease';
|
||||
// import { Iconfont } from './Iconfont';
|
||||
|
||||
import './Modal.less';
|
||||
|
||||
const transitionEndListener = (node: HTMLElement, done: () => void) =>
|
||||
node.addEventListener('transitionend', done, false);
|
||||
|
||||
/**
|
||||
* 模态框
|
||||
*/
|
||||
|
||||
const ModalContext = React.createContext<{
|
||||
closeModal: () => void;
|
||||
}>({
|
||||
closeModal: _noop,
|
||||
});
|
||||
|
||||
interface ModalProps {
|
||||
visible?: boolean;
|
||||
onChangeVisible?: (visible: boolean) => void;
|
||||
|
||||
/**
|
||||
* 是否显示右上角的关闭按钮
|
||||
* @default false
|
||||
*/
|
||||
closable?: boolean;
|
||||
|
||||
/**
|
||||
* 遮罩层是否可关闭
|
||||
*/
|
||||
maskClosable?: boolean;
|
||||
}
|
||||
export const Modal: React.FC<ModalProps> = React.memo((props) => {
|
||||
const {
|
||||
visible,
|
||||
onChangeVisible,
|
||||
closable = false,
|
||||
maskClosable = true,
|
||||
} = props;
|
||||
const [showing, setShowing] = useState(true);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (maskClosable === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowing(false);
|
||||
}, [maskClosable]);
|
||||
|
||||
const stopPropagation = useCallback((e: React.BaseSyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
if (visible === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CSSTransition
|
||||
in={showing}
|
||||
classNames="modal-anim"
|
||||
timeout={200}
|
||||
addEndListener={transitionEndListener}
|
||||
onExited={() => {
|
||||
if (showing === false && _isFunction(onChangeVisible)) {
|
||||
onChangeVisible(false);
|
||||
}
|
||||
}}
|
||||
appear={true}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 right-0 top-0 bottom-0 bg-black bg-opacity-50 flex justify-center items-center"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<ModalContext.Provider value={{ closeModal: handleClose }}>
|
||||
{/* Inner */}
|
||||
<div
|
||||
className="modal-inner bg-gray-800 rounded overflow-auto relative"
|
||||
style={{ maxHeight: '80vh', maxWidth: '80vw' }}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{closable === true && (
|
||||
<Icon
|
||||
className="absolute right-2.5 top-3.5 text-xl z-10"
|
||||
icon="mdi-close"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
)}
|
||||
{props.children}
|
||||
</div>
|
||||
</ModalContext.Provider>
|
||||
</div>
|
||||
</CSSTransition>
|
||||
);
|
||||
});
|
||||
Modal.displayName = 'Modal';
|
||||
|
||||
const modelKeyStack: number[] = [];
|
||||
|
||||
/**
|
||||
* 关闭Modal
|
||||
*/
|
||||
export function closeModal(key?: number): void {
|
||||
if (_isNil(key)) {
|
||||
key = _last(modelKeyStack);
|
||||
}
|
||||
|
||||
if (typeof key === 'number') {
|
||||
_pull(modelKeyStack, key);
|
||||
|
||||
PortalRemove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开新的Modal
|
||||
*/
|
||||
export function openModal(
|
||||
content: React.ReactNode,
|
||||
props?: Pick<ModalProps, 'closable'>
|
||||
): number {
|
||||
const key = PortalAdd(
|
||||
<Modal
|
||||
{...props}
|
||||
visible={true}
|
||||
onChangeVisible={(visible) => {
|
||||
if (visible === false) {
|
||||
closeModal(key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
modelKeyStack.push(key);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取modal上下文
|
||||
*/
|
||||
export function useModalContext() {
|
||||
const { closeModal } = useContext(ModalContext);
|
||||
|
||||
return { closeModal };
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准模态框包装器
|
||||
*/
|
||||
// const ModalWrapperContainer = styled.div`
|
||||
// padding: 16px;
|
||||
// min-width: 300px;
|
||||
// ${(props) => props.theme.mixins.desktop('min-width: 420px;')}
|
||||
// `;
|
||||
export const ModalWrapper: React.FC<{
|
||||
title?: string;
|
||||
}> = React.memo((props) => {
|
||||
const title = _isString(props.title) ? (
|
||||
<Typography.Title
|
||||
level={4}
|
||||
style={{ textAlign: 'center', marginBottom: 16 }}
|
||||
>
|
||||
{props.title}
|
||||
</Typography.Title>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="p-4" style={{ minWidth: 300 }}>
|
||||
{title}
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ModalWrapper.displayName = 'ModalWrapper';
|
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { buildPortal, DefaultEventEmitter } from 'tailchat-shared';
|
||||
|
||||
const eventEmitter = new DefaultEventEmitter();
|
||||
|
||||
const { PortalHost, PortalRender, add, remove } = buildPortal({
|
||||
hostName: 'default',
|
||||
eventEmitter,
|
||||
// eslint-disable-next-line react/display-name
|
||||
renderManagerView: (children) => <div>{children}</div>,
|
||||
});
|
||||
|
||||
export { PortalHost, PortalRender, add as PortalAdd, remove as PortalRemove };
|
Loading…
Reference in New Issue