Live preview: select, delete and word-delete across lines

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-11-01 01:07:41 -07:00
1 parent 2536f223b7
commit adde7c54ce
2 files changed
+97 -11

No files matched your search

+3 -1
View File
@@ -192,7 +192,9 @@ file time as its date, and is then encrypted (name it with lower-case letters, n
built for millions. built for millions.
- Pages are only reloaded from disk when Mako starts. - 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 - 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 ## Design
+94 -10
View File
@@ -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. // conflicts and the other modes need to know nothing about it. A fenced code block is edited as a whole.
function makeLive(ta) { function makeLive(ta) {
const el = h('div', { class: 'live', hidden: true }); 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() { function blocks() {
const out = []; const out = [];
@@ -177,10 +179,48 @@ function makeLive(ta) {
if (m) root.querySelector('ol')?.setAttribute('start', m[1]); if (m) root.querySelector('ol')?.setAttribute('start', m[1]);
box.append(root); 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; 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) { function merge(at, col) {
const len = lines[at].length; const len = lines[at].length;
lines.splice(at, 2, lines[at] + lines[at + 1]); 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" : '' }); 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'); t.value = lines.slice(b.s, b.e + 1).join('\n');
enhance(t, el); 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', () => { t.addEventListener('input', () => {
const fresh = t.value.split('\n'); const fresh = t.value.split('\n');
lines.splice(b.s, b.e - b.s + 1, ...fresh); lines.splice(b.s, b.e - b.s + 1, ...fresh);
b.e = b.s + fresh.length - 1; b.e = b.s + fresh.length - 1;
if (b.span && act) act.e = b.e;
commit(); 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); const before = t.value.slice(0, t.selectionStart);
place(b.s + before.split('\n').length - 1, before.length - before.lastIndexOf('\n') - 1, true); place(b.s + before.split('\n').length - 1, before.length - before.lastIndexOf('\n') - 1, true);
draw(); draw();
@@ -206,34 +248,76 @@ function makeLive(ta) {
t.addEventListener('keydown', e => { t.addEventListener('keydown', e => {
if (e.isComposing) return; if (e.isComposing) return;
if (e.key === 'Escape' && !e.defaultPrevented) { t.blur(); 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 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); } 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 === '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 === '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 === '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) { else if (e.key === 'Backspace' && p === 0 && b.s > 0) {
e.preventDefault(); 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) { } else if (e.key === 'Delete' && p === v.length && b.e < lines.length - 1) {
e.preventDefault(); 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)); t.addEventListener('blur', () => setTimeout(() => { if (act && !el.contains(document.activeElement)) { act = null; draw(); } }, 0));
return t; 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() { function draw() {
lines = ta.value.split('\n'); 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'); const t = el.querySelector('.live-edit');
if (!t) return; if (!t) return;
t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px'; t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px';
if (act.focus) { if (act.focus) {
const c = Math.min(act.col, t.value.length); t.focus();
t.focus(); t.setSelectionRange(c, c); t.scrollIntoView({ block: 'nearest' }); 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; act.focus = false;
} }
} }