低代码平台搭建 - 组件几何变换

image-20220504092632318

在低代码平台,用户可以选中画布中的组件,然后通过鼠标直接改变组件的大小、位置和旋转角度。即用可视化的方式实现组件的几何变换。文章通过手写一个组件几何变换功能来加深理解其背后的原理。

布局

画布中的组件采用绝对定位:即画布是 position: relative,组件是 position: absolute。组件的定位信息left/top就是组件在画布中的坐标(x,y)。

实现原理

把几何变换的过程封装成一个包裹组件geometricTransformer,具体的组件以插槽<solt>形式传递给包裹组件。

  • 点击组件使处于选中状态:出现8个小圆点和旋转图标。

  • 8个小圆点从不同方向改变组件的width/height,当鼠标mousedown小圆点并开始mousemove时,计算鼠标在组件高/宽所在方向的位移,更新组件大小和位置。

  • 当鼠标mousedown旋转图标并开始mousemove时,计算鼠标组件中心点的连线的旋转角度,更新组件旋转角度。

  • 当鼠标mousedown组件并开始mousemove时,计算鼠标的位移,更新组件位置。

代码实现

鼠标事件

下面是geometricTransformer组件的template,并以插槽<slot>接收具体组件。主要通过监听mousedown事件来获得输入信息,分别是:旋转图标的mousedown用来计算旋转角度,圆点的mousedown事件用来计算width/height变化,geometricTransformer组件本身的mousedown用来计算位移。

<template>
    <div
        :class="{ active: isActive }"
        @mousedown="onMouseDownComponent"
    >
        <span
            v-show="isActiveAndUnlock"
            class="iconfont icon-xiangyouxuanzhuan"
            @mousedown="onMouseDownRotate"
        ></span>
        <div
            v-for="point in resizePoint.points"
            v-show="isActiveAndUnlock"
            :key="point.id"
            class="resize-point"
            :style="point.style"
            @mousedown="handleMouseDownOnPoint(point, $event)"
        ></div>
        <slot></slot>
    </div>
</template>

几何变换计算过程

下面主要是三个mousedown事件处理函数:

  • onMouseDownComponent:通过鼠标移动的位移,计算组件的位置变化。
  • onMouseDownRotate:计算鼠标和组件中心点的连线的旋转角度,更新组件旋转角度。
  • handleMouseDownOnPoint:计算鼠标在组件高/宽所在方向的位移,更新组件大小和位置。
<script>
import { mapState } from 'vuex';
import ResizePoint from '@/utils/resizePoint';

export default {
    props: {
        element: {
            require: true,
            type: Object,
            default: () => {},
        },

        index: {
            require: true,
            type: [Number, String],
            default: 0,
        },
    },
    data() {
        return {
            resizePoint: {},
        };
    },
    computed: {
        ...mapState(['curComponent', 'editor']),
        isActive() {
            return this.element.id == this.curComponent?.id;
        },
        isActiveAndUnlock() {
            return this.isActive && !this.element.isLock;
        },
    },
    watch: {
        element: {
            handler(element) {
                // 当组件的大小和旋转角度变化时,更新圆点的位置和光标
                this.resizePoint.computeStyle(element.style);
            },
            deep: true,
        },
    },
    mounted() {
        this.resizePoint = new ResizePoint(this.element.style);
    },
    methods: {
        // 平移:计算鼠标的位移,更新组件位置
        onMouseDownComponent(e) {
            e.stopPropagation();
            this.$store.commit('setCurComponent', {
                component: this.element,
                index: this.index,
            });
            
            const componentStyle = {
                ...this.element.style,
            };
            // 组件起始位置
            const startTop = componentStyle.top;
            const startLeft = componentStyle.left;
            // 鼠标起始位置
            const startY = e.clientY;
            const startX = e.clientX;

            let isMove = false;
            const move = moveEvent => {
                isMove = true;
                // 鼠标当前位置
                const currentX = moveEvent.clientX;
                const currentY = moveEvent.clientY;
                // 移动后的组件位置
                componentStyle.top = startTop + (currentY - startY);
                componentStyle.left = startLeft + (currentX - startX);

                this.$store.commit('setCurrentComponentStyle', componentStyle);
            };

            const up = () => {
                hasMove && this.$store.commit('recordSnapshot');
                // 触发元素停止移动事件,用于隐藏标线
                eventBus.$emit('unmove');
                document.removeEventListener('mousemove', move);
                document.removeEventListener('mouseup', up);
            };

            document.addEventListener('mousemove', move);
            document.addEventListener('mouseup', up);
        },
        // 旋转: 计算鼠标和组件中心点的连线的旋转角度,更新组件旋转角度。
        onMouseDownRotate(e) {
            e.preventDefault();
            e.stopPropagation();
            
            const componentStyle = {
                ...this.element.style,
            };
            // 鼠标起始位置
            const startY = e.clientY;
            const startX = e.clientX;
            // 组件初始角度
            const startRotate = pos.rotate;

            // 获取组件中心点位置
            const rect = this.$el.getBoundingClientRect();
            const centerX = rect.left + rect.width / 2;
            const centerY = rect.top + rect.height / 2;

            // 旋转前的角度
            const rotateDegreeBefore =
                Math.atan2(startY - centerY, startX - centerX) /
                (Math.PI / 180);

            let isMove = false;
            const move = moveEvent => {
                isMove = true;
                // 鼠标当前位置
                const currentX = moveEvent.clientX;
                const currentY = moveEvent.clientY;
                // 旋转后的角度
                const rotateDegreeAfter =
                    Math.atan2(currentY - centerY, currentX - centerX) /
                    (Math.PI / 180);
                // 获取旋转的角度值
                componentStyle.rotate =
                    startRotate +
                    ((rotateDegreeAfter - rotateDegreeBefore + 360) % 360);
                // 修改当前组件样式
                this.$store.commit('setCurrentComponentStyle', componentStyle);
            };

            const up = () => {
                isMove && this.$store.commit('recordSnapshot');
                document.removeEventListener('mousemove', move);
                document.removeEventListener('mouseup', up);
            };

            document.addEventListener('mousemove', move);
            document.addEventListener('mouseup', up);
        },
        // 处理缩放:计算鼠标在组件高/宽所在方向的位移,更新组件大小和位置。
        handleMouseDownOnPoint(point, e) {
            e.stopPropagation();
            e.preventDefault();

            const editorDOMRect = this.editor.getBoundingClientRect();
            const pointDOMRect = e.target.getBoundingClientRect();
            // 当前点击圆点在画布坐标系(左上角为原点)中的坐标
            const pointCoordinate = {
                x: Math.round(
                    pointDOMRect.left -
                        editorDOMRect.left +
                        e.target.offsetWidth / 2
                ),
                y: Math.round(
                    pointDOMRect.top -
                        editorDOMRect.top +
                        e.target.offsetHeight / 2
                ),
            };

            let isSave = false;

            const isKeepProportion = this.isKeepProportion();
            const componentStyle = { ...this.element.style };
            const move = moveEvent => {

                isSave = true;
                const mouseCoordinate = {
                    x: moveEvent.clientX - editorDOMRect.left,
                    y: moveEvent.clientY - editorDOMRect.top,
                };
                // 调用每个point对象的方法,根据组件样式、鼠标当前位置、圆点位置信息计算组件位移信息。 
                let newComponentStyle = point.calculateComponentPositonAndSize(
                    componentStyle,
                    mouseCoordinate,
                    isKeepProportion,
                    pointCoordinate
                );

                this.$store.commit('setCurrentComponentStyle', newComponentStyle);
            };

            const up = () => {
                document.removeEventListener('mousemove', move);
                document.removeEventListener('mouseup', up);
                isSave && this.$store.commit('recordSnapshot');
            };

            document.addEventListener('mousemove', move);
            document.addEventListener('mouseup', up);
        },
    },
};
</script>

每个圆点计算组件大小和位置信息的过程是不同的,这里封装成每个point对象的方法。下面以top圆点和rightTop圆点为例:

  • 鼠标拖动top圆点时,只改变组件的height,鼠标的x坐标可以是任意值,这里为了方便计算就取落在组件中心点和top圆点的延长线上的鼠标坐标。首先,计算top圆点关于组件中心点的对称点坐标;计算鼠标坐标在组件旋转之前的坐标,x坐标取top圆点的x坐标,再将得到的坐标换算成旋转之后的坐标,得到了在组件中心点和top圆点的延长线上的鼠标坐标A。根据鼠标坐标A和对称点坐标计算出新的组件中心点坐标,根据勾股定理,可以计算出两点之间的距离,即新的组件高度。根据新的组件中心点坐标新的组件高度组件宽度就可以获得新的组件大小和位置信息。
  • 鼠标拖动rightTop圆点时,可以改变组件的height和width。首先,计算rightTop圆点关于组件中心点的对称点坐标。然后,根据鼠标坐标对称点坐标计算新的组件中心点坐标。根据鼠标坐标(新的组件右上角坐标)对称点坐标(新的组件左下角坐标)新的组件中心点坐标就可以获得新的组件大小和位置信息。
import {
    calculateCenterPoint
    , calculateRotatedPoint
    , calculateDistance
    , calculateComponentCenter
    , calculateSymmetricPoint
} from '@/utils/translate';

/**
 * 几何变换组件的8个圆点:根据组件样式、鼠标当前位置、圆点位置信息计算组件位移信息。 
 */
export default class ResizePoint {
    /**
     * @param width 8个圆点所在矩形(组件)的宽度
     * @param height 8个圆点所在矩形(组件)的高度
     * @param rotate 8个圆点所在矩形(组件)的旋转角度
    */
    constructor({ width, height, rotate }) {
        this.points = [new TopPoint()
            , new RightTopPoint()
            , new RightPoint()
            , new RightBottomPoint()
            , new BottomPoint()
            , new LeftBottomPoint()
            , new LeftPoint()
            , new IeftTopPoint()
        ]
        this.computeStyle({ width, height, rotate });
    }
    // 根据组件的大小和旋转角度,计算圆点的位置和光标
    computeStyle({ width, height, rotate }) {
        this.points.forEach(point => {
            point.computeStyle(width, height, rotate);
        });
    }
}
// 一共有8个点,分成对角点和每条边的中心点。
// 这里列举top点和rightTop点的计算,其它点也是类似的计算。
class TopPoint {
    constructor() {
        this.id = 't'
        this.style = {
            marginLeft: '-4px',
            marginTop: '-4px',
            left: ``,
            top: ``,
            cursor: '',
        }
    }
    computeStyle(width, height, rotate) {
        this.style.left = width / 2 + 'px';
        this.style.top = 0;
        this.style.cursor = angle2Cursor('n-resize', Number(rotate));
    }
    /**
     * @param {object} componentStyle 组件样式
     * @param {object} mouseCoordinate 鼠标当前坐标(画布坐标系)
     * @param {object} isKeepProportion 是否保持高度/宽度的比例
     * @param {object} point 圆点坐标(画布坐标系)
    */
    calculateComponentPositonAndSize(componentStyle, mouseCoordinate, isKeepProportion, point) {
        const componentCenter = calculateComponentCenter(componentStyle);
        const symmetricPoint = calculateSymmetricPoint(point, componentCenter);

         //鼠标拖动top圆点时,只改变组件的height,鼠标的x坐标可以是任意值,这里为了方便计算就取落在组件中心点和top圆点的延长线上的鼠标坐标。
        // 先计算旋转前的鼠标坐标。旋转中心用 point, componentCenter, symmetricPoint 都可以,只要他们在一条直线上就行
        const beforeRotatedMouseCoordinate = calculateRotatedPoint(mouseCoordinate, point, -componentStyle.rotate)
        // 算出旋转前的鼠标的y坐标,top圆点的x坐标,重新计算它们旋转后对应的坐标
        const rotatedTopPoint = calculateRotatedPoint({
            x: point.x,
            y: beforeRotatedMouseCoordinate.y,
        }, point, componentStyle.rotate)
        const newComponentHeight = calculateDistance(rotatedTopPoint, symmetricPoint);
        const newComponentCenter = calculateCenterPoint(rotatedTopPoint, symmetricPoint);

        let newComponentWidth = componentStyle.width;
        if (isKeepProportion) {
            // 组件宽高比
            const componentProportion = componentStyle.width / componentStyle.height;
            newComponentWidth = newComponentHeight * componentProportion
        }

        let newComponentStyle = { ...componentStyle };
        newComponentStyle.width = Math.round(newComponentWidth);
        newComponentStyle.height = Math.round(newComponentHeight);
        newComponentStyle.top = Math.round(newComponentCenter.y - (newComponentHeight / 2));
        newComponentStyle.left = Math.round(newComponentCenter.x - (newComponentWidth / 2));
        return newComponentStyle;
    }
}

class RightTopPoint {
    constructor() {
        this.id = 'rt'
        this.style = {
            marginLeft: '-4px',
            marginTop: '-4px',
            left: ``,
            top: ``,
            cursor: '',
        }
    }
    computeStyle(width, height, rotate) {
        this.style.left = width + 'px';
        this.style.top = 0;
        this.style.cursor = angle2Cursor('ne-resize', Number(rotate));
    }
    /**
     * @param {object} componentStyle 组件样式
     * @param {object} mouseCoordinate 鼠标当前坐标(画布坐标系)
     * @param {object} isKeepProportion 是否保持高度/宽度的比例
     * @param {object} point 圆点坐标(画布坐标系)
    */
    calculateComponentPositonAndSize(componentStyle, mouseCoordinate, isKeepProportion, point) {
        const componentCenter = calculateComponentCenter(componentStyle);
        const symmetricPoint = calculateSymmetricPoint(point, componentCenter);

        const newComponentCenter = calculateCenterPoint(mouseCoordinate, symmetricPoint)
        const newRightTopPoint = calculateRotatedPoint(mouseCoordinate, newComponentCenter, -componentStyle.rotate)
        const newBottomLeftPoint = calculateRotatedPoint(symmetricPoint, newComponentCenter, -componentStyle.rotate)

        let newComponentWidth = newRightTopPoint.x - newBottomLeftPoint.x
        let newComponentHeight = newBottomLeftPoint.y - newRightTopPoint.y
        if (isKeepProportion) {
            // 组件宽高比
            const componentProportion = componentStyle.width / componentStyle.height;
            if (newComponentWidth / newComponentHeight > componentProportion) {
                newRightTopPoint.x -= Math.abs(newComponentWidth - newComponentHeight * componentProportion)
                newComponentWidth = newComponentHeight * componentProportion
            } else {
                newRightTopPoint.y += Math.abs(newComponentHeight - newComponentWidth / componentProportion)
                newComponentHeight = newComponentWidth / componentProportion
            }

            const rotatedTopRightPoint = calculateRotatedPoint(newRightTopPoint, newComponentCenter, componentStyle.rotate)
            newComponentCenter = calculateCenterPoint(rotatedTopRightPoint, symmetricPoint)
            newRightTopPoint = calculateRotatedPoint(rotatedTopRightPoint, newComponentCenter, -componentStyle.rotate)
            newBottomLeftPoint = calculateRotatedPoint(symmetricPoint, newComponentCenter, -componentStyle.rotate)

            newComponentWidth = newRightTopPoint.x - newBottomLeftPoint.x
            newComponentHeight = newBottomLeftPoint.y - newRightTopPoint.y
        }

        let newComponentStyle = { ...componentStyle };
        if (newComponentWidth > 0 && newComponentHeight > 0) {
            newComponentStyle.width = Math.round(newComponentWidth)
            newComponentStyle.height = Math.round(newComponentHeight)
            newComponentStyle.left = Math.round(newBottomLeftPoint.x)
            newComponentStyle.top = Math.round(newRightTopPoint.y)
        }
        return newComponentStyle;
    }
}

let cursorList = ['n-resize', 'ne-resize', 'e-resize', 'se-resize', 's-resize', 'sw-resize', 'w-resize', 'nw-resize'];
/**
 * 计算每个圆点的光标。
 * 一共有8个方向的光标,相邻两个方向的光标相差45度,将光标按顺时针方向排列(cursorList)。
 * 每个圆点都会设置一个初始光标,然后根据旋转角度计算出漂移量,在cursorList中找到新的光标。
 * 例如:right圆点的初始光标为e-resize,旋转90度后,漂移量为90/45=2,在cursorList中按顺序找到新的光标为s-resize。
*/
function angle2Cursor(defaultCursor, rotateAngle) {
    let defaultIndex = cursorList.indexOf(defaultCursor);
    let offset = Math.trunc(rotateAngle / 45);
    let currentIndex = Math.abs((defaultIndex + offset) % cursorList.length);
    return cursorList[currentIndex];
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值