464 lines
17 KiB
JavaScript
464 lines
17 KiB
JavaScript
class PXSlideViewer {
|
|
constructor(container, options = {}) {
|
|
this.container = container;
|
|
this.slides = [];
|
|
this.currentIndex = 0;
|
|
this.isPlaying = false;
|
|
this.speakerNotesOpen = false;
|
|
this.thumbnailOpen = false;
|
|
|
|
this.transitionType = options.transition || 'fade';
|
|
this.autoplayDelay = options.autoplayDelay || 8000;
|
|
this._options = options;
|
|
|
|
this._init();
|
|
}
|
|
|
|
_init() {
|
|
var opts = this._options || {};
|
|
this.slides = Array.from(this.container.querySelectorAll('.viewer-slide'));
|
|
if (this.slides.length === 0 && opts.slides && opts.slides.length) {
|
|
this.slides = opts.slides;
|
|
}
|
|
if (this.slides.length === 0) return;
|
|
|
|
this._setupHTML();
|
|
this._setupStyles();
|
|
this._setupEvents();
|
|
this.goTo(0);
|
|
}
|
|
|
|
_setupHTML() {
|
|
this.container.classList.add('px-viewer');
|
|
this.container.innerHTML = '';
|
|
|
|
const stage = document.createElement('div');
|
|
stage.className = 'px-viewer__stage';
|
|
this.stageEl = stage;
|
|
|
|
this.slides.forEach((slide, idx) => {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'px-viewer__slide';
|
|
wrapper.dataset.index = idx;
|
|
wrapper.innerHTML = slide.innerHTML || slide.html || '';
|
|
stage.appendChild(wrapper);
|
|
});
|
|
|
|
this.slideEls = stage.querySelectorAll('.px-viewer__slide');
|
|
|
|
const controls = document.createElement('div');
|
|
controls.className = 'px-viewer__controls';
|
|
controls.innerHTML = `
|
|
<div class="px-viewer__progress">
|
|
<div class="px-viewer__progress-fill"></div>
|
|
</div>
|
|
<div class="px-viewer__bar">
|
|
<div class="px-viewer__bar-left">
|
|
<button class="px-viewer__btn" data-action="thumbnails" title="Thumbnails">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
|
</button>
|
|
<button class="px-viewer__btn" data-action="notes" title="Speaker Notes">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
|
</button>
|
|
</div>
|
|
<div class="px-viewer__nav">
|
|
<button class="px-viewer__btn" data-action="prev" title="Previous (←)">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
|
|
</button>
|
|
<span class="px-viewer__counter">1 / ${this.slides.length}</span>
|
|
<button class="px-viewer__btn" data-action="next" title="Next (→)">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
|
</button>
|
|
</div>
|
|
<div class="px-viewer__bar-right">
|
|
<button class="px-viewer__btn" data-action="fullscreen" title="Fullscreen (F)">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
|
</button>
|
|
<button class="px-viewer__btn" data-action="exit" title="Exit (Esc)">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
this.progressFill = controls.querySelector('.px-viewer__progress-fill');
|
|
this.counterEl = controls.querySelector('.px-viewer__counter');
|
|
|
|
const notesPanel = document.createElement('div');
|
|
notesPanel.className = 'px-viewer__notes';
|
|
notesPanel.innerHTML = '<div class="px-viewer__notes-content"></div>';
|
|
this.notesEl = notesPanel.querySelector('.px-viewer__notes-content');
|
|
|
|
const thumbPanel = document.createElement('div');
|
|
thumbPanel.className = 'px-viewer__thumbs';
|
|
this.thumbsEl = thumbPanel;
|
|
this.slides.forEach((slide, idx) => {
|
|
const thumb = document.createElement('div');
|
|
thumb.className = 'px-viewer__thumb';
|
|
thumb.dataset.index = idx;
|
|
const title = slide.title || `Slide ${idx + 1}`;
|
|
const layout = slide.layout || '';
|
|
thumb.innerHTML = `
|
|
<div class="px-viewer__thumb-number">${idx + 1}</div>
|
|
<div class="px-viewer__thumb-info">
|
|
<div class="px-viewer__thumb-title">${title}</div>
|
|
<div class="px-viewer__thumb-layout">${layout.replace(/_/g, ' ')}</div>
|
|
</div>
|
|
`;
|
|
thumb.addEventListener('click', () => this.goTo(idx));
|
|
thumbPanel.appendChild(thumb);
|
|
});
|
|
|
|
this.container.appendChild(stage);
|
|
this.container.appendChild(notesPanel);
|
|
this.container.appendChild(thumbPanel);
|
|
this.container.appendChild(controls);
|
|
}
|
|
|
|
_setupStyles() {
|
|
if (document.getElementById('px-viewer-styles')) return;
|
|
|
|
const style = document.createElement('style');
|
|
style.id = 'px-viewer-styles';
|
|
style.textContent = `
|
|
.px-viewer {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: #0a0a0a;
|
|
z-index: 9999;
|
|
display: flex;
|
|
flex-direction: column;
|
|
font-family: 'Inter', sans-serif;
|
|
}
|
|
.px-viewer__stage {
|
|
flex: 1;
|
|
position: relative;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
}
|
|
.px-viewer__slide {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 1920px;
|
|
height: 1080px;
|
|
transform-origin: top left;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity 0.5s ease, transform 0.5s ease;
|
|
}
|
|
.px-viewer__slide.active {
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
z-index: 1;
|
|
}
|
|
.px-viewer__slide.slide-enter {
|
|
opacity: 0;
|
|
transform: scale(0.97);
|
|
}
|
|
.px-viewer__slide.active.slide-enter {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
.px-viewer__progress {
|
|
height: 3px;
|
|
background: rgba(255,255,255,0.1);
|
|
position: relative;
|
|
}
|
|
.px-viewer__progress-fill {
|
|
height: 100%;
|
|
background: linear-gradient(to right, #005696, #0EA5E9);
|
|
transition: width 0.4s ease;
|
|
border-radius: 0 2px 2px 0;
|
|
}
|
|
.px-viewer__bar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 10px 20px;
|
|
background: rgba(15,15,15,0.95);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
.px-viewer__bar-left, .px-viewer__bar-right {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
.px-viewer__nav {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
.px-viewer__btn {
|
|
background: none;
|
|
border: none;
|
|
color: rgba(255,255,255,0.6);
|
|
cursor: pointer;
|
|
padding: 8px;
|
|
border-radius: 8px;
|
|
transition: all 0.2s;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.px-viewer__btn:hover {
|
|
color: #fff;
|
|
background: rgba(255,255,255,0.1);
|
|
}
|
|
.px-viewer__counter {
|
|
color: rgba(255,255,255,0.5);
|
|
font-size: 13px;
|
|
font-variant-numeric: tabular-nums;
|
|
min-width: 60px;
|
|
text-align: center;
|
|
user-select: none;
|
|
}
|
|
.px-viewer__controls {
|
|
position: relative;
|
|
z-index: 10;
|
|
}
|
|
.px-viewer__notes {
|
|
position: fixed;
|
|
bottom: 60px;
|
|
right: 0;
|
|
width: 400px;
|
|
max-height: 50vh;
|
|
background: rgba(15,15,15,0.95);
|
|
backdrop-filter: blur(10px);
|
|
border-left: 1px solid rgba(255,255,255,0.1);
|
|
border-top: 1px solid rgba(255,255,255,0.1);
|
|
border-radius: 16px 0 0 0;
|
|
padding: 24px;
|
|
color: rgba(255,255,255,0.8);
|
|
font-size: 14px;
|
|
line-height: 1.7;
|
|
overflow-y: auto;
|
|
transform: translateX(100%);
|
|
transition: transform 0.3s ease;
|
|
z-index: 20;
|
|
}
|
|
.px-viewer__notes.open {
|
|
transform: translateX(0);
|
|
}
|
|
.px-viewer__notes-content {
|
|
white-space: pre-wrap;
|
|
}
|
|
.px-viewer__thumbs {
|
|
position: fixed;
|
|
left: 0;
|
|
top: 0;
|
|
bottom: 60px;
|
|
width: 280px;
|
|
background: rgba(15,15,15,0.95);
|
|
backdrop-filter: blur(10px);
|
|
border-right: 1px solid rgba(255,255,255,0.1);
|
|
padding: 20px 16px;
|
|
overflow-y: auto;
|
|
transform: translateX(-100%);
|
|
transition: transform 0.3s ease;
|
|
z-index: 20;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
.px-viewer__thumbs.open {
|
|
transform: translateX(0);
|
|
}
|
|
.px-viewer__thumb {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 10px 12px;
|
|
border-radius: 10px;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
border: 2px solid transparent;
|
|
}
|
|
.px-viewer__thumb:hover {
|
|
background: rgba(255,255,255,0.05);
|
|
}
|
|
.px-viewer__thumb.active {
|
|
background: rgba(14,165,233,0.15);
|
|
border-color: rgba(14,165,233,0.4);
|
|
}
|
|
.px-viewer__thumb-number {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 8px;
|
|
background: rgba(255,255,255,0.1);
|
|
color: rgba(255,255,255,0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
flex-shrink: 0;
|
|
}
|
|
.px-viewer__thumb.active .px-viewer__thumb-number {
|
|
background: rgba(14,165,233,0.3);
|
|
color: #0EA5E9;
|
|
}
|
|
.px-viewer__thumb-title {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: rgba(255,255,255,0.7);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.px-viewer__thumb-layout {
|
|
font-size: 11px;
|
|
color: rgba(255,255,255,0.35);
|
|
text-transform: capitalize;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
_setupEvents() {
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.target.matches('input, textarea, [contenteditable]')) return;
|
|
|
|
switch (e.key) {
|
|
case 'ArrowRight':
|
|
case ' ':
|
|
case 'PageDown':
|
|
e.preventDefault();
|
|
this.next();
|
|
break;
|
|
case 'ArrowLeft':
|
|
case 'PageUp':
|
|
e.preventDefault();
|
|
this.prev();
|
|
break;
|
|
case 'Home':
|
|
e.preventDefault();
|
|
this.goTo(0);
|
|
break;
|
|
case 'End':
|
|
e.preventDefault();
|
|
this.goTo(this.slides.length - 1);
|
|
break;
|
|
case 'Escape':
|
|
e.preventDefault();
|
|
this.destroy();
|
|
break;
|
|
case 'f':
|
|
case 'F':
|
|
e.preventDefault();
|
|
this.toggleFullscreen();
|
|
break;
|
|
case 'n':
|
|
case 'N':
|
|
e.preventDefault();
|
|
this.toggleNotes();
|
|
break;
|
|
case 't':
|
|
case 'T':
|
|
e.preventDefault();
|
|
this.toggleThumbnails();
|
|
break;
|
|
}
|
|
});
|
|
|
|
this.container.addEventListener('click', (e) => {
|
|
const btn = e.target.closest('[data-action]');
|
|
if (!btn) return;
|
|
|
|
const action = btn.dataset.action;
|
|
switch (action) {
|
|
case 'prev': this.prev(); break;
|
|
case 'next': this.next(); break;
|
|
case 'fullscreen': this.toggleFullscreen(); break;
|
|
case 'exit': this.destroy(); break;
|
|
case 'notes': this.toggleNotes(); break;
|
|
case 'thumbnails': this.toggleThumbnails(); break;
|
|
}
|
|
});
|
|
|
|
window.addEventListener('resize', () => this._scale());
|
|
}
|
|
|
|
_scale() {
|
|
const slide = this.stageEl.querySelector('.px-viewer__slide.active');
|
|
if (!slide) return;
|
|
|
|
const vw = this.stageEl.clientWidth;
|
|
const vh = this.stageEl.clientHeight;
|
|
const scale = Math.min(vw / 1920, vh / 1080);
|
|
const offsetX = (vw - 1920 * scale) / 2;
|
|
const offsetY = (vh - 1080 * scale) / 2;
|
|
|
|
this.slideEls.forEach(el => {
|
|
el.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
|
|
});
|
|
}
|
|
|
|
goTo(index) {
|
|
if (index < 0 || index >= this.slides.length) return;
|
|
|
|
this.slideEls.forEach((el, i) => {
|
|
el.classList.remove('active', 'slide-enter');
|
|
if (i === index) {
|
|
el.classList.add('active', 'slide-enter');
|
|
}
|
|
});
|
|
|
|
this.currentIndex = index;
|
|
this.counterEl.textContent = `${index + 1} / ${this.slides.length}`;
|
|
this.progressFill.style.width = `${((index + 1) / this.slides.length) * 100}%`;
|
|
|
|
this.notesEl.textContent = this.slides[index]?.notes || 'No speaker notes for this slide.';
|
|
|
|
this.thumbsEl.querySelectorAll('.px-viewer__thumb').forEach((t, i) => {
|
|
t.classList.toggle('active', i === index);
|
|
});
|
|
|
|
this._scale();
|
|
}
|
|
|
|
next() {
|
|
if (this.currentIndex < this.slides.length - 1) {
|
|
this.goTo(this.currentIndex + 1);
|
|
}
|
|
}
|
|
|
|
prev() {
|
|
if (this.currentIndex > 0) {
|
|
this.goTo(this.currentIndex - 1);
|
|
}
|
|
}
|
|
|
|
toggleFullscreen() {
|
|
if (!document.fullscreenElement) {
|
|
this.container.requestFullscreen();
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
}
|
|
|
|
toggleNotes() {
|
|
this.speakerNotesOpen = !this.speakerNotesOpen;
|
|
this.container.querySelector('.px-viewer__notes').classList.toggle('open', this.speakerNotesOpen);
|
|
}
|
|
|
|
toggleThumbnails() {
|
|
this.thumbnailOpen = !this.thumbnailOpen;
|
|
this.container.querySelector('.px-viewer__thumbs').classList.toggle('open', this.thumbnailOpen);
|
|
if (this.thumbnailOpen) {
|
|
setTimeout(() => this._scale(), 350);
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
if (document.fullscreenElement) {
|
|
document.exitFullscreen();
|
|
}
|
|
this.container.remove();
|
|
if (window._pxViewerCleanup) window._pxViewerCleanup();
|
|
}
|
|
}
|
|
|
|
window.PXSlideViewer = PXSlideViewer;
|