记录元素平移、旋转、缩放和镜像翻转(3)
接着前面的继续写,前面简单实现了元素的移动,旋转变换,接下来还有一个镜像反转功能实现。其实对于常规功能来说,这样就算可以了,最多再新增个元素的删除功能,对于镜像翻转的话,可能没有这个需求,所以我们实现外界可以自定义区域,只要鼠标在这个自定义区域,就执行自定义的方法,来达到我们封装的功能有部分的灵活性。
因为之前实现的变换包含了随着鼠标的拖动也会进行缩放,为了使功能单一,所以进行抽离~,当然抽离完也可以保留边旋转边缩放的功能。因为抽离了功能,为了方便阅读,所以将旋转涉及到的名称也更改一下。
const ROTATE_W = 20;
const ROTATE_H = 20;
// 新增缩放区域常量
const SCALE_W = 20;
const SCALE_H = 20;
// 旋转
const STATUS_ROTATE = 'rotate';
// 缩放
const STATUS_SCALE = 'scale';
const STATUS_ARRAY = [
// ......之前有的状态
STATUS_ROTATE, STATUS_SCALE
];
/**
* 处理元素变换
* 因为抽离功能后单一,所以更改函数名称,
* 涉及到的地方都要更改
*
* @param {Number} x
* @param {Number} y
*/
handleEleRotate(x, y) {
const currItem = this.currItem;
const {
rotate: oRotate
} = this.currItemStartOpt;
// 计算旋转角度
let diffStartX = this.startX - currItem.centerX;
let diffStartY = this.startY - currItem.centerY;
let diffEndX = x - currItem.centerX;
let diffEndY = y - currItem.centerY;
let angleStart = Math.atan2(diffStartY, diffStartX) / Math.PI * 180;
let angleEnd = Math.atan2(diffEndY, diffEndX) / Math.PI * 180;
currItem.rotate = oRotate + angleEnd - angleStart;
// 抽离缩放逻辑,旋转就是单旋转
// this.handleEleScale(x, y);
}
/**
* 抽离处理元素缩放
*
* @param {Number} x 点击的 x 坐标
* @param {Number} y 点击的 y 坐标
*/
handleEleScale(x, y) {
const currItem = this.currItem;
const {
x: oX,
y: oY,
width: oWidth,
height: oHeight
} = this.currItemStartOpt;
// 利用中心点计算鼠标移动前后点距离,
// 用于计算缩放比例,
// 再基于这个比例重新计算元素宽高
let lineStart = Math.sqrt(
Math.pow(currItem.centerX - this.startX, 2) +
Math.pow(currItem.centerY - this.startY, 2)
);
let lineEnd = Math.sqrt(
Math.pow(currItem.centerX - x, 2) +
Math.pow(currItem.centerY - y, 2)
);
// 计算宽高方法1
let resizeRaito = lineEnd / lineStart;
let newW = oWidth * resizeRaito;
let newH = oHeight * resizeRaito;
// // 计算新的宽高方法2
// let resize = lineEnd - lineStart;
// let newW = oWidth + resize * 2;
// let newH = oHeight * newW / oWidth;
// 以短边为基准来计算最小宽高
if (oWidth <= oHeight && newW < MIN_WIDTH) {
newW = MIN_WIDTH;
newH = MIN_WIDTH * oHeight / oWidth;
} else if (oHeight < oWidth && newH < MIN_WIDTH) {
newH = MIN_WIDTH;
newW = MIN_WIDTH * oWidth / oHeight;
}
// 以长边为基准来计算最大宽高
if (oWidth >= oHeight && newW >= this.width) {
newW = this.width;
newH = this.width * oHeight / oWidth;
} else if (oHeight > oWidth && newH >= this.height) {
newH = this.height;
newW = this.height * oWidth / oHeight;
}
currItem.width = Math.round(newW);
currItem.height = Math.round(newH);
currItem.x = Math.round(oX - (newW - oWidth) / 2);
currItem.y = Math.round(oY - (newH - oHeight) / 2);
// 重新计算元素的中心点坐标
currItem.centerX = currItem.x + currItem.width / 2;
currItem.centerY = currItem.y + currItem.height / 2;
}
接下来就开始抽取区域,让对应的区域做对应的事情,目前已有的功能有旋转和缩放,所以用这两个为基准开始修改代码。对于自定义区域,我们能够确定的是这个区域一定是根据元素的宽高来确定的,所以在定义区域的时候最好和元素宽高挂钩,不能胡乱去定义。根据之前定义的变换区域,我们定义了宽高,所以抽离出来依然要有宽高。
根据上面的分析,可以得到这样一种计算区域的方式,假设我们拥有一个宽高为 60 的元素,想要确定的区域中心点为 (10, 10),那么得到这个中心点的计算方式就是 60 * (1 / 6),元素宽高不变,通过调整宽高比例,我们就可以通过宽高计算出我们想要的任何区域。
// 新增区域列表
this.zoneList = [
// 旋转区域
{
width: ROTATE_W,
height: ROTATE_H,
// xy 坐标比例
// 就是响应区域在元素内部所在中心点
// 例如元素整体宽高为20*20
// 我需要响应的中心点在(5,5)
// 那么比例就是 x: 5/20, y: 5/20
// 基于这个规则,我们就可以得到元素内部区域的任何位置
xRatio: 1,
yRatio: 0
},
// 设置缩放区域
{
width: SCALE_W,
height: SCALE_H,
xRatio: 1,
yRatio: 1
}
];
因为我们通过定义区域是想要执行一些操作,所以我们之前定义过元素的状态,通过状态去确定操作,所以在抽取出来的数据里面还得包含状态字段。然后,目前我们涉及到的操作都是在鼠标按下拖动的时候触发,设想这样一种情况,如果我们想实现元素的删除,那么触发方式必然是点击到删除区域然后删除元素,基于此,我们还得定义这个区域的触发方式,目前我们可以使用的触发方式就是鼠标按下和移动,所以定义两种触发方式 move 和 down。
// 因为事件分为 move 和 down,所以抽离数组
const MOVE_STATUS_ARRAY = [STATUS_MOVE, STATUS_ROTATE, STATUS_SCALE, null];
const DOWN_STATUS_ARRAY = [null];
// 新增区域列表
this.zoneList = [
// 旋转区域
{
status: STATUS_ROTATE,
width: ROTATE_W,
height: ROTATE_H,
xRatio: 1,
yRatio: 0,
trigger: 'move'
},
// 缩放区域
{
status: STATUS_SCALE,
width: SCALE_W,
height: SCALE_H,
xRatio: 1,
yRatio: 1,
trigger: 'move'
}
];
这样数据结构就定义的差不多了吧~,嗯…应该还差点点,在之前我们的区域都是用文本来展示的,这样有一点点的怪异,大多数情况还是使用图片来展示,所以还加上图标路径。
// 新增区域列表
this.zoneList = [
// 旋转区域
{
status: STATUS_ROTATE,
width: ROTATE_W,
height: ROTATE_H,
xRatio: 1,
yRatio: 0,
icon: './images/xz_icon.png',
trigger: 'move'
},
// 缩放区域
{
status: STATUS_SCALE,
width: SCALE_W,
height: SCALE_H,
xRatio: 1,
yRatio: 1,
icon: './images/sf_icon.png',
trigger: 'move'
}
];
接下来,我们就渲染定义的区域,后期使用框架就不需要这样了,目前这样写只是图方便,如果真要考虑全面,那么这里可以每次只更改变化的部分,不用每次都全部渲染。
/**
* 渲染
*/
render() {
// 更改渲染结构
let str = '';
this.elementArray.forEach(item => {
let styleStr = `position:absolute;top:0;left:0;z-index:${item.zIndex};width:${item.width}px;height:${item.height}px;transform:translateX(${item.x}px) translateY(${item.y}px) translateZ(0px) rotate(${item.rotate}deg);`
// 多添加一层容器,包裹内层元素,这样做如果后面涉及到镜像翻转,
// 只需要变换内层元素即可,不会影响到外层的区域
str += `
<div style="${styleStr}">
<div style="width:${item.width}px;height:${item.height}px;background-color:${item.backgroundColor};"></div>
${
this.currItem && this.currItem.id === item.id ?
this.zoneList.map(o => {
// 因为我们定义的是区域的中心点,
// 所以渲染区域的时候,减去自身宽高的一半
// 里面还有一些可以抽离的常量,感兴趣的自行修改~
return `<div style="box-sizing:border-box;position:absolute;top:${item.height * o.yRatio - o.height / 2}px;left:${item.width * o.xRatio - o.width / 2}px;width:${o.width}px;height:${o.height}px;border:1px solid #666;font-size:12px;text-align:center;line-height:${o.height}px;border-radius:50%;">
<img src="${o.icon}" style="width:60%;height:60%;-webkit-user-drag:none;-moz-user-drag:none;-ms-user-drag:none;user-drag:none;" />
</div>
`
}).join('') :
''
}
</div>
`;
});
this.elementContainer.innerHTML = str;
}
接下来实现处理区域事件,之前我们使用的是直接将判断的代码写在一个函数中,其实对于区域的判断逻辑,大致相同,所以得改。
/**
* 处理响应区域
*
* @param {Number} x 点击的 x 坐标
* @param {Number} y 点击的 y 坐标
* @param {*} item 要判断响应区域的元素
* @return 在区域内返回区域定义的状态,不在则返回null
*/
handleZone(x, y, item) {
let tempStatus = null;
for (let i = 0, len = this.zoneList.length; i < len; i++) {
const zone = this.zoneList[i];
let pos = this.rotatePoint(
item.x + item.width * zone.xRatio,
item.y + item.height * zone.yRatio,
item.centerX,
item.centerY,
item.rotate
);
let minX = pos[0] - zone.width / 2;
let minY = pos[1] - zone.height / 2;
let maxX = pos[0] + zone.width / 2;
let maxY = pos[1] + zone.height / 2;
// 按理来说同一时刻只响应一个事件,
// 所以在判断到这个区域的时候就直接跳出整个循环
if (
x >= minX &&
x <= maxX &&
y >= minY &&
y <= maxY
) {
tempStatus = zone.status;
break;
}
}
return tempStatus;
}
/**
* 元素点击区域判断,对于不同的点击区响应不同的操作方法
*
* @param {Number} x 点击的 x 坐标
* @param {Number} y 点击的 y 坐标
* @param {*} item 要判断的元素
* @return 不在元素任何区域内返回 false,在区域内返回相应的操作字符串
*/
isEleClickZone(x, y, item) {
// // 因为有多个区域判断,且后期可以自定义区域,所以我们抽离区域定义
// // 判断是否在旋转区域
// // 默认定义的区域在右下角
// let tranPosition = this.rotatePoint(
// item.x + item.width / 2,
// item.y + item.height / 2,
// item.centerX,
// item.centerY,
// item.rotate
// );
// // let tranX = tranPosition[0] - ROTATE_W / 2;
// // let tranY = tranPosition[1] - ROTATE_H / 2;
// // console.log(tranX, tranY, x, y, item.centerX, item.centerY);
// // if (
// // x - tranX >= 0 &&
// // y - tranY >= 0 &&
// // tranX + ROTATE_W - x >= 0 &&
// // tranY + ROTATE_H - y >= 0
// // ) {
// // return STATUS_ROTATE;
// // } else if (this.insideEle(x, y, item)) {
// // return STATUS_MOVE;
// // }
// let minTranX = tranPosition[0] - ROTATE_W / 2;
// let minTranY = tranPosition[1] - ROTATE_H / 2;
// let maxTranX = tranPosition[0] + ROTATE_W / 2;
// let maxTranY = tranPosition[1] + ROTATE_H / 2;
// if (
// x >= minTranX &&
// x <= maxTranX &&
// y >= minTranY &&
// y <= maxTranY
// ) {
// return STATUS_ROTATE;
// } else if (this.insideEle(x, y, item)) {
// return STATUS_MOVE;
// }
const zoneStatus = this.handleZone(x, y, item);
if (zoneStatus) return zoneStatus;
else if (this.insideEle(x, y, item)) return STATUS_MOVE;
// 不在元素区域里面并且不在元素操作按钮区域内
return false;
}
上面我们实现了抽取区域,对应的区域做相应的事情,基于此,我们可以让外界也传入同样的配置,来实现自己的操作,不过这个操作有部分限制,函数定义在配置对象内,那么 this 指向会变,所以也不能完全方便的使用方法,当然这个也可以更改(等等,这里我有个好sou主意,对于涉及到 this 的地方可以增加可以外界传入,然后可以通过 call apply bind 来更改 this 指向),算了,我反正不想改了!哪有什么绝对的自由,在我代码定义的规则里面,那就按照我的规则来实现功能吧~
/**
* 新增区域
* 可以自行添加响应的区域,然后执行对应的方法
*
* @param {*} zone 新增区域的属性描述
*/
addZone(zone) {
let {
status,
width = 20,
height = 20,
xRatio,
yRatio,
icon,
trigger = 'move',
fn
} = zone;
if (
status === undefined ||
xRatio === undefined ||
yRatio === undefined ||
fn === undefined ||
icon === undefined
) {
throw new Error('status, xRatio, yRatio, icon 和 fn 是必须的, 请检查这些字段是否填写准确!');
}
if (trigger !== 'move' && trigger !== 'down') {
throw new Error('trigger 字段的值只能是 move 和 down!');
}
if (
typeof width !== 'number' ||
typeof height !== 'number' ||
typeof xRatio !== 'number' ||
typeof yRatio !== 'number'
) {
throw new Error('width, height, xRatio 和 yRatio 字段的值类型只能是 number!');
}
if (trigger === 'move') MOVE_STATUS_ARRAY.push(status);
else if (trigger === 'down') DOWN_STATUS_ARRAY.push(status);
this.zoneList.push({
status,
width,
height,
xRatio,
yRatio,
icon,
trigger,
fn
});
}
接下来在鼠标点击和移动的时候新增触发逻辑
/**
* 处理鼠标按下事件
*
* @param {*} e 事件参数
*/
handleDown(e) {
// 之前的逻辑省略......
// 如果当前元素和选中的元素是同一个元素
if (currItem && this.currItem && currItem.id === this.currItem.id) {
let fn = this.handleEleStatus(x, y)[tempStatus];
// 如果状态在状态数组中存在,且为系统自定义的函数
// 就执行系统自定义的函数
if (DOWN_STATUS_ARRAY.findIndex(s => s === tempStatus) > -1 && fn) {
fn();
} else {
// 否则就查找区域列表中为点击且状态和当前状态一样的元素
// 如果存在这样的元素,则执行对应的函数
// 将点击坐标传递过去
for (let i = 0, len = this.zoneList.length; i < len; i++) {
const zone = this.zoneList[i];
if (zone.trigger !== 'down') continue;
if (tempStatus === zone.status && zone.trigger === 'down') {
zone.fn(x, y);
break;
};
}
}
}
if (currItem) {
// 省略之前的逻辑......
} else {
// 如果点击区域没有任何元素,就取消之前元素的选中
this.currItem = null;
this.currItemStartOpt = null;
this.render();
}
}
/**
* 处理鼠标移动事件
*
* @param {*} e 事件参数
*/
handleMove(e) {
requestAnimationFrame(() => {
// 省略之前的逻辑......
let fn = this.handleEleStatus(x, y)[status];
if (MOVE_STATUS_ARRAY.findIndex(s => s === status) > -1 && fn) {
fn();
} else {
for (let i = 0, len = this.zoneList.length; i < len; i++) {
const zone = this.zoneList[i];
if (zone.trigger !== 'move') continue;
if (status === zone.status && zone.trigger === 'move') {
zone.fn(x, y);
break;
};
}
}
});
}
接着从外界新增个来测试一下
const eleDrop = new EleDrop({
id: 'eleBox'
});
eleDrop.addZone({
status: 'center',
xRatio: 1 / 2,
yRatio: 1 / 2,
trigger: 'down',
icon: './images/del_icon.png',
fn(x, y) {
console.log(x, y);
// 例如我想让元素移动到中心区
if (eleDrop.currItem) {
// 需要了解里面封装的东西比较了解~
// emmmmm后面有时间可以优化
eleDrop.currItem.x = eleDrop.width / 2 - eleDrop.currItem.width / 2;
eleDrop.currItem.y = eleDrop.height / 2 - eleDrop.currItem.height / 2;
eleDrop.currItem.centerX = eleDrop.currItem.x + eleDrop.currItem.width / 2;
eleDrop.currItem.centerY = eleDrop.currItem.y + eleDrop.currItem.height / 2;
eleDrop.rotateSquare();
}
}
});
查看效果
然后自定义区域移动触发可以自行实验,上面的效果也演示了之前抽离的旋转和缩放,所以不再单独看演示效果了。
至此,本篇因为篇幅也结束了,后面再实现其他的功能。