Skip to content

Instantly share code, notes, and snippets.

@InerkyJad
Created October 31, 2022 15:24
Show Gist options
  • Save InerkyJad/ae02411fd6a110e26114779b26215d0c to your computer and use it in GitHub Desktop.
Save InerkyJad/ae02411fd6a110e26114779b26215d0c to your computer and use it in GitHub Desktop.
JavaScript Infinite Scrolling Canvas

Infinite Scrolling Canvas

this is a simple canvas app that contains 2 stacked HTML5 canvas elements, one of them is used to draw a selection box + any ghost elements to avoid re-rendering the mail canvas. and it contains another canvas that handles a grid and 2 scrollbars

How to use

you can scroll using your mouse (click and drag to all sides) or you can use the mouse wheel and the keyboard key shift

How Does it work

it uses one Javascript object as a state where a property size contains all of height, width, _height, _width, top, left. these properties are used to store numbers indicating to certain height or width or position

State Breakdown

Property Type Description
height number the height of the cnavas element (user screen)
width number the width of the cnavas element (user screen)
_height number the real height of the drawing, so it can be bigger than height
_width number the real width of the drawing, so it can be bigger than width
top number Y position of the canvas box height within the drawing box _height
left number X position of the canvas box width within the drawing box _width
track.top number only used to track the Y scrolling
track.left number only used to track the X scrolling

Rendering Logic

when you want to render you'll only be rendering the visible part aka (width, height) out of (_height, _width), and ofc by taking into account both positions ['top', 'left'] to know where the visible box is located withing the actual drawing by doing that you'll have yourself an infinite drawing space. ofc my current example doesn't include drawing nor rendering, it only covers the implementation of an infinite grid that can be scrolled using the mouse wheel and the mouse itself. but up on checking the grid implementation, and understanding the state management part you'll understand that you have everything you need to render only elements that are within.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Infinite Scrolling Canvas</title>
<link rel="stylesheet" href="main.css">
</head>
<body>
<div class="container">
<canvas id="canvas-space"></canvas>
<canvas id="prototyping-space"></canvas>
<div class="tools">
<div data-tool="hand" class="active">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="currentColor" class="bi bi-hand-index" viewBox="0 0 16 16">
<path d="M6.75 1a.75.75 0 0 1 .75.75V8a.5.5 0 0 0 1 0V5.467l.086-.004c.317-.012.637-.008.816.027.134.027.294.096.448.182.077.042.15.147.15.314V8a.5.5 0 1 0 1 0V6.435a4.9 4.9 0 0 1 .106-.01c.316-.024.584-.01.708.04.118.046.3.207.486.43.081.096.15.19.2.259V8.5a.5.5 0 0 0 1 0v-1h.342a1 1 0 0 1 .995 1.1l-.271 2.715a2.5 2.5 0 0 1-.317.991l-1.395 2.442a.5.5 0 0 1-.434.252H6.035a.5.5 0 0 1-.416-.223l-1.433-2.15a1.5 1.5 0 0 1-.243-.666l-.345-3.105a.5.5 0 0 1 .399-.546L5 8.11V9a.5.5 0 0 0 1 0V1.75A.75.75 0 0 1 6.75 1zM8.5 4.466V1.75a1.75 1.75 0 1 0-3.5 0v5.34l-1.2.24a1.5 1.5 0 0 0-1.196 1.636l.345 3.106a2.5 2.5 0 0 0 .405 1.11l1.433 2.15A1.5 1.5 0 0 0 6.035 16h6.385a1.5 1.5 0 0 0 1.302-.756l1.395-2.441a3.5 3.5 0 0 0 .444-1.389l.271-2.715a2 2 0 0 0-1.99-2.199h-.581a5.114 5.114 0 0 0-.195-.248c-.191-.229-.51-.568-.88-.716-.364-.146-.846-.132-1.158-.108l-.132.012a1.26 1.26 0 0 0-.56-.642 2.632 2.632 0 0 0-.738-.288c-.31-.062-.739-.058-1.05-.046l-.048.002zm2.094 2.025z"/>
</svg>
</div>
<div data-tool="cursor">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="currentColor" class="bi bi-cursor" viewBox="0 0 16 16">
<path d="M14.082 2.182a.5.5 0 0 1 .103.557L8.528 15.467a.5.5 0 0 1-.917-.007L5.57 10.694.803 8.652a.5.5 0 0 1-.006-.916l12.728-5.657a.5.5 0 0 1 .556.103zM2.25 8.184l3.897 1.67a.5.5 0 0 1 .262.263l1.67 3.897L12.743 3.52 2.25 8.184z"/>
</svg>
</div>
</div>
</div>
<script src="main.js" type="text/javascript"></script>
</body>
</html>
html, body {
padding: 0;
margin: 0;
width: 100%;
height: 100vh;
}
.container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.tools {
position: absolute;
bottom: 30px;
border-radius: 8px;
left: 50%;
transform: translateX(-50%);
box-shadow: 0 3px 12px 0 rgba(199, 199, 199, 0.61);
display: flex;
justify-content: space-between;
padding: 8px;
background: #ffffff;
}
.tools [data-tool]:nth-child(1) {
margin-right: 8px;
}
.tools [data-tool] svg {
pointer-events: none;
}
.tools [data-tool] {
border-radius: 8px;
height: 80px;
width: 80px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
transition: all ease-in-out .2s;
}
.tools [data-tool].active {
background: #8b08f2;
}
.tools [data-tool].active svg {
fill: #fff;
}
.container canvas {
position: absolute;
left: 0;
top: 0;
}
const state = {
keys: [],
mouse: {
down: false,
before: {
x: 0,
y: 0
},
now: {
x: 0,
y: 0
}
},
size: {
height: 0, // canvas Height
width: 0, // Canvas Width
_height: 0, // Real Height
_width: 0, // Real Width
top: 0,
left: 0,
track: { // track how much have we moved in any direction
top: 0,
left: 0
}
},
tool: 'hand', // hand is the tool that can grab and move around the canvas unlike cursor
ctx: null, // Used for the grid
/*
* used to draw selection, selection boxes, ghost elements, anything that needs a high refresh rate
* so we can render it without re-rendering every other element
* */
prototyping: null
}
/*
* Init The State
* * * * * * * * * * */
const containerRect = document.querySelector('.container').getBoundingClientRect();
state.size.height = containerRect.height
state.size.width = containerRect.width
// this should be the real width and height, so replace it with your drawing real height and width
// or leave it as it is for new drawings
state.size._height = containerRect.height
state.size._width = containerRect.width
/*
* Draw Grid
* * * * * * * * * * */
const drawGrid = () => {
const space = 150;
// top is the position where the first line should be drawn (it's this part that gives the scrolling illusion)
// left is the position where the first line should be drawn
const top = (- state.size.track.top % space);
const left = (- state.size.track.left % space); // - space is used only to reverse the direction of the lines
// clear the canvas
state.ctx.clearRect(0, 0, state.size.width, state.size.height);
// draw the 1px grid lines on the x axis
for (let i = top; i < state.size.height; i += space) {
state.ctx.beginPath();
state.ctx.moveTo(0, i);
state.ctx.lineTo(state.size.width, i);
state.ctx.strokeStyle = '#ccc';
state.ctx.stroke();
}
// draw the 1px grid lines on the y axis
for (let i = left; i < state.size.width; i += space) {
state.ctx.beginPath();
state.ctx.moveTo(i, 0);
state.ctx.lineTo(i, state.size.height);
state.ctx.strokeStyle = '#ccc';
state.ctx.stroke();
}
// draw Both X and Y Scrollbars to show the scrolling animation and percentage
const yHeight = (state.size.height / state.size._height) * state.size.height
const xWidth = (state.size.width / state.size._width) * state.size.width
const yTop = (state.size.top / state.size._height) * state.size.height
const xLeft = (state.size.left / state.size._width) * state.size.width
const sSize = 10; // scrollbar size
state.ctx.fillStyle = 'rgba(79,79,79,0.42)'
state.ctx.beginPath();
state.ctx.moveTo(state.size.width - sSize, yTop);
state.ctx.arcTo(state.size.width, yTop, state.size.width, yTop + sSize, sSize / 2);
state.ctx.arcTo(state.size.width, yTop + yHeight, state.size.width - sSize, yTop + yHeight, sSize / 2);
state.ctx.arcTo(state.size.width - sSize, yTop + yHeight, state.size.width - sSize, yTop + yHeight - sSize, sSize / 2);
state.ctx.arcTo(state.size.width - sSize, yTop, state.size.width, yTop, sSize / 2);
state.ctx.fill();
state.ctx.beginPath();
state.ctx.moveTo(xLeft, state.size.height - sSize);
state.ctx.arcTo(xLeft, state.size.height, xLeft + sSize, state.size.height, sSize / 2);
state.ctx.arcTo(xLeft + xWidth, state.size.height, xLeft + xWidth, state.size.height - sSize, sSize / 2);
state.ctx.arcTo(xLeft + xWidth, state.size.height - sSize, xLeft + xWidth - sSize, state.size.height - sSize, sSize / 2);
state.ctx.arcTo(xLeft, state.size.height - sSize, xLeft, state.size.height, sSize / 2);
state.ctx.fill();
}
/*
* Mouse Scrolling Handler
* * * * * * * * * * */
const handelMouseScroll = () => {
// if the mouse is down and the tool is hand
if (state.mouse.down && state.tool === 'hand') {
const xDistance = state.mouse.now.x - state.mouse.before.x;
const yDistance = state.mouse.now.y - state.mouse.before.y;
// handel Y Scrolling
if (yDistance >= 0) { // Up
if (state.size.top === 0){ // When reaching top
state.size._height += yDistance
} else { // When There is space to scroll
state.size.top = (state.size.top - yDistance) >= 0 ? (state.size.top - yDistance) : 0
}
state.size.track.top -= yDistance
} else { // DOWN
if (state.size.top + state.size.height === state.size._height){ // when reaching bottom
state.size._height -= yDistance
state.size.top -= yDistance
} else { // When there is space to scroll
state.size.top = (state.size.top - yDistance) <= state.size._height - state.size.height ? (state.size.top - yDistance) : state.size._height - state.size.height
}
state.size.track.top -= yDistance
}
// handel X Scrolling
if (xDistance >= 0) { // Right
if (state.size.left === 0){ // When reaching right
state.size._width += xDistance
} else { // When There is space to scroll
state.size.left = (state.size.left - xDistance) >= 0 ? (state.size.left - xDistance) : 0
}
state.size.track.left -= xDistance
} else { // Left
if (state.size.left + state.size.width === state.size._width) { // when reaching left
state.size._width -= xDistance
state.size.left -= xDistance
} else { // When there is space to scroll
state.size.left = (state.size.left - xDistance) <= state.size._width - state.size.width ? (state.size.left - xDistance) : state.size._width - state.size.width
}
state.size.track.left -= xDistance
}
// update the mouse before position
state.mouse.before.x = state.mouse.now.x
state.mouse.before.y = state.mouse.now.y
// draw the grid
drawGrid()
}
}
/*
* Cursor Selection... handler
* * * * * * * * * * */
const handelCursor = () => {
// compare the mouse.before and mouse.now and draw the selection box
if (state.mouse.down && state.tool === 'cursor') {
const xDistance = state.mouse.now.x - state.mouse.before.x;
const yDistance = state.mouse.now.y - state.mouse.before.y;
state.prototyping.clearRect(0, 0, state.size.width, state.size.height);
state.prototyping.fillStyle = 'rgba(0,123,255,0.25)'
state.prototyping.fillRect(state.mouse.before.x, state.mouse.before.y, xDistance, yDistance);
state.prototyping.strokeStyle = 'rgba(0,123,255,0.5)'
state.prototyping.strokeRect(state.mouse.before.x, state.mouse.before.y, xDistance, yDistance);
drawGrid()
} else {
// In case The Mouseup event fires we will call handelCursor but the mouse.down will be false so it will just clear the prototyping canvas
// it's better if you create a loop to keep the prototyping in sync but in my simple case it's not needed
state.prototyping.clearRect(0, 0, state.size.width, state.size.height);
}
}
/*
* Wheel Scrolling Handler
* * * * * * * * * * */
const handelWheelScroll = (e) => {
if (!state.keys.includes('shift')){
// Y Scrolling
if (e.deltaY > 0){
state.size.track.top += 10
if (state.size.top + state.size.height === state.size._height){
state.size._height += 10
state.size.top += 10
} else {
state.size.top = state.size.top + 10 <= state.size._height - state.size.height ? state.size.top + 10 : state.size._height - state.size.height
}
} else if (e.deltaY < 0) {
state.size.track.top -= 10
if (state.size.top > 0){
state.size.top = state.size.top - 10 >= 0 ? state.size.top - 10 : 0
} else {
state.size._height += 10
}
}
} else {
// X Scrolling
if (e.deltaY > 0){
state.size.track.left += 10
if (state.size.left + state.size.width === state.size._width){
state.size._width += 10
state.size.left += 10
} else {
state.size.left = state.size.left + 10 <= state.size._width - state.size.width ? state.size.left + 10 : state.size._width - state.size.width
}
} else if (e.deltaY < 0) {
state.size.track.left -= 10
if (state.size.left > 0){
state.size.left = state.size.left - 10 >= 0 ? state.size.left - 10 : 0
} else {
state.size._width += 10
}
}
}
// draw the grid
drawGrid();
}
/*
* Init The Canvas and Draw the first grid
* * * * * * * * * * */
const init = () => {
const cvs = document.getElementById('canvas-space');
cvs.setAttribute('width', state.size.width);
cvs.setAttribute('height', state.size.height);
state.ctx = cvs.getContext('2d');
drawGrid();
const prototyping = document.getElementById('prototyping-space');
prototyping.setAttribute('width', state.size.width);
prototyping.setAttribute('height', state.size.height);
state.prototyping = prototyping.getContext('2d');
}
/*
* Init All Events
* * * * * * * * * * */
window.addEventListener('mousemove', (e) => {
if (state.mouse.down && state.tool === 'hand') {
state.mouse.now.x = e.clientX
state.mouse.now.y = e.clientY
handelMouseScroll()
} else if (state.mouse.down && state.tool === 'cursor') {
state.mouse.now.x = e.clientX
state.mouse.now.y = e.clientY
handelCursor()
}
});
window.addEventListener('wheel', (e) => {
handelWheelScroll(e)
})
window.addEventListener('mousedown', (e) => {
state.mouse.down = true
state.mouse.before.x = e.clientX
state.mouse.before.y = e.clientY
state.mouse.now.x = e.clientX
state.mouse.now.y = e.clientY
});
window.addEventListener('mouseup', (e) => {
state.mouse.down = false
if (state.tool === 'cursor') {
handelCursor()
}
});
window.addEventListener('keydown', (e) => {
if (!state.keys.includes(e.key.toLowerCase())) state.keys.push(e.key.toLowerCase())
});
window.addEventListener('keyup', (e) => {
if (state.keys.includes(e.key.toLowerCase())) state.keys = state.keys.filter(k => k !== e.key.toLowerCase())
});
// Tools Changing Events
document.querySelectorAll('.tools [data-tool]').forEach(e => {
e.addEventListener('click', (e) => {
state.tool = e.target.getAttribute('data-tool');
document.querySelector('.tools [data-tool].active').classList.remove('active')
e.target.classList.add('active')
})
});
/*
* Init The App And Enjoy (happy coding 😉)
* * * * * * * * * * * * */
init();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment