| | @@ -16,27 +16,53 @@ |
| 16 | 16 | ** This file contains the JS code specific to the Fossil default skin. |
| 17 | 17 | ** Currently, the only thing this does is handle clicks on its hamburger |
| 18 | 18 | ** menu button. |
| 19 | 19 | */ |
| 20 | 20 | (function() { |
| 21 | + var hbButton = document.getElementById("hbbtn"); |
| 22 | + if (!hbButton) return; // no hamburger button |
| 23 | + if (!document.addEventListener) { |
| 24 | + // Turn the button into a link to the sitemap for incompatible browsers. |
| 25 | + hbButton.href = "$home/sitemap"; |
| 26 | + return; |
| 27 | + } |
| 21 | 28 | var panel = document.getElementById("hbdrop"); |
| 22 | 29 | if (!panel) return; // site admin might've nuked it |
| 23 | 30 | if (!panel.style) return; // shouldn't happen, but be sure |
| 24 | 31 | var panelBorder = panel.style.border; |
| 32 | + var panelInitialized = false; // reset if browser window is resized |
| 33 | + var panelResetBorderTimerID = 0; // used to cancel post-animation tasks |
| 25 | 34 | |
| 26 | 35 | // Disable animation if this browser doesn't support CSS transitions. |
| 27 | 36 | // |
| 28 | 37 | // We need this ugly calling form for old browsers that don't allow |
| 29 | 38 | // panel.style.hasOwnProperty('transition'); catering to old browsers |
| 30 | 39 | // is the whole point here. |
| 31 | 40 | var animate = panel.style.transition !== null && (typeof(panel.style.transition) == "string"); |
| 32 | | - var animMS = 400; |
| 41 | + |
| 42 | + // The duration of the animation can be overridden from the default skin |
| 43 | + // header.txt by setting the "data-anim-ms" attribute of the panel. |
| 44 | + var animMS = panel.getAttribute("data-anim-ms"); |
| 45 | + if (animMS) { // not null or empty string, parse it |
| 46 | + animMS = parseInt(animMS); |
| 47 | + if (isNaN(animMS) || animMS == 0) |
| 48 | + animate = false; // disable animation if non-numeric or zero |
| 49 | + else if (animMS < 0) |
| 50 | + animMS = 400; // set default animation duration if negative |
| 51 | + } |
| 52 | + else // attribute is null or empty string, use default |
| 53 | + animMS = 400; |
| 33 | 54 | |
| 34 | 55 | // Calculate panel height despite its being hidden at call time. |
| 35 | 56 | // Based on https://stackoverflow.com/a/29047447/142454 |
| 36 | | - var panelHeight; // computed on sitemap load |
| 57 | + var panelHeight; // computed on first panel display |
| 37 | 58 | function calculatePanelHeight() { |
| 59 | + |
| 60 | + // Clear the max-height CSS property in case the panel size is recalculated |
| 61 | + // after the browser window was resized. |
| 62 | + panel.style.maxHeight = ''; |
| 63 | + |
| 38 | 64 | // Get initial panel styles so we can restore them below. |
| 39 | 65 | var es = window.getComputedStyle(panel), |
| 40 | 66 | edis = es.display, |
| 41 | 67 | epos = es.position, |
| 42 | 68 | evis = es.visibility; |
| | @@ -62,20 +88,55 @@ |
| 62 | 88 | // instead, that would prevent the browser from seeing the height |
| 63 | 89 | // change as a state transition, so it'd skip the CSS transition: |
| 64 | 90 | // |
| 65 | 91 | // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Transitions/Using_CSS_transitions#JavaScript_examples |
| 66 | 92 | function showPanel() { |
| 93 | + // Cancel the timer to remove the panel border after the closing animation, |
| 94 | + // otherwise double-clicking the hamburger button with the panel opened will |
| 95 | + // remove the borders from the (closed and immediately reopened) panel. |
| 96 | + if (panelResetBorderTimerID) { |
| 97 | + clearTimeout(panelResetBorderTimerID); |
| 98 | + panelResetBorderTimerID = 0; |
| 99 | + } |
| 67 | 100 | if (animate) { |
| 101 | + if (!panelInitialized) { |
| 102 | + panelInitialized = true; |
| 103 | + // Set up a CSS transition to animate the panel open and |
| 104 | + // closed. Only needs to be done once per page load. |
| 105 | + // Based on https://stackoverflow.com/a/29047447/142454 |
| 106 | + calculatePanelHeight(); |
| 107 | + panel.style.transition = 'max-height ' + animMS + |
| 108 | + 'ms ease-in-out'; |
| 109 | + panel.style.overflowY = 'hidden'; |
| 110 | + panel.style.maxHeight = '0'; |
| 111 | + } |
| 68 | 112 | setTimeout(function() { |
| 69 | 113 | panel.style.maxHeight = panelHeight; |
| 70 | 114 | panel.style.border = panelBorder; |
| 71 | 115 | }, 40); // 25ms is insufficient with Firefox 62 |
| 72 | 116 | } |
| 73 | | - else { |
| 74 | | - panel.style.display = 'block'; |
| 75 | | - } |
| 117 | + panel.style.display = 'block'; |
| 118 | + document.addEventListener('keydown',panelKeydown,/* useCapture == */true); |
| 119 | + document.addEventListener('click',panelClick,false); |
| 76 | 120 | } |
| 121 | + |
| 122 | + var panelKeydown = function(event) { |
| 123 | + var key = event.which || event.keyCode; |
| 124 | + if (key == 27) { |
| 125 | + event.stopPropagation(); // ignore other keydown handlers |
| 126 | + panelToggle(true); |
| 127 | + } |
| 128 | + }; |
| 129 | + |
| 130 | + var panelClick = function(event) { |
| 131 | + if (!panel.contains(event.target)) { |
| 132 | + // Call event.preventDefault() to have clicks outside the opened panel |
| 133 | + // just close the panel, and swallow clicks on links or form elements. |
| 134 | + //event.preventDefault(); |
| 135 | + panelToggle(true); |
| 136 | + } |
| 137 | + }; |
| 77 | 138 | |
| 78 | 139 | // Return true if the panel is showing. |
| 79 | 140 | function panelShowing() { |
| 80 | 141 | if (animate) { |
| 81 | 142 | return panel.style.maxHeight == panelHeight; |
| | @@ -82,63 +143,92 @@ |
| 82 | 143 | } |
| 83 | 144 | else { |
| 84 | 145 | return panel.style.display == 'block'; |
| 85 | 146 | } |
| 86 | 147 | } |
| 148 | + |
| 149 | + // Check if the specified HTML element has any child elements. Note that plain |
| 150 | + // text nodes, comments, and any spaces (presentational or not) are ignored. |
| 151 | + function hasChildren(element) { |
| 152 | + var childElement = element.firstChild; |
| 153 | + while (childElement) { |
| 154 | + if (childElement.nodeType == 1) // Node.ELEMENT_NODE == 1 |
| 155 | + return true; |
| 156 | + childElement = childElement.nextSibling; |
| 157 | + } |
| 158 | + return false; |
| 159 | + } |
| 160 | + |
| 161 | + // Reset the state of the panel to uninitialized if the browser window is |
| 162 | + // resized, so the dimensions are recalculated the next time it's opened. |
| 163 | + window.addEventListener('resize',function(event) { |
| 164 | + panelInitialized = false; |
| 165 | + },false); |
| 87 | 166 | |
| 88 | 167 | // Click handler for the hamburger button. |
| 89 | | - var needSitemapHTML = true; |
| 90 | | - document.querySelector("div.mainmenu > a").onclick = function() { |
| 168 | + hbButton.addEventListener('click',function(event) { |
| 169 | + // Break the event handler chain, or the handler for document → click |
| 170 | + // (about to be installed) may already be triggered by the current event. |
| 171 | + event.stopPropagation(); |
| 172 | + event.preventDefault(); // prevent browser from acting on <a> click |
| 173 | + panelToggle(false); |
| 174 | + },false); |
| 175 | + |
| 176 | + function panelToggle(suppressAnimation) { |
| 91 | 177 | if (panelShowing()) { |
| 178 | + document.removeEventListener('keydown',panelKeydown,/* useCapture == */true); |
| 179 | + document.removeEventListener('click',panelClick,false); |
| 92 | 180 | // Transition back to hidden state. |
| 93 | 181 | if (animate) { |
| 94 | | - panel.style.maxHeight = '0'; |
| 95 | | - setTimeout(function() { |
| 96 | | - // Browsers show a 1px high border line when maxHeight == 0, |
| 97 | | - // our "hidden" state, so hide the borders in that state, too. |
| 182 | + if (suppressAnimation) { |
| 183 | + var transition = panel.style.transition; |
| 184 | + panel.style.transition = ''; |
| 185 | + panel.style.maxHeight = '0'; |
| 98 | 186 | panel.style.border = 'none'; |
| 99 | | - }, animMS); |
| 187 | + setTimeout(function() { |
| 188 | + // Make sure CSS transition won't take effect now, so restore it |
| 189 | + // asynchronously. Outer variable 'transition' still valid here. |
| 190 | + panel.style.transition = transition; |
| 191 | + }, 40); // 25ms is insufficient with Firefox 62 |
| 192 | + } |
| 193 | + else { |
| 194 | + panel.style.maxHeight = '0'; |
| 195 | + panelResetBorderTimerID = setTimeout(function() { |
| 196 | + // Browsers show a 1px high border line when maxHeight == 0, |
| 197 | + // our "hidden" state, so hide the borders in that state, too. |
| 198 | + panel.style.border = 'none'; |
| 199 | + panelResetBorderTimerID = 0; // clear ID of completed timer |
| 200 | + }, animMS); |
| 201 | + } |
| 100 | 202 | } |
| 101 | 203 | else { |
| 102 | 204 | panel.style.display = 'none'; |
| 103 | 205 | } |
| 104 | 206 | } |
| 105 | | - else if (needSitemapHTML) { |
| 106 | | - // Only get it once per page load: it isn't likely to |
| 107 | | - // change on us. |
| 108 | | - var xhr = new XMLHttpRequest(); |
| 109 | | - xhr.onload = function() { |
| 110 | | - var doc = xhr.responseXML; |
| 111 | | - if (doc) { |
| 112 | | - var sm = doc.querySelector("ul#sitemap"); |
| 113 | | - if (sm && xhr.status == 200) { |
| 114 | | - // Got sitemap. Insert it into the drop-down panel. |
| 115 | | - needSitemapHTML = false; |
| 116 | | - panel.innerHTML = sm.outerHTML; |
| 117 | | - |
| 118 | | - // Display the panel |
| 119 | | - if (animate) { |
| 120 | | - // Set up a CSS transition to animate the panel open and |
| 121 | | - // closed. Only needs to be done once per page load. |
| 122 | | - // Based on https://stackoverflow.com/a/29047447/142454 |
| 123 | | - calculatePanelHeight(); |
| 124 | | - panel.style.transition = 'max-height ' + animMS + |
| 125 | | - 'ms ease-in-out'; |
| 126 | | - panel.style.overflowY = 'hidden'; |
| 127 | | - panel.style.maxHeight = '0'; |
| 207 | + else { |
| 208 | + if (!hasChildren(panel)) { |
| 209 | + // Only get the sitemap once per page load: it isn't likely to |
| 210 | + // change on us. |
| 211 | + var xhr = new XMLHttpRequest(); |
| 212 | + xhr.onload = function() { |
| 213 | + var doc = xhr.responseXML; |
| 214 | + if (doc) { |
| 215 | + var sm = doc.querySelector("ul#sitemap"); |
| 216 | + if (sm && xhr.status == 200) { |
| 217 | + // Got sitemap. Insert it into the drop-down panel. |
| 218 | + panel.innerHTML = sm.outerHTML; |
| 219 | + // Display the panel |
| 128 | 220 | showPanel(); |
| 129 | 221 | } |
| 130 | | - panel.style.display = 'block'; |
| 131 | | - } |
| 132 | | - } |
| 133 | | - // else, can't parse response as HTML or XML |
| 134 | | - } |
| 135 | | - xhr.open("GET", "$home/sitemap?popup"); // note the TH1 substitution! |
| 136 | | - xhr.responseType = "document"; |
| 137 | | - xhr.send(); |
| 138 | | - } |
| 139 | | - else { |
| 140 | | - showPanel(); // just show what we built above |
| 141 | | - } |
| 142 | | - return false; // prevent browser from acting on <a> click |
| 222 | + } |
| 223 | + // else, can't parse response as HTML or XML |
| 224 | + } |
| 225 | + xhr.open("GET", "$home/sitemap?popup"); // note the TH1 substitution! |
| 226 | + xhr.responseType = "document"; |
| 227 | + xhr.send(); |
| 228 | + } |
| 229 | + else { |
| 230 | + showPanel(); // just show what we built above |
| 231 | + } |
| 232 | + } |
| 143 | 233 | } |
| 144 | 234 | })(); |
| 145 | 235 | |