<script src="https://unpkg.com/konva@9/konva.min.js"></script>
 <p><span id="scaleTbl"></span><span id="scaleMsg"></span></p>
  <p>
    <span id='info'>Stage pos...</span> 
  </p>
 <div id='scroller' class='containersize'>
   <div id="container" class='container containersize'></div>
   <div id='sizer' class=''>
   </div>
</div>


 



    <svg style='display: none' id="stars" viewBox="0 0 900 600" width="900" height="600" xmlns="http://www.w3.org/2000/svg"
        xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
        <rect x="0" y="0" width="900" height="600" fill="#001829"></rect>
        <g fill="#297EA6">
            <path
                d="M0 -43.4L9.7 -13.4L41.3 -13.4L15.8 5.1L25.5 35.1L0 16.6L-25.5 35.1L-15.8 5.1L-41.3 -13.4L-9.7 -13.4Z"
                transform="translate(817 357)"></path>
            <path d="M0 -19.8L4.4 -6.1L18.9 -6.1L7.2 2.3L11.7 16L0 7.6L-11.7 16L-7.2 2.3L-18.9 -6.1L-4.4 -6.1Z"
                transform="translate(633 473)"></path>
            <path
                d="M0 -40.6L9.1 -12.5L38.6 -12.5L14.7 4.8L23.9 32.8L0 15.5L-23.9 32.8L-14.7 4.8L-38.6 -12.5L-9.1 -12.5Z"
                transform="translate(605 31)"></path>
            <path d="M0 -29.3L6.6 -9L27.8 -9L10.6 3.5L17.2 23.7L0 11.2L-17.2 23.7L-10.6 3.5L-27.8 -9L-6.6 -9Z"
                transform="translate(599 569)"></path>
            <path d="M0 -27.4L6.1 -8.5L26 -8.5L9.9 3.2L16.1 22.1L0 10.5L-16.1 22.1L-9.9 3.2L-26 -8.5L-6.1 -8.5Z"
                transform="translate(120 56)"></path>
            <path d="M0 -20.8L4.7 -6.4L19.7 -6.4L7.5 2.5L12.2 16.8L0 7.9L-12.2 16.8L-7.5 2.5L-19.7 -6.4L-4.7 -6.4Z"
                transform="translate(309 323)"></path>
            <path
                d="M0 -34.9L7.8 -10.8L33.2 -10.8L12.7 4.1L20.5 28.3L0 13.3L-20.5 28.3L-12.7 4.1L-33.2 -10.8L-7.8 -10.8Z"
                transform="translate(25 173)"></path>
            <path d="M0 -21.7L4.9 -6.7L20.6 -6.7L7.9 2.6L12.8 17.6L0 8.3L-12.8 17.6L-7.9 2.6L-20.6 -6.7L-4.9 -6.7Z"
                transform="translate(241 87)"></path>
            <path
                d="M0 -40.6L9.1 -12.5L38.6 -12.5L14.7 4.8L23.9 32.8L0 15.5L-23.9 32.8L-14.7 4.8L-38.6 -12.5L-9.1 -12.5Z"
                transform="translate(471 183)"></path>
            <path d="M0 -18.9L4.2 -5.8L18 -5.8L6.9 2.2L11.1 15.3L0 7.2L-11.1 15.3L-6.9 2.2L-18 -5.8L-4.2 -5.8Z"
                transform="translate(866 245)"></path>
            <path d="M0 -38.7L8.7 -12L36.8 -12L14.1 4.6L22.7 31.3L0 14.8L-22.7 31.3L-14.1 4.6L-36.8 -12L-8.7 -12Z"
                transform="translate(775 581)"></path>
            <path d="M0 -31.1L7 -9.6L29.6 -9.6L11.3 3.7L18.3 25.2L0 11.9L-18.3 25.2L-11.3 3.7L-29.6 -9.6L-7 -9.6Z"
                transform="translate(262 491)"></path>
            <path
                d="M0 -40.6L9.1 -12.5L38.6 -12.5L14.7 4.8L23.9 32.8L0 15.5L-23.9 32.8L-14.7 4.8L-38.6 -12.5L-9.1 -12.5Z"
                transform="translate(465 574)"></path>
            <path d="M0 -24.5L5.5 -7.6L23.3 -7.6L8.9 2.9L14.4 19.9L0 9.4L-14.4 19.9L-8.9 2.9L-23.3 -7.6L-5.5 -7.6Z"
                transform="translate(56 414)"></path>
            <path
                d="M0 -34.9L7.8 -10.8L33.2 -10.8L12.7 4.1L20.5 28.3L0 13.3L-20.5 28.3L-12.7 4.1L-33.2 -10.8L-7.8 -10.8Z"
                transform="translate(763 208)"></path>
            <path
                d="M0 -40.6L9.1 -12.5L38.6 -12.5L14.7 4.8L23.9 32.8L0 15.5L-23.9 32.8L-14.7 4.8L-38.6 -12.5L-9.1 -12.5Z"
                transform="translate(389 135)"></path>
            <path
                d="M0 -34.9L7.8 -10.8L33.2 -10.8L12.7 4.1L20.5 28.3L0 13.3L-20.5 28.3L-12.7 4.1L-33.2 -10.8L-7.8 -10.8Z"
                transform="translate(671 150)"></path>
            <path
                d="M0 -39.6L8.9 -12.2L37.7 -12.2L14.4 4.7L23.3 32.1L0 15.1L-23.3 32.1L-14.4 4.7L-37.7 -12.2L-8.9 -12.2Z"
                transform="translate(370 460)"></path>
            <path d="M0 -38.7L8.7 -12L36.8 -12L14.1 4.6L22.7 31.3L0 14.8L-22.7 31.3L-14.1 4.6L-36.8 -12L-8.7 -12Z"
                transform="translate(523 74)"></path>
            <path d="M0 -35.9L8.1 -11.1L34.1 -11.1L13 4.2L21.1 29L0 13.7L-21.1 29L-13 4.2L-34.1 -11.1L-8.1 -11.1Z"
                transform="translate(33 550)"></path>
            <path
                d="M0 -34.9L7.8 -10.8L33.2 -10.8L12.7 4.1L20.5 28.3L0 13.3L-20.5 28.3L-12.7 4.1L-33.2 -10.8L-7.8 -10.8Z"
                transform="translate(525 274)"></path>
            <path d="M0 -29.3L6.6 -9L27.8 -9L10.6 3.5L17.2 23.7L0 11.2L-17.2 23.7L-10.6 3.5L-27.8 -9L-6.6 -9Z"
                transform="translate(56 318)"></path>
            <path d="M0 -31.1L7 -9.6L29.6 -9.6L11.3 3.7L18.3 25.2L0 11.9L-18.3 25.2L-11.3 3.7L-29.6 -9.6L-7 -9.6Z"
                transform="translate(876 97)"></path>
            <path d="M0 -21.7L4.9 -6.7L20.6 -6.7L7.9 2.6L12.8 17.6L0 8.3L-12.8 17.6L-7.9 2.6L-20.6 -6.7L-4.9 -6.7Z"
                transform="translate(198 248)"></path>
            <path d="M0 -38.7L8.7 -12L36.8 -12L14.1 4.6L22.7 31.3L0 14.8L-22.7 31.3L-14.1 4.6L-36.8 -12L-8.7 -12Z"
                transform="translate(450 335)"></path>
            <path d="M0 -33L7.4 -10.2L31.4 -10.2L12 3.9L19.4 26.7L0 12.6L-19.4 26.7L-12 3.9L-31.4 -10.2L-7.4 -10.2Z"
                transform="translate(651 322)"></path>
            <path d="M0 -33L7.4 -10.2L31.4 -10.2L12 3.9L19.4 26.7L0 12.6L-19.4 26.7L-12 3.9L-31.4 -10.2L-7.4 -10.2Z"
                transform="translate(744 433)"></path>
            <path d="M0 -22.7L5.1 -7L21.5 -7L8.2 2.7L13.3 18.3L0 8.7L-13.3 18.3L-8.2 2.7L-21.5 -7L-5.1 -7Z"
                transform="translate(151 461)"></path>
            <path d="M0 -18.9L4.2 -5.8L18 -5.8L6.9 2.2L11.1 15.3L0 7.2L-11.1 15.3L-6.9 2.2L-18 -5.8L-4.2 -5.8Z"
                transform="translate(485 446)"></path>
            <path d="M0 -38.7L8.7 -12L36.8 -12L14.1 4.6L22.7 31.3L0 14.8L-22.7 31.3L-14.1 4.6L-36.8 -12L-8.7 -12Z"
                transform="translate(310 225)"></path>
            <path d="M0 -35.9L8.1 -11.1L34.1 -11.1L13 4.2L21.1 29L0 13.7L-21.1 29L-13 4.2L-34.1 -11.1L-8.1 -11.1Z"
                transform="translate(849 9)"></path>
            <path d="M0 -32.1L7.2 -9.9L30.5 -9.9L11.7 3.8L18.9 26L0 12.3L-18.9 26L-11.7 3.8L-30.5 -9.9L-7.2 -9.9Z"
                transform="translate(889 574)"></path>
            <path d="M0 -32.1L7.2 -9.9L30.5 -9.9L11.7 3.8L18.9 26L0 12.3L-18.9 26L-11.7 3.8L-30.5 -9.9L-7.2 -9.9Z"
                transform="translate(145 148)"></path>
            <path
                d="M0 -34.9L7.8 -10.8L33.2 -10.8L12.7 4.1L20.5 28.3L0 13.3L-20.5 28.3L-12.7 4.1L-33.2 -10.8L-7.8 -10.8Z"
                transform="translate(871 475)"></path>
            <path d="M0 -29.3L6.6 -9L27.8 -9L10.6 3.5L17.2 23.7L0 11.2L-17.2 23.7L-10.6 3.5L-27.8 -9L-6.6 -9Z"
                transform="translate(366 557)"></path>
            <path
                d="M0 -41.5L9.3 -12.8L39.5 -12.8L15.1 4.9L24.4 33.6L0 15.9L-24.4 33.6L-15.1 4.9L-39.5 -12.8L-9.3 -12.8Z"
                transform="translate(229 389)"></path>
            <path d="M0 -25.5L5.7 -7.9L24.2 -7.9L9.3 3L15 20.6L0 9.7L-15 20.6L-9.3 3L-24.2 -7.9L-5.7 -7.9Z"
                transform="translate(319 34)"></path>
            <path d="M0 -27.4L6.1 -8.5L26 -8.5L9.9 3.2L16.1 22.1L0 10.5L-16.1 22.1L-9.9 3.2L-26 -8.5L-6.1 -8.5Z"
                transform="translate(24 17)"></path>
            <path d="M0 -28.3L6.4 -8.7L26.9 -8.7L10.3 3.3L16.6 22.9L0 10.8L-16.6 22.9L-10.3 3.3L-26.9 -8.7L-6.4 -8.7Z"
                transform="translate(541 373)"></path>
            <path d="M0 -24.5L5.5 -7.6L23.3 -7.6L8.9 2.9L14.4 19.9L0 9.4L-14.4 19.9L-8.9 2.9L-23.3 -7.6L-5.5 -7.6Z"
                transform="translate(221 599)"></path>
            <path d="M0 -28.3L6.4 -8.7L26.9 -8.7L10.3 3.3L16.6 22.9L0 10.8L-16.6 22.9L-10.3 3.3L-26.9 -8.7L-6.4 -8.7Z"
                transform="translate(574 193)"></path>
            <path d="M0 -34L7.6 -10.5L32.3 -10.5L12.3 4L20 27.5L0 13L-20 27.5L-12.3 4L-32.3 -10.5L-7.6 -10.5Z"
                transform="translate(707 40)"></path>
            <path
                d="M0 -41.5L9.3 -12.8L39.5 -12.8L15.1 4.9L24.4 33.6L0 15.9L-24.4 33.6L-15.1 4.9L-39.5 -12.8L-9.3 -12.8Z"
                transform="translate(777 113)"></path>
            <path d="M0 -27.4L6.1 -8.5L26 -8.5L9.9 3.2L16.1 22.1L0 10.5L-16.1 22.1L-9.9 3.2L-26 -8.5L-6.1 -8.5Z"
                transform="translate(446 10)"></path>
            <path d="M0 -25.5L5.7 -7.9L24.2 -7.9L9.3 3L15 20.6L0 9.7L-15 20.6L-9.3 3L-24.2 -7.9L-5.7 -7.9Z"
                transform="translate(144 352)"></path>
            <path d="M0 -32.1L7.2 -9.9L30.5 -9.9L11.7 3.8L18.9 26L0 12.3L-18.9 26L-11.7 3.8L-30.5 -9.9L-7.2 -9.9Z"
                transform="translate(135 560)"></path>
            <path d="M0 -28.3L6.4 -8.7L26.9 -8.7L10.3 3.3L16.6 22.9L0 10.8L-16.6 22.9L-10.3 3.3L-26.9 -8.7L-6.4 -8.7Z"
                transform="translate(199 2)"></path>
            <path d="M0 -28.3L6.4 -8.7L26.9 -8.7L10.3 3.3L16.6 22.9L0 10.8L-16.6 22.9L-10.3 3.3L-26.9 -8.7L-6.4 -8.7Z"
                transform="translate(403 253)"></path>
        </g>
    </svg>

body {
  margin: 10px;
  overflow: hidden;
  background-color: #f0f0f0;
} 
#container { 
  box-sizing: border-box;
  position: absolute;
  left: 0;
  top: 0; 
  z-index: 2;
}
#containersize { 
  box-sizing: border-box;
  width: 800px;
  height: 600px; 
}
#scroller {
  box-sizing: border-box;
  position: relative;
  max-width: 800px;
  max-height: 600px;  
  background-color: lime; 
  overflow: auto
}
#sizer{
  box-sizing: border-box;
  position: relative;
  min-width: 800px;
  min-height: 600px;
  background-color: red; 
  z-index: 1;
}

p {
  margin: 4px;
}
#container {
  margin: 10px;
}
#scaleMsg {
  color: red;
}
table {
  display: inline-block;
}
td {
  border: 1px solid silver;
  min-width: 35;
}
.selected {
  background-color: cyan;
}
#zoomErr {
  color: red;
  background-color: gold;
  display: none;
}
$( document ).ready(function(){
  console.log('start')
  let   
    scales = [5,4,3,2.5,2,1.5,1, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, 0.05],
    currentScaleIdx = 6; // holds array index of current scale.
const 
  size = { width: 800, height: 600}
  stage = new Konva.Stage({
    container: "container",
    width: size.width,
    height: size.height,
    draggable: true, 
    dragBoundFunc(pos){
      
      // limit the right & down direction of the stage to keep it in place.
      const stageSz = stage.getClientRect() 
      minX = size.width - stageSz.width
      minY = size.height - stageSz.height 
      
      if (pos.y < minY){
        pos.y = minY
      }
      else if (pos.y > 0)
      {
        pos.y = 0
      }
      if (pos.x < minX){
        pos.x = minX
      }      
      else if (pos.x > 0)
      {
        pos.x = 0
      }
      return pos
    }
  }),
    rect = new Konva.Rect({
      // stroke: 'magenta',
      // strokeWidth: 4,
      // fill: 'transparent',
      width: 100,
      height: 100
    }),
      
  layer = new Konva.Layer({
    draggable: false
  });

stage.add(layer);
 



function setSizer(size){
  // set the sizer div to the size of the stage
    $('#sizer').css({
      x: 0,
      y: 0,
      width: size.width ,
      height: size.height
  }) 
}
  

function updateScrollers(){
  const pos = stage.getClientRect()
  
  setSizer(pos)
  
  $('#info').html("Stage pos " + JSON.stringify(pos));
return  
  stageScroll = true

  $('#scroller').scrollLeft(-pos.x)
  $('#scroller').scrollTop(-pos.y)

  stageScroll = false
  
}
  // React to user dragging stage
let stageScroll = false;
stage.on("dragmove", function () {
  
   updateScrollers()
  
});

// React to user scrolling
$('#scroller').on('scroll', function(evt){
  

  // amount we have scrolled
  const scrollPos = {
    x: -1 * $(this).scrollLeft(),
    y: -1 * $(this).scrollTop()
  } 

  // size of sizer
  const sizerSize = {
    width: $('#sizer').width(),
    height:$('#sizer').height()
  }
  
  // size of scroller
  const scrollerSize = {
    width: $('#scroller').width(),
    height:$('#scroller').height()
  }
  
  
  // proportion of movement 
  const prop = {
    x: scrollPos.x / (sizerSize.width - scrollerSize.width),
    y: scrollPos.y / (sizerSize.height - scrollerSize.height)
  }
 
                
  // react to the vertical scroll
  if (prop.y > -1){
   stage.y(scrollPos.y)
  
   $('#container').css({
     top: $(this).scrollTop()
   })
  }
  else {
    // accounts for user throwing mouse to down!
    $('#container').css({
      top: (sizerSize.height - scrollerSize.height)
    })
  }
  
  // react to the horizontal scroll
   if (prop.x > -1){
    
    stage.x(scrollPos.x)

    $('#container').css({
      left: $(this).scrollLeft(),
    })
  } 
  else {
    // accounts for user throwing mouse to right!
    $('#container').css({
      left: (sizerSize.width - scrollerSize.width)
    })
  }
}) 
 
function init(){
     $('#container').css({
      left: $('#scroller').scrollLeft(),
       top: $('#scroller').scrollTop()
   })

  // Set up the scale indicator
  let tbl = "";
  for (let i = 0; i < scales.length; i++){
    tbl = tbl + '<td id="scale' + i + '">' + scales[i] + '</td>'
  }
  tbl = "<table><tr><td>Scale</td>" + tbl + "<td id='zoomErr'>No more zooms available</td></tr></table>"
  $("#scaleTbl").html(tbl);

}
init();
  

stage.on('wheel', (e) => {
  
  // stop default scrolling
  e.evt.preventDefault();

  const 
  
    // Note old scale (scale before moving to next scale)
    oldScale = scales[currentScaleIdx],

    // Get ABSOLUTE pointer position - doesn't include any transforms (such as scale) of the
    // stage. Is just a plain position of pointer relative to top-left corner of the canvas.
    pointer = stage.getPointerPosition(),

    // Pointer.x/y and stage.x/y are not scaled.
    // Compute the mouse position at on the stage at current scale. We use this to calculate 
    // the stage position movement required at the new scale to place this same point on the 
    // stage under the mouse pointer after the new scale is applied.
    mousePointTo = {
      x: (pointer.x - stage.x()) / oldScale,
      y: (pointer.y - stage.y()) / oldScale,
    };

  // Are we zooming IN or OUT ?
  let direction = e.evt.deltaY > 0 ? -1 : 1;

  // when we zoom on trackpad, e.evt.ctrlKey is true
  // in that case lets revert direction
  if (e.evt.ctrlKey) {
    direction = -direction;
  }

  // This is where we decide the next scale. 
  // Go to the next zoom position on the array of scales.
  
  if (direction > 0){
    zoomErr =  currentScaleIdx > 0 ? false : true;
    currentScaleIdx = currentScaleIdx > 0 ? currentScaleIdx - 1 : currentScaleIdx;
  }
  else {
    zoomErr =  currentScaleIdx < scales.length - 1 ? false : true;
    currentScaleIdx = currentScaleIdx < scales.length - 1 ? currentScaleIdx + 1 : currentScaleIdx;
  }
  
  // Set the scale value
  let newScale = scales[currentScaleIdx];

  // Apply this scale to the stage.
  stage.scale({ x: newScale, y: newScale });

  // Compute the new position of the stage so that the same stage point 
  // is under the mouse pointer.
  const newPos = {
    x: pointer.x - mousePointTo.x * newScale,
    y: pointer.y - mousePointTo.y * newScale,
  };

  // Apply the new position of the stage
  stage.position(newPos);

  // Update the message.        
  $('#scaleMsg').html(scaleMsg);
  
  // Update the info display
  showInfo();
  
  updateScrollers()

});  
  
// Update the info display
function showInfo(){
  const mousePos = stage.getPointerPosition();
  if (mousePos){
    $('#info').html('Pos: ' + mousePos.x + ', ' + mousePos.y)
  }
  $('#info2').html('Stage at: ' + Math.round(stage.x()) + ', ' + Math.round(stage.y()));
  $('.selected').removeClass('selected');
 $('#scale' + currentScaleIdx).addClass('selected');
  if (zoomErr){
    $('#zoomErr').show();
  }
  else {
    $('#zoomErr').hide();
  }
}
showInfo();
  

const picLoader = new Image();
picLoader.onload = function () {
  layer.destroyChildren();
 
  const pic = new Konva.Image({
    x: 0,
    y: 0,
    width: stage.width() * stage.scaleX(), // cover the stage
    height: stage.height() * stage.scaleY(), 
    opacity: 1
  });
  layer.add(pic);

  pic.image(picLoader);
  
 //  layer.add(rect)
  
//   rect.position({
//     x: pic.position().x - 2,
//     y: pic.position().y - 2,
//   })

//   rect.size ({
//     width: pic.size().width - 4,
//     height: pic.size().height - 4,
//   })
  
  setSizer(stage.getClientRect())
};
  
// Just loading something to see in the stage - hapens to be an SVG image in this case
// function to load the svg element with the given id.
function loadSvgPic(picId) { 
  
  var svgElement = document.getElementById(picId);

  let { width, height } = svgElement.getBBox();

  let clonedSvgElement = svgElement.cloneNode(true); // true for deep clone

  let outerHTML = clonedSvgElement.outerHTML,
    blob = new Blob([outerHTML], { type: "image/svg+xml;charset=utf-8" });

  let URL = window.URL || window.webkitURL || window;

  let blobURL = URL.createObjectURL(blob);

  picLoader.src = blobURL;
  
}
  
loadSvgPic('stars')
  
})

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js