From db18dc3e886566491ee3575bef743aa97ac570bf Mon Sep 17 00:00:00 2001 From: Joshua Tenner Date: Thu, 21 Nov 2019 13:11:35 -0500 Subject: [PATCH] feat(clip): add clippath --- ...vasRenderingContext2D.__getClippingPath.js | 55 ++++ ...nderingContext2D.__getClippingPath.js.snap | 267 ++++++++++++++++++ package.json | 12 +- src/classes/CanvasRenderingContext2D.js | 73 +++-- types/index.d.ts | 8 + 5 files changed, 386 insertions(+), 29 deletions(-) create mode 100644 __tests__/classes/CanvasRenderingContext2D.__getClippingPath.js create mode 100644 __tests__/classes/__snapshots__/CanvasRenderingContext2D.__getClippingPath.js.snap diff --git a/__tests__/classes/CanvasRenderingContext2D.__getClippingPath.js b/__tests__/classes/CanvasRenderingContext2D.__getClippingPath.js new file mode 100644 index 0000000..fd2318c --- /dev/null +++ b/__tests__/classes/CanvasRenderingContext2D.__getClippingPath.js @@ -0,0 +1,55 @@ +let ctx; +beforeEach(() => { + // get a new context each test + ctx = document.createElement('canvas') + .getContext('2d'); +}); + +afterEach(() => { + const drawCalls = ctx.__getClippingRegion(); + expect(drawCalls).toMatchSnapshot(); +}); + +describe('__getClippingRegion', () => { + it("should be empty when there are no path elements", () => { + ctx.clip(); + }); + + it("should store the clipping region", () => { + ctx.rect(1, 2, 3, 4); + ctx.arc(1, 2, 3, 4, 5); + ctx.clip(); + }); + + it("shouldn't store the whole clipping region twice when clip is called twice", () => { + ctx.rect(1, 2, 3, 4); + ctx.arc(1, 2, 3, 4, 5); + ctx.clip(); + ctx.rect(1, 2, 3, 4); + ctx.arc(1, 2, 3, 4, 5); + ctx.clip(); + }); + + it("should save the clipping region correctly when saved", () => { + ctx.rect(1, 2, 3, 4); + ctx.arc(1, 2, 3, 4, 5); + ctx.clip(); + const region = ctx.__getClippingRegion(); + ctx.save(); + expect(region).toStrictEqual(ctx.__getClippingRegion()); + }); + + it("should save the clipping region correctly when saved", () => { + ctx.rect(1, 2, 3, 4); + ctx.arc(1, 2, 3, 4, 5); + ctx.clip(); + const region = ctx.__getClippingRegion(); + ctx.save(); + ctx.rect(1, 2, 3, 4); + ctx.arc(1, 2, 3, 4, 5); + ctx.clip(); + expect(region).not.toStrictEqual(ctx.__getClippingRegion()); + ctx.restore(); + expect(region).toStrictEqual(ctx.__getClippingRegion()); + }); +}); diff --git a/__tests__/classes/__snapshots__/CanvasRenderingContext2D.__getClippingPath.js.snap b/__tests__/classes/__snapshots__/CanvasRenderingContext2D.__getClippingPath.js.snap new file mode 100644 index 0000000..7dd3119 --- /dev/null +++ b/__tests__/classes/__snapshots__/CanvasRenderingContext2D.__getClippingPath.js.snap @@ -0,0 +1,267 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`__getClippingRegion should be empty when there are no path elements 1`] = `Array []`; + +exports[`__getClippingRegion should save the clipping region correctly when saved 1`] = ` +Array [ + Object { + "props": Object { + "height": 4, + "width": 3, + "x": 1, + "y": 2, + }, + "transform": Array [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "rect", + }, + Object { + "props": Object { + "anticlockwise": false, + "endAngle": 5, + "radius": 3, + "startAngle": 4, + "x": 1, + "y": 2, + }, + "transform": Array [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arc", + }, +] +`; + +exports[`__getClippingRegion should save the clipping region correctly when saved 2`] = ` +Array [ + Object { + "props": Object { + "height": 4, + "width": 3, + "x": 1, + "y": 2, + }, + "transform": Array [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "rect", + }, + Object { + "props": Object { + "anticlockwise": false, + "endAngle": 5, + "radius": 3, + "startAngle": 4, + "x": 1, + "y": 2, + }, + "transform": Array [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arc", + }, +] +`; + +exports[`__getClippingRegion should store the clipping region 1`] = ` +Array [ + Object { + "props": Object { + "height": 4, + "width": 3, + "x": 1, + "y": 2, + }, + "transform": Array [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "rect", + }, + Object { + "props": Object { + "anticlockwise": false, + "endAngle": 5, + "radius": 3, + "startAngle": 4, + "x": 1, + "y": 2, + }, + "transform": Array [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arc", + }, +] +`; + +exports[`__getClippingRegion shouldn't store the whole clipping region twice when clip is called twice 1`] = ` +Array [ + Object { + "props": Object { + "height": 4, + "width": 3, + "x": 1, + "y": 2, + }, + "transform": Array [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "rect", + }, + Object { + "props": Object { + "anticlockwise": false, + "endAngle": 5, + "radius": 3, + "startAngle": 4, + "x": 1, + "y": 2, + }, + "transform": Array [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arc", + }, + Object { + "props": Object { + "fillRule": "nonzero", + "path": Array [ + Object { + "props": Object {}, + "transform": Array [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "beginPath", + }, + Object { + "props": Object { + "height": 4, + "width": 3, + "x": 1, + "y": 2, + }, + "transform": Array [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "rect", + }, + Object { + "props": Object { + "anticlockwise": false, + "endAngle": 5, + "radius": 3, + "startAngle": 4, + "x": 1, + "y": 2, + }, + "transform": Array [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arc", + }, + ], + }, + "transform": Array [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "clip", + }, + Object { + "props": Object { + "height": 4, + "width": 3, + "x": 1, + "y": 2, + }, + "transform": Array [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "rect", + }, + Object { + "props": Object { + "anticlockwise": false, + "endAngle": 5, + "radius": 3, + "startAngle": 4, + "x": 1, + "y": 2, + }, + "transform": Array [ + 1, + 0, + 0, + 1, + 0, + 0, + ], + "type": "arc", + }, +] +`; diff --git a/package.json b/package.json index c6d459c..f639ed7 100644 --- a/package.json +++ b/package.json @@ -15,16 +15,16 @@ "parse-color": "^1.0.0" }, "devDependencies": { - "@babel/cli": "^7.6.4", - "@babel/core": "^7.6.4", - "@babel/plugin-proposal-class-properties": "^7.5.5", - "@babel/preset-env": "^7.6.3", + "@babel/cli": "^7.7.0", + "@babel/core": "^7.7.2", + "@babel/plugin-proposal-class-properties": "^7.7.0", + "@babel/preset-env": "^7.7.1", "@commitlint/cli": "^8.2.0", "@commitlint/config-angular": "^8.2.0", "babel-jest": "^24.9.0", "babel-plugin-version": "^0.2.3", - "coveralls": "^3.0.7", - "husky": "^3.0.9", + "coveralls": "^3.0.8", + "husky": "^3.1.0", "jest": "^24.9.0" }, "commitlint": { diff --git a/src/classes/CanvasRenderingContext2D.js b/src/classes/CanvasRenderingContext2D.js index 325c267..73ff9ae 100644 --- a/src/classes/CanvasRenderingContext2D.js +++ b/src/classes/CanvasRenderingContext2D.js @@ -4,6 +4,7 @@ import parseColor from 'parse-color'; import cssfontparser from 'cssfontparser'; import TextMetrics from './TextMetrics'; import createCanvasEvent from '../mock/createCanvasEvent'; +import Path2D from "./Path2D"; function parseCSSColor(value) { const result = parseColor(value); @@ -83,9 +84,18 @@ export default class CanvasRenderingContext2D { getTransformSlice(this), { }, ); + // The clipping path should start after the initial beginPath instruction + this._clipIndex = 1; this._path = [event]; } + /** + * Get the current clipping region. + */ + __getClippingRegion() { + return this._clipStack[this._stackIndex]; + } + _directionStack = ['inherit']; _fillStyleStack = ['#000']; _filterStack = ['none']; @@ -109,6 +119,7 @@ export default class CanvasRenderingContext2D { _textAlignStack = ['start']; _textBaselineStack = ['alphabetic']; _transformStack = [[1, 0, 0, 1, 0, 0]]; + _clipStack=[[]]; constructor(canvas) { testFuncs.forEach(key => { @@ -247,20 +258,32 @@ export default class CanvasRenderingContext2D { this._drawCalls.push(event); } + /** + * This index points to the next path item that should be written to the clipStack + * when ctx.clip() is called. + */ + _clipIndex = 1; + clip(path, fillRule) { + let clipPath; if (arguments.length === 0) { fillRule = 'nonzero'; path = this._path.slice(); + clipPath = path.slice(this._clipIndex); + this._clipIndex = path.length; } else { if (arguments.length === 1) fillRule = 'nonzero'; if (path instanceof Path2D) { fillRule = String(fillRule); if (fillRule !== 'nonzero' && fillRule !== 'evenodd') throw new TypeError('Failed to execute \'clip\' on \'' + this.constructor.name + '\': The provided value \'' + fillRule + '\' is not a valid enum value of type CanvasFillRule.'); path = path._path.slice(); + clipPath = path; } else { fillRule = String(path); if (fillRule !== 'nonzero' && fillRule !== 'evenodd') throw new TypeError('Failed to execute \'clip\' on \'' + this.constructor.name + '\': The provided value \'' + fillRule + '\' is not a valid enum value of type CanvasFillRule.'); path = this._path.slice(); + clipPath = path.slice(this._clipIndex); + this._clipIndex = path.length; } } @@ -272,6 +295,8 @@ export default class CanvasRenderingContext2D { this._path.push(event); this._events.push(event); + const currentClip = this._clipStack[this._stackIndex]; + this._clipStack[this._stackIndex] = currentClip.concat(clipPath); } closePath() { @@ -1100,29 +1125,31 @@ export default class CanvasRenderingContext2D { } save() { - this._transformStack.push(this._transformStack[this._stackIndex].slice()); - this._directionStack.push(this._directionStack[this._stackIndex]); - this._fillStyleStack.push(this._fillStyleStack[this._stackIndex]); - this._filterStack.push(this._filterStack[this._stackIndex]); - this._fontStack.push(this._fontStack[this._stackIndex]); - this._globalAlphaStack.push(this._globalAlphaStack[this._stackIndex]); - this._globalCompositeOperationStack.push(this._globalCompositeOperationStack[this._stackIndex]); - this._imageSmoothingEnabledStack.push(this._imageSmoothingEnabledStack[this._stackIndex]); - this._imageSmoothingQualityStack.push(this._imageSmoothingQualityStack[this._stackIndex]); - this._lineCapStack.push(this._lineCapStack[this._stackIndex]); - this._lineDashStack.push(this._lineDashStack[this._stackIndex]); - this._lineDashOffsetStack.push(this._lineDashOffsetStack[this._stackIndex]); - this._lineJoinStack.push(this._lineJoinStack[this._stackIndex]); - this._lineWidthStack.push(this._lineWidthStack[this._stackIndex]); - this._miterLimitStack.push(this._miterLimitStack[this._stackIndex]); - this._shadowBlurStack.push(this._shadowBlurStack[this._stackIndex]); - this._shadowColorStack.push(this._shadowColorStack[this._stackIndex]); - this._shadowOffsetXStack.push(this._shadowOffsetXStack[this._stackIndex]); - this._shadowOffsetYStack.push(this._shadowOffsetYStack[this._stackIndex]); - this._strokeStyleStack.push(this._strokeStyleStack[this._stackIndex]); - this._textAlignStack.push(this._textAlignStack[this._stackIndex]); - this._textBaselineStack.push(this._textBaselineStack[this._stackIndex]); - this._stackIndex += 1; + const stackIndex = this._stackIndex; + this._transformStack.push(this._transformStack[stackIndex].slice()); + this._directionStack.push(this._directionStack[stackIndex]); + this._fillStyleStack.push(this._fillStyleStack[stackIndex]); + this._filterStack.push(this._filterStack[stackIndex]); + this._fontStack.push(this._fontStack[stackIndex]); + this._globalAlphaStack.push(this._globalAlphaStack[stackIndex]); + this._globalCompositeOperationStack.push(this._globalCompositeOperationStack[stackIndex]); + this._imageSmoothingEnabledStack.push(this._imageSmoothingEnabledStack[stackIndex]); + this._imageSmoothingQualityStack.push(this._imageSmoothingQualityStack[stackIndex]); + this._lineCapStack.push(this._lineCapStack[stackIndex]); + this._lineDashStack.push(this._lineDashStack[stackIndex]); + this._lineDashOffsetStack.push(this._lineDashOffsetStack[stackIndex]); + this._lineJoinStack.push(this._lineJoinStack[stackIndex]); + this._lineWidthStack.push(this._lineWidthStack[stackIndex]); + this._miterLimitStack.push(this._miterLimitStack[stackIndex]); + this._shadowBlurStack.push(this._shadowBlurStack[stackIndex]); + this._shadowColorStack.push(this._shadowColorStack[stackIndex]); + this._shadowOffsetXStack.push(this._shadowOffsetXStack[stackIndex]); + this._shadowOffsetYStack.push(this._shadowOffsetYStack[stackIndex]); + this._strokeStyleStack.push(this._strokeStyleStack[stackIndex]); + this._textAlignStack.push(this._textAlignStack[stackIndex]); + this._textBaselineStack.push(this._textBaselineStack[stackIndex]); + this._clipStack.push(this._clipStack[stackIndex].slice()); + this._stackIndex = stackIndex + 1; const event = createCanvasEvent( 'save', diff --git a/types/index.d.ts b/types/index.d.ts index 1acca41..9882705 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -82,5 +82,13 @@ declare global { * `jest-canvas-mock` and should be only used for testing. */ __clearPath(): void; + + /** + * Obtains the current clipping path. + * + * This method cannot be used in a production environment, only with `jest` using + * `jest-canvas-mock` and should be only used for testing. + */ + __getClippingRegion(): CanvasRenderingContext2DEvent[]; } }