/*********************************************************************************************************************
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'
import * as Hammer from '@egjs/hammerjs'
/**
* Initialisation
*/
/* default canvas attributes */
let 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
let 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)
let mc = new Hammer.Manager(tempCanvas, {
recognizers: [[Hammer.Tap], [Hammer.Pan, {direction: Hammer.DIRECTION_ALL, threshold: 1}]],
})
mc.on('tap', mouseDespatch)
mc.on('panstart', mouseDespatch)
mc.on('panmove', mouseDespatch)
mc.on('panend', mouseDespatch)
}
/**
* 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.
let 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) {
let 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() {
let 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
let inpBox = document.getElementById('input')
if (inpBox) {
textHandler.saveText(event)
}
let 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') {
let 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() {
let 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) {
let 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() {
let 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>`
let widthInput = document.getElementById('lineWidth')
widthInput.value = this.lineWidth
widthInput.addEventListener('change', () => {
this.lineWidth = parseInt(widthInput.value)
if (this.lineWidth > 99) this.lineWidth = 99
})
let lineColor = document.getElementById('lineColour')
lineColor.value = this.strokeStyle
lineColor.addEventListener('blur', () => {
this.strokeStyle = lineColor.value
})
let dashed = document.getElementById('dashed')
dashed.checked = this.dashed
dashed.addEventListener('change', () => {
this.dashed = dashed.checked
})
let axes = document.getElementById('axes')
axes.checked = this.axes
axes.addEventListener('change', () => {
this.axes = axes.checked
})
}
}
let 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)
let width = endX - startX
let 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]
}
let width = DOMtoCanvasX(this.endX) - DOMtoCanvasX(this.startX)
let 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() {
let 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>`
let widthInput = document.getElementById('borderWidth')
widthInput.value = this.lineWidth
widthInput.addEventListener('blur', () => {
this.lineWidth = parseInt(widthInput.value)
if (this.lineWidth > 99) this.lineWidth = 99
})
let borderColor = document.getElementById('borderColour')
borderColor.value = this.strokeStyle
borderColor.addEventListener('blur', () => {
this.strokeStyle = borderColor.value
})
let fillColor = document.getElementById('fillColour')
fillColor.value = this.fillStyle
fillColor.addEventListener('blur', () => {
this.fillStyle = fillColor.value
})
let rounded = document.getElementById('rounded')
rounded.checked = this.roundCorners
rounded.addEventListener('change', () => {
this.roundCorners = rounded.checked
})
}
}
let rectHandler = new RectHandler()
/* ========================================================== text ================================================ */
String.prototype.splice = function (index, count, add) {
if (index < 0) {
index = this.length
}
return this.slice(0, index) + (add || '') + this.slice(index + 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) {
let lastSpace = this.inp.value.lastIndexOf(' ')
// TODO
this.inp.value = this.inp.value.splice(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) {
let 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() {
let 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>`
let fontSizeInput = document.getElementById('fontSize')
fontSizeInput.value = parseInt(this.font)
fontSizeInput.addEventListener('blur', () => {
this.font = fontSizeInput.value + 'px ' + this.fontFamily(this.font)
})
let 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 lastPosX = 0
let lastPosY = 0
let width = 0
let height = 0
let isDragging = false
let mc = new Hammer.Manager(elem, {
recognizers: [[Hammer.Pan, {direction: Hammer.DIRECTION_ALL, threshold: 0}]],
})
mc.on('pan', handleDrag)
function handleDrag(e) {
let target = e.target
if (!isDragging) {
isDragging = true
lastPosX = elem.offsetLeft
lastPosY = elem.offsetTop
let rect = elem.getBoundingClientRect()
width = rect.width
height = rect.height
resizing = target.id == 'resizer'
}
if (resizing) {
let newWidth = width + e.deltaX
let newHeight = height + e.deltaY
elem.style.width = `${newWidth}px`
elem.style.height = `${newHeight}px`
} else {
// move
let posX = e.deltaX + lastPosX
let posY = e.deltaY + lastPosY
elem.style.left = posX + 'px'
elem.style.top = posY + 'px'
}
if (e.isFinal) {
isDragging = false
resizing = false
}
}
}
}
let 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() {
let 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() {
let 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>`
let widthInput = document.getElementById('pencilWidth')
widthInput.value = this.lineWidth
widthInput.addEventListener('blur', () => {
this.lineWidth = parseInt(widthInput.value)
if (this.lineWidth > 99) this.lineWidth = 99
})
let pencilColor = document.getElementById('pencilColor')
pencilColor.value = this.strokeStyle
pencilColor.addEventListener('blur', () => {
this.strokeStyle = pencilColor.value
})
}
}
let 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() {
let 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>`
let widthInput = document.getElementById('markerWidth')
widthInput.value = this.markerWidth
widthInput.addEventListener('blur', () => {
this.markerWidth = parseInt(widthInput.value)
if (this.markerWidth > 99) this.markerWidth = 99
})
let markerColor = document.getElementById('markerColor')
markerColor.value = this.fillStyle
markerColor.addEventListener('blur', () => {
this.fillStyle = markerColor.value
})
}
}
let 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() {
let box = super.optionsDialog('eraser')
box.innerHTML = `
<div>Width</div><div><input id="eraserWidth" type="number" min="0" max="99" size="2"></div>`
let 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
})
}
}
let 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) {
let file = e.target.files[0]
let reader = new FileReader()
reader.readAsDataURL(file)
reader.onloadend = function (e) {
let image = new Image()
imageHandler.image = image
image.src = e.target.result
image.onload = function (e) {
let 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
let hScale = Math.ceil(image.origWidth / (underlay.offsetWidth - 100))
let 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
let 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()
}
}
let imageHandler = new ImageHandler()
/* ========================================================== undo ================================================ */
class UndoHandler extends ToolHandler {
constructor() {
super()
}
/**
* starting with the last of the recorded yPointsArray, delete backwards until the previous 'endShape'
* and then redraw what remains
*/
undo() {
let len = yPointsArray.length
let 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()
}
}
let 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) {
let 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()
}
let imageCache = new Map()
let 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()
let lineHeight = ctx.measureText('M').width * 1.2
let 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]) {
let 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]) {
let xt = x + network.body.view.translation.x
let 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 (let option in options) ctx[option] = options[option]
}