diff --git a/packages/amis-core/src/SchemaRenderer.tsx b/packages/amis-core/src/SchemaRenderer.tsx index 25bdfcf80..f49750e02 100644 --- a/packages/amis-core/src/SchemaRenderer.tsx +++ b/packages/amis-core/src/SchemaRenderer.tsx @@ -18,7 +18,13 @@ import {IScopedContext, ScopedContext} from './Scoped'; import {Schema, SchemaNode} from './types'; import {DebugWrapper} from './utils/debug'; import getExprProperties from './utils/filter-schema'; -import {anyChanged, chainEvents, autobind, TestIdBuilder} from './utils/helper'; +import { + anyChanged, + chainEvents, + autobind, + TestIdBuilder, + formateId +} from './utils/helper'; import {SimpleMap} from './utils/SimpleMap'; import {bindEvent, dispatchEvent, RendererEvent} from './utils/renderer-event'; import {isAlive} from 'mobx-state-tree'; @@ -28,6 +34,9 @@ import {buildStyle} from './utils/style'; import {isExpression} from './utils/formula'; import {StatusScopedProps} from './StatusScoped'; import {evalExpression, filter} from './utils/tpl'; +import {CSSTransition} from 'react-transition-group'; +import {createAnimationStyle} from './utils/animations'; +import styleManager from './StyleManager'; interface SchemaRendererProps extends Partial>, @@ -89,17 +98,44 @@ export class SchemaRenderer extends React.Component { schema: any; path: string; + animationTimeout: { + enter?: number; + exit?: number; + } = {}; + animationClassNames: { + appear?: string; + enter?: string; + exit?: string; + } = {}; + reaction: any; unbindEvent: (() => void) | undefined = undefined; isStatic: any = undefined; constructor(props: SchemaRendererProps) { super(props); + const animations = props?.schema?.animations; + if (animations) { + let id = props?.schema.id; + id = formateId(id); + if (animations.enter) { + this.animationTimeout.enter = (animations.enter.duration || 1) * 1000; + this.animationClassNames.enter = `${animations.enter.type}-${id}-enter`; + this.animationClassNames.appear = this.animationClassNames.enter; + } + if (animations.exit) { + this.animationTimeout.exit = (animations.exit.duration || 1) * 1000; + this.animationClassNames.exit = `${animations.exit.type}-${id}-exit`; + } + } + this.refFn = this.refFn.bind(this); this.renderChild = this.renderChild.bind(this); this.reRender = this.reRender.bind(this); this.resolveRenderer(this.props); this.dispatchEvent = this.dispatchEvent.bind(this); + this.addAnimationAttention = this.addAnimationAttention.bind(this); + this.removeAnimationAttention = this.removeAnimationAttention.bind(this); // 监听statusStore更新 this.reaction = reaction( @@ -121,9 +157,18 @@ export class SchemaRenderer extends React.Component { ); } + componentDidMount(): void { + if (this.props.schema.animations) { + let {animations, id} = this.props.schema; + id = formateId(id); + createAnimationStyle(id, animations); + } + } + componentWillUnmount() { this.reaction?.(); this.unbindEvent?.(); + this.removeAnimationStyle(); } // 限制:只有 schema 除外的 props 变化,或者 schema 里面的某个成员值发生变化才更新。 @@ -154,6 +199,14 @@ export class SchemaRenderer extends React.Component { return false; } + removeAnimationStyle() { + if (this.props.schema.animations) { + let {id} = this.props.schema; + id = formateId(id); + styleManager.removeStyles(id); + } + } + resolveRenderer(props: SchemaRendererProps, force = false): any { let schema = props.schema; let path = props.$path; @@ -297,6 +350,25 @@ export class SchemaRenderer extends React.Component { this.forceUpdate(); } + addAnimationAttention(node: HTMLElement) { + const {schema} = this.props || {}; + const {attention} = schema?.animations || {}; + if (attention) { + let {id} = schema; + id = formateId(id); + node.classList.add(`${attention.type}-${id}-attention`); + } + } + removeAnimationAttention(node: HTMLElement) { + const {schema} = this.props || {}; + const {attention} = schema?.animations || {}; + if (attention) { + let {id} = schema; + id = formateId(id); + node.classList.remove(`${attention.type}-${id}-attention`); + } + } + render(): JSX.Element | null { let { $path: _, @@ -447,6 +519,8 @@ export class SchemaRenderer extends React.Component { } = schema; const Component = renderer.component!; + let animationIn = true; + // 原来表单项的 visible: false 和 hidden: true 表单项的值和验证是有效的 // 而 visibleOn 和 hiddenOn 是无效的, // 这个本来就是个bug,但是已经被广泛使用了 @@ -458,7 +532,11 @@ export class SchemaRenderer extends React.Component { !renderer.isFormItem || (schema.visible !== false && !schema.hidden)) ) { - return null; + if (schema.animations) { + animationIn = false; + } else { + return null; + } } // withStore 里面会处理,而且会实时处理 @@ -526,12 +604,28 @@ export class SchemaRenderer extends React.Component { } } - const component = supportRef ? ( + let component = supportRef ? ( ) : ( ); + if (schema.animations) { + component = ( + + {component} + + ); + } + return this.props.env.enableAMISDebug ? ( {component} ) : ( diff --git a/packages/amis-core/src/StyleManager.ts b/packages/amis-core/src/StyleManager.ts new file mode 100644 index 000000000..1bc2734e7 --- /dev/null +++ b/packages/amis-core/src/StyleManager.ts @@ -0,0 +1,63 @@ +import kebabCase from 'lodash/kebabCase'; + +interface Style { + [id: string]: { + [className: string]: { + [propName: string]: string | number; + }; + }; +} + +class StyleManager { + styles: Style; + styleDom: HTMLStyleElement; + styleText: string; + + constructor() { + this.styles = {}; + this.styleDom = document.createElement('style'); + this.styleDom.id = 'amis-styles'; + document.head.appendChild(this.styleDom); + } + + updateStyle(style: Style) { + Object.keys(style).forEach(className => { + if (!this.styles[className]) { + this.styles[className] = style[className]; + } else { + this.styles[className] = { + ...this.styles[className], + ...style[className] + }; + } + }); + this.updateStyleDom(); + } + + removeStyles(id: string) { + delete this.styles[id]; + this.updateStyleDom(); + } + + updateStyleDom() { + const styleText = Object.keys(this.styles) + .map(id => { + const style = this.styles[id]; + return Object.keys(style) + .map(className => { + return `${className} {${Object.keys(style[className]) + .map(propName => { + return `${kebabCase(propName)}: ${style[className][propName]};`; + }) + .join('')}}`; + }) + .join(''); + }) + .join(''); + + this.styleDom.innerHTML = styleText; + this.styleText = styleText; + } +} + +export default new StyleManager(); diff --git a/packages/amis-core/src/index.tsx b/packages/amis-core/src/index.tsx index 80d262769..a3a9fb748 100644 --- a/packages/amis-core/src/index.tsx +++ b/packages/amis-core/src/index.tsx @@ -36,6 +36,7 @@ import './polyfills'; import './renderers/builtin'; import './renderers/register'; export * from './utils/index'; +export * from './utils/animations'; export * from './types'; export * from './store'; import * as utils from './utils/helper'; @@ -123,6 +124,8 @@ import type {IItem} from './store/list'; import CustomStyle from './components/CustomStyle'; import {StatusScoped} from './StatusScoped'; +import styleManager from './StyleManager'; + // @ts-ignore export const version = '__buildVersion'; (window as any).amisVersionInfo = { @@ -131,6 +134,7 @@ export const version = '__buildVersion'; }; export { + styleManager, clearStoresCache, updateEnv, Renderer, diff --git a/packages/amis-core/src/types.ts b/packages/amis-core/src/types.ts index 5f4458bec..28b43e92e 100644 --- a/packages/amis-core/src/types.ts +++ b/packages/amis-core/src/types.ts @@ -3,6 +3,7 @@ import type {JSONSchema7} from 'json-schema'; import {ListenerAction} from './actions/Action'; import {debounceConfig, trackConfig} from './utils/renderer-event'; import type {TestIdBuilder} from './utils/helper'; +import {AnimationsProps} from './utils/animations'; export interface Option { /** @@ -307,6 +308,7 @@ export interface Schema { static?: boolean; children?: JSX.Element | ((props: any, schema?: any) => JSX.Element) | null; definitions?: Definitions; + animations?: AnimationsProps; [propName: string]: any; } diff --git a/packages/amis-core/src/utils/animations.ts b/packages/amis-core/src/utils/animations.ts new file mode 100644 index 000000000..a2b28838c --- /dev/null +++ b/packages/amis-core/src/utils/animations.ts @@ -0,0 +1,86 @@ +import styleManager from '../StyleManager'; + +export interface AnimationsProps { + enter?: { + type: string; + duration?: number; + }; + attention?: { + type: string; + duration?: number; + repeat?: string; + delay?: number; + }; + exit?: { + type: string; + duration?: number; + }; +} + +function generateStyleByAnimation( + className: string[], + animation: { + name: string; + duration?: number; + iterationCount?: string; + delay?: number; + fillMode?: string; + timingFunction?: string; + } +) { + return { + [className.join(',')]: { + animationName: animation.name, + animationDuration: `${animation.duration || 1}s`, + animationIterationCount: animation.iterationCount || 1, + animationDelay: `${animation.delay || 0}s`, + animationTimingFunction: animation.timingFunction || 'ease', + animationFillMode: animation.fillMode || 'none' + } + }; +} + +export function createAnimationStyle( + id: string, + animationsConfig: AnimationsProps +) { + const enterAnimationConfig = animationsConfig.enter; + let enterStyle = {}; + if (enterAnimationConfig?.type) { + enterStyle = generateStyleByAnimation( + [`.${enterAnimationConfig.type}-${id}-enter`], + {name: enterAnimationConfig.type, duration: enterAnimationConfig.duration} + ); + } + + const attentionAnimationConfig = animationsConfig.attention; + let attentionStyle = {}; + if (attentionAnimationConfig?.type) { + attentionStyle = generateStyleByAnimation( + [`.${attentionAnimationConfig.type}-${id}-attention`], + { + name: attentionAnimationConfig.type, + duration: attentionAnimationConfig.duration, + iterationCount: attentionAnimationConfig.repeat || 'infinite', + delay: attentionAnimationConfig.delay + } + ); + } + + const exitAnimationConfig = animationsConfig.exit; + let exitStyle = {}; + if (exitAnimationConfig?.type) { + exitStyle = generateStyleByAnimation( + [`.${exitAnimationConfig.type}-${id}-exit`], + { + name: exitAnimationConfig.type, + duration: exitAnimationConfig.duration, + fillMode: 'forwards' + } + ); + } + + styleManager.updateStyle({ + [id]: Object.assign({}, enterStyle, attentionStyle, exitStyle) + }); +} diff --git a/packages/amis-core/src/utils/helper.ts b/packages/amis-core/src/utils/helper.ts index 0ee038904..1672771ba 100644 --- a/packages/amis-core/src/utils/helper.ts +++ b/packages/amis-core/src/utils/helper.ts @@ -2376,3 +2376,17 @@ export function supportsMjs() { return false; } } + +export function formateId(id: string) { + // 将className非法字符替换为短横线 + id = id.replace(/[^a-zA-Z0-9-]/g, '-'); + // 将连续的-替换为单个- + id = id.replace(/-{2,}/g, '-'); + // 去掉首尾的- + id = id.replace(/^-|-$/g, ''); + // 首字母不能为数字 + if (/^\d/.test(id)) { + id = 'amis-' + id; + } + return id; +} diff --git a/packages/amis-editor-core/src/util.ts b/packages/amis-editor-core/src/util.ts index f4dcb758f..1a1e757b4 100644 --- a/packages/amis-editor-core/src/util.ts +++ b/packages/amis-editor-core/src/util.ts @@ -790,7 +790,14 @@ export function filterSchemaForEditor(schema: any): any { Object.keys(schema).forEach(key => { const value = schema[key]; if ( - ~['visible', 'visibleOn', 'hidden', 'hiddenOn', 'toggled'].indexOf(key) + ~[ + 'visible', + 'visibleOn', + 'hidden', + 'hiddenOn', + 'toggled', + 'animations' // 编辑态也不能有动画 + ].indexOf(key) ) { key = `$$${key}`; modified = true; diff --git a/packages/amis-editor/src/tpl/style.tsx b/packages/amis-editor/src/tpl/style.tsx index 50fe607ea..9a0f7e797 100644 --- a/packages/amis-editor/src/tpl/style.tsx +++ b/packages/amis-editor/src/tpl/style.tsx @@ -1,6 +1,448 @@ import {setSchemaTpl, getSchemaTpl, defaultValue} from 'amis-editor-core'; -import type {SchemaCollection} from 'amis'; +import {createAnimationStyle, formateId, type SchemaCollection} from 'amis'; import kebabCase from 'lodash/kebabCase'; +import {styleManager} from 'amis-core'; + +const animationOptions = { + enter: [ + { + label: '淡入', + children: [ + { + label: '淡入', + value: 'fadeIn' + }, + { + value: 'fadeInDown', + label: '从上淡入' + }, + { + value: 'fadeInDownBig', + label: '从上淡入(加强效果)' + }, + { + value: 'fadeInLeft', + label: '从左淡入' + }, + { + value: 'fadeInLeftBig', + label: '从左淡入(加强效果)' + }, + { + value: 'fadeInRight', + label: '从右淡入' + }, + { + value: 'fadeInRightBig', + label: '从右淡入(加强效果)' + }, + { + value: 'fadeInUp', + label: '从下淡入' + }, + { + value: 'fadeInUpBig', + label: '从下淡入(加强效果)' + } + ] + }, + { + label: '回弹', + children: [ + { + value: 'backInDown', + label: '从上回弹进入' + }, + { + value: 'backInLeft', + label: '从左回弹进入' + }, + { + value: 'backInRight', + label: '从右回弹进入' + }, + { + value: 'backInUp', + label: '从下回弹进入' + } + ] + }, + { + label: '旋转', + children: [ + { + value: 'rotateIn', + label: '旋转进入' + }, + { + value: 'rotateInDownLeft', + label: '左上角旋转进入' + }, + { + value: 'rotateInDownRight', + label: '右上角旋转进入' + }, + { + value: 'rotateInUpLeft', + label: '左下角旋转进入' + }, + { + value: 'rotateInUpRight', + label: '右下角旋转进入' + } + ] + }, + { + label: '滑动', + children: [ + { + value: 'slideInUp', + label: '从下滑入' + }, + { + value: 'slideInDown', + label: '从上滑入' + }, + { + value: 'slideInLeft', + label: '从左滑入' + }, + { + value: 'slideInRight', + label: '从右滑入' + } + ] + }, + { + label: '翻页', + children: [ + { + value: 'flip', + label: '翻页' + }, + { + value: 'flipInY', + label: '水平翻页' + }, + { + value: 'flipInX', + label: '垂直翻页' + } + ] + }, + { + label: '弹跳', + children: [ + { + value: 'bounceIn', + label: '弹跳进入' + }, + { + value: 'bounceInDown', + label: '从上弹跳进入' + }, + { + value: 'bounceInLeft', + label: '从左弹跳进入' + }, + { + value: 'bounceInRight', + label: '从右弹跳进入' + }, + { + value: 'bounceInUp', + label: '从下弹跳进入' + } + ] + }, + { + label: '缩放', + children: [ + { + value: 'zoomIn', + label: '缩放进入' + }, + { + value: 'zoomInDown', + label: '从上缩放进入' + }, + { + value: 'zoomInLeft', + label: '从左缩放进入' + }, + { + value: 'zoomInRight', + label: '从右缩放进入' + }, + { + value: 'zoomInUp', + label: '从下缩放进入' + } + ] + }, + { + label: '其他', + children: [ + { + value: 'lightSpeedInLeft', + label: '从左光速进入' + }, + { + value: 'lightSpeedInRight', + label: '从右光速进入' + }, + { + value: 'rollIn', + label: '滚动进入' + } + ] + } + ], + attention: [ + { + label: '弹跳', + value: 'bounce' + }, + { + label: '闪烁', + value: 'flash' + }, + { + value: 'headShake', + label: '摇头' + }, + { + value: 'heartBeat', + label: '心跳' + }, + { + value: 'jello', + label: '果冻' + }, + { + label: '跳动', + value: 'pulse' + }, + { + label: '摇摆', + value: 'swing' + }, + { + label: '震动', + value: 'tada' + }, + { + label: '晃动', + value: 'wobble' + }, + { + label: '抖动', + value: 'shake' + }, + { + value: 'shakeX', + label: '水平抖动' + }, + { + value: 'shakeY', + label: '垂直抖动' + }, + { + value: 'rubberBand', + label: '橡皮筋' + } + ], + exit: [ + { + label: '淡出', + children: [ + { + label: '淡出', + value: 'fadeOut' + }, + { + value: 'fadeOutDown', + label: '向下淡出' + }, + { + value: 'fadeOutDownBig', + label: '向下淡出(加强效果)' + }, + { + value: 'fadeOutLeft', + label: '向左淡出' + }, + { + value: 'fadeOutLeftBig', + label: '向左淡出(加强效果)' + }, + { + value: 'fadeOutRight', + label: '向右淡出' + }, + { + value: 'fadeOutRightBig', + label: '向右淡出(加强效果)' + }, + { + value: 'fadeOutUp', + label: '向上淡出' + }, + { + value: 'fadeOutUpBig', + label: '向上淡出(加强效果)' + } + ] + }, + { + label: '回弹', + children: [ + { + value: 'backOutDown', + label: '向下回弹退出' + }, + { + value: 'backOutLeft', + label: '向左回弹退出' + }, + { + value: 'backOutRight', + label: '向右回弹退出' + }, + { + value: 'backOutUp', + label: '向上回弹退出' + } + ] + }, + { + label: '旋转', + children: [ + { + value: 'rotateOut', + label: '旋转退出' + }, + { + value: 'rotateOutDownLeft', + label: '左上角旋转退出' + }, + { + value: 'rotateOutDownRight', + label: '右上角旋转退出' + }, + { + value: 'rotateOutUpLeft', + label: '左下角旋转退出' + }, + { + value: 'rotateOutUpRight', + label: '右下角旋转退出' + } + ] + }, + { + label: '滑动', + children: [ + { + value: 'slideOutUp', + label: '向上滑入' + }, + { + value: 'slideOutDown', + label: '向下滑入' + }, + { + value: 'slideOutLeft', + label: '向左滑入' + }, + { + value: 'slideOutRight', + label: '向右滑入' + } + ] + }, + { + label: '翻页', + children: [ + { + value: 'flipOutY', + label: '水平翻页' + }, + { + value: 'flipOutX', + label: '垂直翻页' + } + ] + }, + { + label: '弹跳', + children: [ + { + value: 'bounceOut', + label: '弹跳退出' + }, + { + value: 'bounceOutDown', + label: '向下弹跳退出' + }, + { + value: 'bounceOutLeft', + label: '向左弹跳退出' + }, + { + value: 'bounceOutRight', + label: '向右弹跳退出' + }, + { + value: 'bounceOutUp', + label: '向上弹跳退出' + } + ] + }, + { + label: '缩放', + children: [ + { + value: 'zoomOut', + label: '缩放退出' + }, + { + value: 'zoomOutDown', + label: '向上缩放退出' + }, + { + value: 'zoomOutLeft', + label: '向左缩放退出' + }, + { + value: 'zoomOutRight', + label: '向右缩放退出' + }, + { + value: 'zoomOutUp', + label: '向下缩放退出' + } + ] + }, + { + label: '其他', + children: [ + { + value: 'lightSpeedOutLeft', + label: '向左光速退出' + }, + { + value: 'lightSpeedOutRight', + label: '向右光速退出' + }, + { + value: 'rollOut', + label: '滚动退出' + } + ] + } + ] +}; setSchemaTpl('style:formItem', ({renderer, schema}: any) => { return { @@ -809,7 +1251,8 @@ setSchemaTpl( label: false } ] - } + }, + getSchemaTpl('animation') ].filter(item => !~exclude.indexOf(item.key || '')); } ); @@ -845,3 +1288,118 @@ setSchemaTpl( }; } ); + +setSchemaTpl('animation', () => { + const animation = ( + type: 'enter' | 'attention' | 'exit', + label: string, + schema: any = [] + ) => [ + { + type: 'switch', + name: `animations.${type}`, + pipeIn: (value: boolean) => !!value, + pipeOut: (value: boolean) => { + if (value) { + return {}; + } + return undefined; + }, + label + }, + { + type: 'container', + className: 'm-b ae-ExtendMore', + visibleOn: `animations.${type}`, + body: [ + { + type: 'select', + name: `animations.${type}.type`, + selectMode: 'group', + options: animationOptions[type], + label: '类型', + selectFirst: true + }, + { + type: 'input-number', + name: `animations.${type}.duration`, + label: '持续', + value: 1, + suffix: '秒', + min: 0, + precision: 3 + }, + ...schema + ] + }, + { + type: 'button', + visibleOn: `animations.${type}`, + className: 'm-b', + block: true, + level: 'enhance', + size: 'sm', + label: '播放', + onClick: (e: any, {data}: any) => { + let doc = document; + const isMobile = (window as any).editorStore.isMobile; + + if (isMobile) { + doc = (document.getElementsByClassName('ae-PreviewIFrame')[0] as any) + .contentDocument; + } + let {id, animations} = data; + const el = doc.querySelector(`[name="${id}"]`); + id = formateId(id); + const className = `${animations[type].type}-${id}-${type}`; + el?.classList.add(className); + createAnimationStyle(id, animations); + + if (isMobile) { + let style = doc.getElementById('amis-styles'); + if (!style) { + style = doc.createElement('style'); + style.id = 'amis-styles'; + doc.head.appendChild(style); + } + style.innerHTML = styleManager.styleText; + } + + setTimeout(() => { + el?.classList.remove(className); + }, ((animations[type].duration || 1) + (animations[type].delay || 0)) * 1000); + } + } + ]; + + return { + title: '动画', + body: [ + ...animation('enter', '进入动画'), + ...animation('attention', '强调动画', [ + { + label: '重复', + type: 'select', + name: 'animations.attention.repeat', + value: 'infinite', + options: [ + ...[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => ({ + label: i, + value: i + })), + {label: '无限', value: 'infinite'} + ] + }, + { + label: '延迟', + type: 'input-number', + name: 'animations.attention.delay', + value: 0, + suffix: '秒', + precision: 3 + } + ]), + ...animation('exit', '退出动画') + ] + }; +}); diff --git a/packages/amis-ui/package.json b/packages/amis-ui/package.json index 8f97c360c..28e46f5a5 100644 --- a/packages/amis-ui/package.json +++ b/packages/amis-ui/package.json @@ -38,6 +38,7 @@ "@rc-component/mini-decimal": "^1.0.1", "amis-core": "^6.8.0", "amis-formula": "^6.8.0", + "animate.css": "4.1.1", "classnames": "2.3.2", "codemirror": "^5.63.0", "downshift": "6.1.12", @@ -146,4 +147,4 @@ ] }, "gitHead": "37d23b4a8eb1c663bc38e8dd9040889ea1526ec4" -} +} \ No newline at end of file diff --git a/packages/amis-ui/scss/_thirds.scss b/packages/amis-ui/scss/_thirds.scss index 55751c4b0..9f664070a 100644 --- a/packages/amis-ui/scss/_thirds.scss +++ b/packages/amis-ui/scss/_thirds.scss @@ -7,5 +7,6 @@ @import '../../../node_modules/video-react/dist/video-react'; @import '../../../node_modules/cropperjs/dist/cropper'; @import '../../../node_modules/office-viewer/dist/office'; +@import '../../../node_modules/animate.css/animate.min'; @import './components/react-datetime';