further multi-select UX improvements

Hunter
Thursday, June 11th, 2026 at 4:24 PM

resources/keyboard.js+24-17
resources/multi-select.js+24 
resources/render.js+39-1
resources/reorganize.js+2 

resources/keyboard.js
 42  		return;
 43  	}
 44  
 45 +	// Alt+Left/Right: act like a plain arrow instead of the native word jump;
 46 +	// native is prevented, so the focused caret steps by simulation
 47 +	if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && e.altKey && !cmd && !e.shiftKey) {
 48 +		e.preventDefault();
 49 +		var input = e.currentTarget;
 50 +		var sel = window.getSelection();
 51 +		var end = getCaretOffset(input);
 52 +		if (end != null) {
 53 +			var target;
 54 +			if (sel && !sel.isCollapsed) {
 55 +				// arrows collapse a selection to its start or end, like native
 56 +				target = e.key === 'ArrowLeft' ? end - sel.toString().length : end;
 57 +			} else {
 58 +				target = e.key === 'ArrowLeft'
 59 +					? prevGraphemeBoundary(input.textContent, end)
 60 +					: nextGraphemeBoundary(input.textContent, end);
 61 +			}
 62 +			setCaretOffset(input, target);
 63 +		}
 64 +		if (hasMultiSelect) stepMultiCarets(e.key);
 65 +		return;
 66 +	}
 67 +
 68  	// Shift+Enter: toggle task state (bulk if multi-selected)
 69  	if (e.key === 'Enter' && e.shiftKey) {
 70  		e.preventDefault();

203  	// Plain Left/Right during multi-select: every line's caret steps together
204  	} else if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.shiftKey && !e.altKey && hasMultiSelect) {
205  		// the focused line moves natively; selectionchange syncs its offset
    -		var focusedContainer = document.activeElement ? document.activeElement.closest('.task-container') : null;
    -		var focusedId = focusedContainer ? focusedContainer.dataset.id : null;
    -		for (var t of getMultiSelectedTasks()) {
    -			if (t.id === focusedId) continue;
    -			var range = getMultiRange(t);
    -			if (range) {
    -				// arrows collapse a selection to its start or end, like native
    -				state.multiCaretOffsets[t.id] = e.key === 'ArrowLeft' ? range.start : range.end;
    -				delete state.multiSelectRanges[t.id];
    -			} else {
    -				var off = clampCaret(t.text, state.multiCaretOffsets[t.id]);
    -				state.multiCaretOffsets[t.id] = e.key === 'ArrowLeft'
    -					? prevGraphemeBoundary(t.text, off)
    -					: nextGraphemeBoundary(t.text, off);
    -			}
    -		}
    -		renderSimCarets();
206 +		stepMultiCarets(e.key);
207  	// Cmd+A during multi-select: select all text across all selected lines
208  	} else if ((e.key === 'a' || e.key === 'A') && cmd && !e.shiftKey && !e.altKey && hasMultiSelect) {
209  		e.preventDefault();
resources/multi-select.js
 56  	return end > start ? { start: start, end: end } : null;
 57  }
 58  
 59 +// plain Left/Right during multi-select: step every non-focused line's caret
 60 +// (the focused line is handled natively or by the caller)
 61 +function stepMultiCarets(key) {
 62 +	var focusedContainer = document.activeElement ? document.activeElement.closest('.task-container') : null;
 63 +	var focusedId = focusedContainer ? focusedContainer.dataset.id : null;
 64 +	for (var t of getMultiSelectedTasks()) {
 65 +		if (t.id === focusedId) continue;
 66 +		var range = getMultiRange(t);
 67 +		if (range) {
 68 +			// arrows collapse a selection to its start or end, like native
 69 +			state.multiCaretOffsets[t.id] = key === 'ArrowLeft' ? range.start : range.end;
 70 +			delete state.multiSelectRanges[t.id];
 71 +		} else {
 72 +			var off = clampCaret(t.text, state.multiCaretOffsets[t.id]);
 73 +			state.multiCaretOffsets[t.id] = key === 'ArrowLeft'
 74 +				? prevGraphemeBoundary(t.text, off)
 75 +				: nextGraphemeBoundary(t.text, off);
 76 +		}
 77 +	}
 78 +	renderSimCarets();
 79 +}
 80 +
 81  function insertAtMultiCaret(t, str) {
 82  	var range = getMultiRange(t);
 83  	var start = range ? range.start : clampCaret(t.text, state.multiCaretOffsets[t.id]);

336  	subtasks.splice(insertAt, 0, ...chunk);
337  
338  	state.currentTask.selectedSubtaskId = refocusId;
339 +	var savedSelection = captureActiveSelection();
340  	renderCurrentView();
341  	applyMultiSelectHighlights();
342 +	restoreActiveSelection(savedSelection);
343  	scheduleSave();
344  	return true;
345  }
resources/render.js
 58  
 59  function rescrollActiveCaret() {
 60  	var el = document.querySelector('.task-container.active .task-text');
    -	if (el) setCaretOffset(el, getCaretOffset(el) || 0);
 61 +	var focused = document.activeElement;
 62 +	// in multi-select every row is .active; prefer the truly focused line
 63 +	if (focused && focused.classList && focused.classList.contains('task-text')) el = focused;
 64 +	if (!el) return;
 65 +	var sel = window.getSelection();
 66 +	// keep a live range selection; resetting the caret would collapse it
 67 +	if (sel && sel.rangeCount > 0 && !sel.isCollapsed && el.contains(sel.anchorNode)) {
 68 +		scrollCaretIntoView(el, sel.getRangeAt(0), getCaretOffset(el) || 0);
 69 +		renderSimCarets();
 70 +		return;
 71 +	}
 72 +	setCaretOffset(el, getCaretOffset(el) || 0);
 73 +}
 74 +
 75 +// capture/restore the focused line's text selection across a re-render,
 76 +// which destroys the DOM nodes the live selection points at
 77 +function captureActiveSelection() {
 78 +	var el = document.activeElement;
 79 +	if (!el || !el.classList || !el.classList.contains('task-text')) return null;
 80 +	var sel = window.getSelection();
 81 +	if (!sel || sel.rangeCount === 0 || sel.isCollapsed || !el.contains(sel.anchorNode)) return null;
 82 +	var container = el.closest('.task-container');
 83 +	if (!container) return null;
 84 +	var end = getCaretOffset(el);
 85 +	if (end == null) return null;
 86 +	return { id: container.dataset.id, start: end - sel.toString().length, end: end };
 87 +}
 88 +
 89 +function restoreActiveSelection(saved) {
 90 +	if (!saved) return;
 91 +	var input = document.querySelector(`.task-container[data-id="${saved.id}"] .task-text`);
 92 +	var node = input ? input.firstChild : null;
 93 +	if (!node || node.nodeType !== Node.TEXT_NODE) return;
 94 +	var range = document.createRange();
 95 +	range.setStart(node, Math.max(0, Math.min(saved.start, node.textContent.length)));
 96 +	range.setEnd(node, Math.max(0, Math.min(saved.end, node.textContent.length)));
 97 +	var sel = window.getSelection();
 98 +	sel.removeAllRanges();
 99 +	sel.addRange(range);
100  }
101  
102  function isSelectionCollapsed() {
resources/reorganize.js
17  		return false;
18  	}
19  
20 +	var savedSelection = captureActiveSelection();
21  	renderCurrentView();
22  	selectAndFocusTask(subtask);
23 +	restoreActiveSelection(savedSelection);
24  	scheduleSave();
25  	return true;
26  }