| <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 SPEAKER_LAYOUTS = { |
| 'default': 'Default', |
| 'wide': 'Wide', |
| 'tall': 'Tall', |
| 'notes-only': 'Notes only' |
| }; |
| |
| setupLayout(); |
| |
| var connectionStatus = document.querySelector( '#connection-status' ); |
| 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 ) ) { |
| |
| window.opener.postMessage( JSON.stringify({ method: 'setState', args: [ data.state ]} ), '*' ); |
| |
| } |
| } |
| |
| } ); |
| |
| /** |
| * 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(); |
| } |
| |
| } |
| |
| /** |
| * 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' ); |
| |
| } |
| |
| 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> |