Source: formula.js

/* exported Formula */

/**
 * Field object
 * @typedef Field
 * @property {String}                         name       The field's pretty name
 * @property {Object.<String, Field>|Boolean} children   Either another level of Fields or a boolean (to use in combination of the 'onFieldExpand' callback)
 * @property {Object}                         customData Custom data for the field (Will be the 'onFieldExpand' callback second argument)
 * @memberof Formula
 * @example
 * {
 *   name: 'Pretty name',
 *   children: {
 *     firstChild: {
 *       name: 'Child name'
 *     },
 *     secondChild: {
 *       name: 'Second child name',
 *       children: true
 *     }
 *   }
 * }
 */

/** Formula class */
class Formula {
	/**
	 * Creates an instance of Formula
	 * @param {(Element|String)}        parent                  The intended wrapper
	 * @param {Object}                  [options]               Optional additional parameters
	 * @param {String[]}                [options.separators]    Characters that will process the inputted String into a new tag
	 * @param {String}                  [options.closers]       A chain of characters that will always trigger a new separate tag
	 * @param {Object.<String, String>} [options.lang]          Dictionary holder (The attribute 'field' is the only one needed right now)
	 * @param {Object.<String, Field>}  [options.customFields]  Custom Fields to display
	 * @param {Function}                [options.onFieldExpand] Callback REQUIRED if you use the 'children: true' Field property. Expects a Field-like object to be returned
	 * @param {Function}                [options.onUpdate]      Callback triggered whenever the Formula gets updated. It's first parameter is the String representation of the Formula
	 */
	constructor(parent, options) {
		this._container = parent instanceof Element ? parent : document.querySelector(parent);
		this._options = {
			separators: [' ', 'Enter'],
			closers: '+-*/()%^',
			lang: {
				field: 'Custom Field'
			},
			onFieldExpand: () => ({}),
			onUpdate: () => {},
			...options
		};

		this._build();
		this._listen();
	}

	/**
	 * Build the Formula input
	 * @private
	 */
	_build() {
		this._container.classList.add('formula-js-container');
		this._container.innerHTML = `
			<div class="formula-js-input">
				<div class="formula-js-caret"></div>
			</div>
			<div class="formula-js-buttons">
				${this._options.customFields ? `<span class="formula-js-tag field-button">${this._options.lang.field}</span>` : ''}
				${this._options.closers.split('').map(closer => `
					<span class="formula-js-tag single">${closer}</span>
				`)
				.join('')}
			</div>
			${this._options.customFields ? '<div class="formula-js-fields formula-js-field-children"></div>' : ''}
		`;

		if(this._options.customFields) this._buildFields(this._container.querySelector('.formula-js-fields'), this._options.customFields);

		this._input = this._container.firstElementChild;
		this._caret = this._input.firstElementChild;
	}

	/**
	 * Build the Custom Field tree
	 * @param {Element} wrapper
	 * @param {Object} fields
	 * @param {String} path
	 * @param {String} prefix
	 * @private
	 */
	_buildFields(wrapper, fields, path = '', prefix){
		// Global UL
		const ul = document.createElement('ul');

		wrapper.appendChild(ul);

		// For each property
		Object.entries(fields).forEach(([field, {name, children, customData}]) => {
			// Field main LI
			const fieldLi = document.createElement('li');

			fieldLi.classList.add('formula-js-field');
			fieldLi.setAttribute('data-field', path + field);
			fieldLi.setAttribute('data-name', prefix ? prefix + ' > ' + name : name);
			fieldLi.innerText = name;
			this._listenToFieldClick(fieldLi);

			ul.appendChild(fieldLi);

			if(children){
				// Field chevron SPAN
				const fieldChevron = document.createElement('span');

				fieldChevron.classList.add('children');
				this._listenToFieldChevronClick(fieldChevron, customData);

				fieldLi.appendChild(fieldChevron);

				// Field children LI
				const fieldChildrenLi = document.createElement('li');

				fieldChildrenLi.classList.add('formula-js-field-children');

				if(children !== true) this._buildFields(fieldChildrenLi, children, field + '.', prefix ? prefix + ' > ' + name : name);

				ul.appendChild(fieldChildrenLi);
			}
		});
	}

	/**
	 * Handle the click on a custom field
	 * @param {HTMLLIElement} field 
	 * @private
	 */
	_listenToFieldClick(field){
		field.addEventListener('click', () => {
			const tag = document.createElement('span');

			tag.setAttribute('data-field', field.getAttribute('data-field'));
			tag.innerText = field.getAttribute('data-name');

			this._caret.insertAdjacentElement('beforebegin', tag);

			Reflect.apply(this._options.onUpdate, this, [this.get()]);
		});
	}

	/**
	 * Handle the click on a custom field chevron
	 * @param {HTMLSpanElement} chevron 
	 * @param {Object} customData 
	 * @private
	 */
	_listenToFieldChevronClick(chevron, customData){
		chevron.addEventListener('click', e => {
			e.stopPropagation();

			if(chevron.parentElement.classList.contains('open')){
				chevron.parentElement.classList.remove('open');

				// Close children trees too
				const currentChildren = chevron.parentElement.nextElementSibling;

				if(currentChildren && currentChildren.classList.contains('formula-js-field-children') && currentChildren.children.length){
					currentChildren.querySelectorAll('.formula-js-field.open').forEach(openField => {
						openField.classList.remove('open');
					});
				}
			}else{
				chevron.parentElement.classList.add('open');
			}

			// Children not loaded
			if(!chevron.parentElement.nextElementSibling.children.length){
				Reflect.apply(this._options.onFieldExpand, this, [chevron.parentElement, customData]).then(fields => {
					this._buildFields(
						chevron.parentElement.nextElementSibling,
						fields,
						chevron.parentElement.getAttribute('data-field') + '.',
						chevron.parentElement.getAttribute('data-name')
					);
				}).catch(error => {
					console.log(error);
				});
				
			}
		});
	}

	/**
	 * Attach event listeners
	 * @private
	 */
	_listen() {
		// Main input style toggling
		this._input.addEventListener('click', e => {
			this._input.classList.add('active');

			// Tag click
			if (e.target.nodeName == 'SPAN') {
				e.target.insertAdjacentElement('afterend', this._caret);
			}
		});

		document.addEventListener('click', e => {
			const closestFormulaInput = e.target.closest('.formula-js-input');

			// Clicked inside a FormulaJS input
			if(closestFormulaInput){
				// Didn't click inside the CURRENT FormulaJS input
				if(!this._input.isSameNode(closestFormulaInput)) this._input.classList.remove('active');
			}else{
				// Clicked outside a FormulaJS input
				this._input.classList.remove('active');
			}
		});

		// Keypresses duplicator
		document.addEventListener('keydown', e => {
			if (this._input.classList.contains('active')) {
				// Move the caret in the input
				if ([37, 39].includes(e.keyCode)) {
					if(e.keyCode == 37 && this._caret.previousElementSibling){
						this._caret.previousElementSibling.insertAdjacentElement('beforebegin', this._caret);
					}else if(e.keyCode == 39 && this._caret.nextElementSibling){
						this._caret.nextElementSibling.insertAdjacentElement('afterend', this._caret);
					}
				} else {
					// Separator
					if (this._options.separators.includes(e.key)) {
						e.preventDefault();

						if (this._caret.textContent.length) {
							this._processUserInput();
						}
					} else {
						this._handleKey(e);
					}
				}
			}
		});

		// Custom Field handler
		if(this._options.customFields){
			this._container.querySelector('.field-button').addEventListener('click', e => {
				// Hide custom fields
				if(e.target.classList.contains('active')){
					e.target.classList.remove('active');
					this._container.querySelector('.formula-js-fields').classList.remove('formula-js-field-open');

					// Close all open fields
					this._container.querySelectorAll('.formula-js-fields .open').forEach(openField => {
						openField.classList.remove('open');
					});
				}else{
					// Show custom fields
					e.target.classList.add('active');
					this._container.querySelector('.formula-js-fields').classList.add('formula-js-field-open');
				}
			});
		}

		// Operator click
		this._container.querySelectorAll('.single').forEach(single => {
			single.addEventListener('click', () => {
				this.add(single.textContent);
			});
		});
	}

	/**
	 * Process the user's input after validation
	 * @private
	 */
	_processUserInput() {
		const
			closersRegexParts = this._options.closers
				.split('')
				.map(closer => closer.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'))
				.reduce((acc, curr) => acc.concat(['(?<='+curr+')', '(?='+curr+')']), []),
			finalSeparators = this._options.separators
				.map(separator => separator == 'Enter' ? '\\n' : separator.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'))
				.concat(closersRegexParts),
			regex = new RegExp(finalSeparators.join('|'), 'u'),
			content = this._caret.textContent;

		this._caret.textContent = '';

		content.split(regex).filter(e => e.length).forEach(newPart => {
			const part = document.createElement('span');

			part.innerText = newPart;
			this._caret.insertAdjacentElement('beforebegin', part);
		});

		Reflect.apply(this._options.onUpdate, this, [this.get()]);
	}

	/**
	 * Handle the user's non-separator keydown event
	 * @private
	 */
	_handleKey(event){
		// Printable
		if (event.key.length == 1) {
			// Paste
			if (event.key == 'v' && event.ctrlKey) {
				this._useClipboard();
			} else {
				// User used an auto-closer
				if(this._options.closers.includes(event.key)){
					if (this._caret.textContent.length) {
						this._processUserInput();
					}
					
					this._caret.textContent = event.key;
					this._processUserInput();
				}else{
					// Basic usage
					this._caret.innerHTML += event.key;
				}
			}
		} else {
			// Some sort of control
			switch (event.keyCode) {
				// Backspace
				case 8:
					// There is some text to delete
					if (this._caret.textContent.length) {
						this._caret.textContent = this._caret.textContent.slice(0, -1);
					} else {
						// Remove previous Element if there is one
						if (this._caret.previousElementSibling) {
							this._caret.previousElementSibling.remove();

							Reflect.apply(this._options.onUpdate, this, [this.get()]);
						}
					}

					break;

				default:
					break;
			}
		}
	}

	/**
	 * Use the clipboard to add multiple tags
	 * @private
	 */
	_useClipboard(){
		navigator.clipboard.readText()
			.then(clipboard => {
				this._caret.innerHTML += clipboard;
				this._processUserInput();
			})
			.catch(err => {
				alert('Error while fetching clipboard: ' + err);
			});
	}

	/**
	 * Get the String value of the Formula
	 * @returns {String} The String representation of the Formula
	 */
	get(){
		return [...this._input.children].map(e => e.getAttribute('data-field') || e.textContent).join(' ');
	}

	/**
	 * Set the Formula manually
	 * @param {String} formulaString The Formula String
	 * @returns {Formula} The current Formula
	 */
	set(formulaString){
		return this.clear().add(formulaString);
	}

	/**
	 * Add to the Formula manually
	 * @param {String} formulaString The Formula String
	 * @returns {Formula} The current Formula
	 */
	add(formulaString){
		// Set the caret
		this._caret.textContent += formulaString;

		// Process the string
		this._processUserInput();

		return this;
	}

	/**
	 * Add a custom field to the Formula manually
	 * @param {String} fieldPath The field path
	 * @returns {Formula} The current Formula
	 */
	addField(fieldPath){
		const fieldLi = this._container.querySelector(`li.formula-js-field[data-field="${fieldPath}"]`);

		if(fieldLi){
			fieldLi.dispatchEvent(new Event('click'));
		}else{
			console.warn(`FormulaJS: The custom field ${fieldPath} doesn't exist. It has been added in raw form.`);
			this.add(fieldPath);
		}

		return this;
	}

	/**
	 * Clear the Formula manually
	 * @returns {Formula} The current Formula
	 */
	clear(){
		// Remove all tags
		this._input.querySelectorAll('span').forEach(span => {
			span.remove();
		});

		// Set the caret
		this._caret.textContent = '';

		return this;
	}

	/**
	 * Removes any Formula mutation from the DOM
	 */
	destroy() {
		this._container.innerHTML = '';
		this._container.classList.remove('formula-js-container');
		this._options = null;
	}

	/**
     * Removes any Formula mutation from the DOM
     * @param {String} selector The selector for the Formula parent
     * @static
     */
	static destroy(selector) {
		const formulaNode = document.querySelector(selector);

		if(formulaNode && formulaNode.classList.contains('formula-js-container')){
			formulaNode.innerHTML = '';
			formulaNode.classList.remove('formula-js-container');
		}
	}
}