diff options
| author | kj_sh604 <43.splash@gmail.com> | 2026-04-15 23:05:17 -0400 |
|---|---|---|
| committer | kj_sh604 <43.splash@gmail.com> | 2026-04-15 23:05:17 -0400 |
| commit | 32fb60ed8f96d462edce26710c13d4c032f5ea47 (patch) | |
| tree | cf1a9587ecf957fca9d0a4e1cf820b0e0b122734 | |
| parent | d87cc396c206b9668f8155a4d77ec923dbbc6a90 (diff) | |
refactor: preserve tabs and spaces like the original suckless sent
| -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 @@ <title>sent-web</title> <link rel="icon" type="image/svg+xml" href="/favicon.svg"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kj-sh604/noir.css@latest/out/noir.min.css"> - <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> + <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> <script> /* @licstart The following is the entire license notice for the @@ -297,46 +297,74 @@ questions?</textarea> if (this.presenting) this.renderSlide(); }, + normalizeSentNewlines(text) { + return text.replace(/\r\n?/g, '\n'); + }, + + // convert tabs to fixed tab stops so live rendering and canvas exports match exactly + expandTabs(line, tabSize = 8) { + let col = 0; + let out = ''; + + for (const ch of line) { + if (ch === '\t') { + const pad = tabSize - (col % tabSize); + out += ' '.repeat(pad); + col += pad; + } else { + out += ch; + col += 1; + } + } + + return out; + }, + // sent format parser parseSent(text) { const slides = []; - const paragraphs = text.split(/\n{2,}/); + const rawLines = this.normalizeSentNewlines(text).split('\n'); + let i = 0; + + while (i < rawLines.length) { + // sent ignores consecutive blank lines and comments between slides. + while (i < rawLines.length && (rawLines[i] === '' || rawLines[i].startsWith('#'))) + i += 1; + + if (i >= rawLines.length) + break; - for (const para of paragraphs) { - const rawLines = para.split('\n'); const lines = []; let img = null; - let firstContent = true; - for (const raw of rawLines) { - if (raw.trim() === '' || raw.startsWith('#')) continue; + while (i < rawLines.length && rawLines[i] !== '') { + const raw = rawLines[i]; + i += 1; - let line = raw; + if (raw.startsWith('#')) + continue; - if (firstContent && line.startsWith('@')) { - img = line.substring(1).trim(); - firstContent = false; + // image marker check happens before escape handling to match sent. + if (lines.length === 0 && raw.startsWith('@')) { + img = raw.substring(1); continue; } - firstContent = false; - // image slides ignore remaining text lines - if (img !== null) continue; + // strip leading backslash (escape). + const line = raw.startsWith('\\') ? raw.substring(1) : raw; - // strip leading backslash (escape) - if (line.startsWith('\\')) { - line = line.substring(1); - } + // image slides ignore remaining text lines. + if (img !== null) + continue; lines.push(line); } - if (img !== null || lines.length > 0) { + if (img !== null || lines.length > 0) slides.push({ lines, img }); - } } return slides; @@ -424,11 +452,12 @@ questions?</textarea> pres.style.justifyContent = 'flex-start'; pres.style.alignItems = 'center'; pres.style.paddingLeft = '7.5%'; - const fontSize = this.calcFontSize(slide.lines); + const renderLines = slide.lines.map(line => this.expandTabs(line)); + const fontSize = this.calcFontSize(renderLines); content.style.fontSize = fontSize + 'px'; content.style.lineHeight = String(this.settings.lineSpacing); - slide.lines.forEach((line, i) => { + renderLines.forEach((line, i) => { if (i > 0) content.appendChild(document.createElement('br')); content.appendChild(document.createTextNode(line)); }); @@ -684,18 +713,19 @@ questions?</textarea> }); } else { // text slide — left-aligned, same sizing logic as live view - const fontSize = this.calcFontSizeCanvas(ctx, slide.lines, fontStack, maxW, maxH); + const renderLines = slide.lines.map(line => this.expandTabs(line)); + const fontSize = this.calcFontSizeCanvas(ctx, renderLines, fontStack, maxW, maxH); ctx.font = `${fontSize}px ${fontStack}`; ctx.fillStyle = this.settings.fg; ctx.textBaseline = 'middle'; const lineH = fontSize * this.settings.lineSpacing; - const totalH = slide.lines.length * lineH; + const totalH = renderLines.length * lineH; const startX = marginX; const startY = (H - totalH) / 2; - slide.lines.forEach((line, i) => { + renderLines.forEach((line, i) => { ctx.fillText(line, startX, startY + (i + 0.5) * lineH); }); } |
