以鼠标位置为中心进行缩放/放大

11

我正在努力理解并确定如何根据这个例子,在我的鼠标位置上进行缩放。(https://stackblitz.com/edit/js-fxnmkm?file=index.js

let node,
    scale = 1,
    posX = 0,
    posY = 0,
    node = document.querySelector('.frame');

const render = () => {
  window.requestAnimationFrame(() => {
    let val = `translate3D(${posX}px, ${posY}px, 0px) scale(${scale})`
    node.style.transform = val
  })
}

window.addEventListener('wheel', (e) => {
  e.preventDefault();

  // Zooming happens here
  if (e.ctrlKey) {
    scale -= e.deltaY * 0.01;
  } else {
    posX -= e.deltaX * 2;
    posY -= e.deltaY * 2;
  }

  render();
});

我希望实现的效果基于这个例子 (https://codepen.io/techslides/pen/zowLd?editors=0010) 在缩放时。目前我的示例只能缩放到“视口”中心,但我希望它能缩放到鼠标当前位置。

我已经四处搜索了一个不使用canvas实现的解决方案。感谢任何帮助!

注意: 我使用滚轮事件模拟Figma(设计工具)的平移和缩放交互。


你的变量 scaleposXposY 是从哪里来的?你是否研究过 e.deltaXe.deltaY?我问这个问题是因为如果它们对该向量产生了变化,那么你不应该使用 += 吗? - GetSet
@GetSet 我已经更新了示例,包括来自我的链接的整个基本示例。(https://stackblitz.com/edit/js-fxnmkm?file=index.js) - Nige
你期望的输出应该是第一个链接还是第二个? - Aashif Ahamed
缩放应该像第二个链接一样。 - Nige
5个回答

28

使用画布进行可缩放内容

缩放和平移元素非常棘手。虽然可以实现,但问题列表很长。我绝不会实现这样的界面。

考虑使用画布,通过2D或WebGL显示此类内容,以节省大量麻烦。

答案的第一部分使用画布实现。第二个示例中使用相同的接口view 来平移和缩放元素。

简单的2D视图。

由于您只需要平移和缩放,因此可以使用非常简单的方法。

下面的示例实现了一个名为view的对象。它保存当前比例和位置(pan)。

它提供了两个用于用户交互的函数:

  • 平移函数view.pan(amount)将视图按由amount.xamount.y表示的像素距离平移。
  • 缩放函数view.scaleAt(at, amount)将在at.x, at.y所表示的像素位置上按amount(表示比例变化的数字)缩放(缩放)视图。

在示例中,使用view.apply() 将视图应用于画布渲染上下文,并在视图更改时渲染一组随机框。

平移和缩放通过鼠标事件实现。

使用canvas 2D上下文的示例

使用鼠标按钮拖动进行平移,使用滚轮进行缩放。

const ctx = canvas.getContext("2d");
canvas.width = 500;
canvas.height = 500;
const rand = (m = 255, M = m + (m = 0)) => (Math.random() * (M - m) + m) | 0;


const objects = [];
for (let i = 0; i < 100; i++) {
  objects.push({x: rand(canvas.width), y: rand(canvas.height),w: rand(40),h: rand(40), col: `rgb(${rand()},${rand()},${rand()})`});
}

requestAnimationFrame(drawCanvas); 

const view = (() => {
  const matrix = [1, 0, 0, 1, 0, 0]; // current view transform
  var m = matrix;             // alias 
  var scale = 1;              // current scale
  var ctx;                    // reference to the 2D context
  const pos = { x: 0, y: 0 }; // current position of origin
  var dirty = true;
  const API = {
    set context(_ctx) { ctx = _ctx; dirty = true },
    apply() {
      if (dirty) { this.update() }
      ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5])
    },
    get scale() { return scale },
    get position() { return pos },
    isDirty() { return dirty },
    update() {
      dirty = false;
      m[3] = m[0] = scale;
      m[2] = m[1] = 0;
      m[4] = pos.x;
      m[5] = pos.y;
    },
    pan(amount) {
      if (dirty) { this.update() }
       pos.x += amount.x;
       pos.y += amount.y;
       dirty = true;
    },
    scaleAt(at, amount) { // at in screen coords
      if (dirty) { this.update() }
      scale *= amount;
      pos.x = at.x - (at.x - pos.x) * amount;
      pos.y = at.y - (at.y - pos.y) * amount;
      dirty = true;
    },
  };
  return API;
})();
view.context = ctx;
function drawCanvas() {
    if (view.isDirty()) { 
        ctx.setTransform(1, 0, 0, 1, 0, 0); 
        ctx.clearRect(0, 0, canvas.width, canvas.height);

        view.apply(); // set the 2D context transform to the view
        for (i = 0; i < objects.length; i++) {
            var obj = objects[i];
            ctx.fillStyle = obj.col;
            ctx.fillRect(obj.x, obj.y, obj.h, obj.h);
        }
    }
    requestAnimationFrame(drawCanvas);
}


canvas.addEventListener("mousemove", mouseEvent, {passive: true});
canvas.addEventListener("mousedown", mouseEvent, {passive: true});
canvas.addEventListener("mouseup", mouseEvent, {passive: true});
canvas.addEventListener("mouseout", mouseEvent, {passive: true});
canvas.addEventListener("wheel", mouseWheelEvent, {passive: false});
const mouse = {x: 0, y: 0, oldX: 0, oldY: 0, button: false};
function mouseEvent(event) {
    if (event.type === "mousedown") { mouse.button = true }
    if (event.type === "mouseup" || event.type === "mouseout") { mouse.button = false }
    mouse.oldX = mouse.x;
    mouse.oldY = mouse.y;
    mouse.x = event.offsetX;
    mouse.y = event.offsetY    
    if(mouse.button) { // pan
        view.pan({x: mouse.x - mouse.oldX, y: mouse.y - mouse.oldY});
    }
}
function mouseWheelEvent(event) {
    var x = event.offsetX;
    var y = event.offsetY;
    if (event.deltaY < 0) { view.scaleAt({x, y}, 1.1) }
    else { view.scaleAt({x, y}, 1 / 1.1) }
    event.preventDefault();
}
body {
  background: gainsboro;
  margin: 0;
}
canvas {
  background: white;
  box-shadow: 1px 1px 1px rgba(0, 0, 0, .2);
}
<canvas id="canvas"></canvas>

使用 element.style.transform 的示例

此示例使用元素样式转换属性来进行缩放和平移。

  • 注意:我使用的是2D矩阵而不是3D矩阵,因为后者可能会引入许多与下面所用简单缩放和平移不兼容的问题。

  • 注意:在某些情况下,CSS变换并不适用于元素的左上角。在下面的示例中,原点位于元素的中心。因此,在缩放时,必须通过减去一半的元素大小来调整以点为缩放中心。元素大小不受变换影响。

  • 注意:边框、填充和外边距也会改变原点的位置。要使用view.scaleAt(at, amount)必须将at相对于元素左上角的最左侧像素计算。

  • 注意:当你缩放和平移元素时,还有许多更多的问题和注意事项需要考虑,这些问题无法在一个单独的答案中涵盖。这就是为什么这个回答从canvas示例开始的原因,因为这是远远更安全的处理可缩放视觉内容的方法。

使用鼠标按钮拖动进行平移,使用滚轮进行缩放。如果您失去了位置(缩放过度或超出页面),请重新开始代码段。

const view = (() => {
  const matrix = [1, 0, 0, 1, 0, 0]; // current view transform
  var m = matrix;             // alias 
  var scale = 1;              // current scale
  const pos = { x: 0, y: 0 }; // current position of origin
  var dirty = true;
  const API = {
    applyTo(el) {
      if (dirty) { this.update() }
      el.style.transform = `matrix(${m[0]},${m[1]},${m[2]},${m[3]},${m[4]},${m[5]})`;
    },
    update() {
      dirty = false;
      m[3] = m[0] = scale;
      m[2] = m[1] = 0;
      m[4] = pos.x;
      m[5] = pos.y;
    },
    pan(amount) {
      if (dirty) { this.update() }
       pos.x += amount.x;
       pos.y += amount.y;
       dirty = true;
    },
    scaleAt(at, amount) { // at in screen coords
      if (dirty) { this.update() }
      scale *= amount;
      pos.x = at.x - (at.x - pos.x) * amount;
      pos.y = at.y - (at.y - pos.y) * amount;
      dirty = true;
    },
  };
  return API;
})();


document.addEventListener("mousemove", mouseEvent, {passive: false});
document.addEventListener("mousedown", mouseEvent, {passive: false});
document.addEventListener("mouseup", mouseEvent, {passive: false});
document.addEventListener("mouseout", mouseEvent, {passive: false});
document.addEventListener("wheel", mouseWheelEvent, {passive: false});
const mouse = {x: 0, y: 0, oldX: 0, oldY: 0, button: false};
function mouseEvent(event) {
    if (event.type === "mousedown") { mouse.button = true }
    if (event.type === "mouseup" || event.type === "mouseout") { mouse.button = false }
    mouse.oldX = mouse.x;
    mouse.oldY = mouse.y;
    mouse.x = event.pageX;
    mouse.y = event.pageY;
    if(mouse.button) { // pan
        view.pan({x: mouse.x - mouse.oldX, y: mouse.y - mouse.oldY});
        view.applyTo(zoomMe);
    }
    event.preventDefault();
}
function mouseWheelEvent(event) {
    const x = event.pageX - (zoomMe.width / 2);
    const y = event.pageY - (zoomMe.height / 2);
    if (event.deltaY < 0) { 
        view.scaleAt({x, y}, 1.1);
        view.applyTo(zoomMe);
    } else { 
        view.scaleAt({x, y}, 1 / 1.1);
        view.applyTo(zoomMe);
    }
    event.preventDefault();
}
body {
   user-select: none;    
   -moz-user-select: none;    
}
.zoomables {
    pointer-events: none;
    border: 1px solid black;
}
#zoomMe {
    position: absolute;
    top: 0px;
    left: 0px;
}
  
<img id="zoomMe" class="zoomables" src="https://istack.dev59.com/C7qq2.webp?s=328&g=1">


3
一些注意事项:事件名称为wheel,而不是mousewheel,后者只有一些UA实现了旧版本。您可能希望处理平滑滚动(const scroll_ratio = (Math.abs(event.deltaY) / 100) + 1; if (event.deltaY < 0) { view.scaleAt({x, y}, scroll_ratio); (...) view.scaleAt({x, y}, 1 / scroll_ratio);)。 - Kaiido
平滑滚动可以通过在transform上使用transition更容易地实现。向下迈一步,你就会看到。 - user11484628
@Blindman67,感谢您的回答,它很有帮助。但是我遇到了一个问题,如果我的图像使用flex居中,则计算无法正常工作。有什么想法吗? - karan roy

9

第二个链接的缩放有点过度,所以我尝试添加了一些约束。您可以取消注释并进行更多的操作。目前在我看来,外观和功能完全相同。

const container = document.querySelector('.container');
const image = document.querySelector('.image');
const speed = 0.5;
let size = { 
  w: image.offsetWidth, 
  h: image.offsetHeight 
};
let pos = { x: 0, y: 0 };
let target = { x: 0, y: 0 };
let pointer = { x: 0, y: 0 };
let scale = 1;

window.addEventListener('wheel', event => {
  event.preventDefault();
  
  pointer.x = event.pageX - container.offsetLeft;
  pointer.y = event.pageY - container.offsetTop;
  target.x = (pointer.x - pos.x) / scale;
  target.y = (pointer.y - pos.y) / scale;
 
  scale += -1 * Math.max(-1, Math.min(1, event.deltaY)) * speed * scale;
  
  // Uncomment to constrain scale
  // const max_scale = 4;
  // const min_scale = 1;
  // scale = Math.max(min_scale, Math.min(max_scale, scale));

  pos.x = -target.x * scale + pointer.x;
  pos.y = -target.y * scale + pointer.y;

  // Uncomment for keeping the image within area (works with min scale = 1)
  // if (pos.x > 0) pos.x = 0;
  // if (pos.x + size.w * scale < size.w) pos.x = -size.w * (scale - 1);
  // if (pos.y > 0) pos.y = 0;
  // if (pos.y + size.h * scale < size.h) pos.y = -size.h * (scale - 1);

  image.style.transform = `translate(${pos.x}px,${pos.y}px) scale(${scale},${scale})`;
}, { passive: false });
.container {
  width: 400px;
  height: 400px;
  overflow: hidden;
  outline: 1px solid gray;
}

.image {
  width: 100%;
  height: 100%;
  transition: transform .3s;
  transform-origin: 0 0;
}

img {
  width: auto;
  height: auto;
  max-width: 100%;
}
<div class="container">
  <div class="image">
    <img src="https://picsum.photos/400/400" />
  </div>
</div>


谢谢您的帖子,我必须标记一个人为悬赏赢家。所以我把它给了回答最全面的那个人。如果可以分开评选,我会这样做。但我已经给您的回答点了赞。 - Nige
1
非canvas缩放的最佳解决方案。顺便说一下,整个pointer.x可以很容易地替换为event.clientX。这个与平移的实现可以在这里找到。 - user11484628

3
这是我的版本,支持平移和缩放(按住CTRL键)。

let editor = document.getElementById("editor");
let editorCanvas = editor.querySelector(".canvas");
let scale = 1.0;

const minScale = 0.1;
const maxScale = 8;
const scaleStep = 0.003;

let ctrlDown = false;
let dragging = false;
let dragStartX = 0;
let dragStartY = 0;
let previousScrollLeft = 0;
let previousScrollTop = 0;

window.addEventListener("keydown", (e) => {
    if (e.ctrlKey) {
        ctrlDown = true;
        editorCanvas.style.cursor = "move";
    }
});

window.addEventListener("keyup", (e) => {
    ctrlDown = false;
    editorCanvas.style.cursor = "default";
});

editor.addEventListener("mousedown", (e) => {
    dragging = true;
    dragStartX = e.x - editor.offsetLeft;
    dragStartY = e.y - editor.offsetTop;
    previousScrollLeft = editor.scrollLeft;
    previousScrollTop = editor.scrollTop;
});

editor.addEventListener("mouseup", (e) => {
    dragging = false;
});

editor.addEventListener("mousemove", (e) => {
    if (ctrlDown && dragging) {

        requestAnimationFrame(() => {
            let currentX = e.x - editor.offsetLeft;
            let currentY = e.y - editor.offsetTop;

            let scrollX = previousScrollLeft + (dragStartX - currentX)
            let scrollY = previousScrollTop + (dragStartY - currentY);

            editor.scroll(scrollX, scrollY);
        });
    }
});

editor.addEventListener("wheel", (e) => {
    e.preventDefault();

    requestAnimationFrame(() => {
        if (e.ctrlKey) {
            scale -= e.deltaY * scaleStep;

            if (scale < minScale) {
                scale = minScale;
            }

            if (scale > maxScale) {
                scale = maxScale;
            }

            if (scale < 1) {
                editorCanvas.style.transformOrigin = "50% 50% 0";
            } else {
                editorCanvas.style.transformOrigin = "0 0 0";
            }

            editorCanvas.style.transform = `matrix(${scale}, 0, 0, ${scale}, 0, 0)`;

            let rect = editorCanvas.getBoundingClientRect();

            let ew = rect.width;
            let eh = rect.height;

            let mx = e.x - editor.offsetLeft;
            let my = e.y - editor.offsetTop;

            editor.scroll((ew - editor.offsetWidth) * (mx / editor.clientWidth), (eh - editor.offsetHeight) * (my / editor.clientHeight));
        } else {
            editor.scrollTop += e.deltaY;
            editor.scrollLeft += e.deltaX;
        }
    });
}, { passive: false });
body {
    background-color: lightgray;
}

#editor {
    position: relative;
    width: 1024px;
    height: 768px;
    box-sizing: border-box;
    border: 1px solid darkgray;
    background-color: gray;
    overflow: auto;
}

.canvas {
    position: relative;
    width: 100%;
    height: 100%;
    background-color: white;
}

.frame {
    position: absolute;
    box-sizing: border-box;
    border: 1px solid darkslategrey;
    transition: all 0.25s;
}

.frame.one {
    top: 80px;
    left: 400px;
    width: 300px;
    height: 250px;
    background-color: pink;
}

.frame.two {
    top: 350px;
    left: 150px;
    width: 200px;
    height: 150px;
    background-color: gold;
}

.frame.three {
    top: 130px;
    left: 70px;
    width: 100px;
    height: 150px;
    background-color: cyan;
}

.frame.four {
    top: 368px;
    left: 496px;
    width: 32px;
    height: 32px;
    background-color: lime;
}

.frame:hover {
    cursor: pointer;
    border: 3px solid darkslategrey;
}

.frame:active {
    filter: invert();
}
<!DOCTYPE html>
<html lang="en">
  <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <meta http-equiv="X-UA-Compatible" content="ie=edge">
      <title>Zoom Editor</title>
  </head>
  <body>
      <div id="editor">
          <div class="canvas">
              <div class="frame one"></div>
              <div class="frame two"></div>
              <div class="frame three"></div>
              <div class="frame four"></div>
          </div>
      </div>
  </body>
</html>


感谢您的文章,我不得不将其中一个标记为悬赏获胜者。因此,我把它给了给出最详细答案的人。如果可以分成几份的话,我会这样做的。不过我已经为您的答案点赞了。 - Nige

1

1
jQuery不是我的问题的解决方案-抱歉。不过还是感谢您的建议。 - Nige
无论如何,那似乎是一个常规的JS库,而不是jQuery插件。 - Kalnode

0

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接