Open 10081677wc opened 6 years ago
欢迎来到易企秀(www.eqxiu.com)
今天要简单介绍一下在 H5 编辑器中如何实现组件/组合的缩放功能 :)
首先我们要编写一个 compBind$bar 方法,用来在初始化组件时为八点框的点/边绑定拖拽缩放的事件,达到如上图所示的交互功能,显然这和组件/组合的旋转一样,需要基于鼠标的 mousedown/mousemove/mouseup 事件触发来实现:
function compBind$bar() { const comp = item; $bar.on('mousedown.compResize', (e) => { e.stopPropagation(); /* ... */ $document.on('mousemove.compResize', (e) => { /* ... */ }); $document.on('mouseup.compResize', () => { $document.off('.compResize'); /* ... */ }); }); }
我们以组件未旋转时的四个顶点作为基准进行讨论,逆时针分别为西北点/东北点/东南点/西南点。
const vertexArr = ['nw', 'ne', 'se', 'sw'];
在 mousedown 事件触发时,也就是当用户按下鼠标时,我们需要记录组件当前的状态,并且确定不动点和移动点。
$bar.on('mousedown.compResize', (e) => { e.stopPropagation(); const { left, top, width, height, transform } = comp.getAbsolutePos(); const o = { width, height, top, left }; const rotate = transformUtil.parseTransform(transform).rotate; const rect = new Rect(left, top, width, height); const fixedPoints = rect.rotate(rotate); const movePointName = vertexArr[vertexIndex]; const { x, y } = fixedPoints.p1.transform2RotateCoord(rotate); const css = {}; let start = new Point(e.pageX - coord.left, e.pageY - coord.top); $document.on('mousemove.compResize', (e) => { /* ... */ }); $document.on('mouseup.compResize', () => { $document.off('.compResize'); /* ... */ }); });
在获取组件的 rect 对象(即未旋转时组件的状态信息,也就是组件 $item 节点的状态信息)后,通过使用 Rect.prototype.rotate 方法计算得到组件矩形在顺时针旋转当前角度后的新 rect 对象,这时我们就可以知道组件在当前状态下(包括初始状态就发生过一定角度旋转的情况)的各个顶点坐标。
同时为了统一“组件初始时未旋转和发生过旋转两种情况下的”计算过程,我们首先要借助于 transform2RotateCoord 方法把坐标系进行旋转。
rotate(angle = 0) { const p1 = this.p1.pointRotate2Point(this.center(), angle); const p2 = this.p2.pointRotate2Point(this.center(), angle); return new Rect(p1, p2, (angle + (this.angle || 0) + 360) % 360); }
在 mousemove 事件触发时,也就是当用户移动鼠标时,我们可以得到由鼠标起始点和移动点组成的拉伸向量,这样的话拉伸量的计算就变成拉伸向量在当前坐标系内横轴/纵轴上的分解。
let move = new Point(e.pageX - coord.left, e.pageY - coord.top); let vector = new Vector(start, move); let { x: cx, y: cy } = vector.weight(rotate);
因为我们在记录组件的初始状态时,会根据组件当前旋转的角度把坐标系顺时针旋转,使得在当前坐标系(旋转后的坐标系)下组件是未旋转的,便于计算,只需要进行正常的 {top, left, width, height} 值修改,再把坐标系旋转回去即可。
switch (movePointName) { case 'ne': css.width = width + cx; css.top = y + cy; css.height = height - cy; css.left = x; break; case 'sw': css.height = height + cy; css.left = x + cx; css.width = width - cx; css.top = y; break; case 'se': css.width = width + cx; css.height = height + cy; css.left = x; css.top = y; break; case 'nw': css.left = x + cx; css.width = width - cx; css.top = y + cy; css.height = height - cy; break; } // 旋转坐标系下组件的矩形对象 const rr = new Rect(css); // 转换 rr 的西北点至在原始坐标系中的坐标 const p1 = rr.p1.transform2RotateCoord(-rotate); // 转换 rr 的东南点至在原始坐标系中的坐标 const p2 = rr.p2.transform2RotateCoord(-rotate); // 画出原始坐标系中的组件矩形 const r = (new Rect(p1, p2, rotate)).rotate(-rotate); css.top = r.p1.y; css.left = r.p1.x; css.height = r.height; css.width = r.width;
这里可以看到,我们在计算组件的位置/宽高状态后,还需要把坐标系逆时针旋转回应有的状态,在摆正的坐标系下的坐标值才是我们想要的结果。
需要注意的是,当我们得到 p1 和 p2 两个顶点后,是可以画出无数个矩形的,所以需要在构造函数中传入旋转的角度来获得唯一的结果(即以 p1 和 p2 为对角顶点的,旋转 rotate 角度的矩形),并且还要把该矩形逆时针旋转 rotate 角度,来得到组件 $item 应有的状态信息。
刚才我们所说的是在拖拽移动点时组件状态更新的过程,那么当用户拖拽边来进行缩放时的行为就很好处理,本质上就是把相应方向上的拉伸向量分量置为零即可,代码如下:
if (isLine) { switch (movePointName) { case 'ne': cy = 0; break; case 'sw': cy = 0; break; case 'se': cx = 0; break; case 'nw': cx = 0; break; } }
某些组件定义为只能成比例缩放(比如图片组件 etc.),实现起来十分简单,只需要将拉伸向量的分量以一定的规则限制为组件的宽高比例即可,代码如下:
if (!isLine) { const { cx: tx, cy: ty } = _resizeRadioHandler( cx, cy, width, height, movePointName ); cx = tx; cy = ty; } _resizeRadioHandler(cx, cy, width, height, movePointName) { const ax = Math.abs(cx); const ay = Math.abs(cy); const m = Math.min(ax, ay); if (movePointName === 'sw' || movePointName === 'ne') { if (m === ax) { const radio = height / width; cx = (cx / ax * m) || 0; cy = (-cx * radio) || 0; } else { const radio = width / height; cy = (cy / ay * m) || 0; cx = (-cy * radio) || 0; } } else { if (m === ax) { const radio = height / width; cx = (cx / ax * m) || 0; cy = (cx * radio) || 0; } else { const radio = width / height; cy = (cy / ay * m) || 0; cx = (cy * radio) || 0; } } return { cx, cy }; }
组合的缩放同样基于 mousedown/mousemove/mouseup 事件的触发,不过实现过程比组件缩放更为复杂,本质上为让组合的缩放看起来是整体行为,需要不断的更新其子孙组件的位置/宽高状态信息,下面我们分四种情况进行讨论。
function groupBind$bar() { const group = item; $bar.on('mousedown.groupResize', (e) => { e.stopPropagation(); /* ... */ $document.on('mousemove.groupResize', (e) => { /* ... */ }); $document.on('mouseup.groupResize', () => { $document.off('.groupResize'); /* ... */ }); }); }
组件组合均未旋转是最简单的情况,也是其他缩放场景的基础。
想象我们有三个正方形组件合并成的正方形组合,如下图所示,假设拉伸前每个组件的宽度为1,则组合的宽度就是3,然后拉伸组合至宽度为6,那么每个组件的宽度被拉伸为2,并且处于中间位置的组件距离组合的左/右边距也拉伸为2。
不难发现,组合的拉伸量(3)会按比例分配到组件的拉伸(1)以及组件距离组合的左/右边距(2)上!那么,这个比例应该如何计算呢?
在这里以中间位置的组件为例,我们把组件左边至组合不动边(左边)的距离设为 x1,把组件本身的宽度设为 x2,把组件右边至组合移动边(右边)的距离设为 x3,可以发现组合整体的拉伸量是按照 x1/(x1+x2+x3) : x2/(x1+x2+x3) : x3/(x1+x2+x3) 的比例来分配。
function groupBind$bar() { const group = item; $bar.on('mousedown.groupResize', (e) => { e.stopPropagation(); const { left, top, width, height, transform } = group.getAbsolutePos(); const rotate = transformUtil.parseTransform(transform).rotate; const rect = new Rect(left, top, width, height); const fixedPoints = rect.rotate(rotate); const movePointName = vertexArr[vertexIndex]; const movePoint = fixedPoints[movePointName]; const fixedPointName = vertexArr[(vertexIndex + 2) % 4]; const fixedPoint = fixedPoints[fixedPointName]; if (movePointName === 'nw') { var x1Line = new Line(fixedPoint, fixedPoints[vertexArr[(vertexIndex + 1) % 4]]); var x3Line = new Line(movePoint, fixedPoints[vertexArr[(vertexIndex + 3) % 4]]); var y1Line = new Line(fixedPoint, fixedPoints[vertexArr[(vertexIndex + 3) % 4]]); var y3Line = new Line(movePoint, fixedPoints[vertexArr[(vertexIndex + 1) % 4]]); } else if (movePointName === 'ne') { x1Line = new Line(fixedPoint, fixedPoints[vertexArr[(vertexIndex + 3) % 4]]); x3Line = new Line(movePoint, fixedPoints[vertexArr[(vertexIndex + 1) % 4]]); y1Line = new Line(fixedPoint, fixedPoints[vertexArr[(vertexIndex + 1) % 4]]); y3Line = new Line(movePoint, fixedPoints[vertexArr[(vertexIndex + 3) % 4]]); } else if (movePointName === 'se') { x1Line = new Line(fixedPoint, fixedPoints[vertexArr[(vertexIndex + 1) % 4]]); x3Line = new Line(movePoint, fixedPoints[vertexArr[(vertexIndex + 3) % 4]]); y1Line = new Line(fixedPoint, fixedPoints[vertexArr[(vertexIndex + 3) % 4]]); y3Line = new Line(movePoint, fixedPoints[vertexArr[(vertexIndex + 1) % 4]]); } else if (movePointName === 'sw') { x1Line = new Line(fixedPoint, fixedPoints[vertexArr[(vertexIndex + 3) % 4]]); x3Line = new Line(movePoint, fixedPoints[vertexArr[(vertexIndex + 1) % 4]]); y1Line = new Line(fixedPoint, fixedPoints[vertexArr[(vertexIndex + 1) % 4]]); y3Line = new Line(movePoint, fixedPoints[vertexArr[(vertexIndex + 3) % 4]]); } let p1 = new Point(e.pageX - coord.left, e.pageY - coord.top); group.onResizeStart(); $document.on('mousemove.groupResize', (e) => { let p2 = new Point(e.pageX - coord.left, e.pageY - coord.top); let vector = new Vector(p1, p2); let { x: cx, y: cy } = vector.weight(rotate); group.onResize( vector, // 正交坐标系中的拉伸向量 cx, // 拉伸向量在旋转坐标系中的 x 轴分量 cy, // 拉伸向量在旋转坐标系中的 y 轴分量 movePointName, // 移动点名称 x1Line, // 在旋转坐标系中的不动边 x3Line, // 在旋转坐标系中的移动边 y1Line, // 在旋转坐标系中的不动边 y3Line, // 在旋转坐标系中的移动边 rotate // 组合旋转的角度 ); }); $document.on('mouseup.groupResize', () => { $document.off('.groupResize'); group.onResizeEnd(); $rootScope.$broadcast('group.resize'); }); }); }
与之前的处理逻辑不同的是,在触发 mousedown 事件时,除了要记录组合及其子孙组件的初始状态外,还需要获取不动边和移动边,这里涉及到 Line 几何类,以两个点为参数实例化出一条直线。
下面讲一下组合缩放的核心方法 onResize:
onResize(vector, cx, cy, movePointName, x1Line, x3Line, y1Line, y3Line, rotate) { const group = this; const comps = group.getAllSubEqxComps(); compRotate( comps, // 组合中所有子孙组件 vector, // 正交坐标系中的拉伸向量 cx, // 拉伸向量在旋转坐标系中的 x 轴分量 cy, // 拉伸向量在旋转坐标系中的 y 轴分量 movePointName, // 移动点名称 x1Line, // 在旋转坐标系中的不动边 x3Line, // 在旋转坐标系中的移动边 y1Line, // 在旋转坐标系中的不动边 y3Line, // 在旋转坐标系中的移动边 rotate // 组合旋转的角度 ); group.autoSize(); } function compRotate(comps, vector, cx, cy, move, x1Line, x3Line, y1Line, y3Line, rotate) { for (let i = 0; i < comps.length; i++) { const comp = comps[i]; if (comp.isEditLocked) continue; const { left, top, width, height, transform } = comp.oldAbsolutePosition; const cr = transformUtil.parseTransform(transform).rotate; const rect = new Rect(left, top, width, height); // 在一次旋转的坐标系中拉伸组件 const position = _compRotate( vector, cx, cy, clockwise, move, (cr - rotate + 360) % 360, x1Line, x3Line, y1Line, y3Line ); // 更新组件样式数据 comp.updateCss({ top: anti.top, left: anti.left, width: anti.width, height: anti.height }); } return comps; }
可以看到 onResize 方法逻辑上比较简单,就是把每个组件按照既定算法进行处理后(位移和宽高的拉伸),再进行一次自顶向下的状态同步即可,实际上在确认拉伸组合时其子孙组件应该按照什么规则去更新状态后,实现起来并没什么难度。
function _compRotate(vector, cx, cy, rect, start, offset, x1Line, x3Line, y1Line, y3Line) { let cx1 = 0; let cx2 = 0; let cy1 = 0; let cy2 = 0; const left = rect.p1.x; const top = rect.p1.y; const width = rect.width; const height = rect.height; const x2 = width; const y2 = height; if (start === 'nw') { const x1 = x1Line.distance2Point(rect.p2); const x3 = x3Line.distance2Point(rect.p1); cx1 = -x1 * cx / (x1 + x2 + x3); cx2 = -x2 * cx / (x1 + x2 + x3); const y1 = y1Line.distance2Point(rect.p2); const y3 = y3Line.distance2Point(rect.p1); cy1 = -y1 * cy / (y1 + y2 + y3); cy2 = -y2 * cy / (y1 + y2 + y3); return { left: left - cx1 - cx2, width: width + cx2, top: top - cy1 - cy2, height: height + cy2 }; } else if (start === 'ne') { const x1 = x1Line.distance2Point(rect.p1); const x3 = x3Line.distance2Point(rect.p2); cx1 = x1 * cx / (x1 + x2 + x3); cx2 = x2 * cx / (x1 + x2 + x3); const y1 = y1Line.distance2Point(rect.p2); const y3 = y3Line.distance2Point(rect.p1); cy1 = -y1 * cy / (y1 + y2 + y3); cy2 = -y2 * cy / (y1 + y2 + y3); return { left: left + cx1, width: width + cx2, top: top - cy1 - cy2, height: height + cy2 }; } else if (start === 'se') { const x1 = x1Line.distance2Point(rect.p1); const x3 = x3Line.distance2Point(rect.p2); cx1 = x1 * cx / (x1 + x2 + x3); cx2 = x2 * cx / (x1 + x2 + x3); const y1 = y1Line.distance2Point(rect.p1); const y3 = y3Line.distance2Point(rect.p2); cy1 = y1 * cy / (y1 + y2 + y3); cy2 = y2 * cy / (y1 + y2 + y3); return { left: left + cx1, width: width + cx2, top: top + cy1, height: height + cy2 }; } else if (start === 'sw') { const x1 = x1Line.distance2Point(rect.p2); const x3 = x3Line.distance2Point(rect.p1); cx1 = -x1 * cx / (x1 + x2 + x3); cx2 = -x2 * cx / (x1 + x2 + x3); const y1 = y1Line.distance2Point(rect.p1); const y3 = y3Line.distance2Point(rect.p2); cy1 = y1 * cy / (y1 + y2 + y3); cy2 = y2 * cy / (y1 + y2 + y3); return { left: left - cx1 - cx2, width: width + cx2, top: top + cy1, height: height + cy2 }; } }
其中 Line.prototype.distance2Point 方法用于计算点到直线的距离。
当组件未旋转而组合发生过旋转时进行组合缩放,只比第一种情况多两个步骤,首先需要先顺时针旋转坐标系,再在旋转过一次的坐标系中进行拉伸,最后逆时针把坐标系旋转回来即可。
function compRotate(comps, vector, cx, cy, move, x1Line, x3Line, y1Line, y3Line, rotate) { for (let i = 0; i < comps.length; i++) { const comp = comps[i]; if (comp.isEditLocked) continue; const { left, top, width, height, transform } = comp.oldAbsolutePosition; const cr = transformUtil.parseTransform(transform).rotate; const rect = new Rect(left, top, width, height); // 顺时针旋转坐标系后组件的矩形 const clockwise = _clockwise(rect, rotate); // 在一次旋转的坐标系中拉伸组件 const position = _compRotate( vector, cx, cy, clockwise, move, (cr - rotate + 360) % 360, x1Line, x3Line, y1Line, y3Line ); // 逆时针旋转坐标系后组件的矩形 const anti = _anti(position, rotate); // 更新组件样式数据 comp.updateCss({ top: anti.top, left: anti.left, width: anti.width, height: anti.height }); } return comps; } function _clockwise(rect, rotate) { if (!rotate) return rect; const p1 = rect.rotate(rotate).p1.transform2RotateCoord(rotate); return new Rect(p1, rect.width, rect.height); } function _anti(rect, rotate) { if (!rotate) return rect; if (!(rect instanceof Rect)) rect = new Rect(rect); const p1 = rect.p1.transform2RotateCoord(-rotate); const p2 = rect.p2.transform2RotateCoord(-rotate); const { p1: { x: left, y: top }, width, height } = (new Rect(p1, p2, rotate)).rotate(-rotate); return { width, height, left, top }; }
需要在 compRotate 方法中增加 _clockwise 和 _anti 两步:在 _clockwise 方法中,计算组件 rect 顺时针旋转 rotate 角度后的西北点坐标,并转换至一次旋转的坐标系中,然后使用当前坐标系内的组件 rect 进行拉伸计算;在 _anti 方法中,执行的是 _clockwise 的逆,首先把一次旋转坐标系中的组件 rect 的两个对角点转换至原始坐标系的坐标,然后在原始坐标系内实例化一个具有 rotate 角度的矩形,这就是组件的当前状态,然而为回归至 dom 的状态信息还需要再将该矩形逆时针旋转 rotate 角度。
在这里我们把“组件旋转组合未旋转”和“组件组合均旋转”两种情况放在一起讨论,因为后者只需要在前者的基础上增加旋转坐标系的步骤,和上面的处理过程一样,在此不再赘述。
function _compRotate(vector, cx, cy, rect, start, offset, x1Line, x3Line, y1Line, y3Line) { // 如果组件自身存在旋转则进入二次旋转坐标系 if (offset) { return _compRotateOffset(vector, rect, offset, start); } /* ... */ } function _compRotateOffset(vector, comp, offset, start) { const vertexArr = ['nw', 'ne', 'se', 'sw']; const { left, top, width, height, transform } = group.oldAbsolutePosition; const rotate = transformUtil.parseTransform(transform).rotate; const orth = (new Rect(left, top, width, height)).rotate(rotate); const fr = new Rect(orth.p1.transform2RotateCoord(rotate), width, height); const bounding = Rect.getBoundingRect(fr, offset); const sr = new Rect(bounding.p1.transform2RotateCoord(offset), bounding.width, bounding.height); const src = new Rect(comp.rotate(offset).p1.transform2RotateCoord(offset), comp.width, comp.height); const vi = _findVertexIndex(sr, orth[start].transform2RotateCoord(rotate + offset)); const movePointName = vertexArr[vi]; const movePoint = sr[movePointName]; const fixedPointName = vertexArr[(vi + 2) % 4]; const fixedPoint = sr[fixedPointName]; const { x: cx, y: cy } = vector.weight(rotate + offset); if (movePointName === 'nw') { var x1Line = new Line(fixedPoint, sr[vertexArr[(vi + 1) % 4]]); var x3Line = new Line(movePoint, sr[vertexArr[(vi + 3) % 4]]); var y1Line = new Line(fixedPoint, sr[vertexArr[(vi + 3) % 4]]); var y3Line = new Line(movePoint, sr[vertexArr[(vi + 1) % 4]]); } else if (movePointName === 'ne') { x1Line = new Line(fixedPoint, sr[vertexArr[(vi + 3) % 4]]); x3Line = new Line(movePoint, sr[vertexArr[(vi + 1) % 4]]); y1Line = new Line(fixedPoint, sr[vertexArr[(vi + 1) % 4]]); y3Line = new Line(movePoint, sr[vertexArr[(vi + 3) % 4]]); } else if (movePointName === 'se') { x1Line = new Line(fixedPoint, sr[vertexArr[(vi + 1) % 4]]); x3Line = new Line(movePoint, sr[vertexArr[(vi + 3) % 4]]); y1Line = new Line(fixedPoint, sr[vertexArr[(vi + 3) % 4]]); y3Line = new Line(movePoint, sr[vertexArr[(vi + 1) % 4]]); } else if (movePointName === 'sw') { x1Line = new Line(fixedPoint, sr[vertexArr[(vi + 3) % 4]]); x3Line = new Line(movePoint, sr[vertexArr[(vi + 1) % 4]]); y1Line = new Line(fixedPoint, sr[vertexArr[(vi + 1) % 4]]); y3Line = new Line(movePoint, sr[vertexArr[(vi + 3) % 4]]); } let cx1 = 0; let cx2 = 0; let cy1 = 0; let cy2 = 0; let trc = null; const x2 = src.width; const y2 = src.height; if (movePointName === 'nw') { const x1 = x1Line.distance2Point(src.p2); const x3 = x3Line.distance2Point(src.p1); cx1 = -x1 * cx / (x1 + x2 + x3); cx2 = -x2 * cx / (x1 + x2 + x3); const y1 = y1Line.distance2Point(src.p2); const y3 = y3Line.distance2Point(src.p1); cy1 = -y1 * cy / (y1 + y2 + y3); cy2 = -y2 * cy / (y1 + y2 + y3); trc = { left: src.p1.x - cx1 - cx2, width: src.width + cx2, top: src.p1.y - cy1 - cy2, height: src.height + cy2 }; } else if (movePointName === 'ne') { const x1 = x1Line.distance2Point(src.p1); const x3 = x3Line.distance2Point(src.p2); cx1 = x1 * cx / (x1 + x2 + x3); cx2 = x2 * cx / (x1 + x2 + x3); const y1 = y1Line.distance2Point(src.p2); const y3 = y3Line.distance2Point(src.p1); cy1 = -y1 * cy / (y1 + y2 + y3); cy2 = -y2 * cy / (y1 + y2 + y3); trc = { left: src.p1.x + cx1, width: src.width + cx2, top: src.p1.y - cy1 - cy2, height: src.height + cy2 }; } else if (movePointName === 'se') { const x1 = x1Line.distance2Point(src.p1); const x3 = x3Line.distance2Point(src.p2); cx1 = x1 * cx / (x1 + x2 + x3); cx2 = x2 * cx / (x1 + x2 + x3); const y1 = y1Line.distance2Point(src.p1); const y3 = y3Line.distance2Point(src.p2); cy1 = y1 * cy / (y1 + y2 + y3); cy2 = y2 * cy / (y1 + y2 + y3); trc = { left: src.p1.x + cx1, width: src.width + cx2, top: src.p1.y + cy1, height: src.height + cy2 }; } else if (movePointName === 'sw') { const x1 = x1Line.distance2Point(src.p2); const x3 = x3Line.distance2Point(src.p1); cx1 = -x1 * cx / (x1 + x2 + x3); cx2 = -x2 * cx / (x1 + x2 + x3); const y1 = y1Line.distance2Point(src.p1); const y3 = y3Line.distance2Point(src.p2); cy1 = y1 * cy / (y1 + y2 + y3); cy2 = y2 * cy / (y1 + y2 + y3); trc = { left: src.p1.x - cx1 - cx2, width: src.width + cx2, top: src.p1.y + cy1, height: src.height + cy2 }; } return new Rect( ((new Rect(trc)).rotate(-offset)).p1.transform2RotateCoord(-offset), trc.width, trc.height ); }
我们在执行 _compRotate 方法时如果遇到组件本身发生过旋转(即相对于组合的旋转角度不为零),则需要进入 _compRotateOffset 分支单独进行处理,其实 _compRotateOffset 和 _compRotate 方法处理的流程基本一致,区别在于我们在计算拉伸量比例的时候不动边和移动边的选取发生变化。
在这里我们的做法是在组件二次旋转的坐标系中(如果组合存在旋转则成为一次旋转坐标系),使用组合的外接矩形来作为不动边和移动边的载体,其中 Rect.getBoundingRect 方法用于获取矩形实例的具有角度的外接矩形。
其中 _findVertexIndex 方法用于获取外接矩形的移动边,即选择离真实移动点最近的边作为移动边。
function _findVertexIndex(rect, move) { const points = [rect.nw, rect.ne, rect.se, rect.sw]; let min = Number.MAX_SAFE_INTEGER; let index = 0; points.forEach((point, item) => { const distance = point.distance2Point(move); if (min > distance) { min = distance; index = item; } }); return index; }
目前“组件旋转组合未旋转”的缩放场景用当前的解决方案仍然存在一定的问题,以后有机会再做讨论,至此组件/组合的缩放解决方案阐述结束。
Eqx 组件/组合的缩放
欢迎来到易企秀(www.eqxiu.com)
今天要简单介绍一下在 H5 编辑器中如何实现组件/组合的缩放功能 :)
组件缩放
首先我们要编写一个 compBind$bar 方法,用来在初始化组件时为八点框的点/边绑定拖拽缩放的事件,达到如上图所示的交互功能,显然这和组件/组合的旋转一样,需要基于鼠标的 mousedown/mousemove/mouseup 事件触发来实现:
定义顶点
我们以组件未旋转时的四个顶点作为基准进行讨论,逆时针分别为西北点/东北点/东南点/西南点。
记录组件的初始状态
在 mousedown 事件触发时,也就是当用户按下鼠标时,我们需要记录组件当前的状态,并且确定不动点和移动点。
在获取组件的 rect 对象(即未旋转时组件的状态信息,也就是组件 $item 节点的状态信息)后,通过使用 Rect.prototype.rotate 方法计算得到组件矩形在顺时针旋转当前角度后的新 rect 对象,这时我们就可以知道组件在当前状态下(包括初始状态就发生过一定角度旋转的情况)的各个顶点坐标。
同时为了统一“组件初始时未旋转和发生过旋转两种情况下的”计算过程,我们首先要借助于 transform2RotateCoord 方法把坐标系进行旋转。
更新组件状态信息
在 mousemove 事件触发时,也就是当用户移动鼠标时,我们可以得到由鼠标起始点和移动点组成的拉伸向量,这样的话拉伸量的计算就变成拉伸向量在当前坐标系内横轴/纵轴上的分解。
因为我们在记录组件的初始状态时,会根据组件当前旋转的角度把坐标系顺时针旋转,使得在当前坐标系(旋转后的坐标系)下组件是未旋转的,便于计算,只需要进行正常的 {top, left, width, height} 值修改,再把坐标系旋转回去即可。
这里可以看到,我们在计算组件的位置/宽高状态后,还需要把坐标系逆时针旋转回应有的状态,在摆正的坐标系下的坐标值才是我们想要的结果。
需要注意的是,当我们得到 p1 和 p2 两个顶点后,是可以画出无数个矩形的,所以需要在构造函数中传入旋转的角度来获得唯一的结果(即以 p1 和 p2 为对角顶点的,旋转 rotate 角度的矩形),并且还要把该矩形逆时针旋转 rotate 角度,来得到组件 $item 应有的状态信息。
绑定边拖拽事件
刚才我们所说的是在拖拽移动点时组件状态更新的过程,那么当用户拖拽边来进行缩放时的行为就很好处理,本质上就是把相应方向上的拉伸向量分量置为零即可,代码如下:
组件成比例缩放
某些组件定义为只能成比例缩放(比如图片组件 etc.),实现起来十分简单,只需要将拉伸向量的分量以一定的规则限制为组件的宽高比例即可,代码如下:
组合缩放
组合的缩放同样基于 mousedown/mousemove/mouseup 事件的触发,不过实现过程比组件缩放更为复杂,本质上为让组合的缩放看起来是整体行为,需要不断的更新其子孙组件的位置/宽高状态信息,下面我们分四种情况进行讨论。
组件组合均未旋转
组件组合均未旋转是最简单的情况,也是其他缩放场景的基础。
想象我们有三个正方形组件合并成的正方形组合,如下图所示,假设拉伸前每个组件的宽度为1,则组合的宽度就是3,然后拉伸组合至宽度为6,那么每个组件的宽度被拉伸为2,并且处于中间位置的组件距离组合的左/右边距也拉伸为2。
不难发现,组合的拉伸量(3)会按比例分配到组件的拉伸(1)以及组件距离组合的左/右边距(2)上!那么,这个比例应该如何计算呢?
在这里以中间位置的组件为例,我们把组件左边至组合不动边(左边)的距离设为 x1,把组件本身的宽度设为 x2,把组件右边至组合移动边(右边)的距离设为 x3,可以发现组合整体的拉伸量是按照 x1/(x1+x2+x3) : x2/(x1+x2+x3) : x3/(x1+x2+x3) 的比例来分配。
与之前的处理逻辑不同的是,在触发 mousedown 事件时,除了要记录组合及其子孙组件的初始状态外,还需要获取不动边和移动边,这里涉及到 Line 几何类,以两个点为参数实例化出一条直线。
下面讲一下组合缩放的核心方法 onResize:
可以看到 onResize 方法逻辑上比较简单,就是把每个组件按照既定算法进行处理后(位移和宽高的拉伸),再进行一次自顶向下的状态同步即可,实际上在确认拉伸组合时其子孙组件应该按照什么规则去更新状态后,实现起来并没什么难度。
其中 Line.prototype.distance2Point 方法用于计算点到直线的距离。
组件未旋转组合旋转
当组件未旋转而组合发生过旋转时进行组合缩放,只比第一种情况多两个步骤,首先需要先顺时针旋转坐标系,再在旋转过一次的坐标系中进行拉伸,最后逆时针把坐标系旋转回来即可。
需要在 compRotate 方法中增加 _clockwise 和 _anti 两步:在 _clockwise 方法中,计算组件 rect 顺时针旋转 rotate 角度后的西北点坐标,并转换至一次旋转的坐标系中,然后使用当前坐标系内的组件 rect 进行拉伸计算;在 _anti 方法中,执行的是 _clockwise 的逆,首先把一次旋转坐标系中的组件 rect 的两个对角点转换至原始坐标系的坐标,然后在原始坐标系内实例化一个具有 rotate 角度的矩形,这就是组件的当前状态,然而为回归至 dom 的状态信息还需要再将该矩形逆时针旋转 rotate 角度。
组件旋转组合未旋转&组件组合均旋转
在这里我们把“组件旋转组合未旋转”和“组件组合均旋转”两种情况放在一起讨论,因为后者只需要在前者的基础上增加旋转坐标系的步骤,和上面的处理过程一样,在此不再赘述。
我们在执行 _compRotate 方法时如果遇到组件本身发生过旋转(即相对于组合的旋转角度不为零),则需要进入 _compRotateOffset 分支单独进行处理,其实 _compRotateOffset 和 _compRotate 方法处理的流程基本一致,区别在于我们在计算拉伸量比例的时候不动边和移动边的选取发生变化。
在这里我们的做法是在组件二次旋转的坐标系中(如果组合存在旋转则成为一次旋转坐标系),使用组合的外接矩形来作为不动边和移动边的载体,其中 Rect.getBoundingRect 方法用于获取矩形实例的具有角度的外接矩形。
其中 _findVertexIndex 方法用于获取外接矩形的移动边,即选择离真实移动点最近的边作为移动边。
目前“组件旋转组合未旋转”的缩放场景用当前的解决方案仍然存在一定的问题,以后有机会再做讨论,至此组件/组合的缩放解决方案阐述结束。