UNPKG

@kwiz/fluentui

Version:

KWIZ common controls for FluentUI

504 lines 20.9 kB
import { GetDefaultProp, isNotEmptyArray, isNullOrEmptyString, throttle } from "@kwiz/common"; import { CustomEventTargetBase } from './CustomEventTargetBase'; import { Bezier } from './bezier'; import { Point } from './point'; //inspired by https://www.npmjs.com/package/signature_pad export default class DrawPadManager extends CustomEventTargetBase { constructor(canvas, options = {}) { super(); this.canvas = canvas; // Public stuff this.dotSize = GetDefaultProp(0); this.minWidth = GetDefaultProp(0.5); this.maxWidth = GetDefaultProp(2.5); this.penColor = GetDefaultProp("black"); this.minDistance = GetDefaultProp(5); this.velocityFilterWeight = GetDefaultProp(0.7); this.backgroundColor = GetDefaultProp(null); this.throttle = GetDefaultProp(16); // Event handlers this._handleMouseDown = (event) => { if (event.buttons === 1) { this._drawningStroke = true; this._strokeBegin(event); } }; this._handleMouseMove = (event) => { if (this._drawningStroke) { this._strokeMoveUpdate(event); } }; this._handleMouseUp = (event) => { if (event.buttons === 1 && this._drawningStroke) { this._drawningStroke = false; this._strokeEnd(event); } }; this._handleTouchStart = (event) => { // Prevent scrolling. event.preventDefault(); if (event.targetTouches.length === 1) { const touch = event.changedTouches[0]; this._strokeBegin(touch); } }; this._handleTouchMove = (event) => { // Prevent scrolling. event.preventDefault(); const touch = event.targetTouches[0]; this._strokeMoveUpdate(touch); }; this._handleTouchEnd = (event) => { const wasCanvasTouched = event.target === this.canvas; if (wasCanvasTouched) { event.preventDefault(); const touch = event.changedTouches[0]; this._strokeEnd(touch); } }; this._handlePointerStart = (event) => { this._drawningStroke = true; event.preventDefault(); this._strokeBegin(event); }; this._handlePointerMove = (event) => { if (this._drawningStroke) { event.preventDefault(); this._strokeMoveUpdate(event); } }; this._handlePointerEnd = (event) => { this._drawningStroke = false; const wasCanvasTouched = event.target === this.canvas; if (wasCanvasTouched) { event.preventDefault(); this._strokeEnd(event); } }; this.velocityFilterWeight.value = options.velocityFilterWeight; this.minWidth.value = options.minWidth; this.maxWidth.value = options.maxWidth; this.throttle.value = options.throttle; // in milisecondss this.minDistance.value = options.minDistance; // in pixels this.dotSize.value = options.dotSize; this.penColor.value = options.penColor; this.backgroundColor.value = options.backgroundColor; this._strokeMoveUpdate = this.throttle.value ? throttle(this._strokeUpdate, this.throttle.value, this) : this._strokeUpdate; this._ctx = canvas.getContext('2d'); this.clear(); // Enable mouse and touch event handlers this.on(); } clear() { const { _ctx: ctx, canvas } = this; // Clear canvas using background color ctx.fillStyle = this.backgroundColor.value; ctx.clearRect(0, 0, canvas.width, canvas.height); if (!isNullOrEmptyString(this.backgroundColor.value)) //otherwise, leave it transparent ctx.fillRect(0, 0, canvas.width, canvas.height); this._data = []; this._reset(); this._isEmpty = true; this.resizeCanvas(); } fromDataURL(dataUrl, /** default: clear, shrink and stretch all true */ options = { clear: true, shrinkToFit: true, stretchToFit: true }) { return new Promise((resolve, reject) => { const img = new Image(); this._reset(); img.onload = () => { if (options.clear) { this.clear(); } /** * smallest factor * 1 - image is smaller than canvas. keep as is. * less than 1 - width, height or both are too big - this is the smaller factor that contains both */ //use clientHeight/clientWidth to supprot phones properly and compensate for window.devicePixelRatio let factor = Math.min(this.canvas.clientWidth / img.width, this.canvas.clientHeight / img.height); if (options.shrinkToFit !== true && factor < 1) factor = 1; if (options.stretchToFit !== true && factor > 1) factor = 1; //make sure its contained let width = img.width * factor; let height = img.height * factor; //center it var centerShift_x = this.canvas.clientWidth > width ? (this.canvas.clientWidth / 2) - (width / 2) : 0; var centerShift_y = this.canvas.clientHeight > height ? (this.canvas.clientHeight / 2) - (height / 2) : 0; this._ctx.drawImage(img, centerShift_x, centerShift_y, width, height); resolve(); }; img.onerror = (error) => { reject(error); }; img.crossOrigin = 'anonymous'; img.src = dataUrl; this._isEmpty = false; }); } toPng() { let value = ""; if (!this.isEmpty()) { value = this.toDataURL("image/png"); } return value; } toDataURL(type = 'image/png', encoderOptions) { switch (type) { case 'image/svg+xml': return this._toSVG(); default: return this.canvas.toDataURL(type, encoderOptions); } } on() { // Disable panning/zooming when touching canvas element this.canvas.style.touchAction = 'none'; this.canvas.style.msTouchAction = 'none'; this.canvas.style.userSelect = 'none'; const isIOS = /Macintosh/.test(navigator.userAgent) && 'ontouchstart' in document; // The "Scribble" feature of iOS intercepts point events. So that we can lose some of them when tapping rapidly. // Use touch events for iOS platforms to prevent it. See https://developer.apple.com/forums/thread/664108 for more information. if (window.PointerEvent && !isIOS) { this._handlePointerEvents(); } else { this._handleMouseEvents(); if ('ontouchstart' in window) { this._handleTouchEvents(); } } } off() { // Enable panning/zooming when touching canvas element this.canvas.style.touchAction = 'auto'; this.canvas.style.msTouchAction = 'auto'; this.canvas.style.userSelect = 'auto'; this.canvas.removeEventListener('pointerdown', this._handlePointerStart); this.canvas.removeEventListener('pointermove', this._handlePointerMove); document.removeEventListener('pointerup', this._handlePointerEnd); this.canvas.removeEventListener('mousedown', this._handleMouseDown); this.canvas.removeEventListener('mousemove', this._handleMouseMove); document.removeEventListener('mouseup', this._handleMouseUp); this.canvas.removeEventListener('touchstart', this._handleTouchStart); this.canvas.removeEventListener('touchmove', this._handleTouchMove); this.canvas.removeEventListener('touchend', this._handleTouchEnd); } isEmpty() { return this._isEmpty; } canUndo() { var data = this.toData(); return isNotEmptyArray(data); } undoLast() { if (this.canUndo()) { var data = this.toData(); data.pop(); // remove the last dot or line this.fromData(data); } } resizeCanvas() { var ratio = Math.max(window.devicePixelRatio || 1, 1); this.canvas.width = this.canvas.offsetWidth * ratio; this.canvas.height = this.canvas.offsetHeight * ratio; this.canvas.getContext("2d").scale(ratio, ratio); } fromData(pointGroups, { clear = true } = {}) { if (clear) { this.clear(); } this._fromData(pointGroups, this._drawCurve.bind(this), this._drawDot.bind(this)); this._data = clear ? pointGroups : this._data.concat(pointGroups); } toData() { return this._data; } // Private methods _strokeBegin(event) { this.dispatchEvent(new CustomEvent('beginStroke', { detail: event })); const newPointGroup = { dotSize: this.dotSize.value, minWidth: this.minWidth.value, maxWidth: this.maxWidth.value, penColor: this.penColor.value, points: [], }; this._data.push(newPointGroup); this._reset(); this._strokeUpdate(event); } _strokeUpdate(event) { if (this._data.length === 0) { // This can happen if clear() was called while a drawing is still in progress, // or if there is a race condition between start/update events. this._strokeBegin(event); return; } this.dispatchEvent(new CustomEvent('beforeUpdateStroke', { detail: event })); const x = event.clientX; const y = event.clientY; const pressure = event.pressure !== undefined ? event.pressure : event.force !== undefined ? event.force : 0; const point = this._createPoint(x, y, pressure); const lastPointGroup = this._data[this._data.length - 1]; const lastPoints = lastPointGroup.points; const lastPoint = lastPoints.length > 0 && lastPoints[lastPoints.length - 1]; const isLastPointTooClose = lastPoint ? point.distanceTo(lastPoint) <= this.minDistance.value : false; const { penColor, dotSize, minWidth, maxWidth } = lastPointGroup; // Skip this point if it's too close to the previous one if (!lastPoint || !(lastPoint && isLastPointTooClose)) { const curve = this._addPoint(point); if (!lastPoint) { this._drawDot(point, { penColor, dotSize, minWidth, maxWidth, }); } else if (curve) { this._drawCurve(curve, { penColor, dotSize, minWidth, maxWidth, }); } lastPoints.push({ time: point.time, x: point.x, y: point.y, pressure: point.pressure, }); } this.dispatchEvent(new CustomEvent('afterUpdateStroke', { detail: event })); } _strokeEnd(event) { this._strokeUpdate(event); this.dispatchEvent(new CustomEvent('endStroke', { detail: event })); } _handlePointerEvents() { this._drawningStroke = false; this.canvas.addEventListener('pointerdown', this._handlePointerStart); this.canvas.addEventListener('pointermove', this._handlePointerMove); document.addEventListener('pointerup', this._handlePointerEnd); } _handleMouseEvents() { this._drawningStroke = false; this.canvas.addEventListener('mousedown', this._handleMouseDown); this.canvas.addEventListener('mousemove', this._handleMouseMove); document.addEventListener('mouseup', this._handleMouseUp); } _handleTouchEvents() { this.canvas.addEventListener('touchstart', this._handleTouchStart); this.canvas.addEventListener('touchmove', this._handleTouchMove); this.canvas.addEventListener('touchend', this._handleTouchEnd); } // Called when a new line is started _reset() { this._lastPoints = []; this._lastVelocity = 0; this._lastWidth = (this.minWidth.value + this.maxWidth.value) / 2; this._ctx.fillStyle = this.penColor.value; } _createPoint(x, y, pressure) { const rect = this.canvas.getBoundingClientRect(); return new Point(x - rect.left, y - rect.top, pressure, new Date().getTime()); } // Add point to _lastPoints array and generate a new curve if there are enough points (i.e. 3) _addPoint(point) { const { _lastPoints } = this; _lastPoints.push(point); if (_lastPoints.length > 2) { // To reduce the initial lag make it work with 3 points // by copying the first point to the beginning. if (_lastPoints.length === 3) { _lastPoints.unshift(_lastPoints[0]); } // _points array will always have 4 points here. const widths = this._calculateCurveWidths(_lastPoints[1], _lastPoints[2]); const curve = Bezier.fromPoints(_lastPoints, widths); // Remove the first element from the list, so that there are no more than 4 points at any time. _lastPoints.shift(); return curve; } return null; } _calculateCurveWidths(startPoint, endPoint) { const velocity = this.velocityFilterWeight.value * endPoint.velocityFrom(startPoint) + (1 - this.velocityFilterWeight.value) * this._lastVelocity; const newWidth = this._strokeWidth(velocity); const widths = { end: newWidth, start: this._lastWidth, }; this._lastVelocity = velocity; this._lastWidth = newWidth; return widths; } _strokeWidth(velocity) { return Math.max(this.maxWidth.value / (velocity + 1), this.minWidth.value); } _drawCurveSegment(x, y, width) { const ctx = this._ctx; ctx.moveTo(x, y); ctx.arc(x, y, width, 0, 2 * Math.PI, false); this._isEmpty = false; } _drawCurve(curve, options) { const ctx = this._ctx; const widthDelta = curve.endWidth - curve.startWidth; // '2' is just an arbitrary number here. If only lenght is used, then // there are gaps between curve segments :/ const drawSteps = Math.ceil(curve.length()) * 2; ctx.beginPath(); ctx.fillStyle = options.penColor; for (let i = 0; i < drawSteps; i += 1) { // Calculate the Bezier (x, y) coordinate for this step. const t = i / drawSteps; const tt = t * t; const ttt = tt * t; const u = 1 - t; const uu = u * u; const uuu = uu * u; let x = uuu * curve.startPoint.x; x += 3 * uu * t * curve.control1.x; x += 3 * u * tt * curve.control2.x; x += ttt * curve.endPoint.x; let y = uuu * curve.startPoint.y; y += 3 * uu * t * curve.control1.y; y += 3 * u * tt * curve.control2.y; y += ttt * curve.endPoint.y; const width = Math.min(curve.startWidth + ttt * widthDelta, options.maxWidth); this._drawCurveSegment(x, y, width); } ctx.closePath(); ctx.fill(); } _drawDot(point, options) { const ctx = this._ctx; const width = options.dotSize > 0 ? options.dotSize : (options.minWidth + options.maxWidth) / 2; ctx.beginPath(); this._drawCurveSegment(point.x, point.y, width); ctx.closePath(); ctx.fillStyle = options.penColor; ctx.fill(); } _fromData(pointGroups, drawCurve, drawDot) { for (const group of pointGroups) { const { penColor, dotSize, minWidth, maxWidth, points } = group; if (points.length > 1) { for (let j = 0; j < points.length; j += 1) { const basicPoint = points[j]; const point = new Point(basicPoint.x, basicPoint.y, basicPoint.pressure, basicPoint.time); // All points in the group have the same color, so it's enough to set // penColor just at the beginning. this.penColor.value = penColor; if (j === 0) { this._reset(); } const curve = this._addPoint(point); if (curve) { drawCurve(curve, { penColor, dotSize, minWidth, maxWidth, }); } } } else { this._reset(); drawDot(points[0], { penColor, dotSize, minWidth, maxWidth, }); } } } _toSVG() { const pointGroups = this._data; const ratio = Math.max(window.devicePixelRatio || 1, 1); const minX = 0; const minY = 0; const maxX = this.canvas.width / ratio; const maxY = this.canvas.height / ratio; const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', this.canvas.width.toString()); svg.setAttribute('height', this.canvas.height.toString()); this._fromData(pointGroups, (curve, { penColor }) => { const path = document.createElement('path'); // Need to check curve for NaN values, these pop up when drawing // lines on the canvas that are not continuous. E.g. Sharp corners // or stopping mid-stroke and than continuing without lifting mouse. /* eslint-disable no-restricted-globals */ if (!isNaN(curve.control1.x) && !isNaN(curve.control1.y) && !isNaN(curve.control2.x) && !isNaN(curve.control2.y)) { const attr = `M ${curve.startPoint.x.toFixed(3)},${curve.startPoint.y.toFixed(3)} ` + `C ${curve.control1.x.toFixed(3)},${curve.control1.y.toFixed(3)} ` + `${curve.control2.x.toFixed(3)},${curve.control2.y.toFixed(3)} ` + `${curve.endPoint.x.toFixed(3)},${curve.endPoint.y.toFixed(3)}`; path.setAttribute('d', attr); path.setAttribute('stroke-width', (curve.endWidth * 2.25).toFixed(3)); path.setAttribute('stroke', penColor); path.setAttribute('fill', 'none'); path.setAttribute('stroke-linecap', 'round'); svg.appendChild(path); } /* eslint-enable no-restricted-globals */ }, (point, { penColor, dotSize, minWidth, maxWidth }) => { const circle = document.createElement('circle'); const size = dotSize > 0 ? dotSize : (minWidth + maxWidth) / 2; circle.setAttribute('r', size.toString()); circle.setAttribute('cx', point.x.toString()); circle.setAttribute('cy', point.y.toString()); circle.setAttribute('fill', penColor); svg.appendChild(circle); }); const prefix = 'data:image/svg+xml;base64,'; const header = '<svg' + ' xmlns="http://www.w3.org/2000/svg"' + ' xmlns:xlink="http://www.w3.org/1999/xlink"' + ` viewBox="${minX} ${minY} ${this.canvas.width} ${this.canvas.height}"` + ` width="${maxX}"` + ` height="${maxY}"` + '>'; let body = svg.innerHTML; // IE hack for missing innerHTML property on SVGElement if (body === undefined) { const dummy = document.createElement('dummy'); const nodes = svg.childNodes; dummy.innerHTML = ''; for (let i = 0; i < nodes.length; i += 1) { dummy.appendChild(nodes[i].cloneNode(true)); } body = dummy.innerHTML; } const footer = '</svg>'; const data = header + body + footer; return prefix + btoa(data); } } //# sourceMappingURL=DrawPadManager.js.map