p-slides
Version:
Presentations made simple with Web Components
540 lines (490 loc) • 17.6 kB
JavaScript
import {
applyStylesheets,
checkNoteActivations,
copyNotes,
defaultKeyHandler,
fireEvent,
formatClock,
generateTextId,
getHighlightIndex,
getHighlightSelector,
getLabel,
gridKeyHandler,
isFragmentActivated,
isSlide,
scrollIntoView,
selectSlide,
setCurrentFragments,
setFragmentActivation,
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 */
/** @typedef {import('../declarations.js').PresentationState} PresentationState */
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.6cqw] - 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 - Timer play/pause and timer reset button
* @csspart {<button>} timer-button - Timer play/pause button
* @csspart {<button>} reset-button - Timer reset button
* @csspart {<time>} timer - Container for the elapsed time
* @csspart {<span>} counter - Container for the current slide index (1-based)
*/
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');
/** @type {CSSStyleRule} */
#highlightRule;
/**
* 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 }
]
};
/**
* @type {Partial<Record<PresentationMode, import('../declarations.js').PresentationKeyHandler>>}
*/
keyHandlers = {
grid: gridKeyHandler
};
modes = MODES;
/**
* 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();
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
/** @ignore */
this.shadowRoot.innerHTML = html`<slot></slot>
<aside part="sidebar">
<header part="toolbar">
<span part="counter"></span>
<time part="timer" role="timer" aria-label="${getLabel(this, 'ELAPSED_TIME')}" aria-atomic="true" aria-busy="false"></time>
<button type="button" part="control-button timer-button" aria-label="${getLabel(this, 'TIMER_START')}"></button>
<button type="button" part="control-button reset-button" aria-label="${getLabel(this, 'TIMER_RESET')}"></button>
</header>
<ul part="notelist"></ul>
</aside>`;
}
applyStylesheets(this).then(([styleSheet]) => {
styleSheet.insertRule(`${getHighlightSelector(this.currentIndex + 1)}{--is-highlighted:1}`);
this.#highlightRule = styleSheet.cssRules[0];
});
this.shadowRoot.querySelector('[part~="timer-button"]').addEventListener('click', () => this.toggleClock());
this.shadowRoot.querySelector('[part~="reset-button"]').addEventListener('click', () => (this.clock = 0));
this.shadowRoot.querySelector('slot').addEventListener(
'click',
event => {
if (this.mode === 'presentation') return;
const slide = /** @type {PresentationSlideElement | null} */ (event.target.closest('p-slide'));
if (slide) {
this.currentSlide = slide;
this.restoreMode();
event.stopPropagation();
}
},
{ capture: true }
);
// Channel for state sync
this.#channel.addEventListener('message', (/** @type {MessageEvent<PresentationState>} */ { 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();
Promise.all([whenAllDefined(), this.id || generateTextId(this).then(id => (this.id = `deck_${id}`))]).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'`,
* `'speaker'` or `'grid'` (defaults to the first). 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.
* @type {PresentationMode}
*/
get mode() {
const attrValue = this.getAttribute('mode');
return this.modes.includes(attrValue) ? attrValue : 'presentation';
}
set mode(mode) {
if (!this.modes.includes(mode) || mode === this.mode) return;
this.#previousMode = this.mode;
this.setAttribute('mode', mode);
this.#currentSlide?.scrollIntoView({ block: 'center' });
}
restoreMode() {
return (this.mode = this.#previousMode === this.mode ? 'presentation' : this.#previousMode);
}
#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;
}
}
/** @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('[part~="notelist"]'), 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 (0-based).
*/
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]?.lastActivatedFragments;
}
/**
* 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?.nextInactiveFragments;
}
/**
* Index of the currently highlighted slide (only meaningful in grid mode).
*/
get highlightedSlideIndex() {
return getHighlightIndex(this.#highlightRule.selectorText);
}
set highlightedSlideIndex(index) {
if (this.#highlightRule) {
this.#highlightRule.selectorText = getHighlightSelector(index + 1);
}
scrollIntoView(this.slides[index], { block: 'center' });
}
#keyHandler = /**
* @this {PresentationDeckElement}
* @param {KeyboardEvent} keyEvent
*/ keyEvent => {
const [realTarget] = /** @type {Element[]} */ (keyEvent.composedPath());
if (realTarget.isContentEditable || ['input', 'select', 'textarea'].includes(realTarget.localName)) return;
const handled = this.keyHandlers[this.mode]?.(keyEvent, this);
if (!handled) defaultKeyHandler(keyEvent, this);
};
#updateCounter() {
const counter = this.shadowRoot.querySelector('[part~="counter"]');
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) {
setFragmentActivation(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) {
setFragmentActivation(false)(...this.currentSlide.fragments);
this.#checkNotes();
}
}
#checkNotes() {
checkNoteActivations(this.shadowRoot.querySelector('[part~="notelist"]'), this.currentSlide.notes);
}
/**
* Starts the timer.
*/
startClock() {
this.#clockStart = Date.now();
this.shadowRoot.querySelector('[part~="timer"]').ariaBusy = 'true';
this.shadowRoot.querySelector('[part~="timer-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('[part~="timer"]').ariaBusy = 'false';
this.shadowRoot.querySelector('[part~="timer-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('[part~="timer"]');
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 {PresentationState}
*/
get state() {
return {
deckId: this.id,
currentIndex: this.currentIndex,
currentSlideFragmentActivation: Array.from(this.currentSlide.fragments, isFragmentActivated),
clockElapsed: this.#clockElapsed,
clockStart: this.#clockStart
};
}
set state(state) {
if (state.deckId !== this.id) return;
this.currentIndex = state.currentIndex;
this.#clockElapsed = state.clockElapsed;
this.#clockStart = state.clockStart;
const { currentSlide } = this;
currentSlide.fragments.forEach((fragment, index) => {
setFragmentActivation(state.currentSlideFragmentActivation[index])(fragment);
});
setCurrentFragments(currentSlide);
this.#updateClock();
this.#checkNotes();
}
#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);
}
}