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

+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.
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;
}
}