diff options
| author | kj_sh604 | 2026-04-15 23:05:17 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-04-15 23:05:17 -0400 |
| commit | 32fb60ed8f96d462edce26710c13d4c032f5ea47 (patch) | |
| tree | cf1a9587ecf957fca9d0a4e1cf820b0e0b122734 /src | |
| parent | d87cc396c206b9668f8155a4d77ec923dbbc6a90 (diff) | |
refactor: preserve tabs and spaces like the original suckless sent
Diffstat (limited to 'src')
| -rw-r--r-- | src/index.html | 80 |
1 files changed, 55 insertions, 25 deletions
diff --git a/src/index.html b/src/index.html index 2750ad7..f87ca69 100644 --- a/src/index.html +++ b/src/index.html | |||
| @@ -7,7 +7,7 @@ | |||
| 7 | <title>sent-web</title> | 7 | <title>sent-web</title> |
| 8 | <link rel="icon" type="image/svg+xml" href="/favicon.svg"> | 8 | <link rel="icon" type="image/svg+xml" href="/favicon.svg"> |
| 9 | <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kj-sh604/noir.css@latest/out/noir.min.css"> | 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 { display: block; max-width: 85vw; max-height: 85vh; object-fit: contain; } </style> | 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: break-spaces; tab-size: 8; font-family: var(--sent-font); } #slide-content img { display: block; max-width: 85vw; max-height: 85vh; object-fit: contain; } </style> |
| 11 | <script> | 11 | <script> |
| 12 | /* | 12 | /* |
| 13 | @licstart The following is the entire license notice for the | 13 | @licstart The following is the entire license notice for the |
| @@ -297,46 +297,74 @@ questions?</textarea> | |||
| 297 | if (this.presenting) this.renderSlide(); | 297 | if (this.presenting) this.renderSlide(); |
| 298 | }, | 298 | }, |
| 299 | 299 | ||
| 300 | normalizeSentNewlines(text) { | ||
| 301 | return text.replace(/\r\n?/g, '\n'); | ||
| 302 | }, | ||
| 303 | |||
| 304 | // convert tabs to fixed tab stops so live rendering and canvas exports match exactly | ||
| 305 | expandTabs(line, tabSize = 8) { | ||
| 306 | let col = 0; | ||
| 307 | let out = ''; | ||
| 308 | |||
| 309 | for (const ch of line) { | ||
| 310 | if (ch === '\t') { | ||
| 311 | const pad = tabSize - (col % tabSize); | ||
| 312 | out += ' '.repeat(pad); | ||
| 313 | col += pad; | ||
| 314 | } else { | ||
| 315 | out += ch; | ||
| 316 | col += 1; | ||
| 317 | } | ||
| 318 | } | ||
| 319 | |||
| 320 | return out; | ||
| 321 | }, | ||
| 322 | |||
| 300 | // sent format parser | 323 | // sent format parser |
| 301 | parseSent(text) { | 324 | parseSent(text) { |
| 302 | const slides = []; | 325 | const slides = []; |
| 303 | const paragraphs = text.split(/\n{2,}/); | 326 | const rawLines = this.normalizeSentNewlines(text).split('\n'); |
| 327 | let i = 0; | ||
| 328 | |||
| 329 | while (i < rawLines.length) { | ||
| 330 | // sent ignores consecutive blank lines and comments between slides. | ||
| 331 | while (i < rawLines.length && (rawLines[i] === '' || rawLines[i].startsWith('#'))) | ||
| 332 | i += 1; | ||
| 333 | |||
| 334 | if (i >= rawLines.length) | ||
| 335 | break; | ||
| 304 | 336 | ||
| 305 | for (const para of paragraphs) { | ||
| 306 | const rawLines = para.split('\n'); | ||
| 307 | const lines = []; | 337 | const lines = []; |
| 308 | let img = null; | 338 | let img = null; |
| 309 | let firstContent = true; | ||
| 310 | 339 | ||
| 311 | for (const raw of rawLines) { | 340 | while (i < rawLines.length && rawLines[i] !== '') { |
| 312 | if (raw.trim() === '' || raw.startsWith('#')) continue; | 341 | const raw = rawLines[i]; |
| 342 | i += 1; | ||
| 313 | 343 | ||
| 314 | let line = raw; | 344 | if (raw.startsWith('#')) |
| 345 | continue; | ||
| 315 | 346 | ||
| 316 | if (firstContent && line.startsWith('@')) { | 347 | // image marker check happens before escape handling to match sent. |
| 317 | img = line.substring(1).trim(); | 348 | if (lines.length === 0 && raw.startsWith('@')) { |
| 318 | firstContent = false; | 349 | img = raw.substring(1); |
| 319 | continue; | 350 | continue; |
| 320 | } | 351 | } |
| 321 | firstContent = false; | ||
| 322 | 352 | ||
| 323 | // image slides ignore remaining text lines | 353 | // strip leading backslash (escape). |
| 324 | if (img !== null) continue; | 354 | const line = raw.startsWith('\\') ? raw.substring(1) : raw; |
| 325 | 355 | ||
| 326 | // strip leading backslash (escape) | 356 | // image slides ignore remaining text lines. |
| 327 | if (line.startsWith('\\')) { | 357 | if (img !== null) |
| 328 | line = line.substring(1); | 358 | continue; |
| 329 | } | ||
| 330 | 359 | ||
| 331 | lines.push(line); | 360 | lines.push(line); |
| 332 | } | 361 | } |
| 333 | 362 | ||
| 334 | if (img !== null || lines.length > 0) { | 363 | if (img !== null || lines.length > 0) |
| 335 | slides.push({ | 364 | slides.push({ |
| 336 | lines, | 365 | lines, |
| 337 | img | 366 | img |
| 338 | }); | 367 | }); |
| 339 | } | ||
| 340 | } | 368 | } |
| 341 | 369 | ||
| 342 | return slides; | 370 | return slides; |
| @@ -424,11 +452,12 @@ questions?</textarea> | |||
| 424 | pres.style.justifyContent = 'flex-start'; | 452 | pres.style.justifyContent = 'flex-start'; |
| 425 | pres.style.alignItems = 'center'; | 453 | pres.style.alignItems = 'center'; |
| 426 | pres.style.paddingLeft = '7.5%'; | 454 | pres.style.paddingLeft = '7.5%'; |
| 427 | const fontSize = this.calcFontSize(slide.lines); | 455 | const renderLines = slide.lines.map(line => this.expandTabs(line)); |
| 456 | const fontSize = this.calcFontSize(renderLines); | ||
| 428 | content.style.fontSize = fontSize + 'px'; | 457 | content.style.fontSize = fontSize + 'px'; |
| 429 | content.style.lineHeight = String(this.settings.lineSpacing); | 458 | content.style.lineHeight = String(this.settings.lineSpacing); |
| 430 | 459 | ||
| 431 | slide.lines.forEach((line, i) => { | 460 | renderLines.forEach((line, i) => { |
| 432 | if (i > 0) content.appendChild(document.createElement('br')); | 461 | if (i > 0) content.appendChild(document.createElement('br')); |
| 433 | content.appendChild(document.createTextNode(line)); | 462 | content.appendChild(document.createTextNode(line)); |
| 434 | }); | 463 | }); |
| @@ -684,18 +713,19 @@ questions?</textarea> | |||
| 684 | }); | 713 | }); |
| 685 | } else { | 714 | } else { |
| 686 | // text slide — left-aligned, same sizing logic as live view | 715 | // text slide — left-aligned, same sizing logic as live view |
| 687 | const fontSize = this.calcFontSizeCanvas(ctx, slide.lines, fontStack, maxW, maxH); | 716 | const renderLines = slide.lines.map(line => this.expandTabs(line)); |
| 717 | const fontSize = this.calcFontSizeCanvas(ctx, renderLines, fontStack, maxW, maxH); | ||
| 688 | 718 | ||
| 689 | ctx.font = `${fontSize}px ${fontStack}`; | 719 | ctx.font = `${fontSize}px ${fontStack}`; |
| 690 | ctx.fillStyle = this.settings.fg; | 720 | ctx.fillStyle = this.settings.fg; |
| 691 | ctx.textBaseline = 'middle'; | 721 | ctx.textBaseline = 'middle'; |
| 692 | 722 | ||
| 693 | const lineH = fontSize * this.settings.lineSpacing; | 723 | const lineH = fontSize * this.settings.lineSpacing; |
| 694 | const totalH = slide.lines.length * lineH; | 724 | const totalH = renderLines.length * lineH; |
| 695 | const startX = marginX; | 725 | const startX = marginX; |
| 696 | const startY = (H - totalH) / 2; | 726 | const startY = (H - totalH) / 2; |
| 697 | 727 | ||
| 698 | slide.lines.forEach((line, i) => { | 728 | renderLines.forEach((line, i) => { |
| 699 | ctx.fillText(line, startX, startY + (i + 0.5) * lineH); | 729 | ctx.fillText(line, startX, startY + (i + 0.5) * lineH); |
| 700 | }); | 730 | }); |
| 701 | } | 731 | } |
