diff --git a/ventures/mdecks/README.md b/ventures/mdecks/README.md index a7aee8b..7272b82 100644 --- a/ventures/mdecks/README.md +++ b/ventures/mdecks/README.md @@ -12,7 +12,7 @@ People who make a quick throwaway deck every week — sprint demos, status updat 1. Open the page and type markdown-ish text in the editor on the left. It comes preloaded with a sample deck so you can see real output immediately. 2. Watch the preview on the right re-typeset live. Navigate with the arrow keys (or swipe on touch screens); a progress bar tracks your place. -3. A slide with too much text shrinks to fit the screen, down to 70% of the normal size. If it still doesn't fit, the preview names it so you can split it with `---` (until then it scrolls). The copied deck fits its slides the same way. +3. A slide with too much text shrinks to fit, down to 70% of the normal size. Past that it's cut into two or more slides, between lines and never right after a heading, and each new slide repeats its title with "(cont.)". A note under the preview says which slides were cut; put in a `---` yourself to choose the break. Cuts are worked out for a 1280x720 screen, so the copied deck has the same slides on any screen, and on a smaller one they shrink a little more. 4. Press **Copy deck as HTML** to copy the whole deck as one self-contained HTML file. Open it in any browser and present with the arrow keys — offline, no dependencies. The syntax: diff --git a/ventures/mdecks/app.js b/ventures/mdecks/app.js index 00bbf8a..3d734ec 100644 --- a/ventures/mdecks/app.js +++ b/ventures/mdecks/app.js @@ -124,33 +124,84 @@ return html; } - var slides = []; + // 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 = [[]]; - if (current >= slides.length) current = slides.length - 1; + 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(); - scheduleLint(); + note(cuts, over); } function render() { - slideEl.innerHTML = renderSlide(slides[current]); + slideEl.innerHTML = renderSlide(deck[current]); fit(slideEl, wrap); wrap.scrollTop = 0; - counterEl.textContent = (current + 1) + " / " + slides.length; - var pct = slides.length <= 1 ? 100 : (current / (slides.length - 1)) * 100; + 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 === slides.length - 1; + nextBtn.disabled = current === deck.length - 1; } - // Fit each slide to the screen: shrink its text, never below FLOOR, until nothing has to scroll. - // A slide that still doesn't fit keeps scrolling and is named under the preview. + // 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; - var lintTimer = null; function fits(box) { return box.scrollHeight <= box.clientHeight + 1; @@ -172,34 +223,165 @@ return lo; } - function lint() { - var tooLong = []; - for (var k = 0; k < slides.length; k++) { - slideEl.innerHTML = renderSlide(slides[k]); - if (!fit(slideEl, wrap)) tooLong.push(k + 1); - } - render(); - noteEl.hidden = tooLong.length === 0; - noteEl.textContent = !tooLong.length ? "" : - (tooLong.length === 1 ? "Slide " + tooLong[0] + " is" : "Slides " + tooLong.join(", ") + " are") + - " too long to fit, even with smaller text. Split " + (tooLong.length === 1 ? "it" : "them") + - " with --- or cut some words."; + // 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 = "
" + + '