@deckio/deck-engine 1.8.1 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ import { useSlides } from '../context/SlideContext'
2
2
  import styles from './Navigation.module.css'
3
3
  import { useState, useEffect, useRef } from 'react'
4
4
  import { exportDeckPdf } from './exportDeckPdf.js'
5
+ import { exportDeckPptx } from './exportDeckPptx.js'
5
6
 
6
7
  function resolveProp(value, context) {
7
8
  return typeof value === 'function' ? value(context) : value
@@ -13,6 +14,8 @@ export default function Navigation({ pdfPath = null, pdfLabel = 'Deck PDF' }) {
13
14
  const [idle, setIdle] = useState(false)
14
15
  const [isExporting, setIsExporting] = useState(false)
15
16
  const [exportStatus, setExportStatus] = useState('PDF')
17
+ const [exportMenuOpen, setExportMenuOpen] = useState(false)
18
+ const exportMenuRef = useRef(null)
16
19
  const timerRef = useRef(null)
17
20
 
18
21
  useEffect(() => {
@@ -36,19 +39,34 @@ export default function Navigation({ pdfPath = null, pdfLabel = 'Deck PDF' }) {
36
39
  }
37
40
  }, [])
38
41
 
42
+ // Close export menu when clicking outside
43
+ useEffect(() => {
44
+ if (!exportMenuOpen) return
45
+ const handleClickOutside = (e) => {
46
+ if (exportMenuRef.current && !exportMenuRef.current.contains(e.target)) {
47
+ setExportMenuOpen(false)
48
+ }
49
+ }
50
+ document.addEventListener('mousedown', handleClickOutside)
51
+ return () => document.removeEventListener('mousedown', handleClickOutside)
52
+ }, [exportMenuOpen])
53
+
39
54
  const progress = ((current + 1) / totalSlides) * 100
40
55
  const navigationState = { current, totalSlides, selectedCustomer, project }
41
56
  const resolvedPdfPath = resolveProp(pdfPath, navigationState)
42
57
  const resolvedPdfLabel = resolveProp(pdfLabel, navigationState) || 'Deck PDF'
43
58
 
44
- async function handleExportClick() {
45
- if (resolvedPdfPath || isExporting) return
46
-
59
+ async function handleExport(format) {
60
+ if (isExporting) return
61
+ setExportMenuOpen(false)
47
62
  setIsExporting(true)
48
63
  setExportStatus('Preparing')
49
64
 
65
+ const exportFn = format === 'pptx' ? exportDeckPptx : exportDeckPdf
66
+ const label = format === 'pptx' ? 'PPTX' : 'PDF'
67
+
50
68
  try {
51
- await exportDeckPdf({
69
+ await exportFn({
52
70
  current,
53
71
  goTo,
54
72
  project,
@@ -60,7 +78,7 @@ export default function Navigation({ pdfPath = null, pdfLabel = 'Deck PDF' }) {
60
78
  })
61
79
  setExportStatus('Done')
62
80
  } catch (error) {
63
- console.error('PDF export failed', error)
81
+ console.error(`${label} export failed`, error)
64
82
  setExportStatus('Error')
65
83
  } finally {
66
84
  window.setTimeout(() => {
@@ -89,41 +107,63 @@ export default function Navigation({ pdfPath = null, pdfLabel = 'Deck PDF' }) {
89
107
  </button>
90
108
  )}
91
109
 
92
- {resolvedPdfPath ? (
93
- <a
94
- className={styles.exportBtn}
95
- href={resolvedPdfPath}
96
- target="_blank"
97
- rel="noopener noreferrer"
98
- title={resolvedPdfLabel}
99
- >
100
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
101
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
102
- <polyline points="14 2 14 8 20 8" />
103
- <line x1="16" y1="13" x2="8" y2="13" />
104
- <line x1="16" y1="17" x2="8" y2="17" />
105
- <polyline points="10 9 9 9 8 9" />
106
- </svg>
107
- <span className={styles.exportLabel}>PDF</span>
108
- </a>
109
- ) : (
110
- <button
111
- className={`${styles.exportBtn} ${isExporting ? styles.exportBtnBusy : ''}`}
112
- type="button"
113
- onClick={handleExportClick}
114
- disabled={isExporting}
115
- title={isExporting ? 'Preparing deck PDF' : resolvedPdfLabel}
116
- >
117
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
118
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
119
- <polyline points="14 2 14 8 20 8" />
120
- <path d="M12 12v6" />
121
- <path d="M9 15l3 3 3-3" />
122
- <path d="M8 10h8" />
123
- </svg>
124
- <span className={styles.exportLabel}>{isExporting ? exportStatus : '⬇ PDF'}</span>
125
- </button>
126
- )}
110
+ <div className={styles.exportGroup} ref={exportMenuRef}>
111
+ {resolvedPdfPath ? (
112
+ <a
113
+ className={styles.exportBtn}
114
+ href={resolvedPdfPath}
115
+ target="_blank"
116
+ rel="noopener noreferrer"
117
+ title={resolvedPdfLabel}
118
+ >
119
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
120
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
121
+ <polyline points="14 2 14 8 20 8" />
122
+ <line x1="16" y1="13" x2="8" y2="13" />
123
+ <line x1="16" y1="17" x2="8" y2="17" />
124
+ <polyline points="10 9 9 9 8 9" />
125
+ </svg>
126
+ <span className={styles.exportLabel}>PDF</span>
127
+ </a>
128
+ ) : (
129
+ <button
130
+ className={`${styles.exportBtn} ${isExporting ? styles.exportBtnBusy : ''}`}
131
+ type="button"
132
+ onClick={() => isExporting ? null : setExportMenuOpen(!exportMenuOpen)}
133
+ disabled={isExporting}
134
+ title={isExporting ? 'Exporting...' : 'Export deck'}
135
+ >
136
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
137
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
138
+ <polyline points="14 2 14 8 20 8" />
139
+ <path d="M12 12v6" />
140
+ <path d="M9 15l3 3 3-3" />
141
+ <path d="M8 10h8" />
142
+ </svg>
143
+ <span className={styles.exportLabel}>{isExporting ? exportStatus : '⬇'}</span>
144
+ </button>
145
+ )}
146
+
147
+ {exportMenuOpen && !isExporting && (
148
+ <div className={styles.exportMenu}>
149
+ <button className={styles.exportMenuItem} onClick={() => handleExport('pdf')}>
150
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="14" height="14">
151
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
152
+ <polyline points="14 2 14 8 20 8" />
153
+ </svg>
154
+ PDF
155
+ </button>
156
+ <button className={styles.exportMenuItem} onClick={() => handleExport('pptx')}>
157
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="14" height="14">
158
+ <rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
159
+ <path d="M8 21h8" />
160
+ <path d="M12 17v4" />
161
+ </svg>
162
+ PowerPoint
163
+ </button>
164
+ </div>
165
+ )}
166
+ </div>
127
167
 
128
168
  <button
129
169
  className={`${styles.navBtn} ${styles.prev}`}
@@ -98,6 +98,62 @@
98
98
  text-transform: uppercase;
99
99
  }
100
100
 
101
+ .exportGroup {
102
+ position: fixed;
103
+ top: 16px;
104
+ right: 20px;
105
+ z-index: 200;
106
+ }
107
+ .exportGroup .exportBtn {
108
+ position: static;
109
+ }
110
+
111
+ .exportMenu {
112
+ position: absolute;
113
+ top: calc(100% + 6px);
114
+ right: 0;
115
+ min-width: 140px;
116
+ border-radius: 10px;
117
+ border: 1px solid var(--border);
118
+ background: rgba(22,27,34,0.95);
119
+ backdrop-filter: blur(12px);
120
+ padding: 4px;
121
+ display: flex;
122
+ flex-direction: column;
123
+ gap: 2px;
124
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
125
+ animation: menuFadeIn 0.15s ease;
126
+ }
127
+
128
+ @keyframes menuFadeIn {
129
+ from { opacity: 0; transform: translateY(-4px); }
130
+ to { opacity: 1; transform: translateY(0); }
131
+ }
132
+
133
+ .exportMenuItem {
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 8px;
137
+ padding: 8px 12px;
138
+ border: none;
139
+ border-radius: 6px;
140
+ background: transparent;
141
+ color: var(--text);
142
+ font-size: 12px;
143
+ font-weight: 500;
144
+ font-family: inherit;
145
+ cursor: pointer;
146
+ transition: background 0.15s ease;
147
+ white-space: nowrap;
148
+ }
149
+ .exportMenuItem:hover {
150
+ background: rgba(31,111,235,0.2);
151
+ }
152
+ .exportMenuItem svg {
153
+ width: 14px; height: 14px;
154
+ flex-shrink: 0;
155
+ }
156
+
101
157
  .navBtn {
102
158
  position: fixed;
103
159
  top: 50%;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Export deck slides to PowerPoint (.pptx) — direct download, no dialogs.
3
+ *
4
+ * Uses modern-screenshot (SVG foreignObject) + PptxGenJS.
5
+ * Each slide is captured as a full-bleed image placed on a 10×5.625″ slide (16:9).
6
+ */
7
+
8
+ const PAGE_W = 1920
9
+ const PAGE_H = 1080
10
+ const SETTLE_MS = 600
11
+
12
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms))
13
+
14
+ async function waitForPaint() {
15
+ await new Promise((r) => requestAnimationFrame(() => r()))
16
+ await new Promise((r) => requestAnimationFrame(() => r()))
17
+ }
18
+
19
+ function sanitize(v) {
20
+ return String(v || 'deck').trim().toLowerCase()
21
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'deck'
22
+ }
23
+
24
+ function buildFileName({ project, selectedCustomer }) {
25
+ const base = selectedCustomer
26
+ ? `${selectedCustomer} ${document.title || project || 'deck'}`
27
+ : document.title || project || 'deck'
28
+ return `${sanitize(base)}.pptx`
29
+ }
30
+
31
+ function pauseAnimations(slide) {
32
+ const undo = []
33
+ const pause = (el) => {
34
+ const orig = el.style.animationPlayState
35
+ el.style.animationPlayState = 'paused'
36
+ undo.push(() => { el.style.animationPlayState = orig })
37
+ }
38
+ pause(slide)
39
+ slide.querySelectorAll('*').forEach(pause)
40
+ return () => { for (let i = undo.length - 1; i >= 0; i--) undo[i]() }
41
+ }
42
+
43
+ export async function exportDeckPptx({
44
+ current,
45
+ goTo,
46
+ project,
47
+ selectedCustomer,
48
+ totalSlides,
49
+ onProgress,
50
+ }) {
51
+ const deck = document.querySelector('.deck')
52
+ const slides = Array.from(deck?.querySelectorAll('.slide') || [])
53
+ if (!deck || slides.length === 0) throw new Error('No slides found')
54
+
55
+ const [{ domToPng }, PptxGenJS] = await Promise.all([
56
+ import('modern-screenshot'),
57
+ import('pptxgenjs'),
58
+ ])
59
+ const Pptx = PptxGenJS.default || PptxGenJS
60
+
61
+ const bg = getComputedStyle(document.documentElement)
62
+ .getPropertyValue('--bg-deep').trim() || '#080b10'
63
+ const scale = Math.min(window.devicePixelRatio || 1, 2)
64
+
65
+ const pptx = new Pptx()
66
+ pptx.defineLayout({ name: 'WIDE', width: 10, height: 5.625 })
67
+ pptx.layout = 'WIDE'
68
+
69
+ if (document.fonts?.ready) await document.fonts.ready
70
+
71
+ const origDeckCss = deck.style.cssText
72
+ deck.style.width = `${PAGE_W}px`
73
+ deck.style.height = `${PAGE_H}px`
74
+ await waitForPaint()
75
+ await wait(SETTLE_MS)
76
+
77
+ try {
78
+ for (let i = 0; i < totalSlides; i++) {
79
+ onProgress?.({ current: i + 1, total: totalSlides })
80
+ goTo(i)
81
+ await waitForPaint()
82
+ await wait(SETTLE_MS)
83
+
84
+ const active = document.querySelector('.slide.active') || slides[i]
85
+ if (!active) throw new Error(`Slide ${i + 1} not found`)
86
+
87
+ const restore = pauseAnimations(active)
88
+ await waitForPaint()
89
+
90
+ let dataUrl
91
+ try {
92
+ dataUrl = await domToPng(active, {
93
+ width: PAGE_W,
94
+ height: PAGE_H,
95
+ backgroundColor: bg,
96
+ scale,
97
+ style: {
98
+ opacity: '1',
99
+ transform: 'none',
100
+ transition: 'none',
101
+ },
102
+ })
103
+ } finally {
104
+ restore()
105
+ }
106
+
107
+ const slide = pptx.addSlide()
108
+ slide.background = { color: bg.replace('#', '') }
109
+ slide.addImage({
110
+ data: dataUrl,
111
+ x: 0,
112
+ y: 0,
113
+ w: '100%',
114
+ h: '100%',
115
+ })
116
+ }
117
+ } finally {
118
+ deck.style.cssText = origDeckCss
119
+ goTo(current)
120
+ await waitForPaint()
121
+ }
122
+
123
+ const fileName = buildFileName({ project, selectedCustomer })
124
+ await pptx.writeFile({ fileName })
125
+
126
+ return { fileName }
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deckio/deck-engine",
3
- "version": "1.8.1",
3
+ "version": "1.8.2",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
@@ -36,7 +36,8 @@
36
36
  ],
37
37
  "dependencies": {
38
38
  "jspdf": "^3.0.3",
39
- "modern-screenshot": "^4.6.8"
39
+ "modern-screenshot": "^4.6.8",
40
+ "pptxgenjs": "^3.12.0"
40
41
  },
41
42
  "peerDependencies": {
42
43
  "react": "^19.1.0",