初始化仓库

This commit is contained in:
wangxiaowei
2026-04-14 16:54:04 +08:00
commit 967c25b397
553 changed files with 106514 additions and 0 deletions

View File

@ -0,0 +1,54 @@
## 1.8.5.52021-08-17
- chore: 更新文档,删除 replace
- fix: 修复 text 值为 number时报错
## 1.8.5.42021-08-16
- fix: 字节小程序兼容
## 1.8.5.32021-08-15
- fix: 修复线性渐变与css现实效果不一致的问题
- chore: 更新文档
## 1.8.5.22021-08-13
- chore: 增加`background-image``background-repeat` 能力,主要用于背景纹理的绘制,并不是代替`image`。例如:大面积的重复平铺的水印
- 注意这个功能H5暂时无法使用因为[官方的API有BUG](https://ask.dcloud.net.cn/question/128793),待官方修复!!!
## 1.8.5.12021-08-10
- fix: 修复因`margin`报错问题
## 1.8.52021-08-09
- chore: 增加margin支持`auto`,以达到居中效果
## 1.8.42021-08-06
- chore: 增加判断缓存文件条件
- fix: 修复css 多余空格报错问题
## 1.8.32021-08-04
- tips: 1.6.x 以下的版本升级到1.8.x后要为每个元素都加上定位position: 'absolute'
- fix: 修复只有一个view子元素时不计算高度的问题
## 1.8.22021-08-03
- fix: 修复 path-type 为 `url` 无效问题
- fix: 修复 qrcode `text` 为空时报错问题
- fix: 修复 image `src` 动态设置时不生效问题
- feat: 增加 css 属性 `min-width` `max-width`
## 1.8.12021-08-02
- fix: 修复无法加载本地图片
## 1.8.02021-08-02
- chore 文档更新
- 使用旧版的同学不要升级!
## 1.8.0-beta2021-07-30
- ## 全新布局方式 不兼容旧版!
- chore: 布局方式变更
- tips: 微信canvas 2d 不支持真机调试
## 1.6.62021-07-09
- chore: 统一命名规范,无须主动引入组件
## 1.6.52021-06-08
- chore: 去掉console
## 1.6.42021-06-07
- fix: 修复 数字 为纯字符串时不转换的BUG
## 1.6.32021-06-06
- fix: 修复 PC 端放大的BUG
## 1.6.22021-05-31
- fix: 修复 报`adaptor is not a function`错误
- fix: 修复 text 多行高度
- fix: 优化 默认文字的基准线
- feat: `@progress`事件,监听绘制进度
## 1.6.12021-02-28
- 删除多余节点
## 1.6.02021-02-26
- 调整为uni_modules目录规范
- 修复transform的rotate不能为负数问题
- 新增:`pathType` 指定生成图片返回的路径类型,可选值有 `base64``url`

View File

@ -0,0 +1,141 @@
import {base64ToPath} from '../l-painter/utils.js'
const styles = (v = '') => v.split(';').filter(v => v && !/^[\n\s]+$/.test(v)).map(v => {
const item = v.split(':');
return {
[item[0]
.replace(/-([a-z])/g, function() {
return arguments[1].toUpperCase()
})
.replace(/\s+/g, '')
]: item?. [1]?.replace(/^\s+/, '')?.replace(/\s+$/, '') || ''
}
})
export function parent(parent) {
return {
provide() {
return {
[parent]: this
}
},
data() {
return {
el: {
css: {},
views: []
},
}
},
watch: {
css: {
handler(v) {
if (this.canvasId) {
this.el.css = typeof v == 'object' ? v : v && Object.assign(...styles(v)) || {}
this.canvasWidth = this.el.css?.width || this.canvasWidth
this.canvasHeight = this.el.css?.height || this.canvasHeight
}
},
immediate: true
}
}
}
}
export function children(parent, options = {}) {
const indexKey = options.indexKey || 'index'
return {
inject: {
[parent]: {
default: null
}
},
watch: {
el: {
handler(v, o) {
if (JSON.stringify(v) != JSON.stringify(o))
this.bindRelation()
},
deep: true,
immediate: true
},
src: {
handler(v, o) {
if (v != o)
this.bindRelation()
},
immediate: true
},
text: {
handler(v, o) {
if (v != o) this.bindRelation()
},
immediate: true
},
css: {
handler(v, o) {
if (v != o)
this.el.css = typeof v == 'object' ? v : v && Object.assign(...styles(v)) || {}
},
immediate: true
},
replace: {
handler(v, o) {
if (JSON.stringify(v) != JSON.stringify(o))
this.bindRelation()
},
deep: true,
immediate: true
},
},
created() {
Object.defineProperty(this, 'parent', {
get: () => {
return this[parent]
},
})
Object.defineProperty(this, 'index', {
get: () => {
this.bindRelation();
return this.parent?.el.views?.indexOf(this.el)
},
});
this.el.type = this.type
},
beforeDestroy() {
if (this.parent) {
this.parent.el.views = this.parent.el.views.filter(
(item) => item._uid !== this._uid
);
}
},
methods: {
bindRelation() {
if (!this.el._uid) {
this.el._uid = this._uid
}
if (['text', 'qrcode'].includes(this.type)) {
this.el.text = this.$slots?.default?. [0]?.text || this.text?.replace(/\\n/g, '\n')
}
if (this.type == 'text' && this.replace) {
this.el.replace = this.replace
}
if (this.type == 'image') {
this.el.src = this.src
}
// || this.parent.el.views.indexOf(this.el) !== -1
if (!this.parent) {
return;
}
let views = this.parent.el.views || [];
if (views.indexOf(this.el) !== -1) {
this.parent.el.views = views.map(v => v._uid == this._uid ? this.el : v)
} else {
this.parent.el.views = [...views, this.el];
}
}
},
mounted() {
this.bindRelation()
},
}
}

View File

@ -0,0 +1,27 @@
<template>
<view></view>
</template>
<script>
import {parent, children} from '../common/relation';
export default {
name: 'lime-painter-image',
mixins:[children('painter')],
props: {
css: [String, Object],
src: String
},
data() {
return {
type: 'image',
el: {
css: {},
src: null
},
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,27 @@
<template>
<view></view>
</template>
<script>
import {parent, children} from '../common/relation';
export default {
name: 'lime-painter-qrcode',
mixins:[children('painter')],
props: {
css: [String, Object],
text: String
},
data() {
return {
type: 'qrcode',
el: {
css: {},
text: null
},
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,28 @@
<template>
<text style="opacity: 0;"><slot/></text>
</template>
<script>
import {parent, children} from '../common/relation';
export default {
name: 'lime-painter-text',
mixins:[children('painter')],
props: {
css: [String, Object],
text: [String, Number],
replace: Object,
},
data() {
return {
type: 'text',
el: {
css: {},
text: null
},
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,26 @@
<template>
<view><slot/></view>
</template>
<script>
import {parent, children} from '../common/relation';
export default {
name: 'lime-painter-view',
mixins:[children('painter'), parent('painter')],
props: {
css: [String, Object],
},
data() {
return {
type: 'view',
el: {
css: {},
views:[]
},
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,523 @@
<template>
<view v-if="canvasId && size" class="lime-painter" :style="size + customStyle">
<!-- #ifndef APP-NVUE -->
<canvas class="lime-painter__canvas" v-if="use2dCanvas" :id="canvasId" type="2d" :style="size"></canvas>
<canvas class="lime-painter__canvas" v-else :canvas-id="canvasId" :style="size" :id="canvasId" :width="boardWidth * dpr" :height="boardHeight * dpr"></canvas>
<!-- #endif -->
<!-- #ifdef APP-NVUE -->
<web-view
:style="size"
ref="webview"
class="lime-painter__canvas"
@pagefinish="onPageFinish"
@error="onError"
@onPostMessage="onMessage"
></web-view>
<!-- #endif -->
<slot/>
</view>
</template>
<script>
import { toPx, compareVersion, sleep, base64ToPath, pathToBase64, getImageInfo, isBase64 } from './utils';
import {parent} from '../common/relation'
// #ifndef APP-NVUE
import {Painter} from './painter'
// #endif
// #ifdef APP-NVUE
import painterScript from './nvue'
// #endif
export default {
name: 'lime-painter',
mixins:[parent('painter')],
props: {
board: Object,
pathType: {
type: String,
// default: 'url'
// 'base64'、'url'
},
fileType: {
type: String,
default: 'png'
},
quality: {
type: Number,
default: 1
},
css: [String, Object],
width: [Number, String],
height: [Number, String],
pixelRatio: Number,
customStyle: String,
isCanvasToTempFilePath: Boolean,
sleep: {
type: Number,
default: 1000 / 30
},
beforeDelay: {
type: Number,
default: 100
},
afterDelay: {
type: Number,
default: 100
},
// #ifdef MP-WEIXIN || MP-TOUTIAO||MP-ALIPAY
type: {
type: String,
default: '2d'
},
// #endif
},
data() {
return {
// #ifdef MP-WEIXIN || MP-TOUTIAO || MP-ALIPAY
use2dCanvas: true,
// #endif
// #ifndef MP-WEIXIN || MP-TOUTIAO || MP-ALIPAY
use2dCanvas: false,
// #endif
canvasHeight: 150,
canvasWidth: null,
isDrawIng: false,
isPC: false,
inited: false,
name: 'view',
progress: 0,
// #ifdef APP-NVUE
tempFilePath: [],
// #endif
};
},
computed: {
canvasId() {
return `l-painter${this._uid}`
},
size() {
if(this.boardWidth && this.boardHeight) {
return `width:${this.boardWidth}px; height: ${this.boardHeight}px;`;
}
},
dpr() {
return this.pixelRatio || uni.getSystemInfoSync().pixelRatio;
},
boardWidth() {
const {width = 0, canvasWidth = 0} = this
const {width: boardWidth = 0} = this.board?.css || this.board || {}
return Math.max(toPx(width || boardWidth), toPx(canvasWidth));
},
boardHeight() {
const {height= 0, canvasHeight =0} = this
const {height: boardHeight = 0} = this.board?.css || this.board || {}
return Math.max(toPx(height || boardHeight), toPx(canvasHeight));
}
},
watch: {
canvasWidth(v) {
if(this.el.css && !this.el.css?.width) {
this.el.css.width = v
}
},
size(v) {
// #ifdef MP-WEIXIN
if (this.use2dCanvas) {
this.inited = false;
}
// #endif
// #ifdef MP-ALIPAY
this.inited = false;
// #endif
},
},
// #ifdef MP-WEIXIN || MP-TOUTIAO || MP-ALIPAY
created() {
const { SDKVersion, version, platform, environment } = uni.getSystemInfoSync();
// #ifdef MP-WEIXIN
// ios wx7.0.20 createImage bug
this.isPC = /windows/i.test(platform)
this.use2dCanvas = this.type === '2d' && compareVersion(SDKVersion, '2.9.2') >= 0 && !((/ios/i.test(platform) && /7.0.20/.test(version)) || /wxwork/i.test(environment)) && !this.isPC;
// #endif
// #ifdef MP-TOUTIAO
this.use2dCanvas = this.type === '2d' && compareVersion(SDKVersion, '1.78.0') >= 0;
// #endif
// #ifdef MP-TOUTIAO
this.use2dCanvas = this.type === '2d' && compareVersion(SDKVersion, '2.8.0') >= 0;
// #endif
},
// #endif
mounted() {
// #ifdef APP-NVUE
this.webViewInit()
// #endif
this.$nextTick(() => {
setTimeout(() => {
if(this.board) {
this.$watch('board', this.watchRender, {deep: true,immediate: true});
} else if(this.el.views.length) {
this.$watch('el', this.watchRender, {deep: true,immediate: true});
}
},30)
})
},
methods: {
async watchRender(val) {
this.progress = 0
if (JSON.stringify(val) === '{}' || !val) return;
clearTimeout(this.rendertimer)
this.rendertimer = setTimeout(() => {
this.render(val);
}, this.beforeDelay)
},
async setFilePath(path, isEmit) {
let filePath = path
const {pathType} = this
if(pathType == 'base64' && !isBase64(path)) {
filePath = await pathToBase64(path)
} else if(pathType == 'url' && isBase64(path)) {
filePath = await base64ToPath(path)
}
if(isEmit) {
this.$emit('success', filePath);
}
return filePath
},
// #ifdef APP-NVUE
onError(e) {
console.log('onError', e)
},
// onPagestart() {
// console.log('onPagestart')
// },
// onPageFinish() {
// this.$refs.webview.evalJS(`init()`)
// },
// onReceivedTitle() {
// console.log('onReceivedTitle')
// },
onMessage(e) {
const res = e?.detail?.data[0] || null;
if (res?.event) {
if(res.event == 'inited') {
this.inited = true
}
if(res.event == 'layoutChange') {
const data = JSON.parse(res.data)
this.canvasWidth = data.width;
this.canvasHeight = data.height;
}
if(res.event == 'progressChange') {
this.progress = res.data * 1
}
if(res.event == 'file') {
this.tempFilePath.push(res.data)
if(this.tempFilePath.length > 7) {
this.tempFilePath.shift()
}
return
}
if(res.event == 'success') {
if(res.data) {
this.tempFilePath.push(res.data)
if(this.tempFilePath.length > 8) {
this.tempFilePath.shift()
}
if(this.isCanvasToTempFilePath) {
this.setFilePath(this.tempFilePath.join(''), true)
}
} else {
this.$emit('fail')
}
return
}
this.$emit(res.event, JSON.parse(res.data));
} else if (res?.file) {
this.file = res.data;
} else {
console.error(res);
}
},
async webViewInit() {
await sleep(30)
// await this.getWebViewInited()
const webview = this.$refs.webview;
webview.evalJS(painterScript)
},
getWebViewInited() {
if(this.inited) return Promise.resolve(this.inited);
return new Promise((resolve) => {
this.$watch(
'inited',
async val => {if(val) {resolve(val)}},
{immediate: true}
);
})
},
getTempFilePath() {
if(this.tempFilePath.length == 8) return Promise.resolve(this.tempFilePath)
return new Promise((resolve) => {
this.$watch(
'tempFilePath',
async val => {if(val.length == 8) {resolve(val.join(''))}}
);
})
},
getWebViewDone() {
if(this.progress == 1) return Promise.resolve(this.progress);
return new Promise((resolve) => {
this.$watch(
'progress',
async val => {
if(val == 1) {
this.$emit('done')
resolve(val)
}},
{immediate: true}
);
})
},
async render(args) {
const newNode = await this.calcImage(args);
await this.getWebViewInited()
const webview = this.$refs.webview;
webview.evalJS(`source(${JSON.stringify(newNode)})`)
if(this.isCanvasToTempFilePath) {
await this.getWebViewDone()
await sleep(this.afterDelay)
const params = {fileType: this.fileType, quality: this.quality}
webview.evalJS(`save(${JSON.stringify(params)})`)
}
},
async calcImage(args) {
let node = JSON.parse(JSON.stringify(args))
const url = node.url || node.src
if(node.type === "image" && url && !isBase64(url)) {
const suffix = url.match(/\.(\w+)$/)[1]
const {width = 0, height = 0, path, naturalSrc} = await getImageInfo(url)
const src = await pathToBase64(path)
node.src = src.replace(/^data:application[\w\/]+;base64/,'data:image/'+suffix+';base64')
} else if(node.views?.length) {
for (let i = 0; i < node.views.length; i++) {
node.views[i] = await this.calcImage(node.views[i])
}
}
return node
},
async canvasToTempFilePath(args = {}){
this.tempFilePath = []
this.$refs.webview.evalJS(`save(${JSON.stringify(args)})`)
try{
let tempFilePath = await this.getTempFilePath()
tempFilePath = await this.setFilePath(tempFilePath)
args.success({errMsg: "canvasToTempFilePath:ok", tempFilePath})
}catch(e){
args.fail({error: e})
}
}
// #endif
// #ifndef APP-NVUE
getParentWeith() {
uni.createSelectorQuery()
.in(this)
.select(`.lime-painter`)
.boundingClientRect()
.exec(res => {
this.canvasWidth = Math.ceil(res[0].width)
this.canvasHeight = res[0].height
})
},
async update(args, single) {
this.painter = null;
// #ifdef MP-WEIXIN
if (this.use2dCanvas) {
this.ctx = null;
this.inited = false;
}
// #endif
// #ifdef MP-ALIPAY
this.inited = false;
// #endif
this.isDrawIng = false;
await new Promise(resolve => this.$nextTick(resolve));
await sleep(200);
return this.render(args, single);
},
async render(args = {}, single = false) {
if (this.isDrawIng) {
return this.update(args, single);
}
this.isDrawIng = true;
const isArg = JSON.stringify(args) != '{}';
const ctx = await this.getContext();
let { use2dCanvas, boardWidth, boardHeight, canvas, afterDelay } = this;
if (use2dCanvas && !canvas) {
return Promise.reject(new Error('render: fail canvas has not been created'));
}
this.boundary = {
top: 0,
left: 0,
width: boardWidth,
height: boardHeight
};
// if (!single) {
// ctx.clearRect(0, 0, boardWidth, boardHeight);
// }
if(!this.painter) {
this.painter = new Painter({context: ctx, canvas, width: boardWidth, height: boardHeight, pixelRatio: this.dpr}, this)
}
this.painter.listen('progressChange', (v) => {
this.$emit('progress', v)
})
const {width, height} = await this.painter.source(args)
this.canvasHeight = toPx(this.el.css?.height) || height
this.canvasWidth = toPx(this.el.css?.width) || width
this.boundary.height = this.canvasHeight
this.boundary.width = this.canvasWidth
await sleep(this.sleep);
await this.painter.render()
await new Promise(resolve => this.$nextTick(resolve));
if (!use2dCanvas && !single) {
await this.canvasDraw();
}
if (afterDelay && use2dCanvas) {
await sleep(afterDelay);
}
this.$emit('done');
if (this.isCanvasToTempFilePath && !single && this.isDrawIng) {
this.canvasToTempFilePath()
.then(async res => {
this.$emit('success', res.tempFilePath)
})
.catch(err => {
this.$emit('fail', new Error(JSON.stringify(err)));
});
}
this.isDrawIng = false;
return Promise.resolve({ ctx, draw: this.painter, node: this.node });
},
async custom(cb) {
const { ctx, draw } = await this.render({}, true);
ctx.save();
await cb(ctx, draw);
ctx.restore();
return Promise.resolve(true);
},
async single(args = {}) {
const res = await this.render(args, true);
return Promise.resolve(res);
},
canvasDraw(flag = false) {
return new Promise((resolve, reject) => this.ctx.draw(flag, () => setTimeout(() => resolve(), this.afterDelay)));
},
async getContext() {
if (this.ctx && this.inited) {
return Promise.resolve(this.ctx);
}
this.getParentWeith()
const { type, use2dCanvas, dpr, boardWidth, boardHeight } = this;
const _getContext = () => {
return new Promise(resolve => {
uni.createSelectorQuery()
.in(this)
.select(`#${this.canvasId}`)
.boundingClientRect()
.exec(res => {
if (res) {
const ctx = uni.createCanvasContext(this.canvasId, this);
if (!this.inited) {
this.inited = true;
this.use2dCanvas = false;
this.canvas = res;
}
if(this.isPC) {
ctx.scale(1/dpr, 1/dpr);
}
// #ifdef MP-ALIPAY
ctx.scale(dpr, dpr);
// #endif
this.ctx = ctx
resolve(this.ctx);
}
});
});
};
// #ifndef MP-WEIXIN
return _getContext();
// #endif
if (!use2dCanvas) {
return _getContext();
}
return new Promise(resolve => {
uni.createSelectorQuery()
.in(this)
.select(`#${this.canvasId}`)
.node()
.exec(res => {
let { node: canvas } = res[0];
if (!canvas) {
this.use2dCanvas = false;
resolve(this.getContext());
}
const ctx = canvas.getContext(type);
if (!this.inited) {
this.inited = true;
this.use2dCanvas = true;
this.canvas = canvas;
}
this.ctx = ctx
resolve(this.ctx);
});
});
},
canvasToTempFilePath(args = {}) {
const { use2dCanvas, canvasId, dpr, fileType, quality } = this;
return new Promise((resolve, reject) => {
let { top: y = 0, left: x = 0, width, height } = this.boundary || this;
let destWidth = width * dpr;
let destHeight = height * dpr;
// #ifdef MP-ALIPAY
width = destWidth;
height = destHeight;
// #endif
const success = async (res) => {
const tempFilePath = await this.setFilePath(res.tempFilePath)
resolve(Object.assign(res, {tempFilePath}))
}
const copyArgs = Object.assign({
x,
y,
width,
height,
destWidth,
destHeight,
canvasId,
fileType,
quality,
success,
fail: reject
}, args);
if (use2dCanvas) {
delete copyArgs.canvasId;
copyArgs.canvas = this.canvas;
}
uni.canvasToTempFilePath(copyArgs, this);
});
}
// #endif
}
};
</script>
<style>
.lime-painter, .lime-painter__canvas {
// #ifndef APP-NVUE
width: 100%;
// #endif
// #ifdef APP-NVUE
flex: 1;
// #endif
}
</style>

View File

@ -0,0 +1,103 @@
const painterContent = `
var cache = [];
var painter = null;
var canvas = null;
var context = null;
var timer = null;
var pixelRatio = 1;
console.log = function(...args) {
postMessage(args);
};
function stringify(key, value) {
if (typeof value === 'object' && value !== null) {
if (cache.indexOf(value) !== -1) {
return;
}
cache.push(value);
}
return value;
};
function emit(event, data) {
let dataStr = typeof data !== 'object' && data !== null ? data : JSON.stringify(data, stringify);
postMessage({
event,
data: dataStr
});
cache = [];
};
function postMessage(data) {
uni.postMessage({
data
});
};
function init() {
canvas = document.querySelector('#lime-painter');
context = canvas.getContext('2d');
pixelRatio = window.devicePixelRatio;
painter = new Painter({
id: 'lime-painter',
context,
canvas,
pixelRatio,
width: canvas.offsetWidth,
height: canvas.offsetHeight
});
emit('inited', true);
painter.listen('progressChange', (v) => {
emit('progressChange', v);
});
};
function save(args) {
delete args.success;
delete args.fail;
clearTimeout(timer);
timer = setTimeout(() => {
const path = painter.save(args);
if(typeof path == 'string') {
const index = Math.ceil(path.length / 8);
for (var i = 0; i < 8; i++) {
if(i == 7) {
emit('success', path.substr(i * index, index));
} else {
emit('file', path.substr(i * index, index));
}
};
} else {
emit('fail', '');
};
}, 30);
};
async function source(args) {
let res = await painter.source(args);
emit('layoutChange', res);
await painter.render();
};
`
export default `
document.write("<canvas id='lime-painter'>不支持cavnas</canvas>");
let meta = document.createElement('meta');
meta.name = 'viewport';
meta.content = 'width=device-width, initial-scale=1.0';
document.head.appendChild(meta);
let styleEl = document.createElement('style');
styleEl.setAttribute('type', 'text/css');
styleEl.textContent='html,body,#lime-painter{padding: 0; margin: 0; width:100%;height:100%}';
document.head.appendChild(styleEl);
var script = document.createElement("script");
script.language = "javascript";
script.src = "https://js.cdn.aliyun.dcloud.net.cn/dev/uni-app/uni.webview.1.5.2.js";
script.onload = function() {
var script = document.createElement("script");
script.language = "javascript";
script.src = "https://cdn.jsdelivr.net/gh/liangei/image@latest/lime-ui/lime-painter/painter.js";
script.onload = function() {init()};
document.head.appendChild(script);
};
document.head.appendChild(script);
var script = document.createElement("script");
script.language = "javascript";
script.text = "${painterContent}";
document.body.appendChild(script);
`

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,414 @@
const networkReg = /^(http|\/\/)/;
export const isBase64 = (path) => /^data:image\/(\w+);base64/.test(path);
export function sleep(delay) {
return new Promise(resolve => setTimeout(resolve, delay))
}
const isDev = ['devtools'].includes(uni.getSystemInfoSync().platform)
// 缓存图片
let cache = {}
export function isNumber(value) {
return /^-?\d+(\.\d+)?$/.test(value);
}
export function toPx(value, baseSize, isDecimal = false) {
// 如果是数字
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}(em|rpx|px|%)$/g
const results = reg.exec(value);
if (!value || !results) {
return 0;
}
const unit = results[3];
value = parseFloat(value);
let res = 0;
if (unit === 'rpx') {
res = uni.upx2px(value);
} else if (unit === 'px') {
res = value * 1;
} else if (unit === '%') {
res = value * toPx(baseSize) / 100;
} else if (unit === 'em') {
res =value * toPx(baseSize || 14);
}
return isDecimal ? res.toFixed(2) * 1 : Math.round(res);
}
return 0
}
// 计算版本
export function compareVersion(v1, v2) {
v1 = v1.split('.')
v2 = v2.split('.')
const len = Math.max(v1.length, v2.length)
while (v1.length < len) {
v1.push('0')
}
while (v2.length < len) {
v2.push('0')
}
for (let i = 0; i < len; i++) {
const num1 = parseInt(v1[i], 10)
const num2 = parseInt(v2[i], 10)
if (num1 > num2) {
return 1
} else if (num1 < num2) {
return -1
}
}
return 0
}
// #ifdef MP
export const prefix = () => {
// #ifdef MP-TOUTIAO
return tt
// #endif
// #ifdef MP-WEIXIN
return wx
// #endif
// #ifdef MP-BAIDU
return swan
// #endif
// #ifdef MP-ALIPAY
return my
// #endif
// #ifdef MP-QQ
return qq
// #endif
// #ifdef MP-360
return qh
// #endif
}
// #endif
const base64ToArrayBuffer = (data) => {
// #ifndef MP-WEIXIN || APP-PLUS
/**
* Base64Binary.decode(base64_string);
* Base64Binary.decodeArrayBuffer(base64_string);
*/
const Base64Binary = {
_keyStr : "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
/* will return a Uint8Array type */
decodeArrayBuffer(input) {
const bytes = (input.length/4) * 3;
const ab = new ArrayBuffer(bytes);
this.decode(input, ab);
return ab;
},
removePaddingChars(input) {
const lkey = this._keyStr.indexOf(input.charAt(input.length - 1));
if(lkey == 64){
return input.substring(0,input.length - 1);
}
return input;
},
decode(input, arrayBuffer) {
//get last chars to see if are valid
input = this.removePaddingChars(input);
input = this.removePaddingChars(input);
const bytes = parseInt((input.length / 4) * 3, 10);
let uarray;
let chr1, chr2, chr3;
let enc1, enc2, enc3, enc4;
let i = 0;
let j = 0;
if (arrayBuffer)
uarray = new Uint8Array(arrayBuffer);
else
uarray = new Uint8Array(bytes);
input = input.replace(/[^A-Za-z0-9\+\/\=]/g, "");
for (i=0; i<bytes; i+=3) {
//get the 3 octects in 4 ascii chars
enc1 = this._keyStr.indexOf(input.charAt(j++));
enc2 = this._keyStr.indexOf(input.charAt(j++));
enc3 = this._keyStr.indexOf(input.charAt(j++));
enc4 = this._keyStr.indexOf(input.charAt(j++));
chr1 = (enc1 << 2) | (enc2 >> 4);
chr2 = ((enc2 & 15) << 4) | (enc3 >> 2);
chr3 = ((enc3 & 3) << 6) | enc4;
uarray[i] = chr1;
if (enc3 != 64) uarray[i+1] = chr2;
if (enc4 != 64) uarray[i+2] = chr3;
}
return uarray;
}
}
return Base64Binary.decodeArrayBuffer(data)
// #endif
// #ifdef MP-WEIXIN || APP-PLUS
return uni.base64ToArrayBuffer(data)
// #endif
}
/**
* base64转路径
* @param {Object} base64
*/
export function base64ToPath(base64) {
const [, format, bodyData] = /data:image\/(\w+);base64,(.*)/.exec(base64) || [];
return new Promise((resolve, reject) => {
// #ifdef MP
const fs = uni.getFileSystemManager()
//自定义文件名
if (!format) {
console.error('ERROR_BASE64SRC_PARSE')
reject(new Error('ERROR_BASE64SRC_PARSE'))
}
const time = new Date().getTime();
let pre = prefix()
const filePath = `${pre.env.USER_DATA_PATH}/${time}.${format}`
//let buffer = base64ToArrayBuffer(bodyData)
console.log(filePath)
fs.writeFile({
filePath,
data: base64.replace(/^data:\S+\/\S+;base64,/, ''),
encoding: 'base64',
// data: buffer,
// encoding: 'binary',
success() {
resolve(filePath)
},
fail(err) {
console.log(base64,'!!!!!!')
console.error('获取base64图片失败', JSON.stringify(err))
reject(err)
}
})
// #endif
// #ifdef H5
// mime类型
let mimeString = base64.split(',')[0].split(':')[1].split(';')[0];
//base64 解码
let byteString = atob(base64.split(',')[1]);
//创建缓冲数组
let arrayBuffer = new ArrayBuffer(byteString.length);
//创建视图
let intArray = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteString.length; i++) {
intArray[i] = byteString.charCodeAt(i);
}
resolve(URL.createObjectURL(new Blob([intArray], { type: mimeString })))
// #endif
// #ifdef APP-PLUS
const bitmap = new plus.nativeObj.Bitmap('bitmap' + Date.now())
bitmap.loadBase64Data(base64, () => {
if (!format) {
reject(new Error('ERROR_BASE64SRC_PARSE'))
}
const time = new Date().getTime();
const filePath = `_doc/uniapp_temp/${time}.${format}`
bitmap.save(filePath, {},
() => {
bitmap.clear()
resolve(filePath)
},
(error) => {
bitmap.clear()
console.error(`${JSON.stringify(error)}`)
reject(error)
})
}, (error) => {
bitmap.clear()
console.error(`${JSON.stringify(error)}`)
reject(error)
})
// #endif
})
}
/**
* 路径转base64
* @param {Object} string
*/
export function pathToBase64(path) {
if(/^data:/.test(path)) return path
return new Promise((resolve, reject) => {
// #ifdef H5
const _canvas = ()=> {
let image = new Image();
image.setAttribute("crossOrigin",'Anonymous');
image.onload = function() {
let canvas = document.createElement('canvas');
// 获取图片原始宽高
canvas.width = this.naturalWidth;
canvas.height = this.naturalHeight;
// 将图片插入画布并开始绘制
canvas.getContext('2d').drawImage(image, 0, 0);
let result = canvas.toDataURL('image/png')
resolve(result);
canvas.height = canvas.width = 0
}
image.src = path
image.onerror = (error) => {
console.error(`urlToBase64 error: ${path}`, JSON.stringify(error))
reject(new Error('urlToBase64 error'));
};
}
const _fileReader = (blob) => {
const fileReader = new FileReader();
fileReader.onload = (e) => {
resolve(e.target.result);
};
fileReader.readAsDataURL(blob);
fileReader.onerror = (error) => {
console.error('blobToBase64 error:', JSON.stringify(error))
reject(new Error('blobToBase64 error'));
};
}
const isFileReader = typeof FileReader === 'function'
if(networkReg.test(path) && isFileReader ) {
window.URL = window.URL || window.webkitURL;
const xhr = new XMLHttpRequest();
xhr.open("get", path, true);
xhr.timeout = 2000;
xhr.responseType = "blob";
xhr.onload = function() {
if(this.status == 200) {
_fileReader(this.response)
} else {
_canvas()
}
}
xhr.onreadystatechange = function() {
if(this.status === 0) {
console.error('图片跨域了,得后端处理咯')
}
}
xhr.send();
} else if(/^blob/.test(path) && isFileReader){
_fileReader(path)
} else {
_canvas()
}
// #endif
// #ifdef MP
if(uni.canIUse('getFileSystemManager')) {
uni.getFileSystemManager().readFile({
filePath: path,
encoding: 'base64',
success: (res) => {
resolve('data:image/png;base64,' + res.data)
},
fail: (error) => {
console.error('urlToBase64 error:', JSON.stringify(error))
reject(error)
}
})
}
// #endif
// #ifdef APP-PLUS
plus.io.resolveLocalFileSystemURL(getLocalFilePath(path), (entry) => {
entry.file((file) => {
const fileReader = new plus.io.FileReader()
fileReader.onload = (data) => { resolve(data.target.result)}
fileReader.onerror = (error) => {
console.error('pathToBase64 error:', JSON.stringify(error))
reject(error)
}
fileReader.readAsDataURL(file)
}, (error) => {
console.error('pathToBase64 error:', JSON.stringify(error))
reject(error)
})
}, (error) => {
console.error('pathToBase64 error:', JSON.stringify(error))
reject(error)
})
// #endif
})
}
// #ifdef APP-PLUS
const getLocalFilePath = (path)=> {
if (path.indexOf('_www') === 0 || path.indexOf('_doc') === 0 || path.indexOf('_documents') === 0 || path.indexOf('_downloads') === 0) {
return path
}
if (path.indexOf('file://') === 0) {
return path
}
if (path.indexOf('/storage/emulated/0/') === 0) {
return path
}
if (path.indexOf('/') === 0) {
const localFilePath = plus.io.convertAbsoluteFileSystem(path)
if (localFilePath !== path) {
return localFilePath
} else {
path = path.substr(1)
}
}
return '_www/' + path
}
// #endif
export function getImageInfo(img, isH5PathToBase64 = false) {
return new Promise(async (resolve, reject) => {
// const base64Reg = /^data:image\/(\w+);base64/
const localReg = /^\.|^\/(?=[^\/])/;
// #ifdef H5
if(networkReg.test(img) && isH5PathToBase64) {
img = await pathToBase64(img)
}
// #endif
// #ifndef MP-ALIPAY
if(isBase64(img)) {
if(isDev || !cache[img]) {
const imgName = img
img = await base64ToPath(img)
cache[imgName] = img
} else {
img = cache[img]
}
}
// #endif
if(cache[img] && cache[img].errMsg) {
resolve(cache[img])
} else {
uni.getImageInfo({
src: img,
success: (image) => {
// #ifdef MP-WEIXIN || MP-BAIDU || MP-QQ || MP-TOUTIAO
image.path = localReg.test(img) ? `/${image.path}` : image.path;
// #endif
// #ifdef H5
image.path = image.path.replace(/^\./, window.location.origin)
// #endif
image.naturalSrc = img
if(isDev) {
resolve(image)
} else {
cache[img] = image
resolve(cache[img])
}
},
fail(err) {
resolve({path: img})
console.error(`getImageInfo:fail ${img} failed ${JSON.stringify(err)}`);
}
})
}
})
}

View File

@ -0,0 +1,756 @@
# Painter 画板 测试版
> uniapp 海报画板,更优雅的海报生成方案
> [查看更多 站点1](https://limeui.qcoon.cn/#/painter)
> [查看更多 站点2](http://liangei.gitee.io/limeui/#/painter)
> Q群806744170
## 平台兼容
| H5 | 微信小程序 | 支付宝小程序 | 百度小程序 | 头条小程序 | QQ 小程序 | App |
| --- | ---------- | ------------ | ---------- | ---------- | --------- | --- |
| √ | √ | √ | 未测 | √ | √ | √ |
## 代码演示
### 基本用法
- 插件提供JSON及HTML的方式绘制海报
- 插件参考了 css 块状流布局模拟css schema方式放弃了之前使用的绝对定位布局。
#### 方式一 HTML
- 插件提供了`l-painter-view``l-painter-text``l-painter-image``l-painter-qrcode`四种类型组件
- 通过 `css` 属性绘制样式与style使用方式保持一致。 因为style是保留字段所以命名为`css`,如果有大佬知道如何破解请告之。
```html
<l-painter >
<l-painter-view css="background: #07c160; height: 120rpx; width: 120rpx; display: inline-block"></l-painter-view>
<l-painter-view css="background: #1989fa; height: 120rpx; width: 120rpx; border-top-right-radius: 60rpx; border-bottom-left-radius: 60rpx; display: inline-block; margin: 0 30rpx;"></l-painter-view>
<l-painter-view css="background: #ff9d00; height: 120rpx; width: 120rpx; border-radius: 50%; display: inline-block"></l-painter-view>
</l-painter>
```
#### 方式二 JSON schema
- 在json里四种类型组件的`type``view``text``image``qrcode`
- 通过 `board` 设置海报所需的 JSON schema 数据进行绘制
- 所有类型的schema都具有`css`字段css的样式属性key值使用驼峰命名如`lineHeight`
```html
<l-painter :board="poster" />
```
```js
data() {
return {
poster: {
css: {
// json 方式务必填写画板宽度
width: '750rpx'
},
views: [
{
css: {
background: "#07c160",
height: "120rpx",
width: "120rpx",
display: "inline-block"
},
type: "view"
},
{
css: {
background: "#1989fa",
height: "120rpx",
width: "120rpx",
borderTopRightRadius: "60rpx",
borderBottomLeftRadius: "60rpx",
display: "inline-block",
margin: "0 30rpx"
},
views: [],
type: "view"
},
{
css: {
background: "#ff9d00",
height: "120rpx",
width: "120rpx",
borderRadius: "50%",
display: "inline-block"
},
views: [],
type: "view"
},
]
}
}
}
```
### View 容器
- 类似于 `div` 可以嵌套承载更多的 view、text、imageqrcode共同构建一颗完整的节点树
- 在JSON schema里具有 `views` 的数组字段,用于嵌套承载节点。
#### 方式一 HTML
```html
<l-painter >
<l-painter-view css="background: #f0f0f0; padding-top: 100rpx;">
<l-painter-view css="background: #d9d9d9; width: 33.33%; height: 100rpx; display: inline-block"></l-painter-view>
<l-painter-view css="background: #bfbfbf; width: 66.66%; height: 100rpx; display: inline-block"></l-painter-view>
</l-painter-view>
</l-painter>
```
#### 方式二 JSON schema
```js
{
css: {
width: '750rpx'
},
views: [
{
type: 'view',
css: {
background: '#f0f0f0',
paddingTop: '100rpx'
},
views: [
{
type: 'view',
css: {
background: '#d9d9d9',
width: '33.33%',
height: '100rpx',
display: 'inline-block'
}
},
{
type: 'view',
css: {
background: '#bfbfbf',
width: '66.66%',
height: '100rpx',
display: 'inline-block'
}
}
],
}
]
}
```
### Text 文本
- 通过 `text` 属性填写文本内容。
- 支持`\n`换行符
- 支持省略号使用css的`line-clamp`设置行数,当文字内容超过会显示省略号。
- 支持`text-decoration`
#### 方式一 HTML
```html
<l-painter >
<l-painter-view css="background: #e0e2db; padding: 30rpx; color: #222a29">
<l-painter-text
text="登鹳雀楼\n白日依山尽黄河入海流\n欲穷千里目更上一层楼"/>
<l-painter-text
text="登鹳雀楼\n白日依山尽黄河入海流\n欲穷千里目更上一层楼"
css="text-align:center; padding-top: 20rpx; text-decoration: line-through "
/>
<l-painter-text
text="登鹳雀楼\n白日依山尽黄河入海流\n欲穷千里目更上一层楼"
css="text-align:right; padding-top: 20rpx"
/>
<l-painter-text
text="水调歌头\n明月几时有把酒问青天。不知天上宫阙今夕是何年。我欲乘风归去又恐琼楼玉宇高处不胜寒。起舞弄清影何似在人间。"
css="line-clamp: 3; padding-top: 20rpx"
/>
</l-painter-view>
</l-painter>
```
#### 方式二 JSON schema
```js
// 基础用法
{
type: 'text',
text: '登鹳雀楼\n白日依山尽黄河入海流\n欲穷千里目更上一层楼',
},
{
type: 'text',
text: '登鹳雀楼\n白日依山尽黄河入海流\n欲穷千里目更上一层楼',
css: {
// 设置居中对齐
textAlign: 'center',
// 设置中划线
textDecoration: 'line-through'
}
},
{
type: 'text',
text: '登鹳雀楼\n白日依山尽黄河入海流\n欲穷千里目更上一层楼',
css: {
// 设置右对齐
textAlign: 'right',
}
},
{
type: 'text',
text: '登鹳雀楼\n白日依山尽黄河入海流\n欲穷千里目更上一层楼',
css: {
// 设置行数,超出显示省略号
lineClamp: 3,
}
}
```
### Image 图片
- 通过 `src` 属性填写图片路径。
- 图片路径支持网络图片本地static里的图片路径缓存路径
- 通过 `css``object-fit`属性可以设置图片的填充方式可选值见下方CSS表格。
- 通过 `css``object-position`配合 `object-fit` 可以设置图片的对齐方式,类似于`background-position`详情见下方CSS表格。
- 使用网络图片时:小程序需要去公众平台配置 [downloadFile](https://mp.weixin.qq.com/) 域名
- 使用网络图片时:**H5需要决跨域问题**
#### 方式一 HTML
```html
<l-painter >
<!-- 基础用法 -->
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="width: 200rpx; height: 200rpx"
/>
<!-- 填充方式 -->
<!-- css object-fit 设置 填充方式 见下方表格-->
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="width: 200rpx; height: 200rpx; object-fit: contain; background: #eee"
/>
<!-- css object-position 设置 图片的对齐方式-->
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="width: 200rpx; height: 200rpx; object-fit: contain; object-position: 50% 50%; background: #eee"
/>
</l-painter>
```
#### 方式二 JSON schema
```js
// 基础用法
{
type: 'image',
src: 'https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg',
css: {
width: '200rpx',
height: '200rpx'
}
},
// 填充方式
// css objectFit 设置 填充方式 见下方表格
{
type: 'image',
src: 'https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg',
css: {
width: '200rpx',
height: '200rpx',
objectFit: 'contain'
}
},
// css objectPosition 设置 图片的对齐方式
{
type: 'image',
src: 'https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg',
css: {
width: '200rpx',
height: '200rpx',
objectFit: 'contain',
objectPosition: '50% 50%'
}
}
```
### Qrcode 二维码
- 通过`text`属性填写需要生成二维码的文本。
- 通过 `css` 里的 `color` 可设置生成码点的颜色。
- 通过 `css` 里的 `background`可设置背景色。
- 通过 `css `里的 `width``height`设置尺寸。
#### 方式一 HTML
```html
<l-painter>
<l-painter-qrcode
text="limeui.qcoon.cn"
css="width: 200rpx; height: 200rpx"
/>
</l-painter>
```
#### 方式二 JSON schema
```js
{
type: 'qrcode',
text: 'limeui.qcoon.cn',
css: {
width: '200rpx',
height: '200rpx',
}
}
```
### 生成图片
- 1、通过设置`isCanvasToTempFilePath`自动生成图片并在 `@success` 事件里接收海报临时路径
- 2、通过调用内部方法生成图片
```html
<l-painter ref="painter">...code</l-painter>
```
```js
// 主动调用方式只能在绘制完成之后
// @done 事件表示绘制完成
this.$refs.painter.canvasToTempFilePath({
// 在nvue里是jpeg
fileType: 'jpg',
quality: 1,
success: (res) => {
console.log(res.tempFilePath)
}
})
```
### 海报示例
- 提供一份示例,只把插件当成生成图片的工具,弹窗之类的功能另外寻找。
- 通过设置`isCanvasToTempFilePath`主动生成图片,再由 `@success` 事件接收海报临时路径
- 使用`custom-style`样式把画板移到屏幕之外因为可能canvas的层级比较高无法覆盖。
- **注意**:海报画板不能隐藏 否则无法生成图片。
#### 方式一 HTML
```html
<image :src="path" mode="widthFix"></image>
<l-painter isCanvasToTempFilePath @success="path = $event"
custom-style="position: fixed; left: 200%"
css="width: 750rpx; padding-bottom: 40rpx; background: linear-gradient(,#ff971b 0%, #ff5000 100%)">
<l-painter-image src="https://cdn.jsdelivr.net/gh/liangei/image@latest/avatar-1.jpeg" css="margin-left: 40rpx; margin-top: 40rpx; width: 84rpx; height: 84rpx; border-radius: 50%;"/>
<l-painter-view css="margin-top: 40rpx; padding-left: 20rpx; display: inline-block">
<l-painter-text text="隔壁老王" css="display: block; padding-bottom: 10rpx; color: #fff; font-size: 32rpx; fontWeight: bold"/>
<l-painter-text text="为您挑选了一个好物" css="color: rgba(255,255,255,.7); font-size: 24rpx"/>
</l-painter-view>
<l-painter-view css="margin-left: 40rpx; margin-top: 30rpx; padding: 32rpx; box-sizing: border-box; background: #fff; border-radius: 16rpx; width: 670rpx; box-shadow: 0 20rpx 58rpx rgba(0,0,0,.15)">
<l-painter-image src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg" css="object-fit: cover; object-position: 50% 50%; width: 606rpx; height: 606rpx; border-radius: 12rpx;"/>
<l-painter-view css="margin-top: 32rpx; color: #FF0000; font-weight: bold; font-size: 28rpx; line-height: 1em;">
<l-painter-text text="¥" css="vertical-align: bottom"/>
<l-painter-text text="39" css="vertical-align: bottom; font-size: 58rpx"/>
<l-painter-text text=".39" css="vertical-align: bottom"/>
<l-painter-text text="¥59.99" css="vertical-align: bottom; padding-left: 10rpx; font-weight: normal; text-decoration: line-through; color: #999999"/>
</l-painter-view>
<l-painter-view css="margin-top: 32rpx; font-size: 26rpx; color: #8c5400">
<l-painter-text text="自营" css="color: #212121; background: #ffb400;"/>
<l-painter-text text="30天最低价" css="margin-left: 16rpx; background: #fff4d9; text-decoration: line-through;"/>
<l-painter-text text="满减优惠" css="margin-left: 16rpx; background: #fff4d9"/>
<l-painter-text text="超高好评" css="margin-left: 16rpx; background: #fff4d9"/>
</l-painter-view>
<l-painter-view css="margin-top: 30rpx">
<l-painter-text css="line-clamp: 2; color: #333333; line-height: 1.8em; font-size: 36rpx; width: 478rpx; padding-right:32rpx; box-sizing: border-box" text="360儿童电话手表9X 智能语音问答定位支付手表 4G全网通20米游泳级防水视频通话拍照手表男女孩星空蓝" :replace="{word: ['儿童', '9X'], color: 'red'}"></l-painter-text>
<l-painter-qrcode css="width: 128rpx; height: 128rpx;" text="limeui.qcoon.cn"></l-painter-qrcode>
</l-painter-view>
</l-painter-view>
</l-painter>
```
```js
data() {
return {
path: ''
}
}
```
#### 方式二 JSON schema
```html
<image :src="path" mode="widthFix"></image>
<l-painter
:board="poster"
isCanvasToTempFilePath
@success="path = $event"
custom-style="position: fixed; left: 200%"
/>
```
```js
data() {
return {
path: '',
poster: {
css: {
width: "750rpx",
paddingBottom: "40rpx",
background: "linear-gradient(,#000 0%, #ff5000 100%)"
},
views: [
{
src: "https://cdn.jsdelivr.net/gh/liangei/image@latest/avatar-1.jpeg",
type: "image",
css: {
background: "#fff",
objectFit: "cover",
marginLeft: "40rpx",
marginTop: "40rpx",
width: "84rpx",
border: "2rpx solid #fff",
boxSizing: "border-box",
height: "84rpx",
borderRadius: "50%"
}
},
{
type: "view",
css: {
marginTop: "40rpx",
paddingLeft: "20rpx",
display: "inline-block"
},
views: [
{
text: "隔壁老王",
type: "text",
css: {
display: "block",
paddingBottom: "10rpx",
color: "#fff",
fontSize: "32rpx",
fontWeight: "bold"
}
},
{
text: "为您挑选了一个好物",
type: "text",
css: {
color: "rgba(255,255,255,.7)",
fontSize: "24rpx"
},
}
],
},
{
css: {
marginLeft: "40rpx",
marginTop: "30rpx",
padding: "32rpx",
boxSizing: "border-box",
background: "#fff",
borderRadius: "16rpx",
width: "670rpx",
boxShadow: "0 20rpx 58rpx rgba(0,0,0,.15)"
},
views: [
{
src: "https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg",
type: "image",
css: {
objectFit: "cover",
objectPosition: "50% 50%",
width: "606rpx",
height: "606rpx"
},
}, {
css: {
marginTop: "32rpx",
color: "#FF0000",
fontWeight: "bold",
fontSize: "28rpx",
lineHeight: "1em"
},
views: [{
text: "¥",
type: "text",
css: {
verticalAlign: "bottom"
},
}, {
text: "39",
type: "text",
css: {
verticalAlign: "bottom",
fontSize: "58rpx"
},
}, {
text: ".39",
type: "text",
css: {
verticalAlign: "bottom"
},
}, {
text: "¥59.99",
type: "text",
css: {
verticalAlign: "bottom",
paddingLeft: "10rpx",
fontWeight: "normal",
textDecoration: "line-through",
color: "#999999"
}
}],
type: "view"
}, {
css: {
marginTop: "32rpx",
fontSize: "26rpx",
color: "#8c5400"
},
views: [{
text: "自营",
type: "text",
css: {
color: "#212121",
background: "#ffb400"
},
}, {
text: "30天最低价",
type: "text",
css: {
marginLeft: "16rpx",
background: "#fff4d9",
textDecoration: "line-through"
},
}, {
text: "满减优惠",
type: "text",
css: {
marginLeft: "16rpx",
background: "#fff4d9"
},
}, {
text: "超高好评",
type: "text",
css: {
marginLeft: "16rpx",
background: "#fff4d9"
},
}],
type: "view"
}, {
css: {
marginTop: "30rpx"
},
views: [
{
text: "360儿童电话手表9X 智能语音问答定位支付手表 4G全网通20米游泳级防水视频通话拍照手表男女孩星空蓝",
type: "text",
css: {
paddingRight: "32rpx",
boxSizing: "border-box",
lineClamp: 2,
color: "#333333",
lineHeight: "1.8em",
fontSize: "36rpx",
width: "478rpx"
},
}, {
text: "limeui.qcoon.cn",
type: "qrcode",
css: {
width: "128rpx",
height: "128rpx",
},
}],
type: "view"
}],
type: "view"
}
]
}
}
}
```
### 原生小程序
- 插件里的`painter.js`支持在原生小程序中使用
- new Painter之后在`source`里传入JSON schema
- 再调用`render`绘制海报
- 如需生成图片请查看微信小程序cavnas的[canvasToTempFilePath](https://developers.weixin.qq.com/miniprogram/dev/api/canvas/wx.canvasToTempFilePath.html)
```html
<canvas type="2d" id="painter" style="width: 100%"></canvas>
```
```js
import {Painter} from './painter'
page({
data: {
poster: {
css: {
width: '750rpx'
},
views: [
{
type: 'view',
css: {
background: '#d2d4c8',
paddingTop: '100rpx'
},
views: [
{
type: 'view',
css: {
background: '#5f7470',
width: '33.33%',
height: '100rpx',
display: 'inline-block'
}
},
{
type: 'view',
css: {
background: '#889696',
width: '33.33%',
height: '100rpx',
display: 'inline-block'
}
},
{
type: 'view',
css: {
background: '#b8bdb5',
width: '33.33%',
height: '100rpx',
display: 'inline-block'
}
}
],
}
]
}
},
async onLoad() {
const res = await this.getCentext()
const painter = new Painter(res)
// 返回计算布局后的整个内容尺寸
const {width, height} = await painter.source(this.data.poster)
// 得到计算后的尺寸后 可给canvas尺寸赋值达到动态响应效果
// 渲染
await painter.render()
},
// 获取canvas 2d
// 非2d也可以使用这里只是举个例子
getCentext() {
return new Promise(resolve => {
wx.createSelectorQuery()
.select(`#painter`)
.node()
.exec(res => {
let { node: canvas } = res[0];
resolve({
canvas,
context: canvas.getContext('2d'),
width: canvas.width,
height: canvas.height,
pixelRatio: 2
})
})
})
},
})
```
### Nvue
- 插件是通过 `web-view` 支持 `app-nvue`
- 默认是远端的文件,如果需要本地化,请按下方步骤:
- 1、去码云把[hybrid](https://gitee.com/liangei/lime-painter/tree/master/examples/uni/hybrid)目录的文件,放到自己项目的根目录。
- 2、给插件文件`l-painter.vue``web-view``src`加上根目录的`hybrid路径`并注释`this.webViewInit()`
```html
// 只加上路径 其它参数不要改
<web-view src="/hybrid/html/lime-ui/lime-painter/index.html" />
```
```js
// 153行 注释该方法
// this.webViewInit()
```
### 旧版更新(1.6.x)
- 由于1.8.x版放弃了以定位的方式所以1.6.x版更新之后要每个样式都加上`position: absolute`
- 旧版的 `image` mode 模式被放弃,使用`object-fit`
- 旧版的 `isRenderImage` 改成 `is-canvas-to-temp-filePath`
- 旧版的 `maxLines` 改成 `line-clamp`
## API
### Props
| 参数 | 说明 | 类型 | 默认值 |
| ------------- | ------------ | ---------------- | ------------ |
| board | JSON schema方式的海报元素对象集 | <em>object</em> | - |
| css | 海报最外层的样式,可以理解为`body` | <em>object</em> | 参数请向下看 |
| custom-style | canvas自定义样式 | <em>string</em> | |
| is-canvas-to-temp-filePath | 是否生成图片,在`@success`事件接收图片地址 | <em>boolean</em> | `false` |
| after-delay | 生成图片错乱,可延时生成图片 | <em>number</em> | `100` |
| type | canvas 类型,对微信头条支付宝小程序可有效,可选值:`2d`、`` | <em>string</em> | `2d` |
| file-type | 生成图片的后缀类型, 可选值:`png`、`jpg`在nvue里是`jpeg` | <em>string</em> | `png` |
| path-type | 生成图片路径类型,可选值`url`、`base64` | <em>string</em> | `-` |
| pixel-ratio | 生成图片的像素密度,默认为对应手机的像素密度 | <em>number</em> | `-` |
<!-- | width | 画板的宽度,一般只用于通过内部方法时加上 | <em>number</em> | `` |
| height | 画板的高度 ,同上 | <em>number</em> | `` | -->
### css
| 属性名 | 支持的值或类型 | 默认值 |
| ------------- | ------------ | ---------------- |
| (min\max)width | 支持`%`、`rpx`、`px` | - |
| height | 同上 | - |
| color | `string` | - |
| position | 定位,可选值:`absolute`、`fixed` | - |
| ↳ left、top、right、bottom | 配合`position`才生效,支持`%`、`rpx`、`px` | - |
| margin | 可简写或各方向分别写,如:`margin-top`,支持`auto`、`rpx`、`px` | - |
| padding | 可简写或各方向分别写,支持`rpx`、`px` | - |
| border | 可简写或各个值分开写:`border-width`、`border-style` 、`border-color`,简写请按顺序写| - |
| line-clamp | `number`,超过行数显示省略号 | - |
| background | 支持渐变,但必须写百分比!如:`linear-gradient(,#ff971b 0%, #ff5000 100%)`、`radial-gradient(#0ff 15%, #f0f 60%)`,目前radial-gradient 渐变的圆心为元素中点,半径为最长边,不支持设置 | - |
| vertical-align | 文字垂直对齐,可选值:`bottom`、`top`、`middle` | `middle` |
| line-height | 文字行高,支持`rpx`、`px`、`em`| `1.4em` |
| font-weight | 文字粗细,可选值:`normal`、`bold`| `normal` |
| font-size | 文字大小,`string`,支持`rpx`、`px` | `14px` |
| text-decoration | 文本修饰,可选值:`underline` 、`line-through`、`overline`| - |
| text-align | 文本水平对齐,可选值:`right` 、`center`| - |
| display | 框类型,可选值:`block`、`inline-block`、`none`,当为`none`时是不渲染该段 | - |
| border-radius | 圆角边框,支持`%`、`rpx`、`px` | - |
| box-sizing | 可选值:`border-box` | - |
| box-shadow | 投影 | - |
| background-image | view元素设置背景纹理注意这里的背景纹理一般是用于纹理平铺无法代替`image`元素。如:水印 | - |
| background-repeat | 设置是否及如何重复背景纹理,可选值:`repeat`、`repeat-x`、`repeat-y`、`no-repeat` | `repeat` |
| [object-fit](https://developer.mozilla.org/zh-CN/docs/Web/CSS/object-fit/) | 图片元素适应容器方式,类似于`mode`,可选值:`cover`、 `contain`、 `fill`、 `none` | - |
| [object-position](https://developer.mozilla.org/zh-CN/docs/Web/CSS/object-position) | 图片的对齐方式,配合`object-fit`使用 | - |
### 图片填充模式 object-fit
| 名称 | 含义 |
| ------- | ------------ |
| contain | 保持宽高缩放图片,使图片的长边能完全显示出来 |
| cover | 保持宽高缩放图片,使图片的短边能完全显示出来,裁剪长边 |
| fill | 拉伸图片,使图片填满元素 |
| none | 保持图片原有尺寸 |
### 事件 Events
| 事件名 | 说明 | 回调 |
| ------- | ------------ | -------------- |
| success | 生成图片成功,若使用了`is-canvas-to-temp-filePath` 可以接收图片地址 | path |
| fail | 生成图片失败 | {error: error} |
| done | 绘制成功 | |
| progress | 绘制进度 | number |
## 常见问题
- 1、H5端使用网络图片需要解决跨域问题。
- 2、小程序使用网络图片需要去公众平台增加下载白名单二级域名也需要配
- 3、H5端生成图片是base64有时显示只有一半可以使用原生标签`<IMG/>`
- 4、发生保存图片倾斜变形或提示native buffer exceed size limit时使用pixel-ratio="2"参数,降分辨率。
- 5、h5保存图片不需要调接口提示用户长按图片保存。
- 6、IOS APP 请勿使用HBX2.9.3.20201014的版本!这个版本无法生成图片。
- 7、组件不能隐藏包含`v-if``v-show`、`display:none`、`opacity:0`
- 8、微信小程序 canvas 2d不支持真机调试请使用真机预览方式。
- 9、华为手机APP上无法生成图片请使用HBX2.9.11++
- 10、苹果微信7.0.20存在闪退和图片无法onload为微信bug,请到码云上升级本插件
## 打赏
如果你觉得本插件,解决了你的问题,赠人玫瑰,手留余香。
![输入图片说明](https://cdn.jsdelivr.net/gh/liangei/image@latest/222521_bb543f96_518581.jpeg "微信图片编辑_20201122220352.jpg")