// This file is part of Moodle - http://moodle.org/
//
// Moodle 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.
//
// Moodle 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 Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Tiny Equation UI.
*
* @module tiny_equation/ui
* @copyright 2022 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import EquationModal from 'tiny_equation/modal';
import ModalFactory from 'core/modal_factory';
import ModalEvents from 'core/modal_events';
import {getContextId, getLibraries, getTexDocsUrl} from 'tiny_equation/options';
import {notifyFilterContentUpdated} from 'core/event';
import * as TinyEquationRepository from 'tiny_equation/repository';
import {exception as displayException} from 'core/notification';
import {debounce} from 'core/utils';
import Selectors from 'tiny_equation/selectors';
import {getSourceEquation, getCurrentEquationData, setEquation} from 'tiny_equation/equation';
let currentForm;
let lastCursorPos = 0;
/**
* Handle action
* @param {TinyMCE} editor
*/
export const handleAction = (editor) => {
displayDialogue(editor);
};
/**
* Display the equation editor
* @param {TinyMCE} editor
* @returns {Promise<void>}
*/
const displayDialogue = async(editor) => {
let data = {};
const currentEquationData = getCurrentEquationData(editor);
if (currentEquationData) {
Object.assign(data, currentEquationData);
}
const modalPromises = await ModalFactory.create({
type: EquationModal.TYPE,
templateContext: getTemplateContext(editor, data),
large: true,
});
modalPromises.show();
const $root = await modalPromises.getRoot();
const root = $root[0];
currentForm = root.querySelector(Selectors.elements.form);
const contextId = getContextId(editor);
const debouncedPreviewUpdater = debounce(() => updatePreview(getContextId(editor)), 500);
$root.on(ModalEvents.hidden, () => {
modalPromises.destroy();
});
$root.on(ModalEvents.shown, () => {
const library = root.querySelector(Selectors.elements.library);
TinyEquationRepository.filterEquation(contextId, library.innerHTML).then(async data => {
library.innerHTML = data.content;
updatePreview(contextId);
notifyFilter(library);
return data;
}).catch(displayException);
});
root.addEventListener('click', (e) => {
const libraryItem = e.target.closest(Selectors.elements.libraryItem);
const submitAction = e.target.closest(Selectors.actions.submit);
const textArea = e.target.closest('.tiny_equation_equation');
if (libraryItem) {
e.preventDefault();
selectLibraryItem(libraryItem, contextId);
}
if (submitAction) {
e.preventDefault();
setEquation(currentForm, editor);
modalPromises.destroy();
}
if (textArea) {
debouncedPreviewUpdater();
}
});
root.addEventListener('keyup', (e) => {
const textArea = e.target.closest(Selectors.elements.equationTextArea);
if (textArea) {
debouncedPreviewUpdater();
}
});
root.addEventListener('keydown', (e) => {
const libraryItem = e.target.closest(Selectors.elements.libraryItem);
if (libraryItem) {
if (e.keyCode == 37 || e.keyCode == 39) {
groupNavigation(e);
}
}
});
};
/**
* Get template context.
* @param {TinyMCE} editor
* @param {Object} data
* @returns {Object}
*/
const getTemplateContext = (editor, data) => {
const libraries = getLibraries(editor);
const texDocsUrl = getTexDocsUrl(editor);
return Object.assign({}, {
elementid: editor.id,
libraries: libraries,
texdocsurl: texDocsUrl,
delimiters: Selectors.delimiters,
}, data);
};
/**
* Handle select library item.
* @param {Object} libraryItem
* @param {number} contextId
*/
const selectLibraryItem = (libraryItem, contextId) => {
const tex = libraryItem.getAttribute('data-tex');
const input = currentForm.querySelector(Selectors.elements.equationTextArea);
let oldValue;
let newValue;
let focusPoint = 0;
oldValue = input.value;
newValue = oldValue.substring(0, lastCursorPos);
if (newValue.charAt(newValue.length - 1) !== ' ') {
newValue += ' ';
}
newValue += tex;
focusPoint = newValue.length;
if (oldValue.charAt(lastCursorPos) !== ' ') {
newValue += ' ';
}
newValue += oldValue.substring(lastCursorPos, oldValue.length);
input.value = newValue;
input.focus();
input.selectionStart = input.selectionEnd = focusPoint;
updatePreview(contextId);
};
/**
* Update the preview section.
* @param {number} contextId
*/
const updatePreview = (contextId) => {
const textarea = currentForm.querySelector(Selectors.elements.equationTextArea);
const preview = currentForm.querySelector(Selectors.elements.preview);
const prefix = '';
const cursorLatex = Selectors.cursorLatex;
const isChar = /[a-zA-Z{]/;
let currentPos = textarea.selectionStart;
let equation = textarea.value;
// Move the cursor so it does not break expressions.
// Start at the very beginning.
if (!currentPos) {
currentPos = 0;
}
if (getSourceEquation()) {
currentPos = equation.length;
}
// First move back to the beginning of the line.
while (equation.charAt(currentPos) === '\\' && currentPos >= 0) {
currentPos -= 1;
}
if (currentPos !== 0) {
if (equation.charAt(currentPos - 1) != '{') {
// Now match to the end of the line.
while (isChar.test(equation.charAt(currentPos)) &&
currentPos < equation.length &&
isChar.test(equation.charAt(currentPos - 1))) {
currentPos += 1;
}
}
}
// Save the cursor position - for insertion from the library.
lastCursorPos = currentPos;
equation = prefix + equation.substring(0, currentPos) + cursorLatex + equation.substring(currentPos);
equation = Selectors.delimiters.start + ' ' + equation + ' ' + Selectors.delimiters.end;
TinyEquationRepository.filterEquation(contextId, equation).then((data) => {
preview.innerHTML = data.content;
notifyFilter(preview);
return data;
}).catch(displayException);
};
/**
* Notify the filters about the modified nodes
* @param {Element} element
*/
const notifyFilter = (element) => {
notifyFilterContentUpdated(element);
};
/**
* Callback handling the keyboard navigation in the groups of the library.
* @param {Event} e
*/
const groupNavigation = (e) => {
e.preventDefault();
const current = e.target.closest(Selectors.elements.libraryItem);
const parent = current.parentNode; // This must be the <div> containing all the buttons of the group.
const buttons = Array.prototype.slice.call(parent.querySelectorAll(Selectors.elements.libraryItem));
const direction = e.keyCode !== 37 ? 1 : -1;
let index = buttons.indexOf(current);
let nextButton;
if (index < 0) {
index = 0;
}
index += direction;
if (index < 0) {
index = buttons.length - 1;
} else if (index >= buttons.length) {
index = 0;
}
nextButton = buttons[index];
nextButton.focus();
};