|
import { Canvas, Rect, util } from "https://esm.sh/[email protected]"; |
|
|
|
|
|
(function () { |
|
// Configuration for number of elements |
|
const elementSize = 20 |
|
const gap = elementSize / 3 |
|
const elementColumns = 15 |
|
const elementRows = 10 |
|
const blockColumns = 2 |
|
const blockRows = 3 |
|
const elemDim = elementSize + gap |
|
const blockGap = gap * 2 |
|
|
|
const wrapper = document.querySelector('.canvas-wrapper') |
|
const rect = new Rect({ |
|
width: elementSize, |
|
height: elementSize, |
|
fill: '#0ebeff', |
|
selectable: false, |
|
hasBorders: false, |
|
hasControls: false, |
|
hasRotatingPoint: false, |
|
evented: false, |
|
objectCaching: false, // important for mobile performance |
|
noScaleCache: true, // important for mobile performance |
|
}) |
|
|
|
let lastPosX = 0, |
|
lastPosY = 0, |
|
touchZoom = 1, |
|
initialDistance = 0, |
|
pinchCenter |
|
|
|
// after scaling we transform the CSS scale to canvas zoom so it does not stay blurry |
|
const debouncedScale2Zoom = _.debounce(canvasScaleToZoom, 1000) |
|
// limit visual updates to happen 60 times per second, which is enough |
|
const throttledTranslateCanvas = _.throttle(translateCanvas, 16) // 16.66 ms ~ 60 frames per second |
|
const throttledScaleCanvas = _.throttle(scaleCanvas, 16) |
|
|
|
document.getElementById('resetCanvas').addEventListener('click', resetCanvas) |
|
|
|
/** |
|
* Creates Blocks of rectangles so we have a lot of elements to stress test the performance |
|
*/ |
|
function createElements() { |
|
// create block rows |
|
for (var l = 0; l < blockRows; l++) |
|
{ |
|
const topBlock = blockGap + ( l * ((elemDim * elementRows) + blockGap)) |
|
// create block columns |
|
for (var k = 0; k < blockColumns; k++) |
|
{ |
|
const left = blockGap + (k * ((elemDim * elementColumns) + blockGap)) |
|
|
|
// create element rows |
|
for (var j = 0; j < elementRows; j++) |
|
{ |
|
const top = j * (elementSize + gap) |
|
// create element columns |
|
for (var i = 0; i < elementColumns; i++) |
|
{ |
|
const newRect = _.clone(rect) |
|
newRect.left = left + (i * elemDim) |
|
newRect.top = top + topBlock |
|
canvas.add(newRect); |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Get relevant style values for the given element |
|
* @see https://stackoverflow.com/a/64654744/13221239 |
|
*/ |
|
function getTransformVals(element) { |
|
const style = window.getComputedStyle(element) |
|
const matrix = new DOMMatrixReadOnly(style.transform) |
|
return { |
|
scaleX: matrix.m11, |
|
scaleY: matrix.m22, |
|
translateX: matrix.m41, |
|
translateY: matrix.m42, |
|
width: element.getBoundingClientRect().width, |
|
height: element.getBoundingClientRect().height, |
|
} |
|
} |
|
|
|
/** |
|
* Calculates and caps the container offset relative to the wrapper |
|
*/ |
|
function capCanvasOffset(offset, containerDimension, wrapperDimension) { |
|
const maxPositiveOffset = wrapperDimension * .50 |
|
|
|
offset = Math.max(offset, (containerDimension - maxPositiveOffset) * -1) |
|
offset = Math.min(offset, maxPositiveOffset) |
|
|
|
return offset |
|
} |
|
|
|
/** |
|
* Putting touch point coordinates into an object |
|
*/ |
|
function getPinchCoordinates(touch1, touch2) { |
|
return { |
|
x1: touch1.clientX, |
|
y1: touch1.clientY, |
|
x2: touch2.clientX, |
|
y2: touch2.clientY, |
|
} |
|
} |
|
|
|
/** |
|
* Returns the distance between two touch points |
|
*/ |
|
function getPinchDistance(touch1, touch2) { |
|
const coord = getPinchCoordinates(touch1, touch2) |
|
return Math.sqrt(Math.pow(coord.x2 - coord.x1, 2) + Math.pow(coord.y2 - coord.y1, 2)) |
|
} |
|
|
|
/** |
|
* Pinch center around wich the canvas will be scaled/zoomed |
|
* takes into account the translation of the container element |
|
*/ |
|
function setPinchCenter(touch1, touch2) { |
|
const coord = getPinchCoordinates(touch1, touch2) |
|
|
|
const currentX = (coord.x1 + coord.x2) / 2 |
|
const currentY = (coord.y1 + coord.y2) / 2 |
|
|
|
const transform = getTransformVals(canvas.wrapperEl) |
|
|
|
pinchCenter = { |
|
x: currentX - transform.translateX, |
|
y: currentY - transform.translateY, |
|
} |
|
} |
|
|
|
/** |
|
* Reset all applied styles and changes to position and zoom |
|
*/ |
|
function resetCanvas() { |
|
canvas.wrapperEl.style.transform = `` |
|
canvas.wrapperEl.style.setProperty('--tOriginX', '0px') |
|
canvas.wrapperEl.style.setProperty('--tOriginY', '0px') |
|
|
|
canvas.viewportTransform = [1, 0, 0, 1, 0, 0] |
|
canvas.setHeight((elemDim * elementRows * blockRows) + (blockGap * (blockRows+1))) |
|
canvas.setWidth((elemDim * elementColumns * blockColumns) + (blockGap * (blockColumns+1))) |
|
canvas.setViewportTransform(canvas.viewportTransform) |
|
touchZoom = 1 |
|
} |
|
|
|
/** |
|
* Creat FabriJS Canvas and register relevant event handler |
|
*/ |
|
function createCanvas(width, height, zoom) { |
|
const canvas = new Canvas("canvas", { |
|
allowTouchScrolling: false, |
|
width: width, |
|
height: height, |
|
zoom: zoom, |
|
defaultCursor: 'grab', |
|
selection: false, |
|
renderOnAddRemove: false, |
|
skipTargetFind: true, |
|
}) |
|
|
|
canvas.on("mouse:down", dragCanvasStart) |
|
canvas.on("mouse:move", dragCanvas) |
|
canvas.on("mouse:up", () => {canvas.defaultCursor = 'grab'}) |
|
canvas.on('mouse:wheel', zoomCanvasMouseWheel) |
|
|
|
return canvas |
|
} |
|
|
|
/** |
|
* Register touch events since FabricJS does not (yet) offer touch events |
|
* (only with a wildly outdated custom build) |
|
*/ |
|
function registerTouchEventHandler() { |
|
wrapper.addEventListener('touchstart', (event) => { |
|
dragCanvasStart(event.targetTouches[0]) |
|
pinchCanvasStart(event) |
|
}) |
|
|
|
wrapper.addEventListener('touchmove', (event) => { |
|
pinchCanvas(event) |
|
dragCanvas(event.targetTouches[0]) |
|
}) |
|
|
|
wrapper.addEventListener('touchend', pinchCanvasEnd) |
|
} |
|
|
|
/** |
|
* Save reference point from which the interaction started |
|
*/ |
|
function dragCanvasStart(event) { |
|
const evt = event.e || event // retrieving original event from fabricJS event |
|
|
|
lastPosX = evt.clientX |
|
lastPosY = evt.clientY |
|
canvas.defaultCursor = 'grabbing' |
|
} |
|
|
|
/** |
|
* Start Dragging the Canvas using Fabric JS Events |
|
*/ |
|
function dragCanvas(event) { |
|
|
|
const evt = event.e || event // retrieving original event from fabricJS event |
|
|
|
if (1 !== evt.buttons && !(evt instanceof Touch)) { |
|
return |
|
} |
|
|
|
throttledTranslateCanvas(evt) |
|
} |
|
|
|
/** |
|
* Convert the movement to CSS translate which visually moves the canvas as if being dragged |
|
*/ |
|
function translateCanvas(event) { |
|
const transform = getTransformVals(canvas.wrapperEl) |
|
|
|
let offsetX = transform.translateX + (event.clientX - (lastPosX || 0)) |
|
let offsetY = transform.translateY + (event.clientY - (lastPosY || 0)) |
|
|
|
const viewBox = wrapper.getBoundingClientRect() |
|
const offsetXCapped = capCanvasOffset(offsetX, transform.width, viewBox.width) |
|
const offsetYCapped = capCanvasOffset(offsetY, transform.height, viewBox.height) |
|
|
|
canvas.wrapperEl.style.transform = `translate(${offsetXCapped}px, ${offsetYCapped}px) scale(${transform.scaleX})` |
|
|
|
lastPosX = event.clientX |
|
lastPosY = event.clientY |
|
console.log('translate end') |
|
} |
|
|
|
/** |
|
* Save the distance between the touch points when starting the pinch |
|
*/ |
|
function pinchCanvasStart(event) { |
|
if (event.touches.length !== 2) { |
|
return |
|
} |
|
|
|
initialDistance = getPinchDistance(event.touches[0], event.touches[1]) |
|
} |
|
|
|
/** |
|
* Start pinch-zooming the canvas |
|
*/ |
|
function pinchCanvas(event) { |
|
if (event.touches.length !== 2) { |
|
return |
|
} |
|
|
|
setPinchCenter(event.touches[0], event.touches[1]) |
|
|
|
const currentDistance = getPinchDistance(event.touches[0], event.touches[1]) |
|
let scale = (currentDistance / initialDistance).toFixed(2) |
|
scale = 1 + (scale - 1) / 20 // slows down scale from pinch |
|
|
|
|
|
throttledScaleCanvas(scale * touchZoom, pinchCenter) |
|
} |
|
|
|
/** |
|
* Re-Draw the canvas after pinching ended |
|
*/ |
|
function pinchCanvasEnd(event) { |
|
if (2 > event.touches.length) { |
|
console.log('pinch end') |
|
debouncedScale2Zoom() |
|
} |
|
} |
|
|
|
/** |
|
* Convert zoom to CSS scale which visually zooms the canvas by scaling it up in place |
|
*/ |
|
function scaleCanvas(zoom, aroundPoint) { |
|
const tVals = getTransformVals(canvas.wrapperEl) |
|
|
|
zoom = Math.min(zoom, 2) |
|
zoom = Math.max(zoom, .7) |
|
|
|
if (zoom === touchZoom) { |
|
return -1 |
|
} |
|
|
|
const scaleFactor = tVals.scaleX / touchZoom * zoom |
|
|
|
// see CSS: transform-origin: var(--tOriginX) var(--tOriginY); |
|
canvas.wrapperEl.style.setProperty('--tOriginX', `${aroundPoint.x}px`) |
|
canvas.wrapperEl.style.setProperty('--tOriginY', `${aroundPoint.y}px`) |
|
|
|
canvas.wrapperEl.style.transform = `translate(${tVals.translateX}px, ${tVals.translateY}px) scale(${scaleFactor})` |
|
|
|
touchZoom = zoom |
|
} |
|
|
|
/** |
|
* Converts CSS transform scale to Fabric.js zoom so the blurry image gets sharp again |
|
*/ |
|
function canvasScaleToZoom() { |
|
if (canvas.getZoom() === touchZoom) { |
|
return |
|
} |
|
|
|
const transform = getTransformVals(canvas.wrapperEl) |
|
const canvasBox = canvas.wrapperEl.getBoundingClientRect() |
|
const viewBox = wrapper.getBoundingClientRect() |
|
|
|
// calculate the offset of the canvas inside the wrapper |
|
const offsetX = canvasBox.x - viewBox.x |
|
const offsetY = canvasBox.y - viewBox.y |
|
|
|
// we resize the canvas to the scaled values |
|
canvas.setDimensions({height:transform.height, width:transform.width}) |
|
canvas.setZoom(touchZoom) |
|
|
|
// and reset the transform values |
|
canvas.wrapperEl.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(1)` |
|
canvas.wrapperEl.style.setProperty('--tOriginX', '0px') |
|
canvas.wrapperEl.style.setProperty('--tOriginY', '0px') |
|
} |
|
|
|
/** |
|
* Zoom canvas when user used mouse wheel |
|
*/ |
|
function zoomCanvasMouseWheel(event) { |
|
const delta = event.e.deltaY |
|
let zoom = touchZoom |
|
|
|
zoom *= 0.999 ** delta |
|
const point = {x: event.e.offsetX, y: event.e.offsetY} |
|
|
|
throttledScaleCanvas(zoom, point) |
|
debouncedScale2Zoom() |
|
} |
|
|
|
|
|
|
|
// Prep the show |
|
const width = (elemDim * elementColumns * blockColumns) + (blockGap * (blockColumns+1)) |
|
const height = (elemDim * elementRows * blockRows) + (blockGap * (blockRows+1)) |
|
const zoom = 1 |
|
|
|
|
|
|
|
var canvas = createCanvas(width, height, zoom) |
|
|
|
createElements() |
|
registerTouchEventHandler() |
|
canvas.requestRenderAll() |
|
|
|
// show greeting |
|
const infoModal = new bootstrap.Modal('#infoModal') |
|
infoModal.show() |
|
})() |