Canvas 拖拽标注框:startBox 模式避免累计误差与 offsetX 自然拖拽感

问题:mousemove 中累加导致误差

// 错误写法:每次 mousemove 都叠加
canvas.addEventListener('mousemove', (e) => {
    if (!isDragging) return;
    const dx = e.clientX - lastX;
    const dy = e.clientY - lastY;
    
    bbox.x += dx;  // 浮点累加,误差逐帧积累
    bbox.y += dy;
    
    lastX = e.clientX;
    lastY = e.clientY;
});

每帧的 dx 存在浮点舍入,经过几百次 mousemove 后,bbox 位置与鼠标实际位置产生偏差。

解决:startBox 模式

mousedown 时保存初始状态,mousemove 始终基于初始状态计算绝对位置:

let dragState = null;

canvas.addEventListener('mousedown', (e) => {
    const mouseX = getCanvasX(e);
    const mouseY = getCanvasY(e);
    
    if (isInsideBbox(mouseX, mouseY, bbox)) {
        dragState = {
            startX: mouseX,
            startY: mouseY,
            startBox: { ...bbox }  // 拷贝初始位置
        };
    }
});

canvas.addEventListener('mousemove', (e) => {
    if (!dragState) return;
    
    const mouseX = getCanvasX(e);
    const mouseY = getCanvasY(e);
    
    const dx = mouseX - dragState.startX;
    const dy = mouseY - dragState.startY;
    
    // 每帧都基于 startBox 计算,不累加
    bbox.x = dragState.startBox.x + dx;
    bbox.y = dragState.startBox.y + dy;
    
    redraw();
});

canvas.addEventListener('mouseup', () => {
    dragState = null;
});

bbox.x = startBox.x + dx 而不是 bbox.x += dx,误差不再累积。

offsetX 模式:自然拖拽手感

上面的写法有个体验问题:点击框的右下角时,鼠标移动会让框的左上角跳到鼠标位置。

offsetX 模式记录点击点与框左上角的偏移量,保持鼠标与抓取点的相对位置不变:

canvas.addEventListener('mousedown', (e) => {
    const mouseX = getCanvasX(e);
    const mouseY = getCanvasY(e);
    
    if (isInsideBbox(mouseX, mouseY, bbox)) {
        dragState = {
            offsetX: mouseX - bbox.x,  // 鼠标在框内的相对位置
            offsetY: mouseY - bbox.y
        };
    }
});

canvas.addEventListener('mousemove', (e) => {
    if (!dragState) return;
    
    const mouseX = getCanvasX(e);
    const mouseY = getCanvasY(e);
    
    bbox.x = mouseX - dragState.offsetX;
    bbox.y = mouseY - dragState.offsetY;
    
    redraw();
});

鼠标无论点击框内哪个位置,拖拽过程中该点始终跟随鼠标,手感自然。

三种方案对比

方案误差拖拽手感适用场景
bbox.x += dx(lastX 累加)有累计误差一般不推荐
startBox 模式无误差左上角跳到鼠标精确位移要求高
offsetX 模式无误差自然,保持抓取点用户交互体验优先

大多数标注工具选择 offsetX 模式,既无误差又有自然手感。

坐标转换

Canvas 内部坐标需要考虑缩放和滚动:

function getCanvasX(e) {
    const rect = canvas.getBoundingClientRect();
    return (e.clientX - rect.left) / scale;
}

function getCanvasY(e) {
    const rect = canvas.getBoundingClientRect();
    return (e.clientY - rect.top) / scale;
}

scale 是 Canvas 的缩放比例,不除以 scale 会导致缩放后拖拽位置错误。