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 @@
-
+
No Dependencies
Progressive Enhancement
Just HTML
@@ -18,20 +23,54 @@
Always available:
- command-k : control panel
- command-period : fullscreen
+ command-k : Toggle control panel
+ command-shift-enter : Start presentation (from first slide)
+ command-enter : Resume presentation (from active slide)
+ command-shift-f : Toggle full-screen mode
+
+ command-. :
+ End presentation
+
Active Presentation:
- right-arrow /page-down :
- next slide
+ N
+ / right-arrow
+ / down-arrow
+ / page-down :
+ Next slide
+
+
+ P
+ / left-arrow
+ / up-arrow
+ / page-up :
+ Previous slide
+
+
+ home :
+ First slide
+
+
+ end :
+ Last slide
+
+
+ W
+ / , :
+ Toggle white screen
+
+
+ B
+ / . :
+ Toggle black screen
- left-arrow /page-up :
- previous slide
+ escape :
+ Blur focused element, close control panel, or end presentation
@@ -53,18 +92,17 @@ <button set-view>list<button>
Speaker View
-
Open Source
-
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 {
+ keyboard controls
+
Presentation:
start
end
speaker view
View:
- grid
- list
+ grid
+ list
@@ -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;