diff --git a/docs/renderers.md b/docs/renderers.md index 63e6f11ae..db33ad78b 100644 --- a/docs/renderers.md +++ b/docs/renderers.md @@ -68,6 +68,7 @@ amis 页面是通过 JSON 配置出来的,是由一个一个渲染模型组成 - [Service](./renderers/Service.md): 功能型容器,自身不负责展示内容,主要职责在于通过配置的 api 拉取数据 - [Chart](./renderers/Chart.md): 图表渲染器 - [Collapse](./renderers/Collapse.md): 折叠器 +- [Carousel](./renderers/Carousel.md): 轮播图 - [Audio](./renderers/Audio.md): 音频播放器 - [Video](./renderers/Video.md): 视频播放器 - [Table](./renderers/Table.md): 表格展示 diff --git a/docs/renderers/Carousel.md b/docs/renderers/Carousel.md new file mode 100644 index 000000000..d92323a27 --- /dev/null +++ b/docs/renderers/Carousel.md @@ -0,0 +1,43 @@ +### Carousel + +轮播图 + +- `type` 请设置成 `carousel` +- `className` 外层 Dom 的类名 +- `options` 轮播面板数据,默认`[]`,支持以下模式 + - 图片 + - `image` 图片链接 + - `imageClassName` 图片类名 + - `title` 图片标题 + - `titleClassName` 图片标题类名 + - `description` 图片描述 + - `descriptionClassName` 图片描述类名 + - `html` HTML 自定义,同[Tpl](./Tpl.md)一致 +- `auto` 是否自动轮播,默认`true` +- `interval` 切换动画间隔,默认`5s` +- `duration` 切换动画时长,默认`0.5s` +- `width` 宽度,默认`auto` +- `height` 高度,默认`200px` +- `controls` 显示左右箭头、底部圆点索引,默认`['dots', 'arrows']` +- `controlsTheme` 左右箭头、底部圆点索引颜色,默认`light`,另有`dark`模式 +- `animation` 切换动画效果,默认`fade`,另有`slide`模式 + +```schema:height="350" scope="body" +{ + "type": "carousel", + "controlTheme": "light", + "height": "300", + "animation": "slide", + "options": [ + { + "image": "https://video-react.js.org/assets/poster.png" + }, + { + "html": "
carousel data
" + }, + { + "image": "https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=3893101144,2877209892&fm=23&gp=0.jpg" + } + ] +} +``` diff --git a/examples/components/App.jsx b/examples/components/App.jsx index 6504d177d..f02a467e7 100644 --- a/examples/components/App.jsx +++ b/examples/components/App.jsx @@ -66,6 +66,7 @@ import ChartSchema from './Chart'; import HorizontalSchema from './Horizontal'; import VideoSchema from './Video'; import AudioSchema from './Audio'; +import CarouselSchema from './Carousel'; import TasksSchema from './Tasks'; import ServicesDataSchema from './Services/Data'; import ServicesSchemaSchema from './Services/Schema'; @@ -459,6 +460,12 @@ const navigations = [ path: 'chart', component: makeSchemaRenderer(ChartSchema) }, + { + label: '轮播图', + icon: 'fa fa-pause', + path: 'carousel', + component: makeSchemaRenderer(CarouselSchema) + }, { label: '音频', icon: 'fa fa-volume-up', diff --git a/examples/components/Audio.jsx b/examples/components/Audio.jsx index 01aaf83ac..e47173aa9 100644 --- a/examples/components/Audio.jsx +++ b/examples/components/Audio.jsx @@ -5,7 +5,33 @@ export default { { "type": "audio", "autoPlay": false, + "rates": [1.0, 1.5, 2.0], "src": "http://www.ytmp3.cn/down/32791.mp3", + }, + { + "type": 'form', + "title": '', + "actions": [], + "className": 'b v-middle inline w-lg h-xs', + "controls": [ + { + "type": "card", + "className": 'v-middle w inline no-border', + "header": { + "title": "歌曲名称", + "subTitle": "专辑名称", + "description": "description", + "avatarClassName": "pull-left thumb-md avatar m-r no-border", + "avatar": "http://hiphotos.baidu.com/fex/%70%69%63/item/c9fcc3cec3fdfc03ccabb38edd3f8794a4c22630.jpg" + } + }, + { + "type": "audio", + "className": 'v-middle no-border', + "src": "http://www.ytmp3.cn/down/32791.mp3", + "controls": ['play'] + } + ] } ] } \ No newline at end of file diff --git a/examples/components/CRUD/Fields.jsx b/examples/components/CRUD/Fields.jsx index 7bdcc66d4..9dda381c3 100644 --- a/examples/components/CRUD/Fields.jsx +++ b/examples/components/CRUD/Fields.jsx @@ -10,6 +10,12 @@ export default { label: "ID", type: "text" }, + { + name: "carousel", + label: "轮播图", + type: "carousel", + width: "300" + }, { name: "text", label: "文本", diff --git a/examples/components/Carousel.jsx b/examples/components/Carousel.jsx new file mode 100644 index 000000000..5d5d8bc48 --- /dev/null +++ b/examples/components/Carousel.jsx @@ -0,0 +1,56 @@ +export default { + type: 'page', + title: '轮播图', + data: { + carousel: [ + { + html: '
carousel data in form
' + }, + { + image: 'https://www.baidu.com/img/bd_logo1.png' + }, + { + image: 'https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=3893101144,2877209892&fm=23&gp=0.jpg' + } + ] + }, + body: [ + { + type: 'grid', + columns: [ + { + type: 'carousel', + controlsTheme: 'light', + height: '300', + className: 'm-t-xxl', + options: [ + { + image: 'https://video-react.js.org/assets/poster.png' + }, + { + html: '
carousel data
' + }, + { + image: 'https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=3893101144,2877209892&fm=23&gp=0.jpg' + } + ] + }, + { + type: 'form', + title: '表单', + className: 'm-b-xxl', + controls: [ + { + type: 'carousel', + controlsTheme: 'dark', + name: 'carousel', + label: 'carousel', + animation: 'slide', + height: '300' + } + ] + } + ] + } + ] +} diff --git a/examples/components/Doc.jsx b/examples/components/Doc.jsx index 3db3cf9cd..fa68b9a41 100644 --- a/examples/components/Doc.jsx +++ b/examples/components/Doc.jsx @@ -507,6 +507,13 @@ export default { cb(null, makeMarkdownRenderer(doc)); }), }, + { + label: 'Carousel', + path: '/docs/renderers/Carousel', + getComponent: (location, cb) => require(['../../docs/renderers/Carousel.md'], (doc) => { + cb(null, makeMarkdownRenderer(doc)); + }), + }, { label: 'Audio', path: '/docs/renderers/Audio', diff --git a/mock/crud/list.js b/mock/crud/list.js index f04dd2f4b..78094566f 100644 --- a/mock/crud/list.js +++ b/mock/crud/list.js @@ -24,6 +24,17 @@ module.exports = function(req, res) { title: '{{name.title}}', description: '{{lorem.words}}' }), Math.round(Math.random() * 10)), + carousel: [ + { + image: 'https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=3893101144,2877209892&fm=23&gp=0.jpg' + }, + { + html: '
carousel data in crud
' + }, + { + image: 'https://video-react.js.org/assets/poster.png' + } + ], date: Math.round(Date.now() / 1000), // image: '{{image.imageUrl}}', image: 'https://ss0.bdstatic.com/70cFvHSh_Q1YnxGkpoWK1HF6hhy/it/u=3893101144,2877209892&fm=23&gp=0.jpg', diff --git a/scss/_variables.scss b/scss/_variables.scss index 07582a708..5e4b78d3a 100644 --- a/scss/_variables.scss +++ b/scss/_variables.scss @@ -1334,3 +1334,21 @@ $Audio-svg-width: px2rem(20px) !default; $Audio-svg-height: px2rem(20px) !default; $Audio-svg-top: px2rem(6px) !default; $Audio-item-margin: px2rem(10px) !default; + +// Carousel +$Carousel-bg: #F6F8F8; +$Carousel-minWidth: px2rem(100px) !default; +$Carousel-height: px2rem(200px) !default; +$Carousel-arrowControl-width: px2rem(20px) !default; +$Carousel-arrowControl-height: px2rem(20px) !default; +$Carousel-svg-width: px2rem(20px) !default; +$Carousel-svg-height: px2rem(20px) !default; +$Carousel-dot-width: px2rem(8px) !default; +$Carousel-dot-height: px2rem(8px) !default; +$Carousel-dot-borderRadius: px2rem(4px) !default; +$Carousel-dot-margin: px2rem(7px) px2rem(5px) !default; +$Carousel--light-control: white !default; +$Carousel--dark-control: black !default; +$Carousel-transitionDuration: 0.3s !default; +$Carousel-imageTitle-bottom: px2rem(45px) !default; +$Carousel-imageDescription-bottom: px2rem(25px) !default; \ No newline at end of file diff --git a/scss/components/_carousel.scss b/scss/components/_carousel.scss new file mode 100644 index 000000000..39bd2d20c --- /dev/null +++ b/scss/components/_carousel.scss @@ -0,0 +1,165 @@ +@mixin arrow-control { + width: 10%; + min-width: $Carousel-arrowControl-width; + height: 100%; + cursor: pointer; + position: absolute; + transition-duration: $Carousel-transitionDuration; + svg { + position: absolute; + top: 50%; + left: 50%; + right: 50%; + transform: translate(-50%, -50%); + width: $Carousel-svg-width; + height: $Carousel-svg-height; + } +} + +.#{$ns}Carousel { + position: relative; + display: block; + background: $Carousel-bg; + + &.#{$ns}Carousel--light { + .#{$ns}Carousel-dot { + background-color: $Carousel--light-control; + } + + svg { + fill: $Carousel--light-control; + } + + .#{$ns}Carousel-item { + .title, + .description { + color: $Carousel--light-control; + } + } + } + + &.#{$ns}Carousel--dark { + .#{$ns}Carousel-dot { + background-color: $Carousel--dark-control; + } + + svg { + fill: $Carousel--dark-control; + } + + .#{$ns}Carousel-item { + .title, + .description { + color: $Carousel--dark-control; + } + } + } + + &-container { + min-width: $Carousel-minWidth; + height: $Carousel-height; + position: relative; + overflow: hidden; + + .#{$ns}Carousel-item { + width: 100%; + height: 100%; + position: absolute; + transition: ease-out all $Carousel-transitionDuration; + + &.fade { + opacity: 0; + } + + &.fade.in { + opacity: 1; + } + + &.slide { + transform: translateX(100%); + } + + &.slide.in { + transform: translateX(0); + } + + &.slide.out { + transform: translateX(-100%); + } + + &.slideRight { + transform: translateX(-100%); + } + + &.slideRight.in { + transform: translateX(0); + } + + &.slideRight.out { + transform: translateX(100%); + } + + .title { + position: absolute; + bottom: $Carousel-imageTitle-bottom; + text-align: center; + width: 100%; + opacity: 0.8; + } + + .description { + position: absolute; + bottom: $Carousel-imageDescription-bottom; + text-align: center; + width: 100%; + opacity: 0.8; + } + + .image { + width: 100%; + height: 100%; + background-size: cover; + } + } + } + + &-dotsControl { + position: absolute; + bottom: 0px; + width: 100%; + z-index: 100; + text-align: center; + + .#{$ns}Carousel-dot { + display: inline-block; + height: $Carousel-dot-width; + width: $Carousel-dot-height; + border-radius: $Carousel-dot-borderRadius; + margin: $Carousel-dot-margin; + transition-duration: $Carousel-transitionDuration; + opacity: 0.3; + + &.is-active { + opacity: 1; + } + } + } + + &-arrowsControl { + position: absolute; + width: 100%; + height: inherit; + z-index: 100; + text-align: center; + + .#{$ns}Carousel-leftArrow { + @include arrow-control; + left: 0; + } + + .#{$ns}Carousel-rightArrow { + @include arrow-control; + right: 0; + } + } +} \ No newline at end of file diff --git a/scss/themes/cxd.scss b/scss/themes/cxd.scss index 6a3eafe67..3991ec258 100644 --- a/scss/themes/cxd.scss +++ b/scss/themes/cxd.scss @@ -432,6 +432,7 @@ $TagControl-sugTip-color: $primary; @import "../components/pagination"; @import "../components/wrapper"; @import "../components/status"; +@import "../components/carousel"; @import "../components/form/fieldset"; @import "../components/form/group"; diff --git a/scss/themes/default.scss b/scss/themes/default.scss index a000a0aa6..18c8576db 100644 --- a/scss/themes/default.scss +++ b/scss/themes/default.scss @@ -59,6 +59,7 @@ $Form-input-borderColor: #cfdadd; @import "../components/pagination"; @import "../components/wrapper"; @import "../components/status"; +@import "../components/carousel"; @import "../components/form/fieldset"; @import "../components/form/group"; diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 70b5bafa5..4a02c5a41 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -75,3 +75,19 @@ export const pauseIcon = ( /> ); +export const leftArrowIcon = ( + + + +); +export const rightArrowIcon = ( + + + +); \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 6aee2df09..986d26c03 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -149,6 +149,7 @@ import './renderers/Wrapper'; import './renderers/IFrame'; import './renderers/QRCode'; import './renderers/Icon'; +import './renderers/Carousel'; import Scoped, {ScopedContext} from './Scoped'; import { diff --git a/src/renderers/Audio.tsx b/src/renderers/Audio.tsx index ad4963bb6..311af06d7 100644 --- a/src/renderers/Audio.tsx +++ b/src/renderers/Audio.tsx @@ -379,7 +379,7 @@ export class Audio extends React.Component { const {muted} = this.state; return ( -
+
-
+
{controls && controls.map((control:string, index:number) => { control = 'render' + upperFirst(control); const method:'renderRates'|'renderPlay'|'renderTime'|'renderProcess'|'renderVolume'|'render' = control as any; diff --git a/src/renderers/Carousel.tsx b/src/renderers/Carousel.tsx new file mode 100644 index 000000000..eb9ddeb46 --- /dev/null +++ b/src/renderers/Carousel.tsx @@ -0,0 +1,301 @@ +import * as React from 'react'; +import Transition, {ENTERED, ENTERING, EXITING} from 'react-transition-group/Transition'; +import {Renderer, RendererProps} from '../factory'; +import {autobind, createObject} from '../utils/helper'; +import {leftArrowIcon, rightArrowIcon} from '../components/icons'; + +const animationStyles: { + [propName: string]: string; +} = { + [ENTERING]: 'in', + [ENTERED]: 'in', + [EXITING]: 'out' +}; + +export interface CarouselProps extends RendererProps { + className?: string; + auto?: boolean; + value?: any; + placeholder?: any; + width?: number; + height?: number; + controls: string[]; + interval: number; + duration: number; + controlsTheme: 'light' | 'dark'; + animation: 'fade' | 'slide'; +} + +export interface CarouselState { + current: number; + options: any[]; + showArrows: boolean; + nextAnimation: string; +} + +export class Carousel extends React.Component { + wrapperRef: React.RefObject; + intervalTimeout: number; + durationTimeout: number; + + static defaultProps: Pick< + CarouselProps, + 'auto' | 'interval' | 'duration' | 'controlsTheme' | 'animation' | 'controls' | 'placeholder' + > = { + auto: true, + interval: 5000, + duration: 500, + controlsTheme: 'light', + animation: 'fade', + controls: ['dots', 'arrows'], + placeholder: '' + }; + + constructor(props:CarouselProps) { + super(props); + + this.state = { + current: 0, + options: this.props.value ? this.props.value : this.props.options ? this.props.options : [], + showArrows: false, + nextAnimation: '' + }; + + this.wrapperRef = React.createRef(); + } + + componentDidMount() { + this.prepareAutoSlide(); + } + + componentWillUnmount() { + this.clearAutoTimeout() + } + + @autobind + prepareAutoSlide () { + if (this.state.options.length < 2) { + return; + } + + this.clearAutoTimeout(); + if (this.props.auto) { + this.intervalTimeout = setTimeout(this.autoSlide, this.props.interval); + } + } + + @autobind + autoSlide (rel?:string) { + this.clearAutoTimeout(); + const {animation} = this.props; + let {nextAnimation} = this.state; + + switch (rel) { + case 'prev': + animation === 'slide' ? nextAnimation = 'slideRight' : nextAnimation = ''; + this.transitFramesTowards('right', nextAnimation); + break; + case 'next': + default: + nextAnimation = ''; + this.transitFramesTowards('left', nextAnimation); + break; + } + + this.durationTimeout = setTimeout(this.prepareAutoSlide, this.props.duration); + } + + @autobind + transitFramesTowards (direction:string, nextAnimation: string) { + let {current} = this.state; + + switch (direction) { + case 'left': + current = this.getFrameId('next'); + break; + case 'right': + current = this.getFrameId('prev'); + break; + } + + this.setState({ + current, + nextAnimation + }); + } + + @autobind + getFrameId (pos?:string) { + const {options, current} = this.state; + const total = options.length; + switch (pos) { + case 'prev': + return (current - 1 + total) % total; + case 'next': + return (current + 1) % total; + default: + return current; + } + } + + @autobind + next () { + this.autoSlide('next'); + } + + @autobind + prev () { + this.autoSlide('prev'); + } + + @autobind + clearAutoTimeout () { + clearTimeout(this.intervalTimeout); + clearTimeout(this.durationTimeout); + } + + renderDots() { + const {classnames: cx} = this.props; + const {current, options} = this.state; + return ( +
+ {Array.from({length: options.length}).map((_, i) => + + )} +
+ ) + } + + renderArrows() { + const {classnames: cx} = this.props; + return ( +
+
{leftArrowIcon}
+
{rightArrowIcon}
+
+ ) + } + + @autobind + defaultSchema() { + return { + type: 'tpl', + tpl: + "<% if (data.image) { %> " + + "
)\" class=\"image <%= data.imageClassName %>\">
" + + "<% if (data.title) { %> " + + "
\"><%= data.title %>
" + + "<% } if (data.description) { %> " + + "
\"><%= data.description %>
" + + "<% } %>" + + "<% } else if (data.html) { %>" + + "<%= data.html %>" + + "<% } %>" + } + } + + @autobind + handleMouseEnter() { + this.setState({ + showArrows: true + }); + this.clearAutoTimeout(); + } + + @autobind + handleMouseLeave() { + this.setState({ + showArrows: false + }); + this.prepareAutoSlide(); + } + + render() { + const { + render, + className, + classnames: cx, + itemSchema, + animation, + width, + height, + controls, + controlsTheme, + placeholder, + data + } = this.props; + const { + options, + showArrows, + current, + nextAnimation + } = this.state; + + let body:JSX.Element | null = null; + let carouselStyles: { + [propName: string]: string; + } = {}; + width ? carouselStyles.width = width + 'px' : ''; + height ? carouselStyles.height = height + 'px' : ''; + const [dots, arrows] = [controls.indexOf('dots') > -1, controls.indexOf('arrows') > -1]; + const animationName = nextAnimation || animation; + + if (options && options.length) { + body = ( +
+ {options.map((option:any, key:number) => ( + + {(status:string) => { + if (status === ENTERING) { + this.wrapperRef.current && this.wrapperRef.current.childNodes.forEach((item:HTMLElement) => item.offsetHeight); + } + + return ( +
+ {render(`${current}/body`, itemSchema ? itemSchema : this.defaultSchema(), { + data: createObject(data, option) + })} +
+ ); + }} +
+ ))} + {dots ? this.renderDots() : null} + {arrows && showArrows ? this.renderArrows() : null} +
+ ); + } + + return ( +
+ {body ? body : placeholder} +
+ ); + } +} + +@Renderer({ + test: /(^|\/)carousel/, + name: 'carousel', +}) +export class CarouselRenderer extends Carousel {} diff --git a/src/utils/tpl-lodash.ts b/src/utils/tpl-lodash.ts index 31e841f12..e784ea364 100644 --- a/src/utils/tpl-lodash.ts +++ b/src/utils/tpl-lodash.ts @@ -1,5 +1,5 @@ import { reigsterTplEnginer, filter } from "./tpl"; -import tempalte = require('lodash/template'); +import template = require('lodash/template'); import { filters } from "./tpl-builtin"; import * as React from 'react'; import * as moment from 'moment'; @@ -31,7 +31,7 @@ const imports = { delete imports.default; // default 是个关键字,不能 imports 到 lodash 里面去。 function lodashCompile(str: string, data: object) { try { - const fn = tempalte(str, { + const fn = template(str, { imports: imports, variable: 'data' });