diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..7626b29 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,35 @@ +// Config options: https://github.com/rocker-org/devcontainer-templates/tree/main/src/r-ver +{ + "name": "R (rocker/r-ver base)", + "image": "ghcr.io/rocker-org/devcontainer/r-ver:4.3", + // Add software + "features": { + // Required to test with knitr + // R package config: https://github.com/rocker-org/devcontainer-features/blob/main/src/r-rig/README.md + "ghcr.io/rocker-org/devcontainer-features/r-rig:1": { + "version": "none", + "installRMarkdown": true, + "installJupyterlab": true, + "installRadian": true + }, + // You may wish to switch prerelease to latest for stable development + // Quarto configuration : https://github.com/rocker-org/devcontainer-features/blob/main/src/quarto-cli/README.md + "ghcr.io/rocker-org/devcontainer-features/quarto-cli:1": { + "version": "prerelease" + } + }, + "customizations": { + "vscode": { + "settings": { + "r.rterm.linux": "/usr/local/bin/radian", + "r.bracketedPaste": true, + "r.plot.useHttpgd": true, + "[r]": { + "editor.wordSeparators": "`~!@#%$^&*()-=+[{]}\\|;:'\",<>/?" + } + }, + // Enable a development set of extensions for Lua and Quarto + "extensions": ["quarto.quarto", "sumneko.lua", "GitHub.copilot"] + } + } +} \ No newline at end of file diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index 22fb9e3..49656e8 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -5,6 +5,7 @@ on: branches: [main, master] pull_request: branches: [main, master] + workflow_dispatch: {} name: R-CMD-check @@ -45,6 +46,7 @@ jobs: needs: check working-directory: r + - uses: r-lib/actions/check-r-package@v2 with: upload-snapshots: true diff --git a/.gitignore b/.gitignore index 333387f..74ae8f8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,7 @@ .DS_Store # Directories that start with _ -_*/ -!_snaps/ +_dev/ ## https://github.com/github/gitignore/blob/master/R.gitignore # History files @@ -74,3 +73,6 @@ Icon Network Trash Folder Temporary Items .apdisk + +/.luarc.json +*.luarc.json \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 21bd5d5..0000000 --- a/LICENSE +++ /dev/null @@ -1,2 +0,0 @@ -YEAR: 2022 -COPYRIGHT HOLDER: Garrick Aden-Buie diff --git a/lib/countdown.js b/lib/countdown.js index 76379e2..27f64b2 100644 --- a/lib/countdown.js +++ b/lib/countdown.js @@ -377,7 +377,7 @@ class CountdownTimer { playSound () { let url = this.play_sound - if (!url) return + if (!url || url === "false") return if (typeof url === 'boolean') { const src = this.src_location ? this.src_location.replace('/countdown.js', '') diff --git a/quarto/.gitignore b/quarto/.gitignore new file mode 100644 index 0000000..e877df6 --- /dev/null +++ b/quarto/.gitignore @@ -0,0 +1,3 @@ +*.html +*.pdf +*_files/ \ No newline at end of file diff --git a/quarto/README.md b/quarto/README.md index 6ff9d97..e1adbf1 100644 --- a/quarto/README.md +++ b/quarto/README.md @@ -1,8 +1,8 @@ -# quarto-countdown: A Quarto Extension for Countdown +# quarto-countdown: Countdown Timers for Quarto RevealJS slides -The `countdown` extension allows you to incorporate countdown like timers on Quarto HTML Documents and RevealJS slides. +The `quarto-countdown` extension for [Quarto](https://quarto.org) allows you to incorporate countdown timers on [Quarto RevealJS slides](https://quarto.org/docs/presentations/revealjs/). -This extension can be used without installing R or the `{countdown}` R Package. +This extension doesn't require the installation of _R_ or the `{countdown}` _R_ Package. ## Installation @@ -20,13 +20,100 @@ This command will download and install the extension under the `_extensions` sub ## Usage -To embed a countdown clock, use the `{{< countdown >}}` shortcode. For example: +To embed a countdown clock, use the `{{< countdown >}}` shortcode. For example, a countdown clock can be created by writing anywhere: ```default {{< countdown >}} +``` + +For a longer or shorter countdown, specify the `minutes` and `seconds` options: + +```default {{< countdown minutes=5 seconds=30 >}} ``` +Or use a time string formatted in `"MM:SS"` + +```default +{{< countdown "5:30" >}} +``` + +There are many more customizations to choose from. See the next section for more details. + +### Customizations + +The extension offers extensive customization options, akin to the features provided by the R package version of `countdown`. These customizations span both functionality and style. They can be configured either at the document level or for each individual timer. + +#### In-line options + +The `countdown` timer shortcode has a variety of customizations that can be set. The customizations can be split between functionality and style. + +The functionality options are: + +| Option | Default Value | Description | +| ------------------- | ------------------------------- | ------------------------------------------------------------------------- | +| `minutes` | `1` | Number of minutes with a total cap of 100 minutes | +| `seconds` | `0` | Number of seconds | +| `id` | A generated, unique ID | ID attribute of the HTML element. | +| `class` | "countdown" | Class attribute of the HTML element. | +| `warn_when` | `0` | Number of seconds before the countdown displays a warning. | +| `update_every` | `1` | Frequency at which the countdown should be updated, in seconds. | +| `play_sound` | `"false"` | Boolean indicating whether to play a sound during the countdown. | +| `blink_colon` | `"false"` | Boolean indicating whether the colon in the countdown should blink. | +| `start_immediately` | `"false"` | Boolean indicating whether the countdown should start immediately. | + +The style options are: + +| Style Option | Default Value | Description | +| ------------- | ---------------------------- | ------------------------------------------------------------------------- | +| `top` | `""` (empty) | Top position of the HTML element. | +| `right` | `"0"` | Right position of the HTML element. | +| `bottom` | `"0"` | Bottom position of the HTML element. | +| `left` | `""` (empty) | Left position of the HTML element. | +| `margin` | `"0.6em"` | Margin around the HTML element. | +| `padding` | `"10px 15px"` | Padding within the HTML element. | +| `font-size` | `"3rem"` | Font size of the HTML element. | +| `line-height` | `"1"` | Line height of the HTML element. | +| `style` | Computed based on attributes | String constructed based on style-related attributes of the HTML element. | + +#### Document-level Options + +Document-level options can be specified in the document's header using a YAML key-value format: + +```yaml +--- +title: "Example document-level settings" +countdown: + option: value +--- +``` + +The following options are implemented: + +| Option | Default Value | Description | +| --------------------------- | ------------------------------------------ | ------------------------------------------------- | +| `font_size` | `"3rem"` | Font size for the countdown element | +| `margin` | `"0.6em"` | Margin around the countdown element | +| `padding` | `"10px 15px"` | Padding within the countdown element | +| `box_shadow` | `"0px 4px 10px 0px rgba(50, 50, 50, 0.4)"` | Shadow applied to the countdown element | +| `border_width` | `"0.1875rem"` | Border width of the countdown element | +| `border_radius` | `"0.9rem"` | Border radius of the countdown element | +| `line_height` | `"1"` | Line height of the countdown element | +| `color_border` | `"#ddd"` | Border color of the countdown element | +| `color_background` | `"inherit"` | Background color of the countdown element | +| `color_text` | `"inherit"` | Text color of the countdown element | +| `color_running_background` | `"#43AC6A"` | Background color when the countdown is running | +| `color_running_border` | `"#2A9B59FF"` | Border color when the countdown is running | +| `color_running_text` | `"inherit"` | Text color when the countdown is running | +| `color_finished_background` | `"#F04124"` | Background color when the countdown is finished | +| `color_finished_border` | `"#DE3000FF"` | Border color when the countdown is finished | +| `color_finished_text` | `"inherit"` | Text color when the countdown is finished | +| `color_warning_background` | `"#E6C229"` | Background color when the countdown has a warning | +| `color_warning_border` | `"#CEAC04FF"` | Border color when the countdown has a warning | +| `color_warning_text` | `"inherit"` | Text color when the countdown has a warning | +| `selector` | `"root"` | Selector for the countdown element | + + ## Example You can see a minimal example of the extension in action here: [example.qmd](example.qmd). diff --git a/quarto/_extensions/countdown/_extension.yml b/quarto/_extensions/countdown/_extension.yml new file mode 100644 index 0000000..cc3bad7 --- /dev/null +++ b/quarto/_extensions/countdown/_extension.yml @@ -0,0 +1,8 @@ +title: countdown +author: Garrick Aden-Buie and James Joseph Balamuta +version: 0.0.0-dev.1 +quarto-required: ">=1.4.0" +contributes: + shortcodes: + - countdown.lua + diff --git a/quarto/_extensions/countdown/assets/countdown.css b/quarto/_extensions/countdown/assets/countdown.css new file mode 100644 index 0000000..6edffcf --- /dev/null +++ b/quarto/_extensions/countdown/assets/countdown.css @@ -0,0 +1,175 @@ +.countdown { + --_margin: 0.6em; + --_running-color: var(--countdown-color-running-text, rgba(0, 0, 0, 0.8)); + --_running-background: var(--countdown-color-running-background, #43AC6A); + --_running-border-color: var(--countdown-color-running-border, rgba(0, 0, 0, 0.1)); + --_finished-color: var(--countdown-color-finished-text, rgba(0, 0, 0, 0.7)); + --_finished-background: var(--countdown-color-finished-background, #F04124); + --_finished-border-color: var(--countdown-color-finished-border, rgba(0, 0, 0, 0.1)); + + position: absolute; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + background: var(--countdown-color-background, inherit); + font-size: var(--countdown-font-size, 3rem); + line-height: var(--countdown-line-height, 1); + border-color: var(--countdown-color-border, #ddd); + border-width: var(--countdown-border-width, 0.1875rem); + border-style: solid; + border-radius: var(--countdown-border-radius, 0.9rem); + box-shadow: var(--countdown-box-shadow, 0px 4px 10px 0px rgba(50, 50, 50, 0.4)); + margin: var(--countdown-margin, var(--_margin, 0.6em)); + padding: var(--countdown-padding, 0.625rem 0.9rem); + text-align: center; + z-index: 10; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.countdown.inline { + position: relative; + width: max-content; + max-width: 100%; +} + +.countdown .countdown-time { + background: none; + font-size: 100%; + padding: 0; + color: currentColor; +} + +.countdown-digits { + color: var(--countdown-color-text); +} + +.countdown.running { + border-color: var(--_running-border-color); + background-color: var(--_running-background); +} + +.countdown.running .countdown-digits { + color: var(--_running-color); +} + +.countdown.finished { + border-color: var(--_finished-border-color); + background-color: var(--_finished-background); +} + +.countdown.finished .countdown-digits { + color: var(--_finished-color); +} + +.countdown.running.warning { + border-color: var(--countdown-color-warning-border, rgba(0, 0, 0, 0.1)); + background-color: var(--countdown-color-warning-background, #E6C229); +} + +.countdown.running.warning .countdown-digits { + color: var(--countdown-color-warning-text, rgba(0, 0, 0, 0.7)); +} + +.countdown.running.blink-colon .countdown-digits.colon { + opacity: 0.1; +} + +/* ------ Controls ------ */ +.countdown:not(.running) .countdown-controls, +.countdown.no-controls .countdown-controls { + display: none; +} + +.countdown-controls { + position: absolute; + top: -0.5rem; + right: -0.5rem; + left: -0.5rem; + display: flex; + justify-content: space-between; + margin: 0; + padding: 0; +} + +.countdown-controls>button { + position: relative; + font-size: 1.5rem; + width: 1rem; + height: 1rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-family: monospace; + padding: 10px; + margin: 0; + background: inherit; + border: 2px solid; + border-radius: 100%; + transition: 50ms transform ease-in-out, 150ms opacity ease-in; + box-shadow: 0px 2px 5px 0px rgba(50, 50, 50, 0.4); + -webkit-box-shadow: 0px 2px 5px 0px rgba(50, 50, 50, 0.4); + --_button-bump: 0; + opacity: var(--_opacity, 0); + transform: translate(0, var(--_button-bump)); +} + +/* increase hit area of the +/- buttons */ +.countdown .countdown-controls > button::after { + content: ""; + height: 200%; + width: 200%; + position: absolute; + border-radius: 50%; +} + +.countdown .countdown-controls>button:last-child { + color: var(--_running-color); + background-color: var(--_running-background); + border-color: var(--_running-border-color); +} + +.countdown .countdown-controls>button:first-child { + color: var(--_finished-color); + background-color: var(--_finished-background); + border-color: var(--_finished-border-color); +} + +.countdown.running:hover, .countdown.running:focus-within { + --_opacity: 1; +} + +.countdown.running:hover .countdown-controls>button, +.countdown.running:focus-within .countdown-controls>button { + --_button-bump: -3px; +} + +.countdown.running:hover .countdown-controls>button:active, +.countdown.running:focus-within .countdown-controls>button:active { + --_button-bump: 0; +} + +/* ---- Quarto Reveal.js ---- */ +.reveal .countdown { + --_margin: 0; +} + +/* ----- Fullscreen ----- */ +.countdown.countdown-fullscreen { + z-index: 0; +} + +.countdown-fullscreen.running .countdown-controls { + top: 1rem; + left: 0; + right: 0; + justify-content: center; +} + +.countdown-fullscreen.running .countdown-controls>button+button { + margin-left: 1rem; +} diff --git a/quarto/_extensions/countdown/assets/countdown.js b/quarto/_extensions/countdown/assets/countdown.js new file mode 100644 index 0000000..27f64b2 --- /dev/null +++ b/quarto/_extensions/countdown/assets/countdown.js @@ -0,0 +1,489 @@ +/* globals Shiny,Audio */ +class CountdownTimer { + constructor (el, opts) { + if (typeof el === 'string' || el instanceof String) { + el = document.querySelector(el) + } + + if (el.counter) { + return el.counter + } + + const minutes = parseInt(el.querySelector('.minutes').innerText || '0') + const seconds = parseInt(el.querySelector('.seconds').innerText || '0') + const duration = minutes * 60 + seconds + + function attrIsTrue (x) { + if (typeof x === 'undefined') return false + if (x === true) return true + return !!(x === 'true' || x === '' || x === '1') + } + + this.element = el + this.duration = duration + this.end = null + this.is_running = false + this.warn_when = parseInt(el.dataset.warnWhen) || -1 + this.update_every = parseInt(el.dataset.updateEvery) || 1 + this.play_sound = attrIsTrue(el.dataset.playSound) || el.dataset.playSound + this.blink_colon = attrIsTrue(el.dataset.blinkColon) + this.startImmediately = attrIsTrue(el.dataset.startImmediately) + this.timeout = null + this.display = { minutes, seconds } + + if (opts.src_location) { + this.src_location = opts.src_location + } + + this.addEventListeners() + } + + addEventListeners () { + const self = this + + if (this.startImmediately) { + if (window.remark && window.slideshow) { + // Remark (xaringan) support + const isOnVisibleSlide = () => { + return document.querySelector('.remark-visible').contains(self.element) + } + if (isOnVisibleSlide()) { + self.start() + } else { + let started_once = 0 + window.slideshow.on('afterShowSlide', function () { + if (started_once > 0) return + if (isOnVisibleSlide()) { + self.start() + started_once = 1 + } + }) + } + } else if (window.Reveal) { + // Revealjs (quarto) support + const isOnVisibleSlide = () => { + const currentSlide = document.querySelector('.reveal .slide.present') + return currentSlide ? currentSlide.contains(self.element) : false + } + if (isOnVisibleSlide()) { + self.start() + } else { + const revealStartTimer = () => { + if (isOnVisibleSlide()) { + self.start() + window.Reveal.off('slidechanged', revealStartTimer) + } + } + window.Reveal.on('slidechanged', revealStartTimer) + } + } else if (window.IntersectionObserver) { + // All other situations use IntersectionObserver + const onVisible = (element, callback) => { + new window.IntersectionObserver((entries, observer) => { + entries.forEach(entry => { + if (entry.intersectionRatio > 0) { + callback(element) + observer.disconnect() + } + }) + }).observe(element) + } + onVisible(this.element, el => el.countdown.start()) + } else { + // or just start the timer as soon as it's initialized + this.start() + } + } + + function haltEvent (ev) { + ev.preventDefault() + ev.stopPropagation() + } + function isSpaceOrEnter (ev) { + return ev.code === 'Space' || ev.code === 'Enter' + } + function isArrowUpOrDown (ev) { + return ev.code === 'ArrowUp' || ev.code === 'ArrowDown' + } + + ;['click', 'touchend'].forEach(function (eventType) { + self.element.addEventListener(eventType, function (ev) { + haltEvent(ev) + self.is_running ? self.stop({manual: true}) : self.start() + }) + }) + this.element.addEventListener('keydown', function (ev) { + if (ev.code === "Escape") { + self.reset() + haltEvent(ev) + } + if (!isSpaceOrEnter(ev) && !isArrowUpOrDown(ev)) return + haltEvent(ev) + if (isSpaceOrEnter(ev)) { + self.is_running ? self.stop({manual: true}) : self.start() + return + } + + if (!self.is_running) return + + if (ev.code === 'ArrowUp') { + self.bumpUp() + } else if (ev.code === 'ArrowDown') { + self.bumpDown() + } + }) + this.element.addEventListener('dblclick', function (ev) { + haltEvent(ev) + if (self.is_running) self.reset() + }) + this.element.addEventListener('touchmove', haltEvent) + + const btnBumpDown = this.element.querySelector('.countdown-bump-down') + ;['click', 'touchend'].forEach(function (eventType) { + btnBumpDown.addEventListener(eventType, function (ev) { + haltEvent(ev) + if (self.is_running) self.bumpDown() + }) + }) + btnBumpDown.addEventListener('keydown', function (ev) { + if (!isSpaceOrEnter(ev) || !self.is_running) return + haltEvent(ev) + self.bumpDown() + }) + + const btnBumpUp = this.element.querySelector('.countdown-bump-up') + ;['click', 'touchend'].forEach(function (eventType) { + btnBumpUp.addEventListener(eventType, function (ev) { + haltEvent(ev) + if (self.is_running) self.bumpUp() + }) + }) + btnBumpUp.addEventListener('keydown', function (ev) { + if (!isSpaceOrEnter(ev) || !self.is_running) return + haltEvent(ev) + self.bumpUp() + }) + this.element.querySelector('.countdown-controls').addEventListener('dblclick', function (ev) { + haltEvent(ev) + }) + } + + remainingTime () { + const remaining = this.is_running + ? (this.end - Date.now()) / 1000 + : this.remaining || this.duration + + let minutes = Math.floor(remaining / 60) + let seconds = Math.ceil(remaining - minutes * 60) + + if (seconds > 59) { + minutes = minutes + 1 + seconds = seconds - 60 + } + + return { remaining, minutes, seconds } + } + + start () { + if (this.is_running) return + + this.is_running = true + + if (this.remaining) { + // Having a static remaining time indicates timer was paused + this.end = Date.now() + this.remaining * 1000 + this.remaining = null + } else { + this.end = Date.now() + this.duration * 1000 + } + + this.emitStateEvent('start') + + this.element.classList.remove('finished') + this.element.classList.add('running') + this.update(true) + this.tick() + } + + tick (run_again) { + if (typeof run_again === 'undefined') { + run_again = true + } + + if (!this.is_running) return + + const { seconds: secondsWas } = this.display + this.update() + + if (run_again) { + const delay = (this.end - Date.now() > 10000) ? 1000 : 250 + this.blinkColon(secondsWas) + this.timeout = setTimeout(this.tick.bind(this), delay) + } + } + + blinkColon (secondsWas) { + // don't blink unless option is set + if (!this.blink_colon) return + // warn_when always updates the seconds + if (this.warn_when > 0 && Date.now() + this.warn_when > this.end) { + this.element.classList.remove('blink-colon') + return + } + const { seconds: secondsIs } = this.display + if (secondsIs > 10 || secondsWas !== secondsIs) { + this.element.classList.toggle('blink-colon') + } + } + + update (force) { + if (typeof force === 'undefined') { + force = false + } + + const { remaining, minutes, seconds } = this.remainingTime() + + const setRemainingTime = (selector, time) => { + const timeContainer = this.element.querySelector(selector) + if (!timeContainer) return + time = Math.max(time, 0) + timeContainer.innerText = String(time).padStart(2, 0) + } + + if (this.is_running && remaining < 0.25) { + this.stop() + setRemainingTime('.minutes', 0) + setRemainingTime('.seconds', 0) + this.playSound() + return + } + + const should_update = force || + Math.round(remaining) < this.warn_when || + Math.round(remaining) % this.update_every === 0 + + if (should_update) { + const is_warning = remaining <= this.warn_when + if (is_warning && !this.element.classList.contains('warning')) { + this.emitStateEvent('warning') + } + this.element.classList.toggle('warning', is_warning) + this.display = { minutes, seconds } + setRemainingTime('.minutes', minutes) + setRemainingTime('.seconds', seconds) + } + } + + stop ({manual = false} = {}) { + const { remaining } = this.remainingTime() + if (remaining > 1) { + this.remaining = remaining + } + this.element.classList.remove('running') + this.element.classList.remove('warning') + this.element.classList.remove('blink-colon') + this.element.classList.add('finished') + this.is_running = false + this.end = null + this.emitStateEvent(manual ? 'stop' : 'finished') + this.timeout = clearTimeout(this.timeout) + } + + reset () { + this.stop({manual: true}) + this.remaining = null + this.update(true) + + this.element.classList.remove('finished') + this.element.classList.remove('warning') + this.emitEvents = true + this.emitStateEvent('reset') + } + + setValues (opts) { + if (typeof opts.warn_when !== 'undefined') { + this.warn_when = opts.warn_when + } + if (typeof opts.update_every !== 'undefined') { + this.update_every = opts.update_every + } + if (typeof opts.blink_colon !== 'undefined') { + this.blink_colon = opts.blink_colon + if (!opts.blink_colon) { + this.element.classList.remove('blink-colon') + } + } + if (typeof opts.play_sound !== 'undefined') { + this.play_sound = opts.play_sound + } + if (typeof opts.duration !== 'undefined') { + this.duration = opts.duration + if (this.is_running) { + this.reset() + this.start() + } + } + this.emitStateEvent('update') + this.update(true) + } + + bumpTimer (val, round) { + round = typeof round === 'boolean' ? round : true + const { remaining } = this.remainingTime() + let newRemaining = remaining + val + if (newRemaining <= 0) { + this.setRemaining(0) + this.stop() + return + } + if (round && newRemaining > 10) { + newRemaining = Math.round(newRemaining / 5) * 5 + } + this.setRemaining(newRemaining) + this.emitStateEvent(val > 0 ? 'bumpUp' : 'bumpDown') + this.update(true) + } + + bumpUp (val) { + if (!this.is_running) { + console.error('timer is not running') + return + } + this.bumpTimer( + val || this.bumpIncrementValue(), + typeof val === 'undefined' + ) + } + + bumpDown (val) { + if (!this.is_running) { + console.error('timer is not running') + return + } + this.bumpTimer( + val || -1 * this.bumpIncrementValue(), + typeof val === 'undefined' + ) + } + + setRemaining (val) { + if (!this.is_running) { + console.error('timer is not running') + return + } + this.end = Date.now() + val * 1000 + this.update(true) + } + + playSound () { + let url = this.play_sound + if (!url || url === "false") return + if (typeof url === 'boolean') { + const src = this.src_location + ? this.src_location.replace('/countdown.js', '') + : 'libs/countdown' + url = src + '/smb_stage_clear.mp3' + } + const sound = new Audio(url) + sound.play() + } + + bumpIncrementValue (val) { + val = val || this.remainingTime().remaining + if (val <= 30) { + return 5 + } else if (val <= 300) { + return 15 + } else if (val <= 3000) { + return 30 + } else { + return 60 + } + } + + emitStateEvent (action) { + const data = { + action, + time: new Date().toISOString(), + timer: { + is_running: this.is_running, + end: this.end ? new Date(this.end).toISOString() : null, + remaining: this.remainingTime() + } + } + + this.reportStateToShiny(data) + this.element.dispatchEvent(new CustomEvent('countdown', { detail: data, bubbles: true })) + } + + reportStateToShiny (data) { + if (!window.Shiny) return + + if (!window.Shiny.setInputValue) { + // We're in Shiny but it isn't ready for input updates yet + setTimeout(() => this.reportStateToShiny(data), 100) + return + } + + const { action, time, timer } = data + + const shinyData = { event: { action, time }, timer } + + window.Shiny.setInputValue(this.element.id, shinyData) + } +} + +(function () { + const CURRENT_SCRIPT = document.currentScript.getAttribute('src') + + document.addEventListener('DOMContentLoaded', function () { + const els = document.querySelectorAll('.countdown') + if (!els || !els.length) { + return + } + els.forEach(function (el) { + el.countdown = new CountdownTimer(el, { src_location: CURRENT_SCRIPT }) + }) + + if (window.Shiny) { + Shiny.addCustomMessageHandler('countdown:update', function (x) { + if (!x.id) { + console.error('No `id` provided, cannot update countdown') + return + } + const el = document.getElementById(x.id) + el.countdown.setValues(x) + }) + + Shiny.addCustomMessageHandler('countdown:start', function (id) { + const el = document.getElementById(id) + if (!el) return + el.countdown.start() + }) + + Shiny.addCustomMessageHandler('countdown:stop', function (id) { + const el = document.getElementById(id) + if (!el) return + el.countdown.stop({manual: true}) + }) + + Shiny.addCustomMessageHandler('countdown:reset', function (id) { + const el = document.getElementById(id) + if (!el) return + el.countdown.reset() + }) + + Shiny.addCustomMessageHandler('countdown:bumpUp', function (id) { + const el = document.getElementById(id) + if (!el) return + el.countdown.bumpUp() + }) + + Shiny.addCustomMessageHandler('countdown:bumpDown', function (id) { + const el = document.getElementById(id) + if (!el) return + el.countdown.bumpDown() + }) + } + }) +})() diff --git a/quarto/_extensions/countdown/assets/smb_stage_clear.mp3 b/quarto/_extensions/countdown/assets/smb_stage_clear.mp3 new file mode 100644 index 0000000..da2ddc2 Binary files /dev/null and b/quarto/_extensions/countdown/assets/smb_stage_clear.mp3 differ diff --git a/quarto/_extensions/countdown/countdown.lua b/quarto/_extensions/countdown/countdown.lua new file mode 100644 index 0000000..ec12cdb --- /dev/null +++ b/quarto/_extensions/countdown/countdown.lua @@ -0,0 +1,293 @@ +-- Specify embedded version +local countdownEmbeddedVersion = "0.0.1" + +-- Only embed resources once if there are multiple timers present +local needsToExportDependencies = true + +-- List CSS default options +local default_style_keys = { + "font_size", + "margin", + "padding", + "box_shadow", + "border_width", + "border_radius", + "line_height", + "color_border", + "color_background", + "color_text", + "color_running_background", + "color_running_border", + "color_running_text", + "color_finished_background", + "color_finished_border", + "color_finished_text", + "color_warning_background", + "color_warning_border", + "color_warning_text" +} + +-- Check if variable missing or an empty string +local function isVariableEmpty(s) + return s == nil or s == '' +end + +-- Check if variable is present +local function isVariablePopulated(s) + return not isVariableEmpty(s) +end + +-- Check if a table is empty +local function isTableEmpty(tbl) + return next(tbl) == nil +end + +-- Check if a table is populated +local function isTablePopulated(tbl) + return not isTableEmpty(tbl) +end + +-- Check whether an argument is present in kwargs +-- If it is, return the value +local function tryOption(options, key) + + -- Protect against an empty options + if not (options and options[key]) then + return nil + end + + -- Retrieve the option + local option_value = pandoc.utils.stringify(options[key]) + -- Verify the option's value exists, return value otherwise nil. + if isVariablePopulated(option_value) then + return option_value + else + return nil + end +end + +-- Retrieve the option value or use the default value +local function getOption(options, key, default) + return tryOption(options, key) or default +end + +-- Check whether the play_sound parameter contains `"true"`/`"false"` or +-- if it is a custom path +local function tryPlaySound(play_sound) + if play_sound == "false" or play_sound == "true" then + return play_sound + elseif type(play_sound) == "string" and string.len(play_sound) > 0 then + return play_sound + else + return "false" + end +end + +-- Define the infix operator %:?% to handle styling if missing +local function safeStyle(options, key, fmtString) + -- Attempt to retrieve the style option + local style_option = tryOption(options, key) + -- If it is present, format it as a CSS value + if isVariablePopulated(style_option) then + return string.format(fmtString, key:gsub("_", "-"), style_option) + end + -- Otherwise, return an empty string that when concatenated does nothing. + return "" +end + +-- Construct the CSS style attributes +local function structureCountdownCSSVars(options) + -- Concatenate style properties with their values using %:?% from kwargs + local stylePositional = {"top", "right", "bottom", "left"} + local stylePositionalTable = {} + local styleDefaultOptionsTable = {} + + -- Build the positional style without prefixing countdown variables + for i, key in ipairs(stylePositional) do + stylePositionalTable[i] = safeStyle(options, key, "%s: %s;") + end + + -- Build the countdown variables for styling + for i, key in ipairs(default_style_keys) do + styleDefaultOptionsTable[i] = safeStyle(options, key, "--countdown-%s: %s;") + end + + -- Concatenate entries together + return table.concat(stylePositionalTable) .. table.concat(styleDefaultOptionsTable) +end + +-- Handle global styling options by reading options set in the meta key +local function countdown_style(options) + + -- Check if options have values; if it is empty, just exit. + if isVariableEmpty(options) or isTableEmpty(options) then + return nil + end + + -- Determine the selector value + local possibleSelector = getOption(options, "selector", ":root") + + -- Restructure options to ("key:value;--countdown-: ;) string + local structuredCSS = structureCountdownCSSVars(options) + + -- Embed into the document to avoid rendering to disk and, then, embedding a URL. + quarto.doc.include_text('in-header', + string.format( + "\n", + possibleSelector, + structuredCSS + ) + ) + -- Note: This feature or using `add_supporting` requires Quarto v1.4 or above + +end + +-- Handle embedding/creation of assets once +local function ensureHTMLDependency(meta) + + -- Register _all_ assets together. + quarto.doc.addHtmlDependency({ + name = "countdown", + version = countdownEmbeddedVersion, + scripts = { "assets/countdown.js"}, + stylesheets = { "assets/countdown.css"}, + resources = {"assets/smb_stage_clear.mp3"} + }) + + -- Embed custom settings into the document based on document-level settings + countdown_style(meta.countdown) + + -- Disable re-exporting if no-longer needed + needsToExportDependencies = false +end + +-- Function to parse an unnamed time string argument supplied +-- in the format of 'MM:SS' +local function parseTimeString(args) + -- Check if the input argument is provided and is of type string + if #args == 0 or type(args[1]) ~= "string" then + return nil + end + + -- Attempt to extract minutes and seconds from the time string + local minutes, seconds = args[1]:match("(%d+):(%d+)") + + -- Check if the pattern matching was successful + if isVariableEmpty(minutes) or isVariableEmpty(seconds) then + -- Log an error message if the format is incorrect + quarto.log.error( + "The quartodown time string must be in the format 'MM:SS'.\n" .. + "Please correct countdown timer with time string given as `" .. args[1] .. "`" + ) + -- Raise an assertion error to stop further execution (optional, depending on your requirements) + assert("true" == "false") + end + + -- Return a table containing minutes and seconds as numbers + return { minutes = tonumber(minutes), seconds = tonumber(seconds) } +end + +local function countdown(args, kwargs, meta) + local minutes, seconds + + -- Retrieve named time arguments and fallback on default values if missing + local arg_time = parseTimeString(args) + if isVariablePopulated(arg_time) then + minutes = arg_time.minutes + seconds = arg_time.seconds + if isVariablePopulated(tryOption(kwargs, "minutes")) or + isVariablePopulated(tryOption(kwargs, "seconds")) then + quarto.log.warning( + "Please do not specify `minutes` or `seconds` parameters" .. + "when using the time string format.") + end + else + minutes = tonumber(getOption(kwargs, "minutes", 1)) + seconds = tonumber(getOption(kwargs, "seconds", 0)) + end + + -- Calculate total time in seconds + local time = minutes * 60 + seconds + + -- Calculate minutes by dividing total time by 60 and rounding down + minutes = math.floor(time / 60) + + -- Calculate remaining seconds after extracting minutes + seconds = time - minutes * 60 + + -- Check if minutes is greater than or equal to 100 (the maximum possible for display) + if minutes >= 100 then + quarto.log.error("The number of minutes must be less than 100.") + assert("true" == "false") + end + + if needsToExportDependencies then + ensureHTMLDependency(meta) + end + + -- Retrieve the ID given by the user or attempt to create a unique ID by timestamp + local id = getOption(kwargs, "id", "timer_" .. pandoc.utils.sha1(tostring(os.time()))) + + -- Construct the 'class' attribute by appending "countdown" to the existing class (if any) + local class = getOption(kwargs, "class", "") + class = class ~= "" and "countdown " .. class or "countdown" + + -- Determine if a warning should be given + local warn_when = tonumber(getOption(kwargs, "warn_when", 0)) + + -- Retrieve and convert "update_every" attribute to a number, default to 1 if not present or invalid + local update_every = tonumber(getOption(kwargs, "update_every", 1)) + + -- Retrieve "blink_colon" attribute and set 'blink_colon' to true if it equals "true", otherwise false + local blink_colon = getOption(kwargs, "blink_colon", update_every > 1) == "true" + + -- Retrieve "start_immediately" attribute and set 'start_immediately' to true if it equals "true", otherwise false + local start_immediately = getOption(kwargs, "start_immediately", "false") == "true" + + -- Retrieve "play_sound" attribute as a string, default to "false" if not present + local play_sound = tryPlaySound(getOption(kwargs, "play_sound", "false")) + + -- Check to see if positional outcomes are set; if not, default both bottom and right to 0. + if isVariableEmpty(tryOption(kwargs, "top")) and + isVariableEmpty(tryOption(kwargs, "bottom")) then + kwargs["bottom"] = 0 + end + + if isVariableEmpty(tryOption(kwargs, "left")) and + isVariableEmpty(tryOption(kwargs, "right")) then + kwargs["right"] = 0 + end + + local style = structureCountdownCSSVars(kwargs) + + local rawHtml = table.concat({ + '
', + '\n
', + '\n ', + '\n ', + '\n
', + '\n ', + '', string.format("%02d", minutes), + ':', + '', string.format("%02d", seconds), '', + '\n
' + }) + + -- Return a new Div element with modified attributes + return pandoc.RawBlock("html", rawHtml) +end + + + +return { + ['countdown'] = countdown +} diff --git a/quarto/example.qmd b/quarto/example.qmd new file mode 100644 index 0000000..766c0f1 --- /dev/null +++ b/quarto/example.qmd @@ -0,0 +1,44 @@ +--- +title: "Countdown Example" +countdown: + font_size: 4rem + color_background: "lightblue" +format: revealjs +--- + +## A note before we begin... + +:::{.callout-important} +Please make sure you are on Quarto version 1.4.545 or greater. +::: + + +## Default timer + +{{< countdown >}} + +## Time string + +{{< countdown "1:23" >}} + +{{< countdown "12:53" minutes="12" seconds="53" left=0 bottom=0 >}} + +## Non-standard position + +{{< countdown minutes=1 top=0 right=0 >}} + +{{< countdown minutes=2 bottom=0 left=0 >}} + +{{< countdown minutes=3 bottom=0 right=0 >}} + +## Add class + +{{< countdown minutes=0 seconds=5 class="testing" >}} + +## Sound + +{{< countdown minutes=0 seconds=1 play_sound=true >}} + +## Custom Sound + +{{< countdown minutes=0 seconds=1 play_sound='test-beep.mp3' >}} \ No newline at end of file diff --git a/r/inst/countdown/countdown.js b/r/inst/countdown/countdown.js index 76379e2..27f64b2 100644 --- a/r/inst/countdown/countdown.js +++ b/r/inst/countdown/countdown.js @@ -377,7 +377,7 @@ class CountdownTimer { playSound () { let url = this.play_sound - if (!url) return + if (!url || url === "false") return if (typeof url === 'boolean') { const src = this.src_location ? this.src_location.replace('/countdown.js', '')