Skip to content

Instantly share code, notes, and snippets.

@JobLeonard
Last active July 29, 2023 01:44
Show Gist options
  • Star 16 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save JobLeonard/987731e86b473d42cd1885e70eed616a to your computer and use it in GitHub Desktop.
Save JobLeonard/987731e86b473d42cd1885e70eed616a to your computer and use it in GitHub Desktop.
A react component that wraps and autosizes a canvas element
import React, {PropTypes} from 'react';
import { debounce } from 'lodash';
// A simple helper component, wrapping retina logic for canvas and
// auto-resizing the canvas to fill its parent container.
// To determine size/layout, we just use CSS on the div containing
// the Canvas component (we're using this with flexbox, for example).
// Expects a "paint" function that takes a "context" to draw on
// Whenever this component updates it will call this paint function
// to draw on the canvas. For convenience, pixel dimensions are stored
// in context.width, context.height and contex.pixelRatio.
class CanvasEnhancer extends React.Component {
constructor(props) {
super(props);
this.draw = this.draw.bind(this);
// Attach helper functions to context prototype
let prototype = CanvasRenderingContext2D.prototype;
if (!prototype.circle) {
prototype.circle = function (x, y, radius) {
this.moveTo(x + radius, y);
this.arc(x, y, radius, 0, 2 * Math.PI);
};
}
if (!prototype.textSize) {
prototype.textSize = function (size = 10) {
// will return an array with [ size, font ] as strings
const fontArgs = this.font.split(' ');
const font = fontArgs[fontArgs.length - 1];
switch (typeof size) {
case 'number':
this.font = size + 'px ' + font;
break;
case 'string':
this.font = size + font;
break;
}
};
}
if (!prototype.textStyle) {
prototype.textStyle = function (fill = 'black', stroke = 'white', lineWidth = 2) {
this.fillStyle = fill;
this.strokeStyle = stroke;
this.lineWidth = lineWidth;
};
}
if (!prototype.drawText) {
prototype.drawText = function (text, x, y) {
this.strokeText(text, x, y);
this.fillText(text, x, y);
};
}
}
// Make sure we get a sharp canvas on Retina displays
// as well as adjust the canvas on zoomed browsers
// Does NOT scale; painter functions decide how to handle
// window.devicePixelRatio on a case-by-case basis
componentDidMount() {
const view = this.refs.view;
const ratio = window.devicePixelRatio || 1;
const width = (view.clientWidth * ratio) | 0;
const height = (view.clientHeight * ratio) | 0;
const resizing = false;
this.setState({ width, height, ratio, resizing });
}
componentDidUpdate(prevProps) {
if (!prevProps.loop) {
this.draw();
}
}
// Relies on a ref to a DOM element, so only call
// when canvas element has been rendered!
draw() {
if (this.state) {
const { width, height, ratio } = this.state;
const canvas = this.refs.canvas;
let context = canvas.getContext('2d');
// store width, height and ratio in context for paint functions
context.width = width;
context.height = height;
context.pixelRatio = ratio;
// should we clear the canvas every redraw?
if (this.props.clear) { context.clearRect(0, 0, canvas.width, canvas.height); }
this.props.paint(context);
}
// is the provided paint function an animation? (not entirely sure about this API)
if (this.props.loop) {
window.requestAnimationFrame(this.draw);
}
}
render() {
// The way canvas interacts with CSS layouting is a bit buggy
// and inconsistent across browsers. To make it dependent on
// the layout of the parent container, we only render it after
// mounting, after CSS layouting is done.
const canvas = this.state ? (
<canvas
ref='canvas'
width={this.state.width}
height={this.state.height}
style={{
width: '100%',
height: '100%',
}} />
) : null;
return (
<div
ref='view'
className={this.props.className ? this.props.className : 'view'}
style={this.props.style}>
{canvas}
</div>
);
}
}
CanvasEnhancer.propTypes = {
paint: PropTypes.func.isRequired,
clear: PropTypes.bool,
loop: PropTypes.bool,
className: PropTypes.string,
style: PropTypes.object,
};
// This pattern turns out to be generic enough to
// warrant its own component
export class RemountOnResize extends React.Component {
constructor(props) {
super(props);
this.state = { resizing: true };
const resize = () => { this.setState({ resizing: true }); };
// Because the resize event can fire very often, we
// add a debouncer to minimise pointless
// (unmount, resize, remount)-ing of the child nodes.
this.setResize = debounce(resize, 500);
}
componentDidMount() {
window.addEventListener('resize', this.setResize);
this.setState({ resizing: false });
}
componentWillUnmount() {
window.removeEventListener('resize', this.setResize);
}
componentDidUpdate(prevProps, prevState) {
if (!prevState.resizing && this.state.resizing) {
this.setState({ resizing: false });
}
}
render() {
return this.state.resizing ? null : this.props.children;
}
}
RemountOnResize.propTypes = {
className: PropTypes.string,
style: PropTypes.object,
children: PropTypes.node,
};
export const Canvas = function (props) {
return (
<RemountOnResize
/* Since canvas interferes with CSS layouting,
we unmount and remount it on resize events */
>
<CanvasEnhancer
paint={props.paint}
clear={props.clear}
loop={props.loop}
className={props.className}
style={props.style}
/>
</RemountOnResize>
);
};
Canvas.propTypes = {
paint: PropTypes.func.isRequired,
clear: PropTypes.bool,
loop: PropTypes.bool,
className: PropTypes.string,
style: PropTypes.object,
};
@dstroot
Copy link

dstroot commented Oct 12, 2018

Will this work for drawing an image on the canvas? e.g. context.drawImage(base_image, 0, 0);

I don't understand why the prototype had to be extended with circle, testSize, etc. Does it need a drawImage?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment