@deckio/deck-engine 0.1.0
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.
- package/components/BottomBar.jsx +9 -0
- package/components/BottomBar.module.css +17 -0
- package/components/Navigation.jsx +195 -0
- package/components/Navigation.module.css +210 -0
- package/components/Slide.jsx +43 -0
- package/components/exportDeckPdf.js +142 -0
- package/components/exportDeckPptx.js +127 -0
- package/context/SlideContext.jsx +190 -0
- package/index.js +5 -0
- package/instructions/AGENTS.md +26 -0
- package/instructions/deck-config.instructions.md +34 -0
- package/instructions/deck-project.instructions.md +34 -0
- package/instructions/slide-css.instructions.md +96 -0
- package/instructions/slide-jsx.instructions.md +34 -0
- package/package.json +59 -0
- package/scripts/capture-screen.mjs +127 -0
- package/scripts/export-pdf.mjs +287 -0
- package/scripts/generate-image.mjs +110 -0
- package/scripts/init-project.mjs +214 -0
- package/skills/deck-add-slide/SKILL.md +236 -0
- package/skills/deck-delete-slide/SKILL.md +51 -0
- package/skills/deck-generate-image/SKILL.md +85 -0
- package/skills/deck-inspect/SKILL.md +60 -0
- package/skills/deck-sketch/SKILL.md +91 -0
- package/skills/deck-validate-project/SKILL.md +81 -0
- package/slides/GenericThankYouSlide.jsx +31 -0
- package/styles/global.css +392 -0
- package/themes/dark.css +151 -0
- package/themes/light.css +152 -0
- package/themes/shadcn.css +212 -0
- package/themes/theme-loader.js +47 -0
- package/vite.js +67 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
.bar {
|
|
2
|
+
position: absolute;
|
|
3
|
+
bottom: 0; left: 0; right: 0;
|
|
4
|
+
height: var(--space-11);
|
|
5
|
+
background: var(--background-overlay);
|
|
6
|
+
backdrop-filter: blur(12px);
|
|
7
|
+
border-top: 1px solid var(--border);
|
|
8
|
+
display: flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
justify-content: space-between;
|
|
11
|
+
padding: 0 var(--space-10);
|
|
12
|
+
font-size: var(--font-size-xs);
|
|
13
|
+
letter-spacing: var(--letter-spacing-wider);
|
|
14
|
+
text-transform: uppercase;
|
|
15
|
+
color: var(--muted-foreground);
|
|
16
|
+
z-index: var(--z-bar);
|
|
17
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { useSlides } from '../context/SlideContext'
|
|
2
|
+
import styles from './Navigation.module.css'
|
|
3
|
+
import { useState, useEffect, useRef } from 'react'
|
|
4
|
+
import { exportDeckPdf } from './exportDeckPdf.js'
|
|
5
|
+
import { exportDeckPptx } from './exportDeckPptx.js'
|
|
6
|
+
|
|
7
|
+
function resolveProp(value, context) {
|
|
8
|
+
return typeof value === 'function' ? value(context) : value
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function Navigation({ pdfPath = null, pdfLabel = 'Deck PDF' }) {
|
|
12
|
+
const { current, totalSlides, go, goTo, selectedCustomer, project } = useSlides()
|
|
13
|
+
const [hintVisible, setHintVisible] = useState(true)
|
|
14
|
+
const [idle, setIdle] = useState(false)
|
|
15
|
+
const [isExporting, setIsExporting] = useState(false)
|
|
16
|
+
const [exportStatus, setExportStatus] = useState('PDF')
|
|
17
|
+
const [exportMenuOpen, setExportMenuOpen] = useState(false)
|
|
18
|
+
const exportMenuRef = useRef(null)
|
|
19
|
+
const timerRef = useRef(null)
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const t = setTimeout(() => setHintVisible(false), 5000)
|
|
23
|
+
return () => clearTimeout(t)
|
|
24
|
+
}, [])
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const resetIdle = () => {
|
|
28
|
+
setIdle(false)
|
|
29
|
+
clearTimeout(timerRef.current)
|
|
30
|
+
timerRef.current = setTimeout(() => setIdle(true), 2000)
|
|
31
|
+
}
|
|
32
|
+
resetIdle()
|
|
33
|
+
window.addEventListener('mousemove', resetIdle)
|
|
34
|
+
window.addEventListener('mousedown', resetIdle)
|
|
35
|
+
return () => {
|
|
36
|
+
window.removeEventListener('mousemove', resetIdle)
|
|
37
|
+
window.removeEventListener('mousedown', resetIdle)
|
|
38
|
+
clearTimeout(timerRef.current)
|
|
39
|
+
}
|
|
40
|
+
}, [])
|
|
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
|
+
|
|
54
|
+
const progress = ((current + 1) / totalSlides) * 100
|
|
55
|
+
const navigationState = { current, totalSlides, selectedCustomer, project }
|
|
56
|
+
const resolvedPdfPath = resolveProp(pdfPath, navigationState)
|
|
57
|
+
const resolvedPdfLabel = resolveProp(pdfLabel, navigationState) || 'Deck PDF'
|
|
58
|
+
|
|
59
|
+
async function handleExport(format) {
|
|
60
|
+
if (isExporting) return
|
|
61
|
+
setExportMenuOpen(false)
|
|
62
|
+
setIsExporting(true)
|
|
63
|
+
setExportStatus('Preparing')
|
|
64
|
+
|
|
65
|
+
const exportFn = format === 'pptx' ? exportDeckPptx : exportDeckPdf
|
|
66
|
+
const label = format === 'pptx' ? 'PPTX' : 'PDF'
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
await exportFn({
|
|
70
|
+
current,
|
|
71
|
+
goTo,
|
|
72
|
+
project,
|
|
73
|
+
selectedCustomer,
|
|
74
|
+
totalSlides,
|
|
75
|
+
onProgress: ({ current: slideNumber, total }) => {
|
|
76
|
+
setExportStatus(`${slideNumber}/${total}`)
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
setExportStatus('Done')
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error(`${label} export failed`, error)
|
|
82
|
+
setExportStatus('Error')
|
|
83
|
+
} finally {
|
|
84
|
+
window.setTimeout(() => {
|
|
85
|
+
setIsExporting(false)
|
|
86
|
+
setExportStatus('PDF')
|
|
87
|
+
}, 1200)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className={`${styles.navWrapper} ${idle ? styles.navHidden : ''}`}>
|
|
93
|
+
<div className={styles.progressTrack}>
|
|
94
|
+
<div className={styles.progressFill} style={{ width: `${progress}%` }} />
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{current !== 0 && (
|
|
98
|
+
<button
|
|
99
|
+
className={styles.homeBtn}
|
|
100
|
+
onClick={() => goTo(0)}
|
|
101
|
+
title="Back to home"
|
|
102
|
+
>
|
|
103
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
104
|
+
<path d="M3 12l9-9 9 9" />
|
|
105
|
+
<path d="M9 21V9h6v12" />
|
|
106
|
+
</svg>
|
|
107
|
+
</button>
|
|
108
|
+
)}
|
|
109
|
+
|
|
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>
|
|
167
|
+
|
|
168
|
+
<button
|
|
169
|
+
className={`${styles.navBtn} ${styles.prev}`}
|
|
170
|
+
disabled={current === 0}
|
|
171
|
+
onClick={() => go(-1)}
|
|
172
|
+
>
|
|
173
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
174
|
+
<polyline points="15 18 9 12 15 6" />
|
|
175
|
+
</svg>
|
|
176
|
+
</button>
|
|
177
|
+
<button
|
|
178
|
+
className={`${styles.navBtn} ${styles.next}`}
|
|
179
|
+
disabled={current === totalSlides - 1}
|
|
180
|
+
onClick={() => go(1)}
|
|
181
|
+
>
|
|
182
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
183
|
+
<polyline points="9 6 15 12 9 18" />
|
|
184
|
+
</svg>
|
|
185
|
+
</button>
|
|
186
|
+
|
|
187
|
+
{hintVisible && (
|
|
188
|
+
<div className={styles.keyHint}>
|
|
189
|
+
<span className={styles.kbd}>←</span>
|
|
190
|
+
<span className={styles.kbd}>→</span> or click arrows to navigate
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
.navWrapper {
|
|
2
|
+
transition: opacity var(--transition-slow);
|
|
3
|
+
opacity: 1;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.navHidden {
|
|
7
|
+
opacity: 0;
|
|
8
|
+
pointer-events: none;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.progressTrack {
|
|
12
|
+
position: fixed;
|
|
13
|
+
top: 0; left: 0; right: 0;
|
|
14
|
+
height: 3px;
|
|
15
|
+
background: var(--border-subtle);
|
|
16
|
+
z-index: var(--z-progress);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.progressFill {
|
|
20
|
+
height: 100%;
|
|
21
|
+
background: linear-gradient(90deg, var(--purple), var(--accent), var(--cyan));
|
|
22
|
+
transition: width var(--duration-progress) var(--ease-slide);
|
|
23
|
+
border-radius: 0 2px 2px 0;
|
|
24
|
+
box-shadow: 0 0 12px var(--accent);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.homeBtn {
|
|
28
|
+
position: fixed;
|
|
29
|
+
top: var(--space-4);
|
|
30
|
+
left: var(--space-5);
|
|
31
|
+
z-index: var(--z-nav);
|
|
32
|
+
width: 36px; height: 36px;
|
|
33
|
+
border-radius: var(--radius-lg);
|
|
34
|
+
border: 1px solid var(--border);
|
|
35
|
+
background: var(--surface-overlay);
|
|
36
|
+
backdrop-filter: blur(8px);
|
|
37
|
+
color: var(--foreground);
|
|
38
|
+
cursor: pointer;
|
|
39
|
+
display: flex;
|
|
40
|
+
align-items: center;
|
|
41
|
+
justify-content: center;
|
|
42
|
+
transition: all var(--transition-base);
|
|
43
|
+
opacity: 0.45;
|
|
44
|
+
}
|
|
45
|
+
.homeBtn:hover {
|
|
46
|
+
opacity: 1;
|
|
47
|
+
border-color: var(--accent);
|
|
48
|
+
background: var(--glow-primary);
|
|
49
|
+
}
|
|
50
|
+
.homeBtn svg {
|
|
51
|
+
width: 16px; height: 16px;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.exportBtn {
|
|
55
|
+
position: fixed;
|
|
56
|
+
top: var(--space-4);
|
|
57
|
+
right: var(--space-5);
|
|
58
|
+
z-index: var(--z-nav);
|
|
59
|
+
height: 36px;
|
|
60
|
+
min-width: 36px;
|
|
61
|
+
padding: 0 var(--space-3);
|
|
62
|
+
border-radius: var(--radius-lg);
|
|
63
|
+
border: 1px solid var(--border);
|
|
64
|
+
background: var(--surface-overlay);
|
|
65
|
+
backdrop-filter: blur(8px);
|
|
66
|
+
color: var(--foreground);
|
|
67
|
+
cursor: pointer;
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
justify-content: center;
|
|
71
|
+
gap: var(--space-1-5);
|
|
72
|
+
transition: all var(--transition-base);
|
|
73
|
+
opacity: 0.45;
|
|
74
|
+
text-decoration: none;
|
|
75
|
+
font-family: inherit;
|
|
76
|
+
}
|
|
77
|
+
.exportBtn:disabled {
|
|
78
|
+
cursor: wait;
|
|
79
|
+
}
|
|
80
|
+
.exportBtn:hover {
|
|
81
|
+
opacity: 1;
|
|
82
|
+
border-color: var(--accent);
|
|
83
|
+
background: var(--glow-primary);
|
|
84
|
+
}
|
|
85
|
+
.exportBtnBusy {
|
|
86
|
+
opacity: 1;
|
|
87
|
+
border-color: var(--accent);
|
|
88
|
+
background: var(--glow-primary-strong);
|
|
89
|
+
box-shadow: 0 0 0 1px var(--glow-ring), 0 0 24px var(--glow-primary-shadow);
|
|
90
|
+
}
|
|
91
|
+
.exportBtn svg {
|
|
92
|
+
width: 16px; height: 16px;
|
|
93
|
+
}
|
|
94
|
+
.exportLabel {
|
|
95
|
+
font-size: var(--font-size-xs);
|
|
96
|
+
font-weight: var(--font-weight-semibold);
|
|
97
|
+
letter-spacing: 0.04em;
|
|
98
|
+
text-transform: uppercase;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.exportGroup {
|
|
102
|
+
position: fixed;
|
|
103
|
+
top: var(--space-4);
|
|
104
|
+
right: var(--space-5);
|
|
105
|
+
z-index: var(--z-nav);
|
|
106
|
+
}
|
|
107
|
+
.exportGroup .exportBtn {
|
|
108
|
+
position: static;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.exportMenu {
|
|
112
|
+
position: absolute;
|
|
113
|
+
top: calc(100% + var(--space-1-5));
|
|
114
|
+
right: 0;
|
|
115
|
+
min-width: 140px;
|
|
116
|
+
border-radius: var(--radius-lg);
|
|
117
|
+
border: 1px solid var(--border);
|
|
118
|
+
background: var(--surface-overlay-heavy);
|
|
119
|
+
backdrop-filter: blur(12px);
|
|
120
|
+
padding: var(--space-1);
|
|
121
|
+
display: flex;
|
|
122
|
+
flex-direction: column;
|
|
123
|
+
gap: var(--space-0-5);
|
|
124
|
+
box-shadow: var(--shadow-elevated);
|
|
125
|
+
animation: menuFadeIn var(--transition-fast);
|
|
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: var(--space-2);
|
|
137
|
+
padding: var(--space-2) var(--space-3);
|
|
138
|
+
border: none;
|
|
139
|
+
border-radius: var(--radius-md);
|
|
140
|
+
background: transparent;
|
|
141
|
+
color: var(--foreground);
|
|
142
|
+
font-size: var(--font-size-sm);
|
|
143
|
+
font-weight: var(--font-weight-medium);
|
|
144
|
+
font-family: inherit;
|
|
145
|
+
cursor: pointer;
|
|
146
|
+
transition: background var(--transition-fast);
|
|
147
|
+
white-space: nowrap;
|
|
148
|
+
}
|
|
149
|
+
.exportMenuItem:hover {
|
|
150
|
+
background: var(--glow-primary);
|
|
151
|
+
}
|
|
152
|
+
.exportMenuItem svg {
|
|
153
|
+
width: 14px; height: 14px;
|
|
154
|
+
flex-shrink: 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.navBtn {
|
|
158
|
+
position: fixed;
|
|
159
|
+
top: 50%;
|
|
160
|
+
transform: translateY(-50%);
|
|
161
|
+
z-index: var(--z-nav);
|
|
162
|
+
width: var(--space-12); height: var(--space-12);
|
|
163
|
+
border-radius: var(--radius-full);
|
|
164
|
+
border: 1px solid var(--border);
|
|
165
|
+
background: var(--surface-overlay);
|
|
166
|
+
backdrop-filter: blur(8px);
|
|
167
|
+
color: var(--foreground);
|
|
168
|
+
cursor: pointer;
|
|
169
|
+
display: flex;
|
|
170
|
+
align-items: center;
|
|
171
|
+
justify-content: center;
|
|
172
|
+
transition: all var(--transition-base);
|
|
173
|
+
opacity: 0.5;
|
|
174
|
+
}
|
|
175
|
+
.navBtn:hover { opacity: 1; border-color: var(--accent); background: var(--glow-primary); }
|
|
176
|
+
.prev { left: var(--space-5); }
|
|
177
|
+
.next { right: var(--space-5); }
|
|
178
|
+
.navBtn svg { width: var(--space-5); height: var(--space-5); }
|
|
179
|
+
.navBtn:disabled { opacity: 0.15; cursor: default; pointer-events: none; }
|
|
180
|
+
|
|
181
|
+
.keyHint {
|
|
182
|
+
position: fixed;
|
|
183
|
+
bottom: var(--space-14); right: var(--space-10);
|
|
184
|
+
z-index: var(--z-nav);
|
|
185
|
+
display: flex;
|
|
186
|
+
align-items: center;
|
|
187
|
+
gap: var(--space-1-5);
|
|
188
|
+
font-size: var(--font-size-xs);
|
|
189
|
+
color: var(--muted-foreground);
|
|
190
|
+
opacity: 0.5;
|
|
191
|
+
animation: fadeOut 3s 5s forwards;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
@keyframes fadeOut {
|
|
195
|
+
to { opacity: 0; }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.kbd {
|
|
199
|
+
display: inline-flex;
|
|
200
|
+
align-items: center;
|
|
201
|
+
justify-content: center;
|
|
202
|
+
min-width: 22px; height: 22px;
|
|
203
|
+
padding: 0 5px;
|
|
204
|
+
border: 1px solid var(--border);
|
|
205
|
+
border-radius: var(--radius-sm);
|
|
206
|
+
background: var(--secondary);
|
|
207
|
+
font-size: var(--font-size-2xs);
|
|
208
|
+
font-weight: var(--font-weight-semibold);
|
|
209
|
+
font-family: inherit;
|
|
210
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useRef, useEffect, useState } from 'react'
|
|
2
|
+
import { useSlides } from '../context/SlideContext'
|
|
3
|
+
|
|
4
|
+
const DEV = typeof import.meta !== 'undefined' && import.meta.env?.DEV
|
|
5
|
+
|
|
6
|
+
export default function Slide({ index, className = '', children }) {
|
|
7
|
+
const { current } = useSlides()
|
|
8
|
+
const ref = useRef(null)
|
|
9
|
+
const [overflow, setOverflow] = useState(false)
|
|
10
|
+
|
|
11
|
+
let stateClass = ''
|
|
12
|
+
if (index === current) stateClass = 'active'
|
|
13
|
+
else if (index < current) stateClass = 'exit-left'
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!DEV || index !== current || !ref.current) return
|
|
17
|
+
const el = ref.current
|
|
18
|
+
const check = () => {
|
|
19
|
+
// Only check flow-positioned children; ignore absolute/fixed decorations (orbs, accent-bar)
|
|
20
|
+
const hasOverflow = Array.from(el.children).some(c => {
|
|
21
|
+
const pos = getComputedStyle(c).position
|
|
22
|
+
if (pos === 'absolute' || pos === 'fixed') return false
|
|
23
|
+
return c.offsetTop + c.offsetHeight > el.clientHeight
|
|
24
|
+
})
|
|
25
|
+
setOverflow(hasOverflow)
|
|
26
|
+
}
|
|
27
|
+
check()
|
|
28
|
+
const obs = new ResizeObserver(check)
|
|
29
|
+
obs.observe(el)
|
|
30
|
+
return () => obs.disconnect()
|
|
31
|
+
}, [index, current])
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div ref={ref} className={`slide ${stateClass} ${className}`} data-slide={index}>
|
|
35
|
+
{children}
|
|
36
|
+
{DEV && overflow && (
|
|
37
|
+
<div className="slide-overflow-warn">
|
|
38
|
+
⚠ Content overflows slide — reduce content or use smaller elements
|
|
39
|
+
</div>
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export deck slides to PDF — direct download, no dialogs.
|
|
3
|
+
*
|
|
4
|
+
* Uses modern-screenshot (SVG foreignObject) + jspdf.
|
|
5
|
+
* The browser's own renderer handles all CSS natively:
|
|
6
|
+
* - background-clip: text ✅ (explicit fix in modern-screenshot)
|
|
7
|
+
* - filter: blur() ✅ (native foreignObject rendering)
|
|
8
|
+
* - gradients, shadows ✅
|
|
9
|
+
* - animations paused before capture
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const PAGE_W = 1920
|
|
13
|
+
const PAGE_H = 1080
|
|
14
|
+
const SETTLE_MS = 600
|
|
15
|
+
|
|
16
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
17
|
+
|
|
18
|
+
async function waitForPaint() {
|
|
19
|
+
await new Promise((r) => requestAnimationFrame(() => r()))
|
|
20
|
+
await new Promise((r) => requestAnimationFrame(() => r()))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sanitize(v) {
|
|
24
|
+
return String(v || 'deck').trim().toLowerCase()
|
|
25
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'deck'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildFileName({ project, selectedCustomer }) {
|
|
29
|
+
const base = selectedCustomer
|
|
30
|
+
? `${selectedCustomer} ${document.title || project || 'deck'}`
|
|
31
|
+
: document.title || project || 'deck'
|
|
32
|
+
return `${sanitize(base)}.pdf`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pause animations on a slide for deterministic capture. Returns restore fn.
|
|
37
|
+
*/
|
|
38
|
+
function pauseAnimations(slide) {
|
|
39
|
+
const undo = []
|
|
40
|
+
const pause = (el) => {
|
|
41
|
+
const orig = el.style.animationPlayState
|
|
42
|
+
el.style.animationPlayState = 'paused'
|
|
43
|
+
undo.push(() => { el.style.animationPlayState = orig })
|
|
44
|
+
}
|
|
45
|
+
pause(slide)
|
|
46
|
+
slide.querySelectorAll('*').forEach(pause)
|
|
47
|
+
return () => { for (let i = undo.length - 1; i >= 0; i--) undo[i]() }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function exportDeckPdf({
|
|
51
|
+
current,
|
|
52
|
+
goTo,
|
|
53
|
+
project,
|
|
54
|
+
selectedCustomer,
|
|
55
|
+
totalSlides,
|
|
56
|
+
onProgress,
|
|
57
|
+
}) {
|
|
58
|
+
const deck = document.querySelector('.deck')
|
|
59
|
+
const slides = Array.from(deck?.querySelectorAll('.slide') || [])
|
|
60
|
+
if (!deck || slides.length === 0) throw new Error('No slides found')
|
|
61
|
+
|
|
62
|
+
// Dynamic imports — tree-shaken, only loaded on export
|
|
63
|
+
const [{ domToPng }, { jsPDF }] = await Promise.all([
|
|
64
|
+
import('modern-screenshot'),
|
|
65
|
+
import('jspdf'),
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
const bg = getComputedStyle(document.documentElement)
|
|
69
|
+
.getPropertyValue('--background').trim() || '#080b10'
|
|
70
|
+
const scale = Math.min(window.devicePixelRatio || 1, 2)
|
|
71
|
+
|
|
72
|
+
const pdf = new jsPDF({
|
|
73
|
+
orientation: 'landscape',
|
|
74
|
+
unit: 'px',
|
|
75
|
+
format: [PAGE_W, PAGE_H],
|
|
76
|
+
compress: true,
|
|
77
|
+
hotfixes: ['px_scaling'],
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
if (document.fonts?.ready) await document.fonts.ready
|
|
81
|
+
|
|
82
|
+
// Force deck to canonical PDF dimensions so slides render at exactly
|
|
83
|
+
// PAGE_W × PAGE_H regardless of the current viewport size.
|
|
84
|
+
const origDeckCss = deck.style.cssText
|
|
85
|
+
deck.style.width = `${PAGE_W}px`
|
|
86
|
+
deck.style.height = `${PAGE_H}px`
|
|
87
|
+
await waitForPaint()
|
|
88
|
+
await wait(SETTLE_MS)
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
for (let i = 0; i < totalSlides; i++) {
|
|
92
|
+
onProgress?.({ current: i + 1, total: totalSlides })
|
|
93
|
+
goTo(i)
|
|
94
|
+
await waitForPaint()
|
|
95
|
+
await wait(SETTLE_MS)
|
|
96
|
+
|
|
97
|
+
const active = document.querySelector('.slide.active') || slides[i]
|
|
98
|
+
if (!active) throw new Error(`Slide ${i + 1} not found`)
|
|
99
|
+
|
|
100
|
+
const restore = pauseAnimations(active)
|
|
101
|
+
await waitForPaint()
|
|
102
|
+
|
|
103
|
+
let dataUrl
|
|
104
|
+
try {
|
|
105
|
+
dataUrl = await domToPng(active, {
|
|
106
|
+
width: PAGE_W,
|
|
107
|
+
height: PAGE_H,
|
|
108
|
+
backgroundColor: bg,
|
|
109
|
+
scale,
|
|
110
|
+
style: {
|
|
111
|
+
// Ensure the captured element is visible and static
|
|
112
|
+
opacity: '1',
|
|
113
|
+
transform: 'none',
|
|
114
|
+
transition: 'none',
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
} finally {
|
|
118
|
+
restore()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (i > 0) pdf.addPage([PAGE_W, PAGE_H], 'landscape')
|
|
122
|
+
pdf.addImage(dataUrl, 'PNG', 0, 0, PAGE_W, PAGE_H, undefined, 'FAST')
|
|
123
|
+
}
|
|
124
|
+
} finally {
|
|
125
|
+
deck.style.cssText = origDeckCss
|
|
126
|
+
goTo(current)
|
|
127
|
+
await waitForPaint()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Direct download — no dialog
|
|
131
|
+
const blob = pdf.output('blob')
|
|
132
|
+
const url = URL.createObjectURL(blob)
|
|
133
|
+
const a = document.createElement('a')
|
|
134
|
+
a.href = url
|
|
135
|
+
a.download = buildFileName({ project, selectedCustomer })
|
|
136
|
+
document.body.appendChild(a)
|
|
137
|
+
a.click()
|
|
138
|
+
a.remove()
|
|
139
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
140
|
+
|
|
141
|
+
return { fileName: a.download }
|
|
142
|
+
}
|