diff --git a/README.md b/README.md index c76073b..44b8bb1 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,9 @@ file time as its date, and is then encrypted (name it with lower-case letters, n built for millions. - Pages are only reloaded from disk when Mako starts. - Live preview works a line at a time (a code block counts as one), so a table or a quote spread over - several lines shows rendered line by line. View shows the whole page as one. + several lines shows rendered line by line. View shows the whole page as one. Selecting across lines + (Ctrl+A, Shift+Up or Down, Shift+click, or dragging) opens those whole lines as raw markdown, so a + selection that starts or ends partway through a line takes in the whole line. ## Design diff --git a/internal/web/static/editor.js b/internal/web/static/editor.js index 4c3d0f1..a93b372 100644 --- a/internal/web/static/editor.js +++ b/internal/web/static/editor.js @@ -152,7 +152,9 @@ function enhance(ta, wrap) { // conflicts and the other modes need to know nothing about it. A fenced code block is edited as a whole. function makeLive(ta) { const el = h('div', { class: 'live', hidden: true }); - let lines = [], act = null; // act: the block starting at line s is being edited, caret at col + // act: the block starting at line s is being edited, caret at col. With e set, lines s to e are edited + // together as raw text (a selection across lines), and sel is [start, end, direction] inside them. + let lines = [], act = null; function blocks() { const out = []; @@ -177,10 +179,48 @@ function makeLive(ta) { if (m) root.querySelector('ol')?.setAttribute('start', m[1]); box.append(root); } - box.addEventListener('click', e => { if (!e.target.closest('a,input,button') && !inSelection()) activate(b.s, Infinity, true); }); + box.dataset.s = b.s; box.dataset.e = b.e; + box.addEventListener('click', e => { + if (e.target.closest('a,input,button')) return; + if (e.shiftKey && reach(b)) return; + if (!inSelection()) activate(b.s, Infinity, true); + }); return box; } + // span edits lines s to e as one piece of raw text, so selecting, deleting and Ctrl+Backspace work across lines + function span(s, e, sel) { + lines = ta.value.split('\n'); + act = { s, e, sel, focus: true }; + draw(); + } + // reach extends the selection being edited to a line that was shift-clicked + function reach(b) { + const t = el.querySelector('.live-edit'); + if (!act || !t) return false; + const to = act.e ?? blockAt(act.s).e, back = t.selectionDirection === 'backward'; + const a = back ? t.selectionEnd : t.selectionStart; + if (b.s > to) span(act.s, b.e, [a, lines.slice(act.s, b.e + 1).join('\n').length, 'forward']); + else if (b.e < act.s) span(b.s, to, [0, a + lines.slice(b.s, act.s).join('\n').length + 1, 'backward']); + else return false; + return true; + } + // grab turns lines selected with the mouse into one raw editor holding exactly those lines + function grab() { + const sel = getSelection(); + if (el.hidden || !sel || sel.isCollapsed || !sel.rangeCount) return; + const hit = [...el.querySelectorAll('.live-line')].filter(x => sel.containsNode(x, true)); + if (hit.length < 2) return; + const s = Math.min(...hit.map(x => +x.dataset.s)), e = Math.max(...hit.map(x => +x.dataset.e)); + lines = ta.value.split('\n'); + span(s, e, [0, lines.slice(s, e + 1).join('\n').length, 'forward']); + } + const onUp = () => { + if (!el.isConnected) { document.removeEventListener('mouseup', onUp); return; } + setTimeout(grab, 0); + }; + document.addEventListener('mouseup', onUp); + function merge(at, col) { const len = lines[at].length; lines.splice(at, 2, lines[at] + lines[at + 1]); @@ -192,12 +232,14 @@ function makeLive(ta) { const t = h('textarea', { class: 'live-edit', rows: 1, spellcheck: 'true', 'aria-label': 'Editing', placeholder: empty ? "Write something, or type '/' for commands, '[[' to link a page" : '' }); t.value = lines.slice(b.s, b.e + 1).join('\n'); enhance(t, el); + const multi = b.fence || b.span; // a code block or a selection over lines: Enter just adds a line t.addEventListener('input', () => { const fresh = t.value.split('\n'); lines.splice(b.s, b.e - b.s + 1, ...fresh); b.e = b.s + fresh.length - 1; + if (b.span && act) act.e = b.e; commit(); - if (!b.fence && fresh.length > 1) { // Enter: the line above is finished, the new one is being edited + if (!multi && fresh.length > 1) { // Enter: the line above is finished, the new one is being edited const before = t.value.slice(0, t.selectionStart); place(b.s + before.split('\n').length - 1, before.length - before.lastIndexOf('\n') - 1, true); draw(); @@ -206,34 +248,76 @@ function makeLive(ta) { t.addEventListener('keydown', e => { if (e.isComposing) return; if (e.key === 'Escape' && !e.defaultPrevented) { t.blur(); return; } - if (e.defaultPrevented || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey || t.selectionStart !== t.selectionEnd) return; + if (e.defaultPrevented) return; + const cmd = e.ctrlKey || e.metaKey; + if (extend(e, t, b, cmd)) return; + const word = cmd && !e.shiftKey && !e.altKey && (e.key === 'Backspace' || e.key === 'Delete'); // joins lines like Backspace does + if (e.shiftKey || (cmd && !word) || e.altKey || t.selectionStart !== t.selectionEnd) return; const v = t.value, p = t.selectionStart, here = caretXY(t, p), lh = here.lh; - const firstRow = here.y < lh * 0.5, lastRow = caretXY(t, v.length).y - here.y < lh * 0.5, col = b.fence ? 0 : p; + const firstRow = here.y < lh * 0.5, lastRow = caretXY(t, v.length).y - here.y < lh * 0.5, col = multi ? 0 : p; if (e.key === 'ArrowUp' && firstRow && b.s > 0) { e.preventDefault(); activate(b.s - 1, col, true); } else if (e.key === 'ArrowDown' && lastRow && b.e < lines.length - 1) { e.preventDefault(); activate(b.e + 1, col, true); } else if (e.key === 'ArrowLeft' && p === 0 && b.s > 0) { e.preventDefault(); activate(b.s - 1, Infinity, true); } else if (e.key === 'ArrowRight' && p === v.length && b.e < lines.length - 1) { e.preventDefault(); activate(b.e + 1, 0, true); } else if (e.key === 'Backspace' && p === 0 && b.s > 0) { e.preventDefault(); - if (b.fence || blockAt(b.s - 1).fence) activate(b.s - 1, Infinity, true); else merge(b.s - 1); + if (multi || blockAt(b.s - 1).fence) activate(b.s - 1, Infinity, true); else merge(b.s - 1); } else if (e.key === 'Delete' && p === v.length && b.e < lines.length - 1) { e.preventDefault(); - if (b.fence || blockAt(b.e + 1).fence) activate(b.e + 1, 0, true); else merge(b.s); + if (multi || blockAt(b.e + 1).fence) activate(b.e + 1, 0, true); else merge(b.s); } }); t.addEventListener('blur', () => setTimeout(() => { if (act && !el.contains(document.activeElement)) { act = null; draw(); } }, 0)); return t; } + // extend turns Ctrl+A and Shift+Up or Down past the edge of a line into a selection over several lines + function extend(e, t, b, cmd) { + const v = t.value; + if (cmd && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'a') { + if (b.s === 0 && b.e === lines.length - 1) return false; // already everything: the browser selects it + e.preventDefault(); span(0, lines.length - 1, [0, lines.join('\n').length, 'forward']); + return true; + } + if (!e.shiftKey || cmd || e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return false; + const back = t.selectionDirection === 'backward'; + const a = back ? t.selectionEnd : t.selectionStart, f = back ? t.selectionStart : t.selectionEnd; + const here = caretXY(t, f), col = f - (v.lastIndexOf('\n', f - 1) + 1); + if (e.key === 'ArrowUp' && here.y < here.lh * 0.5 && b.s > 0) { + const nb = blockAt(b.s - 1), add = lines.slice(nb.s, b.s).join('\n').length + 1; + e.preventDefault(); + span(nb.s, b.e, [nb.fence ? 0 : Math.min(col, lines[nb.s].length), a + add, 'backward']); + return true; + } + if (e.key === 'ArrowDown' && caretXY(t, v.length).y - here.y < here.lh * 0.5 && b.e < lines.length - 1) { + const nb = blockAt(b.e + 1), at = v.length + 1; + e.preventDefault(); + span(b.s, nb.e, [a, nb.fence ? at + lines.slice(nb.s, nb.e + 1).join('\n').length : at + Math.min(col, lines[nb.s].length), 'forward']); + return true; + } + return false; + } + function draw() { lines = ta.value.split('\n'); - fill(el, blocks().map(b => act && act.s === b.s ? editor(b) : rendered(b)), h('div', { class: 'live-tail', onclick: () => activate(lines.length - 1, Infinity, true) })); + const kids = []; + let placed = false; + for (const b of blocks()) { + if (act && act.e != null && b.e >= act.s && b.s <= act.e) { // lines edited together + if (!placed) kids.push(editor({ s: act.s, e: Math.min(act.e, lines.length - 1), fence: false, span: true })); + placed = true; + } else if (act && act.e == null && act.s === b.s) kids.push(editor(b)); + else kids.push(rendered(b)); + } + fill(el, kids, h('div', { class: 'live-tail', onclick: () => activate(lines.length - 1, Infinity, true) })); const t = el.querySelector('.live-edit'); if (!t) return; t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px'; if (act.focus) { - const c = Math.min(act.col, t.value.length); - t.focus(); t.setSelectionRange(c, c); t.scrollIntoView({ block: 'nearest' }); + t.focus(); + if (act.sel) t.setSelectionRange(...act.sel); + else { const c = Math.min(act.col, t.value.length); t.setSelectionRange(c, c); } + t.scrollIntoView({ block: 'nearest' }); act.focus = false; } }