diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5e85b4b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changes + +**⚠️ This is a pre-release**: +Breaking changes will be allowed in minor versions +until we achieve a stable v1.0 release + +## v0.1.1 - unreleased + +- 💥 BREAKING: Updated keyboard shortcuts + to match [PowerPoint](https://support.microsoft.com/en-us/office/use-keyboard-shortcuts-to-deliver-powerpoint-presentations-1524ffce-bd2a-45f4-9a7f-f18b992b93a0#bkmk_frequent_macos), + including `command-.` as 'end presentation' + rather than 'toggle full-screen' (now `command-shift-f`) +- 🚀 NEW: Support for blank-screen shortcuts + (inspired by [Curtis Wilcox](https://codepen.io/ccwilcox/details/NWJWwOE)) +- 🚀 NEW: Both start/resume events target active slides +- 🚀 NEW: Control panel includes toggle for keyboard controls +- 🚀 NEW: Control panel buttons have `aria-pressed` styles +- 🚀 NEW: All slide-event buttons that toggle a boolean state + get `aria-pressed` values that update with the state +- 🐞 FIXED: Scroll to the active slide when changing views +- 🐞 FIXED: Control panel view toggles were broken +- 🐞 FIXED: Control panel prevents propagation of keyboard shortcuts +- 👀 INTERNAL: The current slide is stored in an `activeSlide` property + +## v0.1.0 - 2023-12-22 + +Initial draft +based on +[Miriam's Proof of Concept](https://codepen.io/miriamsuzanne/pen/eYXOLjE?editors=1010). diff --git a/README.md b/README.md index 3d6692d..985dc55 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ A Web Component for web presentations. **[Demo](https://slide-deck.netlify.app)** +**⚠️ This is a pre-release**: +Breaking changes will be allowed in minor versions +until we achieve a stable v1.0 release + ## Examples General usage example: @@ -67,6 +71,29 @@ This Web Component allows you to: - Follow along in a second tab (speaker view) - Toggle full-screen mode +## Keyboard Shortcuts + +Always available: + +- `command-k`: Toggle control panel +- `command-shift-enter`: Start presentation (from first slide) +- `command-enter`: Resume presentation (from active slide) +- `command-.`: End presentation +- `command-shift-f`: Toggle full-screen mode + +When presenting (key-control is active): + +- `N`/`rightArrow`/`downArrow`/`pageDown`: Next slide +- `P`/`leftArrow`/`upArrow`/`pageUp`: Previous slide +- `home`: First slide +- `end`: Last slide +- `W`/`,`: Toggle white screen +- `B`/`.`: Toggle black screen +- `escape`: Blur focused element, close control panel, or end presentation + +These are based on +the [PowerPoint shortcuts](https://support.microsoft.com/en-us/office/use-keyboard-shortcuts-to-deliver-powerpoint-presentations-1524ffce-bd2a-45f4-9a7f-f18b992b93a0#bkmk_frequent_macos). + ## Installation You have a few options (choose one of these): diff --git a/index.html b/index.html index db3c5f5..e3624c5 100644 --- a/index.html +++ b/index.html @@ -10,7 +10,12 @@ -

Slide-Deck Web Component

+
+

Slide-Deck Web Component

+

+ github.com/oddbird/slide-deck/ +

+

No Dependencies

Progressive Enhancement

Just HTML

@@ -18,20 +23,54 @@

Always available:

Active Presentation:

@@ -53,18 +92,17 @@

<button set-view>list<button>

Speaker View

-

Open Source

-

ToDo: Github Repo

-

(and NPM package??)

+

Open Source

+

+ github.com/oddbird/slide-deck/ +

-

ToDo: Full Documentation

-

ToDo: Improved

-

ToDo: Speaker Notes

-

ToDo: Slide Templates

-

ToDo: CSS Themes

-

ToDo: More Shortcuts

-

ToDo: More Better Good Stuff

+

To Do…

+

… Speaker Notes

+

… Slide Templates

+

… CSS Themes

+

… More Better Good Stuff

diff --git a/slide-deck.js b/slide-deck.js index 4ac2a74..dc8b5e4 100644 --- a/slide-deck.js +++ b/slide-deck.js @@ -7,14 +7,16 @@ class slideDeck extends HTMLElement {
+ +

Presentation:

View:

- - + +
@@ -30,12 +32,15 @@ class slideDeck extends HTMLElement { static adoptShadowStyles = (node) => { const shadowStyle = new CSSStyleSheet(); shadowStyle.replaceSync(` + :host { + position: relative; + } + :host:not(:fullscreen) { container: host / inline-size; } :host(:fullscreen) { - container-type: auto; background-color: white; overflow-x: clip; overflow-y: auto; @@ -53,6 +58,17 @@ class slideDeck extends HTMLElement { ---slide-list-border: var(--slide-list-border, thin solid); } + :host([blank-slide])::after { + content: ''; + position: absolute; + inset: 0; + background-color: var(--blank-slide-color, black); + } + + :host([blank-slide='white'])::after { + --blank-slide-color: white; + } + [part=contents] { ---slide-gap: clamp(5px, 1.5cqi, 15px); display: grid; @@ -91,6 +107,14 @@ class slideDeck extends HTMLElement { ); outline-offset: var(--slide-active-outline-offset, 3px); } + + button[aria-pressed=true] { + box-shadow: inset 0 0 2px black; + + &::before { + content: ' ✅ '; + } + } `); node.shadowRoot.adoptedStyleSheets = [shadowStyle]; } @@ -122,12 +146,41 @@ class slideDeck extends HTMLElement { 'list', ]; + static controlKeys = { + 'Home': 'firstSlide', + 'End': 'lastSlide', + + // next slide + 'ArrowRight': 'nextSlide', + 'ArrowDown': 'nextSlide', + 'PageDown': 'nextSlide', + 'N': 'nextSlide', + ' ': 'nextSlide', + + // previous slide + 'ArrowLeft': 'previousSlide', + 'ArrowUp': 'previousSlide', + 'PageUp': 'previousSlide', + 'P': 'previousSlide', + 'Delete': 'previousSlide', + + // blank slide + 'B': 'blackOut', + '.': 'blackOut', + 'W': 'whiteOut', + ',': 'whiteOut', + + // end + '-': 'endPresentation' + } + // dynamic store = {}; slideCount; controlPanel; eventButtons; viewButtons; + activeSlide; body; // callbacks @@ -135,18 +188,18 @@ class slideDeck extends HTMLElement { this[slideDeck.attrToPropMap[name]] = newValue || this.hasAttribute(name); switch (name) { - case 'full-screen': - this.fullScreenChange(); - break; case 'follow-active': this.followActiveChange(); break; case 'slide-view': this.updateViewButtons(); + this.scrollToActive(); break; default: break; } + + this.updateEventButtons(); } constructor() { @@ -165,17 +218,18 @@ class slideDeck extends HTMLElement { this.slideCount = this.childElementCount; this.defaultAttrs(); this.setSlideIDs(); - this.slideToStore(); + this.goTo(); // buttons this.setupEventButtons(); this.setupViewButtons(); // event listeners - this.shadowRoot.addEventListener('keydown', (e) => { - if (e.key === 'k' && e.metaKey) { - e.preventDefault(); - e.stopPropagation(); + this.shadowRoot.addEventListener('keydown', (event) => { + event.stopPropagation(); + + if ((event.key === 'k' && event.metaKey) || event.key === 'Escape') { + event.preventDefault(); this.controlPanel.close(); } }); @@ -183,7 +237,7 @@ class slideDeck extends HTMLElement { // custom events this.addEventListener('toggleControl', (e) => this.toggleAttribute('key-control')); this.addEventListener('toggleFollow', (e) => this.toggleAttribute('follow-active')); - this.addEventListener('toggleFullscreen', (e) => this.toggleAttribute('full-screen')); + this.addEventListener('toggleFullscreen', (e) => this.fullScreenEvent()); this.addEventListener('toggleView', (e) => this.toggleView()); this.addEventListener('grid', (e) => this.toggleView('grid')); this.addEventListener('list', (e) => this.toggleView('list')); @@ -194,6 +248,7 @@ class slideDeck extends HTMLElement { this.addEventListener('resume', (e) => this.resumeEvent()); this.addEventListener('end', (e) => this.endEvent()); this.addEventListener('reset', (e) => this.resetEvent()); + this.addEventListener('blankSlide', (e) => this.blankSlideEvent()); this.addEventListener('nextSlide', (e) => this.move(1)); this.addEventListener('savedSlide', (e) => this.goToSaved()); @@ -250,6 +305,23 @@ class slideDeck extends HTMLElement { }; // buttons + getButtonEvent = (btn) => btn.getAttribute('slide-event') || btn.innerText; + + updateEventButtons = () => { + this.eventButtons.forEach((btn) => { + const btnEvent = this.getButtonEvent(btn); + let isActive = { + 'toggleControl': this.keyControl, + 'toggleFollow': this.followActive, + 'toggleFullscreen': this.fullScreen, + } + + if (Object.keys(isActive).includes(btnEvent)) { + btn.setAttribute('aria-pressed', isActive[btnEvent]); + } + }); + } + setupEventButtons = () => { this.eventButtons = [ ...this.querySelectorAll(`button[slide-event]`), @@ -258,10 +330,12 @@ class slideDeck extends HTMLElement { this.eventButtons.forEach((btn) => { btn.addEventListener('click', (e) => { - const event = btn.getAttribute('slide-event') || btn.innerText; + const event = this.getButtonEvent(btn); this.dispatchEvent(new Event(event, { view: window, bubbles: false })); }); }); + + this.updateEventButtons(); } getButtonView = (btn) => btn.getAttribute('set-view') || btn.innerText; @@ -303,10 +377,15 @@ class slideDeck extends HTMLElement { startEvent = () => { this.goTo(1); - this.resumeEvent(); + this.startPresenting(); } resumeEvent = () => { + this.goToSaved(); + this.startPresenting(); + } + + startPresenting = () => { this.setAttribute('slide-view', 'list'); this.setAttribute('key-control', ''); this.setAttribute('follow-active', ''); @@ -335,17 +414,17 @@ class slideDeck extends HTMLElement { this.resetActive(); } - // dynamic attribute methods - followActiveChange = () => { - if (this.followActive) { - this.goToSaved(); - window.addEventListener('storage', (e) => this.goToSaved()); + blankSlideEvent = (color) => { + if (this.hasAttribute('blank-slide')) { + this.removeAttribute('blank-slide'); } else { - window.removeEventListener('storage', (e) => this.goToSaved()); + this.setAttribute('blank-slide', color || 'black'); } } - fullScreenChange = () => { + fullScreenEvent = () => { + this.toggleAttribute('full-screen'); + if (this.fullScreen && this.requestFullscreen) { this.requestFullscreen(); } else if (document.fullscreenElement) { @@ -353,37 +432,73 @@ class slideDeck extends HTMLElement { } } + // dynamic attribute methods + followActiveChange = () => { + if (this.followActive) { + this.goToSaved(); + window.addEventListener('storage', (e) => this.goToSaved()); + } else { + window.removeEventListener('storage', (e) => this.goToSaved()); + } + } + // storage - asSlideInt = (string) => parseInt(string, 10) || 1; + asSlideInt = (string) => parseInt(string, 10); - slideFromHash = (hash) => this.asSlideInt(hash.split('-').pop()); + slideFromHash = () => window.location.hash.startsWith('#slide_') + ? this.asSlideInt(window.location.hash.split('-').pop()) + : null; slideFromStore = () => this.asSlideInt( localStorage.getItem(this.store.slide) ); - slideToHash = (to) => { window.location.hash = this.slideId(to) }; - slideToStore = (to) => { - const active = to || this.slideFromHash(window.location.hash); - localStorage.setItem(this.store.slide, active); + slideToHash = (to) => { + if (to) { + window.location.hash = this.slideId(to); + } }; - - resetActive = () => { - window.location.hash = this.id; - localStorage.removeItem(this.store.slide); + slideToStore = (to) => { + if (to) { + localStorage.setItem(this.store.slide, to); + } else { + localStorage.removeItem(this.store.slide); + } }; // navigation inRange = (slide) => slide >= 1 && slide <= this.slideCount; + getActive = () => this.slideFromHash() || this.activeSlide; - goTo = (slide) => { - if (this.inRange(slide)) { - this.slideToHash(slide); - this.slideToStore(slide); + scrollToActive = () => { + const activeEl = document.getElementById(this.slideId(this.activeSlide)); + + if (activeEl) { + activeEl.scrollIntoView(true); + } + }; + + goTo = (to) => { + const fromHash = this.slideFromHash(); + const setTo = to || this.getActive(); + + if (setTo && this.inRange(setTo)) { + this.activeSlide = setTo; + this.slideToStore(setTo); + + if (setTo !== fromHash) { + this.slideToHash(setTo); + } } + } + + resetActive = () => { + this.activeSlide = null; + window.location.hash = this.id; + localStorage.removeItem(this.store.slide); }; move = (by) => { - const to = this.slideFromHash(window.location.hash) + by; + const to = (this.getActive() || 0) + by; this.goTo(to); }; @@ -392,45 +507,82 @@ class slideDeck extends HTMLElement { } keyEventActions = (event) => { + // always available if (event.metaKey) { switch (event.key) { case 'k': event.preventDefault(); this.controlPanel.showModal(); break; + case 'f': + if (event.shiftKey) { + event.preventDefault(); + this.fullScreenEvent(); + } + break; + case 'Enter': + if (event.shiftKey) { + event.preventDefault(); + this.startEvent(); + } else { + event.preventDefault(); + this.resumeEvent(); + } + break; case '.': event.preventDefault(); - this.toggleAttribute('full-screen'); + this.endEvent(); break; default: break; } + return; + } else if (event.altKey && event.key === 'Enter') { + event.preventDefault(); + this.joinWithNotesEvent(); + return; } - if (event.target !== this.body) { + // only while key-control is active + if (this.keyControl) { if (event.key === 'Escape') { - event.target.blur(); + if (event.target !== this.body) { + event.target.blur(); + } else { + event.preventDefault(); + this.endEvent(); + } + return; } - return; - } - if (this.keyControl) { - switch (event.key) { - case 'ArrowRight': + switch (slideDeck.controlKeys[event.key]) { + case 'firstSlide': event.preventDefault(); - this.move(1); + this.goTo(1); + break; + case 'lastSlide': + event.preventDefault(); + this.goTo(this.slideCount); break; - case 'PageDown': + case 'nextSlide': event.preventDefault(); this.move(1); break; - case 'ArrowLeft': + case 'previousSlide': event.preventDefault(); this.move(-1); break; - case 'PageUp': + case 'blackOut': event.preventDefault(); - this.move(-1); + this.blankSlideEvent('black'); + break; + case 'whiteOut': + event.preventDefault(); + this.blankSlideEvent('white'); + break; + case 'endPresentation': + event.preventDefault(); + this.endEvent(); break; default: break;