没有轮子就自己造一个,用 fabric 写一个标尺

3,819 阅读9分钟

今天领导给我一个任务,就是要求画布上面需要增加一个标尺,用来识别画布内元素的坐标,于是就开始我的搬砖之旅了。

开始想着挺简单的,不就是对画布进行刻度标记吗,写着写着发现有点不连贯了,于是就开始在网上找轮子,找了一圈发现没有合适的,那就只能靠自己了。

先来看效果(按住 alt + 鼠标左键 拖动画布):

1. 网上的资料

任何需求肯定是先要在网上找一圈的,发现很多设计的网站都有实现了这样的功能,例如墨刀的原型设计就有这样的一个功能:

image.png

用起来还是挺丝滑的,那网上会不会有已经实现的轮子呢,于是就开始找了,首先网上所有小demo 都是画了标线不能移动和缩放的,这不能满足我的需求,于是上Github上去找是否有人已经实现了这样的功能;

Github上找到了一个库确实实现了我想要的功能:github.com/MrFrankel/r…

但是我给代码弄下来了之后发现展现的效果和demo的效果不一样,我又懒得去看源码了,于是就放弃了这个库,还是决定自己写吧。

实现下来确实遇到了很多问题,也都解决了,也可能是规避了,开发时间很短,所以可能会有很多问题,但是现在使用起来还是OK的,下面就记录我实现的过程。

2. fabric.js 来制作标尺

fabric.js我这里就不多介绍了,不熟悉的可以自行去了解,这里就直接开始了。

我的思路是使用两层canvas,一层是用来画标尺的,一层是用来画画布的,这样就可以实现标尺和画布的分离。

我将标尺的canvas放在处理图像的canvas的下面,但是会比处理图像的canvas要大一圈,用来显示标尺的刻度,这样就可以实现标尺的刻度和画布的刻度对齐。

布局如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<style>
    html, body {
        margin: 0;
        padding: 0;
        background: #ddd;
    }

    #canvasWrap {
        position: relative;
        width: 800px;
        height: 500px;
        padding: 20px;
    }

    #staticCanvas {
        position: absolute;
        left: 0;
        top: 0;
    }
</style>
<body>
<div id="canvasWrap">
    <canvas id="staticCanvas"></canvas>
    <canvas id="canvas"></canvas>
</div>
</body>
<script>
    // 这里是画布的宽高,下面要 +40 是因为要给标尺留出空间
    let width = 800;
    let height = 500;


    const canvas = new fabric.Canvas("canvas", {
        width: width,
        height: height,
        backgroundColor: "#fff",
    });

    // 创建静态画布
    const staticCanvas = new fabric.StaticCanvas("staticCanvas", {
        width: width + 40,
        height: height + 40,
        backgroundColor: "#ddd"
    });

</script>
</html>

image.png

2.1 制作静态标尺

搞定布局之后,我们就可以开始写内容了,首先我们需要一个静态的标尺,这个标尺是不会动的,我们需要在画布上面画出来,因为标尺是没有交互的,我们就可以直接使用StaticCanvas 来画,这样就不会有交互的事件了。

上面已经写了初始化的代码了,后面的代码都是在这个基础上进行的,首先创建水平标尺,代码如下:

    // length 就是标尺的长度,也是画布的宽度
const length = 800;

// 创建水平标尺线,top: 19 是因为要给标尺下面的线会有 1px 的偏差
const rulerLine = new fabric.Line([0, 19, length, 19], {
    top: 19,
    left: 20,
    stroke: "#000",
    selectable: false,
    strokeWidth: 1,
});
staticCanvas.add(rulerLine);

// 创建水平标尺线上的刻度
for (let i = 0; i < length; i += 10) {
    // p 是刻度的位置,+20 是因为标尺对于画布的偏移
    const p = i + 20;
    const linePoints = [p, 20, p, 15]

    // 判断是否是 100 的倍数,如果是就需要加长刻度,并且显示数字
    const isTen = i % 100 === 0;
    if (isTen) {
        linePoints[3] = 10;
    }

    // 刻度线
    const line = new fabric.Line(linePoints, {
        stroke: '#000',
        selectable: false,
        strokeWidth: 1,
    });

    // 刻度数字
    const text = new fabric.Text(isTen ? (i).toString() : "", {
        left: linePoints[0],
        top: 0,
        fontSize: 10,
        selectable: false,
    });
    staticCanvas.add(line, text);
}

staticCanvas.renderAll();

最后的效果如下:

image.png

这里其实并没有太多的难点,就是很多的线条然后组合到一起;

Line的第一个参数是一个数组,代表的是[x1, y1, x2, y2],就是线条的两个点的坐标,然后就是刻度的数字,这里我是用的Text 来实现的,Textleft属性和Linex1属性是一样的,这样就可以实现数字和刻度的对齐了。

这里可以看到的是Text会创建很多的空文本,这个会用于后续的操作;

对于垂直的标尺,其实和水平的标尺是一样的,只是方向不一样而已,这里就可以对代码做一下优化,将这个功能封装成一个函数,代码如下:

    // 定义两个常量,用于标识标尺的方向
const HORIZONTAL = "horizontal";
const VERTICAL = "vertical";

/**
 * 创建标尺
 * @param length 标尺长度
 * @param direction 标尺方向, 默认水平
 */
function createRuler(length, direction = HORIZONTAL) {
    // 定义两个方向的标尺线的初始化坐标
    const rulerLinePoints = {
        horizontal: [0, 19, length, 19],
        vertical: [19, 0, 19, length],
    }

    // 创建标尺线
    const rulerLine = new fabric.Line(rulerLinePoints[direction], {
        top: 19,
        left: 19,
        stroke: "#000",
        selectable: false,
        strokeWidth: 1,
    });
    staticCanvas.add(rulerLine);

    // 创建水平标尺线上的刻度
    for (let i = 0; i < length; i += 10) {
        const p = i + 20;
        // 通过判断方向来确认刻度线的坐标
        const linePoints = direction === HORIZONTAL ? [p, 20, p, 15] : [20, p, 15, p]
        // 创建刻度
        createScale(linePoints, i, direction);
    }
}

/**
 * 创建刻度
 * @param linePoints 刻度线的坐标
 * @param scale 刻度值
 * @param direction 刻度方向
 */
function createScale(linePoints, scale, direction) {
    const isTen = scale % 100 === 0;
    if (isTen) {
        // 判断方向来修改刻度线的长度
        if (direction === HORIZONTAL) {
            linePoints[3] = 10;
        } else {
            linePoints[2] = 10;
        }
    }
    const line = new fabric.Line(linePoints, {
        stroke: '#000',
        selectable: false,
        strokeWidth: 1,
    });
    const text = new fabric.Text(isTen ? (scale).toString() : "", {
        left: direction === HORIZONTAL ? linePoints[0] : 0,
        top: direction === HORIZONTAL ? 0 : linePoints[1],
        fontSize: 10,
        selectable: false,
    });
    staticCanvas.add(line, text);
}

// 初始化标尺
createRuler(width, HORIZONTAL);
createRuler(height, VERTICAL);

staticCanvas.renderAll();

最后实现的效果如下:

image.png

可以看到效果还是可以的,但是现在还是一个静态的,我们需要实现的是一个可以交互的标尺,坐标轴可以移动和缩放,这样才能实现我们的需求。

2.2 实现交互标尺

可交互的标尺交互的地方不是在静态的标尺上面,而是在画布上面,所以这些东西都是需要我们在画布上进行操作,然后将操作的结果反馈到静态的标尺上面。

2.2.1 标尺坐标确定

首先我们是需要确定我们的鼠标是在画布上面的哪个位置,这样才能确定我们的标尺的位置,那我们加一个标尺的指示线,这样就可以确定我们的鼠标在画布上面的位置了。

// 坐标标识线
const horizontalLine = new fabric.Line([0, 20, 40, 20], {
    stroke: "red",
    selectable: false,
    strokeWidth: 1,
});
staticCanvas.add(horizontalLine);

const verticalLine = new fabric.Line([20, 0, 20, 40], {
    stroke: "red",
    selectable: false,
    strokeWidth: 1,
});
staticCanvas.add(verticalLine);

这样在标尺上面就会多出两个红色的线,这两个线就是我们的标识线:

image.png

接下来就是在画布上监听鼠标的移动事件,然后将标识线移动到鼠标的位置,代码如下:

canvas.on("mouse:move", function ({ e }) {
    const { layerX, layerY } = e;
    horizontalLine.set({
        top: layerY + 20,
    });

    verticalLine.set({
        left: layerX + 20,
    });

    staticCanvas.renderAll();
});

这里可以直接使用mouse:move事件,这个事件会在鼠标移动的时候触发,然后使用layerXlayerY 来获取鼠标在画布上面的位置,然后将标识线移动到鼠标的位置,这样就可以实现标识线跟随鼠标移动了。

注意还是要给标识线的topleft加上20,这样才能和画布的刻度对齐。

QQ录屏20230904155347.gif

这里移动过快会频繁的触发mouse:move事件,导致标识线一直都在更新,会有一些卡顿,这里对staticCanvasrenderAll 方法做了一个节流,这样就不会频繁的触发renderAll方法了。

const _staticCanvasRenderAll = staticCanvas.renderAll
staticCanvas.renderAll = throttle(_staticCanvasRenderAll, 16)

节流如何实现看大家自己的实现了,可以使用lodashthrottle方法,也可以自己实现一个。

2.2.2 标尺拖动(画布平移)

在操作画布的时候,画布肯定是会有拖动和缩放的,这个时候标尺也是需要跟着画布一起移动和缩放的,这样才能保证标尺和画布的刻度对齐。

我们先要解决的就是拖动的问题,首先还是要在画布上面添加画布拖动的事件的监听,代码如下:

// 鼠标按下,将 isDown 设置为 true
let isDown = false;
canvas.on("mouse:down", function () {
    isDown = true;
});

// 鼠标抬起,将 isDown 设置为 false
canvas.on("mouse:up", function (opt) {
    isDown = false;
});

canvas.on("mouse:move", function ({ e }) {
    // 只有在按下 alt 键的时候,并且鼠标左键也按下了,才会触发画布的平移
    if (e.altKey === true && isDown === true) {
        // fabric.Point 是一个坐标点的对象,这里用来存储鼠标移动的距离
        const delta = new fabric.Point(e.movementX, e.movementY);
        // 然后调用画布的 relativePan 方法来进行平移
        canvas.relativePan(delta);
    }

    // 这里将标识线更新封装成了一个函数
    markerFollow(e);
});

// 添加一个正方形来查看效果
const rect = new fabric.Rect({
    left: 100,
    top: 100,
    width: 100,
    height: 100,
    fill: "red",
});
canvas.add(rect);
canvas.renderAll();

QQ录屏20230904160515.gif

现在搞定了画布的拖动,使用fabric来实现这些还是挺简单的,只需要调用relativePan方法就可以实现画布的平移了。

2.2.3 标尺拖动(正文)

接下来就是要实现标尺的平移了,思路也很简单,就是在画布平移的时候,将标尺也平移同样的距离,这样就可以实现标尺的平移了。

如果拖动的点位超出了画布的范围,那就直接移动最左边或者最右边,形成一个新的点位就好了,因为标尺上面显示的坐标点就是固定只有这么多的。

有思路就直接开始,当然在实现这一个过程中我还是遇到了很多的坑,现在已经实现了,这些坑肯定就是在代码实现的过程中直接解决了;

首先我需要记录每个点位的数值,这要才能用于更新标尺点位的数值,我们直接在createScale方法中添加一个自定义属性来记录这些数值,代码如下:

function createScale(linePoints, scale, direction) {
    const isTen = scale % 100 === 0;
    if (isTen) {
        // 判断方向来修改刻度线的长度
        if (direction === HORIZONTAL) {
            linePoints[3] = 10;
        } else {
            linePoints[2] = 10;
        }
    }
    const line = new fabric.Line(linePoints, {
        stroke: '#000',
        selectable: false,
        strokeWidth: 1,
        // 新增 _attr 属性,用于记录刻度的数值和方向
        _attr: {
            scale: scale,
            direction: direction,
        }
    });
    const text = new fabric.Text(isTen ? (scale).toString() : "", {
        left: direction === HORIZONTAL ? linePoints[0] : 0,
        top: direction === HORIZONTAL ? 0 : linePoints[1],
        fontSize: 10,
        selectable: false,
        // 新增 _attr 属性,用于记录刻度的数值和方向
        _attr: {
            scale: scale,
            direction: direction,
        }
    });
    staticCanvas.add(line, text);
}

然后在画布平移的时候,我们就可以获取到画布平移的距离,然后将标尺也平移同样的距离,代码如下:

function moveHorizontally(e) {
    const { horizontalLines, horizontalTexts } = dataPacket();

    // 找到最大值和最小值
    const horizontal = horizontalLines.map(item => item._attr.scale);
    let horizontalMin = Math.min(...horizontal);
    let horizontalMax = Math.max(...horizontal);

    // 缓存文字的偏移量,文字和点位的坐标是相同的
    const textOffsets = {}

    // 定义左移和右移的方法
    const leftMove = (line) => {
        const moveX = line.left + e.movementX;
        let scale = line._attr.scale;
        let options = {
            left: line.left + e.movementX,
        };

        // 如果超出了最小值,那么就需要复用最大值减一个点位,并更新最大值
        if (moveX < 20) {
            scale = horizontalMax = horizontalMax + 10;
            options = {
                left: width + moveX,
            }
        }

        // 缓存文字的偏移量
        textOffsets[line._attr.scale] = {
            scale,
            options,
        }

        // 更新点位
        resetLine(line, scale, options);
    }

    const rightMove = (line) => {
        const moveX = line.left + e.movementX;
        let scale = line._attr.scale;
        let options = {
            left: line.left + e.movementX,
        };

        // 如果超出了最大值,那么就需要复用最小值加一个点位,并更新最小值
        if (moveX >= width + 20) {
            scale = horizontalMin = horizontalMin - 10;
            options = {
                left: moveX - width,
            }
        }

        // 缓存文字的偏移量
        textOffsets[line._attr.scale] = {
            scale,
            options,
        }

        // 更新点位
        resetLine(line, scale, options);
    }

    // 遍历所有的点位,然后进行左移和右移
    let headPointer = 0;
    let tailPointer = horizontalLines.length - 1;
    while (headPointer < tailPointer) {
        // 左移
        const headLine = horizontalLines[headPointer];
        leftMove(headLine);
        headPointer++;

        // 右移
        const tailLine = horizontalLines[tailPointer];
        rightMove(tailLine);
        tailPointer--;
    }

    // 如果是奇数个点位,那么中间的点位就随便左移或者右移都可以,这里我选择左移
    if (headPointer === tailPointer) {
        const headLine = horizontalLines[headPointer];
        leftMove(headLine);
    }

    // 更新文字的位置
    for (let i = 0; i < horizontalTexts.length; i++) {
        const text = horizontalTexts[i];

        // 通过缓存的偏移量来更新文字的位置
        const { scale, options } = textOffsets[text._attr.scale];
        resetText(text, scale, options);
    }
}

因为代码量有点大,我将这些代码封装成一个函数了,上面的代码看一下就好了,下面慢慢解读;

首先会有一个dataPacket函数,这个函数是用来获取标尺上面的所有的点位的,主要是水平点位和垂直点位,并将这些点位排序之后返回,代码如下:

function dataPacket() {
    const horizontalLines = [];
    const horizontalTexts = [];
    const verticalLines = [];
    const verticalTexts = [];
    staticCanvas.getObjects().forEach(item => {
        if (item._attr) {
            if (item._attr.direction === HORIZONTAL) {
                if (item.type === 'line') {
                    horizontalLines.push(item);
                } else if (item.type === 'text') {
                    horizontalTexts.push(item);
                }
            } else if (item._attr.direction === VERTICAL) {
                if (item.type === 'line') {
                    verticalLines.push(item);
                } else if (item.type === 'text') {
                    verticalTexts.push(item);
                }
            }
        }
    });

    return {
        horizontalLines: horizontalLines.sort((a, b) => a._attr.scale - b._attr.scale),
        horizontalTexts: horizontalTexts.sort((a, b) => a._attr.scale - b._attr.scale),
        verticalLines: verticalLines.sort((a, b) => a._attr.scale - b._attr.scale),
        verticalTexts: verticalTexts.sort((a, b) => a._attr.scale - b._attr.scale),
    }
}

就是通过我们定义的_attr属性来区分是水平的还是垂直的,然后将这些点位进行排序,这样就可以保证点位的顺序是正确的;

排序是因为在移动的时候,会将超出的点位进行复用,这样就会导致点位的顺序会乱,所以需要进行排序;

然后找到最大值和最小值,最大值超出之后证明是左移,复用的时候就要变成最小值减一个点位,最小值超出之后证明是右移,复用的时候就要变成最大值加一个点位,这样就可以实现点位的复用了。

const horizontal = horizontalLines.map(item => item._attr.scale);
let horizontalMin = Math.min(...horizontal);
let horizontalMax = Math.max(...horizontal);

然后就是左移和右移的方法了,它们两个函数的内部几乎都是一样的,只是方向不一样而已;

因为方向不一样所以它们内部的逻辑一个是更新最大值,一个是更新最小值,当然这里还是有优化空间的,只是我懒得优化了;

const leftMove = (line) => {
    const moveX = line.left + e.movementX;
    let scale = line._attr.scale;
    let options = {
        left: line.left + e.movementX,
    };

    // 核心只有这一个
    if (moveX < 20) {
        scale = horizontalMax = horizontalMax + 10;
        options = {
            left: width + moveX,
        }
    }

    textOffsets[line._attr.scale] = {
        scale,
        options,
    }
    resetLine(line, scale, options);
}

然后就是遍历所有的点位,然后进行左移和右移,这里使用的是双指针的方式,同时进行左移和右移;

因为在移动的过程中点位会进行复用,如果不同时进行左移和右移,会导致一些点位不能正常更新,这一块感兴趣的可以去复现一下,我就节约文章篇幅了;

// 遍历所有的点位,然后进行左移和右移
let headPointer = 0;
let tailPointer = horizontalLines.length - 1;
while (headPointer < tailPointer) {
    // 左移
    const headLine = horizontalLines[headPointer];
    leftMove(headLine);
    headPointer++;

    // 右移
    const tailLine = horizontalLines[tailPointer];
    rightMove(tailLine);
    tailPointer--;
}

// 如果是奇数个点位,那么中间的点位就随便左移或者右移都可以,这里我选择左移
if (headPointer === tailPointer) {
    const headLine = horizontalLines[headPointer];
    leftMove(headLine);
}

最后就是更新文字的位置了,这里我是将文字的位置缓存起来,然后在遍历所有的文字的时候,通过缓存的偏移量来更新文字的位置,这样就可以保证文字和点位的位置是一致的了。

// 更新文字的位置
for (let i = 0; i < horizontalTexts.length; i++) {
    const text = horizontalTexts[i];

    // 通过缓存的偏移量来更新文字的位置
    const { scale, options } = textOffsets[text._attr.scale];
    resetText(text, scale, options);
}

这一块确实是可以放到和点位一起的,因为之前排查问题的时候抽离出来的,感兴趣可以自己去优化一下。

水平和垂直的标尺的移动是一样的,这里就不再赘述了,下面就是实现的效果:

QQ录屏20230904164843.gif

目前的效果都很棒,最后就剩下缩放的问题了,缩放是真的难搞!!!

2.2.4 标尺缩放(画布缩放)

和上面一样,标尺缩放肯定是需要跟着画布一起缩放的,这样才能保证标尺和画布的刻度对齐。

这里我是通过监听画布的mouse:wheel事件来实现的,代码如下:

canvas.on("mouse:wheel", function (opt) {
    let delta = opt.e.deltaY;
    let zoom = canvas.getZoom();
    zoom += -0.001 * delta;
    zoom = zoom.toFixed(2) * 1;

    if (zoom > 5) zoom = 5;
    if (zoom < 0.5) zoom = 0.5;
    canvas.zoomToPoint({
        x: opt.e.offsetX,
        y: opt.e.offsetY,
    }, zoom);

    opt.e.preventDefault();
    opt.e.stopPropagation();
});

缩放也是很简单的,就是调用zoomToPoint方法,然后传入缩放的中心点和缩放的比例就可以了,这里我是通过鼠标的位置来确定缩放的中心点的。

QQ录屏20230904165607.gif

2.2.5 标尺缩放(正文)

缩放的时候,标尺的刻度也是需要跟着画布一起缩放的,这样才能保证标尺和画布的刻度对齐。

这里的方案也很简单,在放大的时候,标尺刻度的间距就会变大,变大之后就会有些刻度会超出标尺的范围,这个时候就移除掉超出的刻度就好了。

缩小就是相反的操作,缩小之后,标尺刻度的间距就会变小,变小之后就会有些刻度会重叠,这个时候就需要添加一些刻度来填充空白的位置。

同时放大到一定的程度之后,标尺的刻度就需要更新,之前是10:1,后面就是5:1了,这个时候就需要更新刻度的数值了。

这里的代码量比较大,这里需要一步一步的实现;

首先需要定义一些变量来记录标尺的缩放比例,刻度的间距,刻度的数值,代码如下:

// 根据缩放比率更新标尺
let magnificationLevels = [5, 10, 20]; // 标尺的缩放比率,目前只有 5:1,10:1,20:1 三种
let magnificationLevel = 1; // 标尺的缩放比率的索引,对应的就是 magnificationLevels 的索引
let magnification = magnificationLevels[magnificationLevel]; // 标尺的缩放比率
let currScaleMagnification = magnification * 10; // 标尺刻度文字显示的缩放比率
let perScaleMagnification = magnificationLevels[magnificationLevel - 1] * 10; // 上一个标尺刻度文字显示的缩放比率

有了这些变量之后,我们就要把之前的代码都要梳理一下了,把之前写死的值都改成变量,这样才能保证缩放的时候,标尺的刻度也会跟着变化。

这里就不贴代码了,大家可以看一下上面的代码,可以搜索一下10100这两个数字,然后将这些数字都改成变量就好了。

然后就是放大和缩小的方法了,根据上面的写的思路,放大的时候就是将标尺的刻度间距变大,缩小的时候就是将标尺的刻度间距变小,代码如下:

  // 根据缩放比例,调整标尺
const objects = staticCanvas.getObjects();
const length = objects.length;

// 这里就是鼠标的位置,用于确定缩放的中心点
// verticalLine 和 horizontalLine 是标识线,也是鼠标的位置
const zoomOriginX = verticalLine.left;
const zoomOriginY = horizontalLine.top;

// 遍历所有的点位,然后进行缩放
for (let i = 0; i < length; i++) {
    const item = objects[i];

    // 如果没有 _attr 属性,就不是标尺的点位,直接跳过
    if (!item._attr) continue;

    // 获取标尺的方向
    const { direction } = item._attr;

    // 水平方向的标尺,调整的是 left 属性
    if (direction === HORIZONTAL) {
        // 这里 perLeft 指的上一次的 left 值
        // 因为缩放的时候,left 值会变化,所以需要记录上一次的 left 值
        // 如果不记录会导致无限变大或者无限变小,无法还原
        const perLeft = (item.left - zoomOriginX) / perZoom + zoomOriginX;

        // 需要使用上一次的 left 值来计算新的 left 值
        const left = (perLeft - zoomOriginX) * zoom + zoomOriginX;

        // 超出画布的范围,就移除掉,一个是超出左边,一个是超出右边
        if (left < 20) {
            staticCanvas.remove(item);
        } else if (left > staticCanvas.width - 20) {
            staticCanvas.remove(item);
        } else {
            // 没有超出画布的范围,就更新 left 值
            item.set({
                left: left,
            });
        }

        continue;
    }

    // 垂直方向的标尺,和水平方向的标尺是一样的,就是调整的是 top 属性
    if (direction === VERTICAL) {
    }
}

这样就完成放大之后标尺的刻度的更新了,来看看效果:

QQ录屏20230904171752.gif

可以看到放大之后,标尺的刻度的间距变大了,但是有些刻度超出了画布的范围,这个时候就需要移除掉超出的刻度了,这里就是通过判断 left 值来移除掉超出的刻度的。

这里缩小的效果也是有的,都是通过缩放比率来计算的,但是因为缩放的时候移除了一些刻度,所以缩小的时候就需要添加一些刻度来填充空白的位置,代码如下:

// 找到最小和最大的刻度,方便后面添加刻度
const { horizontal, vertical } = staticCanvas.getObjects().reduce((prev, curr) => {
    if (curr._attr) {
        if (curr._attr.direction === HORIZONTAL) {
            if (prev.horizontal.minLeft == null) {
                prev.horizontal.minLeft = curr.left;
                prev.horizontal.maxLeft = curr.left;
                prev.horizontal.minScale = curr._attr.scale;
                prev.horizontal.maxScale = curr._attr.scale;
            }

            if (curr.left < prev.horizontal.minLeft) {
                prev.horizontal.minLeft = curr.left;
                prev.horizontal.minScale = curr._attr.scale;
            }

            if (curr.left > prev.horizontal.maxLeft) {
                prev.horizontal.maxLeft = curr.left;
                prev.horizontal.maxScale = curr._attr.scale;
            }
        }
    }

    return prev;
}, {
    horizontal: {
        minLeft: null,
        maxLeft: null,
        minScale: null,
        maxScale: null,
    }
});

// 使用一个 while 循环来添加刻度,这里是添加左边的刻度
// 这里判断 20 是偏移量,距离静态画布的左边的距离
while ((horizontal.minLeft = horizontal.minLeft - magnification * zoom) > 20) {
    // 每次都更新最小值,这样就可以保证刻度的数值是正确的
    horizontal.minScale = horizontal.minScale - magnification;
    createScale([horizontal.minLeft, 20, horizontal.minLeft, 15], horizontal.minScale, HORIZONTAL, currScaleMagnification);
}

// 这里是添加右边的刻度
while ((horizontal.maxLeft = horizontal.maxLeft + magnification * zoom) < staticCanvas.width - 20) {
    horizontal.maxScale = horizontal.maxScale + magnification;
    createScale([horizontal.maxLeft, 20, horizontal.maxLeft, 15], horizontal.maxScale, HORIZONTAL, currScaleMagnification);
}

这里还是一样删除了对垂直方向的处理,实现都相同,就是处理的属性不一样而已,效果如下:

QQ录屏20230904172924.gif

现在要处理就是放大到一定的程度之后,标尺的刻度就需要更新,之前是10:1,后面就是5:1了,这个时候就需要更新刻度的数值了。

这里首先是要更新缩放比率,然后是更新刻度的数值,代码如下:

 // 计算缩放等级
function computedMagnificationLevel(level) {
    // 重置点位线的标识,就是突出显示 100、200 的那些点位
    const resetScale = (lines, direction = HORIZONTAL) => {
    }

    // 隐藏文字
    const hideText = (text) => {
    }

    // 缩小比率处理函数
    const shrink = () => {
    }

    // 重置并创建刻度
    const resetAndCreateScale = (lines, direction = HORIZONTAL) => {
    }

    // 放大比率处理函数
    const magnify = () => {
    }

    // 计算缩放比率
    perScaleMagnification = currScaleMagnification;
    const perLevel = magnificationLevel;

    magnificationLevel = level;
    magnification = magnificationLevels[magnificationLevel];
    currScaleMagnification = magnification * 10;

    // 如果缩小了,就调用缩小的处理函数
    if (perLevel < level) {
        shrink();
    } else if (perLevel > level) {
        magnify();
    }

}

这里定义了一堆的函数,首先是处理缩放比率的一些变量,然后就是调用放大或缩小的处理函数;

首先是缩小,缩小就是删除一些刻度,然后将突出显示的点位重新设置为普通的点位,代码如下:

const shrink = () => {
    let objects = staticCanvas.getObjects();

    // perScaleMagnification 可能为空
    if (!perScaleMagnification) return;

    // 这里是直接删除掉缩小之后不需要的刻度,通过判断 scale 的值来删除
    staticCanvas.remove(...objects.filter(item => {
        if (item._attr && item._attr.scale % magnification !== 0) {
            return true;
        }
    }));

    // 获取新的点位
    const { horizontalLines, horizontalTexts, verticalLines, verticalTexts } = dataPacket();

    // 重置点位线的标识
    resetScale(horizontalLines, HORIZONTAL);
    resetScale(verticalLines, VERTICAL);

    // 隐藏突出显示的文字
    [].concat(horizontalTexts, verticalTexts).forEach(item => {
        if (item._attr.scale % perScaleMagnification === 0) {
            hideText(item);
        }
    });
}

resetScalehideText就是一些重置和隐藏的操作,代码如下:

const resetScale = (lines, direction = HORIZONTAL) => {
    const getOptions = {
        horizontal: (item) => {
            return {
                x1: item.left,
                x2: item.left,
                y1: 20,
                y2: 15,
            }
        },
        vertical: (item) => {
            return {
                x1: 20,
                x2: 15,
                y1: item.top,
                y2: item.top,
            }
        }
    }

    for (let i = 0; i < lines.length; i++) {
        const item = lines[i];
        // 这里就是将之前突出显示的点位,重新设置为普通的点位
        if (item._attr.scale % currScaleMagnification !== 0 && item._attr.scale % perScaleMagnification === 0) {
            const options = getOptions[direction](item);
            options && item.set(options);
        }
    }
}

const hideText = (text) => {
    if (text._attr.scale % currScaleMagnification !== 0) {
        text.set({ text: '' });
    } else {
        text.set({ text: (text._attr.scale).toString() });
    }
}

然后就是放大的处理函数了,放大就是添加一些刻度,然后将突出显示的点位重新设置为普通的点位,代码如下:

const magnify = () => {
    const { horizontalLines, horizontalTexts, verticalLines, verticalTexts } = dataPacket();
    resetAndCreateScale(horizontalLines, HORIZONTAL);
    resetAndCreateScale(verticalLines, VERTICAL);
    [].concat(horizontalTexts, verticalTexts).forEach(hideText);
}

这里看着代码很少,主要的逻辑都在resetAndCreateScale函数中,代码如下:

const resetAndCreateScale = (lines, direction = HORIZONTAL) => {
    // 通过当前点位来获取新的刻度,只需要在当前刻度后面添加一个新的刻度就好了
    const getLinePoint = {
        horizontal: (item) => {
            return [item.left + magnification * zoom, 20, item.left + magnification * zoom, 15];
        },
        vertical: (item) => {
            return [20, item.top + magnification * zoom, 15, item.top + magnification * zoom];
        }
    }

    // 重置为普通的点位
    const getOptions = {
        horizontal: (item) => {
            return {
                x1: item.left,
                x2: item.left,
                y1: 20,
                y2: 10,
            }
        },
        vertical: (item) => {
            return {
                x1: 20,
                x2: 10,
                y1: item.top,
                y2: item.top,
            }
        }
    }

    for (let i = 0; i < lines.length; i++) {
        const item = lines[i];
        const linePoints = getLinePoint[direction](item);
        createScale(linePoints, item._attr.scale + magnification, direction, currScaleMagnification);

        if (item._attr.scale % currScaleMagnification === 0) {
            const options = getOptions[direction](item);
            options && item.set(options);
        }
    }
}

放大就是添加新的点位,怎么知道新的点位添加到什么地方呢?参考的就是旧的点位,只需要在旧的点位后面添加一个新的点位就好了,这里就是通过getLinePoint来获取新的点位的的坐标。

然后就是将突出显示的点位重新设置为普通的点位,这里的处理方案和缩小的时候是一样的;

最后的效果如下:

QQ录屏20230904175724.gif

3. 总结

实现这个小功能花费了我不小的精力,但是收获也是很大的,这里就不一一列举了;

在此期间查询了不少得资料,例如刻度怎么通过鼠标的位置进行缩放,因为放大刻度是往外扩散的,缩小是往里面收缩的,这里的应该怎么计算?

还有刻度值怎么正确显示,因为每次比率调整之后,显示在标尺上的刻度值也要同步调整,这里应该用什么方案?

然后还有拖动的时候,在开发阶段拖动的时候点位总是乱跑,最后找到问题之后通过头尾指针的方式来解决的,这里应该有更好的方案吧?

当然在这里还有一个bug,就是标尺的放大和缩小值只能是上一个的两倍,不能是任意的倍数;

因为在放大的时候是往两个刻度中间插入一个刻度,缩小的时候同理是删除一个刻度,这样就导致了放大和缩小的倍数只能是上一个的两倍;

这里的解决方案是删除所有的刻度重新进行绘制,这里我只有一个大致的方案,具体会遇到那些问题感兴趣的小伙伴可以自行尝试一下;