paint.js

/*********************************************************************************************************************  

PRSM Participatory System Mapper 

    Copyright (C) 2022  Nigel Gilbert prsm@prsm.uk

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program.  If not, see <https://www.gnu.org/licenses/>.


This module provides the background paint functions.  
 ******************************************************************************************************************** */ /**
 
 * After setup, when user selects a tool from the toolbox, the mouse is used to paint on the temp canvas.
 * When the tool is finished, a set of painting commands is stored; then those commands are used to redraw the network canvas.
 *
 */

import { yPointsArray, network, drawingSwitch } from './prsm.js'

/**
 * Initialisation
 */

/* default canvas attributes */
const defaultOptions = {
  lineWidth: 2,
  strokeStyle: '#000000',
  fillStyle: '#ffffff',
  font: '16px Oxygen',
  fontColor: '#000000',
  globalAlpha: 1.0,
  globalCompositeOperation: 'source-over',
  lineJoin: 'round',
  lineCap: 'round',
}

let selectedTool = null

/* globals */
let underlay
let tempCanvas
let tempctx
const dpr = window.devicePixelRatio || 1
let antTimer = null // timer to advance ants.  To cancel ants, clear this timer

window.yPointsArray = yPointsArray

const GRIDSPACING = 50

/**
 * create the canvases and add listeners for mouse and touch events
 * initialise the array holding drawing commands
 */

export function setUpPaint() {
  underlay = document.getElementById('underlay')
  tempCanvas = setUpCanvas('temp-canvas')
  tempctx = getContext(tempCanvas)

  // Use pointer events for drawing tools
  let isPointerDown = false
  let hasMoved = false

  tempCanvas.addEventListener('pointerdown', (e) => {
    isPointerDown = true
    hasMoved = false
    const event = { type: 'panstart', srcEvent: e }
    mouseDespatch(event)
  })

  tempCanvas.addEventListener('pointermove', (e) => {
    if (!isPointerDown) return
    hasMoved = true
    const event = { type: 'panmove', srcEvent: e }
    mouseDespatch(event)
  })

  tempCanvas.addEventListener('pointerup', (e) => {
    if (!isPointerDown) return
    isPointerDown = false
    // If pointer didn't move, treat it as a tap
    const event = { type: hasMoved ? 'panend' : 'tap', srcEvent: e }
    mouseDespatch(event)
  })

  tempCanvas.addEventListener('pointercancel', (e) => {
    if (!isPointerDown) return
    isPointerDown = false
    const event = { type: 'panend', srcEvent: e }
    mouseDespatch(event)
  })
}
/**
 * set up the dimensions of and return the canvas at the id
 * @param {string} id - canvas id
 * @returns {HTMLCanvasElement}
 */
function setUpCanvas(id) {
  const canvas = document.getElementById(id)
  // Get the size of the canvas in CSS pixels.
  const rect = canvas.parentNode.getBoundingClientRect()
  // Give the canvas pixel dimensions of their CSS size * the device pixel ratio.
  canvas.width = rect.width * dpr
  canvas.height = rect.height * dpr
  canvas.tabIndex = 0 // required to enable mouse click to generate blur event
  return canvas
}

/**
 * return the context for the provided canvas
 * @param {HTMLCanvasElement} canvas
 * @returns {CanvasRenderingContext2D}
 */
function getContext(canvas) {
  const ctx = canvas.getContext('2d')
  ctx.lineWidth = defaultOptions.lineWidth
  ctx.strokeStyle = defaultOptions.strokeStyle
  ctx.fillStyle = defaultOptions.fillStyle
  ctx.font = defaultOptions.font
  ctx.lineJoin = 'round'
  ctx.lineCap = 'round'
  return ctx
}

/**
 * add listeners for when the tool buttons are clicked
 */
export function setUpToolbox() {
  const tools = document.querySelectorAll('.tool')
  Array.from(tools).forEach((tool) => {
    tool.addEventListener('click', selectTool)
  })
}
/**
 *
 * Toolbox
 */

/**
 * event listener: when user clicks a tool icon
 * unselect previous tool, select this one
 * and remember which tool is now selected
 * The undo and image tools are special, because they act
 * immediately when the icon is clicked
 *
 * @param {object} event
 */
function selectTool(event) {
  // cleanup any remaining empty input box
  const inpBox = document.getElementById('input')
  if (inpBox) {
    textHandler.saveText(event)
  }
  const tool = event.currentTarget
  if (tool.id === 'undotool') {
    undoHandler.undo()
    // previous tool remains selected
    return
  }
  //second click on selected tool - unselect it
  if (selectedTool === tool.id) {
    deselectTool()
    return
  }
  // changing tool; unselect previous one
  deselectTool()
  selectedTool = tool.id
  tool.classList.add('selected')
  // display options dialog
  toolHandler(selectedTool).optionsDialog()
  // if tool is 'image', get image file from user
  if (tool.id === 'image') {
    const fileInput = document.createElement('input')
    fileInput.id = 'fileInput'
    fileInput.setAttribute('type', 'file')
    fileInput.setAttribute('accept', 'image/*')
    fileInput.addEventListener('change', imageHandler.loadImage)
    fileInput.click()
  }
}

/**
 * unmark the selected tool, close the option dialog and set tool to null
 */
export function deselectTool() {
  if (selectedTool) {
    document.getElementById(selectedTool).classList.remove('selected')
  }
  selectedTool = null
  if (antTimer) clearTimeout(antTimer)
  closeOptionsDialogs()
}
/**
 * remove any option dialog that is open
 */
function closeOptionsDialogs() {
  const box = document.getElementById('optionsBox')
  if (box) box.remove()
}
/**
 * despatch to and perform tool actions
 */

/**
 * all mouse and touch events for the canvas are handled here - despatch to the selected tool
 * @param {PointerEvent} event
 */
function mouseDespatch(event) {
  event.preventDefault()
  if (!selectedTool) return
  let type = event.type
  if (type === 'tap') type = 'panend'
  toolHandler(selectedTool)[type](event.srcEvent)
}

/**
 * deal with each tool, managing mouse events appropriately
 * yields drawing commands in yPointsArray[] that record what the effect of
 * each tool is
 */

/**
 * superclass for all tool handlers
 */
class ToolHandler {
  constructor() {
    this.isPanstart = false
    this.startX = 0
    this.startY = 0
    this.endX = 0
    this.endY = 0
    this.canvasBox = null
    this.strokeStyle = defaultOptions.strokeStyle
    this.lineWidth = defaultOptions.lineWidth
    this.fillStyle = defaultOptions.fillStyle
    this.font = defaultOptions.font
    this.fontColor = defaultOptions.fontColor
    this.globalAlpha = defaultOptions.globalAlpha
    this.globalCompositeOperation = defaultOptions.globalCompositeOperation
    this.lineJoin = defaultOptions.lineJoin
    this.lineCap = defaultOptions.lineCap
  }
  /**
   * mouse has been pressed - note the starting mouse position and options
   * @param {event} e
   */
  panstart(e) {
    if (this.isPanstart) return
    tempCanvas.focus()
    this.canvasBox = tempCanvas.getBoundingClientRect()
    this.endPosition(e)
    this.startX = this.endX
    this.startY = this.endY
    this.isPanstart = true
    applyOptions(tempctx, this.options())
    yPointsArray.push([['options', this.options()]])
  }
  /**
   * note the mouse coordinates relative to the canvas
   * @param {event} e
   */
  endPosition(e) {
    let domX = e.clientX
    if (domX < 0) domX = 0
    if (domX > this.canvasBox.right) domX = this.canvasBox.right
    this.endX = (domX * tempCanvas.width) / (dpr * tempCanvas.clientWidth)
    let domY = e.clientY
    if (domY < this.canvasBox.top) domY = this.canvasBox.top
    if (domY > this.canvasBox.bottom) domY = this.canvasBox.bottom
    this.endY = ((domY - this.canvasBox.top) * tempCanvas.height) / (dpr * tempCanvas.clientHeight)
  }
  /**
   * do something as the mouse moves
   */
  panmove() {}
  /**
   * panend means the shape has been completed - add a marker to record that
   */
  panend() {
    yPointsArray.push([['endShape']])
    this.isPanstart = false
    network.redraw()
  }
  /**
   * return an object with the current canvas drawing options
   * @returns {object}
   */
  options() {
    return {
      strokeStyle: this.strokeStyle,
      lineWidth: this.lineWidth,
      fillStyle: this.fillStyle,
      font: this.font,
      fontColor: this.fontColor,
      globalAlpha: this.globalAlpha,
      globalCompositeOperation: this.globalCompositeOperation,
      lineJoin: this.lineJoin,
      lineCap: this.lineCap,
    }
  }
  /**
   * create a dialog box to allow the user to choose options for the current shape
   * sub classes fill the box with controls
   * @param {string} tool
   * @returns {HTMLElement}
   */
  optionsDialog(tool) {
    const box = document.createElement('div')
    box.className = 'options'
    box.id = 'optionsBox'
    box.style.top =
      document.getElementById(tool).getBoundingClientRect().top -
      underlay.getBoundingClientRect().top +
      'px'
    box.style.left = document.getElementById(tool).getBoundingClientRect().right + 10 + 'px'
    underlay.appendChild(box)
    return box
  }
}

/* ========================================================== line ================================================ */

class LineHandler extends ToolHandler {
  constructor() {
    super()
    this.axes = false
    this.dashed = false
  }
  panmove(e) {
    if (this.isPanstart) {
      this.endPosition(e)
      if (this.axes) {
        if (this.endX - this.startX > this.endY - this.startY) this.endY = this.startY
        else this.endX = this.startX
      }
      drawHelper.clear(tempctx)
      if (this.dashed) {
        drawHelper.dashedLine(tempctx, [this.startX, this.startY, this.endX, this.endY])
      } else drawHelper.line(tempctx, [this.startX, this.startY, this.endX, this.endY])
    }
  }
  panend() {
    if (this.isPanstart) {
      yPointsArray.push([
        [
          this.dashed ? 'dashedLine' : 'line',
          [
            DOMtoCanvasX(this.startX),
            DOMtoCanvasY(this.startY),
            DOMtoCanvasX(this.endX),
            DOMtoCanvasY(this.endY),
          ],
        ],
      ])
      super.panend()
    }
  }
  optionsDialog() {
    const box = super.optionsDialog('line')
    box.innerHTML = `
	<div>Line width</div><div><input id="lineWidth" type="number" min="0" max="99" size="2"></div>
	<div>Colour</div><div><input id="lineColour" type="color"></div>
	<div>Dashed</div><div><input type="checkbox" id="dashed"></div>
	<div>Vert/Horiz</div><div><input type="checkbox" id="axes"></div>`
    const widthInput = document.getElementById('lineWidth')
    widthInput.value = this.lineWidth
    widthInput.addEventListener('change', () => {
      this.lineWidth = parseInt(widthInput.value)
      if (this.lineWidth > 99) this.lineWidth = 99
    })
    const lineColor = document.getElementById('lineColour')
    lineColor.value = this.strokeStyle
    lineColor.addEventListener('blur', () => {
      this.strokeStyle = lineColor.value
    })
    const dashed = document.getElementById('dashed')
    dashed.checked = this.dashed
    dashed.addEventListener('change', () => {
      this.dashed = dashed.checked
    })
    const axes = document.getElementById('axes')
    axes.checked = this.axes
    axes.addEventListener('change', () => {
      this.axes = axes.checked
    })
  }
}
const lineHandler = new LineHandler()

/* ========================================================== rect ================================================ */

class RectHandler extends ToolHandler {
  constructor() {
    super()
    this.roundCorners = true
    this.globalAlpha = 1.0
  }
  panstart(e) {
    super.panstart(e)
    underlay.style.cursor = 'crosshair'
  }
  panmove(e) {
    if (this.isPanstart) {
      this.endPosition(e)
      let startX = this.startX
      let startY = this.startY
      let endX = this.endX
      let endY = this.endY
      // ensure that the rect can be drawn from top left to bottom right, or vice versa
      if (Math.abs(startX) > Math.abs(endX)) {
        ;[startX, endX] = [endX, startX]
      }
      if (Math.abs(startY) > Math.abs(endY)) {
        ;[startY, endY] = [endY, startY]
      }
      drawHelper.clear(tempctx)
      const width = endX - startX
      const height = endY - startY
      drawHelper[this.roundCorners ? 'rrect' : 'rect'](tempctx, [startX, startY, width, height])
    }
  }
  panend() {
    if (this.isPanstart) {
      if (Math.abs(this.startX) > Math.abs(this.endX)) {
        ;[this.startX, this.endX] = [this.endX, this.startX]
      }
      if (Math.abs(this.startY) > Math.abs(this.endY)) {
        ;[this.startY, this.endY] = [this.endY, this.startY]
      }
      const width = DOMtoCanvasX(this.endX) - DOMtoCanvasX(this.startX)
      const height = DOMtoCanvasY(this.endY) - DOMtoCanvasY(this.startY)
      if (width > 0 && height > 0) {
        yPointsArray.push([
          [
            this.roundCorners ? 'rrect' : 'rect',
            [DOMtoCanvasX(this.startX), DOMtoCanvasY(this.startY), width, height],
          ],
        ])
      }
      underlay.style.cursor = 'auto'
      super.panend()
    }
  }
  optionsDialog() {
    const box = super.optionsDialog('rect')
    box.innerHTML = `
	<div>Border width</div><div><input id="borderWidth"  type="number" min="0" max="99" size="2"></div>
  <div>Border Colour</div><div><input id="borderColour" type="color"></div>
  <div>Fill Colour</div><div><input id="fillColour" type="color"></div>
  <div>Rounded</div><input type="checkbox" id="rounded"></div>`
    const widthInput = document.getElementById('borderWidth')
    widthInput.value = this.lineWidth
    widthInput.addEventListener('blur', () => {
      this.lineWidth = parseInt(widthInput.value)
      if (this.lineWidth > 99) this.lineWidth = 99
    })
    const borderColor = document.getElementById('borderColour')
    borderColor.value = this.strokeStyle
    borderColor.addEventListener('blur', () => {
      this.strokeStyle = borderColor.value
    })
    const fillColor = document.getElementById('fillColour')
    fillColor.value = this.fillStyle
    fillColor.addEventListener('blur', () => {
      this.fillStyle = fillColor.value
    })
    const rounded = document.getElementById('rounded')
    rounded.checked = this.roundCorners
    rounded.addEventListener('change', () => {
      this.roundCorners = rounded.checked
    })
  }
}
const rectHandler = new RectHandler()

/* ========================================================== text ================================================ */
/**
 * Add splice method to strings (for text editing)
 * @param {number} index
 * @param {number} count
 * @param {string} add
 * @returns {string}
 */
function stringSplice(str, index, count, add) {
  let idx = index
  if (idx < 0) {
    idx = str.length
  }
  return str.slice(0, idx) + (add || '') + str.slice(idx + count)
}

const border = 10

class TextHandler extends ToolHandler {
  constructor() {
    super()
    this.inp = null
    this.writing = false
    this.font = defaultOptions.font
    this.fillStyle = defaultOptions.fontColor
  }
  panstart(e) {
    if (this.writing) return
    this.startX = e.offsetX
    this.startY = e.offsetY
    this.div = document.createElement('div')
    underlay.appendChild(this.div)
    this.div.style.position = 'absolute'
    this.div.style.zIndex = 1002
    this.div.style.boxSizing = 'border-box'
    this.div.style.border = border + 'px solid lightgrey'
    this.div.style.left = this.startX - border + 'px'
    this.div.style.top = this.startY - border + 'px'
    this.div.style.width = '300px'
    this.div.style.height = 2 * border + 50 + 'px'
    this.div.style.cursor = 'move'
    this.inp = document.createElement('textarea')
    this.div.appendChild(this.inp)
    this.inp.setAttribute('id', 'input')
    this.inp.setAttribute('type', 'text')
    this.inp.style.font = this.font
    this.inp.style.color = this.fillStyle
    this.inp.style.position = 'absolute'
    this.inp.style.boxSizing = 'border-box'
    this.inp.style.width = '100%'
    this.inp.style.height = '100%'
    this.inp.style.resize = 'none'
    this.inp.wrap = 'off'
    this.inp.addEventListener('keyup', this.insertNewlines.bind(this))
    this.inp.style.overflow = 'hidden'
    //  create a small square box at the bottom right to use as the resizing handle
    this.resizer = document.createElement('div')
    this.resizer.classList.add('resize')
    this.resizer.id = 'resizer'
    this.div.appendChild(this.resizer)
    this.resizer.cursor = 'nwse-resize'
    this.unfocusfn = this.unfocus.bind(this)
    document.addEventListener('click', this.unfocusfn)
    this.writing = true
    this.dragElement(this.div)
    super.panstart(e)
    this.inp.focus()
  }
  // tap to start a text box
  panend(e) {
    this.panstart(e)
  }
  insertNewlines() {
    // If the width of the chars in textarea are greater than its width then insert newline
    if (this.inp.scrollWidth > this.inp.clientWidth) {
      const lastSpace = this.inp.value.lastIndexOf(' ')
      // TODO
      this.inp.value = stringSplice(this.inp.value, lastSpace, 1, '\n')
    }
  }
  unfocus(e) {
    if (this.inp.value.length > 0 && this.writing) {
      this.saveText(e)
    }
  }
  saveText(e) {
    if (
      this.writing &&
      e.target !== this.inp &&
      e.target !== this.div &&
      e.target !== this.resizer
    ) {
      const text = this.inp.value
      if (text.length > 0) {
        yPointsArray.push([
          [
            'text',
            [
              text,
              DOMtoCanvasX(
                ((this.div.offsetLeft + 12) * tempCanvas.width) / (dpr * tempCanvas.clientWidth)
              ), // '12' allows for border and outline
              DOMtoCanvasY(
                ((this.div.offsetTop + 14) * tempCanvas.height) / (dpr * tempCanvas.clientHeight)
              ),
            ],
          ],
        ])
      }
      this.writing = false
      underlay.removeChild(this.div)
      document.removeEventListener('click', this.unfocusfn)
      underlay.style.cursor = 'auto'
      super.panend()
    }
  }
  optionsDialog() {
    const box = super.optionsDialog('text')
    box.innerHTML = `
	<div>Size</div><div><input id="fontSize"  type="number" min="0" max="99" size="2"></div>
	<div>Colour</div><div><input id="fontColor" type="color"></div>`
    const fontSizeInput = document.getElementById('fontSize')
    fontSizeInput.value = parseInt(this.font)
    fontSizeInput.addEventListener('blur', () => {
      this.font = fontSizeInput.value + 'px ' + this.fontFamily(this.font)
    })
    const fontColor = document.getElementById('fontColor')
    fontColor.value = this.fillStyle
    fontColor.addEventListener('blur', () => {
      this.fillStyle = fontColor.value
    })
  }
  /**
   * returns the font-family from a CSS font definition, e.g. "16px sans-serif"
   * @param {string} str
   */
  fontFamily(str) {
    return str.substring(str.indexOf(' ') + 1)
  }
  /**
   * allow user to move and resize the DIV
   * @param {HTMLElement} elem
   */
  dragElement(elem) {
    let resizing = false
    let startPosX = 0
    let startPosY = 0
    let lastPosX = 0
    let lastPosY = 0
    let width = 0
    let height = 0
    let isDragging = false

    function onPointerDown(e) {
      isDragging = true
      startPosX = e.clientX
      startPosY = e.clientY
      lastPosX = elem.offsetLeft
      lastPosY = elem.offsetTop
      const rect = elem.getBoundingClientRect()
      width = rect.width
      height = rect.height
      resizing = e.target.id === 'resizer'
      elem.setPointerCapture(e.pointerId)
    }

    function onPointerMove(e) {
      if (!isDragging) return
      const deltaX = e.clientX - startPosX
      const deltaY = e.clientY - startPosY
      if (resizing) {
        const newWidth = width + deltaX
        const newHeight = height + deltaY
        elem.style.width = `${newWidth}px`
        elem.style.height = `${newHeight}px`
      } else {
        // move
        const posX = deltaX + lastPosX
        const posY = deltaY + lastPosY
        elem.style.left = posX + 'px'
        elem.style.top = posY + 'px'
      }
    }

    function onPointerUp(e) {
      if (!isDragging) return
      isDragging = false
      resizing = false
      elem.releasePointerCapture(e.pointerId)
    }

    function onPointerCancel(e) {
      if (!isDragging) return
      isDragging = false
      resizing = false
      elem.releasePointerCapture(e.pointerId)
    }

    elem.addEventListener('pointerdown', onPointerDown)
    elem.addEventListener('pointermove', onPointerMove)
    elem.addEventListener('pointerup', onPointerUp)
    elem.addEventListener('pointercancel', onPointerCancel)
  }
}
const textHandler = new TextHandler()

/* ========================================================== pencil ================================================ */
class PencilHandler extends ToolHandler {
  constructor() {
    super()
    this.lineCap = 'butt'
    this.lineJoin = 'round'
  }
  panmove(e) {
    if (this.isPanstart) {
      this.endPosition(e)
      drawHelper.pencil(tempctx, [this.startX, this.startY, this.endX, this.endY])
      this.record()
      this.startX = this.endX
      this.startY = this.endY
    }
  }
  panend(e) {
    if (this.isPanstart) {
      this.endPosition(e)
      this.record()
      super.panend()
    }
  }
  record() {
    const scaledLW = Math.round(this.lineWidth / network.body.view.scale)
    yPointsArray.push([
      [
        'pencil',
        [
          DOMtoCanvasX(this.startX),
          DOMtoCanvasY(this.startY),
          DOMtoCanvasX(this.endX),
          DOMtoCanvasY(this.endY),
          scaledLW,
        ],
      ],
    ])
  }
  optionsDialog() {
    const box = super.optionsDialog('pencil')
    box.innerHTML = `
		<div>Width</div><div><input id="pencilWidth"  type="number" min="0" max="99" size="2"></div>
		<div>Colour</div><div><input id="pencilColor" type="color"></div>`
    const widthInput = document.getElementById('pencilWidth')
    widthInput.value = this.lineWidth
    widthInput.addEventListener('blur', () => {
      this.lineWidth = parseInt(widthInput.value)
      if (this.lineWidth > 99) this.lineWidth = 99
    })
    const pencilColor = document.getElementById('pencilColor')
    pencilColor.value = this.strokeStyle
    pencilColor.addEventListener('blur', () => {
      this.strokeStyle = pencilColor.value
    })
  }
}
const pencilHandler = new PencilHandler()

/* ========================================================== marker ================================================ */
class MarkerHandler extends ToolHandler {
  constructor() {
    super()
    this.globalCompositeOperation = 'multiply'
    this.fillStyle = '#ffff00'
    this.markerWidth = 30
  }
  panmove(e) {
    if (this.isPanstart) {
      this.endPosition(e)
      this.record()
      drawHelper.marker(tempctx, [this.startX, this.startY, this.markerWidth])
      this.startX = this.endX
      this.startY = this.endY
    }
  }
  panend(e) {
    if (this.isPanstart) {
      this.endPosition(e)
      this.record()
      super.panend()
    }
  }
  record() {
    yPointsArray.push([
      [
        'marker',
        [
          DOMtoCanvasX(this.startX),
          DOMtoCanvasY(this.startY),
          Math.round(this.markerWidth / network.body.view.scale),
        ],
      ],
    ])
  }
  optionsDialog() {
    const box = super.optionsDialog('marker')
    box.innerHTML = `
		<div>Width</div><div><input id="markerWidth"  type="number" min="0" max="99" size="2"></div>
		<div>Colour</div><div><input id="markerColor" type="color"></div>`
    const widthInput = document.getElementById('markerWidth')
    widthInput.value = this.markerWidth
    widthInput.addEventListener('blur', () => {
      this.markerWidth = parseInt(widthInput.value)
      if (this.markerWidth > 99) this.markerWidth = 99
    })
    const markerColor = document.getElementById('markerColor')
    markerColor.value = this.fillStyle
    markerColor.addEventListener('blur', () => {
      this.fillStyle = markerColor.value
    })
  }
}
const markerHandler = new MarkerHandler()

/* ========================================================== eraser ================================================ */
/* the same as a marker, but with white ink and a special, bespoke cursor */

class EraserHandler extends ToolHandler {
  constructor() {
    super()
    this.fillStyle = '#ffffff'
    this.markerWidth = 30
  }
  panstart(e) {
    super.panstart(e)
    underlay.style.cursor = 'none'
  }
  panmove(e) {
    if (this.isPanstart) {
      this.cursor('#ffffff', 1)
      this.endPosition(e)
      this.record()
      drawHelper.marker(tempctx, [this.startX, this.startY, this.markerWidth])
      this.startX = this.endX
      this.startY = this.endY
      this.cursor('#000000', 2)
    }
  }
  panend(e) {
    if (this.isPanstart) {
      this.endPosition(e)
      this.record()
      underlay.style.cursor = 'auto'
      super.panend()
    }
  }
  record() {
    yPointsArray.push([
      [
        'marker',
        [
          DOMtoCanvasX(this.startX),
          DOMtoCanvasY(this.startY),
          Math.round(this.markerWidth / network.body.view.scale),
        ],
      ],
    ])
  }
  /**
   * draw a circle at the mouse to simulate a cursor
   * @param {string} color - as hex
   * @param {number} width
   */
  cursor(color, width) {
    tempctx.beginPath()
    tempctx.arc(this.startX, this.startY, Math.round(this.markerWidth / 2 - width), 0, 2 * Math.PI)
    tempctx.strokeStyle = color
    tempctx.stroke()
  }
  optionsDialog() {
    const box = super.optionsDialog('eraser')
    box.innerHTML = `
		<div>Width</div><div><input id="eraserWidth"  type="number" min="0" max="99" size="2"></div>`
    const widthInput = document.getElementById('eraserWidth')
    widthInput.value = this.markerWidth
    widthInput.addEventListener('blur', () => {
      this.markerWidth = parseInt(widthInput.value)
      if (this.markerWidth < 3) this.markerWidth = 4
      if (this.markerWidth > 99) this.markerWidth = 99
    })
  }
}
const eraserHandler = new EraserHandler()

/* ========================================================== image ================================================ */
/**
 * ImageHandler works differently.  The image is read from a file and put into a <img> element as a dataURI.
 *  When the user is satisfied, indicated by clicking outside the image, it is drawn on the main canvas.
 *
 * Selecting the file is handled within the selectTool fn, not here, because of the restriction that file dialogs can
 * only be opended from direct user action.
 */

const resizeBox = 10 // size in pixels of small square that is the resizing handle

class ImageHandler extends ToolHandler {
  constructor() {
    super()
    this.image = null
    this.resizing = false
  }
  loadImage(e) {
    if (e.target.files) {
      const file = e.target.files[0]
      const reader = new FileReader()
      reader.readAsDataURL(file)

      reader.onloadend = function (e) {
        const image = new Image()
        imageHandler.image = image
        image.src = e.target.result
        image.onload = function (e) {
          const image = e.target
          image.origWidth = image.width
          image.origHeight = image.height
          underlay.appendChild(image)
          // check that the image is smaller than the canvas - if not, rescale it so that it fits
          const hScale = Math.ceil(image.origWidth / (underlay.offsetWidth - 100))
          const vScale = Math.ceil(image.origHeight / (underlay.offsetHeight - 100))
          let scale = 1
          if (hScale > 1.0 || vScale > 1.0) scale = Math.max(hScale, vScale)
          image.width = Math.round(image.origWidth / scale)
          image.startWidth = image.width
          image.style.width = image.width + 'px'
          image.height = Math.round(image.origHeight / scale)
          image.startHeight = image.height
          image.style.height = image.height + 'px'
          image.left = (underlay.offsetWidth - image.width) / 2
          image.style.left = image.left + 'px'
          image.top = (underlay.offsetHeight - image.height) / 2
          image.style.top = image.top + 'px'
          image.style.position = 'absolute'
          imageHandler.paintImage(
            image,
            image.origWidth,
            image.origHeight,
            image.left,
            image.top,
            image.width,
            image.height
          )
          imageHandler.image = image
          underlay.removeChild(image)
        }
      }
    }
  }
  paintImage(image, ow, oh, left, top, width, height) {
    tempctx.drawImage(image, 0, 0, ow, oh, left, top, width, height)
    //  create a small square box at the bottom right to use as the resizing handle
    tempctx.fillStyle = 'black'
    tempctx.fillRect(left + width - resizeBox, top + height - resizeBox, resizeBox, resizeBox)
    // add marching ants
    antMarch(left, top, width, height)
  }
  /**
   * startX and startY are mouse locations at the start of the drag
   * endX and endY are the locations of the mouse pointer during the drag
   * endX - startX, endY - startY are thus the drag vector
   * origWidth and origHeight are the size of the image when first loaded
   * startWidth and startHeight are the width and height at the start of resizing
   * startLeft and startTop are the position of the top left of the image at the start of dragging
   * image.left, image.top, image.width and image.height are the values during dragging and resizing that change as the mouse moves
   *
   */
  panstart(e) {
    if (this.image == null) {
      deselectTool()
      this.isPanstart = false
      return
    }
    super.panstart(e)
    this.image.startLeft = this.image.left
    this.image.startTop = this.image.top
    const resizingBlock = 2 * resizeBox // give a little leeway for pointer
    this.resizing =
      this.startX >= this.image.left + this.image.width - resizingBlock &&
      this.startX <= this.image.left + this.image.width &&
      this.startY >= this.image.top + this.image.height - resizingBlock &&
      this.startY <= this.image.top + this.image.height
    underlay.style.cursor = this.resizing ? 'nwse-resize' : 'move'
  }
  panmove(e) {
    if (this.isPanstart) {
      this.endPosition(e)
      drawHelper.clear(tempctx)
      if (this.resizing) {
        let hScale = (this.image.startWidth + this.endX - this.startX) / this.image.startWidth
        let vScale = (this.image.startHeight + this.endY - this.startY) / this.image.startHeight
        const scale = Math.max(hScale, vScale)
        hScale = scale
        vScale = scale
        this.image.width = Math.max(20, Math.round(this.image.startWidth * hScale))
        this.image.height = Math.max(20, Math.round(this.image.startHeight * vScale))
        this.paintImage(
          this.image,
          this.image.origWidth,
          this.image.origHeight,
          this.image.left,
          this.image.top,
          this.image.width,
          this.image.height
        )
      } else {
        this.image.left = this.image.startLeft + this.endX - this.startX
        this.image.top = this.image.startTop + this.endY - this.startY
        this.paintImage(
          this.image,
          this.image.origWidth,
          this.image.origHeight,
          this.image.left,
          this.image.top,
          this.image.width,
          this.image.height
        )
      }
    }
  }
  /**
   * if the user clicks anywhere outside the image, stop moving and resizing and copy the image to
   * the canvas
   * @param {event} e
   */
  panend(e) {
    this.isPanstart = false
    this.endPosition(e)
    if (
      !(
        this.endX >= this.image.left &&
        this.endX <= this.image.left + this.image.width &&
        this.endY >= this.image.top &&
        this.endY <= this.image.top + this.image.height
      )
    ) {
      yPointsArray.push([
        [
          'image',
          [
            this.image.src,
            DOMtoCanvasX(this.image.left),
            DOMtoCanvasY(this.image.top),
            this.image.origWidth,
            this.image.origHeight,
            this.image.width,
            this.image.height,
          ],
        ],
      ])
      underlay.style.cursor = 'auto'
      super.panend()
      this.image = null
      deselectTool()
    } else {
      if (this.resizing) {
        this.resizing = false
        this.image.startWidth = this.image.width
        this.image.startHeight = this.image.height
      } else {
        this.image.left = this.image.startLeft + this.endX - this.startX
        this.image.top = this.image.startTop + this.endY - this.startY
      }
    }
  }
  optionsDialog() {
    /* none */
  }
}
let ant = 0
/**
 * draw 'marching ants' around a rectangle
 * @param {Number} left
 * @param {Number} top
 * @param {Number} width
 * @param {Number} height
 */
function antMarch(left, top, width, height) {
  if (antTimer) clearTimeout(antTimer)
  march()

  function march() {
    ant++
    if (ant > 16) ant = 0
    drawAnts()
    antTimer = setTimeout(march, 100)
  }
  function drawAnts() {
    tempctx.save()
    tempctx.strokeStyle = 'white'
    tempctx.strokeRect(left, top, width, height)
    tempctx.strokeStyle = 'rgba(176, 190, 197, 0.8)'
    tempctx.setLineDash([2, 4])
    tempctx.lineDashOffset = -ant
    tempctx.strokeRect(left, top, width, height)
    tempctx.restore()
  }
}
const imageHandler = new ImageHandler()

/* ========================================================== undo ================================================ */
class UndoHandler extends ToolHandler {
  /**
   *  starting with the last of the recorded yPointsArray, delete backwards until the previous 'endShape'
   *  and then redraw what remains
   */
  undo() {
    const len = yPointsArray.length
    const points = yPointsArray.toArray()
    if (len === 0) return
    let i
    for (i = len - 2; i >= 0 && points[i][0] !== 'endShape'; i--);
    yPointsArray.delete(i + 1, len - i - 1)
    deselectTool()
    network.redraw()
  }
}
const undoHandler = new UndoHandler()

const toolToHandler = {
  line: lineHandler,
  rect: rectHandler,
  text: textHandler,
  pencil: pencilHandler,
  marker: markerHandler,
  eraser: eraserHandler,
  image: imageHandler,
  undo: undoHandler,
}
/**
 * return the correct instance of toolHandler for the given tool
 * @param {string} tool
 * @returns {object}
 */
function toolHandler(tool) {
  return toolToHandler[tool]
}

/* ==========================================drag and zoom =======================================*/
/**
 * allow for the canvas being translated, returning a coordinate adjusted for
 * the current translation and zoom, so that it is relative to the origin at the centre
 * with scale = 1.
 */

function DOMtoCanvasX(x) {
  return (
    ((dpr * tempCanvas.clientWidth * x) / tempCanvas.width - network.body.view.translation.x) /
    network.body.view.scale
  )
}

function DOMtoCanvasY(y) {
  return (
    ((dpr * tempCanvas.clientHeight * y) / tempCanvas.height - network.body.view.translation.y) /
    network.body.view.scale
  )
}

/**
 * ================================methods to redraw the canvas, one for each tool========================
 */

/**
 * redraw the main canvas, using the stored commands in yPointsArray[]
 */
export function redraw(netctx) {
  drawHelper.clear(tempctx)
  netctx.save()
  yPointsArray.forEach((point) => {
    drawHelper[point[0]](netctx, point[1], point[2])
  })
  if (drawingSwitch) drawGrid(netctx)
  netctx.restore()
}

/**
 *  Like ctx.rect(), but with rounded corners
 */
CanvasRenderingContext2D.prototype.roundRect = function (x, y, w, h, r) {
  if (w < 2 * r) r = w / 2
  if (h < 2 * r) r = h / 2
  this.beginPath()
  this.moveTo(x + r, y)
  this.arcTo(x + w, y, x + w, y + h, r)
  this.arcTo(x + w, y + h, x, y + h, r)
  this.arcTo(x, y + h, x, y, r)
  this.arcTo(x, y, x + w, y, r)
  this.closePath()
  return this
}
/**
 * draw a faint evenly spaced grid over the drawing area
 * @param {CanvasRenderingContext2D} netctx
 */

function drawGrid(netctx) {
  const netPane = document.getElementById('net-pane')
  netctx.save()
  netctx.lineWidth = 1
  netctx.strokeStyle = 'rgba(211, 211, 211, 0.8)' //'lightgrey';
  netctx.beginPath()
  for (let x = DOMtoCanvasX(0); x <= DOMtoCanvasX(dpr * netPane.offsetWidth); x += GRIDSPACING) {
    // vertical grid lines
    netctx.moveTo(x, DOMtoCanvasY(0))
    netctx.lineTo(x, DOMtoCanvasY(2 * netPane.offsetHeight))
  }
  for (let y = DOMtoCanvasY(0); y <= DOMtoCanvasY(dpr * netPane.offsetHeight); y += GRIDSPACING) {
    // horizontal grid lines
    netctx.moveTo(DOMtoCanvasX(0), y)
    netctx.lineTo(DOMtoCanvasX(2 * netPane.offsetWidth), y)
  }
  netctx.stroke()
  netctx.restore()
}
const imageCache = new Map()

const drawHelper = {
  clear: function (ctx) {
    // Use the identity matrix while clearing the canvas
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
  },
  options: function (ctx, options) {
    applyOptions(ctx, options)
  },
  line: function (ctx, [startX, startY, endX, endY]) {
    ctx.beginPath()
    ctx.moveTo(startX, startY)
    ctx.lineTo(endX, endY)
    ctx.stroke()
  },
  dashedLine: function (ctx, [startX, startY, endX, endY]) {
    ctx.beginPath()
    ctx.moveTo(startX, startY)
    ctx.setLineDash([10, 10])
    ctx.lineTo(endX, endY)
    ctx.stroke()
    ctx.setLineDash([])
  },
  rect: function (ctx, [startX, startY, width, height]) {
    ctx.beginPath()
    ctx.lineJoin = 'miter'
    ctx.rect(startX, startY, width, height)
    if (ctx.lineWidth > 0) ctx.stroke()
    // treat white as transparent
    if (ctx.fillStyle !== '#ffffff') ctx.fill()
  },
  rrect: function (ctx, [startX, startY, width, height]) {
    ctx.beginPath()
    ctx.roundRect(startX, startY, width, height, 10)
    if (ctx.lineWidth > 0) ctx.stroke()
    if (ctx.fillStyle !== '#ffffff') ctx.fill()
  },
  text: function (ctx, [text, x, y]) {
    ctx.textBaseline = 'top'
    ctx.beginPath()
    const lineHeight = ctx.measureText('M').width * 1.2
    const lines = text.split('\n')
    for (let i = 0; i < lines.length; ++i) {
      ctx.fillText(lines[i], x, y)
      y += lineHeight
    }
  },
  pencil: function (ctx, [startX, startY, endX, endY, width]) {
    ctx.lineWidth = width
    ctx.beginPath()
    ctx.moveTo(startX, startY)
    ctx.lineTo(endX, endY)
    ctx.closePath()
    ctx.stroke()
  },
  marker: function (ctx, [startX, startY, width]) {
    const halfWidth = Math.round(width / 2)
    ctx.beginPath()
    ctx.roundRect(startX - halfWidth, startY - halfWidth, width, width, halfWidth)
    ctx.fill()
  },
  eraser: {
    /* never called: eraser uses 'marker'*/
  },
  image: function (ctx, [src, x, y, ow, oh, w, h]) {
    const xt = x + network.body.view.translation.x
    const yt = y + network.body.view.translation.y
    let img = imageCache.get(src.substring(0, 100))
    if (img === undefined) {
      // not yet cached, so create the Image
      img = new Image()
      img.src = src
      imageCache.set(src.substring(0, 100), img)
      img.onload = function () {
        ctx.drawImage(this, 0, 0, ow, oh, xt, yt, w, h)
      }
    } else {
      ctx.drawImage(img, 0, 0, ow, oh, x, y, w, h)
    }
  },
  undo: {
    /* never called */
  },
  endShape: function () {
    /* noop */
  },
}

/**
 * apply the canvas options to the context
 * @param {CanvasRenderingContext2D } ctx
 * @param {object} options - object with options as properties
 */

function applyOptions(ctx, options) {
  for (const option in options) ctx[option] = options[option]
}