pouzzler
pouzzler

Reputation: 1834

Correct programming pattern to add methods and fields to canvas in javascript

I would like to create an html canvas class, managed with js, in order to drag & drop Path2Ds, zoom, and translate by drag & drop, so that everything is cleanly done on a class, not an instance. Canvas is not a class and can't be extended. Touching its prototype seems extremely sketchy, in particular after searching SO.

Something ideal would look like

class MyCanvas extends canvas {
constructor() {
    this.2DShapes= [];
    this.addEventListener('mousedown', this.onmousedown, false);
    ...
}

add2DShape(name, x, y, angle = 0, strokeStyle, fillStyle, lineWidth ) {
    let shape = new 2DShape(name, x, y, angle, strokeStyle, fillStyle, lineWidth);
    this.2DShapes.push(shape);
    this.draw();
}
draw() {
    let ctx = this.getContext("2d");
    
    for(let shape of this.2DShapes) {
        ctx.save();
        // draw shape with rotation zoom whatever
        ctx.restore();
    }
}

But this isn't possible, the best I can manage is to add a canvas member to my class, and then the various in/out functions are not part of the class anymore, but belong to its canvas member (onmouse, draw, ...).

What is the correct pattern to "extend" the functionalities of canvas, and make it all part of canvas? Or is the only choice to have a container class?

Upvotes: 0

Views: 153

Answers (1)

Peter Seliger
Peter Seliger

Reputation: 13432

Edit and Preface

There are a lot of comments regarding the implementation details of both to come code-examples each fitting a different scenario of its usage. Therefore, as a preface I quote myself from my so far last comment ...

@Kaiido ... From all the comments so far I can say, the strategy of providing implementations for two different approaches has fully paid of. Only by "seeing" both examples in action and having a look into each code-base, one can weight the pro and con arguments for each approach. I'm slightly in favor of the 2nd approach, and I also provide its pro arguments. But there are arguments too in favor of the 1st approach. In the end both implementations are POCs, they outline which usage-scenario benefits most from either approach. But [not] each of its implementation details [is] carved in stone.

Answer

One possible approach is implementing the additional canvas abstraction into an Autonomous Custom Element due to Apples policy of not allowing Customized Built-in Elements. The former has to always subclass the HTMLELement whereas with the latter one could extend classes of specific html elements.

Thus such an approach pretty much has to be implemented as a wrapper class which enables the creation and management of a regular <canvas/> element, either in the custom-element's shadow- or light-DOM.

In addition to all the custom-specific canvas behavior, the class has to cover the standard-behavior of a canvas-element as well. The latter can be achieved by forwarding methods (hence the wrapper terminology).

Edit ... regarding following raised concern ...

The first solution will miss all the cases where the canvas element is consumed by another API. For instance createImageBitmap(yourCanvas) will throw, so will 2DContext.drawImage(yourCanvas, 0, 0). – Kaiido

Those cases can be handled by a valueOf method, e.g. createImageBitmap(myCanvas.valueOf()); and context2D.drawImage(myCanvas.valueOf(), 0, 0); ... after all a <my-canvas/> element is not a canvas itself nor an identical replacement. It's a surrogate featuring its own API. Thus having to be consumed via valueOf should not be too much of a burden.

An already woking example code which keeps the public API of an <my-canvas/>-element clean and also is aware of attribute changes to any <my-canvas/>-element is hereby provided ...

function isHTMLCanvasElement(value) {
  return Object.prototype.toString.call(value) === '[object HTMLCanvasElement]';
}

function getUnifiedIntegerValue(value) {
  const num = Number(value);
  const int = Number.isFinite(num) ? parseInt(num, 10) : null;
  return Number.isSafeInteger(int) ? Math.abs(int) : null;
}
function handleDimensionChange(component, managedCanvas, managedProps, key, value) {
  let int = getUnifiedIntegerValue(value);

  if (int === null || String(int) !== value) {
    if (int === null) {

      int = managedProps[key];
    }
    component.setAttribute(key, int);
  } else {
    managedProps[key] = int;

    // forwarding
    managedCanvas[key] = int;
  }
  return int;
}

function handleMouseDown(evt) {
  const { currentTarget, target, type } = evt;
  const { root, props, canvas, shapes } = this;

  console.log({ currentTarget, target, type });
  console.log({ root, props, canvas, shapes });
}

class MyCanvas extends HTMLElement {

  static get observedAttributes() {
    return ['width', 'height'];
  }
  #props = { width: null, height: null };
  #shapes = { '2d': [] };
  #canvas = {};

  // constructor() {
  //  super();
  // }

  connectedCallback() {
    // guard ... element has been connected before.
    if (isHTMLCanvasElement(this.#canvas)) {
      return;
    }
    this.#canvas = document.createElement('canvas');

    this.#canvas.width = this.width;
    this.#canvas.height = this.height;

    const shadowRoot = this.attachShadow({ mode: 'closed' });

    shadowRoot.appendChild(this.#canvas);

    this.addEventListener('mousedown', handleMouseDown.bind({

      root: this,
      props: this.#props,
      canvas: this.#canvas,
      shapes: this.#shapes,

    }), false);
  }
  attributeChangedCallback(key, recent, current) {
    handleDimensionChange(this, this.#canvas, this.#props, key, current);
  }

  get width() {
    return this.#props.width;
  }
  set width(value) {
    return handleDimensionChange(this, this.#canvas, this.#props, 'width', value);
  }

  get height() {
    return this.#props.height;
  }
  set height(value) {
    return handleDimensionChange(this, this.#canvas, this.#props, 'height', value);
  }

  // just forwarding.

  captureStream(...args) {
    return this.#canvas.captureStream(...args);
  }
  getContext(...args) {
    return this.#canvas.getContext(...args);
  }
  toBlob(...args) {
    return this.#canvas.toBlob(...args);
  }
  toDataURL(...args) {
    return this.#canvas.toDataURL(...args);
  }
  transferControlToOffscreen(...args) {
    return this.#canvas.transferControlToOffscreen(...args);
  }

  // component specifc functionality.

  valueOf() {
    return this.#canvas;
  }

  draw() {
    console.log('+++ `draw` called +++');

    const ctx = this.getContext('2d');

    for (let shape of this.#shapes['2d']) {
      ctx.save();
      // draw shape with rotation zoom whatever
      ctx.restore();
    }
  }
  add2DShape(name, x, y, angle = 0, strokeStyle, fillStyle, lineWidth ) {
    console.log('+++ `add2DShape` called +++');

    // const shape = new 2DShape(name, x, y, angle, strokeStyle, fillStyle, lineWidth);
    // this.#shapes['2d'].push(shape);

    this.draw();
  }

  // TODO ... event handling
  //
  // contextlost
  // contextrestored
  // webglcontextcreationerror
  // webglcontextlost
  // webglcontextrestored
}
customElements.define("my-canvas", MyCanvas);
my-canvas {
  display: inline-block;
  position: relative;
  border: 2px dashed red;
}
body { margin: 0; }
.as-console-wrapper { left: auto!important; width: 80%; min-height: 100%; }
<my-canvas width="110" height="190"></my-canvas>

<script>
  document.addEventListener('DOMContentLoaded', () => {

    const myCanvas = document.querySelector('my-canvas');
    const ctx = myCanvas.getContext('2d');

    console.log({ ctx, canvas: myCanvas.valueOf() });

    myCanvas.add2DShape();
  });
</script>

Edit

Another possible approach which one can consider feasibly within a setting which most probably asks for a safe element, event and state handling of just a few canvas elements is entirely bind and "glue-code" based.

A single factory function would connect all involved references (elements, methods, states, event-handlers) in a readable and safe (in terms of state-handling) way.

The footprint of the required code is really small. And as long as one does augment just a few canvas-elements the memory-footprint will not exceed the one of any other solution.

The 2nd approach too comes with a working code-example ...

// import { draw, add2DShape } from './canvas/with-2d-shape-management';

function handleMouseDown(evt) {
  const { currentTarget, type } = evt;
  const { shapes } = this;

  console.log({ currentTarget, type, shapes });
}
function initializeBoundStateCanvas(elmNode) {
  const state = structuredClone({ shapes: { '2d': [] } });

  elmNode.draw = draw.bind(elmNode, state);
  elmNode.add2DShape = add2DShape.bind(elmNode, state);

  elmNode.addEventListener(
    'mousedown', handleMouseDown.bind(state), false,
  );
  console.log({ boundStateCanvas: elmNode });
  
  elmNode.add2DShape();
}

function main() {
  document
    .querySelectorAll('canvas')
    .forEach(initializeBoundStateCanvas);
}
main();
canvas {
  display: inline-block;
  position: relative;
  border: 2px dashed red;
}
body { margin: 0; }
.as-console-wrapper { left: auto!important; width: 80%; min-height: 100%; }
<canvas width="110" height="190"></canvas>


<script>
// local scope of an e.g. './canvas/with-2d-shape-management.js' module.

// - `draw` within/from bound context and state.
/*export */function draw(state) {

  console.log('... `draw` called ...', { 'this': this, state });

  const ctx = this.getContext('2d');

  for (let shape of state.shapes['2d']) {
    ctx.save();
    // draw shape with rotation zoom whatever
    ctx.restore();
  }
}
// - `add2DShape` within/from bound context and state.
/*export */function add2DShape(state, name, x, y, angle = 0, strokeStyle, fillStyle, lineWidth) {

  console.log('... `add2DShape` called ...', { 'this': this, state });

  // const shape = new 2DShape(name, x, y, angle, strokeStyle, fillStyle, lineWidth);
  // state.shapes['2d'].push(shape);

  this.draw();
}
</script>

(partially opinionated) Conclusion

I would always pick the 2nd solution as long as one does not need to take care of the entire lifecycle of a canvas-element. This solution is best suited for an initialize-(some-elements)-and-forget scenario.

As soon as one deals with a dynamic environment where canvas elements get created and removed/destroyed on a regular base, the web-component based approach is unbeatable.

Edit ... due to Danny '365CSI' Engelman's last comment ...

... I don't agree with putting functions outside the component/class scope. They will become global functions or module scoped functions, yet another point of failure. Having to use bind instead of lexical scope is a tell-tale sign of oldskool code. But yes, it works. OP could also use an Autonomous <canvas-element> have it attach all drag-drop feautures on a (new)<canvas> and then finish the Web Component with this.replaceWith(newCanvas) – Danny '365CSI' Engelman

The following 3rd example code shows the minimal differences in between the 2nd provided bind-based code and its all closure-based variant where the closure now gets created with every invocation of the initializeClosedOverCanvas factory function.

The differences are subtle, as little as the differences in between the memory consumption of both approaches most probably will be. Which implementation one finally goes with is merely a matter of personal preferences.

Where I do not agree is ...

I don't agree with putting functions outside the component/class scope. They will become global functions or module scoped functions, yet another point of failure.

Providing module based functionality wich then can be imported and consumed/used as either bound methods (2nd example) or as functions which get their arguments forwarded (3rd example) does not introduce any additional point/s of failure. On the contrary, a code base which is provided in that way is out of the box directly testable at such entry-points.

// import { draw, add2DShape } from './canvas/with-2d-shape-management';

function handleMouseDown(evt, state) {
  const { currentTarget, type } = evt;
  const { shapes } = state;

  console.log({ currentTarget, type, shapes });
}
function initializeClosedOverCanvas(elmNode) {
  const state = structuredClone({ shapes: { '2d': [] } });

  // this line allone will create a closure over `initializeClosedOverCanvas`, 
  elmNode.draw = () => draw(elmNode, state);
  // ... this line will as well ...
  elmNode.add2DShape = (...args) => add2DShape(elmNode, state, ...args);
  // ... and this line would too ...
  elmNode.addEventListener(
    'mousedown', evt => handleMouseDown(evt, state), false,
  );
  console.log({ closedOverCanvas: elmNode });

  // ... no closure creating material here ...
  //
  // add2DShape(elmNode, state);
  elmNode.add2DShape();
}

function main() {
  document
    .querySelectorAll('canvas')

    // going to create a closure for each canvas element.
    .forEach(initializeClosedOverCanvas);
}
main();
canvas {
  display: inline-block;
  position: relative;
  border: 2px dashed red;
}
body { margin: 0; }
.as-console-wrapper { left: auto!important; width: 80%; min-height: 100%; }
<canvas width="110" height="190"></canvas>


<script>
// local scope of an e.g. './canvas/with-2d-shape-management.js' module.

/*export */function draw(elmCanvas, state) {

  console.log('... `draw` called ...', { elmCanvas, state });

  const ctx = elmCanvas.getContext('2d');

  for (let shape of state.shapes['2d']) {
    ctx.save();
    // draw shape with rotation zoom whatever
    ctx.restore();
  }
}
/*export */function add2DShape(
  elmCanvas, state, name, x, y, angle = 0, strokeStyle, fillStyle, lineWidth,
) {
  console.log('... `add2DShape` called ...', { elmCanvas, state });

  // const shape = new 2DShape(name, x, y, angle, strokeStyle, fillStyle, lineWidth);
  // state.shapes['2d'].push(shape);

  draw(elmCanvas, state);
  // or elmCanvas.draw();
}
</script>

Upvotes: 2

Related Questions