diff --git a/ventures/mdecks/README.md b/ventures/mdecks/README.md index 7272b82..e7e1510 100644 --- a/ventures/mdecks/README.md +++ b/ventures/mdecks/README.md @@ -2,7 +2,7 @@ Type slides in plain text and instantly preview a clean presentation layout — right in the browser. -**Live page:** https://teahubxyz.github.io/time/ventures/mdecks/ +**Live page:** https://mdecks.yolopreneur.lol/ ## Who it is for @@ -14,6 +14,7 @@ People who make a quick throwaway deck every week — sprint demos, status updat 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, 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. +5. Or press **Copy link** for a link with the whole deck inside it. Whoever opens it gets the deck in the editor, ready to present or change; changes make a new link. The deck rides after the `#`, which browsers never send to a server, so nothing is stored anywhere. The syntax: @@ -24,11 +25,41 @@ The syntax: - `[text](https://…)` for a link - Any other line becomes a paragraph -Everything runs in the page. Nothing is saved, nothing is sent anywhere, no account is needed, and the page is free. +Everything runs in the page. Nothing is stored, nothing is sent anywhere, no account is needed, and the page is free. + +## Links, for agents and scripts + +A link is the page's address, then `#d=`, then the deck's text (UTF-8) compressed with raw deflate and written in base64url without padding. Anything that can't compress can use `#md=` and the text percent-encoded instead; those links are just longer. The page opens either kind. + +In Python: + +```python +import base64, zlib + +def mdeck_link(text): + deflate = zlib.compressobj(9, zlib.DEFLATED, -15) + data = deflate.compress(text.encode()) + deflate.flush() + return "https://mdecks.yolopreneur.lol/#d=" + base64.urlsafe_b64encode(data).decode().rstrip("=") +``` + +In Node: + +```js +const link = "https://mdecks.yolopreneur.lol/#d=" + require("zlib").deflateRawSync(text).toString("base64url"); +``` + +From a shell, for a deck in `deck.md`: + +```sh +python3 -c 'import base64,sys,zlib; c=zlib.compressobj(9,zlib.DEFLATED,-15); d=c.compress(sys.stdin.buffer.read())+c.flush(); print("https://mdecks.yolopreneur.lol/#d="+base64.urlsafe_b64encode(d).decode().rstrip("="))' < deck.md +``` + +Without compression: `"https://mdecks.yolopreneur.lol/#md=" + encodeURIComponent(text)`. ## Honest limits -- Nothing is saved — close the tab and your text is gone. +- Nothing is stored — close the tab without copying a link or the HTML and your text is gone. +- Anyone with a link can read the deck in it, and a very long deck makes a link some apps cut short. - One layout and one typeface; no themes or branding. - No images, tables, or nested lists yet. - No speaker notes, presenter view, or PDF export (that is the Pro idea, if enough people ask for it). @@ -36,7 +67,7 @@ Everything runs in the page. Nothing is saved, nothing is sent anywhere, no acco ## .mdeck Pro (not built yet) -The paid idea: saved decks, themes, speaker notes, PDF export, and shareable links — still plain text in. Early access is collected via a [public GitHub issue form](https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+.mdeck&venture=mdecks&plan=Pro); no email, no payment, nothing is charged today. A 👍 on the [signal issue](https://teahub.xyz/teahubxyz/time/issues/13) counts as interest too. +The paid idea: saved decks, themes, speaker notes, and PDF export — still plain text in. Early access is collected via a [public GitHub issue form](https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+.mdeck&venture=mdecks&plan=Pro); no email, no payment, nothing is charged today. A 👍 on the [signal issue](https://teahub.xyz/teahubxyz/time/issues/13) counts as interest too. ## Context diff --git a/ventures/mdecks/app.js b/ventures/mdecks/app.js index 3d734ec..68ae693 100644 --- a/ventures/mdecks/app.js +++ b/ventures/mdecks/app.js @@ -40,6 +40,7 @@ "- 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", "", "---", @@ -52,7 +53,7 @@ "", "- Saved decks and themes", "- Speaker notes", - "- PDF export and a shareable link", + "- PDF export", "- **$5/mo**, the founding price", "", "Pro isn't built yet, and nothing is charged today.", @@ -163,6 +164,7 @@ if (current < 0) current = 0; render(); note(cuts, over); + scheduleLink(); } function render() { @@ -403,6 +405,101 @@ 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); + }); + // Build the self-contained export deck: the slides as shown, cuts and all. function buildExport() { var slidesHtml = deck.map(function (s) { @@ -427,31 +524,43 @@ } 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); - } + copyText(buildExport(), copyBtn, "Copied!"); }); - function fallbackCopy(text, done) { + // Copy, then say so on the button for a moment; failed (optional) runs when the browser won't copy. + function copyText(text, button, label, failed) { + var done = function () { + if (!button.dataset.label) button.dataset.label = button.textContent; + button.textContent = label; + clearTimeout(button.labelTimer); + button.labelTimer = setTimeout(function () { button.textContent = button.dataset.label; }, 1500); + }; + var fallback = function () { fallbackCopy(text, done, failed || function () {}); }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(done, fallback); + } else { + fallback(); + } + } + + function fallbackCopy(text, done, failed) { 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 */ } + var copied = false; + try { copied = document.execCommand("copy"); } catch (e) { copied = false; } document.body.removeChild(ta); + if (copied) done(); else failed(); } - src.value = SAMPLE; + // A deck in the address opens in place of the sample, and so does one pasted into it later. + var linked = /(^#|&)(d|md)=/.test(location.hash); + load(linked ? "" : SAMPLE); makeFrame(); parse(); + if (linked) openLink(); + window.addEventListener("hashchange", openLink); })(); \ No newline at end of file diff --git a/ventures/mdecks/index.html b/ventures/mdecks/index.html index 7f50d17..d718446 100644 --- a/ventures/mdecks/index.html +++ b/ventures/mdecks/index.html @@ -411,11 +411,12 @@ + 1 / 1
The copied file is a self-contained deck: open it in any browser and use ← / → to present.
+ +The copied file is a self-contained deck: open it in any browser and use ← / → to present. A copied link carries the deck inside it, after the #, so it opens here with nothing stored.
@@ -432,7 +433,7 @@One button copies the deck as a single self-contained HTML file. Open it anywhere, present it offline, email it, done.
+One button copies the deck as a single self-contained HTML file. Open it anywhere, present it offline, email it, done. Or copy a link with the whole deck inside it.
For people who make a deck every week and want it saved and polished.