diff options
author | David Thompson <dthompson2@worcester.edu> | 2024-06-24 13:49:08 -0400 |
---|---|---|
committer | David Thompson <dthompson2@worcester.edu> | 2024-06-24 13:52:17 -0400 |
commit | d283f7e661e14d6ae1881fe803e5b4f1ed0689ff (patch) | |
tree | 84d3811c6dcb7d7f02aecadad7b2dfacce83bd4f /2024-06-18-guix-social/reveal.js/plugin/notes/speaker-view.html | |
parent | 3d9dcd3099fb252fa35697148fbbd541eb9eecc9 (diff) |
Diffstat (limited to '2024-06-18-guix-social/reveal.js/plugin/notes/speaker-view.html')
-rw-r--r-- | 2024-06-18-guix-social/reveal.js/plugin/notes/speaker-view.html | 898 |
1 files changed, 898 insertions, 0 deletions
diff --git a/2024-06-18-guix-social/reveal.js/plugin/notes/speaker-view.html b/2024-06-18-guix-social/reveal.js/plugin/notes/speaker-view.html new file mode 100644 index 0000000..55bc01f --- /dev/null +++ b/2024-06-18-guix-social/reveal.js/plugin/notes/speaker-view.html @@ -0,0 +1,898 @@ +<!-- + NOTE: You need to build the notes plugin after making changes to this file. +--> +<html lang="en"> + <head> + <meta charset="utf-8"> + + <title>reveal.js - Speaker View</title> + + <style> + body { + font-family: Helvetica; + font-size: 18px; + } + + #current-slide, + #upcoming-slide, + #speaker-controls { + padding: 6px; + box-sizing: border-box; + -moz-box-sizing: border-box; + } + + #current-slide iframe, + #upcoming-slide iframe { + width: 100%; + height: 100%; + border: 1px solid #ddd; + } + + #current-slide .label, + #upcoming-slide .label { + position: absolute; + top: 10px; + left: 10px; + z-index: 2; + } + + #connection-status { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 20; + padding: 30% 20% 20% 20%; + font-size: 18px; + color: #222; + background: #fff; + text-align: center; + box-sizing: border-box; + line-height: 1.4; + } + + .overlay-element { + height: 34px; + line-height: 34px; + padding: 0 10px; + text-shadow: none; + background: rgba( 220, 220, 220, 0.8 ); + color: #222; + font-size: 14px; + } + + .overlay-element.interactive:hover { + background: rgba( 220, 220, 220, 1 ); + } + + #current-slide { + position: absolute; + width: 60%; + height: 100%; + top: 0; + left: 0; + padding-right: 0; + } + + #upcoming-slide { + position: absolute; + width: 40%; + height: 40%; + right: 0; + top: 0; + } + + /* Speaker controls */ + #speaker-controls { + position: absolute; + top: 40%; + right: 0; + width: 40%; + height: 60%; + overflow: auto; + font-size: 18px; + } + + .speaker-controls-time.hidden, + .speaker-controls-notes.hidden { + display: none; + } + + .speaker-controls-time .label, + .speaker-controls-pace .label, + .speaker-controls-notes .label { + text-transform: uppercase; + font-weight: normal; + font-size: 0.66em; + color: #666; + margin: 0; + } + + .speaker-controls-time, .speaker-controls-pace { + border-bottom: 1px solid rgba( 200, 200, 200, 0.5 ); + margin-bottom: 10px; + padding: 10px 16px; + padding-bottom: 20px; + cursor: pointer; + } + + .speaker-controls-time .reset-button { + opacity: 0; + float: right; + color: #666; + text-decoration: none; + } + .speaker-controls-time:hover .reset-button { + opacity: 1; + } + + .speaker-controls-time .timer, + .speaker-controls-time .clock { + width: 50%; + } + + .speaker-controls-time .timer, + .speaker-controls-time .clock, + .speaker-controls-time .pacing .hours-value, + .speaker-controls-time .pacing .minutes-value, + .speaker-controls-time .pacing .seconds-value { + font-size: 1.9em; + } + + .speaker-controls-time .timer { + float: left; + } + + .speaker-controls-time .clock { + float: right; + text-align: right; + } + + .speaker-controls-time span.mute { + opacity: 0.3; + } + + .speaker-controls-time .pacing-title { + margin-top: 5px; + } + + .speaker-controls-time .pacing.ahead { + color: blue; + } + + .speaker-controls-time .pacing.on-track { + color: green; + } + + .speaker-controls-time .pacing.behind { + color: red; + } + + .speaker-controls-notes { + padding: 10px 16px; + } + + .speaker-controls-notes .value { + margin-top: 5px; + line-height: 1.4; + font-size: 1.2em; + } + + /* Layout selector */ + #speaker-layout { + position: absolute; + top: 10px; + right: 10px; + color: #222; + z-index: 10; + } + #speaker-layout select { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + border: 0; + box-shadow: 0; + cursor: pointer; + opacity: 0; + + font-size: 1em; + background-color: transparent; + + -moz-appearance: none; + -webkit-appearance: none; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + } + + #speaker-layout select:focus { + outline: none; + box-shadow: none; + } + + .clear { + clear: both; + } + + /* Speaker layout: Wide */ + body[data-speaker-layout="wide"] #current-slide, + body[data-speaker-layout="wide"] #upcoming-slide { + width: 50%; + height: 45%; + padding: 6px; + } + + body[data-speaker-layout="wide"] #current-slide { + top: 0; + left: 0; + } + + body[data-speaker-layout="wide"] #upcoming-slide { + top: 0; + left: 50%; + } + + body[data-speaker-layout="wide"] #speaker-controls { + top: 45%; + left: 0; + width: 100%; + height: 50%; + font-size: 1.25em; + } + + /* Speaker layout: Tall */ + body[data-speaker-layout="tall"] #current-slide, + body[data-speaker-layout="tall"] #upcoming-slide { + width: 45%; + height: 50%; + padding: 6px; + } + + body[data-speaker-layout="tall"] #current-slide { + top: 0; + left: 0; + } + + body[data-speaker-layout="tall"] #upcoming-slide { + top: 50%; + left: 0; + } + + body[data-speaker-layout="tall"] #speaker-controls { + padding-top: 40px; + top: 0; + left: 45%; + width: 55%; + height: 100%; + font-size: 1.25em; + } + + /* Speaker layout: Notes only */ + body[data-speaker-layout="notes-only"] #current-slide, + body[data-speaker-layout="notes-only"] #upcoming-slide { + display: none; + } + + body[data-speaker-layout="notes-only"] #speaker-controls { + padding-top: 40px; + top: 0; + left: 0; + width: 100%; + height: 100%; + font-size: 1.25em; + } + + @media screen and (max-width: 1080px) { + body[data-speaker-layout="default"] #speaker-controls { + font-size: 16px; + } + } + + @media screen and (max-width: 900px) { + body[data-speaker-layout="default"] #speaker-controls { + font-size: 14px; + } + } + + @media screen and (max-width: 800px) { + body[data-speaker-layout="default"] #speaker-controls { + font-size: 12px; + } + } + + </style> + </head> + + <body> + + <div id="connection-status">Loading speaker view...</div> + + <div id="current-slide"></div> + <div id="upcoming-slide"><span class="overlay-element label">Upcoming</span></div> + <div id="speaker-controls"> + <div class="speaker-controls-time"> + <h4 class="label">Time <span class="reset-button">Click to Reset</span></h4> + <div class="clock"> + <span class="clock-value">0:00 AM</span> + </div> + <div class="timer"> + <span class="hours-value">00</span><span class="minutes-value">:00</span><span class="seconds-value">:00</span> + </div> + <div class="clear"></div> + + <h4 class="label pacing-title" style="display: none">Pacing – Time to finish current slide</h4> + <div class="pacing" style="display: none"> + <span class="hours-value">00</span><span class="minutes-value">:00</span><span class="seconds-value">:00</span> + </div> + </div> + + <div class="speaker-controls-notes hidden"> + <h4 class="label">Notes</h4> + <div class="value"></div> + </div> + </div> + <div id="speaker-layout" class="overlay-element interactive"> + <span class="speaker-layout-label"></span> + <select class="speaker-layout-dropdown"></select> + </div> + + <script> + + (function() { + + var notes, + notesValue, + currentState, + currentSlide, + upcomingSlide, + layoutLabel, + layoutDropdown, + pendingCalls = {}, + lastRevealApiCallId = 0, + connected = false + + var connectionStatus = document.querySelector( '#connection-status' ); + + var SPEAKER_LAYOUTS = { + 'default': 'Default', + 'wide': 'Wide', + 'tall': 'Tall', + 'notes-only': 'Notes only' + }; + + setupLayout(); + + let openerOrigin; + + try { + openerOrigin = window.opener.location.origin; + } + catch ( error ) { console.warn( error ) } + + // In order to prevent XSS, the speaker view will only run if its + // opener has the same origin as itself + if( window.location.origin !== openerOrigin ) { + connectionStatus.innerHTML = 'Cross origin error.<br>The speaker window can only be opened from the same origin.'; + return; + } + + var connectionTimeout = setTimeout( function() { + connectionStatus.innerHTML = 'Error connecting to main window.<br>Please try closing and reopening the speaker view.'; + }, 5000 ); + + window.addEventListener( 'message', function( event ) { + + // Validate the origin of all messages to avoid parsing messages + // that aren't meant for us. Ignore when running off file:// so + // that the speaker view continues to work without a web server. + if( window.location.origin !== event.origin && window.location.origin !== 'file://' ) { + return + } + + clearTimeout( connectionTimeout ); + connectionStatus.style.display = 'none'; + + var data = JSON.parse( event.data ); + + // The overview mode is only useful to the reveal.js instance + // where navigation occurs so we don't sync it + if( data.state ) delete data.state.overview; + + // Messages sent by the notes plugin inside of the main window + if( data && data.namespace === 'reveal-notes' ) { + if( data.type === 'connect' ) { + handleConnectMessage( data ); + } + else if( data.type === 'state' ) { + handleStateMessage( data ); + } + else if( data.type === 'return' ) { + pendingCalls[data.callId](data.result); + delete pendingCalls[data.callId]; + } + } + // Messages sent by the reveal.js inside of the current slide preview + else if( data && data.namespace === 'reveal' ) { + if( /ready/.test( data.eventName ) ) { + // Send a message back to notify that the handshake is complete + window.opener.postMessage( JSON.stringify({ namespace: 'reveal-notes', type: 'connected'} ), '*' ); + } + else if( /slidechanged|fragmentshown|fragmenthidden|paused|resumed/.test( data.eventName ) && currentState !== JSON.stringify( data.state ) ) { + + dispatchStateToMainWindow( data.state ); + + } + } + + } ); + + /** + * Updates the presentation in the main window to match the state + * of the presentation in the notes window. + */ + const dispatchStateToMainWindow = debounce(( state ) => { + window.opener.postMessage( JSON.stringify({ method: 'setState', args: [ state ]} ), '*' ); + }, 500); + + /** + * Asynchronously calls the Reveal.js API of the main frame. + */ + function callRevealApi( methodName, methodArguments, callback ) { + + var callId = ++lastRevealApiCallId; + pendingCalls[callId] = callback; + window.opener.postMessage( JSON.stringify( { + namespace: 'reveal-notes', + type: 'call', + callId: callId, + methodName: methodName, + arguments: methodArguments + } ), '*' ); + + } + + /** + * Called when the main window is trying to establish a + * connection. + */ + function handleConnectMessage( data ) { + + if( connected === false ) { + connected = true; + + setupIframes( data ); + setupKeyboard(); + setupNotes(); + setupTimer(); + setupHeartbeat(); + } + + } + + /** + * Called when the main window sends an updated state. + */ + function handleStateMessage( data ) { + + // Store the most recently set state to avoid circular loops + // applying the same state + currentState = JSON.stringify( data.state ); + + // No need for updating the notes in case of fragment changes + if ( data.notes ) { + notes.classList.remove( 'hidden' ); + notesValue.style.whiteSpace = data.whitespace; + if( data.markdown ) { + notesValue.innerHTML = marked( data.notes ); + } + else { + notesValue.innerHTML = data.notes; + } + } + else { + notes.classList.add( 'hidden' ); + } + + // Update the note slides + currentSlide.contentWindow.postMessage( JSON.stringify({ method: 'setState', args: [ data.state ] }), '*' ); + upcomingSlide.contentWindow.postMessage( JSON.stringify({ method: 'setState', args: [ data.state ] }), '*' ); + upcomingSlide.contentWindow.postMessage( JSON.stringify({ method: 'next' }), '*' ); + + } + + // Limit to max one state update per X ms + handleStateMessage = debounce( handleStateMessage, 200 ); + + /** + * Forward keyboard events to the current slide window. + * This enables keyboard events to work even if focus + * isn't set on the current slide iframe. + * + * Block F5 default handling, it reloads and disconnects + * the speaker notes window. + */ + function setupKeyboard() { + + document.addEventListener( 'keydown', function( event ) { + if( event.keyCode === 116 || ( event.metaKey && event.keyCode === 82 ) ) { + event.preventDefault(); + return false; + } + currentSlide.contentWindow.postMessage( JSON.stringify({ method: 'triggerKey', args: [ event.keyCode ] }), '*' ); + } ); + + } + + /** + * Creates the preview iframes. + */ + function setupIframes( data ) { + + var params = [ + 'receiver', + 'progress=false', + 'history=false', + 'transition=none', + 'autoSlide=0', + 'backgroundTransition=none' + ].join( '&' ); + + var urlSeparator = /\?/.test(data.url) ? '&' : '?'; + var hash = '#/' + data.state.indexh + '/' + data.state.indexv; + var currentURL = data.url + urlSeparator + params + '&scrollActivationWidth=false&postMessageEvents=true' + hash; + var upcomingURL = data.url + urlSeparator + params + '&scrollActivationWidth=false&controls=false' + hash; + + currentSlide = document.createElement( 'iframe' ); + currentSlide.setAttribute( 'width', 1280 ); + currentSlide.setAttribute( 'height', 1024 ); + currentSlide.setAttribute( 'src', currentURL ); + document.querySelector( '#current-slide' ).appendChild( currentSlide ); + + upcomingSlide = document.createElement( 'iframe' ); + upcomingSlide.setAttribute( 'width', 640 ); + upcomingSlide.setAttribute( 'height', 512 ); + upcomingSlide.setAttribute( 'src', upcomingURL ); + document.querySelector( '#upcoming-slide' ).appendChild( upcomingSlide ); + + } + + /** + * Setup the notes UI. + */ + function setupNotes() { + + notes = document.querySelector( '.speaker-controls-notes' ); + notesValue = document.querySelector( '.speaker-controls-notes .value' ); + + } + + /** + * We send out a heartbeat at all times to ensure we can + * reconnect with the main presentation window after reloads. + */ + function setupHeartbeat() { + + setInterval( () => { + window.opener.postMessage( JSON.stringify({ namespace: 'reveal-notes', type: 'heartbeat'} ), '*' ); + }, 1000 ); + + } + + function getTimings( callback ) { + + callRevealApi( 'getSlidesAttributes', [], function ( slideAttributes ) { + callRevealApi( 'getConfig', [], function ( config ) { + var totalTime = config.totalTime; + var minTimePerSlide = config.minimumTimePerSlide || 0; + var defaultTiming = config.defaultTiming; + if ((defaultTiming == null) && (totalTime == null)) { + callback(null); + return; + } + // Setting totalTime overrides defaultTiming + if (totalTime) { + defaultTiming = 0; + } + var timings = []; + for ( var i in slideAttributes ) { + var slide = slideAttributes[ i ]; + var timing = defaultTiming; + if( slide.hasOwnProperty( 'data-timing' )) { + var t = slide[ 'data-timing' ]; + timing = parseInt(t); + if( isNaN(timing) ) { + console.warn("Could not parse timing '" + t + "' of slide " + i + "; using default of " + defaultTiming); + timing = defaultTiming; + } + } + timings.push(timing); + } + if ( totalTime ) { + // After we've allocated time to individual slides, we summarize it and + // subtract it from the total time + var remainingTime = totalTime - timings.reduce( function(a, b) { return a + b; }, 0 ); + // The remaining time is divided by the number of slides that have 0 seconds + // allocated at the moment, giving the average time-per-slide on the remaining slides + var remainingSlides = (timings.filter( function(x) { return x == 0 }) ).length + var timePerSlide = Math.round( remainingTime / remainingSlides, 0 ) + // And now we replace every zero-value timing with that average + timings = timings.map( function(x) { return (x==0 ? timePerSlide : x) } ); + } + var slidesUnderMinimum = timings.filter( function(x) { return (x < minTimePerSlide) } ).length + if ( slidesUnderMinimum ) { + message = "The pacing time for " + slidesUnderMinimum + " slide(s) is under the configured minimum of " + minTimePerSlide + " seconds. Check the data-timing attribute on individual slides, or consider increasing the totalTime or minimumTimePerSlide configuration options (or removing some slides)."; + alert(message); + } + callback( timings ); + } ); + } ); + + } + + /** + * Return the number of seconds allocated for presenting + * all slides up to and including this one. + */ + function getTimeAllocated( timings, callback ) { + + callRevealApi( 'getSlidePastCount', [], function ( currentSlide ) { + var allocated = 0; + for (var i in timings.slice(0, currentSlide + 1)) { + allocated += timings[i]; + } + callback( allocated ); + } ); + + } + + /** + * Create the timer and clock and start updating them + * at an interval. + */ + function setupTimer() { + + var start = new Date(), + timeEl = document.querySelector( '.speaker-controls-time' ), + clockEl = timeEl.querySelector( '.clock-value' ), + hoursEl = timeEl.querySelector( '.hours-value' ), + minutesEl = timeEl.querySelector( '.minutes-value' ), + secondsEl = timeEl.querySelector( '.seconds-value' ), + pacingTitleEl = timeEl.querySelector( '.pacing-title' ), + pacingEl = timeEl.querySelector( '.pacing' ), + pacingHoursEl = pacingEl.querySelector( '.hours-value' ), + pacingMinutesEl = pacingEl.querySelector( '.minutes-value' ), + pacingSecondsEl = pacingEl.querySelector( '.seconds-value' ); + + var timings = null; + getTimings( function ( _timings ) { + + timings = _timings; + if (_timings !== null) { + pacingTitleEl.style.removeProperty('display'); + pacingEl.style.removeProperty('display'); + } + + // Update once directly + _updateTimer(); + + // Then update every second + setInterval( _updateTimer, 1000 ); + + } ); + + + function _resetTimer() { + + if (timings == null) { + start = new Date(); + _updateTimer(); + } + else { + // Reset timer to beginning of current slide + getTimeAllocated( timings, function ( slideEndTimingSeconds ) { + var slideEndTiming = slideEndTimingSeconds * 1000; + callRevealApi( 'getSlidePastCount', [], function ( currentSlide ) { + var currentSlideTiming = timings[currentSlide] * 1000; + var previousSlidesTiming = slideEndTiming - currentSlideTiming; + var now = new Date(); + start = new Date(now.getTime() - previousSlidesTiming); + _updateTimer(); + } ); + } ); + } + + } + + timeEl.addEventListener( 'click', function() { + _resetTimer(); + return false; + } ); + + function _displayTime( hrEl, minEl, secEl, time) { + + var sign = Math.sign(time) == -1 ? "-" : ""; + time = Math.abs(Math.round(time / 1000)); + var seconds = time % 60; + var minutes = Math.floor( time / 60 ) % 60 ; + var hours = Math.floor( time / ( 60 * 60 )) ; + hrEl.innerHTML = sign + zeroPadInteger( hours ); + if (hours == 0) { + hrEl.classList.add( 'mute' ); + } + else { + hrEl.classList.remove( 'mute' ); + } + minEl.innerHTML = ':' + zeroPadInteger( minutes ); + if (hours == 0 && minutes == 0) { + minEl.classList.add( 'mute' ); + } + else { + minEl.classList.remove( 'mute' ); + } + secEl.innerHTML = ':' + zeroPadInteger( seconds ); + } + + function _updateTimer() { + + var diff, hours, minutes, seconds, + now = new Date(); + + diff = now.getTime() - start.getTime(); + + clockEl.innerHTML = now.toLocaleTimeString( 'en-US', { hour12: true, hour: '2-digit', minute:'2-digit' } ); + _displayTime( hoursEl, minutesEl, secondsEl, diff ); + if (timings !== null) { + _updatePacing(diff); + } + + } + + function _updatePacing(diff) { + + getTimeAllocated( timings, function ( slideEndTimingSeconds ) { + var slideEndTiming = slideEndTimingSeconds * 1000; + + callRevealApi( 'getSlidePastCount', [], function ( currentSlide ) { + var currentSlideTiming = timings[currentSlide] * 1000; + var timeLeftCurrentSlide = slideEndTiming - diff; + if (timeLeftCurrentSlide < 0) { + pacingEl.className = 'pacing behind'; + } + else if (timeLeftCurrentSlide < currentSlideTiming) { + pacingEl.className = 'pacing on-track'; + } + else { + pacingEl.className = 'pacing ahead'; + } + _displayTime( pacingHoursEl, pacingMinutesEl, pacingSecondsEl, timeLeftCurrentSlide ); + } ); + } ); + } + + } + + /** + * Sets up the speaker view layout and layout selector. + */ + function setupLayout() { + + layoutDropdown = document.querySelector( '.speaker-layout-dropdown' ); + layoutLabel = document.querySelector( '.speaker-layout-label' ); + + // Render the list of available layouts + for( var id in SPEAKER_LAYOUTS ) { + var option = document.createElement( 'option' ); + option.setAttribute( 'value', id ); + option.textContent = SPEAKER_LAYOUTS[ id ]; + layoutDropdown.appendChild( option ); + } + + // Monitor the dropdown for changes + layoutDropdown.addEventListener( 'change', function( event ) { + + setLayout( layoutDropdown.value ); + + }, false ); + + // Restore any currently persisted layout + setLayout( getLayout() ); + + } + + /** + * Sets a new speaker view layout. The layout is persisted + * in local storage. + */ + function setLayout( value ) { + + var title = SPEAKER_LAYOUTS[ value ]; + + layoutLabel.innerHTML = 'Layout' + ( title ? ( ': ' + title ) : '' ); + layoutDropdown.value = value; + + document.body.setAttribute( 'data-speaker-layout', value ); + + // Persist locally + if( supportsLocalStorage() ) { + window.localStorage.setItem( 'reveal-speaker-layout', value ); + } + + } + + /** + * Returns the ID of the most recently set speaker layout + * or our default layout if none has been set. + */ + function getLayout() { + + if( supportsLocalStorage() ) { + var layout = window.localStorage.getItem( 'reveal-speaker-layout' ); + if( layout ) { + return layout; + } + } + + // Default to the first record in the layouts hash + for( var id in SPEAKER_LAYOUTS ) { + return id; + } + + } + + function supportsLocalStorage() { + + try { + localStorage.setItem('test', 'test'); + localStorage.removeItem('test'); + return true; + } + catch( e ) { + return false; + } + + } + + function zeroPadInteger( num ) { + + var str = '00' + parseInt( num ); + return str.substring( str.length - 2 ); + + } + + /** + * Limits the frequency at which a function can be called. + */ + function debounce( fn, ms ) { + + var lastTime = 0, + timeout; + + return function() { + + var args = arguments; + var context = this; + + clearTimeout( timeout ); + + var timeSinceLastCall = Date.now() - lastTime; + if( timeSinceLastCall > ms ) { + fn.apply( context, args ); + lastTime = Date.now(); + } + else { + timeout = setTimeout( function() { + fn.apply( context, args ); + lastTime = Date.now(); + }, ms - timeSinceLastCall ); + } + + } + + } + + })(); + + </script> + </body> +</html>
\ No newline at end of file |