diff --git a/copyparty/httpcli.py b/copyparty/httpcli.py index acc2c269..371d6d56 100644 --- a/copyparty/httpcli.py +++ b/copyparty/httpcli.py @@ -834,7 +834,7 @@ class HttpCli(object): def tx_md(self, fs_path): logmsg = "{:4} {} ".format("", self.req) - if "edit" in self.uparam: + if "edit2" in self.uparam: html_path = "web/mde.html" template = self.conn.tpl_mde else: @@ -844,18 +844,25 @@ class HttpCli(object): html_path = os.path.join(E.mod, html_path) st = os.stat(fsenc(fs_path)) - sz_md = st.st_size + # sz_md = st.st_size ts_md = st.st_mtime st = os.stat(fsenc(html_path)) ts_html = st.st_mtime + # TODO dont load into memory ;_; + # (trivial fix, count the &'s) + with open(fsenc(fs_path), "rb") as f: + md = f.read().replace(b"&", b"&") + sz_md = len(md) + file_ts = max(ts_md, ts_html) file_lastmod, do_send = self._chk_lastmod(file_ts) self.out_headers["Last-Modified"] = file_lastmod status = 200 if do_send else 304 targs = { + "edit": "edit" in self.uparam, "title": html_escape(self.vpath, quote=False), "lastmod": int(ts_md * 1000), "md": "", @@ -868,9 +875,7 @@ class HttpCli(object): self.log(logmsg) return True - with open(fsenc(fs_path), "rb") as f: - md = f.read() - + # TODO jinja2 can stream this right? targs["md"] = md.decode("utf-8", "replace") html = template.render(**targs).encode("utf-8") try: diff --git a/copyparty/web/md.css b/copyparty/web/md.css index b767bea8..780779ec 100644 --- a/copyparty/web/md.css +++ b/copyparty/web/md.css @@ -4,10 +4,11 @@ html, body { font-family: sans-serif; line-height: 1.5em; } +#mtw { + display: none; +} #mw { - width: 48.5em; margin: 0 auto; - margin-bottom: 6em; } pre, code, a { color: #480; @@ -76,6 +77,9 @@ h2 { padding-left: .4em; margin-top: 3em; } +h3 { + border-bottom: .1em solid #999; +} h1 a, h3 a, h5 a, h2 a, h4 a, h6 a { color: inherit; @@ -116,8 +120,9 @@ small { opacity: .8; } #toc { - width: 48.5em; - margin: 0 auto; + margin: 0 1em; + -ms-scroll-chaining: none; + overscroll-behavior-y: none; } #toc ul { padding-left: 1em; @@ -181,10 +186,24 @@ blink { opacity: 1; } } + @media screen { html, body { margin: 0; padding: 0; + outline: 0; + border: none; + width: 100%; + height: 100%; + } + #mw { + padding: 0 1em; + margin: 0 auto; + right: 0; + } + #mp { + max-width: 54em; + margin-bottom: 6em; } a { color: #fff; @@ -212,15 +231,23 @@ blink { padding: .5em 0; } #mn { - font-weight: normal; padding: 1.3em 0 .7em 1em; - font-size: 1.4em; + border-bottom: 1px solid #ccc; + background: #eee; + z-index: 10; + width: calc(100% - 1em); + } + #mn.undocked { + position: fixed; + padding: 1.2em 0 1em 1em; + box-shadow: 0 0 .5em rgba(0, 0, 0, 0.3); + background: #f7f7f7; } #mn a { color: #444; background: none; margin: 0 0 0 -.2em; - padding: 0 0 0 .4em; + padding: .3em 0 .3em .4em; text-decoration: none; border: none; /* ie: */ @@ -248,7 +275,19 @@ blink { text-decoration: underline; } #mh { - margin: 0 0 1.5em 0; + padding: .4em 1em; + position: relative; + width: 100%; + width: calc(100% - 3em); + background: #eee; + z-index: 9; + top: 0; + } + #mh a { + color: #444; + background: none; + text-decoration: underline; + border: none; } @@ -270,8 +309,7 @@ blink { html.dark #toc li { border-width: 0; } - html.dark #m a, - html.dark #mh a { + html.dark #m a { background: #057; } html.dark #m h1 a, html.dark #m h4 a, @@ -322,26 +360,44 @@ blink { html.dark #mn a { color: #ccc; } + html.dark #mn { + border-bottom: 1px solid #333; + } + html.dark #mn, + html.dark #mh { + background: #222; + } + html.dark #mh a { + color: #ccc; + background: none; + } } -@media screen and (min-width: 64em) { + +@media screen and (min-width: 70em) { #mw { - margin-left: 14em; - margin-left: calc(100% - 50em); + position: fixed; + overflow-y: auto; + left: 14em; + left: calc(100% - 57em); + max-width: none; + bottom: 0; + scrollbar-color: #eb0 #f7f7f7; } #toc { width: 13em; - width: calc(100% - 52.3em); + width: calc(100% - 57.3em); + max-width: 30em; background: #eee; position: fixed; + overflow-y: auto; top: 0; left: 0; - height: 100%; - overflow-y: auto; + bottom: 0; padding: 0; margin: 0; - box-shadow: 0 0 1em #ccc; scrollbar-color: #eb0 #f7f7f7; - xscrollbar-width: thin; + box-shadow: 0 0 1em rgba(0,0,0,0.1); + border-top: 1px solid #d7d7d7; } #toc li { border-left: .3em solid #ccc; @@ -361,13 +417,22 @@ blink { html.dark #toc { background: #282828; + border-top: 1px solid #2c2c2c; box-shadow: 0 0 1em #181818; + } + html.dark #toc, + html.dark #mw { scrollbar-color: #b80 #282828; } + html.dark #mn.undocked { + box-shadow: 0 0 .5em #555; + border: none; + background: #0a0a0a; + } } -@media screen and (min-width: 84em) { +@media screen and (min-width: 87.5em) { #toc { width: 30em } - #mw { margin-left: 32em } + #mw { left: 30.5em } } @media print { a { diff --git a/copyparty/web/md.html b/copyparty/web/md.html index 03e9edb7..73eb44c7 100644 --- a/copyparty/web/md.html +++ b/copyparty/web/md.html @@ -4,32 +4,61 @@ + {%- if edit %} + + {%- endif %} -
+
navbar
+
+ go dark + hide nav + {%- if edit %} + save + help + {%- else %} + edit (basic) + edit (fancy) + {%- endif %} +
+
+ +
-
- go dark // - edit this -
Loading
if you're still reading this, check that javascript is allowed
-
- -
+
+ + {%- if edit %} +
+ +
+ {%- endif %} + + {%- if edit %} + + {%- endif %} diff --git a/copyparty/web/md.js b/copyparty/web/md.js index b7328ebb..5bc9b81f 100644 --- a/copyparty/web/md.js +++ b/copyparty/web/md.js @@ -1,17 +1,16 @@ -/*var conv = new showdown.Converter(); -conv.setFlavor('github'); -conv.setOption('tasklists', 0); -var mhtml = conv.makeHtml(dom_md.value); -*/ - var dom_toc = document.getElementById('toc'); var dom_wrap = document.getElementById('mw'); -var dom_head = document.getElementById('mh'); +var dom_hbar = document.getElementById('mh'); var dom_nav = document.getElementById('mn'); -var dom_doc = document.getElementById('m'); -var dom_md = document.getElementById('mt'); +var dom_pre = document.getElementById('mp'); +var dom_src = document.getElementById('mt'); +var dom_navtgl = document.getElementById('navtoggle'); -// add toolbar buttons +function hesc(txt) { + return txt.replace(/&/g, "&").replace(//g, ">"); +} + +// add navbar (function () { var n = document.location + ''; n = n.substr(n.indexOf('//') + 2).split('?')[0].split('/'); @@ -22,7 +21,7 @@ var dom_md = document.getElementById('mt'); if (a > 0) loc.push(n[a]); - var dec = decodeURIComponent(n[a]).replace(/&/g, "&").replace(//g, ">"); + var dec = hesc(decodeURIComponent(n[a])); nav.push('' + dec + ''); } @@ -36,13 +35,10 @@ function convert_markdown(md_text) { gfm: true }); var html = marked(md_text); - dom_doc.innerHTML = html; - - var loader = document.getElementById('ml'); - loader.parentNode.removeChild(loader); + dom_pre.innerHTML = html; // todo-lists (should probably be a marked extension) - var nodes = dom_doc.getElementsByTagName('input'); + var nodes = dom_pre.getElementsByTagName('input'); for (var a = nodes.length - 1; a >= 0; a--) { var dom_box = nodes[a]; if (dom_box.getAttribute('type') !== 'checkbox') @@ -61,9 +57,32 @@ function convert_markdown(md_text) { '' + char + '' + html.substr(html.indexOf('>') + 1); } + + var manip_nodes = dom_pre.getElementsByTagName('*'); + for (var a = manip_nodes.length - 1; a >= 0; a--) { + var el = manip_nodes[a]; + + var is_precode = + el.tagName == 'PRE' && + el.childNodes.length === 1 && + el.childNodes[0].tagName == 'CODE'; + + if (!is_precode) + continue; + + var nline = parseInt(el.getAttribute('data-ln')) + 1; + var lines = el.innerHTML.replace(/\r?\n<\/code>$/i, '').split(/\r?\n/g); + for (var b = 0; b < lines.length - 1; b++) + lines[b] += '\n'; + + el.innerHTML = lines.join(''); + } } function init_toc() { + var loader = document.getElementById('ml'); + loader.parentNode.removeChild(loader); + var anchors = []; // list of toc entries, complex objects var anchor = null; // current toc node var id_seen = {}; // taken IDs @@ -71,7 +90,7 @@ function init_toc() { var lv = 0; // current indentation level in the toc html var re = new RegExp('^[Hh]([1-3])'); - var manip_nodes_dyn = dom_doc.getElementsByTagName('*'); + var manip_nodes_dyn = dom_pre.getElementsByTagName('*'); var manip_nodes = []; for (var a = 0, aa = manip_nodes_dyn.length; a < aa; a++) manip_nodes.push(manip_nodes_dyn[a]); @@ -79,16 +98,7 @@ function init_toc() { for (var a = 0, aa = manip_nodes.length; a < aa; a++) { var elm = manip_nodes[a]; var m = re.exec(elm.tagName); - - var is_header = - m !== null; - - var is_precode = - !is_header && - elm.tagName == 'PRE' && - elm.childNodes.length === 1 && - elm.childNodes[0].tagName == 'CODE'; - + var is_header = m !== null; if (is_header) { var nlv = m[1]; while (lv < nlv) { @@ -127,17 +137,6 @@ function init_toc() { y: null }; } - else if (is_precode) { - // not actually toc-related (sorry), - // split
into one per line - var nline = parseInt(elm.getAttribute('data-ln')) + 1; - var lines = elm.innerHTML.replace(/\r?\n<\/code>$/i, '').split(/\r?\n/g); - for (var b = 0; b < lines.length - 1; b++) - lines[b] += '\n'; - - elm.innerHTML = lines.join(''); - } - if (!is_header && anchor) anchor.kids.push(elm); } @@ -209,41 +208,77 @@ function init_toc() { // "main" :p -convert_markdown(dom_md.value); +convert_markdown(dom_src.value); var toc = init_toc(); // scroll handler -(function () { - var timer_active = false; - var final = null; +var redraw = (function () { + var sbs = false; + function onresize() { + sbs = window.matchMedia('(min-width: 64em)').matches; + var y = (dom_hbar.offsetTop + dom_hbar.offsetHeight) + 'px'; + if (sbs) { + dom_toc.style.top = y; + dom_wrap.style.top = y; + dom_toc.style.marginTop = '0'; + } + onscroll(); + } function onscroll() { - clearTimeout(final); - timer_active = false; toc.refresh(); - - var y = 0; - if (window.matchMedia('(min-width: 64em)').matches) - y = parseInt(dom_nav.offsetHeight) - window.scrollY; - - dom_toc.style.marginTop = y < 0 ? 0 : y + "px"; } - onscroll(); - function ev_onscroll() { - // long timeout: scroll ended - clearTimeout(final); - final = setTimeout(onscroll, 100); + window.onresize = onresize; + window.onscroll = onscroll; + dom_wrap.onscroll = onscroll; - // short timeout: continuous updates - if (timer_active) + onresize(); + return onresize; +})(); + + +dom_navtgl.onclick = function () { + var timeout = null; + function show_nav(e) { + if (e && e.target == dom_hbar && e.pageX && e.pageX < dom_hbar.offsetWidth / 2) return; - timer_active = true; - setTimeout(onscroll, 10); - }; + clearTimeout(timeout); + dom_nav.style.display = 'block'; + } + function hide_nav() { + clearTimeout(timeout); + timeout = setTimeout(function () { + dom_nav.style.display = 'none'; + }, 30); + } + var hidden = dom_navtgl.innerHTML == 'hide nav'; + dom_navtgl.innerHTML = hidden ? 'show nav' : 'hide nav'; + if (hidden) { + dom_nav.setAttribute('class', 'undocked'); + dom_nav.style.display = 'none'; + dom_nav.style.top = dom_hbar.offsetHeight + 'px'; + dom_nav.onmouseenter = show_nav; + dom_nav.onmouseleave = hide_nav; + dom_hbar.onmouseenter = show_nav; + dom_hbar.onmouseleave = hide_nav; + } + else { + dom_nav.setAttribute('class', ''); + dom_nav.style.display = 'block'; + dom_nav.style.top = '0'; + dom_nav.onmouseenter = null; + dom_nav.onmouseleave = null; + dom_hbar.onmouseenter = null; + dom_hbar.onmouseleave = null; + } + if (window.localStorage) + localStorage.setItem('hidenav', hidden ? 1 : 0); - window.onscroll = ev_onscroll; - window.onresize = ev_onscroll; -})(); + redraw(); +}; + +if (window.localStorage && localStorage.getItem('hidenav') == 1) + dom_navtgl.onclick(); diff --git a/copyparty/web/md2.css b/copyparty/web/md2.css new file mode 100644 index 00000000..35e26e01 --- /dev/null +++ b/copyparty/web/md2.css @@ -0,0 +1,75 @@ +#toc { + display: none; +} +#mtw { + display: block; + position: fixed; + left: 0; + bottom: 0; + width: calc(100% - 58em); +} +#mw { + left: calc(100% - 57em); +} +#mp { + position: relative; +} +#mt, #mtr { + width: 100%; + height: 100%; + color: #444; + background: #f7f7f7; + border: 1px solid #999; + font-family: 'consolas', monospace, monospace; + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: break-word; + word-wrap: break-word; /*ie*/ + overflow-y: scroll; + line-height: 1.3em; + font-size: .9em; + position: relative; +} +html.dark #mt { + color: #eee; + background: #222; + border: 1px solid #777; +} +#mtr { + position: absolute; + top: 1px; + left: 1px; +} +#save.force-save { + color: #400; + background: #f97; + border-radius: .15em; +} +#helpbox { + display: none; + position: fixed; + background: #f7f7f7; + box-shadow: 0 .5em 2em #777; + border-radius: .4em; + padding: 2em; + top: 4em; + left: calc(50% - 15em); + right: 0; + width: 30em; + z-index: 9001; +} +#helpclose { + display: block; +} +html.dark #helpbox { + background: #222; + box-shadow: 0 .5em 2em #444; + border: 1px solid #079; + border-width: 1px 0; +} + +/* dbg: +#mt { + opacity: .5; +} +*/ diff --git a/copyparty/web/md2.js b/copyparty/web/md2.js new file mode 100644 index 00000000..730ba1a0 --- /dev/null +++ b/copyparty/web/md2.js @@ -0,0 +1,388 @@ +// server state +var server_md = dom_src.value; + + +// dom nodes +var dom_swrap = document.getElementById('mtw'); +var dom_ref = (function () { + var d = document.createElement('div'); + d.setAttribute('id', 'mtr'); + dom_swrap.appendChild(d); + d = document.getElementById('mtr'); + // hide behind the textarea (offsetTop is not computed if display:none) + dom_src.style.zIndex = '4'; + d.style.zIndex = '3'; + return d; +})(); + + +// line->scrollpos maps +var map_src = []; +var map_pre = []; +function genmap(dom) { + var ret = []; + var parent_y = 0; + var parent_n = null; + var nodes = dom.querySelectorAll('*[data-ln]'); + for (var a = 0; a < nodes.length; a++) { + var n = nodes[a]; + var ln = parseInt(n.getAttribute('data-ln')); + if (ln in ret) + continue; + + var y = 0; + var par = n.offsetParent; + if (par != parent_n) { + while (par && par != dom) { + y += par.offsetTop; + par = par.offsetParent; + } + if (par != dom) + continue; + + parent_y = y; + parent_n = n.offsetParent; + } + while (ln > ret.length) + ret.push(null); + + ret.push(parent_y + n.offsetTop); + } + return ret; +} + + +// input handler +var nlines = 0; +(function () { + dom_src.oninput = function (e) { + var src = dom_src.value; + convert_markdown(src); + + var lines = hesc(src).replace(/\r/g, "").split('\n'); + nlines = lines.length; + var html = []; + for (var a = 0; a < lines.length; a++) + html.push('' + lines[a] + ""); + + dom_ref.innerHTML = html.join('\n'); + map_src = genmap(dom_ref); + map_pre = genmap(dom_pre); + } + dom_src.oninput(); +})(); + + +// resize handler +redraw = (function () { + function onresize() { + var y = (dom_hbar.offsetTop + dom_hbar.offsetHeight) + 'px'; + dom_wrap.style.top = y; + dom_swrap.style.top = y; + dom_ref.style.width = (dom_src.offsetWidth - 4) + 'px'; + map_src = genmap(dom_ref); + map_pre = genmap(dom_pre); + console.log(document.body.clientWidth + 'x' + document.body.clientHeight); + }; + + window.onresize = onresize; + window.onscroll = null; + dom_wrap.onscroll = null; + + onresize(); + return onresize; +})(); + + +// scroll handlers +(function () { + var skip_src = false, skip_pre = false; + + function scroll(src, srcmap, dst, dstmap) { + var y = src.scrollTop; + if (y < 8) { + dst.scrollTop = 0; + return; + } + if (y + 8 + src.clientHeight > src.scrollHeight) { + dst.scrollTop = dst.scrollHeight - dst.clientHeight; + return; + } + y += src.clientHeight / 2; + var sy1 = -1, sy2 = -1, dy1 = -1, dy2 = -1; + for (var a = 1; a < nlines + 1; a++) { + if (srcmap[a] === null || dstmap[a] === null) + continue; + + if (srcmap[a] > y) { + sy2 = srcmap[a]; + dy2 = dstmap[a]; + break; + } + sy1 = srcmap[a]; + dy1 = dstmap[a]; + } + if (sy1 == -1) + return; + + var dy = dy1; + if (sy2 != -1 && dy2 != -1) { + var mul = (y - sy1) / (sy2 - sy1); + dy = dy1 + (dy2 - dy1) * mul; + } + dst.scrollTop = dy - dst.clientHeight / 2; + } + + dom_src.onscroll = function () { + //dbg: dom_ref.scrollTop = dom_src.scrollTop; + if (skip_src) { + skip_src = false; + return; + } + skip_pre = true; + scroll(dom_src, map_src, dom_wrap, map_pre); + }; + + dom_wrap.onscroll = function () { + if (skip_pre) { + skip_pre = false; + return; + } + skip_src = true; + scroll(dom_wrap, map_pre, dom_src, map_src); + }; +})(); + + +// save handler +function save(e) { + if (e) e.preventDefault(); + var save_btn = document.getElementById("save"), + save_cls = save_btn.getAttribute('class'); + + if (save_cls == 'disabled') { + alert('there is nothing to save'); + return; + } + + var force = save_cls == 'force-save'; + if (force && !confirm('confirm that you wish to lose the changes made on the server since you opened this document')) { + alert('ok, aborted'); + return; + } + + var txt = dom_src.value; + + var fd = new FormData(); + fd.append("act", "tput"); + fd.append("lastmod", (force ? -1 : last_modified)); + fd.append("body", txt); + + var url = (document.location + '').split('?')[0] + '?raw'; + var xhr = new XMLHttpRequest(); + xhr.open('POST', url, true); + xhr.responseType = 'text'; + xhr.onreadystatechange = save_cb; + xhr.btn = save_btn; + xhr.txt = txt; + xhr.send(fd); +} + +function save_cb() { + if (this.readyState != XMLHttpRequest.DONE) + return; + + if (this.status !== 200) { + alert('Error! The file was NOT saved.\n\n' + this.status + ": " + (this.responseText + '').replace(/^
/, ""));
+        return;
+    }
+
+    var r;
+    try {
+        r = JSON.parse(this.responseText);
+    }
+    catch (ex) {
+        alert('Failed to parse reply from server:\n\n' + this.responseText);
+        return;
+    }
+
+    if (!r.ok) {
+        if (!this.btn.classList.contains('force-save')) {
+            this.btn.classList.add('force-save');
+            var msg = [
+                'This file has been modified since you started editing it!\n',
+                'if you really want to overwrite, press save again.\n',
+                'modified ' + ((r.now - r.lastmod) / 1000) + ' seconds ago,',
+                ((r.lastmod - last_modified) / 1000) + ' sec after you opened it\n',
+                last_modified + ' lastmod when you opened it,',
+                r.lastmod + ' lastmod on the server now,',
+                r.now + ' server time now,\n',
+            ];
+            alert(msg.join('\n'));
+        }
+        else {
+            alert('Error! Save failed.  Maybe this JSON explains why:\n\n' + this.responseText);
+        }
+        return;
+    }
+
+    this.btn.classList.remove('force-save');
+    //alert('save OK -- wrote ' + r.size + ' bytes.\n\nsha512: ' + r.sha512);
+
+    // download the saved doc from the server and compare
+    var url = (document.location + '').split('?')[0] + '?raw';
+    var xhr = new XMLHttpRequest();
+    xhr.open('GET', url, true);
+    xhr.responseType = 'text';
+    xhr.onreadystatechange = save_chk;
+    xhr.btn = this.save_btn;
+    xhr.txt = this.txt;
+    xhr.lastmod = r.lastmod;
+    xhr.send();
+}
+
+function save_chk() {
+    if (this.readyState != XMLHttpRequest.DONE)
+        return;
+
+    if (this.status !== 200) {
+        alert('Error!  The file was NOT saved.\n\n' + this.status + ": " + (this.responseText + '').replace(/^
/, ""));
+        return;
+    }
+
+    var doc1 = this.txt.replace(/\r\n/g, "\n");
+    var doc2 = this.responseText.replace(/\r\n/g, "\n");
+    if (doc1 != doc2) {
+        alert(
+            'Error! The document on the server does not appear to have saved correctly (your editor contents and the server copy is not identical). Place the document on your clipboard for now and check the server logs for hints\n\n' +
+            'Length: yours=' + doc1.length + ', server=' + doc2.length
+        );
+        alert('yours, ' + doc1.length + ' byte:\n[' + doc1 + ']');
+        alert('server, ' + doc2.length + ' byte:\n[' + doc2 + ']');
+        return;
+    }
+
+    last_modified = this.lastmod;
+    server_md = this.txt;
+
+    var ok = document.createElement('div');
+    ok.setAttribute('style', 'font-size:6em;font-family:serif;font-weight:bold;color:#cf6;background:#444;border-radius:.3em;padding:.6em 0;position:fixed;top:30%;left:calc(50% - 2em);width:4em;text-align:center;z-index:9001;transition:opacity 0.2s ease-in-out;opacity:1');
+    ok.innerHTML = 'OK✔️';
+    var parent = document.getElementById('m');
+    document.documentElement.appendChild(ok);
+    setTimeout(function () {
+        ok.style.opacity = 0;
+    }, 500);
+    setTimeout(function () {
+        ok.parentNode.removeChild(ok);
+    }, 750);
+}
+
+
+// returns [before,selection,after]
+function getsel() {
+    var car = dom_src.selectionStart;
+    var cdr = dom_src.selectionEnd;
+    console.log(car, cdr);
+
+    var txt = dom_src.value;
+    car = Math.max(car, 0);
+    cdr = Math.min(cdr, txt.length - 1);
+
+    if (car < cdr && txt[car] == '\n')
+        car++;
+
+    if (car < cdr && txt[cdr - 1] == '\n')
+        cdr -= 2;
+
+    car = txt.lastIndexOf('\n', car - 1) + 1;
+    cdr = txt.indexOf('\n', cdr);
+    if (cdr < car)
+        cdr = txt.length;
+
+    return [
+        txt.substring(0, car),
+        txt.substring(car, cdr),
+        txt.substring(cdr)
+    ];
+}
+
+
+// place modified getsel into markdown
+function setsel(a, b, c) {
+    dom_src.value = [a, b, c].join('');
+    dom_src.setSelectionRange(a.length, a.length + b.length);
+    dom_src.oninput();
+}
+
+
+// indent/dedent
+function md_indent(dedent) {
+    var r = getsel(),
+        pre = r[0],
+        sel = r[1],
+        post = r[2];
+
+    if (dedent)
+        sel = sel.replace(/^  /, "").replace(/\n  /g, "\n");
+    else
+        sel = '  ' + sel.replace(/\n/g, '\n  ');
+
+    setsel(pre, sel, post);
+}
+
+
+// header
+function md_header(dedent) {
+    var r = getsel(),
+        pre = r[0],
+        sel = r[1],
+        post = r[2];
+
+    if (dedent)
+        sel = sel.replace(/^#/, "").replace(/^ +/, "");
+    else
+        sel = sel.replace(/^(#*) ?/, "#$1 ");
+
+    setsel(pre, sel, post);
+}
+
+
+// hotkeys
+(function () {
+    function keydown(ev) {
+        ev = ev || window.event;
+        var kc = ev.keyCode || ev.which;
+        var ctrl = ev.ctrlKey || ev.metaKey;
+        //console.log(ev.code, kc);
+        if (ctrl && (ev.code == "KeyS" || kc == 83)) {
+            save();
+            return false;
+        }
+        if (document.activeElement == dom_src) {
+            if (ev.code == "Tab" || kc == 9) {
+                md_indent(ev.shiftKey);
+                return false;
+            }
+            if (ctrl && (ev.code == "KeyH" || kc == 72)) {
+                md_header(ev.shiftKey);
+                return false;
+            }
+        }
+    }
+    document.onkeydown = keydown;
+})();
+
+
+document.getElementById('help').onclick = function (e) {
+    if (e) e.preventDefault();
+    var dom = document.getElementById('helpbox');
+    var dtxt = dom.getElementsByTagName('textarea');
+    if (dtxt.length > 0)
+        dom.innerHTML = 'close' + marked(dtxt[0].value);
+
+    dom.style.display = 'block';
+    document.getElementById('helpclose').onclick = function () {
+        dom.style.display = 'none';
+    };
+};
diff --git a/copyparty/web/mde.css b/copyparty/web/mde.css
index b03576e6..6e73c9cd 100644
--- a/copyparty/web/mde.css
+++ b/copyparty/web/mde.css
@@ -21,7 +21,6 @@ html, body {
 #mn {
     font-weight: normal;
     margin: 1.3em 0 .7em 1em;
-    font-size: 1.4em;
 }
 #mn a {
     color: #444;
diff --git a/copyparty/web/mde.js b/copyparty/web/mde.js
index 5cc4c74f..2a8d7906 100644
--- a/copyparty/web/mde.js
+++ b/copyparty/web/mde.js
@@ -53,7 +53,8 @@ var mde = (function () {
             "save": "Ctrl-S"
         },
         insertTexts: ["[](", ")"],
-        tabSize: 4,
+        indentWithTabs: false,
+        tabSize: 2,
         toolbar: tbar,
         previewClass: 'mdo',
         onToggleFullScreen: set_jumpto,
diff --git a/copyparty/web/splash.css b/copyparty/web/splash.css
index efeb8259..7d29f8cd 100644
--- a/copyparty/web/splash.css
+++ b/copyparty/web/splash.css
@@ -13,6 +13,7 @@ h1 {
 	border-bottom: 1px solid #ccc;
 	margin: 2em 0 .4em 0;
 	padding: 0 0 .2em 0;
+	font-weight: normal;
 }
 li {
 	margin: 1em 0;
@@ -24,4 +25,29 @@ a {
 	border-bottom: 1px solid #aaa;
 	border-radius: .2em;
 	padding: .2em .8em;
+}
+
+
+html.dark,
+html.dark body,
+html.dark #wrap {
+	background: #222;
+	color: #ccc;
+}
+html.dark h1 {
+	border-color: #777;
+}
+html.dark a {
+	color: #fff;
+	background: #057;
+	border-color: #37a;
+}
+html.dark input {
+	color: #fff;
+	background: #624;
+	border: 1px solid #c27;
+	border-width: 1px 0 0 0;
+	border-radius: .5em;
+	padding: .5em .7em;
+	margin: 0 .5em 0 0;
 }
\ No newline at end of file
diff --git a/copyparty/web/splash.html b/copyparty/web/splash.html
index 43eef515..235a7f10 100644
--- a/copyparty/web/splash.html
+++ b/copyparty/web/splash.html
@@ -36,7 +36,11 @@
             
         
     
-    
-
+    
+
 
\ No newline at end of file
diff --git a/scripts/make-sfx.sh b/scripts/make-sfx.sh
index 0f7170bd..6973be3a 100755
--- a/scripts/make-sfx.sh
+++ b/scripts/make-sfx.sh
@@ -13,6 +13,9 @@ echo
 #
 # `no-ogv` saves ~500k by removing the opus/vorbis audio codecs
 #   (only affects apple devices; everything else has native support)
+#
+# `no-cm` saves ~90k by removing easymde/codemirror
+#   (the fancy markdown editor)
 
 
 command -v gtar  >/dev/null &&
@@ -35,6 +38,7 @@ while [ ! -z "$1" ]; do
 	[ "$1" = clean  ] && clean=1  && shift && continue
 	[ "$1" = re     ] && repack=1 && shift && continue
 	[ "$1" = no-ogv ] && no_ogv=1 && shift && continue
+	[ "$1" = no-cm  ] && no_cm=1  && shift && continue
 	break
 done
 
@@ -103,6 +107,11 @@ while IFS= read -r x; do sed -ri 's/\.full\.(js|css)/.\1/g' "$x"; done
 [ $no_ogv ] &&
 	rm -rf copyparty/web/deps/{dynamicaudio,ogv}*
 
+[ $no_cm ] && {
+	rm -rf copyparty/web/mde.* copyparty/web/deps/easymde*
+	sed -ri '/edit2">edit \(fancy/d' copyparty/web/md.html
+}
+
 echo creating tar
 args=(--owner=1000 --group=1000)
 [ "$OSTYPE" = msys ] &&
diff --git a/srv/test.md b/srv/test.md
index 24ae4618..c2208334 100644
--- a/srv/test.md
+++ b/srv/test.md
@@ -1,3 +1,33 @@
+### hello world
+
+```
+[72....................................................................]
+[80............................................................................]
+```
+
+* foo
+  ```
+  [72....................................................................]
+  [80............................................................................]
+  ```
+
+  * bar
+    ```
+    [72....................................................................]
+    [80............................................................................]
+    ```
+
+a123456789b123456789c123456789d123456789e123456789f123456789g123456789h123456789i123456789j123456789k123456789l123456789m123456789n123456789o123456789p123456789q123456789r123456789s123456789t123456789u123456789v123456789w123456789x123456789y123456789z123456789
+
+   bar & baz
+?foo=bar&baz=qwe&rty
+
+```
+   bar & baz
+?foo=bar&baz=qwe&rty
+
+```
+
 *fails marked/showdown/tui/simplemde (just italics), **OK: markdown-it/simplemde:***  
 testing just google.com and underscored _google.com_ also with _google.com,_ trailing comma and _google.com_, comma after