p-slides
Version:
Presentations made simple with Web Components
597 lines (545 loc) • 19.2 kB
JavaScript
import {
checkNoteActivations,
copyNotes,
fireEvent,
formatClock,
getHighlightIndex,
getHoverIndex,
getLabel,
getStylesheets,
isFragmentVisible,
isSlide,
matchKey,
selectSlide,
setCurrentFragments,
setFragmentVisibility,
whenAllDefined
} from '../utils.js';
/** @typedef {import('./slide.js').PresentationSlideElement} PresentationSlideElement */
/** @typedef {import('../declarations.js').PresentationSlideChangeEvent} PresentationSlideChangeEvent */
/** @typedef {import('../declarations.js').PresentationFinishEvent} PresentationFinishEvent */
/** @typedef {import('../declarations.js').PresentationClockStartEvent} PresentationClockStartEvent */
/** @typedef {import('../declarations.js').PresentationClockStopEvent} PresentationClockStopEvent */
/** @typedef {import('../declarations.js').PresentationClockSetEvent} PresentationClockSetEvent */
const MODES = /** @type {const} */ (['presentation', 'speaker', 'grid']);
/** @typedef {typeof MODES[number]} PresentationMode */
const html = String.raw;
/**
* The class corresponding to the `<p-deck>` element wrapper. You'll mostly have to interact with this to manage the
* presentation.
* @tag p-deck
* @slot - Expected to contain `<p-slide>` elements only
* @attribute {PresentationMode} mode - Presentation mode
* @fires {PresentationFinishEvent} p-slides.finish - When reaching the end of the presentation
* @fires {PresentationSlideChangeEvent} p-slides.slidechange - When the current slide changes
* @fires {PresentationClockStartEvent} p-slides.clockstart - When the timer starts
* @fires {PresentationClockStopEvent} p-slides.clockstop - When the timer stops
* @fires {PresentationClockSetEvent} p-slides.clockset - When the timer has been explicitly set
* @cssprop {<time>} [--fragment-duration=300ms] - Time for a fragment's transition
* @cssprop {<integer>} [--grid-columns=4] - Number of columns in grid mode
* @cssprop {<length>} [--grid-gap=0.25em] - Gap and external padding in grid mode
* @cssprop [--grid-highlight-color=color-mix(in srgb, LinkText, transparent)] - Color for the outline of the highlighted slide in grid mode
* @cssprop {<number>} [--slide-aspect-ratio=calc(16 / 9)] - Aspect ratio of the slides
* @cssprop [--slide-font-size=5] - Size of the base presentation font in virtual units. Slides will be 100/(this value) `em`s large
* @cssprop {<time>} [--sliding-duration=0s/0.5s] - Time for the transition between two slides: 0.5s if the user doesn't prefer reduced motion
* @cssprop {<number>} [--speaker-next-scale=calc(2 / 3)] - Scale for the next slide compared to the whole area in speaker mode.
* @csspart {<aside>} sidebar - Spearker mode's sidebar
* @csspart {<header>} toolbar - Spearker mode's toolbar inside the sidenav
* @csspart {<ul>} notelist - Container for the speaker notes
* @csspart {<button>} control-button - Play, pause and clock reset button
*/
export class PresentationDeckElement extends HTMLElement {
/**
* Allows to define the location of one or more stylesheet, either as an URL (absolute or relative), or as raw CSS
* code. You can mix URLs and CSS code as you wish. The logic for telling them apart is simple: if the
* [`CSSStyleSheet`](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet) generated by the given string has
* at least one rule, or if the string contains a newline character, it's considered a valid stylesheet; otherwise, it
* attempts to load the stylesheet treating the given string as a URL.
*
* Set this property _before defining or instantiating_ a `<p-deck>` element.
* @type {string | string[] | null}
*/
static styles = null;
#clockElapsed = 0;
#clockStart = null;
#clockInterval = null;
#channel = new BroadcastChannel('p-slides');
/**
* Deck commands mapped to their key bindings.
* @type {Record<import('../declarations.js').KeyCommand, Array<Partial<KeyboardEvent>>>}
*/
keyCommands = {
next: [{ key: 'ArrowRight' }, { key: 'ArrowDown' }],
previous: [{ key: 'ArrowLeft' }, { key: 'ArrowUp' }],
nextslide: [{ key: 'PageDown' }],
previousslide: [{ key: 'PageUp' }],
gotostart: [{ key: 'Home' }],
gotoend: [{ key: 'End' }],
toggleclock: [{ key: 'P' }, { key: 'p' }],
resetclock: [{ key: '0', altKey: true }],
togglemode: [
{ key: 'M', altKey: true, shiftKey: false },
{ key: 'm', altKey: true, shiftKey: false }
],
previousmode: [
{ key: 'M', altKey: true, shiftKey: true },
{ key: 'm', altKey: true, shiftKey: true }
]
};
/**
* Labels used in speaker mode for accessibility.
* @type {Record<import('../declarations.js').PresentationDeckLabelName, import('../declarations.js').PresentationLabel<PresentationDeckElement>>}
*/
labels = {
ELAPSED_TIME: 'Elapsed time',
TIMER_START: 'Start the timer',
TIMER_PAUSE: 'Pause the timer',
TIMER_RESET: 'Reset the timer',
/** @param {PresentationDeckElement} deck */
SLIDE_COUNTER: deck => `Slide ${deck.currentIndex + 1} of ${deck.slides.length}`
};
constructor() {
super();
this.attachShadow({ mode: 'open' });
/** @ignore */
this.shadowRoot.innerHTML = html`<slot></slot>
<a></a>
<aside part="sidebar">
<header part="toolbar">
<span></span>
<time role="timer" aria-label="${getLabel(this, 'ELAPSED_TIME')}" aria-atomic="true" aria-busy="false"></time>
<button type="button" part="control-button" aria-label="${getLabel(this, 'TIMER_START')}"></button>
<button type="button" part="control-button" aria-label="${getLabel(this, 'TIMER_RESET')}"></button>
</header>
<ul part="notelist"></ul>
</aside>`;
getStylesheets(PresentationDeckElement.styles).then(styles => this.shadowRoot.adoptedStyleSheets.push(...styles));
const [playButton, resetButton] = this.shadowRoot.querySelectorAll('button');
playButton.addEventListener('click', () => this.toggleClock());
resetButton.addEventListener('click', () => (this.clock = 0));
this.#gridLink = this.shadowRoot.querySelector('a');
this.#gridLink.addEventListener('click', () => {
this.currentIndex = this.#hoveredSlideIndex >= 0 ? this.#hoveredSlideIndex : this.#highlightedSlideIndex;
this.mode = this.#previousMode;
});
this.addEventListener('pointermove', this.#handleGridPointer);
// Channel for state sync
this.#channel.addEventListener('message', ({ data }) => {
// Sending a null state => requesting the state
if (data === null) {
this.broadcastState();
} else
this.#muteAction(() => {
this.state = data;
});
});
}
#muteAction(fn) {
this.#preventBroadcast = true;
fn();
this.#preventBroadcast = false;
}
/** @internal */
connectedCallback() {
this.ownerDocument.addEventListener('keydown', this.#keyHandler);
this.#clockInterval = this.ownerDocument.defaultView.setInterval(() => {
if (this.isClockRunning) this.#updateClock();
}, 1000);
this.#updateClock();
whenAllDefined().then(() => {
this.#muteAction(() => this.#resetCurrentSlide());
this.requestState();
});
}
/** @internal */
disconnectedCallback() {
this.ownerDocument.removeEventListener('keydown', this.#keyHandler);
this.ownerDocument.defaultView.clearInterval(this.#clockInterval);
this.#clockInterval = null;
this.stopClock();
}
/** @type {PresentationMode} */
#previousMode = 'presentation';
/**
* Getter/setter of current deck mode. It reflects the same named attribute value _if_ it's either `'presentation'` or
* `'speaker'` (defaults to the former). Also sets it when assigning.
*
* Operatively speaking, changing the deck mode does _nothing_. Its only purpose is to apply a different style to the
* presentation, i.e. either the 'normal' or the 'speaker' mode. If you provide your own stylesheet without a specific
* style for the speaker mode then eh, you're on your own.
* @type {PresentationMode}
*/
get mode() {
const attrValue = this.getAttribute('mode');
return MODES.includes(attrValue) ? attrValue : 'presentation';
}
set mode(mode) {
if (!MODES.includes(mode) || mode === this.mode) return;
this.#previousMode = this.mode;
this.setAttribute('mode', mode);
this.slides.forEach(slide => (slide.inert = mode !== 'presentation'));
if (mode === 'grid') {
this.#hoveredSlideIndex = -1;
this.#gridLink.scrollIntoView({ block: 'center' });
}
}
#resetCurrentSlide() {
const nextSlide = this.querySelector('p-slide[aria-current="page"]') ?? this.querySelector('p-slide');
let { currentSlide } = this;
if (!currentSlide && nextSlide) {
currentSlide = nextSlide;
}
if (currentSlide) {
this.currentSlide = currentSlide;
}
}
get slideSizes() {
const { width, height } = this.getBoundingClientRect();
const deckRatio = width / height;
const aspectRatio = +this.ownerDocument.defaultView.getComputedStyle(this).getPropertyValue('--slide-aspect-ratio') || 16 / 9;
if (deckRatio > aspectRatio) {
return { width: height * aspectRatio, height };
}
return { width, height: width / aspectRatio };
}
/** @type {PresentationSlideElement | null} */
#currentSlide = null;
/**
* Getter/setter for the slide element marked as 'current'. When setting, it _must_ be a `<p-slide>` elements descendant
* of the deck.
*/
get currentSlide() {
return this.#currentSlide;
}
set currentSlide(nextSlide) {
if (this.#currentSlide === nextSlide) {
return;
}
if (!isSlide(nextSlide)) {
throw Error('Current slide can only be a <p-slide> element');
}
if (!this.contains(nextSlide)) {
throw Error('Deck does not contain the given slide');
}
if (!nextSlide.isActive) {
nextSlide.isActive = true;
// We return early because setting isActive will end up setting currentSlide again
return;
}
selectSlide(this.slides, nextSlide);
this.#updateCounter();
copyNotes(this.shadowRoot.querySelector('ul'), nextSlide.notes);
this.#highlightedSlideIndex = this.currentIndex;
this.#currentSlide = nextSlide;
fireEvent(this, 'slidechange', { slide: nextSlide, previous: this.#currentSlide });
if (this.atEnd) {
fireEvent(this, 'finish');
}
this.broadcastState();
}
/**
* Getter/setter of index of the current slide.
*/
get currentIndex() {
return [...this.slides].findIndex(slide => slide.isActive);
}
set currentIndex(index) {
const { slides } = this;
if (slides.length === 0 && +index === 0) {
return;
}
const slide = slides[index];
if (!slide) {
throw Error(`Slide index out of range (must be 0-${slides.length - 1}, ${index} given)`);
}
this.currentSlide = slide;
}
/**
* At the moment, it's just a `querySelectorAll('p-slide')` executed on the deck's host element.
*/
get slides() {
return this.querySelectorAll('p-slide');
}
/**
* It's `true` if and only if the presentation is at the start.
*/
get atStart() {
if (this.currentIndex > 0) {
return false;
}
return !this.slides[0]?.lastVisibleFragments;
}
/**
* It's `true` if and only if the presentation is at the end.
*/
get atEnd() {
const { slides } = this;
if (this.currentIndex < slides.length - 1) return false;
const lastSlide = slides[slides.length - 1];
return !lastSlide?.nextHiddenFragments;
}
/** @type {HTMLAnchorElement} */
#gridLink;
get #highlightedSlideIndex() {
return parseInt(this.#gridLink.style.getPropertyValue('--highlighted-slide-index'), 10);
}
set #highlightedSlideIndex(value) {
this.#gridLink.style.setProperty('--highlighted-slide-index', value);
this.#gridLink.href = `#${value}`;
this.#hoveredSlideIndex = -1;
if (this.mode === 'grid') {
if (this.shadowRoot.activeElement !== this.#gridLink) this.#gridLink.focus();
this.#gridLink.scrollIntoView({
block: 'center',
behavior: matchMedia('(prefers-reduced-motion: no-preference)').matches ? 'smooth' : 'auto'
});
}
}
get #hoveredSlideIndex() {
const value = parseInt(this.#gridLink.style.getPropertyValue('--hovered-slide-index'), 10);
return isNaN(value) ? -1 : value;
}
set #hoveredSlideIndex(value) {
if (value >= 0) {
this.#gridLink.style.setProperty('--hovered-slide-index', value);
this.#gridLink.href = `#${value}`;
} else {
this.#gridLink.style.removeProperty('--hovered-slide-index');
}
}
#keyHandler = /**
* @this {PresentationDeckElement}
* @param {KeyboardEvent} keyEvent
*/ keyEvent => {
const [realTarget] = /** @type {Element[]} */ (keyEvent.composedPath());
if (realTarget.isContentEditable || ['input', 'select', 'textarea'].includes(realTarget.localName)) return;
if (this.mode === 'grid' && ['altKey', 'shiftKey', 'metaKey', 'ctrlKey'].every(modifier => !keyEvent[modifier])) {
if (['altKey', 'shiftKey', 'metaKey', 'ctrlKey'].some(modifier => keyEvent[modifier])) return;
if (keyEvent.key === 'Escape') {
this.mode = this.#previousMode;
return;
}
const gridColumns = parseInt(this.ownerDocument.defaultView.getComputedStyle(this).getPropertyValue('--grid-columns'), 10);
const newIndex = getHighlightIndex(keyEvent.key, this.#highlightedSlideIndex, gridColumns, this.slides.length);
if (!isNaN(newIndex)) {
this.#hoveredSlideIndex = -1;
this.#highlightedSlideIndex = newIndex;
keyEvent.preventDefault();
}
return;
}
const command = matchKey(keyEvent, this.keyCommands);
switch (command) {
case 'previous':
this.previous();
break;
case 'next':
this.next();
break;
case 'previousslide':
this.previousSlide();
break;
case 'nextslide':
this.nextSlide();
break;
case 'gotostart':
this.currentIndex = 0;
this.previousSlide();
break;
case 'gotoend':
this.currentIndex = this.slides.length - 1;
this.nextSlide();
break;
case 'toggleclock':
this.toggleClock();
break;
case 'resetclock':
this.clock = 0;
break;
case 'togglemode':
this.mode = MODES[(MODES.indexOf(this.mode) + 1) % MODES.length];
break;
case 'previousmode':
this.mode = MODES[(MODES.indexOf(this.mode) + MODES.length - 1) % MODES.length];
break;
}
};
#handleGridPointer = /**
* @this {PresentationDeckElement}
* @param {PointerEvent} event
*/ ({ pageX, pageY }) => {
if (this.mode === 'grid') {
this.#hoveredSlideIndex = getHoverIndex(pageX, pageY, this.slides);
}
};
#updateCounter() {
const counter = this.shadowRoot.querySelector('span');
counter.textContent = this.currentIndex + 1;
counter.dataset.total = this.slides.length;
counter.ariaLabel = getLabel(this, 'SLIDE_COUNTER');
}
/**
* Advances the presentation, either by showing a new fragment on the current slide, or switching to the next slide.
*/
next() {
if (this.atEnd) return;
const { currentIndex, currentSlide } = this;
const goToNext = currentSlide.next();
if (goToNext) {
this.slides[currentIndex + 1].isActive = true;
} else {
this.#checkNotes();
if (this.atEnd) {
fireEvent(this, 'finish');
}
}
}
/**
* Brings the presentation back, either by hiding the last shown fragment on the current slide, or switching to the
* previous slide.
*/
previous() {
if (this.atStart) return;
const { currentIndex, currentSlide } = this;
const goToPrevious = currentSlide.previous();
if (goToPrevious) {
this.slides[currentIndex - 1].isActive = true;
} else {
this.#checkNotes();
}
}
/**
* Advances the presentation to the next slide, if possible.
*/
nextSlide() {
const { currentIndex, slides } = this;
if (currentIndex < slides.length - 1) {
this.currentSlide = slides[currentIndex + 1];
if (this.atEnd) fireEvent(this, 'finish');
} else if (!this.atEnd) {
setFragmentVisibility(true)(...this.currentSlide.fragments);
this.#checkNotes();
fireEvent(this, 'finish');
}
}
/**
* Brings the presentation back to the previous slide, if possible.
*/
previousSlide() {
const { currentIndex } = this;
if (currentIndex > 0) {
this.currentSlide = this.slides[currentIndex - 1];
} else if (!this.atStart) {
setFragmentVisibility(false)(...this.currentSlide.fragments);
this.#checkNotes();
}
}
#checkNotes() {
checkNoteActivations(this.shadowRoot.querySelector('ul'), this.currentSlide.notes);
}
/**
* Starts the timer.
*/
startClock() {
this.#clockStart = Date.now();
this.shadowRoot.querySelector('time').ariaBusy = 'true';
this.shadowRoot.querySelector('button').ariaLabel = getLabel(this, 'TIMER_PAUSE');
fireEvent(this, 'clockstart', { timestamp: this.#clockStart, elapsed: this.#clockElapsed });
this.broadcastState();
}
/**
* Stops the timer.
*/
stopClock() {
if (this.isClockRunning) {
this.#clockElapsed += Date.now() - this.#clockStart;
}
this.#clockStart = null;
this.shadowRoot.querySelector('time').ariaBusy = 'false';
this.shadowRoot.querySelector('button').ariaLabel = getLabel(this, 'TIMER_START');
fireEvent(this, 'clockstop', { elapsed: this.#clockElapsed });
this.broadcastState();
}
/**
* Toggles the timer.
*/
toggleClock() {
if (this.isClockRunning) {
this.stopClock();
} else {
this.startClock();
}
}
#updateClock() {
const time = this.shadowRoot.querySelector('time');
const parts = formatClock(this.clock);
time.textContent = parts.map(part => part.toString().padStart(2, '0')).join(':');
time.dateTime = `PT${parts[0]}H${parts[1]}M${parts[2]}S`;
}
/**
* The amount of milliseconds on the timer.
*/
get clock() {
return this.#clockElapsed + (this.isClockRunning ? Date.now() - this.#clockStart : 0);
}
set clock(value) {
if (!isNaN(value)) {
this.#clockElapsed = +value;
if (this.isClockRunning) {
this.#clockStart = Date.now();
}
fireEvent(this, 'clockset', { elapsed: this.#clockElapsed });
this.broadcastState();
}
if (this.#clockInterval) {
this.#updateClock();
}
}
/**
* It's `true` if and only if the timer is not paused.
*/
get isClockRunning() {
return this.#clockStart !== null;
}
/**
* An object that represents the presentation's state. Although exposed, handle it with caution, as changes may not be
* reflected on the view or a second window. Use the method `broadcastState()` to send an updated state to a second
* view.
* @type {import('../declarations.js').PresentationState}
*/
get state() {
const state = {
currentIndex: this.currentIndex,
currentSlideFragmentVisibility: Array.from(this.currentSlide.fragments, isFragmentVisible),
clockElapsed: this.#clockElapsed,
clockStart: this.#clockStart
};
return state;
}
set state(state) {
this.currentIndex = state.currentIndex;
this.#clockElapsed = state.clockElapsed;
this.#clockStart = state.clockStart;
const { currentSlide } = this;
currentSlide.fragments.forEach((fragment, index) => {
setFragmentVisibility(state.currentSlideFragmentVisibility[index])(fragment);
});
setCurrentFragments(currentSlide);
this.#updateClock();
}
#preventBroadcast = false;
/**
* Sends the current presentation's state to other windows/tabs open on the presentation.
*/
broadcastState() {
if (!this.#preventBroadcast) {
this.#channel.postMessage(this.state);
}
}
/**
* Retrieves the presentation's state from other windows/tabs open on the presentation.
*/
requestState() {
this.#channel.postMessage(null);
}
}