From 95048b508635c6c49fbf54319eede41f5ee70dae Mon Sep 17 00:00:00 2001
From: Ales Menzel <ales.menzel@socialbakers.com>
Date: Tue, 2 May 2023 17:27:45 +0200
Subject: [PATCH] support context.clip(...) and context.fill(...) Path2D and
 fill-rule options

---
 package.json               |  4 ++--
 src/Container.ts           | 13 +++++++++----
 src/Context.ts             | 16 ++++++++--------
 src/Shape.ts               | 28 +++++++++++++++++++++++++--
 test/node-global-setup.mjs | 11 +++++++++++
 test/unit/Group-test.ts    | 39 ++++++++++++++++++++++++++++++++++++++
 test/unit/Path-test.ts     | 19 +++++++++++++++++++
 7 files changed, 114 insertions(+), 16 deletions(-)
 create mode 100644 test/node-global-setup.mjs

diff --git a/package.json b/package.json
index 7245339b..a3590d92 100644
--- a/package.json
+++ b/package.json
@@ -20,8 +20,8 @@
     "test": "npm run test:browser && npm run test:node",
     "test:build": "parcel build ./test/unit-tests.html --dist-dir ./test-build --target none --public-url ./ --no-source-maps",
     "test:browser": "npm run test:build && mocha-headless-chrome -f ./test-build/unit-tests.html -a disable-web-security",
-    "test:node": "ts-mocha -p ./test/tsconfig.json test/unit/**/*.ts --exit && npm run test:import",
-    "test:watch": "rm -rf ./parcel-cache && parcel serve ./test/unit-tests.html ./test/manual-tests.html ./test/sandbox.html ./test/text-paths.html",
+    "test:node": "ts-mocha -r ./test/node-global-setup.mjs -p ./test/tsconfig.json test/unit/**/*.ts --exit && npm run test:import",
+    "test:watch": "rm -rf ./.parcel-cache && parcel serve ./test/unit-tests.html ./test/manual-tests.html ./test/sandbox.html ./test/text-paths.html",
     "tsc": "tsc --removeComments",
     "rollup": "rollup -c --bundleConfigAsCjs",
     "clean": "rm -rf ./lib && rm -rf ./types && rm -rf ./cmj && rm -rf ./test-build",
diff --git a/src/Container.ts b/src/Container.ts
index a6abfed6..aefd685a 100644
--- a/src/Container.ts
+++ b/src/Container.ts
@@ -7,9 +7,10 @@ import { Shape } from './Shape';
 import { HitCanvas, SceneCanvas } from './Canvas';
 import { SceneContext } from './Context';
 
+export type ClipFuncOutput = void | [Path2D | CanvasFillRule] | [Path2D, CanvasFillRule]
 export interface ContainerConfig extends NodeConfig {
   clearBeforeDraw?: boolean;
-  clipFunc?: (ctx: SceneContext) => void;
+  clipFunc?: (ctx: SceneContext) => ClipFuncOutput;
   clipX?: number;
   clipY?: number;
   clipWidth?: number;
@@ -396,14 +397,15 @@ export abstract class Container<
       var m = transform.getMatrix();
       context.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
       context.beginPath();
+      let clipArgs;
       if (clipFunc) {
-        clipFunc.call(this, context, this);
+        clipArgs = clipFunc.call(this, context, this);
       } else {
         var clipX = this.clipX();
         var clipY = this.clipY();
         context.rect(clipX, clipY, clipWidth, clipHeight);
       }
-      context.clip();
+      context.clip.apply(context, clipArgs);
       m = transform.copy().invert().getMatrix();
       context.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
     }
@@ -519,7 +521,7 @@ export abstract class Container<
   // there was "this" instead of "Container<ChildType>",
   // but it breaks react-konva types: https://github.com/konvajs/react-konva/issues/390
   clipFunc: GetSet<
-    (ctx: CanvasRenderingContext2D, shape: Container<ChildType>) => void,
+    (ctx: CanvasRenderingContext2D, shape: Container<ChildType>) => ClipFuncOutput,
     this
   >;
 }
@@ -638,5 +640,8 @@ Factory.addGetterSetter(Container, 'clipFunc');
  * // set clip height
  * container.clipFunc(function(ctx) {
  *   ctx.rect(0, 0, 100, 100);
+ *
+ *   // optionally return a clip Path2D and clip-rule or just the clip-rule
+ *   return [new Path2D('M0 0v50h50Z'), 'evenodd']
  * });
  */
diff --git a/src/Context.ts b/src/Context.ts
index 54b4687e..4073342b 100644
--- a/src/Context.ts
+++ b/src/Context.ts
@@ -362,8 +362,10 @@ export class Context {
    * @method
    * @name Konva.Context#clip
    */
-  clip() {
-    this._context.clip();
+  clip(fillRule?: CanvasFillRule): void
+  clip(path: Path2D, fillRule?: CanvasFillRule): void
+  clip(...args: any[]) {
+    this._context.clip.apply(this._context, args);
   }
   /**
    * closePath function.
@@ -482,12 +484,10 @@ export class Context {
    * @method
    * @name Konva.Context#fill
    */
-  fill(path2d?: Path2D) {
-    if (path2d) {
-      this._context.fill(path2d);
-    } else {
-      this._context.fill();
-    }
+  fill(fillRule?: CanvasFillRule): void
+  fill(path: Path2D, fillRule?: CanvasFillRule): void
+  fill(...args: any[]) {
+    this._context.fill.apply(this._context, args);
   }
   /**
    * fillRect function.
diff --git a/src/Shape.ts b/src/Shape.ts
index 2c2cd393..2b9f5223 100644
--- a/src/Shape.ts
+++ b/src/Shape.ts
@@ -56,6 +56,7 @@ export interface ShapeConfig extends NodeConfig {
   fillRadialGradientColorStops?: Array<number | string>;
   fillEnabled?: boolean;
   fillPriority?: string;
+  fillRule?: CanvasFillRule
   stroke?: string | CanvasGradient;
   strokeWidth?: number;
   fillAfterStrokeEnabled?: boolean;
@@ -88,6 +89,8 @@ export interface ShapeGetClientRectConfig {
   relativeTo?: Node;
 }
 
+export type FillFuncOutput = void | [Path2D | CanvasFillRule] | [Path2D, CanvasFillRule]
+
 var HAS_SHADOW = 'hasShadow';
 var SHADOW_RGBA = 'shadowRGBA';
 var patternImage = 'patternImage';
@@ -112,7 +115,12 @@ export const shapes: { [key: string]: Shape } = {};
 // what color to use for hit test?
 
 function _fillFunc(context) {
-  context.fill();
+  const fillRule = this.fillRule()
+  if (fillRule) {
+    context.fill(fillRule);
+  } else {
+    context.fill();
+  }
 }
 function _strokeFunc(context) {
   context.stroke();
@@ -176,7 +184,7 @@ export class Shape<
   _centroid: boolean;
   colorKey: string;
 
-  _fillFunc: (ctx: Context) => void;
+  _fillFunc: (ctx: Context) => FillFuncOutput;
   _strokeFunc: (ctx: Context) => void;
   _fillFuncHit: (ctx: Context) => void;
   _strokeFuncHit: (ctx: Context) => void;
@@ -1987,6 +1995,22 @@ Factory.addGetterSetter(Shape, 'fillPatternRotation', 0);
  * shape.fillPatternRotation(20);
  */
 
+Factory.addGetterSetter(Shape, 'fillRule', undefined, getStringValidator());
+
+/**
+ * get/set fill rule
+ * @name Konva.Shape#fillRule
+ * @method
+ * @param {CanvasFillRule} rotation
+ * @returns {Konva.Shape}
+ * @example
+ * // get fill rule
+ * var fillRule = shape.fillRule();
+ *
+ * // set fill rule
+ * shape.fillRule('evenodd);
+ */
+
 Factory.backCompat(Shape, {
   dashArray: 'dash',
   getDashArray: 'getDash',
diff --git a/test/node-global-setup.mjs b/test/node-global-setup.mjs
new file mode 100644
index 00000000..b54de724
--- /dev/null
+++ b/test/node-global-setup.mjs
@@ -0,0 +1,11 @@
+export function mochaGlobalSetup() {
+  globalThis.Path2D ??= class Path2D {
+    constructor(path) {
+      this.path = path
+    }
+
+    get [Symbol.toStringTag]() {
+      return `Path2D`;
+    }
+  }
+}
diff --git a/test/unit/Group-test.ts b/test/unit/Group-test.ts
index 4dc2c6b9..f0cc602a 100644
--- a/test/unit/Group-test.ts
+++ b/test/unit/Group-test.ts
@@ -1,4 +1,5 @@
 import { addStage, cloneAndCompareLayer, Konva } from './test-utils';
+import { assert } from 'chai';
 
 describe('Group', function () {
   // ======================================================
@@ -45,4 +46,42 @@ describe('Group', function () {
 
     cloneAndCompareLayer(layer, 200);
   });
+
+  it('clip group with a Path2D', function () {
+    var stage = addStage();
+
+    var layer = new Konva.Layer();
+
+    var path = new Konva.Group({
+      width: 100,
+      height: 100,
+      clipFunc: () => [new Path2D('M0 0v50h50Z')]
+    });
+
+    layer.add(path);
+    stage.add(layer);
+
+    const trace = layer.getContext().getTrace()
+
+    assert.equal(trace, 'clearRect(0,0,578,200);save();transform(1,0,0,1,0,0);beginPath();clip([object Path2D]);transform(1,0,0,1,0,0);restore();');
+  });
+
+  it('clip group with a Path2D and clipRule', function () {
+    var stage = addStage();
+
+    var layer = new Konva.Layer();
+
+    var path = new Konva.Group({
+      width: 100,
+      height: 100,
+      clipFunc: () => [new Path2D('M0 0v50h50Z'), 'evenodd'],
+    });
+
+    layer.add(path);
+    stage.add(layer);
+
+    const trace = layer.getContext().getTrace()
+
+    assert.equal(trace, 'clearRect(0,0,578,200);save();transform(1,0,0,1,0,0);beginPath();clip([object Path2D],evenodd);transform(1,0,0,1,0,0);restore();');
+  });
 });
diff --git a/test/unit/Path-test.ts b/test/unit/Path-test.ts
index bddfd6e0..e8cdff82 100644
--- a/test/unit/Path-test.ts
+++ b/test/unit/Path-test.ts
@@ -1603,4 +1603,23 @@ describe('Path', function () {
 
     assert.equal(trace1, trace2);
   });
+
+  it('draw path with fillRule', function () {
+    var stage = addStage();
+
+    var layer = new Konva.Layer();
+
+    var path = new Konva.Path({
+      data: 'M200,100h100v50z',
+      fill: '#ccc',
+      fillRule: 'evenodd',
+    });
+
+    layer.add(path);
+    stage.add(layer);
+
+    const trace = layer.getContext().getTrace()
+
+    assert.equal(trace, 'clearRect(0,0,578,200);save();transform(1,0,0,1,0,0);beginPath();moveTo(200,100);lineTo(300,100);lineTo(300,150);closePath();fillStyle=#ccc;fill(evenodd);restore();');
+  });
 });