DEV Community

Cover image for Create your own CAPTCHA - part 3 - React and PIXI.js
Meat Boy
Meat Boy

Posted on

Create your own CAPTCHA - part 3 - React and PIXI.js

In previous episode, we prepared an architecture of the project and environment for development. Today, we are going to write a client-side application for handling canvas and captcha in the browser.

Sos on his best!

PIXI.js

To control canvas we are going to use PIXI.js, so move to the project directory and install by running:

yarn add pixi.js

Then import in the main component of the canvas.

import * as PIXI from 'pixi.js';

To use the PIXI library, we need to create a PIXI Application and append view somewhere on the website. Because we are working on the widget-like tool, application view is going to be attached inside of the component. The application we will create on the first mounting with componentDidMount method or even in the constructor. In my case, the second option is cleaner, because I won't be switching between different components.

export class App extends React.Component<any, IApp> {
  constructor(props : any) {
    super(props);

    this.state = {
      app: new PIXI.Application({
        width: 480,
        height: 280,
        backgroundColor: 0xeeeeee,
        resolution: window.devicePixelRatio || 1,
      }),
    };
  }
// ...
}

On the first line, you see that I'm telling that interface IApp is going to define how the state of the component is going to looks like. Now, just PIXI application under "app" key is fine.

interface IApp {
  app: PIXI.Application
}

In the initial state, I created new PIXI Application instance with the width and height of the canvas and very bright colour in the background.

View for our application we can append in the previously mentioned componentDidMount like below:

componentDidMount() {
    document.getElementById('devcaptcha-container').appendChild(this.state.app.view);
}

And inside render method we need to create HTML elemenet with devcaptcha-container id:

  render() {
    return <div id={"devcaptcha-container"}/>;
  }

If you did everything well, you should be able to render rectangle somewhere in your application.

Canvas Elements

Now, we need to add canvas elements for captcha. My captcha will contain:

  • instruction how to use captcha,
  • white stripes on the top and the bottom as the background for text and button,
  • button for submitting a captcha response,
  • image background with a picture from the backend with a drawn puzzle,
  • puzzle element to drag and drop to match with this from backend,

PIXI has various classes for representing canvas elements. For the background, we can use Sprite and alternative construction method, which as argument accept image URL.

const background = PIXI.Sprite.from('https://placeholderimg.jpg');

And then set some properties. In this case, we want to stretch the background on the entire canvas. Initial anchor point (position point) of the elements in PIXI is in the top-left corner. Co our background sprite should start at position 0,0 (top-left edge of the canvas) and be 100% width and height. We can use for that previously saved reference to the object of PIXI application, and view.

background.width = this.state.app.view.width;
background.height = this.state.app.view.height;

And finally, we can append this background object inside view:

this.state.app.stage.addChild(background);

Awesome! At this point, you should see your image in the background. Now let add white, background stripes. We are going to use for this Graphics class, which is responsible for primitive, vector shapes. With this class, we can add two 32px stripes for top and the bottom and two 4px thin shadow lines.

    // top stripe
    const stripes = new PIXI.Graphics();
    stripes.beginFill(0xffffff);
    stripes.drawRect(0, 0,
      this.state.app.view.width,
      32
    );
    stripes.endFill();

    // bottom stripe
    stripes.beginFill(0xffffff);
    stripes.drawRect(0,
      this.state.app.view.height - 32,
      this.state.app.view.width,
      32
    );

    // top shadow
    stripes.beginFill(0xdddddd, 0.5);
    stripes.drawRect(0, 32,
      this.state.app.view.width,
      4
    );
    stripes.endFill();

    // bottom shadow
    stripes.beginFill(0xdddddd, 0.5);
    stripes.drawRect(0,
      this.state.app.view.height - 36,
      this.state.app.view.width,
      4
    );
    stripes.endFill();
    this.state.app.stage.addChild(stripes);

We also need a button for submitting the captcha response. We will use the same class as previously. But this time, we will set properties for interactive and event listener.

    // submit button
    const submitButton = new PIXI.Graphics();
    submitButton.interactive = true;
    submitButton.buttonMode = true;
    submitButton.on('pointerdown', () => {
      // on mouse fire
    });
    submitButton.beginFill(0x222222);
    submitButton.drawRect(this.state.app.view.width - 112,
      this.state.app.view.height - 64,
      96,
      48
    );
    submitButton.endFill();
    this.state.app.stage.addChild(submitButton);

Text on the top will inform how to solve captcha:

    // instruction
    const basicText = new PIXI.Text('Move the jigsaw to the correct position to solve captcha.', {
      fontFamily: 'Arial',
      fontSize: 16,
      fill: '#000000',
    });
    basicText.x = 8;
    basicText.y = 8;
    this.state.app.stage.addChild(basicText);

And the second on the button:

    // text on the submit button
    const submitButtonText = new PIXI.Text('Submit', {
      fontFamily: 'Arial',
      fontSize: 14,
      fill: '#ffffff',
    });
    submitButtonText.x = this.state.app.view.width - 112 + 40;
    submitButtonText.y = this.state.app.view.height - 64 + 16;
    this.state.app.stage.addChild(submitButtonText);

To make this button look better, I added icon:

    // icon on the submit button
    const submitButtonIcon = PIXI.Sprite.from('https://i.imgur.com/mgWUPWc.png');
    submitButtonIcon.width = 24;
    submitButtonIcon.height = 24;
    submitButtonIcon.x = this.state.app.view.width - 112 + 12;
    submitButtonIcon.y = this.state.app.view.height - 64 + 12;
    this.state.app.stage.addChild(submitButtonIcon);

And finally, two more labels for Terms of Service and Privacy Policy:

    // privacy policy
    const textPrivacy = new PIXI.Text('Privacy', {
      fontFamily: 'Arial',
      fontSize: 12,
      fill: '#777777',
    });
    textPrivacy.interactive = true;
    textPrivacy.buttonMode = true;
    textPrivacy.on('pointerdown', () => {
      // pp
    });
    textPrivacy.anchor.set(0.5, 0.5);
    textPrivacy.x = 24;
    textPrivacy.y = this.state.app.view.height - 16;
    this.state.app.stage.addChild(textPrivacy);

    // terms of service
    const textTerms = new PIXI.Text('Terms', {
      fontFamily: 'Arial',
      fontSize: 12,
      fill: '#777777',
    });
    textTerms.interactive = true;
    textTerms.buttonMode = true;
    textTerms.on('pointerdown', () => {
      // tos
    });
    textTerms.anchor.set(0.5, 0.5);
    textTerms.x = 72;
    textTerms.y = this.state.app.view.height - 16;
    this.state.app.stage.addChild(textTerms);

Puzzle

Now we need to add puzzle with drag and drop. Puzzle will be Sprite instance with interactive and buttonMode set to true. Also we need to bind event listeners to proper methods. And becuase we want to use our captcha on both mobile and pc we must ensure all input methods are supported.

    // puzzle
    const puzzle = PIXI.Sprite.from('https://i.imgur.com/sNPmMi2.png');
    puzzle.anchor.set(0.5, 0.5);
    puzzle.alpha = 0.5;
    puzzle.interactive = true;
    puzzle.buttonMode = true;
    puzzle.x = 64;
    puzzle.y = this.state.app.view.height / 2;
    puzzle.on('mousedown', this.onDragStart)
      .on('touchstart', this.onDragStart)
      .on('mouseup', this.onDragEnd)
      .on('mouseupoutside', this.onDragEnd)
      .on('touchend', this.onDragEnd)
      .on('touchendoutside', this.onDragEnd)
      .on('mousemove', this.onDragMove)
      .on('touchmove', this.onDragMove);
    this.setState(() => {
      return {
        puzzle
      }
    });
    this.state.app.stage.addChild(puzzle);

Methods onDragStart, on dragEnd, onDragMove are required in the component class. On drag start, we are setting dragging flag in the component state to true, and on drag end to false. When moving cursor or finger above the canvas, onDragMove method will be fired, so we need to make sure we are dragging when holding puzzle piece. Event for onDragMove contains distance from the previous call. And it may be positive or negative.

  onDragStart() {
    this.setState(() => {
      return {
        dragging: true,
      };
    });
  }

  onDragEnd() {
    this.setState(() => {
      return {
        dragging: false,
      };
    });
  }

  onDragMove(event : any) {
    if (this.state.dragging) {
      const puzzle = this.state.puzzle;
      puzzle.position.x += event.data.originalEvent.movementX;
      puzzle.position.y += event.data.originalEvent.movementY;
    }
  }

With this puzzle, we need to add to our state two more properties and bind three new methods to class::

interface IApp {
  app: PIXI.Application,
  dragging: boolean,
  puzzle: PIXI.Sprite,
}

export class App extends React.Component<any, IApp> {
  constructor(props : any) {
    super(props);

    this.state = {
      app: new PIXI.Application({
        width: 480,
        height: 280,
        backgroundColor: 0xeeeeee,
        resolution: window.devicePixelRatio || 1,
      }),
      dragging: false,
      puzzle: null
    };

    this.onDragEnd = this.onDragEnd.bind(this);
    this.onDragStart = this.onDragStart.bind(this);
    this.onDragMove = this.onDragMove.bind(this);
  }
// ...
}

You should be able to drag the puzzle over the canvas and click on the submit button and small text on the bottom of the canvas.

You did it

Congratulations! In the next episode I will explain backend side of the mechanism, so If you want to be notified about the next part, follow me on DEV.to. 😉

meatboy image

Current source code is available on GitHub. Please, leave a star ⭐ if you like project.

GitHub logo pilotpirxie / devcaptcha

🤖 Open source captcha made with React, Node and TypeScript for DEV.to community




Discussion (0)