Konva is easier to use and performs better than older fabrics, thanks to its internal design. This paper analyzes Konva from the following aspects:

  • Extensions to the base element and context
  • Graphics transformation processing (transformation calculation and independent graphics controller)
  • Cursor interaction processing (Pixel-based target detection)
  • Hierarchical rendering processing

Series directory

  • A brief analysis of the Canvas2D rendering library: Fabric
  • Brief analysis of Canvas2D rendering library :(2) Konva
  • Canvas2D rendering library brief analysis :(3) Pixi

Konva.js

Konva describes itself as a Canvas library that extends the 2D context to make its functionality interactive on both desktop and mobile, including high performance animations, transforms, node nesting, event handling, layering, and more.

Konva, which originated from Eric’s KineticJS project and was a bit younger than Fabric, was partially rewritten in TypeScript in early 19 and was on its way to modernization. Now it looks like it’s written in TS, but because of the consistency of the API, you can see the history in some strange places.

Konva.js version 4.1.0 is used in this article

Base elements and context extensions

Element use and customization

Here’s an example of how it can be used

See the Pen konva-base-and-custom-elements by yrq110 (@yrq110) on CodePen.

You can use some of the built-in graphics elements, such as rectangles, circles, and so on, as well as custom graphics.

When you customize a graph, you implement its drawing method, sceneFunc, and you can customize its collision detection region by implementing hitFunc, which is not present in the fabric.

Basic elements

Konva has a number of different base elements designed to manage the canvas hierarchy and graphics, which can be used to form a nested layer tree.

Among them:

  • Stages contain multiple drawing layers
  • You can add Shape or Group elements to Layer
  • Shape is the most fine-grained element, that is, a concrete graphic object
  • Groups are container elements used to manage multiple Shapes or other groups
  • eachLayerContains two internally<canvas>Elements, Scene Graph and Hit Graph
    • The scene layer contains the graphics that are drawn, that is, the graphics that are actually seen
    • The interaction layer is used for high performance interaction event detection
  • The base classes of the above elements are Node

The structure of a Konva graph tree is as follows:

Stage ├ ─ ─ Layer | ├ ─ ─ Group | └ ─ ─ Shape | └ ─ ─ Group | ├ ─ ─ Shape | └ ─ ─ Group | └ ─ ─ Shape └ ─ ─ Layer └ ─ ─ ShapeCopy the code

Context extension

You can use the CANVAS’s 2D context to manipulate the stack of states containing properties such as styles, transforms, and clipping. Konva encapsulates context objects, including API compatibility and parameter handling, property Settings for a given scene, and so on.

API handling:

// Use it directly
moveTo(a0, a1) {
  this._context.moveTo(a0, a1);
}
// Simply check parameters
createImageData(a0, a1) {
  var a = arguments;
  if (a.length === 2) {
    return this._context.createImageData(a0, a1);
  } else if (a.length === 1) {
    return this._context.createImageData(a0); }}// Compatibility processing
setLineDash(a0) {
  // works for Chrome and IE11
  if (this._context.setLineDash) {
    this._context.setLineDash(a0);
  } else if ('mozDash' in this._context) {
    // verified that this works in firefox
    (this._context['mozDash']) = a0;
  } else if ('webkitLineDash' in this._context) {
    // does not currently work for Safari
    (this._context['webkitLineDash']) = a0;
  }
  // no support for IE9 and IE10
}
Copy the code

Prepare special Context for SceneCanvas and HitCanvas: SceneContext and HitContext

Both are Context objects bound to Layer SceneCanvas and HitCanvas, which inherit from Context and implement their respective _fill() and _stroke() methods. As HitContext:

export class HitContext extends Context {
  _fill(shape) {
    this.save();
    this.setAttr('fillStyle', shape.colorKey);
    shape._fillFuncHit(this);
    this.restore();
  }
  _stroke(shape) {
    if (shape.hasHitStroke()) {
      this._applyLineCap(shape);
      var hitStrokeWidth = shape.hitStrokeWidth();
      var strokeWidth =
        hitStrokeWidth === 'auto' ? shape.strokeWidth() : hitStrokeWidth;
      this.setAttr('lineWidth', strokeWidth);
      this.setAttr('strokeStyle', shape.colorKey);
      shape._strokeFuncHit(this);
      if(! strokeScaleEnabled) {this.restore(); }}}}Copy the code

Canvas extension and Layer use:

export class HitCanvas extends Canvas {
  hitCanvas = true;
  constructor(config: ICanvasConfig = { width: 0.height: 0{})super(config);
    this.context = new HitContext(this);
    this.setSize(config.width, config.height); }}export class Layer extends BaseLayer {
  hitCanvas = new HitCanvas({
    pixelRatio: 1
  });
}
Copy the code

Graphic transformation processing

Transform properties, operations and matrix processing

Similar to Fabric, transformation properties are modified by explicitly invoking Node transformation methods or controller, and then the transformation matrix is computed and re-rendered. The Trasnform class is used to manage the relationship between operations and matrices.

The process of converting a transform attribute into a transform matrix in Konva: Attribute => Transform operation => Transform matrix

Transform attribute => Transform operation

_getTransform(): Transform {
    var m = new Transform();
    if(x ! = =0|| y ! = =0) {
      m.translate(x, y);
    }
    if(rotation ! = =0) {
      m.rotate(rotation);
    }
    if(scaleX ! = =1|| scaleY ! = =1) {
      m.scale(scaleX, scaleY);
    }
    // ...
    return m;
}
Copy the code

Transform operation => Transform matrix

export class Transform {
  m: Array<number>;
  constructor(m = [1, 0, 0, 1, 0, 0]) {
    this.m = (m && m.slice()) || [1.0.0.1.0.0];
  }
  translate(x: number, y: number) {
    this.m[4] + =this.m[0] * x + this.m[2] * y;
    this.m[5] + =this.m[1] * x + this.m[3] * y;
    return this;
  }
  scale(sx: number, sy: number) {
    this.m[0] *= sx;
    this.m[1] *= sx;
    this.m[2] *= sy;
    this.m[3] *= sy;
    return this;
  }
  // ...
}
Copy the code

Graphic controller transformation processing

The controller uses a Transformer implementation separate from the Node element

See the Pen konva-control by yrq110 (@yrq110) on CodePen.

To do this, create a Transformer object and attach it to the Shape you want to control using **attachTo()**.

Compared with controllers in the Fabric, not only the usage method is different, but the internal processing is very different. The processing process is as follows:

The first is to bind the controller to the node

attachTo(node) {
  this.setNode(node);
}
setNode(node) {
  // Bind the node to clear the cache
  this._node = node;
  this._resetTransformCache();
  // Listen for node properties to change, update controller in callback
  const onChange = (a)= > {
    this._resetTransformCache();
    if (!this._transforming) {
      this.update(); }}; node.on(additionalEvents, onChange); node.on(TRANSFORM_CHANGE_STR, onChange); } update() {// ...
  // Update properties such as the location of each controller
  this.findOne('.top-left').setAttrs({
    x: -padding,
    y: -padding,
    scale: invertedScale,
    visible: resizeEnabled && enabledAnchors.indexOf('top-left') > =0
  });
  // ...
}
Copy the code

The second is the process of event monitoring and transformation

  1. Add mouseDown event listeners on each controller during initialization

    _createAnchor(name) {
      var anchor = newRect({... });var self = this;
      anchor.on('mousedown touchstart'.function(e) {
       self._handleMouseDown(e);
      });
    }
    Copy the code
  2. Add mousemove event listening when the callback is triggered

    _handleMouseDown(e) {
      window.addEventListener('mousemove'.this._handleMouseMove);
      window.addEventListener('touchmove'.this._handleMouseMove);
    }
    Copy the code
  3. Calculate the change of movement and update the controller position to be changed

    _handleMouseMove(e) {
      // ...
      if (this._movingAnchorName === 'bottom-center') {
        this.findOne('.bottom-right').y(anchorNode.y());
      } else if (this._movingAnchorName === 'bottom-right') {
        if (keepProportion) {
          newHypotenuse = Math.sqrt( Math.pow(this.findOne('.bottom-right').x() - padding, 2) + Math.pow(this.findOne('.bottom-right').y() - padding, 2));
          var reverseX = this.findOne('.top-left').x() > this.findOne('.bottom-right').x() ? - 1 : 1;
          var reverseY = this.findOne('.top-left').y() > this.findOne('.bottom-right').y() ? - 1 : 1;
          x = newHypotenuse * this.cos * reverseX;
          y = newHypotenuse * this.sin * reverseY;
          this.findOne('.bottom-right').x(x + padding);
          this.findOne('.bottom-right').y(y + padding); }}else if (this._movingAnchorName === 'rotater') {
      // ...
    }
    Copy the code
  4. By calculating the region formed by the position of the controller after the change, the transformation region that the node needs to adapt to is obtained

    _handleMouseMove(e) {
      // ...
      x = absPos.x;
      y = absPos.y;
      var width = this.findOne('.bottom-right').x() - this.findOne('.top-left').x();
      var height = this.findOne('.bottom-right').y() - this.findOne('.top-left').y();
      this._fitNodeInto(
        {
          x: x + this.offsetX(),
          y: y + this.offsetY(),
          width: width,
          height: height
        },
        e
      );
    }
    Copy the code
  5. Calculate the size and position attributes of the node after the change according to this area

    this.getNode().setAttrs({
      scaleX: scaleX,
      scaleY: scaleY,
      x: newAttrs.x - (dx * Math.cos(rotation) + dy * Math.sin(-rotation)),
      y: newAttrs.y - (dy * Math.cos(rotation) + dx * Math.sin(rotation))
    });
    Copy the code
  6. Redraw in the next rAF render

    // src/shapes/Transformer.ts
    this.getLayer().batchDraw();
    // src/BaseLayer.ts
    batchDraw() {
      if (!this._waitingForDraw) {
        this._waitingForDraw = true;
        Util.requestAnimFrame((a)= > {
          this.draw();
          this._waitingForDraw = false;
        });
      }
      return this;
    }
    Copy the code

Interactive event processing

Target detection

Konva uses a pixel-based approach to determine cursor collisions with graphics, not geometry.

The main process of target detection is as follows:

  1. Stage::_mousedown => Stage::getIntersection

    Listen for mouse events on the topmost Stage and look up the target graph from the topmost layer based on cursor position and the selector passed in

    for (n = end; n >= 0; n--) {
      shape = layers[n].getIntersection(pos, selector);
      if (shape) {
        returnshape; }}Copy the code
  2. Layer::getIntersection

    // Use INTERSECTION_OFFSETS to extend the range of the cursor to make it easier to generate intersecting cases
    for (i = 0; i < INTERSECTION_OFFSETS_LEN; i++) {
      intersectionOffset = INTERSECTION_OFFSETS[i];
      // Calculate the intersection object
      obj = this._getIntersection({
        x: pos.x + intersectionOffset.x * spiralSearchDistance,
        y: pos.y + intersectionOffset.y * spiralSearchDistance
      });
      shape = obj.shape;
      // If a graph exists and contains an element selector, look for its ancestor, such as 'Group', otherwise return the graph directly
      if (shape && selector) {
        return shape.findAncestor(selector, true);
      } else if (shape) {
        returnshape; }}Copy the code
  3. Layer::_getInersection The core part of target detection is here

    // Get the pixel value of the cursor position in the hitCanvas context
    var p = this.hitCanvas.context.getImageData(Math.round(pos.x * ratio), Math.round(pos.y * ratio), 1.1).data;
    // Convert RGA to HEX, compared with shape colorKey
    var colorKey = Util._rgbToHex(p[0], p[1], p[2]);
    // Shapes contains all the added graphics objects, each with a random HEX color for its key
    var shape = shapes[The '#' + colorKey];
    // If the color of the current position in the Hit graph is the same as the color representing the graph, then that graph is the object that the cursor hit
    if (shape) { return { shape: shape }; }
    Copy the code
  4. Stage::targetShape

    Once you get the targetShape, various interactive events are triggered

    this.targetShape._fireAndBubble(SOME_MOUSE_EVENT, { evt: evt, pointerId });
    Copy the code

In order to determine whether a hit is hit by comparing the cursor position on the Hit graph with the pixel value representing the key of the graph, it is necessary to draw the Hit graph of the Shape object on the HitCanvas of the layer in advance. The following work is done in this part:

  • When you create a graph, you generate a unique key for that graph, which is a random color

    // Generate a unique key
    while (true) {
      key = Util.getRandomColor();
      if(key && ! (keyin shapes)) { break; }}// Save the color for later hit graph drawing
    this.colorKey = key;
    // This object is saved in shapes objects for query at target detection time
    shapes[key] = this;
    Copy the code
  • When a graphic is added to layer, its SceneCanvas and HitCanvas are drawn when layer.draw() is executed

    // Layer::draw() => Node::draw()
    draw() {
      this.drawScene();
      this.drawHit();
      return this;
    }
    // Layer::drawHit() => Container::drawHit(). Container::drawHit().
    this._drawChildren(canvas, 'drawHit', top, false, caching, caching);
    // Container::_drawChildren()
    this.children.each(function(child) {
      // drawHit() is executed on each child element, which is of type Shape or Group
      child[drawMethod](canvas, top, caching, skipBuffer);
    });
    // Shape::drawHit
    drawHit(can) {
      // Get the _hitFunc or _sceneFunc implemented in the built-in or custom Shape object
      var drawFunc = this.hitFunc() || this.sceneFunc();
      context.save(); // The context is a HitContext object
      layer._applyTransform(this, context, top);
      drawFunc.call(this, context, this);
      context.restore();
    }
    Copy the code

There is also a problem in drawing the HitCanvas. It does not show that the color of colorKey is used to draw the HitCanvas. In fact, the fillStyle setting operation has occurred before, in the HitContext class:

export class HitContext extends Context {
  _fill(shape) {
    this.save();
    // Set the fill style for hit Graph here
    this.setAttr('fillStyle', shape.colorKey);
    shape._fillFuncHit(this); // => this.fill()
    this.restore(); }}Copy the code

Element rendering processing

use

Take the example of adding a Layer and a Shape to the Stage to see how hierarchical rendering is handled.

To display a graphic on the interface, use the following steps:

  1. Create a Stagelet stage = new Konva.Stage()
  2. Create a Layerlet layer = new Konva.Layer()
  3. Create a Shapelet box = new Konva.Rect()
  4. Add Shape to Layerlayer.add(box)
  5. Add Layer on Stagestage.add(layer)

You will then see a rectangle displayed on the screen.

If a new shape is added to layer: layer.add(new_box), you can see that the new shape is not displayed and need to execute layer.draw() again. If you change the order based on the above steps, to achieve the same effect, it becomes:

  1. Create a Stagelet stage = new Konva.Stage()
  2. Create a Layerlet layer = new Konva.Layer()
  3. Add Layer on Stagestage.add(layer)
  4. Create a Shapelet box = new Konva.Rect()
  5. Add Shape to Layerlayer.add(box)
  6. Perform Layer drawinglayer.draw()

The principle of

The Add method of Stage draws the layer content and inserts the layer SceneCanvas element into the DOM tree

add(layer) {
  // Handle the current parent-child relationship of the layer in the parent class Container
  super.add(layer);
  // Set the current size
  layer._setCanvasSize(this.width(), this.height());
  // Draw the contents of the layer
  layer.draw();
  // Insert the Canvas element into the DOM tree
  if (Konva.isBrowser) {
    // Just add SceneCanvas, not HitCanvas
    this.content.appendChild(layer.canvas._canvas); }}Copy the code

Layer does not implement its own add method. By default, the add method in Container is implemented

add(... children: ChildType[]) {var child = arguments[0];
   // 1. If there are parents, adopt them.
   if (child.getParent()) {
     child.moveTo(this);
     return this;
   }
   var _children = this.children;
   // 2. Verify the availability of child
   this._validateAdd(child);
   child.index = _children.length;
   child.parent = this;
   // 3. Save to the children array
   _children.push(child);
}
Copy the code

The Layer’s draw() method, as mentioned in the target detection section above, executes the related draw methods for each child in children in turn.

One thing to note: When draw() is executed on the Stage object, it empties and redraws the contents of all layers because Layer, as a child of the Stage, empties the contents according to its clearBeforeDraw property (default true) when executing its drawScene method. Then perform the drawing.

// src/Layer.ts
drawScene(can, top) {
  var layer = this.getLayer(),
    canvas = can || (layer && layer.getCanvas());  
  if (this.clearBeforeDraw()) {
    canvas.getContext().clear();
  }
  Container.prototype.drawScene.call(this, canvas, top);
  return this;
}
Copy the code

This should make it clear that drawing is not actually performed when adding graphics to the layer, so draw() needs to be executed manually when the graphics that the layer contains change, whereas drawing () of the layer object is automatically performed internally by the stage when the layer is added to the stage, so no explicit call is required.

The last

Konva’s main modules are also years old, but I personally feel that modularity is better than Fabric, both in terms of more flexible hierarchical management and component customization. Second, thanks to the TS rewrite, and thanks to editors and code AIDS, both the source code and use are easier to read.

Due to their own business is written in the original, some parts of the implementation of the framework and these ideas also happen to coincide, but more places or frameworks are designed well, worth learning from a lot of places.

reference

  • Github.com/konvajs/kon…
  • Github.com/ericdrowell…