diff --git a/R/html-dependencies.R b/R/html-dependencies.R index f58848331..60e5273f8 100644 --- a/R/html-dependencies.R +++ b/R/html-dependencies.R @@ -83,3 +83,12 @@ ace_html_dependency <- function() { script = "ace.js" ) } + +headroom_html_dependency <- function() { + htmltools::htmlDependency( + name = "headroom", + version = "0.11.0", + src = system.file("lib/headroom", package = "learnr"), + script = "headroom.js" + ) +} \ No newline at end of file diff --git a/R/tutorial-format.R b/R/tutorial-format.R index 1631bc7f1..a8facc7da 100644 --- a/R/tutorial-format.R +++ b/R/tutorial-format.R @@ -23,6 +23,7 @@ #' @param smart Produce typographically correct output, converting straight quotes to curly quotes, #' \code{---} to em-dashes, \code{--} to en-dashes, and \code{...} to ellipses. #' Deprecated in \pkg{rmarkdown} v2.2.0. +#' @param with_headroom Should the menu be dynamically hidden when the user scroll? Defaults to \code{FALSE}. #' @param ... Forward parameters to html_document #' #' @export @@ -45,6 +46,7 @@ tutorial <- function(fig_width = 6.5, includes = NULL, md_extensions = NULL, pandoc_args = NULL, + with_headroom = FALSE, ...) { # base pandoc options @@ -103,6 +105,9 @@ tutorial <- function(fig_width = 6.5, tutorial_html_dependency(), tutorial_autocompletion_html_dependency(), tutorial_diagnostics_html_dependency(), + { + if (with_headroom) headroom_html_dependency() + }, htmltools::htmlDependency( name = "tutorial-format", version = utils::packageVersion("learnr"), diff --git a/inst/lib/headroom/headroom.js b/inst/lib/headroom/headroom.js new file mode 100644 index 000000000..85c8c10b9 --- /dev/null +++ b/inst/lib/headroom/headroom.js @@ -0,0 +1,436 @@ +/*! + * headroom.js v0.11.0 - Give your page some headroom. Hide your header until you need it + * Copyright (c) 2020 Nick Williams - http://wicky.nillia.ms/headroom.js + * License: MIT + */ + +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global = global || self, global.Headroom = factory()); +}(this, function () { 'use strict'; + + function isBrowser() { + return typeof window !== "undefined"; + } + + /** + * Used to detect browser support for adding an event listener with options + * Credit: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener + */ + function passiveEventsSupported() { + var supported = false; + + try { + var options = { + // eslint-disable-next-line getter-return + get passive() { + supported = true; + } + }; + window.addEventListener("test", options, options); + window.removeEventListener("test", options, options); + } catch (err) { + supported = false; + } + + return supported; + } + + function isSupported() { + return !!( + isBrowser() && + function() {}.bind && + "classList" in document.documentElement && + Object.assign && + Object.keys && + requestAnimationFrame + ); + } + + function isDocument(obj) { + return obj.nodeType === 9; // Node.DOCUMENT_NODE === 9 + } + + function isWindow(obj) { + // `obj === window` or `obj instanceof Window` is not sufficient, + // as the obj may be the window of an iframe. + return obj && obj.document && isDocument(obj.document); + } + + function windowScroller(win) { + var doc = win.document; + var body = doc.body; + var html = doc.documentElement; + + return { + /** + * @see http://james.padolsey.com/javascript/get-document-height-cross-browser/ + * @return {Number} the scroll height of the document in pixels + */ + scrollHeight: function() { + return Math.max( + body.scrollHeight, + html.scrollHeight, + body.offsetHeight, + html.offsetHeight, + body.clientHeight, + html.clientHeight + ); + }, + + /** + * @see http://andylangton.co.uk/blog/development/get-viewport-size-width-and-height-javascript + * @return {Number} the height of the viewport in pixels + */ + height: function() { + return win.innerHeight || html.clientHeight || body.clientHeight; + }, + + /** + * Gets the Y scroll position + * @return {Number} pixels the page has scrolled along the Y-axis + */ + scrollY: function() { + if (win.pageYOffset !== undefined) { + return win.pageYOffset; + } + + return (html || body.parentNode || body).scrollTop; + } + }; + } + + function elementScroller(element) { + return { + /** + * @return {Number} the scroll height of the element in pixels + */ + scrollHeight: function() { + return Math.max( + element.scrollHeight, + element.offsetHeight, + element.clientHeight + ); + }, + + /** + * @return {Number} the height of the element in pixels + */ + height: function() { + return Math.max(element.offsetHeight, element.clientHeight); + }, + + /** + * Gets the Y scroll position + * @return {Number} pixels the element has scrolled along the Y-axis + */ + scrollY: function() { + return element.scrollTop; + } + }; + } + + function createScroller(element) { + return isWindow(element) ? windowScroller(element) : elementScroller(element); + } + + /** + * @param element EventTarget + */ + function trackScroll(element, options, callback) { + var isPassiveSupported = passiveEventsSupported(); + var rafId; + var scrolled = false; + var scroller = createScroller(element); + var lastScrollY = scroller.scrollY(); + var details = {}; + + function update() { + var scrollY = Math.round(scroller.scrollY()); + var height = scroller.height(); + var scrollHeight = scroller.scrollHeight(); + + // reuse object for less memory churn + details.scrollY = scrollY; + details.lastScrollY = lastScrollY; + details.direction = scrollY > lastScrollY ? "down" : "up"; + details.distance = Math.abs(scrollY - lastScrollY); + details.isOutOfBounds = scrollY < 0 || scrollY + height > scrollHeight; + details.top = scrollY <= options.offset; + details.bottom = scrollY + height >= scrollHeight; + details.toleranceExceeded = + details.distance > options.tolerance[details.direction]; + + callback(details); + + lastScrollY = scrollY; + scrolled = false; + } + + function handleScroll() { + if (!scrolled) { + scrolled = true; + rafId = requestAnimationFrame(update); + } + } + + var eventOptions = isPassiveSupported + ? { passive: true, capture: false } + : false; + + element.addEventListener("scroll", handleScroll, eventOptions); + update(); + + return { + destroy: function() { + cancelAnimationFrame(rafId); + element.removeEventListener("scroll", handleScroll, eventOptions); + } + }; + } + + function normalizeTolerance(t) { + return t === Object(t) ? t : { down: t, up: t }; + } + + /** + * UI enhancement for fixed headers. + * Hides header when scrolling down + * Shows header when scrolling up + * @constructor + * @param {DOMElement} elem the header element + * @param {Object} options options for the widget + */ + function Headroom(elem, options) { + options = options || {}; + Object.assign(this, Headroom.options, options); + this.classes = Object.assign({}, Headroom.options.classes, options.classes); + + this.elem = elem; + this.tolerance = normalizeTolerance(this.tolerance); + this.initialised = false; + this.frozen = false; + } + Headroom.prototype = { + constructor: Headroom, + + /** + * Start listening to scrolling + * @public + */ + init: function() { + if (Headroom.cutsTheMustard && !this.initialised) { + this.addClass("initial"); + this.initialised = true; + + // defer event registration to handle browser + // potentially restoring previous scroll position + setTimeout( + function(self) { + self.scrollTracker = trackScroll( + self.scroller, + { offset: self.offset, tolerance: self.tolerance }, + self.update.bind(self) + ); + }, + 100, + this + ); + } + + return this; + }, + + /** + * Destroy the widget, clearing up after itself + * @public + */ + destroy: function() { + this.initialised = false; + Object.keys(this.classes).forEach(this.removeClass, this); + this.scrollTracker.destroy(); + }, + + /** + * Unpin the element + * @public + */ + unpin: function() { + if (this.hasClass("pinned") || !this.hasClass("unpinned")) { + this.addClass("unpinned"); + this.removeClass("pinned"); + + if (this.onUnpin) { + this.onUnpin.call(this); + } + } + }, + + /** + * Pin the element + * @public + */ + pin: function() { + if (this.hasClass("unpinned")) { + this.addClass("pinned"); + this.removeClass("unpinned"); + + if (this.onPin) { + this.onPin.call(this); + } + } + }, + + /** + * Freezes the current state of the widget + * @public + */ + freeze: function() { + this.frozen = true; + this.addClass("frozen"); + }, + + /** + * Re-enables the default behaviour of the widget + * @public + */ + unfreeze: function() { + this.frozen = false; + this.removeClass("frozen"); + }, + + top: function() { + if (!this.hasClass("top")) { + this.addClass("top"); + this.removeClass("notTop"); + + if (this.onTop) { + this.onTop.call(this); + } + } + }, + + notTop: function() { + if (!this.hasClass("notTop")) { + this.addClass("notTop"); + this.removeClass("top"); + + if (this.onNotTop) { + this.onNotTop.call(this); + } + } + }, + + bottom: function() { + if (!this.hasClass("bottom")) { + this.addClass("bottom"); + this.removeClass("notBottom"); + + if (this.onBottom) { + this.onBottom.call(this); + } + } + }, + + notBottom: function() { + if (!this.hasClass("notBottom")) { + this.addClass("notBottom"); + this.removeClass("bottom"); + + if (this.onNotBottom) { + this.onNotBottom.call(this); + } + } + }, + + shouldUnpin: function(details) { + var scrollingDown = details.direction === "down"; + + return scrollingDown && !details.top && details.toleranceExceeded; + }, + + shouldPin: function(details) { + var scrollingUp = details.direction === "up"; + + return (scrollingUp && details.toleranceExceeded) || details.top; + }, + + addClass: function(className) { + this.elem.classList.add.apply( + this.elem.classList, + this.classes[className].split(" ") + ); + }, + + removeClass: function(className) { + this.elem.classList.remove.apply( + this.elem.classList, + this.classes[className].split(" ") + ); + }, + + hasClass: function(className) { + return this.classes[className].split(" ").every(function(cls) { + return this.classList.contains(cls); + }, this.elem); + }, + + update: function(details) { + if (details.isOutOfBounds) { + // Ignore bouncy scrolling in OSX + return; + } + + if (this.frozen === true) { + return; + } + + if (details.top) { + this.top(); + } else { + this.notTop(); + } + + if (details.bottom) { + this.bottom(); + } else { + this.notBottom(); + } + + if (this.shouldUnpin(details)) { + this.unpin(); + } else if (this.shouldPin(details)) { + this.pin(); + } + } + }; + + /** + * Default options + * @type {Object} + */ + Headroom.options = { + tolerance: { + up: 0, + down: 0 + }, + offset: 0, + scroller: isBrowser() ? window : null, + classes: { + frozen: "headroom--frozen", + pinned: "headroom--pinned", + unpinned: "headroom--unpinned", + top: "headroom--top", + notTop: "headroom--not-top", + bottom: "headroom--bottom", + notBottom: "headroom--not-bottom", + initial: "headroom" + } + }; + + Headroom.cutsTheMustard = isSupported(); + + return Headroom; + +})); \ No newline at end of file diff --git a/inst/lib/tutorial/tutorial.css b/inst/lib/tutorial/tutorial.css index 0cf6c7128..fcef6ce42 100644 --- a/inst/lib/tutorial/tutorial.css +++ b/inst/lib/tutorial/tutorial.css @@ -132,3 +132,94 @@ margin-right: -9px; /* net 6px */ } + +.pageContent.band { + position: relative; +} + +.topics { + overflow-x: initial; + padding-bottom: 100px; +} + +@media (min-width: 767px) { + .mobile { + display: none !important; + } +} + +@media (max-width: 767px) { + .desktop { + display: none !important; + } +} + +@media (max-width: 767px) { + .tutorial-panel-heading-left > .tutorial_engine_icon { + display: none !important; + } + + .btn-primary[title="Submit Answer"] { + background-color: #387fb4!important; + } + + .tutorial-panel-heading-right, .tutorial-panel-heading-left { + flex-wrap: wrap; + align-items: stretch; + } + + .tutorial-exercise-input .btn-xs { + margin-bottom: 6px!important; + } + + .tutorial-panel-heading-right > .btn-xs { + margin-left: auto; + } + .tutorial-panel-heading-left > .btn-xs { + margin-right: auto; + } +} + +.top-panel-heading { + background-color: #ebebeb!important; +} + +/* Chevrons */ + +.bi-chevron-up { + display: none; +} + +.opened .bi-chevron-down { + display: none; +} + +.opened .bi-chevron-up { + display: block; +} + +header { + position: fixed; + z-index: 10; + right: 0; + left: 0; + top: 0; + background-color: white; +} + +header>h2 { + margin-left: 1em; +} + +.headroom { + will-change: transform; + transition: transform 400ms linear; +} + +.headroom--pinned { + transform: translateY(0%); +} + +.headroom--unpinned { + transform: translateY(-100%); +} diff --git a/inst/lib/tutorial/tutorial.js b/inst/lib/tutorial/tutorial.js index 827d5c240..02123a9f8 100644 --- a/inst/lib/tutorial/tutorial.js +++ b/inst/lib/tutorial/tutorial.js @@ -896,6 +896,8 @@ Tutorial.prototype.$initializeExerciseEditors = function() { input_div.attr('id', create_id('input')); // creating heading + var panel_heading_top = $(`
${JSON.parse(options_script.html()).caption}
`); + input_div.append(panel_heading_top); var panel_heading = $('
'); input_div.append(panel_heading); var panel_heading_left = $('
'); @@ -1233,6 +1235,11 @@ Tutorial.prototype.$addSolution = function(exercise, panel_heading, editor) { copyButton.append($('')); copyButton.append(" Copy to Clipboard"); popoverTitle.append(copyButton); + var closer = $('X'); + closer.on("click", function(e){ + thiz.$removeSolution(exercise); + }) + popoverTitle.append(closer); var clipboard = new Clipboard(copyButton[0], { text: function(trigger) { return solutionEditor.getValue(); diff --git a/inst/rmarkdown/templates/tutorial/resources/tutorial-format.css b/inst/rmarkdown/templates/tutorial/resources/tutorial-format.css index dd4e6b5e3..1450d948e 100644 --- a/inst/rmarkdown/templates/tutorial/resources/tutorial-format.css +++ b/inst/rmarkdown/templates/tutorial/resources/tutorial-format.css @@ -203,6 +203,17 @@ } @media screen and (max-width: 767px) { + + #tutorial-topic { + width: 100%; + border-bottom: 3px dotted rgb(240, 240, 240); + box-shadow: rgba(0, 0, 0, 0.15) 0px 28px 17px -15px; + margin-bottom: 2em; + padding: 0px 1em; + position: absolute; + max-width: 90%; + } + .topics { width: 99%; } @@ -212,6 +223,10 @@ cursor: pointer; } + .topicsHeader>.tutorialTitle { + display: none; + } + .topicsContainer { width: 1%; min-width: 1px; @@ -233,6 +248,18 @@ .paneCloser { display: inline-block; } + + .chevron { + z-index: 999; + } + + .learnr-nav-items{ + border-bottom: 3px dotted rgb(240, 240, 240); + } + + .learnr-nav-items.opened{ + border-bottom: unset; + } } /* override the width to be 100%, not 300px wide */ diff --git a/inst/rmarkdown/templates/tutorial/resources/tutorial-format.htm b/inst/rmarkdown/templates/tutorial/resources/tutorial-format.htm index 8d51aacd5..2ac3b3cfc 100644 --- a/inst/rmarkdown/templates/tutorial/resources/tutorial-format.htm +++ b/inst/rmarkdown/templates/tutorial/resources/tutorial-format.htm @@ -200,6 +200,11 @@

$date$

+
+
+
+
+
$for(include-after)$ $include-after$ diff --git a/inst/rmarkdown/templates/tutorial/resources/tutorial-format.js b/inst/rmarkdown/templates/tutorial/resources/tutorial-format.js index 05f2722bd..92dffc66e 100644 --- a/inst/rmarkdown/templates/tutorial/resources/tutorial-format.js +++ b/inst/rmarkdown/templates/tutorial/resources/tutorial-format.js @@ -32,6 +32,17 @@ $(document).ready(function() { }; })(); + const range = (start, stop, step = 1) => Array.from({ length: (stop - start) / step + 1}, (_, i) => start + (i * step)); + + const updateCssUpper = function(e){ + var pct = $(e.target).data("css-progress"); + $("#progress_upper").css("width", pct + "%") + + if (pct > parseInt(document.querySelector("#progress_middle").style.width)){ + $("#progress_middle").css("width", pct + "%") + } + } + function setCurrentTopic(topicIndex, notify) { if (typeof(notify) === "undefined") { @@ -73,7 +84,28 @@ $(document).ready(function() { window.location = href; } + // Based on http://detectmobilebrowsers.com/ and https://stackoverflow.com/a/11381730/8236642 + function isMobile() { + let check = false; + (function(a) { if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) check = true; })(navigator.userAgent || navigator.vendor || window.opera); + return check; + } + function handleTopicClick(event) { + if (isMobile()) { + $('.topicsList').hide(); + $('.learnr-nav-items').toggleClass('opened'); + } else { + if (window.innerWidth > 767) { + $('.topicsList').show(); + } else { + $('.topicsList').hide(); + $('.learnr-nav-items').toggleClass('opened'); + } + } + var topicIndex = parseInt($(event.target).attr("index")); + var pct = (100 / topics.length - 1) * (topicIndex + 1); + // $("#progress_upper").css("width", pct + "%") hideFloatingTopics(); updateLocation(this.getAttribute('index')); } @@ -139,6 +171,7 @@ $(document).ready(function() { } function updateTopicProgressBar(topicIndex) { + var topic = topics[topicIndex]; var percentToDo; @@ -149,11 +182,12 @@ $(document).ready(function() { percentToDo = (1 - topic.sectionsSkipped/topic.sections.length) * 100; } - $(topic.jqListElement).css('background-position-y', percentToDo + '%' ); + // $(topic.jqListElement).css('background-position-y', percentToDo + '%' ); } function handleSkipClick(event) { + $(this).data('n_clicks', $(this).data('n_clicks') + 1) var sectionId = this.getAttribute('data-section-id'); @@ -215,9 +249,9 @@ $(document).ready(function() { var topicsHeader = $('
'); topicsHeader.append($('

' + titleText + '

')); - var topicsCloser = $('
'); - topicsCloser.on('click', hideFloatingTopics); - topicsHeader.append(topicsCloser); + //var topicsCloser = $('
'); + //topicsCloser.on('click', hideFloatingTopics); + //topicsHeader.append(topicsCloser); topicsList.append(topicsHeader); $('#doc-metadata').appendTo(topicsList); @@ -251,12 +285,12 @@ $(document).ready(function() { var topicActions = $('
'); if (topicIndex > 0) { - var prevButton = $(''); + var prevButton = $(''); prevButton.on('click', handlePreviousTopicClick); topicActions.append(prevButton); } if (topicIndex < topicsDOM.length - 1) { - var nextButton = $(''); + var nextButton = $(''); nextButton.on('click', handleNextTopicClick); topicActions.append(nextButton); } @@ -287,7 +321,7 @@ $(document).ready(function() { if (topic.progressiveReveal) { var continueButton = $( - '' ); @@ -420,8 +454,26 @@ $(document).ready(function() { }); Shiny.inputBindings.register(continueButtonInputBinding, 'learnr.continueButtonInputBinding'); + function attachHeadroom(){ + if (typeof Headroom != "undefined") { + if (isMobile() | window.innerWidth < 767) { + const headroom = new Headroom( + document.querySelector("header"), { + onUnpin: function() { + $("header").removeClass("opened"); + // slideUp only if mobile like + if (isMobile() | window.innerWidth < 767) { + $("#tutorial-topic").slideUp("300ms"); + } + } + } + ); + headroom.init(); + } + } + } - // transform the DOM here + // transform the DOM here function transformDOM() { titleText = $('title')[0].innerText; @@ -431,8 +483,11 @@ $(document).ready(function() { var allowSkipAttr = $('meta[name=allow-skip]').attr("content"); docAllowSkip = (allowSkipAttr == 'true' || allowSkipAttr == 'TRUE'); - var tutorialTitle = $('

' + titleText + '

'); - tutorialTitle.on('click', showFloatingTopics); + // var tutorialTitle = $('

' + titleText + '

'); + // tutorialTitle.on('click', showFloatingTopics); + + var tutorialTitle = $(`

${titleText}

`); + $('.topics').prepend(tutorialTitle); $('.bandContent.topicsListContainer').append(buildTopicsList()); @@ -443,7 +498,23 @@ $(document).ready(function() { } function handleResize() { + if (!$('header').hasClass('headroom')){ + attachHeadroom(); + } $('.topicsList').css("max-height", window.innerHeight); + // When on a Mobile or width is mobile like, we want to hide the topicList + // and to pad the sections + if (isMobile() | window.innerWidth < 767) { + $('.topicsList').hide(); + $(".section.level2").css("padding-top", $("header").height()); + $("#tutorial-topic").css("padding-top", $("header").height()); + } else { + $('.learnr-nav-items').removeClass('opened'); + $(".section.level2").css("padding-top", "unset"); + $("#tutorial-topic").css("padding-top", "unset"); + $('.topicsList').show(); + $(".section.level2").css("padding-top", 0) + } } handleResize(); @@ -468,12 +539,24 @@ $(document).ready(function() { return topicIndex; } + function setProgressBarFromHash(){ + var next_topics = $(".btn.btn-primary.progress-mover"); + var steps = range(0, 100, Math.round(100 / (next_topics.length + 1))); + var pct = steps[findTopicIndexFromHash()]; + $("#progress_upper").css("width", pct + "%") + if (pct > parseInt(document.querySelector("#progress_middle").style.width)){ + $("#progress_middle").css("width", pct + "%") + } + } // select topic from hash on the url - setCurrentTopic(findTopicIndexFromHash()); + // Restore the progress bar css + setCurrentTopic(findTopicIndexFromHash()); + setProgressBarFromHash() // navigate to a topic when the history changes window.addEventListener("popstate", function(e) { setCurrentTopic(findTopicIndexFromHash()); + setProgressBarFromHash() }); } @@ -619,6 +702,7 @@ $(document).ready(function() { transformDOM(); handleLocationHash(); + attachHeadroom(); // initialize components within tutorial.onInit event tutorial.onInit(function() { @@ -631,6 +715,61 @@ $(document).ready(function() { sectionSkipped(progressEvent.element); }); + // We want the css to move 100/(next_topics.length + 1) % + // When clicking on each "Next Topic" button + var next_topics = $(".btn.btn-primary.progress-mover"); + // We need to create a range of next_topics.length + 1, so that the + // first progression is not 0 + var steps = range(0, 100, Math.round(100 / (next_topics.length + 1))); + var steps_not_shifted = range(0, 100, Math.round(100 / (next_topics.length + 1))); + steps.shift() + // adding a data-css-progress attribute to all + // we start at 1 cause we don't need the 0% + for( var i = 0; i < next_topics.length; i ++){ + $(next_topics[i]).attr("data-css-progress", steps[i]); + $(next_topics[i]).click(function(e){updateCssUpper(e)}); + } + + // Same for topics on the left + var topic = $(".topic"); + for( var i = 0; i < topic.length; i ++){ + $(topic[i]).attr("data-css-progress", steps_not_shifted[i]); + $(topic[i]).click(function(e){updateCssUpper(e)}); + } + + // Add the css progress amount to previous Topic + var previous_topic = $(".previous-mover"); + for( var i = 0; i < previous_topic.length; i ++){ + $(previous_topic[i]).attr("data-css-progress", steps_not_shifted[i]); + $(previous_topic[i]).click(function(e){updateCssUpper(e)}); + } + + // Make the progress bar move on click on Continue button + // To do that, we need to compute steps between each `Next Topic` buttons + // In each section level2, Potential Continue buttons. We'll use Next and previous + // values to compute the range + var section_2 = $(".section.level2") + + for( var i = 0; i < section_2.length; i ++){ + let current = $(section_2[i]); + let continue_button = current.find(".btn.btn-default.progress-mover") + if (continue_button.length > 0){ + // Get the lower range. If none, it's because it's the first topic, so we set it to 0 + let lower_boundary = current.find(".previous-mover").attr("data-css-progress") || 0 + // Get the upper range. If none, it's because it's the last topic, so we set it to 0 + let upper_boundary = current.find(".btn.btn-primary.progress-mover").attr("data-css-progress") || 100 + // build the steps + let steps = range(lower_boundary, upper_boundary, Math.round(upper_boundary - lower_boundary) / (continue_button.length + 1)); + steps.shift() + for( var i = 0; i < next_topics.length; i ++){ + $(continue_button[i]).attr("data-css-progress", steps[i]); + $(continue_button[i]).click(function(e){updateCssUpper(e)}); + } + } + } + + + }); }); diff --git a/man/tutorial.Rd b/man/tutorial.Rd index 824bec8af..9db0f4604 100644 --- a/man/tutorial.Rd +++ b/man/tutorial.Rd @@ -23,6 +23,7 @@ tutorial( includes = NULL, md_extensions = NULL, pandoc_args = NULL, + with_headroom = FALSE, ... ) } @@ -95,6 +96,8 @@ additional details.} \item{pandoc_args}{Additional command line options to pass to pandoc} +\item{with_headroom}{Should the menu be dynamically hidden when the user scroll? Defaults to \code{FALSE}.} + \item{...}{Forward parameters to html_document} } \description{ diff --git a/sandbox/sandbox_with_headroom.Rmd b/sandbox/sandbox_with_headroom.Rmd new file mode 100644 index 000000000..134468a34 --- /dev/null +++ b/sandbox/sandbox_with_headroom.Rmd @@ -0,0 +1,191 @@ +--- +title: "Tutorial" +output: + learnr::tutorial: + progressive: true + allow_skip: true + with_headroom: true +runtime: shiny_prerendered +--- + + + +```{r setup, include=FALSE} +library(learnr) +library(nycflights13) +options(tutorial.event_recorder = learnr:::debug_event_recorder) +tutorial_options( + exercise.eval = FALSE, + exercise.checker = function(label, user_code, envir_result, ...) { + if (is.null(envir_result)) + list(message = "Bad code!", + correct = FALSE) + else + list(message = "Nice job!", + correct = TRUE, + location = "append") + } +) +``` + + + +## intro + +This is the intro to the whole ball of wax. + +### flights + +```{r filter, exercise=TRUE} +# filter the flights table to include only United and American flights +flights +``` + +```{r filter-hint-1} +filter(flights, ...) +``` + +```{r filter-hint-2} +filter(flights, UniqueCarrier=="AA") +``` + +```{r filter-hint-3} +filter(flights, UniqueCarrier=="AA" | UniqueCarrier=="UA") +``` + +### YouTube Video + +![](https://www.youtube.com/watch?v=TJmNvfhLCoI) + +### Vimeo Video + +![](https://player.vimeo.com/video/76979871) + + +### in between + +In betwixt and in between. + +### solution {style="margin-top: 50px;"} + +```{r exercise1-setup} +library(dplyr) +nycflights <- nycflights13::flights +``` + +```{r exercise1, exercise=TRUE} +# Change the filter to select February rather than January +nycflights <- filter(nycflights, month == 1) +``` + +
+ +This is the hint for the exercise which I just provided. + +
+ + +## Viewing Data + +Modify this code so that it prints only the first 5 rows of the `mtcars` dataset: + +```{r cars, exercise=TRUE, exercise.eval=TRUE, exercise.timelimit = 10} +head(mtcars, n = 5) +``` + + +```{r cars-solution} +mtcars <- head(mtcars, n = 5) +print(mtcars) +mtcars <- head(mtcars, n = 5) +print(mtcars) +mtcars <- head(mtcars, n = 5) +print(mtcars) +``` + +```{r cars-code-check} +# +``` + +```{r cars-check} +# +``` + +
 
+ +Modify this plot to use the `cyl` variable rather than the `wt` variable: + +```{r ggplot, exercise=TRUE, exercise.eval=TRUE, fig.width=8} +library(ggplot2) +qplot(mpg, cyl, data = mtcars) +``` + +```{r ggplot-check} +mtcars <- head(mtcars, n = 5) +mtcars +``` + +## dygraphs + +Add a range selector to the following dygraph: + +```{r dygraphs, exercise=TRUE} +library(dygraphs) +dygraph(ldeaths) +``` + +## quiz + + +```{r letter-a, echo=FALSE} +question("What number is the letter A in the English alphabet?", allow_retry = TRUE, + answer("8"), + answer("14"), + answer("1", correct = TRUE), + answer("23") +) +``` + + +```{r quiz1, echo=FALSE} +quiz( + question("What number is the letter A in the *English* alphabet?", + answer("8"), + answer("14", message = "That's $x+ 1$ **insane!**"), + answer("1", correct = TRUE, message = "Good job!"), + answer("23") + ), + question("Where are you right now? (select ALL that apply)", + answer("Planet Earth", correct = TRUE), + answer("Pluto", message = "How could you think we are on Pluto?"), + answer("At a computing device", correct = TRUE), + answer("In the Milky Way", correct = TRUE), + incorrect = "Incorrect. You're on Earth, in the Milky Way, at a computer." + ), + question(sprintf("Suppose $x = %s$. Choose the correct statement:", 42), + answer(sprintf("$\\sqrt{x} = %d$", 42 + 1)), + answer(sprintf("$x ^ 2 = %d$", 42^2), correct = TRUE), + answer("$\\sin x = 1$") + ) +) +``` + +## diagnostics buglet + +```{r diagnostics-comma, exercise=TRUE} +# a false 'unexpected comma' diagnostic was printed at the end of line 4 below +GapMinderPlot( + LifeExpectency, + countries = c("Germany", "United States", "New Zealand", "China", + "India", "Japan")) +GapMinderPlot(LifeExpectency) +``` + +## for in + +```{r diagnostics-for, exercise=TRUE} +for (i in seq(1,5)){ + x <- i + } +``` + diff --git a/sandbox/sandbox_with_headroom_gradethis.Rmd b/sandbox/sandbox_with_headroom_gradethis.Rmd new file mode 100644 index 000000000..6a827f784 --- /dev/null +++ b/sandbox/sandbox_with_headroom_gradethis.Rmd @@ -0,0 +1,46 @@ +--- +title: "Tutorial" +output: + learnr::tutorial: + progressive: true + allow_skip: true + with_headroom: true +runtime: shiny_prerendered +--- + + + +```{r setup, include=FALSE} +library(learnr) +library(dplyr) +#options(tutorial.event_recorder = learnr:::debug_event_recorder) +gradethis::gradethis_setup() +``` + + + +## intro + +This is the intro to the whole ball of wax. + +### flights + +```{r mtcars2, exercise = TRUE} + +``` + +```{r mtcars2-solution} +mtcars +``` + +```{r mtcars2-hint} +mt +``` + + +```{r mtcars2-check } +grade_result( + fail_if(~identical(.result, cars), "This is the cars (not mtcars) dataset."), + pass_if(~identical(.result, mtcars)) +) +```