UNPKG

p-slides

Version:

Presentations made simple with Web Components

540 lines (490 loc) 17.6 kB
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); } }