Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
315 changes: 11 additions & 304 deletions htdocs/js/MathQuill/mqeditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
// initialize MathQuill
const MQ = MathQuill.getInterface();

let toolbarEnabled = (localStorage.getItem('MQEditorToolbarEnabled') ?? 'true') === 'true';

const setupMQInput = (mq_input) => {
if (mq_input.dataset.mqEditorInitialized) return;
mq_input.dataset.mqEditorInitialized = 'true';
Expand Down Expand Up @@ -50,30 +48,15 @@
.join(' '),
rootsAreExponents: true,
logsChangeBase: true,
useToolbar: true,
maxDepth: 10
};

// Merge options that are set by the problem.
if (answerQuill.latexInput.dataset.mqOpts)
Object.assign(cfgOptions, JSON.parse(answerQuill.latexInput.dataset.mqOpts));

// The handlers and blurWithCursor options are set after
// the option merge to prevent them from being overridden.
cfgOptions.handlers = {
// Disable the toolbar when a text block is entered.
textBlockEnter: () => {
answerQuill.toolbar?.querySelectorAll('button').forEach((button) => (button.disabled = true));
},
// Re-enable the toolbar when a text block is exited.
textBlockExit: () => {
answerQuill.toolbar?.querySelectorAll('button').forEach((button) => (button.disabled = false));
}
};

cfgOptions.blurWithCursor = (e) =>
toolbarEnabled &&
answerQuill.toolbar &&
(e.relatedTarget?.closest('.quill-toolbar') || e.relatedTarget?.classList.contains('symbol-button'));
cfgOptions.handlers = {};

const latexEntryMode = input.classList.contains('latexentryfield');

Expand Down Expand Up @@ -276,11 +259,6 @@

// Trigger a button press when the enter key is pressed in an answer box.
cfgOptions.handlers.enter = () => {
// Ensure that the toolbar and any open tooltips are removed.
answerQuill.toolbar?.tooltips.forEach((tooltip) => tooltip.dispose());
answerQuill.toolbar?.remove();
delete answerQuill.toolbar;

// For ww2 homework if the enter_key_submit button is found, then use that.
// This Depends on $pg{options}{enterKey}.
const enterKeySubmit = document.getElementById('enter_key_submit');
Expand All @@ -291,12 +269,6 @@
// If the enter_key_submit button is not found (it will not be present in tests),
// then use the preview button.
document.querySelector('input[name=previewAnswers]')?.click();

// For ww3
const previewButtonId = answerQuill.textarea
.closest('[name=problemMainForm]')
?.id.replace('problemMainForm', 'previewAnswers');
if (previewButtonId) document.getElementById(previewButtonId)?.click();
};

input.after(answerQuill);
Expand All @@ -306,282 +278,17 @@

answerQuill.textarea = answerQuill.querySelector('textarea');

answerQuill.buttons = [
{ id: 'frac', latex: '/', tooltip: 'fraction (/)', icon: '\\frac{\\text{ }}{\\text{ }}' },
{ id: 'abs', latex: '|', tooltip: 'absolute value (|)', icon: '|\\text{ }|' },
{ id: 'sqrt', latex: '\\sqrt', tooltip: 'square root (sqrt)', icon: '\\sqrt{\\text{ }}' },
{ id: 'nthroot', latex: '\\root', tooltip: 'nth root (root)', icon: '\\sqrt[\\text{ }]{\\text{ }}' },
{ id: 'exponent', latex: '^', tooltip: 'exponent (^)', icon: '\\text{ }^\\text{ }' },
...(cfgOptions.logsChangeBase
? []
: [{ id: 'subscript', latex: '_', tooltip: 'subscript (_)', icon: '\\text{ }_\\text{ }' }]),
{ id: 'infty', latex: '\\infty', tooltip: 'infinity (inf)', icon: '\\infty' },
{ id: 'pi', latex: '\\pi', tooltip: 'pi (pi)', icon: '\\pi' },
{ id: 'vert', latex: '\\vert', tooltip: 'such that (vert)', icon: '|' },
{ id: 'cup', latex: '\\cup', tooltip: 'union (union)', icon: '\\cup' },
// { id: 'leq', latex: '\\leq', tooltip: 'less than or equal (<=)', icon: '\\leq' },
// { id: 'geq', latex: '\\geq', tooltip: 'greater than or equal (>=)', icon: '\\geq' },
{ id: 'text', latex: '\\text', tooltip: 'text mode (")', icon: 'Tt' }
];

const toolbarRemove = () => {
if (answerQuill.toolbar) {
const toolbar = answerQuill.toolbar;
delete answerQuill.toolbar;
toolbar.style.opacity = 0;
window.removeEventListener('resize', toolbar.setPosition);
window.removeEventListener('focus', toolbar.removeOnWindowRefocus);
toolbar.tooltips.forEach((tooltip) => tooltip.dispose());
toolbar.addEventListener('transitionend', () => toolbar.remove(), { once: true });
toolbar.addEventListener('transitioncancel', () => toolbar.remove(), { once: true });
if (toolbarEnabled && document.activeElement !== answerQuill.textarea) answerQuill.mathField.blur();
}
};

// Open the toolbar when the mathquill answer box gains focus.
answerQuill.textarea.addEventListener('focusin', () => {
if (!toolbarEnabled) return;
if (answerQuill.toolbar) return;

answerQuill.toolbar = document.createElement('div');
answerQuill.toolbar.tabIndex = -1;
answerQuill.toolbar.classList.add('quill-toolbar');
answerQuill.toolbar.style.opacity = 0;

answerQuill.toolbar.addEventListener('focusout', (e) => {
if (
!document.hasFocus() ||
(e.relatedTarget &&
(e.relatedTarget.closest('.quill-toolbar') ||
e.relatedTarget.classList.contains('symbol-button') ||
e.relatedTarget === answerQuill.textarea)) ||
(answerQuill.clearButton && e.relatedTarget === answerQuill.clearButton)
)
return;

toolbarRemove();
});

// If the window is refocused after a blur, and the focus is not on the toolbar
// or the MathQuill input, then remove the toolbar.
answerQuill.toolbar.removeOnWindowRefocus = () => {
if (
document.activeElement &&
!document.activeElement.closest('.quill-toolbar') &&
!document.activeElement.classList.contains('symbol-button') &&
document.activeElement !== answerQuill.textarea
)
toolbarRemove();
};
window.addEventListener('focus', answerQuill.toolbar.removeOnWindowRefocus);

answerQuill.toolbar.tooltips = [];

for (const buttonData of answerQuill.buttons) {
const button = document.createElement('button');
button.type = 'button';
button.id = `${buttonData.id}-${answerQuill.id}`;
button.classList.add('symbol-button', 'btn', 'btn-dark');
button.dataset.latex = buttonData.latex;
button.dataset.bsToggle = 'tooltip';
button.title = buttonData.tooltip;
const icon = document.createElement('span');
icon.id = `icon-${buttonData.id}-${answerQuill.id}`;
icon.textContent = buttonData.icon;
icon.setAttribute('aria-hidden', 'true');
button.append(icon);
answerQuill.toolbar.append(button);

MQ.StaticMath(icon, { mouseEvents: false, tabbable: false });

answerQuill.toolbar.tooltips.push(new bootstrap.Tooltip(button, { placement: 'left' }));

button.addEventListener('click', () => {
answerQuill.textarea.focus();
answerQuill.mathField.cmd(button.dataset.latex);
});
}

const getNextFocusableElement = (currentElement) => {
const focusableElements = Array.from(
document.querySelectorAll(
'a[href]:not([tabindex="-1"]),' +
'button:not([tabindex="-1"]),' +
'input:not([tabindex="-1"]),' +
'textarea:not([tabindex="-1"]),' +
'select:not([tabindex="-1"]),' +
'details:not([tabindex="-1"]),' +
'[tabindex]:not([tabindex="-1"])'
)
);

let currentIndex = focusableElements.indexOf(currentElement);
if (currentIndex === -1) return;

for (const focusableElement of focusableElements.slice(currentIndex + 1)) {
if (!focusableElement.disabled && focusableElement.offsetParent !== null) return focusableElement;
}
};

answerQuill.toolbar.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const nextFocusable = getNextFocusableElement(answerQuill.toolbar.lastElementChild);
toolbarRemove();
nextFocusable?.focus();
}
});

answerQuill.toolbar.setPosition = () => {
// Note that this must be kept in sync with css. Currently each symbol button has a fixed height (due
// to flex-shrink being 0) of 45px plus a 1px padding on the top and bottom plus a 1px margin on the top
// and bottom, giving a 49px total height for each symbol button . Also, the toolbar itself has a 2px
// border on the top and bottom, hence 4px is added to the end. These computations take into account
// that box-sizing is border-box.
const toolbarHeight = 49 * answerQuill.buttons.length + 4;

const pageHeight = (() => {
const documentElHeight = document.documentElement.getBoundingClientRect().height;
if (window.innerHeight > documentElHeight) return window.innerHeight;
return documentElHeight;
})();

// Different positioning is needed when contained in a relatively positioned parent.
const relativeParent = (() => {
let parent = answerQuill.parentElement;
while (parent && parent !== document) {
const positionType = window.getComputedStyle(parent).position;
if (positionType === 'relative') return parent;
// If a fixed parent is encountered before a relative parent is encountered,
// that negates relative positioning.
if (positionType === 'fixed') return;
parent = parent.parentElement;
}
})();

if (relativeParent) {
// If contained in a relatively positioned parent, the toolbar needs
// to be positioned relative to that parent.
const pageWidth = (() => {
const documentElWidth = document.documentElement.getBoundingClientRect().width;
if (window.innerWidth > documentElWidth) return window.innerWidth;
return documentElWidth;
})();

const parentRect = relativeParent.getBoundingClientRect();
answerQuill.toolbar.style.right = `${window.scrollX + parentRect.right + 10 - pageWidth}px`;

const elRect = answerQuill.getBoundingClientRect();

if (window.scrollY + elRect.top + elRect.height / 2 < toolbarHeight / 2) {
answerQuill.toolbar.style.top = `-${window.scrollY + parentRect.top}px`;
answerQuill.toolbar.style.bottom =
toolbarHeight > pageHeight ? `${window.scrollY + parentRect.bottom - pageHeight}px` : null;
} else if (window.scrollY + elRect.top + elRect.height / 2 + toolbarHeight / 2 > pageHeight) {
answerQuill.toolbar.style.top = null;
answerQuill.toolbar.style.bottom = `${window.scrollY + parentRect.bottom - pageHeight}px`;
} else {
answerQuill.toolbar.style.top = `${
elRect.top + elRect.height / 2 - toolbarHeight / 2 - parentRect.top
}px`;
answerQuill.toolbar.style.bottom = null;
}
} else {
// If not in a relatively positioned parent, the toolbar is positioned absolutely on the page.
if (toolbarHeight > pageHeight) {
answerQuill.toolbar.style.top = 0;
answerQuill.toolbar.style.height = '100%';
} else {
const elRect = answerQuill.getBoundingClientRect();
const top = window.scrollY + elRect.bottom - elRect.height / 2 - toolbarHeight / 2;
const bottom = top + toolbarHeight;
answerQuill.toolbar.style.top = `${
top < 0 ? 0 : bottom > pageHeight ? pageHeight - toolbarHeight : top
}px`;
answerQuill.toolbar.style.height = null;
}
}
};

window.addEventListener('resize', answerQuill.toolbar.setPosition);
answerQuill.toolbar.setPosition();

answerQuill.after(answerQuill.toolbar);
setTimeout(() => {
if (answerQuill.toolbar) answerQuill.toolbar.style.opacity = 1;
}, 0);
});

// Add a context menu to toggle whether the toolbar is enabled or not.
answerQuill.addEventListener('contextmenu', (e) => {
e.preventDefault();

const container = document.createElement('div');
container.classList.add('dropdown', 'd-inline-block');
answerQuill.after(container);

const hiddenLink = document.createElement('a');
hiddenLink.classList.add('dropdown-toggle', 'd-none');
hiddenLink.dataset.bsToggle = 'dropdown';
hiddenLink.href = '#';
container.append(hiddenLink);

const menuEl = document.createElement('ul');
menuEl.classList.add('dropdown-menu');
const li = document.createElement('li');
menuEl.append(li);
const action = document.createElement('a');
action.classList.add('dropdown-item');
action.href = '#';
action.textContent = toolbarEnabled ? 'Disable Toolbar' : 'Enable Toolbar';
li.append(action);
container.append(menuEl);

const menu = new bootstrap.Dropdown(hiddenLink, {
reference: answerQuill,
offset: [answerQuill.offsetWidth, 0]
});
menu.show();

hiddenLink.addEventListener('hidden.bs.dropdown', () => {
menu.dispose();
menuEl.remove();
container.remove();
});

action.addEventListener(
'click',
(e) => {
e.preventDefault();
toolbarEnabled = !toolbarEnabled;
localStorage.setItem('MQEditorToolbarEnabled', toolbarEnabled);
if (!toolbarEnabled && answerQuill.toolbar) toolbarRemove();
// Bootstrap tries to focus the triggering element after hiding the menu. However, the menu gets
// disposed of and the hidden link which is the triggering element removed too quickly in the
// hidden.bs.dropdown event, and that causes an exception. So ignore that exception so that the
// answerQuill textarea is focused instead.
try {
menu.hide();
} catch {
/* ignore */
}
answerQuill.textarea.focus();
if (!cfgOptions.logsChangeBase) {
answerQuill.mathField.options.addToolbarButton(
{
id: 'subscript',
latex: '_',
tooltip: 'subscript (_)',
icon: '\\text{ }_\\text{ }'
},
{ once: true }
'exponent'
);
});

answerQuill.textarea.addEventListener('focusout', (e) => {
if (
!document.hasFocus() ||
(e.relatedTarget &&
(e.relatedTarget.closest('.quill-toolbar') ||
e.relatedTarget.classList.contains('symbol-button') ||
(answerQuill.clearButton && e.relatedTarget === answerQuill.clearButton)))
)
return;

toolbarRemove();
});
}

window.answerQuills[answerLabel] = answerQuill;

Expand Down
Loading
Loading