diff options
Diffstat (limited to 'src/index.html')
| -rw-r--r-- | src/index.html | 742 |
1 files changed, 742 insertions, 0 deletions
diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..ef2f584 --- /dev/null +++ b/src/index.html | |||
| @@ -0,0 +1,742 @@ | |||
| 1 | <!DOCTYPE html> | ||
| 2 | <html lang="en"> | ||
| 3 | |||
| 4 | <head> | ||
| 5 | <meta charset="UTF-8"> | ||
| 6 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| 7 | <title>sent-web</title> | ||
| 8 | <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kj-sh604/noir.css@latest/out/noir.min.css"> | ||
| 9 | <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 { display: block; max-width: 85vw; max-height: 85vh; object-fit: contain; } </style> | ||
| 10 | <script> | ||
| 11 | /* | ||
| 12 | @licstart The following is the entire license notice for the | ||
| 13 | JavaScript code in this page. | ||
| 14 | |||
| 15 | Copyright (c) 2026 kj_sh604/kj-sh604/kjsh604 | ||
| 16 | |||
| 17 | SPDX-License-Identifier: MIT | ||
| 18 | |||
| 19 | Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| 20 | of this software and associated documentation files (the "Software"), to deal | ||
| 21 | in the Software without restriction, including without limitation the rights | ||
| 22 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| 23 | copies of the Software, and to permit persons to whom the Software is | ||
| 24 | furnished to do so, subject to the following conditions: | ||
| 25 | |||
| 26 | The above copyright notice and this permission notice shall be included in all | ||
| 27 | copies or substantial portions of the Software. | ||
| 28 | |||
| 29 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| 30 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| 31 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| 32 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| 33 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| 34 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| 35 | SOFTWARE. | ||
| 36 | |||
| 37 | @licend The above is the entire license notice | ||
| 38 | for the JavaScript code in this page. | ||
| 39 | */ | ||
| 40 | </script> | ||
| 41 | </head> | ||
| 42 | |||
| 43 | <body> | ||
| 44 | |||
| 45 | <header> | ||
| 46 | <h1>sent-web</h1> | ||
| 47 | <p class="subtitle">suckless's sent tool ported to the very sucky web world</p> | ||
| 48 | </header> | ||
| 49 | |||
| 50 | <main> | ||
| 51 | <section id="controls"> | ||
| 52 | <label> | ||
| 53 | fg | ||
| 54 | <input type="color" id="fg-color" value="#000000"> | ||
| 55 | </label> | ||
| 56 | <label> | ||
| 57 | bg | ||
| 58 | <input type="color" id="bg-color" value="#ffffff"> | ||
| 59 | </label> | ||
| 60 | <label> | ||
| 61 | font | ||
| 62 | <select id="font-select"> | ||
| 63 | <option value="">loading…</option> | ||
| 64 | </select> | ||
| 65 | </label> | ||
| 66 | <div class="upload-area"> | ||
| 67 | <button type="button" onclick="document.getElementById('upload-input').click()">upload image</button> | ||
| 68 | <input type="file" id="upload-input" accept="image/*"> | ||
| 69 | <span id="upload-status"></span> | ||
| 70 | </div> | ||
| 71 | </section> | ||
| 72 | |||
| 73 | <textarea id="input"> | ||
| 74 | sent-web | ||
| 75 | |||
| 76 | a port of suckless sent | ||
| 77 | for the very sucky web 🌎 | ||
| 78 | |||
| 79 | # this is a comment and will not appear | ||
| 80 | # the next slide is blank slide since we escaped with \ | ||
| 81 | |||
| 82 | \ | ||
| 83 | |||
| 84 | @nyan_819cac51.png | ||
| 85 | |||
| 86 | why? | ||
| 87 | • PPTX sucks | ||
| 88 | • LATEX sucks | ||
| 89 | • PDF sucks | ||
| 90 | • but everything is in | ||
| 91 | the web now 😓 | ||
| 92 | |||
| 93 | easy to use | ||
| 94 | |||
| 95 | ▸ one slide per paragraph | ||
| 96 | ▸ lines starting with # are ignored | ||
| 97 | ▸ image slide: @filename | ||
| 98 | ▸ empty slide: just use \ | ||
| 99 | |||
| 100 | navigate with: | ||
| 101 | ← → ↑ ↓ h j k l | ||
| 102 | space, enter, backspace | ||
| 103 | or mouse clicks | ||
| 104 | |||
| 105 | press escape to exit | ||
| 106 | |||
| 107 | 😀😁😂😃😄😅😆😇😈😉😊 | ||
| 108 | emoji just works™ | ||
| 109 | |||
| 110 | thanks. | ||
| 111 | questions?</textarea> | ||
| 112 | |||
| 113 | <div class="btn-row"> | ||
| 114 | <button type="button" id="btn-present" onclick="App.startPresentation()">present</button> | ||
| 115 | <button type="button" onclick="App.downloadSent()">download for local sent usage</button> | ||
| 116 | <button type="button" onclick="App.exportPDF()">export .pdf</button> | ||
| 117 | </div> | ||
| 118 | <p class="hint">F5 to present · Esc to exit · ← → h l j k space enter to navigate</p> | ||
| 119 | </main> | ||
| 120 | |||
| 121 | <!-- presentation overlay --> | ||
| 122 | <div id="presentation"> | ||
| 123 | <div id="slide-content"></div> | ||
| 124 | </div> | ||
| 125 | |||
| 126 | <script> | ||
| 127 | // sent-web — vanilla JS presentation engine | ||
| 128 | |||
| 129 | const App = { | ||
| 130 | slides: [], | ||
| 131 | idx: 0, | ||
| 132 | presenting: false, | ||
| 133 | loadedFonts: new Set(), | ||
| 134 | |||
| 135 | settings: { | ||
| 136 | fg: '#000000', | ||
| 137 | bg: '#ffffff', | ||
| 138 | fontFamily: '', | ||
| 139 | lineSpacing: 1.25, | ||
| 140 | usableWidth: 0.85, | ||
| 141 | usableHeight: 0.85, | ||
| 142 | }, | ||
| 143 | |||
| 144 | init() { | ||
| 145 | this.restoreState(); | ||
| 146 | this.loadFonts(); | ||
| 147 | this.bindEvents(); | ||
| 148 | this.updateColors(); | ||
| 149 | }, | ||
| 150 | |||
| 151 | restoreState() { | ||
| 152 | const saved = localStorage.getItem('sent-web-content'); | ||
| 153 | if (saved !== null) { | ||
| 154 | document.getElementById('input').value = saved; | ||
| 155 | } | ||
| 156 | |||
| 157 | const raw = localStorage.getItem('sent-web-settings'); | ||
| 158 | if (raw) { | ||
| 159 | try { | ||
| 160 | const s = JSON.parse(raw); | ||
| 161 | if (s.fg) { | ||
| 162 | this.settings.fg = s.fg; | ||
| 163 | document.getElementById('fg-color').value = s.fg; | ||
| 164 | } | ||
| 165 | if (s.bg) { | ||
| 166 | this.settings.bg = s.bg; | ||
| 167 | document.getElementById('bg-color').value = s.bg; | ||
| 168 | } | ||
| 169 | if (s.fontFamily) this.settings.fontFamily = s.fontFamily; | ||
| 170 | } catch (_) {} | ||
| 171 | } | ||
| 172 | }, | ||
| 173 | |||
| 174 | saveSettings() { | ||
| 175 | localStorage.setItem('sent-web-settings', JSON.stringify({ | ||
| 176 | fg: this.settings.fg, | ||
| 177 | bg: this.settings.bg, | ||
| 178 | fontFamily: this.settings.fontFamily, | ||
| 179 | })); | ||
| 180 | }, | ||
| 181 | |||
| 182 | async loadFonts() { | ||
| 183 | try { | ||
| 184 | const res = await fetch('/fonts'); | ||
| 185 | const data = await res.json(); | ||
| 186 | const sel = document.getElementById('font-select'); | ||
| 187 | sel.innerHTML = ''; | ||
| 188 | |||
| 189 | // preload fallback fonts | ||
| 190 | const notoEmoji = data.find(f => f.family === 'Noto Color Emoji'); | ||
| 191 | const dejavu = data.find(f => f.family.startsWith('DejaVu Sans') && !f.family.includes('Mono')); | ||
| 192 | |||
| 193 | if (notoEmoji) await this.loadFont(notoEmoji); | ||
| 194 | if (dejavu) await this.loadFont(dejavu); | ||
| 195 | |||
| 196 | // populate dropdown | ||
| 197 | let selectedOpt = null; | ||
| 198 | data.forEach(f => { | ||
| 199 | const opt = document.createElement('option'); | ||
| 200 | opt.value = JSON.stringify(f); | ||
| 201 | opt.textContent = f.family; | ||
| 202 | sel.appendChild(opt); | ||
| 203 | |||
| 204 | // restore saved selection or default to DejaVu Sans | ||
| 205 | if (this.settings.fontFamily && f.family === this.settings.fontFamily) { | ||
| 206 | selectedOpt = opt; | ||
| 207 | } else if (!this.settings.fontFamily && dejavu && f.family === dejavu.family) { | ||
| 208 | selectedOpt = opt; | ||
| 209 | } | ||
| 210 | }); | ||
| 211 | |||
| 212 | if (selectedOpt) selectedOpt.selected = true; | ||
| 213 | this.onFontChange(); | ||
| 214 | } catch (e) { | ||
| 215 | console.error('Failed to load fonts:', e); | ||
| 216 | } | ||
| 217 | }, | ||
| 218 | |||
| 219 | async loadFont(fontData) { | ||
| 220 | if (this.loadedFonts.has(fontData.family)) return; | ||
| 221 | |||
| 222 | try { | ||
| 223 | const url = `/font?f=${encodeURIComponent(fontData.file)}`; | ||
| 224 | const src = `local('${fontData.family}'), url(${url}) format('${fontData.format}')`; | ||
| 225 | const face = new FontFace(fontData.family, src, { | ||
| 226 | display: 'swap' | ||
| 227 | }); | ||
| 228 | const loaded = await face.load(); | ||
| 229 | document.fonts.add(loaded); | ||
| 230 | this.loadedFonts.add(fontData.family); | ||
| 231 | } catch (e) { | ||
| 232 | console.warn(`Could not load font "${fontData.family}":`, e); | ||
| 233 | } | ||
| 234 | }, | ||
| 235 | |||
| 236 | async onFontChange() { | ||
| 237 | const sel = document.getElementById('font-select'); | ||
| 238 | if (!sel.value) return; | ||
| 239 | |||
| 240 | const fontData = JSON.parse(sel.value); | ||
| 241 | this.settings.fontFamily = fontData.family; | ||
| 242 | await this.loadFont(fontData); | ||
| 243 | |||
| 244 | const stack = `'${fontData.family}', 'Noto Color Emoji', 'DejaVu Sans', sans-serif`; | ||
| 245 | document.documentElement.style.setProperty('--sent-font', stack); | ||
| 246 | |||
| 247 | this.saveSettings(); | ||
| 248 | if (this.presenting) this.renderSlide(); | ||
| 249 | }, | ||
| 250 | |||
| 251 | bindEvents() { | ||
| 252 | document.getElementById('fg-color').addEventListener('input', () => this.updateColors()); | ||
| 253 | document.getElementById('bg-color').addEventListener('input', () => this.updateColors()); | ||
| 254 | document.getElementById('font-select').addEventListener('change', () => this.onFontChange()); | ||
| 255 | document.getElementById('upload-input').addEventListener('change', e => this.handleUpload(e)); | ||
| 256 | |||
| 257 | document.getElementById('input').addEventListener('input', () => { | ||
| 258 | localStorage.setItem('sent-web-content', document.getElementById('input').value); | ||
| 259 | }); | ||
| 260 | |||
| 261 | document.addEventListener('keydown', e => this.handleKeydown(e)); | ||
| 262 | |||
| 263 | const pres = document.getElementById('presentation'); | ||
| 264 | pres.addEventListener('click', e => { | ||
| 265 | if (e.clientX < window.innerWidth / 2) this.navigate(-1); | ||
| 266 | else this.navigate(1); | ||
| 267 | }); | ||
| 268 | pres.addEventListener('wheel', e => { | ||
| 269 | e.preventDefault(); | ||
| 270 | this.navigate(e.deltaY > 0 ? 1 : -1); | ||
| 271 | }, { | ||
| 272 | passive: false | ||
| 273 | }); | ||
| 274 | |||
| 275 | window.addEventListener('resize', () => { | ||
| 276 | if (this.presenting) this.renderSlide(); | ||
| 277 | }); | ||
| 278 | |||
| 279 | // stop presentation whenever fullscreen is exited (covers browser- | ||
| 280 | // intercepted Escape that never reaches the keydown handler). | ||
| 281 | document.addEventListener('fullscreenchange', () => { | ||
| 282 | if (!document.fullscreenElement && this.presenting) { | ||
| 283 | this.stopPresentation(); | ||
| 284 | } else if (document.fullscreenElement && this.presenting) { | ||
| 285 | this.renderSlide(); | ||
| 286 | } | ||
| 287 | }); | ||
| 288 | }, | ||
| 289 | |||
| 290 | updateColors() { | ||
| 291 | this.settings.fg = document.getElementById('fg-color').value; | ||
| 292 | this.settings.bg = document.getElementById('bg-color').value; | ||
| 293 | document.documentElement.style.setProperty('--sent-fg', this.settings.fg); | ||
| 294 | document.documentElement.style.setProperty('--sent-bg', this.settings.bg); | ||
| 295 | this.saveSettings(); | ||
| 296 | if (this.presenting) this.renderSlide(); | ||
| 297 | }, | ||
| 298 | |||
| 299 | // sent format parser | ||
| 300 | parseSent(text) { | ||
| 301 | const slides = []; | ||
| 302 | const paragraphs = text.split(/\n{2,}/); | ||
| 303 | |||
| 304 | for (const para of paragraphs) { | ||
| 305 | const rawLines = para.split('\n'); | ||
| 306 | const lines = []; | ||
| 307 | let img = null; | ||
| 308 | let firstContent = true; | ||
| 309 | |||
| 310 | for (const raw of rawLines) { | ||
| 311 | if (raw.trim() === '' || raw.startsWith('#')) continue; | ||
| 312 | |||
| 313 | let line = raw; | ||
| 314 | |||
| 315 | if (firstContent && line.startsWith('@')) { | ||
| 316 | img = line.substring(1).trim(); | ||
| 317 | firstContent = false; | ||
| 318 | continue; | ||
| 319 | } | ||
| 320 | firstContent = false; | ||
| 321 | |||
| 322 | // image slides ignore remaining text lines | ||
| 323 | if (img !== null) continue; | ||
| 324 | |||
| 325 | // strip leading backslash (escape) | ||
| 326 | if (line.startsWith('\\')) { | ||
| 327 | line = line.substring(1); | ||
| 328 | } | ||
| 329 | |||
| 330 | lines.push(line); | ||
| 331 | } | ||
| 332 | |||
| 333 | if (img !== null || lines.length > 0) { | ||
| 334 | slides.push({ | ||
| 335 | lines, | ||
| 336 | img | ||
| 337 | }); | ||
| 338 | } | ||
| 339 | } | ||
| 340 | |||
| 341 | return slides; | ||
| 342 | }, | ||
| 343 | |||
| 344 | // presentation controls | ||
| 345 | startPresentation() { | ||
| 346 | const text = document.getElementById('input').value; | ||
| 347 | this.slides = this.parseSent(text); | ||
| 348 | |||
| 349 | if (this.slides.length === 0) { | ||
| 350 | alert('No slides to present.'); | ||
| 351 | return; | ||
| 352 | } | ||
| 353 | |||
| 354 | this.idx = 0; | ||
| 355 | this.presenting = true; | ||
| 356 | document.getElementById('presentation').classList.add('active'); | ||
| 357 | document.body.style.overflow = 'hidden'; | ||
| 358 | |||
| 359 | const el = document.getElementById('presentation'); | ||
| 360 | if (el.requestFullscreen) el.requestFullscreen().catch(() => {}); | ||
| 361 | |||
| 362 | this.renderSlide(); | ||
| 363 | }, | ||
| 364 | |||
| 365 | stopPresentation() { | ||
| 366 | this.presenting = false; | ||
| 367 | document.getElementById('presentation').classList.remove('active'); | ||
| 368 | document.body.style.overflow = ''; | ||
| 369 | |||
| 370 | if (document.fullscreenElement) { | ||
| 371 | document.exitFullscreen().catch(() => {}); | ||
| 372 | } | ||
| 373 | }, | ||
| 374 | |||
| 375 | navigate(dir) { | ||
| 376 | if (!this.presenting) return; | ||
| 377 | const next = this.idx + dir; | ||
| 378 | if (next >= 0 && next < this.slides.length) { | ||
| 379 | this.idx = next; | ||
| 380 | this.renderSlide(); | ||
| 381 | } | ||
| 382 | }, | ||
| 383 | |||
| 384 | // rendering engine | ||
| 385 | renderSlide() { | ||
| 386 | if (!this.presenting || this.slides.length === 0) return; | ||
| 387 | |||
| 388 | const slide = this.slides[this.idx]; | ||
| 389 | const content = document.getElementById('slide-content'); | ||
| 390 | const pres = document.getElementById('presentation'); | ||
| 391 | |||
| 392 | pres.style.backgroundColor = this.settings.bg; | ||
| 393 | pres.style.color = this.settings.fg; | ||
| 394 | |||
| 395 | content.innerHTML = ''; | ||
| 396 | |||
| 397 | if (slide.img) { | ||
| 398 | // center image slides — override the text-layout defaults | ||
| 399 | pres.style.justifyContent = 'center'; | ||
| 400 | pres.style.alignItems = 'center'; | ||
| 401 | pres.style.paddingLeft = '0'; | ||
| 402 | const img = document.createElement('img'); | ||
| 403 | if (slide.img.startsWith('http://') || slide.img.startsWith('https://')) { | ||
| 404 | img.src = slide.img; | ||
| 405 | } else { | ||
| 406 | img.src = 'uploads/' + slide.img; | ||
| 407 | } | ||
| 408 | img.alt = slide.img; | ||
| 409 | // changed to reflect the fullscreen viewport. | ||
| 410 | const pw = pres.offsetWidth || window.innerWidth; | ||
| 411 | const ph = pres.offsetHeight || window.innerHeight; | ||
| 412 | const maxW = Math.floor(pw * this.settings.usableWidth); | ||
| 413 | const maxH = Math.floor(ph * this.settings.usableHeight); | ||
| 414 | img.style.width = maxW + 'px'; | ||
| 415 | img.style.height = maxH + 'px'; | ||
| 416 | img.style.maxWidth = maxW + 'px'; | ||
| 417 | img.style.maxHeight = maxH + 'px'; | ||
| 418 | img.style.objectFit = 'contain'; | ||
| 419 | img.style.display = 'block'; | ||
| 420 | content.appendChild(img); | ||
| 421 | } else { | ||
| 422 | // restore text-layout defaults | ||
| 423 | pres.style.justifyContent = 'flex-start'; | ||
| 424 | pres.style.alignItems = 'center'; | ||
| 425 | pres.style.paddingLeft = '7.5%'; | ||
| 426 | const fontSize = this.calcFontSize(slide.lines); | ||
| 427 | content.style.fontSize = fontSize + 'px'; | ||
| 428 | content.style.lineHeight = String(this.settings.lineSpacing); | ||
| 429 | |||
| 430 | slide.lines.forEach((line, i) => { | ||
| 431 | if (i > 0) content.appendChild(document.createElement('br')); | ||
| 432 | content.appendChild(document.createTextNode(line)); | ||
| 433 | }); | ||
| 434 | } | ||
| 435 | |||
| 436 | }, | ||
| 437 | |||
| 438 | calcFontSize(lines) { | ||
| 439 | const maxW = window.innerWidth * this.settings.usableWidth; | ||
| 440 | const maxH = window.innerHeight * this.settings.usableHeight; | ||
| 441 | const font = getComputedStyle(document.documentElement) | ||
| 442 | .getPropertyValue('--sent-font').trim() || 'sans-serif'; | ||
| 443 | |||
| 444 | const canvas = document.createElement('canvas'); | ||
| 445 | const ctx = canvas.getContext('2d'); | ||
| 446 | |||
| 447 | let lo = 1, | ||
| 448 | hi = 500, | ||
| 449 | best = 1; | ||
| 450 | |||
| 451 | while (lo <= hi) { | ||
| 452 | const mid = Math.floor((lo + hi) / 2); | ||
| 453 | ctx.font = `${mid}px ${font}`; | ||
| 454 | |||
| 455 | let fits = true; | ||
| 456 | for (const line of lines) { | ||
| 457 | if (ctx.measureText(line).width > maxW) { | ||
| 458 | fits = false; | ||
| 459 | break; | ||
| 460 | } | ||
| 461 | } | ||
| 462 | |||
| 463 | // height check: line-spacing × (n-1) + 1 base height | ||
| 464 | const totalH = mid * this.settings.lineSpacing * (lines.length - 1) + mid; | ||
| 465 | if (totalH > maxH) fits = false; | ||
| 466 | |||
| 467 | if (fits) { | ||
| 468 | best = mid; | ||
| 469 | lo = mid + 1; | ||
| 470 | } else { | ||
| 471 | hi = mid - 1; | ||
| 472 | } | ||
| 473 | } | ||
| 474 | |||
| 475 | return best; | ||
| 476 | }, | ||
| 477 | |||
| 478 | // keyboard handler | ||
| 479 | |||
| 480 | handleKeydown(e) { | ||
| 481 | // F5 to start presentation from editor | ||
| 482 | if (!this.presenting && e.key === 'F5') { | ||
| 483 | e.preventDefault(); | ||
| 484 | this.startPresentation(); | ||
| 485 | return; | ||
| 486 | } | ||
| 487 | |||
| 488 | if (!this.presenting) return; | ||
| 489 | |||
| 490 | switch (e.key) { | ||
| 491 | case 'Escape': | ||
| 492 | case 'q': | ||
| 493 | e.preventDefault(); | ||
| 494 | this.stopPresentation(); | ||
| 495 | break; | ||
| 496 | case 'ArrowRight': | ||
| 497 | case 'ArrowDown': | ||
| 498 | case ' ': | ||
| 499 | case 'Enter': | ||
| 500 | case 'l': | ||
| 501 | case 'j': | ||
| 502 | case 'n': | ||
| 503 | case 'PageDown': | ||
| 504 | e.preventDefault(); | ||
| 505 | this.navigate(1); | ||
| 506 | break; | ||
| 507 | case 'ArrowLeft': | ||
| 508 | case 'ArrowUp': | ||
| 509 | case 'Backspace': | ||
| 510 | case 'h': | ||
| 511 | case 'k': | ||
| 512 | case 'p': | ||
| 513 | case 'PageUp': | ||
| 514 | e.preventDefault(); | ||
| 515 | this.navigate(-1); | ||
| 516 | break; | ||
| 517 | } | ||
| 518 | }, | ||
| 519 | |||
| 520 | // image upload | ||
| 521 | |||
| 522 | async handleUpload(e) { | ||
| 523 | const file = e.target.files[0]; | ||
| 524 | if (!file) return; | ||
| 525 | |||
| 526 | const status = document.getElementById('upload-status'); | ||
| 527 | status.textContent = 'uploading…'; | ||
| 528 | |||
| 529 | const fd = new FormData(); | ||
| 530 | fd.append('image', file); | ||
| 531 | |||
| 532 | try { | ||
| 533 | const res = await fetch('/upload', { | ||
| 534 | method: 'POST', | ||
| 535 | body: fd | ||
| 536 | }); | ||
| 537 | const data = await res.json(); | ||
| 538 | |||
| 539 | if (data.error) { | ||
| 540 | status.textContent = 'error: ' + data.error; | ||
| 541 | return; | ||
| 542 | } | ||
| 543 | |||
| 544 | // insert @filename at cursor position | ||
| 545 | const ta = document.getElementById('input'); | ||
| 546 | const pos = ta.selectionStart; | ||
| 547 | const txt = ta.value; | ||
| 548 | const ins = `\n@${data.filename}\n`; | ||
| 549 | ta.value = txt.substring(0, pos) + ins + txt.substring(pos); | ||
| 550 | ta.selectionStart = ta.selectionEnd = pos + ins.length; | ||
| 551 | ta.focus(); | ||
| 552 | |||
| 553 | localStorage.setItem('sent-web-content', ta.value); | ||
| 554 | status.textContent = `uploaded: ${data.filename}`; | ||
| 555 | setTimeout(() => { | ||
| 556 | status.textContent = ''; | ||
| 557 | }, 3000); | ||
| 558 | } catch (err) { | ||
| 559 | status.textContent = 'upload failed'; | ||
| 560 | console.error(err); | ||
| 561 | } | ||
| 562 | |||
| 563 | e.target.value = ''; | ||
| 564 | }, | ||
| 565 | |||
| 566 | // download .sent file for local usage (base64-encoded to preserve unicode and avoid filename issues) | ||
| 567 | downloadSent() { | ||
| 568 | const text = document.getElementById('input').value; | ||
| 569 | const blob = new Blob([text], { | ||
| 570 | type: 'text/plain' | ||
| 571 | }); | ||
| 572 | const a = document.createElement('a'); | ||
| 573 | a.href = URL.createObjectURL(blob); | ||
| 574 | a.download = 'presentation.sent'; | ||
| 575 | a.click(); | ||
| 576 | URL.revokeObjectURL(a.href); | ||
| 577 | }, | ||
| 578 | |||
| 579 | // download pdf export of the presentation from canvas | ||
| 580 | async exportPDF() { | ||
| 581 | const text = document.getElementById('input').value; | ||
| 582 | const slides = this.parseSent(text); | ||
| 583 | |||
| 584 | if (slides.length === 0) { | ||
| 585 | alert('No slides to export.'); | ||
| 586 | return; | ||
| 587 | } | ||
| 588 | |||
| 589 | const btn = document.querySelector('button[onclick="App.exportPDF()"]'); | ||
| 590 | if (btn) { | ||
| 591 | btn.textContent = 'generating pdf…'; | ||
| 592 | btn.disabled = true; | ||
| 593 | } | ||
| 594 | |||
| 595 | try { | ||
| 596 | if (!window.jspdf) { | ||
| 597 | await new Promise((resolve, reject) => { | ||
| 598 | const s = document.createElement('script'); | ||
| 599 | s.src = 'https://cdn.jsdelivr.net/npm/jspdf@2.5.2/dist/jspdf.umd.min.js'; | ||
| 600 | s.onload = resolve; | ||
| 601 | s.onerror = () => reject(new Error('Failed to load jsPDF')); | ||
| 602 | document.head.appendChild(s); | ||
| 603 | }); | ||
| 604 | } | ||
| 605 | |||
| 606 | // 1440p canvas per slide — rasterise via browser engine | ||
| 607 | // (handles fonts, unicode, emoji, images exactly as the live view does) | ||
| 608 | const W = 2560; | ||
| 609 | const H = 1440; | ||
| 610 | |||
| 611 | const { | ||
| 612 | jsPDF | ||
| 613 | } = window.jspdf; | ||
| 614 | // px unit + hotfix keeps jsPDF from rescaling our pixel-perfect canvases | ||
| 615 | const pdf = new jsPDF({ | ||
| 616 | orientation: 'landscape', | ||
| 617 | unit: 'px', | ||
| 618 | format: [W, H], | ||
| 619 | hotfixes: ['px_scaling'], | ||
| 620 | }); | ||
| 621 | |||
| 622 | for (let i = 0; i < slides.length; i++) { | ||
| 623 | const canvas = await this.renderSlideToCanvas(slides[i], W, H); | ||
| 624 | const imgData = canvas.toDataURL('image/jpeg', 0.93); | ||
| 625 | |||
| 626 | if (i > 0) pdf.addPage([W, H], 'landscape'); | ||
| 627 | pdf.addImage(imgData, 'JPEG', 0, 0, W, H); | ||
| 628 | } | ||
| 629 | |||
| 630 | const epoch = Math.floor(Date.now() / 1000); | ||
| 631 | const uid = crypto.randomUUID().replace(/-/g, '').slice(0, 8); | ||
| 632 | pdf.save(`sent-web${epoch}-${uid}.pdf`); | ||
| 633 | |||
| 634 | } catch (err) { | ||
| 635 | console.error('PDF export failed:', err); | ||
| 636 | alert('PDF export failed: ' + err.message); | ||
| 637 | } finally { | ||
| 638 | if (btn) { | ||
| 639 | btn.textContent = 'export .pdf'; | ||
| 640 | btn.disabled = false; | ||
| 641 | } | ||
| 642 | } | ||
| 643 | }, | ||
| 644 | |||
| 645 | // render one slide to an off-screen canvas, mirrors renderSlide() exactly | ||
| 646 | async renderSlideToCanvas(slide, W, H) { | ||
| 647 | const canvas = document.createElement('canvas'); | ||
| 648 | canvas.width = W; | ||
| 649 | canvas.height = H; | ||
| 650 | const ctx = canvas.getContext('2d'); | ||
| 651 | |||
| 652 | const usable = this.settings.usableWidth; // 0.85 | ||
| 653 | const maxW = W * usable; | ||
| 654 | const maxH = H * usable; | ||
| 655 | const marginX = (W - maxW) / 2; | ||
| 656 | |||
| 657 | // resolve the same font stack the live view uses | ||
| 658 | const fontStack = getComputedStyle(document.documentElement) | ||
| 659 | .getPropertyValue('--sent-font').trim() || 'sans-serif'; | ||
| 660 | |||
| 661 | // background | ||
| 662 | ctx.fillStyle = this.settings.bg; | ||
| 663 | ctx.fillRect(0, 0, W, H); | ||
| 664 | |||
| 665 | if (slide.img) { | ||
| 666 | // image slide — draw the actual image | ||
| 667 | await new Promise((resolve) => { | ||
| 668 | const img = new Image(); | ||
| 669 | img.crossOrigin = 'anonymous'; | ||
| 670 | img.onload = () => { | ||
| 671 | const ratio = img.naturalWidth / img.naturalHeight; | ||
| 672 | let dw = maxW, | ||
| 673 | dh = maxH; | ||
| 674 | if (dw / dh > ratio) dw = dh * ratio; | ||
| 675 | else dh = dw / ratio; | ||
| 676 | ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh); | ||
| 677 | resolve(); | ||
| 678 | }; | ||
| 679 | img.onerror = resolve; // still produce a page even on failure | ||
| 680 | img.src = (slide.img.startsWith('http://') || slide.img.startsWith('https://')) ? | ||
| 681 | slide.img : | ||
| 682 | 'uploads/' + slide.img; | ||
| 683 | }); | ||
| 684 | } else { | ||
| 685 | // text slide — left-aligned, same sizing logic as live view | ||
| 686 | const fontSize = this.calcFontSizeCanvas(ctx, slide.lines, fontStack, maxW, maxH); | ||
| 687 | |||
| 688 | ctx.font = `${fontSize}px ${fontStack}`; | ||
| 689 | ctx.fillStyle = this.settings.fg; | ||
| 690 | ctx.textBaseline = 'middle'; | ||
| 691 | |||
| 692 | const lineH = fontSize * this.settings.lineSpacing; | ||
| 693 | const totalH = slide.lines.length * lineH; | ||
| 694 | const startX = marginX; | ||
| 695 | const startY = (H - totalH) / 2; | ||
| 696 | |||
| 697 | slide.lines.forEach((line, i) => { | ||
| 698 | ctx.fillText(line, startX, startY + (i + 0.5) * lineH); | ||
| 699 | }); | ||
| 700 | } | ||
| 701 | |||
| 702 | return canvas; | ||
| 703 | }, | ||
| 704 | |||
| 705 | // binary-search font size on a canvas context for given absolute dimensions | ||
| 706 | calcFontSizeCanvas(ctx, lines, fontStack, maxW, maxH) { | ||
| 707 | let lo = 1, | ||
| 708 | hi = 600, | ||
| 709 | best = 1; | ||
| 710 | |||
| 711 | while (lo <= hi) { | ||
| 712 | const mid = Math.floor((lo + hi) / 2); | ||
| 713 | ctx.font = `${mid}px ${fontStack}`; | ||
| 714 | |||
| 715 | let fits = true; | ||
| 716 | for (const line of lines) { | ||
| 717 | if (ctx.measureText(line).width > maxW) { | ||
| 718 | fits = false; | ||
| 719 | break; | ||
| 720 | } | ||
| 721 | } | ||
| 722 | |||
| 723 | const totalH = mid * this.settings.lineSpacing * (lines.length - 1) + mid; | ||
| 724 | if (totalH > maxH) fits = false; | ||
| 725 | |||
| 726 | if (fits) { | ||
| 727 | best = mid; | ||
| 728 | lo = mid + 1; | ||
| 729 | } else { | ||
| 730 | hi = mid - 1; | ||
| 731 | } | ||
| 732 | } | ||
| 733 | |||
| 734 | return best; | ||
| 735 | }, | ||
| 736 | }; | ||
| 737 | |||
| 738 | document.addEventListener('DOMContentLoaded', () => App.init()); | ||
| 739 | </script> | ||
| 740 | </body> | ||
| 741 | |||
| 742 | </html> \ No newline at end of file | ||
