UNPKG

@flyskywhy/react-native-gcanvas

Version:

A C++ native canvas 2D/WebGL component based on gpu opengl glsl shader GCanvas

788 lines (665 loc) 24.7 kB
import ImageData from '@canvas/image-data/index'; import base64 from 'base64-js'; import FillStylePattern from './FillStylePattern'; import FillStyleLinearGradient from './FillStyleLinearGradient'; import FillStyleRadialGradient from './FillStyleRadialGradient'; export default class CanvasRenderingContext2D { _drawCommands = ''; _savedGlobalAlpha = []; timer = null; componentId = null; // _imageMap = new GHashMap(); // _textureMap = new GHashMap(); _resetContextAttributes({ globalAlpha = 1.0, fillStyle = 'rgb(0,0,0)', strokeStyle = 'rgb(0,0,0)', shadowColor = 'rgb(0,0,0)', shadowBlur = 0, shadowOffsetX = 0, shadowOffsetY = 0, imageSmoothingEnabled = 1, lineWidth = 1, lineCap = 'butt', lineJoin = 'miter', lineDash = [], lineDashOffset = 0, miterLimit = 10, globalCompositeOperation = 'source-over', textAlign = 'start', textBaseline = 'alphabetic', font = '10px sans-serif', }) { this.globalAlpha = globalAlpha; this.fillStyle = fillStyle; this.strokeStyle = strokeStyle; this.shadowColor = shadowColor; this.shadowBlur = shadowBlur; this.shadowOffsetX = shadowOffsetX; this.shadowOffsetY = shadowOffsetY; this.imageSmoothingEnabled = imageSmoothingEnabled; this.lineWidth = lineWidth; this.lineCap = lineCap; this.lineJoin = lineJoin; this.setLineDash(lineDash); this.lineDashOffset = lineDashOffset; this.miterLimit = miterLimit; this.globalCompositeOperation = globalCompositeOperation; this.textAlign = textAlign; this.textBaseline = textBaseline; this.font = font; } constructor(canvas) { this._canvas = canvas; this.className = 'CanvasRenderingContext2D'; this._resetContextAttributes({}); } get canvas() { return this._canvas; } set fillStyle(value) { this._fillStyle = value; if (typeof value == 'string') { this._drawCommands = this._drawCommands.concat('F' + value + ';'); } else if (value instanceof FillStylePattern) { const image = value._img; CanvasRenderingContext2D.GBridge.bindImageTexture(this.componentId, image.src, image._id); this._drawCommands = this._drawCommands.concat('G' + image._id + ',' + value._style + ';'); this.flushJsCommands2CallNative('sync', 'execWithoutDisplay'); } else if (value instanceof FillStyleLinearGradient) { var command = 'D' + value._start_pos._x.toFixed(2) + ',' + value._start_pos._y.toFixed(2) + ',' + value._end_pos._x.toFixed(2) + ',' + value._end_pos._y.toFixed(2) + ',' + value._stop_count; for (var i = 0; i < value._stop_count; ++i) { command += ',' + value._stops[i]._pos + ',' + value._stops[i]._color; } this._drawCommands = this._drawCommands.concat(command + ';'); } else if (value instanceof FillStyleRadialGradient) { var command = 'H' + value._start_pos._x.toFixed(2) + ',' + value._start_pos._y.toFixed(2) + ',' + value._start_pos._r.toFixed(2) + ',' + value._end_pos._x.toFixed(2) + ',' + value._end_pos._y.toFixed(2) + ',' + value._end_pos._r.toFixed(2) + ',' + value._stop_count; for (var i = 0; i < value._stop_count; ++i) { command += ',' + value._stops[i]._pos + ',' + value._stops[i]._color; } this._drawCommands = this._drawCommands.concat(command + ';'); } } get fillStyle() { return this._fillStyle; } get globalAlpha() { return this._globalAlpha; } set globalAlpha(value) { this._globalAlpha = value; this._drawCommands = this._drawCommands.concat('a' + value.toFixed(2) + ';'); } get strokeStyle() { return this._strokeStyle; } set strokeStyle(value) { this._strokeStyle = value; if (typeof value == 'string') { this._drawCommands = this._drawCommands.concat('S' + value + ';'); } else if (value instanceof FillStylePattern) { const image = value._img; CanvasRenderingContext2D.GBridge.bindImageTexture(this.componentId, image.src, image._id); this._drawCommands = this._drawCommands.concat('G' + image._id + ',' + value._style + ';'); this.flushJsCommands2CallNative('sync', 'execWithoutDisplay'); } else if (value instanceof FillStyleLinearGradient) { var command = 'D' + value._start_pos._x.toFixed(2) + ',' + value._start_pos._y.toFixed(2) + ',' + value._end_pos._x.toFixed(2) + ',' + value._end_pos._y.toFixed(2) + ',' + value._stop_count; for (var i = 0; i < value._stop_count; ++i) { command += ',' + value._stops[i]._pos + ',' + value._stops[i]._color; } this._drawCommands = this._drawCommands.concat(command + ';'); } else if (value instanceof FillStyleRadialGradient) { var command = 'H' + value._start_pos._x.toFixed(2) + ',' + value._start_pos._y.toFixed(2) + ',' + value._start_pos._r.toFixed(2) + ',' + value._end_pos._x.toFixed(2) + ',' + value._end_pos._y + ','.toFixed(2) + value._end_pos._r.toFixed(2) + ',' + value._stop_count; for (var i = 0; i < value._stop_count; ++i) { command += ',' + value._stops[i]._pos + ',' + value._stops[i]._color; } this._drawCommands = this._drawCommands.concat(command + ';'); } } get shadowColor() { return this._shadowColor; } set shadowColor(value) { this._shadowColor = value; this._drawCommands = this._drawCommands.concat('K' + value + ';'); } get shadowBlur() { return this._shadowBlur; } set shadowBlur(value) { this._shadowBlur = value; this._drawCommands = this._drawCommands.concat('Z' + value + ';'); } get shadowOffsetX() { return this._shadowOffsetX; } set shadowOffsetX(value) { this._shadowOffsetX = value; this._drawCommands = this._drawCommands.concat('X' + value + ';'); } get shadowOffsetY() { return this._shadowOffsetY; } set shadowOffsetY(value) { this._shadowOffsetY = value; this._drawCommands = this._drawCommands.concat('Y' + value + ';'); } get imageSmoothingEnabled() { return this._imageSmoothingEnabled; } set imageSmoothingEnabled(value) { // 'if' can be here because needDisableImageSmoothing in ios/BridgeModule/GCanvasPlugin.mm // and core/android/3d/view/grenderer.cpp also do the work when resetGlViewport if (this._imageSmoothingEnabled !== Number(value)) { this._imageSmoothingEnabled = Number(value); this._drawCommands = this._drawCommands.concat('O' + this._imageSmoothingEnabled + ';'); this.flushJsCommands2CallNative('sync', 'execWithoutDisplay'); } } get lineDashOffset() { return this._lineDashOffset; } set lineDashOffset(value) { this._lineWidth = value; this._drawCommands = this._drawCommands.concat('N' + value + ';'); } get lineWidth() { return this._lineWidth; } set lineWidth(value) { this._lineWidth = value; this._drawCommands = this._drawCommands.concat('W' + value + ';'); } get lineCap() { return this._lineCap; } set lineCap(value) { this._lineCap = value; this._drawCommands = this._drawCommands.concat('C' + value + ';'); } get lineJoin() { return this._lineJoin; } set lineJoin(value) { this._lineJoin = value; this._drawCommands = this._drawCommands.concat('J' + value + ';'); } get miterLimit() { return this._miterLimit; } set miterLimit(value) { this._miterLimit = value; this._drawCommands = this._drawCommands.concat('M' + value + ';'); } get globalCompositeOperation() { return this._globalCompositeOperation; } set globalCompositeOperation(value) { // to avoid blinks, ref to https://github.com/flyskywhy/react-native-gcanvas/issues/31 this.flushJsCommands2CallNative('sync', 'execWithoutDisplay'); this._globalCompositeOperation = value; let mode = 0; switch (value) { case 'source-over': mode = 0; break; case 'source-atop': mode = 5; break; case 'source-in': mode = 0; // TODO break; case 'source-out': mode = 10; break; case 'destination-over': mode = 4; break; case 'destination-atop': mode = 4; // TODO break; case 'destination-in': mode = 11; break; case 'destination-out': mode = 3; break; case 'lighter': mode = 1; break; case 'copy': mode = 7; break; case 'xor': mode = 6; break; // TODO: add more mode ref to // https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation // GCompositeOperation in core/src/gcanvas/GContext2dType.h // GBlendOperationFuncs in core/src/gcanvas/GCanvas2dContext.cpp default: mode = 0; } this._drawCommands = this._drawCommands.concat('B' + mode + ';'); } get textAlign() { return this._textAlign; } set textAlign(value) { this._textAlign = value; let Align = 0; switch (value) { case 'start': Align = 0; break; case 'end': Align = 1; break; case 'left': Align = 2; break; case 'center': Align = 3; break; case 'right': Align = 4; break; default: Align = 0; } this._drawCommands = this._drawCommands.concat('A' + Align + ';'); } get textBaseline() { return this._textBaseline; } set textBaseline(value) { this._textBaseline = value; let baseline = 0; switch (value) { case 'alphabetic': baseline = 0; break; case 'middle': baseline = 1; break; case 'top': baseline = 2; break; case 'hanging': baseline = 3; break; case 'bottom': baseline = 4; break; case 'ideographic': baseline = 5; break; default: baseline = 0; break; } this._drawCommands = this._drawCommands.concat('E' + baseline + ';'); } get font() { return this._font; } set font(value) { this._font = value; this._drawCommands = this._drawCommands.concat('j' + value + ';'); } getLineDash() { return this._lineDash; } setLineDash(value) { if( Array.isArray(value) ) { this._lineDash = value; this._drawCommands = this._drawCommands.concat('I'+ value.length +','+ value.join(',') + ';'); } } setTransform(a, b, c, d, tx, ty) { this._drawCommands = this._drawCommands.concat('t' + (a === 1 ? '1' : a.toFixed(2)) + ',' + (b === 0 ? '0' : b.toFixed(2)) + ',' + (c === 0 ? '0' : c.toFixed(2)) + ',' + (d === 1 ? '1' : d.toFixed(2)) + ',' + tx.toFixed(2) + ',' + ty.toFixed(2) + ';'); } transform(a, b, c, d, tx, ty) { this._drawCommands = this._drawCommands.concat('f' + (a === 1 ? '1' : a.toFixed(2)) + ',' + (b === 0 ? '0' : b.toFixed(2)) + ',' + (c === 0 ? '0' : c.toFixed(2)) + ',' + (d === 1 ? '1' : d.toFixed(2)) + ',' + tx + ',' + ty + ';'); } resetTransform() { this._drawCommands = this._drawCommands.concat('m;'); } scale(a, d) { this._drawCommands = this._drawCommands.concat('k' + a.toFixed(2) + ',' + d.toFixed(2) + ';'); } rotate(angle) { this._drawCommands = this._drawCommands .concat('r' + angle.toFixed(6) + ';'); } translate(tx, ty) { this._drawCommands = this._drawCommands.concat('l' + tx.toFixed(2) + ',' + ty.toFixed(2) + ';'); } save() { this._savedGlobalAlpha.push(this._globalAlpha); this._drawCommands = this._drawCommands.concat('v;'); } restore() { this._drawCommands = this._drawCommands.concat('e;'); this._globalAlpha = this._savedGlobalAlpha.pop(); } createPattern(img, pattern) { return new FillStylePattern(img, pattern); } createLinearGradient(x0, y0, x1, y1) { return new FillStyleLinearGradient(x0, y0, x1, y1); } createRadialGradient = function(x0, y0, r0, x1, y1, r1) { return new FillStyleRadialGradient(x0, y0, r0, x1, y1, r1); }; strokeRect(x, y, w, h) { this._drawCommands = this._drawCommands.concat('s' + x + ',' + y + ',' + w + ',' + h + ';'); } clearRect(x, y, w, h) { this._drawCommands = this._drawCommands.concat('c' + x + ',' + y + ',' + w + ',' + h + ';'); } clip() { this._drawCommands = this._drawCommands.concat('p;'); } resetClip() { this._drawCommands = this._drawCommands.concat('q;'); } closePath() { this._drawCommands = this._drawCommands.concat('o;'); } moveTo(x, y) { this._drawCommands = this._drawCommands.concat('g' + x.toFixed(2) + ',' + y.toFixed(2) + ';'); } lineTo(x, y) { this._drawCommands = this._drawCommands.concat('i' + x.toFixed(2) + ',' + y.toFixed(2) + ';'); } quadraticCurveTo = function(cpx, cpy, x, y) { this._drawCommands = this._drawCommands.concat('u' + cpx + ',' + cpy + ',' + x + ',' + y + ';'); } bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y, ) { this._drawCommands = this._drawCommands.concat( 'z' + cp1x.toFixed(2) + ',' + cp1y.toFixed(2) + ',' + cp2x.toFixed(2) + ',' + cp2y.toFixed(2) + ',' + x.toFixed(2) + ',' + y.toFixed(2) + ';'); } arcTo(x1, y1, x2, y2, radius) { this._drawCommands = this._drawCommands.concat('h' + x1 + ',' + y1 + ',' + x2 + ',' + y2 + ',' + radius + ';'); } beginPath() { this._drawCommands = this._drawCommands.concat('b;'); } fillRect(x, y, w, h) { this._drawCommands = this._drawCommands.concat('n' + x + ',' + y + ',' + w + ',' + h + ';'); } rect(x, y, w, h) { this._drawCommands = this._drawCommands.concat('w' + x + ',' + y + ',' + w + ',' + h + ';'); } fill() { this._drawCommands = this._drawCommands.concat('L;'); } stroke(path) { this._drawCommands = this._drawCommands.concat('x;'); } arc(x, y, radius, startAngle, endAngle, anticlockwise) { let ianticlockwise = 0; if (anticlockwise) { ianticlockwise = 1; } this._drawCommands = this._drawCommands.concat( 'y' + x.toFixed(2) + ',' + y.toFixed(2) + ',' + radius.toFixed(2) + ',' + startAngle + ',' + endAngle + ',' + ianticlockwise + ';' ); } fillText(text, x, y) { let tmptext = text.replace(/!/g, '!!'); tmptext = tmptext.replace(/,/g, '!,'); tmptext = tmptext.replace(/;/g, '!;'); this._drawCommands = this._drawCommands.concat('T' + tmptext + ',' + x + ',' + y + ',0.0;'); } strokeText = function(text, x, y) { let tmptext = text.replace(/!/g, '!!'); tmptext = tmptext.replace(/,/g, '!,'); tmptext = tmptext.replace(/;/g, '!;'); this._drawCommands = this._drawCommands.concat('U' + tmptext + ',' + x + ',' + y + ',0.0;'); } measureText = function(text) { cancelAnimationFrame(this._canvas._renderLoopId); this.flushJsCommands2CallNative('sync', 'execWithoutDisplay'); let tmpMeasure = CanvasRenderingContext2D.GBridge.callNative( this.componentId, 'V' + text + ';', false, '2d', 'sync', 'execWithoutDisplay', ); this._canvas._renderLoopId = requestAnimationFrame(this._canvas._renderLoop.bind(this._canvas)); let textMetrics = { width: 37.343750, actualBoundingBoxAscent: 18.4, actualBoundingBoxDescent: 5.6, }; try { const {width, height, actualBoundingBoxAscent, actualBoundingBoxDescent} = JSON.parse(tmpMeasure); textMetrics.width = width; if (actualBoundingBoxAscent > 0 && actualBoundingBoxDescent > 0) { textMetrics.actualBoundingBoxAscent = actualBoundingBoxAscent; textMetrics.actualBoundingBoxDescent = actualBoundingBoxDescent; } else { textMetrics.actualBoundingBoxAscent = height * 1.15 / 1.5; textMetrics.actualBoundingBoxDescent = height - textMetrics.actualBoundingBoxAscent; } } catch (err) { __DEV__ && console.warn('measureText parse error:' + tmpMeasure); } return textMetrics; } isPointInPath = function(x, y) { throw new Error('GCanvas not supported yet'); } drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh) { const numArgs = arguments.length; let srcX, srcY, srcW, srcH, dstX, dstY, dstW, dstH; if (numArgs === 3) { srcX = 0; srcY = 0; srcW = image.width; srcH = image.height; dstX = parseFloat(sx) || 0.0; dstY = parseFloat(sy) || 0.0; dstW = image.width; dstH = image.height; } else if (numArgs === 5) { srcX = 0; srcY = 0; srcW = image.width; srcH = image.height; dstX = parseFloat(sx) || 0.0; dstY = parseFloat(sy) || 0.0; dstW = parseInt(sw) || image.width; dstH = parseInt(sh) || image.height; } else if (numArgs === 9) { srcX = parseFloat(sx) || 0.0; srcY = parseFloat(sy) || 0.0; srcW = parseInt(sw) || image.width; srcH = parseInt(sh) || image.height; dstX = parseFloat(dx) || 0.0; dstY = parseFloat(dy) || 0.0; dstW = parseInt(dw) || image.width; dstH = parseInt(dh) || image.height; } const imageIsCanvas = image.hasOwnProperty('nodeName') && image.nodeName.toLowerCase() === 'canvas'; if (imageIsCanvas) { if (!image._context) { return; } // If use 'execWithDisplay' here, will cause 2nd imageIsCanvas flick of // https://github.com/flyskywhy/GCanvasRNExamples/blob/master/app/components/AudioWaveSurfer.js // // Even if use flushJsCommands2CallNative here, will cause imageIsCanvas not display on screen if // not follow by other ctx.some(). this.flushJsCommands2CallNative('sync', 'execWithoutDisplay'); image._context.flushJsCommands2CallNative('sync'); let sCanvasId = image.id; // even README.md said "despite of values into `canvas.width =` and `canvas.height =`", // because canvas of sCanvasId is mostly comes from document.createElement('canvas') // that just like offscreen canvas, so it will use style {position: 'absolute'} and // that means it's clientWidth will not change (to avoid re-render as offscreen), so // it's meaningful to use canvas.width usually be changed after document.createElement('canvas') let sCanvasWidth = image.width; let sCanvasHeight = image.height; CanvasRenderingContext2D.GBridge.drawCanvas2Canvas({ srcComponentId: sCanvasId, dstComponentId: this.componentId, tw: sCanvasWidth, th: sCanvasHeight, sx: srcX, sy: srcY, sw: srcW, sh: srcH, dx: dstX, dy: dstY, dw: dstW, dh: dstH, }); } else { CanvasRenderingContext2D.GBridge.bindImageTexture(this.componentId, image.src, image._id); this._drawCommands += 'd' + image._id + ',' + srcX + ',' + srcY + ',' + srcW + ',' + srcH + ',' + dstX + ',' + dstY + ',' + dstW + ',' + dstH + ';'; // If use 'execWithDisplay' here, will cause 2nd image flick with 'forceCanvas = true' of // https://github.com/flyskywhy/GCanvasRNExamples/blob/master/app/components/Pixi.js , // because in '2d' not 'webgl' PIXI, every PIXI app tick will let all image sprites be drawImage(). // // Even if use flushJsCommands2CallNative here, will cause image not display on screen if // not follow by other ctx.some(). // this.flushJsCommands2CallNative('sync', 'execWithoutDisplay'); } } createImageData(widthOrImagedata, height) { if (arguments.length === 2) { return new ImageData(widthOrImagedata, height); } else { return new ImageData(widthOrImagedata.width, widthOrImagedata.height); } } // not a Web canvas API, used by @flyskywhy/react-native-gcanvas flushJsCommands2CallNative( methodType = 'async', optionType = 'execWithDisplay', ) { const commands = this._drawCommands; this._drawCommands = ''; // let cmds cache be empty if (commands !== '') { CanvasRenderingContext2D.GBridge.callNative( this.componentId, commands, false, '2d', methodType, optionType, ); } } // no need ctx.getImageData(x * PixelRatio.get(), y * PixelRatio.get(), w * PixelRatio.get(), h * PixelRatio.get()) // e.g. ctx.getImageData(0, 0, 2, 2) will return a `w === 2, h === 2` ImageData // since PixelsSampler() in GetImageData() of core/src/gcanvas/GCanvas2dContext.cpp // will scale the image automatically getImageData(sx, sy, sw, sh) { // if not stop _renderLoop(), sometimes will cause display issue with 'lightener' tool of https://github.com/flyskywhy/PixelShapeRN cancelAnimationFrame(this._canvas._renderLoopId); // If use 'async' here, will cause last commands be executed after getImageData's 'sync'. if (this._drawCommands.includes(';T') || this._drawCommands.startsWith('T')) { // Use 'sync' + 'execWithDisplay' here to make sure last graphics by fillText() be generated // before execute getImageData's 'R', ref to context.fillText in // https://github.com/flyskywhy/react-native-runescape-text/blob/main/src/classes/Motion.js // TODO: more commands other than 'T' if someone issue on github this.flushJsCommands2CallNative('sync', 'execWithDisplay'); } else { // If use 'execWithDisplay' here, will cause low JS FPS on iOS with 'lightener' tool of https://github.com/flyskywhy/PixelShapeRN , // because 'lightener' will call getImageData frequently while moving finger, will cause setNeedsDisplay() then // drawInRect() be invoked more than 1 times in 16ms e.g. 1times/1ms thus cause low JS FPS! this.flushJsCommands2CallNative('sync', 'execWithoutDisplay'); } // now can getImageData from last generated (even not displayed) graphics, otherwise will `return new ImageData(w, h)` // thus `this.ctx.putImageData()` has no effect with the first 'Click me to draw some on canvas' in README.md let x = sx; let y = sy; let w = sw; let h = sh; if (sx + sw > this.canvas.clientWidth) { x = 0; w = this.canvas.clientWidth; } if (sy + sh > this.canvas.clientHeight) { y = 0; h = this.canvas.clientHeight; } let base64Data = CanvasRenderingContext2D.GBridge.callNative( this.componentId, 'R' + x + ',' + y + ',' + w + ',' + h + ';', false, '2d', 'sync', 'execWithoutDisplay', ); // start _renderLoop() again, and thus display last generated graphics on screen // TODO: allow empty commands invoke callNative() once in flushJsCommands2CallNative() // while start _renderLoop() just in case no commands after getImageData's 'R' // thus no display // TODO: this.flushJsCommands2CallNative('sync', 'execWithDisplay') on Android // test with 'lightener' tool of https://github.com/flyskywhy/PixelShapeRN // to see if low JS FPS this._canvas._renderLoopId = requestAnimationFrame(this._canvas._renderLoop.bind(this._canvas)); if (base64Data === '') { console.warn('getImageData: not good to be here, should refactor source code somewhere'); return new ImageData(w, h); } return new ImageData(new Uint8ClampedArray(base64.toByteArray(base64Data)), w, h); } // no need ctx.getImageData(imageData, x * PixelRatio.get(), y * PixelRatio.get()) // and the imageData is also not `w * PixelRatio.get(), h * PixelRatio.get()` putImageData(imageData, x, y, dirtyX, dirtyY, dirtyWidth, dirtyHeight) { const base64Data = base64.fromByteArray(imageData.data); let tw = imageData.width; // textureWidth let th = imageData.height; let sx, sy, sw, sh; if (arguments.length === 3) { sx = 0; sy = 0; sw = tw; sh = th; } else { sx = dirtyX; sy = dirtyY; // dirtyWidth in https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/putImageData // means the width of the (src) image data sw = dirtyWidth; sh = dirtyHeight; } if (this._canvas._isAutoClearRectBeforePutImageData) { this.clearRect(x + sx, y + sy, sw, sh); } this._drawCommands = this._drawCommands.concat('P' + base64Data + ',' + tw + ',' + th + ',' + x + ',' + y + ',' + sx + ',' + sy + ',' + sw + ',' + sh + ';'); } }