diff options
| -rw-r--r-- | src/font.php | 44 | ||||
| -rw-r--r-- | src/fonts.php | 49 | ||||
| -rw-r--r-- | src/index.php | 693 | ||||
| -rw-r--r-- | src/upload.php | 66 | ||||
| -rw-r--r-- | src/uploads/.htaccess | 2 |
5 files changed, 854 insertions, 0 deletions
diff --git a/src/font.php b/src/font.php new file mode 100644 index 0000000..de39569 --- /dev/null +++ b/src/font.php | |||
| @@ -0,0 +1,44 @@ | |||
| 1 | <?php | ||
| 2 | /* font.php — serve font files from the server's font directories */ | ||
| 3 | |||
| 4 | $encoded = $_GET['f'] ?? ''; | ||
| 5 | if (empty($encoded)) { | ||
| 6 | http_response_code(400); | ||
| 7 | exit('Missing parameter'); | ||
| 8 | } | ||
| 9 | |||
| 10 | $file = base64_decode($encoded, true); | ||
| 11 | if ($file === false || !file_exists($file)) { | ||
| 12 | http_response_code(404); | ||
| 13 | exit('Font not found'); | ||
| 14 | } | ||
| 15 | |||
| 16 | $real = realpath($file); | ||
| 17 | $allowed = ['/usr/share/fonts', '/usr/local/share/fonts']; | ||
| 18 | $ok = false; | ||
| 19 | |||
| 20 | foreach ($allowed as $dir) { | ||
| 21 | if (str_starts_with($real, $dir)) { | ||
| 22 | $ok = true; | ||
| 23 | break; | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | if (!$ok) { | ||
| 28 | http_response_code(403); | ||
| 29 | exit('Access denied'); | ||
| 30 | } | ||
| 31 | |||
| 32 | $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); | ||
| 33 | $mime = match ($ext) { | ||
| 34 | 'ttf' => 'font/ttf', | ||
| 35 | 'otf' => 'font/otf', | ||
| 36 | 'woff' => 'font/woff', | ||
| 37 | 'woff2' => 'font/woff2', | ||
| 38 | default => 'application/octet-stream', | ||
| 39 | }; | ||
| 40 | |||
| 41 | header("Content-Type: $mime"); | ||
| 42 | header('Cache-Control: public, max-age=31536000, immutable'); | ||
| 43 | header('Content-Length: ' . filesize($file)); | ||
| 44 | readfile($file); | ||
diff --git a/src/fonts.php b/src/fonts.php new file mode 100644 index 0000000..1959f51 --- /dev/null +++ b/src/fonts.php | |||
| @@ -0,0 +1,49 @@ | |||
| 1 | <?php | ||
| 2 | // fonts.php — LIST server-side fonts via fontconfig | ||
| 3 | |||
| 4 | header('Content-Type: application/json'); | ||
| 5 | header('Cache-Control: public, max-age=3600'); | ||
| 6 | |||
| 7 | $output = shell_exec('fc-list --format="%{family}|%{file}\n" 2>/dev/null'); | ||
| 8 | if (!$output) { | ||
| 9 | echo json_encode([]); | ||
| 10 | exit; | ||
| 11 | } | ||
| 12 | |||
| 13 | $lines = array_filter(explode("\n", trim($output))); | ||
| 14 | $fonts = []; | ||
| 15 | $seen = []; | ||
| 16 | |||
| 17 | foreach ($lines as $line) { | ||
| 18 | $parts = explode('|', $line, 2); | ||
| 19 | if (count($parts) < 2) continue; | ||
| 20 | |||
| 21 | /* take first family name (some entries are comma-separated) */ | ||
| 22 | $families = explode(',', $parts[0]); | ||
| 23 | $family = trim($families[0]); | ||
| 24 | |||
| 25 | if (empty($family) || isset($seen[$family])) continue; | ||
| 26 | |||
| 27 | $file = trim($parts[1]); | ||
| 28 | if (!file_exists($file)) continue; | ||
| 29 | |||
| 30 | $seen[$family] = true; | ||
| 31 | |||
| 32 | $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); | ||
| 33 | $format = match ($ext) { | ||
| 34 | 'ttf' => 'truetype', | ||
| 35 | 'otf' => 'opentype', | ||
| 36 | 'woff' => 'woff', | ||
| 37 | 'woff2' => 'woff2', | ||
| 38 | default => 'truetype', | ||
| 39 | }; | ||
| 40 | |||
| 41 | $fonts[] = [ | ||
| 42 | 'family' => $family, | ||
| 43 | 'file' => base64_encode($file), | ||
| 44 | 'format' => $format, | ||
| 45 | ]; | ||
| 46 | } | ||
| 47 | |||
| 48 | usort($fonts, fn($a, $b) => strcasecmp($a['family'], $b['family'])); | ||
| 49 | echo json_encode($fonts); | ||
diff --git a/src/index.php b/src/index.php new file mode 100644 index 0000000..062d80e --- /dev/null +++ b/src/index.php | |||
| @@ -0,0 +1,693 @@ | |||
| 1 | <?php /* sent-web — index.php */ ?> | ||
| 2 | <!DOCTYPE html> | ||
| 3 | <html lang="en"> | ||
| 4 | |||
| 5 | <head> | ||
| 6 | <meta charset="UTF-8"> | ||
| 7 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| 8 | <title>sent-web</title> | ||
| 9 | <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kj-sh604/noir.css@latest/out/noir.min.css"> | ||
| 10 | <style> :root { --sent-fg: #000000; --sent-bg: #ffffff; --sent-font: 'Noto Color Emoji', 'DejaVu Sans', sans-serif; } body { max-width: 960px; margin: 0 auto; padding: 1rem; } .subtitle { opacity: 0.6; font-size: 0.9em; margin-top: -0.8em; } /* ── controls ── */ #controls { display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; margin-bottom: 1rem; padding: 0.75rem; border: 1px solid currentColor; border-radius: 4px; } #controls label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; } #controls input[type="color"] { width: 2rem; height: 2rem; padding: 0; border: 1px solid currentColor; cursor: pointer; background: none; } #controls select { max-width: 200px; } .upload-area { display: flex; align-items: center; gap: 0.5rem; } #upload-input { display: none; } #upload-status { font-size: 0.85rem; opacity: 0.7; } /* ── editor ── */ #input { width: 100%; min-height: 420px; font-family: monospace; font-size: 0.95rem; resize: vertical; tab-size: 4; } .btn-row { display: flex; gap: 0.5rem; margin-top: 0.5rem; } .hint { font-size: 0.8rem; opacity: 0.5; margin-top: 0.25rem; } /* ── presentation overlay ── */ #presentation { position: fixed; inset: 0; z-index: 9999; display: none; align-items: center; justify-content: flex-start; background: var(--sent-bg); color: var(--sent-fg); cursor: none; overflow: hidden; padding-left: 7.5%; } #presentation.active { display: flex; } #slide-content { text-align: left; white-space: pre-line; word-wrap: break-word; font-family: var(--sent-font); } #slide-content img { max-width: 85vw; max-height: 85vh; width: 85vw; height: 85vh; object-fit: contain; } </style> | ||
| 11 | </head> | ||
| 12 | |||
| 13 | <body> | ||
| 14 | |||
| 15 | <header> | ||
| 16 | <h1>sent-web</h1> | ||
| 17 | <p class="subtitle">suckless's sent tool ported to the very sucky web world</p> | ||
| 18 | </header> | ||
| 19 | |||
| 20 | <main> | ||
| 21 | <section id="controls"> | ||
| 22 | <label> | ||
| 23 | fg | ||
| 24 | <input type="color" id="fg-color" value="#000000"> | ||
| 25 | </label> | ||
| 26 | <label> | ||
| 27 | bg | ||
| 28 | <input type="color" id="bg-color" value="#ffffff"> | ||
| 29 | </label> | ||
| 30 | <label> | ||
| 31 | font | ||
| 32 | <select id="font-select"> | ||
| 33 | <option value="">loading…</option> | ||
| 34 | </select> | ||
| 35 | </label> | ||
| 36 | <div class="upload-area"> | ||
| 37 | <button type="button" onclick="document.getElementById('upload-input').click()">upload image</button> | ||
| 38 | <input type="file" id="upload-input" accept="image/*"> | ||
| 39 | <span id="upload-status"></span> | ||
| 40 | </div> | ||
| 41 | </section> | ||
| 42 | |||
| 43 | <textarea id="input"> | ||
| 44 | sent-web | ||
| 45 | |||
| 46 | a port of suckless sent | ||
| 47 | for the very sucky web 🌎 | ||
| 48 | |||
| 49 | # this is a comment and will not appear | ||
| 50 | # the next slide is blank slide since we escaped with \ | ||
| 51 | |||
| 52 | \ | ||
| 53 | |||
| 54 | why? | ||
| 55 | • PPTX sucks | ||
| 56 | • LATEX sucks | ||
| 57 | • PDF sucks | ||
| 58 | • but everything is in | ||
| 59 | the web now 😓 | ||
| 60 | |||
| 61 | easy to use | ||
| 62 | |||
| 63 | ▸ one slide per paragraph | ||
| 64 | ▸ lines starting with # are ignored | ||
| 65 | ▸ image slide: @filename | ||
| 66 | ▸ empty slide: just use \ | ||
| 67 | |||
| 68 | navigate with: | ||
| 69 | ← → ↑ ↓ h j k l | ||
| 70 | space, enter, backspace | ||
| 71 | or mouse clicks | ||
| 72 | |||
| 73 | press escape to exit | ||
| 74 | |||
| 75 | 😀😁😂😃😄😅😆😇😈😉😊 | ||
| 76 | emoji just works™ | ||
| 77 | |||
| 78 | thanks. | ||
| 79 | questions?</textarea> | ||
| 80 | |||
| 81 | <div class="btn-row"> | ||
| 82 | <button type="button" id="btn-present" onclick="App.startPresentation()">present</button> | ||
| 83 | <button type="button" onclick="App.downloadSent()">download for local sent usage</button> | ||
| 84 | <button type="button" onclick="App.exportPDF()">export .pdf</button> | ||
| 85 | </div> | ||
| 86 | <p class="hint">F5 to present · Esc to exit · ← → h l j k space enter to navigate</p> | ||
| 87 | </main> | ||
| 88 | |||
| 89 | <!-- presentation overlay --> | ||
| 90 | <div id="presentation"> | ||
| 91 | <div id="slide-content"></div> | ||
| 92 | </div> | ||
| 93 | |||
| 94 | <script> | ||
| 95 | // sent-web — vanilla JS presentation engine | ||
| 96 | |||
| 97 | const App = { | ||
| 98 | slides: [], | ||
| 99 | idx: 0, | ||
| 100 | presenting: false, | ||
| 101 | loadedFonts: new Set(), | ||
| 102 | |||
| 103 | settings: { | ||
| 104 | fg: '#000000', | ||
| 105 | bg: '#ffffff', | ||
| 106 | fontFamily: '', | ||
| 107 | lineSpacing: 1.4, | ||
| 108 | usableWidth: 0.85, | ||
| 109 | usableHeight: 0.85, | ||
| 110 | }, | ||
| 111 | |||
| 112 | init() { | ||
| 113 | this.restoreState(); | ||
| 114 | this.loadFonts(); | ||
| 115 | this.bindEvents(); | ||
| 116 | this.updateColors(); | ||
| 117 | }, | ||
| 118 | |||
| 119 | restoreState() { | ||
| 120 | const saved = localStorage.getItem('sent-web-content'); | ||
| 121 | if (saved !== null) { | ||
| 122 | document.getElementById('input').value = saved; | ||
| 123 | } | ||
| 124 | |||
| 125 | const raw = localStorage.getItem('sent-web-settings'); | ||
| 126 | if (raw) { | ||
| 127 | try { | ||
| 128 | const s = JSON.parse(raw); | ||
| 129 | if (s.fg) { | ||
| 130 | this.settings.fg = s.fg; | ||
| 131 | document.getElementById('fg-color').value = s.fg; | ||
| 132 | } | ||
| 133 | if (s.bg) { | ||
| 134 | this.settings.bg = s.bg; | ||
| 135 | document.getElementById('bg-color').value = s.bg; | ||
| 136 | } | ||
| 137 | if (s.fontFamily) this.settings.fontFamily = s.fontFamily; | ||
| 138 | } catch (_) {} | ||
| 139 | } | ||
| 140 | }, | ||
| 141 | |||
| 142 | saveSettings() { | ||
| 143 | localStorage.setItem('sent-web-settings', JSON.stringify({ | ||
| 144 | fg: this.settings.fg, | ||
| 145 | bg: this.settings.bg, | ||
| 146 | fontFamily: this.settings.fontFamily, | ||
| 147 | })); | ||
| 148 | }, | ||
| 149 | |||
| 150 | async loadFonts() { | ||
| 151 | try { | ||
| 152 | const res = await fetch('fonts.php'); | ||
| 153 | const data = await res.json(); | ||
| 154 | const sel = document.getElementById('font-select'); | ||
| 155 | sel.innerHTML = ''; | ||
| 156 | |||
| 157 | // preload fallback fonts | ||
| 158 | const notoEmoji = data.find(f => f.family === 'Noto Color Emoji'); | ||
| 159 | const dejavu = data.find(f => f.family.startsWith('DejaVu Sans') && !f.family.includes('Mono')); | ||
| 160 | |||
| 161 | if (notoEmoji) await this.loadFont(notoEmoji); | ||
| 162 | if (dejavu) await this.loadFont(dejavu); | ||
| 163 | |||
| 164 | // populate dropdown | ||
| 165 | let selectedOpt = null; | ||
| 166 | data.forEach(f => { | ||
| 167 | const opt = document.createElement('option'); | ||
| 168 | opt.value = JSON.stringify(f); | ||
| 169 | opt.textContent = f.family; | ||
| 170 | sel.appendChild(opt); | ||
| 171 | |||
| 172 | // restore saved selection or default to DejaVu Sans | ||
| 173 | if (this.settings.fontFamily && f.family === this.settings.fontFamily) { | ||
| 174 | selectedOpt = opt; | ||
| 175 | } else if (!this.settings.fontFamily && dejavu && f.family === dejavu.family) { | ||
| 176 | selectedOpt = opt; | ||
| 177 | } | ||
| 178 | }); | ||
| 179 | |||
| 180 | if (selectedOpt) selectedOpt.selected = true; | ||
| 181 | this.onFontChange(); | ||
| 182 | } catch (e) { | ||
| 183 | console.error('Failed to load fonts:', e); | ||
| 184 | } | ||
| 185 | }, | ||
| 186 | |||
| 187 | async loadFont(fontData) { | ||
| 188 | if (this.loadedFonts.has(fontData.family)) return; | ||
| 189 | |||
| 190 | try { | ||
| 191 | const url = `font.php?f=${encodeURIComponent(fontData.file)}`; | ||
| 192 | const src = `local('${fontData.family}'), url(${url}) format('${fontData.format}')`; | ||
| 193 | const face = new FontFace(fontData.family, src, { | ||
| 194 | display: 'swap' | ||
| 195 | }); | ||
| 196 | const loaded = await face.load(); | ||
| 197 | document.fonts.add(loaded); | ||
| 198 | this.loadedFonts.add(fontData.family); | ||
| 199 | } catch (e) { | ||
| 200 | console.warn(`Could not load font "${fontData.family}":`, e); | ||
| 201 | } | ||
| 202 | }, | ||
| 203 | |||
| 204 | async onFontChange() { | ||
| 205 | const sel = document.getElementById('font-select'); | ||
| 206 | if (!sel.value) return; | ||
| 207 | |||
| 208 | const fontData = JSON.parse(sel.value); | ||
| 209 | this.settings.fontFamily = fontData.family; | ||
| 210 | await this.loadFont(fontData); | ||
| 211 | |||
| 212 | const stack = `'${fontData.family}', 'Noto Color Emoji', 'DejaVu Sans', sans-serif`; | ||
| 213 | document.documentElement.style.setProperty('--sent-font', stack); | ||
| 214 | |||
| 215 | this.saveSettings(); | ||
| 216 | if (this.presenting) this.renderSlide(); | ||
| 217 | }, | ||
| 218 | |||
| 219 | bindEvents() { | ||
| 220 | document.getElementById('fg-color').addEventListener('input', () => this.updateColors()); | ||
| 221 | document.getElementById('bg-color').addEventListener('input', () => this.updateColors()); | ||
| 222 | document.getElementById('font-select').addEventListener('change', () => this.onFontChange()); | ||
| 223 | document.getElementById('upload-input').addEventListener('change', e => this.handleUpload(e)); | ||
| 224 | |||
| 225 | document.getElementById('input').addEventListener('input', () => { | ||
| 226 | localStorage.setItem('sent-web-content', document.getElementById('input').value); | ||
| 227 | }); | ||
| 228 | |||
| 229 | document.addEventListener('keydown', e => this.handleKeydown(e)); | ||
| 230 | |||
| 231 | const pres = document.getElementById('presentation'); | ||
| 232 | pres.addEventListener('click', e => { | ||
| 233 | if (e.clientX < window.innerWidth / 2) this.navigate(-1); | ||
| 234 | else this.navigate(1); | ||
| 235 | }); | ||
| 236 | pres.addEventListener('wheel', e => { | ||
| 237 | e.preventDefault(); | ||
| 238 | this.navigate(e.deltaY > 0 ? 1 : -1); | ||
| 239 | }, { | ||
| 240 | passive: false | ||
| 241 | }); | ||
| 242 | |||
| 243 | window.addEventListener('resize', () => { | ||
| 244 | if (this.presenting) this.renderSlide(); | ||
| 245 | }); | ||
| 246 | |||
| 247 | // stop presentation whenever fullscreen is exited (covers browser- | ||
| 248 | // intercepted Escape that never reaches the keydown handler) | ||
| 249 | document.addEventListener('fullscreenchange', () => { | ||
| 250 | if (!document.fullscreenElement && this.presenting) { | ||
| 251 | this.stopPresentation(); | ||
| 252 | } | ||
| 253 | }); | ||
| 254 | }, | ||
| 255 | |||
| 256 | updateColors() { | ||
| 257 | this.settings.fg = document.getElementById('fg-color').value; | ||
| 258 | this.settings.bg = document.getElementById('bg-color').value; | ||
| 259 | document.documentElement.style.setProperty('--sent-fg', this.settings.fg); | ||
| 260 | document.documentElement.style.setProperty('--sent-bg', this.settings.bg); | ||
| 261 | this.saveSettings(); | ||
| 262 | if (this.presenting) this.renderSlide(); | ||
| 263 | }, | ||
| 264 | |||
| 265 | // sent format parser | ||
| 266 | parseSent(text) { | ||
| 267 | const slides = []; | ||
| 268 | const paragraphs = text.split(/\n{2,}/); | ||
| 269 | |||
| 270 | for (const para of paragraphs) { | ||
| 271 | const rawLines = para.split('\n'); | ||
| 272 | const lines = []; | ||
| 273 | let img = null; | ||
| 274 | let firstContent = true; | ||
| 275 | |||
| 276 | for (const raw of rawLines) { | ||
| 277 | if (raw.trim() === '' || raw.startsWith('#')) continue; | ||
| 278 | |||
| 279 | let line = raw; | ||
| 280 | |||
| 281 | if (firstContent && line.startsWith('@')) { | ||
| 282 | img = line.substring(1).trim(); | ||
| 283 | firstContent = false; | ||
| 284 | continue; | ||
| 285 | } | ||
| 286 | firstContent = false; | ||
| 287 | |||
| 288 | // image slides ignore remaining text lines | ||
| 289 | if (img !== null) continue; | ||
| 290 | |||
| 291 | // strip leading backslash (escape) | ||
| 292 | if (line.startsWith('\\')) { | ||
| 293 | line = line.substring(1); | ||
| 294 | } | ||
| 295 | |||
| 296 | lines.push(line); | ||
| 297 | } | ||
| 298 | |||
| 299 | if (img !== null || lines.length > 0) { | ||
| 300 | slides.push({ | ||
| 301 | lines, | ||
| 302 | img | ||
| 303 | }); | ||
| 304 | } | ||
| 305 | } | ||
| 306 | |||
| 307 | return slides; | ||
| 308 | }, | ||
| 309 | |||
| 310 | // presentation controls | ||
| 311 | startPresentation() { | ||
| 312 | const text = document.getElementById('input').value; | ||
| 313 | this.slides = this.parseSent(text); | ||
| 314 | |||
| 315 | if (this.slides.length === 0) { | ||
| 316 | alert('No slides to present.'); | ||
| 317 | return; | ||
| 318 | } | ||
| 319 | |||
| 320 | this.idx = 0; | ||
| 321 | this.presenting = true; | ||
| 322 | document.getElementById('presentation').classList.add('active'); | ||
| 323 | document.body.style.overflow = 'hidden'; | ||
| 324 | |||
| 325 | const el = document.getElementById('presentation'); | ||
| 326 | if (el.requestFullscreen) el.requestFullscreen().catch(() => {}); | ||
| 327 | |||
| 328 | this.renderSlide(); | ||
| 329 | }, | ||
| 330 | |||
| 331 | stopPresentation() { | ||
| 332 | this.presenting = false; | ||
| 333 | document.getElementById('presentation').classList.remove('active'); | ||
| 334 | document.body.style.overflow = ''; | ||
| 335 | |||
| 336 | if (document.fullscreenElement) { | ||
| 337 | document.exitFullscreen().catch(() => {}); | ||
| 338 | } | ||
| 339 | }, | ||
| 340 | |||
| 341 | navigate(dir) { | ||
| 342 | if (!this.presenting) return; | ||
| 343 | const next = this.idx + dir; | ||
| 344 | if (next >= 0 && next < this.slides.length) { | ||
| 345 | this.idx = next; | ||
| 346 | this.renderSlide(); | ||
| 347 | } | ||
| 348 | }, | ||
| 349 | |||
| 350 | // rendering engine | ||
| 351 | renderSlide() { | ||
| 352 | if (!this.presenting || this.slides.length === 0) return; | ||
| 353 | |||
| 354 | const slide = this.slides[this.idx]; | ||
| 355 | const content = document.getElementById('slide-content'); | ||
| 356 | const pres = document.getElementById('presentation'); | ||
| 357 | |||
| 358 | pres.style.backgroundColor = this.settings.bg; | ||
| 359 | pres.style.color = this.settings.fg; | ||
| 360 | |||
| 361 | content.innerHTML = ''; | ||
| 362 | |||
| 363 | if (slide.img) { | ||
| 364 | const img = document.createElement('img'); | ||
| 365 | if (slide.img.startsWith('http://') || slide.img.startsWith('https://')) { | ||
| 366 | img.src = slide.img; | ||
| 367 | } else { | ||
| 368 | img.src = 'uploads/' + slide.img; | ||
| 369 | } | ||
| 370 | img.alt = slide.img; | ||
| 371 | img.style.maxWidth = (this.settings.usableWidth * 100) + 'vw'; | ||
| 372 | img.style.maxHeight = (this.settings.usableHeight * 100) + 'vh'; | ||
| 373 | img.style.width = (this.settings.usableWidth * 100) + 'vw'; | ||
| 374 | img.style.height = (this.settings.usableHeight * 100) + 'vh'; | ||
| 375 | img.style.objectFit = 'contain'; | ||
| 376 | content.appendChild(img); | ||
| 377 | } else { | ||
| 378 | const fontSize = this.calcFontSize(slide.lines); | ||
| 379 | content.style.fontSize = fontSize + 'px'; | ||
| 380 | content.style.lineHeight = String(this.settings.lineSpacing); | ||
| 381 | |||
| 382 | slide.lines.forEach((line, i) => { | ||
| 383 | if (i > 0) content.appendChild(document.createElement('br')); | ||
| 384 | content.appendChild(document.createTextNode(line)); | ||
| 385 | }); | ||
| 386 | } | ||
| 387 | |||
| 388 | }, | ||
| 389 | |||
| 390 | calcFontSize(lines) { | ||
| 391 | const maxW = window.innerWidth * this.settings.usableWidth; | ||
| 392 | const maxH = window.innerHeight * this.settings.usableHeight; | ||
| 393 | const font = getComputedStyle(document.documentElement) | ||
| 394 | .getPropertyValue('--sent-font').trim() || 'sans-serif'; | ||
| 395 | |||
| 396 | const canvas = document.createElement('canvas'); | ||
| 397 | const ctx = canvas.getContext('2d'); | ||
| 398 | |||
| 399 | let lo = 1, | ||
| 400 | hi = 500, | ||
| 401 | best = 1; | ||
| 402 | |||
| 403 | while (lo <= hi) { | ||
| 404 | const mid = Math.floor((lo + hi) / 2); | ||
| 405 | ctx.font = `${mid}px ${font}`; | ||
| 406 | |||
| 407 | let fits = true; | ||
| 408 | for (const line of lines) { | ||
| 409 | if (ctx.measureText(line).width > maxW) { | ||
| 410 | fits = false; | ||
| 411 | break; | ||
| 412 | } | ||
| 413 | } | ||
| 414 | |||
| 415 | // height check: line-spacing × (n-1) + 1 base height | ||
| 416 | const totalH = mid * this.settings.lineSpacing * (lines.length - 1) + mid; | ||
| 417 | if (totalH > maxH) fits = false; | ||
| 418 | |||
| 419 | if (fits) { | ||
| 420 | best = mid; | ||
| 421 | lo = mid + 1; | ||
| 422 | } else { | ||
| 423 | hi = mid - 1; | ||
| 424 | } | ||
| 425 | } | ||
| 426 | |||
| 427 | return best; | ||
| 428 | }, | ||
| 429 | |||
| 430 | // keyboard handler | ||
| 431 | |||
| 432 | handleKeydown(e) { | ||
| 433 | // F5 to start presentation from editor | ||
| 434 | if (!this.presenting && e.key === 'F5') { | ||
| 435 | e.preventDefault(); | ||
| 436 | this.startPresentation(); | ||
| 437 | return; | ||
| 438 | } | ||
| 439 | |||
| 440 | if (!this.presenting) return; | ||
| 441 | |||
| 442 | switch (e.key) { | ||
| 443 | case 'Escape': | ||
| 444 | case 'q': | ||
| 445 | e.preventDefault(); | ||
| 446 | this.stopPresentation(); | ||
| 447 | break; | ||
| 448 | case 'ArrowRight': | ||
| 449 | case 'ArrowDown': | ||
| 450 | case ' ': | ||
| 451 | case 'Enter': | ||
| 452 | case 'l': | ||
| 453 | case 'j': | ||
| 454 | case 'n': | ||
| 455 | case 'PageDown': | ||
| 456 | e.preventDefault(); | ||
| 457 | this.navigate(1); | ||
| 458 | break; | ||
| 459 | case 'ArrowLeft': | ||
| 460 | case 'ArrowUp': | ||
| 461 | case 'Backspace': | ||
| 462 | case 'h': | ||
| 463 | case 'k': | ||
| 464 | case 'p': | ||
| 465 | case 'PageUp': | ||
| 466 | e.preventDefault(); | ||
| 467 | this.navigate(-1); | ||
| 468 | break; | ||
| 469 | } | ||
| 470 | }, | ||
| 471 | |||
| 472 | // image upload | ||
| 473 | |||
| 474 | async handleUpload(e) { | ||
| 475 | const file = e.target.files[0]; | ||
| 476 | if (!file) return; | ||
| 477 | |||
| 478 | const status = document.getElementById('upload-status'); | ||
| 479 | status.textContent = 'uploading…'; | ||
| 480 | |||
| 481 | const fd = new FormData(); | ||
| 482 | fd.append('image', file); | ||
| 483 | |||
| 484 | try { | ||
| 485 | const res = await fetch('upload.php', { | ||
| 486 | method: 'POST', | ||
| 487 | body: fd | ||
| 488 | }); | ||
| 489 | const data = await res.json(); | ||
| 490 | |||
| 491 | if (data.error) { | ||
| 492 | status.textContent = 'error: ' + data.error; | ||
| 493 | return; | ||
| 494 | } | ||
| 495 | |||
| 496 | // insert @filename at cursor position | ||
| 497 | const ta = document.getElementById('input'); | ||
| 498 | const pos = ta.selectionStart; | ||
| 499 | const txt = ta.value; | ||
| 500 | const ins = `\n@${data.filename}\n`; | ||
| 501 | ta.value = txt.substring(0, pos) + ins + txt.substring(pos); | ||
| 502 | ta.selectionStart = ta.selectionEnd = pos + ins.length; | ||
| 503 | ta.focus(); | ||
| 504 | |||
| 505 | localStorage.setItem('sent-web-content', ta.value); | ||
| 506 | status.textContent = `uploaded: ${data.filename}`; | ||
| 507 | setTimeout(() => { | ||
| 508 | status.textContent = ''; | ||
| 509 | }, 3000); | ||
| 510 | } catch (err) { | ||
| 511 | status.textContent = 'upload failed'; | ||
| 512 | console.error(err); | ||
| 513 | } | ||
| 514 | |||
| 515 | e.target.value = ''; | ||
| 516 | }, | ||
| 517 | |||
| 518 | // download .sent file for local usage (base64-encoded to preserve unicode and avoid filename issues) | ||
| 519 | downloadSent() { | ||
| 520 | const text = document.getElementById('input').value; | ||
| 521 | const blob = new Blob([text], { | ||
| 522 | type: 'text/plain' | ||
| 523 | }); | ||
| 524 | const a = document.createElement('a'); | ||
| 525 | a.href = URL.createObjectURL(blob); | ||
| 526 | a.download = 'presentation.sent'; | ||
| 527 | a.click(); | ||
| 528 | URL.revokeObjectURL(a.href); | ||
| 529 | }, | ||
| 530 | |||
| 531 | // download pdf export of the presentation from canvas | ||
| 532 | async exportPDF() { | ||
| 533 | const text = document.getElementById('input').value; | ||
| 534 | const slides = this.parseSent(text); | ||
| 535 | |||
| 536 | if (slides.length === 0) { | ||
| 537 | alert('No slides to export.'); | ||
| 538 | return; | ||
| 539 | } | ||
| 540 | |||
| 541 | const btn = document.querySelector('button[onclick="App.exportPDF()"]'); | ||
| 542 | if (btn) { | ||
| 543 | btn.textContent = 'generating pdf…'; | ||
| 544 | btn.disabled = true; | ||
| 545 | } | ||
| 546 | |||
| 547 | try { | ||
| 548 | if (!window.jspdf) { | ||
| 549 | await new Promise((resolve, reject) => { | ||
| 550 | const s = document.createElement('script'); | ||
| 551 | s.src = 'https://cdn.jsdelivr.net/npm/jspdf@2.5.2/dist/jspdf.umd.min.js'; | ||
| 552 | s.onload = resolve; | ||
| 553 | s.onerror = () => reject(new Error('Failed to load jsPDF')); | ||
| 554 | document.head.appendChild(s); | ||
| 555 | }); | ||
| 556 | } | ||
| 557 | |||
| 558 | // 1440p canvas per slide — rasterise via browser engine | ||
| 559 | // (handles fonts, unicode, emoji, images exactly as the live view does) | ||
| 560 | const W = 2560; | ||
| 561 | const H = 1440; | ||
| 562 | |||
| 563 | const { | ||
| 564 | jsPDF | ||
| 565 | } = window.jspdf; | ||
| 566 | // px unit + hotfix keeps jsPDF from rescaling our pixel-perfect canvases | ||
| 567 | const pdf = new jsPDF({ | ||
| 568 | orientation: 'landscape', | ||
| 569 | unit: 'px', | ||
| 570 | format: [W, H], | ||
| 571 | hotfixes: ['px_scaling'], | ||
| 572 | }); | ||
| 573 | |||
| 574 | for (let i = 0; i < slides.length; i++) { | ||
| 575 | const canvas = await this.renderSlideToCanvas(slides[i], W, H); | ||
| 576 | const imgData = canvas.toDataURL('image/jpeg', 0.93); | ||
| 577 | |||
| 578 | if (i > 0) pdf.addPage([W, H], 'landscape'); | ||
| 579 | pdf.addImage(imgData, 'JPEG', 0, 0, W, H); | ||
| 580 | } | ||
| 581 | |||
| 582 | const epoch = Math.floor(Date.now() / 1000); | ||
| 583 | const uid = crypto.randomUUID().replace(/-/g, '').slice(0, 8); | ||
| 584 | pdf.save(`sent-web${epoch}-${uid}.pdf`); | ||
| 585 | |||
| 586 | } catch (err) { | ||
| 587 | console.error('PDF export failed:', err); | ||
| 588 | alert('PDF export failed: ' + err.message); | ||
| 589 | } finally { | ||
| 590 | if (btn) { | ||
| 591 | btn.textContent = 'export .pdf'; | ||
| 592 | btn.disabled = false; | ||
| 593 | } | ||
| 594 | } | ||
| 595 | }, | ||
| 596 | |||
| 597 | // render one slide to an off-screen canvas, mirrors renderSlide() exactly | ||
| 598 | async renderSlideToCanvas(slide, W, H) { | ||
| 599 | const canvas = document.createElement('canvas'); | ||
| 600 | canvas.width = W; | ||
| 601 | canvas.height = H; | ||
| 602 | const ctx = canvas.getContext('2d'); | ||
| 603 | |||
| 604 | const usable = this.settings.usableWidth; // 0.85 | ||
| 605 | const maxW = W * usable; | ||
| 606 | const maxH = H * usable; | ||
| 607 | const marginX = (W - maxW) / 2; | ||
| 608 | |||
| 609 | // resolve the same font stack the live view uses | ||
| 610 | const fontStack = getComputedStyle(document.documentElement) | ||
| 611 | .getPropertyValue('--sent-font').trim() || 'sans-serif'; | ||
| 612 | |||
| 613 | // background | ||
| 614 | ctx.fillStyle = this.settings.bg; | ||
| 615 | ctx.fillRect(0, 0, W, H); | ||
| 616 | |||
| 617 | if (slide.img) { | ||
| 618 | // image slide — draw the actual image | ||
| 619 | await new Promise((resolve) => { | ||
| 620 | const img = new Image(); | ||
| 621 | img.crossOrigin = 'anonymous'; | ||
| 622 | img.onload = () => { | ||
| 623 | const ratio = img.naturalWidth / img.naturalHeight; | ||
| 624 | let dw = maxW, | ||
| 625 | dh = maxH; | ||
| 626 | if (dw / dh > ratio) dw = dh * ratio; | ||
| 627 | else dh = dw / ratio; | ||
| 628 | ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh); | ||
| 629 | resolve(); | ||
| 630 | }; | ||
| 631 | img.onerror = resolve; // still produce a page even on failure | ||
| 632 | img.src = (slide.img.startsWith('http://') || slide.img.startsWith('https://')) ? | ||
| 633 | slide.img : | ||
| 634 | 'uploads/' + slide.img; | ||
| 635 | }); | ||
| 636 | } else { | ||
| 637 | // text slide — left-aligned, same sizing logic as live view | ||
| 638 | const fontSize = this.calcFontSizeCanvas(ctx, slide.lines, fontStack, maxW, maxH); | ||
| 639 | |||
| 640 | ctx.font = `${fontSize}px ${fontStack}`; | ||
| 641 | ctx.fillStyle = this.settings.fg; | ||
| 642 | ctx.textBaseline = 'alphabetic'; | ||
| 643 | |||
| 644 | const lineH = fontSize * this.settings.lineSpacing; | ||
| 645 | const totalH = lineH * (slide.lines.length - 1) + fontSize; | ||
| 646 | const startX = marginX; | ||
| 647 | const startY = (H - totalH) / 2 + fontSize; // first baseline | ||
| 648 | |||
| 649 | slide.lines.forEach((line, i) => { | ||
| 650 | ctx.fillText(line, startX, startY + i * lineH); | ||
| 651 | }); | ||
| 652 | } | ||
| 653 | |||
| 654 | return canvas; | ||
| 655 | }, | ||
| 656 | |||
| 657 | // binary-search font size on a canvas context for given absolute dimensions | ||
| 658 | calcFontSizeCanvas(ctx, lines, fontStack, maxW, maxH) { | ||
| 659 | let lo = 1, | ||
| 660 | hi = 600, | ||
| 661 | best = 1; | ||
| 662 | |||
| 663 | while (lo <= hi) { | ||
| 664 | const mid = Math.floor((lo + hi) / 2); | ||
| 665 | ctx.font = `${mid}px ${fontStack}`; | ||
| 666 | |||
| 667 | let fits = true; | ||
| 668 | for (const line of lines) { | ||
| 669 | if (ctx.measureText(line).width > maxW) { | ||
| 670 | fits = false; | ||
| 671 | break; | ||
| 672 | } | ||
| 673 | } | ||
| 674 | |||
| 675 | const totalH = mid * this.settings.lineSpacing * (lines.length - 1) + mid; | ||
| 676 | if (totalH > maxH) fits = false; | ||
| 677 | |||
| 678 | if (fits) { | ||
| 679 | best = mid; | ||
| 680 | lo = mid + 1; | ||
| 681 | } else { | ||
| 682 | hi = mid - 1; | ||
| 683 | } | ||
| 684 | } | ||
| 685 | |||
| 686 | return best; | ||
| 687 | }, | ||
| 688 | }; | ||
| 689 | |||
| 690 | document.addEventListener('DOMContentLoaded', () => App.init()); | ||
| 691 | </script> | ||
| 692 | </body> | ||
| 693 | </html> \ No newline at end of file | ||
diff --git a/src/upload.php b/src/upload.php new file mode 100644 index 0000000..62db139 --- /dev/null +++ b/src/upload.php | |||
| @@ -0,0 +1,66 @@ | |||
| 1 | <?php | ||
| 2 | /* upload.php — handle image uploads */ | ||
| 3 | |||
| 4 | header('Content-Type: application/json'); | ||
| 5 | |||
| 6 | if ($_SERVER['REQUEST_METHOD'] !== 'POST') { | ||
| 7 | http_response_code(405); | ||
| 8 | echo json_encode(['error' => 'Method not allowed']); | ||
| 9 | exit; | ||
| 10 | } | ||
| 11 | |||
| 12 | if (!isset($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) { | ||
| 13 | $code = $_FILES['image']['error'] ?? 'unknown'; | ||
| 14 | http_response_code(400); | ||
| 15 | echo json_encode(['error' => "Upload failed (code: $code)"]); | ||
| 16 | exit; | ||
| 17 | } | ||
| 18 | |||
| 19 | $file = $_FILES['image']; | ||
| 20 | $allowed = [ | ||
| 21 | 'image/png', 'image/jpeg', 'image/gif', | ||
| 22 | 'image/webp', 'image/svg+xml', 'image/bmp', | ||
| 23 | ]; | ||
| 24 | |||
| 25 | $finfo = finfo_open(FILEINFO_MIME_TYPE); | ||
| 26 | $mime = finfo_file($finfo, $file['tmp_name']); | ||
| 27 | finfo_close($finfo); | ||
| 28 | |||
| 29 | if (!in_array($mime, $allowed, true)) { | ||
| 30 | http_response_code(400); | ||
| 31 | echo json_encode(['error' => "Invalid file type: $mime"]); | ||
| 32 | exit; | ||
| 33 | } | ||
| 34 | |||
| 35 | $ext = match ($mime) { | ||
| 36 | 'image/png' => 'png', | ||
| 37 | 'image/jpeg' => 'jpg', | ||
| 38 | 'image/gif' => 'gif', | ||
| 39 | 'image/webp' => 'webp', | ||
| 40 | 'image/svg+xml' => 'svg', | ||
| 41 | 'image/bmp' => 'bmp', | ||
| 42 | default => 'bin', | ||
| 43 | }; | ||
| 44 | |||
| 45 | /* generate safe filename */ | ||
| 46 | $basename = pathinfo($file['name'], PATHINFO_FILENAME); | ||
| 47 | $basename = preg_replace('/[^a-zA-Z0-9_-]/', '_', $basename); | ||
| 48 | $basename = substr($basename, 0, 64); | ||
| 49 | $filename = $basename . '_' . bin2hex(random_bytes(4)) . '.' . $ext; | ||
| 50 | |||
| 51 | $uploadDir = __DIR__ . '/uploads'; | ||
| 52 | if (!is_dir($uploadDir)) { | ||
| 53 | mkdir($uploadDir, 0755, true); | ||
| 54 | } | ||
| 55 | |||
| 56 | $dest = $uploadDir . '/' . $filename; | ||
| 57 | if (!move_uploaded_file($file['tmp_name'], $dest)) { | ||
| 58 | http_response_code(500); | ||
| 59 | echo json_encode(['error' => 'Failed to save file']); | ||
| 60 | exit; | ||
| 61 | } | ||
| 62 | |||
| 63 | echo json_encode([ | ||
| 64 | 'filename' => $filename, | ||
| 65 | 'url' => 'uploads/' . $filename, | ||
| 66 | ]); | ||
diff --git a/src/uploads/.htaccess b/src/uploads/.htaccess new file mode 100644 index 0000000..2cf2ddc --- /dev/null +++ b/src/uploads/.htaccess | |||
| @@ -0,0 +1,2 @@ | |||
| 1 | # prevent PHP execution in uploads directory | ||
| 2 | php_flag engine off | ||
