(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 noteEl = document.getElementById("fitnote"); var SAMPLE = [ "# .mdeck", "## Slides from plain text", "", "This deck is the demo, and its source is in the editor: change a word there and watch this slide change.", "", "---", "# Why", "## A deck shouldn't take longer than the meeting", "", "- Dragging boxes isn't the job", "- Your words are the job", "- So type them, and let the slides follow", "", "---", "# How it works", "", "- `#` for a title, `##` for a subtitle", "- `-` for a point like this one", "- **bold**, *italic* and `code` work inline", "- `---` on its own line starts a new slide", "", "Any other line is a paragraph, like this one.", "", "---", "# Free, right now", "", "- This editor and the live preview", "- Arrow keys or swipe to present", "- **Copy deck as HTML**: one file that opens in any browser", "- **Copy link**: the whole deck rides inside the link", "- Nothing is saved or sent: your deck stays in this tab", "", "---", "# Who it's for", "", "Anyone who'd rather type a deck than drag one: the weekly update, the demo, the talk, the class notes.", "", "---", "# Pro, if you want more", "", "- Saved decks and themes", "- Speaker notes", "- PDF export", "- **$5/mo**, the founding price", "", "Pro isn't built yet, and nothing is charged today.", "", "---", "# Your turn", "", "Select everything in the editor, delete it, and type your own.", "Or [get early access to Pro](https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+.mdeck&venture=mdecks&plan=Pro)." ].join("\n"); function esc(s) { return s.replace(/&/g, "&").replace(//g, ">"); } function inline(s) { var out = esc(s); out = out.replace(/\*\*([^*]+)\*\*/g, "$1"); out = out.replace(/\*([^*]+)\*/g, "$1"); out = out.replace(/`([^`]+)`/g, "$1"); // Handle links - only http and https out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s\"'<>]+)\)/g, '$1'); 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 += ""; inList = false; } continue; } hasContent = true; if (t.indexOf("## ") === 0) { if (inList) { html += ""; inList = false; } html += "

" + inline(t.slice(3)) + "

"; } else if (t.indexOf("# ") === 0) { if (inList) { html += ""; inList = false; } html += "

" + inline(t.slice(2)) + "

"; } else if (t.indexOf("- ") === 0 || t.indexOf("* ") === 0) { if (!inList) { html += ""; inList = false; } html += "

" + inline(line) + "

"; } } if (inList) html += ""; if (!hasContent) html = '

(empty slide)

'; return html; } // The copied deck's stylesheet. Slides are also measured with it, in a hidden frame the size of a // 1280x720 screen, to decide where a long slide is cut. var DECK_CSS = "*{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;--fit:1;padding:clamp(24px,6vw,64px);min-height:100%;flex-direction:column;justify-content:center;overflow-wrap:anywhere}" + ".s.on{display:flex}" + "h1{font-size:calc(clamp(1.8rem,5vw,3.2rem)*var(--fit));margin:0 0 .5em;line-height:1.15}" + "h2{font-size:calc(clamp(1.2rem,3vw,1.9rem)*var(--fit));color:#9aa1af;font-weight:500;margin:0 0 .6em}" + "ul{margin:0;padding-left:1.3em;font-size:calc(clamp(1.1rem,2.5vw,1.5rem)*var(--fit));line-height:1.7}" + "p{font-size:calc(clamp(1.1rem,2.5vw,1.5rem)*var(--fit));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}" + "a{color:#6ea8fe;text-decoration:none}a:hover{text-decoration:underline}" + ".empty{color:#9aa1af;font-style:italic}" + ".n{position:fixed;bottom:12px;right:16px;color:#9aa1af;font-size:.85rem;font-variant-numeric:tabular-nums}"; var slides = []; // the slides as typed, each a list of lines var deck = []; // the slides as shown and copied: the typed ones, long ones cut var current = 0; function parse() { slides = splitSlides(src.value); if (slides.length === 0) slides = [[]]; var cuts = [], over = []; deck = []; for (var k = 0; k < slides.length; k++) { var pieces = cut(slides[k]), first = deck.length + 1; for (var j = 0; j < pieces.length; j++) { deck.push(pieces[j].lines); if (pieces[j].over) over.push(deck.length); } if (pieces.length > 1) cuts.push([first, deck.length]); } if (current >= deck.length) current = deck.length - 1; if (current < 0) current = 0; render(); note(cuts, over); scheduleLink(); } function render() { slideEl.innerHTML = renderSlide(deck[current]); fit(slideEl, wrap); wrap.scrollTop = 0; counterEl.textContent = (current + 1) + " / " + deck.length; var pct = deck.length <= 1 ? 100 : (current / (deck.length - 1)) * 100; barEl.style.width = pct + "%"; prevBtn.disabled = current === 0; nextBtn.disabled = current === deck.length - 1; } // Under the preview: which slides were cut, and any still too long. function note(cuts, over) { var text = ""; if (cuts.length === 1) { text = "Slide " + cuts[0][0] + " was too long, so it runs onto " + (cuts[0][1] === cuts[0][0] + 1 ? "slide " + cuts[0][1] : "slides " + (cuts[0][0] + 1) + "–" + cuts[0][1]) + ". Put in a --- to choose the break yourself."; } else if (cuts.length > 1) { text = "Slides " + numbers(cuts.map(function (c) { return c[0]; })) + " were too long, so they run onto the slides after them. Put in a --- to choose a break yourself."; } if (over.length) { text += (text ? " " : "") + (over.length === 1 ? "Slide " + over[0] + " is" : "Slides " + numbers(over) + " are") + " too long to fit even with smaller text: shorten " + (over.length === 1 ? "its longest line." : "their longest lines."); } noteEl.hidden = !text; noteEl.textContent = text; noteEl.classList.toggle("warn", over.length > 0); } function numbers(ns) { return ns.length === 1 ? String(ns[0]) : ns.slice(0, -1).join(", ") + " and " + ns[ns.length - 1]; } // Fit: a slide's text shrinks, never below FLOOR, until nothing has to scroll. var FLOOR = 0.7; function fits(box) { return box.scrollHeight <= box.clientHeight + 1; } // The text scale the slide got, or 0 when it is too long even at FLOOR. function fit(el, box) { el.style.setProperty("--fit", "1"); if (fits(box)) return 1; el.style.setProperty("--fit", String(FLOOR)); if (!fits(box)) return 0; var lo = FLOOR, hi = 1; for (var k = 0; k < 6; k++) { var mid = (lo + hi) / 2; el.style.setProperty("--fit", String(mid)); if (fits(box)) lo = mid; else hi = mid; } el.style.setProperty("--fit", String(lo)); return lo; } // Cut: a slide too long even at FLOOR is shown as two or more slides that fit. They are measured in a // hidden frame that shows the copied deck on a 1280x720 screen, so the cuts are the same on any screen // and in the copy. A cut falls between lines, never right after a heading, and as evenly as it can, // though a heading, a blank line or a change between list and text is worth a little evenness. Each // new slide repeats the slide's title, marked (cont.). var frameMain = null, frameSlide = null; var cutCache = Object.create(null), cached = 0; function makeFrame() { var frame = document.createElement("iframe"); frame.setAttribute("aria-hidden", "true"); frame.setAttribute("tabindex", "-1"); frame.style.cssText = "position:fixed;left:-20000px;top:0;width:1280px;height:720px;border:0;visibility:hidden;pointer-events:none"; frame.addEventListener("load", function () { try { frameMain = frame.contentDocument.querySelector("main"); frameSlide = frame.contentDocument.querySelector(".s"); } catch (e) { return; // nowhere to measure: slides are not cut } cutCache = Object.create(null); cached = 0; parse(); }); // The page's height is set too, in case a browser grows the frame to fit what is in it. frame.srcdoc = "" + '
'; document.body.appendChild(frame); } function fitsAt(lines, scale) { frameSlide.innerHTML = renderSlide(lines); frameSlide.style.setProperty("--fit", String(scale)); return fits(frameMain); } function kindOf(t) { if (t.indexOf("## ") === 0) return "h2"; if (t.indexOf("# ") === 0) return "h1"; if (t.indexOf("- ") === 0 || t.indexOf("* ") === 0) return "li"; return "p"; } function heading(block) { return block.kind === "h1" || block.kind === "h2"; } // A typed slide as the slides it is shown as: [{lines, over}], over meaning still too long. function cut(lines) { if (!frameSlide || !frameMain) return [{ lines: lines, over: false }]; var key = lines.join("\n"); if (!(key in cutCache)) { if (++cached > 500) { cutCache = Object.create(null); cached = 1; } cutCache[key] = cutNow(lines); } return cutCache[key]; } function cutNow(lines) { if (fitsAt(lines, FLOOR)) return [{ lines: lines, over: false }]; // One block per line that isn't blank, noting a blank line before it. A leading heading is the title // unless a later heading is as big (then it heads a section, like the rest). var blocks = [], gap = false; for (var i = 0; i < lines.length; i++) { var t = lines[i].trim(); if (t === "") { gap = blocks.length > 0; continue; } blocks.push({ line: lines[i], kind: kindOf(t), gap: gap }); gap = false; } var lead = blocks.length > 1 && heading(blocks[0]) ? blocks[0].kind : null; for (var n = 1; lead && n < blocks.length; n++) { if (blocks[n].kind === "h1" || blocks[n].kind === lead) lead = null; } var title = lead ? blocks[0].line.trim() : null; var body = title ? blocks.slice(1) : blocks, m = body.length; if (m < 2) return [{ lines: lines, over: true }]; var piece = function (a, e) { var out = title ? [a === 0 ? title : title + " (cont.)"] : []; for (var i = a; i < e; i++) { if (body[i].gap && i > a) out.push(""); out.push(body[i].line); } return out; }; // reach[a]: body[a..reach[a]) is the most that fits on one slide starting at a (a lone line may not). var reach = [], end = 0; for (var a = 0; a < m; a++) { if (end < a) end = a; if (a === 1) while (end > a && !fitsAt(piece(a, end), FLOOR)) end--; // "(cont.)" can make the title longer while (end < m && fitsAt(piece(a, end + 1), FLOOR)) end++; reach[a] = end; } // Where each line starts at full size, to cut evenly. seam[c]: what a cut before body[c] costs in evenness, // nothing before a heading, a little at a blank line or between list and text, more inside a list or run. frameSlide.innerHTML = renderSlide(piece(0, m)); frameSlide.style.setProperty("--fit", "1"); var els = frameSlide.querySelectorAll("h1,h2,li,p"), skip = title ? 1 : 0, top = [], seam = []; for (var c = 0; c < m; c++) { top.push(els[c + skip].getBoundingClientRect().top); seam.push(c === 0 || heading(body[c]) ? 0 : body[c].gap || (body[c].kind === "li") !== (body[c - 1].kind === "li") ? 1 / 16 : 1 / 4); } top.push(els[m - 1 + skip].getBoundingClientRect().bottom); // The fewest slides it can be, with as many lines on each as fit; then as even as that many can be. var fewest = 0; for (var at = 0; at < m; fewest++) at = Math.max(reach[at], at + 1); var runs = null; for (var k = fewest; !runs && k < fewest + 4 && k <= m; k++) runs = evenly(k, body, reach, top, seam, true); if (!runs) runs = evenly(fewest, body, reach, top, seam, false); if (!runs) return [{ lines: lines, over: true }]; return runs.map(function (r) { return { lines: piece(r[0], r[1]), over: reach[r[0]] < r[1] }; }); } // body cut into k runs [a, e), each within reach, as evenly by height as it can be, at the best seams; // null when it can't be done. Strict: never right after a heading. function evenly(k, body, reach, top, seam, strict) { var m = body.length, target = (top[m] - top[0]) / k; var best = [[0]], from = [[]]; for (var j = 1; j <= k; j++) { best[j] = []; from[j] = []; for (var e = j; e <= m; e++) { best[j][e] = Infinity; if (strict && e < m && heading(body[e - 1])) continue; for (var a = e - 1; a >= j - 1; a--) { if (e > Math.max(reach[a], a + 1)) { if (a === 1) continue; // reach can dip from 0 to 1, where "(cont.)" joins the title break; } if (!(best[j - 1][a] < Infinity)) continue; var d = top[e] - top[a] - target; var cost = best[j - 1][a] + d * d + (e < m ? seam[e] * target * target : 0); if (cost < best[j][e]) { best[j][e] = cost; from[j][e] = a; } } } } if (!(best[k][m] < Infinity)) return null; var runs = []; for (var i = k, stop = m; i >= 1; i--) { runs.unshift([from[i][stop], stop]); stop = from[i][stop]; } return runs; } function go(delta) { var next = current + delta; if (next < 0 || next > deck.length - 1) return; current = next; render(); } src.addEventListener("input", parse); var resizeTimer = null; window.addEventListener("resize", function () { clearTimeout(resizeTimer); resizeTimer = setTimeout(function () { fit(slideEl, wrap); }, 100); }); 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 = deck.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 }); // Links: the deck's text travels in the link after "#", which browsers never send to a server, so // nothing is stored. #d= is the text deflated (raw) and base64url-encoded; #md= is the text // percent-encoded, for anything that can't compress. The README shows how to make either. var linkBtn = document.getElementById("link"); var linkNote = document.getElementById("linknote"); var linkFor = null, link = null, linkTimer = null; function say(text, warn) { linkNote.hidden = !text; linkNote.textContent = text || ""; linkNote.classList.toggle("warn", !!warn); } function base64url(bytes) { var s = ""; for (var i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function unbase64url(text) { var s = atob(text.replace(/-/g, "+").replace(/_/g, "/")); var bytes = new Uint8Array(s.length); for (var i = 0; i < s.length; i++) bytes[i] = s.charCodeAt(i); return bytes; } function through(data, transform) { return new Response(new Blob([data]).stream().pipeThrough(transform)); } // A link to this text, as a promise. function makeLink(text) { var base = location.href.split("#")[0]; if (typeof CompressionStream !== "function") return Promise.resolve(base + "#md=" + encodeURIComponent(text)); return through(text, new CompressionStream("deflate-raw")).arrayBuffer().then(function (buf) { return base + "#d=" + base64url(new Uint8Array(buf)); }); } // A link to the deck is kept ready, so a click copies it at once: some browsers only let a page // copy while it is still answering the click. function scheduleLink() { clearTimeout(linkTimer); linkTimer = setTimeout(function () { var text = src.value; makeLink(text).then(function (url) { linkFor = text; link = url; }); }, 250); } // The deck in a "#", as a promise of its text, or null when there is none. function readLink(hash) { var params = new URLSearchParams(hash.replace(/^#/, "")); if (params.has("md")) return Promise.resolve(params.get("md")); if (!params.has("d")) return null; return Promise.resolve(params.get("d")).then(function (data) { if (typeof DecompressionStream !== "function") throw new Error("no DecompressionStream"); return through(unbase64url(data), new DecompressionStream("deflate-raw")).text(); }); } function openLink() { var reading = readLink(location.hash); if (!reading) return; reading.then(function (text) { load(text); current = 0; parse(); say("Opened from a link. Edits stay in this tab; copy a new link to share them."); }, function () { if (!src.value) { load(SAMPLE); parse(); } say("This link's deck couldn't be opened. It may have been cut short on the way.", true); }); } // Put text in the editor, showing its start. function load(text) { src.value = text; src.scrollTop = 0; } function share(url) { history.replaceState(null, "", url); say(url.length > 8000 ? "This link is " + url.length.toLocaleString("en") + " characters long. Some apps " + "cut links that long short; the HTML copy travels anywhere." : ""); copyText(url, linkBtn, "Link copied!", function () { say("Couldn't copy here, but the link is in the address bar.", true); }); } linkBtn.addEventListener("click", function () { var text = src.value; if (linkFor === text) share(link); else makeLink(text).then(share); }); // The prompt for an agent is SKILL.md, fetched once, so pasting it teaches any agent to make these links; // it also points to the file, for agents that can install it as a skill. Until SKILL.md arrives (or if // it can't), the prompt only points to it. var SKILL_URL = "https://mdecks.yolopreneur.lol/SKILL.md"; var agentBtn = document.getElementById("agent"); var agentPrompt = "Read " + SKILL_URL + " and follow it to make slide decks for me as .mdeck links."; fetch("SKILL.md").then(function (r) { if (!r.ok) throw new Error("SKILL.md: " + r.status); return r.text(); }).then(function (skill) { agentPrompt = "Use .mdeck to make slide decks for me. These instructions are also at " + SKILL_URL + "; if you can install skills, save that file as a skill named mdeck so you have it next time.\n\n" + skill.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n+/, ""); }).catch(function () { /* keep the prompt that points to SKILL.md */ }); agentBtn.addEventListener("click", function () { copyText(agentPrompt, agentBtn, "Prompt copied!"); }); // Build the self-contained export deck: the slides as shown, cuts and all. function buildExport() { var slidesHtml = deck.map(function (s) { return '
' + renderSlide(s) + "
"; }).join("\n"); return "\n\n\n\n" + "\n" + "Deck — .mdeck\n\n\n\n
\n
\n" + slidesHtml + "\n
\n
\n