mousemove引起的叠加问题

superposition issue caused by mousemove

我的团队正在开发时间轴应用程序,类似于 adobe premier pro 中的应用程序。代码长约250行,我遇到了一个奇怪的错误,我整天都在努力但未能解决。这是简要的体系结构:

我想实现 left-right-drag 以更改 Stamp 的持续时间,其中:

因此,我将一个Stamp元素分为三个部分:左手柄、主(中)元素和右手柄,它们包含在el

left_handleright_handle上实现mousedownmousemovemouseup并不健壮,因为一旦光标飞出元素鼠标移动,mouseup 不会开火。因此,mousemove 和 mouseup 事件侦听器设置在 window

请看代码:

codepen

function render_element(styles, el) {
  for (const [kk, vv] of Object.entries(styles)) {
el.style[kk] = vv;
  }
}

const endListeners = {
'mousemove': [],
'mouseup': [],
};

function addMouseMove(func){
endListeners['mousemove'].push(func);
}

function addMouseUp(func){
endListeners['mouseup'].push(func);
}

class Stamp{
constructor(
    host,
    parent_el,
    width_percent,
){
    this.host = host;
    this.parent_el = parent_el;
    // percent to pixels
    this.width = width_percent / 100 * window.innerWidth;
    [
        this.el,
        this.left_handle,
        this.main_el,
        this.right_handle,
    ] = Array.from({length: 4}, ()=>document.createElement('div'));
    render_element({
        position: 'relative',
        display: 'inline-block',
        width: this.width.toString() + 'px',
        height: '100%',
        display: 'flex',
        flexDirection: 'row',
        border: '1px solid',
    }, this.el);
    render_element({
        width: '10%',
        height: '100%',
        border: '1px solid',
    }, this.left_handle);
    render_element({
        width: '80%',
        height: '100%',
        cursor: 'ew-resize',
        border: '1px solid',
    }, this.main_el);
    render_element({
        width: '10%',
        height: '100%',
        cursor: 'ew-resize',
        border: '1px solid',
    }, this.right_handle);
    this.el.appendChild(this.left_handle);
    this.el.appendChild(this.main_el);
    this.el.appendChild(this.right_handle);
    // indicator during movement
    this.indicator = document.createElement('div');
    render_element({
        position: 'absolute',
        width: '5px',
        height: '100%',
        background: 'grey',
        display: 'none',
    }, this.indicator);
    this.el.appendChild(this.indicator);
    // move
    // mousedown start move
    this.in_move = false;
    this.in_left_move = false;
    this.left_handle.addEventListener('mousedown', e=>{
        this.startMove(e);
        this.in_move = true;
        this.in_left_move = true;
    });
    this.right_handle.addEventListener('mousedown', e=>{
        this.startMove(e);
        this.in_move = true;
        this.in_left_move = false;
    });
    // mousemove 
    this.move = function(e){
        if(!this.in_move)return;
        this.moveIndicator(e);
        if(this.in_left_move){
            this.expandLeft(e);
        }else{
            this.expandRight(e);
        }
    }.bind(this);
    addMouseMove(this.move);
    // mouseend finish move
    addMouseUp(function(e){
        this.move(e);
        this.endMove(e);
        this.in_move = false;
    }.bind(this));
    this.parent_el.appendChild(this.el);
    this.getHandleRects = this.getHandleRects.bind(this);
    this.startMove = this.startMove.bind(this);
    this.endMove = this.endMove.bind(this);
    this.expandLeft = this.expandLeft.bind(this);
    this.expandRight = this.expandRight.bind(this);
    this.moveIndicator = this.moveIndicator.bind(this);
}

getHandleRects(){
    return [
        this.el.getBoundingClientRect(),
        this.left_handle.getBoundingClientRect(),
        this.right_handle.getBoundingClientRect(),
    ]
}

startMove(e){
    // show indicator
    this.indicator.style.display = 'block';
    this.moveIndicator(e);
    // change color
    this.el.style.background = 'lightblue';
}

endMove(){
    // hide indicator
    this.indicator.style.display = 'none';
    // change color back
    this.el.style.background = 'none';
}

expandLeft(e){
    var [el_rect, left_rect, right_rect] = this.getHandleRects();
    if(e.clientX >= right_rect.left)return;
    let dif = el_rect.left - e.clientX;
    this.el.style.width = (el_rect.width + dif).toString() + 'px';
    this.el.style.marginLeft = (el_rect.left - dif).toString() + 'px';
}

expandRight(e){
    var [el_rect, left_rect, right_rect] = this.getHandleRects();
    if(e.clientX <= left_rect.right)return;
    this.el.style.width = (e.clientX - el_rect.left).toString() + 'px';
}

moveIndicator(e){
    this.indicator.style.marginLeft = e.clientX.toString() + 'px';
}
}

class Channel{
constructor(
    host,
    parent_el,
    height_percent,
){
    this.host = host;
    this.parent_el = parent_el;
    this.height = height_percent;
    this.stamps = [];
    this.el = document.createElement('div');
    render_element({
        position: 'relative',
        width: '100%',
        height: this.height.toString() + '%',
        // display: 'flex',
        // flexDirection: 'row',
        border: '1px solid',
    }, this.el);
    this.stamp_indicator = document.createElement('div');
    this.parent_el.appendChild(this.el);
    this.addStamp = this.addStamp.bind(this);
}

addStamp(width_percent){
    this.stamps.push(new Stamp(this, this.el, width_percent));
    return this.stamps[this.stamps.length - 1];
}
}

class Timeline{
constructor(
    parent_el,
    frame_rate,
){
    this.parent_el = parent_el;
    this.frame_rate = frame_rate;
    this.el = document.createElement('div');
    render_element({
        width: '100%',
        height: '100%',
        background: 'lightgrey',
    }, this.el);
    this.channels = [];
    this.parent_el.appendChild(this.el);
    this.el.droppable = true;
    this.el.addEventListener('dragover', e=>{
        e.preventDefault();
    });
    this.el.addEventListener('drop', e=>{
        e.preventDefault();
        console.log(e.dataTransfer.getData('text/plain'));
    });
    this.addChanel = this.addChannel.bind(this);
}

addChannel(height_percent){
    this.channels.push(new Channel(this, this.el, height_percent));
    return this.channels[this.channels.length - 1];
}
}

var tl = new Timeline(
document.querySelector('#timeline'), 
2,
);
var c1 = tl.addChannel(33);
var s1 = c1.addStamp(20);
// if I call it explicitly, it works fine
s1.expandLeft({
clientX: 50,
});


// endListeners exec at end
for (const [kk, vv] of Object.entries(endListeners)) {
window.addEventListener(kk, e=>{vv.forEach(v=>v(e))});
}
#timeline{
position: fixed;
left: 0;
bottom: 0;
width: 100vw;
height: 300px;
border: 1px solid;
}
<div id="timeline"></div>

请注意第138~144行,expandLeft以一个事件为参数,改变Channel(设置开始时间)中的Stamp位置,通过改变两个css 属性:

非常奇怪的是:


如果这个问题让您感到困惑,我们深表歉意。 如果你能帮助我,我会很高兴。


更新

即使我画了很多图表来确定,我的计算也没有发现任何问题。

我观察到一个有趣的现象:

这是由于精度损失还是我在计算中忽略了什么?


添加此测试代码后:

s1.expandRight({
    clientX: 800,
});
setTimeout(()=>{
    s1.expandLeft({
        clientX: 200,
    });
    setTimeout(()=>{
        s1.expandLeft({
            clientX: 400,
        });
        setTimeout(()=>{
            s1.expandLeft({
                clientX: 600,
            });
            }, 500);
    }, 500);
}, 500);

我可以看到右边的宽度在扩大。一定有一个计算我误解或忽略了。


现在我完全糊涂了,我把getBoundingClientRect().width赋给了css宽度属性,这应该不会改变宽度,因为我把宽度赋给了自己!但是宽度一直在增加!!

    expandLeft(e){
        var [el_rect, left_rect, right_rect] = this.getHandleRects();
        if(e.clientX >= right_rect.left)return;
        let dif = el_rect.left - e.clientX;
        // this.el.style.width = (el_rect.width + dif).toString() + 'px';
        this.el.style.width = (el_rect.width).toString() + 'px';
        this.el.style.marginLeft = e.clientX.toString() + 'px';
    }

codepen

两行结果相同,拖动左手柄时宽度会扩大:

如果你能解释为什么会这样,我将非常高兴

您需要考虑向左扩展时的偏移量: this.el.style.width = (el_rect.width + dif).toString() + 'px'; 对比 this.el.style.width = (el_rect.width + dif - el_rect.offsetLeft).toString() + 'px';

function render_element(styles, el) {
  for (const [kk, vv] of Object.entries(styles)) {
el.style[kk] = vv;
  }
}

const endListeners = {
'mousemove': [],
'mouseup': [],
};

function addMouseMove(func){
endListeners['mousemove'].push(func);
}

function addMouseUp(func){
endListeners['mouseup'].push(func);
}

class Stamp{
constructor(
    host,
    parent_el,
    width_percent,
){
    this.host = host;
    this.parent_el = parent_el;
    // percent to pixels
    this.width = width_percent / 100 * window.innerWidth;
    [
        this.el,
        this.left_handle,
        this.main_el,
        this.right_handle,
    ] = Array.from({length: 4}, ()=>document.createElement('div'));
    render_element({
        position: 'relative',
        display: 'inline-block',
        width: this.width.toString() + 'px',
        height: '100%',
        display: 'flex',
        flexDirection: 'row',
        border: '1px solid',
    }, this.el);
    render_element({
        width: '10%',
        height: '100%',
        border: '1px solid',
    }, this.left_handle);
    render_element({
        width: '80%',
        height: '100%',
        cursor: 'ew-resize',
        border: '1px solid',
    }, this.main_el);
    render_element({
        width: '10%',
        height: '100%',
        cursor: 'ew-resize',
        border: '1px solid',
    }, this.right_handle);
    this.el.appendChild(this.left_handle);
    this.el.appendChild(this.main_el);
    this.el.appendChild(this.right_handle);
    // indicator during movement
    this.indicator = document.createElement('div');
    render_element({
        position: 'absolute',
        width: '5px',
        height: '100%',
        background: 'grey',
        display: 'none',
    }, this.indicator);
    this.el.appendChild(this.indicator);
    // move
    // mousedown start move
    this.in_move = false;
    this.in_left_move = false;
    this.left_handle.addEventListener('mousedown', e=>{
        this.startMove(e);
        this.in_move = true;
        this.in_left_move = true;
    });
    this.right_handle.addEventListener('mousedown', e=>{
        this.startMove(e);
        this.in_move = true;
        this.in_left_move = false;
    });
    // mousemove 
    this.move = function(e){
        if(!this.in_move)return;
        this.moveIndicator(e);
        if(this.in_left_move){
            this.expandLeft(e);
        }else{
            this.expandRight(e);
        }
    }.bind(this);
    addMouseMove(this.move);
    // mouseend finish move
    addMouseUp(function(e){
        this.move(e);
        this.endMove(e);
        this.in_move = false;
    }.bind(this));
    this.parent_el.appendChild(this.el);
    this.getHandleRects = this.getHandleRects.bind(this);
    this.startMove = this.startMove.bind(this);
    this.endMove = this.endMove.bind(this);
    this.expandLeft = this.expandLeft.bind(this);
    this.expandRight = this.expandRight.bind(this);
    this.moveIndicator = this.moveIndicator.bind(this);
}

getHandleRects(){
    return [
        this.el.getBoundingClientRect(),
        this.left_handle.getBoundingClientRect(),
        this.right_handle.getBoundingClientRect(),
    ]
}

startMove(e){
    // show indicator
    this.indicator.style.display = 'block';
    this.moveIndicator(e);
    // change color
    this.el.style.background = 'lightblue';
}

endMove(){
    // hide indicator
    this.indicator.style.display = 'none';
    // change color back
    this.el.style.background = 'none';
}

expandLeft(e){
    var [el_rect, left_rect, right_rect] = this.getHandleRects();
    if(e.clientX >= right_rect.left)return;
    let dif = el_rect.left - e.clientX;
    this.el.style.width = (el_rect.width + dif - el_rect.offsetLeft).toString() + 'px';
    this.el.style.marginLeft = (el_rect.left - dif).toString() + 'px';
}

expandRight(e){
    var [el_rect, left_rect, right_rect] = this.getHandleRects();
    if(e.clientX <= left_rect.right)return;
    this.el.style.width = (e.clientX - el_rect.left).toString() + 'px';
}

moveIndicator(e){
    this.indicator.style.marginLeft = e.clientX.toString() + 'px';
}
}

class Channel{
constructor(
    host,
    parent_el,
    height_percent,
){
    this.host = host;
    this.parent_el = parent_el;
    this.height = height_percent;
    this.stamps = [];
    this.el = document.createElement('div');
    render_element({
        position: 'relative',
        width: '100%',
        height: this.height.toString() + '%',
        // display: 'flex',
        // flexDirection: 'row',
        border: '1px solid',
    }, this.el);
    this.stamp_indicator = document.createElement('div');
    this.parent_el.appendChild(this.el);
    this.addStamp = this.addStamp.bind(this);
}

addStamp(width_percent){
    this.stamps.push(new Stamp(this, this.el, width_percent));
    return this.stamps[this.stamps.length - 1];
}
}

class Timeline{
constructor(
    parent_el,
    frame_rate,
){
    this.parent_el = parent_el;
    this.frame_rate = frame_rate;
    this.el = document.createElement('div');
    render_element({
        width: '100%',
        height: '100%',
        background: 'lightgrey',
    }, this.el);
    this.channels = [];
    this.parent_el.appendChild(this.el);
    this.el.droppable = true;
    this.el.addEventListener('dragover', e=>{
        e.preventDefault();
    });
    this.el.addEventListener('drop', e=>{
        e.preventDefault();
        console.log(e.dataTransfer.getData('text/plain'));
    });
    this.addChanel = this.addChannel.bind(this);
}

addChannel(height_percent){
    this.channels.push(new Channel(this, this.el, height_percent));
    return this.channels[this.channels.length - 1];
}
}

var tl = new Timeline(
document.querySelector('#timeline'), 
2,
);
var c1 = tl.addChannel(33);
var s1 = c1.addStamp(20);
// if I call it explicitly, it works fine
s1.expandLeft({
clientX: 50,
});


// endListeners exec at end
for (const [kk, vv] of Object.entries(endListeners)) {
window.addEventListener(kk, e=>{vv.forEach(v=>v(e))});
}
#timeline{
position: fixed;
left: 0;
bottom: 0;
width: 100vw;
height: 300px;
border: 1px solid;
}
<div id="timeline"></div>

是的,我成功了!!通过存储鼠标移动前的左边距和宽度,并引用它而不是动态左边距和宽度。

function render_element(styles, el) {
  for (const [kk, vv] of Object.entries(styles)) {
    el.style[kk] = vv;
  }
}

const endListeners = {
    'mousemove': [],
    'mouseup': [],
};

function addMouseMove(func){
    endListeners['mousemove'].push(func);
}

function addMouseUp(func){
    endListeners['mouseup'].push(func);
}

class Stamp{
    constructor(
        host,
        parent_el,
        width_percent,
    ){
        this.host = host;
        this.parent_el = parent_el;
        // percent to pixels
        this.width = width_percent / 100 * window.innerWidth;
        [
            this.el,
            this.left_handle,
            this.main_el,
            this.right_handle,
        ] = Array.from({length: 4}, ()=>document.createElement('div'));
        render_element({
            position: 'relative',
            display: 'inline-block',
            width: this.width.toString() + 'px',
            height: '100%',
            display: 'flex',
            flexDirection: 'row',
            border: '1px solid',
        }, this.el);
        render_element({
            width: '10%',
            height: '100%',
            border: '1px solid',
        }, this.left_handle);
        render_element({
            width: '80%',
            height: '100%',
            cursor: 'ew-resize',
            border: '1px solid',
        }, this.main_el);
        render_element({
            width: '10%',
            height: '100%',
            cursor: 'ew-resize',
            border: '1px solid',
        }, this.right_handle);
        this.el.appendChild(this.left_handle);
        this.el.appendChild(this.main_el);
        this.el.appendChild(this.right_handle);
        // move
        // mousedown start move
        this.in_move = false;
        this.in_left_move = false;
        this.lwd = this.el.offsetWidth;
        this.lft = this.el.offsetLeft;
        this.left_handle.addEventListener('mousedown', function(e){
            this.startMove(e);
            this.in_move = true;
            this.in_left_move = true;
            this.lwd = this.el.offsetWidth;
            this.lft = this.el.offsetLeft;
        }.bind(this));
        this.right_handle.addEventListener('mousedown', function(e){
            this.startMove(e);
            this.in_move = true;
            this.in_left_move = false;
        }.bind(this));
        // mousemove 
        this.move = function(e){
            if(!this.in_move)return;
            if(this.in_left_move){
                this.expandLeft(e);
            }else{
                this.expandRight(e);
            }
        }.bind(this);
        addMouseMove(this.move);
        // mouseend finish move
        addMouseUp(function(e){
            this.move(e);
            this.endMove(e);
            this.in_move = false;
        }.bind(this));
        this.parent_el.appendChild(this.el);
        this.getHandleRects = this.getHandleRects.bind(this);
        this.startMove = this.startMove.bind(this);
        this.endMove = this.endMove.bind(this);
        this.expandLeft = this.expandLeft.bind(this);
        this.expandRight = this.expandRight.bind(this);
    }

    getHandleRects(){
        return [
            this.el.getBoundingClientRect(),
            this.left_handle.getBoundingClientRect(),
            this.right_handle.getBoundingClientRect(),
        ]
    }

    startMove(e){
        // change color
        this.el.style.background = 'lightblue';
    }

    endMove(){
        // change color back
        this.el.style.background = 'none';
    }

    expandLeft(e){
        var [el_rect, left_rect, right_rect] = this.getHandleRects();
        if(e.clientX >= right_rect.left)return;
        let dif = this.lft - e.clientX;
        this.el.style.width = (this.lwd + dif).toString() + 'px';
        this.el.style.marginLeft = e.clientX.toString() + 'px';
    }

    expandRight(e){
        var [el_rect, left_rect, right_rect] = this.getHandleRects();
        if(e.clientX <= left_rect.right)return;
        this.el.style.width = (e.clientX - el_rect.left).toString() + 'px';
    }
}

class Channel{
    constructor(
        host,
        parent_el,
        height_percent,
    ){
        this.host = host;
        this.parent_el = parent_el;
        this.height = height_percent;
        this.stamps = [];
        this.el = document.createElement('div');
        render_element({
            position: 'relative',
            width: '100%',
            height: this.height.toString() + '%',
            // display: 'flex',
            // flexDirection: 'row',
            border: '1px solid',
        }, this.el);
        this.parent_el.appendChild(this.el);
        this.addStamp = this.addStamp.bind(this);
    }

    addStamp(width_percent){
        this.stamps.push(new Stamp(this, this.el, width_percent));
        return this.stamps[this.stamps.length - 1];
    }
}

class Timeline{
    constructor(
        parent_el,
        frame_rate,
    ){
        this.parent_el = parent_el;
        this.frame_rate = frame_rate;
        this.el = document.createElement('div');
        render_element({
            width: '100%',
            height: '100%',
            background: 'lightgrey',
        }, this.el);
        this.channels = [];
        this.parent_el.appendChild(this.el);
        this.el.droppable = true;
        this.el.addEventListener('dragover', e=>{
            e.preventDefault();
        });
        this.el.addEventListener('drop', e=>{
            e.preventDefault();
            console.log(e.dataTransfer.getData('text/plain'));
        });
        this.addChanel = this.addChannel.bind(this);
    }

    addChannel(height_percent){
        this.channels.push(new Channel(this, this.el, height_percent));
        return this.channels[this.channels.length - 1];
    }
}

var tl = new Timeline(
    document.querySelector('#timeline'), 
    2,
);
var c1 = tl.addChannel(33);
var s1 = c1.addStamp(20);
var c2 = tl.addChannel(33);
var s2 = c2.addStamp(30);
#timeline{
position: fixed;
left: 0;
bottom: 0;
width: 100vw;
height: 300px;
border: 1px solid;
}
<div id="timeline"></div>