fin-hypergrid
Version:
Canvas-based high-performance grid
831 lines (714 loc) • 23.6 kB
JavaScript
/* eslint-env browser */
'use strict';
if (typeof window.CustomEvent !== 'function') {
window.CustomEvent = function(event, params) {
params = params || { bubbles: false, cancelable: false, detail: undefined };
var evt = document.createEvent('CustomEvent');
evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail);
return evt;
};
window.CustomEvent.prototype = window.Event.prototype;
}
var rectangular = require('rectangular');
var RESIZE_POLLING_INTERVAL = 200,
paintables = [],
resizables = [],
paintRequest,
resizeInterval,
charMap = makeCharMap();
function Canvas(div, component) {
var self = this;
// create the containing <div>...</div>
this.div = div;
this.component = component;
this.dragEndtime = Date.now();
// create and append the info <div>...</div> (to be displayed when there are no data rows)
this.infoDiv = document.createElement('div');
this.infoDiv.className = 'info';
this.div.appendChild(this.infoDiv);
// create and append the canvas
this.gc = getCachedContext(this.canvas = document.createElement('canvas'));
this.bc = getCachedContext(this.buffer = document.createElement('canvas'));
this.div.appendChild(this.canvas);
this.canvas.style.outline = 'none';
this.mouseLocation = new rectangular.Point(-1, -1);
this.dragstart = new rectangular.Point(-1, -1);
//this.origin = new rectangular.Point(0, 0);
this.bounds = new rectangular.Rectangle(0, 0, 0, 0);
this.hasMouse = false;
document.addEventListener('mousemove', function(e) {
if (self.hasMouse || self.isDragging()) {
self.finmousemove(e);
}
});
document.addEventListener('mouseup', function(e) {
self.finmouseup(e);
});
document.addEventListener('wheel', function(e) {
self.finwheelmoved(e);
});
document.addEventListener('keydown', function(e) {
self.finkeydown(e);
});
document.addEventListener('keyup', function(e) {
self.finkeyup(e);
});
this.canvas.onmouseover = function() {
self.hasMouse = true;
};
this.addEventListener('focus', function(e) {
self.finfocusgained(e);
});
this.addEventListener('blur', function(e) {
self.finfocuslost(e);
});
this.addEventListener('mousedown', function(e) {
self.finmousedown(e);
});
this.addEventListener('mouseout', function(e) {
self.hasMouse = false;
self.finmouseout(e);
});
this.addEventListener('click', function(e) {
self.finclick(e);
});
this.addEventListener('dblclick', function(e) {
self.findblclick(e);
});
this.addEventListener('contextmenu', function(e) {
self.fincontextmenu(e);
e.preventDefault();
return false;
});
this.canvas.setAttribute('tabindex', 0);
this.resize();
this.beginResizing();
this.beginPainting();
}
Canvas.prototype = {
constructor: Canvas.prototype.constructor,
div: null,
component: null,
canvas: null,
focuser: null,
buffer: null,
ctx: null,
mouseLocation: null,
dragstart: null,
origin: null,
bounds: null,
dirty: false,
size: null,
mousedown: false,
dragging: false,
repeatKeyCount: 0,
repeatKey: null,
repeatKeyStartTime: 0,
currentKeys: [],
hasMouse: false,
dragEndTime: 0,
lastRepaintTime: 0,
currentPaintCount: 0,
currentFPS: 0,
lastFPSComputeTime: 0,
addEventListener: function(name, callback) {
this.canvas.addEventListener(name, callback);
},
removeEventListener: function(name, callback) {
this.canvas.removeEventListener(name, callback);
},
stopPaintLoop: stopPaintLoop,
restartPaintLoop: restartPaintLoop,
stopResizeLoop: stopResizeLoop,
restartResizeLoop: restartResizeLoop,
detached: function() {
this.stopPainting();
this.stopResizing();
},
getCurrentFPS:function() {
return this.currentFPS;
},
tickPaint: function(now) {
var isContinuousRepaint = this.component.properties.enableContinuousRepaint,
fps = this.component.properties.repaintIntervalRate;
if (fps === 0) {
return;
}
var interval = 1000 / fps;
var elapsed = now - this.lastRepaintTime;
if (elapsed > interval && (isContinuousRepaint || this.dirty)) {
this.paintNow();
this.lastRepaintTime = now;
/* - (elapsed % interval);*/
if (isContinuousRepaint) {
this.currentPaintCount++;
if (now - this.lastFPSComputeTime >= 1000) {
this.currentFPS = (this.currentPaintCount * 1000) / (now - this.lastFPSComputeTime);
this.currentPaintCount = 0;
this.lastFPSComputeTime = now;
}
}
}
},
beginPainting: function() {
var self = this;
this.dirty = true;
this.tickPainter = function(now) {
self.tickPaint(now);
};
paintables.push(this);
},
stopPainting: function() {
paintables.splice(paintables.indexOf(this), 1);
},
beginResizing: function() {
var self = this;
this.tickResizer = function() {
self.checksize();
};
resizables.push(this);
},
stopResizing: function() {
resizables.splice(resizables.indexOf(this), 1);
},
start: function() {
this.beginPainting();
this.beginResizing();
},
stop: function() {
this.stopPainting();
this.stopResizing();
},
getDivBoundingClientRect: function() {
// Make sure our canvas has integral dimensions
var rect = this.div.getBoundingClientRect();
var top = Math.floor(rect.top),
left = Math.floor(rect.left),
width = Math.ceil(rect.width),
height = Math.ceil(rect.height);
return {
top: top,
right: left + width,
bottom: top + height,
left: left,
width: width,
height: height,
x: rect.x,
y: rect.y
};
},
checksize: function() {
//this is expensive lets do it at some modulo
var sizeNow = this.getDivBoundingClientRect();
if (sizeNow.width !== this.size.width || sizeNow.height !== this.size.height) {
this.resize();
}
},
resize: function() {
var box = this.size = this.getDivBoundingClientRect();
this.width = box.width;
this.height = box.height;
//fix ala sir spinka, see
//http://www.html5rocks.com/en/tutorials/canvas/hidpi/
//just add 'hdpi' as an attribute to the fin-canvas tag
var ratio = 1;
var isHIDPI = window.devicePixelRatio && this.component.properties.useHiDPI;
if (isHIDPI) {
var devicePixelRatio = window.devicePixelRatio || 1;
var backingStoreRatio = this.gc.webkitBackingStorePixelRatio ||
this.gc.mozBackingStorePixelRatio ||
this.gc.msBackingStorePixelRatio ||
this.gc.oBackingStorePixelRatio ||
this.gc.backingStorePixelRatio || 1;
ratio = devicePixelRatio / backingStoreRatio;
//this.canvasCTX.scale(ratio, ratio);
}
this.buffer.width = this.canvas.width = this.width * ratio;
this.buffer.height = this.canvas.height = this.height * ratio;
this.canvas.style.width = this.buffer.style.width = this.width + 'px';
this.canvas.style.height = this.buffer.style.height = this.height + 'px';
this.bc.scale(ratio, ratio);
if (isHIDPI && !this.component.properties.useBitBlit) {
this.gc.scale(ratio, ratio);
}
this.bounds = new rectangular.Rectangle(0, 0, this.width, this.height);
this.component.setBounds(this.bounds);
this.resizeNotification();
this.paintNow();
},
resizeNotification: function() {
this.dispatchNewEvent(undefined, 'fin-canvas-resized', {
width: this.width,
height: this.height
});
},
getBounds: function() {
return this.bounds;
},
paintNow: function() {
var useBitBlit = this.component.properties.useBitBlit,
gc = useBitBlit ? this.bc : this.gc;
try {
gc.cache.save();
this.dirty = false;
this.component.paint(gc);
} catch (e) {
console.error(e);
} finally {
gc.cache.restore();
}
if (useBitBlit) {
this.flushBuffer();
}
},
flushBuffer: function() {
if (this.buffer.width > 0 && this.buffer.height > 0) {
this.gc.drawImage(this.buffer, 0, 0);
}
},
newEvent: function(primitiveEvent, name, detail) {
var event = {
detail: detail || {}
};
if (primitiveEvent) {
event.detail.primitiveEvent = primitiveEvent;
}
return new CustomEvent(name, event);
},
dispatchNewEvent: function(primitiveEvent, name, detail) {
return this.canvas.dispatchEvent(this.newEvent(primitiveEvent, name, detail));
},
dispatchNewMouseKeysEvent: function(event, name, detail) {
detail = detail || {};
detail.mouse = this.mouseLocation;
detail.keys = this.currentKeys;
return this.dispatchNewEvent(event, name, detail);
},
finmousemove: function(e) {
if (!this.isDragging() && this.mousedown) {
this.beDragging();
this.dispatchNewMouseKeysEvent(e, 'fin-canvas-dragstart', {
isRightClick: this.isRightClick(e),
dragstart: this.dragstart
});
this.dragstart = new rectangular.Point(this.mouseLocation.x, this.mouseLocation.y);
}
this.mouseLocation = this.getLocal(e);
//console.log(this.mouseLocation);
if (this.isDragging()) {
this.dispatchNewMouseKeysEvent(e, 'fin-canvas-drag', {
dragstart: this.dragstart,
isRightClick: this.isRightClick(e)
});
}
if (this.bounds.contains(this.mouseLocation)) {
this.dispatchNewMouseKeysEvent(e, 'fin-canvas-mousemove');
}
},
finmousedown: function(e) {
this.mouseLocation = this.mouseDownLocation = this.getLocal(e);
this.mousedown = true;
this.dispatchNewMouseKeysEvent(e, 'fin-canvas-mousedown', {
isRightClick: this.isRightClick(e)
});
this.takeFocus();
},
finmouseup: function(e) {
if (!this.mousedown) {
// ignore document:mouseup unless preceded by a canvas:mousedown
return;
}
if (this.isDragging()) {
this.dispatchNewMouseKeysEvent(e, 'fin-canvas-dragend', {
dragstart: this.dragstart,
isRightClick: this.isRightClick(e)
});
this.beNotDragging();
this.dragEndtime = Date.now();
}
this.mousedown = false;
this.dispatchNewMouseKeysEvent(e, 'fin-canvas-mouseup', {
dragstart: this.dragstart,
isRightClick: this.isRightClick(e)
});
//this.mouseLocation = new rectangular.Point(-1, -1);
},
finmouseout: function(e) {
if (!this.mousedown) {
this.mouseLocation = new rectangular.Point(-1, -1);
}
this.repaint();
this.dispatchNewMouseKeysEvent(e, 'fin-canvas-mouseout', {
dragstart: this.dragstart
});
},
finwheelmoved: function(e) {
if (this.isDragging() || !this.hasFocus()) {
return;
}
e.preventDefault();
this.dispatchNewMouseKeysEvent(e, 'fin-canvas-wheelmoved', {
isRightClick: this.isRightClick(e)
});
},
finclick: function(e) {
this.mouseLocation = this.getLocal(e);
this.dispatchNewMouseKeysEvent(e, 'fin-canvas-click', {
isRightClick: this.isRightClick(e)
});
},
findblclick: function(e) {
this.mouseLocation = this.getLocal(e);
this.dispatchNewMouseKeysEvent(e, 'fin-canvas-dblclick', {
isRightClick: this.isRightClick(e)
});
},
getCharMap: function() {
return charMap;
},
getKeyChar: function(e) {
var key = e.keyCode || e.detail.key,
shift = e.shiftKey || e.detail.shift;
return charMap[key][shift ? 1 : 0];
},
finkeydown: function(e) {
if (!this.hasFocus()) {
return;
}
// prevent TAB from moving focus off the canvas element
if (e.keyCode === 9) {
e.preventDefault();
}
var keyChar = this.getKeyChar(e);
if (e.repeat) {
if (this.repeatKey === keyChar) {
this.repeatKeyCount++;
} else {
this.repeatKey = keyChar;
this.repeatKeyStartTime = Date.now();
}
} else {
this.repeatKey = null;
this.repeatKeyCount = 0;
this.repeatKeyStartTime = 0;
}
if (this.currentKeys.indexOf(keyChar) === -1) {
this.currentKeys.push(keyChar);
}
this.dispatchNewEvent(e, 'fin-canvas-keydown', {
alt: e.altKey,
ctrl: e.ctrlKey,
char: keyChar,
code: e.charCode,
key: e.keyCode,
meta: e.metaKey,
repeatCount: this.repeatKeyCount,
repeatStartTime: this.repeatKeyStartTime,
shift: e.shiftKey,
identifier: e.key,
currentKeys: this.currentKeys.slice(0)
});
},
finkeyup: function(e) {
if (!this.hasFocus()) {
return;
}
// prevent TAB from moving focus off the canvas element
if (e.keyCode === 9) {
e.preventDefault();
}
var keyChar = this.getKeyChar(e);
this.currentKeys.splice(this.currentKeys.indexOf(keyChar), 1);
this.repeatKeyCount = 0;
this.repeatKey = null;
this.repeatKeyStartTime = 0;
this.dispatchNewEvent(e, 'fin-canvas-keyup', {
alt: e.altKey,
ctrl: e.ctrlKey,
char: keyChar,
code: e.charCode,
key: e.keyCode,
meta: e.metaKey,
repeat: e.repeat,
shift: e.shiftKey,
identifier: e.key,
currentKeys: this.currentKeys.slice(0)
});
},
finfocusgained: function(e) {
this.dispatchNewEvent(e, 'fin-canvas-focus-gained');
},
finfocuslost: function(e) {
this.dispatchNewEvent(e, 'fin-canvas-focus-lost');
},
fincontextmenu: function(e) {
if (e.ctrlKey && this.currentKeys.indexOf('CTRL') === -1) {
this.currentKeys.push('CTRL');
}
this.dispatchNewMouseKeysEvent(e, 'fin-canvas-context-menu', {
isRightClick: this.isRightClick(e)
});
},
repaint: function() {
this.dirty = true;
if (!paintRequest || this.component.properties.repaintIntervalRate === 0) {
this.paintNow();
}
},
getMouseLocation: function() {
return this.mouseLocation;
},
getOrigin: function() {
var rect = this.canvas.getBoundingClientRect();
var p = new rectangular.Point(rect.left, rect.top);
return p;
},
getLocal: function(e) {
var rect = this.canvas.getBoundingClientRect();
var p = new rectangular.Point(e.clientX - rect.left, e.clientY - rect.top);
return p;
},
hasFocus: function() {
return document.activeElement === this.canvas;
},
takeFocus: function() {
var self = this;
if (!this.hasFocus()) {
setTimeout(function() {
self.canvas.focus();
}, 10);
}
},
beDragging: function() {
this.dragging = true;
this.disableDocumentElementSelection();
},
beNotDragging: function() {
this.dragging = false;
this.enableDocumentElementSelection();
},
isDragging: function() {
return this.dragging;
},
disableDocumentElementSelection: function() {
var style = document.body.style;
style.cssText = style.cssText + '-webkit-user-select: none';
},
enableDocumentElementSelection: function() {
var style = document.body.style;
style.cssText = style.cssText.replace('-webkit-user-select: none', '');
},
setFocusable: function(truthy) {
this.focuser.style.display = truthy ? '' : 'none';
},
isRightClick: function(e) {
var isRightMB;
e = e || window.event;
if ('which' in e) { // Gecko (Firefox), WebKit (Safari/Chrome) & Opera
isRightMB = e.which === 3;
} else if ('button' in e) { // IE, Opera
isRightMB = e.button === 2;
}
return isRightMB;
},
dispatchEvent: function(e) {
return this.canvas.dispatchEvent(e);
},
setInfo: function(message, width) {
if (message) {
if (width !== undefined) {
if (width && !isNaN(Number(width))) {
width += 'px';
}
this.infoDiv.style.width = width;
}
if (message.indexOf('<')) {
this.infoDiv.innerHTML = message;
} else {
this.infoDiv.innerText = message;
}
}
this.infoDiv.style.display = message ? 'block' : 'none';
}
};
function paintLoopFunction(now) {
if (paintRequest) {
paintables.forEach(function(paintable) {
try {
paintable.tickPainter(now);
} catch (e) {
console.error(e);
}
if (paintable.component.tickNotification) {
paintable.component.tickNotification();
}
});
paintRequest = requestAnimationFrame(paintLoopFunction);
}
}
function restartPaintLoop() {
paintRequest = paintRequest || requestAnimationFrame(paintLoopFunction);
}
function stopPaintLoop() {
if (paintRequest) {
cancelAnimationFrame(paintRequest);
paintRequest = undefined;
}
}
restartPaintLoop();
function resizablesLoopFunction(now) {
if (resizeInterval) {
for (var i = 0; i < resizables.length; i++) {
try {
resizables[i].tickResizer(now);
} catch (e) {
console.error(e);
}
}
}
}
function restartResizeLoop() {
resizeInterval = resizeInterval || setInterval(resizablesLoopFunction, RESIZE_POLLING_INTERVAL);
}
function stopResizeLoop() {
if (resizeInterval) {
clearInterval(resizeInterval);
resizeInterval = undefined;
}
}
restartResizeLoop();
function makeCharMap() {
var map = [];
var empty = ['', ''];
for (var i = 0; i < 256; i++) {
map[i] = empty;
}
map[27] = ['ESC', 'ESCSHIFT'];
map[192] = ['`', '~'];
map[49] = ['1', '!'];
map[50] = ['2', '@'];
map[51] = ['3', '#'];
map[52] = ['4', '$'];
map[53] = ['5', '%'];
map[54] = ['6', '^'];
map[55] = ['7', '&'];
map[56] = ['8', '*'];
map[57] = ['9', '('];
map[48] = ['0', ')'];
map[189] = ['-', '_'];
map[187] = ['=', '+'];
map[8] = ['BACKSPACE', 'BACKSPACESHIFT'];
map[46] = ['DELETE', 'DELETESHIFT'];
map[9] = ['TAB', 'TABSHIFT'];
map[81] = ['q', 'Q'];
map[87] = ['w', 'W'];
map[69] = ['e', 'E'];
map[82] = ['r', 'R'];
map[84] = ['t', 'T'];
map[89] = ['y', 'Y'];
map[85] = ['u', 'U'];
map[73] = ['i', 'I'];
map[79] = ['o', 'O'];
map[80] = ['p', 'P'];
map[219] = ['[', '{'];
map[221] = [']', '}'];
map[220] = ['\\', '|'];
map[220] = ['CAPSLOCK', 'CAPSLOCKSHIFT'];
map[65] = ['a', 'A'];
map[83] = ['s', 'S'];
map[68] = ['d', 'D'];
map[70] = ['f', 'F'];
map[71] = ['g', 'G'];
map[72] = ['h', 'H'];
map[74] = ['j', 'J'];
map[75] = ['k', 'K'];
map[76] = ['l', 'L'];
map[186] = [';', ':'];
map[222] = ['\'', '|'];
map[13] = ['RETURN', 'RETURNSHIFT'];
map[16] = ['SHIFT', 'SHIFT'];
map[90] = ['z', 'Z'];
map[88] = ['x', 'X'];
map[67] = ['c', 'C'];
map[86] = ['v', 'V'];
map[66] = ['b', 'B'];
map[78] = ['n', 'N'];
map[77] = ['m', 'M'];
map[188] = [',', '<'];
map[190] = ['.', '>'];
map[191] = ['/', '?'];
map[16] = ['SHIFT', 'SHIFT'];
map[17] = ['CTRL', 'CTRLSHIFT'];
map[18] = ['ALT', 'ALTSHIFT'];
map[91] = ['COMMANDLEFT', 'COMMANDLEFTSHIFT'];
map[32] = ['SPACE', 'SPACESHIFT'];
map[93] = ['COMMANDRIGHT', 'COMMANDRIGHTSHIFT'];
map[18] = ['ALT', 'ALTSHIFT'];
map[38] = ['UP', 'UPSHIFT'];
map[37] = ['LEFT', 'LEFTSHIFT'];
map[40] = ['DOWN', 'DOWNSHIFT'];
map[39] = ['RIGHT', 'RIGHTSHIFT'];
map[33] = ['PAGEUP', 'PAGEUPSHIFT'];
map[34] = ['PAGEDOWN', 'PAGEDOWNSHIFT'];
map[35] = ['PAGERIGHT', 'PAGERIGHTSHIFT']; // END
map[36] = ['PAGELEFT', 'PAGELEFTSHIFT']; // HOME
map[112] = ['F1', 'F1SHIFT'];
map[113] = ['F2', 'F2SHIFT'];
map[114] = ['F3', 'F3SHIFT'];
map[115] = ['F4', 'F4SHIFT'];
map[116] = ['F5', 'F5SHIFT'];
map[117] = ['F6', 'F6SHIFT'];
map[118] = ['F7', 'F7SHIFT'];
map[119] = ['F8', 'F8SHIFT'];
map[120] = ['F9', 'F9SHIFT'];
map[121] = ['F10', 'F10SHIFT'];
map[122] = ['F11', 'F11SHIFT'];
map[123] = ['F12', 'F12SHIFT'];
return map;
}
function getCachedContext(canvasElement, type) {
var gc = canvasElement.getContext(type || '2d'),
props = {},
values = {};
// Stub out all the prototype members of the canvas 2D graphics context:
Object.keys(Object.getPrototypeOf(gc)).forEach(makeStub);
// Some older browsers (e.g., Chrome 40) did not have all members of canvas
// 2D graphics context in the prototype so we make this additional call:
Object.keys(gc).forEach(makeStub);
function makeStub(key) {
if (
!(key in props) &&
!/^(webkit|moz|ms|o)[A-Z]/.test(key) &&
typeof gc[key] !== 'function'
) {
Object.defineProperty(props, key, {
get: function() {
return (values[key] = values[key] || gc[key]);
},
set: function(value) {
if (value !== values[key]) {
gc[key] = values[key] = value;
}
}
});
}
}
gc.cache = props;
gc.cache.save = function() {
gc.save();
values = Object.create(values);
};
gc.cache.restore = function() {
gc.restore();
values = Object.getPrototypeOf(values);
};
gc.conditionalsStack = [];
Object.getOwnPropertyNames(Canvas.graphicsContextAliases).forEach(function(alias) {
gc[alias] = gc[Canvas.graphicsContextAliases[alias]];
});
return Object.assign(gc, require('./graphics'));
}
Canvas.graphicsContextAliases = {
simpleText: 'fillText'
};
module.exports = Canvas;