455 lines
15 KiB
HTML
455 lines
15 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Deck Preview — markdown in, slides out, nothing leaves your browser</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0f1115;
|
|
--panel: #171a21;
|
|
--panel-2: #1e222b;
|
|
--ink: #e8eaf0;
|
|
--ink-dim: #9aa1af;
|
|
--accent: #6ea8fe;
|
|
--accent-2: #9d7bff;
|
|
--line: #2a2f3a;
|
|
--radius: 12px;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { height: 100%; }
|
|
body {
|
|
margin: 0;
|
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--ink);
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 100vh;
|
|
}
|
|
header {
|
|
padding: 18px 20px 10px;
|
|
}
|
|
header h1 {
|
|
margin: 0;
|
|
font-size: 1.35rem;
|
|
letter-spacing: 0.2px;
|
|
}
|
|
header h1 .dot { color: var(--accent); }
|
|
header p {
|
|
margin: 6px 0 0;
|
|
color: var(--ink-dim);
|
|
font-size: 0.9rem;
|
|
max-width: 62ch;
|
|
}
|
|
main {
|
|
flex: 1;
|
|
display: grid;
|
|
grid-template-columns: minmax(280px, 420px) 1fr;
|
|
gap: 14px;
|
|
padding: 12px 20px 16px;
|
|
min-height: 0;
|
|
}
|
|
.editor-pane {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
.editor-pane label {
|
|
font-size: 0.78rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--ink-dim);
|
|
margin-bottom: 6px;
|
|
}
|
|
textarea {
|
|
flex: 1;
|
|
min-height: 220px;
|
|
resize: none;
|
|
background: var(--panel);
|
|
color: var(--ink);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius);
|
|
padding: 14px;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
font-size: 0.9rem;
|
|
line-height: 1.55;
|
|
outline: none;
|
|
}
|
|
textarea:focus { border-color: var(--accent); }
|
|
.hint {
|
|
margin-top: 8px;
|
|
font-size: 0.78rem;
|
|
color: var(--ink-dim);
|
|
line-height: 1.5;
|
|
}
|
|
.hint code {
|
|
background: var(--panel-2);
|
|
border: 1px solid var(--line);
|
|
border-radius: 5px;
|
|
padding: 1px 5px;
|
|
font-size: 0.75rem;
|
|
}
|
|
.stage {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
button {
|
|
background: var(--panel-2);
|
|
color: var(--ink);
|
|
border: 1px solid var(--line);
|
|
border-radius: 9px;
|
|
padding: 8px 14px;
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
touch-action: manipulation;
|
|
}
|
|
button:hover { border-color: var(--accent); }
|
|
button:disabled { opacity: 0.4; cursor: default; }
|
|
button.primary {
|
|
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
|
color: #0b0d12;
|
|
font-weight: 600;
|
|
border: none;
|
|
}
|
|
.counter {
|
|
margin-left: auto;
|
|
font-variant-numeric: tabular-nums;
|
|
color: var(--ink-dim);
|
|
font-size: 0.85rem;
|
|
}
|
|
.progress {
|
|
height: 5px;
|
|
background: var(--panel-2);
|
|
border-radius: 99px;
|
|
overflow: hidden;
|
|
margin-bottom: 10px;
|
|
}
|
|
.progress > div {
|
|
height: 100%;
|
|
width: 0;
|
|
background: linear-gradient(90deg, var(--accent), var(--accent-2));
|
|
transition: width 180ms ease;
|
|
}
|
|
.slide-wrap {
|
|
flex: 1;
|
|
min-height: 0;
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius);
|
|
background:
|
|
radial-gradient(1200px 500px at 80% -10%, rgba(110,168,254,0.10), transparent),
|
|
radial-gradient(900px 500px at 0% 110%, rgba(157,123,255,0.10), transparent),
|
|
var(--panel);
|
|
overflow-y: auto;
|
|
touch-action: pan-y;
|
|
}
|
|
.slide {
|
|
padding: clamp(22px, 5vw, 56px);
|
|
min-height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
}
|
|
.slide h1 {
|
|
font-size: clamp(1.6rem, 4.5vw, 3rem);
|
|
margin: 0 0 0.5em;
|
|
line-height: 1.15;
|
|
}
|
|
.slide h2 {
|
|
font-size: clamp(1.2rem, 3vw, 1.9rem);
|
|
margin: 0 0 0.6em;
|
|
color: var(--ink-dim);
|
|
font-weight: 500;
|
|
}
|
|
.slide ul {
|
|
margin: 0;
|
|
padding-left: 1.3em;
|
|
font-size: clamp(1rem, 2.2vw, 1.35rem);
|
|
line-height: 1.7;
|
|
}
|
|
.slide p {
|
|
font-size: clamp(1rem, 2.2vw, 1.35rem);
|
|
line-height: 1.7;
|
|
margin: 0.3em 0;
|
|
}
|
|
.slide .empty { color: var(--ink-dim); font-style: italic; }
|
|
.slide code, .export-note code {
|
|
background: rgba(110,168,254,0.12);
|
|
border-radius: 5px;
|
|
padding: 1px 6px;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
font-size: 0.9em;
|
|
}
|
|
.export-note {
|
|
font-size: 0.78rem;
|
|
color: var(--ink-dim);
|
|
margin-top: 8px;
|
|
}
|
|
footer {
|
|
padding: 10px 20px 16px;
|
|
font-size: 0.8rem;
|
|
color: var(--ink-dim);
|
|
}
|
|
footer a { color: var(--accent); text-decoration: none; }
|
|
footer a:hover { text-decoration: underline; }
|
|
@media (max-width: 820px) {
|
|
main {
|
|
grid-template-columns: 1fr;
|
|
grid-template-rows: auto 1fr;
|
|
}
|
|
textarea { min-height: 150px; flex: none; }
|
|
.slide-wrap { min-height: 300px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Deck Preview<span class="dot">.</span></h1>
|
|
<p>Type markdown-ish slides on the left, watch them render live on the right.
|
|
Navigate with arrow keys or swipe. Everything runs in your browser — nothing is saved or sent anywhere.</p>
|
|
</header>
|
|
<main>
|
|
<section class="editor-pane">
|
|
<label for="src">Slides</label>
|
|
<textarea id="src" spellcheck="false"></textarea>
|
|
<p class="hint">
|
|
<code># Title</code> heading · <code>## Subtitle</code> · <code>- bullet</code> point ·
|
|
<code>---</code> on its own line starts a new slide · inline <code>**bold**</code>, <code>*italic*</code>, <code>`code`</code>.
|
|
Plain lines become paragraphs.
|
|
</p>
|
|
</section>
|
|
<section class="stage">
|
|
<div class="toolbar">
|
|
<button id="prev" aria-label="Previous slide">← Prev</button>
|
|
<button id="next" aria-label="Next slide">Next →</button>
|
|
<button id="copy" class="primary">Copy deck as HTML</button>
|
|
<span class="counter" id="counter">1 / 1</span>
|
|
</div>
|
|
<div class="progress" role="progressbar" aria-label="Deck progress"><div id="bar"></div></div>
|
|
<div class="slide-wrap" id="wrap"><article class="slide" id="slide"></article></div>
|
|
<p class="export-note">The copied file is a self-contained deck: open it in any browser and use ← / → to present.</p>
|
|
</section>
|
|
</main>
|
|
<footer>built by the <a href="https://github.com/teahubxyz/time">Time venture loop</a></footer>
|
|
<script>
|
|
(function () {
|
|
"use strict";
|
|
var src = document.getElementById("src");
|
|
var slideEl = document.getElementById("slide");
|
|
var counterEl = document.getElementById("counter");
|
|
var barEl = document.getElementById("bar");
|
|
var wrap = document.getElementById("wrap");
|
|
var prevBtn = document.getElementById("prev");
|
|
var nextBtn = document.getElementById("next");
|
|
var copyBtn = document.getElementById("copy");
|
|
|
|
var SAMPLE = [
|
|
"# Deck Preview",
|
|
"## Markdown in, slides out",
|
|
"",
|
|
"- Type on the left, slides render live",
|
|
"- Navigate with arrow keys or swipe",
|
|
"- Nothing is saved or sent anywhere",
|
|
"",
|
|
"---",
|
|
"# A simple syntax",
|
|
"",
|
|
"- `#` and `##` for titles and subtitles",
|
|
"- `-` or `*` for bullet points",
|
|
"- `---` on its own line starts a new slide",
|
|
"- Inline **bold**, *italic* and `code`",
|
|
"",
|
|
"---",
|
|
"# Take it with you",
|
|
"",
|
|
"Press *Copy deck as HTML* and you get one self-contained file.",
|
|
"Open it in any browser and present with the arrow keys.",
|
|
"",
|
|
"---",
|
|
"# That's it",
|
|
"",
|
|
"- No accounts",
|
|
"- No servers",
|
|
"- Just your words, as slides"
|
|
].join("\n");
|
|
|
|
function esc(s) {
|
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
function inline(s) {
|
|
var out = esc(s);
|
|
out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
out = out.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
out = out.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
return out;
|
|
}
|
|
|
|
// Split raw text into slides on lines that are only "---".
|
|
function splitSlides(text) {
|
|
var lines = text.split(/\r?\n/);
|
|
var slides = [[]];
|
|
for (var i = 0; i < lines.length; i++) {
|
|
if (/^\s*---+\s*$/.test(lines[i])) {
|
|
slides.push([]);
|
|
} else {
|
|
slides[slides.length - 1].push(lines[i]);
|
|
}
|
|
}
|
|
return slides;
|
|
}
|
|
|
|
// Render one slide's lines to HTML.
|
|
function renderSlide(lines) {
|
|
var html = "";
|
|
var inList = false;
|
|
var hasContent = false;
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var line = lines[i];
|
|
var t = line.trim();
|
|
if (t === "") {
|
|
if (inList) { html += "</ul>"; inList = false; }
|
|
continue;
|
|
}
|
|
hasContent = true;
|
|
if (t.indexOf("# ") === 0) {
|
|
if (inList) { html += "</ul>"; inList = false; }
|
|
html += "<h1>" + inline(t.slice(2)) + "</h1>";
|
|
} else if (t.indexOf("## ") === 0) {
|
|
if (inList) { html += "</ul>"; inList = false; }
|
|
html += "<h2>" + inline(t.slice(3)) + "</h2>";
|
|
} else if (t.indexOf("- ") === 0 || t.indexOf("* ") === 0) {
|
|
if (!inList) { html += "<ul>"; inList = true; }
|
|
html += "<li>" + inline(t.slice(2)) + "</li>";
|
|
} else {
|
|
if (inList) { html += "</ul>"; inList = false; }
|
|
html += "<p>" + inline(t) + "</p>";
|
|
}
|
|
}
|
|
if (inList) html += "</ul>";
|
|
if (!hasContent) html = '<p class="empty">(empty slide)</p>';
|
|
return html;
|
|
}
|
|
|
|
var slides = [];
|
|
var current = 0;
|
|
|
|
function parse() {
|
|
slides = splitSlides(src.value);
|
|
if (slides.length === 0) slides = [[]];
|
|
if (current >= slides.length) current = slides.length - 1;
|
|
if (current < 0) current = 0;
|
|
render();
|
|
}
|
|
|
|
function render() {
|
|
slideEl.innerHTML = renderSlide(slides[current]);
|
|
counterEl.textContent = (current + 1) + " / " + slides.length;
|
|
var pct = slides.length <= 1 ? 100 : (current / (slides.length - 1)) * 100;
|
|
barEl.style.width = pct + "%";
|
|
prevBtn.disabled = current === 0;
|
|
nextBtn.disabled = current === slides.length - 1;
|
|
}
|
|
|
|
function go(delta) {
|
|
var next = current + delta;
|
|
if (next < 0 || next > slides.length - 1) return;
|
|
current = next;
|
|
render();
|
|
}
|
|
|
|
src.addEventListener("input", parse);
|
|
prevBtn.addEventListener("click", function () { go(-1); });
|
|
nextBtn.addEventListener("click", function () { go(1); });
|
|
|
|
document.addEventListener("keydown", function (e) {
|
|
if (e.target === src) return; // don't steal keys while typing
|
|
if (e.key === "ArrowRight" || e.key === "PageDown" || e.key === " ") { e.preventDefault(); go(1); }
|
|
else if (e.key === "ArrowLeft" || e.key === "PageUp") { e.preventDefault(); go(-1); }
|
|
else if (e.key === "Home") { current = 0; render(); }
|
|
else if (e.key === "End") { current = slides.length - 1; render(); }
|
|
});
|
|
|
|
// Swipe navigation.
|
|
var touchX = null;
|
|
wrap.addEventListener("touchstart", function (e) { touchX = e.touches[0].clientX; }, { passive: true });
|
|
wrap.addEventListener("touchend", function (e) {
|
|
if (touchX === null) return;
|
|
var dx = e.changedTouches[0].clientX - touchX;
|
|
if (Math.abs(dx) > 40) go(dx < 0 ? 1 : -1);
|
|
touchX = null;
|
|
}, { passive: true });
|
|
|
|
// Build the self-contained export deck.
|
|
function buildExport() {
|
|
var slidesHtml = slides.map(function (s) {
|
|
return '<section class="s">' + renderSlide(s) + "</section>";
|
|
}).join("\n");
|
|
return "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n" +
|
|
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n" +
|
|
"<title>Deck</title>\n<style>\n" +
|
|
"*{box-sizing:border-box}html,body{height:100%;margin:0}" +
|
|
"body{font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;background:#0f1115;color:#e8eaf0;display:flex;flex-direction:column}" +
|
|
".bar{height:5px;background:#1e222b}.bar>div{height:100%;width:0;background:linear-gradient(90deg,#6ea8fe,#9d7bff);transition:width .18s ease}" +
|
|
"main{flex:1;overflow-y:auto}" +
|
|
".s{display:none;padding:clamp(24px,6vw,64px);min-height:100%;flex-direction:column;justify-content:center}" +
|
|
".s.on{display:flex}" +
|
|
"h1{font-size:clamp(1.8rem,5vw,3.2rem);margin:0 0 .5em;line-height:1.15}" +
|
|
"h2{font-size:clamp(1.2rem,3vw,1.9rem);color:#9aa1af;font-weight:500;margin:0 0 .6em}" +
|
|
"ul{margin:0;padding-left:1.3em;font-size:clamp(1.1rem,2.5vw,1.5rem);line-height:1.7}" +
|
|
"p{font-size:clamp(1.1rem,2.5vw,1.5rem);line-height:1.7;margin:.3em 0}" +
|
|
"code{background:rgba(110,168,254,.12);border-radius:5px;padding:1px 6px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:.9em}" +
|
|
".empty{color:#9aa1af;font-style:italic}" +
|
|
".n{position:fixed;bottom:12px;right:16px;color:#9aa1af;font-size:.85rem;font-variant-numeric:tabular-nums}" +
|
|
"\n</style>\n</head>\n<body>\n<div class=\"bar\"><div id=\"b\"></div></div>\n<main>\n" +
|
|
slidesHtml +
|
|
"\n</main>\n<div class=\"n\" id=\"n\"></div>\n<script>\n" +
|
|
"var s=document.querySelectorAll('.s'),i=0,n=document.getElementById('n'),b=document.getElementById('b');\n" +
|
|
"function show(){for(var k=0;k<s.length;k++)s[k].classList.toggle('on',k===i);n.textContent=(i+1)+' / '+s.length;b.style.width=(s.length<=1?100:i/(s.length-1)*100)+'%';}\n" +
|
|
"function go(d){var j=i+d;if(j<0||j>s.length-1)return;i=j;show();}\n" +
|
|
"document.addEventListener('keydown',function(e){if(e.key==='ArrowRight'||e.key===' '||e.key==='PageDown'){e.preventDefault();go(1);}else if(e.key==='ArrowLeft'||e.key==='PageUp'){e.preventDefault();go(-1);}else if(e.key==='Home'){i=0;show();}else if(e.key==='End'){i=s.length-1;show();}});\n" +
|
|
"var x=null;document.addEventListener('touchstart',function(e){x=e.touches[0].clientX;},{passive:true});" +
|
|
"document.addEventListener('touchend',function(e){if(x===null)return;var d=e.changedTouches[0].clientX-x;if(Math.abs(d)>40)go(d<0?1:-1);x=null;},{passive:true});\n" +
|
|
"show();\n<\/script>\n</body>\n</html>";
|
|
}
|
|
|
|
copyBtn.addEventListener("click", function () {
|
|
var html = buildExport();
|
|
var done = function () {
|
|
var old = copyBtn.textContent;
|
|
copyBtn.textContent = "Copied!";
|
|
setTimeout(function () { copyBtn.textContent = old; }, 1500);
|
|
};
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(html).then(done, function () { fallbackCopy(html, done); });
|
|
} else {
|
|
fallbackCopy(html, done);
|
|
}
|
|
});
|
|
|
|
function fallbackCopy(text, done) {
|
|
var ta = document.createElement("textarea");
|
|
ta.value = text;
|
|
ta.style.position = "fixed";
|
|
ta.style.opacity = "0";
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
try { document.execCommand("copy"); done(); } catch (e) { /* give up quietly */ }
|
|
document.body.removeChild(ta);
|
|
}
|
|
|
|
src.value = SAMPLE;
|
|
parse();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|