summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/index.html80
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 }