From 632ce00cd2d66cb5dd56ead82ba7f95a0ad20b27 Mon Sep 17 00:00:00 2001 From: Matt Chaffe Date: Tue, 7 May 2019 23:21:49 +0100 Subject: [PATCH] Line highlight: Batching DOM read/writes to avoid reflows (#1865) This batches DOM read and write operation to avoid reflows resulting for better performance. --- .../line-highlight/prism-line-highlight.js | 324 ++++++++++-------- .../prism-line-highlight.min.js | 2 +- 2 files changed, 179 insertions(+), 147 deletions(-) diff --git a/plugins/line-highlight/prism-line-highlight.js b/plugins/line-highlight/prism-line-highlight.js index 5d919ae6c3..bebbcbf1cf 100644 --- a/plugins/line-highlight/prism-line-highlight.js +++ b/plugins/line-highlight/prism-line-highlight.js @@ -1,181 +1,213 @@ -(function(){ - -if (typeof self === 'undefined' || !self.Prism || !self.document || !document.querySelector) { - return; -} - -function $$(expr, con) { - return Array.prototype.slice.call((con || document).querySelectorAll(expr)); -} - -function hasClass(element, className) { - className = " " + className + " "; - return (" " + element.className + " ").replace(/[\n\t]/g, " ").indexOf(className) > -1 -} - -// Some browsers round the line-height, others don't. -// We need to test for it to position the elements properly. -var isLineHeightRounded = (function() { - var res; - return function() { - if(typeof res === 'undefined') { - var d = document.createElement('div'); - d.style.fontSize = '13px'; - d.style.lineHeight = '1.5'; - d.style.padding = 0; - d.style.border = 0; - d.innerHTML = ' 
 '; - document.body.appendChild(d); - // Browsers that round the line-height should have offsetHeight === 38 - // The others should have 39. - res = d.offsetHeight === 38; - document.body.removeChild(d); - } - return res; - } -}()); - -function highlightLines(pre, lines, classes) { - lines = typeof lines === 'string' ? lines : pre.getAttribute('data-line'); - - var ranges = lines.replace(/\s+/g, '').split(','), - offset = +pre.getAttribute('data-line-offset') || 0; +(function () { - var parseMethod = isLineHeightRounded() ? parseInt : parseFloat; - var lineHeight = parseMethod(getComputedStyle(pre).lineHeight); - var hasLineNumbers = hasClass(pre, 'line-numbers'); - - for (var i=0, currentRange; currentRange = ranges[i++];) { - var range = currentRange.split('-'); + if (typeof self === 'undefined' || !self.Prism || !self.document || !document.querySelector) { + return; + } - var start = +range[0], - end = +range[1] || start; + function $$(expr, con) { + return Array.prototype.slice.call((con || document).querySelectorAll(expr)); + } - var line = pre.querySelector('.line-highlight[data-range="' + currentRange + '"]') || document.createElement('div'); + function hasClass(element, className) { + className = " " + className + " "; + return (" " + element.className + " ").replace(/[\n\t]/g, " ").indexOf(className) > -1 + } - line.setAttribute('aria-hidden', 'true'); - line.setAttribute('data-range', currentRange); - line.className = (classes || '') + ' line-highlight'; + function callFunction(func) { + func(); + } - //if the line-numbers plugin is enabled, then there is no reason for this plugin to display the line numbers - if(hasLineNumbers && Prism.plugins.lineNumbers) { - var startNode = Prism.plugins.lineNumbers.getLine(pre, start); - var endNode = Prism.plugins.lineNumbers.getLine(pre, end); - - if (startNode) { - line.style.top = startNode.offsetTop + 'px'; + // Some browsers round the line-height, others don't. + // We need to test for it to position the elements properly. + var isLineHeightRounded = (function () { + var res; + return function () { + if (typeof res === 'undefined') { + var d = document.createElement('div'); + d.style.fontSize = '13px'; + d.style.lineHeight = '1.5'; + d.style.padding = 0; + d.style.border = 0; + d.innerHTML = ' 
 '; + document.body.appendChild(d); + // Browsers that round the line-height should have offsetHeight === 38 + // The others should have 39. + res = d.offsetHeight === 38; + document.body.removeChild(d); } - - if (endNode) { - line.style.height = (endNode.offsetTop - startNode.offsetTop) + endNode.offsetHeight + 'px'; - } - } else { - line.setAttribute('data-start', start); + return res; + } + }()); - if(end > start) { - line.setAttribute('data-end', end); + /** + * Highlights the lines of the given pre. + * + * This function is split into a DOM measuring and mutate phase to improve performance. + * The returned function mutates the DOM when called. + * + * @param {HTMLElement} pre + * @param {string} [lines] + * @param {string} [classes=''] + * @returns {() => void} + */ + function highlightLines(pre, lines, classes) { + lines = typeof lines === 'string' ? lines : pre.getAttribute('data-line'); + + var ranges = lines.replace(/\s+/g, '').split(','); + var offset = +pre.getAttribute('data-line-offset') || 0; + + var parseMethod = isLineHeightRounded() ? parseInt : parseFloat; + var lineHeight = parseMethod(getComputedStyle(pre).lineHeight); + var hasLineNumbers = hasClass(pre, 'line-numbers'); + var parentElement = hasLineNumbers ? pre : pre.querySelector('code') || pre; + var mutateActions = /** @type {(() => void)[]} */ ([]); + + ranges.forEach(function (currentRange) { + var range = currentRange.split('-'); + + var start = +range[0]; + var end = +range[1] || start; + + var line = pre.querySelector('.line-highlight[data-range="' + currentRange + '"]') || document.createElement('div'); + + mutateActions.push(function () { + line.setAttribute('aria-hidden', 'true'); + line.setAttribute('data-range', currentRange); + line.className = (classes || '') + ' line-highlight'; + }); + + // if the line-numbers plugin is enabled, then there is no reason for this plugin to display the line numbers + if (hasLineNumbers && Prism.plugins.lineNumbers) { + var startNode = Prism.plugins.lineNumbers.getLine(pre, start); + var endNode = Prism.plugins.lineNumbers.getLine(pre, end); + + if (startNode) { + var top = startNode.offsetTop + 'px'; + mutateActions.push(function () { + line.style.top = top; + }); + } + + if (endNode) { + var height = (endNode.offsetTop - startNode.offsetTop) + endNode.offsetHeight + 'px'; + mutateActions.push(function () { + line.style.height = height; + }); + } + } else { + mutateActions.push(function () { + line.setAttribute('data-start', start); + + if (end > start) { + line.setAttribute('data-end', end); + } + + line.style.top = (start - offset - 1) * lineHeight + 'px'; + + line.textContent = new Array(end - start + 2).join(' \n'); + }); } - - line.style.top = (start - offset - 1) * lineHeight + 'px'; - line.textContent = new Array(end - start + 2).join(' \n'); - } + mutateActions.push(function () { + // allow this to play nicely with the line-numbers plugin + // need to attack to pre as when line-numbers is enabled, the code tag is relatively which screws up the positioning + parentElement.appendChild(line); + }); + }); - //allow this to play nicely with the line-numbers plugin - if(hasLineNumbers) { - //need to attack to pre as when line-numbers is enabled, the code tag is relatively which screws up the positioning - pre.appendChild(line); - } else { - (pre.querySelector('code') || pre).appendChild(line); - } + return function () { + mutateActions.forEach(callFunction); + }; } -} -function applyHash() { - var hash = location.hash.slice(1); + function applyHash() { + var hash = location.hash.slice(1); - // Remove pre-existing temporary lines - $$('.temporary.line-highlight').forEach(function (line) { - line.parentNode.removeChild(line); - }); + // Remove pre-existing temporary lines + $$('.temporary.line-highlight').forEach(function (line) { + line.parentNode.removeChild(line); + }); - var range = (hash.match(/\.([\d,-]+)$/) || [,''])[1]; + var range = (hash.match(/\.([\d,-]+)$/) || [, ''])[1]; - if (!range || document.getElementById(hash)) { - return; - } + if (!range || document.getElementById(hash)) { + return; + } - var id = hash.slice(0, hash.lastIndexOf('.')), - pre = document.getElementById(id); + var id = hash.slice(0, hash.lastIndexOf('.')), + pre = document.getElementById(id); - if (!pre) { - return; - } + if (!pre) { + return; + } - if (!pre.hasAttribute('data-line')) { - pre.setAttribute('data-line', ''); - } + if (!pre.hasAttribute('data-line')) { + pre.setAttribute('data-line', ''); + } - highlightLines(pre, range, 'temporary '); + var mutateDom = highlightLines(pre, range, 'temporary '); + mutateDom(); - document.querySelector('.temporary.line-highlight').scrollIntoView(); -} + document.querySelector('.temporary.line-highlight').scrollIntoView(); + } -var fakeTimer = 0; // Hack to limit the number of times applyHash() runs + var fakeTimer = 0; // Hack to limit the number of times applyHash() runs -Prism.hooks.add('before-sanity-check', function(env) { - var pre = env.element.parentNode; - var lines = pre && pre.getAttribute('data-line'); + Prism.hooks.add('before-sanity-check', function (env) { + var pre = env.element.parentNode; + var lines = pre && pre.getAttribute('data-line'); - if (!pre || !lines || !/pre/i.test(pre.nodeName)) { - return; - } - - /* - * Cleanup for other plugins (e.g. autoloader). - * - * Sometimes blocks are highlighted multiple times. It is necessary - * to cleanup any left-over tags, because the whitespace inside of the
- * tags change the content of the tag. - */ - var num = 0; - $$('.line-highlight', pre).forEach(function (line) { - num += line.textContent.length; - line.parentNode.removeChild(line); + if (!pre || !lines || !/pre/i.test(pre.nodeName)) { + return; + } + + /* + * Cleanup for other plugins (e.g. autoloader). + * + * Sometimes blocks are highlighted multiple times. It is necessary + * to cleanup any left-over tags, because the whitespace inside of the
+ * tags change the content of the tag. + */ + var num = 0; + $$('.line-highlight', pre).forEach(function (line) { + num += line.textContent.length; + line.parentNode.removeChild(line); + }); + // Remove extra whitespace + if (num && /^( \n)+$/.test(env.code.slice(-num))) { + env.code = env.code.slice(0, -num); + } }); - // Remove extra whitespace - if (num && /^( \n)+$/.test(env.code.slice(-num))) { - env.code = env.code.slice(0, -num); - } -}); -Prism.hooks.add('complete', function completeHook(env) { - var pre = env.element.parentNode; - var lines = pre && pre.getAttribute('data-line'); + Prism.hooks.add('complete', function completeHook(env) { + var pre = env.element.parentNode; + var lines = pre && pre.getAttribute('data-line'); - if (!pre || !lines || !/pre/i.test(pre.nodeName)) { - return; - } + if (!pre || !lines || !/pre/i.test(pre.nodeName)) { + return; + } - clearTimeout(fakeTimer); + clearTimeout(fakeTimer); - var hasLineNumbers = Prism.plugins.lineNumbers; - var isLineNumbersLoaded = env.plugins && env.plugins.lineNumbers; + var hasLineNumbers = Prism.plugins.lineNumbers; + var isLineNumbersLoaded = env.plugins && env.plugins.lineNumbers; - if (hasClass(pre, 'line-numbers') && hasLineNumbers && !isLineNumbersLoaded) { - Prism.hooks.add('line-numbers', completeHook); - } else { - highlightLines(pre, lines); - fakeTimer = setTimeout(applyHash, 1); - } -}); + if (hasClass(pre, 'line-numbers') && hasLineNumbers && !isLineNumbersLoaded) { + Prism.hooks.add('line-numbers', completeHook); + } else { + var mutateDom = highlightLines(pre, lines); + mutateDom(); + fakeTimer = setTimeout(applyHash, 1); + } + }); window.addEventListener('hashchange', applyHash); window.addEventListener('resize', function () { - var preElements = document.querySelectorAll('pre[data-line]'); - Array.prototype.forEach.call(preElements, function (pre) { - highlightLines(pre); + var actions = []; + $$('pre[data-line]').forEach(function (pre) { + actions.push(highlightLines(pre)); }); + actions.forEach(callFunction); }); -})(); \ No newline at end of file +})(); diff --git a/plugins/line-highlight/prism-line-highlight.min.js b/plugins/line-highlight/prism-line-highlight.min.js index b54a86c96e..adfe7f3738 100644 --- a/plugins/line-highlight/prism-line-highlight.min.js +++ b/plugins/line-highlight/prism-line-highlight.min.js @@ -1 +1 @@ -!function(){if("undefined"!=typeof self&&self.Prism&&self.document&&document.querySelector){var t,h=function(){if(void 0===t){var e=document.createElement("div");e.style.fontSize="13px",e.style.lineHeight="1.5",e.style.padding=0,e.style.border=0,e.innerHTML=" 
 ",document.body.appendChild(e),t=38===e.offsetHeight,document.body.removeChild(e)}return t},l=0;Prism.hooks.add("before-sanity-check",function(e){var t=e.element.parentNode,n=t&&t.getAttribute("data-line");if(t&&n&&/pre/i.test(t.nodeName)){var i=0;r(".line-highlight",t).forEach(function(e){i+=e.textContent.length,e.parentNode.removeChild(e)}),i&&/^( \n)+$/.test(e.code.slice(-i))&&(e.code=e.code.slice(0,-i))}}),Prism.hooks.add("complete",function e(t){var n=t.element.parentNode,i=n&&n.getAttribute("data-line");if(n&&i&&/pre/i.test(n.nodeName)){clearTimeout(l);var r=Prism.plugins.lineNumbers,o=t.plugins&&t.plugins.lineNumbers;g(n,"line-numbers")&&r&&!o?Prism.hooks.add("line-numbers",e):(a(n,i),l=setTimeout(s,1))}}),window.addEventListener("hashchange",s),window.addEventListener("resize",function(){var e=document.querySelectorAll("pre[data-line]");Array.prototype.forEach.call(e,function(e){a(e)})})}function r(e,t){return Array.prototype.slice.call((t||document).querySelectorAll(e))}function g(e,t){return t=" "+t+" ",-1<(" "+e.className+" ").replace(/[\n\t]/g," ").indexOf(t)}function a(e,t,n){for(var i,r=(t="string"==typeof t?t:e.getAttribute("data-line")).replace(/\s+/g,"").split(","),o=+e.getAttribute("data-line-offset")||0,l=(h()?parseInt:parseFloat)(getComputedStyle(e).lineHeight),a=g(e,"line-numbers"),s=0;i=r[s++];){var d=i.split("-"),u=+d[0],c=+d[1]||u,m=e.querySelector('.line-highlight[data-range="'+i+'"]')||document.createElement("div");if(m.setAttribute("aria-hidden","true"),m.setAttribute("data-range",i),m.className=(n||"")+" line-highlight",a&&Prism.plugins.lineNumbers){var p=Prism.plugins.lineNumbers.getLine(e,u),f=Prism.plugins.lineNumbers.getLine(e,c);p&&(m.style.top=p.offsetTop+"px"),f&&(m.style.height=f.offsetTop-p.offsetTop+f.offsetHeight+"px")}else m.setAttribute("data-start",u),u ",document.body.appendChild(e),t=38===e.offsetHeight,document.body.removeChild(e)}return t},a=0;Prism.hooks.add("before-sanity-check",function(e){var t=e.element.parentNode,n=t&&t.getAttribute("data-line");if(t&&n&&/pre/i.test(t.nodeName)){var i=0;r(".line-highlight",t).forEach(function(e){i+=e.textContent.length,e.parentNode.removeChild(e)}),i&&/^( \n)+$/.test(e.code.slice(-i))&&(e.code=e.code.slice(0,-i))}}),Prism.hooks.add("complete",function e(t){var n=t.element.parentNode,i=n&&n.getAttribute("data-line");if(n&&i&&/pre/i.test(n.nodeName)){clearTimeout(a);var r=Prism.plugins.lineNumbers,o=t.plugins&&t.plugins.lineNumbers;if(l(n,"line-numbers")&&r&&!o)Prism.hooks.add("line-numbers",e);else s(n,i)(),a=setTimeout(u,1)}}),window.addEventListener("hashchange",u),window.addEventListener("resize",function(){var t=[];r("pre[data-line]").forEach(function(e){t.push(s(e))}),t.forEach(i)})}function r(e,t){return Array.prototype.slice.call((t||document).querySelectorAll(e))}function l(e,t){return t=" "+t+" ",-1<(" "+e.className+" ").replace(/[\n\t]/g," ").indexOf(t)}function i(e){e()}function s(u,e,d){var t=(e="string"==typeof e?e:u.getAttribute("data-line")).replace(/\s+/g,"").split(","),c=+u.getAttribute("data-line-offset")||0,f=(n()?parseInt:parseFloat)(getComputedStyle(u).lineHeight),h=l(u,"line-numbers"),p=h?u:u.querySelector("code")||u,m=[];return t.forEach(function(e){var t=e.split("-"),n=+t[0],i=+t[1]||n,r=u.querySelector('.line-highlight[data-range="'+e+'"]')||document.createElement("div");if(m.push(function(){r.setAttribute("aria-hidden","true"),r.setAttribute("data-range",e),r.className=(d||"")+" line-highlight"}),h&&Prism.plugins.lineNumbers){var o=Prism.plugins.lineNumbers.getLine(u,n),a=Prism.plugins.lineNumbers.getLine(u,i);if(o){var l=o.offsetTop+"px";m.push(function(){r.style.top=l})}if(a){var s=a.offsetTop-o.offsetTop+a.offsetHeight+"px";m.push(function(){r.style.height=s})}}else m.push(function(){r.setAttribute("data-start",n),n