Files
2021-03-30 23:27:23 +02:00

1143 lines
27 KiB
JavaScript

/*
* pure-knob
*
* Canvas-based JavaScript UI element implementing touch,
* keyboard, mouse and scroll wheel support.
*
* Copyright 2018 - 2021 Andre Plötze
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
"use strict";
(function (root, factory) {
if ((typeof define === 'function') && define.amd) {
/* AMD. Register as an anonymous module. */
define([], factory);
} else if ((typeof module === 'object') && module.exports) {
/*
* Node.
*
* Does not work with strict CommonJS, but
* only CommonJS-like environments that support
* module.exports, like Node.
*/
module.exports = factory();
} else {
/* Browser globals (root is window). */
root.pureknob = factory();
}
} (typeof self !== 'undefined' ? self : this, function () {
function PureKnob() {
/*
* Creates a bar graph element.
*/
this.createBarGraph = function(width, height) {
const heightString = height.toString();
const widthString = width.toString();
const canvas = document.createElement('canvas');
const div = document.createElement('div');
div.style.display = 'inline-block';
div.style.height = heightString + 'px';
div.style.position = 'relative';
div.style.textAlign = 'center';
div.style.width = widthString + 'px';
div.appendChild(canvas);
/*
* The bar graph object.
*/
const graph = {
'_canvas': canvas,
'_div': div,
'_height': height,
'_width': width,
/*
* Properties of this bar graph.
*/
'_properties': {
'colorBG': '#181818',
'colorFG': '#ff8800',
'colorMarkers': '#888888',
'markerStart': 0,
'markerEnd': 100,
'markerStep': 20,
'trackWidth': 0.5,
'valMin': 0,
'valMax': 100,
'valPeaks': [],
'val': 0
},
/*
* Returns the peak values for this bar graph.
*/
'getPeaks': function() {
const properties = this._properties;
const peaks = properties.valPeaks;
const numPeaks = peaks.length;
const peaksCopy = [];
/*
* Iterate over the peak values and copy them.
*/
for (let i = 0; i < numPeaks; i++) {
const peak = peaks[i];
peaksCopy.push(peak);
}
return peaksCopy;
},
/*
* Returns the value of a property of this bar graph.
*/
'getProperty': function(key) {
const properties = this._properties;
const value = properties[key];
return value;
},
/*
* Returns the current value of the bar graph.
*/
'getValue': function() {
const properties = this._properties;
const value = properties.val;
return value;
},
/*
* Return the DOM node representing this bar graph.
*/
'node': function() {
const div = this._div;
return div;
},
/*
* Redraw the bar graph on the canvas.
*/
'redraw': function() {
this.resize();
const properties = this._properties;
const colorTrack = properties.colorBG;
const colorFilling = properties.colorFG;
const colorMarkers = properties.colorMarkers;
const markerStart = properties.markerStart;
const markerEnd = properties.markerEnd;
const markerStep = properties.markerStep;
const trackWidth = properties.trackWidth;
const valMin = properties.valMin;
const valMax = properties.valMax;
const peaks = properties.valPeaks;
const value = properties.val;
const height = this._height;
const width = this._width;
const lineWidth = Math.round(trackWidth * height);
const halfWidth = 0.5 * lineWidth;
const centerY = 0.5 * height;
const lineTop = centerY - halfWidth;
const lineBottom = centerY + halfWidth;
const relativeValue = (value - valMin) / (valMax - valMin);
const fillingEnd = width * relativeValue;
const numPeaks = peaks.length;
const canvas = this._canvas;
const ctx = canvas.getContext('2d');
/*
* Clear the canvas.
*/
ctx.clearRect(0, 0, width, height);
/*
* Check if markers should be drawn.
*/
if ((markerStart !== null) & (markerEnd !== null) & (markerStep !== null) & (markerStep !== 0)) {
ctx.lineCap = 'butt';
ctx.lineWidth = '2';
ctx.strokeStyle = colorMarkers;
/*
* Draw the markers.
*/
for (let v = markerStart; v <= markerEnd; v += markerStep) {
const relativePos = (v - valMin) / (valMax - valMin);
const pos = Math.round(width * relativePos);
ctx.beginPath();
ctx.moveTo(pos, 0);
ctx.lineTo(pos, height);
ctx.stroke();
}
}
/*
* Draw the track.
*/
ctx.beginPath();
ctx.rect(0, lineTop, width, lineWidth);
ctx.fillStyle = colorTrack;
ctx.fill();
/*
* Draw the filling.
*/
ctx.beginPath();
ctx.rect(0, lineTop, fillingEnd, lineWidth);
ctx.fillStyle = colorFilling;
ctx.fill();
/*
* Prepare for drawing the peaks.
*/
ctx.strokeStyle = colorFilling;
/*
* Draw the peaks.
*/
for (let i = 0; i < numPeaks; i++) {
const peak = peaks[i];
const relativePeak = (peak - valMin) / (valMax - valMin);
const pos = Math.round(width * relativePeak);
ctx.beginPath();
ctx.moveTo(pos, lineTop);
ctx.lineTo(pos, lineBottom);
ctx.stroke();
}
},
/*
* This is called as the canvas or the surrounding DIV is resized.
*/
'resize': function() {
const canvas = this._canvas;
canvas.style.height = '100%';
canvas.style.width = '100%';
canvas.height = this._height;
canvas.width = this._width;
},
/*
* Sets the peak values of this bar graph.
*/
'setPeaks': function(peaks) {
const properties = this._properties;
const peaksCopy = [];
const numPeaks = peaks.length;
/*
* Iterate over the peak values and append them to the array.
*/
for (let i = 0; i < numPeaks; i++) {
const peak = peaks[i];
peaksCopy.push(peak);
}
this.setProperty('valPeaks', peaksCopy);
},
/*
* Sets the value of a property of this bar graph.
*/
'setProperty': function(key, value) {
this._properties[key] = value;
this.redraw();
},
/*
* Sets the value of this bar graph.
*/
'setValue': function(value) {
const properties = this._properties;
const valMin = properties.valMin;
const valMax = properties.valMax;
/*
* Clamp the actual value into the [valMin; valMax] range.
*/
if (value < valMin) {
value = valMin;
} else if (value > valMax) {
value = valMax;
}
value = Math.round(value);
this.setProperty('val', value);
}
};
/*
* This is called when the size of the canvas changes.
*/
const resizeListener = function(e) {
graph.redraw();
};
canvas.addEventListener('resize', resizeListener);
return graph;
}
/*
* Creates a knob element.
*/
this.createKnob = function(width, height) {
const heightString = height.toString();
const widthString = width.toString();
const smaller = width < height ? width : height;
const fontSize = 0.2 * smaller;
const fontSizeString = fontSize.toString();
const canvas = document.createElement('canvas');
const div = document.createElement('div');
div.style.display = 'inline-block';
div.style.height = heightString + 'px';
div.style.position = 'relative';
div.style.textAlign = 'center';
div.style.width = widthString + 'px';
div.appendChild(canvas);
const input = document.createElement('input');
input.style.appearance = 'textfield';
input.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
input.style.border = 'none';
input.style.color = '#ff8800';
input.style.fontFamily = 'sans-serif';
input.style.fontSize = fontSizeString + 'px';
input.style.height = heightString + 'px';
input.style.margin = 'auto';
input.style.padding = '0px';
input.style.textAlign = 'center';
input.style.width = widthString + 'px';
const inputMode = document.createAttribute('inputmode');
inputMode.value = 'numeric';
input.setAttributeNode(inputMode);
const inputDiv = document.createElement('div');
inputDiv.style.bottom = '0px';
inputDiv.style.display = 'none';
inputDiv.style.left = '0px';
inputDiv.style.position = 'absolute';
inputDiv.style.right = '0px';
inputDiv.style.top = '0px';
inputDiv.appendChild(input);
div.appendChild(inputDiv);
/*
* The knob object.
*/
const knob = {
'_canvas': canvas,
'_div': div,
'_height': height,
'_input': input,
'_inputDiv': inputDiv,
'_listeners': [],
'_mousebutton': false,
'_previousVal': 0,
'_timeout': null,
'_timeoutDoubleTap': null,
'_touchCount': 0,
'_width': width,
/*
* Notify listeners about value changes.
*/
'_notifyUpdate': function() {
const properties = this._properties;
const value = properties.val;
const listeners = this._listeners;
const numListeners = listeners.length;
/*
* Call all listeners.
*/
for (let i = 0; i < numListeners; i++) {
const listener = listeners[i];
/*
* Call listener, if it exists.
*/
if (listener !== null) {
listener(this, value);
}
}
},
/*
* Properties of this knob.
*/
'_properties': {
'angleEnd': 2.0 * Math.PI,
'angleOffset': -0.5 * Math.PI,
'angleStart': 0,
'colorBG': '#181818',
'colorFG': '#ff8800',
'colorLabel': '#ffffff',
'fnStringToValue': function(string) { return parseInt(string); },
'fnValueToString': function(value) { return value.toString(); },
'label': null,
'needle': false,
'readonly': false,
'textScale': 1.0,
'trackWidth': 0.4,
'valMin': 0,
'valMax': 100,
'val': 0
},
/*
* Abort value change, restoring the previous value.
*/
'abort': function() {
const previousValue = this._previousVal;
const properties = this._properties;
properties.val = previousValue;
this.redraw();
},
/*
* Adds an event listener.
*/
'addListener': function(listener) {
const listeners = this._listeners;
listeners.push(listener);
},
/*
* Commit value, indicating that it is no longer temporary.
*/
'commit': function() {
const properties = this._properties;
const value = properties.val;
this._previousVal = value;
this.redraw();
this._notifyUpdate();
},
/*
* Returns the value of a property of this knob.
*/
'getProperty': function(key) {
const properties = this._properties;
const value = properties[key];
return value;
},
/*
* Returns the current value of the knob.
*/
'getValue': function() {
const properties = this._properties;
const value = properties.val;
return value;
},
/*
* Return the DOM node representing this knob.
*/
'node': function() {
const div = this._div;
return div;
},
/*
* Redraw the knob on the canvas.
*/
'redraw': function() {
this.resize();
const properties = this._properties;
const needle = properties.needle;
const angleStart = properties.angleStart;
const angleOffset = properties.angleOffset;
const angleEnd = properties.angleEnd;
const actualStart = angleStart + angleOffset;
const actualEnd = angleEnd + angleOffset;
const label = properties.label;
const value = properties.val;
const valueToString = properties.fnValueToString;
const valueStr = valueToString(value);
const valMin = properties.valMin;
const valMax = properties.valMax;
const relValue = (value - valMin) / (valMax - valMin);
const relAngle = relValue * (angleEnd - angleStart);
const angleVal = actualStart + relAngle;
const colorTrack = properties.colorBG;
const colorFilling = properties.colorFG;
const colorLabel = properties.colorLabel;
const textScale = properties.textScale;
const trackWidth = properties.trackWidth;
const height = this._height;
const width = this._width;
const smaller = width < height ? width : height;
const centerX = 0.5 * width;
const centerY = 0.5 * height;
const radius = 0.4 * smaller;
const labelY = centerY + radius;
const lineWidth = Math.round(trackWidth * radius);
const labelSize = Math.round(0.8 * lineWidth);
const labelSizeString = labelSize.toString();
const fontSize = (0.2 * smaller) * textScale;
const fontSizeString = fontSize.toString();
const canvas = this._canvas;
const ctx = canvas.getContext('2d');
/*
* Clear the canvas.
*/
ctx.clearRect(0, 0, width, height);
/*
* Draw the track.
*/
ctx.beginPath();
ctx.arc(centerX, centerY, radius, actualStart, actualEnd);
ctx.lineCap = 'butt';
ctx.lineWidth = lineWidth;
ctx.strokeStyle = colorTrack;
ctx.stroke();
/*
* Draw the filling.
*/
ctx.beginPath();
/*
* Check if we're in needle mode.
*/
if (needle) {
ctx.arc(centerX, centerY, radius, angleVal - 0.1, angleVal + 0.1);
} else {
ctx.arc(centerX, centerY, radius, actualStart, angleVal);
}
ctx.lineCap = 'butt';
ctx.lineWidth = lineWidth;
ctx.strokeStyle = colorFilling;
ctx.stroke();
/*
* Draw the number.
*/
ctx.font = fontSizeString + 'px sans-serif';
ctx.fillStyle = colorFilling;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(valueStr, centerX, centerY);
/*
* Draw the label
*/
if (label !== null) {
ctx.font = labelSizeString + 'px sans-serif';
ctx.fillStyle = colorLabel;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, centerX, labelY);
}
/*
* Set the color and font size of the input element.
*/
const elemInput = this._input;
elemInput.style.color = colorFilling;
elemInput.style.fontSize = fontSizeString + 'px';
},
/*
* This is called as the canvas or the surrounding DIV is resized.
*/
'resize': function() {
const canvas = this._canvas;
canvas.style.height = '100%';
canvas.style.width = '100%';
canvas.height = this._height;
canvas.width = this._width;
},
/*
* Sets the value of a property of this knob.
*/
'setProperty': function(key, value) {
this._properties[key] = value;
this.redraw();
},
/*
* Sets the value of this knob.
*/
'setValue': function(value) {
this.setValueFloating(value);
this.commit();
},
/*
* Sets floating (temporary) value of this knob.
*/
'setValueFloating': function(value) {
const properties = this._properties;
const valMin = properties.valMin;
const valMax = properties.valMax;
/*
* Clamp the actual value into the [valMin; valMax] range.
*/
if (value < valMin) {
value = valMin;
} else if (value > valMax) {
value = valMax;
}
value = Math.round(value);
this.setProperty('val', value);
}
};
/*
* Convert mouse event to value.
*/
const mouseEventToValue = function(e, properties) {
const canvas = e.target;
const width = canvas.scrollWidth;
const height = canvas.scrollHeight;
const centerX = 0.5 * width;
const centerY = 0.5 * height;
const x = e.offsetX;
const y = e.offsetY;
const relX = x - centerX;
const relY = y - centerY;
const angleStart = properties.angleStart;
const angleEnd = properties.angleEnd;
const angleDiff = angleEnd - angleStart;
let angle = Math.atan2(relX, -relY) - angleStart;
const twoPi = 2.0 * Math.PI;
/*
* Make negative angles positive.
*/
if (angle < 0) {
if (angleDiff >= twoPi) {
angle += twoPi;
} else {
angle = 0;
}
}
const valMin = properties.valMin;
const valMax = properties.valMax;
let value = ((angle / angleDiff) * (valMax - valMin)) + valMin;
/*
* Clamp values into valid interval.
*/
if (value < valMin) {
value = valMin;
} else if (value > valMax) {
value = valMax;
}
return value;
};
/*
* Convert touch event to value.
*/
const touchEventToValue = function(e, properties) {
const canvas = e.target;
const rect = canvas.getBoundingClientRect();
const offsetX = rect.left;
const offsetY = rect.top;
const width = canvas.scrollWidth;
const height = canvas.scrollHeight;
const centerX = 0.5 * width;
const centerY = 0.5 * height;
const touches = e.targetTouches;
let touch = null;
/*
* If there are touches, extract the first one.
*/
if (touches.length > 0) {
touch = touches.item(0);
}
let x = 0.0;
let y = 0.0;
/*
* If a touch was extracted, calculate coordinates relative to
* the element position.
*/
if (touch !== null) {
const touchX = touch.pageX;
x = touchX - offsetX;
const touchY = touch.pageY;
y = touchY - offsetY;
}
const relX = x - centerX;
const relY = y - centerY;
const angleStart = properties.angleStart;
const angleEnd = properties.angleEnd;
const angleDiff = angleEnd - angleStart;
const twoPi = 2.0 * Math.PI;
let angle = Math.atan2(relX, -relY) - angleStart;
/*
* Make negative angles positive.
*/
if (angle < 0) {
if (angleDiff >= twoPi) {
angle += twoPi;
} else {
angle = 0;
}
}
const valMin = properties.valMin;
const valMax = properties.valMax;
let value = ((angle / angleDiff) * (valMax - valMin)) + valMin;
/*
* Clamp values into valid interval.
*/
if (value < valMin) {
value = valMin;
} else if (value > valMax) {
value = valMax;
}
return value;
};
/*
* Show input element on double click.
*/
const doubleClickListener = function(e) {
const properties = knob._properties;
const readonly = properties.readonly;
/*
* If knob is not read-only, display input element.
*/
if (!readonly) {
e.preventDefault();
const inputDiv = knob._inputDiv;
inputDiv.style.display = 'block';
const inputElem = knob._input;
inputElem.focus();
knob.redraw();
}
};
/*
* This is called when the mouse button is depressed.
*/
const mouseDownListener = function(e) {
const btn = e.buttons;
/*
* It is a left-click.
*/
if (btn === 1) {
const properties = knob._properties;
const readonly = properties.readonly;
/*
* If knob is not read-only, process mouse event.
*/
if (!readonly) {
e.preventDefault();
const val = mouseEventToValue(e, properties);
knob.setValueFloating(val);
}
knob._mousebutton = true;
}
/*
* It is a middle click.
*/
if (btn === 4) {
const properties = knob._properties;
const readonly = properties.readonly;
/*
* If knob is not read-only, display input element.
*/
if (!readonly) {
e.preventDefault();
const inputDiv = knob._inputDiv;
inputDiv.style.display = 'block';
const inputElem = knob._input;
inputElem.focus();
knob.redraw();
}
}
};
/*
* This is called when the mouse cursor is moved.
*/
const mouseMoveListener = function(e) {
const btn = knob._mousebutton;
/*
* Only process event, if mouse button is depressed.
*/
if (btn) {
const properties = knob._properties;
const readonly = properties.readonly;
/*
* If knob is not read-only, process mouse event.
*/
if (!readonly) {
e.preventDefault();
const val = mouseEventToValue(e, properties);
knob.setValueFloating(val);
}
}
};
/*
* This is called when the mouse button is released.
*/
const mouseUpListener = function(e) {
const btn = knob._mousebutton;
/*
* Only process event, if mouse button was depressed.
*/
if (btn) {
const properties = knob._properties;
const readonly = properties.readonly;
/*
* If knob is not read only, process mouse event.
*/
if (!readonly) {
e.preventDefault();
const val = mouseEventToValue(e, properties);
knob.setValue(val);
}
}
knob._mousebutton = false;
};
/*
* This is called when the drag action is canceled.
*/
const mouseCancelListener = function(e) {
const btn = knob._mousebutton;
/*
* Abort action if mouse button was depressed.
*/
if (btn) {
knob.abort();
knob._mousebutton = false;
}
};
/*
* This is called when a user touches the element.
*/
const touchStartListener = function(e) {
const properties = knob._properties;
const readonly = properties.readonly;
/*
* If knob is not read-only, process touch event.
*/
if (!readonly) {
const touches = e.targetTouches;
const numTouches = touches.length;
const singleTouch = (numTouches === 1);
/*
* Only process single touches, not multi-touch
* gestures.
*/
if (singleTouch) {
knob._mousebutton = true;
/*
* If this is the first touch, bind double tap
* interval.
*/
if (knob._touchCount === 0) {
/*
* This is executed when the double tap
* interval times out.
*/
const f = function() {
/*
* If control was tapped exactly
* twice, enable on-screen keyboard.
*/
if (knob._touchCount === 2) {
const properties = knob._properties;
const readonly = properties.readonly;
/*
* If knob is not read-only,
* display input element.
*/
if (!readonly) {
e.preventDefault();
const inputDiv = knob._inputDiv;
inputDiv.style.display = 'block';
const inputElem = knob._input;
inputElem.focus();
knob.redraw();
}
}
knob._touchCount = 0;
};
let timeout = knob._timeoutDoubleTap;
window.clearTimeout(timeout);
timeout = window.setTimeout(f, 500);
knob._timeoutDoubleTap = timeout;
}
knob._touchCount++;
const val = touchEventToValue(e, properties);
knob.setValueFloating(val);
}
}
};
/*
* This is called when a user moves a finger on the element.
*/
var touchMoveListener = function(e) {
const btn = knob._mousebutton;
/*
* Only process event, if mouse button is depressed.
*/
if (btn) {
const properties = knob._properties;
const readonly = properties.readonly;
/*
* If knob is not read-only, process touch event.
*/
if (!readonly) {
const touches = e.targetTouches;
const numTouches = touches.length;
const singleTouch = (numTouches === 1);
/*
* Only process single touches, not multi-touch
* gestures.
*/
if (singleTouch) {
e.preventDefault();
const val = touchEventToValue(e, properties);
knob.setValueFloating(val);
}
}
}
};
/*
* This is called when a user lifts a finger off the element.
*/
const touchEndListener = function(e) {
const btn = knob._mousebutton;
/*
* Only process event, if mouse button was depressed.
*/
if (btn) {
const properties = knob._properties;
const readonly = properties.readonly;
/*
* If knob is not read only, process touch event.
*/
if (!readonly) {
const touches = e.targetTouches;
const numTouches = touches.length;
const noMoreTouches = (numTouches === 0);
/*
* Only commit value after the last finger has
* been lifted off.
*/
if (noMoreTouches) {
e.preventDefault();
knob._mousebutton = false;
knob.commit();
}
}
}
knob._mousebutton = false;
};
/*
* This is called when a user cancels a touch action.
*/
const touchCancelListener = function(e) {
const btn = knob._mousebutton;
/*
* Abort action if mouse button was depressed.
*/
if (btn) {
knob.abort();
knob._touchCount = 0;
const timeout = knob._timeoutDoubleTap;
window.clearTimeout(timeout);
}
knob._mousebutton = false;
};
/*
* This is called when the size of the canvas changes.
*/
const resizeListener = function(e) {
knob.redraw();
};
/*
* This is called when the mouse wheel is moved.
*/
const scrollListener = function(e) {
const readonly = knob.getProperty('readonly');
/*
* If knob is not read only, process mouse wheel event.
*/
if (!readonly) {
e.preventDefault();
const delta = e.deltaY;
const direction = delta > 0 ? 1 : (delta < 0 ? -1 : 0);
let val = knob.getValue();
val += direction;
knob.setValueFloating(val);
/*
* Perform delayed commit.
*/
const commit = function() {
knob.commit();
};
let timeout = knob._timeout;
window.clearTimeout(timeout);
timeout = window.setTimeout(commit, 250);
knob._timeout = timeout;
}
};
/*
* This is called when the user presses a key on the keyboard.
*/
const keyDownListener = function(e) {
const k = e.key;
/*
* Hide input element when user presses enter or escape.
*/
if ((k === 'Enter') || (k === 'Escape')) {
const inputDiv = knob._inputDiv;
inputDiv.style.display = 'none';
const input = e.target;
/*
* Only evaluate value when user pressed enter.
*/
if (k === 'Enter') {
const properties = knob._properties;
const value = input.value;
const stringToValue = properties.fnStringToValue;
const val = stringToValue(value);
const valid = isFinite(val);
/*
* Check if input is a valid number.
*/
if (valid) {
knob.setValue(val);
}
}
input.value = '';
}
};
canvas.addEventListener('dblclick', doubleClickListener);
canvas.addEventListener('mousedown', mouseDownListener);
canvas.addEventListener('mouseleave', mouseCancelListener);
canvas.addEventListener('mousemove', mouseMoveListener);
canvas.addEventListener('mouseup', mouseUpListener);
canvas.addEventListener('resize', resizeListener);
canvas.addEventListener('touchstart', touchStartListener);
canvas.addEventListener('touchmove', touchMoveListener);
canvas.addEventListener('touchend', touchEndListener);
canvas.addEventListener('touchcancel', touchCancelListener);
canvas.addEventListener('wheel', scrollListener);
input.addEventListener('keydown', keyDownListener);
return knob;
};
}
return new PureKnob();
}));