拖动缩放功能
需求背景:选一张海报图片,在海报图片上实现自定义填充文字内容,文字颜色和字体可自定义,且文字区域可以拖动和等比例缩放,最终将文字和海报合成一张新的图片
需要用到的插件:html2canvas(用于将一段html转成canvas)
下面附上整个组件功能代码:
/* eslint-disable camelcase */ import React from 'react' import cx from 'classnames' import {connect} from 'react-redux' import PT from 'prop-types' import html2canvas from 'html2canvas' import AsyncLottie from '../../../../components/AsyncLottie' import lottie from './loading.json' import {showToast} from '../../../../utils/zyHybrid' import {getImgUrl} from '../../../../utils' import {TAB_PANEL, COLOR_SELECTOR, STROKE_COLOR, GREY_STROKE} from './constants' import TextInput from './components/textInput' import bgUrl from '../images/example1.png' import './index.scss' const lottieOpts = { loop: true, autoplay: true, rendererSettings: { preserveAspectRatio: 'xMidYMid slice', }, } class PosterEditorTool extends React.Component { timer = null resizeRef = null dragRef = null isDraging = false isResizing = false state = { toCanvas: false, currentTabValue: 0, img: { id: ENVIRONMENT === 'production' ? 1398831582 : 1820587098, type: 'img', }, showFontWrap: true, showTextInputCom: false, fontWrapContent: '活动主题', currentFontColor: '#FFFFFF', currentFontFamily: '', fontFamilySelector: [ {family: '', showLoading: false, showDownload: false}, {family: 'PangMenZhengDao', showLoading: false, showDownload: true}, {family: 'SourceHanSansCN-Bold', showLoading: false, showDownload: true}, {family: 'zcool-gdh', showLoading: false, showDownload: true}, {family: 'ZCOOL_KuHei', showLoading: false, showDownload: true}, { family: 'SourceHanSerifCN-Heavy', showLoading: false, showDownload: true, }, { family: 'jiangxizhuokai-Regular', showLoading: false, showDownload: true, }, {family: 'HappyZcool-2016', showLoading: false, showDownload: true}, {family: 'JiangChengYuanTi-500W', showLoading: false, showDownload: true}, {family: 'zcoolwenyiti', showLoading: false, showDownload: true}, {family: 'xiaowei', showLoading: false, showDownload: true}, { family: 'HanaMinPlus', showLoading: false, showDownload: true, }, ], touchPos: {x: 0, y: 0}, dragContainerRefPos: {x: 0, y: 0}, textFontSize: 24, } componentDidMount() { this.showFontInit() } componentWillUnmount() { const {resizeRef, dragRef} = this document.removeEventListener('touchmove', this.handleTouchMove) document.removeEventListener('touchend', this.handleTouchEnd) if (dragRef) { dragRef.removeEventListener('touchstart', this.handleDragRefTouchStart) } if (resizeRef) { resizeRef.removeEventListener( 'touchstart', this.handleResizeRefTouchStart ) } } handleDragRefTouchStart = evt => { this.isDraging = true this.setState({ touchPos: { x: evt.touches[0].pageX, y: evt.touches[0].pageY, }, dragContainerRefPos: { x: parseInt(this.dragRef.style.left, 10), y: parseInt(this.dragRef.style.top, 10), }, }) } handleTouchMove = evt => { const {isDraging, isResizing, dragRef, canvasRef} = this const {touchPos, dragContainerRefPos, textFontSize} = this.state if (isDraging) { const tempTop = parseInt(dragContainerRefPos.y, 10) + parseInt(evt.touches[0].pageY, 10) - parseInt(touchPos.y, 10) dragRef.style.left = `${dragContainerRefPos.x + evt.touches[0].pageX - touchPos.x}px` dragRef.style.top = `${dragContainerRefPos.y + evt.touches[0].pageY - touchPos.y}px` if (tempTop < 0) { dragRef.style.top = '0px' } if (tempTop > canvasRef.offsetHeight - dragRef.offsetHeight) { dragRef.style.top = `${canvasRef.offsetHeight - dragRef.offsetHeight}px` } } if (isResizing) { let fontSize = textFontSize + (evt.touches[0].pageX - touchPos.x) * 0.1 if (fontSize < 10) { fontSize = 10 } else if (fontSize > 80) { fontSize = 80 } dragRef.style.fontSize = `${fontSize}px` } } handleTouchEnd = () => { this.isDraging = false this.isResizing = false } handleResizeRefTouchStart = evt => { evt.stopPropagation() this.isResizing = true this.setState({ textFontSize: parseFloat(this.dragRef.style.fontSize), touchPos: { x: evt.touches[0].pageX, y: evt.touches[0].pageY, }, }) } handleCloseFont = () => { this.setState({ showFontWrap: false, fontWrapContent: '活动主题', textFontSize: '24px', }) } showFontInit = () => { const {resizeRef, dragRef} = this dragRef.style.fontSize = '24px' dragRef.style.top = '0px' dragRef.style.left = '0px' document.addEventListener('touchmove', this.handleTouchMove) document.addEventListener('touchend', this.handleTouchEnd) if (dragRef) { dragRef.addEventListener('touchstart', this.handleDragRefTouchStart) } if (resizeRef) { resizeRef.addEventListener('touchstart', this.handleResizeRefTouchStart) } } handleConfirm = value => { this.setState({ showTextInputCom: false, fontWrapContent: value, }) } setDefaultImg = id => { const img = {id, type: 'img'} this.setState({ img, }) } uploadImg = () => { window.ZuiyouJSBridge.callHandler( 'uploadFile', { count: 1, file_type: 'img', edit: true, multiple: false, clip_scale: 3 / 1, }, data => { const { list: [img], ret, errmsg, } = data // ios 返回 ret 为 "1" if (Number(ret) !== 1) { showToast(errmsg || '上传图片异常,请重试~') return } this.setState({ img, }) } ) } handleClickComplete = () => { const {onComplete} = this.props this.setState({toCanvas: true}, () => { html2canvas(this.canvasRef, { logging: false, useCORS: true, }).then(canvas => { const imgUrl = canvas.toDataURL('image/png') onComplete(imgUrl) }) }) } changeTab = value => { this.setState({ currentTabValue: value, }) } handleSelectColor = color => { this.setState({currentFontColor: color}) const {showFontWrap} = this.state if (!showFontWrap) { this.setState( { showFontWrap: true, }, () => { this.showFontInit() } ) } } handleSelectFontFamily = font => { this.changeFontObjField(font, true, false) const {showFontWrap} = this.state if (!showFontWrap) { this.setState( { showFontWrap: true, }, () => { this.showFontInit() } ) } this.setState( { currentFontFamily: font.family, }, () => { this.changeFontObjField(font, false, false) } ) } changeFontObjField = (fontObj, loading, download) => { const {fontFamilySelector} = this.state const newFontFamilySelector = fontFamilySelector.map(item => { if (item.family === fontObj.family) { return {...item, showLoading: loading, showDownload: download} } return item }) this.setState({fontFamilySelector: newFontFamilySelector}) } renderPosterCoverTab = () => { const {newPosterList} = this.props const {img} = this.state return ( <div className="PosterEditorTool__PosterCoverTab"> <div className="PosterEditorTool__PosterCoverTab__posterUpload" onClick={this.uploadImg} role="button" tabIndex={0} /> {newPosterList.map(item => ( <div className={cx('PosterEditorTool__PosterCoverTab__posterItem', { 'PosterEditorTool__PosterCoverTab__posterItem--selected': img.id === item.id, })} key={item.id} style={{ // backgroundImage: `url(${'https://file.izuiyou.com/img/png/id/1090013920'})`, backgroundImage: `url(${getImgUrl({id: item.id})})`, }} onClick={() => { this.setDefaultImg(item.id) }} role="button" tabIndex={0} /> ))} </div> ) } renderFontSelectWrap = () => { const {currentFontFamily, fontFamilySelector} = this.state return ( <div className="PosterEditorTool__PosterTextTab__fontSelectWrap"> {fontFamilySelector.map((font, index) => ( <div className={cx('PosterEditorTool__PosterTextTab__fontSelectItem', { 'PosterEditorTool__PosterTextTab__fontSelectItem--selected': currentFontFamily === font.family, 'PosterEditorTool__PosterTextTab__fontSelectItem--loading': font.showLoading === true, })} key={font.family} role="button" tabIndex={0} onClick={() => { this.handleSelectFontFamily(font) }} > <div className={`PosterEditorTool__PosterTextTab__fontItemBg PosterEditorTool__PosterTextTab__fontItemBg--${index}`} /> {font.showDownload && ( <div className="PosterEditorTool__PosterTextTab__fontDownloadIcon" /> )} {font.showLoading && ( <div className="PosterEditorTool__PosterTextTab__fontDownloadLoading"> <AsyncLottie options={lottieOpts} animationDataLoader={() => lottie} height="100%" width="100%" /> </div> )} </div> ))} </div> ) } renderPosterTextTab = () => { const {currentFontColor} = this.state return ( <div className="PosterEditorTool__PosterTextTab"> <div className="PosterEditorTool__PosterTextTab__colorWrap"> {COLOR_SELECTOR.map(color => ( <div className="PosterEditorTool__PosterTextTab__colorItemWrap" key={color} role="button" tabIndex={0} onClick={() => { this.handleSelectColor(color) }} > {currentFontColor === color ? ( <div className="PosterEditorTool__PosterTextTab__colorItemSelected" style={{ border: color === GREY_STROKE ? '1px solid #EAEAEA' : `2px solid ${color}`, }} > <span style={{ background: color, border: color === GREY_STROKE ? '1px solid #EAEAEA' : 'none', }} /> </div> ) : ( <div className="PosterEditorTool__PosterTextTab__colorItem" style={{ background: color, border: STROKE_COLOR.indexOf(color) > -1 ? '1px solid #EAEAEA' : 'none', }} /> )} </div> ))} </div> {this.renderFontSelectWrap()} </div> ) } render() { const { currentTabValue, img, showFontWrap, showTextInputCom, fontWrapContent, currentFontColor, currentFontFamily, toCanvas, } = this.state return ( <div className="PosterEditorTool"> <div className="PosterEditorTool__header"> <span onClick={this.handleClickComplete} role="button" tabIndex={0}> 完成 </span> </div> <div ref={ref => { this.canvasRef = ref }} className="PosterEditorTool__posterWrap" > <img src={getImgUrl(img) || bgUrl} alt="" className="PosterEditorTool__posterImg" /> {showFontWrap && ( <div ref={ref => { this.dragRef = ref }} className="PosterEditorTool__dragResizeContainer" style={{ border: !toCanvas ? '1px solid #FFFFFF' : 'none', top: '0px', left: '0px', fontSize: '24px', }} role="button" tabIndex={0} onClick={evt => { this.setState({ currentTabValue: 1, showTextInputCom: true, }) }} > <span className="PosterEditorTool__posterTextWrap" style={{ color: currentFontColor, fontFamily: currentFontFamily, }} > {fontWrapContent} </span> {!toCanvas && ( <div className="PosterEditorTool__closeHandle" role="button" tabIndex={0} onClick={evt => { evt.stopPropagation() this.handleCloseFont() }} /> )} {!toCanvas && ( <div ref={ref => { this.resizeRef = ref }} className="PosterEditorTool__resizeHandle" role="button" tabIndex={0} /> )} </div> )} </div> <div className="PosterEditorTool__bottomContainer"> <div className="PosterEditorTool__tabPanel"> {TAB_PANEL.items().map(item => ( <div className={cx('PosterEditorTool__tabPanelItem', { 'PosterEditorTool__tabPanelItem--selected': item.value === currentTabValue, })} key={item.value} onClick={() => { this.changeTab(item.value) }} role="button" tabIndex={0} > <span>{item.text}</span> </div> ))} </div> {currentTabValue === TAB_PANEL.alias2Value('posterCover') && this.renderPosterCoverTab()} {currentTabValue === TAB_PANEL.alias2Value('posterText') && this.renderPosterTextTab()} </div> {showTextInputCom && ( <TextInput onConfirm={this.handleConfirm} currentFontColor={currentFontColor} currentFontFamily={currentFontFamily} fontWrapContent={fontWrapContent} /> )} </div> ) } } PosterEditorTool.propTypes = { onComplete: PT.func, newPosterList: PT.arrayOf(PT.shape()), } PosterEditorTool.defaultProps = { onComplete: () => {}, newPosterList: [], } export default connect((state, props) => { const { topic: {activity = {}}, } = state const {newPosterList} = activity return {newPosterList} })(PosterEditorTool)
css如下:
@font-face { font-family: "PangMenZhengDao"; src: url("./fonts/PangMenZhengDao.ttf"); } @font-face { font-family: "SourceHanSansCN-Bold"; src: url("./fonts/SourceHanSansCN-Bold.otf"); } @font-face { font-family: "zcool-gdh"; src: url("./fonts/zcool-gdh.ttf"); } @font-face { font-family: "ZCOOL_KuHei"; src: url("./fonts/ZCOOL_KuHei.ttf"); } @font-face { font-family: "SourceHanSerifCN-Heavy"; src: url("./fonts/SourceHanSerifCN-Heavy.otf"); } @font-face { font-family: "jiangxizhuokai-Regular"; src: url("./fonts/jiangxizhuokai-Regular.ttf"); } @font-face { font-family: "HappyZcool-2016"; src: url("./fonts/HappyZcool-2016.ttf"); } @font-face { font-family: "JiangChengYuanTi-500W"; src: url("./fonts/JiangChengYuanTi-500W.ttf"); } @font-face { font-family: "zcoolwenyiti"; src: url("./fonts/zcoolwenyiti.ttf"); } @font-face { font-family: "xiaowei"; src: url("./fonts/xiaowei.otf"); } @font-face { font-family: "HanaMinPlus"; src: url("./fonts/HanaMinPlus.ttf"); } .PosterEditorTool { position: fixed; width: 100%; height: 100%; top: 0; bottom: 0; left: 0; right: 0; background: #000000; box-sizing: border-box; z-index: 999; padding-top: 10px; &__header { font-family: PingFang SC; font-style: normal; font-weight: normal; font-size: 17px; line-height: 24px; text-align: right; color: #FFFFFF; padding-right: 10px; } &__posterWrap { position: relative; width: 100vw; height: 33.33333333vw; margin-top: 100px; } &__posterImg { width: 100%; height: 100%; object-fit: cover; } &__dragResizeContainer { position: absolute; box-sizing: border-box; padding: 13px 25px; font-size: 24px; background-color: transparent; white-space: nowrap; top: 0px; left: 0px; } &__posterTextWrap { color: #ffffff; white-space: nowrap; } &__resizeHandle { z-index: 11; width: 24px; height: 24px; position: absolute; background: url("./images/drag_icon.png") no-repeat center center / 100% 100% transparent; right: -10px; bottom: -10px; } &__closeHandle { z-index: 11; width: 24px; height: 24px; position: absolute; background: url("./images/font_close_icon.png") no-repeat center center / 100% 100% transparent; right: -10px; top: -10px; } &__bottomContainer { position: absolute; width: 100%; height: 320px; left: 0px; bottom: 0px; background: #FFFFFF; display: flex; flex-direction: column; } &__tabPanel { display: flex; padding: 11px 0 0 16px; flex-shrink: 0; } &__tabPanelItem { display: flex; flex-direction: column; align-items: center; &:nth-of-type(2) { margin-left: 26px; } span { font-family: PingFang SC; font-style: normal; font-weight: normal; font-size: 16px; line-height: 22px; color: #626470; } &--selected { span { font-weight: 500; color: #242529; } &::after { margin-top: 2px; display: inline-block; content: ''; width: 9px; height: 3px; background: url("./images/panel_line_icon.png") no-repeat center center / 100% 100% transparent; } } } &__PosterCoverTab { flex-grow: 1; overflow-y: scroll; padding: 12px 16px 0 16px; display: flex; flex-wrap: wrap; justify-content: space-between; &__posterItem, &__posterUpload { width: 44.8vw; height: 14.933333vw; border-radius: 4px; background-repeat: no-repeat; background-size: 100% auto; flex-shrink: 0; } &__posterItem { margin-top: 7px; box-sizing: border-box; &--selected { border: 1.5px solid #149EFF; } &:nth-of-type(2) { margin-top: 0px; } } &__posterUpload { background-image: url('./images/upload_bg_icon.png'); } } &__PosterTextTab { flex-grow: 1; overflow-y: scroll; padding-top: 12px; &__fontSelectWrap { padding: 10px 16px 16px 16px; padding-bottom: calc(16px + env(safe-area-inset-bottom)); display: flex; flex-wrap: wrap; justify-content: space-between; } &__fontSelectItem { width: 81px; height: 56px; background: #F5F5F7; border-radius: 6px; box-sizing: border-box; margin-top: 6px; position: relative; &--selected { background: rgba(20, 158, 255, 0.1); border: 1.5px solid #149EFF; } &--loading { background: rgba(0, 0, 0, 0.1); border: none; pointer-events: none; } } &__fontItemBg { width: 100%; height: 100%; &--0 { display: flex; justify-content: center; align-items: center; &::after { content: '默认字体'; font-family: ''; font-style: normal; font-weight: normal; font-size: 13px; color: #626470; } } &--1 { background: url("./images/font_icon_1.png") no-repeat center center / 100% 100% transparent; } &--2 { background: url("./images/font_icon_2.png") no-repeat center center / 100% 100% transparent; } &--3 { background: url("./images/font_icon_3.png") no-repeat center center / 100% 100% transparent; } &--4 { background: url("./images/font_icon_4.png") no-repeat center center / 100% 100% transparent; } &--5 { background: url("./images/font_icon_5.png") no-repeat center center / 100% 100% transparent; } &--6 { background: url("./images/font_icon_6.png") no-repeat center center / 100% 100% transparent; } &--7 { background: url("./images/font_icon_7.png") no-repeat center center / 100% 100% transparent; } &--8 { background: url("./images/font_icon_8.png") no-repeat center center / 100% 100% transparent; } &--9 { background: url("./images/font_icon_9.png") no-repeat center center / 100% 100% transparent; } &--10 { background: url("./images/font_icon_10.png") no-repeat center center / 100% 100% transparent; } &--11 { background: url("./images/font_icon_11.png") no-repeat center center / 100% 100% transparent; } } &__fontDownloadIcon { position: absolute; width: 16px; height: 16px; background: url("./images/font_download_icon.png") no-repeat center center / 100% 100% transparent; right: -2px; bottom: -2px; } &__fontDownloadLoading { position: absolute; width: 20px; height: 20px; top: 50%; left: 50%; transform: translate(-50%, -50%); } &__colorWrap { overflow-x: scroll; padding-left: 16px; display: flex; align-items: center; } &__colorItemWrap { margin-left: 12px; &:nth-of-type(1) { margin-left: 0px; } } &__colorItem { width: 24px; height: 24px; border-radius: 50%; box-sizing: border-box; } &__colorItemSelected { width: 24px; height: 24px; box-sizing: border-box; background-color: #ffffff; border-radius: 50%; display: flex; align-items: center; justify-content: center; span { display: inline-block; width: 12px; height: 12px; border-radius: 50%; } } } }
注意点:在使用html2canvas时,如果HTML内包含需要请求接口URL的图片时,会有跨域问题,需要URL域名设置允许跨域。