From 45bdd18b7d8d9a403af92620524dfaf223fc5123 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Sat, 2 May 2026 17:33:33 -0500 Subject: [PATCH] Update the mqeditor to use MathQuill's new internal toolbar. These are changes needed to use the new internal toolbar implemented in https://github.com/openwebwork/mathquill/pull/46. To test this you need to use that branch. Clone that branch somewhere on the webwork2 server, and then in the directory of that clone execute ```bash npm ci npm run build npm link ``` Then in the `pg/htdocs` directory run `npm link @openwebwork/mathquill`. After doing so if you look at `pg/htdocs/node_modules/@openwebwork/mathquill` you will see that it is now a link to the MathQuill clone above. Execute `npm ci` again to reset that back to the currently published MathQuill version. Once https://github.com/openwebwork/mathquill/pull/46 is merged, I will publish it and add it to this pull request. So don't merge this pull request until then. --- htdocs/js/MathQuill/mqeditor.js | 315 ++---------------------------- htdocs/js/MathQuill/mqeditor.scss | 76 ------- 2 files changed, 11 insertions(+), 380 deletions(-) diff --git a/htdocs/js/MathQuill/mqeditor.js b/htdocs/js/MathQuill/mqeditor.js index 7d2de503fa..30d6b9f76e 100644 --- a/htdocs/js/MathQuill/mqeditor.js +++ b/htdocs/js/MathQuill/mqeditor.js @@ -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'; @@ -50,6 +48,7 @@ .join(' '), rootsAreExponents: true, logsChangeBase: true, + useToolbar: true, maxDepth: 10 }; @@ -57,23 +56,7 @@ 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'); @@ -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'); @@ -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); @@ -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; diff --git a/htdocs/js/MathQuill/mqeditor.scss b/htdocs/js/MathQuill/mqeditor.scss index 6d780bdd38..870b99cbbf 100644 --- a/htdocs/js/MathQuill/mqeditor.scss +++ b/htdocs/js/MathQuill/mqeditor.scss @@ -96,79 +96,3 @@ input[type='text'].codeshard.mq-edit { } } } - -.quill-toolbar { - position: absolute; - font-size: 0.75em; - /*rtl:ignore*/ - direction: ltr; - display: flex; - flex-direction: column; - justify-content: start; - box-sizing: border-box; - border-radius: 4px; - border: 2px solid darkgray; - background-color: white; - /*rtl:ignore*/ - right: 10px; - z-index: 1001; - overflow-x: hidden; - overflow-y: auto; - scrollbar-width: thin; - opacity: 1; - transition: opacity 500ms ease; - - .symbol-button { - box-sizing: border-box; - text-align: center; - flex-shrink: 0; - padding: 3px; - margin: 2px; - display: block; - width: 45px; - height: 45px; - border-radius: 4px; - background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0)); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.15), - 0 1px 1px rgba(0, 0, 0, 0.075); - - &:focus { - z-index: 9999; - } - - span[id^='icon-']:hover { - cursor: pointer; - } - - &:not([id^='text-mq-answer']) .mq-text-mode { - height: 10px; - width: 8px; - transform: translateY(2px); - background-color: skyblue !important; - } - - .mq-nthroot, - .mq-sup, - .mq-sub { - & > .mq-text-mode { - height: 6px; - width: 6px; - } - } - - .mq-sup > .mq-text-mode { - transform: translateY(2px); - } - - .mq-sub > .mq-text-mode { - transform: translateY(0); - } - - .mq-supsub { - height: 6px; - width: 6px; - margin-left: 2px; - } - } -}