Live preview: select, delete and word-delete across lines
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2536f223b7
commit
adde7c54ce
2 files changed
+97
-11
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user