diff --git a/js/Injector.js b/js/Injector.js new file mode 100644 index 0000000..38e2131 --- /dev/null +++ b/js/Injector.js @@ -0,0 +1,93 @@ +import Adapt from 'core/js/adapt'; +import documentModifications from 'core/js/DOMElementModifications'; +import Handlebars from 'handlebars'; + +const forbiddenParents = 'button, a, [role=dialog], [role=heading], header, span[definition], [no-definition]'; +const requireParents = '.contentobject'; + +class Injector extends Backbone.Controller { + + initialize() { + this.listenTo(Adapt, 'app:dataReady', this.onLoaded); + this._isWatching = false; + this.definitionId = 0; + } + + onLoaded() { + this.processDocument(); + this.startWatching(); + } + + startWatching () { + if (this._isWatching) return; + this._isWatching = true; + this.listenTo(documentModifications, 'added', this.onMutation); + } + + onMutation(event) { + setTimeout(() => { + this.processNode(event.target); + }); + } + + processDocument() { + const nodes = [...document.querySelectorAll('body *:not(script, style, svg)')]; + nodes.forEach(this.processNode); + } + + processNode(node) { + if (node._isDefinitioned) return; + const textNodes = [...node.childNodes] + .filter(node => node.nodeType === Node.TEXT_NODE) + .filter(node => node.nodeValue.trim()); + if (!textNodes.length) return; + textNodes.forEach(node => { + const parentElement = node.parentNode; + const isInsideForbiddenParents = Boolean($(parentElement).closest(forbiddenParents).length); + if (isInsideForbiddenParents) return; + const isInsideRequiredParents = Boolean($(parentElement).closest(requireParents).length); + if (!isInsideRequiredParents) return; + parentElement._isDefinitioned = true; + const value = String(node.nodeValue); + const children = []; + const keywords = [...value.matchAll(Adapt.definitions._regexp)]; + if (!keywords.length) return; + let last = null; + const selected = keywords.reduce((parts, entry) => { + const keyword = entry[0]; + const nextStart = entry.index; + const nextLength = keyword.length; + const lastEnd = last + ? last.index + last[0].length + : null; + if (!last && keywords[0].index - 1 > 0) { + parts.push(document.createTextNode(value.substring(0, keywords[0].index))); + } + if (last && lastEnd < nextStart) { + parts.push(document.createTextNode(value.substring(lastEnd, nextStart))); + + } + const term = value.substring(nextStart, nextStart + nextLength); + const definition = Adapt.definitions._table[keyword]; + this.definitionId++; + const elements = $(Handlebars.templates.definition({ term, definition, id: this.definitionId })); + parts.push(...elements); + last = entry; + return parts; + }, []).filter(Boolean); + last = keywords[keywords.length - 1]; + if (last.index + last[0].length < value.length) { + selected.push(document.createTextNode(value.substring(last.index + last[0].length))); + } + children.push(...selected.map(copy => { + parentElement.insertBefore(copy, node); + copy._isDefinitioned = true; + return copy; + })); + parentElement.removeChild(node); + }); + } + +} + +export default new Injector(); diff --git a/js/adapt-definitions.js b/js/adapt-definitions.js index aa31249..8f1fe5e 100644 --- a/js/adapt-definitions.js +++ b/js/adapt-definitions.js @@ -1,166 +1,82 @@ -define([ - 'core/js/adapt', - 'handlebars' -],function(Adapt, Handlebars) { +import './Injector'; +import Adapt from 'core/js/adapt'; +import Handlebars from 'handlebars'; +import notify from 'core/js/notify'; - function escapeRegExp(text) { - return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); - } +function escapeRegExp(text) { + return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); +} - function chain(subject, func_name, callback) { - var original = subject[func_name]; - subject[func_name] = function() { - var args = Array.prototype.slice.call(arguments, 0); - args.unshift(function() { - var args = Array.prototype.slice.call(arguments, 0); - return original.apply(this, args); - }.bind(this)); - return callback.apply(this, args); - }; - } +class Definitions extends Backbone.Controller { - var Definitions = Backbone.Controller.extend({ + initialize () { + _.bindAll(this, 'onAbbrClick'); + this.listenTo(Adapt, 'app:dataLoaded', this.loadData); - initialize: function() { - _.bindAll(this, "a11y_text", "onAbbrClick"); - this.listenTo(Adapt, "app:dataLoaded", this.loadData); - - $('body').on('click', "[definition]", this.onAbbrClick); - $('body').on('keypress', "[definition]", e => { - if (e.which !== 13 ) return; - this.onAbbrClick(e); - }); - }, - - loadData: function() { - this.model = new Backbone.Model(Adapt.course.get("_definitions") || {_isEnabled: false}); - - if (!this.model.get("_isEnabled")) { - return; - } - - var items = this.model.get("_items"); - if (!items || !items.length) { - return; - } - - this.setUpRegExps(); - this.setUpA11yTextHook(); - - }, - - setUpRegExps: function() { - this._items = this.model.get("_items"); - var allWords = []; - this._items.forEach(function(item, index) { - item._index = index; - var words = []; - item.words.forEach(function(find) { - var escaped = escapeRegExp(find); - words.push(escaped); - allWords.push(escaped); - }); - item._regexp = new RegExp("\\b"+words.join("\\b|\\b")+"\\b", "gi"); - }); - this._regexp = new RegExp("\\b"+allWords.join("\\b|\\b")+"\\b", "gi"); - }, - - setUpA11yTextHook: function() { - chain(Handlebars.helpers, "a11y_text", this.a11y_text); - }, - - a11y_text: function a11y_text(a11y_text, html) { - - var $html= $("
", { - html: html - }); - $html.find("*").add($html).each(function(index, node) { - - if ($(node).is("[definition], [no-definition]")){ - return; - } - - var newChildNodes = []; - var wasChanged = false; - - for (var nc = 0, ncl = node.childNodes.length; nc < ncl; nc++) { - - var child = node.childNodes[nc]; - - if (child.nodeType !== 3) { - newChildNodes.push(child); - continue; - } - - var text = child.textContent; - if (text.search(this._regexp) < 0) { - newChildNodes.push(child); - continue; - } - - wasChanged = true; - - text = text.replace(this._regexp, function(match, offset, string) { - for (var d = 0, dl = this._items.length; d < dl; d++) { - var item = this._items[d]; - if (!match.match(item._regexp)) continue; - - return ""+match+""; - } - }.bind(this)); - - var $html2 = $("
", { - html: text - }); - - $html2[0].childNodes.forEach(function(childNode) { - newChildNodes.push(childNode); - }); - - } - - if (!wasChanged) return; - - for (var i = node.childNodes.length-1; i > -1; i--) { - node.removeChild(node.childNodes[i]); - } - - newChildNodes.forEach(function(child) { - node.appendChild(child); - }); - - }.bind(this)); - - return a11y_text($html[0].outerHTML); - }, - - onAbbrClick: function(event) { - Adapt.trigger("definition:remove"); - var $target = $(event.target); + $('body').on('click', '[definition]', this.onAbbrClick); + $('body').on('keypress', '[definition]', e => { + if (e.which !== 13) return; + this.onAbbrClick(e); + }); + } + + loadData() { + this.model = new Backbone.Model(Adapt.course.get('_definitions') || { _isEnabled: false }); + if (!this.model.get('_isEnabled')) return; + this._items = this.model.get('_items'); + if (!this._items?.length) return; + this.setUpRegExps(); + this.setUpTable(); + } + + setUpRegExps() { + const allWords = []; + this._items.forEach(function(item, index) { + item._index = index; + const words = []; + item.words.forEach(function(find) { + const escaped = escapeRegExp(find); + words.push(escaped); + allWords.push(escaped); + }); + item._regexp = new RegExp('\\b' + words.join('\\b|\\b') + '\\b', 'g'); + }); + this._regexp = new RegExp('\\b' + allWords.join('\\b|\\b') + '\\b', 'g'); + } + + setUpTable() { + this._table = {}; + this._items.forEach(item => { + item.words.forEach(word => { + this._table[word] = item.definition; + }); + }); + } - var word = $target.text(); - var definition = $target.attr("definition"); + onAbbrClick(event) { + const $target = $(event.target); - var json = _.extend({}, this.model.toJSON(), {word: word, definition: definition}); + const word = $target.text(); + const definition = $target.attr('definition'); - var title = Handlebars.compile(this.model.get("title"))(json); - var body = Handlebars.compile(this.model.get("body"))(json); + const json = _.extend({}, this.model.toJSON(), { word, definition }); - Adapt.notify.popup({ - "title": title, - "body": "
"+body+"
", - "_prompts": [ - { - promptText: this.model.get("confirmText") || "Close" - } - ], - "_showIcon": this.model.get("_showIcon") - }); + const title = Handlebars.compile(this.model.get('title'))(json); + const body = Handlebars.compile(this.model.get('body'))(json); + notify.popup({ + title, + body: '
' + body + '
', + _prompts: [ + { + promptText: this.model.get('confirmText') || 'Close' } - + ], + _showIcon: this.model.get('_showIcon') }); - return new Definitions(); + } + +} -}); +export default (Adapt.definitions = new Definitions()); diff --git a/less/definition.less b/less/definition.less index 5a2a8d6..91d9907 100644 --- a/less/definition.less +++ b/less/definition.less @@ -2,3 +2,5 @@ cursor: pointer; text-decoration: underline; } + + diff --git a/templates/definition.hbs b/templates/definition.hbs new file mode 100644 index 0000000..3b80e85 --- /dev/null +++ b/templates/definition.hbs @@ -0,0 +1,3 @@ + + {{~ term ~}} +