This commit is contained in:
zoujiandong
2025-07-16 13:05:58 +08:00
parent eb358d594b
commit 96859c920f
211 changed files with 26537 additions and 151 deletions
+72
View File
@@ -0,0 +1,72 @@
## 2.2.52024-07-30
* 修复 当 checkRange=true 时,拖动四个伸缩角放大图片时还可能会超出或未到边界的问题
* 修复 当 checkRange=false 时,图片旋转时会放大图片适应裁剪尺寸的问题
* 修复 当 checkRange=true 时,图片旋转 90° 或 270° 进行缩放可能会无法拖动图片的问题
## 2.2.42024-06-21
* 新增 reverseRotatable 属性,是否支持逆向翻转
* 修复 `2.1.7` 版本导致旋转后图片没有自动适配裁剪框的问题
## 2.2.32024-06-21
* 新增 gpu 属性,是否开启硬件加速,图片缩放过程中如果出现元素的“留影”或“重影”效果,可通过该方式解决或减轻这一问题
* 修复 组件使用 `v-if` 并设置 `src` 属性时可能会出现图片渲染位置存在偏差的问题
## 2.2.22024-06-21
* 优化 组件实例 chooseImage 方法支持传参
* 修复 组件使用 `v-if` 时组件无非正常渲染的问题
## 2.2.12024-06-15
* 修复 H5平台不支持手势拖动图片的问题
## 2.2.02024-05-31
* 修复 APP平台 `vue2` 项目因 `2.1.9` 版本修复 `vue3` 项目bug而引发的问题
## 2.1.92024-05-29
* 修复 APP平台 `vue3` 项目因 uniapp `renderjs` 中未支持条件编译,导致运行了H5平台代码报错的问题
## 2.1.82024-05-29
* 新增 zIndex 属性,调整组件层级
* 新增 组件内容插槽
* 优化 微信小程序平台动态修改元素style时的多余内容
## 2.1.72024-05-28
* 新增 checkRange 属性,当 checkRange=false 时允许图片位置超出裁剪边界
* 新增 minScale 属性,图片最小缩放倍数,当 minScale<0 时可使图片宽高不再受裁剪区域宽高限制
* 新增 backgroundColor 属性,生成图片背景色,如果裁剪区域没有完全包含在图片中时,不设置该属性生成图片存在一定的透明块
* 优化 动态修改图片宽高但没有传入src时,尺寸适应问题
* 修复 APP平台通过 `this.$ownerInstance` 获取组件实例时机过早,其值为 `undefined` 导致报错界面没有正常渲染的问题
## 2.1.62023-04-16
* 修复 组件使用 v-show 指令会导致选择图片后初始位置严重偏位的问题
## 2.1.52023-04-15
* 新增 兼容APP平台
## 2.1.42023-03-13
* 新增 fileType 属性,用于指定生成文件的类型,只支持 'jpg' 或 'png',默认为 'png'
* 新增 delay 属性,微信小程序平台使用 `Canvas 2D` 绘制时控制图片从绘制到生成所需时间
* 优化 当生成图片的尺寸宽/高超过 Canvas 2D 最大限制(1365*1365)则将画布尺寸缩放在限制范围内绘制完成后输出目标尺寸
* 优化 旋转图标指示方向与实际旋转方向不符
## 2.1.32023-02-06
* 优化 vue3支持
## 2.1.22023-02-03
* 新增 navigation 属性,H5平台当 showAngle 为 true 时,使用插件的页面在 `page.json` 中配置了 "navigationStyle": "custom" 时,必须将此值设为 false ,否则四个可拉伸角的触发位置会有偏差
* 修复 H5平台部分设备(已知iPhone11以下机型)拍照的图片缩放时会闪动的问题
## 2.1.12022-12-06
* 修复 横屏适配问题
## 2.1.02022-12-06
* 新增 兼容H5平台,使用 renderjs 响应手势事件
## 2.0.02022-12-05
* 重构 插件,使用 WXS 响应手势事件
* 新增 图片翻转
* 新增 拉伸裁剪框放大图片
* 新增 监听PC鼠标滚轮触发缩放
* 新增 圆形、圆角矩形的图片裁剪
* 优化 图片缩放,移动端以双指触摸中心点为缩放中心点,PC端以鼠标所在点为缩放中心点
* 优化 裁剪框样式
* 优化 图片位置拖动 支持边界回弹效果(滑动时可滑出边界,释放时回弹到边界)
* 优化 生成图片使用新版 Canvas 2D 接口
@@ -0,0 +1,855 @@
/**
* 图片编辑器-手势监听
* 1. 支持编译到app-vueuni-app 2.5.5及以上版本)、H5上
*/
/** 图片偏移量 */
var offset = { x: 0, y: 0 };
/** 图片缩放比例 */
var scale = 1;
/** 图片最小缩放比例 */
var minScale = 1;
/** 图片旋转角度 */
var rotate = 0;
/** 触摸点 */
var touches = [];
/** 图片布局信息 */
var img = {};
/** 系统信息 */
var sys = {};
/** 裁剪区域布局信息 */
var area = {};
/** 触摸行为类型 */
var touchType = '';
/** 操作角的位置 */
var activeAngle = 0;
/** 裁剪区域布局信息偏移量 */
var areaOffset = { left: 0, right: 0, top: 0, bottom: 0 };
/** 元素ID */
var elIds = {
'imageStyles': 'crop-image',
'maskStylesList': 'crop-mask-block',
'borderStyles': 'crop-border',
'circleBoxStyles': 'crop-circle-box',
'circleStyles': 'crop-circle',
'gridStylesList': 'crop-grid',
'angleStylesList': 'crop-angle',
}
/** 记录上次初始化时间戳,排除APP重复更新 */
var timestamp = 0;
/** vue3 renderjs 条件编译无效,以此方式区别 APP 和 H5 */
// #ifdef H5
var platform = 'H5';
// #endif
// #ifdef APP
var platform = 'APP';
// #endif
/** 容错值 */
var fault = 0.000001;
/**
* 获取a、b两数中的最小正数
* @param a
* @param b
*/
function minimum(a, b) {
if (a > 0 && b < 0) return a;
if (a < 0 && b > 0) return b;
if (a > 0 && b > 0) return Math.min(a, b);
return 0;
}
/**
* 在容错访问内获取n近似值
* @param n
*/
function num(n) {
var m = parseFloat((n).toFixed(6));
return m === fault || m === -fault ? 0 : m;
}
/**
* 比较a值在容错值范围内是否等于b值
* @param a
* @param b
*/
function equalsByFault(a, b) {
return Math.abs(a - b) <= fault;
}
/**
* 比较a值在容错值范围内是否小于b值
* @param a
* @param b
*/
function lessThanByFault(a, b) {
var c = a - b;
return c < 0 ? c < -fault : c < fault;
}
/**
* 验证并获取有效最大值
* @param v
* @param max
* @param isInclude
* @param x
* @param y
* @param rate
* @returns
*/
function validMax(v, max, isInclude, x, y, rate) {
if(typeof max === 'number') {
if(isInclude && equalsByFault(max, y)) { // 宽高不等时,x轴用y轴值要做等比例转换
var n = num(max * rate);
if (n <= x) return n; // 转化后值在x轴最大值范围内
return x; // 转化后值超出x轴最大值范围则用最大值
}
return max;
}
return v;
}
/**
* 样式对象转字符串
* @param {Object} style 样式对象
*/
function styleToString(style) {
if(typeof style === 'string') return style;
var str = '';
for (let k in style) {
str += k + ':' + style[k] + ';';
}
return str;
}
/**
*
* @param {Object} instance 页面实例对象
* @param {Object} key 要修改样式的key
* @param {Object|Array} style 样式
*/
function setStyle(instance, key, style) {
// console.log('setStyle', instance, key, JSON.stringify(style))
// #ifdef APP-PLUS
if(platform === 'APP') {
if(Object.prototype.toString.call(style) === '[object Array]') {
for (var i = 0, len = style.length; i < len; i++) {
var el = window.document.getElementById(elIds[key] + '-' + (i + 1));
el && (el.style = styleToString(style[i]));
}
} else {
var el = window.document.getElementById(elIds[key]);
el && (el.style = styleToString(style));
}
}
// #endif
// #ifdef H5
if(platform === 'H5') instance[key] = style;
// #endif
}
/**
* 触发页面实例指定方法
* @param {Object} instance 页面实例对象
* @param {Object} name 方法名称
* @param {Object} obj 传递参数
*/
function callMethod(instance, name, obj) {
// #ifdef APP-PLUS
if(platform === 'APP') instance.callMethod(name, obj);
// #endif
// #ifdef H5
if(platform === 'H5') instance[name](obj);
// #endif
}
/**
* 计算两点间距
* @param {Object} touches 触摸点信息
*/
function getDistanceByTouches(touches) {
// 根据勾股定理求两点间距离
var a = touches[1].pageX - touches[0].pageX;
var b = touches[1].pageY - touches[0].pageY;
var c = Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
// 求两点间的中点坐标
// 1. a、b可能为负值
// 2. 在求a、b时,如用touches[1]减touches[0],则求中点坐标也得用touches[1]减a/2、b/2
// 3. 同理,在求a、b时,也可用touches[0]减touches[1],则求中点坐标也得用touches[0]减a/2、b/2
var x = touches[1].pageX - a / 2;
var y = touches[1].pageY - b / 2;
return { c, x, y };
};
/**
* 修正取值
* @param {Object} a
* @param {Object} b
* @param {Object} c
* @param {Object} reverse 是否反向
*/
function correctValue(a, b, c, reverse) {
return num(reverse ? Math.max(Math.min(a, b), c) : Math.min(Math.max(a, b), c));
}
/**
* 旋转90°或270°时检查边界:限制 x、y 拖动范围,禁止滑出边界
* @param {Object} e 点坐标
* @param {Object} xReverse x是否反向
* @param {Object} yReverse y是否反向
*/
function checkRotateRange(e, xReverse, yReverse) {
var o = num((img.height - img.width) / 2); // 宽高差值一半
return {
x: correctValue(e.x, -img.height + o + area.width + area.left, area.left + o, xReverse),
y: correctValue(e.y, -img.width - o + area.height + area.top, area.top - o, yReverse)
};
}
/**
* 检查边界:限制 x、y 拖动范围,禁止滑出边界
* @param {Object} e 点坐标
*/
function checkRange(e) {
var r = rotate / 90 % 2;
if(r === 1) { // 因图片宽高可能不等,翻转 90° 或 270° 后图片宽高需反着计算,且左右和上下边界要根据差值做偏移
if (area.width === area.height) {
return checkRotateRange(e, img.height < area.height, img.width < area.width);
}
var isInclude = img.height < area.width && img.width < area.height; // 图片是否包含在裁剪区域内
if (img.width < area.height || img.height < area.width) {
if (area.width < area.height && img.width < img.height) {
return isInclude
? checkRotateRange(e, area.width < area.height, area.width < area.height)
: checkRotateRange(e, false, true);
}
if (area.height < area.width && img.height < img.width) {
return isInclude
? checkRotateRange(e, area.height < area.width, area.height < area.width)
: checkRotateRange(e, true, false);
}
}
if (img.height >= area.width && img.width >= area.height) {
return checkRotateRange(e, false, false);
}
if (isInclude) {
return area.height < area.width
? checkRotateRange(e, true, true)
: checkRotateRange(e, area.width < area.height, area.width < area.height);
}
if (img.height < area.width && !img.width < area.height) {
return checkRotateRange(e, true, false);
}
if (!img.height < area.width && img.width < area.height) {
return checkRotateRange(e, false, true);
}
return checkRotateRange(e, img.height < area.height, img.width < area.width);
}
return {
x: correctValue(e.x, -img.width + area.width + area.left, area.left, img.width < area.width),
y: correctValue(e.y, -img.height + area.height + area.top, area.top, img.height < area.height)
}
};
/**
* 变更图片布局信息
* @param {Object} e 布局信息
*/
function changeImageRect(e) {
// console.log('changeImageRect', e)
offset.x += e.x || 0;
offset.y += e.y || 0;
if(e.check && area.checkRange) { // 检查边界
var point = checkRange(offset);
if(offset.x !== point.x || offset.y !== point.y) {
offset = point;
}
}
// 因频繁修改 width/height 会造成大量的内存消耗,改为scale
// e.instance.imageStyles = {
// width: img.width + 'px',
// height: img.height + 'px',
// transform: 'translate(' + (offset.x + ox) + 'px, ' + (offset.y + ox) + 'px) rotate(' + rotate +'deg)'
// };
var ox = (img.width - img.oldWidth) / 2;
var oy = (img.height - img.oldHeight) / 2;
// e.instance.imageStyles = {
// width: img.oldWidth + 'px',
// height: img.oldHeight + 'px',
// transform: 'translate(' + (offset.x + ox) + 'px, ' + (offset.y + oy) + 'px) rotate(' + rotate +'deg) scale(' + scale + ')'
// };
setStyle(e.instance, 'imageStyles', {
width: img.oldWidth + 'px',
height: img.oldHeight + 'px',
transform: (img.gpu ? 'translateZ(0) ' : '') + 'translate(' + (offset.x + ox) + 'px, ' + (offset.y + oy) + 'px' + ') rotate(' + rotate +'deg) scale(' + scale + ')'
});
callMethod(e.instance, 'dataChange', {
width: img.width,
height: img.height,
x: offset.x,
y: offset.y,
rotate: rotate
});
};
/**
* 变更裁剪区域布局信息
* @param {Object} e 布局信息
*/
function changeAreaRect(e) {
// console.log('changeAreaRect', e)
// 变更蒙版样式
setStyle(e.instance, 'maskStylesList', [
{
left: 0,
width: (area.left + areaOffset.left) + 'px',
top: 0,
bottom: 0,
'z-index': area.zIndex + 2
},
{
left: (area.right + areaOffset.right) + 'px',
right: 0,
top: 0,
bottom: 0,
'z-index': area.zIndex + 2
},
{
left: (area.left + areaOffset.left) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
top: 0,
height: (area.top + areaOffset.top) + 'px',
'z-index': area.zIndex + 2
},
{
left: (area.left + areaOffset.left) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
top: (area.bottom + areaOffset.bottom) + 'px',
// height: (area.top - areaOffset.bottom + sys.offsetBottom) + 'px',
bottom: 0,
'z-index': area.zIndex + 2
}
]);
// 变更边框样式
if(area.showBorder) {
setStyle(e.instance, 'borderStyles', {
left: (area.left + areaOffset.left) + 'px',
top: (area.top + areaOffset.top) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
height: (area.height + areaOffset.bottom - areaOffset.top) + 'px',
'z-index': area.zIndex + 3
});
}
// 变更参考线样式
if(area.showGrid) {
setStyle(e.instance, 'gridStylesList', [
{
'border-width': '1px 0 0 0',
left: (area.left + areaOffset.left) + 'px',
right: (area.right + areaOffset.right) + 'px',
top: (area.top + areaOffset.top + (area.height + areaOffset.bottom - areaOffset.top) / 3 - 0.5) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
'z-index': area.zIndex + 3
},
{
'border-width': '1px 0 0 0',
left: (area.left + areaOffset.left) + 'px',
right: (area.right + areaOffset.right) + 'px',
top: (area.top + areaOffset.top + (area.height + areaOffset.bottom - areaOffset.top) * 2 / 3 - 0.5) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
'z-index': area.zIndex + 3
},
{
'border-width': '0 1px 0 0',
top: (area.top + areaOffset.top) + 'px',
bottom: (area.bottom + areaOffset.bottom) + 'px',
left: (area.left + areaOffset.left + (area.width + areaOffset.right - areaOffset.left) / 3 - 0.5) + 'px',
height: (area.height + areaOffset.bottom - areaOffset.top) + 'px',
'z-index': area.zIndex + 3
},
{
'border-width': '0 1px 0 0',
top: (area.top + areaOffset.top) + 'px',
bottom: (area.bottom + areaOffset.bottom) + 'px',
left: (area.left + areaOffset.left + (area.width + areaOffset.right - areaOffset.left) * 2 / 3 - 0.5) + 'px',
height: (area.height + areaOffset.bottom - areaOffset.top) + 'px',
'z-index': area.zIndex + 3
}
]);
}
// 变更四个伸缩角样式
if(area.showAngle) {
setStyle(e.instance, 'angleStylesList', [
{
'border-width': area.angleBorderWidth + 'px 0 0 ' + area.angleBorderWidth + 'px',
left: (area.left + areaOffset.left - area.angleBorderWidth) + 'px',
top: (area.top + areaOffset.top - area.angleBorderWidth) + 'px',
'z-index': area.zIndex + 3
},
{
'border-width': area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px 0 0',
left: (area.right + areaOffset.right - area.angleSize) + 'px',
top: (area.top + areaOffset.top - area.angleBorderWidth) + 'px',
'z-index': area.zIndex + 3
},
{
'border-width': '0 0 ' + area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px',
left: (area.left + areaOffset.left - area.angleBorderWidth) + 'px',
top: (area.bottom + areaOffset.bottom - area.angleSize) + 'px',
'z-index': area.zIndex + 3
},
{
'border-width': '0 ' + area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px 0',
left: (area.right + areaOffset.right - area.angleSize) + 'px',
top: (area.bottom + areaOffset.bottom - area.angleSize) + 'px',
'z-index': area.zIndex + 3
}
]);
}
// 变更圆角样式
if(area.radius > 0) {
var radius = area.radius;
if(area.width === area.height && area.radius >= area.width / 2) { // 圆形
radius = (area.width / 2);
} else { // 圆角矩形
if(area.width !== area.height) { // 限制圆角半径不能超过短边的一半
radius = Math.min(area.width / 2, area.height / 2, radius);
}
}
setStyle(e.instance, 'circleBoxStyles', {
left: (area.left + areaOffset.left) + 'px',
top: (area.top + areaOffset.top) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
height: (area.height + areaOffset.bottom - areaOffset.top) + 'px',
'z-index': area.zIndex + 2
});
setStyle(e.instance, 'circleStyles', {
'box-shadow': '0 0 0 ' + Math.max(area.width, area.height) + 'px rgba(51, 51, 51, 0.8)',
'border-radius': radius + 'px'
});
}
};
/**
* 缩放图片
* @param {Object} e 布局信息
*/
function scaleImage(e) {
// console.log('scaleImage', e)
var last = scale;
scale = Math.min(Math.max(e.scale + scale, minScale), img.maxScale);
if(last !== scale) {
img.width = num(img.oldWidth * scale);
img.height = num(img.oldHeight * scale);
// 参考问题:有一个长4000px、宽4000px的四方形ABCDA点的坐标固定在(-2000,-2000)
// 该四边形上有一个点E,坐标为(-100,-300),将该四方形复制一份并缩小到90%后,
// 新四边形的A点坐标为多少时可使新四边形的E点与原四边形的E点重合?
// 预期效果:从图中选取某点(参照物)为中心点进行缩放,缩放时无论图像怎么变化,该点位置始终固定不变
// 计算方法:以相同起点先计算缩放前后两点间的距离,再加上原图像偏移量即可
e.x = num((e.x - offset.x) * (1 - scale / last));
e.y = num((e.y - offset.y) * (1 - scale / last));
changeImageRect(e);
return true;
}
return false;
};
/**
* 获取触摸点在哪个角
* @param {number} x 触摸点x轴坐标
* @param {number} y 触摸点y轴坐标
* @return {number} 角的位置:0=无;1=左上;2=右上;3=左下;4=右下;
*/
function getToucheAngle(x, y) {
// console.log('getToucheAngle', x, y, JSON.stringify(area))
var o = area.angleBorderWidth; // 需扩大触发范围则把 o 值加大即可
var oy = sys.navigation ? 0 : sys.windowTop;
if(y >= area.top - o + oy && y <= area.top + area.angleSize + o + oy) {
if(x >= area.left - o && x <= area.left + area.angleSize + o) {
return 1; // 左上角
} else if(x >= area.right - area.angleSize - o && x <= area.right + o) {
return 2; // 右上角
}
} else if(y >= area.bottom - area.angleSize - o + oy && y <= area.bottom + o + oy) {
if(x >= area.left - o && x <= area.left + area.angleSize + o) {
return 3; // 左下角
} else if(x >= area.right - area.angleSize - o && x <= area.right + o) {
return 4; // 右下角
}
}
return 0; // 无触摸到角
};
/**
* 重置数据
*/
function resetData() {
offset = { x: 0, y: 0 };
scale = 1;
minScale = img.minScale;
rotate = 0;
};
function getTouchs(touches) {
var result = [];
var len = touches ? touches.length : 0
for (var i = 0; i < len; i++) {
result[i] = {
pageX: touches[i].pageX,
// h5无标题栏时,窗口顶部距离仍为标题栏高度,且触摸点y轴坐标还是有标题栏的值,即减去标题栏高度的值
pageY: touches[i].pageY + sys.windowTop
};
}
return result;
};
var mouseEvent = false;
export default {
data() {
return {
imageStyles: {},
maskStylesList: [{}, {}, {}, {}],
borderStyles: {},
gridStylesList: [{}, {}, {}, {}],
angleStylesList: [{}, {}, {}, {}],
circleBoxStyles: {},
circleStyles: {}
}
},
created() {
// 监听 PC 端鼠标滚轮
// #ifdef H5
platform === 'H5' && window.addEventListener('mousewheel', async (e) => {
var touchs = getTouchs([e])
img.src && scaleImage({
instance: await this.getInstance(),
check: true,
// 鼠标向上滚动时,deltaY 固定 -100,鼠标向下滚动时,deltaY 固定 100
scale: e.deltaY > 0 ? -0.05 : 0.05,
x: touchs[0].pageX,
y: touchs[0].pageY
});
});
// #endif
},
// #ifdef H5
mounted() {
platform === 'H5' && this.initH5Events();
},
// #endif
setPlatform(p) {
platform = p;
},
methods: {
// #ifdef H5
getTouchEvent(e) {
e.touches = [
{ pageX: e.pageX, pageY: e.pageY }
];
return e;
},
initH5Events() {
const preview = document.getElementById('pic-preview');
preview?.addEventListener('mousedown', (e, ev) => {
mouseEvent = true;
this.touchstart(this.getTouchEvent(e));
});
preview?.addEventListener('mousemove', (e) => {
if (!mouseEvent) return;
this.touchmove(this.getTouchEvent(e));
});
preview?.addEventListener('mouseup', (e) => {
mouseEvent = false;
this.touchend(this.getTouchEvent(e))
});
preview?.addEventListener('mouseleave', (e) => {
mouseEvent = false;
this.touchend(this.getTouchEvent(e))
});
},
// #endif
async getInstance() {
// #ifdef APP-PLUS
if(platform === 'APP')
return this.$ownerInstance
? Promise.resolve(this.$ownerInstance)
: new Promise((resolve) => {
setTimeout(async () => {
resolve(await this.getInstance());
});
});
// #endif
// #ifdef H5
if(platform === 'H5')
return Promise.resolve(this);
// #endif
},
/**
* 初始化:观察数据变更
* @param {Object} newVal 新数据
* @param {Object} oldVal 旧数据
* @param {Object} o 组件实例对象
*/
initObserver: async function(newVal, oldVal, o, i) {
// console.log('initObserver', newVal, oldVal, o, i)
if(newVal && (!img.src || timestamp !== newVal.timestamp)) {
timestamp = newVal.timestamp;
img = newVal.img;
sys = newVal.sys;
area = newVal.area;
minScale = img.minScale;
resetData();
const instance = await this.getInstance()
img.src && changeImageRect({
instance,
x: (sys.windowWidth - img.width) / 2,
y: (sys.windowHeight + sys.windowTop - sys.offsetBottom - img.height) / 2
});
changeAreaRect({
instance
});
}
},
/**
* 鼠标滚轮滚动
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
mousewheel: function(e, o) {
// h5平台 wheel 事件无法判断滚轮滑动方向,需使用 mousewheel
},
/**
* 触摸开始
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
touchstart: function(e, o) {
if(!img.src) return;
touches = getTouchs(e.touches);
activeAngle = area.showAngle ? getToucheAngle(touches[0].pageX, touches[0].pageY) : 0;
if(touches.length === 1 && activeAngle !== 0) {
touchType = 'stretch'; // 伸缩裁剪区域
} else {
touchType = '';
}
// console.log('touchstart', e, activeAngle)
},
/**
* 触摸移动
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
touchmove: async function(e, o) {
if(!img.src) return;
// console.log('touchmove', e, o)
e.touches = getTouchs(e.touches);
if(touchType === 'stretch') { // 触摸四个角进行拉伸
var point = e.touches[0];
var start = touches[0];
var x = point.pageX - start.pageX;
var y = point.pageY - start.pageY;
if(x !== 0 || y !== 0) {
var maxX = num(area.width * (1 - area.minScale));
var maxY = num(area.height * (1 - area.minScale));
// console.log(x, y, maxX, maxY, offset, area)
touches[0] = point;
var r = rotate / 90 % 2;
var m = r === 1 ? num((img.height - img.width) / 2) : 0; // 宽高差值一半
var xCompare = r === 1 ? lessThanByFault(img.height, area.width) : lessThanByFault(img.width, area.width);
var yCompare = r === 1 ? lessThanByFault(img.width, area.height) : lessThanByFault(img.height, area.height)
var isInclude = xCompare && yCompare;
var isIntersect = area.checkRange && (xCompare || yCompare); // 图片是否包含在裁剪区域内
var isReverse = !isInclude || num((offset.x - area.left) / area.width) <= num((offset.y - area.top) / area.height) || (area.width > area.height && img.width < img.height && r === 1);
switch(activeAngle) {
case 1: // 左上角
x = num(x + areaOffset.left);
y = num(y + areaOffset.top);
if(x >= 0 && y >= 0) { // 有效滑动
var t = num(offset.y + m - area.top);
var l = num(offset.x - m - area.left);
// && (offset.x + img.width < area.right || offset.y + img.height < area.bottom)
var max = isIntersect && ((l >= 0) || (t >= 0))
? minimum(t, l)
: false;
if(x > y && isReverse) { // 以x轴滑动距离为缩放基准
maxX = validMax(maxX, max, isInclude, l, t, area.width / area.height);
if(x > maxX) x = maxX;
y = num(x * area.height / area.width);
} else { // 以y轴滑动距离为缩放基准
maxY = validMax(maxY, max, isInclude, t, l, area.height / area.width);
if(y > maxY) y = maxY;
x = num(y * area.width / area.height);
}
areaOffset.left = x;
areaOffset.top = y;
}
break;
case 2: // 右上角
x = num(x + areaOffset.right);
y = num(y + areaOffset.top);
if(x <= 0 && y >= 0) { // 有效滑动
var w = (r === 1 ? img.height : img.width);
var t = num(offset.y + m - area.top);
var l = num(area.right + m - offset.x - w);
var max = isIntersect && ((t >= 0) || (l >= 0))
? minimum(t, l)
: false;
if(-x > y && isReverse) { // 以x轴滑动距离为缩放基准
maxX = validMax(maxX, max, isInclude, l, t, area.width / area.height);
if(-x > maxX) x = -maxX;
y = num(-x * area.height / area.width);
} else { // 以y轴滑动距离为缩放基准
maxY = validMax(maxY, max, isInclude, t, l, area.height / area.width);
if(y > maxY) y = maxY;
x = num(-y * area.width / area.height);
}
areaOffset.right = x;
areaOffset.top = y;
}
break;
case 3: // 左下角
x += num(x + areaOffset.left);
y += num(y + areaOffset.bottom);
if(x >= 0 && y <= 0) { // 有效滑动
var w = (r === 1 ? img.width : img.height);
var t = num(area.bottom - m - offset.y - w);
var l = num(offset.x - m - area.left);
var max = isIntersect && ((l >= 0) || (t >= 0))
? minimum(t, l)
: false;
if(x > -y && isReverse) { // 以x轴滑动距离为缩放基准
maxX = validMax(maxX, max, isInclude, l, t, area.width / area.height);
if(x > maxX) x = maxX;
y = num(-x * area.height / area.width);
} else { // 以y轴滑动距离为缩放基准
maxY = validMax(maxY, max, isInclude, t, l, area.height / area.width);
if(-y > maxY) y = -maxY;
x = num(-y * area.width / area.height);
}
areaOffset.left = x;
areaOffset.bottom = y;
}
break;
case 4: // 右下角
x = num(x + areaOffset.right);
y = num(y + areaOffset.bottom);
if(x <= 0 && y <= 0) { // 有效滑动
var w = (r === 1 ? img.height : img.width);
var h = (r === 1 ? img.width : img.height);
var t = num(area.bottom - offset.y - h - m);
var l = num(area.right + m - offset.x - w);
var max = isIntersect && ((l >= 0) || (t >= 0))
? minimum(t, l)
: false;
if(-x > -y && isReverse) { // 以x轴滑动距离为缩放基准
maxX = validMax(maxX, max, isInclude, l, t, area.width / area.height);
if(-x > maxX) x = -maxX;
y = num(x * area.height / area.width);
} else { // 以y轴滑动距离为缩放基准
maxY = validMax(maxY, max, isInclude, t, l, area.height / area.width);
if(-y > maxY) y = -maxY;
x = num(y * area.width / area.height);
}
areaOffset.right = x;
areaOffset.bottom = y;
}
break;
}
// console.log(x, y, JSON.stringify(areaOffset))
changeAreaRect({
instance: await this.getInstance(),
});
// this.draw();
}
} else if (e.touches.length == 2) { // 双点触摸缩放
var start = getDistanceByTouches(touches);
var end = getDistanceByTouches(e.touches);
scaleImage({
instance: await this.getInstance(),
check: !area.bounce,
scale: (end.c - start.c) / 100,
x: end.x,
y: end.y
});
touchType = 'scale';
} else if(touchType === 'scale') {// 从双点触摸变成单点触摸 / 从缩放变成拖动
touchType = 'move';
} else {
changeImageRect({
instance: await this.getInstance(),
check: !area.bounce,
x: e.touches[0].pageX - touches[0].pageX,
y: e.touches[0].pageY - touches[0].pageY
});
touchType = 'move';
}
touches = e.touches;
},
/**
* 触摸结束
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
touchend: async function(e, o) {
if(!img.src) return;
if(touchType === 'stretch') { // 拉伸裁剪区域的四个角缩放
// 裁剪区域宽度被缩放到多少
var left = areaOffset.left;
var right = areaOffset.right;
var top = areaOffset.top;
var bottom = areaOffset.bottom;
var w = area.width + right - left;
var h = area.height + bottom - top;
// 图像放大倍数
var p = scale * (area.width / w) - scale;
// 复原裁剪区域
areaOffset = { left: 0, right: 0, top: 0, bottom: 0 };
changeAreaRect({
instance: await this.getInstance(),
});
scaleImage({
instance: await this.getInstance(),
scale: p,
x: area.left + left + (1 === activeAngle || 3 === activeAngle ? w : 0),
y: area.top + top + (1 === activeAngle || 2 === activeAngle ? h : 0)
});
} else if (area.bounce) { // 检查边界并矫正,实现拖动到边界时有回弹效果
changeImageRect({
instance: await this.getInstance(),
check: true
});
}
},
/**
* 顺时针翻转图片90°
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
rotateImage: async function(r) {
rotate = (rotate + (r || 90)) % 360;
if(img.minScale >= 1 && area.checkRange) {
// 因图片宽高可能不等,翻转后图片宽高需足够填满裁剪区域
minScale = 1;
if(img.width < area.height) {
minScale = area.height / img.oldWidth;
} else if(img.height < area.width) {
minScale = area.width / img.oldHeight;
}
if(minScale !== 1) {
scaleImage({
instance: await this.getInstance(),
scale: minScale - scale,
x: sys.windowWidth / 2,
y: (sys.windowHeight - sys.offsetBottom) / 2
});
}
}
// 由于拖动画布后会导致图片位置偏移,翻转时的旋转中心点需是图片区域+偏移区域的中心点
// 翻转x轴中心点 = (超出裁剪区域右侧的图片宽度 - 超出裁剪区域左侧的图片宽度) / 2
// 翻转y轴中心点 = (超出裁剪区域下方的图片宽度 - 超出裁剪区域上方的图片宽度) / 2
var ox = ((offset.x + img.width - area.right) - (area.left - offset.x)) / 2;
var oy = ((offset.y + img.height - area.bottom) - (area.top - offset.y)) / 2;
changeImageRect({
instance: await this.getInstance(),
check: true,
x: -ox - oy,
y: -oy + ox
});
},
rotateImage90: function() {
this.rotateImage(90)
},
rotateImage270: function() {
this.rotateImage(270)
},
}
}
@@ -0,0 +1,752 @@
<template>
<view class="image-cropper" :style="{ zIndex }" @wheel="cropper.mousewheel">
<canvas v-if="use2d" type="2d" id="imgCanvas" class="img-canvas" :style="{
width: `${canvansWidth}px`,
height: `${canvansHeight}px`
}"></canvas>
<canvas v-else id="imgCanvas" canvas-id="imgCanvas" class="img-canvas" :style="{
width: `${canvansWidth}px`,
height: `${canvansHeight}px`
}"></canvas>
<view id="pic-preview" class="pic-preview" :change:init="cropper.initObserver" :init="initData" @touchstart="cropper.touchstart" @touchmove="cropper.touchmove" @touchend="cropper.touchend">
<image v-if="imgSrc" id="crop-image" class="crop-image" :style="cropper.imageStyles" :src="imgSrc" webp></image>
<view v-for="(item, index) in maskList" :key="item.id" :id="item.id" class="crop-mask-block" :style="cropper.maskStylesList[index]"></view>
<view v-if="showBorder" id="crop-border" class="crop-border" :style="cropper.borderStyles"></view>
<view v-if="radius > 0" id="crop-circle-box" class="crop-circle-box" :style="cropper.circleBoxStyles">
<view class="crop-circle" id="crop-circle" :style="cropper.circleStyles"></view>
</view>
<block v-if="showGrid">
<view v-for="(item, index) in gridList" :key="item.id" :id="item.id" class="crop-grid" :style="cropper.gridStylesList[index]"></view>
</block>
<block v-if="showAngle">
<view v-for="(item, index) in angleList" :key="item.id" :id="item.id" class="crop-angle" :style="cropper.angleStylesList[index]">
<view :style="[{
width: `${angleSize}px`,
height: `${angleSize}px`
}]"></view>
</view>
</block>
</view>
<slot />
<view class="fixed-bottom safe-area-inset-bottom" :style="{ zIndex: initData.area.zIndex + 99 }">
<view v-if="(rotatable || reverseRotatable) && !!imgSrc" class="action-bar">
<view v-if="reverseRotatable" class="rotate-icon" @click="cropper.rotateImage270"></view>
<view v-if="rotatable" class="rotate-icon is-reverse" @click="cropper.rotateImage90"></view>
</view>
<view v-if="!choosable" class="choose-btn" @click="cropClick">确定</view>
<block v-else-if="!!imgSrc">
<view class="rechoose" @click="chooseImage">重选</view>
<button class="button warn" type="primary" size="mini" @click="cropCancel">取消</button>
<button class="button" size="mini" @click="cropClick">确定</button>
</block>
<view v-else class="choose-btn" @click="chooseImage">选择图片</view>
</view>
</view>
</template>
<!-- #ifdef APP-VUE -->
<script module="cropper" lang="renderjs">
import cropper from './qf-image-cropper.render.js';
// vue3 app renderjs中条件编译无效
cropper.setPlatform('APP');
export default {
mixins: [ cropper ]
}
</script>
<!-- #endif -->
<!-- #ifdef H5 -->
<script module="cropper" lang="renderjs">
import cropper from './qf-image-cropper.render.js';
export default {
mixins: [ cropper ]
}
</script>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN || MP-QQ -->
<script module="cropper" lang="wxs" src="./qf-image-cropper.wxs"></script>
<!-- #endif -->
<script>
/** 裁剪区域最大宽高所占屏幕宽度百分比 */
const AREA_SIZE = 75;
/** 图片默认宽高 */
const IMG_SIZE = 300;
export default {
name:"qf-image-cropper",
// #ifdef MP-WEIXIN
options: {
// 表示启用样式隔离,在自定义组件内外,使用 class 指定的样式将不会相互影响
styleIsolation: "isolated"
},
// #endif
props: {
/** 图片资源地址 */
src: {
type: String,
default: ''
},
/** 裁剪宽度,有些平台或设备对于canvas的尺寸有限制,过大可能会导致无法正常绘制 */
width: {
type: Number,
default: IMG_SIZE
},
/** 裁剪高度,有些平台或设备对于canvas的尺寸有限制,过大可能会导致无法正常绘制 */
height: {
type: Number,
default: IMG_SIZE
},
/** 是否绘制裁剪区域边框 */
showBorder: {
type: Boolean,
default: true
},
/** 是否绘制裁剪区域网格参考线 */
showGrid: {
type: Boolean,
default: true
},
/** 是否展示四个支持伸缩的角 */
showAngle: {
type: Boolean,
default: true
},
/** 裁剪区域最小缩放倍数 */
areaScale: {
type: Number,
default: 0.3
},
/** 图片最小缩放倍数 */
minScale: {
type: Number,
default: 1
},
/** 图片最大缩放倍数 */
maxScale: {
type: Number,
default: 5
},
/** 检查图片位置是否超出裁剪边界,如果超出则会矫正位置 */
checkRange: {
type: Boolean,
default: true
},
/** 生成图片背景色:如果裁剪区域没有完全包含在图片中时,不设置该属性生成图片存在一定的透明块 */
backgroundColor: {
type: String
},
/** 是否有回弹效果:当 checkRange 为 true 时有效,拖动时可以拖出边界,释放时会弹回边界 */
bounce: {
type: Boolean,
default: true
},
/** 是否支持翻转 */
rotatable: {
type: Boolean,
default: true
},
/** 是否支持逆向翻转 */
reverseRotatable: {
type: Boolean,
default: false
},
/** 是否支持从本地选择素材 */
choosable: {
type: Boolean,
default: true
},
/** 是否开启硬件加速,图片缩放过程中如果出现元素的“留影”或“重影”效果,可通过该方式解决或减轻这一问题 */
gpu: {
type: Boolean,
default: false
},
/** 四个角尺寸,单位px */
angleSize: {
type: Number,
default: 20
},
/** 四个角边框宽度,单位px */
angleBorderWidth: {
type: Number,
default: 2
},
zIndex: {
type: [Number, String]
},
/** 裁剪图片圆角半径,单位px */
radius: {
type: Number,
default: 0
},
/** 生成文件的类型,只支持 'jpg' 或 'png'。默认为 'png' */
fileType: {
type: String,
default: 'png'
},
/**
* 图片从绘制到生成所需时间,单位ms
* 微信小程序平台使用 `Canvas 2D` 绘制时有效
* 如绘制大图或出现裁剪图片空白等情况应适当调大该值,因 `Canvas 2d` 采用同步绘制,需自己把控绘制完成时间
*/
delay: {
type: Number,
default: 1000
},
// #ifdef H5
/**
* 页面是否是原生标题栏
* H5平台当 showAngle 为 true 时,使用插件的页面在 `page.json` 中配置了 "navigationStyle": "custom" 时,必须将此值设为 false ,否则四个可拉伸角的触发位置会有偏差。
* 注:因H5平台的窗口高度是包含标题栏的,而屏幕触摸点的坐标是不包含的
*/
navigation: {
type: Boolean,
default: true
}
// #endif
},
emits: ["crop"],
data() {
return {
// 用不同 id 使 v-for key 不重复
maskList: [
{ id: 'crop-mask-block-1' },
{ id: 'crop-mask-block-2' },
{ id: 'crop-mask-block-3' },
{ id: 'crop-mask-block-4' },
],
gridList: [
{ id: 'crop-grid-1' },
{ id: 'crop-grid-2' },
{ id: 'crop-grid-3' },
{ id: 'crop-grid-4' },
],
angleList: [
{ id: 'crop-angle-1' },
{ id: 'crop-angle-2' },
{ id: 'crop-angle-3' },
{ id: 'crop-angle-4' },
],
/** 本地缓存的图片路径 */
imgSrc: '',
/** 图片的裁剪宽度 */
imgWidth: IMG_SIZE,
/** 图片的裁剪高度 */
imgHeight: IMG_SIZE,
/** 裁剪区域最大宽度所占屏幕宽度百分比 */
widthPercent: AREA_SIZE,
/** 裁剪区域最大高度所占屏幕宽度百分比 */
heightPercent: AREA_SIZE,
/** 裁剪区域布局信息 */
area: {},
/** 未被缩放过的图片宽 */
oldWidth: 0,
/** 未被缩放过的图片高 */
oldHeight: 0,
/** 系统信息 */
sys: uni.getSystemInfoSync(),
scaleWidth: 0,
scaleHeight: 0,
rotate: 0,
offsetX: 0,
offsetY: 0,
use2d: false,
canvansWidth: 0,
canvansHeight: 0,
// imageStyles: {},
// maskStylesList: [{}, {}, {}, {}],
// borderStyles: {},
// gridStylesList: [{}, {}, {}, {}],
// angleStylesList: [{}, {}, {}, {}],
// circleBoxStyles: {},
// circleStyles: {},
}
},
computed: {
initData() {
// console.log('initData')
return {
timestamp: new Date().getTime(),
area: {
...this.area,
bounce: this.bounce,
showBorder: this.showBorder,
showGrid: this.showGrid,
showAngle: this.showAngle,
angleSize: this.angleSize,
angleBorderWidth: this.angleBorderWidth,
minScale: this.areaScale,
widthPercent: this.widthPercent,
heightPercent: this.heightPercent,
radius: this.radius,
checkRange: this.checkRange,
zIndex: +this.zIndex || 0,
},
sys: this.sys,
img: {
minScale: this.minScale,
maxScale: this.maxScale,
src: this.imgSrc,
width: this.oldWidth,
height: this.oldHeight,
oldWidth: this.oldWidth,
oldHeight: this.oldHeight,
gpu: this.gpu,
}
}
},
imgProps() {
return {
width: this.width,
height: this.height,
src: this.src,
}
}
},
watch: {
imgProps: {
handler(val, oldVal) {
// 自定义裁剪尺,示例如下:
this.imgWidth = Number(val.width) || IMG_SIZE;
this.imgHeight = Number(val.height) || IMG_SIZE;
let use2d = true;
// #ifndef MP-WEIXIN
use2d = false;
// #endif
// if(use2d && (this.imgWidth > 1365 || this.imgHeight > 1365)) {
// use2d = false;
// }
let canvansWidth = this.imgWidth;
let canvansHeight = this.imgHeight;
let size = Math.max(canvansWidth, canvansHeight)
let scalc = 1;
if(size > 1365) {
scalc = 1365 / size;
}
this.canvansWidth = canvansWidth * scalc;
this.canvansHeight = canvansHeight * scalc;
this.use2d = use2d;
this.initArea();
const src = val.src || this.imgSrc;
src && this.initImage(src, oldVal === undefined);
},
immediate: true
},
},
methods: {
/** 提供给wxs调用,用来接收图片变更数据 */
dataChange(e) {
// console.log('dataChange', e)
this.scaleWidth = e.width;
this.scaleHeight = e.height;
this.rotate = e.rotate;
this.offsetX = e.x;
this.offsetY = e.y;
},
/** 初始化裁剪区域布局信息 */
initArea() {
// 底部操作栏高度 = 底部底部操作栏内容高度 + 设备底部安全区域高度
this.sys.offsetBottom = uni.upx2px(100) + this.sys.safeAreaInsets.bottom;
// #ifndef H5
this.sys.windowTop = 0;
this.sys.navigation = true;
// #endif
// #ifdef H5
// h5平台的窗口高度是包含标题栏的
this.sys.windowTop = this.sys.windowTop || 44;
this.sys.navigation = this.navigation;
// #endif
let wp = this.widthPercent;
let hp = this.heightPercent;
if (this.imgWidth > this.imgHeight) {
hp = hp * this.imgHeight / this.imgWidth;
} else if (this.imgWidth < this.imgHeight) {
wp = wp * this.imgWidth / this.imgHeight;
}
const size = this.sys.windowWidth > this.sys.windowHeight ? this.sys.windowHeight : this.sys.windowWidth;
const width = size * wp / 100;
const height = size * hp / 100;
const left = (this.sys.windowWidth - width) / 2;
const right = left + width;
const top = (this.sys.windowHeight + this.sys.windowTop - this.sys.offsetBottom - height) / 2;
const bottom = this.sys.windowHeight + this.sys.windowTop - this.sys.offsetBottom - top;
this.area = { width, height, left, right, top, bottom };
this.scaleWidth = width;
this.scaleHeight = height;
},
/** 从本地选取图片 */
chooseImage(options) {
// #ifdef MP-WEIXIN || MP-JD
if(uni.chooseMedia) {
uni.chooseMedia({
...options,
count: 1,
mediaType: ['image'],
success: (res) => {
this.resetData();
this.initImage(res.tempFiles[0].tempFilePath);
}
});
return;
}
// #endif
uni.chooseImage({
...options,
count: 1,
success: (res) => {
this.resetData();
this.initImage(res.tempFiles[0].path);
}
});
},
/** 重置数据 */
resetData() {
this.imgSrc = '';
this.rotate = 0;
this.offsetX = 0;
this.offsetY = 0;
this.initArea();
},
/**
* 初始化图片信息
* @param {String} url 图片链接
*/
initImage(url, isFirst) {
uni.getImageInfo({
src: url,
success: async (res) => {
if (isFirst && this.src === url) await (new Promise((resolve) => setTimeout(resolve, 50)));
this.imgSrc = res.path;
let scale = res.width / res.height;
let areaScale = this.area.width / this.area.height;
if (scale > 1) { // 横向图片
if (scale >= areaScale) { // 图片宽不小于目标宽,则高固定,宽自适应
this.scaleWidth = (this.scaleHeight / res.height) * this.scaleWidth * (res.width / this.scaleWidth);
} else { // 否则宽固定、高自适应
this.scaleHeight = res.height * this.scaleWidth / res.width;
}
} else { // 纵向图片
if (scale <= areaScale) { // 图片高不小于目标高,宽固定,高自适应
this.scaleHeight = (this.scaleWidth / res.width) * this.scaleHeight / (this.scaleHeight / res.height);
} else { // 否则高固定,宽自适应
this.scaleWidth = res.width * this.scaleHeight / res.height;
}
}
// 记录原始宽高,为缩放比列做限制
this.oldWidth = +this.scaleWidth.toFixed(2);
this.oldHeight = +this.scaleHeight.toFixed(2);
},
fail: (err) => {
console.error(err)
}
});
},
/**
* 剪切图片圆角
* @param {Object} ctx canvas 的绘图上下文对象
* @param {Number} radius 圆角半径
* @param {Number} scale 生成图片的实际尺寸与截取区域比
* @param {Function} drawImage 执行剪切时所调用的绘图方法,入参为是否执行了剪切
*/
drawClipImage(ctx, radius, scale, drawImage) {
if(radius > 0) {
ctx.save();
ctx.beginPath();
const w = this.canvansWidth;
const h = this.canvansHeight;
if(w === h && radius >= w / 2) { // 圆形
ctx.arc(w / 2, h / 2, w / 2, 0, 2 * Math.PI);
} else { // 圆角矩形
if(w !== h) { // 限制圆角半径不能超过短边的一半
radius = Math.min(w / 2, h / 2, radius);
// radius = Math.min(Math.max(w, h) / 2, radius);
}
ctx.moveTo(radius, 0);
ctx.arcTo(w, 0, w, h, radius);
ctx.arcTo(w, h, 0, h, radius);
ctx.arcTo(0, h, 0, 0, radius);
ctx.arcTo(0, 0, w, 0, radius);
ctx.closePath();
}
ctx.clip();
drawImage && drawImage(true);
ctx.restore();
} else {
drawImage && drawImage(false);
}
},
/**
* 旋转图片
* @param {Object} ctx canvas 的绘图上下文对象
* @param {Number} rotate 旋转角度
* @param {Number} scale 生成图片的实际尺寸与截取区域比
*/
drawRotateImage(ctx, rotate, scale) {
if(rotate !== 0) {
// 1. 以图片中心点为旋转中心点
const x = this.scaleWidth * scale / 2;
const y = this.scaleHeight * scale / 2;
ctx.translate(x, y);
// 2. 旋转画布
ctx.rotate(rotate * Math.PI / 180);
// 3. 旋转完画布后恢复设置旋转中心时所做的偏移
ctx.translate(-x, -y);
}
},
drawImage(ctx, image, callback) {
// 生成图片的实际尺寸与截取区域比
const scale = this.canvansWidth / this.area.width;
if(this.backgroundColor) {
if(ctx.setFillStyle) ctx.setFillStyle(this.backgroundColor);
else ctx.fillStyle = this.backgroundColor;
ctx.fillRect(0, 0, this.canvansWidth, this.canvansHeight);
}
this.drawClipImage(ctx, this.radius, scale, () => {
this.drawRotateImage(ctx, this.rotate, scale);
const r = this.rotate / 90;
ctx.drawImage(
image,
[
(this.offsetX - this.area.left),
(this.offsetY - this.area.top),
-(this.offsetX - this.area.left),
-(this.offsetY - this.area.top)
][r] * scale,
[
(this.offsetY - this.area.top),
-(this.offsetX - this.area.left),
-(this.offsetY - this.area.top),
(this.offsetX - this.area.left)
][r] * scale,
this.scaleWidth * scale,
this.scaleHeight * scale
);
});
},
/**
* 绘图
* @param {Object} canvas
* @param {Object} ctx canvas 的绘图上下文对象
* @param {String} src 图片路径
* @param {Function} callback 开始绘制时回调
*/
draw2DImage(canvas, ctx, src, callback) {
// console.log('draw2DImage', canvas, ctx, src, callback)
if(canvas) {
const image = canvas.createImage();
image.onload = () => {
this.drawImage(ctx, image);
// 如果觉得`生成时间过长`或`出现生成图片空白`可尝试调整延迟时间
callback && setTimeout(callback, this.delay);
};
image.onerror = (err) => {
console.error(err)
uni.hideLoading();
};
image.src = src;
} else {
this.drawImage(ctx, src);
setTimeout(() => {
ctx.draw(false, callback);
}, 200);
}
},
/**
* 画布转图片到本地缓存
* @param {Object} canvas
* @param {String} canvasId
*/
canvasToTempFilePath(canvas, canvasId) {
// console.log('canvasToTempFilePath', canvas, canvasId)
uni.canvasToTempFilePath({
canvas,
canvasId,
x: 0,
y: 0,
width: this.canvansWidth,
height: this.canvansHeight,
destWidth: this.imgWidth, // 必要,保证生成图片宽度不受设备分辨率影响
destHeight: this.imgHeight, // 必要,保证生成图片高度不受设备分辨率影响
fileType: this.fileType, // 目标文件的类型,默认png
success: (res) => {
// 生成的图片临时文件路径
this.handleImage(res.tempFilePath);
},
fail: (err) => {
uni.hideLoading();
uni.showToast({ title: '裁剪失败,生成图片异常!', icon: 'none' });
}
}, this);
},
cropCancel(){
this.$emit('cancel');
},
/** 确认裁剪 */
cropClick() {
uni.showLoading({ title: '裁剪中...', mask: true });
if(!this.use2d) {
const ctx = uni.createCanvasContext('imgCanvas', this);
ctx.clearRect(0, 0, this.canvansWidth, this.canvansHeight);
this.draw2DImage(null, ctx, this.imgSrc, () => {
this.canvasToTempFilePath(null, 'imgCanvas');
});
return;
}
// #ifdef MP-WEIXIN
const query = uni.createSelectorQuery().in(this);
query.select('#imgCanvas')
.fields({ node: true, size: true })
.exec((res) => {
const canvas = res[0].node;
const dpr = uni.getSystemInfoSync().pixelRatio;
canvas.width = res[0].width * dpr;
canvas.height = res[0].height * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, this.canvansWidth, this.canvansHeight);
this.draw2DImage(canvas, ctx, this.imgSrc, () => {
this.canvasToTempFilePath(canvas);
});
});
// #endif
},
handleImage(tempFilePath){
// 在H5平台下,tempFilePath 为 base64
// console.log(tempFilePath)
uni.hideLoading();
this.$emit('crop', { tempFilePath });
}
}
}
</script>
<style lang="scss" scoped>
.image-cropper {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
overflow: hidden;
display: flex;
flex-direction: column;
background-color: #000;
.img-canvas {
position: absolute !important;
transform: translateX(-100%);
}
.pic-preview {
width: 100%;
flex: 1;
position: relative;
.crop-mask-block {
background-color: rgba(51, 51, 51, 0.8);
z-index: 2;
position: fixed;
box-sizing: border-box;
pointer-events: none;
}
.crop-circle-box {
position: fixed;
box-sizing: border-box;
z-index: 2;
pointer-events: none;
overflow: hidden;
.crop-circle {
width: 100%;
height: 100%;
}
}
.crop-image {
padding: 0 !important;
margin: 0 !important;
border-radius: 0 !important;
display: block !important;
backface-visibility: hidden;
}
.crop-border {
position: fixed;
border: 1px solid #fff;
box-sizing: border-box;
z-index: 3;
pointer-events: none;
}
.crop-grid {
position: fixed;
z-index: 3;
border-style: dashed;
border-color: #fff;
pointer-events: none;
opacity: 0.5;
}
.crop-angle {
position: fixed;
z-index: 3;
border-style: solid;
border-color: #fff;
pointer-events: none;
}
}
.fixed-bottom {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
display: flex;
flex-direction: row;
background-color: $uni-bg-color-grey;
.action-bar {
position: absolute;
top: -90rpx;
left: 10rpx;
display: flex;
.rotate-icon {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAABCFJREFUaEPtml3IpVMUx3//ko/ChTIyiGFSMyhllI8bc4F85yuNC2FCqLmQC1+FZORiEkUMNW7UjKjJULgxV+NzSkxDhEkZgwsyigv119J63p7zvOc8z37OmXdOb51dz82711r7/99r7bXXXucVi3xokeNnRqCvB20fDmwAlgK/5bcD+FTSr33tHXQP2H4MeHQE0A+B5yRtLiUyDQJrgVc6AAaBpyV93kXkoBMIQLbfBS5NcK8BRwDXNcD+AdwnaVMbiWkRCPBBohpxHuK7M7865sclRdgNHVMhkF6IMIpwirFEUhzo8M7lwIvASTXEqyVtH8ZgagQSbOzsDknv18HZXpHn5IL8+94IOUm7miSmSqAttjPdbgGuTrnNktYsGgLpoYuAD2qg1zRTbG8P2D4SOC6/Q7vSHPALsE/S7wWy80RsPw/ckxMfSTq/LtRJwPbxwF3ASiCUTxwHCPAnEBfVF8AWSTtL7Ng+LfWOTfmlkn6udFsJ5K15R6a4kvX6yGyUFBvTOWzHXXFzCt4g6c1OArYj9iIGh43YgR+BvztXh1PSa4cMkd0jaVmXDduPAE+k3HpJD7cSGFKvfAc8FQUX8IOk/V2L1udtB/hTgdOBW4Aba/M7Ja1qs2f7euCNlHlZUlx4/495IWQ7Jl+qGbxX0gt9AHfJ2o6zFBVoNVrDKe+F3Sm8VdK1bQQ+A85JgXckXdkFaJx527cC9TpnVdvBtl3h2iapuhsGPdBw1b9xnUvaNw7AEh3bnwDnpuwGSfeP0rN9NvAMELXRXFkxEEK2nwQeSiOtRVQJwC4Z29cAW1Nuu6TVXTrN+SaBt4ErUug2Sa/2NdhH3vZy4NvU2S/p6D768w5xI3WOrAD7LtISFpGdIhVXKfaYvjd20wP13L9M0p4DBbaFRKToSLExVkr6qs+aIwlI6iwz+izUQqC+ab29PiMwqRcmPXczD8w8MFj1zg7xXEqbpdHCw7FgWSjafZL+KcQxtpjteCeflwYulFR/J3TabSslVkj6utPChAK2f6q9uZdLitKieLQRuExSvX9ZbLRUMFs09efpUZL+KtUfVo1GW/umNHC3pOhRLtiwfSbwZS6wV9IJfRdreuBBYH0a2STp9r4G+8jbXgc8mzoDT8VSO00ClwDv1ZR7XyylC4ec7ejaLUmdsV6Aw7oSbwFXpdFdks7qA6pU1na0aR6owgeIR/1cx63UzjAC0YXYVjMQHlkn6ZtSo21ytuPZGKFagQ/xsXZ/3iGuFrYdjafXG0DiQMeBi47c9/GV3BO247UV38n5o0UAP6xmu7jFOGxjRr66On5NPBDOCBsDTapxjHY1dyOcolNXnYlx1himE53p2PmNkxosevfavhg4Izt2k7TXPwZ2S6p6QZPin/2rwcQ7OKmBohCadJGF1P8PG6aaQBKVX/8AAAAASUVORK5CYII=');
background-size: 60% 60%;
background-repeat: no-repeat;
background-position: center;
width: 80rpx;
height: 80rpx;
&.is-reverse {
transform: rotateY(180deg);
}
}
}
.rechoose {
color: $uni-color-primary;
padding: 0 $uni-spacing-row-lg;
line-height: 100rpx;
}
.choose-btn {
color: $uni-color-primary;
text-align: center;
line-height: 100rpx;
flex: 1;
}
.button {
margin: auto $uni-spacing-row-lg auto auto;
background-color: $uni-color-success;
color: #fff;
}
.warn {
margin: auto $uni-spacing-row-lg auto auto;
background-color:$uni-color-error;
color: #fff;
}
}
.safe-area-inset-bottom {
padding-bottom: 0;
padding-bottom: constant(safe-area-inset-bottom); // 兼容 IOS<11.2
padding-bottom: env(safe-area-inset-bottom); // 兼容 IOS>=11.2
}
}
</style>
@@ -0,0 +1,727 @@
/**
* 图片编辑器-手势监听
* 1. wxs 暂不支持 es6 语法
* 2. 支持编译到微信小程序、QQ小程序、app-vue、H5上(uni-app 2.2.5及以上版本)
*/
/** 图片偏移量 */
var offset = { x: 0, y: 0 };
/** 图片缩放比例 */
var scale = 1;
/** 图片最小缩放比例 */
var minScale = 1;
/** 图片旋转角度 */
var rotate = 0;
/** 触摸点 */
var touches = [];
/** 图片布局信息 */
var img = {};
/** 系统信息 */
var sys = {};
/** 裁剪区域布局信息 */
var area = {};
/** 触摸行为类型 */
var touchType = '';
/** 操作角的位置 */
var activeAngle = 0;
/** 裁剪区域布局信息偏移量 */
var areaOffset = { left: 0, right: 0, top: 0, bottom: 0 };
/** 容错值 */
var fault = 0.000001;
/**
* 获取a、b两数中的最小正数
* @param a
* @param b
*/
function minimum(a, b) {
if (a > 0 && b < 0) return a;
if (a < 0 && b > 0) return b;
if (a > 0 && b > 0) return Math.min(a, b);
return 0;
}
/**
* 在容错访问内获取n近似值
* @param n
*/
function num(n) {
var m = parseFloat((n).toFixed(6));
return m === fault || m === -fault ? 0 : m;
}
/**
* 比较a值在容错值范围内是否等于b值
* @param a
* @param b
*/
function equalsByFault(a, b) {
return Math.abs(a - b) <= fault;
}
/**
* 比较a值在容错值范围内是否小于b值
* @param a
* @param b
*/
function lessThanByFault(a, b) {
var c = a - b;
return c < 0 ? c < -fault : c < fault;
}
/**
* 验证并获取有效最大值
* @param v
* @param max
* @param isInclude
* @param x
* @param y
* @param rate
* @returns
*/
function validMax(v, max, isInclude, x, y, rate) {
if(typeof max === 'number') {
if(isInclude && equalsByFault(max, y)) { // 宽高不等时,x轴用y轴值要做等比例转换
var n = num(max * rate);
if (n <= x) return n; // 转化后值在x轴最大值范围内
return x; // 转化后值超出x轴最大值范围则用最大值
}
return max;
}
return v;
}
/**
* 计算两点间距
* @param {Object} touches 触摸点信息
*/
function getDistanceByTouches(touches) {
// 根据勾股定理求两点间距离
var a = touches[1].pageX - touches[0].pageX;
var b = touches[1].pageY - touches[0].pageY;
var c = Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
// 求两点间的中点坐标
// 1. a、b可能为负值
// 2. 在求a、b时,如用touches[1]减touches[0],则求中点坐标也得用touches[1]减a/2、b/2
// 3. 同理,在求a、b时,也可用touches[0]减touches[1],则求中点坐标也得用touches[0]减a/2、b/2
var x = touches[1].pageX - a / 2;
var y = touches[1].pageY - b / 2;
return { c, x, y };
};
/**
* 修正取值
* @param {Object} a
* @param {Object} b
* @param {Object} c
* @param {Object} reverse 是否反向
*/
function correctValue(a, b, c, reverse) {
return num(reverse ? Math.max(Math.min(a, b), c) : Math.min(Math.max(a, b), c));
}
/**
* 旋转90°或270°时检查边界:限制 x、y 拖动范围,禁止滑出边界
* @param {Object} e 点坐标
* @param {Object} xReverse x是否反向
* @param {Object} yReverse y是否反向
*/
function checkRotateRange(e, xReverse, yReverse) {
var o = num((img.height - img.width) / 2); // 宽高差值一半
return {
x: correctValue(e.x, -img.height + o + area.width + area.left, area.left + o, xReverse),
y: correctValue(e.y, -img.width - o + area.height + area.top, area.top - o, yReverse)
};
}
/**
* 检查边界:限制 x、y 拖动范围,禁止滑出边界
* @param {Object} e 点坐标
*/
function checkRange(e) {
var r = rotate / 90 % 2;
if(r === 1) { // 因图片宽高可能不等,翻转 90° 或 270° 后图片宽高需反着计算,且左右和上下边界要根据差值做偏移
if (area.width === area.height) {
return checkRotateRange(e, img.height < area.height, img.width < area.width);
}
var isInclude = img.height < area.width && img.width < area.height; // 图片是否包含在裁剪区域内
if (img.width < area.height || img.height < area.width) {
if (area.width < area.height && img.width < img.height) {
return isInclude
? checkRotateRange(e, area.width < area.height, area.width < area.height)
: checkRotateRange(e, false, true);
}
if (area.height < area.width && img.height < img.width) {
return isInclude
? checkRotateRange(e, area.height < area.width, area.height < area.width)
: checkRotateRange(e, true, false);
}
}
if (img.height >= area.width && img.width >= area.height) {
return checkRotateRange(e, false, false);
}
if (isInclude) {
return area.height < area.width
? checkRotateRange(e, true, true)
: checkRotateRange(e, area.width < area.height, area.width < area.height);
}
if (img.height < area.width && !img.width < area.height) {
return checkRotateRange(e, true, false);
}
if (!img.height < area.width && img.width < area.height) {
return checkRotateRange(e, false, true);
}
return checkRotateRange(e, img.height < area.height, img.width < area.width);
}
return {
x: correctValue(e.x, -img.width + area.width + area.left, area.left, img.width < area.width),
y: correctValue(e.y, -img.height + area.height + area.top, area.top, img.height < area.height)
};
};
/**
* 变更图片布局信息
* @param {Object} e 布局信息
*/
function changeImageRect(e) {
offset.x += e.x || 0;
offset.y += e.y || 0;
var image = e.instance.selectComponent('.crop-image');
if(e.check && area.checkRange) { // 检查边界
var point = checkRange(offset);
if(offset.x !== point.x || offset.y !== point.y) {
offset = point;
}
}
// image.setStyle({
// width: img.width + 'px',
// height: img.height + 'px',
// transform: 'translate(' + offset.x + 'px, ' + offset.y + 'px) rotate(' + rotate +'deg)'
// });
var ox = (img.width - img.oldWidth) / 2;
var oy = (img.height - img.oldHeight) / 2;
image.setStyle({
width: img.oldWidth + 'px',
height: img.oldHeight + 'px',
transform: (img.gpu ? 'translateZ(0) ' : '') + 'translate(' + (offset.x + ox) + 'px, ' + (offset.y + oy) + 'px) rotate(' + rotate +'deg) scale(' + scale + ')'
});
e.instance.callMethod('dataChange', {
width: img.width,
height: img.height,
x: offset.x,
y: offset.y,
rotate: rotate
});
};
/**
* 变更裁剪区域布局信息
* @param {Object} e 布局信息
*/
function changeAreaRect(e) {
// 变更蒙版样式
var masks = e.instance.selectAllComponents('.crop-mask-block');
var maskStyles = [
{
left: 0,
width: (area.left + areaOffset.left) + 'px',
top: 0,
bottom: 0,
'z-index': area.zIndex + 2
},
{
left: (area.right + areaOffset.right) + 'px',
right: 0,
top: 0,
bottom: 0,
'z-index': area.zIndex + 2
},
{
left: (area.left + areaOffset.left) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
top: 0,
height: (area.top + areaOffset.top) + 'px',
'z-index': area.zIndex + 2
},
{
left: (area.left + areaOffset.left) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
top: (area.bottom + areaOffset.bottom) + 'px',
// height: (area.top - areaOffset.bottom + sys.offsetBottom) + 'px',
bottom: 0,
'z-index': area.zIndex + 2
}
];
var len = masks.length;
for (var i = 0; i < len; i++) {
masks[i].setStyle(maskStyles[i]);
}
// 变更边框样式
if(area.showBorder) {
var border = e.instance.selectComponent('.crop-border');
border.setStyle({
left: (area.left + areaOffset.left) + 'px',
top: (area.top + areaOffset.top) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
height: (area.height + areaOffset.bottom - areaOffset.top) + 'px',
'z-index': area.zIndex + 3
});
}
// 变更参考线样式
if(area.showGrid) {
var grids = e.instance.selectAllComponents('.crop-grid');
var gridStyles = [
{
'border-width': '1px 0 0 0',
left: (area.left + areaOffset.left) + 'px',
right: (area.right + areaOffset.right) + 'px',
top: (area.top + areaOffset.top + (area.height + areaOffset.bottom - areaOffset.top) / 3 - 0.5) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
'z-index': area.zIndex + 3
},
{
'border-width': '1px 0 0 0',
left: (area.left + areaOffset.left) + 'px',
right: (area.right + areaOffset.right) + 'px',
top: (area.top + areaOffset.top + (area.height + areaOffset.bottom - areaOffset.top) * 2 / 3 - 0.5) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
'z-index': area.zIndex + 3
},
{
'border-width': '0 1px 0 0',
top: (area.top + areaOffset.top) + 'px',
bottom: (area.bottom + areaOffset.bottom) + 'px',
left: (area.left + areaOffset.left + (area.width + areaOffset.right - areaOffset.left) / 3 - 0.5) + 'px',
height: (area.height + areaOffset.bottom - areaOffset.top) + 'px',
'z-index': area.zIndex + 3
},
{
'border-width': '0 1px 0 0',
top: (area.top + areaOffset.top) + 'px',
bottom: (area.bottom + areaOffset.bottom) + 'px',
left: (area.left + areaOffset.left + (area.width + areaOffset.right - areaOffset.left) * 2 / 3 - 0.5) + 'px',
height: (area.height + areaOffset.bottom - areaOffset.top) + 'px',
'z-index': area.zIndex + 3
}
];
var len = grids.length;
for (var i = 0; i < len; i++) {
grids[i].setStyle(gridStyles[i]);
}
}
// 变更四个伸缩角样式
if(area.showAngle) {
var angles = e.instance.selectAllComponents('.crop-angle');
var angleStyles = [
{
'border-width': area.angleBorderWidth + 'px 0 0 ' + area.angleBorderWidth + 'px',
left: (area.left + areaOffset.left - area.angleBorderWidth) + 'px',
top: (area.top + areaOffset.top - area.angleBorderWidth) + 'px',
'z-index': area.zIndex + 3
},
{
'border-width': area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px 0 0',
left: (area.right + areaOffset.right - area.angleSize) + 'px',
top: (area.top + areaOffset.top - area.angleBorderWidth) + 'px',
'z-index': area.zIndex + 3
},
{
'border-width': '0 0 ' + area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px',
left: (area.left + areaOffset.left - area.angleBorderWidth) + 'px',
top: (area.bottom + areaOffset.bottom - area.angleSize) + 'px',
'z-index': area.zIndex + 3
},
{
'border-width': '0 ' + area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px 0',
left: (area.right + areaOffset.right - area.angleSize) + 'px',
top: (area.bottom + areaOffset.bottom - area.angleSize) + 'px',
'z-index': area.zIndex + 3
}
];
var len = angles.length;
for (var i = 0; i < len; i++) {
angles[i].setStyle(angleStyles[i]);
}
}
// 变更圆角样式
if(area.radius > 0) {
var circleBox = e.instance.selectComponent('.crop-circle-box');
var circle = e.instance.selectComponent('.crop-circle');
var radius = area.radius;
if(area.width === area.height && area.radius >= area.width / 2) { // 圆形
radius = (area.width / 2);
} else { // 圆角矩形
if(area.width !== area.height) { // 限制圆角半径不能超过短边的一半
radius = Math.min(area.width / 2, area.height / 2, radius);
}
}
circleBox.setStyle({
left: (area.left + areaOffset.left) + 'px',
top: (area.top + areaOffset.top) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
height: (area.height + areaOffset.bottom - areaOffset.top) + 'px',
'z-index': area.zIndex + 2
});
circle.setStyle({
'box-shadow': '0 0 0 ' + Math.max(area.width, area.height) + 'px rgba(51, 51, 51, 0.8)',
'border-radius': radius + 'px'
});
}
};
/**
* 缩放图片
* @param {Object} e 布局信息
*/
function scaleImage(e) {
var last = scale;
scale = Math.min(Math.max(e.scale + scale, minScale), img.maxScale);
if(last !== scale) {
img.width = num(img.oldWidth * scale);
img.height = num(img.oldHeight * scale);
// 参考问题:有一个长4000px、宽4000px的四方形ABCDA点的坐标固定在(-2000,-2000)
// 该四边形上有一个点E,坐标为(-100,-300),将该四方形复制一份并缩小到90%后,
// 新四边形的A点坐标为多少时可使新四边形的E点与原四边形的E点重合?
// 预期效果:从图中选取某点(参照物)为中心点进行缩放,缩放时无论图像怎么变化,该点位置始终固定不变
// 计算方法:以相同起点先计算缩放前后两点间的距离,再加上原图像偏移量即可
e.x = num((e.x - offset.x) * (1 - scale / last));
e.y = num((e.y - offset.y) * (1 - scale / last));
changeImageRect(e);
return true;
}
return false;
};
/**
* 获取触摸点在哪个角
* @param {number} x 触摸点x轴坐标
* @param {number} y 触摸点y轴坐标
* @return {number} 角的位置:0=无;1=左上;2=右上;3=左下;4=右下;
*/
function getToucheAngle(x, y) {
// console.log('getToucheAngle', x, y, JSON.stringify(area))
var o = area.angleBorderWidth; // 需扩大触发范围则把 o 值加大即可
if(y >= area.top - o && y <= area.top + area.angleSize + o) {
if(x >= area.left - o && x <= area.left + area.angleSize + o) {
return 1; // 左上角
} else if(x >= area.right - area.angleSize - o && x <= area.right + o) {
return 2; // 右上角
}
} else if(y >= area.bottom - area.angleSize - o && y <= area.bottom + o) {
if(x >= area.left - o && x <= area.left + area.angleSize + o) {
return 3; // 左下角
} else if(x >= area.right - area.angleSize - o && x <= area.right + o) {
return 4; // 右下角
}
}
return 0; // 无触摸到角
};
/**
* 重置数据
*/
function resetData() {
offset = { x: 0, y: 0 };
scale = 1;
minScale = img.minScale;
rotate = 0;
};
/**
* 顺时针翻转图片90°
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
function rotateImage(e, o, r) {
rotate = (rotate + r) % 360;
if(img.minScale >= 1 && area.checkRange) {
// 因图片宽高可能不等,翻转后图片宽高需足够填满裁剪区域
minScale = 1;
if(img.width < area.height) {
minScale = area.height / img.oldWidth;
} else if(img.height < area.width) {
minScale = area.width / img.oldHeight;
}
if(minScale !== 1) {
scaleImage({
instance: o,
scale: minScale - scale,
x: sys.windowWidth / 2,
y: (sys.windowHeight - sys.offsetBottom) / 2
});
}
}
// 由于拖动画布后会导致图片位置偏移,翻转时的旋转中心点需是图片区域+偏移区域的中心点
// 翻转x轴中心点 = (超出裁剪区域右侧的图片宽度 - 超出裁剪区域左侧的图片宽度) / 2
// 翻转y轴中心点 = (超出裁剪区域下方的图片宽度 - 超出裁剪区域上方的图片宽度) / 2
var ox = ((offset.x + img.width - area.right) - (area.left - offset.x)) / 2;
var oy = ((offset.y + img.height - area.bottom) - (area.top - offset.y)) / 2;
changeImageRect({
instance: o,
check: true,
x: -ox - oy,
y: -oy + ox
});
};
module.exports = {
/**
* 初始化:观察数据变更
* @param {Object} newVal 新数据
* @param {Object} oldVal 旧数据
* @param {Object} o 组件实例对象
*/
initObserver: function(newVal, oldVal, o, i) {
if(newVal) {
img = newVal.img;
sys = newVal.sys;
area = newVal.area;
minScale = img.minScale;
resetData();
img.src && changeImageRect({
instance: o,
x: (sys.windowWidth - img.width) / 2,
y: (sys.windowHeight - sys.offsetBottom - img.height) / 2
});
changeAreaRect({
instance: o
});
// console.log('initRect', JSON.stringify(newVal))
}
},
/**
* 鼠标滚轮滚动
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
mousewheel: function(e, o) {
if(!img.src) return;
scaleImage({
instance: o,
check: true,
// 鼠标向上滚动时,deltaY 固定 -100,鼠标向下滚动时,deltaY 固定 100
scale: e.detail.deltaY > 0 ? -0.05 : 0.05,
x: e.touches[0].pageX,
y: e.touches[0].pageY
});
},
/**
* 触摸开始
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
touchstart: function(e, o) {
if(!img.src) return;
touches = e.touches;
activeAngle = area.showAngle ? getToucheAngle(touches[0].pageX, touches[0].pageY) : 0;
if(touches.length === 1 && activeAngle !== 0) {
touchType = 'stretch'; // 伸缩裁剪区域
} else {
touchType = '';
}
// console.log('touchstart', JSON.stringify(e), activeAngle)
},
/**
* 触摸移动
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
touchmove: function(e, o) {
if(!img.src) return;
// console.log('touchmove', JSON.stringify(e), JSON.stringify(o))
if(touchType === 'stretch') { // 触摸四个角进行拉伸
var point = e.touches[0];
var start = touches[0];
var x = point.pageX - start.pageX;
var y = point.pageY - start.pageY;
if(x !== 0 || y !== 0) {
var maxX = num(area.width * (1 - area.minScale));
var maxY = num(area.height * (1 - area.minScale));
// console.log(x, y, maxX, maxY, offset, area)
touches[0] = point;
var r = rotate / 90 % 2;
var m = r === 1 ? num((img.height - img.width) / 2) : 0; // 宽高差值一半
var xCompare = r === 1 ? lessThanByFault(img.height, area.width) : lessThanByFault(img.width, area.width);
var yCompare = r === 1 ? lessThanByFault(img.width, area.height) : lessThanByFault(img.height, area.height)
var isInclude = xCompare && yCompare;
var isIntersect = area.checkRange && (xCompare || yCompare); // 图片是否包含在裁剪区域内
var isReverse = !isInclude || num((offset.x - area.left) / area.width) <= num((offset.y - area.top) / area.height) || (area.width > area.height && img.width < img.height && r === 1);
switch(activeAngle) {
case 1: // 左上角
x = num(x + areaOffset.left);
y = num(y + areaOffset.top);
if(x >= 0 && y >= 0) { // 有效滑动
var t = num(offset.y + m - area.top);
var l = num(offset.x - m - area.left);
// && (offset.x + img.width < area.right || offset.y + img.height < area.bottom)
var max = isIntersect && ((l >= 0) || (t >= 0))
? minimum(t, l)
: false;
if(x > y && isReverse) { // 以x轴滑动距离为缩放基准
maxX = validMax(maxX, max, isInclude, l, t, area.width / area.height);
if(x > maxX) x = maxX;
y = num(x * area.height / area.width);
} else { // 以y轴滑动距离为缩放基准
maxY = validMax(maxY, max, isInclude, t, l, area.height / area.width);
if(y > maxY) y = maxY;
x = num(y * area.width / area.height);
}
areaOffset.left = x;
areaOffset.top = y;
}
break;
case 2: // 右上角
x = num(x + areaOffset.right);
y = num(y + areaOffset.top);
if(x <= 0 && y >= 0) { // 有效滑动
var w = (r === 1 ? img.height : img.width);
var t = num(offset.y + m - area.top);
var l = num(area.right + m - offset.x - w);
var max = isIntersect && ((t >= 0) || (l >= 0))
? minimum(t, l)
: false;
// var max = isInclude && ((offset.x > 0 && offset.x + img.width <= area.right) || (offset.y > 0 && offset.y >= area.top))
// ? minimum(offset.y - area.top, area.right - offset.x - img.width)
// : false;
// console.log(offset.x, offset.y, img.width, img.height, area.top, area.right, m, max)
// console.log(offset.y + m - area.top, area.right + m - offset.x - w)
if(-x > y && isReverse) { // 以x轴滑动距离为缩放基准
maxX = validMax(maxX, max, isInclude, l, t, area.width / area.height);
if(-x > maxX) x = -maxX;
y = num(-x * area.height / area.width);
} else { // 以y轴滑动距离为缩放基准
maxY = validMax(maxY, max, isInclude, t, l, area.height / area.width);
if(y > maxY) y = maxY;
x = num(-y * area.width / area.height);
}
areaOffset.right = x;
areaOffset.top = y;
}
break;
case 3: // 左下角
x += num(x + areaOffset.left);
y += num(y + areaOffset.bottom);
if(x >= 0 && y <= 0) { // 有效滑动
var w = (r === 1 ? img.width : img.height);
var t = num(area.bottom - m - offset.y - w);
var l = num(offset.x - m - area.left);
var max = isIntersect && ((l >= 0) || (t >= 0))
? minimum(t, l)
: false;
if(x > -y && isReverse) { // 以x轴滑动距离为缩放基准
maxX = validMax(maxX, max, isInclude, l, t, area.width / area.height);
if(x > maxX) x = maxX;
y = num(-x * area.height / area.width);
} else { // 以y轴滑动距离为缩放基准
maxY = validMax(maxY, max, isInclude, t, l, area.height / area.width);
if(-y > maxY) y = -maxY;
x = num(-y * area.width / area.height);
}
areaOffset.left = x;
areaOffset.bottom = y;
}
break;
case 4: // 右下角
x = num(x + areaOffset.right);
y = num(y + areaOffset.bottom);
if(x <= 0 && y <= 0) { // 有效滑动
var w = (r === 1 ? img.height : img.width);
var h = (r === 1 ? img.width : img.height);
var t = num(area.bottom - offset.y - h - m);
var l = num(area.right + m - offset.x - w);
var max = isIntersect && ((l >= 0) || (t >= 0))
? minimum(t, l)
: false;
if(-x > -y && isReverse) { // 以x轴滑动距离为缩放基准
maxX = validMax(maxX, max, isInclude, l, t, area.width / area.height);
if(-x > maxX) x = -maxX;
y = num(x * area.height / area.width);
} else { // 以y轴滑动距离为缩放基准
maxY = validMax(maxY, max, isInclude, t, l, area.height / area.width);
if(-y > maxY) y = -maxY;
x = num(y * area.width / area.height);
}
areaOffset.right = x;
areaOffset.bottom = y;
}
break;
}
// console.log(x, y, JSON.stringify(areaOffset))
changeAreaRect({
instance: o,
});
// this.draw();
}
} else if (e.touches.length == 2) { // 双点触摸缩放
var start = getDistanceByTouches(touches);
var end = getDistanceByTouches(e.touches);
scaleImage({
instance: o,
check: !area.bounce,
scale: (end.c - start.c) / 100,
x: end.x,
y: end.y
});
touchType = 'scale';
} else if(touchType === 'scale') {// 从双点触摸变成单点触摸 / 从缩放变成拖动
touchType = 'move';
} else {
changeImageRect({
instance: o,
check: !area.bounce,
x: e.touches[0].pageX - touches[0].pageX,
y: e.touches[0].pageY - touches[0].pageY
});
touchType = 'move';
}
touches = e.touches;
},
/**
* 触摸结束
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
touchend: function(e, o) {
if(!img.src) return;
if(touchType === 'stretch') { // 拉伸裁剪区域的四个角缩放
// 裁剪区域宽度被缩放到多少
var left = areaOffset.left;
var right = areaOffset.right;
var top = areaOffset.top;
var bottom = areaOffset.bottom;
var w = area.width + right - left;
var h = area.height + bottom - top;
// 图像放大倍数
var p = scale * (area.width / w) - scale;
// 复原裁剪区域
areaOffset = { left: 0, right: 0, top: 0, bottom: 0 };
changeAreaRect({
instance: o,
});
scaleImage({
instance: o,
scale: p,
x: area.left + left + (1 === activeAngle || 3 === activeAngle ? w : 0),
y: area.top + top + (1 === activeAngle || 2 === activeAngle ? h : 0)
});
} else if (area.bounce) { // 检查边界并矫正,实现拖动到边界时有回弹效果
changeImageRect({
instance: o,
check: true
});
}
},
/**
* 顺时针翻转图片90°
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
rotateImage: function(e, o) {
rotateImage(e, o, 90);
},
rotateImage90: function(e, o) {
rotateImage(e, o, 90)
},
rotateImage270: function(e, o) {
rotateImage(e, o, 270)
},
// 此处只用于对齐其他平台端的样式参数,防止异常,无作用
imageStyles: '',
maskStylesList: ['', '', '', ''],
borderStyles: '',
gridStylesList: ['', '', '', ''],
angleStylesList: ['', '', '', ''],
circleBoxStyles: '',
circleStyles: '',
}
+81
View File
@@ -0,0 +1,81 @@
{
"id": "qf-image-cropper",
"displayName": "图片裁剪插件",
"version": "2.2.5",
"description": "图片裁剪插件,支持自定义尺寸、定点等比例缩放、拖动、图片翻转、剪切圆形/圆角图片、定制样式,功能多性能高体验好注释全。",
"keywords": [
"qf-image-cropper",
"图片裁剪",
"图片编辑",
"头像裁剪",
"小程序"
],
"repository": "",
"engines": {
"HBuilderX": "^3.1.0"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "n"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "u"
},
"H5-pc": {
"Chrome": "u",
"IE": "u",
"Edge": "u",
"Firefox": "u",
"Safari": "u"
},
"小程序": {
"微信": "y",
"阿里": "n",
"百度": "n",
"字节跳动": "n",
"QQ": "u",
"钉钉": "n",
"快手": "n",
"飞书": "n",
"京东": "n"
},
"快应用": {
"华为": "n",
"联盟": "n"
}
}
}
}
}
+97
View File
@@ -0,0 +1,97 @@
# qf-image-cropper
## 图片裁剪插件
uniapp微信小程序图片裁剪插件,支持自定义尺寸、定点等比例缩放、拖动、图片翻转、剪切圆形/圆角图片、定制样式,功能多性能高体验好注释全。
### 平台支持:
1. 支持微信小程序:移动端、PC端、开发者工具
2. 支持H5平台(2.1.0版本起)
3. 支持APP平台(2.1.5版本起):Android、IOS
4. 其他平台暂未测试兼容性未知
### 支持功能:
1. 自定义裁剪尺寸
2. 定点等比例缩放:移动端以双指触摸中心点为缩放中心点,PC端以鼠标所在点为缩放中心点
3. 自由拖动:支持限制滑出边界,也支持回弹效果(滑动时可滑出边界,释放时回弹到边界)
4. 图片翻转:在裁剪尺寸非 1:1 的情况下,翻转时宽高无法铺满裁剪区域时,图片会自动放大到合适尺寸
5. 裁剪生成新图片
6. 本地选择图片
7. 可定制样式:可自由选择是否渲染裁剪边框、可伸缩裁剪顶角、参考线
8. 裁剪圆角图片:圆形、圆角矩形
### 属性说明
| 属性名 | 类型 | 默认值 | 说明 |
|:---|:---|:---|:---|
| src | String | | 图片资源地址 |
| width | Number | 300 | 裁剪宽度 |
| height | Number | 300 | 裁剪高度 |
| showBorder | Boolean | true | 是否绘制裁剪区域边框 |
| showGrid | Boolean | true | 是否绘制裁剪区域网格参考线 |
| showAngle | Boolean | true | 是否展示四个支持伸缩的角 |
| areaScale | Number | 0.3 | 裁剪区域最小缩放倍数 |
| minScale | Number | 1 | 图片最小缩放倍数 |
| maxScale | Number | 5 | 图片最大缩放倍数 |
| checkRange | Boolean | true | 检查图片位置是否超出裁剪边界,如果超出则会矫正位置 |
| backgroundColor | String | | 生成图片背景色:如果裁剪区域没有完全包含在图片中时,不设置该属性则生成图片存在一定的透明块 |
| bounce | Boolean | true | 是否有回弹效果:当 checkRange 为 true 时有效,拖动时可以拖出边界,释放时会弹回边界 |
| rotatable | Boolean | true | 是否支持翻转 |
| reverseRotatable | Boolean | false | 是否支持逆向翻转 |
| choosable | Boolean | true | 是否支持从本地选择素材 |
| gpu | Boolean | false | 是否开启硬件加速,图片缩放过程中如果出现元素的“留影”或“重影”效果,可通过该方式解决或减轻这一问题 |
| angleSize | Number | 20 | 四个角尺寸,单位px |
| angleBorderWidth | Number | 2 | 四个角边框宽度,单位px |
| zIndex | Number/String | | 调整组件层级 |
| radius | Number | | 裁剪图片圆角半径,单位px |
| fileType | String | png | 生成文件的类型,只支持 'jpg' 或 'png'。默认为 'png' |
| delay | Number | 1000 | 图片从绘制到生成所需时间,单位ms<br>微信小程序平台使用 `Canvas 2D` 绘制时有效<br>如绘制大图或出现裁剪图片空白等情况应适当调大该值,因 `Canvas 2d` 采用同步绘制,需自己把控绘制完成时间 |
| navigation | Boolean | true | 页面是否是原生标题栏:<br>H5平台当 showAngle 为 true 时,使用插件的页面在 `page.json` 中配置了 `"navigationStyle": "custom"` 时,必须将此值设为 false ,否则四个可拉伸角的触发位置会有偏差。<br>注:因H5平台的窗口高度是包含标题栏的,而屏幕触摸点的坐标是不包含的 |
| @crop | EventHandle | | 剪裁完成后触发,event = { tempFilePath }。在H5平台下,tempFilePath 为 base64 |
### 基本用法
```
<template>
<div>
<qf-image-cropper :width="500" :height="500" :radius="30" @crop="handleCrop"></qf-image-cropper>
</div>
</template>
<script>
import QfImageCropper from '@/components/qf-image-cropper/qf-image-cropper.vue';
export default {
components: {
QfImageCropper
},
methods: {
handleCrop(e) {
uni.previewImage({
urls: [e.tempFilePath],
current: 0
});
}
}
}
</script>
```
通过ref组件实例可在进入页面后直接打开相册选择图片
```
mounted() {
this.$refs.qfImageCropper.chooseImage({ sourceType: ['album'] });
}
```
### 使用说明
1.建议在`pages.json`中将引用插件的页面添加一下配置禁止下拉刷新和禁止页面滑动,防止出现性能或页面抖动等问题。
```
{
"enablePullDownRefresh": false,
"disableScroll": true
}
```
2.建议使用本插件不要设置过大宽高的目标图片尺寸,建议1365x1365以内,否则可能会导致如下问题:
```
1.界面卡顿,内存占用过高
2.生成图片失真(模糊)
3.确定裁剪后一直显示 `裁剪中...`,该问题是由 `uni.canvasToTempFilePath` 无法回调导致,不同平台不同设备限制可能有所不同。
```
3.如裁剪后的图片存在偏移的问题,请检查是否受自己项目中父组件或全局样式影响。
4.src属性设置网络图片时,图片资源必须是能触发 `getImageInfo` API 的 success 回调才可用于插件裁剪。因此小程序平台获取网络图片信息需先配置download域名白名单才能生效。
5.如果组件无法正常渲染且使用了 `v-if` 时,可尝试将 `v-if` 替换为 `v-show`
6.如果App端导入组件后无法正常渲染,请尝试重新运行
+35
View File
@@ -0,0 +1,35 @@
## 1.1.22025-04-14
1. 更新视频封面图片地址,之前的已失效
2. 更新文档
## 1.1.12025-03-19
1. 更新vue2环境下状态销毁异常的问题
## 1.1.02025-03-07
1. 更新状态销毁逻辑
## 1.0.92025-01-18
1. 修复调色板在微信小程序vue2环境下的问题
## 1.0.82025-01-18
1. 修复了微信小程序在vue2环境下的报错
## 1.0.72025-01-18
1. 修复了微信小程序在vue2环境下出现的报错
## 1.0.62024-12-17
1. 优化了ios端兼容性问题
2. 更新示例工程和文档
## 1.0.52024-12-17
1. 更新文档
## 1.0.42024-12-17
1. 新增扩展功能
2. 更新文档
3. 更新示例工程
## 1.0.32024-12-11
1. 优化了多编辑器实例模式,现在单页面可以存在多个编辑器了
2. 更新了文档与示例工程,多实例可以参考示例二
## 1.0.22024-12-10
1. 添加调色板功能
2. 预设更多样式格式
3. 更新示例工程和文档
## 1.0.12024-12-06
1. v1正式版发布
2. 更新文档
3. 上传示例工程
## 1.0.02024-11-29
1. 基于uni-editor的仿知乎富文本初稿
@@ -0,0 +1,656 @@
<template>
<text
:eid="eid"
:change:eid="quillEditor.watchEID"
:sid="sid"
:change:sid="quillEditor.watchStartID"
:video="videoUrl"
:change:pastemode="quillEditor.watchPasteMode"
:pastemode="pastemode"
:change:video="quillEditor.watchVideoUrl"
:cover="coverUrl"
:change:cover="quillEditor.watchCoverUrl"
:coverios="coverUrlIOS"
:change:coverios="quillEditor.watchCoverUrlIOS"
></text>
</template>
<script>
/**
* 富文本plugin特殊扩展
* @author sonve
* @version 1.0.0
* @date 2024-12-14
*/
export default {
props: {
sid: {
type: String,
default: ''
},
eid: {
type: String,
default: ''
}
},
data() {
return {
videoUrl: '', // 视频地址
coverUrl: '', // 封面地址
coverUrlIOS: '', // ios端封面地址
pastemode: 'text' // 粘贴模式 text | origin
}
},
mounted() {},
methods: {
changePasteMode(e) {
this.pastemode = e
},
editorPaste(e) {
this.$emit('epaste', e)
},
createVideoThumbnail(url) {
this.videoUrl = url
},
getVideoThumbnail(e) {
// e: { video, cover }
uni.$emit(`E_EDITOR_GET_VIDEO_THUMBNAIL_${e.video}`, e)
},
createCoverThumbnail(url) {
// #ifdef H5
this.coverUrl = url
// #endif
// #ifdef APP
const isIOS = uni.getSystemInfoSync().platform == 'ios'
if (isIOS) {
this.coverUrlIOS = url // iOS用不了OffscreenCanvas
} else {
this.coverUrl = url
}
// #endif
},
getCoverThumbnail(e) {
// e: { image, cover }
uni.$emit(`E_EDITOR_GET_COVER_THUMBNAIL_${e.image}`, e)
}
}
}
</script>
<script module="quillEditor" lang="renderjs">
import config from '../common/config.js'
export default {
data() {
return {
editorID: '',
idStack: [], // sid栈
matcherMode: '' // 粘贴模式 text | origin
}
},
methods: {
/**
* 注意:watch频繁触发时需要异步修改,否则可能会导致监听不到
*/
watchPasteMode(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.matcherMode = newValue
}
},
watchStartID(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.addMatcher(newValue)
}
},
watchEID(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.editorID = newValue
}
},
watchVideoUrl(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.generateVideoThumbnail(newValue).then((res) => {
this.$ownerInstance.callMethod('getVideoThumbnail', {
video: newValue,
cover: res
})
})
}
},
watchCoverUrl(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.generateCoverThumbnail(newValue).then((res) => {
this.$ownerInstance.callMethod('getCoverThumbnail', {
image: newValue,
cover: res
})
})
}
},
/**
* Only Apple Can Do !!!
*/
watchCoverUrlIOS(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.generateCoverThumbnailIOS(newValue).then((res) => {
this.$ownerInstance.callMethod('getCoverThumbnail', {
image: newValue,
cover: res
})
})
}
},
/**
* 保留格式粘贴内容
* @description 此方式尽可能保留原格式,易于再编辑,但是部分格式会丢失
* @param {String} sid 当前编辑器id
*/
addMatcher(sid) {
if(this.idStack.includes(sid)) return // 禁止重复添加Matcher
this.idStack.push(sid)
const el = document.querySelector(`#${sid}`);
const quill = Quill.find(el);
const getStyleAttributes = (node, style) => {
let attributes = {}
// node属性
const width = node.getAttribute('width');
const height = node.getAttribute('height');
if (width) attributes.width = width
if (height) attributes.height = height
const dataCustom = node.getAttribute('data-custom');
if (dataCustom) attributes['data-custom'] = dataCustom;
// style样式
if (style.textAlign) attributes.align = style.textAlign;
if (style.fontWeight === 'bold' || style.fontWeight === '700') attributes.bold = true;
if (style.fontStyle === 'italic') attributes.italic = true;
if (style.textDecoration.includes('underline')) attributes.underline = true;
if (style.textDecoration.includes('line-through')) attributes.strike = true;
if (style.verticalAlign === 'super') attributes.script = 'super'
if (style.verticalAlign === 'sub') attributes.script = 'sub'
if (style.fontFamily) attributes.font = style.fontFamily;
if (style.fontSize) attributes.size = parseFloat(style.fontSize);
if (style.color) attributes.color = style.color;
if (style.backgroundColor) attributes.background = style.backgroundColor;
return attributes
}
const processNode = (node) => {
let ops = [];
if (node.nodeType === Node.ELEMENT_NODE) {
const computedStyle = document.defaultView.getComputedStyle(node);
// 处理 <img> 标签
if (node.tagName === 'IMG') {
const imgSrc = node.getAttribute('src');
if (imgSrc) {
ops.push({ insert: '\n' }); // 插入换行符,确保图片前有一个空行
ops.push({
insert: { image: imgSrc },
attributes: getStyleAttributes(node, computedStyle)
});
ops.push({ insert: '\n' }); // 插入换行符,确保图片后有一个空行
return ops; // 不参与递归
}
}
// 处理 <p> 和 <div> 标签
else if (node.tagName === 'P' || node.tagName === 'DIV') {
ops.push({ insert: '\n' }); // 插入换行符
}
// 处理 <ol> 标签 有序
else if (node.tagName === 'OL') {
// ops.push({ insert: '\n', attributes: { list: 'ordered' } });
}
// 处理 <ul> 标签 无序
else if (node.tagName === 'UL') {
// ops.push({ insert: '\n', attributes: { list: 'bullet' } });
}
// 处理 <li> 标签
else if (node.tagName === 'LI') {
ops.push({ insert: '\n' });
}
// 处理 <hr> 标签
else if (node.tagName === 'HR') {
ops.push({ insert: '\n' }); // 插入换行符
ops.push({ insert: { divider: true } });
return ops; // 不参与递归
}
// 处理 <a> 标签
else if (node.tagName === 'A') {
const href = node.getAttribute('href');
const textContent = node.textContent.trim();
if (href && textContent) {
ops.push({
insert: ' ' + textContent + ' ',
attributes: {
link: href,
textDecoration: computedStyle.textDecoration,
...getStyleAttributes(node, computedStyle)
}
});
return ops; // 不参与递归
}
}
// 处理 <h1> 到 <h6> 标题
else if (['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(node.tagName)) {
// 处理 <h1> 到 <h6> 标题,生成 header 操作
const headerLevel = parseInt(node.tagName.charAt(1), 10);
const textContent = node.textContent.trim();
if (textContent) {
ops.push({
insert: textContent + '\n', // 必须要加上换行
attributes: {
header: headerLevel,
...getStyleAttributes(node, computedStyle)
}
});
return ops; // 不参与递归
}
}
// 递归处理子节点
for (let child of node.childNodes) {
ops = ops.concat(processNode(child));
}
} else if (node.nodeType === Node.TEXT_NODE) {
const textContent = node.nodeValue.trim();
if (textContent) {
// 从父元素获取样式
const parentNode = node.parentNode;
if (parentNode) {
const computedStyle = document.defaultView.getComputedStyle(parentNode);
ops.push({
insert: textContent,
attributes: getStyleAttributes(parentNode, computedStyle)
});
} else {
// 如果没有父元素,直接插入文本
ops.push({ insert: textContent });
}
}
}
return ops;
}
quill.clipboard.addMatcher(Node.ELEMENT_NODE, (node, delta) => {
if (this.matcherMode == 'origin') {
let newOps = processNode(node);
if (newOps.length > 0) {
return { ops: newOps };
}
}
return delta;
})
const cleanClipboardHTML = (html, text) => {
if(!html) return text
// 使用正则表达式匹配 <!--StartFragment--> 和 <!--EndFragment--> 之间的内容
const fragmentRegex = /<!--StartFragment-->([\s\S]*?)<!--EndFragment-->/;
const match = html.match(fragmentRegex);
if (match && match[1]) {
// 返回匹配到的内容
return match[1].trim();
}
// 如果没有匹配到片段内容,返回原始 HTML
return html;
}
el.addEventListener('paste', (e) => {
let clipboardText = e.clipboardData.getData('text/plain'); // 获取剪切板中的纯文本内容
let clipboardHtml = e.clipboardData.getData('text/html'); // 获取剪切板中的 HTML 内容(如果存在)
clipboardHtml = cleanClipboardHTML(clipboardHtml, clipboardText)
setTimeout(() => {
this.$ownerInstance.callMethod('editorPaste', {
id: sid,
text: clipboardText,
html: clipboardHtml,
range: quill.getSelection() // 获取当前光标位置
})
}, 100);
});
},
/**
* 保留格式粘贴内容
* @description 此方式虽然可以极大程度保留格式,但是会导致粘贴下来的内容为一整个块,且不易再编辑
* @param {String} sid 当前编辑器id
*/
/*
addMatcher(sid) {
if(this.idStack.includes(sid)) return // 禁止重复添加Matcher
this.idStack.push(sid)
// 引入源码中的BlockEmbed
const BlockEmbed = Quill.import('blots/block/embed');
// 定义新的blot类型
class AppPanelEmbed extends BlockEmbed {
static create(value) {
const node = super.create(value);
node.setAttribute('width', '100%');
// 设置自定义html
node.innerHTML = this.transformValue(value)
return node;
}
static transformValue(value) {
let handleArr = value.split('\n')
handleArr = handleArr.map(e => e.replace(/^[\s]+/, '').replace(/[\s]+$/, ''))
return handleArr.join('')
}
// 返回节点自身的value值 用于撤销操作
static value(node) {
return node.innerHTML
}
}
// blotName
AppPanelEmbed.blotName = 'AppPanelEmbed';
// 标签类型自定义
AppPanelEmbed.tagName = 'p';
Quill.register(AppPanelEmbed, true);
const el = document.querySelector(`#${sid}`);
const quill = Quill.find(el);
const cleanClipboardHTML = (html, text) => {
if(!html) return text
// 使用正则表达式匹配 <!--StartFragment--> 和 <!--EndFragment--> 之间的内容
const fragmentRegex = /<!--StartFragment-->([\s\S]*?)<!--EndFragment-->/;
const match = html.match(fragmentRegex);
if (match && match[1]) {
// 返回匹配到的内容
return match[1].trim();
}
// 如果没有匹配到片段内容,返回原始 HTML
return html;
}
el.addEventListener('paste', (e) => {
e.preventDefault();
let clipboardText = e.clipboardData.getData('text/plain'); // 获取剪切板中的纯文本内容
let clipboardHtml = e.clipboardData.getData('text/html'); // 获取剪切板中的 HTML 内容(如果存在)
clipboardHtml = cleanClipboardHTML(clipboardHtml, clipboardText)
this.$ownerInstance.callMethod('editorPaste', {
id: sid,
text: clipboardText,
html: clipboardHtml
})
setTimeout(() => {
const range = quill.getSelection(); // 获取当前光标位置
quill.insertEmbed(range.index, 'AppPanelEmbed', clipboardHtml);
}, 100);
});
},
*/
/**
* 生成视频封面图片(不兼容iOS)
* @property {String} videoUrl 视频地址
* @returns {Promise} 视频封面图片 注意异步处理
*/
async generateVideoThumbnail(videoUrl) {
// 绘制播放按钮图标
// @param {CanvasContext} context canvas上下文
// @param {Canvas} canvas
const drawPlayButton = (context, canvas) => {
// 创建一个 <img> 元素来加载播放图标
const img = new Image();
img.src = config.video_playicon;
// 等待图像加载完成
return new Promise((resolve, reject) => {
img.onload = () => {
// 计算播放按钮的位置和大小
// const playButtonSize = Math.min(canvas.width, canvas.height) * 0.15;
const playButtonSize = canvas.width * 0.15;
const playButtonX = (canvas.width - playButtonSize) / 2;
const playButtonY = (canvas.height - playButtonSize) / 2;
// 绘制播放按钮到 canvas
context.drawImage(img, playButtonX, playButtonY, playButtonSize, playButtonSize);
resolve();
};
img.onerror = (error) => {
reject(new Error('Failed to load SVG image.'));
};
});
}
return new Promise(async (resolve, reject) => {
try {
// 创建一个新的 video 元素,并设置 crossOrigin 属性
const video = document.createElement('video');
video.crossOrigin = 'anonymous'; // 添加 crossOrigin 属性
video.preload = 'metadata';
video.src = videoUrl;
// 创建一个新的 canvas 元素
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
// 监听视频加载元数据完成
video.onloadedmetadata = async () => {
// 设置 canvas 尺寸与视频相同
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// 尝试绘制视频的第一帧到 canvas
video.currentTime = 0; // 确保我们从视频的第一帧开始
video.onseeked = async () => {
try {
context.drawImage(video, 0, 0, canvas.width, canvas.height);
// 绘制播放按钮
await drawPlayButton(context, canvas);
// 将 canvas 内容转换为 Data URL
// resolve(canvas.toDataURL('image/png')); // base64太长了,不建议使用
// 将 canvas 内容转换为 Blob 对象
canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob));
}, 'image/png');
} catch (error) {
reject(new Error('Failed to draw image to canvas.'));
}
};
// 如果 seek 操作没有成功,尝试直接绘制当前帧
setTimeout(async () => {
if (!video.seeking) {
try {
context.drawImage(video, 0, 0, canvas.width, canvas.height);
// 绘制播放按钮
await drawPlayButton(context, canvas);
// 将 canvas 内容转换为 Data URL
// resolve(canvas.toDataURL('image/png')); // base64太长了,不建议使用
// 将 canvas 内容转换为 Blob 对象
canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob));
}, 'image/png');
} catch (error) {
reject(new Error('Failed to draw image to canvas.'));
}
}
}, 1000); // 等待1秒后尝试绘制,防止 seek 操作未完成
};
// 监听视频加载错误
video.onerror = (error) => {
// reject(new Error('Failed to load video or get metadata. PS: Maybe the browser cannot play videos.'));
// 不直接抛出错误,而是抛出一个默认的封面图片,但是需要加以警告提示
console.warn('Failed to load video or get metadata. PS: Maybe the browser cannot play videos.');
resolve(config.video_thumbnail);
};
} catch (error) {
// reject(error);
// 不直接抛出错误,而是抛出一个默认的封面图片,但是需要加以警告提示
console.warn(error)
resolve(config.video_thumbnail);
}
});
},
/**
* 生成封面图片,OffscreenCanvas方式(不兼容iOS
* @param {Object} coverUrl 封面图片地址
* @returns {Promise}
*/
async generateCoverThumbnail(coverUrl) {
return new Promise((resolve, reject) => {
// 内联 Worker 代码
const workerCode = `
self.onmessage = async function(e) {
const { imageUrl, iconBase64 } = e.data;
try {
// 加载图片并创建 ImageBitmap
let imgResponse = await fetch(imageUrl);
if (!imgResponse.ok) {
throw new Error(\`Failed to load image from \${imageUrl}: \${imgResponse.statusText}\`);
}
let imgBlob = await imgResponse.blob();
let imgBitmap = await createImageBitmap(imgBlob);
// 创建 OffscreenCanvas 并绘制图片
const offscreen = new OffscreenCanvas(imgBitmap.width, imgBitmap.height);
const ctx = offscreen.getContext('2d');
ctx.drawImage(imgBitmap, 0, 0);
// 加载图标并创建 ImageBitmap
let iconResponse = await fetch(iconBase64);
if (!iconResponse.ok) {
throw new Error(\`Failed to load icon from \${iconBase64}: \${iconResponse.statusText}\`);
}
let iconBlob = await iconResponse.blob();
let iconBitmap = await createImageBitmap(iconBlob);
// 计算图标的中心位置并绘制
const x = (imgBitmap.width - iconBitmap.width) / 2;
const y = (imgBitmap.height - iconBitmap.height) / 2;
ctx.drawImage(iconBitmap, x, y);
// 获取处理后的图像数据
const result = await offscreen.convertToBlob();
// 发送结果回主线程
self.postMessage(result);
} catch (error) {
console.error('Error processing image:', error.message);
self.postMessage({ error: error.message });
}
};
`
// 创建 Blob
const blob = new Blob([workerCode], { type: 'application/javascript' });
// 创建一个指向 Blob 的 URL
const workerUrl = URL.createObjectURL(blob);
// 实例化 Worker
const worker = new Worker(workerUrl);
// 监听来自 Worker 的消息
worker.onmessage = (e) => {
if (e.data instanceof Blob) {
resolve(URL.createObjectURL(e.data));
} else {
console.warn(e.data.error);
// 不直接抛出错误,而是抛出一个默认的封面图片,但是需要加以警告提示
resolve(config.video_thumbnail);
}
worker.terminate(); // 处理完成后终止 worker
};
// 向 Worker 发送消息
worker.postMessage({ imageUrl: coverUrl, iconBase64: config.video_playicon });
});
},
/**
* 生成封面图片,普通方式,可能影响性能(兼容iOS)
* @param {Object} coverUrl 封面图片地址
* @returns {Promise}
*/
async generateCoverThumbnailIOS(coverUrl){
return new Promise(async (resolve, reject) => {
try {
// 创建 Image 对象并加载封面图片
const img = new Image();
img.src = coverUrl;
await new Promise(resolve => img.onload = resolve);
// 创建 Canvas 并绘制封面图片
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// 加载播放按钮图标并绘制
const icon = new Image();
icon.src = config.video_playicon; // 假设这是播放按钮图标的 URL
await new Promise(resolve => icon.onload = resolve);
// 计算播放按钮的位置和大小
// const playButtonSize = Math.min(canvas.width, canvas.height) * 0.15;
const playButtonSize = canvas.width * 0.15;
const playButtonX = (canvas.width - playButtonSize) / 2;
const playButtonY = (canvas.height - playButtonSize) / 2;
// 确保播放按钮图标按比例缩放
const iconAspectRatio = icon.width / icon.height;
const iconWidth = playButtonSize;
const iconHeight = iconWidth / iconAspectRatio;
// 绘制播放按钮图标到 Canvas
ctx.drawImage(icon, playButtonX, playButtonY, iconWidth, iconHeight);
// 将 canvas 内容转换为 Blob 对象
canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob));
}, 'image/png');
} catch (error) {
// iOS Safari 的安全策略通常比其他浏览器更严格,本地file://协议也会导致跨域
console.warn('iOS createCoverThumbnail error :', error);
// reject(error);
// 不直接抛出错误,而是抛出一个默认的封面图片,但是需要加以警告提示
resolve(config.video_thumbnail);
}
})
},
}
}
</script>
@@ -0,0 +1,94 @@
/**
* 富文本plugin微信小程序特殊扩展
* @author sonve
* @version 1.0.0
* @date 2024-12-17
*/
import config from '../common/config.js'
/**
* 微信小程序特有的OffscreenCanvas方法
* @param {String} coverImageUrl 封面资源地址
* @returns {Promise<String>} 处理后的封面图片的临时文件路径
*/
export function wxCreateCoverThumbnail(coverImageUrl) {
const loadImage = () => {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src: coverImageUrl,
success: (info) => {
resolve(info)
},
fail: (err) => {
reject(err)
}
})
})
}
return new Promise(async (resolve, reject) => {
try {
const imageInfo = await loadImage()
// 创建离屏 Canvas
const canvas = uni.createOffscreenCanvas({
type: '2d',
width: imageInfo.width,
height: imageInfo.height
})
const ctx = canvas.getContext('2d')
// 创建一个图片
const coverImg = canvas.createImage()
// 等待图片加载
await new Promise((resolve) => {
coverImg.onload = resolve
coverImg.src = coverImageUrl // 要加载的图片 url
})
// 绘制封面图片到离屏 Canvas
ctx.drawImage(coverImg, 0, 0, canvas.width, canvas.height)
// 加载播放按钮图标
const playIcon = canvas.createImage()
// 等待图片加载
await new Promise((resolve) => {
playIcon.onload = resolve
playIcon.src = config.video_playicon // 要加载的图片 url
})
// 计算播放按钮的位置和大小
// const playButtonSize = Math.min(canvas.width, canvas.height) * 0.15
const playButtonSize = canvas.width * 0.15
const playButtonX = (canvas.width - playButtonSize) / 2
const playButtonY = (canvas.height - playButtonSize) / 2
// 确保播放按钮图标按比例缩放
const iconAspectRatio = playIcon.width / playIcon.height
const iconWidth = playButtonSize
const iconHeight = iconWidth / iconAspectRatio
// 绘制播放按钮图标到离屏 Canvas
ctx.drawImage(playIcon, playButtonX, playButtonY, iconWidth, iconHeight)
// 获取画完后的数据
uni.canvasToTempFilePath({
canvas: canvas,
destWidth: canvas.width,
destHeight: canvas.height,
fileType: 'png',
success: (res) => {
resolve(res.tempFilePath)
},
fail: (err) => {
reject(new Error('Failed to convert canvas to image.'))
}
})
} catch (error) {
reject(error)
}
})
}
export default {
wxCreateCoverThumbnail
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,261 @@
/**
* 以下方法出自 image-tools
* @see https://ext.dcloud.net.cn/plugin?id=123
*/
function 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) {
let localFilePath = plus.io.convertAbsoluteFileSystem(path)
if (localFilePath !== path) {
return localFilePath
} else {
path = path.substr(1)
}
}
return '_www/' + path
}
function dataUrlToBase64(str) {
let array = str.split(',')
return array[array.length - 1]
}
let index = 0
function getNewFileId() {
return Date.now() + String(index++)
}
function biggerThan(v1, v2) {
let v1Array = v1.split('.')
let v2Array = v2.split('.')
let update = false
for (let index = 0; index < v2Array.length; index++) {
let diff = v1Array[index] - v2Array[index]
if (diff !== 0) {
update = diff > 0
break
}
}
return update
}
export function pathToBase64(path) {
return new Promise(function(resolve, reject) {
if (typeof window === 'object' && 'document' in window) {
if (typeof FileReader === 'function') {
let xhr = new XMLHttpRequest()
xhr.open('GET', path, true)
xhr.responseType = 'blob'
xhr.onload = function() {
if (this.status === 200) {
let fileReader = new FileReader()
fileReader.onload = function(e) {
resolve(e.target.result)
}
fileReader.onerror = reject
fileReader.readAsDataURL(this.response)
}
}
xhr.onerror = reject
xhr.send()
return
}
let canvas = document.createElement('canvas')
let c2x = canvas.getContext('2d')
let img = new Image
img.onload = function() {
canvas.width = img.width
canvas.height = img.height
c2x.drawImage(img, 0, 0)
resolve(canvas.toDataURL())
canvas.height = canvas.width = 0
}
img.onerror = reject
img.src = path
return
}
if (typeof plus === 'object') {
plus.io.resolveLocalFileSystemURL(getLocalFilePath(path), function(entry) {
entry.file(function(file) {
let fileReader = new plus.io.FileReader()
fileReader.onload = function(data) {
resolve(data.target.result)
}
fileReader.onerror = function(error) {
reject(error)
}
fileReader.readAsDataURL(file)
}, function(error) {
reject(error)
})
}, function(error) {
reject(error)
})
return
}
if (typeof wx === 'object' && wx.canIUse('getFileSystemManager')) {
wx.getFileSystemManager().readFile({
filePath: path,
encoding: 'base64',
success: function(res) {
resolve('data:image/png;base64,' + res.data)
},
fail: function(error) {
reject(error)
}
})
return
}
reject(new Error('not support'))
})
}
export function base64ToPath(base64) {
return new Promise(function(resolve, reject) {
if (typeof window === 'object' && 'document' in window) {
base64 = base64.split(',')
let type = base64[0].match(/:(.*?);/)[1]
let str = atob(base64[1])
let n = str.length
let array = new Uint8Array(n)
while (n--) {
array[n] = str.charCodeAt(n)
}
return resolve((window.URL || window.webkitURL).createObjectURL(new Blob([array], {
type: type
})))
}
let extName = base64.split(',')[0].match(/data\:\S+\/(\S+);/)
if (extName) {
extName = extName[1]
} else {
reject(new Error('base64 error'))
}
let fileName = getNewFileId() + '.' + extName
if (typeof plus === 'object') {
let basePath = '_doc'
let dirPath = 'uniapp_temp'
let filePath = basePath + '/' + dirPath + '/' + fileName
if (!biggerThan(plus.os.name === 'Android' ? '1.9.9.80627' : '1.9.9.80472', plus.runtime.innerVersion)) {
plus.io.resolveLocalFileSystemURL(basePath, function(entry) {
entry.getDirectory(dirPath, {
create: true,
exclusive: false,
}, function(entry) {
entry.getFile(fileName, {
create: true,
exclusive: false,
}, function(entry) {
entry.createWriter(function(writer) {
writer.onwrite = function() {
resolve(filePath)
}
writer.onerror = reject
writer.seek(0)
writer.writeAsBinary(dataUrlToBase64(base64))
}, reject)
}, reject)
}, reject)
}, reject)
return
}
let bitmap = new plus.nativeObj.Bitmap(fileName)
bitmap.loadBase64Data(base64, function() {
bitmap.save(filePath, {}, function() {
bitmap.clear()
resolve(filePath)
}, function(error) {
bitmap.clear()
reject(error)
})
}, function(error) {
bitmap.clear()
reject(error)
})
return
}
if (typeof wx === 'object' && wx.canIUse('getFileSystemManager')) {
let filePath = wx.env.USER_DATA_PATH + '/' + fileName
wx.getFileSystemManager().writeFile({
filePath: filePath,
data: dataUrlToBase64(base64),
encoding: 'base64',
success: function() {
resolve(filePath)
},
fail: function(error) {
reject(error)
}
})
return
}
reject(new Error('not support'))
})
}
/**
* 本方法为本人自己写的,建议还是使用上述的pathToBase64方法
* @description 图片地址转换为base64格式图片
* @param {string} url 图片地址 网络地址 本地相对路径
* @param {string} type base64图片类型 默认png
*/
export function urlToBase64(url, type = 'png') {
let promises
// 网络地址 或者h5端本地相对路径 可使用request方式
promises = new Promise((resolve, reject) => {
uni.request({
url: url,
method: 'GET',
responseType: 'arraybuffer',
success: (res) => {
const base64 = `data:image/${type};base64,${uni.arrayBufferToBase64(res.data)}`
resolve(base64);
},
fail: (err) => {
reject(err);
},
})
})
// #ifdef APP
if (!url.startsWith('http')) {
// app真机本地相对路径
promises = new Promise((resolve, reject) => {
// 使用compressImage获取到安卓本地路径file:///...
uni.compressImage({
src: url,
quality: 100,
success: (res) => {
const tempUrl = res.tempFilePath
plus.io.resolveLocalFileSystemURL(tempUrl, (entry) => {
entry.file((e) => {
let fileReader = new plus.io.FileReader();
fileReader.onload = (r) => {
resolve(r.target.result)
}
fileReader.readAsDataURL(e)
})
})
},
fail: (err) => {
reject(err);
},
})
})
}
// #endif
return promises
}
@@ -0,0 +1,180 @@
/**
* 富文本解析工具
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
import config from './config.js'
/**
* 将含有封面占位图形式的视频富文本转换成正常视频的富文本
* @param {String} richText 要进行处理的富文本字符串
* @returns {String} 返回处理结果
*/
export function parseHtmlWithVideo(richText) {
// 正则表达式匹配<img>标签及其属性
const imgRegex = /<img\s+([^>]+)>/gi;
// 正则表达式匹配data-custom属性中的url值
const customUrlRegex = /\bdata-custom="[^"]*url=([^&"]+)/i;
return richText.replace(imgRegex, (match, attrs) => {
// 查找data-custom属性中的url值
const urlMatch = attrs.match(customUrlRegex);
if (urlMatch) {
// 获取data-custom中的url
const videoUrl = urlMatch[1];
// 解析出所有属性
const attrArray = attrs.split(/\s+/).filter(attr => attr.trim() !== '');
// 过滤掉src属性和data-custom属性
const newAttrs = attrArray.filter(attr => !attr.startsWith('src=') && !attr.startsWith('data-custom='))
.join(' ');
// 构建新的video标签,保留原有的其他属性,但去除src和data-custom
//return `<video controls ${newAttrs}><source src="${videoUrl}" /></video>`;
return `<video controls ${newAttrs} src="${videoUrl}" width="100%" ></video>`;
}
// 如果没有匹配到data-custom中的url,则保持原样
return match;
});
}
/**
* 带有视频的富文本逆向转换
* @description 可自定义处理封面
* @param {Promise} richText 要转换的富文本
* @param {Function<Promise>} customCallback 自定义处理封面回调,需要return封面图片资源,自带参数为视频地址
* @returns {Promise} 转换后的富文本 注意异步处理
*/
export async function replaceVideoWithImageRender(richText, customCallback) {
// 正则表达式用于匹配 <video> 标签以及其内部的 <source> 标签
const videoRegex = /<video\s+([^>]+)>(.*?)<\/video>/gi;
// 找到所有的 <video> 标签
const matches = [];
let match;
while ((match = videoRegex.exec(richText)) !== null) {
matches.push(match);
}
// 并行处理每个 <video> 标签,生成对应的缩略图
const replacements = await Promise.all(
matches.map(async (match) => {
const [fullMatch, attributes, content] = match;
// 匹配 <source> 标签中的 src 属性
const sourceRegex = /<source\s+[^>]*src="([^">]+)"/i;
const matchSource = content.match(sourceRegex);
let videoUrl = '';
if (matchSource && matchSource.length > 1) {
videoUrl = matchSource[1];
}
// 生成视频封面图
let thumbnailRes
if (customCallback) thumbnailRes = await customCallback(videoUrl) // 自定义封面处理
if (!thumbnailRes) thumbnailRes = config.video_thumbnail // 无效值则默认封面处理
// 过滤掉不需要的属性,例如 controls
const filteredAttributes = attributes
.split(/\s+/)
.filter(attr => !attr.startsWith('controls'))
.join(' ');
// 构建新的 img 标签,继承 video 的属性(除了 controls)并添加 data-custom 属性
const imgTag = `<img ${filteredAttributes} src="${thumbnailRes}" data-custom="url=${videoUrl}" />`;
return { fullMatch, imgTag };
}));
// 使用 replacements 替换原始的 <video> 标签
let result = richText;
for (const { fullMatch, imgTag } of replacements) {
result = result.replace(fullMatch, imgTag);
}
return result;
}
/**
* 解析出富文本中的图片和视频
* @param {String} richText 要解析的富文本
* @returns {Array} 图片和视频数组
*/
export function parseImagesAndVideos(richText) {
// 创建一个空数组用于存储图片和视频信息
const result = [];
// 正则表达式匹配 <img> 标签及其属性
const imgRegex = /<img\s+[^>]*>/gi;
// 匹配属性名和值的正则表达式,改进后的版本可以处理属性名中包含连字符的情况
const attrRegex = /(\w+(-\w+)*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'<>]+))/gi;
// 找到所有的 <img> 标签
const matches = richText.match(imgRegex);
// 如果没有找到任何 <img> 标签,返回空数组
if (!matches) return [];
// 遍历所有的 <img> 标签
matches.forEach(match => {
// 创建一个对象用于存储单个图片或视频的信息
const ivInfo = {};
// 使用正则表达式匹配每个 <img> 标签的属性
let attrsMatch;
while ((attrsMatch = attrRegex.exec(match)) !== null) {
// 属性名
const name = attrsMatch[1].toLowerCase();
// 属性值可能存在于第三、第四或第五个捕获组中
let value = attrsMatch[3] || attrsMatch[4] || attrsMatch[5] || '';
// 去除属性值两端可能存在的引号
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
value = value.substring(1, value.length - 1);
}
// 将属性名和值添加到 ivInfo 对象中
ivInfo[name] = value;
}
// 将单个图片或视频信息添加到数组中
result.push(ivInfo);
});
// 返回包含所有图片和视频信息的数组
return result;
}
/**
* 解析出富文本中的图片
* @param {String} richText 要解析的富文本
* @returns {Array} 图片数组
*/
export function parseImages(richText) {
let result = []
const ivList = parseImagesAndVideos(richText)
ivList.forEach(item => {
if (!item['data-custom'] || !item['data-custom'].startsWith('url')) {
result.push(item)
}
})
return result
}
/**
* 解析出富文本中的视频
* @param {String} richText 要解析的富文本
* @returns {Array} 视频数组
*/
export function parseVideos(richText) {
let result = []
const ivList = parseImagesAndVideos(richText)
ivList.forEach(item => {
if (item['data-custom'] && item['data-custom'].startsWith('url')) {
result.push(item)
}
})
return result
}
@@ -0,0 +1,101 @@
/**
* 插件内全局状态管理
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
// #ifdef VUE3
import { reactive } from 'vue';
// #endif
// #ifdef VUE2
import Vue from 'vue';
// #endif
// 定义state状态
let state = null
// #ifdef VUE3
// 定义响应式状态
state = reactive({
curEID: '',
formats: {},
isReadOnly: false,
firstInstanceFlag: '' // 首次实例化标志,禁止手动更改
})
// #endif
// #ifdef VUE2
// 定义响应式状态
state = Vue.observable({
curEID: '',
formats: {},
isReadOnly: false,
firstInstanceFlag: '' // 首次实例化标志,禁止手动更改
})
// #endif
// 定义方法
function getEditor(eid) {
return state[`${eid}-ctx`];
};
function setEditor(eid, ctx) {
state[`${eid}-ctx`] = ctx
// #ifdef MP-WEIXIN
state[`${eid}-ctx`].id = eid
// #endif
}
function getEID() {
return state.curEID
};
function setEID(eid) {
state.curEID = eid
}
function getFormats() {
return state.formats
}
function setFormats(formats) {
state.formats = formats
}
function getReadOnly() {
return state.isReadOnly
}
function setReadOnly(readOnly) {
state.isReadOnly = readOnly
}
function destroy() {
// 重置所有状态
state = {}
state.curEID = ''
state.formats = {}
state.isReadOnly = false
state.firstInstanceFlag = '' // 首次实例化标志,禁止手动更改
}
// 定义options对象
const options = {
state,
actions: {
getEditor,
setEditor,
getEID,
setEID,
getFormats,
setFormats,
getReadOnly,
setReadOnly,
destroy
}
}
// 导出
export default options
@@ -0,0 +1,208 @@
/**
* 工具栏
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
export const toolList = [
{ title: '样式', name: 'style', icon: 'icon-zitiyanse' },
{ title: '表情', name: 'emoji', icon: 'icon-xiaolian' },
{ title: '撤销', name: 'undo', icon: 'icon-shangyibu1' },
{ title: '重做', name: 'redo', icon: 'icon-xiayibu1' },
{ title: '更多', name: 'more', icon: 'icon-icon_tianjia' },
{ title: '扩展', name: 'setting', icon: 'icon-bianji' },
]
export const styleToolList = [
{ title: '标题', name: 'header', value: 2, icon: 'icon-zitibiaoti' },
{ title: '分割线', name: 'divider', icon: 'icon-fengexian' },
{ title: '粗体', name: 'bold', icon: 'icon-zitijiacu' },
{ title: '斜体', name: 'italic', icon: 'icon-zitixieti' },
{ title: '下划线', name: 'underline', icon: 'icon-zitixiahuaxian' },
{ title: '删除线', name: 'strike', icon: 'icon-zitishanchuxian' },
{ title: '左对齐', name: 'align', value: 'left', icon: 'icon-zuoduiqi' },
{ title: '居中', name: 'align', value: 'center', icon: 'icon-juzhongduiqi' },
{ title: '右对齐', name: 'align', value: 'right', icon: 'icon-youduiqi' },
{ title: '有序列表', name: 'list', value: 'ordered', icon: 'icon-youxupailie' },
{ title: '无序列表', name: 'list', value: 'bullet', icon: 'icon-wuxupailie' },
{ title: '上标', name: 'script', value: 'super', icon: 'icon-zitishangbiao' },
{ title: '左缩进', name: 'indent', value: '+1', icon: 'icon-zuosuojin' },
{ title: '右缩进', name: 'indent', value: '-1', icon: 'icon-yousuojin' },
{ title: '下标', name: 'script', value: 'sub', icon: 'icon-ziti-xiabiao' },
{ title: '文字颜色', name: 'color', icon: 'icon-wenziyanse' },
{ title: '背景颜色', name: 'backgroundColor', icon: 'icon-beijingyanse' },
{ title: '清除格式', name: 'removeformat', icon: 'icon-qingchugeshi' },
]
export const moreToolList = [
{ title: '添加图片', name: 'image', value: 'popup', icon: 'icon-charutupian' },
{ title: '添加视频', name: 'video', value: 'popup', icon: 'icon-shexiangji' },
{ title: '添加链接', name: 'link', value: 'popup', icon: 'icon-charulianjie' },
{ title: '添加附件', name: 'attachment', value: 'popup', icon: 'icon-huixingzhen' },
{ title: '提及', name: 'at', value: 'popup', icon: 'icon-at' },
{ title: '话题', name: 'topic', value: 'popup', icon: 'icon-huati' },
{ title: '清空', name: 'clear', value: 'button', icon: 'icon-shanchu' },
]
export const emojiToolList = [
'😊', // 笑笑
'😃', // 大笑
'😄', // 开心果
'😁', // 嘲讽
'😆', // 爆笑
'😅', // 出汗笑
'🤣', // 滚地大笑
'😂', // 泪流满面
'🙂', // 轻松愉快
'🙃', // 上下翻白眼
'😉', // 鬼鬼祟祟
'😌', // 安慰
'😍', // 心动
'🥰', // 深情
'😘', // 吻
'😗', // 接吻
'😙', // 亲吻
'😚', // 亲吻
'😋', // 哇塞
'😛', // 舌头外伸
'😝', // 舌头吐出
'😜', // 顽皮
'🤪', // 疯狂
'😎', // 自豪
'🤓', // 学究
'🧐', // 思考
'😏', // 狡猾
'😒', // 不高兴
'😞', // 不开心
'😔', // 抒发情绪
'😟', // 担忧
'😕', // 困惑
'🙁', // 小失望
'☹️', // 不好意思
'😣', // 苦恼
'😖', // 愤怒
'😫', // 累
'😩', // 悲伤
'😤', // 生气
'😠', // 生气
'😡', // 极端愤怒
'🤬', // 发飙
'🤯', // 爆炸头脑
'😳', // 吃惊
'😱', // 惊吓
'😨', // 恐惧
'😰', // 慌张
'😢', // 哭泣
'😭', // 大哭
'😓', // 受挫
'🤗', // 给力
'🤔', // 思考
'🤭', // 戴口罩捂嘴笑
'🤫', // 戴口罩做鬼脸
'🤥', // 说谎
'😬', // 格格不入
'😴', // 睡觉
'🤤', // 垂涎欲滴
'🥳', // 庆祝
'🥺', // 求求你
'😈', // 恶魔
'👿', // 恶灵
'🤡', // 小丑
'👻', // 鬼魂
'👽', // 外星人
'👾', // 游戏角色
'🤖', // 机器人
'😺', // 笑猫
'😸', // 大笑猫
'😹', // 开心猫
'😻', // 心动猫
'😼', // 傲娇猫
'😽', // 亲吻猫
'🙀', // 惊吓猫
'😿', // 哭猫
'😾' // 生气猫
]
export const colorList = [
'#000000',
'#222222',
'#444444',
'#666666',
'#999999',
'#cccccc',
'#eeeeee',
'#ffffff',
'#c92a2a',
'#e03131',
'#f03e3e',
'#fa5252',
'#ff6b6b',
'#ff8787',
'#ffa8a8',
'#ffc9c9',
'#a61e4d',
'#c2255c',
'#d6336c',
'#e64980',
'#f06595',
'#f783ac',
'#faa2c1',
'#fcc2d7',
'#862e9c',
'#9c36b5',
'#ae3ec9',
'#be4bdb',
'#cc5de8',
'#da77f2',
'#e599f7',
'#eebefa',
'#5f3dc4',
'#6741d9',
'#7048e8',
'#7950f2',
'#845ef7',
'#9775fa',
'#b197fc',
'#d0bfff',
'#0b7285',
'#0c8599',
'#1098ad',
'#15aabf',
'#22b8cf',
'#3bc9db',
'#66d9e8',
'#99e9f2',
'#087f5b',
'#099268',
'#0ca678',
'#12b886',
'#20c997',
'#38d9a9',
'#63e6be',
'#96f2d7',
'#5c940d',
'#66a80f',
'#74b816',
'#82c91e',
'#94d82d',
'#a9e34b',
'#c0eb75',
'#ffec99',
'#d9480f',
'#e8590c',
'#f76707',
'#fd7e14',
'#ff922b',
'#ffa94d',
'#ffc078',
'#ffd8a8'
]
@@ -0,0 +1,430 @@
/**
* 通用工具api
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
import store from './store.js'
export function addText(word){
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 取消键盘副作用
noKeyboardEffect(() => {
editorCtx.insertText({ text: '\n' })
editorCtx.insertText({ text:'【'+word+'】:' })
// 建议加个换行,虽然会导致input回调再次触发,不过问题不大
editorCtx.insertText({ text: '\n' })
})
}
/**
* 添加图片
* @param {Function} uploadFunc 文件上传函数(开发者自行调用上传接口上传本地图片至服务器后获取服务器图片真实地址,需要return包含地址的数组)
* @param {Object} options 图片配置项
* @property {String} options.srcFiled 图片地址字段名,默认无时使用数组元素本身
* @property {String} options.alt 图像无法显示时的替代文本
* @property {String} options.width 图片宽度(pixels/百分比)为空时自适应图片本身宽度,默认空(不建议100%,预留一点空隙以便用户编辑)
* @property {String} options.height 图片高度 (pixels/百分比)为空时自适应图片本身高度,默认空
* @property {String} options.extClass 添加到图片 img 标签上的类名
* @property {String} options.data 被序列化为 v1=1;v2=2 的格式挂在属性 data-custom 上
* @returns {Array|Promise} 上传的文件数组
*/
export function addImage(imgs, options = {}) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 服务器上传图片
//if (!uploadFunc) return
const upRes = imgs //await uploadFunc(editorCtx)
console.log('upRes')
console.log(upRes)
console.log(upRes instanceof Array)
console.log(upRes.length)
if (!upRes || upRes.length==0) return
// 取消键盘副作用
noKeyboardEffect(() => {
editorCtx.insertText({ text: '\n' })
upRes?.forEach((item) => {
console.log('item')
console.log(item)
editorCtx.insertImage({
...options,
src: options.srcFiled ? item[options.srcFiled] : item,
})
})
// 建议加个换行,虽然会导致input回调再次触发,不过问题不大
editorCtx.insertText({ text: '\n' })
})
return upRes
}
/**
* 添加视频
* @description uni-editor暂不支持插入视频,此处使用视频封面占位,将视频地址作为属性挂在至data-custom上,携带视频的富文本需要使用专用的api进行解析导出。注:建议后端配合返回视频封面图片地址,或者使用固定的网络图片作为封面。
* @param {Function} uploadFunc 文件上传函数(开发者自行调用上传接口上传本地视频至服务器后获取服务器视频真实地址,需要return包含地址的数组)
* @param {Object} options 视频封面图片配置项
* @property {String} options.imageFiled 视频封面图片地址字段名,默认imagePath
* @property {String} options.videoFiled 视频真实地址字段名,默认videoPath
* @property {String} options.alt 视频封面图片无法显示时的替代文本
* @property {String} options.width 视频封面图片宽度(pixels/百分比)默认空,但是要注意,不设置width的话,video标签默认宽度为300px
* @property {String} options.height 视频封面图片高度 (pixels/百分比)默认空
* @property {String} options.extClass 添加到视频封面图片 img 标签上的类名
* @property {String} options.data 警告:视频地址已存入data-custom中,请勿使用此参数导致视频地址被覆盖
* @returns {Array|Promise} 上传的文件数组
*/
export async function addVideo(uploadFunc, options = {}) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 服务器上传视频
if (!uploadFunc) return
const upRes = await uploadFunc(editorCtx)
console.log(upRes);
if (!upRes || !upRes?.length) return
// 取消键盘副作用
noKeyboardEffect(() => {
editorCtx.insertText({ text: '\n' })
upRes?.forEach((item) => {
editorCtx.insertImage({
...options,
src: item.videoImg,//item[options.imageFiled || 'imagePath'],
data: { url: item.videoUrl },
})
})
// 建议加个换行,虽然会导致input回调再次触发,不过问题不大
editorCtx.insertText({ text: '\n' })
})
return upRes
}
/**
* 添加链接
* @param {Object} options 链接配置项
* @property {String} options.link 链接地址
* @property {String} options.text 链接文本 空缺时使用link
* @property {String} options.textDecoration 下划线
* @property {String} options.color 颜色 默认#007aff
* @property {Object} options.style 其他样式,例如 { bold: true, italic: true } 等,详见:https://quilljs.com/docs/delta
* @param {Function} callback 添加链接成功后回调
* @returns {void}
*/
export async function addLink(options = {}, callback) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 取消键盘副作用
noKeyboardEffect(() => {
insertLink(editorCtx, {
...options,
link: options.link,
text: ` ${options.text || options.link} `, // 前后各加一个空格
}, () => {
editorCtx.changeInput() // 通知更新编辑器input事件
if (callback) callback()
})
})
}
/**
* 添加附件
* @param {Function} uploadFunc 文件上传函数(开发者自行调用上传接口上传本地附件至服务器后获取服务器附件真实地址,需要return包含地址的对象)
* @param {Object} options 附件配置项
* @property {String} options.srcFiled 附件地址字段名,默认path
* @property {String} options.link 附件地址 注:临时地址会自动转成about:blank导致无效
* @property {String} options.text 附件文本 空缺时使用link
* @property {String} options.textDecoration 下划线
* @property {String} options.color 颜色 默认#34d19d
* @property {Object} options.style 其他样式,例如 { bold: true, italic: true } 等,详见:https://quilljs.com/docs/delta
* @param {Function} callback 添加附件成功后回调
* @returns {Object|Promise} 上传的文件对象
*/
export async function addAttachment(uploadFunc, options = {}, callback) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 服务器上传附件
if (!uploadFunc) return
const upRes = await uploadFunc(editorCtx)
if (!upRes) return
const link = upRes[options.srcFiled || 'path'] || options.link
if (!link) return
const text = ` 📄${upRes.text || options.text || upRes.file?.name || link } ` // 加上附件图标前置,并前后各加一个空格
// 取消键盘副作用
noKeyboardEffect(() => {
insertLink(editorCtx, {
color: '#34d19d',
...options,
text,
link,
}, () => {
editorCtx.changeInput() // 通知更新编辑器input事件
if (callback) callback()
})
})
return upRes
}
/**
* 添加提及
* @param {Object} options 提及配置项
* @property {String} options.username 用户名称
* @property {String} options.userid 用户id
* @property {String} options.textDecoration 下划线
* @property {String} options.color 颜色 默认#66ccff
* @property {Object} options.style 其他样式,例如 { bold: true, italic: true } 等,详见:https://quilljs.com/docs/delta
* @param {Function} callback 添加链接成功后回调
*/
export async function addAt(options = {}, callback) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 取消键盘副作用
noKeyboardEffect(() => {
insertLink(editorCtx, {
color: '#66ccff',
...options,
link: `@${options.userid}`, // 添加特殊前缀,后续便于解析标识
text: ` @${options.username} `, // 前后各加一个空格
}, () => {
editorCtx.changeInput() // 通知更新编辑器input事件
if (callback) callback()
})
})
}
/**
* 添加话题
* @param {Object} options 话题配置项
* @property {String} options.link 话题链接
* @property {String} options.topic 话题名称
* @property {String} options.textDecoration 下划线
* @property {String} options.color 颜色 默认#909399
* @property {Object} options.style 其他样式,例如 { bold: true, italic: true } 等,详见:https://quilljs.com/docs/delta
* @param {Function} callback 添加链接成功后回调
*/
export async function addTopic(options = {}, callback) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 取消键盘副作用
noKeyboardEffect(() => {
insertLink(editorCtx, {
color: '#909399',
...options,
link: `#${options.link}`, // 添加特殊前缀,后续便于解析标识
text: ` #${options.topic}# `, // 前后各加一个空格
}, () => {
editorCtx.changeInput() // 通知更新编辑器input事件
if (callback) callback()
})
})
}
/**
* 标识必须独一无二 - 标识是为了使用insertText插入标识文本后,查找到标识所在delta位置的索引
* 注:因为做了一次insertText操作,所有可能会有linkFlag标识字样闪一下的副作用,没有办法避免
*/
export const linkFlag = '🔗添加链接中, 请稍后...🔗' // 建议语义化该标识,否则闪烁的时候可能会有点尴尬
/**
* 插入链接
* @description uni-editor暂不支持插入链接,此api使用delta替换链接本文标识方式实现,因硬性原因会导致光标失焦
* @param {Object} editorCtx 编辑器上下文
* @param {Object} attr 链接属性
* @property {String} attr.link 链接地址 注:临时地址会自动转成about:blank导致无效
* @property {String} attr.text 链接文本 空缺时使用link
* @property {String} attr.textDecoration 下划线
* @property {String} attr.color 颜色 默认#007aff
* @property {Object} attr.style 其他样式,例如 { bold: true, italic: true } 等,详见:https://quilljs.com/docs/delta
* @param {Object} callback 成功回调
*/
export function insertLink(editorCtx, attr, callback) {
// 先插入一段文本内容
editorCtx.insertText({ text: linkFlag })
// 必须先失焦,否则光标会移至开始位置
editorCtx.blur()
// 获取全文delta内容
editorCtx.getContents({
success: (res) => {
let options = res.delta.ops
const findex = options.findIndex(item => {
return item.insert && typeof item.insert !== 'object' && item.insert?.indexOf(linkFlag) !== -1
})
// 根据标识查找到插入的位置
if (findex > -1) {
const findOption = options[findex]
const findAttributes = findOption.attributes
// 将该findOption分成三部分:前内容 要插入的link 后内容
const [prefix, suffix] = findOption.insert.split(linkFlag);
const handleOps = []
// 前内容
if (prefix) {
const prefixOps = findAttributes ? {
insert: prefix,
attributes: findAttributes
} : {
insert: prefix
}
handleOps.push(prefixOps)
}
// 插入的link
const linkOps = {
insert: attr.text || attr.link,
attributes: {
link: attr.link,
textDecoration: attr.textDecoration || 'none', // 下划线
color: attr.color || '#007aff',
...attr.style
}
}
handleOps.push(linkOps)
// 后内容
if (suffix) {
const suffixOps = findAttributes ? {
insert: suffix,
attributes: findAttributes
} : {
insert: suffix
}
handleOps.push(suffixOps)
}
// 删除原options[findex]并在findex位置插入上述三个ops
options.splice(findex, 1);
options.splice(findex, 0, ...handleOps);
// 最后重新初始化内容
editorCtx.setContents({
delta: {
ops: options
}
})
// 清除格式,以防残留超链接格式
editorCtx.removeFormat()
editorCtx.format('color', 'inherit')
// 后续回调操作
if (callback) callback()
}
}
})
}
/**
* 尽量消除键盘带来的影响;重要:核心功能!!!
* @param {Function} callback 回调
* @param {Object} options 配置项
* @property {String} options.mode 可选:setInputMode:通过控制ql-editor的inputmode属性控制键盘 [H5 APP] | loseFocus:通过blur失焦隐藏键盘 [MP-WEIXIN] | hideKeyboard:通过hideKeyboard隐藏键盘 | setReadOnly:通过控制读写隐藏键盘
* @property {Number} options.delay 延时(毫秒)默认50
*/
export function noKeyboardEffect(callback, options) {
let defaultOpt = { delay: 50 }
// #ifdef APP
const isIOS = uni.getSystemInfoSync().platform == 'ios'
defaultOpt.mode = isIOS ? 'loseFocus' : 'setInputMode' // iOS使用setInputMode无效
// #endif
// #ifdef H5
defaultOpt.mode = 'setInputMode'
// #endif
// #ifdef MP-WEIXIN
defaultOpt.mode = 'loseFocus'
// #endif
const opt = Object.assign(defaultOpt, options)
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 通过 uni.hideKeyboard() 隐藏键盘,但是会导致键盘闪烁
// 微信小程序好像无法正常隐藏键盘
if (opt.mode == 'hideKeyboard') {
callback()
setTimeout(() => {
uni.hideKeyboard()
}, opt.delay)
}
// 通过控制编辑器失焦来隐藏键盘,但是会导致键盘闪烁
// 只推荐微信小程序使用(也是无可奈何)
if (opt.mode == 'loseFocus') {
callback()
editorCtx.blur()
}
// 通过控制编辑器读写模式进行屏蔽焦点,虽然隐藏了键盘,但是也失焦了
// 微信小程序中当只读时是无法使用api去修改内容的
if (opt.mode == 'setReadOnly') {
store.actions.setReadOnly(true)
callback()
setTimeout(() => {
store.actions.setReadOnly(false)
}, opt.delay)
}
// 使用renderjs给ql-editor节点设置inputmode属性来控制键盘是否弹出
// 设置none时将会阻止键盘弹出,设置remove将会恢复,完美适配H5、App(Android),但是不支持App(iOS)和微信小程序
if (opt.mode == 'setInputMode') {
// #ifdef APP || H5
// 以下严格处理异步与延时操作,缺一不可
editorCtx.changeInputMode('none')
setTimeout(() => {
callback()
setTimeout(() => {
editorCtx.changeInputMode('remove')
}, opt.delay)
}, opt.delay)
// #endif
}
}
/**
* 版权信息
*/
import packageConfig from '../../package.json'
export function copyrightPrint() {
/* 标题样式 */
const styleTitle1 = `font-size:16px;font-weight:700;color:#ff4500;`
const styleTitle2 = `font-style:oblique;font-size:14px;color:#fb7299;`
const styleContent = `color:#66ccff;`
/* 版权信息 */
const title1 = ` 📝 sv-editor v${packageConfig.version} `
const title2 = 'by Sonve'
const content = `
版权声明:
1. 本插件免费开源,还望保留此版权声明在控制台输出
2. 如需借鉴源码,还望注明出处
3. 未经授权您不得以任何形式转载、售卖本插件,或以其他形式侵犯版权及附属权利
4. 作者将保留对此插件版权信息的最终解释权
🏠 地址: https://ext.dcloud.net.cn/plugin?id=21184
😸 Gitee: https://gitee.com/Sonve/sv-editor
💬 微信: s1051399604
🐧 QQ群: ① 852637893 ② 816646292
`
console.log(`%c${title1}%c${title2}%c${content}`, styleTitle1, styleTitle2, styleContent)
}
export function noAuthorization(name) {
/* 标题样式 */
const styleTitle1 = `font-size:16px;font-weight:700;color:#e6a23c;`
const styleTitle2 = `font-style:oblique;font-size:14px;color:#fb7299;`
const styleContent = `color:#f56c6c;`
/* 授权信息 */
const title1 = ` ⛔ sv-editor ${name} `
const title2 = 'by Sonve'
const content = `
提示:您还未获取插件特殊扩展功能授权,可联系作者获取
💬 微信: s1051399604 | 🐧 QQ群: ① 852637893 ② 816646292
🏠 插件地址: https://ext.dcloud.net.cn/plugin?id=21184
`
console.log(`%c${title1}%c${title2}%c${content}`, styleTitle1, styleTitle2, styleContent)
}
@@ -0,0 +1,233 @@
@font-face {
font-family: "iconfont";
/* 在vue2中直接使用 ./iconfont.ttf 会找不到文件,很坑,需要返回上一级再点回来 */
src: url('../icons/iconfont.ttf') format('truetype');
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-zitishangbiao:before {
content: "\e8e5";
}
.icon-ziti-xiabiao:before {
content: "\e8ea";
}
.icon-yousuojin:before {
content: "\e6f0";
}
.icon-zuosuojin:before {
content: "\e6f1";
}
.icon-wenziyanse:before {
content: "\e60b";
}
.icon-beijingyanse:before {
content: "\e634";
}
.icon-qingchugeshi:before {
content: "\e6f5";
}
.icon-tuige:before {
content: "\e61a";
}
.icon-xiajiantou:before {
content: "\eb6d";
}
.icon-shangjiantou:before {
content: "\eb6e";
}
.icon-huati:before {
content: "\e659";
}
.icon-video:before {
content: "\e60a";
}
.icon-jianpan:before {
content: "\e636";
}
.icon-at:before {
content: "\e81e";
}
.icon-bianji:before {
content: "\eb61";
}
.icon-icon_tianjia:before {
content: "\eb89";
}
.icon-xingzhuang-tupian:before {
content: "\eb98";
}
.icon-xingzhuang-wenzi:before {
content: "\eb99";
}
.icon-huixingzhen:before {
content: "\ebe6";
}
.icon-xiayibu:before {
content: "\ebef";
}
.icon-shangyibu:before {
content: "\ebf0";
}
.icon-baocun:before {
content: "\ec09";
}
.icon-xiayibu1:before {
content: "\ec0a";
}
.icon-shangyibu1:before {
content: "\ec0b";
}
.icon-weizhigeshi:before {
content: "\ec1a";
}
.icon-chehuisekuai:before {
content: "\ec45";
}
.icon-shexiangji:before {
content: "\ec59";
}
.icon-fuzhi:before {
content: "\ec7a";
}
.icon-shanchu:before {
content: "\ec7b";
}
.icon-bianjisekuai:before {
content: "\ec7c";
}
.icon-fengexian:before {
content: "\ec7f";
}
.icon-charulianjie:before {
content: "\ec80";
}
.icon-charutupian:before {
content: "\ec81";
}
.icon-quxiaolianjie:before {
content: "\ec82";
}
.icon-wuxupailie:before {
content: "\ec83";
}
.icon-juzhongduiqi:before {
content: "\ec84";
}
.icon-yinyong:before {
content: "\ec85";
}
.icon-youxupailie:before {
content: "\ec86";
}
.icon-youduiqi:before {
content: "\ec87";
}
.icon-zitidaima:before {
content: "\ec88";
}
.icon-xiaolian:before {
content: "\ec89";
}
.icon-zitijiacu:before {
content: "\ec8a";
}
.icon-zitishanchuxian:before {
content: "\ec8b";
}
.icon-zitibiaoti:before {
content: "\ec8c";
}
.icon-zitixiahuaxian:before {
content: "\ec8d";
}
.icon-zitixieti:before {
content: "\ec8e";
}
.icon-zitiyanse:before {
content: "\ec8f";
}
.icon-zuoduiqi:before {
content: "\ec90";
}
.icon-zuoyouduiqi:before {
content: "\ec91";
}
.icon-tianxie:before {
content: "\ec92";
}
.icon-kongxinwenhao:before {
content: "\ed19";
}
.icon-fangkuai:before {
content: "\ed1a";
}
.icon-jia_sekuai:before {
content: "\ed21";
}
.icon-jian_sekuai:before {
content: "\ed22";
}
.icon-fenxiangfangshi:before {
content: "\ed2e";
}
Binary file not shown.
@@ -0,0 +1,656 @@
<template>
<text
:eid="eid"
:change:eid="quillEditor.watchEID"
:sid="sid"
:change:sid="quillEditor.watchStartID"
:video="videoUrl"
:change:pastemode="quillEditor.watchPasteMode"
:pastemode="pastemode"
:change:video="quillEditor.watchVideoUrl"
:cover="coverUrl"
:change:cover="quillEditor.watchCoverUrl"
:coverios="coverUrlIOS"
:change:coverios="quillEditor.watchCoverUrlIOS"
></text>
</template>
<script>
/**
* 富文本plugin特殊扩展
* @author sonve
* @version 1.0.0
* @date 2024-12-14
*/
export default {
props: {
sid: {
type: String,
default: ''
},
eid: {
type: String,
default: ''
}
},
data() {
return {
videoUrl: '', // 视频地址
coverUrl: '', // 封面地址
coverUrlIOS: '', // ios端封面地址
pastemode: 'text' // 粘贴模式 text | origin
}
},
mounted() {},
methods: {
changePasteMode(e) {
this.pastemode = e
},
editorPaste(e) {
this.$emit('epaste', e)
},
createVideoThumbnail(url) {
this.videoUrl = url
},
getVideoThumbnail(e) {
// e: { video, cover }
uni.$emit(`E_EDITOR_GET_VIDEO_THUMBNAIL_${e.video}`, e)
},
createCoverThumbnail(url) {
// #ifdef H5
this.coverUrl = url
// #endif
// #ifdef APP
const isIOS = uni.getSystemInfoSync().platform == 'ios'
if (isIOS) {
this.coverUrlIOS = url // iOS用不了OffscreenCanvas
} else {
this.coverUrl = url
}
// #endif
},
getCoverThumbnail(e) {
// e: { image, cover }
uni.$emit(`E_EDITOR_GET_COVER_THUMBNAIL_${e.image}`, e)
}
}
}
</script>
<script module="quillEditor" lang="renderjs">
import config from '../common/config.js'
export default {
data() {
return {
editorID: '',
idStack: [], // sid栈
matcherMode: '' // 粘贴模式 text | origin
}
},
methods: {
/**
* 注意:watch频繁触发时需要异步修改,否则可能会导致监听不到
*/
watchPasteMode(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.matcherMode = newValue
}
},
watchStartID(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.addMatcher(newValue)
}
},
watchEID(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.editorID = newValue
}
},
watchVideoUrl(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.generateVideoThumbnail(newValue).then((res) => {
this.$ownerInstance.callMethod('getVideoThumbnail', {
video: newValue,
cover: res
})
})
}
},
watchCoverUrl(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.generateCoverThumbnail(newValue).then((res) => {
this.$ownerInstance.callMethod('getCoverThumbnail', {
image: newValue,
cover: res
})
})
}
},
/**
* Only Apple Can Do !!!
*/
watchCoverUrlIOS(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.generateCoverThumbnailIOS(newValue).then((res) => {
this.$ownerInstance.callMethod('getCoverThumbnail', {
image: newValue,
cover: res
})
})
}
},
/**
* 保留格式粘贴内容
* @description 此方式尽可能保留原格式,易于再编辑,但是部分格式会丢失
* @param {String} sid 当前编辑器id
*/
addMatcher(sid) {
if(this.idStack.includes(sid)) return // 禁止重复添加Matcher
this.idStack.push(sid)
const el = document.querySelector(`#${sid}`);
const quill = Quill.find(el);
const getStyleAttributes = (node, style) => {
let attributes = {}
// node属性
const width = node.getAttribute('width');
const height = node.getAttribute('height');
if (width) attributes.width = width
if (height) attributes.height = height
const dataCustom = node.getAttribute('data-custom');
if (dataCustom) attributes['data-custom'] = dataCustom;
// style样式
if (style.textAlign) attributes.align = style.textAlign;
if (style.fontWeight === 'bold' || style.fontWeight === '700') attributes.bold = true;
if (style.fontStyle === 'italic') attributes.italic = true;
if (style.textDecoration.includes('underline')) attributes.underline = true;
if (style.textDecoration.includes('line-through')) attributes.strike = true;
if (style.verticalAlign === 'super') attributes.script = 'super'
if (style.verticalAlign === 'sub') attributes.script = 'sub'
if (style.fontFamily) attributes.font = style.fontFamily;
if (style.fontSize) attributes.size = parseFloat(style.fontSize);
if (style.color) attributes.color = style.color;
if (style.backgroundColor) attributes.background = style.backgroundColor;
return attributes
}
const processNode = (node) => {
let ops = [];
if (node.nodeType === Node.ELEMENT_NODE) {
const computedStyle = document.defaultView.getComputedStyle(node);
// 处理 <img> 标签
if (node.tagName === 'IMG') {
const imgSrc = node.getAttribute('src');
if (imgSrc) {
ops.push({ insert: '\n' }); // 插入换行符,确保图片前有一个空行
ops.push({
insert: { image: imgSrc },
attributes: getStyleAttributes(node, computedStyle)
});
ops.push({ insert: '\n' }); // 插入换行符,确保图片后有一个空行
return ops; // 不参与递归
}
}
// 处理 <p> 和 <div> 标签
else if (node.tagName === 'P' || node.tagName === 'DIV') {
ops.push({ insert: '\n' }); // 插入换行符
}
// 处理 <ol> 标签 有序
else if (node.tagName === 'OL') {
// ops.push({ insert: '\n', attributes: { list: 'ordered' } });
}
// 处理 <ul> 标签 无序
else if (node.tagName === 'UL') {
// ops.push({ insert: '\n', attributes: { list: 'bullet' } });
}
// 处理 <li> 标签
else if (node.tagName === 'LI') {
ops.push({ insert: '\n' });
}
// 处理 <hr> 标签
else if (node.tagName === 'HR') {
ops.push({ insert: '\n' }); // 插入换行符
ops.push({ insert: { divider: true } });
return ops; // 不参与递归
}
// 处理 <a> 标签
else if (node.tagName === 'A') {
const href = node.getAttribute('href');
const textContent = node.textContent.trim();
if (href && textContent) {
ops.push({
insert: ' ' + textContent + ' ',
attributes: {
link: href,
textDecoration: computedStyle.textDecoration,
...getStyleAttributes(node, computedStyle)
}
});
return ops; // 不参与递归
}
}
// 处理 <h1> 到 <h6> 标题
else if (['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(node.tagName)) {
// 处理 <h1> 到 <h6> 标题,生成 header 操作
const headerLevel = parseInt(node.tagName.charAt(1), 10);
const textContent = node.textContent.trim();
if (textContent) {
ops.push({
insert: textContent + '\n', // 必须要加上换行
attributes: {
header: headerLevel,
...getStyleAttributes(node, computedStyle)
}
});
return ops; // 不参与递归
}
}
// 递归处理子节点
for (let child of node.childNodes) {
ops = ops.concat(processNode(child));
}
} else if (node.nodeType === Node.TEXT_NODE) {
const textContent = node.nodeValue.trim();
if (textContent) {
// 从父元素获取样式
const parentNode = node.parentNode;
if (parentNode) {
const computedStyle = document.defaultView.getComputedStyle(parentNode);
ops.push({
insert: textContent,
attributes: getStyleAttributes(parentNode, computedStyle)
});
} else {
// 如果没有父元素,直接插入文本
ops.push({ insert: textContent });
}
}
}
return ops;
}
quill.clipboard.addMatcher(Node.ELEMENT_NODE, (node, delta) => {
if (this.matcherMode == 'origin') {
let newOps = processNode(node);
if (newOps.length > 0) {
return { ops: newOps };
}
}
return delta;
})
const cleanClipboardHTML = (html, text) => {
if(!html) return text
// 使用正则表达式匹配 <!--StartFragment--> 和 <!--EndFragment--> 之间的内容
const fragmentRegex = /<!--StartFragment-->([\s\S]*?)<!--EndFragment-->/;
const match = html.match(fragmentRegex);
if (match && match[1]) {
// 返回匹配到的内容
return match[1].trim();
}
// 如果没有匹配到片段内容,返回原始 HTML
return html;
}
el.addEventListener('paste', (e) => {
let clipboardText = e.clipboardData.getData('text/plain'); // 获取剪切板中的纯文本内容
let clipboardHtml = e.clipboardData.getData('text/html'); // 获取剪切板中的 HTML 内容(如果存在)
clipboardHtml = cleanClipboardHTML(clipboardHtml, clipboardText)
setTimeout(() => {
this.$ownerInstance.callMethod('editorPaste', {
id: sid,
text: clipboardText,
html: clipboardHtml,
range: quill.getSelection() // 获取当前光标位置
})
}, 100);
});
},
/**
* 保留格式粘贴内容
* @description 此方式虽然可以极大程度保留格式,但是会导致粘贴下来的内容为一整个块,且不易再编辑
* @param {String} sid 当前编辑器id
*/
/*
addMatcher(sid) {
if(this.idStack.includes(sid)) return // 禁止重复添加Matcher
this.idStack.push(sid)
// 引入源码中的BlockEmbed
const BlockEmbed = Quill.import('blots/block/embed');
// 定义新的blot类型
class AppPanelEmbed extends BlockEmbed {
static create(value) {
const node = super.create(value);
node.setAttribute('width', '100%');
// 设置自定义html
node.innerHTML = this.transformValue(value)
return node;
}
static transformValue(value) {
let handleArr = value.split('\n')
handleArr = handleArr.map(e => e.replace(/^[\s]+/, '').replace(/[\s]+$/, ''))
return handleArr.join('')
}
// 返回节点自身的value值 用于撤销操作
static value(node) {
return node.innerHTML
}
}
// blotName
AppPanelEmbed.blotName = 'AppPanelEmbed';
// 标签类型自定义
AppPanelEmbed.tagName = 'p';
Quill.register(AppPanelEmbed, true);
const el = document.querySelector(`#${sid}`);
const quill = Quill.find(el);
const cleanClipboardHTML = (html, text) => {
if(!html) return text
// 使用正则表达式匹配 <!--StartFragment--> 和 <!--EndFragment--> 之间的内容
const fragmentRegex = /<!--StartFragment-->([\s\S]*?)<!--EndFragment-->/;
const match = html.match(fragmentRegex);
if (match && match[1]) {
// 返回匹配到的内容
return match[1].trim();
}
// 如果没有匹配到片段内容,返回原始 HTML
return html;
}
el.addEventListener('paste', (e) => {
e.preventDefault();
let clipboardText = e.clipboardData.getData('text/plain'); // 获取剪切板中的纯文本内容
let clipboardHtml = e.clipboardData.getData('text/html'); // 获取剪切板中的 HTML 内容(如果存在)
clipboardHtml = cleanClipboardHTML(clipboardHtml, clipboardText)
this.$ownerInstance.callMethod('editorPaste', {
id: sid,
text: clipboardText,
html: clipboardHtml
})
setTimeout(() => {
const range = quill.getSelection(); // 获取当前光标位置
quill.insertEmbed(range.index, 'AppPanelEmbed', clipboardHtml);
}, 100);
});
},
*/
/**
* 生成视频封面图片(不兼容iOS)
* @property {String} videoUrl 视频地址
* @returns {Promise} 视频封面图片 注意异步处理
*/
async generateVideoThumbnail(videoUrl) {
// 绘制播放按钮图标
// @param {CanvasContext} context canvas上下文
// @param {Canvas} canvas
const drawPlayButton = (context, canvas) => {
// 创建一个 <img> 元素来加载播放图标
const img = new Image();
img.src = config.video_playicon;
// 等待图像加载完成
return new Promise((resolve, reject) => {
img.onload = () => {
// 计算播放按钮的位置和大小
// const playButtonSize = Math.min(canvas.width, canvas.height) * 0.15;
const playButtonSize = canvas.width * 0.15;
const playButtonX = (canvas.width - playButtonSize) / 2;
const playButtonY = (canvas.height - playButtonSize) / 2;
// 绘制播放按钮到 canvas
context.drawImage(img, playButtonX, playButtonY, playButtonSize, playButtonSize);
resolve();
};
img.onerror = (error) => {
reject(new Error('Failed to load SVG image.'));
};
});
}
return new Promise(async (resolve, reject) => {
try {
// 创建一个新的 video 元素,并设置 crossOrigin 属性
const video = document.createElement('video');
video.crossOrigin = 'anonymous'; // 添加 crossOrigin 属性
video.preload = 'metadata';
video.src = videoUrl;
// 创建一个新的 canvas 元素
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
// 监听视频加载元数据完成
video.onloadedmetadata = async () => {
// 设置 canvas 尺寸与视频相同
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// 尝试绘制视频的第一帧到 canvas
video.currentTime = 0; // 确保我们从视频的第一帧开始
video.onseeked = async () => {
try {
context.drawImage(video, 0, 0, canvas.width, canvas.height);
// 绘制播放按钮
await drawPlayButton(context, canvas);
// 将 canvas 内容转换为 Data URL
// resolve(canvas.toDataURL('image/png')); // base64太长了,不建议使用
// 将 canvas 内容转换为 Blob 对象
canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob));
}, 'image/png');
} catch (error) {
reject(new Error('Failed to draw image to canvas.'));
}
};
// 如果 seek 操作没有成功,尝试直接绘制当前帧
setTimeout(async () => {
if (!video.seeking) {
try {
context.drawImage(video, 0, 0, canvas.width, canvas.height);
// 绘制播放按钮
await drawPlayButton(context, canvas);
// 将 canvas 内容转换为 Data URL
// resolve(canvas.toDataURL('image/png')); // base64太长了,不建议使用
// 将 canvas 内容转换为 Blob 对象
canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob));
}, 'image/png');
} catch (error) {
reject(new Error('Failed to draw image to canvas.'));
}
}
}, 1000); // 等待1秒后尝试绘制,防止 seek 操作未完成
};
// 监听视频加载错误
video.onerror = (error) => {
// reject(new Error('Failed to load video or get metadata. PS: Maybe the browser cannot play videos.'));
// 不直接抛出错误,而是抛出一个默认的封面图片,但是需要加以警告提示
console.warn('Failed to load video or get metadata. PS: Maybe the browser cannot play videos.');
resolve(config.video_thumbnail);
};
} catch (error) {
// reject(error);
// 不直接抛出错误,而是抛出一个默认的封面图片,但是需要加以警告提示
console.warn(error)
resolve(config.video_thumbnail);
}
});
},
/**
* 生成封面图片,OffscreenCanvas方式(不兼容iOS
* @param {Object} coverUrl 封面图片地址
* @returns {Promise}
*/
async generateCoverThumbnail(coverUrl) {
return new Promise((resolve, reject) => {
// 内联 Worker 代码
const workerCode = `
self.onmessage = async function(e) {
const { imageUrl, iconBase64 } = e.data;
try {
// 加载图片并创建 ImageBitmap
let imgResponse = await fetch(imageUrl);
if (!imgResponse.ok) {
throw new Error(\`Failed to load image from \${imageUrl}: \${imgResponse.statusText}\`);
}
let imgBlob = await imgResponse.blob();
let imgBitmap = await createImageBitmap(imgBlob);
// 创建 OffscreenCanvas 并绘制图片
const offscreen = new OffscreenCanvas(imgBitmap.width, imgBitmap.height);
const ctx = offscreen.getContext('2d');
ctx.drawImage(imgBitmap, 0, 0);
// 加载图标并创建 ImageBitmap
let iconResponse = await fetch(iconBase64);
if (!iconResponse.ok) {
throw new Error(\`Failed to load icon from \${iconBase64}: \${iconResponse.statusText}\`);
}
let iconBlob = await iconResponse.blob();
let iconBitmap = await createImageBitmap(iconBlob);
// 计算图标的中心位置并绘制
const x = (imgBitmap.width - iconBitmap.width) / 2;
const y = (imgBitmap.height - iconBitmap.height) / 2;
ctx.drawImage(iconBitmap, x, y);
// 获取处理后的图像数据
const result = await offscreen.convertToBlob();
// 发送结果回主线程
self.postMessage(result);
} catch (error) {
console.error('Error processing image:', error.message);
self.postMessage({ error: error.message });
}
};
`
// 创建 Blob
const blob = new Blob([workerCode], { type: 'application/javascript' });
// 创建一个指向 Blob 的 URL
const workerUrl = URL.createObjectURL(blob);
// 实例化 Worker
const worker = new Worker(workerUrl);
// 监听来自 Worker 的消息
worker.onmessage = (e) => {
if (e.data instanceof Blob) {
resolve(URL.createObjectURL(e.data));
} else {
console.warn(e.data.error);
// 不直接抛出错误,而是抛出一个默认的封面图片,但是需要加以警告提示
resolve(config.video_thumbnail);
}
worker.terminate(); // 处理完成后终止 worker
};
// 向 Worker 发送消息
worker.postMessage({ imageUrl: coverUrl, iconBase64: config.video_playicon });
});
},
/**
* 生成封面图片,普通方式,可能影响性能(兼容iOS)
* @param {Object} coverUrl 封面图片地址
* @returns {Promise}
*/
async generateCoverThumbnailIOS(coverUrl){
return new Promise(async (resolve, reject) => {
try {
// 创建 Image 对象并加载封面图片
const img = new Image();
img.src = coverUrl;
await new Promise(resolve => img.onload = resolve);
// 创建 Canvas 并绘制封面图片
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// 加载播放按钮图标并绘制
const icon = new Image();
icon.src = config.video_playicon; // 假设这是播放按钮图标的 URL
await new Promise(resolve => icon.onload = resolve);
// 计算播放按钮的位置和大小
// const playButtonSize = Math.min(canvas.width, canvas.height) * 0.15;
const playButtonSize = canvas.width * 0.15;
const playButtonX = (canvas.width - playButtonSize) / 2;
const playButtonY = (canvas.height - playButtonSize) / 2;
// 确保播放按钮图标按比例缩放
const iconAspectRatio = icon.width / icon.height;
const iconWidth = playButtonSize;
const iconHeight = iconWidth / iconAspectRatio;
// 绘制播放按钮图标到 Canvas
ctx.drawImage(icon, playButtonX, playButtonY, iconWidth, iconHeight);
// 将 canvas 内容转换为 Blob 对象
canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob));
}, 'image/png');
} catch (error) {
// iOS Safari 的安全策略通常比其他浏览器更严格,本地file://协议也会导致跨域
console.warn('iOS createCoverThumbnail error :', error);
// reject(error);
// 不直接抛出错误,而是抛出一个默认的封面图片,但是需要加以警告提示
resolve(config.video_thumbnail);
}
})
},
}
}
</script>
@@ -0,0 +1,175 @@
/**
* 富文本plugin微信小程序特殊扩展
* @author sonve
* @version 1.0.0
* @date 2024-12-17
*/
import config from '../common/config.js'
/**
* 微信小程序特有的OffscreenCanvas方法
* @param {String} coverImageUrl 封面资源地址
* @returns {Promise<String>} 处理后的封面图片的临时文件路径
*/
export function wxCreateCoverThumbnail(coverImageUrl) {
const loadImage = () => {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src: coverImageUrl,
success: (info) => {
resolve(info)
},
fail: (err) => {
reject(err)
}
})
})
}
return new Promise(async (resolve, reject) => {
try {
const imageInfo = await loadImage()
// 创建离屏 Canvas
const canvas = uni.createOffscreenCanvas({
type: '2d',
width: imageInfo.width,
height: imageInfo.height
})
const ctx = canvas.getContext('2d')
// 创建一个图片
const coverImg = canvas.createImage()
// 等待图片加载
await new Promise((resolve) => {
coverImg.onload = resolve
coverImg.src = coverImageUrl // 要加载的图片 url
})
// 绘制封面图片到离屏 Canvas
ctx.drawImage(coverImg, 0, 0, canvas.width, canvas.height)
// 加载播放按钮图标
const playIcon = canvas.createImage()
// 等待图片加载
await new Promise((resolve) => {
playIcon.onload = resolve
playIcon.src = config.video_playicon // 要加载的图片 url
})
// 计算播放按钮的位置和大小
// const playButtonSize = Math.min(canvas.width, canvas.height) * 0.15
const playButtonSize = canvas.width * 0.15
const playButtonX = (canvas.width - playButtonSize) / 2
const playButtonY = (canvas.height - playButtonSize) / 2
// 确保播放按钮图标按比例缩放
const iconAspectRatio = playIcon.width / playIcon.height
const iconWidth = playButtonSize
const iconHeight = iconWidth / iconAspectRatio
// 绘制播放按钮图标到离屏 Canvas
ctx.drawImage(playIcon, playButtonX, playButtonY, iconWidth, iconHeight)
// 获取画完后的数据
uni.canvasToTempFilePath({
canvas: canvas,
destWidth: canvas.width,
destHeight: canvas.height,
fileType: 'png',
success: (res) => {
resolve(res.tempFilePath)
},
fail: (err) => {
reject(new Error('Failed to convert canvas to image.'))
}
})
} catch (error) {
reject(error)
}
})
}
export function wxCreateVideoThumbnail(coverImageUrl) {
const loadVideo = () => {
return new Promise((resolve, reject) => {
uni.getVideoInfo({
src: coverImageUrl,
success: (info) => {
resolve(info)
},
fail: (err) => {
reject(err)
}
})
})
}
return new Promise(async (resolve, reject) => {
try {
const imageInfo = await loadVideo()
// 创建离屏 Canvas
const canvas = uni.createOffscreenCanvas({
type: '2d',
width: imageInfo.width,
height: imageInfo.height
})
const ctx = canvas.getContext('2d')
// 创建一个图片
const coverImg = canvas.createImage()
// 等待图片加载
await new Promise((resolve) => {
coverImg.onload = resolve
coverImg.src = coverImageUrl // 要加载的图片 url
})
// 绘制封面图片到离屏 Canvas
ctx.drawImage(coverImg, 0, 0, canvas.width, canvas.height)
// 加载播放按钮图标
const playIcon = canvas.createImage()
// 等待图片加载
await new Promise((resolve) => {
playIcon.onload = resolve
playIcon.src = config.video_playicon // 要加载的图片 url
})
// 计算播放按钮的位置和大小
// const playButtonSize = Math.min(canvas.width, canvas.height) * 0.15
const playButtonSize = canvas.width * 0.15
const playButtonX = (canvas.width - playButtonSize) / 2
const playButtonY = (canvas.height - playButtonSize) / 2
// 确保播放按钮图标按比例缩放
const iconAspectRatio = playIcon.width / playIcon.height
const iconWidth = playButtonSize
const iconHeight = iconWidth / iconAspectRatio
// 绘制播放按钮图标到离屏 Canvas
ctx.drawImage(playIcon, playButtonX, playButtonY, iconWidth, iconHeight)
// 获取画完后的数据
uni.canvasToTempFilePath({
canvas: canvas,
destWidth: canvas.width,
destHeight: canvas.height,
fileType: 'png',
success: (res) => {
resolve(res.tempFilePath)
},
fail: (err) => {
reject(new Error('Failed to convert canvas to image.'))
}
})
} catch (error) {
reject(error)
}
})
}
export default {
wxCreateCoverThumbnail,
wxCreateVideoThumbnail
}
@@ -0,0 +1,122 @@
<template>
<text :data="flag" :props="config" :change:data="fileManager.watchData" :change:props="fileManager.watchProps"></text>
</template>
<script>
/**
* 文件选择 - APP端
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
export default {
props: {
/**
* 配置项
* @tutorial https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/input/file
*/
config: {
type: Object,
default: () => {
return {
accept: `.doc,.docx,.xls,.xlsx,.pdf,.zip,.rar,
application/msword,
application/vnd.openxmlformats-officedocument.wordprocessingml.document,
application/vnd.ms-excel,
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,
application/pdf,
application/zip,
application/x-rar-compressed`,
multiple: false
}
}
}
},
data() {
return {
flag: 0 // 监听标志
}
},
methods: {
chooseFile() {
this.flag++ // 修改监听标志
},
rawFile(file) {
this.$emit('confirm', file)
}
}
}
</script>
<script module="fileManager" lang="renderjs">
import { base64ToPath } from '../common/file-handler.js';
export default {
data() {
return {
configCopy: {}, // 跟随vue中props的配置
}
},
methods: {
watchData(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.openFileManager()
}
},
watchProps(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.configCopy = newValue
}
},
openFileManager() {
try {
const { accept, multiple } = this.configCopy
// 创建文件选择器input
let fileInput = document.createElement('input')
fileInput.setAttribute('type', 'file')
fileInput.setAttribute('accept', accept)
// 注:是否多选不要直接赋值multiple,应当是为false时不添加multiple属性
if(multiple) fileInput.setAttribute('multiple', multiple)
fileInput.click()
// 封装为Promise的FileReader读取文件
const readFileAsDataURL = (file) => {
return new Promise((resolve, reject) => {
let reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = async (event) => {
const base64 = event.target.result
const path = await base64ToPath(base64)
resolve({
name: file.name,
type: file.type,
size: file.size,
base64,
path
});
};
reader.onerror = (error) => {
reject(error);
};
});
}
fileInput.addEventListener('change', async (e) => {
let files = e.target.files // 注:此处为FileList对象,并非常规数组
let results = await Promise.all(
// Array.from 方法可以将类数组对象转换为真正的数组
Array.from(files).map(item => readFileAsDataURL(item))
);
// callMethod不支持流数据,无法直接传递文件流对象
this.$ownerInstance.callMethod('rawFile', results)
})
} catch (err) {
console.warn('==== openFileManager catch error :', err);
}
}
}
}
</script>
@@ -0,0 +1,178 @@
<template>
<view @touchmove.stop.prevent="moveStop">
<view class="sv-editor-colorpicker" v-if="showPicker">
<view class="editor-popup-header">
<!-- <view class="header-left" @click="cancel">取消</view> -->
<view class="header-left" @click="reset">重置</view>
<view class="header-title" :style="{ backgroundColor: selectColor }" v-if="selectColor">{{ selectColor }}</view>
<view class="header-right" @click="confirm">确认</view>
</view>
<view class="sv-editor-colorpicker-container">
<view
v-for="item in allColors"
:key="item"
class="color-item"
:style="{ backgroundColor: item }"
@click="onSelect(item)"
></view>
</view>
</view>
<view class="mask" v-if="showPicker" @click.stop="onMask"></view>
</view>
</template>
<script>
import { colorList } from '../common/tool-list'
export default {
name: 'sv-editor-colorpicker',
props: {
show: {
type: Boolean,
default: false
},
color: {
type: String,
default: ''
},
type: {
type: String,
default: 'color'
},
// 点击遮罩层自动关闭弹窗
maskClose: {
type: Boolean,
default: true
}
},
emits: ['update:show', 'open', 'close', 'onMask', 'cancel', 'confirm'],
// #ifdef VUE2
model: {
prop: 'show',
event: 'update:show'
},
// #endif
data() {
return {
selectColor: this.color
}
},
watch: {
color(newVal) {
this.selectColor = newVal
}
},
computed: {
showPicker: {
set(newVal) {
this.$emit('update:show', newVal)
},
get() {
return this.show
}
},
allColors() {
return colorList
}
},
methods: {
// 阻止滑动穿透
moveStop() {},
open() {
this.showPicker = true
this.$emit('open')
},
close() {
this.showPicker = false
this.$emit('close')
},
onMask() {
if (this.maskClose) this.close()
this.$emit('onMask')
},
cancel() {
this.$emit('cancel')
this.close()
},
confirm() {
this.$emit('confirm', this.selectColor, this.type)
},
reset() {
this.selectColor = ''
},
onSelect(e) {
this.selectColor = e
}
}
}
</script>
<style lang="scss">
.sv-editor-colorpicker {
--editor-colorpicker-bgcolor: #ffffff;
--editor-colorpicker-radius: 30rpx 30rpx 0 0;
--editor-colorpicker-confirm: #4d80f0;
--editor-colorpicker-cancel: #fa4350;
--editor-colorpicker-header-height: 50rpx;
width: 100%;
position: absolute;
bottom: 0;
z-index: 10000;
border-radius: var(--editor-colorpicker-radius);
padding: 30rpx;
padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
background-color: var(--editor-colorpicker-bgcolor);
box-sizing: border-box;
.editor-popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
box-sizing: border-box;
height: var(--editor-colorpicker-header-height);
.header-left {
color: var(--editor-colorpicker-cancel);
}
.header-title {
color: #000000;
text-shadow: 1rpx 1rpx #ffffff, -1rpx 1rpx #ffffff, 1rpx -1rpx #ffffff, -1rpx -1rpx #ffffff;
padding: 4rpx 12rpx;
box-shadow: 0 0 8rpx #cccccc;
border-radius: 10rpx;
}
.header-right {
color: var(--editor-colorpicker-confirm);
}
}
.sv-editor-colorpicker-container {
// max-height: 40vh;
overflow: auto;
display: grid;
grid-template-columns: repeat(8, 1fr);
align-items: center; /* 垂直居中 */
justify-items: center; /* 水平居中 */
gap: 20rpx;
box-sizing: border-box;
.color-item {
width: 100%;
height: 60rpx;
box-shadow: 0 0 8rpx #ccc;
border-radius: 10rpx;
}
}
}
.mask {
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 9999;
background-color: rgba(0, 0, 0, 0.5);
}
</style>
@@ -0,0 +1,445 @@
<template>
<view @touchmove.stop.prevent="moveStop">
<view class="sv-editor-popup" v-if="showPop">
<view class="editor-popup-header">
<view class="header-left" @click="cancel">取消</view>
<view class="header-title">{{ title }}</view>
<view class="header-right" @click="confirm">确认</view>
</view>
<view class="sv-editor-popup-container">
<!-- 添加图片 -->
<view class="popup-image" v-if="toolName == 'image'">
<view class="popup-form-input">
<text class="form-label">网络图片</text>
<input v-model="imageForm.link" type="text" class="form-input" placeholder="请输入图片地址" />
</view>
<view class="popup-form-input">
<text class="form-label">本地图片</text>
<button size="mini" class="form-button" @click="selectImage">选择文件</button>
<view class="form-thumbnail">
<image
class="form-thumbnail-item form-thumbnail-image"
v-for="(item, index) in imageForm.file"
:key="item.path"
:src="item.path"
@click="deleteImage(index)"
></image>
</view>
</view>
</view>
<!-- 添加视频 -->
<view class="popup-video" v-if="toolName == 'video'">
<view class="popup-form-input">
<text class="form-label">网络视频</text>
<input v-model="videoForm.link" type="text" class="form-input" placeholder="请输入视频地址" />
</view>
<view class="popup-form-input">
<text class="form-label">本地视频</text>
<button size="mini" class="form-button" @click="selectVideo">选择文件</button>
<view class="form-thumbnail" v-if="videoForm.file.tempFilePath">
<view class="form-thumbnail-item form-thumbnail-icon" @click="deleteVideo">
<text class="iconfont icon-video"></text>
</view>
</view>
</view>
</view>
<!-- 添加链接 -->
<view class="popup-link" v-if="toolName == 'link'">
<view class="popup-form-input">
<text class="form-label">链接地址</text>
<input v-model="linkForm.link" type="text" class="form-input" placeholder="请输入链接地址 (必填)" />
</view>
<view class="popup-form-input">
<text class="form-label">链接文本</text>
<input v-model="linkForm.text" type="text" class="form-input" placeholder="请输入链接文本 (可选)" />
</view>
</view>
<!-- 添加附件 -->
<view class="popup-attachment" v-if="toolName == 'attachment'">
<view class="popup-form-input">
<text class="form-label">附件地址</text>
<input v-model="attachmentForm.link" type="text" class="form-input" placeholder="请输入附件地址" />
</view>
<view class="popup-form-input">
<text class="form-label">附件描述</text>
<input v-model="attachmentForm.text" type="text" class="form-input" placeholder="请输入附件描述" />
</view>
<view class="popup-form-input">
<text class="form-label">本地文件</text>
<button size="mini" class="form-button" @click="selectAttachment">选择文件</button>
<view class="form-thumbnail" v-if="attachmentForm.file.path">
<view class="form-thumbnail-item form-thumbnail-icon" @click="deleteAttachment">
<text class="iconfont icon-huixingzhen"></text>
</view>
</view>
</view>
</view>
<!-- 提及 -->
<view class="popup-at" v-if="toolName == 'at'">
<slot name="at"></slot>
</view>
<!-- 话题 -->
<view class="popup-topic" v-if="toolName == 'topic'">
<slot name="topic"></slot>
</view>
</view>
</view>
<view class="mask" v-if="showPop" @click.stop="onMask"></view>
<!-- #ifdef APP -->
<sv-choose-file ref="chooseFileRef" @confirm="selectAppFile"></sv-choose-file>
<!-- #endif -->
</view>
</template>
<script>
/**
* 扩展工具面板弹窗
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
import { moreToolList } from '../common/tool-list.js'
import SvChooseFile from './sv-choose-file.vue'
export default {
name: 'sv-editor-popup-more',
// #ifdef MP-WEIXIN
// 微信小程序特殊配置
options: {
addGlobalClass: true,
virtualHost: true,
styleIsolation: 'shared'
},
// #endif
components: {
SvChooseFile
},
props: {
show: {
type: Boolean,
default: false
},
toolName: {
type: [String, null],
default: 'image'
},
// 点击遮罩层自动关闭弹窗
maskClose: {
type: Boolean,
default: true
}
},
emits: ['update:show', 'open', 'close', 'onMask', 'cancel', 'confirm'],
// #ifdef VUE2
model: {
prop: 'show',
event: 'update:show'
},
// #endif
data() {
return {
imageForm: {
link: '',
file: []
},
videoForm: {
link: '',
file: {}
},
linkForm: {
link: '',
text: ''
},
attachmentForm: {
link: '',
text: '',
file: {}
}
}
},
computed: {
showPop: {
set(newVal) {
this.$emit('update:show', newVal)
},
get() {
return this.show
}
},
title() {
return moreToolList.find((item) => item.name == this.toolName)?.title
}
},
methods: {
// 阻止滑动穿透
moveStop() {},
open() {
this.showPop = true
this.$emit('open')
},
close() {
this.showPop = false
this.$emit('close')
},
onMask() {
if (this.maskClose) this.close()
this.$emit('onMask')
},
cancel() {
this.$emit('cancel')
this.close()
},
confirm() {
let params = {}
params.name = this.toolName
switch (this.toolName) {
case 'image':
Object.assign(params, this.imageForm)
break
case 'video':
Object.assign(params, this.videoForm)
break
case 'link':
Object.assign(params, this.linkForm)
break
case 'attachment':
Object.assign(params, this.attachmentForm)
break
}
this.$emit('confirm', params)
},
/**
* 业务方法
*/
// 选择图片
selectImage() {
// #ifdef APP || H5
uni.chooseImage({
count: 5, // 默认9,此处限制为5
success: (res) => {
this.imageForm.file = res.tempFiles
},
fail: () => {
uni.showToast({
title: '未授权访问相册权限,请授权后使用',
icon: 'none'
})
}
})
// #endif
// #ifdef MP-WEIXIN
uni.chooseMedia({
count: 5, // 默认9,此处限制为5
mediaType: ['image'],
success: (res) => {
this.imageForm.file = res.tempFiles
},
fail: () => {
uni.showToast({
title: '未授权访问相册权限,请授权后使用',
icon: 'none'
})
}
})
// #endif
},
// 删除指定图片
deleteImage(index) {
this.imageForm.file.splice(index, 1)
},
// 选择视频
selectVideo() {
uni.chooseVideo({
sourceType: ['camera', 'album'],
success: (res) => {
this.videoForm.file = res
},
fail: () => {
uni.showToast({
title: '未授权访问媒体权限,请授权后使用',
icon: 'none'
})
}
})
},
// 删除选择的本地视频
deleteVideo() {
this.videoForm.file = {}
},
// 选择附件
selectAttachment() {
// #ifdef H5
uni.chooseFile({
count: 1, // 默认100,此处限制为1
extension: ['.doc', '.docx', '.xls', '.xlsx', '.pdf', '.zip', '.rar'],
success: (res) => {
this.attachmentForm.file = res.tempFiles[0]
},
fail: () => {
uni.showToast({
title: '未授权访问文件权限,请授权后使用',
icon: 'none'
})
}
})
// #endif
// #ifdef APP
this.$refs.chooseFileRef.chooseFile()
// 选择文件完成后触发selectAppFile方法
// #endif
// #ifdef MP-WEIXIN
wx.chooseMessageFile({
count: 1, // 最多可以选择的文件个数,可以 0~100,此处限制为1
type: 'file', // 可以选择除了图片和视频之外的其它的文件
extension: ['.doc', '.docx', '.xls', '.xlsx', '.pdf', '.zip', '.rar'],
success: (res) => {
this.attachmentForm.file = res.tempFiles[0]
},
fail: () => {
uni.showToast({
title: '未授权访问文件权限,请授权后使用',
icon: 'none'
})
}
})
// #endif
},
// 选择文件完成后触发
selectAppFile(files) {
this.attachmentForm.file = files[0]
},
// 删除指定附件
deleteAttachment() {
this.attachmentForm.file = {}
}
}
}
</script>
<style lang="scss">
@import '../icons/iconfont.css';
.sv-editor-popup {
--editor-popup-radius: 30rpx 30rpx 0 0;
--editor-popup-bgcolor: #ffffff;
--editor-popup-confirm: #4d80f0;
--editor-popup-cancel: #fa4350;
--thumbnail-icon-bgcolor: #cccccc;
--editor-popup-header-height: 50rpx;
width: 100%;
position: absolute;
bottom: 0;
z-index: 10000;
border-radius: var(--editor-popup-radius);
padding: 30rpx;
padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
background-color: var(--editor-popup-bgcolor);
box-sizing: border-box;
.editor-popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
box-sizing: border-box;
height: var(--editor-popup-header-height);
.header-left {
color: var(--editor-popup-cancel);
}
.header-right {
color: var(--editor-popup-confirm);
}
}
.sv-editor-popup-container {
box-sizing: border-box;
.popup-form-input {
display: flex;
align-items: center;
margin-bottom: 30rpx;
box-sizing: border-box;
.form-label {
margin-right: 20rpx;
flex-shrink: 0;
}
.form-input {
flex: 1;
padding: 12rpx;
border: 1rpx solid #eeeeee;
border-radius: 8rpx;
line-height: unset;
height: unset;
min-height: unset;
box-sizing: border-box;
.uni-input-placeholder {
color: #dddddd;
}
}
.form-button {
margin-left: unset;
margin-right: unset;
}
.form-thumbnail {
.form-thumbnail-item {
width: 25px;
height: 25px;
margin-left: 20rpx;
position: relative;
border: 1rpx solid #eeeeee;
&:active {
border-color: #d83b01;
&::after {
content: 'X';
font-size: 25px;
font-weight: bold;
color: #d83b01;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.form-thumbnail-image {
vertical-align: bottom; // 取消image标签底部留白
}
.form-thumbnail-icon {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--form-thumbnail-icon-bgcolor);
}
}
}
}
}
.mask {
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 9999;
background-color: rgba(0, 0, 0, 0.5);
}
</style>
@@ -0,0 +1,150 @@
<template>
<text
:eid="eid"
:change:eid="quillEditor.watchEID"
:mode="inputmode"
:change:mode="quillEditor.watchInputMode"
:focus="focusFlag"
:change:focus="quillEditor.watchFocus"
:backspace="backspaceFlag"
:change:backspace="quillEditor.watchBackSpace"
></text>
</template>
<script>
/**
* 富文本renderjs扩展
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
export default {
props: {
eid: {
type: String,
default: ''
}
},
data() {
return {
inputmode: '', // none | remove
focusFlag: 0, // 主动聚焦标志
backspaceFlag: 0 // 主动删除标志
}
},
methods: {
changeInputMode(mode) {
this.inputmode = mode
},
focus() {
this.focusFlag++
},
backspace() {
this.backspaceFlag++
}
}
}
</script>
<script module="quillEditor" lang="renderjs">
export default {
data() {
return {
editorID: ''
}
},
methods: {
watchEID(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.editorID = newValue
}
},
watchInputMode(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.changeQuillInputMode(newValue)
}
},
watchFocus(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.changeFocus(newValue)
}
},
watchBackSpace(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.changeBackSpace(newValue)
}
},
/**
* 通过增加或移出inputmode属性来控制是否允许键盘弹出
* @param {String} type none | remove
* @tutorial https://ask.dcloud.net.cn/article/39915
*/
changeQuillInputMode(type) {
try {
// 要关闭软键盘的话,需要给inputmode属性设置none
// 如果要打开软键盘的话,需要移出inputmode属性
const el = document.querySelector(`#${this.editorID} .ql-editor`);
if(!el) return console.warn('==== quill dom error ====');
if(type == 'none') el.setAttribute('inputmode', 'none')
if(type == 'remove') el.removeAttribute('inputmode')
} catch (err) {
console.warn('==== changeQuillInputMode catch error :', err);
}
},
/**
* 通过quill节点实例的focus方法来主动触发编辑器聚焦
*/
changeFocus() {
try {
const el = document.querySelector(`#${this.editorID} .ql-editor`);
if(!el) return console.warn('==== quill dom error ====');
el.focus()
} catch (err) {
console.warn('==== changeFocus catch error :', err);
}
},
/**
* 通过quill节点实例的deleteText方法来主动触发编辑器删除
*/
changeBackSpace() {
try {
const el = document.querySelector(`#${this.editorID}`);
const quill = Quill.find(el);
if(!el || !quill) return console.warn('==== quill dom error ====');
const range = quill.getSelection(); // 获取当前光标位置
if (range && range.length === 0) {
// 如果没有选中文本且光标存在,则删除前一个字符或 emoji
if (range.index > 0) {
// 获取光标前的所有文本
const text = quill.getText(0, range.index);
// 规范化 Unicode 字符,确保正确处理组合字符和 emoji
const normalizedText = text.normalize('NFC');
// 将文本转换为字符数组,确保正确处理多字节字符
const chars = Array.from(normalizedText);
// 计算前一个字符的索引
const lastCharIndex = chars.length - 1;
if (lastCharIndex >= 0) {
// 删除前一个字符(包括多字节字符)
const lastChar = chars[lastCharIndex];
const lastCharLength = text.slice(-lastChar.length).length;
quill.deleteText(range.index - lastCharLength, lastCharLength);
quill.setSelection(range.index - lastCharLength); // 更新光标位置
}
}
} else if (range && range.length > 0) {
// 如果有选中文本,则删除选中的文本
quill.deleteText(range.index, range.length);
quill.setSelection(range.index); // 更新光标位置
}
} catch (err) {
console.warn('==== changeBackSpace catch error :', err);
}
},
}
}
</script>
@@ -0,0 +1,647 @@
<template>
<view class="sv-editor-toolbar">
<view class="editor-tools" @tap="onTool">
<text
v-for="item in allTools"
:key="item.title"
class="iconfont"
:class="item.icon"
:data-name="item.name"
></text>
<!-- [展开/折叠] 为固定工具 -->
<text v-if="isShowPanel" class="iconfont icon-xiajiantou" data-name="fold" data-value="0"></text>
<text v-else class="iconfont icon-shangjiantou" data-name="fold" data-value="1"></text>
</view>
<!-- 样式面板 不建议使用 :key="item.name" 因为 name 可能重复 -->
<view class="tool-panel" v-if="curTool == 'style' && isShowPanel">
<view class="panel-grid panel-style">
<view
class="panel-style-item"
:class="[(item.value ? formats[item.name] === item.value : formats[item.name]) ? 'ql-active' : '']"
:style="{ color: item.name == 'color' ? curTextColor : item.name == 'backgroundColor' ? curBgColor : '' }"
v-for="item in allStyleTools"
:key="item.title"
:title="item.title"
:data-name="item.name"
:data-value="item.value"
@tap="onToolStyleItem"
>
<text class="iconfont pointer-events-none" :class="item.icon"></text>
<text class="tool-item-title pointer-events-none">{{ item.title }}</text>
</view>
</view>
</view>
<!-- 表情面板 -->
<view class="tool-panel" v-if="curTool == 'emoji' && isShowPanel">
<view class="panel-grid panel-emoji">
<view
class="panel-emoji-item"
v-for="item in allEmojiTools"
:key="item"
:data-name="item"
@tap="onToolEmojiItem"
>
{{ item }}
</view>
</view>
<!-- #ifdef H5 -->
<view class="editor-backspace iconfont icon-tuige" @click="onBackSpace"></view>
<!-- #endif -->
<!-- #ifdef APP -->
<view v-if="!isIOS" class="editor-backspace iconfont icon-tuige" @click="onBackSpace"></view>
<!-- #endif -->
</view>
<!-- 更多功能面板 -->
<view class="tool-panel" v-if="curTool == 'more' && isShowPanel">
<view class="panel-grid panel-more">
<view
class="panel-more-item"
v-for="item in allMoreTools"
:key="item.title"
:title="item.title"
:data-name="item.name"
:data-value="item.value"
@tap="onToolMoreItem"
>
<view class="iconfont pointer-events-none" :class="item.icon"></view>
<view class="panel-more-item-title pointer-events-none">{{ item.title }}</view>
</view>
</view>
</view>
<!-- 扩展面板 -->
<view class="tool-panel" v-if="curTool == 'setting' && isShowPanel">
<slot name="setting"></slot>
</view>
<!-- 弹窗 因vue2/3的v-model写法有区别故需要条件编译我也是醉了 -->
<!-- #ifdef VUE3 -->
<sv-editor-popup-more v-model:show="showMorePop" :tool-name="curMoreTool" @confirm="moreItemConfirm">
<!-- APP端不支持循环插槽此处建议挨个写 -->
<!-- <template v-for="(slot, name) in $slots" #[name]="scope">
<slot :name="name" v-bind="scope"></slot>
</template> -->
<template #at>
<slot name="at"></slot>
</template>
<template #topic>
<slot name="topic"></slot>
</template>
</sv-editor-popup-more>
<!-- #endif -->
<!-- 弹窗 特别是微信小程序端的vue2必须使用.sync -->
<!-- #ifdef VUE2 -->
<sv-editor-popup-more :show.sync="showMorePop" :tool-name="curMoreTool" @confirm="moreItemConfirm">
<template #at>
<slot name="at"></slot>
</template>
<template #topic>
<slot name="topic"></slot>
</template>
</sv-editor-popup-more>
<!-- #endif -->
<!-- 调色板 -->
<!-- #ifdef VUE3 -->
<sv-editor-colorpicker
v-model:show="showColorPicker"
:type="colorType"
:color="curColor"
@confirm="selectColor"
></sv-editor-colorpicker>
<!-- #endif -->
<!-- #ifdef VUE2 -->
<sv-editor-colorpicker
:show.sync="showColorPicker"
:type="colorType"
:color="curColor"
@confirm="selectColor"
></sv-editor-colorpicker>
<!-- #endif -->
</view>
</template>
<script>
/**
* sv-editor 默认工具栏
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
import store from '../common/store.js'
import { toolList, emojiToolList, styleToolList, moreToolList } from '../common/tool-list.js'
import { noKeyboardEffect } from '../common/utils.js'
import SvEditorPopupMore from './sv-editor-popup-more.vue'
import SvEditorColorpicker from './sv-editor-colorpicker.vue'
export default {
// #ifdef MP-WEIXIN
// 微信小程序特殊配置
options: {
addGlobalClass: true,
virtualHost: true,
styleIsolation: 'shared'
},
// #endif
components: {
SvEditorPopupMore,
SvEditorColorpicker
},
props: {
// 工具栏列表
tools: {
type: Array,
default: () => {
return [] // 空默认为全列表
}
},
// 样式工具列表
styleTools: {
type: Array,
default: () => {
return [] // 空默认为全列表
}
},
// 更多功能列表
moreTools: {
type: Array,
default: () => {
return [] // 空默认为全列表
}
}
},
emits: [
'toolMoreItem',
'moreItemConfirm',
'keyboardChange',
'changeMorePop',
'changeTool',
'tapTool',
'tapStyle',
'tapEmoji',
'backspace'
],
data() {
return {
curTool: '', // 当前工具(头部工具栏)默认第一个
showPanel: false, // 是否能显示工具面板,区别于isShowPanel
showMorePop: false, // 是否弹出更多功能面板弹窗
showColorPicker: false, // 是否弹出调色板
curColor: '', // 当前颜色
curTextColor: '', // 当前文字颜色暂存
curBgColor: '', // 当前背景颜色暂存
colorType: '', // 当前颜色模式,可选 color | backgroundColor
curMoreTool: '', // 当前所选的更多功能项
keyboardHeight: 0 // 键盘高度
}
},
computed: {
isIOS() {
return uni.getSystemInfoSync().platform == 'ios'
},
allTools() {
if (this.tools.length == 0) return toolList
const indexMap = new Map(this.tools.map((item, index) => [item, index]))
const filtered = toolList
.filter((item) => indexMap.has(item.name)) // 过滤
.sort((a, b) => indexMap.get(a.name) - indexMap.get(b.name)) // 排序
return filtered
},
allStyleTools() {
if (this.styleTools.length == 0) return styleToolList
const indexMap = new Map(this.styleTools.map((item, index) => [item, index]))
const filtered = styleToolList
.filter((item) => indexMap.has(item.name)) // 过滤
.sort((a, b) => indexMap.get(a.name) - indexMap.get(b.name)) // 排序
return filtered
},
allEmojiTools() {
return emojiToolList
},
allMoreTools() {
if (this.moreTools.length == 0) return moreToolList
const indexMap = new Map(this.moreTools.map((item, index) => [item, index]))
const filtered = moreToolList
.filter((item) => indexMap.has(item.name)) // 过滤
.sort((a, b) => indexMap.get(a.name) - indexMap.get(b.name)) // 排序
return filtered
},
/**
* 在微信小程序端的vue2环境下无法直接使用计算属性读取editorCtx
* 为了统一化,只在各自需要使用编辑器实例的地方,按需重新获取
*/
// editorCtx() {
// const eid = store.actions.getEID()
// return store.actions.getEditor(eid)
// },
formats() {
return store.actions.getFormats()
},
isReadOnly: {
set(newVal) {
store.actions.setReadOnly(newVal)
},
get() {
return store.actions.getReadOnly()
}
},
isShowPanel() {
let show = this.showPanel
/**
* 规则:
* 1. 当键盘弹出时,必须折叠面板
* 2. 当点击有面板的工具栏时,必须展开面板
* 3. 展开工具栏时,可以点击fold进行展开折叠切换
*/
if (this.keyboardHeight !== 0) {
show = this.showMorePop ? true : false
} else {
if (!this.curTool) {
show = false
}
}
return show
}
},
watch: {
curTool(newVal) {
this.$emit('changeTool', newVal)
}
},
mounted() {
this.curTool = this.allTools[0].name // 当前工具(头部工具栏)默认第一个
uni.$on('E_EDITOR_STATUSCHANGE', (e) => {
this.curTextColor = e.detail.color || ''
this.curBgColor = e.detail.backgroundColor || ''
})
// #ifndef H5
uni.onKeyboardHeightChange(this.keyboardChange)
// #endif
},
destroyed() {
// #ifndef H5
uni.offKeyboardHeightChange(this.keyboardChange)
// #endif
uni.$off('E_EDITOR_STATUSCHANGE')
},
unmounted() {
// #ifndef H5
uni.offKeyboardHeightChange(this.keyboardChange)
// #endif
uni.$off('E_EDITOR_STATUSCHANGE')
},
methods: {
getEditorCtx() {
const eid = store.actions.getEID()
return store.actions.getEditor(eid)
},
onTool(e) {
this.editorCtx = this.getEditorCtx() // 按需重新获取编辑器实例
if (!this.editorCtx) return console.warn('editor is null')
const { name, value } = e.target.dataset
this.$emit('tapTool', { name, value })
switch (name) {
case 'style':
case 'emoji':
case 'more':
case 'setting':
this.curTool = name
this.showPanel = true
break
case 'undo':
noKeyboardEffect(() => {
this.editorCtx.undo()
})
break
case 'redo':
noKeyboardEffect(() => {
this.editorCtx.redo()
})
break
case 'fold':
this.showPanel = value == '1' ? true : false
break
}
// 点击toolbar需要主动聚焦
// #ifdef H5
noKeyboardEffect(() => {
this.editorCtx.focus()
})
// #endif
// #ifdef APP
if (!this.isIOS) {
noKeyboardEffect(() => {
this.editorCtx.focus()
})
}
// #endif
},
onToolStyleItem(e) {
const { name, value } = e.target.dataset
this.$emit('tapStyle', { name, value })
this.editorCtx = this.getEditorCtx() // 按需重新获取编辑器实例
switch (name) {
case 'divider':
// 分割线单独使用insertDivider处理
noKeyboardEffect(() => {
this.editorCtx.insertDivider()
})
break
case 'color':
this.colorType = name
this.curColor = this.curTextColor
this.showColorPicker = true
break
case 'backgroundColor':
this.colorType = name
this.curColor = this.curBgColor
this.showColorPicker = true
break
case 'removeformat':
// 清除当前选区的样式
uni.showModal({
title: '系统提示',
content: '是否清除当前选区样式',
success: ({ confirm }) => {
if (confirm) {
noKeyboardEffect(() => {
this.editorCtx.removeFormat()
})
}
}
})
break
case 'bold':
case 'italic':
case 'underline':
case 'strike':
case 'script':
// 部分格式需要弹出键盘,若禁止弹出键盘,则会使格式丢失
this.editorCtx.format(name, value)
break
default:
noKeyboardEffect(() => {
this.editorCtx.format(name, value)
})
break
}
},
onToolEmojiItem(e) {
const { name, value } = e.target.dataset
this.$emit('tapEmoji', { name, value })
this.editorCtx = this.getEditorCtx() // 按需重新获取编辑器实例
noKeyboardEffect(() => {
this.editorCtx.insertText({
text: name
})
})
},
onToolMoreItem(e) {
const { name, value } = e.target.dataset
this.curMoreTool = name
if (value == 'popup') this.openMorePop()
this.$emit('toolMoreItem', { name, value })
},
moreItemConfirm(e) {
this.$emit('moreItemConfirm', e)
},
// 打开内置更多功能弹窗
openMorePop() {
this.showMorePop = true
this.$emit('changeMorePop', this.showMorePop)
},
// 关闭内置更多功能弹窗
closeMorePop() {
this.showMorePop = false
this.$emit('changeMorePop', this.showMorePop)
},
/**
* 键盘相关方法
*/
keyboardChange(e) {
this.keyboardHeight = e.height
this.$emit('keyboardChange', e)
if (this.showMorePop) return
// #ifdef H5
if (this.keyboardHeight > 0) {
this.showPanel = false
}
// #endif
// 可能存在秒闪的情况, 因此需要短暂延后判断
const timerHandler = () => {
if (this.timer) {
// 清除已有的计时器
clearTimeout(this.timer)
this.timer = null
}
this.timer = setTimeout(() => {
if (this.keyboardHeight > 0) {
this.showPanel = false
}
this.timer = null
}, 50)
}
// #ifdef APP
if (this.isIOS) {
timerHandler()
} else {
if (this.keyboardHeight > 0) {
this.showPanel = false
}
}
// #endif
// #ifdef MP-WEIXIN
timerHandler()
// #endif
},
// 退格
onBackSpace() {
this.$emit('backspace')
// #ifdef H5 || APP
this.editorCtx = this.getEditorCtx() // 按需重新获取编辑器实例
noKeyboardEffect(() => {
this.editorCtx.backspace()
})
// #endif
},
// 调色板确认
selectColor(color, type) {
this.curColor = color
this.showColorPicker = false
if (type == 'color') {
this.curTextColor = color
} else {
this.curBgColor = color
}
// 确认颜色选择后不要noKeyboardEffect取消键盘,会造成颜色格式丢失
this.editorCtx = this.getEditorCtx() // 按需重新获取编辑器实例
this.editorCtx.format(type, color)
}
}
}
</script>
<style lang="scss">
@import '../icons/iconfont.css';
.sv-editor-toolbar {
--editor-toolbar-height: 88rpx;
--editor-toolbar-bgcolor: #ffffff;
--editor-toolbar-bordercolor: #eeeeee;
--editor-toolbar-iconsize: 32rpx;
--tool-panel-height: auto;
--tool-panel-bgcolor: #ffffff;
--tool-panel-max-height: 400rpx;
--tool-style-columns: 3;
--tool-style-iconsize: 32rpx;
--tool-style-titlesize: 28rpx;
--tool-emoji-columns: 8;
--tool-more-columns: 4;
--tool-more-iconsize: 60rpx;
--tool-more-titlesize: 24rpx;
--tool-item-bgcolor: #f1f1f1;
--editor-backspace-bgcolor: #ffffff;
--editor-backspace-shadow: 0 0 8px 6px rgba(0, 0, 0, 0.08);
.editor-tools {
width: 100%;
height: var(--editor-toolbar-height);
background-color: var(--editor-toolbar-bgcolor);
border-top: 1rpx solid var(--editor-toolbar-bordercolor);
border-bottom: 1rpx solid var(--editor-toolbar-bordercolor);
display: flex;
align-items: center;
justify-content: space-around;
box-sizing: border-box;
.iconfont {
width: 100%;
height: 100%;
font-size: var(--editor-toolbar-iconsize);
flex: 1;
display: flex;
justify-content: center;
align-items: center;
}
}
.tool-panel {
height: var(--tool-panel-height);
max-height: var(--tool-panel-max-height);
overflow: auto;
padding: 30rpx;
box-sizing: border-box;
// position: relative;
background-color: var(--tool-panel-bgcolor);
.editor-backspace {
width: 80rpx;
height: 60rpx;
position: absolute;
bottom: 30rpx;
right: 30rpx;
display: flex;
justify-content: center;
align-items: center;
font-size: 50rpx;
border-radius: 20rpx;
background-color: var(--editor-backspace-bgcolor);
box-shadow: var(--editor-backspace-shadow);
&:active {
opacity: 0.8;
bottom: 32rpx;
right: 32rpx;
}
}
.panel-grid {
width: 100%;
display: grid;
align-items: center; /* 垂直居中 */
justify-items: center; /* 水平居中 */
gap: 30rpx;
box-sizing: border-box;
&.panel-style {
grid-template-columns: repeat(var(--tool-style-columns), 1fr);
}
&.panel-emoji {
grid-template-columns: repeat(var(--tool-emoji-columns), 1fr);
}
&.panel-more {
grid-template-columns: repeat(var(--tool-more-columns), 1fr);
}
}
.panel-style-item {
width: 100%;
height: 80rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
background-color: var(--tool-item-bgcolor);
padding: 0 20rpx;
box-sizing: border-box;
.tool-item-title {
font-size: var(--tool-style-titlesize);
}
.iconfont {
font-size: var(--tool-style-iconsize);
margin-right: 10rpx;
}
}
.panel-emoji-item {
}
.panel-more-item {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: var(--tool-item-bgcolor);
padding: 20rpx;
border-radius: 20rpx;
box-sizing: border-box;
&:active {
opacity: 0.85;
}
.iconfont {
font-size: var(--tool-more-iconsize);
}
.panel-more-item-title {
font-size: var(--tool-more-titlesize);
margin-top: 12rpx;
}
}
}
}
.ql-active {
color: #66ccff;
}
.pointer-events-none {
pointer-events: none;
}
</style>
@@ -0,0 +1,446 @@
<template>
<view class="sv-editor-wrapper" @longpress="eLongpress">
<slot name="header"></slot>
<editor
:id="eid"
class="sv-editor-container"
show-img-size
show-img-toolbar
show-img-resize
:placeholder="placeholder"
:read-only="isReadOnly"
@statuschange="onStatusChange"
@ready="onEditorReady"
@input="onEditorInput"
@focus="onEditorFocus"
@blur="onEditorBlur"
></editor>
<view class="maxlength-tip" v-if="maxlength > 0 && !hideMax">{{ textlength }}/{{ maxlength }}</view>
<slot name="footer"></slot>
<!-- renderjs辅助插件 -->
<!-- #ifdef APP || H5 -->
<sv-editor-render ref="editorRenderRef" :eid="editorEID"></sv-editor-render>
<sv-editor-plugin ref="editorPluginRef" :sid="startID" :eid="editorEID" @epaste="ePaste"></sv-editor-plugin>
<!-- #endif -->
</view>
</template>
<script>
/**
* sv-editor
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
import store from '../common/store.js'
import { linkFlag, copyrightPrint } from '../common/utils.js'
import { parseHtmlWithVideo, replaceVideoWithImageRender } from '../common/parse.js'
import SvEditorRender from './sv-editor-render.vue'
import SvEditorPlugin from '../plugins/sv-editor-plugin.vue'
import wxplugin from '../plugins/sv-editor-wxplugin.js'
export default {
// #ifdef MP-WEIXIN
// 微信小程序特殊配置
options: {
addGlobalClass: true,
virtualHost: true,
styleIsolation: 'shared'
},
// #endif
components: {
SvEditorRender,
SvEditorPlugin
},
props: {
// 编辑器id可传入,以便循环组件使用,防止id重复
eid: {
type: String,
default: 'sv-editor' // 唯一,禁止重复
},
placeholder: {
type: String,
default: '写点什么吧 ~'
},
// 是否只读
readOnly: {
type: Boolean,
default: false
},
// 最大字数限制,<=0时表示不限
maxlength: {
type: Number,
default: -1
},
// 是否关闭最大字数显示
hideMax: {
type: Boolean,
default: false
},
// 粘贴模式,可选 text 纯文本(默认) | origin 尽可能保持原格式
pasteMode: {
type: String,
default: 'text'
}
},
emits: ['ready', 'input', 'statuschange', 'focus', 'blur', 'overmax', 'epaste'],
data() {
return {
textlength: 0, // 当前字数统计
startID: '',
// #ifdef VUE2
// #ifdef MP-WEIXIN
editorIns: null // 仅vue2环境下的微信小程序需要声明实例变量,否则报错,属实逆天
// #endif
// #endif
}
},
computed: {
editorEID: {
set(newVal) {
store.actions.setEID(newVal)
},
get() {
return store.actions.getEID()
}
},
editorCtx: {
set(newVal) {
store.actions.setEditor(newVal.eid, newVal.ctx)
// #ifdef VUE2
this.editorIns = newVal.ctx
this.editorIns.id = newVal.eid
// #endif
},
get() {
let instance = store.actions.getEditor(this.eid)
// #ifdef VUE2
instance = store.actions.getEditor(this.eid) || this.editorIns
// #endif
return instance
}
},
isReadOnly: {
set(newVal) {
store.actions.setReadOnly(newVal)
},
get() {
return store.actions.getReadOnly()
}
}
},
watch: {
readOnly(newVal) {
this.isReadOnly = newVal
}
},
mounted() {
// 首个实例初始化时执行
if (!store.state.firstInstanceFlag) {
this.editorEID = this.eid
store.state.firstInstanceFlag = this.eid
copyrightPrint()
}
},
destroyed() {
store.actions.destroy()
},
unmounted() {
store.actions.destroy()
},
methods: {
onEditorReady() {
this.$nextTick(() => {
uni
.createSelectorQuery()
.in(this)
.select('#' + this.eid)
.context((res) => {
// 存储上下文
this.editorCtx = { eid: this.eid, ctx: res.context }
// 挂载实例api
this.bindMethods()
// 初始化完成
this.$emit('ready', this.editorCtx)
// #ifdef APP || H5
if (this.pasteMode == 'origin') this.editorCtx.changePasteMode('origin')
// #endif
})
.exec()
})
},
/**
* 挂载实例api
*/
bindMethods() {
// ===== renderjs相关扩展api =====
// #ifdef APP || H5
/**
* 主动聚焦
* @returns {void}
*/
this.editorCtx.focus = this.$refs.editorRenderRef.focus
/**
* 退格
* @returns {void}
*/
this.editorCtx.backspace = this.$refs.editorRenderRef.backspace
/**
* 键盘输入模式
* @param {String} type 模式,可选:none | remove
* @returns {void}
*/
this.editorCtx.changeInputMode = this.$refs.editorRenderRef.changeInputMode
/**
* 粘贴模式
* @param {String} type 模式,可选:text纯文本(默认) | origin尽可能保持原格式
* @returns {void}
*/
this.editorCtx.changePasteMode = (type) => {
// 告知plugin启动
this.startID = this.eid
if (this.$refs.editorPluginRef?.changePasteMode) {
this.$refs.editorPluginRef.changePasteMode(type)
}
}
/**
* 生成视频封面图
* @param {String} url 封面图片地址
* @returns {Promise} 携带播放图标的封面图地址
*/
this.editorCtx.createCoverThumbnail = (url) => {
return new Promise((resolve) => {
if (this.$refs.editorPluginRef?.createCoverThumbnail) {
// 事件名必须唯一,否则会覆盖
uni.$once(`E_EDITOR_GET_COVER_THUMBNAIL_${url}`, (res) => {
resolve(res.cover)
})
setTimeout(() => {
this.$refs.editorPluginRef?.createCoverThumbnail(url)
})
}
})
}
/**
* 生成视频封面图
* @param {String} url 视频地址
* @returns {Promise} 封面图地址
*/
this.editorCtx.createVideoThumbnail = (url) => {
return new Promise((resolve) => {
if (this.$refs.editorPluginRef?.createVideoThumbnail) {
// 事件名必须唯一,否则会覆盖
uni.$once(`E_EDITOR_GET_VIDEO_THUMBNAIL_${url}`, (res) => {
resolve(res.cover)
})
setTimeout(() => {
this.$refs.editorPluginRef?.createVideoThumbnail(url)
})
}
})
}
// #endif
// ===== 微信小程序扩展api =====
// #ifdef MP-WEIXIN
/**
* 生成视频封面图
* @param {String} url 视频地址
* @returns {Promise} 封面图地址
*/
this.editorCtx.createCoverThumbnail = wxplugin?.wxCreateCoverThumbnail
//this.editorCtx.createVideoThumbnail = wxplugin?.wxCreateVideoThumbnail
// #endif
// ===== 通用扩展api =====
/**
* 主动触发input回调事件
* @returns {void}
*/
this.editorCtx.changeInput = () => {
this.editorCtx.getContents({
success: (res) => {
this.$emit('input', { ctx: this.editorCtx, html: res.html, text: res.text })
}
})
}
/**
* 获取最新内容
* @returns {Promise} 内容对象 { html, text... }
*/
this.editorCtx.getLastContent = async () => {
return new Promise((resolve) => {
this.editorCtx.getContents({
success: (res) => {
resolve(res)
}
})
})
}
/**
* 富文本内容初始化
* 注意:微信小程序会导致聚焦滚动,建议先将编辑器v-show=false,待initHtml内容初始化完成后再true
* 也正是因为微信小程序端会聚焦滚动,所以editorEID在初始阶段会默认保持最后一个实例eid,需要手动重新聚焦
* @param {String} html 初始化的富文本
* @param {Function<Promise>} customCallback 自定义处理封面回调,需要以Promise形式返回封面图片资源
* @returns {void}
*/
this.editorCtx.initHtml = async (html, customCallback) => {
let transHtml = await replaceVideoWithImageRender(html, customCallback)
// #ifdef APP || H5
this.editorCtx.changePasteMode('text') // text模式下可以防止初始化时对格式的影响
// #endif
setTimeout(() => {
this.editorCtx.setContents({
html: transHtml,
success: () => {
// 主动触发一次input回调事件
this.editorCtx.changeInput()
// #ifdef APP || H5
if (this.pasteMode == 'origin') this.editorCtx.changePasteMode('origin')
// #endif
}
})
})
}
/**
* 导出处理
* @param {String} html 要导出的富文本
* @returns {String} 处理后的富文本
*/
this.editorCtx.exportHtml = (html) => {
return parseHtmlWithVideo(html)
}
},
onEditorInput(e) {
// 注意不要使用getContents获取html和text,会导致重复触发onStatusChange从而失去toolbar工具的高亮状态
// 复制粘贴的时候detail会为空,此时应当直接return
if (Object.keys(e.detail).length <= 0) return
const { html, text } = e.detail
// 识别到链接特殊标识立即return
if (text.indexOf(linkFlag) !== -1) return
/**
* 因为uni-editor不提供最大字符限制,故需要手动进行以下特殊处理
*/
const maxlength = parseInt(this.maxlength)
const textStr = text.replace(/[ \t\r\n]/g, '')
this.textlength = textStr.length // 当前字符数
if (this.textlength >= maxlength && maxlength > 0) {
this.textlength = maxlength // 因为editor特性,需要手动赋阈值
if (!this.lockHtmlFlag) {
this.lockHtml = html // 锁定最后一次超出字数前的html
this.lockHtmlFlag = true // 锁定标志
// 首次到达最大限制时还需最后回调一次input事件
this.$emit('input', { ctx: this.editorCtx, html, text })
} else {
// 在超过字数时锁定,若再编辑则抛出超出事件
this.$emit('overmax', { ctx: this.editorCtx })
}
// 超过字数时,锁定最后一次超出字数前的html
this.editorCtx.setContents({ html: this.lockHtml })
} else {
// 正常输入
this.$emit('input', { ctx: this.editorCtx, html, text })
this.lockHtmlFlag = false // 锁定标志
}
},
/**
* 样式格式改变时触发
* 注意:微信小程序端在多编辑器实例下切换编辑器后可能不会及时触发onStatusChange
*/
onStatusChange(e) {
store.actions.setFormats(e.detail)
this.$emit('statuschange', { ...e, ctx: this.editorCtx })
uni.$emit('E_EDITOR_STATUSCHANGE', { ...e, ctx: this.editorCtx })
},
onEditorFocus(e) {
this.editorEID = this.eid
this.$emit('focus', { ...e, ctx: this.editorCtx })
},
onEditorBlur(e) {
this.$emit('blur', { ...e, ctx: this.editorCtx })
},
ePaste(e) {
this.$emit('epaste', { ...e, ctx: this.editorCtx })
uni.$emit('E_EDITOR_PASTE', { ...e, ctx: this.editorCtx })
},
/**
* 微信小程序官方editor的长按事件有bug,需要重写覆盖,不需做任何逻辑,可见下面小程序社区问题链接
* @tutorial https://developers.weixin.qq.com/community/develop/doc/000c04b3e1c1006f660065e4f61000
*/
eLongpress() {}
}
}
</script>
<style lang="scss">
.sv-editor-wrapper {
--maxlength-text-color: #666666;
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.sv-editor-container {
flex: 1;
}
.maxlength-tip {
position: absolute;
bottom: 0;
right: 0;
font-size: 24rpx;
color: var(--maxlength-text-color);
opacity: 0.6;
}
}
// placeholder字样
.sv-editor-container ::v-deep .ql-blank::before {
font-style: normal;
color: #cccccc;
}
// 图片工具样式
::v-deep .ql-container {
min-height: unset;
.ql-image-overlay {
pointer-events: none;
.ql-image-size {
right: 28px !important;
}
.ql-image-toolbar {
// 删除按钮
pointer-events: auto;
}
.ql-image-handle {
// 四角缩放按钮
width: 30px;
height: 30px;
pointer-events: auto;
}
}
}
</style>
+87
View File
@@ -0,0 +1,87 @@
{
"id": "sv-editor",
"displayName": "基于官方 uni-editor 的富文本编辑器",
"version": "1.1.2",
"description": "可插入图片、视频、链接、@提及、#话题、Emoji表情包,且优化了聚焦键盘闪烁等问题",
"keywords": [
"富文本",
"编辑器",
"editor",
"html"
],
"repository": "https://gitee.com/Sonve/sv-editor",
"engines": {
"HBuilderX": "^3.1.0"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "y"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "u",
"app-uvue": "u",
"app-harmony": "u"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}
+333
View File
@@ -0,0 +1,333 @@
## 基于官方 uni-editor 的富文本编辑器 [sv-editor]
### 一、前言
首先,你需要了解 uni-editor 相关注意事项,以及api
传送门:
1. [editor 组件概况](https://uniapp.dcloud.net.cn/component/editor.html)
2. [editorContext api详情](https://uniapp.dcloud.net.cn/api/media/editor-context.html)
3. 仔细阅读 [HTML 标签和 style 内联样式支持情况](https://uniapp.dcloud.net.cn/component/editor.html#html-%E6%A0%87%E7%AD%BE%E5%92%8C-style-%E5%86%85%E8%81%94%E6%A0%B7%E5%BC%8F%E6%94%AF%E6%8C%81%E6%83%85%E5%86%B5)
4. 仔细了解 [注意事项](https://uniapp.dcloud.net.cn/component/editor.html#%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9)
### 二、本插件在官方 uni-editor 基础上做了什么
1. 提供插入视频的api
2. 提供插入链接的api
3. 在插入链接的基础上扩展了 @某人#话题#、以及 添加附件 的api
4. 支持插入emoji表情包,可自定义表情包面板
5. 解决了在app端插入内容后,编辑器聚焦后自动弹出键盘的问题,提供api可在聚焦的同时取消键盘反复弹出带来的影响!!!
6. 工具栏toolbar与编辑器editor分离式写法,让你的代码更加自由
7. 插件内部大部分样式由css变量控制,更方便你使用样式穿透去自定义,对有暗黑主题的需求更加友好
8. 所有组件添加了 styleIsolation: 'shared' 配置项,再也不用怕小程序端的样式隔离穿透不了
9. 部分扩展基于renderjs,因此小程序端无法使用,可见下列关键功能概况详情
10. App与H5端关键扩展api如下:
- noKeyboardEffect:取消键盘影响,不想让富文本聚焦后总是自动弹出键盘?这个api可以完美解决你的问题
- focus:主动聚焦,你可以直接通过 editorCtx 实例调用此api,以便直接主动使富文本聚焦
- backspace:主动退格(删除),希望可以模拟键盘上的退格键?这个api如同键盘的 backspace 键一样,删除光标前一个单位,或者删除所选区域
- 等等其他api,详见下文
### 三、兼容性
✅已兼容,❌未兼容
| VUE2 | VUE3 | APP(Android) | APP(iOS)| H5 | 微信小程序 | 其他小程序 |
| :---:| :---:| :---: | :---: | :---: | :---: | :---: |
| ✅ | ✅️ | ✅ | ✅ | ✅ | ✅️ | ❌(没测过) |
1. 实际请以真机效果为准,并不能保证所有机型都兼容,如遇到问题还请加群讨论
2. 注意:因为部分api基于renderjs,而小程序无法使用renderjs,故部分api和功能并不适配小程序,更多详情会在各api中说明
3. 特别注意:**在微信小程序中,生成的 a 标签的 href 属性会被自动抹去,因此在微信小程序中是无法点击超链接跳转的,这点目前微信小程序官方固件不支持,暂时也没啥好的办法**
### 四、关键功能概况
✅完美支持,☑可用但或有副作用,❌不支持
| 功能 | VUE2 | VUE3 | H5 | APP(Android) | APP(iOS) | 微信小程序 |
| :---: | :---:| :---:| :---:| :---: | :---: | :---: |
| 插入图片 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 插入视频 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 插入链接 | ✅ | ✅ | ✅ | ✅ | ✅ | ☑️ |
| 插入提及 | ✅ | ✅ | ✅ | ✅ | ✅ | ☑️ |
| 插入话题 | ✅ | ✅ | ✅ | ✅ | ✅ | ☑️ |
| 插入附件 | ✅ | ✅ | ✅ | ✅ | ✅ | ☑️ |
| 主动聚焦 | ✅ | ✅ | ✅ | ✅ | ✅ | ❌️ |
| 主动退格 | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
| 多编辑器实例 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 消除键盘影响 | ✅ | ✅ | ✅ | ✅ | ☑ | ☑️ |
| 粘贴保留格式 <br/> [(*特殊扩展)](#特殊扩展) | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| 粘贴事件监听 <br/> [(*特殊扩展)](#特殊扩展) | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| 视频截取封面 | ✅ | ✅ | ✅ | ✅ | ☑️ | ☑ |
| 视频回显解析 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 待补充 ... | | | | | | |
### 五、开始
1. 因为本插件不依赖其他第三方插件,因此直接点击右上角 `下载并导入HBuilderX` 导入至你的项目中即可
2. 强烈建议你先 `使用HBuilderX导入示例项目` ,跑一下示例看亿下先,部分写法可以直接抄示例
3. 因为本插件提供除 [editorContext](https://uniapp.dcloud.net.cn/api/media/editor-context.html) 官方api外,额外扩展的api,需要你对js有着基本的掌握,特别是Promise和异步处理
4. 本插件仅为富文本编辑器,如要解析回显还请自行寻找富文本解析插件(不推荐rich-text)
### 六、插件目录结构
```
uni_modules
└─ sv-editor
├─ components
│ ├─ common
│ │ ├─ config.js // 配置文件
│ │ ├─ file-handler.js // 文件处理方法
│ │ ├─ parse.js // 富文本解析工具
│ │ ├─ store.js // 插件内全局状态管理
│ │ ├─ tool-list.js // 工具栏工具列表
│ │ └─ utils.js // 通用工具api
│ ├─ icons
│ │ ├─ iconfont.css // 字体图标样式
│ │ └─ iconfont.ttf // 字体图标
│ └─ sv-editor
│ ├─ sv-choose-file.vue // 文件选择器
│ ├─ sv-editor-popup-more.vue // 更多工具弹窗面板
│ ├─ sv-editor-render.vue // renderjs组件
│ ├─ sv-editor-toolbar.vue // 内置工具栏
│ └─ sv-editor.vue // 编辑器主体
├─ changelog.md
├─ package.json
└─ readme.md
```
### 七、基本使用
#### sv-editor 编辑器主体
`符合uni_modules规范,无需引入直接使用`
1. props
| 属性名 | 类型 | 默认值 | 说明 |
| :--- | :--- | :--- | :--- |
| eid | String | 'sv-editor' | 编辑器id,唯一,禁止重复,多编辑器实例时必填 |
| placeholder | String | '写点什么吧 ~' | 占位字样 |
| readOnly | Boolean | false | 是否只读 |
| maxlength | Number | -1 | 最大字数限制,<=0时表示不限 |
| hideMax | Boolean | false | 是否关闭最大字数显示 |
2. emits
| 事件名 | 参数 | 说明 |兼容性 |
| :--- | :--- | :--- | :--- |
| ready | ctx 当前编辑器上下文实例 | 编辑器初始化完成时触发 | 通用 |
| input | { ctx, html, text } | 编辑器内容改变时触发 | 通用 |
| focus | { ctx, event } | 编辑器聚焦时触发 | 通用 |
| blur | { ctx, event } | 编辑器失去焦点时触发 | 通用 |
| statuschange | { ctx, event } | 通过 Context 方法改变编辑器内样式时触发,返回选区已设置的样式 | 通用 |
| overmax | { ctx } | 超过最大字数限制时回调 | 通用 |
| epaste <br/> [(*特殊扩展)](#特殊扩展) | { ctx, id, text, html, range } | 粘贴回调事件 | H5、APP |
- statuschange 事件还提供 uni.$emit('E_EDITOR_STATUSCHANGE', { ctx, event }) 抛出,你可以通过 uni.$on('E_EDITOR_STATUSCHANGE') 进行监听,但是不要忘记在适当的地方off关掉
- epaste [(*特殊扩展)](#特殊扩展) 事件还提供 uni.$emit('E_EDITOR_PASTE', { ctx, id, text, html, range }) 抛出,你可以通过 uni.$on('E_EDITOR_PASTE') 进行监听,但是不要忘记在适当的地方off关掉
#### sv-editor-toolbar 编辑器工具栏
`与编辑器本体分离,按需引入使用`
1. props
| 属性名 | 类型 | 默认值 | 说明 |
| :--- | :--- | :--- | :--- |
| tools | Array | [] 默认空数组即为全工具,可选 [详见 toolList](#toolList) | 工具栏列表,例如 ['style', ...] |
| styleTools| Array | [] 默认空数组即为全工具,可选 [详见 styleToolList](#styleToolList) | 样式工具列表,例如 ['header', ...] |
| moreTools | Array | [] 默认空数组即为全工具,可选 [详见 moreToolList](#moreToolList) | 更多功能列表,例如 ['image', ...] |
注意:
- 此处 toolList 等为全列表,详见 `uni_modules/sv-editor/components/common/tool-list.js` 文件。
- 若只想使用部分工具以及修改顺序,则给组件对应的props属性例如 `:tools="['style', 'undo', 'redo']"` 即可只使用该三项工具且顺序以该数组顺序排序。
- 关于图标,本插件内置了 [阿里巴巴矢量图标库](https://www.iconfont.cn/) 的字体图标,如需使用其他图标,请自行替换。
2. emits
| 事件名 | 参数 | 说明 |
| :--- | :--- | :--- |
| toolMoreItem | { name, value } | 点击更多功能面板子项 |
| moreItemConfirm | { link, text, file } | 点击更多功能弹窗确认后回调 |
| keyboardChange | { height } | 键盘高度变化 |
| changeMorePop | true 打开 / false 关闭 | 更多功能弹窗打开/关闭 |
| tapTool | { name, value } | 点击工具栏 |
| changeTool | 工具name | 工具栏改变 |
| tapStyle | { name, value } | 点击样式工具 |
| tapEmoji | { name, value } | 点击Emoji表情 |
| backspace | | 触发编辑器实例主动使用backspace后回调 |
##### toolList
| title | name | value | icon |
| :--- | :--- | :--- | :--- |
| 样式 | style | | icon-zitiyanse |
| 表情 | emoji | | icon-xiaolian |
| 撤销 | undo | | icon-shangyibu1 |
| 重做 | redo | | icon-xiayibu1 |
| 更多 | more | | icon-icon_tianjia |
| 扩展 | setting | | icon-bianji |
##### styleToolList
| title | name | value | icon |
| :--- | :--- | :--- | :--- |
| 标题 | header | 2 | icon-zitibiaoti |
| 分割线 | divider | | icon-fengexian |
| 粗体 | bold | | icon-zitijiacu |
| 斜体 | italic | | icon-zitixieti |
| 下划线 | underline | | icon-zitixiahuaxian |
| 删除线 | strike | | icon-zitishanchuxian|
| 左对齐 | align | left | icon-zuoduiqi |
| 居中 | align | center | icon-juzhongduiqi |
| 右对齐 | align | right | icon-youduiqi |
| 有序列表 | list | ordered | icon-youxupailie |
| 无序列表 | list | bullet | icon-wuxupailie |
| 上标 | script | super | icon-zitishangbiao |
| 左缩进 | indent | +1 | icon-zuosuojin |
| 右缩进 | indent | -1 | icon-yousuojin |
| 下标 | script | sub | icon-ziti-xiabiao |
| 文字颜色 | color | | icon-wenziyanse |
| 背景颜色 | backgroundColor | | icon-beijingyanse' |
| 清除格式 | removeformat | | icon-qingchugeshi |
- 以上为插件内置样式工具,更多详见 [支持设置的样式列表](https://uniapp.dcloud.net.cn/api/media/editor-context.html#editorcontext-format)
- 缩进时,需要在解析插件(此处以mp-html为例)中添加如下缩进样式以供识别:
```
// uni_modules/mp-html/components/mp-html/node/node.vue
// 不要管插件内原始的样式代码
<style>...</style>
// 直接在该vue文件最底下添加如下scss样式
<style lang="scss">
@for $i from 1 through 10 {
.ql-indent-#{$i} {
// 默认一个缩进为2个em单位,此处对应
text-indent: #{$i * 2}em;
}
}
</style>
```
##### moreToolList
| title | name | value | icon |
| :--- | :--- | :--- | :--- |
| 添加图片 | image | popup | icon-charutupian |
| 添加视频 | video | popup | icon-shexiangji |
| 添加链接 | link | popup | icon-charulianjie |
| 添加附件 | attachment| popup | icon-huixingzhen |
| 提及 | at | popup | icon-at |
| 话题 | topic | popup | icon-huati |
| 清空 | clear | button| icon-shanchu |
*在微信小程序中,生成的 a 标签的 href 属性会被自动抹去,因此在微信小程序中是无法点击超链接跳转的,这点目前微信小程序官方固件不支持,暂时也没啥好的办法*
##### emojiToolList
emoji默认列表
##### colorList
调色板默认颜色列表
#### api 合集
1. [editorContext 官方api](https://uniapp.dcloud.net.cn/api/media/editor-context.html)
2. 本插件编辑器实例 `editorCtx` 中,你可以直接通过富文本实例调用
| 方法名 | 参数 | 返回值 | 说明 | 兼容性 |
| :--- | :--- | :--- | :--- | :--- |
| focus | | | 主动聚焦 | H5、App |
| backspace | | | 退格,会触发sv-editor-toolbar的backspace回调函数 | H5、App(Android) |
| createVideoThumbnail <br/> [(*特殊扩展)](#特殊扩展)| url:string 视频地址 | 封面图地址 Promise | 以视频资源地址,直接生成视频封面图(需要保证视频资源正常可以播放) | H5、App(Android) |
| createCoverThumbnail <br/> [(*特殊扩展)](#特殊扩展)| url:string 图片资源 | 封面图地址 Promise | 若后端返回视频封面但是没有播放图标,可以用此方法在图片中央叠加播放图标,用于作为视频封面 | 通用 |
| changeInputMode | type:string 模式,可选:none/remove | | 修改输入模式,该api是取消键盘闪烁的关键,none时将禁止键盘弹出,remove时将移除该限制 | H5、App |
| changeInput | | | 主动触发input回调事件 | 通用 |
| getLastContent | | { html, text... } 内容对象 Promise | 获取富文本当前最新内容 | 通用 |
| exportHtml | html:string 要导出的富文本 | 处理后的富文本 String | 富文本导出,若富文本携带视频,则会自动解析为video标签 | 通用 |
| initHtml | html:string 初始化的富文本 <br/> customCallback 详见补充说明 | | 富文本内容初始化,若富文本携带video标签,将会自动进行解析转换 | 通用 |
- initHtml 在微信小程序端会导致聚焦滚动,建议先将编辑器 v-show=false,待 initHtml 内容初始化完成后再 true。也正是因为微信小程序端会聚焦滚动,所以 editorEID 在初始阶段会默认保持最后一个实例 eid,需要手动重新聚焦
- initHtml 第二个参数 customCallback 和 api: replaceVideoWithImageRender 一致,customCallback 为自定义处理封面回调,自带参数为视频地址,需要return封面图片资源,若无有效返回则走默认封面处理,建议配合后端生成视频封面以兼容各端。
3. `uni_modules/sv-editor/components/common/store.js` 文件中,插件内全局状态仓库,你可以按需引入后通过 store.state 与 store.actions 来访问变量
| 方法名 | 参数 | 返回值 | 说明 | 兼容性 |
| :--- | :--- | :--- | :--- | :--- |
| getEditor | eid | eid编辑器实例 | 获取指定eid的编辑器实例 | 通用 |
| setEditor | eid, ctx | | 设置eid编辑器实例 | 通用 |
| getEID | | 当前编辑器eid | 获取当前编辑器eid | 通用 |
| setEID | 当前编辑器eid | | 设置当前编辑器eid | 通用 |
| getFormats | | 编辑器样式格式 | 获取编辑器样式格式 | 通用 |
| setFormats | 编辑器样式格式 | | 设置编辑器样式格式 | 通用 |
| getReadOnly | | 是否只读 Boolean | 获取编辑器是否只读 | 通用 |
| setReadOnly | 是否只读 Boolean | | 设置编辑器是否只读 | 通用 |
4. `uni_modules/sv-editor/components/common/utils.js` 文件中,需要按需引入,实用工具
| 方法名 | 参数 | 返回值 | 说明 | 兼容性 |
| :--- | :--- | :--- | :--- | :--- |
| addImage | (uploadFunc必填, options) | Array/Promise 上传的文件 | 添加图片 | 通用 |
| addVideo | (uploadFunc必填, options) | Array/Promise 上传的文件 | 添加视频 | 通用 |
| addLink | (options, callback) | | 添加链接 | 通用 |
| addAttachment | (uploadFunc必填, options, callback) | Object/Promise 上传的文件 | 添加附件 | 通用 |
| addAt | (options, callback) | | 添加提及 | 通用 |
| addTopic | (options, callback) | | 添加话题 | 通用 |
| insertLink | (editorCtx必填, options, callback) | | 插入链接母本:添加链接、添加附件、添加提及、添加话题均基于此 | 通用 |
| noKeyboardEffect | (callback必填, options) | | 核心:消除键盘影响,但是微信小程序只能通过编辑器失焦的方式关闭键盘(依然会闪一下) | 通用 |
5. `uni_modules/sv-editor/components/common/parse.js` 文件中,需要按需引入,正则解析工具
| 方法名 | 参数 | 返回值 | 说明 | 兼容性 |
| :--- | :--- | :--- | :--- | :--- |
| replaceVideoWithImageRender| richText:string 要进行处理的富文本字符串 <br/> customCallback 自定义处理封面回调,需要return处理后的封面图片,自带参数为视频地址 | 处理结果 Promise | 带有视频的富文本逆向转换,可通过customCallback回调函数自定义处理封面 | 通用 |
| parseHtmlWithVideo | richText:string 要进行处理的富文本字符串 | 处理结果 String | 将含有封面占位图形式的视频富文本转换成正常视频的富文本 | 通用 |
| parseImagesAndVideos | richText:string 要进行处理的富文本字符串 | 处理结果 Array < Object >| 解析当前富文本中所有图片和视频 | 通用 |
| parseImages | richText:string 要进行处理的富文本字符串 | 处理结果 Array < Object >| 解析当前富文本中所有图片 | 通用 |
| parseVideos | richText:string 要进行处理的富文本字符串 | 处理结果 Array < Object >| 解析当前富文本中所有视频 | 通用 |
6. `uni_modules/sv-editor/components/common/config.js` 配置文件
| 参数 | 说明 |
| :--- | :--- |
| video_thumbnail | 视频默认封面,默认封面图可能会失效,原图在示例工程根目录下static文件夹中,建议自行替换 |
| video_playicon | 视频封面播放图标(默认三角播放图标) |
7. 具体使用代码案例请 `使用HBuilderX导入示例项目` 导入示例工程参考
### 八、特殊扩展
**本插件提供部分额外特殊扩展功能,具体如下:**
| 功能 | 说明 | 类型 | 兼容 |
| :--- | :--- | :--- | :--- |
| 粘贴保留格式 | 粘贴时尽可能的保留原有格式(并非完全复制) | 固有功能 | H5、APP |
| epaste | 粘贴回调事件 | 事件 | H5、APP |
| createVideoThumbnail| 以视频资源地址,直接生成视频封面图(需要保证视频资源正常可以播放) | api | H5、APP |
| createCoverThumbnail| 若后端返回视频封面但是没有播放图标,可以用此方法在图片中央叠加播放图标,用于作为视频封面 | api | 通用 |
| 待补充 ... | | | |
- createCoverThumbnail 在iOS端可能会报 `the operation is insecure` 的错,这是iOS更加严格的安全策略导致的,本地file://协议也会导致跨域,从而污染了画布
制作不易,特殊扩展功能限时免费开放,感谢支持Thanks♪(・ω・)ノ
使用方式:将插件内 backup 文件夹下的文件复制并粘贴进 plugins 文件夹下并覆盖原文件
### 九、结语
本插件免费开源(除特殊扩展外),如若借鉴源码还请注明出处,未经授权禁止转载售卖等侵犯版权行为,谢谢!
如若商用,望您可以联系作者本人,留下您的项目名,我希望能以方式此推广,谢谢!
感谢您使用本插件,如果在使用过程中遇到任何问题,欢迎在评论区留言,或在 [Gitee](https://gitee.com/Sonve/sv-editor) 上提交issue,我会尽快回复您。
制作不易,还望五星好评,若能在 [Gitee](https://gitee.com/Sonve/sv-editor) 上点个 ⭐star,不胜感激Thanks♪(・ω・)ノ
欢迎加群讨论,Q群:
① [852637893](https://qm.qq.com/cgi-bin/qm/qr?k=R7DHSqqDI4-xRCfwdUB2e3NrTytHpcVe&jump_from=webapi&authKey=2IpufavBOSPOLdncCt7EFnbmbWrUHg1c8iqNEdTzG8zCvnKb8/0aaLXF4HJzlp2R)
② [816646292](https://qm.qq.com/cgi-bin/qm/qr?k=ndZIUqx0xctbq8oDQVTiDir7AUO5jq9X&jump_from=webapi&authKey=fgk45wWObUUvig7FIuFUuM+0IFLvOJI7LMc1d4qNbWAIfehakai/ZfckYfAGLPne)
+41
View File
@@ -0,0 +1,41 @@
## 2.8.62025-03-17
1.`新增` 聊天记录模式流式输出(类似chatGPT回答)演示demo。
2.`新增` z-paging及其公共子组件支持`HBuilderX`代码文档提示。
3.`新增` props`virtual-in-swiper-slot`,用以解决vue3+(微信小程序或QQ小程序)中,使用非内置列表写法时,若z-paging在`swiper-item`中存在的无法获取slot插入的cell高度进而导致虚拟列表失败的问题。
4.`新增` `@scrolltolower`和@`scrolltoupper`支持nvue。
5.`修复``v2.8.1`引出的方法`scrollIntoViewById`在微信小程序+vue3中无效的问题。
6.`修复``v2.8.1`引出的在子组件内使用z-paging虚拟列表无效的问题。
7.`修复` 在微信小程序中基础库版本较高时`wx.getSystemInfoSync is deprecated`警告。
8.`优化` 提升下拉刷新在鸿蒙Next中的性能。
9.`优化` `@scrolltolower``@scrolltoupper`在倒置的聊天记录模式下的触发逻辑。
10.`优化` 其他细节调整。
## 2.8.52025-02-09
1.`新增` 方法`scrollToX`,支持控制x轴滚动到指定位置。
2.`修复` 快手小程序中报错`await isn't allowed in non-async function`的问题。
3.`修复` 在iOS+nvue中,设置了`:loading-more-enabled="false"`后,调用`scrollToBottom`无法滚动到底部的问题。
4.`修复` 在支付宝小程序+页面滚动中,数据为空时空数据图未居中的问题。
5.`优化` fetch types修改。
## 2.8.42024-12-02
1.`修复` 在虚拟列表+vue2中,顶部占位采用transformY方案;在虚拟列表+vue3中,顶部占位采用view占位方案。以解决在vue2+微信小程序+安卓+兼容模式中,可能出现的虚拟列表闪动的问题。
2.`修复` 在列表渲染时(尤其是在虚拟列表中)偶现的【点击加载更多】闪现的问题。
3.`优化` 统一在RefresherStatus枚举中Loading取值。
4.`优化` `defaultPageNo`&`defaultPageSize`修改为只允许number类型。
5.`优化` 提升兼容性&细节优化。
## 2.8.32024-11-27
1.`修复` `doInsertVirtualListItem`插入数据无效的问题。
2.`优化` 提升兼容性&细节优化。
## 2.8.22024-11-25
1.`优化` types中`ZPagingRef``ZPagingInstance`支持泛型。
## 2.8.12024-11-24
1.`新增` 完整的`props``slots``methods``events`的typescript types声明,可在ts中获得绝佳的代码提示体验。
2.`新增` `virtual-cell-id-prefix`:虚拟列表cell id的前缀,适用于一个页面有多个虚拟列表的情况,用以区分不同虚拟列表cell的id。
3.`修复` 在vue3+(微信小程序或QQ小程序)中,使用非内置列表写法时,若`z-paging``swiper-item`标签内的情况下存在的无法获取slot插入的cell高度的问题。
4.`修复` 在虚拟列表中分页数据小于1页时插入新数据,虚拟列表未生效的问题。
5.`修复` 在虚拟列表中调用`refresh`时,cell的index计算不正确的问题。
6.`修复` 在快手小程序中内容较少或空数据时`z-paging`未能铺满全屏的问题。
7.`优化` `events`中的参数涉及枚举的部分,统一由之前的number类型修改为string类型,展示更直观!涉及的events:`@query`中的`from`参数;`@refresherStatusChange`中的`status`参数;`@loadingStatusChange`中的`status`参数;`slot=refresher`中的`refresherStatus`参数;`slot=chatLoading`中的`loadingMoreStatus`参数。更新版本请特别留意!
## 2.8.02024-10-21
1.`新增` 全面支持鸿蒙Next。
2.`修复` 设置了`refresher-complete-delay`后,在下拉刷新期间调用reload导致的无法再次下拉刷新的问题。
3.`优化` 废弃虚拟列表transformY顶部占位方案,修改为空view占位。解决因使用旧方案导致的vue3中可能出现的虚拟列表闪动问题。提升虚拟列表的兼容性。
@@ -0,0 +1,47 @@
<!-- z-paging -->
<!-- github地址:https://github.com/SmileZXLee/uni-z-paging -->
<!-- dcloud地址:https://ext.dcloud.net.cn/plugin?id=3935 -->
<!-- 反馈QQ群790460711 -->
<!-- z-paging-cell用于在nvue中使用cell包裹vue中使用view包裹 -->
<template>
<!-- #ifdef APP-NVUE -->
<cell :style="[cellStyle]" @touchstart="onTouchstart">
<slot />
</cell>
<!-- #endif -->
<!-- #ifndef APP-NVUE -->
<view :style="[cellStyle]" @touchstart="onTouchstart">
<slot />
</view>
<!-- #endif -->
</template>
<script>
/**
* z-paging-cell 组件
* @description 用于兼容 nvue 和 vue 中的 cell 渲染。因为在 nvue 中 z-paging 内置的是 list,因此列表 item 必须使用 cell 包住;在 vue 中不能使用 cell,否则会报组件找不到的错误。此子组件为了兼容这两种情况,内部作了条件编译处理。
* @tutorial https://z-paging.zxlee.cn/api/sub-components/main.html#z-paging-cell配置
* @notice 以下为 z-paging-cell 的配置项
* @property {Object} cellStyle cell 样式,默认为 {}
* @example <z-paging-cell :cellStyle="{ backgroundColor: '#f0f0f0' }"></z-paging-cell>
*/
export default {
name: "z-paging-cell",
props: {
//cellStyle
cellStyle: {
type: Object,
default: function() {
return {}
}
}
},
methods: {
onTouchstart(e) {
this.$emit('touchstart', e);
}
}
}
</script>
@@ -0,0 +1,209 @@
<!-- z-paging -->
<!-- github地址:https://github.com/SmileZXLee/uni-z-paging -->
<!-- dcloud地址:https://ext.dcloud.net.cn/plugin?id=3935 -->
<!-- 反馈QQ群790460711 -->
<!-- 空数据占位view此组件支持easycom规范可以在项目中直接引用 -->
<template>
<view :class="{'zp-container':true,'zp-container-fixed':emptyViewFixed}" :style="[finalEmptyViewStyle]" @click="emptyViewClick">
<view class="zp-main">
<image v-if="!emptyViewImg.length" :class="{'zp-main-image-rpx':unit==='rpx','zp-main-image-px':unit==='px'}" :style="[emptyViewImgStyle]" :src="emptyImg" />
<image v-else :class="{'zp-main-image-rpx':unit==='rpx','zp-main-image-px':unit==='px'}" mode="aspectFit" :style="[emptyViewImgStyle]" :src="emptyViewImg" />
<text class="zp-main-title" :class="{'zp-main-title-rpx':unit==='rpx','zp-main-title-px':unit==='px'}" :style="[emptyViewTitleStyle]">{{emptyViewText}}</text>
<text v-if="showEmptyViewReload" :class="{'zp-main-error-btn':true,'zp-main-error-btn-rpx':unit==='rpx','zp-main-error-btn-px':unit==='px'}" :style="[emptyViewReloadStyle]" @click.stop="reloadClick">{{emptyViewReloadText}}</text>
</view>
</view>
</template>
<script>
import zStatic from '../z-paging/js/z-paging-static'
/**
* z-paging-empty-view 空数据组件
* @description 通用的 z-paging 空数据组件
* @tutorial https://z-paging.zxlee.cn/api/sub-components/main.html#z-paging-empty-view配置
* @property {Boolean} emptyViewFixed 空数据图片是否铺满 z-paging,默认为 false。若设置为 true,则为填充满 z-paging 的剩余部分
* @property {String} emptyViewText 空数据图描述文字,默认为 '没有数据哦~'
* @property {String} emptyViewImg 空数据图图片,默认使用 z-paging 内置的图片 (建议使用绝对路径,开头不要添加 "@",请以 "/" 开头)
* @property {String} emptyViewReloadText 空数据图点击重新加载文字,默认为 '重新加载'
* @property {Object} emptyViewStyle 空数据图样式,可设置空数据 view 的 top 等,如: empty-view-style="{'top':'100rpx'}" (如果空数据图不是 fixed 布局,则此处是 margin-top),默认为 {}
* @property {Object} emptyViewImgStyle 空数据图 img 样式,默认为 {}
* @property {Object} emptyViewTitleStyle 空数据图描述文字样式,默认为 {}
* @property {Object} emptyViewReloadStyle 空数据图重新加载按钮样式,默认为 {}
* @property {Boolean} showEmptyViewReload 是否显示空数据图重新加载按钮(无数据时),默认为 false
* @property {Boolean} isLoadFailed 是否是加载失败,默认为 false
* @property {String} unit 空数据图中布局的单位,默认为 'rpx'
* @event {Function} reload 点击了重新加载按钮
* @event {Function} viewClick 点击了空数据图 view
* @example <z-paging-empty-view empty-view-text="暂无数据" />
*/
export default {
name: "z-paging-empty-view",
data() {
return {
};
},
props: {
// 空数据描述文字
emptyViewText: {
type: String,
default: '没有数据哦~'
},
// 空数据图片
emptyViewImg: {
type: String,
default: ''
},
// 是否显示空数据图重新加载按钮
showEmptyViewReload: {
type: Boolean,
default: false
},
// 空数据点击重新加载文字
emptyViewReloadText: {
type: String,
default: '重新加载'
},
// 是否是加载失败
isLoadFailed: {
type: Boolean,
default: false
},
// 空数据图样式
emptyViewStyle: {
type: Object,
default: function() {
return {}
}
},
// 空数据图img样式
emptyViewImgStyle: {
type: Object,
default: function() {
return {}
}
},
// 空数据图描述文字样式
emptyViewTitleStyle: {
type: Object,
default: function() {
return {}
}
},
// 空数据图重新加载按钮样式
emptyViewReloadStyle: {
type: Object,
default: function() {
return {}
}
},
// 空数据图z-index
emptyViewZIndex: {
type: Number,
default: 9
},
// 空数据图片是否使用fixed布局并铺满z-paging
emptyViewFixed: {
type: Boolean,
default: true
},
// 空数据图中布局的单位,默认为rpx
unit: {
type: String,
default: 'rpx'
}
},
computed: {
emptyImg() {
return this.isLoadFailed ? zStatic.base64Error : zStatic.base64Empty;
},
finalEmptyViewStyle(){
this.emptyViewStyle['z-index'] = this.emptyViewZIndex;
return this.emptyViewStyle;
}
},
methods: {
// 点击了reload按钮
reloadClick() {
this.$emit('reload');
},
// 点击了空数据view
emptyViewClick() {
this.$emit('viewClick');
}
}
}
</script>
<style scoped>
.zp-container{
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
align-items: center;
justify-content: center;
}
.zp-container-fixed {
/* #ifndef APP-NVUE */
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* #endif */
/* #ifdef APP-NVUE */
flex: 1;
/* #endif */
}
.zp-main{
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
align-items: center;
padding: 50rpx 0rpx;
}
.zp-main-image-rpx {
width: 240rpx;
height: 240rpx;
}
.zp-main-image-px {
width: 120px;
height: 120px;
}
.zp-main-title {
color: #aaaaaa;
text-align: center;
}
.zp-main-title-rpx {
font-size: 28rpx;
margin-top: 10rpx;
padding: 0rpx 20rpx;
}
.zp-main-title-px {
font-size: 14px;
margin-top: 5px;
padding: 0px 10px;
}
.zp-main-error-btn {
border: solid 1px #dddddd;
color: #aaaaaa;
}
.zp-main-error-btn-rpx {
font-size: 28rpx;
padding: 8rpx 24rpx;
border-radius: 6rpx;
margin-top: 50rpx;
}
.zp-main-error-btn-px {
font-size: 14px;
padding: 4px 12px;
border-radius: 3px;
margin-top: 25px;
}
</style>
@@ -0,0 +1,160 @@
<!-- z-paging -->
<!-- github地址:https://github.com/SmileZXLee/uni-z-paging -->
<!-- dcloud地址:https://ext.dcloud.net.cn/plugin?id=3935 -->
<!-- 反馈QQ群790460711 -->
<!-- 滑动切换选项卡swiper-item此组件支持easycom规范可以在项目中直接引用 -->
<template>
<view class="zp-swiper-item-container">
<z-paging ref="paging" :fixed="false"
:auto="false" :useVirtualList="useVirtualList" :useInnerList="useInnerList" :cellKeyName="cellKeyName" :innerListStyle="innerListStyle"
:preloadPage="preloadPage" :cellHeightMode="cellHeightMode" :virtualScrollFps="virtualScrollFps" :virtualListCol="virtualListCol"
@query="_queryList" @listChange="_updateList">
<slot />
<template #header>
<slot name="header"/>
</template>
<template #cell="{item,index}">
<slot name="cell" :item="item" :index="index"/>
</template>
<template #footer>
<slot name="footer"/>
</template>
</z-paging>
</view>
</template>
<script>
import zPaging from '../z-paging/z-paging'
/**
* z-paging-swiper-item 组件
* @description swiper+list极简写法中使用到,实际上就是对普通的swiper+list中swiper-item的包装封装,用以简化写法,但个性化配置局限较多
* @tutorial https://z-paging.zxlee.cn/api/sub-components/main.html#z-paging-swiper-item配置
* @notice 以下为 z-paging-swiper-item 的配置项
* @property {Number} tabIndex 当前组件的 index,也就是当前组件是 swiper 中的第几个,默认为 0
* @property {Number} currentIndex 当前 swiper 切换到第几个 index,默认为 0
* @property {Boolean} useVirtualList 是否使用虚拟列表,默认为 false
* @property {Boolean} useInnerList 是否在 z-paging 内部循环渲染列表(内置列表),默认为 false。若 useVirtualList 为 true,则此项恒为 true
* @property {String} cellKeyName 内置列表 cell 的 key 名称,仅 nvue 有效,在 nvue 中开启 useInnerList 时必须填此项,默认为 ''
* @property {Object} innerListStyle innerList 样式,默认为 {}
* @property {Number|String} preloadPage 预加载的列表可视范围(列表高度)页数,默认为 12。此数值越大,则虚拟列表中加载的 dom 越多,内存消耗越大(会维持在一个稳定值),但增加预加载页面数量可缓解快速滚动短暂白屏问题
* @property {String} cellHeightMode 虚拟列表 cell 高度模式,默认为 'fixed',也就是每个 cell 高度完全相同,将以第一个 cell 高度为准进行计算。可选值【dynamic】,即代表高度是动态非固定的,【dynamic】性能低于【fixed】
* @property {Number|String} virtualListCol 虚拟列表列数,默认为 1。常用于每行有多列的情况,例如每行有 2 列数据,需要将此值设置为 2
* @property {Number|String} virtualScrollFps 虚拟列表 scroll 取样帧率,默认为 60,过高可能出现卡顿等问题
* @example <z-paging-swiper-item ref="swiperItem" :tabIndex="index" :currentIndex="current" @query="queryList" @updateList="updateList"></z-paging-swiper-item>
*/
export default {
name: "z-paging-swiper-item",
components: { zPaging },
data() {
return {
firstLoaded: false
}
},
props: {
// 当前组件的index,也就是当前组件是swiper中的第几个
tabIndex: {
type: Number,
default: function() {
return 0
}
},
// 当前swiper切换到第几个index
currentIndex: {
type: Number,
default: function() {
return 0
}
},
// 是否使用虚拟列表,默认为否
useVirtualList: {
type: Boolean,
default: false
},
// 是否在z-paging内部循环渲染列表(内置列表),默认为否。若use-virtual-list为true,则此项恒为true
useInnerList: {
type: Boolean,
default: false
},
// 内置列表cell的key名称,仅nvue有效,在nvue中开启use-inner-list时必须填此项
cellKeyName: {
type: String,
default: ''
},
// innerList样式
innerListStyle: {
type: Object,
default: function() {
return {};
}
},
// 预加载的列表可视范围(列表高度)页数,默认为12,即预加载当前页及上下各12页的cell。此数值越大,则虚拟列表中加载的dom越多,内存消耗越大(会维持在一个稳定值),但增加预加载页面数量可缓解快速滚动短暂白屏问题
preloadPage: {
type: [Number, String],
default: 12
},
// 虚拟列表cell高度模式,默认为fixed,也就是每个cell高度完全相同,将以第一个cell高度为准进行计算。可选值【dynamic】,即代表高度是动态非固定的,【dynamic】性能低于【fixed】。
cellHeightMode: {
type: String,
default: 'fixed'
},
// 虚拟列表列数,默认为1。常用于每行有多列的情况,例如每行有2列数据,需要将此值设置为2
virtualListCol: {
type: [Number, String],
default: 1
},
// 虚拟列表scroll取样帧率,默认为60,过高可能出现卡顿等问题
virtualScrollFps: {
type: [Number, String],
default: 60
},
},
watch: {
currentIndex: {
handler(newVal, oldVal) {
if (newVal === this.tabIndex) {
// 懒加载,当滑动到当前的item时,才去加载
if (!this.firstLoaded) {
this.$nextTick(()=>{
let delay = 5;
// #ifdef MP-TOUTIAO
delay = 100;
// #endif
setTimeout(() => {
this.$refs.paging.reload().catch(() => {});
}, delay);
})
}
}
},
immediate: true
}
},
methods: {
reload(data) {
return this.$refs.paging.reload(data);
},
complete(data) {
this.firstLoaded = true;
return this.$refs.paging.complete(data);
},
_queryList(pageNo, pageSize, from) {
this.$emit('query', pageNo, pageSize, from);
},
_updateList(list) {
this.$emit('updateList', list);
}
}
}
</script>
<style scoped>
.zp-swiper-item-container {
/* #ifndef APP-NVUE */
height: 100%;
/* #endif */
/* #ifdef APP-NVUE */
flex: 1;
/* #endif */
}
</style>
@@ -0,0 +1,176 @@
<!-- z-paging -->
<!-- github地址:https://github.com/SmileZXLee/uni-z-paging -->
<!-- dcloud地址:https://ext.dcloud.net.cn/plugin?id=3935 -->
<!-- 反馈QQ群790460711 -->
<!-- 滑动切换选项卡swiper容器此组件支持easycom规范可以在项目中直接引用 -->
<template>
<view :class="fixed?'zp-swiper-container zp-swiper-container-fixed':'zp-swiper-container'" :style="[finalSwiperStyle]">
<!-- #ifndef APP-PLUS -->
<view v-if="cssSafeAreaInsetBottom===-1" class="zp-safe-area-inset-bottom"></view>
<!-- #endif -->
<slot v-if="zSlots.top" name="top" />
<view class="zp-swiper-super">
<view v-if="zSlots.left" :class="{'zp-swiper-left':true,'zp-absoulte':isOldWebView}">
<slot name="left" />
</view>
<view :class="{'zp-swiper':true,'zp-absoulte':isOldWebView}" :style="[swiperContentStyle]">
<slot />
</view>
<view v-if="zSlots.right" :class="{'zp-swiper-right':true,'zp-absoulte zp-right':isOldWebView}">
<slot name="right" />
</view>
</view>
<slot v-if="zSlots.bottom" name="bottom" />
</view>
</template>
<script>
import commonLayoutModule from '../z-paging/js/modules/common-layout'
/**
* z-paging-swiper 组件
* @description 在 swiper 中使用 z-paging 时(左右滑动切换列表),在根节点使用 z-paging-swiper,其相当于一个 view 容器,默认铺满全屏,可免计算高度直接插入 swiper 的视图容器。
* @tutorial https://z-paging.zxlee.cn/api/sub-components/main.html#z-paging-swiper配置
* @property {Boolean} fixed 是否使用 fixed 布局,默认为 true
* @property {Boolean} safeAreaInsetBottom 是否开启底部安全区域适配,默认为 false
* @property {Object} swiperStyle z-paging-swiper 样式,默认为 {}
* @example <z-paging-swiper :safeAreaInsetBottom="true"></z-paging-swiper>
*/
export default {
name: "z-paging-swiper",
mixins: [commonLayoutModule],
data() {
return {
swiperContentStyle: {}
};
},
props: {
// 是否使用fixed布局,默认为是
fixed: {
type: Boolean,
default: true
},
// 是否开启底部安全区域适配
safeAreaInsetBottom: {
type: Boolean,
default: false
},
// z-paging-swiper样式
swiperStyle: {
type: Object,
default: function() {
return {};
},
}
},
mounted() {
this.$nextTick(() => {
this.systemInfo = this._getSystemInfoSync();
setTimeout(this.updateFixedLayout, 100)
})
// #ifndef APP-PLUS
this._getCssSafeAreaInsetBottom();
// #endif
this.updateLeftAndRightWidth();
this.swiperContentStyle = { 'flex': '1' };
// #ifndef APP-NVUE
this.swiperContentStyle = { width: '100%',height: '100%' };
// #endif
},
computed: {
finalSwiperStyle() {
const swiperStyle = { ...this.swiperStyle };
if (!this.systemInfo) return swiperStyle;
const windowTop = this.windowTop;
const windowBottom = this.systemInfo.windowBottom;
if (this.fixed) {
if (windowTop && !swiperStyle.top) {
swiperStyle.top = windowTop + 'px';
}
if (!swiperStyle.bottom) {
let bottom = windowBottom || 0;
bottom += this.safeAreaInsetBottom ? this.safeAreaBottom : 0;
if (bottom > 0) {
swiperStyle.bottom = bottom + 'px';
}
}
}
return swiperStyle;
}
},
methods: {
// 更新slot="left"和slot="right"宽度,当slot="left"或slot="right"宽度动态改变时调用
updateLeftAndRightWidth() {
if (!this.isOldWebView) return;
this.$nextTick(() => this._updateLeftAndRightWidth(this.swiperContentStyle, 'zp-swiper'));
}
}
}
</script>
<style scoped>
.zp-swiper-container {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
flex: 1;
}
.zp-swiper-container-fixed {
position: fixed;
/* #ifndef APP-NVUE */
height: auto;
width: auto;
/* #endif */
top: 0;
left: 0;
bottom: 0;
right: 0;
}
.zp-safe-area-inset-bottom {
position: absolute;
/* #ifndef APP-PLUS */
height: env(safe-area-inset-bottom);
/* #endif */
}
.zp-swiper-super {
flex: 1;
overflow: hidden;
position: relative;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: row;
}
.zp-swiper-left,.zp-swiper-right{
/* #ifndef APP-NVUE */
height: 100%;
/* #endif */
}
.zp-swiper {
flex: 1;
/* #ifndef APP-NVUE */
height: 100%;
width: 100%;
/* #endif */
}
.zp-absoulte {
/* #ifndef APP-NVUE */
position: absolute;
top: 0;
width: auto;
/* #endif */
}
.zp-swiper-item {
height: 100%;
}
</style>
@@ -0,0 +1,182 @@
<!-- [z-paging]上拉加载更多view -->
<template>
<view class="zp-l-container" :class="{'zp-l-container-rpx':c.unit==='rpx','zp-l-container-px':c.unit==='px'}" :style="[c.customStyle]" @click="doClick">
<template v-if="!c.hideContent">
<!-- 底部加载更多没有更多数据分割线 -->
<text v-if="c.showNoMoreLine&&finalStatus===M.NoMore" :class="{'zp-l-line-rpx':c.unit==='rpx','zp-l-line-px':c.unit==='px'}" :style="[{backgroundColor:zTheme.line[ts]},c.noMoreLineCustomStyle]" />
<!-- 底部加载更多loading -->
<!-- #ifndef APP-NVUE -->
<image v-if="finalStatus===M.Loading&&!!c.loadingIconCustomImage"
:src="c.loadingIconCustomImage" :style="[c.iconCustomStyle]" :class="{'zp-l-line-loading-custom-image':true,'zp-l-line-loading-custom-image-animated':c.loadingAnimated,'zp-l-line-loading-custom-image-rpx':c.unit==='rpx','zp-l-line-loading-custom-image-px':c.unit==='px'}" />
<image v-if="finalStatus===M.Loading&&finalLoadingIconType==='flower'&&!c.loadingIconCustomImage.length"
:class="{'zp-line-loading-image':true,'zp-line-loading-image-rpx':c.unit==='rpx','zp-line-loading-image-px':c.unit==='px'}" :style="[c.iconCustomStyle]" :src="zTheme.flower[ts]" />
<!-- #endif -->
<!-- #ifdef APP-NVUE -->
<!-- 在nvue中底部加载更多loading使用系统自带的 -->
<view>
<loading-indicator v-if="finalStatus===M.Loading&&finalLoadingIconType!=='circle'" :class="{'zp-line-loading-image-rpx':c.unit==='rpx','zp-line-loading-image-px':c.unit==='px'}" :style="[{color:zTheme.indicator[ts]}]" :animating="true" />
</view>
<!-- #endif -->
<!-- 底部加载更多文字 -->
<text v-if="finalStatus===M.Loading&&finalLoadingIconType==='circle'&&!c.loadingIconCustomImage.length"
class="zp-l-circle-loading-view" :class="{'zp-l-circle-loading-view-rpx':c.unit==='rpx','zp-l-circle-loading-view-px':c.unit==='px'}" :style="[{borderColor:zTheme.circleBorder[ts],borderTopColor:zTheme.circleBorderTop[ts]},c.iconCustomStyle]" />
<text v-if="!c.isChat||(!c.chatDefaultAsLoading&&finalStatus===M.Default)||finalStatus===M.Fail" :class="{'zp-l-text-rpx':c.unit==='rpx','zp-l-text-px':c.unit==='px'}" :style="[{color:zTheme.title[ts]},c.titleCustomStyle]">{{ownLoadingMoreText}}</text>
<!-- 底部加载更多没有更多数据分割线 -->
<text v-if="c.showNoMoreLine&&finalStatus===M.NoMore" :class="{'zp-l-line-rpx':c.unit==='rpx','zp-l-line-px':c.unit==='px'}" :style="[{backgroundColor:zTheme.line[ts]},c.noMoreLineCustomStyle]" />
</template>
</view>
</template>
<script>
import zStatic from '../js/z-paging-static'
import Enum from '../js/z-paging-enum'
export default {
name: 'z-paging-load-more',
data() {
return {
M: Enum.More,
zTheme: {
title: { white: '#efefef', black: '#a4a4a4' },
line: { white: '#efefef', black: '#eeeeee' },
circleBorder: { white: '#aaaaaa', black: '#c8c8c8' },
circleBorderTop: { white: '#ffffff', black: '#444444' },
flower: { white: zStatic.base64FlowerWhite, black: zStatic.base64Flower },
indicator: { white: '#eeeeee', black: '#777777' }
}
};
},
props: ['zConfig'],
computed: {
ts() {
return this.c.defaultThemeStyle;
},
// 底部加载更多配置
c() {
return this.zConfig || {};
},
// 底部加载更多文字
ownLoadingMoreText() {
return {
[this.M.Default]: this.c.defaultText,
[this.M.Loading]: this.c.loadingText,
[this.M.NoMore]: this.c.noMoreText,
[this.M.Fail]: this.c.failText,
}[this.finalStatus];
},
// 底部加载更多状态
finalStatus() {
if (this.c.defaultAsLoading && this.c.status === this.M.Default) return this.M.Loading;
return this.c.status;
},
// 加载更多icon类型
finalLoadingIconType() {
// #ifdef APP-NVUE
return 'flower';
// #endif
return this.c.loadingIconType;
}
},
methods: {
// 点击了加载更多
doClick() {
this.$emit('doClick');
}
}
}
</script>
<style scoped>
@import "../css/z-paging-static.css";
.zp-l-container {
/* #ifndef APP-NVUE */
clear: both;
display: flex;
/* #endif */
flex-direction: row;
align-items: center;
justify-content: center;
}
.zp-l-container-rpx {
height: 80rpx;
font-size: 27rpx;
}
.zp-l-container-px {
height: 40px;
font-size: 14px;
}
.zp-l-line-loading-custom-image {
color: #a4a4a4;
}
.zp-l-line-loading-custom-image-rpx {
margin-right: 8rpx;
width: 28rpx;
height: 28rpx;
}
.zp-l-line-loading-custom-image-px {
margin-right: 4px;
width: 14px;
height: 14px;
}
.zp-l-line-loading-custom-image-animated{
/* #ifndef APP-NVUE */
animation: loading-circle 1s linear infinite;
/* #endif */
}
.zp-l-circle-loading-view {
border: 3rpx solid #dddddd;
border-radius: 50%;
/* #ifndef APP-NVUE */
animation: loading-circle 1s linear infinite;
/* #endif */
/* #ifdef APP-NVUE */
width: 30rpx;
height: 30rpx;
/* #endif */
}
.zp-l-circle-loading-view-rpx {
margin-right: 8rpx;
width: 23rpx;
height: 23rpx;
}
.zp-l-circle-loading-view-px {
margin-right: 4px;
width: 12px;
height: 12px;
}
.zp-l-text-rpx {
font-size: 30rpx;
margin: 0rpx 6rpx;
}
.zp-l-text-px {
font-size: 15px;
margin: 0px 3px;
}
.zp-l-line-rpx {
height: 1px;
width: 100rpx;
margin: 0rpx 10rpx;
}
.zp-l-line-px {
height: 1px;
width: 50px;
margin: 0rpx 5px;
}
/* #ifndef APP-NVUE */
@keyframes loading-circle {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
/* #endif */
</style>
@@ -0,0 +1,214 @@
<!-- [z-paging]下拉刷新view -->
<template>
<view style="height: 100%;">
<view :class="showUpdateTime?'zp-r-container zp-r-container-padding':'zp-r-container'">
<view class="zp-r-left">
<!-- 非加载中(继续下拉刷新松手立即刷新状态图片) -->
<image v-if="status!==R.Loading" :class="leftImageClass" :style="[leftImageStyle,imgStyle]" :src="leftImageSrc" />
<!-- 加载状态图片 -->
<!-- #ifndef APP-NVUE -->
<image v-else :class="{'zp-line-loading-image':refreshingAnimated,'zp-r-left-image':true,'zp-r-left-image-pre-size-rpx':unit==='rpx','zp-r-left-image-pre-size-px':unit==='px'}" :style="[leftImageStyle,imgStyle]" :src="leftImageSrc" />
<!-- #endif -->
<!-- 在nvue中加载状态loading使用系统loading -->
<!-- #ifdef APP-NVUE -->
<view v-else :style="[{'margin-right':showUpdateTime?addUnit(18,unit):addUnit(12, unit)}]">
<loading-indicator :class="isIos?{'zp-loading-image-ios-rpx':unit==='rpx','zp-loading-image-ios-px':unit==='px'}:{'zp-loading-image-android-rpx':unit==='rpx','zp-loading-image-android-px':unit==='px'}"
:style="[{color:zTheme.indicator[ts]},imgStyle]" :animating="true" />
</view>
<!-- #endif -->
</view>
<!-- 右侧文字内容 -->
<view class="zp-r-right">
<!-- 右侧下拉刷新状态文字 -->
<text class="zp-r-right-text" :style="[rightTextStyle,titleStyle]">{{currentTitle}}</text>
<!-- 右侧下拉刷新时间文字 -->
<text v-if="showUpdateTime&&refresherTimeText.length" class="zp-r-right-text" :class="{'zp-r-right-time-text-rpx':unit==='rpx','zp-r-right-time-text-px':unit==='px'}" :style="[{color:zTheme.title[ts]},updateTimeStyle]">
{{refresherTimeText}}
</text>
</view>
</view>
</view>
</template>
<script>
import zStatic from '../js/z-paging-static'
import u from '../js/z-paging-utils'
import Enum from '../js/z-paging-enum'
export default {
name: 'z-paging-refresh',
data() {
return {
R: Enum.Refresher,
refresherTimeText: '',
zTheme: {
title: { white: '#efefef', black: '#555555' },
arrow: { white: zStatic.base64ArrowWhite, black: zStatic.base64Arrow },
flower: { white: zStatic.base64FlowerWhite, black: zStatic.base64Flower },
success: { white: zStatic.base64SuccessWhite, black: zStatic.base64Success },
indicator: { white: '#eeeeee', black: '#777777' }
}
};
},
props: ['status', 'defaultThemeStyle', 'defaultText', 'pullingText', 'refreshingText', 'completeText', 'goF2Text', 'defaultImg', 'pullingImg',
'refreshingImg', 'completeImg', 'refreshingAnimated', 'showUpdateTime', 'updateTimeKey', 'imgStyle', 'titleStyle', 'updateTimeStyle', 'updateTimeTextMap', 'unit', 'isIos'
],
computed: {
ts() {
return this.defaultThemeStyle;
},
// 当前状态Map
statusTextMap() {
this.updateTime();
const { R, defaultText, pullingText, refreshingText, completeText, goF2Text } = this;
return {
[R.Default]: defaultText,
[R.ReleaseToRefresh]: pullingText,
[R.Loading]: refreshingText,
[R.Complete]: completeText,
[R.GoF2]: goF2Text,
};
},
// 当前状态文字
currentTitle() {
return this.statusTextMap[this.status] || this.defaultText;
},
// 左侧图片class
leftImageClass() {
const preSizeClass = `zp-r-left-image-pre-size-${this.unit}`;
if (this.status === this.R.Complete) return preSizeClass;
return `zp-r-left-image ${preSizeClass} ${this.status === this.R.Default ? 'zp-r-arrow-down' : 'zp-r-arrow-top'}`;
},
// 左侧图片style
leftImageStyle() {
const showUpdateTime = this.showUpdateTime;
const size = showUpdateTime ? u.addUnit(36, this.unit) : u.addUnit(34, this.unit);
return {width: size,height: size,'margin-right': showUpdateTime ? u.addUnit(20, this.unit) : u.addUnit(9, this.unit)};
},
// 左侧图片src
leftImageSrc() {
const R = this.R;
const status = this.status;
if (status === R.Default) {
if (!!this.defaultImg) return this.defaultImg;
return this.zTheme.arrow[this.ts];
} else if (status === R.ReleaseToRefresh) {
if (!!this.pullingImg) return this.pullingImg;
if (!!this.defaultImg) return this.defaultImg;
return this.zTheme.arrow[this.ts];
} else if (status === R.Loading) {
if (!!this.refreshingImg) return this.refreshingImg;
return this.zTheme.flower[this.ts];;
} else if (status === R.Complete) {
if (!!this.completeImg) return this.completeImg;
return this.zTheme.success[this.ts];;
} else if (status === R.GoF2) {
return this.zTheme.arrow[this.ts];
}
return '';
},
// 右侧文字style
rightTextStyle() {
let stl = {};
// #ifdef APP-NVUE
const textHeight = this.showUpdateTime ? u.addUnit(40, this.unit) : u.addUnit(80, this.unit);
stl = {'height': textHeight, 'line-height': textHeight}
// #endif
stl['color'] = this.zTheme.title[this.ts];
stl['font-size'] = u.addUnit(30, this.unit);
return stl;
}
},
methods: {
// 添加单位
addUnit(value, unit) {
return u.addUnit(value, unit);
},
// 更新下拉刷新时间
updateTime() {
if (this.showUpdateTime) {
this.refresherTimeText = u.getRefesrherFormatTimeByKey(this.updateTimeKey, this.updateTimeTextMap);
}
}
}
}
</script>
<style scoped>
@import "../css/z-paging-static.css";
.zp-r-container {
/* #ifndef APP-NVUE */
display: flex;
height: 100%;
/* #endif */
flex-direction: row;
justify-content: center;
align-items: center;
}
.zp-r-container-padding {
/* #ifdef APP-NVUE */
padding: 7px 0rpx;
/* #endif */
}
.zp-r-left {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: row;
align-items: center;
overflow: hidden;
/* #ifdef MP-ALIPAY */
margin-top: -4rpx;
/* #endif */
}
.zp-r-left-image {
transition-duration: .2s;
transition-property: transform;
color: #666666;
}
.zp-r-left-image-pre-size-rpx {
/* #ifndef APP-NVUE */
width: 34rpx;
height: 34rpx;
overflow: hidden;
/* #endif */
}
.zp-r-left-image-pre-size-px {
/* #ifndef APP-NVUE */
width: 17px;
height: 17px;
overflow: hidden;
/* #endif */
}
.zp-r-arrow-top {
transform: rotate(0deg);
}
.zp-r-arrow-down {
transform: rotate(180deg);
}
.zp-r-right {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
align-items: center;
justify-content: center;
}
.zp-r-right-time-text-rpx {
margin-top: 10rpx;
font-size: 26rpx;
}
.zp-r-right-time-text-px {
margin-top: 5px;
font-size: 13px;
}
</style>
@@ -0,0 +1,3 @@
// z-paging全局配置文件,注意避免更新时此文件被覆盖,若被覆盖,可在此文件中右键->点击本地历史记录,找回覆盖前的配置
export default {}
@@ -0,0 +1,241 @@
/* [z-paging]公共css*/
.z-paging-content {
position: relative;
flex-direction: column;
/* #ifndef APP-NVUE */
overflow: hidden;
/* #endif */
}
.z-paging-content-full {
/* #ifndef APP-NVUE */
display: flex;
width: 100%;
height: 100%;
/* #endif */
}
.z-paging-content-fixed, .zp-loading-fixed {
position: fixed;
/* #ifndef APP-NVUE */
height: auto;
width: auto;
/* #endif */
top: 0;
left: 0;
bottom: 0;
right: 0;
}
.zp-f2-content {
width: 100%;
position: fixed;
top: 0;
left: 0;
background-color: white;
}
.zp-page-top, .zp-page-bottom {
/* #ifndef APP-NVUE */
width: auto;
/* #endif */
position: fixed;
left: 0;
right: 0;
z-index: 999;
}
.zp-page-left, .zp-page-right {
/* #ifndef APP-NVUE */
height: 100%;
/* #endif */
}
.zp-scroll-view-super {
flex: 1;
overflow: hidden;
position: relative;
}
.zp-view-super {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: row;
}
.zp-scroll-view-container, .zp-scroll-view {
position: relative;
/* #ifndef APP-NVUE */
height: 100%;
width: 100%;
/* #endif */
}
.zp-absoulte {
/* #ifndef APP-NVUE */
position: absolute;
top: 0;
width: auto;
/* #endif */
}
.zp-scroll-view-absolute {
position: absolute;
top: 0;
left: 0;
}
/* #ifndef APP-NVUE */
.zp-scroll-view-hide-scrollbar ::-webkit-scrollbar {
display: none;
-webkit-appearance: none;
width: 0 !important;
height: 0 !important;
background: transparent;
}
/* #endif */
.zp-paging-touch-view {
width: 100%;
height: 100%;
position: relative;
}
.zp-fixed-bac-view {
position: absolute;
width: 100%;
top: 0;
left: 0;
height: 200px;
}
.zp-paging-main {
height: 100%;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
}
.zp-paging-container {
flex: 1;
position: relative;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
}
.zp-chat-record-loading-custom-image {
width: 35rpx;
height: 35rpx;
/* #ifndef APP-NVUE */
animation: loading-flower 1s linear infinite;
/* #endif */
}
.zp-page-bottom-keyboard-placeholder-animate {
transition-property: height;
transition-duration: 0.15s;
/* #ifndef APP-NVUE */
will-change: height;
/* #endif */
}
.zp-custom-refresher-container {
overflow: hidden;
}
.zp-custom-refresher-refresh {
/* #ifndef APP-NVUE */
display: block;
/* #endif */
}
.zp-back-to-top {
z-index: 999;
position: absolute;
bottom: 0rpx;
transition-duration: .3s;
transition-property: opacity;
}
.zp-back-to-top-rpx {
width: 76rpx;
height: 76rpx;
bottom: 0rpx;
right: 25rpx;
}
.zp-back-to-top-px {
width: 38px;
height: 38px;
bottom: 0px;
right: 13px;
}
.zp-back-to-top-show {
opacity: 1;
}
.zp-back-to-top-hide {
opacity: 0;
}
.zp-back-to-top-img {
/* #ifndef APP-NVUE */
width: 100%;
height: 100%;
/* #endif */
/* #ifdef APP-NVUE */
flex: 1;
/* #endif */
z-index: 999;
}
.zp-back-to-top-img-inversion {
transform: rotate(180deg);
}
.zp-empty-view {
/* #ifdef APP-NVUE */
height: 100%;
/* #endif */
flex: 1;
}
.zp-empty-view-center {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
align-items: center;
justify-content: center;
}
.zp-loading-fixed {
z-index: 9999;
}
.zp-safe-area-inset-bottom {
position: absolute;
/* #ifndef APP-PLUS */
height: env(safe-area-inset-bottom);
/* #endif */
}
.zp-n-refresh-container {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
justify-content: center;
width: 750rpx;
}
.zp-n-list-container{
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: row;
flex: 1;
}
@@ -0,0 +1,50 @@
/* [z-paging]公用的静态css资源 */
.zp-line-loading-image {
/* #ifndef APP-NVUE */
animation: loading-flower 1s steps(12) infinite;
/* #endif */
color: #666666;
}
.zp-line-loading-image-rpx {
margin-right: 8rpx;
width: 34rpx;
height: 34rpx;
}
.zp-line-loading-image-px {
margin-right: 4px;
width: 17px;
height: 17px;
}
.zp-loading-image-ios-rpx {
width: 40rpx;
height: 40rpx;
}
.zp-loading-image-ios-px {
width: 20px;
height: 20px;
}
.zp-loading-image-android-rpx {
width: 34rpx;
height: 34rpx;
}
.zp-loading-image-android-px {
width: 17px;
height: 17px;
}
/* #ifndef APP-NVUE */
@keyframes loading-flower {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-webkit-transform: rotate(1turn);
transform: rotate(1turn);
}
}
/* #endif */
@@ -0,0 +1,23 @@
{
"zp.refresher.default": "Pull down to refresh",
"zp.refresher.pulling": "Release to refresh",
"zp.refresher.refreshing": "Refreshing...",
"zp.refresher.complete": "Refresh succeeded",
"zp.refresher.f2": "Refresh to enter 2f",
"zp.loadingMore.default": "Click to load more",
"zp.loadingMore.loading": "Loading...",
"zp.loadingMore.noMore": "No more data",
"zp.loadingMore.fail": "Load failed,click to reload",
"zp.emptyView.title": "No data",
"zp.emptyView.reload": "Reload",
"zp.emptyView.error": "Sorry,load failed",
"zp.refresherUpdateTime.title": "Last update: ",
"zp.refresherUpdateTime.none": "None",
"zp.refresherUpdateTime.today": "Today",
"zp.refresherUpdateTime.yesterday": "Yesterday",
"zp.systemLoading.title": "Loading..."
}
@@ -0,0 +1,8 @@
import en from './en.json'
import zhHans from './zh-Hans.json'
import zhHant from './zh-Hant.json'
export default {
en,
'zh-Hans': zhHans,
'zh-Hant': zhHant
}
@@ -0,0 +1,23 @@
{
"zp.refresher.default": "继续下拉刷新",
"zp.refresher.pulling": "松开立即刷新",
"zp.refresher.refreshing": "正在刷新...",
"zp.refresher.complete": "刷新成功",
"zp.refresher.f2": "松手进入二楼",
"zp.loadingMore.default": "点击加载更多",
"zp.loadingMore.loading": "正在加载...",
"zp.loadingMore.noMore": "没有更多了",
"zp.loadingMore.fail": "加载失败,点击重新加载",
"zp.emptyView.title": "没有数据哦~",
"zp.emptyView.reload": "重新加载",
"zp.emptyView.error": "很抱歉,加载失败",
"zp.refresherUpdateTime.title": "最后更新:",
"zp.refresherUpdateTime.none": "无",
"zp.refresherUpdateTime.today": "今天",
"zp.refresherUpdateTime.yesterday": "昨天",
"zp.systemLoading.title": "加载中..."
}
@@ -0,0 +1,23 @@
{
"zp.refresher.default": "繼續下拉重繪",
"zp.refresher.pulling": "鬆開立即重繪",
"zp.refresher.refreshing": "正在重繪...",
"zp.refresher.complete": "重繪成功",
"zp.refresher.f2": "鬆手進入二樓",
"zp.loadingMore.default": "點擊加載更多",
"zp.loadingMore.loading": "正在加載...",
"zp.loadingMore.noMore": "沒有更多了",
"zp.loadingMore.fail": "加載失敗,點擊重新加載",
"zp.emptyView.title": "沒有數據哦~",
"zp.emptyView.reload": "重新加載",
"zp.emptyView.error": "很抱歉,加載失敗",
"zp.refresherUpdateTime.title": "最後更新:",
"zp.refresherUpdateTime.none": "無",
"zp.refresherUpdateTime.today": "今天",
"zp.refresherUpdateTime.yesterday": "昨天",
"zp.systemLoading.title": "加載中..."
}
@@ -0,0 +1,25 @@
// [z-paging]useZPaging hooks
import { onPageScroll, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app';
function useZPaging(paging) {
const cPaging = !!paging ? paging.value || paging : null;
onPullDownRefresh(() => {
if (!cPaging || !cPaging.value) return;
cPaging.value.reload().catch(() => {});
})
onPageScroll(e => {
if (!cPaging || !cPaging.value) return;
cPaging.value.updatePageScrollTop(e.scrollTop);
e.scrollTop < 10 && cPaging.value.doChatRecordLoadMore();
})
onReachBottom(() => {
if (!cPaging || !cPaging.value) return;
cPaging.value.pageReachBottom();
})
}
export default useZPaging
@@ -0,0 +1,25 @@
// [z-paging]useZPagingComp hooks
function useZPagingComp(paging) {
const cPaging = !!paging ? paging.value || paging : null;
const reload = () => {
if (!cPaging || !cPaging.value) return;
cPaging.value.reload().catch(() => {});
}
const updatePageScrollTop = scrollTop => {
if (!cPaging || !cPaging.value) return;
cPaging.value.updatePageScrollTop(scrollTop);
}
const doChatRecordLoadMore = () => {
if (!cPaging || !cPaging.value) return;
cPaging.value.doChatRecordLoadMore();
}
const pageReachBottom = () => {
if (!cPaging || !cPaging.value) return;
cPaging.value.pageReachBottom();
}
return { reload, updatePageScrollTop, doChatRecordLoadMore, pageReachBottom };
}
export default useZPagingComp
@@ -0,0 +1,125 @@
// [z-paging]点击返回顶部view模块
import u from '.././z-paging-utils'
export default {
props: {
// 自动显示点击返回顶部按钮,默认为否
autoShowBackToTop: {
type: Boolean,
default: u.gc('autoShowBackToTop', false)
},
// 点击返回顶部按钮显示/隐藏的阈值(滚动距离),单位为px,默认为400rpx
backToTopThreshold: {
type: [Number, String],
default: u.gc('backToTopThreshold', '400rpx')
},
// 点击返回顶部按钮的自定义图片地址,默认使用z-paging内置的图片
backToTopImg: {
type: String,
default: u.gc('backToTopImg', '')
},
// 点击返回顶部按钮返回到顶部时是否展示过渡动画,默认为是
backToTopWithAnimate: {
type: Boolean,
default: u.gc('backToTopWithAnimate', true)
},
// 点击返回顶部按钮与底部的距离,注意添加单位px或rpx,默认为160rpx
backToTopBottom: {
type: [Number, String],
default: u.gc('backToTopBottom', '160rpx')
},
// 点击返回顶部按钮的自定义样式
backToTopStyle: {
type: Object,
default: u.gc('backToTopStyle', {}),
},
// iOS点击顶部状态栏、安卓双击标题栏时,滚动条返回顶部,只支持竖向,默认为是
enableBackToTop: {
type: Boolean,
default: u.gc('enableBackToTop', true)
},
},
data() {
return {
// 点击返回顶部的class
backToTopClass: 'zp-back-to-top zp-back-to-top-hide',
// 上次点击返回顶部的时间
lastBackToTopShowTime: 0,
// 点击返回顶部显示的class是否在展示中,使得按钮展示/隐藏过度效果更自然
showBackToTopClass: false,
}
},
computed: {
backToTopThresholdUnitConverted() {
return u.addUnit(this.backToTopThreshold, this.unit);
},
backToTopBottomUnitConverted() {
return u.addUnit(this.backToTopBottom, this.unit);
},
finalEnableBackToTop() {
return this.usePageScroll ? false : this.enableBackToTop;
},
finalBackToTopThreshold() {
return u.convertToPx(this.backToTopThresholdUnitConverted);
},
finalBackToTopStyle() {
const backToTopStyle = this.backToTopStyle;
if (!backToTopStyle.bottom) {
backToTopStyle.bottom = this.windowBottom + u.convertToPx(this.backToTopBottomUnitConverted) + 'px';
}
if(!backToTopStyle.position){
backToTopStyle.position = this.usePageScroll ? 'fixed': 'absolute';
}
return backToTopStyle;
},
finalBackToTopClass() {
return `${this.backToTopClass} zp-back-to-top-${this.unit}`;
}
},
methods: {
// 点击了返回顶部
_backToTopClick() {
let callbacked = false;
this.$emit('backToTopClick', toTop => {
(toTop === undefined || toTop === true) && this._handleToTop();
callbacked = true;
});
// 如果用户没有禁止默认的返回顶部事件,则触发滚动到顶部
this.$nextTick(() => {
!callbacked && this._handleToTop();
})
},
// 处理滚动到顶部(聊天记录模式中为滚动到底部)
_handleToTop() {
!this.backToTopWithAnimate && this._checkShouldShowBackToTop(0);
!this.useChatRecordMode ? this.scrollToTop(this.backToTopWithAnimate) : this.scrollToBottom(this.backToTopWithAnimate);
},
// 判断是否要显示返回顶部按钮
_checkShouldShowBackToTop(scrollTop) {
if (!this.autoShowBackToTop) {
this.showBackToTopClass = false;
return;
}
if (scrollTop > this.finalBackToTopThreshold) {
if (!this.showBackToTopClass) {
// 记录当前点击返回顶部按钮显示的class生效了
this.showBackToTopClass = true;
this.lastBackToTopShowTime = new Date().getTime();
// 当滚动到需要展示返回顶部的阈值内,则延迟300毫秒展示返回到顶部按钮
u.delay(() => {
this.backToTopClass = 'zp-back-to-top zp-back-to-top-show';
}, 300)
}
} else {
// 如果当前点击返回顶部按钮显示的class是生效状态并且滚动小于触发阈值,则隐藏返回顶部按钮
if (this.showBackToTopClass) {
this.backToTopClass = 'zp-back-to-top zp-back-to-top-hide';
u.delay(() => {
this.showBackToTopClass = false;
}, new Date().getTime() - this.lastBackToTopShowTime < 500 ? 0 : 300)
}
}
},
}
}
@@ -0,0 +1,149 @@
// [z-paging]聊天记录模式模块
import u from '.././z-paging-utils'
export default {
props: {
// 使用聊天记录模式,默认为否
useChatRecordMode: {
type: Boolean,
default: u.gc('useChatRecordMode', false)
},
// 使用聊天记录模式时滚动到顶部后,列表垂直移动偏移距离。默认0rpx。单位px(暂时无效)
chatRecordMoreOffset: {
type: [Number, String],
default: u.gc('chatRecordMoreOffset', '0rpx')
},
// 使用聊天记录模式时是否自动隐藏键盘:在用户触摸列表时候自动隐藏键盘,默认为是
autoHideKeyboardWhenChat: {
type: Boolean,
default: u.gc('autoHideKeyboardWhenChat', true)
},
// 使用聊天记录模式中键盘弹出时是否自动调整slot="bottom"高度,默认为是
autoAdjustPositionWhenChat: {
type: Boolean,
default: u.gc('autoAdjustPositionWhenChat', true)
},
// 使用聊天记录模式中键盘弹出时占位高度偏移距离。默认0rpx。单位px
chatAdjustPositionOffset: {
type: [Number, String],
default: u.gc('chatAdjustPositionOffset', '0rpx')
},
// 使用聊天记录模式中键盘弹出时是否自动滚动到底部,默认为否
autoToBottomWhenChat: {
type: Boolean,
default: u.gc('autoToBottomWhenChat', false)
},
// 使用聊天记录模式中reload时是否显示chatLoading,默认为否
showChatLoadingWhenReload: {
type: Boolean,
default: u.gc('showChatLoadingWhenReload', false)
},
// 在聊天记录模式中滑动到顶部状态为默认状态时,以加载中的状态展示,默认为是。若设置为否,则默认会显示【点击加载更多】,然后才会显示loading
chatLoadingMoreDefaultAsLoading: {
type: Boolean,
default: u.gc('chatLoadingMoreDefaultAsLoading', true)
},
},
data() {
return {
// 键盘高度
keyboardHeight: 0,
// 键盘高度是否未改变,此时占位高度变化不需要动画效果
isKeyboardHeightChanged: false,
}
},
computed: {
finalChatRecordMoreOffset() {
return u.convertToPx(this.chatRecordMoreOffset);
},
finalChatAdjustPositionOffset() {
return u.convertToPx(this.chatAdjustPositionOffset);
},
// 聊天记录模式旋转180度style
chatRecordRotateStyle() {
let cellStyle;
// 在vue中,直接将列表倒置,因此在vue的cell中,也直接写style="transform: scaleY(-1)"转回来即可。
// #ifndef APP-NVUE
cellStyle = this.useChatRecordMode ? { transform: 'scaleY(-1)' } : {};
// #endif
// 在nvue中,需要考虑数据量不满一页的情况,因为nvue中的list无法通过flex-end修改不满一页的起始位置,会导致不满一页时列表数据从底部开始,因此需要特别判断
// 当数据不满一屏的时候,不进行列表倒置
// #ifdef APP-NVUE
cellStyle = this.useChatRecordMode ? { transform: this.isFirstPageAndNoMore ? 'scaleY(1)' : 'scaleY(-1)' } : {};
// #endif
this.$emit('update:cellStyle', cellStyle);
this.$emit('cellStyleChange', cellStyle);
// 在聊天记录模式中,如果列表没有倒置并且当前是第一页,则需要自动滚动到最底部
this.$nextTick(() => {
if (this.isFirstPage && this.isChatRecordModeAndNotInversion) {
this.$nextTick(() => {
// 这里多次触发滚动到底部是为了避免在某些情况下,即使是在nextTick但是cell未渲染完毕导致滚动到底部位置不正确的问题
this._scrollToBottom(false);
u.delay(() => {
this._scrollToBottom(false);
u.delay(() => {
this._scrollToBottom(false);
}, 50)
}, 50)
})
}
})
return cellStyle;
},
// 是否是聊天记录列表并且有配置transform
isChatRecordModeHasTransform() {
return this.useChatRecordMode && this.chatRecordRotateStyle && this.chatRecordRotateStyle.transform;
},
// 是否是聊天记录列表并且列表未倒置
isChatRecordModeAndNotInversion() {
return this.isChatRecordModeHasTransform && this.chatRecordRotateStyle.transform === 'scaleY(1)';
},
// 是否是聊天记录列表并且列表倒置
isChatRecordModeAndInversion() {
return this.isChatRecordModeHasTransform && this.chatRecordRotateStyle.transform === 'scaleY(-1)';
},
// 最终的聊天记录模式中底部安全区域的高度,如果开启了底部安全区域并且键盘未弹出,则添加底部区域高度
chatRecordModeSafeAreaBottom() {
return this.safeAreaInsetBottom && !this.keyboardHeight ? this.safeAreaBottom : 0;
}
},
mounted() {
// 监听键盘高度变化(H5、百度小程序、抖音小程序、飞书小程序不支持)
// #ifndef H5 || MP-BAIDU || MP-TOUTIAO
if (this.useChatRecordMode) {
uni.onKeyboardHeightChange(this._handleKeyboardHeightChange);
}
// #endif
},
methods: {
// 添加聊天记录
addChatRecordData(data, toBottom = true, toBottomWithAnimate = true) {
if (!this.useChatRecordMode) return;
this.isTotalChangeFromAddData = true;
this.addDataFromTop(data, toBottom, toBottomWithAnimate);
},
// 手动触发滚动到顶部加载更多,聊天记录模式时有效
doChatRecordLoadMore() {
this.useChatRecordMode && this._onLoadingMore('click');
},
// 处理键盘高度变化
_handleKeyboardHeightChange(res) {
this.$emit('keyboardHeightChange', res);
if (this.autoAdjustPositionWhenChat) {
this.isKeyboardHeightChanged = true;
this.keyboardHeight = res.height > 0 ? res.height + this.finalChatAdjustPositionOffset : res.height;
}
if (this.autoToBottomWhenChat && this.keyboardHeight > 0) {
u.delay(() => {
this.scrollToBottom(false);
u.delay(() => {
this.scrollToBottom(false);
})
})
}
}
}
}
@@ -0,0 +1,152 @@
// [z-paging]通用布局相关模块
import u from '.././z-paging-utils'
// #ifdef APP-NVUE
const weexDom = weex.requireModule('dom');
// #endif
export default {
data() {
return {
systemInfo: null,
cssSafeAreaInsetBottom: -1,
isReadyDestroy: false,
}
},
computed: {
// 顶部可用距离
windowTop() {
if (!this.systemInfo) return 0;
// 暂时修复vue3中隐藏系统导航栏后windowTop获取不正确的问题,具体bug详见https://ask.dcloud.net.cn/question/141634
// 感谢litangyu!!https://github.com/SmileZXLee/uni-z-paging/issues/25
// #ifdef VUE3 && H5
const pageHeadNode = document.getElementsByTagName("uni-page-head");
if (!pageHeadNode.length) return 0;
// #endif
return this.systemInfo.windowTop || 0;
},
// 底部安全区域高度
safeAreaBottom() {
if (!this.systemInfo) return 0;
let safeAreaBottom = 0;
// #ifdef APP-PLUS
safeAreaBottom = this.systemInfo.safeAreaInsets.bottom || 0 ;
// #endif
// #ifndef APP-PLUS
safeAreaBottom = Math.max(this.cssSafeAreaInsetBottom, 0);
// #endif
return safeAreaBottom;
},
// 是否是比较老的webview,在一些老的webview中,需要进行一些特殊处理
isOldWebView() {
// #ifndef APP-NVUE || MP-KUAISHOU
try {
const systemInfos = u.getSystemInfoSync(true).system.split(' ');
const deviceType = systemInfos[0];
const version = parseInt(systemInfos[1]);
if ((deviceType === 'iOS' && version <= 10) || (deviceType === 'Android' && version <= 6)) {
return true;
}
} catch(e) {
return false;
}
// #endif
return false;
},
// 当前组件的$slots,兼容不同平台
zSlots() {
// #ifdef VUE2
// #ifdef MP-ALIPAY
return this.$slots;
// #endif
return this.$scopedSlots || this.$slots;
// #endif
return this.$slots;
},
},
beforeDestroy() {
this.isReadyDestroy = true;
},
// #ifdef VUE3
unmounted() {
this.isReadyDestroy = true;
},
// #endif
methods: {
// 更新fixed模式下z-paging的布局
updateFixedLayout() {
this.fixed && this.$nextTick(() => {
this.systemInfo = u.getSystemInfoSync();
})
},
// 获取节点尺寸
_getNodeClientRect(select, inDom = true, scrollOffset = false) {
if (this.isReadyDestroy) {
return Promise.resolve(false);
};
// nvue中获取节点信息
// #ifdef APP-NVUE
select = select.replace(/[.|#]/g, '');
const ref = this.$refs[select];
return new Promise((resolve, reject) => {
if (ref) {
weexDom.getComponentRect(ref, option => {
resolve(option && option.result ? [option.size] : false);
})
} else {
resolve(false);
}
});
return;
// #endif
// vue中获取节点信息
//#ifdef MP-ALIPAY
inDom = false;
//#endif
/*
inDom可能是true、false,也可能是具体的dom节点
如果inDom不为false,则使用uni.createSelectorQuery().in()进行查询,如果inDom为true,则in中的是this,否则in中的为具体的dom
如果inDom为false,则使用uni.createSelectorQuery()进行查询
*/
let res = !!inDom ? uni.createSelectorQuery().in(inDom === true ? this : inDom) : uni.createSelectorQuery();
scrollOffset ? res.select(select).scrollOffset() : res.select(select).boundingClientRect();
return new Promise((resolve, reject) => {
res.exec(data => {
resolve((data && data != '' && data != undefined && data.length) ? data : false);
});
});
},
// 获取slot="left"和slot="right"宽度并且更新布局
_updateLeftAndRightWidth(targetStyle, parentNodePrefix) {
this.$nextTick(() => {
let delayTime = 0;
// #ifdef MP-BAIDU
delayTime = 10;
// #endif
setTimeout(() => {
['left','right'].map(position => {
this._getNodeClientRect(`.${parentNodePrefix}-${position}`).then(res => {
this.$set(targetStyle, position, res ? res[0].width + 'px' : '0px');
});
})
}, delayTime)
})
},
// 通过获取css设置的底部安全区域占位view高度设置bottom距离(直接通过systemInfo在部分平台上无法获取到底部安全区域)
_getCssSafeAreaInsetBottom(success) {
this._getNodeClientRect('.zp-safe-area-inset-bottom').then(res => {
this.cssSafeAreaInsetBottom = res ? res[0].height : -1;
res && success && success();
});
},
// 同步获取系统信息,兼容不同平台(供z-paging-swiper使用)
_getSystemInfoSync(useCache = false) {
return u.getSystemInfoSync(useCache);
}
}
}
@@ -0,0 +1,736 @@
// [z-paging]数据处理模块
import u from '.././z-paging-utils'
import c from '.././z-paging-constant'
import Enum from '.././z-paging-enum'
import interceptor from '../z-paging-interceptor'
export default {
props: {
// 自定义初始的pageNo,默认为1
defaultPageNo: {
type: Number,
default: u.gc('defaultPageNo', 1),
observer: function(newVal) {
this.pageNo = newVal;
},
},
// 自定义pageSize,默认为10
defaultPageSize: {
type: Number,
default: u.gc('defaultPageSize', 10),
validator: (value) => {
if (value <= 0) u.consoleErr('default-page-size必须大于0');
return value > 0;
}
},
// 为保证数据一致,设置当前tab切换时的标识key,并在complete中传递相同key,若二者不一致,则complete将不会生效
dataKey: {
type: [Number, String, Object],
default: u.gc('dataKey', null),
},
// 使用缓存,若开启将自动缓存第一页的数据,默认为否。请注意,因考虑到切换tab时不同tab数据不同的情况,默认仅会缓存组件首次加载时第一次请求到的数据,后续的下拉刷新操作不会更新缓存。
useCache: {
type: Boolean,
default: u.gc('useCache', false)
},
// 使用缓存时缓存的key,用于区分不同列表的缓存数据,useCache为true时必须设置,否则缓存无效
cacheKey: {
type: String,
default: u.gc('cacheKey', null)
},
// 缓存模式,默认仅会缓存组件首次加载时第一次请求到的数据,可设置为always,即代表总是缓存,每次列表刷新(下拉刷新、调用reload等)都会更新缓存
cacheMode: {
type: String,
default: u.gc('cacheMode', Enum.CacheMode.Default)
},
// 自动注入的list名,可自动修改父view(包含ref="paging")中对应name的list值
autowireListName: {
type: String,
default: u.gc('autowireListName', '')
},
// 自动注入的query名,可自动调用父view(包含ref="paging")中的query方法
autowireQueryName: {
type: String,
default: u.gc('autowireQueryName', '')
},
// 获取分页数据Function,功能与@query类似。若设置了fetch则@query将不再触发
fetch: {
type: Function,
default: null
},
// fetch的附加参数,fetch配置后有效
fetchParams: {
type: Object,
default: u.gc('fetchParams', null)
},
// z-paging mounted后自动调用reload方法(mounted后自动调用接口),默认为是
auto: {
type: Boolean,
default: u.gc('auto', true)
},
// 用户下拉刷新时是否触发reload方法,默认为是
reloadWhenRefresh: {
type: Boolean,
default: u.gc('reloadWhenRefresh', true)
},
// reload时自动滚动到顶部,默认为是
autoScrollToTopWhenReload: {
type: Boolean,
default: u.gc('autoScrollToTopWhenReload', true)
},
// reload时立即自动清空原list,默认为是,若立即自动清空,则在reload之后、请求回调之前页面是空白的
autoCleanListWhenReload: {
type: Boolean,
default: u.gc('autoCleanListWhenReload', true)
},
// 列表刷新时自动显示下拉刷新view,默认为否
showRefresherWhenReload: {
type: Boolean,
default: u.gc('showRefresherWhenReload', false)
},
// 列表刷新时自动显示加载更多view,且为加载中状态,默认为否
showLoadingMoreWhenReload: {
type: Boolean,
default: u.gc('showLoadingMoreWhenReload', false)
},
// 组件created时立即触发reload(可解决一些情况下先看到页面再看到loading的问题),auto为true时有效。为否时将在mounted+nextTick后触发reload,默认为否
createdReload: {
type: Boolean,
default: u.gc('createdReload', false)
},
// 本地分页时上拉加载更多延迟时间,单位为毫秒,默认200毫秒
localPagingLoadingTime: {
type: [Number, String],
default: u.gc('localPagingLoadingTime', 200)
},
// 自动拼接complete中传过来的数组(使用聊天记录模式时无效)
concat: {
type: Boolean,
default: u.gc('concat', true)
},
// 请求失败是否触发reject,默认为是
callNetworkReject: {
type: Boolean,
default: u.gc('callNetworkReject', true)
},
// 父组件v-model所绑定的list的值
value: {
type: Array,
default: function() {
return [];
}
},
// #ifdef VUE3
modelValue: {
type: Array,
default: function() {
return [];
}
}
// #endif
},
data (){
return {
currentData: [],
totalData: [],
realTotalData: [],
totalLocalPagingList: [],
dataPromiseResultMap: {
reload: null,
complete: null,
localPaging: null
},
isSettingCacheList: false,
pageNo: 1,
currentRefreshPageSize: 0,
isLocalPaging: false,
isAddedData: false,
isTotalChangeFromAddData: false,
privateConcat: true,
myParentQuery: -1,
firstPageLoaded: false,
pagingLoaded: false,
loaded: false,
isUserReload: true,
fromEmptyViewReload: false,
queryFrom: '',
listRendering: false,
isHandlingRefreshToPage: false,
isFirstPageAndNoMore: false,
totalDataChangeThrow: true
}
},
computed: {
pageSize() {
return this.defaultPageSize;
},
finalConcat() {
return this.concat && this.privateConcat;
},
finalUseCache() {
if (this.useCache && !this.cacheKey) {
u.consoleErr('use-cache为true时,必须设置cache-key,否则缓存无效!');
}
return this.useCache && !!this.cacheKey;
},
finalCacheKey() {
return this.cacheKey ? `${c.cachePrefixKey}-${this.cacheKey}` : null;
},
isFirstPage() {
return this.pageNo === this.defaultPageNo;
}
},
watch: {
totalData(newVal, oldVal) {
this._totalDataChange(newVal, oldVal, this.totalDataChangeThrow);
this.totalDataChangeThrow = true;
},
currentData(newVal, oldVal) {
this._currentDataChange(newVal, oldVal);
},
useChatRecordMode(newVal, oldVal) {
if (newVal) {
this.nLoadingMoreFixedHeight = false;
}
},
value: {
handler(newVal) {
// 当v-model绑定的数据源被更改时,此时数据源改变不emit input事件,避免循环调用
if (newVal !== this.totalData) {
this.totalDataChangeThrow = false;
this.totalData = newVal;
}
},
immediate: true
},
// #ifdef VUE3
modelValue: {
handler(newVal) {
// 当v-model绑定的数据源被更改时,此时数据源改变不emit input事件,避免循环调用
if (newVal !== this.totalData) {
this.totalDataChangeThrow = false;
this.totalData = newVal;
}
},
immediate: true
}
// #endif
},
methods: {
// 请求结束(成功或者失败)调用此方法,将请求的结果传递给z-paging处理,第一个参数为请求结果数组,第二个参数为是否成功(默认为是)
complete(data, success = true) {
this.customNoMore = -1;
return this.addData(data, success);
},
//【保证数据一致】请求结束(成功或者失败)调用此方法,将请求的结果传递给z-paging处理,第一个参数为请求结果数组,第二个参数为dataKey,需与:data-key绑定的一致,第三个参数为是否成功(默认为是)
completeByKey(data, dataKey = null, success = true) {
if (dataKey !== null && this.dataKey !== null && dataKey !== this.dataKey) {
this.isFirstPage && this.endRefresh();
return new Promise(resolve => resolve());
}
this.customNoMore = -1;
return this.addData(data, success);
},
//【通过total判断是否有更多数据】请求结束(成功或者失败)调用此方法,将请求的结果传递给z-paging处理,第一个参数为请求结果数组,第二个参数为total(列表总数),第三个参数为是否成功(默认为是)
completeByTotal(data, total, success = true) {
if (total == 'undefined') {
this.customNoMore = -1;
} else {
const dataTypeRes = this._checkDataType(data, success, false);
data = dataTypeRes.data;
success = dataTypeRes.success;
if (total >= 0 && success) {
return new Promise((resolve, reject) => {
this.$nextTick(() => {
let nomore = false;
const realTotalDataCount = this.pageNo == this.defaultPageNo ? 0 : this.realTotalData.length;
const dataLength = this.privateConcat ? data.length : 0;
let exceedCount = realTotalDataCount + dataLength - total;
// 没有更多数据了
if (exceedCount >= 0) {
nomore = true;
// 仅截取total内部分的数据
exceedCount = this.defaultPageSize - exceedCount;
if (this.privateConcat && exceedCount > 0 && exceedCount < data.length) {
data = data.splice(0, exceedCount);
}
}
this.completeByNoMore(data, nomore, success).then(res => resolve(res)).catch(() => reject());
})
});
}
}
return this.addData(data, success);
},
//【自行判断是否有更多数据】请求结束(成功或者失败)调用此方法,将请求的结果传递给z-paging处理,第一个参数为请求结果数组,第二个参数为是否没有更多数据,第三个参数为是否成功(默认是是)
completeByNoMore(data, nomore, success = true) {
if (nomore != 'undefined') {
this.customNoMore = nomore == true ? 1 : 0;
}
return this.addData(data, success);
},
// 请求结束且请求失败时调用,支持传入请求失败原因
completeByError(errorMsg) {
this.customerEmptyViewErrorText = errorMsg;
return this.complete(false);
},
// 与上方complete方法功能一致,新版本中设置服务端回调数组请使用complete方法
addData(data, success = true) {
if (!this.fromCompleteEmit) {
this.disabledCompleteEmit = true;
this.fromCompleteEmit = false;
}
const currentTimeStamp = u.getTime();
const disTime = currentTimeStamp - this.requestTimeStamp;
let minDelay = this.minDelay;
if (this.isFirstPage && this.finalShowRefresherWhenReload) {
minDelay = Math.max(400, minDelay);
}
const addDataDalay = (this.requestTimeStamp > 0 && disTime < minDelay) ? minDelay - disTime : 0;
this.$nextTick(() => {
u.delay(() => {
this._addData(data, success, false);
}, this.delay > 0 ? this.delay : addDataDalay)
})
return new Promise((resolve, reject) => {
this.dataPromiseResultMap.complete = { resolve, reject };
});
},
// 从顶部添加数据,不会影响分页的pageNo和pageSize
addDataFromTop(data, toTop = true, toTopWithAnimate = true) {
// 数据是否拼接到顶部,如果是聊天记录模式并且列表没有倒置,则应该拼接在底部
let addFromTop = !this.isChatRecordModeAndNotInversion;
data = Object.prototype.toString.call(data) !== '[object Array]' ? [data] : (addFromTop ? data.reverse() : data);
// #ifndef APP-NVUE
this.finalUseVirtualList && this._setCellIndex(data, 'top')
// #endif
this.totalData = addFromTop ? [...data, ...this.totalData] : [...this.totalData, ...data];
if (toTop) {
u.delay(() => this.useChatRecordMode ? this.scrollToBottom(toTopWithAnimate) : this.scrollToTop(toTopWithAnimate));
}
},
// 重新设置列表数据,调用此方法不会影响pageNo和pageSize,也不会触发请求。适用场景:当需要删除列表中某一项时,将删除对应项后的数组通过此方法传递给z-paging。(当出现类似的需要修改列表数组的场景时,请使用此方法,请勿直接修改page中:list.sync绑定的数组)
resetTotalData(data) {
this.isTotalChangeFromAddData = true;
data = Object.prototype.toString.call(data) !== '[object Array]' ? [data] : data;
this.totalData = data;
},
// 设置本地分页数据,请求结束(成功或者失败)调用此方法,将请求的结果传递给z-paging作分页处理(若调用了此方法,则上拉加载更多时内部会自动分页,不会触发@query所绑定的事件)
setLocalPaging(data, success = true) {
this.isLocalPaging = true;
this.$nextTick(() => {
this._addData(data, success, true);
})
return new Promise((resolve, reject) => {
this.dataPromiseResultMap.localPaging = { resolve, reject };
});
},
// 重新加载分页数据,pageNo会恢复为默认值,相当于下拉刷新的效果(animate为true时会展示下拉刷新动画,默认为false)
reload(animate = this.showRefresherWhenReload) {
if (animate) {
this.privateShowRefresherWhenReload = animate;
this.isUserPullDown = true;
}
if (!this.showLoadingMoreWhenReload) {
this.listRendering = true;
}
this.$nextTick(() => {
this._preReload(animate, false);
})
return new Promise((resolve, reject) => {
this.dataPromiseResultMap.reload = { resolve, reject };
});
},
// 刷新列表数据,pageNo和pageSize不会重置,列表数据会重新从服务端获取。必须保证@query绑定的方法中的pageNo和pageSize和传给服务端的一致
refresh() {
return this._handleRefreshWithDisPageNo(this.pageNo - this.defaultPageNo + 1);
},
// 刷新列表数据至指定页,例如pageNo=5时则代表刷新列表至第5页,此时pageNo会变为5,列表会展示前5页的数据。必须保证@query绑定的方法中的pageNo和pageSize和传给服务端的一致
refreshToPage(pageNo) {
this.isHandlingRefreshToPage = true;
return this._handleRefreshWithDisPageNo(pageNo + this.defaultPageNo - 1);
},
// 手动更新列表缓存数据,将自动截取v-model绑定的list中的前pageSize条覆盖缓存,请确保在list数据更新到预期结果后再调用此方法
updateCache() {
if (this.finalUseCache && this.totalData.length) {
this._saveLocalCache(this.totalData.slice(0, Math.min(this.totalData.length, this.pageSize)));
}
},
// 清空分页数据
clean() {
this._reload(true);
this._addData([], true, false);
},
// 清空分页数据
clear() {
this.clean();
},
// reload之前的一些处理
_preReload(animate = this.showRefresherWhenReload, isFromMounted = true, retryCount = 0) {
const showRefresher = this.finalRefresherEnabled && this.useCustomRefresher;
// #ifndef APP-NVUE
// 如果获取slot="refresher"高度失败,则不触发reload,直到获取slot="refresher"高度成功
if (this.customRefresherHeight === -1 && showRefresher) {
u.delay(() => {
retryCount ++;
// 如果重试次数是10的倍数(也就是每500毫秒),尝试重新获取一下slot="refresher"高度
// 此举是为了解决在某些特殊情况下,z-paging组件mounted了,但是未展示在用户面前,(比如在tabbar页面中,未切换到对应tabbar但是通过代码让z-paging展示了,此时控制台会报Error: Not FoundPage,因为这时候去获取dom节点信息获取不到)
// 当用户在某个时刻让此z-paging展示在面前时,即可顺利获取到slot="refresher"高度,递归停止
if (retryCount % 10 === 0) {
this._updateCustomRefresherHeight();
}
this._preReload(animate, isFromMounted, retryCount);
}, c.delayTime / 2);
return;
}
// #endif
this.isUserReload = true;
this.loadingType = Enum.LoadingType.Refresher;
if (animate) {
this.privateShowRefresherWhenReload = animate;
// #ifndef APP-NVUE
if (this.useCustomRefresher) {
this._doRefresherRefreshAnimate();
} else {
this.refresherTriggered = true;
}
// #endif
// #ifdef APP-NVUE
this.refresherStatus = Enum.Refresher.Loading;
this.refresherRevealStackCount ++;
u.delay(() => {
this._getNodeClientRect('zp-n-refresh-container', false).then((node) => {
if (node) {
let nodeHeight = node[0].height;
this.nShowRefresherReveal = true;
this.nShowRefresherRevealHeight = nodeHeight;
u.delay(() => {
this._nDoRefresherEndAnimation(0, -nodeHeight, false, false);
u.delay(() => {
this._nDoRefresherEndAnimation(nodeHeight, 0);
}, 10)
}, 10)
}
this._reload(false, isFromMounted);
this._doRefresherLoad(false);
});
}, this.pagingLoaded ? 10 : 100)
return;
// #endif
} else {
this._refresherEnd(false, false, false, false);
}
this._reload(false, isFromMounted);
},
// 重新加载分页数据
_reload(isClean = false, isFromMounted = false, isUserPullDown = false) {
this.isAddedData = false;
this.insideOfPaging = -1;
this.cacheScrollNodeHeight = -1;
this.pageNo = this.defaultPageNo;
this._cleanRefresherEndTimeout();
!this.privateShowRefresherWhenReload && !isClean && this._startLoading(true);
this.firstPageLoaded = true;
this.isTotalChangeFromAddData = false;
if (!this.isSettingCacheList) {
this.totalData = [];
}
if (!isClean) {
this._emitQuery(this.pageNo, this.defaultPageSize, isUserPullDown ? Enum.QueryFrom.UserPullDown : Enum.QueryFrom.Reload);
let delay = 0;
// #ifdef MP-TOUTIAO
delay = 5;
// #endif
u.delay(this._callMyParentQuery, delay);
if (!isFromMounted && this.autoScrollToTopWhenReload) {
let checkedNRefresherLoading = true;
// #ifdef APP-NVUE
checkedNRefresherLoading = !this.nRefresherLoading;
// #endif
checkedNRefresherLoading && this._scrollToTop(false);
}
}
// #ifdef APP-NVUE
this.$nextTick(() => {
this.nShowBottom = this.realTotalData.length > 0;
})
// #endif
},
// 处理服务端返回的数组
_addData(data, success, isLocal) {
this.isAddedData = true;
this.fromEmptyViewReload = false;
this.isTotalChangeFromAddData = true;
this.refresherTriggered = false;
this._endSystemLoadingAndRefresh();
const tempIsUserPullDown = this.isUserPullDown;
if (this.showRefresherUpdateTime && this.isFirstPage) {
u.setRefesrherTime(u.getTime(), this.refresherUpdateTimeKey);
this.$refs.refresh && this.$refs.refresh.updateTime();
}
if (!isLocal && tempIsUserPullDown && this.isFirstPage) {
this.isUserPullDown = false;
}
this.listRendering = true;
this.$nextTick(() => {
u.delay(() => this.listRendering = false);
})
let dataTypeRes = this._checkDataType(data, success, isLocal);
data = dataTypeRes.data;
success = dataTypeRes.success;
let delayTime = c.delayTime;
if (this.useChatRecordMode) delayTime = 0;
this.loadingForNow = false;
u.delay(() => {
this.pagingLoaded = true;
this.$nextTick(()=>{
!isLocal && this._refresherEnd(delayTime > 0, true, tempIsUserPullDown);
})
})
if (this.isFirstPage) {
this.isLoadFailed = !success;
this.$emit('isLoadFailedChange', this.isLoadFailed);
if (this.finalUseCache && success && (this.cacheMode === Enum.CacheMode.Always ? true : this.isSettingCacheList)) {
this._saveLocalCache(data);
}
}
this.isSettingCacheList = false;
if (success) {
if (!(this.privateConcat === false && !this.isHandlingRefreshToPage && this.loadingStatus === Enum.More.NoMore)) {
this.loadingStatus = Enum.More.Default;
}
if (isLocal) {
// 如果当前是本地分页,则必然是由setLocalPaging方法触发,此时直接本地加载第一页数据即可。后续本地分页加载更多方法由滚动到底部加载更多事件处理
this.totalLocalPagingList = data;
const localPageNo = this.defaultPageNo;
const localPageSize = this.queryFrom !== Enum.QueryFrom.Refresh ? this.defaultPageSize : this.currentRefreshPageSize;
this._localPagingQueryList(localPageNo, localPageSize, 0, res => {
u.delay(() => {
this.completeByTotal(res, this.totalLocalPagingList.length);;
}, 0)
})
} else {
// 如果当前不是本地分页,则按照正常分页逻辑进行数据处理&emit数据
let dataChangeDelayTime = 0;
// #ifdef APP-NVUE
if (this.privateShowRefresherWhenReload && this.finalNvueListIs === 'waterfall') {
dataChangeDelayTime = 150;
}
// #endif
u.delay(() => {
this._currentDataChange(data, this.currentData);
this._callDataPromise(true, this.totalData);
}, dataChangeDelayTime)
}
if (this.isHandlingRefreshToPage) {
this.isHandlingRefreshToPage = false;
this.pageNo = this.defaultPageNo + Math.ceil(data.length / this.pageSize) - 1;
if (data.length % this.pageSize !== 0) {
this.customNoMore = 1;
}
}
} else {
this._currentDataChange(data, this.currentData);
this._callDataPromise(false);
this.loadingStatus = Enum.More.Fail;
this.isHandlingRefreshToPage = false;
if (this.loadingType === Enum.LoadingType.LoadMore) {
this.pageNo --;
}
}
},
// 所有数据改变时调用
_totalDataChange(newVal, oldVal, eventThrow=true) {
if ((!this.isUserReload || !this.autoCleanListWhenReload) && this.firstPageLoaded && !newVal.length && oldVal.length) {
return;
}
this._doCheckScrollViewShouldFullHeight(newVal);
if(!this.realTotalData.length && !newVal.length){
eventThrow = false;
}
this.realTotalData = newVal;
// emit列表更新事件
if (eventThrow) {
this.$emit('input', newVal);
// #ifdef VUE3
this.$emit('update:modelValue', newVal);
// #endif
this.$emit('update:list', newVal);
this.$emit('listChange', newVal);
this._callMyParentList(newVal);
}
this.firstPageLoaded = false;
this.isTotalChangeFromAddData = false;
this.$nextTick(() => {
u.delay(()=>{
// emit z-paging内容区域高度改变事件
this._getNodeClientRect('.zp-paging-container-content').then(res => {
res && this.$emit('contentHeightChanged', res[0].height);
});
}, c.delayTime * (this.isIos ? 1 : 3))
// #ifdef APP-NVUE
// 在nvue中延时600毫秒展示底部加载更多,避免底部加载更多太早加载闪一下的问题
u.delay(() => {
this.nShowBottom = true;
}, c.delayTime * 6, 'nShowBottomDelay');
// #endif
})
},
// 当前数据改变时调用
_currentDataChange(newVal, oldVal) {
newVal = [...newVal];
// #ifndef APP-NVUE
this.finalUseVirtualList && this._setCellIndex(newVal, 'bottom');
// #endif
if (this.isFirstPage && this.finalConcat) {
this.totalData = [];
}
// customNoMore-1代表交由z-paging自行判断;1代表没有更多了;0代表还有更多数据
if (this.customNoMore !== -1) {
// 如果customNoMore等于1 或者 customNoMore不是0并且新增数组长度为0(也就是不是明确的还有更多数据并且新增的数组长度为0),则没有更多数据了
if (this.customNoMore === 1 || (this.customNoMore !== 0 && !newVal.length)) {
this.loadingStatus = Enum.More.NoMore;
}
} else {
// 如果新增的数据数组长度为0 或者 新增的数组长度小于默认的pageSize,则没有更多数据了
if (!newVal.length || (newVal.length && newVal.length < this.defaultPageSize)) {
this.loadingStatus = Enum.More.NoMore;
}
}
if (!this.totalData.length) {
// #ifdef APP-NVUE
// 如果在聊天记录模式+nvue中,并且数据不满一页时需要将列表倒序,因为此时没有将列表旋转180度,数组中第0条数据应当在最底下显示
if (this.useChatRecordMode && this.finalConcat && this.isFirstPage && this.loadingStatus === Enum.More.NoMore) {
newVal.reverse();
}
// #endif
this.totalData = newVal;
} else {
if (this.finalConcat) {
const currentScrollTop = this.oldScrollTop;
this.totalData = [...this.totalData, ...newVal];
// 此处是为了解决在微信小程序中,在某些情况下滚动到底部加载更多后滚动位置直接变为最底部的问题,因此需要通过代码强制滚动回加载更多前的位置
// #ifdef MP-WEIXIN
if (!this.isIos && !this.refresherOnly && !this.usePageScroll && newVal.length) {
this.loadingMoreTimeStamp = u.getTime();
this.$nextTick(() => {
this.scrollToY(currentScrollTop);
})
}
// #endif
} else {
this.totalData = newVal;
}
}
this.privateConcat = true;
},
// 根据pageNo处理refresh操作
_handleRefreshWithDisPageNo(pageNo) {
if (!this.isHandlingRefreshToPage && !this.realTotalData.length) return this.reload();
if (pageNo >= 1) {
this.loading = true;
this.privateConcat = false;
const totalPageSize = pageNo * this.pageSize;
this.currentRefreshPageSize = totalPageSize;
// 如果调用refresh时是本地分页,则在组件内部自己处理分页逻辑,不emit query相关事件
if (this.isLocalPaging && this.isHandlingRefreshToPage) {
this._localPagingQueryList(this.defaultPageNo, totalPageSize, 0, res => {
this.complete(res);
})
} else {
// emit query相关事件
this._emitQuery(this.defaultPageNo, totalPageSize, Enum.QueryFrom.Refresh);
this._callMyParentQuery(this.defaultPageNo, totalPageSize);
}
}
return new Promise((resolve, reject) => {
this.dataPromiseResultMap.reload = { resolve, reject };
});
},
// 本地分页请求
_localPagingQueryList(pageNo, pageSize, localPagingLoadingTime, callback) {
pageNo = Math.max(1, pageNo);
pageSize = Math.max(1, pageSize);
const totalPagingList = [...this.totalLocalPagingList];
const pageNoIndex = (pageNo - 1) * pageSize;
const finalPageNoIndex = Math.min(totalPagingList.length, pageNoIndex + pageSize);
const resultPagingList = totalPagingList.splice(pageNoIndex, finalPageNoIndex - pageNoIndex);
u.delay(() => callback(resultPagingList), localPagingLoadingTime)
},
// 存储列表缓存数据
_saveLocalCache(data) {
uni.setStorageSync(this.finalCacheKey, data);
},
// 通过缓存数据填充列表数据
_setListByLocalCache() {
this.totalData = uni.getStorageSync(this.finalCacheKey) || [];
this.isSettingCacheList = true;
},
// 修改父view的list
_callMyParentList(newVal) {
if (this.autowireListName.length) {
const myParent = u.getParent(this.$parent);
if (myParent && myParent[this.autowireListName]) {
myParent[this.autowireListName] = newVal;
}
}
},
// 调用父view的query
_callMyParentQuery(customPageNo = 0, customPageSize = 0) {
if (this.autowireQueryName) {
if (this.myParentQuery === -1) {
const myParent = u.getParent(this.$parent);
if (myParent && myParent[this.autowireQueryName]) {
this.myParentQuery = myParent[this.autowireQueryName];
}
}
if (this.myParentQuery !== -1) {
customPageSize > 0 ? this.myParentQuery(customPageNo, customPageSize) : this.myParentQuery(this.pageNo, this.defaultPageSize);
}
}
},
// emit query事件
_emitQuery(pageNo, pageSize, from){
this.queryFrom = from;
this.requestTimeStamp = u.getTime();
const [lastItem] = this.realTotalData.slice(-1);
if (this.fetch) {
const fetchParams = interceptor._handleFetchParams({pageNo, pageSize, from, lastItem: lastItem || null}, this.fetchParams);
const fetchResult = this.fetch(fetchParams);
if (!interceptor._handleFetchResult(fetchResult, this, fetchParams)) {
u.isPromise(fetchResult) ? fetchResult.then(res => {
this.complete(res);
}).catch(err => {
this.complete(false);
}) : this.complete(fetchResult)
}
} else {
this.$emit('query', ...interceptor._handleQuery(pageNo, pageSize, from, lastItem || null));
}
},
// 触发数据改变promise
_callDataPromise(success, totalList) {
for (const key in this.dataPromiseResultMap) {
const obj = this.dataPromiseResultMap[key];
if (!obj) continue;
success ? obj.resolve({ totalList, noMore: this.loadingStatus === Enum.More.NoMore }) : this.callNetworkReject && obj.reject(`z-paging-${key}-error`);
}
},
// 检查complete data的类型
_checkDataType(data, success, isLocal) {
const dataType = Object.prototype.toString.call(data);
if (dataType === '[object Boolean]') {
success = data;
data = [];
} else if (dataType !== '[object Array]') {
data = [];
if (dataType !== '[object Undefined]' && dataType !== '[object Null]') {
u.consoleErr(`${isLocal ? 'setLocalPaging' : 'complete'}参数类型不正确,第一个参数类型必须为Array!`);
}
}
return { data, success };
},
}
}
@@ -0,0 +1,144 @@
// [z-paging]空数据图view模块
import u from '.././z-paging-utils'
export default {
props: {
// 是否强制隐藏空数据图,默认为否
hideEmptyView: {
type: Boolean,
default: u.gc('hideEmptyView', false)
},
// 空数据图描述文字,默认为“没有数据哦~”
emptyViewText: {
type: [String, Object],
default: u.gc('emptyViewText', null)
},
// 是否显示空数据图重新加载按钮(无数据时),默认为否
showEmptyViewReload: {
type: Boolean,
default: u.gc('showEmptyViewReload', false)
},
// 加载失败时是否显示空数据图重新加载按钮,默认为是
showEmptyViewReloadWhenError: {
type: Boolean,
default: u.gc('showEmptyViewReloadWhenError', true)
},
// 空数据图点击重新加载文字,默认为“重新加载”
emptyViewReloadText: {
type: [String, Object],
default: u.gc('emptyViewReloadText', null)
},
// 空数据图图片,默认使用z-paging内置的图片
emptyViewImg: {
type: String,
default: u.gc('emptyViewImg', '')
},
// 空数据图“加载失败”描述文字,默认为“很抱歉,加载失败”
emptyViewErrorText: {
type: [String, Object],
default: u.gc('emptyViewErrorText', null)
},
// 空数据图“加载失败”图片,默认使用z-paging内置的图片
emptyViewErrorImg: {
type: String,
default: u.gc('emptyViewErrorImg', '')
},
// 空数据图样式
emptyViewStyle: {
type: Object,
default: u.gc('emptyViewStyle', {})
},
// 空数据图容器样式
emptyViewSuperStyle: {
type: Object,
default: u.gc('emptyViewSuperStyle', {})
},
// 空数据图img样式
emptyViewImgStyle: {
type: Object,
default: u.gc('emptyViewImgStyle', {})
},
// 空数据图描述文字样式
emptyViewTitleStyle: {
type: Object,
default: u.gc('emptyViewTitleStyle', {})
},
// 空数据图重新加载按钮样式
emptyViewReloadStyle: {
type: Object,
default: u.gc('emptyViewReloadStyle', {})
},
// 空数据图片是否铺满z-paging,默认为否,即填充满z-paging内列表(滚动区域)部分。若设置为否,则为填铺满整个z-paging
emptyViewFixed: {
type: Boolean,
default: u.gc('emptyViewFixed', false)
},
// 空数据图片是否垂直居中,默认为是,若设置为否即为从空数据容器顶部开始显示。emptyViewFixed为false时有效
emptyViewCenter: {
type: Boolean,
default: u.gc('emptyViewCenter', true)
},
// 加载中时是否自动隐藏空数据图,默认为是
autoHideEmptyViewWhenLoading: {
type: Boolean,
default: u.gc('autoHideEmptyViewWhenLoading', true)
},
// 用户下拉列表触发下拉刷新加载中时是否自动隐藏空数据图,默认为是
autoHideEmptyViewWhenPull: {
type: Boolean,
default: u.gc('autoHideEmptyViewWhenPull', true)
},
// 空数据view的z-index,默认为9
emptyViewZIndex: {
type: Number,
default: u.gc('emptyViewZIndex', 9)
},
},
data() {
return {
customerEmptyViewErrorText: ''
}
},
computed: {
finalEmptyViewImg() {
return this.isLoadFailed ? this.emptyViewErrorImg : this.emptyViewImg;
},
finalShowEmptyViewReload() {
return this.isLoadFailed ? this.showEmptyViewReloadWhenError : this.showEmptyViewReload;
},
// 是否展示空数据图
showEmpty() {
if (this.refresherOnly || this.hideEmptyView || this.realTotalData.length) return false;
if (this.autoHideEmptyViewWhenLoading) {
if (this.isAddedData && !this.firstPageLoaded && !this.loading) return true;
} else {
return true;
}
return !this.autoHideEmptyViewWhenPull && !this.isUserReload;
},
},
methods: {
// 点击了空数据view重新加载按钮
_emptyViewReload() {
let callbacked = false;
this.$emit('emptyViewReload', reload => {
if (reload === undefined || reload === true) {
this.fromEmptyViewReload = true;
this.reload().catch(() => {});
}
callbacked = true;
});
// 如果用户没有禁止默认的点击重新加载刷新列表事件,则触发列表重新刷新
this.$nextTick(() => {
if (!callbacked) {
this.fromEmptyViewReload = true;
this.reload().catch(() => {});
}
})
},
// 点击了空数据view
_emptyViewClick() {
this.$emit('emptyViewClick');
},
}
}
@@ -0,0 +1,113 @@
// [z-paging]i18n模块
import { initVueI18n } from '@dcloudio/uni-i18n'
import messages from '../../i18n/index.js'
const { t } = initVueI18n(messages)
import u from '.././z-paging-utils'
import c from '.././z-paging-constant'
import interceptor from '../z-paging-interceptor'
export default {
computed: {
finalLanguage() {
try {
const local = uni.getLocale();
const language = this.systemInfo.appLanguage;
return local === 'auto' ? interceptor._handleLanguage2Local(language, this._language2Local(language)) : local;
} catch (e) {
// 如果获取系统本地语言异常,则默认返回中文,uni.getLocale在部分低版本HX或者cli中可能报找不到的问题
return 'zh-Hans';
}
},
// 最终的下拉刷新默认状态的文字
finalRefresherDefaultText() {
return this._getI18nText('zp.refresher.default', this.refresherDefaultText);
},
// 最终的下拉刷新下拉中的文字
finalRefresherPullingText() {
return this._getI18nText('zp.refresher.pulling', this.refresherPullingText);
},
// 最终的下拉刷新中文字
finalRefresherRefreshingText() {
return this._getI18nText('zp.refresher.refreshing', this.refresherRefreshingText);
},
// 最终的下拉刷新完成文字
finalRefresherCompleteText() {
return this._getI18nText('zp.refresher.complete', this.refresherCompleteText);
},
// 最终的下拉刷新上次更新时间文字
finalRefresherUpdateTimeTextMap() {
return {
title: t('zp.refresherUpdateTime.title'),
none: t('zp.refresherUpdateTime.none'),
today: t('zp.refresherUpdateTime.today'),
yesterday: t('zp.refresherUpdateTime.yesterday')
};
},
// 最终的继续下拉进入二楼文字
finalRefresherGoF2Text() {
return this._getI18nText('zp.refresher.f2', this.refresherGoF2Text);
},
// 最终的底部加载更多默认状态文字
finalLoadingMoreDefaultText() {
return this._getI18nText('zp.loadingMore.default', this.loadingMoreDefaultText);
},
// 最终的底部加载更多加载中文字
finalLoadingMoreLoadingText() {
return this._getI18nText('zp.loadingMore.loading', this.loadingMoreLoadingText);
},
// 最终的底部加载更多没有更多数据文字
finalLoadingMoreNoMoreText() {
return this._getI18nText('zp.loadingMore.noMore', this.loadingMoreNoMoreText);
},
// 最终的底部加载更多加载失败文字
finalLoadingMoreFailText() {
return this._getI18nText('zp.loadingMore.fail', this.loadingMoreFailText);
},
// 最终的空数据图title
finalEmptyViewText() {
return this.isLoadFailed ? this.finalEmptyViewErrorText : this._getI18nText('zp.emptyView.title', this.emptyViewText);
},
// 最终的空数据图reload title
finalEmptyViewReloadText() {
return this._getI18nText('zp.emptyView.reload', this.emptyViewReloadText);
},
// 最终的空数据图加载失败文字
finalEmptyViewErrorText() {
return this.customerEmptyViewErrorText || this._getI18nText('zp.emptyView.error', this.emptyViewErrorText);
},
// 最终的系统loading title
finalSystemLoadingText() {
return this._getI18nText('zp.systemLoading.title', this.systemLoadingText);
},
},
methods: {
// 获取当前z-paging的语言
getLanguage() {
return this.finalLanguage;
},
// 获取国际化转换后的文本
_getI18nText(key, value) {
const dataType = Object.prototype.toString.call(value);
if (dataType === '[object Object]') {
const nextValue = value[this.finalLanguage];
if (nextValue) return nextValue;
} else if (dataType === '[object String]') {
return value;
}
return t(key);
},
// 系统language转i18n local
_language2Local(language) {
const formatedLanguage = language.toLowerCase().replace(new RegExp('_', ''), '-');
if (formatedLanguage.indexOf('zh') !== -1) {
if (formatedLanguage === 'zh' || formatedLanguage === 'zh-cn' || formatedLanguage.indexOf('zh-hans') !== -1) {
return 'zh-Hans';
}
return 'zh-Hant';
}
if (formatedLanguage.indexOf('en') !== -1) return 'en';
return language;
}
}
}
@@ -0,0 +1,374 @@
// [z-paging]滚动到底部加载更多模块
import u from '.././z-paging-utils'
import Enum from '.././z-paging-enum'
export default {
props: {
// 自定义底部加载更多样式
loadingMoreCustomStyle: {
type: Object,
default: u.gc('loadingMoreCustomStyle', {})
},
// 自定义底部加载更多文字样式
loadingMoreTitleCustomStyle: {
type: Object,
default: u.gc('loadingMoreTitleCustomStyle', {})
},
// 自定义底部加载更多加载中动画样式
loadingMoreLoadingIconCustomStyle: {
type: Object,
default: u.gc('loadingMoreLoadingIconCustomStyle', {})
},
// 自定义底部加载更多加载中动画图标类型,可选flower或circle,默认为flower
loadingMoreLoadingIconType: {
type: String,
default: u.gc('loadingMoreLoadingIconType', 'flower')
},
// 自定义底部加载更多加载中动画图标图片
loadingMoreLoadingIconCustomImage: {
type: String,
default: u.gc('loadingMoreLoadingIconCustomImage', '')
},
// 底部加载更多加载中view是否展示旋转动画,默认为是
loadingMoreLoadingAnimated: {
type: Boolean,
default: u.gc('loadingMoreLoadingAnimated', true)
},
// 是否启用加载更多数据(含滑动到底部加载更多数据和点击加载更多数据),默认为是
loadingMoreEnabled: {
type: Boolean,
default: u.gc('loadingMoreEnabled', true)
},
// 是否启用滑动到底部加载更多数据,默认为是
toBottomLoadingMoreEnabled: {
type: Boolean,
default: u.gc('toBottomLoadingMoreEnabled', true)
},
// 滑动到底部状态为默认状态时,以加载中的状态展示,默认为否。若设置为是,可避免滚动到底部看到默认状态然后立刻变为加载中状态的问题,但分页数量未超过一屏时,不会显示【点击加载更多】
loadingMoreDefaultAsLoading: {
type: Boolean,
default: u.gc('loadingMoreDefaultAsLoading', false)
},
// 滑动到底部"默认"文字,默认为【点击加载更多】
loadingMoreDefaultText: {
type: [String, Object],
default: u.gc('loadingMoreDefaultText', null)
},
// 滑动到底部"加载中"文字,默认为【正在加载...】
loadingMoreLoadingText: {
type: [String, Object],
default: u.gc('loadingMoreLoadingText', null)
},
// 滑动到底部"没有更多"文字,默认为【没有更多了】
loadingMoreNoMoreText: {
type: [String, Object],
default: u.gc('loadingMoreNoMoreText', null)
},
// 滑动到底部"加载失败"文字,默认为【加载失败,点击重新加载】
loadingMoreFailText: {
type: [String, Object],
default: u.gc('loadingMoreFailText', null)
},
// 当没有更多数据且分页内容未超出z-paging时是否隐藏没有更多数据的view,默认为否
hideNoMoreInside: {
type: Boolean,
default: u.gc('hideNoMoreInside', false)
},
// 当没有更多数据且分页数组长度少于这个值时,隐藏没有更多数据的view,默认为0,代表不限制。
hideNoMoreByLimit: {
type: Number,
default: u.gc('hideNoMoreByLimit', 0)
},
// 是否显示默认的加载更多text,默认为是
showDefaultLoadingMoreText: {
type: Boolean,
default: u.gc('showDefaultLoadingMoreText', true)
},
// 是否显示没有更多数据的view
showLoadingMoreNoMoreView: {
type: Boolean,
default: u.gc('showLoadingMoreNoMoreView', true)
},
// 是否显示没有更多数据的分割线,默认为是
showLoadingMoreNoMoreLine: {
type: Boolean,
default: u.gc('showLoadingMoreNoMoreLine', true)
},
// 自定义底部没有更多数据的分割线样式
loadingMoreNoMoreLineCustomStyle: {
type: Object,
default: u.gc('loadingMoreNoMoreLineCustomStyle', {})
},
// 当分页未满一屏时,是否自动加载更多,默认为否(nvue无效)
insideMore: {
type: Boolean,
default: u.gc('insideMore', false)
},
// 距底部/右边多远时(单位px),触发 scrolltolower 事件,默认为100rpx
lowerThreshold: {
type: [Number, String],
default: u.gc('lowerThreshold', '100rpx')
},
},
data() {
return {
M: Enum.More,
// 底部加载更多状态
loadingStatus: Enum.More.Default,
// 在渲染之后的底部加载更多状态
loadingStatusAfterRender: Enum.More.Default,
// 底部加载更多时间戳
loadingMoreTimeStamp: 0,
// 底部加载更多slot
loadingMoreDefaultSlot: null,
// 是否展示底部加载更多
showLoadingMore: false,
// 是否是开发者自定义的加载更多,-1代表交由z-paging自行判断;1代表没有更多了;0代表还有更多数据
customNoMore: -1,
}
},
computed: {
// 底部加载更多配置
zLoadMoreConfig() {
return {
status: this.loadingStatusAfterRender,
defaultAsLoading: this.loadingMoreDefaultAsLoading || (this.useChatRecordMode && this.chatLoadingMoreDefaultAsLoading),
defaultThemeStyle: this.finalLoadingMoreThemeStyle,
customStyle: this.loadingMoreCustomStyle,
titleCustomStyle: this.loadingMoreTitleCustomStyle,
iconCustomStyle: this.loadingMoreLoadingIconCustomStyle,
loadingIconType: this.loadingMoreLoadingIconType,
loadingIconCustomImage: this.loadingMoreLoadingIconCustomImage,
loadingAnimated: this.loadingMoreLoadingAnimated,
showNoMoreLine: this.showLoadingMoreNoMoreLine,
noMoreLineCustomStyle: this.loadingMoreNoMoreLineCustomStyle,
defaultText: this.finalLoadingMoreDefaultText,
loadingText: this.finalLoadingMoreLoadingText,
noMoreText: this.finalLoadingMoreNoMoreText,
failText: this.finalLoadingMoreFailText,
hideContent: !this.loadingMoreDefaultAsLoading && this.listRendering,
unit: this.unit,
isChat: this.useChatRecordMode,
chatDefaultAsLoading: this.chatLoadingMoreDefaultAsLoading
};
},
// 最终的底部加载更多主题
finalLoadingMoreThemeStyle() {
return this.loadingMoreThemeStyle.length ? this.loadingMoreThemeStyle : this.defaultThemeStyle;
},
// 最终的底部加载更多触发阈值
finalLowerThreshold() {
return u.convertToPx(this.lowerThreshold);
},
// 是否显示默认状态下的底部加载更多
showLoadingMoreDefault() {
return this._showLoadingMore('Default');
},
// 是否显示加载中状态下的底部加载更多
showLoadingMoreLoading() {
return this._showLoadingMore('Loading');
},
// 是否显示没有更多了状态下的底部加载更多
showLoadingMoreNoMore() {
return this._showLoadingMore('NoMore');
},
// 是否显示加载失败状态下的底部加载更多
showLoadingMoreFail() {
return this._showLoadingMore('Fail');
},
// 是否显示自定义状态下的底部加载更多
showLoadingMoreCustom() {
return this._showLoadingMore('Custom');
},
// 底部加载更多固定高度
loadingMoreFixedHeight() {
return u.addUnit('80rpx', this.unit);
},
},
methods: {
// 页面滚动到底部时通知z-paging进行进一步处理
pageReachBottom() {
!this.useChatRecordMode && this.toBottomLoadingMoreEnabled && this._onLoadingMore('toBottom');
},
// 手动触发上拉加载更多(非必须,可依据具体需求使用)
doLoadMore(type) {
this._onLoadingMore(type);
},
// 通过@scroll事件检测是否滚动到了底部(顺带检测下是否滚动到了顶部)
_checkScrolledToBottom(scrollDiff, checked = false) {
// 如果当前scroll-view高度未获取,则获取其高度
if (this.cacheScrollNodeHeight === -1) {
// 获取当前scroll-view高度
this._getNodeClientRect('.zp-scroll-view').then((res) => {
if (res) {
const scrollNodeHeight = res[0].height;
// 缓存当前scroll-view高度,如果获取过了不再获取
this.cacheScrollNodeHeight = scrollNodeHeight;
// // scrollDiff - this.cacheScrollNodeHeight = 当前滚动区域的顶部与内容底部的距离 - scroll-view高度 = 当前滚动区域的底部与内容底部的距离(也就是最终的与底部的距离)
if (scrollDiff - scrollNodeHeight <= this.finalLowerThreshold) {
// 如果与底部的距离小于阈值,则判断为滚动到了底部,触发滚动到底部事件
this._onLoadingMore('toBottom');
}
}
});
} else {
// scrollDiff - this.cacheScrollNodeHeight = 当前滚动区域的顶部与内容底部的距离 - scroll-view高度 = 当前滚动区域的底部与内容底部的距离(也就是最终的与底部的距离)
if (scrollDiff - this.cacheScrollNodeHeight <= this.finalLowerThreshold) {
// 如果与底部的距离小于阈值,则判断为滚动到了底部,触发滚动到底部事件
this._onLoadingMore('toBottom');
} else if (scrollDiff - this.cacheScrollNodeHeight <= 500 && !checked) {
// 如果与底部的距离小于500px,则获取当前滚动的位置,延迟150毫秒重复上述步骤再次检测(避免@scroll触发时获取的scrollTop不正确导致的其他问题,此时获取的scrollTop不一定可信)。防止因为部分性能较差安卓设备@scroll采样率过低导致的滚动到底部但是依然没有触发的问题
u.delay(() => {
this._getNodeClientRect('.zp-scroll-view', true, true).then((res) => {
if (res) {
this.oldScrollTop = res[0].scrollTop;
const newScrollDiff = res[0].scrollHeight - this.oldScrollTop;
this._checkScrolledToBottom(newScrollDiff, true);
}
})
}, 150, 'checkScrolledToBottomDelay')
}
// 检测一下是否已经滚动到了顶部了,因为在安卓中滚动到顶部时scrollTop不一定为0(和滚动到底部一样的原因),所以需要在scrollTop小于150px时,通过获取.zp-scroll-view的scrollTop再判断一下
if (this.oldScrollTop <= 150 && this.oldScrollTop !== 0) {
u.delay(() => {
// 这里再判断一下是否确实已经滚动到顶部了,如果已经滚动到顶部了,则不用再判断了,再次判断的原因是可能150毫秒之后oldScrollTop才是0
if (this.oldScrollTop !== 0) {
this._getNodeClientRect('.zp-scroll-view', true, true).then((res) => {
// 如果150毫秒后.zp-scroll-view的scrollTop为0,则认为已经滚动到了顶部了
if (res && res[0].scrollTop === 0 && this.oldScrollTop !== 0) {
this._onScrollToUpper();
}
})
}
}, 150, 'checkScrolledToTopDelay')
}
}
},
// 触发加载更多时调用,from:toBottom-滑动到底部触发;click-点击加载更多触发
_onLoadingMore(from = 'click') {
// 如果是ios并且是滚动到底部的,则在滚动到底部时候尝试将列表设置为禁止滚动然后设置为允许滚动,以禁止底部bounce的效果
if (this.isIos && from === 'toBottom' && !this.scrollToBottomBounceEnabled && this.scrollEnable) {
this.scrollEnable = false;
this.$nextTick(() => {
this.scrollEnable = true;
})
}
// emit scrolltolower
this._emitScrollEvent('scrolltolower');
// 如果是只使用下拉刷新 或者 禁用底部加载更多 或者 底部加载更多不是默认状态或加载失败状态 或者 是加载中状态 或者 空数据图已经展示了,则return,不触发内部加载更多逻辑
if (this.refresherOnly || !this.loadingMoreEnabled || !(this.loadingStatus === Enum.More.Default || this.loadingStatus === Enum.More.Fail) || this.loading || this.showEmpty) return;
// #ifdef MP-WEIXIN
if (!this.isIos && !this.refresherOnly && !this.usePageScroll) {
const currentTimestamp = u.getTime();
// 在非ios平台+scroll-view中节流处理
if (this.loadingMoreTimeStamp > 0 && currentTimestamp - this.loadingMoreTimeStamp < 100) {
this.loadingMoreTimeStamp = 0;
return;
}
}
// #endif
// 处理加载更多数据
this._doLoadingMore();
},
// 处理开始加载更多
_doLoadingMore() {
if (this.pageNo >= this.defaultPageNo && this.loadingStatus !== Enum.More.NoMore) {
this.pageNo ++;
this._startLoading(false);
if (this.isLocalPaging) {
// 如果是本地分页,则在组件内部对数据进行分页处理,不触发@query事件
this._localPagingQueryList(this.pageNo, this.defaultPageSize, this.localPagingLoadingTime, res => {
this.completeByTotal(res, this.totalLocalPagingList.length);
this.queryFrom = Enum.QueryFrom.LoadMore;
})
} else {
// emit @query相关加载更多事件
this._emitQuery(this.pageNo, this.defaultPageSize, Enum.QueryFrom.LoadMore);
this._callMyParentQuery();
}
// 设置当前加载状态为底部加载更多状态
this.loadingType = Enum.LoadingType.LoadMore;
}
},
// (预处理)判断当没有更多数据且分页内容未超出z-paging时是否显示没有更多数据的view
_preCheckShowNoMoreInside(newVal, scrollViewNode, pagingContainerNode) {
if (this.loadingStatus === Enum.More.NoMore && this.hideNoMoreByLimit > 0 && newVal.length) {
this.showLoadingMore = newVal.length > this.hideNoMoreByLimit;
} else if ((this.loadingStatus === Enum.More.NoMore && this.hideNoMoreInside && newVal.length) || (this.insideMore && this.insideOfPaging !== false && newVal.length)) {
this.$nextTick(() => {
this._checkShowNoMoreInside(newVal, scrollViewNode, pagingContainerNode);
})
if (this.insideMore && this.insideOfPaging !== false && newVal.length) {
this.showLoadingMore = newVal.length;
}
} else {
this.showLoadingMore = newVal.length;
}
},
// 判断当没有更多数据且分页内容未超出z-paging时是否显示没有更多数据的view
async _checkShowNoMoreInside(totalData, oldScrollViewNode, oldPagingContainerNode) {
try {
const scrollViewNode = oldScrollViewNode || await this._getNodeClientRect('.zp-scroll-view');
// 在页面滚动模式下
if (this.usePageScroll) {
if (scrollViewNode) {
// 获取滚动内容总高度
const scrollViewTotalH = scrollViewNode[0].top + scrollViewNode[0].height;
// 如果滚动内容总高度小于窗口高度,则认为内容未超出z-paging
this.insideOfPaging = scrollViewTotalH < this.windowHeight;
// 如果需要没有更多数据时,隐藏底部加载更多view,并且内容未超过z-paging,则隐藏底部加载更多
if (this.hideNoMoreInside) {
this.showLoadingMore = !this.insideOfPaging;
}
// 如果需要内容未超过z-paging时自动加载更多,则触发加载更多
this._updateInsideOfPaging();
}
} else {
// 在scroll-view滚动模式下
const pagingContainerNode = oldPagingContainerNode || await this._getNodeClientRect('.zp-paging-container-content');
// 获取滚动内容总高度
const pagingContainerH = pagingContainerNode ? pagingContainerNode[0].height : 0;
// 获取z-paging内置scroll-view高度
const scrollViewH = scrollViewNode ? scrollViewNode[0].height : 0;
// 如果滚动内容总高度小于z-paging内置scroll-view高度,则认为内容未超出z-paging
this.insideOfPaging = pagingContainerH < scrollViewH;
if (this.hideNoMoreInside) {
this.showLoadingMore = !this.insideOfPaging;
}
// 如果需要内容未超过z-paging时自动加载更多,则触发加载更多
this._updateInsideOfPaging();
}
} catch (e) {
// 如果发生了异常,判断totalData数组长度为0,则认为内容未超出z-paging
this.insideOfPaging = !totalData.length;
if (this.hideNoMoreInside) {
this.showLoadingMore = !this.insideOfPaging;
}
// 如果需要内容未超过z-paging时自动加载更多,则触发加载更多
this._updateInsideOfPaging();
}
},
// 是否要展示上拉加载更多view
_showLoadingMore(type) {
if (!this.showLoadingMoreWhenReload && (!(this.loadingStatus === Enum.More.Default ? this.nShowBottom : true) || !this.realTotalData.length)) return false;
if (((!this.showLoadingMoreWhenReload || this.isUserPullDown || this.loadingStatus !== Enum.More.Loading) && !this.showLoadingMore) ||
(!this.loadingMoreEnabled && (!this.showLoadingMoreWhenReload || this.isUserPullDown || this.loadingStatus !== Enum.More.Loading)) || this.refresherOnly) {
return false;
}
if (this.useChatRecordMode && type !== 'Loading') return false;
if (!this.zSlots) return false;
if (type === 'Custom') {
return this.showDefaultLoadingMoreText && !(this.loadingStatus === Enum.More.NoMore && !this.showLoadingMoreNoMoreView);
}
const res = this.loadingStatus === Enum.More[type] && this.zSlots[`loadingMore${type}`] && (type === 'NoMore' ? this.showLoadingMoreNoMoreView : true);
if (res) {
// #ifdef APP-NVUE
if (!this.isIos) {
this.nLoadingMoreFixedHeight = false;
}
// #endif
}
return res;
},
}
}
@@ -0,0 +1,95 @@
// [z-paging]loading相关模块
import u from '.././z-paging-utils'
import Enum from '.././z-paging-enum'
export default {
props: {
// 第一次加载后自动隐藏loading slot,默认为是
autoHideLoadingAfterFirstLoaded: {
type: Boolean,
default: u.gc('autoHideLoadingAfterFirstLoaded', true)
},
// loading slot是否铺满屏幕并固定,默认为否
loadingFullFixed: {
type: Boolean,
default: u.gc('loadingFullFixed', false)
},
// 是否自动显示系统Loading:即uni.showLoading,若开启则将在刷新列表时(调用reload、refresh时)显示,下拉刷新和滚动到底部加载更多不会显示,默认为false。
autoShowSystemLoading: {
type: Boolean,
default: u.gc('autoShowSystemLoading', false)
},
// 显示系统Loading时是否显示透明蒙层,防止触摸穿透,默认为是(H5、App、微信小程序、百度小程序有效)
systemLoadingMask: {
type: Boolean,
default: u.gc('systemLoadingMask', true)
},
// 显示系统Loading时显示的文字,默认为"加载中"
systemLoadingText: {
type: [String, Object],
default: u.gc('systemLoadingText', null)
},
},
data() {
return {
loading: false,
loadingForNow: false,
}
},
watch: {
// loading状态
loadingStatus(newVal) {
this.$emit('loadingStatusChange', newVal);
this.$nextTick(() => {
this.loadingStatusAfterRender = newVal;
})
if (this.useChatRecordMode) {
if (this.isFirstPage && (newVal === Enum.More.NoMore || newVal === Enum.More.Fail)) {
this.isFirstPageAndNoMore = true;
return;
}
}
this.isFirstPageAndNoMore = false;
},
loading(newVal){
if (newVal) {
this.loadingForNow = newVal;
}
},
},
computed: {
// 是否显示loading
showLoading() {
if (this.firstPageLoaded || !this.loading || !this.loadingForNow) return false;
if (this.finalShowSystemLoading) {
// 显示系统loading
uni.showLoading({
title: this.finalSystemLoadingText,
mask: this.systemLoadingMask
})
}
return this.autoHideLoadingAfterFirstLoaded ? (this.fromEmptyViewReload ? true : !this.pagingLoaded) : this.loadingType === Enum.LoadingType.Refresher;
},
// 最终的是否显示系统loading
finalShowSystemLoading() {
return this.autoShowSystemLoading && this.loadingType === Enum.LoadingType.Refresher;
}
},
methods: {
// 处理开始加载更多状态
_startLoading(isReload = false) {
if ((this.showLoadingMoreWhenReload && !this.isUserPullDown) || !isReload) {
this.loadingStatus = Enum.More.Loading;
}
this.loading = true;
},
// 停止系统loading和refresh
_endSystemLoadingAndRefresh(){
this.finalShowSystemLoading && uni.hideLoading();
!this.useCustomRefresher && uni.stopPullDownRefresh();
// #ifdef APP-NVUE
this.usePageScroll && uni.stopPullDownRefresh();
// #endif
}
}
}
@@ -0,0 +1,268 @@
// [z-paging]nvue独有部分模块
import u from '.././z-paging-utils'
import c from '.././z-paging-constant'
import Enum from '.././z-paging-enum'
// #ifdef APP-NVUE
const weexAnimation = weex.requireModule('animation');
// #endif
export default {
props: {
// #ifdef APP-NVUE
// nvue中修改列表类型,可选值有list、waterfall和scroller,默认为list
nvueListIs: {
type: String,
default: u.gc('nvueListIs', 'list')
},
// nvue waterfall配置,仅在nvue中且nvueListIs=waterfall时有效,配置参数详情参见:https://uniapp.dcloud.io/component/waterfall
nvueWaterfallConfig: {
type: Object,
default: u.gc('nvueWaterfallConfig', {})
},
// nvue 控制是否回弹效果,iOS不支持动态修改
nvueBounce: {
type: Boolean,
default: u.gc('nvueBounce', true)
},
// nvue中通过代码滚动到顶部/底部时,是否加快动画效果(无滚动动画时无效),默认为否
nvueFastScroll: {
type: Boolean,
default: u.gc('nvueFastScroll', false)
},
// nvue中list的id
nvueListId: {
type: String,
default: u.gc('nvueListId', '')
},
// nvue中refresh组件的样式
nvueRefresherStyle: {
type: Object,
default: u.gc('nvueRefresherStyle', {})
},
// nvue中是否按分页模式(类似竖向swiper)显示List,默认为false
nvuePagingEnabled: {
type: Boolean,
default: u.gc('nvuePagingEnabled', false)
},
// 是否隐藏nvue列表底部的tagView,此view用于标识滚动到底部位置,若隐藏则滚动到底部功能将失效,在nvue中实现吸顶+swiper功能时需将最外层z-paging的此属性设置为true。默认为否
hideNvueBottomTag: {
type: Boolean,
default: u.gc('hideNvueBottomTag', false)
},
// nvue中控制onscroll事件触发的频率:表示两次onscroll事件之间列表至少滚动了10px。注意,将该值设置为较小的数值会提高滚动事件采样的精度,但同时也会降低页面的性能
offsetAccuracy: {
type: Number,
default: u.gc('offsetAccuracy', 10)
},
// #endif
},
data() {
return {
nRefresherLoading: false,
nListIsDragging: false,
nShowBottom: true,
nFixFreezing: false,
nShowRefresherReveal: false,
nLoadingMoreFixedHeight: false,
nShowRefresherRevealHeight: 0,
nOldShowRefresherRevealHeight: -1,
nRefresherWidth: u.rpx2px(750),
nF2Opacity: 0
}
},
computed: {
// #ifdef APP-NVUE
nScopedSlots() {
// #ifdef VUE2
return this.$scopedSlots;
// #endif
// #ifdef VUE3
return null;
// #endif
},
nWaterfallColumnCount() {
if (this.finalNvueListIs !== 'waterfall') return 0;
return this._nGetWaterfallConfig('column-count', 2);
},
nWaterfallColumnWidth() {
return this._nGetWaterfallConfig('column-width', 'auto');
},
nWaterfallColumnGap() {
return this._nGetWaterfallConfig('column-gap', 'normal');
},
nWaterfallLeftGap() {
return this._nGetWaterfallConfig('left-gap', 0);
},
nWaterfallRightGap() {
return this._nGetWaterfallConfig('right-gap', 0);
},
nViewIs() {
const is = this.finalNvueListIs;
return is === 'scroller' || is === 'view' ? 'view' : is === 'waterfall' ? 'header' : 'cell';
},
nSafeAreaBottomHeight() {
return this.safeAreaInsetBottom ? this.safeAreaBottom : 0;
},
finalNvueListIs() {
if (this.usePageScroll) return 'view';
const nvueListIsLowerCase = this.nvueListIs.toLowerCase();
if (['list','waterfall','scroller'].indexOf(nvueListIsLowerCase) !== -1) return nvueListIsLowerCase;
return 'list';
},
finalNvueSuperListIs() {
return this.usePageScroll ? 'view' : 'scroller';
},
finalNvueRefresherEnabled() {
return this.finalNvueListIs !== 'view' && this.finalRefresherEnabled && !this.nShowRefresherReveal && !this.useChatRecordMode;
},
// #endif
},
mounted(){
// #ifdef APP-NVUE
//旋转屏幕时更新宽度
uni.onWindowResize((res) => {
// this._nUpdateRefresherWidth();
})
// #endif
},
methods: {
// #ifdef APP-NVUE
// 列表滚动时触发
_nOnScroll(e) {
this.$emit('scroll', e);
const contentOffsetY = -e.contentOffset.y;
this.oldScrollTop = contentOffsetY;
this.nListIsDragging = e.isDragging;
this._checkShouldShowBackToTop(contentOffsetY, contentOffsetY - 1);
},
// 列表滚动结束
_nOnScrollend(e) {
this.$emit('scrollend', e);
// 判断是否滚动到顶部了
if (e?.contentOffset?.y >= 0) {
this._emitScrollEvent('scrolltoupper');
}
// 判断是否滚动到底部了
this._getNodeClientRect('.zp-n-list').then(node => {
if (node) {
if (e?.contentSize?.height + e?.contentOffset?.y <= node[0].height) {
this._emitScrollEvent('scrolltolower');
}
}
})
},
// 下拉刷新刷新中
_nOnRrefresh() {
if (this.nShowRefresherReveal) return;
// 进入刷新状态
this.nRefresherLoading = true;
if (this.refresherStatus === Enum.Refresher.GoF2) {
this._handleGoF2();
this.$nextTick(() => {
this._nRefresherEnd();
})
} else {
this.refresherStatus = Enum.Refresher.Loading;
this._doRefresherLoad();
}
},
// 下拉刷新下拉中
_nOnPullingdown(e) {
if (this.refresherStatus === Enum.Refresher.Loading || (this.isIos && !this.nListIsDragging)) return;
this._emitTouchmove(e);
let { viewHeight, pullingDistance } = e;
// 更新下拉刷新状态
// 下拉刷新距离超过阈值
if (pullingDistance >= viewHeight) {
// 如果开启了下拉进入二楼并且下拉刷新距离超过进入二楼阈值,则当前下拉刷新状态为松手进入二楼,否则为松手立即刷新
// (pullingDistance - viewHeight) + this.finalRefresherThreshold 不等同于pullingDistance,此处是为了兼容不同平台下拉相同距离pullingDistance不一致的问题,pullingDistance仅与viewHeight互相关联
this.refresherStatus = this.refresherF2Enabled && (pullingDistance - viewHeight) + this.finalRefresherThreshold >= this.finalRefresherF2Threshold ? Enum.Refresher.GoF2 : Enum.Refresher.ReleaseToRefresh;
} else {
// 下拉刷新距离未超过阈值,显示默认状态
this.refresherStatus = Enum.Refresher.Default;
}
},
// 下拉刷新结束
_nRefresherEnd(doEnd = true) {
if (doEnd) {
this._nDoRefresherEndAnimation(0, -this.nShowRefresherRevealHeight);
!this.usePageScroll && this.$refs['zp-n-list'].resetLoadmore();
this.nRefresherLoading = false;
}
},
// 执行主动触发下拉刷新动画
_nDoRefresherEndAnimation(height, translateY, animate = true, checkStack = true) {
// 清除下拉刷新相关timeout
this._cleanRefresherCompleteTimeout();
this._cleanRefresherEndTimeout();
if (!this.finalShowRefresherWhenReload) {
// 如果reload不需要自动展示下拉刷新view,则在complete duration结束后再把下拉刷新状态设置回默认
this.refresherEndTimeout = u.delay(() => {
this.refresherStatus = Enum.Refresher.Default;
}, this.refresherCompleteDuration);
return;
}
// 用户处理用户在短时间内多次调用reload的情况,此时下拉刷新view不需要重复显示,只需要保证最后一次reload对应的请求结束后收回下拉刷新view即可
const stackCount = this.refresherRevealStackCount;
if (height === 0 && checkStack) {
this.refresherRevealStackCount --;
if (stackCount > 1) return;
this.refresherEndTimeout = u.delay(() => {
this.refresherStatus = Enum.Refresher.Default;
}, this.refresherCompleteDuration);
}
if (stackCount > 1) {
this.refresherStatus = Enum.Refresher.Loading;
}
const duration = animate ? 200 : 0;
if (this.nOldShowRefresherRevealHeight !== height) {
if (height > 0) {
this.nShowRefresherReveal = true;
}
// 展示下拉刷新view
weexAnimation.transition(this.$refs['zp-n-list-refresher-reveal'], {
styles: {
height: `${height}px`,
transform: `translateY(${translateY}px)`,
},
duration,
timingFunction: 'linear',
needLayout: true,
delay: 0
})
}
u.delay(() => {
if (animate) {
this.nShowRefresherReveal = height > 0;
}
}, duration > 0 ? duration - 60 : 0);
this.nOldShowRefresherRevealHeight = height;
},
// 滚动到底部加载更多
_nOnLoadmore() {
if (this.nShowRefresherReveal || !this.totalData.length) return;
this.useChatRecordMode ? this.doChatRecordLoadMore() : this._onLoadingMore('toBottom');
},
// 获取nvue waterfall单项配置
_nGetWaterfallConfig(key, defaultValue) {
return this.nvueWaterfallConfig[key] || defaultValue;
},
// 更新nvue 下拉刷新view容器的宽度
_nUpdateRefresherWidth() {
u.delay(() => {
this.$nextTick(()=>{
this._getNodeClientRect('.zp-n-list').then(node => {
if (node) {
this.nRefresherWidth = node[0].width || this.nRefresherWidth;
}
})
})
})
}
// #endif
}
}
@@ -0,0 +1,831 @@
// [z-paging]下拉刷新view模块
import u from '.././z-paging-utils'
import c from '.././z-paging-constant'
import Enum from '.././z-paging-enum'
// #ifdef APP-NVUE
const weexAnimation = weex.requireModule('animation');
// #endif
export default {
props: {
// 下拉刷新的主题样式,支持blackwhite,默认black
refresherThemeStyle: {
type: String,
default: u.gc('refresherThemeStyle', '')
},
// 自定义下拉刷新中左侧图标的样式
refresherImgStyle: {
type: Object,
default: u.gc('refresherImgStyle', {})
},
// 自定义下拉刷新中右侧状态描述文字的样式
refresherTitleStyle: {
type: Object,
default: u.gc('refresherTitleStyle', {})
},
// 自定义下拉刷新中右侧最后更新时间文字的样式(show-refresher-update-time为true时有效)
refresherUpdateTimeStyle: {
type: Object,
default: u.gc('refresherUpdateTimeStyle', {})
},
// 在微信小程序和QQ小程序中,是否实时监听下拉刷新中进度,默认为否
watchRefresherTouchmove: {
type: Boolean,
default: u.gc('watchRefresherTouchmove', false)
},
// 底部加载更多的主题样式,支持black,white,默认black
loadingMoreThemeStyle: {
type: String,
default: u.gc('loadingMoreThemeStyle', '')
},
// 是否只使用下拉刷新,设置为true后将关闭mounted自动请求数据、关闭滚动到底部加载更多,强制隐藏空数据图。默认为否
refresherOnly: {
type: Boolean,
default: u.gc('refresherOnly', false)
},
// 自定义下拉刷新默认状态下回弹动画时间,单位为毫秒,默认为100毫秒,nvue无效
refresherDefaultDuration: {
type: [Number, String],
default: u.gc('refresherDefaultDuration', 100)
},
// 自定义下拉刷新结束以后延迟回弹的时间,单位为毫秒,默认为0
refresherCompleteDelay: {
type: [Number, String],
default: u.gc('refresherCompleteDelay', 0)
},
// 自定义下拉刷新结束回弹动画时间,单位为毫秒,默认为300毫秒(refresherEndBounceEnabled为false时,refresherCompleteDuration为设定值的1/3)nvue无效
refresherCompleteDuration: {
type: [Number, String],
default: u.gc('refresherCompleteDuration', 300)
},
// 自定义下拉刷新中是否允许列表滚动,默认为是
refresherRefreshingScrollable: {
type: Boolean,
default: u.gc('refresherRefreshingScrollable', true)
},
// 自定义下拉刷新结束状态下是否允许列表滚动,默认为否
refresherCompleteScrollable: {
type: Boolean,
default: u.gc('refresherCompleteScrollable', false)
},
// 是否使用自定义的下拉刷新,默认为是,即使用z-paging的下拉刷新。设置为false即代表使用uni scroll-view自带的下拉刷新,h5、App、微信小程序以外的平台不支持uni scroll-view自带的下拉刷新
useCustomRefresher: {
type: Boolean,
default: u.gc('useCustomRefresher', true)
},
// 自定义下拉刷新下拉帧率,默认为40,过高可能会出现抖动问题
refresherFps: {
type: [Number, String],
default: u.gc('refresherFps', 40)
},
// 自定义下拉刷新允许触发的最大下拉角度,默认为40度,当下拉角度小于设定值时,自定义下拉刷新动画不会被触发
refresherMaxAngle: {
type: [Number, String],
default: u.gc('refresherMaxAngle', 40)
},
// 自定义下拉刷新的角度由未达到最大角度变到达到最大角度时,是否继续下拉刷新手势,默认为否
refresherAngleEnableChangeContinued: {
type: Boolean,
default: u.gc('refresherAngleEnableChangeContinued', false)
},
// 自定义下拉刷新默认状态下的文字
refresherDefaultText: {
type: [String, Object],
default: u.gc('refresherDefaultText', null)
},
// 自定义下拉刷新松手立即刷新状态下的文字
refresherPullingText: {
type: [String, Object],
default: u.gc('refresherPullingText', null)
},
// 自定义下拉刷新刷新中状态下的文字
refresherRefreshingText: {
type: [String, Object],
default: u.gc('refresherRefreshingText', null)
},
// 自定义下拉刷新刷新结束状态下的文字
refresherCompleteText: {
type: [String, Object],
default: u.gc('refresherCompleteText', null)
},
// 自定义继续下拉进入二楼文字
refresherGoF2Text: {
type: [String, Object],
default: u.gc('refresherGoF2Text', null)
},
// 自定义下拉刷新默认状态下的图片
refresherDefaultImg: {
type: String,
default: u.gc('refresherDefaultImg', null)
},
// 自定义下拉刷新松手立即刷新状态下的图片,默认与refresherDefaultImg一致
refresherPullingImg: {
type: String,
default: u.gc('refresherPullingImg', null)
},
// 自定义下拉刷新刷新中状态下的图片
refresherRefreshingImg: {
type: String,
default: u.gc('refresherRefreshingImg', null)
},
// 自定义下拉刷新刷新结束状态下的图片
refresherCompleteImg: {
type: String,
default: u.gc('refresherCompleteImg', null)
},
// 自定义下拉刷新刷新中状态下是否展示旋转动画
refresherRefreshingAnimated: {
type: Boolean,
default: u.gc('refresherRefreshingAnimated', true)
},
// 是否开启自定义下拉刷新刷新结束回弹效果,默认为是
refresherEndBounceEnabled: {
type: Boolean,
default: u.gc('refresherEndBounceEnabled', true)
},
// 是否开启自定义下拉刷新,默认为是
refresherEnabled: {
type: Boolean,
default: u.gc('refresherEnabled', true)
},
// 设置自定义下拉刷新阈值,默认为80rpx
refresherThreshold: {
type: [Number, String],
default: u.gc('refresherThreshold', '80rpx')
},
// 设置系统下拉刷新默认样式,支持设置 black,white,none,none 表示不使用默认样式,默认为black
refresherDefaultStyle: {
type: String,
default: u.gc('refresherDefaultStyle', 'black')
},
// 设置自定义下拉刷新区域背景
refresherBackground: {
type: String,
default: u.gc('refresherBackground', 'transparent')
},
// 设置固定的自定义下拉刷新区域背景
refresherFixedBackground: {
type: String,
default: u.gc('refresherFixedBackground', 'transparent')
},
// 设置固定的自定义下拉刷新区域高度,默认为0
refresherFixedBacHeight: {
type: [Number, String],
default: u.gc('refresherFixedBacHeight', 0)
},
// 设置自定义下拉刷新下拉超出阈值后继续下拉位移衰减的比例,范围0-1,值越大代表衰减越多。默认为0.65(nvue无效)
refresherOutRate: {
type: Number,
default: u.gc('refresherOutRate', 0.65)
},
// 是否开启下拉进入二楼功能,默认为否
refresherF2Enabled: {
type: Boolean,
default: u.gc('refresherF2Enabled', false)
},
// 下拉进入二楼阈值,默认为200rpx
refresherF2Threshold: {
type: [Number, String],
default: u.gc('refresherF2Threshold', '200rpx')
},
// 下拉进入二楼动画时间,单位为毫秒,默认为200毫秒
refresherF2Duration: {
type: [Number, String],
default: u.gc('refresherF2Duration', 200)
},
// 下拉进入二楼状态松手后是否弹出二楼,默认为是
showRefresherF2: {
type: Boolean,
default: u.gc('showRefresherF2', true)
},
// 设置自定义下拉刷新下拉时实际下拉位移与用户下拉距离的比值,默认为0.75,即代表若用户下拉10px,则实际位移为7.5px(nvue无效)
refresherPullRate: {
type: Number,
default: u.gc('refresherPullRate', 0.75)
},
// 是否显示最后更新时间,默认为否
showRefresherUpdateTime: {
type: Boolean,
default: u.gc('showRefresherUpdateTime', false)
},
// 如果需要区别不同页面的最后更新时间,请为不同页面的z-paging的`refresher-update-time-key`设置不同的字符串
refresherUpdateTimeKey: {
type: String,
default: u.gc('refresherUpdateTimeKey', 'default')
},
// 下拉刷新时下拉到“松手立即刷新”或“松手进入二楼”状态时是否使手机短振动,默认为否(h5无效)
refresherVibrate: {
type: Boolean,
default: u.gc('refresherVibrate', false)
},
// 下拉刷新时是否禁止下拉刷新view跟随用户触摸竖直移动,默认为否。注意此属性只是禁止下拉刷新view移动,其他下拉刷新逻辑依然会正常触发
refresherNoTransform: {
type: Boolean,
default: u.gc('refresherNoTransform', false)
},
// 是否开启下拉刷新状态栏占位,适用于隐藏导航栏时,下拉刷新需要避开状态栏高度的情况,默认为否
useRefresherStatusBarPlaceholder: {
type: Boolean,
default: u.gc('useRefresherStatusBarPlaceholder', false)
},
},
data() {
return {
R: Enum.Refresher,
//下拉刷新状态
refresherStatus: Enum.Refresher.Default,
refresherTouchstartY: 0,
lastRefresherTouchmove: null,
refresherReachMaxAngle: true,
refresherTransform: 'translateY(0px)',
refresherTransition: '',
finalRefresherDefaultStyle: 'black',
refresherRevealStackCount: 0,
refresherCompleteTimeout: null,
refresherCompleteSubTimeout: null,
refresherEndTimeout: null,
isTouchmovingTimeout: null,
refresherTriggered: false,
isTouchmoving: false,
isTouchEnded: false,
isUserPullDown: false,
privateRefresherEnabled: -1,
privateShowRefresherWhenReload: false,
customRefresherHeight: -1,
showCustomRefresher: false,
doRefreshAnimateAfter: false,
isRefresherInComplete: false,
showF2: false,
f2Transform: '',
pullDownTimeStamp: 0,
moveDis: 0,
oldMoveDis: 0,
currentDis: 0,
oldCurrentMoveDis: 0,
oldRefresherTouchmoveY: 0,
oldTouchDirection: '',
oldEmitedTouchDirection: '',
oldPullingDistance: -1,
refresherThresholdUpdateTag: 0
}
},
watch: {
refresherDefaultStyle: {
handler(newVal) {
if (newVal.length) {
this.finalRefresherDefaultStyle = newVal;
}
},
immediate: true
},
refresherStatus(newVal) {
newVal === Enum.Refresher.Loading && this._cleanRefresherEndTimeout();
this.refresherVibrate && (newVal === Enum.Refresher.ReleaseToRefresh || newVal === Enum.Refresher.GoF2) && this._doVibrateShort();
this.$emit('refresherStatusChange', newVal);
this.$emit('update:refresherStatus', newVal);
},
// 监听当前下拉刷新启用/禁用状态
refresherEnabled(newVal) {
// 当禁用下拉刷新时,强制收回正在展示的下拉刷新view
!newVal && this.endRefresh();
}
},
computed: {
pullDownDisTimeStamp() {
return 1000 / this.refresherFps;
},
refresherThresholdUnitConverted() {
return u.addUnit(this.refresherThreshold, this.unit);
},
finalRefresherEnabled() {
if (this.useChatRecordMode) return false;
if (this.privateRefresherEnabled === -1) return this.refresherEnabled;
return this.privateRefresherEnabled === 1;
},
finalRefresherThreshold() {
let refresherThreshold = this.refresherThresholdUnitConverted;
let idDefault = false;
if (refresherThreshold === u.addUnit(80, this.unit)) {
idDefault = true;
if (this.showRefresherUpdateTime) {
refresherThreshold = u.addUnit(120, this.unit);
}
}
if (idDefault && this.customRefresherHeight > 0) return this.customRefresherHeight + this.finalRefresherThresholdPlaceholder;
return u.convertToPx(refresherThreshold) + this.finalRefresherThresholdPlaceholder;
},
finalRefresherF2Threshold() {
return u.convertToPx(u.addUnit(this.refresherF2Threshold, this.unit));
},
finalRefresherThresholdPlaceholder() {
return this.useRefresherStatusBarPlaceholder ? this.statusBarHeight : 0;
},
finalRefresherFixedBacHeight() {
return u.convertToPx(this.refresherFixedBacHeight);
},
finalRefresherThemeStyle() {
return this.refresherThemeStyle.length ? this.refresherThemeStyle : this.defaultThemeStyle;
},
finalRefresherOutRate() {
let rate = this.refresherOutRate;
rate = Math.max(0,rate);
rate = Math.min(1,rate);
return rate;
},
finalRefresherPullRate() {
let rate = this.refresherPullRate;
rate = Math.max(0,rate);
return rate;
},
finalRefresherTransform() {
if (this.refresherNoTransform || this.refresherTransform === 'translateY(0px)') return 'none';
return this.refresherTransform;
},
finalShowRefresherWhenReload() {
return this.showRefresherWhenReload || this.privateShowRefresherWhenReload;
},
finalRefresherTriggered() {
if (!(this.finalRefresherEnabled && !this.useCustomRefresher)) return false;
return this.refresherTriggered;
},
showRefresher() {
const showRefresher = this.finalRefresherEnabled || this.useCustomRefresher && !this.useChatRecordMode;
// #ifndef APP-NVUE
this.active && this.customRefresherHeight === -1 && showRefresher && this.updateCustomRefresherHeight();
// #endif
return showRefresher;
},
hasTouchmove() {
// #ifdef VUE2
// #ifdef APP-VUE || H5
if (this.$listeners && !this.$listeners.refresherTouchmove) return false;
// #endif
// #ifdef MP-WEIXIN || MP-QQ
return this.watchRefresherTouchmove;
// #endif
return true;
// #endif
return this.watchRefresherTouchmove;
},
},
methods: {
// 终止下拉刷新状态
endRefresh() {
this.totalData = this.realTotalData;
this._refresherEnd();
this._endSystemLoadingAndRefresh();
this._handleScrollViewBounce({ bounce: true });
this.$nextTick(() => {
this.refresherTriggered = false;
})
},
// 手动更新自定义下拉刷新view高度
updateCustomRefresherHeight() {
u.delay(() => this.$nextTick(this._updateCustomRefresherHeight));
},
// 关闭二楼
closeF2() {
this._handleCloseF2();
},
// 自定义下拉刷新被触发
_onRefresh(fromScrollView = false, isUserPullDown = true) {
if (fromScrollView && !(this.finalRefresherEnabled && !this.useCustomRefresher)) return;
this.$emit('onRefresh');
this.$emit('Refresh');
// #ifdef APP-NVUE
if (this.loading) {
u.delay(this._nRefresherEnd, 500)
return;
}
// #endif
if (this.loading || this.isRefresherInComplete) return;
this.loadingType = Enum.LoadingType.Refresher;
if (this.nShowRefresherReveal) return;
this.isUserPullDown = isUserPullDown;
this.isUserReload = !isUserPullDown;
this._startLoading(true);
this.refresherTriggered = true;
if (this.reloadWhenRefresh && isUserPullDown) {
this.useChatRecordMode ? this._onLoadingMore('click') : this._reload(false, false, isUserPullDown);
}
},
// 自定义下拉刷新被复位
_onRestore() {
this.refresherTriggered = 'restore';
this.$emit('onRestore');
this.$emit('Restore');
},
// #ifndef APP-VUE || MP-WEIXIN || MP-QQ || H5
// touch开始
_refresherTouchstart(e) {
this._handleListTouchstart();
if (this._touchDisabled()) return;
this._handleRefresherTouchstart(u.getTouch(e));
},
// #endif
// 进一步处理touch开始结果
_handleRefresherTouchstart(touch) {
if (!this.loading && this.isTouchEnded) {
this.isTouchmoving = false;
}
this.loadingType = Enum.LoadingType.Refresher;
this.isTouchmovingTimeout && clearTimeout(this.isTouchmovingTimeout);
this.isTouchEnded = false;
this.refresherTransition = '';
this.refresherTouchstartY = touch.touchY;
this.$emit('refresherTouchstart', this.refresherTouchstartY);
this.lastRefresherTouchmove = touch;
this._cleanRefresherCompleteTimeout();
this._cleanRefresherEndTimeout();
},
// 非app-vue或微信小程序或QQ小程序或h5平台,使用js控制下拉刷新
// #ifndef APP-VUE || MP-WEIXIN || MP-QQ || H5
// touch中
_refresherTouchmove(e) {
const currentTimeStamp = u.getTime();
let touch = null;
let refresherTouchmoveY = 0;
if (this.watchTouchDirectionChange) {
// 检测下拉刷新方向改变
touch = u.getTouch(e);
refresherTouchmoveY = touch.touchY;
const direction = refresherTouchmoveY > this.oldRefresherTouchmoveY ? 'top' : 'bottom';
// 只有在方向改变的时候才emit相关事件
if (direction === this.oldTouchDirection && direction !== this.oldEmitedTouchDirection) {
this._handleTouchDirectionChange({ direction });
this.oldEmitedTouchDirection = direction;
}
this.oldTouchDirection = direction;
this.oldRefresherTouchmoveY = refresherTouchmoveY;
}
// 节流处理,在pullDownDisTimeStamp时间内的下拉刷新中事件不进行处理
if (this.pullDownTimeStamp && currentTimeStamp - this.pullDownTimeStamp <= this.pullDownDisTimeStamp) return;
// 如果不允许下拉,则return
if (this._touchDisabled()) return;
this.pullDownTimeStamp = Number(currentTimeStamp);
touch = u.getTouch(e);
refresherTouchmoveY = touch.touchY;
// 获取当前touch的y - 初始touch的y,计算它们的差
let moveDis = refresherTouchmoveY - this.refresherTouchstartY;
if (moveDis < 0) return;
// 对下拉刷新的角度进行限制
if (this.refresherMaxAngle >= 0 && this.refresherMaxAngle <= 90 && this.lastRefresherTouchmove && this.lastRefresherTouchmove.touchY <= refresherTouchmoveY) {
if (!moveDis && !this.refresherAngleEnableChangeContinued && this.moveDis < 1 && !this.refresherReachMaxAngle) return;
const x = Math.abs(touch.touchX - this.lastRefresherTouchmove.touchX);
const y = Math.abs(refresherTouchmoveY - this.lastRefresherTouchmove.touchY);
const z = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
if ((x || y) && x > 1) {
// 获取下拉刷新前后两次位移的角度
const angle = Math.asin(y / z) / Math.PI * 180;
// 如果角度小于配置要求,则return
if (angle < this.refresherMaxAngle) {
this.lastRefresherTouchmove = touch;
this.refresherReachMaxAngle = false;
return;
}
}
}
// 获取最终的moveDis
moveDis = this._getFinalRefresherMoveDis(moveDis);
// 处理下拉刷新位移
this._handleRefresherTouchmove(moveDis, touch);
// 下拉刷新时,禁止页面滚动以防止页面向下滚动和下拉刷新同时作用导致下拉刷新位置偏移超过预期
if (!this.disabledBounce) {
// #ifndef MP-LARK
this._handleScrollViewBounce({ bounce: false });
// #endif
this.disabledBounce = true;
}
this._emitTouchmove({ pullingDistance: moveDis, dy: this.moveDis - this.oldMoveDis });
},
// #endif
// 进一步处理touch中结果
_handleRefresherTouchmove(moveDis, touch) {
this.refresherReachMaxAngle = true;
this.isTouchmovingTimeout && clearTimeout(this.isTouchmovingTimeout);
this.isTouchmoving = true;
this.isTouchEnded = false;
// 更新下拉刷新状态
// 下拉刷新距离超过阈值
if (moveDis >= this.finalRefresherThreshold) {
// 如果开启了下拉进入二楼并且下拉刷新距离超过进入二楼阈值,则当前下拉刷新状态为松手进入二楼,否则为松手立即刷新
this.refresherStatus = this.refresherF2Enabled && moveDis >= this.finalRefresherF2Threshold ? Enum.Refresher.GoF2 : Enum.Refresher.ReleaseToRefresh;
} else {
// 下拉刷新距离未超过阈值,显示默认状态
this.refresherStatus = Enum.Refresher.Default;
}
// #ifndef APP-VUE || MP-WEIXIN || MP-QQ || H5
// this.scrollEnable = false;
// 通过transform控制下拉刷新view垂直偏移
this.refresherTransform = `translateY(${moveDis}px)`;
this.lastRefresherTouchmove = touch;
// #endif
this.moveDis = moveDis;
},
// #ifndef APP-VUE || MP-WEIXIN || MP-QQ || H5
// touch结束
_refresherTouchend(e) {
// 下拉刷新用户手离开屏幕,允许列表滚动
this._handleScrollViewBounce({bounce: true});
if (this._touchDisabled() || !this.isTouchmoving) return;
const touch = u.getTouch(e);
let refresherTouchendY = touch.touchY;
let moveDis = refresherTouchendY - this.refresherTouchstartY;
moveDis = this._getFinalRefresherMoveDis(moveDis);
this._handleRefresherTouchend(moveDis);
this.disabledBounce = false;
},
// #endif
// 进一步处理touch结束结果
_handleRefresherTouchend(moveDis) {
// #ifndef APP-PLUS || H5 || MP-WEIXIN
if (!this.isTouchmoving) return;
// #endif
this.isTouchmovingTimeout && clearTimeout(this.isTouchmovingTimeout);
this.refresherReachMaxAngle = true;
this.isTouchEnded = true;
const refresherThreshold = this.finalRefresherThreshold;
if (moveDis >= refresherThreshold && (this.refresherStatus === Enum.Refresher.ReleaseToRefresh || this.refresherStatus === Enum.Refresher.GoF2)) {
// 如果是松手进入二楼状态,则触发进入二楼
if (this.refresherStatus === Enum.Refresher.GoF2) {
this._handleGoF2();
this._refresherEnd();
} else {
// 如果是松手立即刷新状态,则触发下拉刷新
// #ifndef APP-VUE || MP-WEIXIN || MP-QQ || H5
this.refresherTransform = `translateY(${refresherThreshold}px)`;
this.refresherTransition = 'transform .1s linear';
// #endif
u.delay(() => {
this._emitTouchmove({ pullingDistance: refresherThreshold, dy: this.moveDis - refresherThreshold });
}, 0.1);
this.moveDis = refresherThreshold;
this.refresherStatus = Enum.Refresher.Loading;
this._doRefresherLoad();
}
} else {
this._refresherEnd();
this.isTouchmovingTimeout = u.delay(() => {
this.isTouchmoving = false;
}, this.refresherDefaultDuration);
}
this.scrollEnable = true;
this.$emit('refresherTouchend', moveDis);
},
// 处理列表触摸开始事件
_handleListTouchstart() {
if (this.useChatRecordMode && this.autoHideKeyboardWhenChat) {
uni.hideKeyboard();
this.$emit('hidedKeyboard');
}
},
// 处理scroll-view bounce是否生效
_handleScrollViewBounce({ bounce }) {
if (!this.usePageScroll && !this.scrollToTopBounceEnabled) {
if (this.wxsScrollTop <= 5) {
// #ifdef APP-VUE || MP-WEIXIN || MP-QQ || H5
this.refresherTransition = '';
// #endif
this.scrollEnable = bounce;
} else if (bounce) {
this.scrollEnable = bounce;
}
}
},
// wxs正在下拉状态改变处理
_handleWxsPullingDownStatusChange(onPullingDown) {
this.wxsOnPullingDown = onPullingDown;
if (onPullingDown && !this.useChatRecordMode) {
this.renderPropScrollTop = 0;
}
},
// wxs正在下拉处理
_handleWxsPullingDown({ moveDis, diffDis }){
this._emitTouchmove({ pullingDistance: moveDis,dy: diffDis });
},
// wxs触摸方向改变
_handleTouchDirectionChange({ direction }) {
this.$emit('touchDirectionChange',direction);
},
// wxs通知更新其props
_handlePropUpdate(){
this.wxsPropType = u.getTime().toString();
},
// 下拉刷新结束
_refresherEnd(shouldEndLoadingDelay = true, fromAddData = false, isUserPullDown = false, setLoading = true) {
if (this.loadingType === Enum.LoadingType.Refresher) {
// 计算当前下拉刷新结束需要延迟的时间
const refresherCompleteDelay = (fromAddData && (isUserPullDown || this.showRefresherWhenReload)) ? this.refresherCompleteDelay : 0;
// 如果延迟时间大于0,则展示刷新结束状态,否则直接展示默认状态
const refresherStatus = refresherCompleteDelay > 0 ? Enum.Refresher.Complete : Enum.Refresher.Default;
if (this.finalShowRefresherWhenReload) {
const stackCount = this.refresherRevealStackCount;
this.refresherRevealStackCount --;
if (stackCount > 1) return;
}
this._cleanRefresherEndTimeout();
this.refresherEndTimeout = u.delay(() => {
// 更新下拉刷新状态
this.refresherStatus = refresherStatus;
// 如果当前下拉刷新状态不是刷新结束,则认为其不在刷新结束状态
if (refresherStatus !== Enum.Refresher.Complete) {
this.isRefresherInComplete = false;
}
}, this.refresherStatus !== Enum.Refresher.Default && refresherStatus === Enum.Refresher.Default ? this.refresherCompleteDuration : 0);
// #ifndef APP-NVUE
if (refresherCompleteDelay > 0) {
this.isRefresherInComplete = true;
}
// #endif
this._cleanRefresherCompleteTimeout();
this.refresherCompleteTimeout = u.delay(() => {
let animateDuration = 1;
const animateType = this.refresherEndBounceEnabled && fromAddData ? 'cubic-bezier(0.19,1.64,0.42,0.72)' : 'linear';
if (fromAddData) {
animateDuration = this.refresherEndBounceEnabled ? this.refresherCompleteDuration / 1000 : this.refresherCompleteDuration / 3000;
}
this.refresherTransition = `transform ${fromAddData ? animateDuration : this.refresherDefaultDuration / 1000}s ${animateType}`;
// #ifndef APP-VUE || MP-WEIXIN || MP-QQ || H5
this.refresherTransform = 'translateY(0px)';
this.currentDis = 0;
// #endif
// #ifdef APP-VUE || MP-WEIXIN || MP-QQ || H5
this.wxsPropType = this.refresherTransition + 'end' + u.getTime();
// #endif
// #ifdef APP-NVUE
this._nRefresherEnd();
// #endif
this.moveDis = 0;
// #ifndef APP-NVUE
if (refresherStatus === Enum.Refresher.Complete) {
if (this.refresherCompleteSubTimeout) {
clearTimeout(this.refresherCompleteSubTimeout);
this.refresherCompleteSubTimeout = null;
}
this.refresherCompleteSubTimeout = u.delay(() => {
this.$nextTick(() => {
this.refresherStatus = Enum.Refresher.Default;
this.isRefresherInComplete = false;
})
}, animateDuration * 800);
}
// #endif
this._emitTouchmove({ pullingDistance: 0, dy: this.moveDis });
}, refresherCompleteDelay);
}
if (setLoading) {
u.delay(() => this.loading = false, shouldEndLoadingDelay ? 10 : 0);
isUserPullDown && this._onRestore();
}
},
// 处理进入二楼
_handleGoF2() {
if (this.showF2 || !this.refresherF2Enabled) return;
this.$emit('refresherF2Change', 'go');
if (!this.showRefresherF2) return;
// #ifndef APP-NVUE
this.f2Transform = `translateY(${-this.superContentHeight}px)`;
this.showF2 = true;
u.delay(() => {
this.f2Transform = 'translateY(0px)';
}, 100, 'f2ShowDelay')
// #endif
// #ifdef APP-NVUE
this.showF2 = true;
this.$nextTick(() => {
weexAnimation.transition(this.$refs['zp-n-f2'], {
styles: { transform: `translateY(${-this.superContentHeight}px)` },
duration: 0,
timingFunction: 'linear',
needLayout: true,
delay: 0
})
this.nF2Opacity = 1;
})
u.delay(() => {
weexAnimation.transition(this.$refs['zp-n-f2'], {
styles: { transform: 'translateY(0px)' },
duration: this.refresherF2Duration,
timingFunction: 'linear',
needLayout: true,
delay: 0
})
}, 10, 'f2GoDelay')
// #endif
},
// 处理退出二楼
_handleCloseF2() {
if (!this.showF2 || !this.refresherF2Enabled) return;
this.$emit('refresherF2Change', 'close');
if (!this.showRefresherF2) return;
// #ifndef APP-NVUE
this.f2Transform = `translateY(${-this.superContentHeight}px)`;
// #endif
// #ifdef APP-NVUE
weexAnimation.transition(this.$refs['zp-n-f2'], {
styles: { transform: `translateY(${-this.superContentHeight}px)` },
duration: this.refresherF2Duration,
timingFunction: 'linear',
needLayout: true,
delay: 0
})
// #endif
u.delay(() => {
this.showF2 = false;
this.nF2Opacity = 0;
}, this.refresherF2Duration, 'f2CloseDelay')
},
// 模拟用户手动触发下拉刷新
_doRefresherRefreshAnimate() {
this._cleanRefresherCompleteTimeout();
// 用户处理用户在短时间内多次调用reload的情况,此时下拉刷新view不需要重复显示,只需要保证最后一次reload对应的请求结束后收回下拉刷新view即可
// #ifndef APP-NVUE
const doRefreshAnimateAfter = !this.doRefreshAnimateAfter && (this.finalShowRefresherWhenReload) && this
.customRefresherHeight === -1 && this.refresherThreshold === u.addUnit(80, this.unit);
if (doRefreshAnimateAfter) {
this.doRefreshAnimateAfter = true;
return;
}
// #endif
this.refresherRevealStackCount ++;
// #ifndef APP-VUE || MP-WEIXIN || MP-QQ || H5
this.refresherTransform = `translateY(${this.finalRefresherThreshold}px)`;
// #endif
// #ifdef APP-VUE || MP-WEIXIN || MP-QQ || H5
this.wxsPropType = 'begin' + u.getTime();
// #endif
this.moveDis = this.finalRefresherThreshold;
this.refresherStatus = Enum.Refresher.Loading;
this.isTouchmoving = true;
this.isTouchmovingTimeout && clearTimeout(this.isTouchmovingTimeout);
this._doRefresherLoad(false);
},
// 触发下拉刷新
_doRefresherLoad(isUserPullDown = true) {
this._onRefresh(false, isUserPullDown);
this.loading = true;
},
// #ifndef APP-VUE || MP-WEIXIN || MP-QQ || H5
// 获取处理后的moveDis
_getFinalRefresherMoveDis(moveDis) {
let diffDis = moveDis - this.oldCurrentMoveDis;
this.oldCurrentMoveDis = moveDis;
if (diffDis > 0) {
// 根据配置的下拉刷新用户手势位移与实际需要的位移比率计算最终的diffDis
diffDis = diffDis * this.finalRefresherPullRate;
if (this.currentDis > this.finalRefresherThreshold) {
diffDis = diffDis * (1 - this.finalRefresherOutRate);
}
}
// 控制diffDis过大的情况,比如进入页面突然猛然下拉,此时diffDis不应进行太大的偏移
diffDis = diffDis > 100 ? diffDis / 100 : diffDis;
this.currentDis += diffDis;
this.currentDis = Math.max(0, this.currentDis);
return this.currentDis;
},
// 判断touch手势是否要触发
_touchDisabled() {
const checkOldScrollTop = this.oldScrollTop > 5;
return this.loading || this.isRefresherInComplete || this.useChatRecordMode || !this.refresherEnabled || !this.useCustomRefresher ||(this.usePageScroll && this.useCustomRefresher && this.pageScrollTop > 10) || (!(this.usePageScroll && this.useCustomRefresher) && checkOldScrollTop);
},
// #endif
// 更新自定义下拉刷新view高度
_updateCustomRefresherHeight() {
this._getNodeClientRect('.zp-custom-refresher-slot-view').then((res) => {
this.customRefresherHeight = res ? res[0].height : 0;
this.showCustomRefresher = this.customRefresherHeight > 0;
if (this.doRefreshAnimateAfter) {
this.doRefreshAnimateAfter = false;
this._doRefresherRefreshAnimate();
}
});
},
// emit pullingDown事件
_emitTouchmove(e) {
// #ifndef APP-NVUE
e.viewHeight = this.finalRefresherThreshold;
// #endif
e.rate = e.viewHeight > 0 ? e.pullingDistance / e.viewHeight : 0;
this.hasTouchmove && this.oldPullingDistance !== e.pullingDistance && this.$emit('refresherTouchmove', e);
this.oldPullingDistance = e.pullingDistance;
},
// 清除refresherCompleteTimeout
_cleanRefresherCompleteTimeout() {
this.refresherCompleteTimeout = this._cleanTimeout(this.refresherCompleteTimeout);
// #ifdef APP-NVUE
this._nRefresherEnd(false);
// #endif
},
// 清除refresherEndTimeout
_cleanRefresherEndTimeout() {
this.refresherEndTimeout = this._cleanTimeout(this.refresherEndTimeout);
},
}
}
@@ -0,0 +1,550 @@
// [z-paging]scroll相关模块
import u from '.././z-paging-utils'
import Enum from '.././z-paging-enum'
// #ifdef APP-NVUE
const weexDom = weex.requireModule('dom');
// #endif
export default {
props: {
// 使用页面滚动,默认为否,当设置为是时则使用页面的滚动而非此组件内部的scroll-view的滚动,使用页面滚动时z-paging无需设置确定的高度且对于长列表展示性能更高,但配置会略微繁琐
usePageScroll: {
type: Boolean,
default: u.gc('usePageScroll', false)
},
// 是否可以滚动,使用内置scroll-view和nvue时有效,默认为是
scrollable: {
type: Boolean,
default: u.gc('scrollable', true)
},
// 控制是否出现滚动条,默认为是
showScrollbar: {
type: Boolean,
default: u.gc('showScrollbar', true)
},
// 是否允许横向滚动,默认为否
scrollX: {
type: Boolean,
default: u.gc('scrollX', false)
},
// iOS设备上滚动到顶部时是否允许回弹效果,默认为否。关闭回弹效果后可使滚动到顶部与下拉刷新更连贯,但是有吸顶view时滚动到顶部时可能出现抖动。
scrollToTopBounceEnabled: {
type: Boolean,
default: u.gc('scrollToTopBounceEnabled', false)
},
// iOS设备上滚动到底部时是否允许回弹效果,默认为是。
scrollToBottomBounceEnabled: {
type: Boolean,
default: u.gc('scrollToBottomBounceEnabled', true)
},
// 在设置滚动条位置时使用动画过渡,默认为否
scrollWithAnimation: {
type: Boolean,
default: u.gc('scrollWithAnimation', false)
},
// 值应为某子元素id(id不能以数字开头)。设置哪个方向可滚动,则在哪个方向滚动到该元素
scrollIntoView: {
type: String,
default: u.gc('scrollIntoView', '')
},
},
data() {
return {
scrollTop: 0,
oldScrollTop: 0,
scrollLeft: 0,
oldScrollLeft: 0,
scrollViewStyle: {},
scrollViewContainerStyle: {},
scrollViewInStyle: {},
pageScrollTop: -1,
scrollEnable: true,
privateScrollWithAnimation: -1,
cacheScrollNodeHeight: -1,
superContentHeight: 0,
}
},
watch: {
oldScrollTop(newVal) {
!this.usePageScroll && this._scrollTopChange(newVal,false);
},
pageScrollTop(newVal) {
this.usePageScroll && this._scrollTopChange(newVal,true);
},
usePageScroll: {
handler(newVal) {
this.loaded && this.autoHeight && this._setAutoHeight(!newVal);
// #ifdef H5
if (newVal) {
this.$nextTick(() => {
const mainScrollRef = this.$refs['zp-scroll-view'].$refs.main;
if (mainScrollRef) {
mainScrollRef.style = {};
}
})
}
// #endif
},
immediate: true
},
finalScrollTop(newVal) {
this.renderPropScrollTop = newVal < 6 ? 0 : 10;
}
},
computed: {
finalScrollWithAnimation() {
if (this.privateScrollWithAnimation !== -1) {
return this.privateScrollWithAnimation === 1;
}
return this.scrollWithAnimation;
},
finalScrollViewStyle() {
if (this.superContentZIndex != 1) {
this.scrollViewStyle['z-index'] = this.superContentZIndex;
this.scrollViewStyle['position'] = 'relative';
}
return this.scrollViewStyle;
},
finalScrollTop() {
return this.usePageScroll ? this.pageScrollTop : this.oldScrollTop;
},
// 当前是否是旧版webview
finalIsOldWebView() {
return this.isOldWebView && !this.usePageScroll;
},
// 当前scroll-view/list-view是否允许滚动
finalScrollable() {
return this.scrollable && !this.usePageScroll && this.scrollEnable
&& (this.refresherCompleteScrollable ? true : this.refresherStatus !== Enum.Refresher.Complete)
&& (this.refresherRefreshingScrollable ? true : this.refresherStatus !== Enum.Refresher.Loading);
}
},
methods: {
// 滚动到顶部,animate为是否展示滚动动画,默认为是
scrollToTop(animate, checkReverse = true) {
// 如果是聊天记录模式并且列表倒置了,则滚动到顶部实际上是滚动到底部
if (this.useChatRecordMode && checkReverse && !this.isChatRecordModeAndNotInversion) {
this.scrollToBottom(animate, false);
return;
}
this.$nextTick(() => {
this._scrollToTop(animate, false);
// #ifdef APP-NVUE
if (this.nvueFastScroll && animate) {
u.delay(() => {
this._scrollToTop(false, false);
});
}
// #endif
})
},
// 滚动到底部,animate为是否展示滚动动画,默认为是
scrollToBottom(animate, checkReverse = true) {
// 如果是聊天记录模式并且列表倒置了,则滚动到底部实际上是滚动到顶部
if (this.useChatRecordMode && checkReverse && !this.isChatRecordModeAndNotInversion) {
this.scrollToTop(animate, false);
return;
}
this.$nextTick(() => {
this._scrollToBottom(animate);
// #ifdef APP-NVUE
if (this.nvueFastScroll && animate) {
u.delay(() => {
this._scrollToBottom(false);
});
}
// #endif
})
},
// 滚动到指定view(vue中有效)。sel为需要滚动的view的id值,不包含"#"offset为偏移量,单位为px;animate为是否展示滚动动画,默认为否
scrollIntoViewById(sel, offset, animate) {
this._scrollIntoView(sel, offset, animate);
},
// 滚动到指定view(vue中有效)。nodeTop为需要滚动的view的top值(通过uni.createSelectorQuery()获取)offset为偏移量,单位为px;animate为是否展示滚动动画,默认为否
scrollIntoViewByNodeTop(nodeTop, offset, animate) {
this.scrollTop = this.oldScrollTop;
this.$nextTick(() => {
this._scrollIntoViewByNodeTop(nodeTop, offset, animate);
})
},
// y轴滚动到指定位置(vue中有效)。y为与顶部的距离,单位为px;offset为偏移量,单位为px;animate为是否展示滚动动画,默认为否
scrollToY(y, offset, animate) {
this.scrollTop = this.oldScrollTop;
this.$nextTick(() => {
this._scrollToY(y, offset, animate);
})
},
// x轴滚动到指定位置(非页面滚动且在vue中有效)。x为与左侧的距离,单位为px;offset为偏移量,单位为px;animate为是否展示滚动动画,默认为否
scrollToX(x, offset, animate) {
this.scrollLeft = this.oldScrollLeft;
this.$nextTick(() => {
this._scrollToX(x, offset, animate);
})
},
// 滚动到指定view(nvue中和虚拟列表中有效)。index为需要滚动的view的index(第几个,从0开始)offset为偏移量,单位为px;animate为是否展示滚动动画,默认为否
scrollIntoViewByIndex(index, offset, animate) {
if (index >= this.realTotalData.length) {
u.consoleErr('当前滚动的index超出已渲染列表长度,请先通过refreshToPage加载到对应index页并等待渲染成功后再调用此方法!');
return;
}
this.$nextTick(() => {
// #ifdef APP-NVUE
// 在nvue中,根据index获取对应节点信息并滚动到此节点位置
this._scrollIntoView(index, offset, animate);
// #endif
// #ifndef APP-NVUE
if (this.finalUseVirtualList) {
const isCellFixed = this.cellHeightMode === Enum.CellHeightMode.Fixed;
u.delay(() => {
if (this.finalUseVirtualList) {
// 虚拟列表 + 每个cell高度完全相同模式下,此时滚动到对应index的cell就是滚动到scrollTop = cellHeight * index的位置
// 虚拟列表 + 高度是动态非固定的模式下,此时滚动到对应index的cell就是滚动到scrollTop = 缓存的cell高度数组中第index个的lastTotalHeight的位置
const scrollTop = isCellFixed ? this.virtualCellHeight * index : this.virtualHeightCacheList[index].lastTotalHeight;
this.scrollToY(scrollTop, offset, animate);
}
}, isCellFixed ? 0 : 100)
}
// #endif
})
},
// 滚动到指定view(nvue中有效)。view为需要滚动的view(通过`this.$refs.xxx`获取),不包含"#"offset为偏移量,单位为px;animate为是否展示滚动动画,默认为否
scrollIntoViewByView(view, offset, animate) {
this._scrollIntoView(view, offset, animate);
},
// 当使用页面滚动并且自定义下拉刷新时,请在页面的onPageScroll中调用此方法,告知z-paging当前的pageScrollTop,否则会导致在任意位置都可以下拉刷新
updatePageScrollTop(value) {
this.pageScrollTop = value;
},
// 当使用页面滚动并且设置了slot="top"时,默认初次加载会自动获取其高度,并使内部容器下移,当slot="top"的view高度动态改变时,在其高度需要更新时调用此方法
updatePageScrollTopHeight() {
this._updatePageScrollTopOrBottomHeight('top');
},
// 当使用页面滚动并且设置了slot="bottom"时,默认初次加载会自动获取其高度,并使内部容器下移,当slot="bottom"的view高度动态改变时,在其高度需要更新时调用此方法
updatePageScrollBottomHeight() {
this._updatePageScrollTopOrBottomHeight('bottom');
},
// 更新slot="left"和slot="right"宽度,当slot="left"或slot="right"宽度动态改变时调用
updateLeftAndRightWidth() {
if (!this.finalIsOldWebView) return;
this.$nextTick(() => this._updateLeftAndRightWidth(this.scrollViewContainerStyle, 'zp-page'));
},
// 更新z-paging内置scroll-view的scrollTop
updateScrollViewScrollTop(scrollTop, animate = true) {
this._updatePrivateScrollWithAnimation(animate);
this.scrollTop = this.oldScrollTop;
this.$nextTick(() => {
this.scrollTop = scrollTop;
this.oldScrollTop = this.scrollTop;
});
},
// 当滚动到顶部时
_onScrollToUpper() {
this._emitScrollEvent('scrolltoupper');
this.$emit('scrollTopChange', 0);
this.$nextTick(() => {
this.oldScrollTop = 0;
})
},
// 当滚动到底部时
_onScrollToLower(e) {
(!e.detail || !e.detail.direction || e.detail.direction === 'bottom')
&& this.toBottomLoadingMoreEnabled
&& this._onLoadingMore(this.useChatRecordMode ? 'click' : 'toBottom')
},
// 滚动到顶部
_scrollToTop(animate = true, isPrivate = true) {
// #ifdef APP-NVUE
// 在nvue中需要通过weex.scrollToElement滚动到顶部,此时在顶部插入了一个view,使得滚动到这个view位置
const el = this.$refs['zp-n-list-top-tag'];
if (this.usePageScroll) {
this._getNodeClientRect('zp-page-scroll-top', false).then(node => {
const nodeHeight = node ? node[0].height : 0;
weexDom.scrollToElement(el, {
offset: -nodeHeight,
animated: animate
});
});
} else {
if (!this.isIos && this.nvueListIs === 'scroller') {
this._getNodeClientRect('zp-n-refresh-container', false).then(node => {
const nodeHeight = node ? node[0].height : 0;
weexDom.scrollToElement(el, {
offset: -nodeHeight,
animated: animate
});
});
} else {
weexDom.scrollToElement(el, {
offset: 0,
animated: animate
});
}
}
return;
// #endif
if (this.usePageScroll) {
this.$nextTick(() => {
uni.pageScrollTo({
scrollTop: 0,
duration: animate ? 100 : 0,
});
});
return;
}
this._updatePrivateScrollWithAnimation(animate);
this.scrollTop = this.oldScrollTop;
this.$nextTick(() => {
this.scrollTop = 0;
this.oldScrollTop = this.scrollTop;
});
},
// 滚动到底部
async _scrollToBottom(animate = true) {
// #ifdef APP-NVUE
// 在nvue中需要通过weex.scrollToElement滚动到顶部,此时在底部插入了一个view,使得滚动到这个view位置
const el = this.$refs['zp-n-list-bottom-tag'];
if (el) {
weexDom.scrollToElement(el, {
offset: 0,
animated: animate
});
} else {
u.consoleErr('滚动到底部失败,因为您设置了hideNvueBottomTag为true');
}
return;
// #endif
if (this.usePageScroll) {
this.$nextTick(() => {
uni.pageScrollTo({
scrollTop: Number.MAX_VALUE,
duration: animate ? 100 : 0,
});
});
return;
}
try {
this._updatePrivateScrollWithAnimation(animate);
const pagingContainerNode = await this._getNodeClientRect('.zp-paging-container');
const scrollViewNode = await this._getNodeClientRect('.zp-scroll-view');
const pagingContainerH = pagingContainerNode ? pagingContainerNode[0].height : 0;
const scrollViewH = scrollViewNode ? scrollViewNode[0].height : 0;
if (pagingContainerH > scrollViewH) {
this.scrollTop = this.oldScrollTop;
this.$nextTick(() => {
this.scrollTop = pagingContainerH - scrollViewH + this.virtualPlaceholderTopHeight;
this.oldScrollTop = this.scrollTop;
});
}
} catch (e) {}
},
// 滚动到指定view
_scrollIntoView(sel, offset = 0, animate = false, finishCallback) {
try {
this.scrollTop = this.oldScrollTop;
this.$nextTick(() => {
// #ifdef APP-NVUE
const refs = this.$parent.$refs;
if (!refs) return;
const dataType = Object.prototype.toString.call(sel);
let el = null;
if (dataType === '[object Number]') {
const els = refs[`z-paging-${sel}`];
el = els ? els[0] : null;
} else if (dataType === '[object Array]') {
el = sel[0];
} else {
el = sel;
}
if (el) {
weexDom.scrollToElement(el, {
offset: -offset,
animated: animate
});
} else {
u.consoleErr('在nvue中滚动到指定位置,cell必须设置 :ref="`z-paging-${index}`"');
}
return;
// #endif
this._getNodeClientRect('#' + sel.replace('#', ''), this.$parent).then((node) => {
if (node) {
let nodeTop = node[0].top;
this._scrollIntoViewByNodeTop(nodeTop, offset, animate);
finishCallback && finishCallback();
}
});
});
} catch (e) {}
},
// 通过nodeTop滚动到指定view
_scrollIntoViewByNodeTop(nodeTop, offset = 0, animate = false) {
// 如果是聊天记录模式并且列表倒置了,此时nodeTop需要等于scroll-view高度 - nodeTop
if (this.isChatRecordModeAndInversion) {
this._getNodeClientRect('.zp-scroll-view').then(sNode => {
if (sNode) {
this._scrollToY(sNode[0].height - nodeTop, offset, animate, true);
}
})
} else {
this._scrollToY(nodeTop, offset, animate, true);
}
},
// y轴滚动到指定位置
_scrollToY(y, offset = 0, animate = false, addScrollTop = false) {
this._updatePrivateScrollWithAnimation(animate);
u.delay(() => {
if (this.usePageScroll) {
if (addScrollTop && this.pageScrollTop !== -1) {
y += this.pageScrollTop;
}
const scrollTop = y - offset;
uni.pageScrollTo({
scrollTop,
duration: animate ? 100 : 0
});
} else {
if (addScrollTop) {
y += this.oldScrollTop;
}
this.scrollTop = y - offset;
}
}, 10)
},
// x轴滚动到指定位置
_scrollToX(x, offset = 0, animate = false) {
this._updatePrivateScrollWithAnimation(animate);
u.delay(() => {
if (!this.usePageScroll) {
this.scrollLeft = x - offset;
} else {
u.consoleErr('使用页面滚动时不支持scrollToX');
}
}, 10)
},
// scroll-view滚动中
_scroll(e) {
this.$emit('scroll', e);
const { scrollTop, scrollLeft } = e.detail;
// #ifndef APP-NVUE
this.finalUseVirtualList && this._updateVirtualScroll(scrollTop, this.oldScrollTop - scrollTop);
// #endif
this.oldScrollTop = scrollTop;
this.oldScrollLeft = scrollLeft;
// 滚动区域内容的总高度 - 当前滚动的scrollTop = 当前滚动区域的顶部与内容底部的距离
const scrollDiff = e.detail.scrollHeight - this.oldScrollTop;
// 在非ios平台滚动中,再次验证一下是否滚动到了底部。因为在一些安卓设备中,有概率滚动到底部不触发@scrolltolower事件,因此添加双重检测逻辑
!this.isIos && this._checkScrolledToBottom(scrollDiff);
},
// emit scrolltolower/scrolltoupper事件
_emitScrollEvent(type) {
const reversedType = type === 'scrolltolower' ? 'scrolltoupper' : 'scrolltolower';
const eventType = this.useChatRecordMode && !this.isChatRecordModeAndNotInversion
? reversedType
: type;
this.$emit(eventType);
},
// 更新内置的scroll-view是否启用滚动动画
_updatePrivateScrollWithAnimation(animate) {
this.privateScrollWithAnimation = animate ? 1 : 0;
u.delay(() => this.$nextTick(() => {
// 在滚动结束后将滚动动画状态设置回初始状态
this.privateScrollWithAnimation = -1;
}), 100, 'updateScrollWithAnimationDelay')
},
// 检测scrollView是否要铺满屏幕
_doCheckScrollViewShouldFullHeight(totalData) {
if (this.autoFullHeight && this.usePageScroll && this.isTotalChangeFromAddData) {
// #ifndef APP-NVUE
this.$nextTick(() => {
this._checkScrollViewShouldFullHeight((scrollViewNode, pagingContainerNode) => {
this._preCheckShowNoMoreInside(totalData, scrollViewNode, pagingContainerNode)
});
})
// #endif
// #ifdef APP-NVUE
this._preCheckShowNoMoreInside(totalData)
// #endif
} else {
this._preCheckShowNoMoreInside(totalData)
}
},
// 检测z-paging是否要全屏覆盖(当使用页面滚动并且不满全屏时,默认z-paging需要铺满全屏,避免数据过少时内部的empty-view无法正确展示)
async _checkScrollViewShouldFullHeight(callback) {
try {
const scrollViewNode = await this._getNodeClientRect('.zp-scroll-view');
const pagingContainerNode = await this._getNodeClientRect('.zp-paging-container-content');
if (!scrollViewNode || !pagingContainerNode) return;
const scrollViewHeight = pagingContainerNode[0].height;
const scrollViewTop = scrollViewNode[0].top;
if (this.isAddedData && scrollViewHeight + scrollViewTop <= this.windowHeight) {
this._setAutoHeight(true, scrollViewNode);
callback(scrollViewNode, pagingContainerNode);
} else {
this._setAutoHeight(false);
callback(null, null);
}
} catch (e) {
callback(null, null);
}
},
// 更新缓存中z-paging整个内容容器高度
async _updateCachedSuperContentHeight() {
const superContentNode = await this._getNodeClientRect('.z-paging-content');
if (superContentNode) {
this.superContentHeight = superContentNode[0].height;
}
},
// scrollTop改变时触发
_scrollTopChange(newVal, isPageScrollTop){
this.$emit('scrollTopChange', newVal);
this.$emit('update:scrollTop', newVal);
this._checkShouldShowBackToTop(newVal);
// 之前在安卓中scroll-view有概率滚动到顶部时scrollTop不为0导致下拉刷新判断异常,因此判断scrollTop在105之内都允许下拉刷新,但此方案会导致某些情况(例如滚动到距离顶部10px处)下拉抖动,因此改为通过获取zp-scroll-view的节点信息中的scrollTop进行验证的方案
// const scrollTop = this.isIos ? (newVal > 5 ? 6 : 0) : (newVal > 105 ? 106 : (newVal > 5 ? 6 : 0));
const scrollTop = newVal > 5 ? 6 : 0;
if (isPageScrollTop && this.wxsPageScrollTop !== scrollTop) {
this.wxsPageScrollTop = scrollTop;
} else if (!isPageScrollTop && this.wxsScrollTop !== scrollTop) {
this.wxsScrollTop = scrollTop;
if (scrollTop > 6) {
this.scrollEnable = true;
}
}
},
// 更新使用页面滚动时slot="top"或"bottom"插入view的高度
_updatePageScrollTopOrBottomHeight(type) {
// #ifndef APP-NVUE
if (!this.usePageScroll) return;
// #endif
this._doCheckScrollViewShouldFullHeight(this.realTotalData);
const node = `.zp-page-${type}`;
const marginText = `margin${type.slice(0,1).toUpperCase() + type.slice(1)}`;
let safeAreaInsetBottomAdd = this.safeAreaInsetBottom;
this.$nextTick(() => {
let delayTime = 0;
// #ifdef MP-BAIDU || APP-NVUE
delayTime = 50;
// #endif
u.delay(() => {
this._getNodeClientRect(node).then((res) => {
if (res) {
let pageScrollNodeHeight = res[0].height;
if (type === 'bottom') {
if (safeAreaInsetBottomAdd) {
pageScrollNodeHeight += this.safeAreaBottom;
}
} else {
this.cacheTopHeight = pageScrollNodeHeight;
}
this.$set(this.scrollViewStyle, marginText, `${pageScrollNodeHeight}px`);
} else if (safeAreaInsetBottomAdd) {
this.$set(this.scrollViewStyle, marginText, `${this.safeAreaBottom}px`);
}
});
}, delayTime)
})
},
}
}
@@ -0,0 +1,555 @@
// [z-paging]虚拟列表模块
import u from '.././z-paging-utils'
import c from '.././z-paging-constant'
import Enum from '.././z-paging-enum'
export default {
props: {
// 是否使用虚拟列表,默认为否
useVirtualList: {
type: Boolean,
default: u.gc('useVirtualList', false)
},
// 在使用虚拟列表时,是否使用兼容模式,默认为否
useCompatibilityMode: {
type: Boolean,
default: u.gc('useCompatibilityMode', false)
},
// 使用兼容模式时传递的附加数据
extraData: {
type: Object,
default: u.gc('extraData', {})
},
// 是否在z-paging内部循环渲染列表(内置列表),默认为否。若use-virtual-list为true,则此项恒为true
useInnerList: {
type: Boolean,
default: u.gc('useInnerList', false)
},
// 强制关闭inner-list,默认为false,如果为true将强制关闭innerList,适用于开启了虚拟列表后需要强制关闭inner-list的情况
forceCloseInnerList: {
type: Boolean,
default: u.gc('forceCloseInnerList', false)
},
// 内置列表cell的key名称,仅nvue有效,在nvue中开启use-inner-list时必须填此项
cellKeyName: {
type: String,
default: u.gc('cellKeyName', '')
},
// innerList样式
innerListStyle: {
type: Object,
default: u.gc('innerListStyle', {})
},
// innerCell样式
innerCellStyle: {
type: Object,
default: u.gc('innerCellStyle', {})
},
// 预加载的列表可视范围(列表高度)页数,默认为12,即预加载当前页及上下各12页的cell。此数值越大,则虚拟列表中加载的dom越多,内存消耗越大(会维持在一个稳定值),但增加预加载页面数量可缓解快速滚动短暂白屏问题
preloadPage: {
type: [Number, String],
default: u.gc('preloadPage', 12),
validator: (value) => {
if (value <= 0) u.consoleErr('preload-page必须大于0');
return value > 0;
}
},
// 虚拟列表cell高度模式,默认为fixed,也就是每个cell高度完全相同,将以第一个cell高度为准进行计算。可选值【dynamic】,即代表高度是动态非固定的,【dynamic】性能低于【fixed】。
cellHeightMode: {
type: String,
default: u.gc('cellHeightMode', Enum.CellHeightMode.Fixed)
},
// 固定的cell高度,cellHeightMode=fixed才有效,若设置了值,则不计算第一个cell高度而使用设置的cell高度
fixedCellHeight: {
type: [Number, String],
default: u.gc('fixedCellHeight', 0)
},
// 虚拟列表列数,默认为1。常用于每行有多列的情况,例如每行有2列数据,需要将此值设置为2
virtualListCol: {
type: [Number, String],
default: u.gc('virtualListCol', 1)
},
// 虚拟列表scroll取样帧率,默认为80,过低容易出现白屏问题,过高容易出现卡顿问题
virtualScrollFps: {
type: [Number, String],
default: u.gc('virtualScrollFps', 80)
},
// 虚拟列表cell id的前缀,适用于一个页面有多个虚拟列表的情况,用以区分不同虚拟列表cell的id,注意:请勿传数字或以数字开头的字符串。如设置为list1,则cell的id应为:list1-zp-id-${item.zp_index}
virtualCellIdPrefix: {
type: String,
default: u.gc('virtualCellIdPrefix', '')
},
// 虚拟列表是否使用swiper-item包裹,默认为否,此属性为了解决vue3+(微信小程序或QQ小程序)中,使用非内置列表写法时,若z-paging在swiper-item内存在无法获取slot插入的cell高度进而导致虚拟列表失败的问题
// 仅vue3+(微信小程序或QQ小程序)+非内置列表写法虚拟列表有效,其他情况此属性设置任何值都无效,所以如果您在swiper-item内使用z-paging的非内置虚拟列表写法,将此属性设置为true即可
virtualInSwiperSlot: {
type: Boolean,
default: false
},
},
data() {
return {
virtualListKey: u.getInstanceId(),
virtualPageHeight: 0,
virtualCellHeight: 0,
virtualScrollTimeStamp: 0,
virtualList: [],
virtualPlaceholderTopHeight: 0,
virtualPlaceholderBottomHeight: 0,
virtualTopRangeIndex: 0,
virtualBottomRangeIndex: 0,
lastVirtualTopRangeIndex: 0,
lastVirtualBottomRangeIndex: 0,
virtualItemInsertedCount: 0,
virtualHeightCacheList: [],
getCellHeightRetryCount: {
fixed: 0,
dynamic: 0
},
pagingOrgTop: -1,
updateVirtualListFromDataChange: false
}
},
watch: {
// 监听总数据的改变,刷新虚拟列表布局
realTotalData() {
this.updateVirtualListRender();
},
// 监听虚拟列表渲染数组的改变并emit
virtualList(newVal){
this.$emit('update:virtualList', newVal);
this.$emit('virtualListChange', newVal);
},
// 监听虚拟列表顶部占位高度改变并emit
virtualPlaceholderTopHeight(newVal) {
this.$emit('virtualTopHeightChange', newVal);
}
},
computed: {
virtualCellIndexKey() {
return c.listCellIndexKey;
},
finalUseVirtualList() {
if (this.useVirtualList && this.usePageScroll){
u.consoleErr('使用页面滚动时,开启虚拟列表无效!');
}
return this.useVirtualList && !this.usePageScroll;
},
finalUseInnerList() {
return this.useInnerList || (this.finalUseVirtualList && !this.forceCloseInnerList);
},
finalCellKeyName() {
// #ifdef APP-NVUE
if (this.finalUseVirtualList && !this.cellKeyName.length){
u.consoleErr('在nvue中开启use-virtual-list必须设置cell-key-name,否则将可能导致列表渲染错误!');
}
// #endif
return this.cellKeyName;
},
finalVirtualPageHeight(){
return this.virtualPageHeight > 0 ? this.virtualPageHeight : this.windowHeight;
},
finalFixedCellHeight() {
return u.convertToPx(this.fixedCellHeight);
},
fianlVirtualCellIdPrefix() {
const prefix = this.virtualCellIdPrefix ? this.virtualCellIdPrefix + '-' : '';
return prefix + 'zp-id';
},
finalPlaceholderTopHeightStyle() {
// #ifdef VUE2
return { transform: this.virtualPlaceholderTopHeight > 0 ? `translateY(${this.virtualPlaceholderTopHeight}px)` : 'none' };
// #endif
return {};
},
virtualRangePageHeight(){
return this.finalVirtualPageHeight * this.preloadPage;
},
virtualScrollDisTimeStamp() {
return 1000 / this.virtualScrollFps;
}
},
methods: {
// 在使用动态高度虚拟列表时,若在列表数组中需要插入某个item,需要调用此方法;item:需要插入的itemindex:插入的cell位置,若index为2,则插入的item在原list的index=1之后,index从0开始
doInsertVirtualListItem(item, index) {
if (this.cellHeightMode !== Enum.CellHeightMode.Dynamic) return;
this.realTotalData.splice(index, 0, item);
// #ifdef VUE3
this.realTotalData = [...this.realTotalData];
// #endif
this.virtualItemInsertedCount ++;
if (!item || Object.prototype.toString.call(item) !== '[object Object]') {
item = { item };
}
const cellIndexKey = this.virtualCellIndexKey;
item[cellIndexKey] = `custom-${this.virtualItemInsertedCount}`;
item[c.listCellIndexUniqueKey] = `${this.virtualListKey}-${item[cellIndexKey]}`;
this.$nextTick(async () => {
let retryCount = 0;
while (retryCount <= 10) {
await u.wait(c.delayTime);
const cellNode = await this._getVirtualCellNodeByIndex(item[cellIndexKey]);
// 如果获取当前cell的节点信息失败,则重试(不超过10次)
if (!cellNode) {
retryCount ++;
continue;
}
const currentHeight = cellNode ? cellNode[0].height : 0;
const lastHeightCache = this.virtualHeightCacheList[index - 1];
const lastTotalHeight = lastHeightCache ? lastHeightCache.totalHeight : 0;
// 在缓存的cell高度数组中,插入此cell高度信息
this.virtualHeightCacheList.splice(index, 0, {
height: currentHeight,
lastTotalHeight,
totalHeight: lastTotalHeight + currentHeight
});
// 从当前index起后续的cell缓存高度的lastTotalHeight和totalHeight需要加上当前cell的高度
for (let i = index + 1; i < this.virtualHeightCacheList.length; i++) {
const thisNode = this.virtualHeightCacheList[i];
thisNode.lastTotalHeight += currentHeight;
thisNode.totalHeight += currentHeight;
}
this._updateVirtualScroll(this.oldScrollTop);
break;
}
})
},
// 在使用动态高度虚拟列表时,手动更新指定cell的缓存高度(当cell高度在初始化之后再次改变后调用);index:需要更新的cell在列表中的位置,从0开始
didUpdateVirtualListCell(index) {
if (this.cellHeightMode !== Enum.CellHeightMode.Dynamic) return;
const currentNode = this.virtualHeightCacheList[index];
this.$nextTick(() => {
this._getVirtualCellNodeByIndex(index).then(cellNode => {
// 更新当前cell的高度
const cellNodeHeight = cellNode ? cellNode[0].height : 0;
const heightDis = cellNodeHeight - currentNode.height;
currentNode.height = cellNodeHeight;
currentNode.totalHeight = currentNode.lastTotalHeight + cellNodeHeight;
// 从当前index起后续的cell缓存高度的lastTotalHeight和totalHeight需要加上当前cell变化的高度
for (let i = index + 1; i < this.virtualHeightCacheList.length; i++) {
const thisNode = this.virtualHeightCacheList[i];
thisNode.totalHeight += heightDis;
thisNode.lastTotalHeight += heightDis;
}
});
})
},
// 在使用动态高度虚拟列表时,若删除了列表数组中的某个item,需要调用此方法以更新高度缓存数组;index:删除的cell在列表中的位置,从0开始
didDeleteVirtualListCell(index) {
if (this.cellHeightMode !== Enum.CellHeightMode.Dynamic) return;
const currentNode = this.virtualHeightCacheList[index];
// 从当前index起后续的cell缓存高度的lastTotalHeight和totalHeight需要减去当前cell的高度
for (let i = index + 1; i < this.virtualHeightCacheList.length; i++) {
const thisNode = this.virtualHeightCacheList[i];
thisNode.totalHeight -= currentNode.height;
thisNode.lastTotalHeight -= currentNode.height;
}
// 将当前cell的高度信息从高度缓存数组中删除
this.virtualHeightCacheList.splice(index, 1);
},
// 手动触发虚拟列表渲染更新,可用于解决例如修改了虚拟列表数组中元素,但展示未更新的情况
updateVirtualListRender() {
// #ifndef APP-NVUE
if (this.finalUseVirtualList) {
this.updateVirtualListFromDataChange = true;
this.$nextTick(() => {
this.getCellHeightRetryCount.fixed = 0;
if (this.realTotalData.length) {
this.cellHeightMode === Enum.CellHeightMode.Fixed && this.isFirstPage && this._updateFixedCellHeight()
} else {
this._resetDynamicListState(!this.isUserPullDown);
}
this._updateVirtualScroll(this.oldScrollTop);
})
}
// #endif
},
// 初始化虚拟列表
_virtualListInit() {
this.$nextTick(() => {
u.delay(() => {
// 获取虚拟列表滚动区域的高度
this._getNodeClientRect('.zp-scroll-view').then(node => {
if (node) {
this.pagingOrgTop = node[0].top;
this.virtualPageHeight = node[0].height;
}
});
});
})
},
// cellHeightMode为fixed时获取第一个cell高度
_updateFixedCellHeight() {
if (!this.finalFixedCellHeight) {
this.$nextTick(() => {
u.delay(() => {
this._getVirtualCellNodeByIndex(0).then(cellNode => {
if (!cellNode) {
if (this.getCellHeightRetryCount.fixed > 10) return;
this.getCellHeightRetryCount.fixed ++;
// 如果获取第一个cell的节点信息失败,则重试(不超过10次)
this._updateFixedCellHeight();
} else {
this.virtualCellHeight = cellNode[0].height;
this._updateVirtualScroll(this.oldScrollTop);
}
});
}, c.delayTime, 'updateFixedCellHeightDelay');
})
} else {
this.virtualCellHeight = this.finalFixedCellHeight;
}
},
// cellHeightMode为dynamic时获取每个cell高度
_updateDynamicCellHeight(list, dataFrom = 'bottom') {
const dataFromTop = dataFrom === 'top';
const heightCacheList = this.virtualHeightCacheList;
const currentCacheList = dataFromTop ? [] : heightCacheList;
let listTotalHeight = 0;
this.$nextTick(() => {
u.delay(async () => {
for (let i = 0; i < list.length; i++) {
const cellNode = await this._getVirtualCellNodeByIndex(list[i][this.virtualCellIndexKey]);
const currentHeight = cellNode ? cellNode[0].height : 0;
if (!cellNode) {
if (this.getCellHeightRetryCount.dynamic <= 10) {
heightCacheList.splice(heightCacheList.length - i, i);
this.getCellHeightRetryCount.dynamic ++;
// 如果获取当前cell的节点信息失败,则重试(不超过10次)
this._updateDynamicCellHeight(list, dataFrom);
}
return;
}
const lastHeightCache = currentCacheList.length ? currentCacheList.slice(-1)[0] : null;
const lastTotalHeight = lastHeightCache ? lastHeightCache.totalHeight : 0;
// 缓存当前cell的高度信息:height-当前cell高度;lastTotalHeight-前面所有cell的高度总和;totalHeight-包含当前cell的所有高度总和
currentCacheList.push({
height: currentHeight,
lastTotalHeight,
totalHeight: lastTotalHeight + currentHeight
});
if (dataFromTop) {
listTotalHeight += currentHeight;
}
}
// 如果数据是从顶部拼接的
if (dataFromTop && list.length) {
for (let i = 0; i < heightCacheList.length; i++) {
// 更新之前所有项的缓存高度,需要加上此次插入的所有cell高度之和(因为是从顶部插入的cell)
const heightCacheItem = heightCacheList[i];
heightCacheItem.lastTotalHeight += listTotalHeight;
heightCacheItem.totalHeight += listTotalHeight;
}
this.virtualHeightCacheList = currentCacheList.concat(heightCacheList);
}
this._updateVirtualScroll(this.oldScrollTop);
}, c.delayTime, 'updateDynamicCellHeightDelay')
})
},
// 设置cellItem的index
_setCellIndex(list, dataFrom = 'bottom') {
let currentItemIndex = 0;
const cellIndexKey = this.virtualCellIndexKey;
dataFrom === 'bottom' && ([Enum.QueryFrom.Refresh, Enum.QueryFrom.Reload].indexOf(this.queryFrom) >= 0) && this._resetDynamicListState();
if (this.totalData.length && this.queryFrom !== Enum.QueryFrom.Refresh) {
if (dataFrom === 'bottom') {
currentItemIndex = this.realTotalData.length;
const lastItem = this.realTotalData.length ? this.realTotalData.slice(-1)[0] : null;
if (lastItem && lastItem[cellIndexKey] !== undefined) {
currentItemIndex = lastItem[cellIndexKey] + 1;
}
} else if (dataFrom === 'top') {
const firstItem = this.realTotalData.length ? this.realTotalData[0] : null;
if (firstItem && firstItem[cellIndexKey] !== undefined) {
currentItemIndex = firstItem[cellIndexKey] - list.length;
}
}
} else {
this._resetDynamicListState();
}
for (let i = 0; i < list.length; i++) {
let item = list[i];
if (!item || Object.prototype.toString.call(item) !== '[object Object]') {
item = { item };
}
if (item[c.listCellIndexUniqueKey]) {
item = u.deepCopy(item);
}
item[cellIndexKey] = currentItemIndex + i;
item[c.listCellIndexUniqueKey] = `${this.virtualListKey}-${item[cellIndexKey]}`;
list[i] = item;
}
this.getCellHeightRetryCount.dynamic = 0;
this.cellHeightMode === Enum.CellHeightMode.Dynamic && this._updateDynamicCellHeight(list, dataFrom);
},
// 更新scroll滚动(虚拟列表滚动时触发)
_updateVirtualScroll(scrollTop, scrollDiff = 0) {
const currentTimeStamp = u.getTime();
scrollTop === 0 && this._resetTopRange();
if (scrollTop !== 0 && this.virtualScrollTimeStamp && currentTimeStamp - this.virtualScrollTimeStamp <= this.virtualScrollDisTimeStamp) {
return;
}
this.virtualScrollTimeStamp = currentTimeStamp;
let scrollIndex = 0;
const cellHeightMode = this.cellHeightMode;
if (cellHeightMode === Enum.CellHeightMode.Fixed) {
// 如果是固定高度的虚拟列表
// 计算当前滚动到的cell的index = scrollTop / 虚拟列表cell的固定高度
scrollIndex = parseInt(scrollTop / this.virtualCellHeight) || 0;
// 更新顶部和底部占位view的高度(为兼容考虑,顶部采用transformY的方式占位)
this._updateFixedTopRangeIndex(scrollIndex);
this._updateFixedBottomRangeIndex(scrollIndex);
} else if(cellHeightMode === Enum.CellHeightMode.Dynamic) {
// 如果是不固定高度的虚拟列表
// 当前滚动的方向
const scrollDirection = scrollDiff > 0 ? 'top' : 'bottom';
// 视图区域的高度
const rangePageHeight = this.virtualRangePageHeight;
// 顶部视图区域外的高度(顶部不需要渲染而是需要占位部分的高度)
const topRangePageOffset = scrollTop - rangePageHeight;
// 底部视图区域外的高度(底部不需要渲染而是需要占位部分的高度)
const bottomRangePageOffset = scrollTop + this.finalVirtualPageHeight + rangePageHeight;
let virtualBottomRangeIndex = 0;
let virtualPlaceholderBottomHeight = 0;
let reachedLimitBottom = false;
const heightCacheList = this.virtualHeightCacheList;
const lastHeightCache = !!heightCacheList ? heightCacheList.slice(-1)[0] : null;
let startTopRangeIndex = this.virtualTopRangeIndex;
// 如果是向底部滚动(顶部占位的高度不断增大,顶部的实际渲染cell数量不断减少)
if (scrollDirection === 'bottom') {
// 从顶部视图边缘的cell的位置开始向后查找
for (let i = startTopRangeIndex; i < heightCacheList.length; i++){
const heightCacheItem = heightCacheList[i];
// 如果查找到某个cell对应的totalHeight大于顶部视图区域外的高度,则此cell为顶部视图边缘的cell
if (heightCacheItem && heightCacheItem.totalHeight > topRangePageOffset) {
// 记录顶部视图边缘cell的index并更新顶部占位区域的高度并停止继续查找
this.virtualTopRangeIndex = i;
this.virtualPlaceholderTopHeight = heightCacheItem.lastTotalHeight;
break;
}
}
} else {
// 如果是向顶部滚动(顶部占位的高度不断减少,顶部的实际渲染cell数量不断增加)
let topRangeMatched = false;
// 从顶部视图边缘的cell的位置开始向前查找
for (let i = startTopRangeIndex; i >= 0; i--){
const heightCacheItem = heightCacheList[i];
// 如果查找到某个cell对应的totalHeight小于顶部视图区域外的高度,则此cell为顶部视图边缘的cell
if (heightCacheItem && heightCacheItem.totalHeight < topRangePageOffset) {
// 记录顶部视图边缘cell的index并更新顶部占位区域的高度并停止继续查找
this.virtualTopRangeIndex = i;
this.virtualPlaceholderTopHeight = heightCacheItem.lastTotalHeight;
topRangeMatched = true;
break;
}
}
// 如果查找不到,则认为顶部占位高度为0了,顶部cell不需要继续复用,重置topRangeIndex和placeholderTopHeight
!topRangeMatched && this._resetTopRange();
}
// 从顶部视图边缘的cell的位置开始向后查找
for (let i = this.virtualTopRangeIndex; i < heightCacheList.length; i++){
const heightCacheItem = heightCacheList[i];
// 如果查找到某个cell对应的totalHeight大于底部视图区域外的高度,则此cell为底部视图边缘的cell
if (heightCacheItem && heightCacheItem.totalHeight > bottomRangePageOffset) {
// 记录底部视图边缘cell的index并更新底部占位区域的高度并停止继续查找
virtualBottomRangeIndex = i;
virtualPlaceholderBottomHeight = lastHeightCache.totalHeight - heightCacheItem.totalHeight;
reachedLimitBottom = true;
break;
}
}
if (!reachedLimitBottom || this.virtualBottomRangeIndex === 0) {
this.virtualBottomRangeIndex = this.realTotalData.length ? this.realTotalData.length - 1 : this.pageSize;
this.virtualPlaceholderBottomHeight = 0;
} else {
this.virtualBottomRangeIndex = virtualBottomRangeIndex;
this.virtualPlaceholderBottomHeight = virtualPlaceholderBottomHeight;
}
this._updateVirtualList();
}
},
// 更新fixedCell模式下topRangeIndex&placeholderTopHeight
_updateFixedTopRangeIndex(scrollIndex) {
let virtualTopRangeIndex = this.virtualCellHeight === 0 ? 0 : scrollIndex - (parseInt(this.finalVirtualPageHeight / this.virtualCellHeight) || 1) * this.preloadPage;
virtualTopRangeIndex *= this.virtualListCol;
virtualTopRangeIndex = Math.max(0, virtualTopRangeIndex);
this.virtualTopRangeIndex = virtualTopRangeIndex;
this.virtualPlaceholderTopHeight = (virtualTopRangeIndex / this.virtualListCol) * this.virtualCellHeight;
},
// 更新fixedCell模式下bottomRangeIndex&placeholderBottomHeight
_updateFixedBottomRangeIndex(scrollIndex) {
let virtualBottomRangeIndex = this.virtualCellHeight === 0 ? this.pageSize : scrollIndex + (parseInt(this.finalVirtualPageHeight / this.virtualCellHeight) || 1) * (this.preloadPage + 1);
virtualBottomRangeIndex *= this.virtualListCol;
virtualBottomRangeIndex = Math.min(this.realTotalData.length, virtualBottomRangeIndex);
this.virtualBottomRangeIndex = virtualBottomRangeIndex;
this.virtualPlaceholderBottomHeight = (this.realTotalData.length - virtualBottomRangeIndex) * this.virtualCellHeight / this.virtualListCol;
this._updateVirtualList();
},
// 更新virtualList
_updateVirtualList() {
const shouldUpdateList = this.updateVirtualListFromDataChange || (this.lastVirtualTopRangeIndex !== this.virtualTopRangeIndex || this.lastVirtualBottomRangeIndex !== this.virtualBottomRangeIndex);
if (shouldUpdateList) {
this.updateVirtualListFromDataChange = false;
this.lastVirtualTopRangeIndex = this.virtualTopRangeIndex;
this.lastVirtualBottomRangeIndex = this.virtualBottomRangeIndex;
this.virtualList = this.realTotalData.slice(this.virtualTopRangeIndex, this.virtualBottomRangeIndex + 1);
}
},
// 重置动态cell模式下的高度缓存数据、虚拟列表和滚动状态
_resetDynamicListState(resetVirtualList = false) {
this.virtualHeightCacheList = [];
if (resetVirtualList) {
this.virtualList = [];
}
this.virtualTopRangeIndex = 0;
this.virtualPlaceholderTopHeight = 0;
},
// 重置topRangeIndex和placeholderTopHeight
_resetTopRange() {
this.virtualTopRangeIndex = 0;
this.virtualPlaceholderTopHeight = 0;
this._updateVirtualList();
},
// 检测虚拟列表当前滚动位置,如发现滚动位置不正确则重新计算虚拟列表相关参数(为解决在App中可能出现的长时间进入后台后打开App白屏的问题)
_checkVirtualListScroll() {
if (this.finalUseVirtualList) {
this.$nextTick(() => {
this._getNodeClientRect('.zp-paging-touch-view').then(node => {
const currentTop = node ? node[0].top : 0;
if (!node || (currentTop === this.pagingOrgTop && this.virtualPlaceholderTopHeight !== 0)) {
this._updateVirtualScroll(0);
}
});
})
}
},
// 获取对应index的虚拟列表cell节点信息
_getVirtualCellNodeByIndex(index) {
let inDom = this.finalUseInnerList;
// 在vue3+(微信小程序或QQ小程序)中,使用非内置列表写法时,若z-paging在swiper-item内存在无法获取slot插入的cell高度的问题
// 通过uni.createSelectorQuery().in(this.$parent)来解决此问题
// #ifdef VUE3
// #ifdef MP-WEIXIN || MP-QQ
if (this.forceCloseInnerList && this.virtualInSwiperSlot) {
inDom = this.$parent;
}
// #endif
// #endif
return this._getNodeClientRect(`#${this.fianlVirtualCellIdPrefix}-${index}`, inDom);
},
// 处理使用内置列表时点击了cell事件
_innerCellClick(item, index) {
this.$emit('innerCellClick', item, index);
}
}
}
@@ -0,0 +1,19 @@
// [z-paging]常量
export default {
// 当前版本号
version: '2.8.6',
// 延迟操作的通用时间
delayTime: 100,
// 请求失败时候全局emit使用的key
errorUpdateKey: 'z-paging-error-emit',
// 全局emit complete的key
completeUpdateKey: 'z-paging-complete-emit',
// z-paging缓存的前缀key
cachePrefixKey: 'z-paging-cache',
// 虚拟列表中列表index的key
listCellIndexKey: 'zp_index',
// 虚拟列表中列表的唯一key
listCellIndexUniqueKey: 'zp_unique_index'
}
@@ -0,0 +1,45 @@
// [z-paging]枚举
export default {
// 当前加载类型 refresher:下拉刷新 load-more:上拉加载更多
LoadingType: {
Refresher: 'refresher',
LoadMore: 'load-more'
},
// 下拉刷新状态 default:默认状态 release-to-refresh:松手立即刷新 loading:刷新中 complete:刷新结束 go-f2:松手进入二楼
Refresher: {
Default: 'default',
ReleaseToRefresh: 'release-to-refresh',
Loading: 'loading',
Complete: 'complete',
GoF2: 'go-f2'
},
// 底部加载更多状态 default:默认状态 loading:加载中 no-more:没有更多数据 fail:加载失败
More: {
Default: 'default',
Loading: 'loading',
NoMore: 'no-more',
Fail: 'fail'
},
// @query触发来源 user-pull-down:用户主动下拉刷新 reload:通过reload触发 refresh:通过refresh触发 load-more:通过滚动到底部加载更多或点击底部加载更多触发
QueryFrom: {
UserPullDown: 'user-pull-down',
Reload: 'reload',
Refresh: 'refresh',
LoadMore: 'load-more'
},
// 虚拟列表cell高度模式
CellHeightMode: {
// 固定高度
Fixed: 'fixed',
// 动态高度
Dynamic: 'dynamic'
},
// 列表缓存模式
CacheMode: {
// 默认模式,只会缓存一次
Default: 'default',
// 总是缓存,每次列表刷新(下拉刷新、调用reload等)都会更新缓存
Always: 'always'
}
}
@@ -0,0 +1,97 @@
// [z-paging]拦截器
const queryKey = 'Query';
const fetchParamsKey = 'FetchParams';
const fetchResultKey = 'FetchResult';
const language2LocalKey = 'Language2Local';
// 拦截&处理@query事件
function handleQuery(callback) {
_addHandleByKey(queryKey, callback);
return this;
}
// 拦截&处理@query事件(私有,请勿调用)
function _handleQuery(pageNo, pageSize, from, lastItem) {
const callback = _getHandleByKey(queryKey);
return callback ? callback(pageNo, pageSize, from, lastItem) : [pageNo, pageSize, from];
}
// 拦截&处理:fetch参数
function handleFetchParams(callback) {
_addHandleByKey(fetchParamsKey, callback);
return this;
}
// 拦截&处理:fetch参数(私有,请勿调用)
function _handleFetchParams(parmas, extraParams) {
const callback = _getHandleByKey(fetchParamsKey);
return callback ? callback(parmas, extraParams || {}) : { pageNo: parmas.pageNo, pageSize: parmas.pageSize, ...(extraParams || {}) };
}
// 拦截&处理:fetch结果
function handleFetchResult(callback) {
_addHandleByKey(fetchResultKey, callback);
return this;
}
// 拦截&处理:fetch结果(私有,请勿调用)
function _handleFetchResult(result, paging, params) {
const callback = _getHandleByKey(fetchResultKey);
callback && callback(result, paging, params);
return callback ? true : false;
}
// 拦截&处理系统language转i18n local
function handleLanguage2Local(callback) {
_addHandleByKey(language2LocalKey, callback);
return this;
}
// 拦截&处理系统language转i18n local(私有,请勿调用)
function _handleLanguage2Local(language, local) {
const callback = _getHandleByKey(language2LocalKey);
return callback ? callback(language, local) : local;
}
// 获取当前app对象
function _getApp(){
// #ifndef APP-NVUE
return getApp();
// #endif
// #ifdef APP-NVUE
return getApp({ allowDefault: true });
// #endif
}
// 是否可以访问globalData
function _hasGlobalData() {
return _getApp() && _getApp().globalData;
}
// 添加处理函数
function _addHandleByKey(key, callback) {
try {
setTimeout(function() {
if (_hasGlobalData()) {
_getApp().globalData[`zp_handle${key}Callback`] = callback;
}
}, 1);
} catch (_) {}
}
// 获取处理回调函数
function _getHandleByKey(key) {
return _hasGlobalData() ? _getApp().globalData[`zp_handle${key}Callback`] : null;
}
export default {
handleQuery,
_handleQuery,
handleFetchParams,
_handleFetchParams,
handleFetchResult,
_handleFetchResult,
handleLanguage2Local,
_handleLanguage2Local
};
@@ -0,0 +1,515 @@
// [z-paging]核心js
import zStatic from './z-paging-static'
import c from './z-paging-constant'
import u from './z-paging-utils'
import zPagingRefresh from '../components/z-paging-refresh'
import zPagingLoadMore from '../components/z-paging-load-more'
import zPagingEmptyView from '../../z-paging-empty-view/z-paging-empty-view'
// modules
import commonLayoutModule from './modules/common-layout'
import dataHandleModule from './modules/data-handle'
import i18nModule from './modules/i18n'
import nvueModule from './modules/nvue'
import emptyModule from './modules/empty'
import refresherModule from './modules/refresher'
import loadMoreModule from './modules/load-more'
import loadingModule from './modules/loading'
import chatRecordModerModule from './modules/chat-record-mode'
import scrollerModule from './modules/scroller'
import backToTopModule from './modules/back-to-top'
import virtualListModule from './modules/virtual-list'
import Enum from './z-paging-enum'
const systemInfo = u.getSystemInfoSync();
export default {
name: "z-paging",
components: {
zPagingRefresh,
zPagingLoadMore,
zPagingEmptyView
},
mixins: [
commonLayoutModule,
dataHandleModule,
i18nModule,
nvueModule,
emptyModule,
refresherModule,
loadMoreModule,
loadingModule,
chatRecordModerModule,
scrollerModule,
backToTopModule,
virtualListModule
],
data() {
return {
// --------------静态资源---------------
base64BackToTop: zStatic.base64BackToTop,
// -------------全局数据相关--------------
// 当前加载类型
loadingType: Enum.LoadingType.Refresher,
requestTimeStamp: 0,
wxsPropType: '',
renderPropScrollTop: -1,
checkScrolledToBottomTimeOut: null,
cacheTopHeight: -1,
statusBarHeight: systemInfo.statusBarHeight,
// --------------状态&判断---------------
insideOfPaging: -1,
isLoadFailed: false,
isIos: systemInfo.platform === 'ios',
disabledBounce: false,
fromCompleteEmit: false,
disabledCompleteEmit: false,
pageLaunched: false,
active: false,
// ---------------wxs相关---------------
wxsIsScrollTopInTopRange: true,
wxsScrollTop: 0,
wxsPageScrollTop: 0,
wxsOnPullingDown: false,
};
},
props: {
// 调用complete后延迟处理的时间,单位为毫秒,默认0毫秒,优先级高于minDelay
delay: {
type: [Number, String],
default: u.gc('delay', 0),
},
// 触发@query后最小延迟处理的时间,单位为毫秒,默认0毫秒,优先级低于delay(假设设置为300毫秒,若分页请求时间小于300毫秒,则在调用complete后延迟[300毫秒-请求时长];若请求时长大于300毫秒,则不延迟),当show-refresher-when-reload为true或reload(true)时,其最小值为400
minDelay: {
type: [Number, String],
default: u.gc('minDelay', 0),
},
// 设置z-paging的style,部分平台(如微信小程序)无法直接修改组件的style,可使用此属性代替
pagingStyle: {
type: Object,
default: u.gc('pagingStyle', {}),
},
// z-paging的高度,优先级低于pagingStyle中设置的height;传字符串,如100px、100rpx、100%
height: {
type: String,
default: u.gc('height', '')
},
// z-paging的宽度,优先级低于pagingStyle中设置的width;传字符串,如100px、100rpx、100%
width: {
type: String,
default: u.gc('width', '')
},
// z-paging的最大宽度,优先级低于pagingStyle中设置的max-width;传字符串,如100px、100rpx、100%。默认为空,也就是铺满窗口宽度,若设置了特定值则会自动添加margin: 0 auto
maxWidth: {
type: String,
default: u.gc('maxWidth', '')
},
// z-paging的背景色,优先级低于pagingStyle中设置的background。传字符串,如"#ffffff"
bgColor: {
type: String,
default: u.gc('bgColor', '')
},
// 设置z-paging的容器(插槽的父view)的style
pagingContentStyle: {
type: Object,
default: u.gc('pagingContentStyle', {}),
},
// z-paging是否自动高度,若自动高度则会自动铺满屏幕
autoHeight: {
type: Boolean,
default: u.gc('autoHeight', false)
},
// z-paging是否自动高度时,附加的高度,注意添加单位px或rpx,若需要减少高度,则传负数
autoHeightAddition: {
type: [Number, String],
default: u.gc('autoHeightAddition', '0px')
},
// loading(下拉刷新、上拉加载更多)的主题样式,支持black,white,默认black
defaultThemeStyle: {
type: String,
default: u.gc('defaultThemeStyle', 'black')
},
// z-paging是否使用fixed布局,若使用fixed布局,则z-paging的父view无需固定高度,z-paging高度默认为100%,默认为是(当使用内置scroll-view滚动时有效)
fixed: {
type: Boolean,
default: u.gc('fixed', true)
},
// 是否开启底部安全区域适配
safeAreaInsetBottom: {
type: Boolean,
default: u.gc('safeAreaInsetBottom', false)
},
// 开启底部安全区域适配后,是否使用placeholder形式实现,默认为否。为否时滚动区域会自动避开底部安全区域,也就是所有滚动内容都不会挡住底部安全区域,若设置为是,则滚动时滚动内容会挡住底部安全区域,但是当滚动到底部时才会避开底部安全区域
useSafeAreaPlaceholder: {
type: Boolean,
default: u.gc('useSafeAreaPlaceholder', false)
},
// z-paging bottom的背景色,默认透明,传字符串,如"#ffffff"
bottomBgColor: {
type: String,
default: u.gc('bottomBgColor', '')
},
// slot="top"的view的z-index,默认为99,仅使用页面滚动时有效
topZIndex: {
type: Number,
default: u.gc('topZIndex', 99)
},
// z-paging内容容器父view的z-index,默认为1
superContentZIndex: {
type: Number,
default: u.gc('superContentZIndex', 1)
},
// z-paging内容容器部分的z-index,默认为1
contentZIndex: {
type: Number,
default: u.gc('contentZIndex', 1)
},
// z-paging二楼的z-index,默认为100
f2ZIndex: {
type: Number,
default: u.gc('f2ZIndex', 100)
},
// 使用页面滚动时,是否在不满屏时自动填充满屏幕,默认为是
autoFullHeight: {
type: Boolean,
default: u.gc('autoFullHeight', true)
},
// 是否监听列表触摸方向改变,默认为否
watchTouchDirectionChange: {
type: Boolean,
default: u.gc('watchTouchDirectionChange', false)
},
// z-paging中布局的单位,默认为rpx
unit: {
type: String,
default: u.gc('unit', 'rpx')
}
},
created() {
// 组件创建时,检测是否开始加载状态
if (this.createdReload && !this.refresherOnly && this.auto) {
this._startLoading();
this.$nextTick(this._preReload);
}
},
mounted() {
this.active = true;
this.wxsPropType = u.getTime().toString();
this.renderJsIgnore;
if (!this.createdReload && !this.refresherOnly && this.auto) {
// 开始预加载
u.delay(() => this.$nextTick(this._preReload), 0);
}
// 如果开启了列表缓存,在初始化的时候通过缓存数据填充列表数据
this.finalUseCache && this._setListByLocalCache();
let delay = 0;
// #ifdef H5 || MP
delay = c.delayTime;
// #endif
this.$nextTick(() => {
// 初始化systemInfo
this.systemInfo = u.getSystemInfoSync();
// 初始化z-paging高度
!this.usePageScroll && this.autoHeight && this._setAutoHeight();
// #ifdef MP-KUAISHOU
this._setFullScrollViewInHeight();
// #endif
this.loaded = true;
u.delay(() => {
// 更新fixed模式下z-paging的布局,主要是更新windowTop、windowBottom
this.updateFixedLayout();
// 更新缓存中z-paging整个内容容器高度
this._updateCachedSuperContentHeight();
});
})
// 初始化页面滚动模式下slot="top"、slot="bottom"高度
this.updatePageScrollTopHeight();
this.updatePageScrollBottomHeight();
// 初始化slot="left"、slot="right"宽度
this.updateLeftAndRightWidth();
if (this.finalRefresherEnabled && this.useCustomRefresher) {
this.$nextTick(() => {
this.isTouchmoving = true;
})
}
// 监听uni.$emit中全局emit的complete error等事件
this._onEmit();
// #ifdef APP-NVUE
if (!this.isIos && !this.useChatRecordMode) {
this.nLoadingMoreFixedHeight = true;
}
// 在nvue中更新nvue下拉刷新view容器的宽度,而不是写死默认的750rpx,需要考虑列表宽度不是铺满屏幕的情况
this._nUpdateRefresherWidth();
// #endif
// #ifndef APP-NVUE
// 虚拟列表模式时,初始化数据
this.finalUseVirtualList && this._virtualListInit();
// #endif
// #ifndef APP-PLUS
this.$nextTick(() => {
// 非app平台中,在通过获取css设置的底部安全区域占位view高度设置bottom距离后,更新页面滚动底部高度
setTimeout(() => {
this._getCssSafeAreaInsetBottom(() => this.safeAreaInsetBottom && this.updatePageScrollBottomHeight());
}, delay)
})
// #endif
},
destroyed() {
this._handleUnmounted();
},
// #ifdef VUE3
unmounted() {
this._handleUnmounted();
},
// #endif
watch: {
defaultThemeStyle: {
handler(newVal) {
if (newVal.length) {
this.finalRefresherDefaultStyle = newVal;
}
},
immediate: true
},
autoHeight(newVal) {
this.loaded && !this.usePageScroll && this._setAutoHeight(newVal);
},
autoHeightAddition(newVal) {
this.loaded && !this.usePageScroll && this.autoHeight && this._setAutoHeight(newVal);
},
},
computed: {
// 当前z-paging的内置样式
finalPagingStyle() {
const pagingStyle = { ...this.pagingStyle };
if (!this.systemInfo) return pagingStyle;
const { windowTop, windowBottom } = this;
if (!this.usePageScroll && this.fixed) {
if (windowTop && !pagingStyle.top) {
pagingStyle.top = windowTop + 'px';
}
if (windowBottom && !pagingStyle.bottom) {
pagingStyle.bottom = windowBottom + 'px';
}
}
if (this.bgColor.length && !pagingStyle['background']) {
pagingStyle['background'] = this.bgColor;
}
if (this.height.length && !pagingStyle['height']) {
pagingStyle['height'] = this.height;
}
if (this.width.length && !pagingStyle['width']) {
pagingStyle['width'] = this.width;
}
if (this.maxWidth.length && !pagingStyle['max-width']) {
pagingStyle['max-width'] = this.maxWidth;
pagingStyle['margin'] = '0 auto';
}
return pagingStyle;
},
// 当前z-paging内容的样式
finalPagingContentStyle() {
if (this.contentZIndex != 1) {
this.pagingContentStyle['z-index'] = this.contentZIndex;
this.pagingContentStyle['position'] = 'relative';
}
return this.pagingContentStyle;
},
renderJsIgnore() {
if ((this.usePageScroll && this.useChatRecordMode) || (!this.refresherEnabled && this.scrollable) || !this.useCustomRefresher) {
this.$nextTick(() => {
this.renderPropScrollTop = 10;
})
}
return 0;
},
windowHeight() {
if (!this.systemInfo) return 0;
return this.systemInfo.windowHeight || 0;
},
windowBottom() {
if (!this.systemInfo) return 0;
let windowBottom = this.systemInfo.windowBottom || 0;
// 如果开启底部安全区域适配并且不使用placeholder的形式体现并且不是聊天记录模式(因为聊天记录模式在keyboardHeight计算初已添加了底部安全区域),在windowBottom添加底部安全区域高度
if (this.safeAreaInsetBottom && !this.useSafeAreaPlaceholder && !this.useChatRecordMode) {
windowBottom += this.safeAreaBottom;
}
return windowBottom;
},
isIosAndH5() {
// #ifndef H5
return false;
// #endif
return this.isIos;
}
},
methods: {
// 当前版本号
getVersion() {
return `z-paging v${c.version}`;
},
// 设置nvue List的specialEffects
setSpecialEffects(args) {
this.setListSpecialEffects(args);
},
// 与setSpecialEffects等效,兼容旧版本
setListSpecialEffects(args) {
this.nFixFreezing = args && Object.keys(args).length;
if (this.isIos) {
this.privateRefresherEnabled = 0;
}
!this.usePageScroll && this.$refs['zp-n-list'].setSpecialEffects(args);
},
// #ifdef APP-VUE
// 当app长时间进入后台后进入前台,因系统内存管理导致app重新加载时,进行一些适配处理
_handlePageLaunch() {
// 首次触发不进行处理,只有进入后台后打开app重新加载时才处理
if (this.pageLaunched) {
// 解决在vue3+ios中,app ReLaunch时顶部下拉刷新展示位置向下偏移的问题
// #ifdef VUE3
this.refresherThresholdUpdateTag = 1;
this.$nextTick(() => {
this.refresherThresholdUpdateTag = 0;
})
// #endif
// 解决使用虚拟列表时,app ReLaunch时白屏问题
this._checkVirtualListScroll();
}
this.pageLaunched = true;
},
// #endif
// 使手机发生较短时间的振动(15ms)
_doVibrateShort() {
// #ifndef H5
// #ifdef APP-PLUS
if (this.isIos) {
const UISelectionFeedbackGenerator = plus.ios.importClass('UISelectionFeedbackGenerator');
const feedbackGenerator = new UISelectionFeedbackGenerator();
feedbackGenerator.init();
setTimeout(() => {
feedbackGenerator.selectionChanged();
}, 0)
} else {
plus.device.vibrate(15);
}
// #endif
// #ifndef APP-PLUS
uni.vibrateShort();
// #endif
// #endif
},
// 设置z-paging高度
async _setAutoHeight(shouldFullHeight = true, scrollViewNode = null) {
const heightKey = 'min-height';
try {
if (shouldFullHeight) {
// 如果需要铺满全屏,则计算当前全屏可是区域的高度
let finalScrollViewNode = scrollViewNode || await this._getNodeClientRect('.zp-scroll-view');
let finalScrollBottomNode = await this._getNodeClientRect('.zp-page-bottom');
if (finalScrollViewNode) {
const scrollViewTop = finalScrollViewNode[0].top;
let scrollViewHeight = this.windowHeight - scrollViewTop;
scrollViewHeight -= finalScrollBottomNode ? finalScrollBottomNode[0].height : 0;
const additionHeight = u.convertToPx(this.autoHeightAddition);
// 在支付宝小程序中,添加!important会导致min-height失效,因此在支付宝小程序中需要去掉
let importantSuffix = ' !important';
// #ifdef MP-ALIPAY
importantSuffix = '';
// #endif
const finalHeight = scrollViewHeight + additionHeight - (this.insideMore ? 1 : 0) + 'px' + importantSuffix;
this.$set(this.scrollViewStyle, heightKey, finalHeight);
this.$set(this.scrollViewInStyle, heightKey, finalHeight);
}
} else {
this.$delete(this.scrollViewStyle, heightKey);
this.$delete(this.scrollViewInStyle, heightKey);
}
} catch (e) {}
},
// #ifdef MP-KUAISHOU
// 设置scroll-view内容器的最小高度等于scroll-view的高度(为了解决在快手小程序中内容较少时scroll-view内容器高度无法铺满scroll-view的问题)
async _setFullScrollViewInHeight() {
try {
// 如果需要铺满全屏,则计算当前全屏可是区域的高度
const scrollViewNode = await this._getNodeClientRect('.zp-scroll-view');
scrollViewNode && this.$set(this.scrollViewInStyle, 'min-height', scrollViewNode[0].height + 'px');
} catch (e) {}
},
// #endif
// 组件销毁后续处理
_handleUnmounted() {
this.active = false;
this._offEmit();
// 取消监听键盘高度变化事件(H5、百度小程序、抖音小程序、飞书小程序、QQ小程序、快手小程序不支持)
// #ifndef H5 || MP-BAIDU || MP-TOUTIAO || MP-QQ || MP-KUAISHOU
this.useChatRecordMode && uni.offKeyboardHeightChange(this._handleKeyboardHeightChange);
// #endif
},
// 触发更新是否超出页面状态
_updateInsideOfPaging() {
this.insideMore && this.insideOfPaging === true && setTimeout(this.doLoadMore, 200)
},
// 清除timeout
_cleanTimeout(timeout) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
return timeout;
},
// 添加全局emit监听
_onEmit() {
uni.$on(c.errorUpdateKey, (errorMsg) => {
if (this.loading) {
if (!!errorMsg) {
this.customerEmptyViewErrorText = errorMsg;
}
this.complete(false).catch(() => {});
}
})
uni.$on(c.completeUpdateKey, (data) => {
setTimeout(() => {
if (this.loading) {
if (!this.disabledCompleteEmit) {
const type = data.type || 'normal';
const list = data.list || data;
const rule = data.rule;
this.fromCompleteEmit = true;
switch (type){
case 'normal':
this.complete(list);
break;
case 'total':
this.completeByTotal(list, rule);
break;
case 'nomore':
this.completeByNoMore(list, rule);
break;
case 'key':
this.completeByKey(list, rule);
break;
default:
break;
}
} else {
this.disabledCompleteEmit = false;
}
}
}, 1);
})
},
// 销毁全局emit和listener监听
_offEmit(){
uni.$off(c.errorUpdateKey);
uni.$off(c.completeUpdateKey);
},
},
};
@@ -0,0 +1,22 @@
// [z-paging]使用页面滚动时引入此mixin,用于监听和处理onPullDownRefresh等页面生命周期方法
export default {
onPullDownRefresh() {
if (this.isPagingRefNotFound()) return;
this.$refs.paging.reload().catch(() => {});
},
onPageScroll(e) {
if (this.isPagingRefNotFound()) return;
this.$refs.paging.updatePageScrollTop(e.scrollTop);
e.scrollTop < 10 && this.$refs.paging.doChatRecordLoadMore();
},
onReachBottom() {
if (this.isPagingRefNotFound()) return;
this.$refs.paging.pageReachBottom();
},
methods: {
isPagingRefNotFound() {
return !this.$refs.paging;
}
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,302 @@
// [z-paging]工具类
import zLocalConfig from '../config/index'
import c from './z-paging-constant'
const storageKey = 'Z-PAGING-REFRESHER-TIME-STORAGE-KEY';
let config = null;
let configLoaded = false;
let cachedSystemInfo = null;
const timeoutMap = {};
// 获取默认配置信息
function gc(key, defaultValue) {
// 这里return一个函数以解决在vue3+appvue中,props默认配置读取在main.js之前执行导致uni.$zp全局配置无效的问题。相当于props的default中传入一个带有返回值的函数
return () => {
// 处理z-paging全局配置
_handleDefaultConfig();
// 如果全局配置不存在,则返回默认值
if (!config) return defaultValue;
const value = config[key];
// 如果全局配置存在但对应的配置项不存在,则返回默认值;反之返回配置项
return value === undefined ? defaultValue : value;
};
}
// 获取最终的touch位置
function getTouch(e) {
let touch = null;
if (e.touches && e.touches.length) {
touch = e.touches[0];
} else if (e.changedTouches && e.changedTouches.length) {
touch = e.changedTouches[0];
} else if (e.datail && e.datail != {}) {
touch = e.datail;
} else {
return { touchX: 0, touchY: 0 }
}
return {
touchX: touch.clientX,
touchY: touch.clientY
};
}
// 判断当前手势是否在z-paging内触发
function getTouchFromZPaging(target) {
if (target && target.tagName && target.tagName !== 'BODY' && target.tagName !== 'UNI-PAGE-BODY') {
const classList = target.classList;
if (classList && classList.contains('z-paging-content')) {
// 此处额外记录当前z-paging是否是页面滚动、是否滚动到了顶部、是否是聊天记录模式以传给renderjs。避免不同z-paging组件renderjs内部判断数据互相影响导致的各种问题
return {
isFromZp: true,
isPageScroll: classList.contains('z-paging-content-page'),
isReachedTop: classList.contains('z-paging-reached-top'),
isUseChatRecordMode: classList.contains('z-paging-use-chat-record-mode')
};
} else {
return getTouchFromZPaging(target.parentNode);
}
} else {
return { isFromZp: false };
}
}
// 递归获取z-paging所在的parent,如果查找不到则返回null
function getParent(parent) {
if (!parent) return null;
if (parent.$refs.paging) return parent;
return getParent(parent.$parent);
}
// 打印错误信息
function consoleErr(err) {
console.error(`[z-paging]${err}`);
}
// 延时操作,如果key存在,调用时清除对应key之前的延时操作
function delay(callback, ms = c.delayTime, key) {
const timeout = setTimeout(callback, ms);;
if (!!key) {
timeoutMap[key] && clearTimeout(timeoutMap[key]);
timeoutMap[key] = timeout;
}
return timeout;
}
// 设置下拉刷新时间
function setRefesrherTime(time, key) {
const datas = getRefesrherTime() || {};
datas[key] = time;
uni.setStorageSync(storageKey, datas);
}
// 获取下拉刷新时间
function getRefesrherTime() {
return uni.getStorageSync(storageKey);
}
// 通过下拉刷新标识key获取下拉刷新时间
function getRefesrherTimeByKey(key) {
const datas = getRefesrherTime();
return datas && datas[key] ? datas[key] : null;
}
// 通过下拉刷新标识key获取下拉刷新时间(格式化之后)
function getRefesrherFormatTimeByKey(key, textMap) {
const time = getRefesrherTimeByKey(key);
const timeText = time ? _timeFormat(time, textMap) : textMap.none;
return `${textMap.title}${timeText}`;
}
// 将文本的px或者rpx转为px的值
function convertToPx(text) {
const dataType = Object.prototype.toString.call(text);
if (dataType === '[object Number]') return text;
let isRpx = false;
if (text.indexOf('rpx') !== -1 || text.indexOf('upx') !== -1) {
text = text.replace('rpx', '').replace('upx', '');
isRpx = true;
} else if (text.indexOf('px') !== -1) {
text = text.replace('px', '');
}
if (!isNaN(text)) {
if (isRpx) return Number(rpx2px(text));
return Number(text);
}
return 0;
}
// rpx => px,预留的兼容处理
function rpx2px(rpx) {
return uni.upx2px(rpx);
}
// 同步获取系统信息,兼容不同平台
function getSystemInfoSync(useCache = false) {
if (useCache && cachedSystemInfo) {
return cachedSystemInfo;
}
// 目前只用到了deviceInfo、appBaseInfo和windowInfo中的信息,因此仅整合这两个信息数据
const infoTypes = ['DeviceInfo', 'AppBaseInfo', 'WindowInfo'];
const { deviceInfo, appBaseInfo, windowInfo } = infoTypes.reduce((acc, key) => {
const method = `get${key}`;
if (uni[method] && uni.canIUse(method)) {
acc[key.charAt(0).toLowerCase() + key.slice(1)] = uni[method]();
}
return acc;
}, {});
// 如果deviceInfo、appBaseInfo和windowInfo都可以从各自专属的api中获取,则整合它们的数据
if (deviceInfo && appBaseInfo && windowInfo) {
cachedSystemInfo = { ...deviceInfo, ...appBaseInfo, ...windowInfo };
} else {
// 使用uni.getSystemInfoSync兜底,确保能获取到最终的系统信息
cachedSystemInfo = uni.getSystemInfoSync();
}
return cachedSystemInfo;
}
// 获取当前时间
function getTime() {
return (new Date()).getTime();
}
// 获取z-paging实例id,随机生成10位数字+字母
function getInstanceId() {
const s = [];
const hexDigits = "0123456789abcdef";
for (let i = 0; i < 10; i++) {
s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1);
}
return s.join('') + getTime();
}
// 等待一段时间
function wait(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
// 是否是promise
function isPromise(func) {
return Object.prototype.toString.call(func) === '[object Promise]';
}
// 添加单位
function addUnit(value, unit) {
if (Object.prototype.toString.call(value) === '[object String]') {
let tempValue = value;
tempValue = tempValue.replace('rpx', '').replace('upx', '').replace('px', '');
if (value.indexOf('rpx') === -1 && value.indexOf('upx') === -1 && value.indexOf('px') !== -1) {
tempValue = parseFloat(tempValue) * 2;
}
value = tempValue;
}
return unit === 'rpx' ? value + 'rpx' : (value / 2) + 'px';
}
// 深拷贝
function deepCopy(obj) {
if (typeof obj !== 'object' || obj === null) return obj;
let newObj = Array.isArray(obj) ? [] : {};
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
newObj[key] = deepCopy(obj[key]);
}
}
return newObj;
}
// ------------------ 私有方法 ------------------------
// 处理全局配置
function _handleDefaultConfig() {
// 确保只加载一次全局配置
if (configLoaded) return;
// 优先从config.js中读取
if (zLocalConfig && Object.keys(zLocalConfig).length) {
config = zLocalConfig;
}
// 如果在config.js中读取不到,则尝试到uni.$zp读取
if (!config && uni.$zp) {
config = uni.$zp.config;
}
// 将config中的短横线写法全部转为驼峰写法,使得读取配置时可以直接通过key去匹配,而非读取每个配置时候再去转,减少不必要的性能开支
config = config ? Object.keys(config).reduce((result, key) => {
result[_toCamelCase(key)] = config[key];
return result;
}, {}) : null;
configLoaded = true;
}
// 时间格式化
function _timeFormat(time, textMap) {
const date = new Date(time);
const currentDate = new Date();
// 设置time对应的天,去除时分秒,使得可以直接比较日期
const dateDay = new Date(time).setHours(0, 0, 0, 0);
// 设置当前的天,去除时分秒,使得可以直接比较日期
const currentDateDay = new Date().setHours(0, 0, 0, 0);
const disTime = dateDay - currentDateDay;
let dayStr = '';
const timeStr = _dateTimeFormat(date);
if (disTime === 0) {
dayStr = textMap.today;
} else if (disTime === -86400000) {
dayStr = textMap.yesterday;
} else {
dayStr = _dateDayFormat(date, date.getFullYear() !== currentDate.getFullYear());
}
return `${dayStr} ${timeStr}`;
}
// date格式化为年月日
function _dateDayFormat(date, showYear = true) {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
return showYear ? `${year}-${_fullZeroToTwo(month)}-${_fullZeroToTwo(day)}` : `${_fullZeroToTwo(month)}-${_fullZeroToTwo(day)}`;
}
// data格式化为时分
function _dateTimeFormat(date) {
const hour = date.getHours();
const minute = date.getMinutes();
return `${_fullZeroToTwo(hour)}:${_fullZeroToTwo(minute)}`;
}
// 不满2位在前面填充0
function _fullZeroToTwo(str) {
str = str.toString();
return str.length === 1 ? '0' + str : str;
}
// 驼峰转短横线
function _toKebab(value) {
return value.replace(/([A-Z])/g, "-$1").toLowerCase();
}
// 短横线转驼峰
function _toCamelCase(value) {
return value.replace(/-([a-z])/g, (_, group1) => group1.toUpperCase());
}
export default {
gc,
setRefesrherTime,
getRefesrherFormatTimeByKey,
getTouch,
getTouchFromZPaging,
getParent,
convertToPx,
getTime,
getInstanceId,
consoleErr,
delay,
wait,
isPromise,
addUnit,
deepCopy,
rpx2px,
getSystemInfoSync
};
@@ -0,0 +1,67 @@
// [z-paging]使用renderjs在app-vue和h5中对touchmove事件冒泡进行处理
import u from '../js/z-paging-utils'
const data = {
startY: 0,
isTouchFromZPaging: false,
isUsePageScroll: false,
isReachedTop: true,
isIosAndH5: false,
useChatRecordMode: false,
appLaunched: false
}
export default {
mounted() {
if (window) {
this._handleTouch();
// #ifdef APP-VUE
this.$ownerInstance.callMethod('_handlePageLaunch');
// #endif
}
},
methods: {
// 接收逻辑层发送的数据(是否是ios+h5)
renderPropIsIosAndH5Change(newVal) {
if (newVal === -1) return;
data.isIosAndH5 = newVal;
},
// 拦截处理touch事件
_handleTouch() {
if (!window.$zPagingRenderJsInited) {
window.$zPagingRenderJsInited = true;
window.addEventListener('touchstart', this._handleTouchstart, { passive: true })
window.addEventListener('touchmove', this._handleTouchmove, { passive: false })
}
},
// 处理touch开始
_handleTouchstart(e) {
const touch = u.getTouch(e);
data.startY = touch.touchY;
const touchResult = u.getTouchFromZPaging(e.target);
data.isTouchFromZPaging = touchResult.isFromZp;
data.isUsePageScroll = touchResult.isPageScroll;
data.isReachedTop = touchResult.isReachedTop;
data.useChatRecordMode = touchResult.isUseChatRecordMode;
},
// 处理touch中
_handleTouchmove(e) {
const touch = u.getTouch(e);
const moveY = touch.touchY - data.startY;
// 如果是在z-paging内触摸并且(是在顶部位置且是下拉的情况下(或不是聊天记录滚动模式并且在iOS+h5+scroll-view并且是往上拉的情况:避免在此平台中滚动到底部后上拉有个系统灰色遮罩导致列表被短暂锁定的问题))
// (data.useChatRecordMode ? moveY < 0 : moveY > 0)是为了判断是否是上拉的情况,聊天记录模式列表倒置,因此moveY < 0为上拉
if (data.isTouchFromZPaging && ((data.isReachedTop && (data.useChatRecordMode ? moveY < 0 : moveY > 0)) || (!data.useChatRecordMode && data.isIosAndH5 && !data.isUsePageScroll && moveY < 0))) {
if (e.cancelable && !e.defaultPrevented) {
// 阻止事件冒泡,以避免在一些平台中下拉刷新时整个page跟着一起下拉&在iOS+h5+scroll-view中在底部上拉有个系统灰色遮罩导致列表被短暂锁定的问题
e.preventDefault();
}
}
},
// 移除touch相关事件监听
_removeAllEventListener(){
window.removeEventListener('touchstart');
window.removeEventListener('touchmove');
}
}
};
@@ -0,0 +1,382 @@
// [z-paging]微信小程序、QQ小程序、app-vue、h5上使用wxs实现自定义下拉刷新,降低逻辑层与视图层的通信折损,提升性能
var currentDis = 0;
var isPCFlag = -1;
var startY = -1;
// 监听js层传过来的数据
function propObserver(newVal, oldVal, ownerIns, ins) {
var state = ownerIns.getState() || {};
state.currentIns = ins;
var dataset = ins.getDataset();
var loading = dataset.loading == true;
// 如果是下拉刷新结束,更新transform
if (newVal && newVal.indexOf('end') != -1) {
var transition = newVal.split('end')[0];
_setTransform('translateY(0px)', ins, false, transition);
state.moveDis = 0;
state.oldMoveDis = 0;
currentDis = 0;
} else if (newVal && newVal.indexOf('begin') != -1) {
// 如果是下拉刷新开始,更新transform
var refresherThreshold = ins.getDataset().refresherthreshold;
_setTransformValue(refresherThreshold, ins, state, false);
}
}
// touch开始
function touchstart(e, ownerIns) {
var ins = _getIns(ownerIns);
var state = {};
var dataset = {};
ownerIns.callMethod('_handleListTouchstart');
if (ins) {
state = ins.getState();
dataset = ins.getDataset();
if (_touchDisabled(e, ins, 0)) return;
}
var isTouchEnded = state.isTouchEnded;
state.oldMoveDis = 0;
var touch = _getTouch(e);
var loading = _isTrue(dataset.loading);
state.startY = touch.touchY;
startY = state.startY;
state.lastTouch = touch;
if (!loading && isTouchEnded) {
state.isTouchmoving = false;
}
state.isTouchEnded = false;
// 通知js层touch开始
ownerIns.callMethod('_handleRefresherTouchstart', touch);
}
// touch中
function touchmove(e, ownerIns) {
var touch = _getTouch(e);
var ins = _getIns(ownerIns);
var dataset = ins.getDataset();
var refresherThreshold = dataset.refresherthreshold;
var refresherF2Threshold = dataset.refresherf2threshold;
var refresherF2Enabled = _isTrue(dataset.refresherf2enabled);
var isIos = _isTrue(dataset.isios);
var state = ins.getState();
var watchTouchDirectionChange = _isTrue(dataset.watchtouchdirectionchange);
var moveDisObj = {};
var moveDis = 0;
var prevent = false;
// 如果需要监听touch方向的改变
if (watchTouchDirectionChange) {
moveDisObj = _getMoveDis(e, ins);
moveDis = moveDisObj.currentDis;
prevent = moveDisObj.isDown;
var direction = prevent ? 'top' : 'bottom';
// 确保只在touch方向改变时通知一次js层,而不是touchmove中持续通知
if (prevent == state.oldTouchDirection && prevent != state.oldEmitedTouchDirection) {
ownerIns.callMethod('_handleTouchDirectionChange', { direction: direction });
state.oldEmitedTouchDirection = prevent;
}
state.oldTouchDirection = prevent;
}
// 判断是否允许下拉刷新
if (_touchDisabled(e, ins, 1)) {
_handlePullingDown(state, ownerIns, false);
return true;
}
// 判断下拉刷新的角度是否在要求范围内
if (!_getAngleIsInRange(e, touch, state, dataset)) {
_handlePullingDown(state, ownerIns, false);
return true;
}
moveDisObj = _getMoveDis(e, ins);
moveDis = moveDisObj.currentDis;
prevent = moveDisObj.isDown;
if (moveDis < 0) {
// moveDis小于0,将transform重置为0
_setTransformValue(0, ins, state, false);
_handlePullingDown(state, ownerIns, false);
return true;
}
if (prevent && !state.disabledBounce) {
// 如果是用户下拉并且需要触发下拉刷新,需要通知js层将列表禁止滚动,防止在下拉刷新过程中列表也可以滚动导致的下拉刷新偏移过大的问题(在下拉刷新过程中仅通知一次)
ownerIns.callMethod('_handleScrollViewBounce', { bounce: false });
state.disabledBounce = true;
_handlePullingDown(state, ownerIns, prevent);
return !prevent;
}
// 更新transform
_setTransformValue(moveDis, ins, state, false);
var oldRefresherStatus = state.refresherStatus;
var oldIsTouchmoving = _isTrue(dataset.oldistouchmoving);
var hasTouchmove = _isTrue(dataset.hastouchmove);
var isTouchmoving = state.isTouchmoving;
state.refresherStatus = moveDis >= refresherThreshold ? (refresherF2Enabled && moveDis > refresherF2Threshold ? 'goF2' : 'releaseToRefresh') : 'default';
if (!isTouchmoving) {
state.isTouchmoving = true;
isTouchmoving = true;
}
if (state.isTouchEnded) {
state.isTouchEnded = false;
}
// 如果需要实时监听下拉位置偏移,则需要实时通知js层,此操作会使wxs层与js层频繁通信从而导致在一些性能较差设备中下拉刷新卡顿
if (hasTouchmove) {
ownerIns.callMethod('_handleWxsPullingDown', { moveDis: moveDis, diffDis: moveDisObj.diffDis });
}
// 在下拉刷新状态改变时通知js层
if (oldRefresherStatus == undefined || oldRefresherStatus != state.refresherStatus || oldIsTouchmoving != isTouchmoving) {
ownerIns.callMethod('_handleRefresherTouchmove', moveDis, touch);
}
_handlePullingDown(state, ownerIns, prevent);
return !prevent;
}
// touch结束
function touchend(e, ownerIns) {
var touch = _getTouch(e);
var ins = _getIns(ownerIns);
var dataset = ins.getDataset();
var state = ins.getState();
if (state.disabledBounce) {
// 通知js允许列表滚动
ownerIns.callMethod('_handleScrollViewBounce', { bounce: true });
state.disabledBounce = false;
}
if (_touchDisabled(e, ins, 2)) return;
state.reachMaxAngle = true;
state.hitReachMaxAngleCount = 0;
state.fixedIsTopHitCount = 0;
if (!state.isTouchmoving) return;
var oldRefresherStatus = state.refresherStatus;
var oldMoveDis = state.moveDis;
var refresherThreshold = ins.getDataset().refresherthreshold;
var moveDis = _getMoveDis(e, ins).currentDis;
if (!(moveDis >= refresherThreshold && oldRefresherStatus === 'releaseToRefresh')) {
state.isTouchmoving = false;
}
// 通知js层touch结束
ownerIns.callMethod('_handleRefresherTouchend', moveDis);
state.isTouchEnded = true;
if (oldMoveDis < refresherThreshold) return;
var animate = false;
if (moveDis >= refresherThreshold) {
moveDis = refresherThreshold;
animate = true;
}
_setTransformValue(moveDis, ins, state, animate);
}
// #ifdef H5
// 判断是否是pc平台
function isPC() {
if (!navigator) return false;
if (isPCFlag != -1) return isPCFlag;
var agents = ["Android", "iPhone", "SymbianOS", "Windows Phone", "iPad", "iPod"];
isPCFlag = agents.every(function(item) { return navigator.userAgent.indexOf(item) < 0 });
return isPCFlag;
}
var movable = false;
// 在pc平台监听mousedown、mousemove、mouseup等相关事件并转为对应touch事件处理,使得在pc平台也支持通过鼠标进行下拉刷新
function mousedown(e, ins) {
if (!isPC()) return;
touchstart(e, ins);
movable = true;
}
function mousemove(e, ins) {
if (!isPC() || !movable) return;
touchmove(e, ins);
}
function mouseup(e, ins) {
if (!isPC()) return;
touchend(e, ins);
movable = false;
}
function mouseleave(e, ins) {
if (!isPC()) return;
movable = false;
}
// #endif
// 修改视图层transform
function _setTransformValue(value, ins, state, animate) {
value = value || 0;
if (state.moveDis == value) return;
state.moveDis = value;
_setTransform('translateY(' + value + 'px)', ins, animate, '');
}
// 设置视图层transform,直接在视图层操作下拉刷新,使得js层不需要频繁和视图层通信,从而大大提升下拉刷新性能
function _setTransform(transform, ins, animate, transition) {
var dataset = ins.getDataset();
if (_isTrue(dataset.refreshernotransform)) return;
transform = transform == 'translateY(0px)' ? 'none' : transform;
ins.requestAnimationFrame(function() {
var stl = { 'transform': transform };
if (animate) {
stl['transition'] = 'transform .1s linear';
}
if (transition.length) {
stl['transition'] = transition;
}
ins.setStyle(stl);
})
}
// 进一步处理下拉刷新的偏移数据
function _getMoveDis(e, ins) {
var state = ins.getState();
var refresherThreshold = parseFloat(ins.getDataset().refresherthreshold);
var refresherOutRate = parseFloat(ins.getDataset().refresheroutrate);
var refresherPullRate = parseFloat(ins.getDataset().refresherpullrate);
var touch = _getTouch(e);
var currentStartY = !state.startY || state.startY == 'NaN' ? startY : state.startY;
var moveDis = touch.touchY - currentStartY;
var oldMoveDis = state.oldMoveDis || 0;
state.oldMoveDis = moveDis;
// 获取当前下拉刷新位置与上次的偏移量
var diffDis = moveDis - oldMoveDis;
if (diffDis > 0) {
// 对偏移量进行进一步处理,通过refresherPullRate等配置进行约束
diffDis = diffDis * refresherPullRate;
if (currentDis > refresherThreshold) {
diffDis = diffDis * (1 - refresherOutRate);
}
}
// 控制diffDis过大的情况,比如进入页面突然猛然下拉,此时diffDis不应进行太大的偏移
diffDis = diffDis > 100 ? diffDis / 100 : (diffDis > 20 ? diffDis / 2.2 : diffDis);
currentDis += diffDis;
currentDis = Math.max(0, currentDis);
return {
currentDis: currentDis,
diffDis: diffDis,
isDown: diffDis > 0
};
}
// 获取经过统一格式包装的当前touch对象
function _getTouch(e) {
var touch = e;
if (e.touches && e.touches.length) {
touch = e.touches[0];
} else if (e.changedTouches && e.changedTouches.length) {
touch = e.changedTouches[0];
} else if (e.datail && e.datail != {}) {
touch = e.datail;
}
return {
touchX: touch.clientX,
touchY: touch.clientY
};
}
// 获取当前currentIns
function _getIns(ownerIns) {
var ins = ownerIns.getState().currentIns;
if (!ins) {
ownerIns.callMethod('_handlePropUpdate');
}
return ins;
}
// 判断当前状态是否允许下拉刷新
function _touchDisabled(e, ins, processTag) {
var dataset = ins.getDataset();
var state = ins.getState();
var loading = _isTrue(dataset.loading);
var useChatRecordMode = _isTrue(dataset.usechatrecordmode);
var refresherEnabled = _isTrue(dataset.refresherenabled);
var useCustomRefresher = _isTrue(dataset.usecustomrefresher);
var usePageScroll = _isTrue(dataset.usepagescroll);
var pageScrollTop = parseFloat(dataset.pagescrolltop);
var scrollTop = parseFloat(dataset.scrolltop);
var finalScrollTop = usePageScroll ? pageScrollTop : scrollTop;
var fixedIsTop = false;
// 是否要处理滚动到顶部scrollTop不为0时候的容错,为解决在安卓中scroll-view有概率滚动到顶部时scrollTop不为0导致下拉刷新判断异常,但此方案会导致某些情况(例如滚动到距离顶部10px处)下拉抖动,因此改为通过获取zp-scroll-view的节点信息中的scrollTop进行验证的方案
var handleFaultTolerantMove = false;
if (handleFaultTolerantMove && finalScrollTop == (state.startScrollTop || 0) && finalScrollTop <= 105) {
fixedIsTop = true;
}
var fixedIsTopHitCount = state.fixedIsTopHitCount || 0;
if (fixedIsTop) {
fixedIsTopHitCount ++;
if (fixedIsTopHitCount <= 2) {
fixedIsTop = false;
}
state.fixedIsTopHitCount = fixedIsTopHitCount;
} else {
state.fixedIsTopHitCount = 0;
}
if (handleFaultTolerantMove && processTag === 0) {
state.startScrollTop = finalScrollTop || 0;
}
if (handleFaultTolerantMove && processTag === 2) {
fixedIsTop = true;
}
return loading || useChatRecordMode || !refresherEnabled || !useCustomRefresher ||
((usePageScroll && useCustomRefresher && pageScrollTop > 5) && !fixedIsTop) ||
((!usePageScroll && useCustomRefresher && scrollTop > 5) && !fixedIsTop);
}
// 判断下拉刷新的角度是否在要求范围内
function _getAngleIsInRange(e, touch, state, dataset) {
var maxAngle = dataset.refreshermaxangle;
var refresherAecc = _isTrue(dataset.refresheraecc);
var lastTouch = state.lastTouch;
var reachMaxAngle = state.reachMaxAngle;
var moveDis = state.oldMoveDis;
if (!lastTouch) return true;
if (maxAngle >= 0 && maxAngle <= 90 && lastTouch) {
// 考虑下拉刷新手势由水平移动转为垂直方向移动的情况,此时不应当只判断垂直方向角度是否符合要求,应当直接禁止以避免在swiper中使用下拉刷新时,横向切换swiper途中手未离开屏幕还可以下拉刷新的问题
if ((!moveDis || moveDis < 1) && !refresherAecc && reachMaxAngle != null && !reachMaxAngle) return false;
var x = Math.abs(touch.touchX - lastTouch.touchX);
var y = Math.abs(touch.touchY - lastTouch.touchY);
var z = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
if ((x || y) && x > 1) {
// 获取下拉刷新前后两次位移的角度
var angle = Math.asin(y / z) / Math.PI * 180;
if (angle < maxAngle) {
// 如果角度小于配置要求,则return,同时通过hitReachMaxAngleCount控制角度判断的灵敏程度以最大程度兼容各种使用场景
var hitReachMaxAngleCount = state.hitReachMaxAngleCount || 0;
state.hitReachMaxAngleCount = ++hitReachMaxAngleCount;
if (state.hitReachMaxAngleCount > 2) {
state.lastTouch = touch;
state.reachMaxAngle = false;
}
return false;
}
}
}
state.lastTouch = touch;
return true;
}
// 进一步处理是否在下拉刷新并通知js层
function _handlePullingDown(state, ins, onPullingDown) {
var oldOnPullingDown = state.onPullingDown || false;
if (oldOnPullingDown != onPullingDown) {
ins.callMethod('_handleWxsPullingDownStatusChange', onPullingDown);
}
state.onPullingDown = onPullingDown;
}
// 判断js层传过来的值是否为true
function _isTrue(value) {
value = (typeof(value) === 'string' ? JSON.parse(value) : value) || false;
return value == true || value == 'true';
}
module.exports = {
touchstart: touchstart,
touchmove: touchmove,
touchend: touchend,
mousedown: mousedown,
mousemove: mousemove,
mouseup: mouseup,
mouseleave: mouseleave,
propObserver: propObserver
}
@@ -0,0 +1,538 @@
<!-- _
____ _ __ __ _ __ _(_)_ __ __ _
|_ /____| '_ \ / _` |/ _` | | '_ \ / _` |
/ /_____| |_) | (_| | (_| | | | | | (_| |
/___| | .__/ \__,_|\__, |_|_| |_|\__, |
|_| |___/ |___/
v2.8.6 (2025-03-17)
@author ZXLee <admin@zxlee.cn>
-->
<!-- 文档地址:https://z-paging.zxlee.cn -->
<!-- github地址:https://github.com/SmileZXLee/uni-z-paging -->
<!-- dcloud地址:https://ext.dcloud.net.cn/plugin?id=3935 -->
<!-- 反馈QQ群:343409055 -->
<template name="z-paging">
<!-- #ifndef APP-NVUE -->
<view :class="{'z-paging-content':true,'z-paging-content-full':!usePageScroll,'z-paging-content-fixed':!usePageScroll&&fixed,'z-paging-content-page':usePageScroll,'z-paging-reached-top':renderPropScrollTop<1,'z-paging-use-chat-record-mode':useChatRecordMode}" :style="[finalPagingStyle]">
<!-- #ifndef APP-PLUS -->
<view v-if="cssSafeAreaInsetBottom===-1" class="zp-safe-area-inset-bottom"></view>
<!-- #endif -->
<!-- 二楼view -->
<view v-if="showF2 && showRefresherF2" @touchmove.stop.prevent class="zp-f2-content" :style="[{'transform': f2Transform, 'transition': `transform .2s linear`, 'height': superContentHeight + 'px', 'z-index': f2ZIndex}]">
<slot name="f2"/>
</view>
<!-- 顶部固定的slot -->
<slot v-if="!usePageScroll&&zSlots.top" name="top" />
<view class="zp-page-top" @touchmove.stop.prevent v-else-if="usePageScroll&&zSlots.top" :style="[{'top':`${windowTop}px`,'z-index':topZIndex}]">
<slot name="top" />
</view>
<view :class="{'zp-view-super':true,'zp-scroll-view-super':!usePageScroll}" :style="[finalScrollViewStyle]">
<view v-if="zSlots.left" :class="{'zp-page-left':true,'zp-absoulte':finalIsOldWebView}">
<slot name="left" />
</view>
<view :class="{'zp-scroll-view-container':true,'zp-absoulte':finalIsOldWebView}" :style="[scrollViewContainerStyle]">
<scroll-view
ref="zp-scroll-view" :class="{'zp-scroll-view':true,'zp-scroll-view-absolute':!usePageScroll,'zp-scroll-view-hide-scrollbar':!showScrollbar}" :style="[chatRecordRotateStyle]"
:scroll-top="scrollTop" :scroll-left="scrollLeft" :scroll-x="scrollX"
:scroll-y="finalScrollable" :enable-back-to-top="finalEnableBackToTop"
:show-scrollbar="showScrollbar" :scroll-with-animation="finalScrollWithAnimation"
:scroll-into-view="scrollIntoView" :lower-threshold="finalLowerThreshold" :upper-threshold="5"
:refresher-enabled="finalRefresherEnabled&&!useCustomRefresher" :refresher-threshold="finalRefresherThreshold"
:refresher-default-style="finalRefresherDefaultStyle" :refresher-background="refresherBackground"
:refresher-triggered="finalRefresherTriggered" @scroll="_scroll" @scrolltolower="_onScrollToLower"
@scrolltoupper="_onScrollToUpper" @refresherrestore="_onRestore" @refresherrefresh="_onRefresh(true)"
>
<view class="zp-paging-touch-view"
<!-- #ifndef APP-VUE || MP-WEIXIN || MP-QQ || H5 -->
@touchstart="_refresherTouchstart" @touchmove="_refresherTouchmove" @touchend="_refresherTouchend" @touchcancel="_refresherTouchend"
<!-- #endif -->
<!-- #ifdef APP-VUE || MP-WEIXIN || MP-QQ || H5 -->
@touchstart="pagingWxs.touchstart" @touchmove="pagingWxs.touchmove" @touchend="pagingWxs.touchend" @touchcancel="pagingWxs.touchend"
@mousedown="pagingWxs.mousedown" @mousemove="pagingWxs.mousemove" @mouseup="pagingWxs.mouseup" @mouseleave="pagingWxs.mouseleave"
<!-- #endif -->
>
<view v-if="finalRefresherFixedBacHeight>0" class="zp-fixed-bac-view" :style="[{'background': refresherFixedBackground,'height': `${finalRefresherFixedBacHeight}px`}]"></view>
<view class="zp-paging-main" :style="[scrollViewInStyle,{'transform': finalRefresherTransform,'transition': refresherTransition}]"
<!-- #ifdef APP-VUE || MP-WEIXIN || MP-QQ || H5 -->
:change:prop="pagingWxs.propObserver" :prop="wxsPropType"
:data-refresherThreshold="finalRefresherThreshold" :data-refresherF2Enabled="refresherF2Enabled" :data-refresherF2Threshold="finalRefresherF2Threshold" :data-isIos="isIos"
:data-loading="loading||isRefresherInComplete" :data-useChatRecordMode="useChatRecordMode"
:data-refresherEnabled="refresherEnabled" :data-useCustomRefresher="useCustomRefresher" :data-pageScrollTop="wxsPageScrollTop"
:data-scrollTop="wxsScrollTop" :data-refresherMaxAngle="refresherMaxAngle" :data-refresherNoTransform="refresherNoTransform"
:data-refresherAecc="refresherAngleEnableChangeContinued" :data-usePageScroll="usePageScroll" :data-watchTouchDirectionChange="watchTouchDirectionChange"
:data-oldIsTouchmoving="isTouchmoving" :data-refresherOutRate="finalRefresherOutRate" :data-refresherPullRate="finalRefresherPullRate" :data-hasTouchmove="hasTouchmove"
<!-- #endif -->
<!-- #ifdef APP-VUE || H5 -->
:change:renderPropIsIosAndH5="pagingRenderjs.renderPropIsIosAndH5Change" :renderPropIsIosAndH5="isIosAndH5"
<!-- #endif -->
>
<view v-if="showRefresher" class="zp-custom-refresher-view" :style="[{'margin-top': `-${finalRefresherThreshold+refresherThresholdUpdateTag}px`,'background': refresherBackground,'opacity': isTouchmoving ? 1 : 0}]">
<view class="zp-custom-refresher-container" :style="[{'height': `${finalRefresherThreshold}px`,'background': refresherBackground}]">
<view v-if="useRefresherStatusBarPlaceholder" class="zp-custom-refresher-status-bar-placeholder" :style="[{'height': `${statusBarHeight}px`}]" />
<!-- 下拉刷新view -->
<view class="zp-custom-refresher-slot-view">
<slot v-if="!(zSlots.refresherComplete&&refresherStatus===R.Complete)&&!(zSlots.refresherF2&&refresherStatus===R.GoF2)" :refresherStatus="refresherStatus" name="refresher" />
</view>
<slot v-if="zSlots.refresherComplete&&refresherStatus===R.Complete" name="refresherComplete" />
<slot v-else-if="zSlots.refresherF2&&refresherStatus===R.GoF2" name="refresherF2" />
<z-paging-refresh ref="refresh" v-else-if="!showCustomRefresher" class="zp-custom-refresher-refresh" :style="[{'height': `${finalRefresherThreshold - finalRefresherThresholdPlaceholder}px`}]" :status="refresherStatus"
:defaultThemeStyle="finalRefresherThemeStyle" :defaultText="finalRefresherDefaultText" :isIos="isIos"
:pullingText="finalRefresherPullingText" :refreshingText="finalRefresherRefreshingText" :completeText="finalRefresherCompleteText" :goF2Text="finalRefresherGoF2Text"
:defaultImg="refresherDefaultImg" :pullingImg="refresherPullingImg" :refreshingImg="refresherRefreshingImg" :completeImg="refresherCompleteImg" :refreshingAnimated="refresherRefreshingAnimated"
:showUpdateTime="showRefresherUpdateTime" :updateTimeKey="refresherUpdateTimeKey" :updateTimeTextMap="finalRefresherUpdateTimeTextMap"
:imgStyle="refresherImgStyle" :titleStyle="refresherTitleStyle" :updateTimeStyle="refresherUpdateTimeStyle" :unit="unit" />
</view>
</view>
<view class="zp-paging-container" :style="[{justifyContent:useChatRecordMode?'flex-end':'flex-start'}]">
<!-- 全屏Loading -->
<slot v-if="showLoading&&zSlots.loading&&!loadingFullFixed" name="loading" />
<!-- 主体内容 -->
<view class="zp-paging-container-content" :style="[finalPlaceholderTopHeightStyle,finalPagingContentStyle]">
<!-- #ifdef VUE3 -->
<!-- 虚拟列表顶部占位view -->
<view v-if="useVirtualList" class="zp-virtual-placeholder" :style="[{height:virtualPlaceholderTopHeight+'px'}]"/>
<!-- #endif -->
<slot />
<!-- 内置列表&虚拟列表 -->
<template v-if="finalUseInnerList">
<slot name="header"/>
<view class="zp-list-container" :style="[innerListStyle]">
<template v-if="finalUseVirtualList">
<view class="zp-list-cell" :style="[innerCellStyle]" :id="`${fianlVirtualCellIdPrefix}-${item[virtualCellIndexKey]}`" v-for="(item,index) in virtualList" :key="item['zp_unique_index']" @click="_innerCellClick(item,virtualTopRangeIndex+index)">
<view v-if="useCompatibilityMode">使用兼容模式请在组件源码z-paging.vue第103行中注释这一行,并打开下面一行注释</view>
<!-- <zp-public-virtual-cell v-if="useCompatibilityMode" :extraData="extraData" :item="item" :index="virtualTopRangeIndex+index" /> -->
<slot v-else name="cell" :item="item" :index="virtualTopRangeIndex+index"/>
</view>
</template>
<template v-else>
<view class="zp-list-cell" v-for="(item,index) in realTotalData" :key="index" @click="_innerCellClick(item,index)">
<slot name="cell" :item="item" :index="index"/>
</view>
</template>
</view>
<slot name="footer"/>
</template>
<!-- 聊天记录模式加载更多loading -->
<template v-if="useChatRecordMode&&realTotalData.length>=defaultPageSize&&(loadingStatus!==M.NoMore||zSlots.chatNoMore)&&(realTotalData.length||(showChatLoadingWhenReload&&showLoading))&&!isFirstPageAndNoMore">
<view :style="[chatRecordRotateStyle]">
<slot v-if="loadingStatus===M.NoMore&&zSlots.chatNoMore" name="chatNoMore" />
<template v-else>
<slot v-if="zSlots.chatLoading" :loadingMoreStatus="loadingStatus" name="chatLoading" />
<z-paging-load-more v-else @doClick="_onLoadingMore('click')" :zConfig="zLoadMoreConfig" />
</template>
</view>
</template>
<!-- 虚拟列表底部占位view -->
<view v-if="useVirtualList" class="zp-virtual-placeholder" :style="[{height:virtualPlaceholderBottomHeight+'px'}]"/>
<!-- 上拉加载更多view -->
<!-- #ifndef MP-ALIPAY -->
<slot v-if="showLoadingMoreDefault" name="loadingMoreDefault" />
<slot v-else-if="showLoadingMoreLoading" name="loadingMoreLoading" />
<slot v-else-if="showLoadingMoreNoMore" name="loadingMoreNoMore" />
<slot v-else-if="showLoadingMoreFail" name="loadingMoreFail" />
<z-paging-load-more @doClick="_onLoadingMore('click')" v-else-if="showLoadingMoreCustom" :zConfig="zLoadMoreConfig" />
<!-- #endif -->
<!-- #ifdef MP-ALIPAY -->
<slot v-if="loadingStatus===M.Default&&zSlots.loadingMoreDefault&&showLoadingMore&&loadingMoreEnabled&&!useChatRecordMode" name="loadingMoreDefault" />
<slot v-else-if="loadingStatus===M.Loading&&zSlots.loadingMoreLoading&&showLoadingMore&&loadingMoreEnabled" name="loadingMoreLoading" />
<slot v-else-if="loadingStatus===M.NoMore&&zSlots.loadingMoreNoMore&&showLoadingMore&&showLoadingMoreNoMoreView&&loadingMoreEnabled&&!useChatRecordMode" name="loadingMoreNoMore" />
<slot v-else-if="loadingStatus===M.Fail&&zSlots.loadingMoreFail&&showLoadingMore&&loadingMoreEnabled&&!useChatRecordMode" name="loadingMoreFail" />
<z-paging-load-more @doClick="_onLoadingMore('click')" v-else-if="showLoadingMore&&showDefaultLoadingMoreText&&!(loadingStatus===M.NoMore&&!showLoadingMoreNoMoreView)&&loadingMoreEnabled&&!useChatRecordMode" :zConfig="zLoadMoreConfig" />
<!-- #endif -->
<view v-if="safeAreaInsetBottom&&useSafeAreaPlaceholder&&!useChatRecordMode" class="zp-safe-area-placeholder" :style="[{height:safeAreaBottom+'px'}]" />
</view>
<!-- 空数据图 -->
<view v-if="showEmpty" :class="{'zp-empty-view':true,'zp-empty-view-center':emptyViewCenter}" :style="[emptyViewSuperStyle,chatRecordRotateStyle]">
<slot v-if="zSlots.empty" name="empty" :isLoadFailed="isLoadFailed"/>
<z-paging-empty-view v-else :emptyViewImg="finalEmptyViewImg" :emptyViewText="finalEmptyViewText" :showEmptyViewReload="finalShowEmptyViewReload"
:emptyViewReloadText="finalEmptyViewReloadText" :isLoadFailed="isLoadFailed" :emptyViewStyle="emptyViewStyle" :emptyViewTitleStyle="emptyViewTitleStyle"
:emptyViewImgStyle="emptyViewImgStyle" :emptyViewReloadStyle="emptyViewReloadStyle" :emptyViewZIndex="emptyViewZIndex" :emptyViewFixed="emptyViewFixed" :unit="unit"
@reload="_emptyViewReload" @viewClick="_emptyViewClick" />
</view>
</view>
</view>
</view>
</scroll-view>
</view>
<view v-if="zSlots.right" :class="{'zp-page-right':true,'zp-absoulte zp-right':finalIsOldWebView}">
<slot name="right" />
</view>
</view>
<!-- 底部固定的slot -->
<view class="zp-page-bottom-container" :style="{'background': bottomBgColor}">
<slot v-if="!usePageScroll&&zSlots.bottom" name="bottom" />
<view class="zp-page-bottom" @touchmove.stop.prevent v-else-if="usePageScroll&&zSlots.bottom" :style="[{'bottom': `${windowBottom}px`}]">
<slot name="bottom" />
</view>
<!-- 聊天记录模式底部占位 -->
<template v-if="useChatRecordMode&&autoAdjustPositionWhenChat">
<view :style="[{height:chatRecordModeSafeAreaBottom+'px'}]" />
<view class="zp-page-bottom-keyboard-placeholder-animate" :style="[{height:keyboardHeight+'px'}]" />
</template>
</view>
<!-- 点击返回顶部view -->
<view v-if="showBackToTopClass" :class="finalBackToTopClass" :style="[finalBackToTopStyle]" @click.stop="_backToTopClick">
<slot v-if="zSlots.backToTop" name="backToTop" />
<image v-else class="zp-back-to-top-img" :class="{'zp-back-to-top-img-inversion': useChatRecordMode&&!backToTopImg.length}" :src="backToTopImg.length?backToTopImg:base64BackToTop" />
</view>
<!-- 全屏Loading(铺满z-paging并固定) -->
<view v-if="showLoading&&zSlots.loading&&loadingFullFixed" class="zp-loading-fixed">
<slot name="loading" />
</view>
</view>
<!-- #endif -->
<!-- #ifdef APP-NVUE -->
<component ref="z-paging-content" :is="finalNvueSuperListIs" :style="[finalPagingStyle]" :class="{'z-paging-content-fixed':fixed&&!usePageScroll}" :scrollable="false">
<!-- 二楼view -->
<view v-if="showF2 && showRefresherF2" ref="zp-n-f2" class="zp-f2-content" @touchmove.stop.prevent :style="[{'height': superContentHeight + 'px', 'width': nRefresherWidth + 'px', 'opacity': nF2Opacity}]">
<slot name="f2"/>
</view>
<!-- 顶部固定的slot -->
<view ref="zp-page-top" v-if="zSlots.top" :class="{'zp-page-top':usePageScroll}" :style="[usePageScroll?{'top':`${windowTop}px`,'z-index':topZIndex}:{}]">
<slot name="top" />
</view>
<!-- 聊天记录模式加载更多loadingloading时候显示) -->
<view v-if="useChatRecordMode&&loadingStatus!==M.NoMore&&showChatLoadingWhenReload&&showLoading">
<slot v-if="zSlots.chatLoading" :loadingMoreStatus="loadingStatus" name="chatLoading" />
<z-paging-load-more v-else @doClick="_onLoadingMore('click')" :zConfig="zLoadMoreConfig" />
</view>
<component :is="finalNvueSuperListIs" class="zp-n-list-container" :scrollable="false">
<view v-if="zSlots.left" class="zp-page-left">
<slot name="left" />
</view>
<component :is="finalNvueListIs" ref="zp-n-list" :id="nvueListId" :style="[{'flex': 1,'top':isIos?'0px':'-1px'},usePageScroll?scrollViewStyle:{},chatRecordRotateStyle]" :alwaysScrollableVertical="true"
:fixFreezing="nFixFreezing" :show-scrollbar="showScrollbar" :loadmoreoffset="finalLowerThreshold" :enable-back-to-top="enableBackToTop"
:scrollable="finalScrollable" :bounce="nvueBounce" :column-count="nWaterfallColumnCount" :column-width="nWaterfallColumnWidth"
:column-gap="nWaterfallColumnGap" :left-gap="nWaterfallLeftGap" :right-gap="nWaterfallRightGap" :pagingEnabled="nvuePagingEnabled" :offset-accuracy="offsetAccuracy"
@loadmore="_nOnLoadmore" @scroll="_nOnScroll" @scrollend="_nOnScrollend">
<refresh v-if="(zSlots.top?cacheTopHeight!==-1:true)&&finalNvueRefresherEnabled" class="zp-n-refresh" :style="[nvueRefresherStyle]" :display="nRefresherLoading?'show':'hide'" @refresh="_nOnRrefresh" @pullingdown="_nOnPullingdown">
<view ref="zp-n-refresh-container" class="zp-n-refresh-container" :style="[{background:refresherBackground,width:nRefresherWidth}]" id="zp-n-refresh-container">
<view v-if="useRefresherStatusBarPlaceholder" class="zp-custom-refresher-status-bar-placeholder" :style="[{'height': `${statusBarHeight}px`}]" />
<!-- 下拉刷新view -->
<slot v-if="zSlots.refresherComplete&&refresherStatus===R.Complete" name="refresherComplete" />
<slot v-else-if="zSlots.refresherF2&&refresherStatus===R.GoF2" name="refresherF2" />
<slot v-else-if="(nScopedSlots?nScopedSlots:zSlots).refresher" :refresherStatus="refresherStatus" name="refresher" />
<z-paging-refresh ref="refresh" v-else :status="refresherStatus" :defaultThemeStyle="finalRefresherThemeStyle" :isIos="isIos"
:defaultText="finalRefresherDefaultText" :pullingText="finalRefresherPullingText" :refreshingText="finalRefresherRefreshingText" :completeText="finalRefresherCompleteText" :goF2Text="finalRefresherGoF2Text"
:defaultImg="refresherDefaultImg" :pullingImg="refresherPullingImg" :refreshingImg="refresherRefreshingImg" :completeImg="refresherCompleteImg" :refreshingAnimated="refresherRefreshingAnimated"
:showUpdateTime="showRefresherUpdateTime" :updateTimeKey="refresherUpdateTimeKey" :updateTimeTextMap="finalRefresherUpdateTimeTextMap"
:imgStyle="refresherImgStyle" :titleStyle="refresherTitleStyle" :updateTimeStyle="refresherUpdateTimeStyle" :unit="unit" />
</view>
</refresh>
<component :is="nViewIs" v-if="isIos&&!useChatRecordMode?oldScrollTop>10:true" ref="zp-n-list-top-tag" class="zp-n-list-top-tag" style="margin-top: -1rpx;" :style="[{height:finalNvueRefresherEnabled?'0px':'1px'}]"></component>
<component :is="nViewIs" v-if="nShowRefresherReveal" ref="zp-n-list-refresher-reveal" :style="[{transform:`translateY(-${nShowRefresherRevealHeight}px)`},{background:refresherBackground}]">
<view v-if="useRefresherStatusBarPlaceholder" class="zp-custom-refresher-status-bar-placeholder" :style="[{'height': `${statusBarHeight}px`}]" />
<!-- 下拉刷新view -->
<slot v-if="zSlots.refresherComplete&&refresherStatus===R.Complete" name="refresherComplete" />
<slot v-else-if="zSlots.refresherF2&&refresherStatus===R.GoF2" name="refresherF2" />
<slot v-else-if="(nScopedSlots?nScopedSlots:$slots).refresher" :refresherStatus="R.Loading" name="refresher" />
<z-paging-refresh ref="refresh" v-else :status="R.Loading" :defaultThemeStyle="finalRefresherThemeStyle" :isIos="isIos"
:defaultText="finalRefresherDefaultText" :pullingText="finalRefresherPullingText" :refreshingText="finalRefresherRefreshingText" :completeText="finalRefresherCompleteText" :goF2Text="finalRefresherGoF2Text"
:defaultImg="refresherDefaultImg" :pullingImg="refresherPullingImg" :refreshingImg="refresherRefreshingImg" :completeImg="refresherCompleteImg" :refreshingAnimated="refresherRefreshingAnimated"
:showUpdateTime="showRefresherUpdateTime" :updateTimeKey="refresherUpdateTimeKey" :updateTimeTextMap="finalRefresherUpdateTimeTextMap"
:imgStyle="refresherImgStyle" :titleStyle="refresherTitleStyle" :updateTimeStyle="refresherUpdateTimeStyle" :unit="unit" />
</component>
<!-- 内置列表 -->
<template v-if="finalUseInnerList">
<component :is="nViewIs">
<slot name="header"/>
</component>
<component :is="nViewIs" class="zp-list-cell" v-for="(item,index) in realTotalData" :key="finalCellKeyName.length?item[finalCellKeyName]:index">
<slot name="cell" :item="item" :index="index"/>
</component>
<component :is="nViewIs">
<slot name="footer"/>
</component>
</template>
<template v-else>
<slot />
</template>
<!-- 全屏Loading -->
<component :is="nViewIs" v-if="showLoading&&zSlots.loading&&!loadingFullFixed" :class="{'z-paging-content-fixed':usePageScroll}" style="flex:1" :style="[chatRecordRotateStyle]">
<slot name="loading" />
</component>
<!-- 上拉加载更多view -->
<component :is="nViewIs" v-if="!refresherOnly&&loadingMoreEnabled&&!showEmpty">
<!-- 聊天记录模式加载更多loading(滚动到顶部加载更多或无更多数据时显示) -->
<template v-if="useChatRecordMode&&realTotalData.length>=defaultPageSize&&(loadingStatus!==M.NoMore||zSlots.chatNoMore)&&realTotalData.length&&isChatRecordModeAndInversion">
<view :style="[chatRecordRotateStyle]">
<slot v-if="loadingStatus===M.NoMore&&zSlots.chatNoMore" name="chatNoMore" />
<template v-else>
<slot v-if="zSlots.chatLoading" :loadingMoreStatus="loadingStatus" name="chatLoading" />
<z-paging-load-more v-else @doClick="_onLoadingMore('click')" :zConfig="zLoadMoreConfig" />
</template>
</view>
</template>
<view :style="nLoadingMoreFixedHeight?{height:loadingMoreCustomStyle&&loadingMoreCustomStyle.height?loadingMoreCustomStyle.height:loadingMoreFixedHeight}:{}">
<slot v-if="showLoadingMoreDefault" name="loadingMoreDefault" />
<slot v-else-if="showLoadingMoreLoading" name="loadingMoreLoading" />
<slot v-else-if="showLoadingMoreNoMore" name="loadingMoreNoMore" />
<slot v-else-if="showLoadingMoreFail" name="loadingMoreFail" />
<z-paging-load-more @doClick="_onLoadingMore('click')" v-else-if="showLoadingMoreCustom" :zConfig="zLoadMoreConfig" />
<view v-if="safeAreaInsetBottom&&useSafeAreaPlaceholder&&!useChatRecordMode" class="zp-safe-area-placeholder" :style="[{height:safeAreaBottom+'px'}]" />
</view>
</component>
<!-- 空数据图 -->
<component :is="nViewIs" v-if="showEmpty" :class="{'z-paging-content-fixed':usePageScroll}" :style="[{flex:emptyViewCenter?1:0},emptyViewSuperStyle,chatRecordRotateStyle]">
<view :class="{'zp-empty-view':true,'zp-empty-view-center':emptyViewCenter}">
<slot v-if="zSlots.empty" name="empty" :isLoadFailed="isLoadFailed" />
<z-paging-empty-view v-else :emptyViewImg="finalEmptyViewImg" :emptyViewText="finalEmptyViewText" :showEmptyViewReload="finalShowEmptyViewReload"
:emptyViewReloadText="finalEmptyViewReloadText" :isLoadFailed="isLoadFailed" :emptyViewStyle="emptyViewStyle" :emptyViewTitleStyle="emptyViewTitleStyle"
:emptyViewImgStyle="emptyViewImgStyle" :emptyViewReloadStyle="emptyViewReloadStyle" :emptyViewZIndex="emptyViewZIndex" :emptyViewFixed="emptyViewFixed" :unit="unit"
@reload="_emptyViewReload" @viewClick="_emptyViewClick" />
</view>
</component>
<component :is="nViewIs" v-if="!hideNvueBottomTag" ref="zp-n-list-bottom-tag" class="zp-n-list-bottom-tag"></component>
</component>
<view v-if="zSlots.right" class="zp-page-right">
<slot name="right" />
</view>
</component>
<!-- 底部固定的slot -->
<view class="zp-page-bottom-container" :style="{'background': bottomBgColor}">
<slot name="bottom" />
<!-- 聊天记录模式底部占位 -->
<template v-if="useChatRecordMode&&autoAdjustPositionWhenChat">
<view :style="[{height:chatRecordModeSafeAreaBottom+'px'}]" />
<view class="zp-page-bottom-keyboard-placeholder-animate" :style="[{height:keyboardHeight+'px'}]" />
</template>
</view>
<!-- 点击返回顶部view -->
<view v-if="showBackToTopClass" :class="finalBackToTopClass" :style="[finalBackToTopStyle]" @click.stop="_backToTopClick">
<slot v-if="zSlots.backToTop" name="backToTop" />
<image v-else class="zp-back-to-top-img" :class="{'zp-back-to-top-img-inversion': useChatRecordMode&&!backToTopImg.length}" :src="backToTopImg.length?backToTopImg:base64BackToTop" />
</view>
<!-- 全屏Loading(铺满z-paging并固定) -->
<view v-if="showLoading&&zSlots.loading&&loadingFullFixed" class="zp-loading-fixed">
<slot name="loading" />
</view>
</component>
<!-- #endif -->
</template>
<script module="pagingRenderjs" lang="renderjs">
import pagingRenderjs from './wxs/z-paging-renderjs.js';
/**
* z-paging 分页组件
* @description z-paging 分页组件,高性能,全平台兼容。支持自定义下拉刷新、上拉加载更多、虚拟列表、下拉进入二楼、自动管理空数据图、全自动分页、无闪动聊天分页、本地分页等,也支持作为基本布局容器使用
* @tutorial https://z-paging.zxlee.cn
* @property {Array} value 父组件v-model所绑定的list的值,默认为[]
* @property {Number|String} defaultPageNo 自定义初始的pageNo,默认为1
* @property {Number|String} defaultPageSize 自定义pageSize(每页显示多少条),默认为10
* @property {Boolean} fixed z-paging是否使用fixed布局,默认为true
* @property {Boolean} safeAreaInsetBottom 是否开启底部安全区域适配,默认为false
* @property {Boolean} useSafeAreaPlaceholder 开启底部安全区域适配后,是否使用placeholder形式实现,默认为false
* @property {Boolean} usePageScroll 使用页面滚动,默认为false
* @property {Boolean} autoFullHeight 使用页面滚动时,是否在不满屏时自动填充满屏幕,默认为true
* @property {String} defaultThemeStyle loading(下拉刷新、上拉加载更多)的主题样式,支持black,white,默认为black
* @property {Object} pagingStyle 设置z-paging的style,部分平台(如微信小程序)无法直接修改组件的style,可使用此属性代替
* @property {String} height z-paging的高度,优先级低于pagingStyle中设置的height,传字符串,如100px、100rpx、100%
* @property {String} width z-paging的宽度,优先级低于pagingStyle中设置的width,传字符串,如100px、100rpx、100%
* @property {String} maxWidth z-paging的最大宽度,优先级低于pagingStyle中设置的max-width,默认为空
* @property {String} bgColor z-paging的背景色(为css中的background,因此也可以设置渐变,背景图片等),优先级低于pagingStyle中设置的background-color
* @property {Boolean} watchTouchDirectionChange 是否监听列表触摸方向改变,默认为false
* @property {Number|String} delay 调用complete后延迟处理的时间,单位为毫秒,优先级高于min-delay,默认为0
* @property {Number|String} minDelay 触发@query后最小延迟处理的时间,单位为毫秒,优先级低于delay,默认为0
* @property {Boolean} callNetworkReject 请求失败是否触发reject,默认为true
* @property {String} unit z-paging中默认布局的单位,默认为rpx
* @property {Boolean} concat 自动拼接complete中传过来的数组,默认为true
* @property {Number|String|Object} dataKey 为保证数据一致,设置当前tab切换时的标识key,并在complete中传递相同key,若二者不一致,则complete将不会生效
* @property {String} autowireListName 【极简写法】自动注入的list名,可自动修改父view(包含ref="paging")中对应name的list值
* @property {String} autowireQueryName 【极简写法】自动注入的query名,可自动调用父view(包含ref="paging")中的query方法
* @property {Function} fetch 【极简写法】获取分页数据Function,功能与@query类似。若设置了fetch则@query将不再触发
* @property {Object} fetchParams fetch的附加参数,fetch配置后有效
* @property {Boolean} auto [z-paging]mounted后是否自动调用reload方法(mounted后自动调用接口),默认为true
* @property {Boolean} autoScrollToTopWhenReload reload时自动滚动到顶部,默认为true
* @property {Boolean} autoCleanListWhenReload reload时立即自动清空原list,默认为true
* @property {Boolean} showRefresherWhenReload 列表刷新时自动显示下拉刷新view,默认为false
* @property {Boolean} showLoadingMoreWhenReload 列表刷新时自动显示加载更多view,且为加载中状态,默认为false
* @property {Boolean} createdReload 组件created时立即触发reload,默认为false
* @property {Boolean} refresherEnabled 是否开启下拉刷新,默认为true
* @property {Number|String} refresherThreshold 设置自定义下拉刷新阈值,默认单位为px,默认为80rpx
* @property {Boolean} useRefresherStatusBarPlaceholder 是否开启下拉刷新状态栏占位,默认为false
* @property {Boolean} refresherOnly 是否只使用下拉刷新,默认为false
* @property {Boolean} useCustomRefresher 是否使用自定义的下拉刷新,默认为true
* @property {Boolean} reloadWhenRefresh 用户下拉刷新时是否触发reload方法,默认为true
* @property {String} refresherThemeStyle 下拉刷新的主题样式,支持blackwhite,默认为black
* @property {Object} refresherImgStyle 自定义下拉刷新中左侧图标的样式
* @property {Object} refresherTitleStyle 自定义下拉刷新中右侧状态描述文字的样式
* @property {Object} refresherUpdateTimeStyle 自定义下拉刷新中右侧最后更新时间文字的样式
* @property {Boolean} watchRefresherTouchmove 是否实时监听下拉刷新中进度,并通过@refresherTouchmove传递给父组件,默认为false
* @property {Boolean} showRefresherUpdateTime 是否显示最后更新时间,默认为false
* @property {String|Object} refresherDefaultText 自定义下拉刷新默认状态下的文字
* @property {String|Object} refresherPullingText 自定义下拉刷新松手立即刷新状态下的文字
* @property {String|Object} refresherRefreshingText 自定义下拉刷新刷新中状态下的文字
* @property {String|Object} refresherCompleteText 自定义下拉刷新刷新结束状态下的文字
* @property {String} refresherDefaultImg 自定义下拉刷新默认状态下的图片
* @property {String} refresherPullingImg 自定义下拉刷新松手立即刷新状态下的图片
* @property {String} refresherRefreshingImg 自定义下拉刷新刷新中状态下的图片
* @property {String} refresherCompleteImg 自定义下拉刷新刷新结束状态下的图片
* @property {Boolean} refresherRefreshingAnimated 自定义下拉刷新刷新中状态下是否展示旋转动画,默认为true
* @property {Boolean} refresherEndBounceEnabled 是否开启自定义下拉刷新刷新结束回弹动画效果,默认为true
* @property {String} refresherDefaultStyle 设置系统下拉刷新默认样式,支持设置black,whitenone,默认为black
* @property {String} refresherBackground 设置自定义下拉刷新区域背景颜色,默认为#FFFFFF00
* @property {String} refresherFixedBackground 设置固定的自定义下拉刷新区域背景颜色,默认为#FFFFFF00
* @property {Number|String} refresherFixedBacHeight 设置固定的自定义下拉刷新区域高度,默认为0
* @property {Number|String} refresherDefaultDuration 设置自定义下拉刷新默认状态下回弹动画时间,单位为毫秒,默认为100
* @property {Number|String} refresherCompleteDelay 自定义下拉刷新结束以后延迟收回的时间,单位为毫秒,默认为0
* @property {Number|String} refresherCompleteDuration 自定义下拉刷新结束收回动画时间,单位为毫秒,默认为300
* @property {Boolean} refresherVibrate 下拉刷新时下拉到“松手立即刷新”状态时是否使手机短振动,默认为false
* @property {Boolean} refresherRefreshingScrollable 自定义下拉刷新刷新中状态是否允许列表滚动,默认为true
* @property {Boolean} refresherCompleteScrollable 自定义下拉刷新结束状态下是否允许列表滚动,默认为false
* @property {Number} refresherOutRate 设置自定义下拉刷新下拉超出阈值后继续下拉位移衰减的比例,默认为0.65
* @property {Boolean} refresherF2Enabled 是否开启下拉进入二楼功能,默认为false
* @property {Number|String} refresherF2Threshold 下拉进入二楼阈值,默认为200rpx
* @property {Number|String} refresherF2Duration 下拉进入二楼动画时间,单位为毫秒,默认为200
* @property {Boolean} showRefresherF2 下拉进入二楼状态松手后是否弹出二楼,默认为true
* @property {Number} refresherPullRate 设置自定义下拉刷新下拉时实际下拉位移与用户下拉距离的比值,默认为0.75
* @property {Number|String} refresherFps 自定义下拉刷新下拉帧率,默认为40
* @property {Number|String} refresherMaxAngle 自定义下拉刷新允许触发的最大下拉角度,默认为40度
* @property {Boolean} refresherAngleEnableChangeContinued 自定义下拉刷新的角度由未达到最大角度变到达到最大角度时,是否继续下拉刷新手势,默认为false
* @property {Boolean} refresherNoTransform 下拉刷新时是否禁止下拉刷新view跟随用户触摸竖直移动,默认为false
* @property {Boolean} loadingMoreEnabled 是否启用加载更多数据(含滑动到底部加载更多数据和点击加载更多数据),默认为true
* @property {Number|String} lowerThreshold 距底部/右边多远时,触发scrolltolower事件,默认单位为px,默认为100rpx
* @property {Boolean} toBottomLoadingMoreEnabled 是否启用滑动到底部加载更多数据,默认为true
* @property {String} loadingMoreThemeStyle 底部加载更多的主题样式,支持black,white,默认为black
* @property {Object} loadingMoreCustomStyle 自定义底部加载更多样式
* @property {Object} loadingMoreTitleCustomStyle 自定义底部加载更多文字样式
* @property {Object} loadingMoreLoadingIconCustomStyle 自定义底部加载更多加载中动画样式
* @property {String} loadingMoreLoadingIconType 自定义底部加载更多加载中动画图标类型,可选flower或circle,默认为flower
* @property {String} loadingMoreLoadingIconCustomImage 自定义底部加载更多加载中动画图标图片
* @property {Boolean} loadingMoreLoadingAnimated 底部加载更多加载中view是否展示旋转动画,默认为true
* @property {String|Object} loadingMoreDefaultText 滑动到底部"默认"文字
* @property {String|Object} loadingMoreLoadingText 滑动到底部"加载中"文字
* @property {String|Object} loadingMoreNoMoreText 滑动到底部"没有更多"文字
* @property {String|Object} loadingMoreFailText 滑动到底部"加载失败"文字
* @property {Boolean} hideNoMoreInside 当没有更多数据且分页内容未超出z-paging时是否隐藏没有更多数据的view,默认为false
* @property {Number} hideNoMoreByLimit 当没有更多数据且分页数组长度少于这个值时,隐藏没有更多数据的view,默认为0
* @property {Boolean} insideMore 当分页未满一屏时,是否自动加载更多,默认为false
* @property {Boolean} loadingMoreDefaultAsLoading 滑动到底部状态为默认状态时,以加载中的状态展示,默认为false
* @property {Boolean} showLoadingMoreNoMoreView 是否显示没有更多数据的view,默认为true
* @property {Boolean} showDefaultLoadingMoreText 是否显示默认的加载更多text,默认为true
* @property {Boolean} showLoadingMoreNoMoreLine 是否显示没有更多数据的分割线,默认为true
* @property {Object} loadingMoreNoMoreLineCustomStyle 自定义底部没有更多数据的分割线样式
* @property {Boolean} hideEmptyView 是否强制隐藏空数据图,默认为false
* @property {Boolean} emptyViewFixed 空数据图片是否铺满z-paging,默认为false
* @property {Boolean} emptyViewCenter 空数据图片是否垂直居中,默认为true
* @property {String|Object} emptyViewText 空数据图描述文字
* @property {String} emptyViewImg 空数据图图片
* @property {String} emptyViewErrorImg 空数据图“加载失败”图片
* @property {String|Object} emptyViewReloadText 空数据图点击重新加载文字
* @property {String|Object} emptyViewErrorText 空数据图“加载失败”描述文字
* @property {Object} emptyViewSuperStyle 空数据图父view样式
* @property {Object} emptyViewStyle 空数据图样式
* @property {Object} emptyViewImgStyle 空数据图img样式
* @property {Object} emptyViewTitleStyle 空数据图描述文字样式
* @property {Object} emptyViewReloadStyle 空数据图重新加载按钮样式
* @property {Boolean} showEmptyViewReload 是否显示空数据图重新加载按钮(无数据时),默认为false
* @property {Boolean} showEmptyViewReloadWhenError 加载失败时是否显示空数据图重新加载按钮,默认为true
* @property {Boolean} autoHideEmptyViewWhenLoading 加载中时是否自动隐藏空数据图,默认为true
* @property {Boolean} autoHideEmptyViewWhenPull 用户下拉列表触发下拉刷新加载中时是否自动隐藏空数据图,默认为true
* @property {Boolean} autoHideLoadingAfterFirstLoaded 第一次加载后自动隐藏loading slot,默认为true
* @property {Boolean} loadingFullFixed loading slot的父view是否铺满屏幕并固定,默认为false
* @property {Boolean} autoShowSystemLoading 是否自动显示系统Loading:即uni.showLoading,默认为false
* @property {String|Object} systemLoadingText 显示系统Loading时显示的文字
* @property {Boolean} systemLoadingMask 显示系统Loading时是否显示透明蒙层,防止触摸穿透,默认为true
* @property {Boolean} autoShowBackToTop 自动显示点击返回顶部按钮,默认为false
* @property {Number|String} backToTopThreshold 点击返回顶部按钮显示/隐藏的阈值(滚动距离),默认单位为px,默认为400rpx
* @property {String} backToTopImg 点击返回顶部按钮的自定义图片地址
* @property {Boolean} backToTopWithAnimate 点击返回顶部按钮返回到顶部时是否展示过渡动画,默认为true
* @property {Number|String} backToTopBottom 点击返回顶部按钮与底部的距离,默认单位为px,默认为160rpx
* @property {Object} backToTopStyle 点击返回顶部按钮的自定义样式
* @property {Boolean} useVirtualList 是否使用虚拟列表,默认为false
* @property {Boolean} useCompatibilityMode 在使用虚拟列表时,是否使用兼容模式,默认为false
* @property {Object} extraData 使用兼容模式时传递的附加数据
* @property {String} cellHeightMode 虚拟列表cell高度模式,默认为fixed
* @property {Number|String} preloadPage 预加载的列表可视范围(列表高度)页数,默认为12
* @property {Number|String} fixedCellHeight 固定的cell高度,`cell-height-mode=fixed`才有效,默认为空
* @property {Number|String} virtualListCol 虚拟列表列数,默认为1
* @property {Number|String} virtualScrollFps 虚拟列表scroll取样帧率,默认为80
* @property {String} virtualCellIdPrefix 虚拟列表cell id的前缀
* @property {Boolean} useInnerList 是否在z-paging内部循环渲染列表(使用内置列表),默认为false
* @property {Boolean} forceCloseInnerList 强制关闭inner-list,默认为false
* @property {Boolean} virtualInSwiperSlot 虚拟列表是否使用swiper-item包裹,默认为false
* @property {String} cellKeyName 内置列表cell的key名称(仅nvue有效)
* @property {Object} innerListStyle innerList样式
* @property {Object} innerCellStyle innerCell样式
* @property {Number|String} localPagingLoadingTime 本地分页时上拉加载更多延迟时间,单位为毫秒,默认为200
* @property {Boolean} useChatRecordMode 使用聊天记录模式,默认为false
* @property {Boolean} autoHideKeyboardWhenChat 使用聊天记录模式时是否自动隐藏键盘,默认为true
* @property {Boolean} autoAdjustPositionWhenChat 使用聊天记录模式中键盘弹出时是否自动调整slot="bottom"高度,默认为true
* @property {Boolean} autoToBottomWhenChat 使用聊天记录模式中键盘弹出时是否自动滚动到底部,默认为false
* @property {String} chatAdjustPositionOffset 使用聊天记录模式中键盘弹出时占位高度偏移距离,默认为0px
* @property {Boolean} showChatLoadingWhenReload 使用聊天记录模式中`reload`时是否显示`chatLoading`,默认为false
* @property {String} bottomBgColor `bottom`的背景色,默认透明
* @property {Boolean} chatLoadingMoreDefaultAsLoading 在聊天记录模式中滑动到顶部状态为默认状态时,是否以加载中的状态展示,默认为true
* @property {Boolean} showScrollbar 控制是否出现滚动条,默认为true
* @property {Boolean} scrollable 是否可以滚动,使用内置scroll-view和nvue时有效,默认为true
* @property {Boolean} scrollX 是否允许横向滚动,默认为false
* @property {Boolean} scrollToTopBounceEnabled iOS设备上滚动到顶部时是否允许回弹效果,默认为false
* @property {Boolean} scrollToBottomBounceEnabled iOS设备上滚动到底部时是否允许回弹效果,默认为true
* @property {Boolean} scrollWithAnimation 在设置滚动条位置时使用动画过渡,默认为false
* @property {String} scrollIntoView 值应为某子元素id(id不能以数字开头)。设置哪个方向可滚动,则在哪个方向滚动到该元素
* @property {Boolean} enableBackToTop iOS点击顶部状态栏、安卓双击标题栏时,滚动条返回顶部,默认为true
* @property {String} nvueListIs nvue中修改列表类型,默认为list
* @property {Object} nvueWaterfallConfig waterfall配置,仅在nvue中且nvueListIs=waterfall时有效
* @property {Boolean} nvueBounce nvue控制是否回弹效果,iOS不支持动态修改,默认为true
* @property {Boolean} nvueFastScroll nvue中通过代码滚动到顶部/底部时,是否加快动画效果,默认为false
* @property {String} nvueListId nvue中list的id
* @property {Boolean} hideNvueBottomTag 是否隐藏nvue列表底部的tagView,默认为false
* @property {Boolean} nvuePagingEnabled 设置nvue中是否按分页模式(类似竖向swiper)显示List,默认为false
* @property {Number} offsetAccuracy nvue中控制onscroll事件触发的频率,默认为空
* @property {Boolean} useCache 是否使用缓存,默认为false
* @property {String} cacheKey 使用缓存时缓存的key
* @property {String} cacheMode 缓存模式,默认为default
* @property {Number} topZIndex slot="top"的view的z-index,默认为99
* @property {Number} superContentZIndex z-paging内容容器父view的z-index,默认为1
* @property {Number} contentZIndex z-paging内容容器部分的z-index,默认为1
* @property {Number} emptyViewZIndex 空数据view的z-index,默认为9
* @property {Boolean} autoHeight z-paging是否自动高度,默认为false
* @property {Number|String} autoHeightAddition z-paging自动高度时的附加高度,默认为0px
* @event {Function} input 父组件v-model所绑定的list的值改变时触发此事件
* @event {Function} query 下拉刷新或滚动到底部时会自动触发此方法。z-paging加载时也会触发(若要禁止,请设置:auto="false")。pageNo和pageSize会自动计算好,直接传给服务器即可。
* @event {Function} listChange 分页渲染的数组改变时触发
* @event {Function} refresherStatusChange 自定义下拉刷新状态改变
* @event {Function} refresherTouchstart 自定义下拉刷新下拉开始
* @event {Function} refresherTouchmove 自定义下拉刷新下拉拖动中
* @event {Function} refresherTouchend 自定义下拉刷新下拉结束
* @event {Function} refresherF2Change 下拉进入二楼状态改变
* @event {Function} refresh 自定义下拉刷新被触发
* @event {Function} restore 自定义下拉刷新被复位
* @event {Function} loadingStatusChange 自定义下拉刷新状态改变
* @event {Function} emptyViewReload 点击了空数据图中的重新加载按钮
* @event {Function} emptyViewClick 点击了空数据图view
* @event {Function} isLoadFailedChange z-paging请求失败状态改变
* @event {Function} backToTopClick 点击了返回顶部按钮
* @event {Function} virtualListChange 虚拟列表当前渲染的数组改变时触发
* @event {Function} innerCellClick 使用虚拟列表或内置列表时点击了cell
* @event {Function} virtualPlaceholderTopHeight 虚拟列表顶部占位高度改变
* @event {Function} hidedKeyboard 在聊天记录模式下,触摸列表隐藏了键盘
* @event {Function} keyboardHeightChange 键盘高度改变
* @event {Function} scroll z-paging列表滚动时触发
* @event {Function} scrollTopChange scrollTop改变时触发
* @event {Function} scrolltolower z-paging内置的scroll-view/list-view/waterfall滚动底部时触发
* @event {Function} scrolltoupper z-paging内置的scroll-view/list-view/waterfall滚动顶部时触发
* @event {Function} scrollend z-paging内置的list滚动结束时触发
* @event {Function} contentHeightChanged z-paging中内容高度改变时触发
* @event {Function} touchDirectionChange 监听列表触摸方向改变
* @example <z-paging ref="paging" v-model="dataList" @query="queryList"></z-paging>
*/
export default {
name:"z-paging",
// #ifdef APP-VUE || H5
mixins: [pagingRenderjs],
// #endif
}
</script>
<script src="./js/z-paging-main.js" />
<!-- #ifdef APP-VUE || MP-WEIXIN || MP-QQ || H5 -->
<script src="./wxs/z-paging-wxs.wxs" module="pagingWxs" lang="wxs"></script>
<!-- #endif -->
<style scoped>
@import "./css/z-paging-main.css";
@import "./css/z-paging-static.css";
</style>
+89
View File
@@ -0,0 +1,89 @@
{
"id": "z-paging",
"name": "z-paging",
"displayName": "【z-paging下拉刷新、上拉加载】高性能,全平台兼容。支持虚拟列表,分页全自动处理",
"version": "2.8.6",
"description": "超简单、低耦合!使用wxs+renderjs实现。支持自定义下拉刷新、上拉加载更多、虚拟列表、下拉进入二楼、自动管理空数据图、无闪动聊天分页、本地分页、国际化等数百项配置",
"keywords": [
"下拉刷新",
"上拉加载",
"分页器",
"nvue",
"虚拟列表"
],
"repository": "https://github.com/SmileZXLee/uni-z-paging",
"engines": {
"HBuilderX": "^3.0.7"
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": "393727164"
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": "https://www.npmjs.com/package/z-paging",
"type": "component-vue"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "n"
},
"client": {
"App": {
"app-vue": "y",
"app-nvue": "y",
"app-harmony": "u",
"app-uvue": "u"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "y",
"百度": "y",
"字节跳动": "y",
"QQ": "y",
"钉钉": "y",
"快手": "y",
"飞书": "y",
"京东": "y"
},
"快应用": {
"华为": "y",
"联盟": "y"
},
"Vue": {
"vue2": "y",
"vue3": "y"
}
}
}
}
}
+57
View File
@@ -0,0 +1,57 @@
# z-paging
<p align="center">
<img alt="logo" src="https://z-paging.zxlee.cn/img/title-logo.png" height="100" style="margin-bottom: 50px;" />
</p>
[![version](https://img.shields.io/badge/version-2.8.6-blue)](https://github.com/SmileZXLee/uni-z-paging) [![license](https://img.shields.io/github/license/SmileZXLee/uni-z-paging)](https://en.wikipedia.org/wiki/MIT_License)
<img height="0" width="0" src="https://api.z-notify.zxlee.cn/v1/public/statistics/8293556910106066944/addOnly?from=uni" />
`z-paging-x`现已支持uniapp x,持续完善中,插件地址👉🏻 [https://ext.dcloud.net.cn/plugin?name=z-paging-x](https://ext.dcloud.net.cn/plugin?name=z-paging-x)
### 文档地址:[https://z-paging.zxlee.cn](https://z-paging.zxlee.cn)
### 更新组件前,请注意[版本差异](https://z-paging.zxlee.cn/start/upgrade-guide.html)
***
### 功能&特点
* 【配置简单】仅需两步(绑定网络请求方法、绑定分页结果数组)轻松完成完整下拉刷新,上拉加载更多功能。
* 【低耦合,低侵入】分页自动管理。在page中无需处理任何分页相关逻辑,无需在data中定义任何分页相关变量,全由z-paging内部处理。
* 【超灵活,支持各种类型自定义】支持自定义下拉刷新,自定义上拉加载更多等各种自定义效果;支持使用内置自动分页,同时也支持通过监听下拉刷新和滚动到底部事件自行处理;支持使用自带全屏布局规范,同时也支持将z-paging自由放在任意容器中。
* 【功能丰富】支持国际化,支持自定义且自动管理空数据图,支持主题模式切换,支持本地分页,支持无闪动聊天分页模式,支持展示最后更新时间,支持吸顶效果,支持内部scroll-view滚动与页面滚动,支持一键滚动到顶部,支持下拉进入二楼等诸多功能。
* 【【全平台兼容】支持vue&nvuevue2&vue3js&ts,支持h5、app、鸿蒙Next及各家小程序。
* 【高性能】在app-vue、h5、微信小程序、QQ小程序上使用wxs+renderjs在视图层实现下拉刷新;支持虚拟列表,轻松渲染百万级列表数据!
***
### 反馈qq群
* 官方1群`已满`[790460711](https://jq.qq.com/?_wv=1027&k=vU2fKZZH)
* 官方2群`已满`[371624008](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=avPmibADf2TNi4LxkIwjCE5vbfXpa-r1&authKey=dQ%2FVDAR87ONxI4b32Py%2BvmXbhnopjHN7%2FJPtdsqJdsCPFZB6zDQ17L06Uh0kITUZ&noverify=0&group_code=371624008)
* 官方3群:[343409055](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=sIaNqiCMIjxGQVksjytCw6R8DSiibHR7&authKey=pp995q8ZzFtl5F2xUwvvceP24QTcguWW%2FRVoDnMa8JZF4L2DmS%2B%2FV%2F5sYrcgPsmW&noverify=0&group_code=343409055)
***
### 预览
***
| 自定义下拉刷新效果演示 | 滑动切换选项卡+吸顶演示 | 聊天记录模式演示 |
| :----------------------------------------------------------: | :----------------------------------------------------------: | ------------------------------------------------------------ |
| ![](https://z-paging.zxlee.cn/public/img/z-paging-demo5.gif) | ![](https://z-paging.zxlee.cn/public/img/z-paging-demo6.gif) | ![](https://z-paging.zxlee.cn/public/img/z-paging-demo7.gif) |
| 虚拟列表(流畅渲染1万+条)演示 | 下拉进入二楼演示 | 在弹窗内使用演示 |
| :----------------------------------------------------------: | :----------------------------------------------------------: | ------------------------------------------------------------ |
| ![](https://z-paging.zxlee.cn/public/img/z-paging-demo8.gif) | ![](https://z-paging.zxlee.cn/public/img/z-paging-demo9.gif) | ![](https://z-paging.zxlee.cn/public/img/z-paging-demo10.gif) |
### 在线demo体验地址:
* [https://demo.z-paging.zxlee.cn](https://demo.z-paging.zxlee.cn)
| 扫码体验 |
| ------------------------------------------------------------ |
| ![](https://z-paging.zxlee.cn/public/img/code.png) |
### demo下载
* 支持vue2&vue3的`选项式api`写法demo下载,请点击页面右上角的【使用HBuilderX导入示例项目】或【下载示例项目ZIP】。
* 支持vue3的`组合式api`写法demo下载,请访问[github](https://github.com/SmileZXLee/uni-z-paging)。
+11
View File
@@ -0,0 +1,11 @@
declare module 'vue' {
export interface GlobalComponents {
['z-paging']: typeof import('./comps/z-paging')['ZPaging']
['z-paging-swiper']: typeof import('./comps/z-paging-swiper')['ZPagingSwiper']
['z-paging-swiper-item']: typeof import('./comps/z-paging-swiper-item')['ZPagingSwiperItem']
['z-paging-empty-view']: typeof import('./comps/z-paging-empty-view')['ZPagingEmptyView']
['z-paging-cell']: typeof import('./comps/z-paging-cell')['ZPagingCell']
}
}
export {}
+9
View File
@@ -0,0 +1,9 @@
export interface AllowedComponentProps {
class?: unknown;
style?: unknown;
}
export interface VNodeProps {
key?: string | number | symbol;
ref?: unknown;
}
+29
View File
@@ -0,0 +1,29 @@
import { AllowedComponentProps, VNodeProps } from './_common'
// ****************************** Props ******************************
declare interface ZPagingCellProps {
/**
* z-paging-cell样式
*/
cellStyle?: Record<string, any>
}
// ****************************** Slots ******************************
declare interface ZPagingCellSlots {
// ******************** 主体布局Slot ********************
/**
* 默认插入的view
*/
['default']?: () => any
}
declare interface _ZPagingCell {
new (): {
$props: AllowedComponentProps &
VNodeProps &
ZPagingCellProps
$slots: ZPagingCellSlots
}
}
export declare const ZPagingCell: _ZPagingCell
@@ -0,0 +1,95 @@
import { AllowedComponentProps, VNodeProps } from './_common'
// ****************************** Props ******************************
declare interface ZPagingEmptyViewProps {
/**
* 空数据图片是否铺满z-paging,默认为是。若设置为否,则为填充满z-paging的剩余部分
* @default false
* @since 2.0.3
*/
emptyViewFixed?: boolean;
/**
* 空数据图描述文字
* @default "没有数据哦~"
*/
emptyViewText?: string;
/**
* 空数据图图片,默认使用z-paging内置的图片
* - 建议使用绝对路径,开头不要添加"@",请以"/"开头
*/
emptyViewImg?: string;
/**
* 空数据图点击重新加载文字
* @default "重新加载"
* @since 1.6.7
*/
emptyViewReloadText?: string;
/**
* 空数据图样式,可设置空数据view的top等
* - 如果空数据图不是fixed布局,则此处是`margin-top`
*/
emptyViewStyle?: Record<string, any>;
/**
* 空数据图img样式
*/
emptyViewImgStyle?: Record<string, any>;
/**
* 空数据图描述文字样式
*/
emptyViewTitleStyle?: Record<string, any>;
/**
* 空数据图重新加载按钮样式
* @since 1.6.7
*/
emptyViewReloadStyle?: Record<string, any>;
/**
* 是否显示空数据图重新加载按钮(无数据时)
* @default false
* @since 1.6.7
*/
showEmptyViewReload?: boolean;
/**
* 是否是加载失败
* @default false
*/
isLoadFailed?: boolean;
/**
* 空数据图中布局的单位
* @default 'rpx'
* @since 2.6.7
*/
unit?: 'rpx' | 'px';
// ****************************** Events ******************************
/**
* 点击了重新加载按钮
*/
onReload?: () => void
/**
* 点击了空数据view
* @since 2.3.3
*/
onViewClick?: () => void
}
declare interface _ZPagingEmptyView {
new (): {
$props: AllowedComponentProps &
VNodeProps &
ZPagingEmptyViewProps
}
}
export declare const ZPagingEmptyView: _ZPagingEmptyView
@@ -0,0 +1,95 @@
import { AllowedComponentProps, VNodeProps } from './_common'
// ****************************** Props ******************************
declare interface ZPagingSwiperItemProps {
/**
* 当前组件的index,也就是当前组件是swiper中的第几个
* @default 0
*/
tabIndex?: number
/**
* 当前swiper切换到第几个index
* @default 0
*/
currentIndex?: number
/**
* 是否使用虚拟列表。使用页面滚动或nvue时,不支持虚拟列表。在nvue中z-paging内置了list组件,效果与虚拟列表类似,并且可以提供更好的性能。
* @default false
*/
useVirtualList?: boolean
/**
* 虚拟列表cell高度模式,默认为`fixed`,也就是每个cell高度完全相同,将以第一个cell高度为准进行计算。
* @default 'fixed'
*/
cellHeightMode?: 'fixed' | 'dynamic'
/**
* 预加载的列表可视范围(列表高度)页数。此数值越大,则虚拟列表中加载的dom越多,内存消耗越大(会维持在一个稳定值),但增加预加载页面数量可缓解快速滚动短暂白屏问题。
* @default 12
*/
preloadPage?: number | string
/**
* 虚拟列表列数,默认为1。常用于每行有多列的情况,例如每行有2列数据,需要将此值设置为2。
* @default 1
* @since 2.2.8
*/
virtualListCol?: number | string
/**
* 虚拟列表scroll取样帧率,默认为80,过低容易出现白屏问题,过高容易出现卡顿问题
* @default 80
*/
virtualScrollFps?: number | string
/**
* 是否在z-paging内部循环渲染列表(使用内置列表)。
* @default false
*/
useInnerList?: boolean
/**
* 内置列表cell的key名称(仅nvue有效,在nvue中开启use-inner-list时必须填此项)
* @since 2.2.7
*/
cellKeyName?: string
/**
* innerList样式
*/
innerListStyle?: Record<string, any>
}
// ****************************** Methods ******************************
declare interface _ZPagingSwiperItemRef {
/**
* 重新加载分页数据,pageNo恢复为默认值,相当于下拉刷新的效果
*
* @param [animate=false] 是否展示下拉刷新动画
*/
reload: (animate?: boolean) => void;
/**
* 请求结束
* - 当通过complete传进去的数组长度小于pageSize时,则判定为没有更多了
*
* @param [data] 请求结果数组
* @param [success=true] 是否请求成功
*/
complete: (data?: any[] | false, success?: boolean) => void;
}
declare interface _ZPagingSwiperItem {
new (): {
$props: AllowedComponentProps &
VNodeProps &
ZPagingSwiperItemProps
}
}
export declare const ZPagingSwiperItem: _ZPagingSwiperItem
export declare const ZPagingSwiperItemRef: _ZPagingSwiperItemRef
+89
View File
@@ -0,0 +1,89 @@
import { AllowedComponentProps, VNodeProps } from './_common'
// ****************************** Props ******************************
declare interface ZPagingSwiperProps {
/**
* 是否使用fixed布局,若使用fixed布局,则z-paging-swiper的父view无需固定高度,z-paging高度默认铺满屏幕,页面中的view请放z-paging-swiper标签内,需要固定在顶部的view使用slot="top"包住,需要固定在底部的view使用slot="bottom"包住。
* @default true
*/
fixed?: boolean
/**
* 是否开启底部安全区域适配
* @default false
*/
safeAreaInsetBottom?: boolean
/**
* z-paging-swiper样式
*/
swiperStyle?: Record<string, any>
}
// ****************************** Slots ******************************
declare interface ZPagingSwiperSlots {
// ******************** 主体布局Slot ********************
/**
* 默认插入的view
*/
['default']?: () => any
/**
* 可以将自定义导航栏、tab-view等需要固定的(不需要跟着滚动的)元素放入slot="top"的view中。
* 注意,当有多个需要固定的view时,请用一个view包住它们,并且在这个view上设置slot="top"。需要固定在顶部的view请勿设置position: fixed;
* @since 1.5.5
*/
['top']?: () => any
/**
* 可以将需要固定在底部的(不需要跟着滚动的)元素放入slot="bottom"的view中。
* 注意,当有多个需要固定的view时,请用一个view包住它们,并且在这个view上设置slot="bottom"。需要固定在底部的view请勿设置position: fixed;
* @since 1.6.2
*/
['bottom']?: () => any
/**
* 可以将需要固定在左侧的(不需要跟着滚动的)元素放入slot="left"的view中。
* 注意,当有多个需要固定的view时,请用一个view包住它们,并且在这个view上设置slot="left"。需要固定在左侧的view请勿设置position: fixed;
* @since 2.2.3
*/
['left']?: () => any
/**
* 可以将需要固定在左侧的(不需要跟着滚动的)元素放入slot="right"的view中。
* 注意,当有多个需要固定的view时,请用一个view包住它们,并且在这个view上设置slot="right"。需要固定在右侧的view请勿设置position: fixed;
* @since 2.2.3
*/
['right']?: () => any
}
// ****************************** Methods ******************************
declare interface _ZPagingSwiperRef {
/**
* 更新slot="left"和slot="right"宽度,当slot="left"或slot="right"宽度动态改变后调用
*
* @since 2.3.5
*/
updateLeftAndRightWidth: () => void;
/**
* 更新fixed模式下z-paging-swiper的布局,在onShow时候调用,以修复在iOS+h5+tabbar+fixed+底部有安全区域的设备中从tabbar页面跳转到无tabbar页面后返回,底部有一段空白区域的问题
*
* @since 2.6.5
*/
updateFixedLayout: () => void;
}
declare interface _ZPagingSwiper {
new (): {
$props: AllowedComponentProps &
VNodeProps &
ZPagingSwiperProps
$slots: ZPagingSwiperSlots
}
}
export declare const ZPagingSwiper: _ZPagingSwiper
export declare const ZPagingSwiperRef: _ZPagingSwiperRef
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
/// <reference path="./comps.d.ts" />
declare module 'z-paging' {
export function install() : void
/**
* z-paging全局配置
* - uni.$zp
*
* @since 2.6.5
*/
interface $zp {
/**
* 全局配置
*/
config : Record<string, any>;
}
global {
interface Uni {
$zp : $zp
}
}
}
declare type ZPagingSwiperRef = typeof import('./comps/z-paging-swiper')['ZPagingSwiperRef']
declare type ZPagingSwiperItemRef = typeof import('./comps/z-paging-swiper-item')['ZPagingSwiperItemRef']