tutorial.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 handles the display of tutorial help messages to beginners.  
 ******************************************************************************************************************** */

/**
 * Class to display help messages in sequence as a user guide
 *
 * Each HTML element that should have a tutorial message should include the attributes:
 * data-step {number} specifies the order of the sequence of messages
 * data-tutorial: the message (can include HTML tags)
 * data-position: the location of the message relative to the element, one of
 * above, above-left, above-middle, above-right, left, right and below and their variants
 * or splash (which positions the message in the middle of the viewport)
 * To initialise:
 *  tutorial = new Tutorial()
 *  tutorial.start()
 *
 *  tutorial.onstep(Array of step numbers or a step number, callback) Evaluate call back
 *       after displaying tutorial message
 *  tutorial.onexit(callback) Evaluate callback when user finishes or skips the tutorial
 */
export default class Tutorial {
	constructor() {
		this.step = 0
		this.steps = Array.from(document.querySelectorAll('[data-step]')).sort((a, b) => {
			return parseInt(a.dataset.step) - parseInt(b.dataset.step)
		})
		this.back = null
	}
	/**
	 * initialise the step counter and display the first step
	 * @param {number} start optional step to start at (if not provided, start at the lowest numbered step)
	 */
	start(start) {
		if (start !== undefined) this.step = start
		this.stepStart()
	}
	/**
	 * create a tutorial element and position it
	 */
	stepStart() {
		let elem = this.steps[this.step]
		let text = elem.dataset.tutorial
		let position = elem.dataset.position
		let classToAdd = elem.dataset.tutorialclass
		let prevLegend = 'Back'
		let nextLegend = 'Next'
		// first and last have special buttons
		if (this.step == 0) prevLegend = 'Skip'
		if (this.step == this.steps.length - 1) nextLegend = 'Done'
		let dialog = document.createElement('div')
		dialog.className = `tutorial-dialog ${position}`
		if (classToAdd) dialog.classList.add(classToAdd)
		dialog.id = 'tutorial'
		dialog.innerHTML = `
<div class="tutorial-arrow ${position}"></div>
<div class="x-button" id="tutorial-cancel">&times;</div>
<div class="text">
    ${text}
</div>
<input
    type="button"
    value="${nextLegend}"
    id="next"
    class="tutorial-button next"
/>
<input
    type="button"
    value="${prevLegend}"
    id="prev"
    class="tutorial-button prev"
/>
</div>`
		dialog.style.visibility = 'hidden'
		document.querySelector('body').appendChild(dialog)
		// position the tutorial item and the border around the item being explained
		let dialogBR = dialog.getBoundingClientRect()
		let elemBR = elem.getBoundingClientRect()
		let top = elemBR.top
		let left = elemBR.left
		if (position == 'splash') {
			if (!this.back) {
				this.back = document.createElement('div')
				this.back.classList.add('tutorial-background')
				elem.insertAdjacentElement('afterend', this.back)
			}
			dialog.classList.add('splash')
			if (this.step == 0) {
				// initial splash screen for new users
				dialog.classList.add('intro-splash')
				let img = document.createElement('img')
				img.src = new URL('../icons/PRSMlogo200.png?as=webp&width=80', import.meta.url)
				document.getElementById('intro-logo').appendChild(img)
			}
		} else {
			let border = document.createElement('div')
			border.className = 'tutorial-border'
			border.id = 'tutorial-border'
			border.style.top = elemBR.top - 3 + 'px'
			border.style.left = elemBR.left - 3 + 'px'
			border.style.width = elemBR.width + 'px'
			border.style.height = elemBR.height + 'px'
			document.querySelector('body').appendChild(border)
			switch (position) {
				case 'below':
				case 'below-right':
				case 'below-middle':
				case 'below-left':
					top = elemBR.bottom + 15
					break
				case 'above':
				case 'above-left':
				case 'above-middle':
				case 'above-right':
					top = elemBR.top - dialogBR.height - 15
					break
				case 'right':
				case 'right-middle':
				case 'right-bottom':
					left = elemBR.right + 15
					break
				case 'left':
				case 'left-middle':
				case 'left-bottom':
					left = elemBR.left - dialogBR.width - 15
					break
				default:
					console.log(`Tutorial: Unknown data-position at step ${this.step}`)
					break
			}
			// ensure the dialog is in the viewport
			if (top < 0) top = 0
			if (top > window.innerHeight - dialogBR.height) top = window.innerHeight - dialogBR.height
			if (left < 0) left = 0
			if (left > window.innerWidth - dialogBR.width) left = window.innerWidth - dialogBR.width
			dialog.style.top = top + 'px'
			dialog.style.left = left + 'px'
		}
		dialog.style.visibility = ''

		// add event listeners to buttons to increment/decrement step,
		// destroy this dialog and then call step() to display next one
		document.querySelector('#next').addEventListener('click', () => {
			this.step += 1
			this.stepFinish()
		})
		document.querySelector('#prev').addEventListener('click', () => {
			this.step -= 1
			this.stepFinish()
		})
		document.querySelector('#tutorial-cancel').addEventListener('click', () => {
			this.step = this.steps.length
			this.stepFinish()
		})
		// call onsstepstart function if to run now
		this.runStepStart()
	}
	runStepStart() {
		if (this.onstep == undefined) return
		if (Array.isArray(this.onstep)) {
			if (this.onstep.indexOf(this.step) == -1) return
		} else {
			if (this.onstep != this.step) return
		}
		if (typeof this.onstepfn === 'function') this.onstepfn()
	}
	/**
	 * destroy the tutorial dialog, remove the border around the item being explained, and
	 * and call stepStart() to display the next one
	 */
	stepFinish() {
		let dialog = document.querySelector('#tutorial')
		if (dialog) dialog.remove()
		let border = document.querySelector('#tutorial-border')
		if (border) border.remove()
		if (this.step < 0 || this.step >= this.steps.length) this.stepsEnd()
		else this.stepStart()
	}
	/**
	 * called on exit
	 */
	stepsEnd() {
		if (this.back) {
			this.back.remove()
			this.back = null
		}
		if (typeof this.onexitfn === 'function') this.onexitfn()
	}
	/**
	 * store the cleanup function until needed
	 * @param {function} callback
	 */
	onexit(callback) {
		this.onexitfn = callback
	}
	onstep(step, callback) {
		this.onstep = step
		this.onstepfn = callback
	}
}