diff --git a/.eslintignore b/.eslintignore index ce8d250..4b2d211 100644 --- a/.eslintignore +++ b/.eslintignore @@ -5,3 +5,4 @@ project.*.json *.lock *.log +iconfont/ diff --git a/src/components/Dialog/index.module.scss b/src/components/Dialog/index.module.scss index afb4530..c3468ac 100644 --- a/src/components/Dialog/index.module.scss +++ b/src/components/Dialog/index.module.scss @@ -1,6 +1,6 @@ $am-ms: 200ms; -.dialog{ +.dialog { position: fixed; left: 0; right: 0; @@ -14,7 +14,7 @@ $am-ms: 200ms; left: 0; width: 100%; height: 100%; - background: rgba(0, 0, 0, 0.6); + background: rgba(0, 0, 0, 0.8); z-index: 1; opacity: 0; transition: opacity $am-ms ease-in; diff --git a/src/components/painter/index.tsx b/src/components/painter/index.tsx new file mode 100644 index 0000000..86a63c4 --- /dev/null +++ b/src/components/painter/index.tsx @@ -0,0 +1,268 @@ +import { Canvas } from '@tarojs/components' +import Taro from '@tarojs/taro' +import { forwardRef, useImperativeHandle, useRef, useState } from 'react' +import Downloader from './lib/downloader' +import Pen from './lib/pen' +import { equal, toPx } from './lib/util' +/** + * + * 入参 palette 格式 + * const imgDraw = { + width: '1124rpx', + height: '1578rpx', + background: getCDNSource('/user/inviteCodePopup.png'), + views: [ + { + type: 'image', + url: 'https://qiniu-image.qtshe.com/1560248372315_467.jpg', + css: { + top: '32rpx', + left: '30rpx', + right: '32rpx', + width: '688rpx', + height: '420rpx', + borderRadius: '16rpx', + }, + }, + { + type: 'text', + text: '青团子', + css: { + top: '532rpx', + fontSize: '28rpx', + left: '375rpx', + align: 'center', + color: '#3c3c3c', + }, + }, + ], + } + * + */ +const downloader = new Downloader() + +let SystemInfo = Taro.getSystemInfoSync() + +// 最大尝试的绘制次数 +const MAX_PAINT_COUNT = 5 + +interface PropsType { + customStyle?: React.CSSProperties + painterStyle?: React.CSSProperties + dirty?: boolean + palette?: any // 参考上方 + onImgErr: (result) => void + onImgOK: (result) => void +} +// 绘画 +const Painter = (props: PropsType, ref) => { + const { customStyle, painterStyle: _painterStyle, dirty, palette, onImgErr, onImgOK } = props + const [painterStyle, setPainterStyle] = useState(_painterStyle) + const canvasWidthInPx = useRef(0) + const canvasHeightInPx = useRef(0) + const paintCount = useRef(0) + + const [, setForceUpdate] = useState({}) + const canvasNode = useRef(null) + const ctx = useRef(null) + /** + * 判断一个 object 是否为 空 + * @param {object} object + */ + const isEmpty = (object) => { + for (const i in object) { + if (i) { + return false + } + } + return true + } + const isNeedRefresh = (newVal, oldVal) => { + if (!newVal || isEmpty(newVal) || (dirty && equal(newVal, oldVal))) { + return false + } + return true + } + + const downloadImages = () => { + return new Promise((resolve, reject) => { + let preCount = 0 + let completeCount = 0 + const paletteCopy = JSON.parse(JSON.stringify(palette)) + if (paletteCopy.background) { + preCount++ + downloader.download(paletteCopy.background).then((path) => { + paletteCopy.background = path + completeCount++ + if (preCount === completeCount) { + resolve(paletteCopy) + } + }, () => { + completeCount++ + if (preCount === completeCount) { + resolve(paletteCopy) + } + }) + } + if (paletteCopy.views) { + for (const view of paletteCopy.views) { + if (view && view.type === 'image' && view.url) { + preCount++ + console.log('url', view.url) + /* eslint-disable no-loop-func */ + downloader.download(view.url).then((path) => { + console.log('path', path) + view.url = path + Taro.getImageInfo({ + src: view.url, + success: (res) => { + // 获得一下图片信息,供后续裁减使用 + view.sWidth = res.width + view.sHeight = res.height + }, + fail: (error) => { + console.log(`imgDownloadErr failed, ${JSON.stringify(error)}`) + onImgErr({ error }) + }, + complete: () => { + completeCount++ + if (preCount === completeCount) { + resolve(paletteCopy) + } + }, + }) + }, () => { + completeCount++ + if (preCount === completeCount) { + resolve(paletteCopy) + } + }) + } + } + } + if (preCount === 0) { + resolve(paletteCopy) + } + }) + } + // 初始化 canvas + const initCanvas = async() => { + return new Promise<{ canvas: Taro.Canvas; ctx: Taro.RenderingContext }>((resolve, reject) => { + Taro.nextTick(() => { + const query = Taro.createSelectorQuery() + query.select('#canvas').node(({ node: canvas }: { node: Taro.Canvas }) => { + console.log('canvas==>', canvas) + const context = canvas.getContext('2d') + console.log('ctx', context) + canvasNode.current = canvas + ctx.current = context + console.log('canvas', canvas) + setForceUpdate({}) + resolve({ canvas: canvasNode.current, ctx: ctx.current }) + }).exec() + }) + }) + } + // 开始绘制 + const _startPaint = () => { + if (isEmpty(palette)) { + return + } + console.log('startPaint', palette) + if (!(SystemInfo && SystemInfo.screenWidth)) { + try { + SystemInfo = Taro.getSystemInfoSync() + } + catch (e) { + const error = `Painter get system info failed, ${JSON.stringify(e)}` + // that.triggerEvent('imgErr', { error }) + onImgErr({ error }) + console.log(error) + return + } + } + + downloadImages().then((palette: any) => { + const { width, height } = palette + canvasWidthInPx.current = toPx(width) + canvasHeightInPx.current = toPx(height) + if (!width || !height) { + console.log(`You should set width and height correctly for painter, width: ${width}, height: ${height}`) + return + } + console.log('palette', palette, width, height) + setPainterStyle({ + width: `${width}`, + height: `${height}`, + }) + // 初始化canvas + initCanvas().then(({ ctx, canvas }) => { + // const ctx = Taro.createCanvasContext('k-canvas', this) + const pen = new Pen(ctx, canvas, palette) + pen.paint((canvas) => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + saveImgToLocal(canvas) + }) + }) + }) + } + + const getImageInfo = (filePath: string) => { + Taro.getImageInfo({ + src: filePath, + success: (infoRes) => { + if (paintCount.current > MAX_PAINT_COUNT) { + const error = `The result is always fault, even we tried ${MAX_PAINT_COUNT} times` + console.log(error) + onImgErr({ error }) + return + } + // 比例相符时才证明绘制成功,否则进行强制重绘制 + if (Math.abs((infoRes.width * canvasHeightInPx.current - canvasWidthInPx.current * infoRes.height) / (infoRes.height * canvasHeightInPx.current)) < 0.01) { + onImgOK({ path: filePath }) + } + else { + console.log('infoRes filePath', infoRes, filePath, canvasHeightInPx.current, canvasWidthInPx.current) + _startPaint() + } + paintCount.current++ + }, + fail: (error) => { + console.log(`getImageInfo failed, ${JSON.stringify(error)}`) + onImgErr({ error }) + }, + }) + } + const saveImgToLocal = (canvas) => { + setTimeout(() => { + Taro.canvasToTempFilePath({ + canvas, + fileType: 'png', + success(res) { + console.log('tempFilePath', res.tempFilePath, canvas) + getImageInfo(res.tempFilePath) + }, + fail(error) { + console.log(`canvasToTempFilePath failed, ${JSON.stringify(error)}`) + // that.triggerEvent('imgErr', { error }) + onImgErr({ error }) + }, + }, this) + }, 300) + } + // 暴露 + useImperativeHandle( + ref, + () => { + return { + startPaint: () => { + paintCount.current = 0 + _startPaint() + }, + } + }, + [paintCount.current], + ) + return +} +export default forwardRef(Painter) diff --git a/src/components/painter/lib/downloader.ts b/src/components/painter/lib/downloader.ts new file mode 100644 index 0000000..0179eb6 --- /dev/null +++ b/src/components/painter/lib/downloader.ts @@ -0,0 +1,239 @@ +/** + * LRU 文件存储,使用该 downloader 可以让下载的文件存储在本地,下次进入小程序后可以直接使用 + * 详细设计文档可查看 https://juejin.im/post/5b42d3ede51d4519277b6ce3 + */ +import Taro from '@tarojs/taro' +import { + equal, + isValidUrl, +} from './util' + +const SAVED_FILES_KEY = 'savedFiles' +const KEY_TOTAL_SIZE = 'totalSize' +const KEY_PATH = 'path' +const KEY_TIME = 'time' +const KEY_SIZE = 'size' + +// 可存储总共为 6M,目前小程序可允许的最大本地存储为 10M +const MAX_SPACE_IN_B = 6 * 1024 * 1024 +let savedFiles: Record = {} + +export default class Dowloader { + constructor() { + // app 如果设置了最大存储空间,则使用 app 中的 + // if (getApp().PAINTER_MAX_LRU_SPACE) { + // MAX_SPACE_IN_B = getApp().PAINTER_MAX_LRU_SPACE + // } + Taro.getStorage({ + key: SAVED_FILES_KEY, + success(res) { + if (res.data) { + savedFiles = res.data + } + }, + }) + } + + /** + * 下载文件,会用 lru 方式来缓存文件到本地 + * @param {String} url 文件的 url + */ + download(url) { + return new Promise((resolve, reject) => { + if (!(url && isValidUrl(url))) { + resolve(url) + return + } + const file = getFile(url) + + if (file) { + // 检查文件是否正常,不正常需要重新下载 + Taro.getSavedFileInfo({ + filePath: file[KEY_PATH], + success: (res) => { + resolve(file[KEY_PATH]) + }, + fail: (error) => { + console.error(`the file is broken, redownload it, ${JSON.stringify(error)}`) + downloadFile(url).then((path) => { + resolve(path) + }, () => { + // eslint-disable-next-line prefer-promise-reject-errors + reject() + }) + }, + }) + } + else { + downloadFile(url).then((path) => { + resolve(path) + }, () => { + // eslint-disable-next-line prefer-promise-reject-errors + reject() + }) + } + }) + } +} + +function downloadFile(url) { + return new Promise((resolve, reject) => { + Taro.downloadFile({ + url, + success(res) { + if (res.statusCode !== 200) { + console.error(`downloadFile ${url} failed res.statusCode is not 200`) + // eslint-disable-next-line prefer-promise-reject-errors + reject() + return + } + const { tempFilePath } = res + Taro.getFileInfo({ + filePath: tempFilePath, + success: (tmpRes) => { + const newFileSize = tmpRes.size + doLru(newFileSize).then(() => { + saveFile(url, newFileSize, tempFilePath).then((filePath) => { + resolve(filePath) + }) + }, () => { + resolve(tempFilePath) + }) + }, + fail: (error) => { + // 文件大小信息获取失败,则此文件也不要进行存储 + console.error(`getFileInfo ${res.tempFilePath} failed, ${JSON.stringify(error)}`) + resolve(res.tempFilePath) + }, + }) + }, + fail(error) { + console.error(`downloadFile failed, ${JSON.stringify(error)} `) + // eslint-disable-next-line prefer-promise-reject-errors + reject() + }, + }) + }) +} + +function saveFile(key, newFileSize, tempFilePath) { + return new Promise((resolve, reject) => { + Taro.saveFile({ + tempFilePath, + success: (fileRes) => { + const totalSize = savedFiles[KEY_TOTAL_SIZE] ? savedFiles[KEY_TOTAL_SIZE] : 0 + savedFiles[key] = {} + savedFiles[key][KEY_PATH] = fileRes.savedFilePath + savedFiles[key][KEY_TIME] = new Date().getTime() + savedFiles[key][KEY_SIZE] = newFileSize + savedFiles.totalSize = newFileSize + totalSize + Taro.setStorage({ + key: SAVED_FILES_KEY, + data: savedFiles, + }) + resolve(fileRes.savedFilePath) + }, + fail: (error) => { + console.error(`saveFile ${key} failed, then we delete all files, ${JSON.stringify(error)}`) + // 由于 saveFile 成功后,res.tempFilePath 处的文件会被移除,所以在存储未成功时,我们还是继续使用临时文件 + resolve(tempFilePath) + // 如果出现错误,就直接情况本地的所有文件,因为你不知道是不是因为哪次lru的某个文件未删除成功 + reset() + }, + }) + }) +} + +/** + * 清空所有下载相关内容 + */ +function reset() { + Taro.removeStorage({ + key: SAVED_FILES_KEY, + success: () => { + Taro.getSavedFileList({ + success: (listRes) => { + removeFiles(listRes.fileList) + }, + fail: (getError) => { + console.error(`getSavedFileList failed, ${JSON.stringify(getError)}`) + }, + }) + }, + }) +} + +function doLru(size) { + return new Promise((resolve, reject) => { + let totalSize = savedFiles[KEY_TOTAL_SIZE] ? savedFiles[KEY_TOTAL_SIZE] : 0 + + if (size + totalSize <= MAX_SPACE_IN_B) { + resolve() + return + } + // 如果加上新文件后大小超过最大限制,则进行 lru + const pathsShouldDelete = [] + // 按照最后一次的访问时间,从小到大排序 + const allFiles = JSON.parse(JSON.stringify(savedFiles)) + delete allFiles[KEY_TOTAL_SIZE] + const sortedKeys = Object.keys(allFiles).sort((a, b) => { + return allFiles[a][KEY_TIME] - allFiles[b][KEY_TIME] + }) + + for (const sortedKey of sortedKeys) { + totalSize -= savedFiles[sortedKey].size + // @ts-expect-error sdfsf + pathsShouldDelete.push(savedFiles[sortedKey][KEY_PATH]) + delete savedFiles[sortedKey] + if (totalSize + size < MAX_SPACE_IN_B) { + break + } + } + + savedFiles.totalSize = totalSize + + Taro.setStorage({ + key: SAVED_FILES_KEY, + data: savedFiles, + success: () => { + // 保证 storage 中不会存在不存在的文件数据 + if (pathsShouldDelete.length > 0) { + removeFiles(pathsShouldDelete) + } + resolve() + }, + fail: (error) => { + console.error(`doLru setStorage failed, ${JSON.stringify(error)}`) + // eslint-disable-next-line prefer-promise-reject-errors + reject() + }, + }) + }) +} + +function removeFiles(pathsShouldDelete) { + for (const pathDel of pathsShouldDelete) { + let delPath = pathDel + if (typeof pathDel === 'object') { + delPath = pathDel.filePath + } + Taro.removeSavedFile({ + filePath: delPath, + fail: (error) => { + console.error(`removeSavedFile ${pathDel} failed, ${JSON.stringify(error)}`) + }, + }) + } +} + +function getFile(key) { + if (!savedFiles[key]) { + return + } + savedFiles[key].time = new Date().getTime() + Taro.setStorage({ + key: SAVED_FILES_KEY, + data: savedFiles, + }) + return savedFiles[key] +} diff --git a/src/components/painter/lib/pen.ts b/src/components/painter/lib/pen.ts new file mode 100644 index 0000000..572fcc9 --- /dev/null +++ b/src/components/painter/lib/pen.ts @@ -0,0 +1,400 @@ +import Taro from '@tarojs/taro' +import { toPx } from './util' + +const SystemInfo = Taro.getSystemInfoSync() +export default class Painter { + ctx: any + canvas: any + data: any + style: { width: any; height: any } + constructor(ctx, canvas, data: any) { + this.ctx = ctx + this.canvas = canvas + this.data = data + } + + getImageObject = (canvas, src) => { + return new Promise((resolve, reject) => { + console.log('getImageObject param', canvas, src) + const img = canvas.createImage() + img.src = src + img.onload = () => { + console.log('image===>', img) + resolve(img) + } + img.onerror = (err) => { + console.log('image error===>', err) + reject(err) + } + }) + } + + paint(callback) { + this.style = { + width: toPx(this.data.width), + height: toPx(this.data.height), + } + this._background() + for (const view of this.data.views) { + this._drawAbsolute(view) + } + callback(this.canvas) + } + + _background() { + this.ctx.save() + const { + width, + height, + } = this.style + // 绘制画布 + this.canvas.width = width + this.canvas.height = height + console.log('background', this.canvas.width, this.data.background, width, height) + const bg = this.data.background + this.ctx.translate(width / 2, height / 2) + + this._doClip(this.data.borderRadius, width, height) + if (!bg) { + // 如果未设置背景,则默认使用白色 + this.ctx.fillStyle = '#fff' + this.ctx.fillRect(-(width / 2), -(height / 2), width, height) + } + else if (bg.startsWith('#') || bg.startsWith('rgba') || bg.toLowerCase() === 'transparent') { + // 背景填充颜色 + this.ctx.fillStyle = bg + this.ctx.fillRect(-(width / 2), -(height / 2), width, height) + } + else { + console.log('bg', bg) + this.getImageObject(this.canvas, bg).then((image) => { + // 背景填充图片 + this.ctx.drawImage(image, -(width / 2), -(height / 2), width, height) + }) + } + this.ctx.restore() + } + + _drawAbsolute(view) { + // 证明 css 为数组形式,需要合并 + if (view.css && view.css.length) { + /* eslint-disable no-param-reassign */ + // @ts-expect-error sdfsf + view.css = Object.assign(...view.css) + } + switch (view.type) { + case 'image': + this._drawAbsImage(view) + break + case 'text': + this._fillAbsText(view) + break + case 'rect': + this._drawAbsRect(view) + break + // case 'qrcode': + // this._drawQRCode(view) + // break + default: + break + } + } + + /** + * 根据 borderRadius 进行裁减 + */ + _doClip(borderRadius, width, height) { + if (borderRadius && width && height) { + const r = Math.min(toPx(borderRadius), width / 2, height / 2) + // 防止在某些机型上周边有黑框现象,此处如果直接设置 setFillStyle 为透明,在 Android 机型上会导致被裁减的图片也变为透明, iOS 和 IDE 上不会 + // setGlobalAlpha 在 1.9.90 起支持,低版本下无效,但把 setFillStyle 设为了 white,相对默认的 black 要好点 + this.ctx.globalAlpha = 0 + this.ctx.fillStyle = 'white' + this.ctx.beginPath() + this.ctx.arc(-width / 2 + r, -height / 2 + r, r, 1 * Math.PI, 1.5 * Math.PI) + this.ctx.lineTo(width / 2 - r, -height / 2) + this.ctx.arc(width / 2 - r, -height / 2 + r, r, 1.5 * Math.PI, 2 * Math.PI) + this.ctx.lineTo(width / 2, height / 2 - r) + this.ctx.arc(width / 2 - r, height / 2 - r, r, 0, 0.5 * Math.PI) + this.ctx.lineTo(-width / 2 + r, height / 2) + this.ctx.arc(-width / 2 + r, height / 2 - r, r, 0.5 * Math.PI, 1 * Math.PI) + this.ctx.closePath() + this.ctx.fill() + // 在 ios 的 6.6.6 版本上 clip 有 bug,禁掉此类型上的 clip,也就意味着,在此版本微信的 ios 设备下无法使用 border 属性 + if (!(SystemInfo + && SystemInfo?.version as string <= '6.6.6' + && SystemInfo.platform === 'ios')) { + this.ctx.clip() + } + this.ctx.globalAlpha = 1 + } + } + + /** + * 画边框 + */ + _doBorder(view, width, height) { + if (!view.css) { + return + } + const { + borderRadius, + borderWidth, + borderColor, + } = view.css + if (!borderWidth) { + return + } + this.ctx.save() + this._preProcess(view, true) + let r + if (borderRadius) { + r = Math.min(toPx(borderRadius), width / 2, height / 2) + } + else { + r = 0 + } + const lineWidth = toPx(borderWidth) + this.ctx.lineWidth = lineWidth + this.ctx.strokeStyle = borderColor || 'black' + this.ctx.beginPath() + this.ctx.arc(-width / 2 + r, -height / 2 + r, r + lineWidth / 2, 1 * Math.PI, 1.5 * Math.PI) + this.ctx.lineTo(width / 2 - r, -height / 2 - lineWidth / 2) + this.ctx.arc(width / 2 - r, -height / 2 + r, r + lineWidth / 2, 1.5 * Math.PI, 2 * Math.PI) + this.ctx.lineTo(width / 2 + lineWidth / 2, height / 2 - r) + this.ctx.arc(width / 2 - r, height / 2 - r, r + lineWidth / 2, 0, 0.5 * Math.PI) + this.ctx.lineTo(-width / 2 + r, height / 2 + lineWidth / 2) + this.ctx.arc(-width / 2 + r, height / 2 - r, r + lineWidth / 2, 0.5 * Math.PI, 1 * Math.PI) + this.ctx.closePath() + this.ctx.stroke() + this.ctx.restore() + } + + _preProcess(view, notClip?: boolean) { + let width + let height + let extra + switch (view.type) { + case 'text': { + const fontWeight = view.css.fontWeight === 'bold' ? 'bold' : 'normal' + view.css.fontSize = view.css.fontSize ? view.css.fontSize : '20rpx' + this.ctx.font = `normal ${fontWeight} ${toPx(view.css.fontSize)}px sans-serif` + // this.ctx.setFontSize(view.css.fontSize.toPx()); + const textLength = this.ctx.measureText(view.text).width + width = view.css.width ? toPx(view.css.width) : textLength + // 计算行数 + const calLines = Math.ceil(textLength / width) + const lines = view.css.maxLines < calLines ? view.css.maxLines : calLines + const lineHeight = view.css.lineHeight ? toPx(view.css.lineHeight) : toPx(view.css.fontSize) + height = lineHeight * lines + extra = { lines, lineHeight } + break + } + case 'image': { + // image 如果未设置长宽,则使用图片本身的长宽 + const ratio = SystemInfo.pixelRatio ? SystemInfo.pixelRatio : 2 + width = view.css && view.css.width ? toPx(view.css.width) : Math.round(view.sWidth / ratio) + height = view.css && view.css.height ? toPx(view.css.height) : Math.round(view.sHeight / ratio) + break + } + default: { + if (!(view.css.width && view.css.height)) { + console.error('You should set width and height') + return + } + width = toPx(view.css.width) + height = toPx(view.css.height) + } + } + const x = view.css && view.css.right ? this.style.width - toPx(view.css.right, true) : (view.css && view.css.left ? toPx(view.css.left, true) : 0) + const y = view.css && view.css.bottom ? this.style.height - height - toPx(view.css.bottom, true) : (view.css && view.css.top ? toPx(view.css.top, true) : 0) + + const angle = view.css && view.css.rotate ? this._getAngle(view.css.rotate) : 0 + // 当设置了 right 时,默认 align 用 right,反之用 left + const align = view.css && view.css.align ? view.css.align : (view.css && view.css.right ? 'right' : 'left') + switch (align) { + case 'center': + this.ctx.translate(x, y + height / 2) + break + case 'right': + this.ctx.translate(x - width / 2, y + height / 2) + break + default: + this.ctx.translate(x + width / 2, y + height / 2) + break + } + this.ctx.rotate(angle) + if (!notClip && view.css && view.css.borderRadius) { + this._doClip(view.css.borderRadius, width, height) + } + + return { + width, + height, + x, + y, + extra, + } + } + + // _drawQRCode(view) { + // this.ctx.save() + // const { + // width, + // height, + // } = this._preProcess(view) + // QR.api.draw(view.content, this.ctx, -width / 2, -height / 2, width, height, view.css.background, view.css.color) + // this.ctx.restore() + // this._doBorder(view, width, height) + // } + + _drawAbsImage(view) { + if (!view.url) { + return + } + this.ctx.save() + const { + width, + height, + } = this._preProcess(view)! + // 获得缩放到图片大小级别的裁减框 + let rWidth + let rHeight + let startX = 0 + let startY = 0 + if (width > height) { + rHeight = Math.round((view.sWidth / width) * height) + rWidth = view.sWidth + } + else { + rWidth = Math.round((view.sHeight / height) * width) + rHeight = view.sHeight + } + if (view.sWidth > rWidth) { + startX = Math.round((view.sWidth - rWidth) / 2) + } + if (view.sHeight > rHeight) { + startY = Math.round((view.sHeight - rHeight) / 2) + } + if (view.css && view.css.mode === 'scaleToFill') { + this.getImageObject(this.canvas, view.url).then((image) => { + this.ctx.drawImage(image, -(width / 2), -(height / 2), width, height) + }) + } + else { + this.getImageObject(this.canvas, view.url).then((image) => { + this.ctx.drawImage(image, startX, startY, rWidth, rHeight, -(width / 2), -(height / 2), width, height) + }) + } + this.ctx.restore() + this._doBorder(view, width, height) + } + + _fillAbsText(view) { + if (!view.text) { + return + } + this.ctx.save() + const { + width, + height, + extra, + } = this._preProcess(view)! + this.ctx.fillStyle = view.css.color || 'black' + const { lines, lineHeight } = extra + const preLineLength = Math.round(view.text.length / lines) + let start = 0 + let alreadyCount = 0 + for (let i = 0; i < lines; ++i) { + alreadyCount = preLineLength + let text = view.text.substr(start, alreadyCount) + let measuredWith = this.ctx.measureText(text).width + // 如果测量大小小于width一个字符的大小,则进行补齐,如果测量大小超出 width,则进行减除 + // 如果已经到文本末尾,也不要进行该循环 + while ((start + alreadyCount <= view.text.length) && (width - measuredWith > toPx(view.css.fontSize) || measuredWith > width)) { + if (measuredWith < width) { + text = view.text.substr(start, ++alreadyCount) + } + else { + if (text.length <= 1) { + // 如果只有一个字符时,直接跳出循环 + break + } + text = view.text.substr(start, --alreadyCount) + } + measuredWith = this.ctx.measureText(text).width + } + start += text.length + // 如果是最后一行了,发现还有未绘制完的内容,则加... + if (i === lines - 1 && start < view.text.length) { + while (this.ctx.measureText(`${text}...`).width > width) { + if (text.length <= 1) { + // 如果只有一个字符时,直接跳出循环 + break + } + text = text.substring(0, text.length - 1) + } + text += '...' + measuredWith = this.ctx.measureText(text).width + } + this.ctx.textAlign = view.css.align ? view.css.align : 'left' + let x + switch (view.css.align) { + case 'center': + x = 0 + break + case 'right': + x = (width / 2) + break + default: + x = -(width / 2) + break + } + const y = -(height / 2) + (i === 0 ? toPx(view.css.fontSize) : (toPx(view.css.fontSize) + i * lineHeight)) + if (view.css.textStyle === 'stroke') { + this.ctx.strokeText(text, x, y, measuredWith) + } + else { + this.ctx.fillText(text, x, y, measuredWith) + } + const fontSize = toPx(view.css.fontSize) + if (view.css.textDecoration) { + this.ctx.beginPath() + if (/\bunderline\b/.test(view.css.textDecoration)) { + this.ctx.moveTo(x, y) + this.ctx.lineTo(x + measuredWith, y) + } + if (/\boverline\b/.test(view.css.textDecoration)) { + this.ctx.moveTo(x, y - fontSize) + this.ctx.lineTo(x + measuredWith, y - fontSize) + } + if (/\bline-through\b/.test(view.css.textDecoration)) { + this.ctx.moveTo(x, y - fontSize / 3) + this.ctx.lineTo(x + measuredWith, y - fontSize / 3) + } + this.ctx.closePath() + this.ctx.strokeStyle = view.css.color + this.ctx.stroke() + } + } + + this.ctx.restore() + this._doBorder(view, width, height) + } + + _drawAbsRect(view) { + this.ctx.save() + const { + width, + height, + } = this._preProcess(view)! + this.ctx.fillStyle = view.css.color + this.ctx.fillRect(-(width / 2), -(height / 2), width, height) + this.ctx.restore() + this._doBorder(view, width, height) + } + + _getAngle(angle) { + return Number(angle) * Math.PI / 180 + } +} diff --git a/src/components/painter/lib/util.ts b/src/components/painter/lib/util.ts new file mode 100644 index 0000000..f8629e0 --- /dev/null +++ b/src/components/painter/lib/util.ts @@ -0,0 +1,94 @@ +import Taro from '@tarojs/taro' + +const SystemInfo = Taro.getSystemInfoSync() +export const screenK = SystemInfo.screenWidth / 750 + +export function isValidUrl(url: string) { + return /(ht|f)tp(s?):\/\/([^ \\/]*\.)+[^ \\/]*(:[0-9]+)?\/?/.test(url) +} + +/** + * 深度对比两个对象是否一致 + * from: https://github.com/epoberezkin/fast-deep-equal + * @param {Object} a 对象a + * @param {Object} b 对象b + * @return {Boolean} 是否相同 + */ +export function equal(a, b) { + if (a === b) { return true } + + if (a && b && typeof a == 'object' && typeof b == 'object') { + const arrA = Array.isArray(a) + const arrB = Array.isArray(b) + let i + let length + let key + + if (arrA && arrB) { + length = a.length + if (length != b.length) { return false } + for (i = length; i-- !== 0;) { if (!equal(a[i], b[i])) { return false } } + return true + } + + if (arrA != arrB) { return false } + + const dateA = a instanceof Date + const dateB = b instanceof Date + if (dateA != dateB) { return false } + if (dateA && dateB) { return a.getTime() == b.getTime() } + + const regexpA = a instanceof RegExp + const regexpB = b instanceof RegExp + if (regexpA != regexpB) { return false } + if (regexpA && regexpB) { return a.toString() == b.toString() } + + const keys = Object.keys(a) + length = keys.length + + if (length !== Object.keys(b).length) { return false } + + for (i = length; i-- !== 0;) { if (!Object.prototype.hasOwnProperty.call(b, keys[i])) { return false } } + + for (i = length; i-- !== 0;) { + key = keys[i] + if (!equal(a[key], b[key])) { return false } + } + + return true + } + + // eslint-disable-next-line no-self-compare + return a !== a && b !== b +} +export /** + * 是否支持负数 + * @param {Boolean} minus 是否支持负数 + */ +const toPx = (num: string, minus = false) => { + let reg + if (minus) { + reg = /^-?[0-9]+([.]{1}[0-9]+){0,1}(rpx|px)$/g + } + else { + reg = /^[0-9]+([.]{1}[0-9]+){0,1}(rpx|px)$/g + } + const results = reg.exec(num) + console.log('results', results) + if (!num || !results) { + console.log(`The size: ${num} is illegal`) + return 0 + } + const unit = results[2] + const value = parseFloat(num) + + let res = 0 + if (unit === 'rpx') { + res = Math.round(value * screenK) + } + else if (unit === 'px') { + res = value + } + console.log('res', res) + return res +} diff --git a/src/pages/inviteCode/index.module.scss b/src/pages/inviteCode/index.module.scss index 206bafc..87fda3e 100644 --- a/src/pages/inviteCode/index.module.scss +++ b/src/pages/inviteCode/index.module.scss @@ -7,11 +7,11 @@ page { display: flex; flex-flow: column nowrap; height: 100%; - background: linear-gradient(to bottom, #E4EEFD 25%, $color_bg_one 42%); + background: linear-gradient(to bottom, #e4eefd 25%, $color_bg_one 42%); padding-bottom: 0; box-sizing: border-box; overflow-y: scroll; - .content{ + .content { flex: 1 1 auto; overflow: scroll; } @@ -24,85 +24,88 @@ page { align-items: center; justify-content: space-between; overflow: hidden; - .left{ - .title{ - padding-bottom: 15px; + .left { + .title { + padding-bottom: 5px; font-size: 60px; font-weight: 500; } - .description{ + .description { font-size: 28px; + color: #848689; font-weight: 400; } } - .right{ - .iconContainer{ + .right { + .iconContainer { width: 260px; position: relative; bottom: -36px; - .icon{ + .icon { width: 130%; } } } } -.codeBar{ +.inviteCodeContent { + position: relative; + top: -20px; +} +.codeBar { border-radius: 15px; background-color: #f7f8fa; padding: 40px 0; - .inviteCodeBar{ + .inviteCodeBar { display: flex; flex-flow: row nowrap; justify-content: center; align-items: center; margin-bottom: 16px; - .invite{ + .invite { padding: 0 40px; font-size: 46px; font-weight: 500; - color: #337FFF; + color: #337fff; line-height: 65px; } } - } -.tips{ - display: flex; - flex-flow: row nowrap; - justify-content: center; - align-items: center; - font-size: 24px; - font-weight: 400; - color: #9fa0a1; - line-height: 28px; - padding: 0 40px; - } -.inviteListTitle{ +.tips { + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; + font-size: 24px; + font-weight: 400; + color: #9fa0a1; + line-height: 28px; + padding: 0 40px; +} +.inviteListTitle { display: flex; flex-flow: row nowrap; justify-content: center; align-items: center; padding-top: 15px; padding-bottom: 30px; - .listTitle{ + .listTitle { padding: 0 20px; font-size: 32px; font-weight: 500; color: #000000; } - .titleIconLeft{ + .titleIconLeft { width: 24px; height: 4px; - background: linear-gradient(270deg, #333333 0%, rgba(51,51,51,0) 100%); + background: linear-gradient(270deg, #333333 0%, rgba(51, 51, 51, 0) 100%); opacity: 0.3; } - .titleIconRight{ + .titleIconRight { width: 24px; height: 4px; - background: linear-gradient(270deg, rgba(51,51,51,0) 0%, #333333 100%); + background: linear-gradient(270deg, rgba(51, 51, 51, 0) 0%, #333333 100%); opacity: 0.3; } - } .bottomBar { flex: none; @@ -111,27 +114,31 @@ page { justify-content: space-between; align-items: center; padding-top: 24px; - padding-right: 24px; - padding-left: 24px; + padding-right: 48px; + padding-left: 48px; background-color: #ffffff; padding-bottom: calc(20px + constant(safe-area-inset-bottom)); padding-bottom: calc(20px + env(safe-area-inset-bottom)); + &__text{ + font-weight: 500; + } } -.codePreview{ +.codePreview { display: flex; flex-flow: column nowrap; justify-content: center; align-items: center; position: relative; - .imageContainer{ + top: -120px; + .imageContainer { width: 80vw; height: auto; - .image{ + .image { width: 100%; height: 100%; } } - .previewTips{ + .previewTips { position: absolute; bottom: -80px; font-size: 40px; diff --git a/src/pages/inviteCode/index.tsx b/src/pages/inviteCode/index.tsx index 6127b7a..d91a905 100644 --- a/src/pages/inviteCode/index.tsx +++ b/src/pages/inviteCode/index.tsx @@ -1,9 +1,9 @@ import { Canvas, Image, Text, View } from '@tarojs/components' import Taro, { useReady } from '@tarojs/taro' -import { useCallback, useRef, useState } from 'react' +import React, { useCallback, useRef, useState } from 'react' import { imageAudit } from '@tarojs/taro-h5' import style from './index.module.scss' -import inviteCodePng from './inviteCode.png' +import inviteCodePng from './inviteCodePopupX2.png' import QRcode from './inviteCodePopup.png' import Dialog from '@/components/Dialog' import LayoutBlock from '@/components/layoutBlock' @@ -14,6 +14,40 @@ import NormalButton from '@/components/normalButton' import { alert } from '@/common/common' import { GenBarCodeOrQrCode, GetInvitationInfo } from '@/api' import { getCDNSource } from '@/common/constant' +import Painter from '@/components/painter' + +const SystemInfo = Taro.getSystemInfoSync() +let screenK = 5 +/** + * 是否支持负数 + * @param {Boolean} minus 是否支持负数 + */ +const toPx = (num: string, minus = false) => { + let reg + if (minus) { + reg = /^-?[0-9]+([.]{1}[0-9]+){0,1}(rpx|px)$/g + } + else { + reg = /^[0-9]+([.]{1}[0-9]+){0,1}(rpx|px)$/g + } + const results = reg.exec(num) + console.log('results', results) + if (!num || !results) { + console.log(`The size: ${num} is illegal`) + return 0 + } + const unit = results[2] + const value = parseFloat(num) + + let res = 0 + if (unit === 'rpx') { + res = Math.round(value * screenK) + } + else if (unit === 'px') { + res = value + } + return res +} // 需要传进来的表头数据示例 const inviteColumns = [ { @@ -29,8 +63,13 @@ const inviteColumns = [ width: '50%', }, ] + + + // 邀请码 const InviteCode = () => { + screenK = SystemInfo.screenWidth / 750 + const { fetchData } = GetInvitationInfo() const { fetchData: genCode } = GenBarCodeOrQrCode() const [inviteInfo, setInviteInfo] = useState({}) @@ -71,14 +110,14 @@ const InviteCode = () => { } const [, setForceUpdate] = useState({}) - const canvasNode = useRef() - const ctx = useRef(null) + const canvasNode = useRef(null) + const ctx = useRef(null) const [showPopup, setShowPopup] = useState(false) const [targetImageUrl, setTargetImageUrl] = useState('') - const getImageObject = (canvas, src) => { - return new Promise((resolve, reject) => { + const getImageObject = (canvas: Taro.Canvas, src: string) => { + return new Promise((resolve, reject) => { console.log('getImageObject param', canvas, src) const img = canvas.createImage() img.src = src @@ -95,6 +134,7 @@ const InviteCode = () => { } // canvas 生成 图片 const saveCanvasToImage = (canvas) => { + console.log('pixelRatio', SystemInfo.pixelRatio, canvas) Taro.canvasToTempFilePath({ canvas, fileType: 'png', @@ -102,6 +142,9 @@ const InviteCode = () => { console.log('tempFilePath', res.tempFilePath) setTargetImageUrl(res.tempFilePath) }, + fail: (error) => { + console.log('error', error) + }, complete: () => { setLoading(false) }, @@ -133,44 +176,56 @@ const InviteCode = () => { }) }) } - const startPaint = async(ctx, canvas, image) => { - console.log('startPaint param', ctx, canvas, image) + + const doublePick = (num, minus = false) => { + if (minus) { + return num / 2 + } + else { + return num * 2 + } + } + const [canvasStyle, setCanvasStyle] = useState({}) + const painter = useRef(null) + const startPaint = async(ctx: Taro.RenderingContext, canvas: Taro.Canvas, image: Taro.Image) => { // 开始绘制 + const { width, height } = canvas + console.log('startPaint param', ctx, canvas, image) const cCanvasCtx = ctx as any - cCanvasCtx.clearRect(0, 0, canvas.width, canvas.height) - cCanvasCtx.drawImage(image, 0, 0, canvas.width, canvas.height) + cCanvasCtx.clearRect(0, 0, width, height) + cCanvasCtx.drawImage(image, 0, 0, width, height) cCanvasCtx.save() - cCanvasCtx.font = `${40}px 微软雅黑` + cCanvasCtx.font = `${doublePick(40)}px 微软雅黑` cCanvasCtx.fillStyle = '#000000' - cCanvasCtx.fillText('蜘蛛管家', 40, 80) // text up above canvas + cCanvasCtx.fillText('蜘蛛管家', doublePick(40), doublePick(80)) // text up above canvas cCanvasCtx.save() - cCanvasCtx.font = `${26}px 微软雅黑` + cCanvasCtx.font = `${doublePick(26)}px 微软雅黑` cCanvasCtx.fillStyle = '#8f9398' - cCanvasCtx.fillText('真挚邀请您建立合作关系', 40, 130) // text up above canvas + cCanvasCtx.fillText('真挚邀请您建立合作关系', doublePick(40), doublePick(130)) // text up above canvas cCanvasCtx.save() - cCanvasCtx.font = `${24}px 微软雅黑` + cCanvasCtx.font = `${doublePick(24)}px 微软雅黑` cCanvasCtx.fillStyle = '#a6a6a6' - cCanvasCtx.fillText('请前往邀请码页面,进行扫描邀请', 100, 630) // text up above canvas + cCanvasCtx.fillText('请前往邀请码页面,进行扫描邀请', doublePick(100), doublePick(630)) // text up above canvas cCanvasCtx.save() - cCanvasCtx.font = `${36}px 微软雅黑` + cCanvasCtx.font = `${doublePick(36)}px 微软雅黑` cCanvasCtx.fillStyle = '#7f7f7f' - cCanvasCtx.fillText('邀 请 码', 72, 730) // text up above canvas + cCanvasCtx.fillText('邀 请 码', doublePick(72), doublePick(730)) // text up above canvas cCanvasCtx.save() - cCanvasCtx.font = `${24}px 微软雅黑` + cCanvasCtx.font = `${doublePick(24)}px 微软雅黑` cCanvasCtx.fillStyle = '#cccccc' - cCanvasCtx.fillText('|', 258, 724) // text up above canvas + cCanvasCtx.fillText('|', doublePick(258), doublePick(724)) // text up above canvas cCanvasCtx.save() - cCanvasCtx.font = `${36}px 微软雅黑` + cCanvasCtx.font = `${doublePick(36)}px 微软雅黑` cCanvasCtx.fillStyle = '#7f7f7f' - cCanvasCtx.fillText(`${inviteInfo.invitation_code}`, 311, 730) // text up above canvas + cCanvasCtx.fillText(`${inviteInfo.invitation_code}`, doublePick(311), doublePick(730)) // text up above canvas cCanvasCtx.save() const codeUrl = await genQRcode() try { - const code: any = await getImageObject(canvas, codeUrl) - cCanvasCtx.drawImage(code, 110, 213, 342, 342) + const code = await getImageObject(canvas, codeUrl) + cCanvasCtx.drawImage(code, doublePick(110), doublePick(213), doublePick(342), doublePick(342)) } catch (err) { - console.error('合成二维邀请码失败') + console.error('合成二维邀请码失败', err) setLoading(false) throw new Error('合成二维邀请码失败') } @@ -186,16 +241,21 @@ const InviteCode = () => { // 重新初始化canvas await initCanvas() } - const canvas = canvasNode.current + const canvas = canvasNode.current! Taro.getImageInfo({ src: getCDNSource('/user/inviteCodePopup.png'), success: (res) => { - canvas.width = res.width / 2 - canvas.height = res.height / 2 + console.log('res==>', res) + canvas.width = res.width + canvas.height = res.height + setCanvasStyle({ + width: `${doublePick(canvas.width, true)}px`, + height: `${doublePick(canvas.height, true)}px`, + }) getImageObject(canvas, `${res.path}`).then(async(image) => { try { // 开始绘制 - await startPaint(ctx.current, canvasNode.current, image) + await startPaint(ctx.current!, canvas, image) resolve(true) } catch (err) { @@ -217,6 +277,7 @@ const InviteCode = () => { } const handleQRcodeShare = async() => { try { + // painter.current.startPaint() const flag = await drawPictorial() if (flag) { setShowPopup(true) @@ -233,8 +294,6 @@ const InviteCode = () => { }) } const handleChange = (value: boolean) => { - console.log('value', value) - setShowPopup(value) } useReady(() => { @@ -243,7 +302,19 @@ const InviteCode = () => { initCanvas() }, 200) }) - + const onImgErr = (e) => { + Taro.hideLoading() + console.error('onImgErr', e.error) + Taro.showToast({ + title: '生成分享图失败,请刷新页面重试', + }) + } + const onImgOK = (e) => { + console.log('onImgOK', e.path) + setTargetImageUrl(e.path) + setShowPopup(true) + Taro.hideLoading() + } return @@ -257,34 +328,38 @@ const InviteCode = () => { - - - - 邀请码 - - {inviteInfo.invitation_code} + + + + + 邀请码 + + {inviteInfo.invitation_code} + + 填写邀请码,即可在蜘蛛管家下单购物 - 填写邀请码,即可在蜘蛛管家下单购物 - - - - - - 成功邀请 - - - -
-
-
- 温馨提示:邀请码确定绑定后,不支持解绑。 + + + + + 成功邀请 + + + +
+
+
+ 温馨提示:邀请码确定绑定后,不支持解绑。 + - + {/* */} + {/* 已踩坑,这里必须设置canvas的style的width和height,单单只设置canvas实例的width和height是不行的。会模糊! */} + - + 二维码分享 - + 复制邀请码 diff --git a/src/pages/inviteCode/inviteCodePopupX2.png b/src/pages/inviteCode/inviteCodePopupX2.png new file mode 100644 index 0000000..9ee1046 Binary files /dev/null and b/src/pages/inviteCode/inviteCodePopupX2.png differ diff --git a/src/pages/inviteCode/inviteCodePopupX3.png b/src/pages/inviteCode/inviteCodePopupX3.png new file mode 100644 index 0000000..0f727ca Binary files /dev/null and b/src/pages/inviteCode/inviteCodePopupX3.png differ