mousemove 引起的叠加问题 更新

问题描述

我的团队正在开发一个类似于 adobe Premier pro 中的时间轴应用程序。代码大约有 250 行长,我遇到了一个奇怪的错误,我花了一整天但未能解决。以下是架构简介:

  • Stamp 是单个时间线中的块。
  • Channel 一个通道对应一个时间参考。
  • Timeline 包含许多频道。

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

  • 向左拖动将改变开始时间
  • 拖拽结束将改变结束时间

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

mousedownmousemove 上实现 mouseupleft_handleright_handle 并不健壮,因为一旦光标在鼠标移动期间飞出元素,mouseup 不会触发。因此,mousemove 和 mouseup 事件侦听器设置在窗口上

请看代码

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%',this.left_handle);
    render_element({
        width: '80%',cursor: 'ew-resize',this.main_el);
    render_element({
        width: '10%',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',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,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,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',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%',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,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 以事件为参数,通过更改两个 css 属性来更改 Stamp(设置开始时间)中的 Channel 位置:

  • 左边距
  • 宽度

非常奇怪的是:

  • 拖动右手柄时,它的工作完全符合我的预期。
  • 调用 expandLeft 时,它也能正常工作。 (这是在脚本末尾测试的)
  • 但是当我将它附加到 mousemove 侦听器时,宽度似乎是累积的。

抱歉,如果问题让您感到困惑。 如果你能帮我一把,我会很高兴的。


更新

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

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

  • 快速拖动左侧手柄时,宽度变化不大。
  • 缓慢拖动左侧手柄时,宽度会以增加的速度扩展。
  • 尽管有鼠标移动距离,但宽度始终以与时间成正比的恒定速率扩展。

这是由于精度损失还是我在计算中忽略了一些东西?


在我添加了这个测试代码之后:

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,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).toString() + 'px'
  • this.el.style.width = (this.el.offsetWidth).toString() + 'px';

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

解决方法

向左扩展时需要考虑偏移量: 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%',this.left_handle);
    render_element({
        width: '80%',cursor: 'ew-resize',this.main_el);
    render_element({
        width: '10%',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',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,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,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',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%',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,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,vv] of Object.entries(styles)) {
    el.style[kk] = vv;
  }
}

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

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

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

class Stamp{
    constructor(
        host,){
        this.host = host;
        this.parent_el = parent_el;
        // percent to pixels
        this.width = width_percent / 100 * window.innerWidth;
        [
            this.el,()=>document.createElement('div'));
        render_element({
            position: 'relative',this.el);
        render_element({
            width: '10%',this.left_handle);
        render_element({
            width: '80%',this.main_el);
        render_element({
            width: '10%',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(),]
    }

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

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

    expandLeft(e){
        var [el_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,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,){
        this.host = host;
        this.parent_el = parent_el;
        this.height = height_percent;
        this.stamps = [];
        this.el = document.createElement('div');
        render_element({
            position: 'relative',this.el);
        this.parent_el.appendChild(this.el);
        this.addStamp = this.addStamp.bind(this);
    }

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

class Timeline{
    constructor(
        parent_el,){
        this.parent_el = parent_el;
        this.frame_rate = frame_rate;
        this.el = document.createElement('div');
        render_element({
            width: '100%',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,height_percent));
        return this.channels[this.channels.length - 1];
    }
}

var tl = new Timeline(
    document.querySelector('#timeline'),);
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>