Update math equation rendering

This commit changes nothing in the RCE, but changes how canvas renders
equations in the resulting page. Rather than
adding mathml in a hidden span adjacent to the equation image for
MathJax to process, this change replaces the image with a span containing
the LaTex source for MathJax to format.  This is better for a couple
reasons.

MathJax is not intended for formatting equations as they are
being edited and dealing with MathJax processed equations in the RCE when you
may want to edit an existing equation is never going to work well.

The visible MathJax-ified equations in the resulting page provides the
accessiblity we require.

This approach will update all existing content with math images,
so old content gets the benefit too.

Contrary to what some believe, you will not be able to select, copy
and paste parts of an equation.

closes: LS-1401
flag=new_math_equation_handling

test plan:
  - with new_math_equation_handling flag on
  - insert a math equation in the RCE
  > notice that when you click on the equation's image, you do not
    get the "Image Options" popup button
  - click on the equation
  > expect the "Edit Equation" popup button, not "Image Options"
  - edit the equation and save
  > expect it to be updated
  - save the page
  > expect the equation to fade into view after being processed by MathJax.
  > expect the MathJax menu when you right click in it
  > expect screenreaders read it nicely
  - edit the page again
  > expect the equation as an image again.
  > expect to be able to edit the equation
  - switch your user to a different language
  - open a page with an equation and right-click in the eq.
  > expect the mathjax context menu to be in the user's language
    (assuming mathjax supports it)

Change-Id: Ieac6785d51c0cab475b1176712f46fc2c964ff71
Reviewed-on: https://gerrit.instructure.com/c/canvas-lms/+/247471
Tested-by: Service Cloud Jenkins <svc.cloudjenkins@instructure.com>
Reviewed-by: Jackson Howe <jackson.howe@instructure.com>
QA-Review: Daniel Sasaki <dsasaki@instructure.com>
Product-Review: Peyton Craighill <pcraighill@instructure.com>
This commit is contained in:
Ed Schiebel 2020-09-11 13:15:52 -04:00
parent 842daace63
commit a992d05633
8 changed files with 124 additions and 30 deletions

View File

@ -214,7 +214,7 @@ class ApplicationController < ActionController::Base
# put feature checks on Account.site_admin and @domain_root_account that we're loading for every page in here # put feature checks on Account.site_admin and @domain_root_account that we're loading for every page in here
# so altogether we can get them faster the vast majority of the time # so altogether we can get them faster the vast majority of the time
JS_ENV_SITE_ADMIN_FEATURES = [:cc_in_rce_video_tray, :featured_help_links, :rce_lti_favorites].freeze JS_ENV_SITE_ADMIN_FEATURES = [:cc_in_rce_video_tray, :featured_help_links, :rce_lti_favorites, :new_math_equation_handling].freeze
JS_ENV_ROOT_ACCOUNT_FEATURES = [ JS_ENV_ROOT_ACCOUNT_FEATURES = [
:direct_share, :assignment_bulk_edit, :responsive_awareness, :recent_history, :direct_share, :assignment_bulk_edit, :responsive_awareness, :recent_history,
:responsive_misc, :product_tours, :module_dnd, :files_dnd, :unpublished_courses, :bulk_delete_pages :responsive_misc, :product_tours, :module_dnd, :files_dnd, :unpublished_courses, :bulk_delete_pages
@ -2138,7 +2138,7 @@ class ApplicationController < ActionController::Base
is_public: is_public is_public: is_public
).processed_url ).processed_url
end end
UserContent.escape(rewriter.translate_content(str), request.host_with_port) UserContent.escape(rewriter.translate_content(str), request.host_with_port, Account.site_admin.feature_enabled?(:new_math_equation_handling))
end end
helper_method :public_user_content helper_method :public_user_content

View File

@ -80,28 +80,63 @@
visibility: hidden; visibility: hidden;
} }
.math_equation_latex {
visibility: hidden;
display: inline-block;
}
.fade-in-equation {
visibility: visible;
animation: fadein ease 0.3s;
}
@keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
// Visibility utilities // Visibility utilities
// For desktops // For desktops
.hidden-phone { } .hidden-phone {
.hidden-tablet { } }
.hidden-desktop { display: none !important; } .hidden-tablet {
.visible-desktop { display: inherit !important; } }
.hidden-desktop {
display: none !important;
}
.visible-desktop {
display: inherit !important;
}
// Tablets & small desktops only // Tablets & small desktops only
@media (min-width: 768px) and (max-width: 979px) { @media (min-width: 768px) and (max-width: 979px) {
// Hide everything else // Hide everything else
.hidden-desktop { display: inherit !important; } .hidden-desktop {
.visible-desktop { display: none !important ; } display: inherit !important;
}
.visible-desktop {
display: none !important ;
}
// Hide // Hide
.hidden-tablet { display: none !important; } .hidden-tablet {
display: none !important;
}
} }
// Phones only // Phones only
@media (max-width: 767px) { @media (max-width: 767px) {
// Hide everything else // Hide everything else
.hidden-desktop { display: inherit !important; } .hidden-desktop {
.visible-desktop { display: none !important; } display: inherit !important;
}
.visible-desktop {
display: none !important;
}
// Hide // Hide
.hidden-phone { display: none !important; } .hidden-phone {
display: none !important;
}
} }

View File

@ -56,3 +56,11 @@ enable_fullstory:
description: |- description: |-
Include FullStory recording of the user's session Include FullStory recording of the user's session
applies_to: SiteAdmin applies_to: SiteAdmin
new_math_equation_handling:
state: hidden
display_name: Updated math equation display
description: |-
Replaces the existing image of the math equation plus the hidden mathml
with a MathJax formatted version. This provides better accessibility
and many options for the reader to interact with the equation on the page.
applies_to: SiteAdmin

View File

@ -20,7 +20,7 @@ require 'ritex'
require 'securerandom' require 'securerandom'
module UserContent module UserContent
def self.escape(str, current_host = nil) def self.escape(str, current_host = nil, use_updated_math_rendering = false)
html = Nokogiri::HTML::DocumentFragment.parse(str) html = Nokogiri::HTML::DocumentFragment.parse(str)
find_user_content(html) do |obj, uc| find_user_content(html) do |obj, uc|
uuid = SecureRandom.uuid uuid = SecureRandom.uuid
@ -55,13 +55,23 @@ module UserContent
find_equation_images(html) do |node| find_equation_images(html) do |node|
equation = node['data-equation-content'] || node['alt'] equation = node['data-equation-content'] || node['alt']
mathml = UserContent.latex_to_mathml(equation) next if equation.blank?
next if mathml.blank? if use_updated_math_rendering
# replace the equation image with a span containing the
mathml_span = Nokogiri::HTML::DocumentFragment.parse( # LaTex, which MathJAX will typeset once we're in the browser
"<span class=\"hidden-readable\">#{mathml}</span>" latex_span = Nokogiri::HTML::DocumentFragment.parse(
) "<span class=\"math_equation_latex\">\\(#{equation}\\)</span>"
node.add_next_sibling(mathml_span) )
node.replace(latex_span)
else
mathml = UserContent.latex_to_mathml(equation)
next if mathml.blank?
mathml_span = Nokogiri::HTML::DocumentFragment.parse(
"<span class=\"hidden-readable\">#{mathml}</span>"
)
node.add_next_sibling(mathml_span)
end
end end
html.to_s.html_safe html.to_s.html_safe

View File

@ -53,6 +53,25 @@ tinymce.create('tinymce.plugins.InstructureEquation', {
return () => ed.off('NodeChange', toggleActive) return () => ed.off('NodeChange', toggleActive)
} }
}) })
function isEquationImage(node) {
return node.tagName === 'IMG' && node.classList.contains('equation_image')
}
ed.ui.registry.addButton('instructure-equation-options', {
onAction(/* buttonApi */) {
ed.execCommand('instructureEquation')
},
text: formatMessage('Edit Equation')
})
ed.ui.registry.addContextToolbar('instructure-equation-toolbar', {
items: 'instructure-equation-options',
position: 'node',
predicate: isEquationImage,
scope: 'node'
})
} }
}) })

View File

@ -120,6 +120,10 @@ tinymce.create('tinymce.plugins.InstructureImagePlugin', {
* Register the Image "Options" button that will open the Image Options * Register the Image "Options" button that will open the Image Options
* tray. * tray.
*/ */
function canUpdateImageProps(node) {
return !node.classList.contains('equation_image') && isImageEmbed(node)
}
const buttonAriaLabel = formatMessage('Show image options') const buttonAriaLabel = formatMessage('Show image options')
editor.ui.registry.addButton('instructure-image-options', { editor.ui.registry.addButton('instructure-image-options', {
onAction(/* buttonApi */) { onAction(/* buttonApi */) {
@ -134,7 +138,7 @@ tinymce.create('tinymce.plugins.InstructureImagePlugin', {
editor.ui.registry.addContextToolbar('instructure-image-toolbar', { editor.ui.registry.addContextToolbar('instructure-image-toolbar', {
items: 'instructure-image-options', items: 'instructure-image-options',
position: 'node', position: 'node',
predicate: isImageEmbed, predicate: canUpdateImageProps,
scope: 'node' scope: 'node'
}) })
}, },

View File

@ -25,24 +25,37 @@ const localConfig = {
} }
} }
export function loadMathJax(configFile, cb = null) { export function loadMathJax(configFile = 'TeX-MML-AM_HTMLorMML', cb = null) {
if (!isMathJaxLoaded()) { if (!isMathJaxLoaded()) {
const locale = ENV.locale || 'en'
// signal local config to mathjax as it loads // signal local config to mathjax as it loads
window.MathJax = localConfig window.MathJax = localConfig
$.getScript( $.getScript(
`//cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/MathJax.js?config=${configFile}`, `//cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/MathJax.js?config=${configFile}&locale=${locale}`,
cb () => {
window.MathJax.Hub.Register.MessageHook('End Math', function(message) {
message[1]
.querySelectorAll('.math_equation_latex')
.forEach(m => m.classList.add('fade-in-equation'))
})
cb?.()
}
) )
} else if (typeof cb === 'function') { } else {
// Make sure we always call the callback if it is loaded already and make sure we // Make sure we always call the callback if it is loaded already and make sure we
// also reprocess the page since chances are if we are requesting MathJax again, // also reprocess the page since chances are if we are requesting MathJax again,
// something has changed on the page and needs to get pulled into the MathJax ecosystem // something has changed on the page and needs to get pulled into the MathJax ecosystem
window.MathJax.Hub.Reprocess() window.MathJax.Hub.Reprocess()
cb() cb?.()
} }
} }
export function isMathMLOnPage() { export function isMathMLOnPage() {
// handle the change from image + hidden mathml to mathjax formatted latex
if (document.querySelector('.math_equation_latex')) {
return true
}
const mathElements = document.getElementsByTagName('math') const mathElements = document.getElementsByTagName('math')
for (let i = 0; i < mathElements.length; i++) { for (let i = 0; i < mathElements.length; i++) {
const $el = $(mathElements[i]) const $el = $(mathElements[i])
@ -51,14 +64,14 @@ export function isMathMLOnPage() {
} }
export function isMathJaxLoaded() { export function isMathJaxLoaded() {
return !(typeof MathJax === 'undefined') return !!window.MathJax?.Hub
} }
/* /*
* elem: string with elementId or en elem object * elem: string with elementId or en elem object
*/ */
export function reloadElement(elem) { export function reloadElement(elem) {
if (window.MathJax) { if (isMathJaxLoaded()) {
window.MathJax.Hub.Queue(['Typeset', window.MathJax.Hub, elem]) window.MathJax.Hub.Queue(['Typeset', window.MathJax.Hub, elem])
} }
} }

View File

@ -24,6 +24,7 @@ QUnit.module('MathML and MathJax test', {
const mathElem = document.createElement('math') const mathElem = document.createElement('math')
mathElem.innerHTML = '<mi>&#x3C0;</mi> <msup> <mi>r</mi> <mn>2</mn> </msup>' mathElem.innerHTML = '<mi>&#x3C0;</mi> <msup> <mi>r</mi> <mn>2</mn> </msup>'
$('body')[0].appendChild(mathElem) $('body')[0].appendChild(mathElem)
window.ENV.locale = 'en'
} }
}) })
@ -37,7 +38,11 @@ test('loadMathJax loads mathJax', () => {
test('loadMathJax does not load mathJax', () => { test('loadMathJax does not load mathJax', () => {
sinon.stub($, 'getScript') sinon.stub($, 'getScript')
window.MathJax = {} window.MathJax = {
Hub: {
Reprocess: () => {}
}
}
mathml.loadMathJax('bogus') mathml.loadMathJax('bogus')
ok(!$.getScript.called) ok(!$.getScript.called)
$.getScript.restore() $.getScript.restore()
@ -48,7 +53,7 @@ test('isMathMLOnPage returns true', () => {
}) })
test('isMathJaxLoaded return true', () => { test('isMathJaxLoaded return true', () => {
window.MathJax = {} window.MathJax = {Hub: {}}
ok(mathml.isMathJaxLoaded()) ok(mathml.isMathJaxLoaded())
}) })