<div id="element"></div>
<div id="controls">
  <svg id="brush" class="active" enable-background="new 0 0 430.51 430.51" version="1.1" viewBox="0 0 430.51 430.51" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m296 284.52-52.542-30.805c-2.865 0.345-5.734 0.913-8.59 1.73-13.717 3.937-33.557 15.227-50.186 46.668-13.202 24.961-8.33 46.331-4.031 65.187 4.108 18.023 7.354 32.259-4.718 46.5-1.187 1.399-1.511 3.336-0.845 5.047 0.419 1.076 1.188 1.952 2.15 2.512 0.571 0.33 1.21 0.551 1.885 0.637 30.535 3.836 76.235-14.17 104.42-62.771 12.802-22.074 18.3-43.299 15.902-61.38-0.624-4.717-1.796-9.17-3.446-13.325zm-51.496 3.33c-0.941 2.5-2.8 4.482-5.234 5.584-17.741 8.03-20.915 22.067-21.042 22.662-0.702 3.69-3.553 6.751-7.244 7.732-0.327 0.087-0.659 0.157-0.995 0.212-1.14 0.182-2.299 0.163-3.441-0.055-5.418-1.033-8.985-6.277-7.954-11.694 0.19-1.001 4.985-24.655 32.431-37.078 0.506-0.229 1.027-0.414 1.557-0.556 4.743-1.261 9.654 1.07 11.678 5.542 1.098 2.434 1.186 5.151 0.244 7.651z"/><path d="m418.94 11.153c-10.538-6.111-24.009-3.033-30.847 7.05-4.299 6.338-105.41 155.47-118.43 177.92-6.509 11.224-13.845 29.443-19.947 46.1l52.542 30.805c11.443-13.576 23.674-29.061 30.275-40.443 13.17-22.708 92.25-184.28 95.609-191.15 5.357-10.945 1.337-24.166-9.204-30.277z"/><path d="m166.59 341.07c-4.498-1.502-8.881-2.384-13.327-2.657-0.747-0.046-1.549-0.069-2.383-0.069-6.69 0-15.097 1.466-23.227 2.884-7.245 1.265-14.089 2.457-18.864 2.457-3.359 0-4.52-0.616-4.895-0.936-4.059-5.095 1.807-18.923 4.318-24.849 0.642-1.512 1.148-2.706 1.44-3.559 4.072-11.981 7.063-24.737 1.425-36.843-4.687-10.065-15.643-18.076-27.824-20.391-3.548-0.838-7.62-1.228-12.816-1.228-4.277 0-8.854 0.271-13.281 0.534-4.54 0.27-9.235 0.548-13.731 0.548-15.464 0-24.366-3.528-29.749-11.769-9.992-15.865 2.009-44.283 14.332-56.117 0.758-0.728 0.823-1.918 0.149-2.725-0.675-0.807-1.857-0.955-2.708-0.337-15.781 11.433-26.229 32.205-25.406 50.514 0.439 9.764 4.197 23.327 19.612 32.506 8.298 4.94 21.718 5.98 34.696 6.986 12.768 0.989 25.971 2.013 30.04 6.977 1.066 1.301 1.472 2.876 1.233 4.859-0.888 9.078-5.293 18.018-9.554 26.661-8.047 16.327-16.368 33.209-1.986 51.944 12.631 16.451 29.399 16.451 46.625 16.331 1.41-0.01 2.823-0.02 4.235-0.02 7.515 0 16.196 0.23 24.068 2.972 5.602 2.171 8.419 6.662 9.737 15.571 1.009 5.462 3.436 9.762 6.657 13 0.471-2.511 1.577-4.904 3.29-6.927 8.458-9.986 6.803-19.319 2.577-37.812-1.962-8.595-4.137-18.122-4.683-28.505z"/></svg>
<svg id="eraser" enable-background="new 0 0 360 360" version="1.1" viewBox="0 0 360 360" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
    <path d="m348.99 102.95-98.954-98.953c-5.323-5.323-13.954-5.324-19.277 0l-153.7 153.7 118.23 118.23 153.7-153.7c5.323-5.322 5.323-13.953 0-19.278z"/>
    <path d="m52.646 182.11-41.64 41.64c-5.324 5.322-5.324 13.953 0 19.275l98.954 98.957c5.323 5.322 13.954 5.32 19.277 0l41.639-41.641-118.23-118.23z"/>
    <polygon points="150.13 360 341.77 360 341.77 331.95 182.81 331.95"/>
</svg>
<svg id="undo" class="disabled" enable-background="new 0 0 52.502 52.502" version="1.1" viewBox="0 0 52.502 52.502" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g><path d="m21.524 16.094v-12.048l-20.108 19.952 20.108 20.143v-12.047s17.598-4.355 29.712 16c0 0 3.02-15.536-10.51-26.794 1e-3 -1e-3 -5.991-5.604-19.202-5.206z"/><path d="m51.718 50.857-1.341-2.252c-10.214-17.164-24.401-16.203-27.853-15.68v13.634l-22.524-22.564 22.524-22.351v13.431c12.728-0.103 18.644 5.268 18.886 5.494 13.781 11.465 10.839 27.554 10.808 27.715l-0.5 2.573zm-26.073-20.155c5.761 0 16.344 1.938 24.854 14.376 0.128-4.873-0.896-15.094-10.41-23.01-0.099-0.088-5.982-5.373-18.533-4.975l-1.03 0.03v-10.676l-17.694 17.554 17.692 17.724v-10.414l0.76-0.188c0.07-0.018 1.73-0.421 4.361-0.421z"/></g></svg>  
<svg id="redo" class="disabled" enable-background="new 0 0 52.495 52.495" version="1.1" viewBox="0 0 52.495 52.495" xml:space=preserve" xmlns="http://www.w3.org/2000/svg">
<g><path d="m31.113 16.038v-12.048l19.971 20.08-19.971 20.08v-12.048s-17.735-4.292-29.849 16.064c0 0-3.02-15.536 10.51-26.794 0-1e-3 6.129-5.732 19.339-5.334z"/><path d="m0.783 50.929-0.5-2.573c-0.031-0.161-2.974-16.25 10.852-27.753 0.202-0.191 6.116-5.585 18.674-5.585 0.102 0 0.203 0 0.305 1e-3v-13.453l22.381 22.504-22.382 22.504v-13.637c-0.662-0.098-1.725-0.213-3.071-0.213-5.761 0-16.657 2.073-24.918 15.953l-1.341 2.252zm29.025-33.911c-11.776 0-17.297 5.033-17.352 5.084-9.545 7.944-10.578 18.172-10.452 23.047 12.361-18.058 29.123-14.072 29.344-14.019l0.765 0.185v10.411l17.561-17.656-17.561-17.657v10.654l-1.03-0.03c-0.433-0.013-0.857-0.019-1.275-0.019z"/></g></svg>
<svg id="download" class="disabled" enable-background="new 0 0 512 512" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m382.56 233.38c-2.592-5.728-8.288-9.376-14.56-9.376h-64v-208c0-8.832-7.168-16-16-16h-64c-8.832 0-16 7.168-16 16v208h-64c-6.272 0-11.968 3.68-14.56 9.376-2.624 5.728-1.6 12.416 2.528 17.152l112 128c3.04 3.488 7.424 5.472 12.032 5.472s8.992-2.016 12.032-5.472l112-128c4.16-4.704 5.12-11.424 2.528-17.152z"/><path d="M432,352v96H80v-96H16v128c0,17.696,14.336,32,32,32h416c17.696,0,32-14.304,32-32V352H432z"/></svg>
</div>
body {
  background: #252525;
  padding: 0;
  margin: 0;
  overflow: hidden;
}

#controls {
  position: absolute;
  top: 0;
  left: 0;
  padding: 12px;
  height: 100%;
  width: 32px;
}

svg {
  fill: #fff;
  width: 32px;
  height: 32px;
  margin-bottom: 24px;
  cursor: pointer;
}

svg:hover, svg.active {
  fill: #1b9cd8;
}

svg.disabled {
  opacity: 0.4;
  cursor: default;
}

svg.disabled:hover {
  fill: #fff;
}
class Sketch {
  constructor(container, drawingBoard, historyCount) {
    this._changeFn = null
    this._changeScope = null
    this._stage = new Stage(container, drawingBoard, historyCount, this._emitChange.bind(this))
  }
  onChange(scope, fn) {
    this._changeFn = fn
    this._changeScope = scope
  }
  canUndo() {
    return this._stage.canUndo()
  }
  canRedo() {
    return this._stage.canRedo()
  }
  undo() {
    this._stage.undo(() => {
      this._emitChange()
    })
  }
  redo() {
    this._stage.redo(() => {
      this._emitChange()
    })
  }
  setStrokeWidth(width) {
    this._stage.setStrokeWidth(width)
  }
  setStrokeColour(colour) {
    this._stage.setStrokeColour(colour)
  }
  selectPencil() {
    this._stage.selectPencil()
  }
  selectEraser() {
    this._stage.selectEraser()
  }
  export() {
    return this._stage.export()
  }
  update(container, drawingBoard) {
    this._stage.update(container, drawingBoard)
  }
  _emitChange() {
    if(this._changeFn) {
      this._changeFn(this._changeScope)
    }
  }
}

class Stage {
  constructor(container, drawingBoard, historyCount = 100, change) {

    container = Object.assign({
      element: null,
      dimensions: {
        width: 400,
        height: 400
      }
    }, container)

    drawingBoard = Object.assign({
      pathType: 'bezier',
      dimensions: {
        width: 400,
        height: 400
      },
      position: {
        x: 0,
        y: 0
      },
      scale: 1
    }, drawingBoard)

    this._stage = new Konva.Stage({
      container: container.element,
      width: container.dimensions.width,
      height: container.dimensions.height
    })

    this._init()
    this._drawingBoard = new DrawingBoard(this._stage, this._canvas, drawingBoard, historyCount, change)
  }
  canUndo() {
    return this._drawingBoard.canUndo()
  }
  canRedo() {
    return this._drawingBoard.canRedo()
  }
  undo(callback) {
    this._drawingBoard.undo(callback)
  }
  redo(callback) {
    this._drawingBoard.redo(callback)
  }
  setStrokeColour(colour) {
    this._drawingBoard.setStrokeColour(colour)
  }
  setStrokeWidth(width) {
    this._drawingBoard.setStrokeWidth(width)
  }
  selectPencil() {
    this._drawingBoard.selectPencil()
  }
  selectEraser() {
    this._drawingBoard.selectEraser()
  }
  export() {
    return this._drawingBoard.export()
  }
  update(container, drawingBoard) {

    if(container.dimensions && container.dimensions.width) {
      this._stage.setWidth(container.dimensions.width)
    }
    if(container.dimensions && container.dimensions.height) {
      this._stage.setHeight(container.dimensions.height)
    }

    this._drawingBoard.update(drawingBoard)

  }
  _init() {
    this._canvas = new Konva.Layer()
    this._stage.add(this._canvas)
    this._draw()
  }
  _draw() {
    this._stage.draw()
  }
}

class DrawingBoard {
  constructor(stage, parentCanvas, settings, historyCount, change) {
    this._stage = stage
    this._parentCanvas = parentCanvas

    this._pathType = settings.pathType

    this._dimensions = settings.dimensions
    this._position = settings.position
    this._scale = settings.scale

    this._offsets = this._calculateOffsets()

    this._canvas = null
    this._board = null
    this._ctx = null

    this._strokeStyle = '#1b9cd8'
    this._lineWidth   = 4

    this._lineJoin    = 'round'
    this._lineCap     = 'round'
    this._shadowColor = 'rgb(27, 156, 216)'
    this._shadowBlur  = 2

    this._build(() => {

      this._exportManager = new ExportManager(this._canvas)

      this._historyManager = new HistoryManager({
        maxLength: historyCount
      },this._exportManager)

      this.save()
    })

    this._lastPointerPosition = null
    this._isPainting = false
    this._currentPoints = [ ]

    this._drawingMode = 'brush'

    this._listen()

    this._boardNeedsRendering = false

    this._change = change
    this._animate()

  }
  setStrokeColour(strokeColour) {
    this._strokeStyle = strokeColour
    this._shadowColor = this._strokeStyle

    this._ctx.strokeStyle = this._strokeStyle
    this._ctx.shadowColor = this._shadowColor
  }
  setStrokeWidth(width) {
    this._lineWidth = width
    this._ctx.lineWidth = this._lineWidth
  }
  selectPencil() {
    this._drawingMode = 'brush'
  }
  selectEraser() {
    this._drawingMode = 'eraser'
  }
  undo(callback) {
    this._historyManager.getPrevious((image) => {
      this._ctx.clearRect(0, 0, this._ctx.canvas.width, this._ctx.canvas.height)
      this._ctx.globalCompositeOperation = 'source-over'

      this._ctx.drawImage(image, 0, 0)
      this._parentCanvas.draw()

      callback()
    })
  }
  canUndo() {
    return this._historyManager.canUndo()
  }
  canRedo() {
    return this._historyManager.canRedo()
  }
  redo(callback) {
    this._historyManager.getNext((image) => {
      this._ctx.clearRect(0, 0, this._ctx.canvas.width, this._ctx.canvas.height)
      this._ctx.globalCompositeOperation = 'source-over'

      this._ctx.drawImage(image, 0, 0)
      this._parentCanvas.draw()
      
      callback()
    })
  }
  save() {
    this._historyManager.add()
  }
  export() {
    return this._exportManager.cropCanvasAndConvertToBase64(this._canvas)
  }
  update(settings) {
    if(settings.dimensions) {
      this._dimensions = settings.dimensions
    }

    if(settings.position) {
      this._position = settings.position
    }

    if(settings.scale) {
      this._scale = settings.scale
    }

    if(settings.scale || settings.position || settings.dimensions) {
      this._offsets = this._calculateOffsets()
    }

    this._board.width(this._dimensions.width)
    this._board.height(this._dimensions.height)

    this._board.x(this._offsets.x)
    this._board.y(this._offsets.y)

    this._board.scaleX(this._scale)
    this._board.scaleY(this._scale)

  }
  _animate() {
    if(this._boardNeedsRendering) {
      this._ctx.stroke()
      this._stage.draw()
      this._boardNeedsRendering = false
    }

    requestAnimationFrame(this._animate.bind(this))
  }
  _listen() {
    this._stage.on('contentMousedown.proto contentTouchstart.proto', () => {

      this._isPainting = true
      this._lastPointerPosition = this._getPointerPosition()

      if (this._drawingMode === 'brush') {
        this._ctx.globalCompositeOperation = 'source-over'
      }
      if (this._drawingMode === 'eraser') {
        this._ctx.globalCompositeOperation = 'destination-out'
      }

      if(this._pathType === 'bezier') {
        this._currentPoints.push({ x: this._lastPointerPosition.x, y: this._lastPointerPosition.y })
      }

      let p1 = this._currentPoints[ 0 ]
      // let p2 = this._currentPoints[ 1 ]

      this._ctx.beginPath()
      this._ctx.lineTo(p1.x, p1.y)

      this._boardNeedsRendering = true
    })
    this._stage.on('contentMouseup.proto contentTouchend.proto', () => {
      this._isPainting = false

      if(this._pathType === 'bezier') {
        this._currentPoints.length = 0
      }

      this.save()
      this._change()
    })
    this._stage.on('contentMousemove.proto contentTouchmove.proto', () => {

      if (!this._isPainting) { return }

      this._lastPointerPosition = this._getPointerPosition()

      if(this._pathType === 'bezier') {

        this._currentPoints.push({ x: this._lastPointerPosition.x, y: this._lastPointerPosition.y })

        let p1 = this._currentPoints[ 0 ]
        let p2 = this._currentPoints[ 1 ]

        this._ctx.beginPath()
        this._ctx.moveTo(p1.x, p1.y)

        for(let i = 1; i < this._currentPoints.length; i++) {

          let midPoint = this._midPointBetween(p1, p2)

          this._ctx.quadraticCurveTo(p1.x, p1.y, midPoint.x, midPoint.y)

          p1 = this._currentPoints[ i ]
          p2 = this._currentPoints[ i + 1 ]
        }

        this._ctx.lineTo(p1.x, p1.y)

        this._boardNeedsRendering = true
      }
    })
  }
  _build(callback) {
    this._canvas = document.createElement('canvas')
    this._canvas.width = this._dimensions.width
    this._canvas.height = this._dimensions.height

    this._ctx = this._canvas.getContext('2d')

    this._ctx.strokeStyle = this._strokeStyle
    this._ctx.lineWidth = this._lineWidth

    this._ctx.lineJoin = this._lineJoin
    this._ctx.lineCap = this._lineCap
    this._ctx.shadowColor = this._shadowColor
    this._ctx.shadowBlur = this._shadowBlur

    this._board = new Konva.Image({
      image: this._canvas,
      ...this._offsets,
      width: this._dimensions.width * this._scale,
      height: this._dimensions.height * this._scale
    })

    this._parentCanvas.add(this._board)
    this._draw()

    callback()
  }
  _midPointBetween(p1, p2) {
    return {
      x: p1.x + (p2.x - p1.x) / 2,
      y: p1.y + (p2.y - p1.y) / 2
    }
  }
  _getPointerPosition() {
    let n = this._stage.getPointerPosition()

    return {
      x: (n.x - this._offsets.x) / this._scale,
      y: (n.y - this._offsets.y) / this._scale
    }
  }
  _calculateOffsets() {
    return {
      x: this._position.x + (this._dimensions.width - (this._dimensions.width * this._scale)) / 2,
      y: this._position.y + (this._dimensions.height - (this._dimensions.height * this._scale)) / 2
    }
  }
  _draw() {
    this._board.draw()
  }
}

class HistoryManager {
  constructor({ maxLength } = { }, exportManager) {
    if(!(exportManager instanceof ExportManager)) {
      throw new Error('Must provide ExportManager instance')
    }

    this._exportManager = exportManager
    this._maxLength = maxLength
    this._history = [ ]
    this._index = -1
  }
  add() {
    this._index = this._index + 1
    this._history = this._history.slice(0, this._index)

    this._history.push(this._exportManager.fromCanvas(null, {
      to: 'base64'
    }))
  }
  canUndo() {
    return (this._index > 0)
  }
  canRedo() {
    return (this._history.length > this._index + 1)
  }
  getPrevious(callback) {
    this._exportManager.toCanvas(this._history[ this._getIndex('prev') ], {
      from: 'base64'
    }, (image) => {
      callback(image)
    })
  }
  getNext(callback) {
    this._exportManager.toCanvas(this._history[ this._getIndex('next') ], {
      from: 'base64'
    }, (image) => {
      callback(image)
    })
  }
  _getIndex(type) {
    if(type === 'prev') {
      if (this.canUndo()) {
        this._index = this._index - 1 
      }

      return this._index
    } else {
      if (this.canRedo()) {
        this._index = this._index + 1
      }

      return this._index
    }
  }
}

class ExportManager {
  constructor(canvas) {
    if(typeof canvas.getContext !== 'function') {
      throw new Error('Must provide canvas to Export Manager')
    }

    this._canvas = canvas
  }
  fromCanvas(canvas = null, { to = 'base64' } = { }) {

    if(canvas === null) {
      canvas = this._canvas
    } else if(typeof canvas.getContext !== 'function') {
      throw new Error('Invalid canvas element')
    }

    switch(to) {
      case 'base64':
        return canvas.toDataURL()
      default:
        throw new Error('Unknown export type')
    }
  }
  toCanvas(payload, { from = 'base64' } = { }, callback) {

    switch(from) {
      case 'base64':

        let img = new Image()
        img.src = payload

        img.onload = () => {
          callback(img)
        }

        break;
      default:
        throw new Error('Unknown export type')
    }
  }
  cropCanvasAndConvertToBase64(canvas) {

    if(this._isBlank(canvas)) {
      console.warn('Empty Canvas')
      return false
    }

    canvas = this._cloneCanvas(canvas)

    let ctx = canvas.getContext('2d')

    let w = canvas.width
    let h = canvas.height
    let pix = { x:[ ], y:[ ] }
    let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)

    let x
    let y
    let index

    for(y = 0; y < h; y++) {
      for(x = 0; x < w; x++) {
        index = (y * w + x) * 4
        if(imageData.data[ index + 3 ] > 0) {
          pix.x.push(x)
          pix.y.push(y)
        }
      }
    }

    pix.x.sort((a, b) => {
      return a - b
    })

    pix.y.sort((a, b) => {
      return a - b
    })

    let n = pix.x.length - 1

    w = pix.x[ n ] - pix.x[ 0 ]
    h = pix.y[ n ] - pix.y[ 0 ]

    let cut = ctx.getImageData(pix.x[ 0 ], pix.y[ 0 ], w, h)

    canvas.width = w
    canvas.height = h

    ctx.putImageData(cut, 0, 0)

    return {
      file: canvas.toDataURL(),
      x: pix.x[ 0 ],
      y: pix.y[ 0 ]
    }
  }
  _cloneCanvas(old) {

    let n = document.createElement('canvas')
    let ctx = n.getContext('2d')

    n.width = old.width
    n.height = old.height

    ctx.drawImage(old, 0, 0)

    return n
  }
  _isBlank(a) {
    let b = document.createElement('canvas')
    b.width = a.width
    b.height = a.height

    return a.toDataURL() === b.toDataURL()
  }
}

let sketch = new Sketch({
  element: 'element',
  dimensions: {
    width: window.innerWidth,
    height: window.innerHeight
  }
}, {
  pathType: 'bezier',
  dimensions: {
    width: window.innerWidth,
    height: window.innerHeight
  },
  position: {
    x: 0,
    y: 0
  },
  scale: 1,
}, 50)

window.onresize = function() {
  sketch = new Sketch({
    element: 'element',
    dimensions: {
      width: window.innerWidth,
      height: window.innerHeight
    }
  }, {
    pathType: 'bezier',
    dimensions: {
      width: window.innerWidth,
      height: window.innerHeight
    },
    position: {
      x: 0,
      y: 0
    },
    scale: 1,
  }, 50)
}

sketch.onChange(sketch, (scope) => {
  const canUndo = sketch.canUndo()
  const canRedo = sketch.canRedo()
  document.querySelector('#undo').setAttribute('class', (canUndo) ? '' : 'disabled')
  document.querySelector('#redo').setAttribute('class', (canRedo) ? '' : 'disabled')
  document.querySelector('#download').setAttribute('class', (canUndo || canRedo) ? '' : 'disabled')
})

document.getElementById('brush').onclick = function() {
  document.querySelector('#eraser').setAttribute('class', '')
  document.querySelector('#brush').setAttribute('class', 'active')  
  sketch.selectPencil()
}
document.getElementById('eraser').onclick = function() {
  document.querySelector('#eraser').setAttribute('class', 'active')
  document.querySelector('#brush').setAttribute('class', '')
  sketch.selectEraser()
}
document.getElementById('undo').onclick = function() {
  sketch.undo()
}
document.getElementById('redo').onclick = function() {
  sketch.redo()
}
document.getElementById('download').onclick = function() {
  const file = sketch.export()

  if (file) {
    console.log(file)
  }
}

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/konva/4.0.13/konva.min.js