Преглед на файлове

Add slides feature

Example added to `exampleSite/content/slides/example-slides.md` and
linked from `exampleSite/content/talk/example-talk.md`.

Close #693
George Cushen преди 6 години
родител
ревизия
6faf695727

+ 1 - 0
.gitignore

@@ -1,3 +1,4 @@
 # IDE specific
 .idea/
 .vscode/
+resources/

+ 15 - 0
archetypes/slides.md

@@ -0,0 +1,15 @@
++++
+title = "Slides"
+
+[slides]
+# Choose a theme from https://github.com/hakimel/reveal.js#theming
+theme = "black"
++++
+
+# Title
+
+Author Name
+
+---
+
+## Slide 2

+ 31 - 0
assets/css/reveal.css

@@ -0,0 +1,31 @@
+/*************************************************
+ *  Reveal JS
+ **************************************************/
+
+/* This is a copy of MathJax's `.mjx-chtml` with font-family added to override `.reveal span`. */
+/* See https://github.com/hakimel/reveal.js/issues/1924 */
+.reveal span.mjx-chtml {
+  display: inline-block;
+  line-height: 0;
+  text-indent: 0;
+  text-align: left;
+  text-transform: none;
+  font-style: normal;
+  font-weight: normal;
+  font-size: 100%;
+  font-size-adjust: none;
+  letter-spacing: normal;
+  word-wrap: normal;
+  word-spacing: normal;
+  white-space: nowrap;
+  float: none;
+  direction: ltr;
+  max-width: none;
+  max-height: none;
+  min-width: 0;
+  min-height: 0;
+  border: 0;
+  margin: 0;
+  padding: 1px 0;
+  font-family: MJXc-TeX-math-I,MJXc-TeX-math-Ix,MJXc-TeX-math-Iw;
+}

+ 0 - 0
assets/css/reveal_custom.css


+ 159 - 0
exampleSite/content/slides/example-slides.md

@@ -0,0 +1,159 @@
++++
+title = "Slides"
+
+[slides]
+# Choose a theme from https://github.com/hakimel/reveal.js#theming
+theme = "black"
++++
+
+# Welcome to Slides
+
+[Academic](https://sourcethemes.com/academic/)
+
+---
+
+## Features
+
+- Efficiently write slides in Markdown
+- 3-in-1: Create, Present, and Publish your slides
+- Supports speaker notes
+- Mobile friendly slides
+
+---
+
+## Controls
+
+- Next: `Right Arrow` or `Space`
+- Previous: `Left Arrow`
+- Start: `Home`
+- Finish: `End`
+- Overview: `Esc`
+- Speaker notes: `S`
+- Fullscreen: `F`
+- Zoom: `Alt + Click`
+- [PDF Export](https://github.com/hakimel/reveal.js#pdf-export): `E`
+
+---
+
+## Code Highlighting
+
+Inline code: `variable`
+
+Code block:
+```python
+porridge = "blueberry"
+if porridge == "blueberry":
+    print("Eating...")
+```
+
+---
+
+## Math
+
+In-line math: $x + y = z$
+
+Block math:
+
+$$
+f\left( x \right) = \;\frac{{2\left( {x + 4} \right)\left( {x - 4} \right)}}{{\left( {x + 4} \right)\left( {x + 1} \right)}}
+$$
+
+---
+
+## Fragments
+
+Make content appear incrementally
+
+```
+{{%/* fragment */%}} One {{%/* /fragment */%}}
+{{%/* fragment */%}} **Two** {{%/* /fragment */%}}
+{{%/* fragment */%}} Three {{%/* /fragment */%}}
+```
+
+Press `Space` to play!
+
+{{% fragment %}} One {{% /fragment %}}
+{{% fragment %}} **Two** {{% /fragment %}}
+{{% fragment %}} Three {{% /fragment %}}
+
+---
+
+A fragment can accept two optional parameters:
+
+- `class`: use a custom style (requires definition in custom CSS)
+- `weight`: sets the order in which a fragment appears
+
+---
+
+## Speaker Notes
+
+Add speaker notes to your presentation
+
+```markdown
+{{%/* speaker_note */%}}
+- Only the speaker can read these notes
+- Press `S` key to view
+{{%/* /speaker_note */%}}
+```
+
+Press the `S` key to view the speaker notes!
+
+{{< speaker_note >}}
+- Only the speaker can read these notes
+- Press `S` key to view
+{{< /speaker_note >}}
+
+---
+
+## Themes
+
+- black: Black background, white text, blue links (default)
+- white: White background, black text, blue links
+- league: Gray background, white text, blue links
+- beige: Beige background, dark text, brown links
+- sky: Blue background, thin dark text, blue links
+
+---
+
+- night: Black background, thick white text, orange links
+- serif: Cappuccino background, gray text, brown links
+- simple: White background, black text, blue links
+- solarized: Cream-colored background, dark green text, blue links
+
+---
+
+{{< slide background-image="/img/boards.jpg" >}}
+
+## Custom Slide
+
+Customize the slide style and background
+
+```markdown
+{{</* slide background-image="/img/boards.jpg" */>}}
+{{</* slide background-color="#0000FF" */>}}
+{{</* slide class="my-style" */>}}
+```
+
+---
+
+## Custom CSS Example
+
+Let's make headers navy colored.
+
+Create `assets/css/reveal_custom.css` with:
+
+```css
+.reveal section h1,
+.reveal section h2,
+.reveal section h3 {
+  color: navy;
+}
+```
+
+---
+
+# Questions?
+
+[Ask](https://discourse.gohugo.io)
+
+[Documentation](https://sourcethemes.com/academic/docs/)

+ 12 - 2
exampleSite/content/talk/example-talk.md

@@ -38,7 +38,7 @@ tags = []
 
 # Links (optional).
 url_pdf = ""
-url_slides = ""
+url_slides = "slides/example-slides"
 url_video = ""
 url_code = ""
 
@@ -56,4 +56,14 @@ caption = "My caption :smile:"
 
 +++
 
-Embed your slides or video here using [shortcodes](https://sourcethemes.com/academic/post/writing-markdown-latex/). Further details can easily be added using *Markdown* and $\rm \LaTeX$ math code.
+{{% alert note %}}
+Click on the **Slides** button above to view the built-in slides feature.
+{{% /alert %}}
+
+Slides can be added in a few ways:
+
+- **Create** slides using Academic's *Slides* feature and link using `url_slides` parameter in the front matter of the talk file
+- **Upload** an existing slide deck to `static/` and link using `url_slides` parameter in the front matter of the talk file
+- **Embed** your slides (e.g. Google Slides) or presentation video on this page using [shortcodes](https://sourcethemes.com/academic/docs/writing-markdown-latex/).
+
+Further talk details can easily be added to this page using *Markdown* and $\rm \LaTeX$ math code.

+ 25 - 0
layouts/partials/slides.html

@@ -0,0 +1,25 @@
+<div class="reveal">
+  <div class="slides">
+    {{/* Loop over each page. */}}
+    {{ range . }}
+    {{ if ne (len .Content) 0 }}
+
+    {{/* Must remove `<hr />` generated by Blackfriday footnotes as conflicts with slide delimiter. */}}
+    {{ $content := replace .Content "<div class=\"footnotes\">\n\n<hr />" "<div class=\"footnotes\">" }}
+
+    {{/* Let `<hr />` delimit slides. */}}
+    {{ range (split $content "<hr />") }}
+
+    {{/* Each `<section>` defines a new slide. */}}
+    {{/* Only begin new slide `<section>` if not already added by custom `slide` shortcode. */}}
+    {{ if not (in . "data-noprocess") }}
+    <section>
+    {{ end }}
+      {{ . | safeHTML }}
+    </section>
+    {{ end }}
+
+    {{ end }}
+    {{ end }}
+  </div>
+</div>

+ 5 - 5
layouts/partials/talk_links.html

@@ -2,22 +2,22 @@
 {{ $ := .content }}
 
 {{ with $.Params.url_pdf }}
-<a class="btn btn-outline-primary my-1 mr-1{{ if $is_list }} btn-sm{{end}}" href="{{ . | relURL }}">
+<a class="btn btn-outline-primary my-1 mr-1{{ if $is_list }} btn-sm{{end}}" href="{{ . | relURL }}" target="_blank" rel="noopener">
   {{ i18n "btn_pdf" }}
 </a>
 {{ end }}
 {{ with $.Params.url_slides }}
-<a class="btn btn-outline-primary my-1 mr-1{{ if $is_list }} btn-sm{{end}}" href="{{ . | relURL }}">
+<a class="btn btn-outline-primary my-1 mr-1{{ if $is_list }} btn-sm{{end}}" href="{{ . | relURL }}" target="_blank">
   {{ i18n "btn_slides" }}
 </a>
 {{ end }}
 {{ with $.Params.url_video }}
-<a class="btn btn-outline-primary my-1 mr-1{{ if $is_list }} btn-sm{{end}}" href="{{ . | relURL }}">
+<a class="btn btn-outline-primary my-1 mr-1{{ if $is_list }} btn-sm{{end}}" href="{{ . | relURL }}" target="_blank" rel="noopener">
   {{ i18n "btn_video" }}
 </a>
 {{ end }}
 {{ with $.Params.url_code }}
-<a class="btn btn-outline-primary my-1 mr-1{{ if $is_list }} btn-sm{{end}}" href="{{ . | relURL }}">
+<a class="btn btn-outline-primary my-1 mr-1{{ if $is_list }} btn-sm{{end}}" href="{{ . | relURL }}" target="_blank" rel="noopener">
   {{ i18n "btn_code" }}
 </a>
 {{ end }}
@@ -31,7 +31,7 @@
   {{ end }}
 {{ end }}
 {{ range $.Params.url_custom }}
-<a class="btn btn-outline-primary my-1 mr-1{{ if $is_list }} btn-sm{{end}}" href="{{ .url | relURL }}">
+<a class="btn btn-outline-primary my-1 mr-1{{ if $is_list }} btn-sm{{end}}" href="{{ .url | relURL }}" target="_blank" rel="noopener">
   {{ .name }}
 </a>
 {{ end }}

+ 3 - 0
layouts/shortcodes/fragment.html

@@ -0,0 +1,3 @@
+<span class="fragment {{ .Get "class" }}" {{ with .Get "weight" }}data-fragment-index="{{ . }}"{{ end }}>
+  {{ .Inner }}
+</span>

+ 6 - 0
layouts/shortcodes/slide.html

@@ -0,0 +1,6 @@
+{{ $non_data_attributes := slice "id" "class" }}
+<section data-noprocess data-shortcode-slide
+  {{ range $key, $value := $.Params }}
+      {{ $attribute := cond (in $non_data_attributes $key) $key (delimit (slice "data" $key) "-") }}
+      {{ $attribute | safeHTMLAttr }}="{{ $value }}"
+  {{ end }}>

+ 3 - 0
layouts/shortcodes/speaker_note.html

@@ -0,0 +1,3 @@
+<aside class="notes">
+  {{ .Inner | markdownify }}
+</aside>

+ 66 - 0
layouts/slides/baseof.html

@@ -0,0 +1,66 @@
+<!DOCTYPE html>
+<html lang="{{ .Site.LanguageCode | default "en-us" }}">
+<head>
+  {{ $css := .Site.Data.assets.css }}
+  {{ $cdn_url_reveal := "https://cdnjs.cloudflare.com/ajax/libs/reveal.js/3.7.0" }}
+
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <meta http-equiv="X-UA-Compatible" content="IE=edge">
+  <meta name="generator" content="Source Themes Academic {{ .Site.Data.academic.version }}">
+
+  <link rel="manifest" href="{{ "site.webmanifest" | relURL }}">
+  <link rel="icon" type="image/png" href="{{ "/img/icon.png" | relURL }}">
+  <link rel="apple-touch-icon" type="image/png" href="{{ "/img/icon-192.png" | relURL }}">
+
+  <link rel="canonical" href="{{ .Permalink }}">
+
+  <title>{{ .Title }} | {{ .Site.Title }}</title>
+
+  <link rel="stylesheet" href="{{ $cdn_url_reveal }}/css/reveal.min.css">
+  {{- $theme := $.Param "slides.theme" | default "black" -}}
+  <link rel="stylesheet" href="{{ $cdn_url_reveal }}/css/theme/{{ $theme }}.min.css">
+
+  {{ printf "<link rel=\"stylesheet\" href=\"%s\" crossorigin=\"anonymous\">" (printf $css.highlight.url $css.highlight.version "dracula") | safeHTML }}
+
+  {{ $css := resources.Get "css/reveal.css" }}
+  {{ $css_custom := resources.Get "css/reveal_custom.css" }}
+  {{ $style := slice $css $css_custom | resources.Concat "css/reveal_custom.css" | resources.Minify }}
+  <link rel="stylesheet" href="{{ $style.RelPermalink }}">
+
+  <!-- Printing and PDF exports -->
+  <script>
+    var link = document.createElement( 'link' );
+    link.rel = 'stylesheet';
+    link.type = 'text/css';
+    link.href = window.location.search.match( /print-pdf/gi ) ? '{{ $cdn_url_reveal }}/css/print/pdf.css' : '{{ $cdn_url_reveal }}/css/print/paper.css';
+    document.getElementsByTagName( 'head' )[0].appendChild( link );
+  </script>
+
+</head>
+<body>
+
+  {{ block "main" . }}{{ end }}
+
+  <script src="{{ $cdn_url_reveal }}/lib/js/head.min.js"></script>
+  <script src="{{ $cdn_url_reveal }}/js/reveal.min.js"></script>
+
+  <script>
+    window.revealPlugins = { dependencies: [
+      { src: '{{ $cdn_url_reveal }}/plugin/markdown/marked.js', condition: function() { return !!document.querySelector( '[data-markdown]' ); } },
+      { src: '{{ $cdn_url_reveal }}/plugin/markdown/markdown.js', condition: function() { return !!document.querySelector( '[data-markdown]' ); } },
+      { src: '{{ $cdn_url_reveal }}/plugin/highlight/highlight.js', async: true, callback: function() { hljs.initHighlightingOnLoad(); } },
+      { src: '{{ $cdn_url_reveal }}/plugin/zoom-js/zoom.js', async: true },
+      { src: '{{ $cdn_url_reveal }}/plugin/math/math.js', async: true },
+      { src: '{{ $cdn_url_reveal }}/plugin/print-pdf/print-pdf.js', async: true },
+      // Notes plugin must be loaded locally as CDN is missing `notes.html`.
+      { src: '{{ "js/vendor/reveal.js/plugin/notes/notes.js" | relURL }}', async: true }
+    ]};
+
+    let revealDefaults = { center: true, controls: true, history: true, progress: true, transition: 'slide', mouseWheel: true };
+    let revealOptions = Object.assign({}, revealDefaults, revealPlugins);
+    Reveal.initialize(revealOptions);
+  </script>
+
+</body>
+</html>

+ 0 - 0
layouts/slides/list.html


+ 3 - 0
layouts/slides/single.html

@@ -0,0 +1,3 @@
+{{ define "main" }}
+{{ partial "slides" (union (slice .Page) .Pages) }}
+{{ end }}

+ 792 - 0
static/js/vendor/reveal.js/plugin/notes/notes.html

@@ -0,0 +1,792 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+
+  <title>Speaker Notes</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 src="../../plugin/markdown/marked.js"></script>
+<script>
+
+  (function() {
+
+    var notes,
+      notesValue,
+      currentState,
+      currentSlide,
+      upcomingSlide,
+      layoutLabel,
+      layoutDropdown,
+      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 );
+        }
+      }
+      // 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 ]} ), '*' );
+
+        }
+      }
+
+    } );
+
+    /**
+     * 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() {
+
+      var slides = Reveal.getSlides();
+      var defaultTiming = Reveal.getConfig().defaultTiming;
+      if (defaultTiming == null) {
+        return null;
+      }
+      var timings = [];
+      for ( var i in slides ) {
+        var slide = slides[i];
+        var timing = defaultTiming;
+        if( slide.hasAttribute( 'data-timing' )) {
+          var t = slide.getAttribute( '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);
+      }
+      return timings;
+
+    }
+
+    /**
+     * Return the number of seconds allocated for presenting
+     * all slides up to and including this one.
+     */
+    function getTimeAllocated(timings) {
+
+      var slides = Reveal.getSlides();
+      var allocated = 0;
+      var currentSlide = Reveal.getSlidePastCount();
+      for (var i in slides.slice(0, currentSlide + 1)) {
+        allocated += timings[i];
+      }
+      return 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 = getTimings();
+      if (timings !== null) {
+        pacingTitleEl.style.removeProperty('display');
+        pacingEl.style.removeProperty('display');
+      }
+
+      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) {
+
+        var slideEndTiming = getTimeAllocated(timings) * 1000;
+        var currentSlide = Reveal.getSlidePastCount();
+        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 );
+
+      }
+
+      // Update once directly
+      _updateTimer();
+
+      // Then update every second
+      setInterval( _updateTimer, 1000 );
+
+      function _resetTimer() {
+
+        if (timings == null) {
+          start = new Date();
+        }
+        else {
+          // Reset timer to beginning of current slide
+          var slideEndTiming = getTimeAllocated(timings) * 1000;
+          var currentSlide = Reveal.getSlidePastCount();
+          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;
+      } );
+
+    }
+
+    /**
+     * 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>

+ 147 - 0
static/js/vendor/reveal.js/plugin/notes/notes.js

@@ -0,0 +1,147 @@
+/**
+ * Handles opening of and synchronization with the reveal.js
+ * notes window.
+ *
+ * Handshake process:
+ * 1. This window posts 'connect' to notes window
+ *    - Includes URL of presentation to show
+ * 2. Notes window responds with 'connected' when it is available
+ * 3. This window proceeds to send the current presentation state
+ *    to the notes window
+ */
+var RevealNotes = (function() {
+
+  function openNotes( notesFilePath ) {
+
+    if( !notesFilePath ) {
+      var jsFileLocation = document.querySelector('script[src$="notes.js"]').src;  // this js file path
+      jsFileLocation = jsFileLocation.replace(/notes\.js(\?.*)?$/, '');   // the js folder path
+      notesFilePath = jsFileLocation + 'notes.html';
+    }
+
+    var notesPopup = window.open( notesFilePath, 'reveal.js - Notes', 'width=1100,height=700' );
+
+    if( !notesPopup ) {
+      alert( 'Speaker view popup failed to open. Please make sure popups are allowed and reopen the speaker view.' );
+      return;
+    }
+
+    // Allow popup window access to Reveal API
+    notesPopup.Reveal = window.Reveal;
+
+    /**
+     * Connect to the notes window through a postmessage handshake.
+     * Using postmessage enables us to work in situations where the
+     * origins differ, such as a presentation being opened from the
+     * file system.
+     */
+    function connect() {
+      // Keep trying to connect until we get a 'connected' message back
+      var connectInterval = setInterval( function() {
+        notesPopup.postMessage( JSON.stringify( {
+          namespace: 'reveal-notes',
+          type: 'connect',
+          url: window.location.protocol + '//' + window.location.host + window.location.pathname + window.location.search,
+          state: Reveal.getState()
+        } ), '*' );
+      }, 500 );
+
+      window.addEventListener( 'message', function( event ) {
+        var data = JSON.parse( event.data );
+        if( data && data.namespace === 'reveal-notes' && data.type === 'connected' ) {
+          clearInterval( connectInterval );
+          onConnected();
+        }
+      } );
+    }
+
+    /**
+     * Posts the current slide data to the notes window
+     */
+    function post( event ) {
+
+      var slideElement = Reveal.getCurrentSlide(),
+        notesElement = slideElement.querySelector( 'aside.notes' ),
+        fragmentElement = slideElement.querySelector( '.current-fragment' );
+
+      var messageData = {
+        namespace: 'reveal-notes',
+        type: 'state',
+        notes: '',
+        markdown: false,
+        whitespace: 'normal',
+        state: Reveal.getState()
+      };
+
+      // Look for notes defined in a slide attribute
+      if( slideElement.hasAttribute( 'data-notes' ) ) {
+        messageData.notes = slideElement.getAttribute( 'data-notes' );
+        messageData.whitespace = 'pre-wrap';
+      }
+
+      // Look for notes defined in a fragment
+      if( fragmentElement ) {
+        var fragmentNotes = fragmentElement.querySelector( 'aside.notes' );
+        if( fragmentNotes ) {
+          notesElement = fragmentNotes;
+        }
+        else if( fragmentElement.hasAttribute( 'data-notes' ) ) {
+          messageData.notes = fragmentElement.getAttribute( 'data-notes' );
+          messageData.whitespace = 'pre-wrap';
+
+          // In case there are slide notes
+          notesElement = null;
+        }
+      }
+
+      // Look for notes defined in an aside element
+      if( notesElement ) {
+        messageData.notes = notesElement.innerHTML;
+        messageData.markdown = typeof notesElement.getAttribute( 'data-markdown' ) === 'string';
+      }
+
+      notesPopup.postMessage( JSON.stringify( messageData ), '*' );
+
+    }
+
+    /**
+     * Called once we have established a connection to the notes
+     * window.
+     */
+    function onConnected() {
+
+      // Monitor events that trigger a change in state
+      Reveal.addEventListener( 'slidechanged', post );
+      Reveal.addEventListener( 'fragmentshown', post );
+      Reveal.addEventListener( 'fragmenthidden', post );
+      Reveal.addEventListener( 'overviewhidden', post );
+      Reveal.addEventListener( 'overviewshown', post );
+      Reveal.addEventListener( 'paused', post );
+      Reveal.addEventListener( 'resumed', post );
+
+      // Post the initial state
+      post();
+
+    }
+
+    connect();
+
+  }
+
+  if( !/receiver/i.test( window.location.search ) ) {
+
+    // If the there's a 'notes' query set, open directly
+    if( window.location.search.match( /(\?|\&)notes/gi ) !== null ) {
+      openNotes();
+    }
+
+    // Open the notes when the 's' key is hit
+    Reveal.addKeyBinding({keyCode: 83, key: 'S', description: 'Speaker notes view'}, function() {
+      openNotes();
+    } );
+
+  }
+
+  return { open: openNotes };
+
+})();