first commit

This commit is contained in:
编码猿
2024-09-27 02:06:13 +08:00
commit 852d94fbb9
36760 changed files with 3274413 additions and 0 deletions

View File

@@ -0,0 +1,555 @@
# Painter 画板
> 可根据自身需求配置生成海报的画板
<br>
### 平台兼容
| H5 | 微信小程序 | 支付宝小程序 | 百度小程序 | 头条小程序 | QQ 小程序 | App |
| --- | ---------- | ------------ | ---------- | ---------- | --------- | --- |
| √ | √ | 未测 | 未测 | √ | 未测 | √ |
<br>
1、增加支持2d <br>
> 不建议开启因为PC端小程序并不支持。
2、增加支持渐变色
```js
{
css: {
// 一定要写百分比
background: 'linear-gradient(135deg, #ff971b 0%, #ff5000 100%)' ,
}
}
```
3、删除drawAll等方法改用成render <br>
```html
<l-painter ref="painter" ></l-painter>
```
```js
// 支持通过调用render传入参数
const painter = this.$refs.painter
painter.render({..参数..})
// 支持通过调用canvasToTempFilePath方法传入参数 调取生成图片
painter.canvasToTempFilePath({..参数..}).then(fun)
```
### 代码演示
#### 基本用法
定一个画板对象,包含`width`、`height`、`background``views`为画板里的元素集,它是一个数组对象。<br>
元素类型目前有`view`、`text`、`image`<br>
css 对象里的位置都是相对于画板的绝对定位,支持`rpx`、`px`
```html
<l-painter :board="base"></l-painter>
```
```js
export default {
data() {
return {
base {
width: '686rpx',
height: '130rpx',
views: [
{
type: 'view',
css: {
left: '0rpx',
top: '0rpx',
background: '#07c160',
width: '120rpx',
height: '120rpx'
}
},
{
type: 'view',
css: {
left: '180rpx',
top: '18rpx',
background: '#1989fa',
width: '80rpx',
height: '80rpx',
rotate: 50
}
}
]
};
}
}
}
```
<br><br>
#### 圆角
为可元素定一个圆角`radius`,支持`rpx`、`px`、`%`
```html
<l-painter :board="base"></l-painter>
```
```js
export default {
data() {
return {
base: {
width: '686rpx',
height: '130rpx',
views: [
{
type: 'view',
css: {
left: '0rpx',
top: '0rpx',
background: '#07c160',
width: '120rpx',
height: '120rpx',
radius: '16rpx 30rpx 10rpx 80rpx'
}
},
{
type: 'view',
css: {
left: '150rpx',
top: '0rpx',
background: '#1989fa',
width: '120rpx',
height: '120rpx',
radius: '16rpx 60rpx'
}
},
{
type: 'view',
css: {
left: '300rpx',
top: '0rpx',
background: '#ff976a',
width: '120rpx',
height: '120rpx',
radius: '50%'
}
}
]
};
}
}
}
```
<br><br>
#### 描边投影
为可元素定一个描边`border`和投影`shadow`,支持`rpx`、`px`
```html
<l-painter :board="base"></l-painter>
```
```js
export default {
data() {
return {
base: {
width: '686rpx',
height: '130rpx',
views: [
{
type: 'view',
css: {
left: '10rpx',
top: '10rpx',
background: 'rgba(7,193,96,.1)',
width: '120rpx',
height: '120rpx',
radius: '50%',
border: '2rpx dashed rgb(7,193,96)'
}
},
{
type: 'view',
css: {
left: '150rpx',
top: '10rpx',
background: 'rgba(25,137,250,.4)',
width: '120rpx',
height: '120rpx',
radius: '50%',
shadow: '0 5rpx 10rpx rgba(25,137,250,.8)'
}
},
{
type: 'view',
css: {
left: '300rpx',
top: '10rpx',
background: 'rgba(255, 151, 106, .1)',
width: '120rpx',
height: '120rpx',
radius: '50%',
border: '2rpx solid #ff976a'
}
}
]
};
}
}
}
```
<br><br>
#### 图片
元素类型为`image`时,需要一个图片地址`url`,图片地址支持`相对路径`和`网络地址`<br>
::: warning 温馨提示
当为网络地址时,
H5需要解决跨域问题 <br>
小程序:需要配置 downloadFile 域名 <br>
建议使用 base64 图片
:::
```html
<l-painter :board="base"></l-painter>
```
```js
export default {
data() {
return {
base: {
width: '686rpx',
height: '130rpx',
views: [
{
type: 'image',
url: '../../static/avatar-1.jpeg',
css: {
left: '0rpx',
top: '0rpx',
width: '120rpx',
height: '120rpx'
}
},
{
type: 'image',
url: '../../static/avatar-2.jpg',
css: {
left: '150rpx',
top: '0rpx',
width: '120rpx',
height: '120rpx',
radius: '16rpx'
}
},
{
type: 'image',
url:
'../../static/avatar-3.jpeg',
css: {
left: '300rpx',
top: '0rpx',
background: '#ff976a',
width: '120rpx',
height: '120rpx',
radius: '50%'
}
}
]
};
}
}
}
```
<br><br>
#### 文字
元素类型`text`时,内容写在`text`里,支持`\n`换行符css 的属性有字体大小`fontSize`、行高`lineHeight`、对齐`textAlign`、修饰`textDecoration`、粗细`fontWeight`、 宽度`width`、最大行数`maxLines`。
设置了最大行数和宽度时,当文字内容超过会显示省略号。
```html
<l-painter :board="base"></l-painter>
```
```js
export default {
data() {
return {
base: {
width: '686rpx',
height: '500rpx',
views: [
{
type: 'text',
text: '左对齐,下划线\n无风才到地有风还满空\n缘渠偏似雪莫近鬓毛生',
css: {
left: '0rpx',
top: '10rpx',
fontSize: '28rpx',
lineHeight: '36rpx',
textDecoration: 'underline'
}
},
{
type: 'text',
text: '居中,上划线\n无风才到地有风还满空\n缘渠偏似雪莫近鬓毛生',
css: {
left: '0rpx',
top: '150rpx',
fontSize: '28rpx',
lineHeight: '36rpx',
textAlign: 'center',
textDecoration: 'overline'
}
},
{
type: 'text',
text: '右对齐,中划线\n无风才到地有风还满空\n缘渠偏似雪莫近鬓毛生',
css: {
left: '0rpx',
top: '290rpx',
fontSize: '28rpx',
lineHeight: '36rpx',
textAlign: 'right',
textDecoration: 'line-through',
}
},
{
type: 'text',
text: '省略号\n明月几时有把酒问青天。不知天上宫阙今夕是何年。我欲乘风归去又恐琼楼玉宇高处不胜寒。起舞弄清影何似在人间。',
css: {
left: '0rpx',
top: '420rpx',
fontSize: '28rpx',
maxLines: 2,
width: '686rpx',
lineHeight: '36rpx'
}
}
]
};
}
}
}
```
<br><br>
#### 提供一份海报样式案例
是否生成图片:`isRenderImage`、自定样式:`custom-style`,把 canvas 移到看不到的地方、生成图片成功:`success`,返回一个图片临时地址。
```html
<l-painter
v-if="isShowPainter"
isRenderImage
custom-style="position: fixed; left: 200%;"
:board="base"
@success="path = $event"
></l-painter>
```
```js
export default {
data() {
return {
base: {
width: '750rpx',
height: '1114rpx',
background: '#F6F7FB',
views: [
{
type: 'view',
css: {
left: '40rpx',
top: '144rpx',
background: '#fff',
radius: '16rpx',
width: '670rpx',
height: '930rpx',
shadow: '0 20rpx 48rpx rgba(0,0,0,.05)'
}
},
{
type: 'image',
url: '../../static/avatar-2.jpg',
mode: 'widthFix',
css: {
left: '40rpx',
top: '40rpx',
width: '84rpx',
height: '84rpx',
radius: '50%',
color: '#999'
}
},
{
type: 'text',
text: '隔壁老王',
css: {
color: '#333',
left: '144rpx',
top: '40rpx',
fontSize: '32rpx',
fontWeight: 'bold'
}
},
{
type: 'text',
text: '为您挑选了一个好物',
css: {
color: '#666',
left: '144rpx',
top: '90rpx',
fontSize: '24rpx'
}
},
{
type: 'image',
url: '../../static/goods.jpg',
mode: 'widthFix',
css: {
left: '72rpx',
top: '176rpx',
width: '606rpx',
height: '606rpx',
radius: '12rpx'
}
},
{
type: 'text',
text: '¥39.90',
css: {
color: '#FF0000',
left: '66rpx',
top: '812rpx',
fontSize: '56rpx',
fontWeight: 'bold'
}
},
{
type: 'text',
text: '360儿童电话手表9X 智能语音问答定位支付手表 4G全网通20米游泳级防水视频通话拍照手表男女孩星空蓝',
css: {
maxLines: 2,
width: '396rpx',
color: '#333',
left: '72rpx',
top: '948rpx',
fontSize: '36rpx',
lineHeight: '50rpx'
}
},
{
type: 'image',
url: '../../static/qr.png',
mode: 'widthFix',
css: {
left: '500rpx',
top: '864rpx',
width: '178rpx',
height: '178rpx'
}
}
]
};
}
},
methods: {
saveImage() {
this.isShowPopup = false
uni.saveImageToPhotosAlbum({
filePath: this.path,
success(res) {
uni.showToast({
title: '已保存到相册',
icon: 'success',
duration: 2000
})
}
})
},
}
}
```
### API
#### Props
| 参数 | 说明 | 类型 | 默认值 |
| ------------- | ------------ | ---------------- | ------------ |
| board | 画板对象 | <em>object</em> | 参数请向下看 |
| customStyle | 自定义样式 | <em>string</em> | |
| isRenderImage | 是否生成图片 | <em>boolean</em> | `false` |
<br>
#### Board
| 参数 | 说明 | 类型 |
| ---------- | ---------------------------------- | --------------- |
| width | 画板的宽度 | <em>string</em> |
| height | 画板的高度 | <em>string</em> |
| background | 画板的背景色 | <em>string</em> |
| views | 画板的元素集,请向下看各元素的参数 | <em>object</em> |
<br>
#### 元素 View
| 参数 | 说明 |
| ---- | --------------------------------------------------------------------------------------------------- |
| type | 元素类型`view` |
| css | 元素的样式,`top`、`left`、`width`、`height`、`background`、`radius`、`border`、`shadow` 、`rotate` |
<br>
#### 元素 text
| 参数 | 说明 |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| type | 元素类型`text` |
| text | 文本内容 |
| css | 元素的样式,`top`、`left`、`fontSize`、`fontWeight`、`fontFamily`、`width`、`lineHeight`、`color`、`textDecoration`、`textAlign`center, left, right、最大行数`maxLines` |
<br>
#### 元素 image
| 参数 | 说明 |
| ---- | -------------------------------------------------------------------------- |
| type | 元素类型`image` |
| url | 图片地址 |
| css | 元素的样式,`top`、`left`、`width`、`height`、`radius`、`border`、`shadow` |
<br>
#### 事件 Events
| 事件名 | 说明 | 回调 |
| ------- | ------------ | -------------- |
| success | 生成图片成功 | \$event |
| fail | 生成图片失败 | {error: error} |

View File

@@ -0,0 +1,43 @@
export function adaptor(ctx) {
// @ts-ignore
return Object.assign(ctx, {
setStrokeStyle(val) {
ctx.strokeStyle = val;
},
setLineWidth(val) {
ctx.lineWidth = val;
},
setLineCap(val) {
ctx.lineCap = val;
},
setFillStyle(val) {
ctx.fillStyle = val;
},
setFontSize(val) {
ctx.font = String(val);
},
setGlobalAlpha(val) {
ctx.globalAlpha = val;
},
setLineJoin(val) {
ctx.lineJoin = val;
},
setTextAlign(val) {
ctx.textAlign = val;
},
setMiterLimit(val) {
ctx.miterLimit = val;
},
setShadow(offsetX, offsetY, blur, color) {
ctx.shadowOffsetX = offsetX;
ctx.shadowOffsetY = offsetY;
ctx.shadowBlur = blur;
ctx.shadowColor = color;
},
setTextBaseline(val) {
ctx.textBaseline = val;
},
createCircularGradient() {},
draw() {},
});
}

View File

@@ -0,0 +1,670 @@
import { toPx, CHAR_WIDTH_SCALE_MAP, base64src } from './utils'
import { GD } from './gradient'
let id = 0
export class Draw {
constructor(context, canvas, use2dCanvas = false) {
this.ctx = context
this.canvas = canvas || null
this.use2dCanvas = use2dCanvas
}
roundRect(x, y, w, h, r, fill = false, stroke = false) {
if (r < 0) return
const ctx = this.ctx
ctx.beginPath()
if(!r) {
ctx.rect(x, y, w, h)
} else {
let {
borderTopLeftRadius: tl = r || 0,
borderTopRightRadius: tr = r || 0,
borderBottomRightRadius: br = r || 0,
borderBottomLeftRadius: bl = r || 0
} = r || {r,r,r,r}
ctx.beginPath()
// 右下角
ctx.arc(x + w - br, y + h - br, br, 0, Math.PI * 0.5)
ctx.lineTo(x + bl, y + h)
// 左下角
ctx.arc(x + bl, y + h - bl, bl, Math.PI * 0.5, Math.PI)
ctx.lineTo(x, y + tl)
// 左上角
ctx.arc(x + tl, y + tl, tl, Math.PI, Math.PI * 1.5)
ctx.lineTo(x + w - tr, y)
// 右上角
ctx.arc(x + w - tr, y + tr, tr, Math.PI * 1.5, Math.PI * 2)
ctx.closePath()
}
if (stroke) ctx.stroke()
if (fill) ctx.fill()
}
measureText(text, fontSize) {
const ctx = this.ctx
// #ifndef APP-PLUS
return ctx.measureText(text).width
// #endif
// #ifdef APP-PLUS
// app measureText为0需要累加计算0
return text.split("").reduce((widthScaleSum, char) => {
let code = char.charCodeAt(0);
let widthScale = CHAR_WIDTH_SCALE_MAP[code - 0x20] || 1;
return widthScaleSum + widthScale;
}, 0) * fontSize;
// #endif
}
setFont({fontFamily, fontSize, fontWeight, textStyle}) {
let ctx = this.ctx
// 设置属性
// #ifndef MP-TOUTIAO
fontWeight = fontWeight === 'bold' ? 'bold' : 'normal'
textStyle = textStyle === 'italic' ? 'italic' : 'normal'
// #endif
// #ifdef MP-TOUTIAO
fontWeight = fontWeight === 'bold' ? 'bold' : ''
textStyle = textStyle === 'italic' ? 'italic' : ''
// #endif
fontSize = toPx(fontSize)
ctx.font = `${textStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;
}
dradwBackground(bd, w, h) {
const ctx = this.ctx
if (!bd) {
// #ifndef MP-TOUTIAO
ctx.setFillStyle('transparent')
// #endif
// #ifdef MP-TOUTIAO
ctx.setFillStyle('rgba(0,0,0,0)')
// #endif
// } else if(bd.startsWith('#') || bd.startsWith('rgba') || bd.toLowerCase() === 'transparent' ) {
// ctx.setFillStyle(bd)
} else if(GD.isGradient(bd)) {
GD.doGradient(bd, w, h, ctx);
} else {
ctx.setFillStyle(bd)
}
}
drawView(box, style) {
const ctx = this.ctx
const {
left: x,
top: y,
width: w,
height: h
} = box
let {
boxShadow = [],
borderRadius = 0,
borderWidth = 0,
borderStyle,
borderColor,
color = '#000000',
backgroundColor: bg,
rotate,
shadow
} = style
ctx.save()
// 旋转
if (rotate) {
ctx.translate(x + w / 2, y + h / 2)
ctx.rotate(rotate * Math.PI / 180)
ctx.translate(-x - w / 2, -y - h / 2)
}
// 投影
if (boxShadow.length) {
const [x, y, b, c] = boxShadow
ctx.setShadow(x, y, b, c)
}
// 描边
if (borderStyle) {
ctx.lineWidth = borderWidth
if (borderStyle == 'dashed') {
ctx.setLineDash([Math.ceil(borderWidth * 4 / 3), Math.ceil(borderWidth * 4 / 3)])
} else if (borderStyle == 'dotted') {
ctx.setLineDash([borderWidth, borderWidth])
}
ctx.setStrokeStyle(borderColor)
}
// 背景
this.dradwBackground(bg, w, h)
this.roundRect(x, y, w, h, borderRadius, true, borderColor ? true : false)
ctx.restore()
}
async drawImage(img, box, style) {
await new Promise(async (resolve, reject) => {
const ctx = this.ctx
const canvas = this.canvas
const {
borderRadius = 0,
mode,
backgroundColor: bg
} = style
const {
left: x,
top: y,
width: w,
height: h
} = box
ctx.save()
// 背景
this.dradwBackground(bg || 'white', w, h)
this.roundRect(x, y, w, h, borderRadius, true, false)
ctx.clip()
const _modeImage = (img) => {
// 获得缩放到图片大小级别的裁减框
let rWidth = img.width
let rHeight = img.height
let startX = 0
let startY = 0
// 绘画区域比例
const cp = w / h
// 原图比例
const op = rWidth / rHeight
if (cp >= op) {
rHeight = rWidth / cp;
// startY = Math.round((h - rHeight) / 2)
} else {
rWidth = rHeight * cp;
startX = Math.round(((img.width || w) - rWidth) / 2)
}
if (mode === 'scaleToFill' || !img.width) {
ctx.drawImage(img.path, x, y, w, h);
} else {
ctx.drawImage(img.path, startX, startY, rWidth, rHeight, x, y, w, h)
}
}
const _drawImage = (img) => {
if (this.use2dCanvas) {
const Image = canvas.createImage()
Image.onload = () => {
_modeImage(img)
ctx.restore()
setTimeout(()=> resolve(), 100)
}
Image.onerror = () => {
reject(new Error(`createImage fail: ${img}`))
}
Image.src = img
} else {
_modeImage(img)
ctx.restore()
setTimeout(()=> resolve(), 100)
}
}
// #ifdef MP
if(/^data:image\/(\w+);base64/.test(img)) {
img = await base64src(img)
}
// #endif
uni.getImageInfo({
src: img,
success: (image) => {
image.path = /^(http|\/\/|\/|wxfile|data:image\/(\w+);base64|file|bdfile|ttfile)/.test(image.path) ? image.path : `/${image.path}`;
console.log('image:::', image)
_drawImage(image)
},
fail(err) {
if(/^\.|^\/(?=[^\/])/.test(img) || /^data:image\/(\w+);base64/.test(img)) {
_drawImage({path: img})
} else {
console.error(`getImageInfo:fail ${img} failed ${JSON.stringify(err)}`);
reject(new Error(`getImageInfo:fail ${img} ${JSON.stringify(err)}`));
}
}
})
})
}
drawLine(x, y, x2, y2, color, type) {
const ctx = this.ctx
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x2, y2);
ctx.closePath();
ctx.setStrokeStyle(color);
ctx.stroke();
}
// eslint-disable-next-line complexity
drawText(text, box, style) {
const ctx = this.ctx
let {
left: x,
top: y,
width: w,
height: h
} = box
let {
color = '#000000',
lineHeight = '1.4em',
fontSize = 14,
fontWeight,
fontFamily = 'sans-serif',
textStyle,
textAlign = 'left',
verticalAlign = 'top',
backgroundColor: bg,
maxLines,
textDecoration
} = style
if (typeof lineHeight === 'string') { // 1.4em
lineHeight = Math.ceil(parseFloat(lineHeight.replace('em')) * fontSize)
}
// || (lineHeight > h)
if (!text) return
ctx.save()
ctx.setTextBaseline(verticalAlign)
// 设置属性
this.setFont({fontFamily, fontSize, fontWeight, textStyle})
ctx.setTextAlign(textAlign)
// 背景色
this.dradwBackground(bg, w, h)
this.roundRect(x, y, w, h, 0)
// 文字颜色
ctx.setFillStyle(color)
// 水平布局
switch (textAlign) {
case 'left':
break
case 'center':
x += 0.5 * w
break
case 'right':
x += w
break
default:
break
}
const textWidth = this.measureText(text, fontSize)
const actualHeight = Math.ceil(textWidth / w) * lineHeight
let paddingTop = Math.ceil((h - actualHeight) / 2)
if (paddingTop < 0) paddingTop = 0
// 垂直布局
switch (verticalAlign) {
case 'top':
break
case 'middle':
y += fontSize / 2 // paddingTop
break
case 'bottom':
y += fontSize // 2 * paddingTop
break
default:
break
}
// 绘线
const _drawLine = (x, y, textWidth) => {
const { system } = uni.getSystemInfoSync()
if(/win|mac/.test(system)){
y += (fontSize / 3)
}
let to = x
switch (textAlign) {
case 'left':
x = x
to+= textWidth
break
case 'center':
x = x - textWidth / 2
to = x + textWidth
break
case 'right':
to = x
x = x - textWidth
break
default:
break
}
if(textDecoration) {
ctx.setLineWidth(fontSize / 13);
ctx.beginPath();
if (/\bunderline\b/.test(textDecoration)) {
y -= inlinePaddingTop * 0.8
ctx.moveTo(x, y);
ctx.lineTo(to, y);
}
if (/\boverline\b/.test(textDecoration)) {
y += inlinePaddingTop
ctx.moveTo(x, y - lineHeight);
ctx.lineTo(to, y - lineHeight);
}
if (/\bline-through\b/.test(textDecoration)) {
ctx.moveTo(x , y - lineHeight / 2.5 );
ctx.lineTo(to, y - lineHeight /2.5 );
}
ctx.closePath();
ctx.setStrokeStyle(color);
ctx.stroke();
}
}
const inlinePaddingTop = Math.ceil((lineHeight - fontSize) / 2)
// 不超过一行
if (textWidth <= w && !text.includes('\n')) {
ctx.fillText(text, x, y + inlinePaddingTop)
_drawLine(x, y, textWidth)
return
}
// 多行文本
const chars = text.split('')
const _y = y
// 逐行绘制
let line = ''
let lineIndex = 0
let textArray = []
for(let index = 0 ; index <= chars.length; index++){
let ch = chars[index]
// for (let ch of chars) {
const isLine = ch === '\n'
const isRight = index === chars.length - 1
ch = isLine ? '' : ch
let testLine = line + ch
const testWidth = this.measureText(testLine, fontSize) //ctx.measureText(testLine).width
// 绘制行数大于最大行数,则直接跳出循环
if (lineIndex >= maxLines) {
break;
}
if (testWidth > w || isLine || isRight) {
lineIndex++
line = isRight ? testLine : line
if(lineIndex === maxLines) {
// ctx.measureText(`${line}...`).width
while( this.measureText(`${line}...`, fontSize) > w) {
if (line.length <= 1) {
// 如果只有一个字符时,直接跳出循环
break;
}
line = line.substring(0, line.length - 1);
}
line += '...'
}
textArray.push(line)
ctx.fillText(line, x, y + inlinePaddingTop)
y += lineHeight
_drawLine(x, y, testWidth)
line = ch
if ((y + lineHeight) > (_y + h)) break
// if ((y + lineHeight) > h) break
} else {
line = testLine
}
}
// 避免溢出
// if ((y + lineHeight) <= (_y + h)) {
// ctx.fillText(line, x, y + inlinePaddingTop)
// }
ctx.restore()
}
findNode(element, parent = {}, index = 0, siblings = [], source) {
let computedStyle = Object.assign({}, this.getComputedStyle(element, parent, index));
let node = {
id: id++,
parent,
computedStyle,
attributes: Object.assign({}, this.getAttributes(element)),
name: element?.type || 'view',
}
if(JSON.stringify(parent) === '{}') {
const {left = 0, top = 0, width = 0, height = 0 } = computedStyle
node.layoutBox = {left, top, width, height }
} else {
node.layoutBox = Object.assign({left: 0, top: 0}, this.getLayoutBox(node, parent, index, siblings, source))
}
if (element?.views) {
let childrens = []
node.children = []
element.views.forEach((v, i) => {
childrens.push(this.findNode(v, node, i, childrens, element))
})
node.children = childrens
}
return node
}
getComputedStyle(element, parent = {}, index = 0) {
const style = {}
if(parent.computedStyle) {
for (let value of Object.keys(parent.computedStyle)){
const item = parent.computedStyle[value]
if(['color', 'fontSize', 'lineHeight', 'verticalAlign', 'fontWeight'].includes(value)) {
style[value] = /px$/.test(item) ? toPx(item) : item
}
}
}
const node = element?.css ? element.css : element;
if(!node) return style
for (let value of Object.keys(node)) {
const item = node[value]
if (['boxShadow', 'shadow'].includes(value)) {
let shadows = item.split(' ').map(v => /^\d/.test(v) ? toPx(v) : v)
style.boxShadow = shadows
}
if (value == 'border') {
let border = item.split(' ').map(v => /^\d/.test(v) ? toPx(v) : v)
style.borderWidth = border[0]
style.borderStyle = border[1]
style.borderColor = border[2]
}
if (['background', 'backgroundColor'].includes(value)) {
style['backgroundColor'] = item
}
// 圆角
if (value.includes('adius')) {
if(value == 'radius') {
let radius = item?.split(' ').map((item) => /^\d/.test(item) && toPx(item, style['width']), []) ||[0];
if (radius.length == 1) {
style.borderRadius = radius[0]
} else {
let [tl, tr, br, bl] = radius
style.borderRadius = {
borderTopLeftRadius: tl,
borderTopRightRadius: tr || tl,
borderBottomRightRadius: br || tl,
borderBottomLeftRadius: tr
}
}
} else {
if(typeof style.borderRadius === 'object') {
style.borderRadius[value] = toPx(item, style['width'])
}else {
style.borderRadius = {
borderTopLeftRadius: style.borderRadius || 0,
borderTopRightRadius: style.borderRadius || 0,
borderBottomRightRadius: style.borderRadius || 0,
borderBottomLeftRadius: style.borderRadius ||0
}
style.borderRadius[value] = toPx(item, style['width'])
}
}
} else if(value == 'views') {
} else {
style[value] = /%|px|rpx$/.test(item) ? toPx(item) : item
}
}
return style
}
getLayoutBox(element, parent = {}, index = 0, siblings = [], source = {}) {
let box = {}
let {name, computedStyle, layoutBox, attributes} = element || {}
if(!name) return box
const isText = name === 'text'
const isParentText = parent.name ==='text';
const ctx = this.ctx
// 获取left
const getNodeLeft = () => {
if(typeof computedStyle.left === 'number') {
return computedStyle.left
}
// 如果是块元素
if(!isText) {
return parent.layoutBox?.left || 0
}
// 如果是第1个元素
const isLeft = index == 0
if(isLeft) {
// 如果父级是文本
if(isParentText) {
return (parent?.layoutBox?.left || 0) + (parent?.layoutBox?.width || 0)
} else {
return (parent?.layoutBox?.left || 0)
}
} else {
const leftNode = siblings[index - 1]
return leftNode.layoutBox.left + leftNode.layoutBox.width
}
}
// 获取宽度
const getNodeWidth = () => {
if(typeof computedStyle.width === 'number') {
return computedStyle.width
}
if(!isText) {
return parent?.layoutBox?.width
}
if(isText) {
let {
fontSize = 14,
lineHeight = '1.4em',
fontWeight,
fontFamily = 'sans-serif',
textStyle
} = computedStyle || {}
this.setFont({fontFamily, fontSize, fontWeight, textStyle})
let width = this.measureText(attributes.text, fontSize)
if(!isParentText) {
if(width < (parent?.layoutBox?.width || 0)) {
return width
} else {
return parent?.layoutBox?.width || 0
}
} else {
const res = this.getParent(parent, 'view')
const maxWidth = (res.layoutBox.width + res.layoutBox.left) - box.left
return maxWidth > width ? width : maxWidth
}
}
}
// 获取高度
const getNodeHeight = () => {
if(computedStyle.height) {
return computedStyle.height
}
if(!isText) {
return 0
}
// 如果父级有高度
if(parent.layoutBox.height == parent.computedStyle.height && parent.computedStyle.height != 0) {
return parent.layoutBox.height
}
// 如果父级没有高度
if(!parent.computedStyle.height) {
let {
fontSize = 14,
lineHeight = '1.4em',
} = computedStyle || {}
if (typeof lineHeight === 'string') { // 1.4em
lineHeight = Math.ceil(parseFloat(lineHeight.replace('em')) * fontSize)
}
parent.layoutBox.height = parent.layoutBox.height > lineHeight ? parent.layoutBox.height : lineHeight
return lineHeight
}
// const res = this.getParent(parent, 'view')
// const maxHeight = res.layoutBox.height + res.layoutBox.top - box.top
// return maxHeight
}
// 获取top
const getNodeTop = () => {
const { verticalAlign } = computedStyle
if(computedStyle.top) {
return computedStyle.top
}
if(verticalAlign === 'bottom') {
return parent?.layoutBox?.top + (parent?.layoutBox?.height - box.height || 0)
}
if(verticalAlign === 'middle') {
return parent?.layoutBox?.top + (parent?.layoutBox?.height - box.height || 0) / 2
}
return parent?.layoutBox?.top || 0
}
box.left = getNodeLeft()
ctx.save()
box.width = getNodeWidth()
box.height = getNodeHeight()
// 获取top
box.top = getNodeTop()
ctx.restore()
// for (let value of Object.keys(node)) {
// const item = node[value]
// if(['left', 'right', 'top', 'bottom', 'width', 'height'].includes(value)) {
// box[value] = toPx(item) + (parent?.layoutBox[value] || 0)
// }
// }
return box
}
getParent(element, name) {
if(element.name === name) {
return element
} else if(element.parent){
return this.getParent(element.parent, name)
}
}
getAttributes(element) {
let arr = { }
if(element?.url) {
arr.src = element.url
}
if(element?.text) {
arr.text = element.text
}
return arr
}
async drawBoard(element) {
const node = this.findNode(element)
console.log('node', node)
return this.drawNode(node)
}
async drawNode(element) {
const {
layoutBox,
computedStyle,
name
} = element
const {
src,
text
} = element.attributes
if (name === 'view') {
this.drawView(layoutBox, computedStyle)
} else if (name === 'image') {
await this.drawImage(src, layoutBox, computedStyle)
} else if (name === 'text') {
this.drawText(text, layoutBox, computedStyle)
}
if (!element.children) return
const childs = Object.values ? Object.values(element.children) : Object.keys(element.children).map((key) => element.children[key]);
for (const child of childs) {
await this.drawNode(child)
}
}
}

View File

@@ -0,0 +1,109 @@
/* eslint-disable */
export const GD = {
isGradient(bg) {
if (bg && (bg.startsWith('linear') || bg.startsWith('radial'))) {
return true;
}
return false;
},
doGradient(bg, width, height, ctx) {
if (bg.startsWith('linear')) {
linearEffect(width, height, bg, ctx);
} else if (bg.startsWith('radial')) {
radialEffect(width, height, bg, ctx);
}
},
}
function analizeGrad(string) {
const colorPercents = string.substring(0, string.length - 1).split("%,");
const colors = [];
const percents = [];
for (let colorPercent of colorPercents) {
colors.push(colorPercent.substring(0, colorPercent.lastIndexOf(" ")).trim());
percents.push(colorPercent.substring(colorPercent.lastIndexOf(" "), colorPercent.length) / 100);
}
return {
colors: colors,
percents: percents
};
}
function radialEffect(width, height, bg, ctx) {
const colorPer = analizeGrad(bg.match(/radial-gradient\((.+)\)/)[1]);
const grd = ctx.createCircularGradient(0, 0, width < height ? height / 2 : width / 2);
for (let i = 0; i < colorPer.colors.length; i++) {
grd.addColorStop(colorPer.percents[i], colorPer.colors[i]);
}
ctx.setFillStyle(grd);
//ctx.fillRect(-(width / 2), -(height / 2), width, height);
}
function analizeLinear(bg, width, height) {
const direction = bg.match(/([-]?\d{1,3})deg/);
const dir = direction && direction[1] ? parseFloat(direction[1]) : 0;
let coordinate;
switch (dir) {
case 0:
coordinate = [0, -height / 2, 0, height / 2];
break;
case 90:
coordinate = [width / 2, 0, -width / 2, 0];
break;
case -90:
coordinate = [-width / 2, 0, width / 2, 0];
break;
case 180:
coordinate = [0, height / 2, 0, -height / 2];
break;
case -180:
coordinate = [0, -height / 2, 0, height / 2];
break;
default:
let x1 = 0;
let y1 = 0;
let x2 = 0;
let y2 = 0;
if (direction[1] > 0 && direction[1] < 90) {
x1 = (width / 2) - ((width / 2) * Math.tan((90 - direction[1]) * Math.PI * 2 / 360) - height / 2) * Math.sin(2 * (
90 - direction[1]) * Math.PI * 2 / 360) / 2;
y2 = Math.tan((90 - direction[1]) * Math.PI * 2 / 360) * x1;
x2 = -x1;
y1 = -y2;
} else if (direction[1] > -180 && direction[1] < -90) {
x1 = -(width / 2) + ((width / 2) * Math.tan((90 - direction[1]) * Math.PI * 2 / 360) - height / 2) * Math.sin(2 * (
90 - direction[1]) * Math.PI * 2 / 360) / 2;
y2 = Math.tan((90 - direction[1]) * Math.PI * 2 / 360) * x1;
x2 = -x1;
y1 = -y2;
} else if (direction[1] > 90 && direction[1] < 180) {
x1 = (width / 2) + (-(width / 2) * Math.tan((90 - direction[1]) * Math.PI * 2 / 360) - height / 2) * Math.sin(2 * (
90 - direction[1]) * Math.PI * 2 / 360) / 2;
y2 = Math.tan((90 - direction[1]) * Math.PI * 2 / 360) * x1;
x2 = -x1;
y1 = -y2;
} else {
x1 = -(width / 2) - (-(width / 2) * Math.tan((90 - direction[1]) * Math.PI * 2 / 360) - height / 2) * Math.sin(2 *
(90 - direction[1]) * Math.PI * 2 / 360) / 2;
y2 = Math.tan((90 - direction[1]) * Math.PI * 2 / 360) * x1;
x2 = -x1;
y1 = -y2;
}
coordinate = [x1, y1, x2, y2];
break;
}
return coordinate;
}
function linearEffect(width, height, bg, ctx) {
const param = analizeLinear(bg, width, height);
const grd = ctx.createLinearGradient(param[0], param[1], param[2], param[3]);
const content = bg.match(/linear-gradient\((.+)\)/)[1];
const colorPer = analizeGrad(content.substring(content.indexOf(',') + 1));
for (let i = 0; i < colorPer.colors.length; i++) {
grd.addColorStop(colorPer.percents[i], colorPer.colors[i]);
}
ctx.setFillStyle(grd);
//ctx.fillRect(-(width / 2), -(height / 2), width, height);
}

View File

@@ -0,0 +1,180 @@
<template>
<canvas :type="type" id="l-painter" :style="style" :canvas-id="canvasId"></canvas>
</template>
<script>
import { toPx, dataURItoBlob, blobToDataURL} from './utils';
import { Draw } from './draw';
import { adaptor } from './canvas'
export default {
name: 'l-painter',
props: {
board: Object,
fileType: {
type: String,
default: 'jpg'
},
pixelRatio: Number,
customStyle: String,
isRenderImage: Boolean,
isH5RenderBlob: Boolean,
type: {
type: String,
default: '',
},
},
data() {
// #ifndef MP-WEIXIN
const canvasId = `l-painter_${JSON.stringify(Math.random()).split('.')[1]}`
// #endif
// #ifdef MP-WEIXIN
const canvasId = `l-painter`
// #endif
return {
canvasId,
use2dCanvas: false ,// 微信 2.9.2 后可用canvas 2d 接口,
draw: null,
ctx: null
};
},
watch: {
board: {
handler(val) {
if (JSON.stringify(val) === '{}') return;
this.render();
},
immediate: true,
deep: true
}
},
computed: {
style() {
return `width:${this.boardWidth}px; height: ${this.boardHeight}px; ${this.customStyle}`;
},
dpr() {
return this.pixelRatio || wx.getSystemInfoSync().pixelRatio;
},
boardWidth() {
const { width = 200 } = this.board || {};
return toPx(width);
},
boardHeight() {
const { height = 200 } = this.board || {};
return toPx(height);
}
},
created() {},
mounted() {},
methods: {
render(args = {}) {
this.getContext().then(async (ctx) => {
if(!this.ctx) {
this.ctx = ctx
}
const { use2dCanvas, boardWidth, boardHeight, board, canvas, isH5RenderBlob } = this;
const {width, height} = args
if (use2dCanvas && !canvas) {
return Promise.reject(new Error('render: fail canvas has not been created'));
}
this.boundary = {
top: 0,
left: 0,
width: boardWidth || width,
height: boardHeight || height,
}
this.ctx.clearRect(0, 0, boardWidth, boardHeight);
if(!this.draw) {
this.draw = new Draw(this.ctx, canvas, use2dCanvas);
}
await this.draw.drawBoard(JSON.stringify(args) != '{}' ? args : board);
if (!use2dCanvas) {
const isDraw = await this.canvasDraw(this.ctx, this.isRenderImage);
if(isDraw && this.isRenderImage) {
this.canvasToTempFilePath()
.then(res => {
if(/^data:image\/(\w+);base64/.test(res.tempFilePath) && isH5RenderBlob) {
const img = URL.createObjectURL(dataURItoBlob(res.tempFilePath))
this.$emit('success', img)
} else {
this.$emit('success', res.tempFilePath)
}
})
.catch(err => {
this.$emit('fail', err)
new Error(JSON.stringify(err))
})
}
}
return Promise.resolve('ok');
});
},
canvasDraw(ctx, reserve) {
return new Promise(resolve => {
ctx.draw(reserve, () => {
resolve(true);
});
});
},
getContext() {
const { type, dpr, boardWidth, boardHeight } = this;
// #ifndef MP-WEIXIN
const ctx = uni.createCanvasContext(this.canvasId, this);
return Promise.resolve(ctx);
// #endif
if (type === '') {
const ctx = uni.createCanvasContext(this.canvasId, this);
return Promise.resolve(ctx);
}
return new Promise(resolve => {
uni.createSelectorQuery()
.in(this)
.select('#l-painter')
.node()
.exec(res => {
const canvas = res[0].node;
const ctx = canvas.getContext(type);
if (!this.inited) {
this.inited = true;
canvas.width = boardWidth * dpr;
canvas.height = boardHeight * dpr;
this.use2dCanvas = true;
this.canvas = canvas
ctx.scale(dpr, dpr);
}
resolve(adaptor(ctx));
});
});
},
canvasToTempFilePath(args = {}) {
const {use2dCanvas, canvasId} = this
return new Promise((resolve, reject) => {
const { top = 0, left = 0, width, height } = this.boundary
const copyArgs = {
x: left,
y: top,
width,
height,
destWidth: width * this.dpr,
destHeight: height * this.dpr,
canvasId,
fileType: args.fileType || this.fileType || 'png',
quality: args.quality || 1,
success: resolve,
fail: reject
}
if (use2dCanvas) {
delete copyArgs.canvasId
copyArgs.canvas = this.canvas
}
uni.canvasToTempFilePath(copyArgs, this)
})
}
}
};
</script>
<style></style>

View File

@@ -0,0 +1,106 @@
const screen = uni.getSystemInfoSync().windowWidth / 750
export function toPx(value, baseSize) {
// 如果是数字
if (typeof value === 'number') {
return value
}
// 如果是字符串数字
if (isNumber(value)) {
return value * 1
}
// 如果有单位
if (typeof value === 'string') {
const reg = /^-?[0-9]+([.]{1}[0-9]+){0,1}(rpx|px|%)$/g
const results = reg.exec(value);
if (!value || !results) {
return 0;
}
const unit = results[2];
value = parseFloat(value);
let res = 0;
if (unit === 'rpx') {
res = Math.round(value * (screen || 0.5) * 1);
} else if (unit === 'px') {
res = Math.round(value * 1);
} else if (unit === '%') {
res = Math.round(value * toPx(baseSize) / 100);
}
return res;
}
}
export function isNumber(value) {
return /^-?\d+(\.\d+)?$/.test(value);
}
/** 从 0x20 开始到 0x80 的字符宽度数据 */
export const CHAR_WIDTH_SCALE_MAP = [0.296, 0.313, 0.436, 0.638, 0.586, 0.89, 0.87, 0.256, 0.334, 0.334, 0.455, 0.742,
0.241, 0.433, 0.241, 0.427, 0.586, 0.586, 0.586, 0.586, 0.586, 0.586, 0.586, 0.586, 0.586, 0.586, 0.241, 0.241, 0.742,
0.742, 0.742, 0.483, 1.031, 0.704, 0.627, 0.669, 0.762, 0.55, 0.531, 0.744, 0.773, 0.294, 0.396, 0.635, 0.513, 0.977,
0.813, 0.815, 0.612, 0.815, 0.653, 0.577, 0.573, 0.747, 0.676, 1.018, 0.645, 0.604, 0.62, 0.334, 0.416, 0.334, 0.742,
0.448, 0.295, 0.553, 0.639, 0.501, 0.64, 0.567, 0.347, 0.64, 0.616, 0.266, 0.267, 0.544, 0.266, 0.937, 0.616, 0.636,
0.639, 0.64, 0.382, 0.463, 0.373, 0.616, 0.525, 0.79, 0.507, 0.529, 0.492, 0.334, 0.269, 0.334, 0.742, 0.296
];
/**
* @param {Object} base64data
*/
export function base64src(base64data) {
return new Promise((resolve, reject) => {
const fs = uni.getFileSystemManager()
//自定义文件名
const [, format, bodyData] = /data:image\/(\w+);base64,(.*)/.exec(base64data) || [];
if (!format) {
reject(new Error('ERROR_BASE64SRC_PARSE'))
}
const time = new Date().getTime();
// #ifdef MP-TOUTIAO
const filePath = `${tt.env.USER_DATA_PATH}/${time}.${format}`
// #endif
// #ifdef MP-WEIXIN
const filePath = `${wx.env.USER_DATA_PATH}/${time}.${format}`
// #endif
// #ifdef MP-BAIDU
const filePath = `${bd.env.USER_DATA_PATH}/${time}.${format}`
// #endif
// #ifdef MP-ALIPAY
const filePath = `${my.env.USER_DATA_PATH}/${time}.${format}`
// #endif
// #ifdef MP-QQ
const filePath = `${qq.env.USER_DATA_PATH}/${time}.${format}`
// #endif
// #ifdef MP-360
const filePath = `${qh.env.USER_DATA_PATH}/${time}.${format}`
// #endif
const buffer = uni.base64ToArrayBuffer(bodyData)
fs.writeFile({
filePath,
data: buffer,
encoding: 'binary',
success() {
resolve(filePath)
},
fail(err) {
console.error('获取base64图片失败', JSON.stringify(err))
reject(err)
}
})
})
}
/**
* base64 to blob二进制
*/
export function dataURItoBlob(dataURI) {
let mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]; // mime类型
let byteString = atob(dataURI.split(',')[1]); //base64 解码
let arrayBuffer = new ArrayBuffer(byteString.length); //创建缓冲数组
let intArray = new Uint8Array(arrayBuffer); //创建视图
for (let i = 0; i < byteString.length; i++) {
intArray[i] = byteString.charCodeAt(i);
}
return new Blob([intArray], {
type: mimeString
});
}