diff options
Diffstat (limited to 'present/plugin/notes/speaker-view.html')
| -rw-r--r-- | present/plugin/notes/speaker-view.html | 891 | 
1 files changed, 891 insertions, 0 deletions
| diff --git a/present/plugin/notes/speaker-view.html b/present/plugin/notes/speaker-view.html new file mode 100644 index 0000000..8381453 --- /dev/null +++ b/present/plugin/notes/speaker-view.html @@ -0,0 +1,891 @@ +<!-- +	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 ) { + +					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 + '&postMessageEvents=true' + hash; +					var upcomingURL = data.url + urlSeparator + params + '&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 | 
