518 lines
16 KiB
JavaScript
518 lines
16 KiB
JavaScript
import {
|
|
extend,
|
|
queryAll,
|
|
closest,
|
|
getMimeTypeFromFile,
|
|
encodeRFC3986URI,
|
|
} from "../utils/util.js";
|
|
import { isMobile } from "../utils/device.js";
|
|
|
|
import fitty from "fitty";
|
|
|
|
/**
|
|
* Handles loading, unloading and playback of slide
|
|
* content such as images, videos and iframes.
|
|
*/
|
|
export default class SlideContent {
|
|
constructor(Reveal) {
|
|
this.Reveal = Reveal;
|
|
|
|
this.startEmbeddedIframe = this.startEmbeddedIframe.bind(this);
|
|
}
|
|
|
|
/**
|
|
* Should the given element be preloaded?
|
|
* Decides based on local element attributes and global config.
|
|
*
|
|
* @param {HTMLElement} element
|
|
*/
|
|
shouldPreload(element) {
|
|
// Prefer an explicit global preload setting
|
|
let preload = this.Reveal.getConfig().preloadIframes;
|
|
|
|
// If no global setting is available, fall back on the element's
|
|
// own preload setting
|
|
if (typeof preload !== "boolean") {
|
|
preload = element.hasAttribute("data-preload");
|
|
}
|
|
|
|
return preload;
|
|
}
|
|
|
|
/**
|
|
* Called when the given slide is within the configured view
|
|
* distance. Shows the slide element and loads any content
|
|
* that is set to load lazily (data-src).
|
|
*
|
|
* @param {HTMLElement} slide Slide to show
|
|
*/
|
|
load(slide, options = {}) {
|
|
// Show the slide element
|
|
slide.style.display = this.Reveal.getConfig().display;
|
|
|
|
// Media elements with data-src attributes
|
|
queryAll(
|
|
slide,
|
|
"img[data-src], video[data-src], audio[data-src], iframe[data-src]"
|
|
).forEach((element) => {
|
|
if (element.tagName !== "IFRAME" || this.shouldPreload(element)) {
|
|
element.setAttribute("src", element.getAttribute("data-src"));
|
|
element.setAttribute("data-lazy-loaded", "");
|
|
element.removeAttribute("data-src");
|
|
}
|
|
});
|
|
|
|
// Media elements with <source> children
|
|
queryAll(slide, "video, audio").forEach((media) => {
|
|
let sources = 0;
|
|
|
|
queryAll(media, "source[data-src]").forEach((source) => {
|
|
source.setAttribute("src", source.getAttribute("data-src"));
|
|
source.removeAttribute("data-src");
|
|
source.setAttribute("data-lazy-loaded", "");
|
|
sources += 1;
|
|
});
|
|
|
|
// Enable inline video playback in mobile Safari
|
|
if (isMobile && media.tagName === "VIDEO") {
|
|
media.setAttribute("playsinline", "");
|
|
}
|
|
|
|
// If we rewrote sources for this video/audio element, we need
|
|
// to manually tell it to load from its new origin
|
|
if (sources > 0) {
|
|
media.load();
|
|
}
|
|
});
|
|
|
|
// Show the corresponding background element
|
|
let background = slide.slideBackgroundElement;
|
|
if (background) {
|
|
background.style.display = "block";
|
|
|
|
let backgroundContent = slide.slideBackgroundContentElement;
|
|
let backgroundIframe = slide.getAttribute("data-background-iframe");
|
|
|
|
// If the background contains media, load it
|
|
if (background.hasAttribute("data-loaded") === false) {
|
|
background.setAttribute("data-loaded", "true");
|
|
|
|
let backgroundImage = slide.getAttribute("data-background-image"),
|
|
backgroundVideo = slide.getAttribute("data-background-video"),
|
|
backgroundVideoLoop = slide.hasAttribute(
|
|
"data-background-video-loop"
|
|
),
|
|
backgroundVideoMuted = slide.hasAttribute(
|
|
"data-background-video-muted"
|
|
);
|
|
|
|
// Images
|
|
if (backgroundImage) {
|
|
// base64
|
|
if (/^data:/.test(backgroundImage.trim())) {
|
|
backgroundContent.style.backgroundImage = `url(${backgroundImage.trim()})`;
|
|
}
|
|
// URL(s)
|
|
else {
|
|
backgroundContent.style.backgroundImage = backgroundImage
|
|
.split(",")
|
|
.map((background) => {
|
|
// Decode URL(s) that are already encoded first
|
|
let decoded = decodeURI(background.trim());
|
|
return `url(${encodeRFC3986URI(decoded)})`;
|
|
})
|
|
.join(",");
|
|
}
|
|
}
|
|
// Videos
|
|
else if (backgroundVideo && !this.Reveal.isSpeakerNotes()) {
|
|
let video = document.createElement("video");
|
|
|
|
if (backgroundVideoLoop) {
|
|
video.setAttribute("loop", "");
|
|
}
|
|
|
|
if (backgroundVideoMuted) {
|
|
video.muted = true;
|
|
}
|
|
|
|
// Enable inline playback in mobile Safari
|
|
//
|
|
// Mute is required for video to play when using
|
|
// swipe gestures to navigate since they don't
|
|
// count as direct user actions :'(
|
|
if (isMobile) {
|
|
video.muted = true;
|
|
video.setAttribute("playsinline", "");
|
|
}
|
|
|
|
// Support comma separated lists of video sources
|
|
backgroundVideo.split(",").forEach((source) => {
|
|
let type = getMimeTypeFromFile(source);
|
|
if (type) {
|
|
video.innerHTML += `<source src="${source}" type="${type}">`;
|
|
} else {
|
|
video.innerHTML += `<source src="${source}">`;
|
|
}
|
|
});
|
|
|
|
backgroundContent.appendChild(video);
|
|
}
|
|
// Iframes
|
|
else if (backgroundIframe && options.excludeIframes !== true) {
|
|
let iframe = document.createElement("iframe");
|
|
iframe.setAttribute("allowfullscreen", "");
|
|
iframe.setAttribute("mozallowfullscreen", "");
|
|
iframe.setAttribute("webkitallowfullscreen", "");
|
|
iframe.setAttribute("allow", "autoplay");
|
|
|
|
iframe.setAttribute("data-src", backgroundIframe);
|
|
|
|
iframe.style.width = "100%";
|
|
iframe.style.height = "100%";
|
|
iframe.style.maxHeight = "100%";
|
|
iframe.style.maxWidth = "100%";
|
|
|
|
backgroundContent.appendChild(iframe);
|
|
}
|
|
}
|
|
|
|
// Start loading preloadable iframes
|
|
let backgroundIframeElement =
|
|
backgroundContent.querySelector("iframe[data-src]");
|
|
if (backgroundIframeElement) {
|
|
// Check if this iframe is eligible to be preloaded
|
|
if (
|
|
this.shouldPreload(background) &&
|
|
!/autoplay=(1|true|yes)/gi.test(backgroundIframe)
|
|
) {
|
|
if (
|
|
backgroundIframeElement.getAttribute("src") !== backgroundIframe
|
|
) {
|
|
backgroundIframeElement.setAttribute("src", backgroundIframe);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
this.layout(slide);
|
|
}
|
|
|
|
/**
|
|
* Applies JS-dependent layout helpers for the scope.
|
|
*/
|
|
layout(scopeElement) {
|
|
// Autosize text with the r-fit-text class based on the
|
|
// size of its container. This needs to happen after the
|
|
// slide is visible in order to measure the text.
|
|
Array.from(scopeElement.querySelectorAll(".r-fit-text")).forEach(
|
|
(element) => {
|
|
fitty(element, {
|
|
minSize: 24,
|
|
maxSize: this.Reveal.getConfig().height * 0.8,
|
|
observeMutations: false,
|
|
observeWindow: false,
|
|
});
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Unloads and hides the given slide. This is called when the
|
|
* slide is moved outside of the configured view distance.
|
|
*
|
|
* @param {HTMLElement} slide
|
|
*/
|
|
unload(slide) {
|
|
// Hide the slide element
|
|
slide.style.display = "none";
|
|
|
|
// Hide the corresponding background element
|
|
let background = this.Reveal.getSlideBackground(slide);
|
|
if (background) {
|
|
background.style.display = "none";
|
|
|
|
// Unload any background iframes
|
|
queryAll(background, "iframe[src]").forEach((element) => {
|
|
element.removeAttribute("src");
|
|
});
|
|
}
|
|
|
|
// Reset lazy-loaded media elements with src attributes
|
|
queryAll(
|
|
slide,
|
|
"video[data-lazy-loaded][src], audio[data-lazy-loaded][src], iframe[data-lazy-loaded][src]"
|
|
).forEach((element) => {
|
|
element.setAttribute("data-src", element.getAttribute("src"));
|
|
element.removeAttribute("src");
|
|
});
|
|
|
|
// Reset lazy-loaded media elements with <source> children
|
|
queryAll(
|
|
slide,
|
|
"video[data-lazy-loaded] source[src], audio source[src]"
|
|
).forEach((source) => {
|
|
source.setAttribute("data-src", source.getAttribute("src"));
|
|
source.removeAttribute("src");
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Enforces origin-specific format rules for embedded media.
|
|
*/
|
|
formatEmbeddedContent() {
|
|
let _appendParamToIframeSource = (sourceAttribute, sourceURL, param) => {
|
|
queryAll(
|
|
this.Reveal.getSlidesElement(),
|
|
"iframe[" + sourceAttribute + '*="' + sourceURL + '"]'
|
|
).forEach((el) => {
|
|
let src = el.getAttribute(sourceAttribute);
|
|
if (src && src.indexOf(param) === -1) {
|
|
el.setAttribute(
|
|
sourceAttribute,
|
|
src + (!/\?/.test(src) ? "?" : "&") + param
|
|
);
|
|
}
|
|
});
|
|
};
|
|
|
|
// YouTube frames must include "?enablejsapi=1"
|
|
_appendParamToIframeSource("src", "youtube.com/embed/", "enablejsapi=1");
|
|
_appendParamToIframeSource(
|
|
"data-src",
|
|
"youtube.com/embed/",
|
|
"enablejsapi=1"
|
|
);
|
|
|
|
// Vimeo frames must include "?api=1"
|
|
_appendParamToIframeSource("src", "player.vimeo.com/", "api=1");
|
|
_appendParamToIframeSource("data-src", "player.vimeo.com/", "api=1");
|
|
}
|
|
|
|
/**
|
|
* Start playback of any embedded content inside of
|
|
* the given element.
|
|
*
|
|
* @param {HTMLElement} element
|
|
*/
|
|
startEmbeddedContent(element) {
|
|
if (element && !this.Reveal.isSpeakerNotes()) {
|
|
// Restart GIFs
|
|
queryAll(element, 'img[src$=".gif"]').forEach((el) => {
|
|
// Setting the same unchanged source like this was confirmed
|
|
// to work in Chrome, FF & Safari
|
|
el.setAttribute("src", el.getAttribute("src"));
|
|
});
|
|
|
|
// HTML5 media elements
|
|
queryAll(element, "video, audio").forEach((el) => {
|
|
if (closest(el, ".fragment") && !closest(el, ".fragment.visible")) {
|
|
return;
|
|
}
|
|
|
|
// Prefer an explicit global autoplay setting
|
|
let autoplay = this.Reveal.getConfig().autoPlayMedia;
|
|
|
|
// If no global setting is available, fall back on the element's
|
|
// own autoplay setting
|
|
if (typeof autoplay !== "boolean") {
|
|
autoplay =
|
|
el.hasAttribute("data-autoplay") ||
|
|
!!closest(el, ".slide-background");
|
|
}
|
|
|
|
if (autoplay && typeof el.play === "function") {
|
|
// If the media is ready, start playback
|
|
if (el.readyState > 1) {
|
|
this.startEmbeddedMedia({ target: el });
|
|
}
|
|
// Mobile devices never fire a loaded event so instead
|
|
// of waiting, we initiate playback
|
|
else if (isMobile) {
|
|
let promise = el.play();
|
|
|
|
// If autoplay does not work, ensure that the controls are visible so
|
|
// that the viewer can start the media on their own
|
|
if (
|
|
promise &&
|
|
typeof promise.catch === "function" &&
|
|
el.controls === false
|
|
) {
|
|
promise.catch(() => {
|
|
el.controls = true;
|
|
|
|
// Once the video does start playing, hide the controls again
|
|
el.addEventListener("play", () => {
|
|
el.controls = false;
|
|
});
|
|
});
|
|
}
|
|
}
|
|
// If the media isn't loaded, wait before playing
|
|
else {
|
|
el.removeEventListener("loadeddata", this.startEmbeddedMedia); // remove first to avoid dupes
|
|
el.addEventListener("loadeddata", this.startEmbeddedMedia);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Normal iframes
|
|
queryAll(element, "iframe[src]").forEach((el) => {
|
|
if (closest(el, ".fragment") && !closest(el, ".fragment.visible")) {
|
|
return;
|
|
}
|
|
|
|
this.startEmbeddedIframe({ target: el });
|
|
});
|
|
|
|
// Lazy loading iframes
|
|
queryAll(element, "iframe[data-src]").forEach((el) => {
|
|
if (closest(el, ".fragment") && !closest(el, ".fragment.visible")) {
|
|
return;
|
|
}
|
|
|
|
if (el.getAttribute("src") !== el.getAttribute("data-src")) {
|
|
el.removeEventListener("load", this.startEmbeddedIframe); // remove first to avoid dupes
|
|
el.addEventListener("load", this.startEmbeddedIframe);
|
|
el.setAttribute("src", el.getAttribute("data-src"));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Starts playing an embedded video/audio element after
|
|
* it has finished loading.
|
|
*
|
|
* @param {object} event
|
|
*/
|
|
startEmbeddedMedia(event) {
|
|
let isAttachedToDOM = !!closest(event.target, "html"),
|
|
isVisible = !!closest(event.target, ".present");
|
|
|
|
if (isAttachedToDOM && isVisible) {
|
|
event.target.currentTime = 0;
|
|
event.target.play();
|
|
}
|
|
|
|
event.target.removeEventListener("loadeddata", this.startEmbeddedMedia);
|
|
}
|
|
|
|
/**
|
|
* "Starts" the content of an embedded iframe using the
|
|
* postMessage API.
|
|
*
|
|
* @param {object} event
|
|
*/
|
|
startEmbeddedIframe(event) {
|
|
let iframe = event.target;
|
|
|
|
if (iframe && iframe.contentWindow) {
|
|
let isAttachedToDOM = !!closest(event.target, "html"),
|
|
isVisible = !!closest(event.target, ".present");
|
|
|
|
if (isAttachedToDOM && isVisible) {
|
|
// Prefer an explicit global autoplay setting
|
|
let autoplay = this.Reveal.getConfig().autoPlayMedia;
|
|
|
|
// If no global setting is available, fall back on the element's
|
|
// own autoplay setting
|
|
if (typeof autoplay !== "boolean") {
|
|
autoplay =
|
|
iframe.hasAttribute("data-autoplay") ||
|
|
!!closest(iframe, ".slide-background");
|
|
}
|
|
|
|
// YouTube postMessage API
|
|
if (
|
|
/youtube\.com\/embed\//.test(iframe.getAttribute("src")) &&
|
|
autoplay
|
|
) {
|
|
iframe.contentWindow.postMessage(
|
|
'{"event":"command","func":"playVideo","args":""}',
|
|
"*"
|
|
);
|
|
}
|
|
// Vimeo postMessage API
|
|
else if (
|
|
/player\.vimeo\.com\//.test(iframe.getAttribute("src")) &&
|
|
autoplay
|
|
) {
|
|
iframe.contentWindow.postMessage('{"method":"play"}', "*");
|
|
}
|
|
// Generic postMessage API
|
|
else {
|
|
iframe.contentWindow.postMessage("slide:start", "*");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop playback of any embedded content inside of
|
|
* the targeted slide.
|
|
*
|
|
* @param {HTMLElement} element
|
|
*/
|
|
stopEmbeddedContent(element, options = {}) {
|
|
options = extend(
|
|
{
|
|
// Defaults
|
|
unloadIframes: true,
|
|
},
|
|
options
|
|
);
|
|
|
|
if (element && element.parentNode) {
|
|
// HTML5 media elements
|
|
queryAll(element, "video, audio").forEach((el) => {
|
|
if (!el.hasAttribute("data-ignore") && typeof el.pause === "function") {
|
|
el.setAttribute("data-paused-by-reveal", "");
|
|
el.pause();
|
|
}
|
|
});
|
|
|
|
// Generic postMessage API for non-lazy loaded iframes
|
|
queryAll(element, "iframe").forEach((el) => {
|
|
if (el.contentWindow) el.contentWindow.postMessage("slide:stop", "*");
|
|
el.removeEventListener("load", this.startEmbeddedIframe);
|
|
});
|
|
|
|
// YouTube postMessage API
|
|
queryAll(element, 'iframe[src*="youtube.com/embed/"]').forEach((el) => {
|
|
if (
|
|
!el.hasAttribute("data-ignore") &&
|
|
el.contentWindow &&
|
|
typeof el.contentWindow.postMessage === "function"
|
|
) {
|
|
el.contentWindow.postMessage(
|
|
'{"event":"command","func":"pauseVideo","args":""}',
|
|
"*"
|
|
);
|
|
}
|
|
});
|
|
|
|
// Vimeo postMessage API
|
|
queryAll(element, 'iframe[src*="player.vimeo.com/"]').forEach((el) => {
|
|
if (
|
|
!el.hasAttribute("data-ignore") &&
|
|
el.contentWindow &&
|
|
typeof el.contentWindow.postMessage === "function"
|
|
) {
|
|
el.contentWindow.postMessage('{"method":"pause"}', "*");
|
|
}
|
|
});
|
|
|
|
if (options.unloadIframes === true) {
|
|
// Unload lazy-loaded iframes
|
|
queryAll(element, "iframe[data-src]").forEach((el) => {
|
|
// Only removing the src doesn't actually unload the frame
|
|
// in all browsers (Firefox) so we set it to blank first
|
|
el.setAttribute("src", "about:blank");
|
|
el.removeAttribute("src");
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|