multiselect improvements

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

resources/keyboard.js+40-5
resources/main.css+41-6
resources/main.js+50-2
resources/multi-select.js+215 
resources/render.js+107-1
resources/state.js+5 
resources/task-element.js+76-24

resources/keyboard.js
113  	} else if (e.key === 'ArrowRight' && cmd && e.shiftKey && !e.altKey) {
114  		e.preventDefault();
115  		hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id);
    -	// Plain Up/Down: navigate (clears multi-select)
116 +	// Plain Up/Down: navigate (multi-select exits onto the task beyond the chunk)
117  	} else if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) {
118  		e.preventDefault();
    -		if (hasMultiSelect) clearMultiSelect();
    -		navigateTasks('up');
119 +		if (hasMultiSelect) {
120 +			exitMultiSelect('up');
121 +		} else {
122 +			navigateTasks('up');
123 +		}
124  	} else if (e.key === 'ArrowDown' && !e.shiftKey && !e.altKey) {
125  		e.preventDefault();
    -		if (hasMultiSelect) clearMultiSelect();
    -		navigateTasks('down');
126 +		if (hasMultiSelect) {
127 +			exitMultiSelect('down');
128 +		} else {
129 +			navigateTasks('down');
130 +		}
131  	// Shift+Up/Down: move task
132  	} else if (e.key === 'ArrowUp' && e.shiftKey && !e.altKey) {
133  		e.preventDefault();

177  			keyHandler.shiftLeft.pressed = true;
178  			navigateToParentTask();
179  		}
180 +	// Plain Left/Right during multi-select: every line's caret steps together
181 +	} else if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.shiftKey && !e.altKey && hasMultiSelect) {
182 +		// the focused line moves natively; selectionchange syncs its offset
183 +		var focusedContainer = document.activeElement ? document.activeElement.closest('.task-container') : null;
184 +		var focusedId = focusedContainer ? focusedContainer.dataset.id : null;
185 +		for (var t of getMultiSelectedTasks()) {
186 +			if (t.id === focusedId) continue;
187 +			var range = getMultiRange(t);
188 +			if (range) {
189 +				// arrows collapse a selection to its start or end, like native
190 +				state.multiCaretOffsets[t.id] = e.key === 'ArrowLeft' ? range.start : range.end;
191 +				delete state.multiSelectRanges[t.id];
192 +			} else {
193 +				var off = clampCaret(t.text, state.multiCaretOffsets[t.id]);
194 +				state.multiCaretOffsets[t.id] = e.key === 'ArrowLeft'
195 +					? prevGraphemeBoundary(t.text, off)
196 +					: nextGraphemeBoundary(t.text, off);
197 +			}
198 +		}
199 +		renderSimCarets();
200 +	// Cmd+A during multi-select: select all text across all selected lines
201 +	} else if ((e.key === 'a' || e.key === 'A') && cmd && !e.shiftKey && !e.altKey && hasMultiSelect) {
202 +		e.preventDefault();
203 +		selectAllMultiSelected(task);
204 +	// Cmd+Z / Cmd+Shift+Z during multi-select: app-level undo/redo (native
205 +	// history would only touch the focused line, so it's blocked)
206 +	} else if ((e.key === 'z' || e.key === 'Z') && cmd && !e.altKey && hasMultiSelect) {
207 +		e.preventDefault();
208 +		e.shiftKey ? redoMultiEdit(task) : undoMultiEdit(task);
209  	}
210  }
resources/main.css
123  	.custom-checkbox:checked + .checkbox-label::before {
124  		display: none;
125  	}
    -	.task-text {
126 +	.task-text,
127 +	.pseudo-selection {
128  		text-shadow: 1px 1px 1px var(--cola);
129  	}
    -	.task-text::selection {
130 +	.task-text::selection,
131 +	.pseudo-selection {
132  		color: var(--text);
133  	}
134  }

142  	.task-text {
143  		font-family: BasteleurMoonlight;
144  	}
    -	.task-text::selection {
145 +	.task-text::selection,
146 +	.pseudo-selection {
147  		background-color: var(--background);
148  		color: var(--text);
149 +	}
150 +	.task-text::selection {
151  		text-shadow: none;
152  	}
153  	.active {

212  		color: var(--mothball);
213  		text-shadow: 1px 1px 1px var(--bakelite);
214  	}
215 +	.active .pseudo-selection {
216 +		text-shadow: 1px 1px 1px var(--bakelite);
217 +	}
218  	.parent-task {
219  		--text: var(--mothball)
220  	}

246  	.active .custom-checkbox:indeterminate + .checkbox-label::before {
247  		background-color: var(--mothball);
248  	}
    -	.task-text::selection {
249 +	.task-text::selection,
250 +	.pseudo-selection {
251  		background-color: var(--lipstick);
252  		color: var(--mothball);
253  	}

306  	color: var(--text);
307  	overflow: hidden;
308  	text-overflow: ellipsis;
    -	white-space: nowrap;
309 +	white-space: pre; /* preserve spaces; stops contenteditable nbsp substitution */
310  	cursor: text;
311  	line-height: normal;
312  	min-height: 1.17em;

317  .active .task-text {
318  	text-overflow: clip;
319  }
    -.task-text::selection {
320 +.task-text::selection,
321 +.pseudo-selection {
322  	background-color: var(--accent);
323  	color: var(--background);
324  }

330  	padding-right: 11px;
331  	padding-top: 4px;
332  	padding-bottom: 4px;
333 +	position: relative;
334 +}
335 +/* the native caret is hidden app-wide; simulated carets render identically
336 +   across OSes and between single- and multi-select editing */
337 +.task-text {
338 +	caret-color: transparent;
339 +}
340 +.sim-caret {
341 +	position: absolute;
342 +	width: 1px;
343 +	pointer-events: none;
344 +	animation: caret-blink 1s step-end infinite;
345 +}
346 +/* non-focused lines' selections: a clipped text duplicate in ::selection
347 +   colors, built and positioned by updateMultiRangeStyles */
348 +.pseudo-selection {
349 +	position: absolute;
350 +	overflow: hidden;
351 +	pointer-events: none;
352 +	white-space: pre;
353 +	line-height: normal;
354 +}
355 +@keyframes caret-blink {
356 +	0%, 49% { opacity: 1; }
357 +	50%, 100% { opacity: 0; }
358  }
359  .active {
360  	background-color: var(--highlight);
resources/main.js
 69  	state.appContainer.addEventListener('focusin', function(e) {
 70  		if (e.target.classList && e.target.classList.contains('task-text')) {
 71  			document.querySelectorAll('.task-text').forEach(input => {
    -				if (input !== e.target) {
    -					placeCursorAtBeginning(input);
 72 +				if (input === e.target) return;
 73 +				// multi-selected lines keep their own caret scrolled into view
 74 +				if (state.multiSelectedIds.length > 1) {
 75 +					var container = input.closest('.task-container');
 76 +					if (container && state.multiSelectedIds.includes(container.dataset.id)) return;
 77  				}
 78 +				placeCursorAtBeginning(input);
 79  			});
 80  		}
 81  	});
 82  
 83 +	// The native caret is hidden app-wide; mirror the focused line's caret
 84 +	// position and redraw the simulated carets on every selection change
 85 +	document.addEventListener('selectionchange', function() {
 86 +		var el = document.activeElement;
 87 +		if (!el || !el.classList || !el.classList.contains('task-text')) return;
 88 +		if (state.multiSelectedIds.length > 1) {
 89 +			var container = el.closest('.task-container');
 90 +			var id = container ? container.dataset.id : null;
 91 +			var sel = window.getSelection();
 92 +			if (id && state.multiSelectedIds.includes(id) && sel && sel.rangeCount > 0) {
 93 +				var offset = getCaretOffset(el);
 94 +				if (offset != null) {
 95 +					// live-track the focused line's selection so it persists as a
 96 +					// painted range when focus moves to another line; a collapse
 97 +					// (click, arrow) clears it, like native
 98 +					var len = sel.isCollapsed ? 0 : sel.toString().length;
 99 +					if (len > 0) {
100 +						state.multiSelectRanges[id] = { start: offset - len, end: offset };
101 +					} else {
102 +						delete state.multiSelectRanges[id];
103 +					}
104 +					state.multiCaretOffsets[id] = offset;
105 +				}
106 +			}
107 +		}
108 +		renderSimCarets();
109 +	});
110 +
111 +	// hide simulated carets while the window is inactive, like native ones
112 +	state.windowFocused = document.hasFocus();
113 +	window.addEventListener('focus', function() {
114 +		state.windowFocused = true;
115 +		renderSimCarets();
116 +	});
117 +	window.addEventListener('blur', function() {
118 +		state.windowFocused = false;
119 +		renderSimCarets();
120 +	});
121 +
122 +	// drop the caret if focus leaves the task texts entirely
123 +	state.appContainer.addEventListener('focusout', function() {
124 +		requestAnimationFrame(renderSimCarets);
125 +	});
126 +
127  	// Initialize
128  	setInitialTheme();
129  	renderCurrentView();
resources/multi-select.js
  3  function clearMultiSelect() {
  4  	state.multiSelectAnchorId = null;
  5  	state.multiSelectedIds = [];
  6 +	state.multiCaretOffsets = {};
  7 +	state.multiSelectRanges = {};
  8 +	state.multiUndoStack = [];
  9 +	state.multiRedoStack = [];
 10 +	updateMultiRangeStyles();
 11 +	document.querySelectorAll('.sim-caret').forEach(el => el.remove());
 12  	var focused = document.activeElement;
 13  	var focusedContainer = focused ? focused.closest('.task-container') : null;
 14  	document.querySelectorAll('.task-container.active').forEach(el => {

 33  		var container = document.querySelector(`.task-container[data-id="${id}"]`);
 34  		if (container) container.classList.add('active');
 35  	}
 36 +	renderSimCarets();
 37  }
 38  
 39 +function clampCaret(text, offset) {
 40 +	if (typeof offset !== 'number') return text.length;
 41 +	return Math.max(0, Math.min(offset, text.length));
 42 +}
 43 +
 44 +function syncMultiTaskText(t) {
 45 +	var inp = document.querySelector(`.task-container[data-id="${t.id}"] .task-text`);
 46 +	if (inp) inp.textContent = t.text;
 47 +}
 48 +
 49 +// a line's pending edit target: its stored selection range if it has one
 50 +// (clamped to the current text), else null
 51 +function getMultiRange(t) {
 52 +	var range = state.multiSelectRanges[t.id];
 53 +	if (!range) return null;
 54 +	var start = Math.min(range.start, t.text.length);
 55 +	var end = Math.min(range.end, t.text.length);
 56 +	return end > start ? { start: start, end: end } : null;
 57 +}
 58 +
 59 +function insertAtMultiCaret(t, str) {
 60 +	var range = getMultiRange(t);
 61 +	var start = range ? range.start : clampCaret(t.text, state.multiCaretOffsets[t.id]);
 62 +	var end = range ? range.end : start;
 63 +	t.text = t.text.slice(0, start) + str + t.text.slice(end);
 64 +	state.multiCaretOffsets[t.id] = start + str.length;
 65 +	delete state.multiSelectRanges[t.id];
 66 +	syncMultiTaskText(t);
 67 +}
 68 +
 69 +function deleteAtMultiCaret(t, direction) {
 70 +	var range = getMultiRange(t);
 71 +	var start, end;
 72 +	if (range) {
 73 +		start = range.start;
 74 +		end = range.end;
 75 +	} else {
 76 +		var off = clampCaret(t.text, state.multiCaretOffsets[t.id]);
 77 +		if (direction === 'forward') {
 78 +			start = off;
 79 +			end = nextGraphemeBoundary(t.text, off);
 80 +		} else {
 81 +			start = prevGraphemeBoundary(t.text, off);
 82 +			end = off;
 83 +		}
 84 +	}
 85 +	if (start < end) {
 86 +		t.text = t.text.slice(0, start) + t.text.slice(end);
 87 +	}
 88 +	state.multiCaretOffsets[t.id] = start;
 89 +	delete state.multiSelectRanges[t.id];
 90 +	syncMultiTaskText(t);
 91 +}
 92 +
 93 +// Up/Down: leave multi-select onto the task just above/below the chunk
 94 +function exitMultiSelect(direction) {
 95 +	var selected = getMultiSelectedTasks();
 96 +	clearMultiSelect();
 97 +	if (selected.length === 0) return;
 98 +	var subtasks = state.currentTask.subtasks;
 99 +	var indices = selected.map(t => subtasks.findIndex(s => s.id === t.id)).sort((a, b) => a - b);
100 +	if (direction === 'up') {
101 +		// above the first subtask sits the parent task
102 +		selectAndFocusTask(subtasks[indices[0] - 1] || state.currentTask);
103 +	} else {
104 +		selectAndFocusTask(subtasks[Math.min(indices[indices.length - 1] + 1, subtasks.length - 1)]);
105 +	}
106 +}
107 +
108 +// Cmd+Z / Cmd+Shift+Z: per-session undo/redo of multi-line edits; snapshots
109 +// are pushed before each mutation (focusedOldText covers input events, where
110 +// the focused line has already changed)
111 +function captureMultiSnapshot(focusedId, focusedOldText) {
112 +	var texts = {};
113 +	for (var t of getMultiSelectedTasks()) {
114 +		texts[t.id] = t.id === focusedId ? focusedOldText : t.text;
115 +	}
116 +	return {
117 +		texts: texts,
118 +		offsets: Object.assign({}, state.multiCaretOffsets),
119 +		ranges: Object.assign({}, state.multiSelectRanges)
120 +	};
121 +}
122 +
123 +function pushMultiUndo(focusedId, focusedOldText) {
124 +	state.multiUndoStack.push(captureMultiSnapshot(focusedId, focusedOldText));
125 +	state.multiRedoStack = [];
126 +}
127 +
128 +function undoMultiEdit(focusedTask) {
129 +	var entry = state.multiUndoStack.pop();
130 +	if (!entry) return;
131 +	state.multiRedoStack.push(captureMultiSnapshot());
132 +	applyMultiSnapshot(entry, focusedTask);
133 +}
134 +
135 +function redoMultiEdit(focusedTask) {
136 +	var entry = state.multiRedoStack.pop();
137 +	if (!entry) return;
138 +	state.multiUndoStack.push(captureMultiSnapshot());
139 +	applyMultiSnapshot(entry, focusedTask);
140 +}
141 +
142 +function applyMultiSnapshot(entry, focusedTask) {
143 +	for (var t of getMultiSelectedTasks()) {
144 +		if (typeof entry.texts[t.id] === 'string') {
145 +			t.text = entry.texts[t.id];
146 +			syncMultiTaskText(t);
147 +		}
148 +	}
149 +	state.multiCaretOffsets = entry.offsets;
150 +	state.multiSelectRanges = entry.ranges || {};
151 +	var input = document.querySelector(`.task-container[data-id="${focusedTask.id}"] .task-text`);
152 +	if (input) setCaretOffset(input, clampCaret(focusedTask.text, state.multiCaretOffsets[focusedTask.id]));
153 +	renderSimCarets();
154 +	scheduleSave();
155 +}
156 +
157 +// Cmd+A: native select-all on the focused line plus stored full-line ranges
158 +// on the others; the next edit then replaces each line's selection
159 +function selectAllMultiSelected(focusedTask) {
160 +	for (var t of getMultiSelectedTasks()) {
161 +		state.multiSelectRanges[t.id] = { start: 0, end: t.text.length };
162 +		state.multiCaretOffsets[t.id] = t.text.length;
163 +	}
164 +	var input = document.querySelector(`.task-container[data-id="${focusedTask.id}"] .task-text`);
165 +	if (input) selectAllText(input);
166 +	renderSimCarets();
167 +}
168 +
169 +// only one real DOM selection can exist, so non-focused lines paint their
170 +// stored ranges with an overlay: a clipped duplicate of the line's text in
171 +// the theme's ::selection colors, glyph-aligned with the original
172 +function updateMultiRangeStyles() {
173 +	document.querySelectorAll('.pseudo-selection').forEach(el => el.remove());
174 +	if (state.multiSelectedIds.length <= 1) return;
175 +	var focusedContainer = document.activeElement ? document.activeElement.closest('.task-container') : null;
176 +	var focusedId = focusedContainer ? focusedContainer.dataset.id : null;
177 +	for (var t of getMultiSelectedTasks()) {
178 +		if (t.id === focusedId) continue;
179 +		var range = getMultiRange(t);
180 +		if (!range) continue;
181 +		var container = document.querySelector(`.task-container[data-id="${t.id}"]`);
182 +		var input = container ? container.querySelector('.task-text') : null;
183 +		var node = input ? input.firstChild : null;
184 +		if (!node || node.nodeType !== Node.TEXT_NODE) continue;
185 +		var r = document.createRange();
186 +		r.setStart(node, Math.min(range.start, node.textContent.length));
187 +		r.setEnd(node, Math.min(range.end, node.textContent.length));
188 +		var rect = r.getBoundingClientRect();
189 +		var inputRect = input.getBoundingClientRect();
190 +		var containerRect = container.getBoundingClientRect();
191 +		var scale = container.offsetWidth ? containerRect.width / container.offsetWidth : 1;
192 +		// clamp to the line's visible text box
193 +		var left = Math.max(rect.left, inputRect.left);
194 +		var right = Math.min(rect.right, inputRect.right);
195 +		if (right <= left || rect.height <= 0) continue;
196 +
197 +		var overlay = document.createElement('div');
198 +		overlay.className = 'pseudo-selection';
199 +		var inner = document.createElement('span');
200 +		inner.textContent = t.text;
201 +		overlay.appendChild(inner);
202 +
203 +		// match the line's text rendering so the duplicate glyphs coincide;
204 +		// styled on the overlay (not the span) so its line-box strut shares
205 +		// the font metrics, else baseline alignment shifts the span down.
206 +		// text-shadow stays in CSS: themes restyle it under ::selection, and
207 +		// an inline copy of the row's shadow couldn't be overridden there
208 +		var cs = getComputedStyle(input);
209 +		overlay.style.fontFamily = cs.fontFamily;
210 +		overlay.style.fontSize = cs.fontSize;
211 +		overlay.style.fontWeight = cs.fontWeight;
212 +		overlay.style.letterSpacing = cs.letterSpacing;
213 +
214 +		overlay.style.left = ((left - containerRect.left) / scale) + 'px';
215 +		overlay.style.top = ((rect.top - containerRect.top) / scale) + 'px';
216 +		overlay.style.width = ((right - left) / scale) + 'px';
217 +		overlay.style.height = (rect.height / scale) + 'px';
218 +		// shift the duplicate so the selected substring lands in the window
219 +		inner.style.marginLeft = (-((left - inputRect.left) / scale + input.scrollLeft)) + 'px';
220 +		container.appendChild(overlay);
221 +
222 +		// some fonts' selection rects sit off their line box by ~1px, which
223 +		// would drift the duplicate; measure against the original and cancel
224 +		var probe = document.createRange();
225 +		probe.setStart(inner.firstChild, Math.min(range.start, t.text.length));
226 +		probe.setEnd(inner.firstChild, Math.min(range.end, t.text.length));
227 +		var drift = probe.getBoundingClientRect().top - rect.top;
228 +		if (drift) {
229 +			inner.style.position = 'relative';
230 +			inner.style.top = (-drift / scale) + 'px';
231 +		}
232 +	}
233 +}
234 +
235 +
236  function shakeAllSelected(direction = 'horizontal') {
237  	for (var id of state.multiSelectedIds) {
238  		applyShakeAnimation(id, direction);

248  	if (state.multiSelectAnchorId === null) {
249  		state.multiSelectAnchorId = task.id;
250  		state.multiSelectedIds = [task.id];
251 +		var anchorInput = document.querySelector(`.task-container[data-id="${task.id}"] .task-text`);
252 +		var anchorOffset = anchorInput ? getCaretOffset(anchorInput) : null;
253 +		state.multiCaretOffsets[task.id] = anchorOffset != null ? anchorOffset : task.text.length;
254  	}
255  
256  	var anchorIndex = tasks.findIndex(t => t.id === state.multiSelectAnchorId);

263  		});
264  		if (currentIndex <= anchorIndex && state.multiSelectedIds.length > 1) {
265  			state.multiSelectedIds = state.multiSelectedIds.filter(id => id !== task.id);
266 +			delete state.multiCaretOffsets[task.id];
267 +			delete state.multiSelectRanges[task.id];
268  			var nextTask = tasks[currentIndex + 1];
269  			if (nextTask) selectAndFocusTask(nextTask);
270  		} else if (bottomIndex < tasks.length - 1) {
271  			var nextTask = tasks[bottomIndex + 1];
272  			state.multiSelectedIds.push(nextTask.id);
273 +			state.multiCaretOffsets[nextTask.id] = nextTask.text.length;
274 +			delete state.multiSelectRanges[nextTask.id];
275  			selectAndFocusTask(nextTask);
276  		}
277  	} else {
278  		var topIndex = tasks.findIndex(t => state.multiSelectedIds.includes(t.id));
279  		if (currentIndex >= anchorIndex && state.multiSelectedIds.length > 1) {
280  			state.multiSelectedIds = state.multiSelectedIds.filter(id => id !== task.id);
281 +			delete state.multiCaretOffsets[task.id];
282 +			delete state.multiSelectRanges[task.id];
283  			var prevTask = tasks[currentIndex - 1];
284  			if (prevTask) selectAndFocusTask(prevTask);
285  		} else if (topIndex > 0) {
286  			var prevTask = tasks[topIndex - 1];
287  			state.multiSelectedIds.unshift(prevTask.id);
288 +			state.multiCaretOffsets[prevTask.id] = prevTask.text.length;
289 +			delete state.multiSelectRanges[prevTask.id];
290  			selectAndFocusTask(prevTask);
291  		}
292  	}
resources/render.js
124  	}
125  }
126  
127 +// Simulated carets: the native caret is hidden app-wide (caret-color) so the
128 +// caret looks identical across OSes and between single- and multi-select.
129 +// Single mode mirrors the hidden native caret; multi mode uses the tracked
130 +// per-line offsets. Carets hide on window blur and behind range selections.
131 +function renderSimCarets() {
132 +	var multi = state.multiSelectedIds.length > 1;
133 +	var focusedEl = document.activeElement;
134 +	if (!focusedEl || !focusedEl.classList || !focusedEl.classList.contains('task-text')) focusedEl = null;
135 +	var sel = window.getSelection();
136 +	var focusedHasRange = !!(focusedEl && sel && sel.rangeCount > 0 && !sel.isCollapsed && focusedEl.contains(sel.anchorNode));
137 +
138 +	document.querySelectorAll('.sim-caret').forEach(el => {
139 +		var container = el.closest('.task-container');
140 +		var id = container ? container.dataset.id : null;
141 +		var keep = multi ? state.multiSelectedIds.includes(id) : !!(focusedEl && container && container.contains(focusedEl));
142 +		if (!keep) el.remove();
143 +	});
144 +
145 +	if (multi) {
146 +		for (var t of getMultiSelectedTasks()) {
147 +			var container = document.querySelector(`.task-container[data-id="${t.id}"]`);
148 +			if (!container) continue;
149 +			var offset = clampCaret(t.text, state.multiCaretOffsets[t.id]);
150 +			state.multiCaretOffsets[t.id] = offset;
151 +			var isFocusedLine = !!(focusedEl && container.contains(focusedEl));
152 +			// a line showing a selection shows no caret, like native
153 +			var lineHasRange = isFocusedLine ? focusedHasRange : !!getMultiRange(t);
154 +			positionSimCaret(container, offset, !state.windowFocused || lineHasRange);
155 +		}
156 +		updateMultiRangeStyles();
157 +	} else if (focusedEl) {
158 +		var container = focusedEl.closest('.task-container');
159 +		var offset = getCaretOffset(focusedEl);
160 +		if (container) {
161 +			if (offset == null) {
162 +				var stray = container.querySelector('.sim-caret');
163 +				if (stray) stray.remove();
164 +			} else {
165 +				positionSimCaret(container, offset, !state.windowFocused || focusedHasRange);
166 +			}
167 +		}
168 +	}
169 +}
170 +
171 +function positionSimCaret(container, offset, hidden) {
172 +	var textEl = container.querySelector('.task-text');
173 +	var caretEl = container.querySelector('.sim-caret');
174 +	if (!caretEl) {
175 +		caretEl = document.createElement('div');
176 +		caretEl.className = 'sim-caret';
177 +		container.appendChild(caretEl);
178 +	}
179 +	var containerRect = container.getBoundingClientRect();
180 +	var textRect = textEl.getBoundingClientRect();
181 +	// client rects include the body zoom but style px get re-zoomed, so unscale
182 +	var scale = container.offsetWidth ? containerRect.width / container.offsetWidth : 1;
183 +	var x = textRect.left;
184 +	var top = textRect.top;
185 +	var height = textRect.height;
186 +	var node = textEl.firstChild;
187 +	if (node && node.nodeType === Node.TEXT_NODE) {
188 +		var range = document.createRange();
189 +		range.setStart(node, Math.min(offset, node.textContent.length));
190 +		range.collapse(true);
191 +		// keep each line scrolled so its own insertion point stays visible
192 +		scrollCaretIntoView(textEl, range, offset);
193 +		var rect = range.getBoundingClientRect();
194 +		if (rect.height > 0) {
195 +			x = rect.left;
196 +			top = rect.top;
197 +			height = rect.height;
198 +		}
199 +	} else {
200 +		// empty line: the element box (padding + min-height) is taller than a
201 +		// caret; approximate the line box from font metrics instead
202 +		var styles = getComputedStyle(textEl);
203 +		height = parseFloat(styles.fontSize) * 1.15 * scale;
204 +		top = textRect.top + (parseFloat(styles.paddingTop) || 0) * scale;
205 +	}
206 +	// keep the caret inside the line's visible text box
207 +	x = Math.max(textRect.left, Math.min(x, textRect.right - 1));
208 +	// snap to whole zoomed pixels and size to exactly one, so the caret
209 +	// never straddles a pixel boundary and fattens to 2px
210 +	caretEl.style.left = (Math.round(x - containerRect.left) / scale) + 'px';
211 +	caretEl.style.top = ((top - containerRect.top) / scale) + 'px';
212 +	caretEl.style.height = (height / scale) + 'px';
213 +	caretEl.style.width = (1 / scale) + 'px';
214 +	if (hidden) {
215 +		caretEl.style.visibility = 'hidden';
216 +		return;
217 +	}
218 +	caretEl.style.visibility = '';
219 +	caretEl.style.backgroundColor = getComputedStyle(textEl).color;
220 +	// restart the blink so all carets stay in phase
221 +	caretEl.style.animation = 'none';
222 +	void caretEl.offsetWidth;
223 +	caretEl.style.animation = '';
224 +}
225 +
226  function selectAndFocusTask(task, cursorPos) {
227  	var taskInput = document.querySelector(`.task-container[data-id="${task.id}"] .task-text`);
228  	if (taskInput) {

293  
294  function handleCopyAndCut(e) {
295  	if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'x')) {
296 +		// copy/cut are disabled during multi-select (no single "true" line)
297 +		if (state.multiSelectedIds.length > 1) {
298 +			e.preventDefault();
299 +			return;
300 +		}
301  		var activeTaskInput = document.querySelector('.task-container.active .task-text');
302  		if (activeTaskInput) {
303  			e.preventDefault();

367  	if (state.currentTask.selectedSubtaskId) {
368  		var selectedTask = state.currentTask.subtasks.find(t => t.id === state.currentTask.selectedSubtaskId);
369  		if (selectedTask) {
    -			selectAndFocusTask(selectedTask);
370 +			// during multi-select, keep the focused line's caret where it was
371 +			var pos = state.multiCaretOffsets[selectedTask.id];
372 +			selectAndFocusTask(selectedTask, typeof pos === 'number' ? pos : undefined);
373  		} else {
374  			selectFirstSubtask();
375  		}
resources/state.js
12  	isF2Pressed: false,
13  	themes: ['gak', 'swamp', 'sunflower', 'harvest', 'sugar', 'vineyard', 'woodstove', 'medieval', 'goblin'],
14  	isInWheelEvent: false,
15 +	windowFocused: true,
16  
17  	// Multi-select state
18  	multiSelectAnchorId: null,
19  	multiSelectedIds: [],
20 +	multiCaretOffsets: {},
21 +	multiSelectRanges: {},
22 +	multiUndoStack: [],
23 +	multiRedoStack: [],
24  };
25  
26  function generateId() {
resources/task-element.js
  1  // Task element: DOM creation and per-element event handlers
  2  
  3 +// grapheme-aware caret stepping so surrogate pairs / ZWJ emoji never split
  4 +function prevGraphemeBoundary(text, offset) {
  5 +	if (offset <= 0) return 0;
  6 +	if (typeof Intl !== 'undefined' && Intl.Segmenter) {
  7 +		var prev = 0;
  8 +		for (var seg of new Intl.Segmenter().segment(text)) {
  9 +			if (seg.index >= offset) break;
 10 +			prev = seg.index;
 11 +		}
 12 +		return prev;
 13 +	}
 14 +	// fallback: step one code point
 15 +	var cut = offset - 1;
 16 +	if (cut > 0 && text.charCodeAt(cut) >= 0xDC00 && text.charCodeAt(cut) <= 0xDFFF) cut--;
 17 +	return cut;
 18 +}
 19 +
 20 +function nextGraphemeBoundary(text, offset) {
 21 +	if (offset >= text.length) return text.length;
 22 +	if (typeof Intl !== 'undefined' && Intl.Segmenter) {
 23 +		for (var seg of new Intl.Segmenter().segment(text)) {
 24 +			var end = seg.index + seg.segment.length;
 25 +			if (end > offset) return end;
 26 +		}
 27 +		return text.length;
 28 +	}
 29 +	var next = offset + 1;
 30 +	if (next < text.length && text.charCodeAt(offset) >= 0xD800 && text.charCodeAt(offset) <= 0xDBFF) next++;
 31 +	return next;
 32 +}
 33 +
 34  function createTaskElement(task, isParentTask = false) {
 35  	var taskContainer = document.createElement('div');
 36  	taskContainer.className = 'task-container';

102  					}
103  					return;
104  				}
    -				// Some tasks still have text: remove last char from all that have text
105 +				// Some tasks still have text: delete each line's selection,
106 +				// or the grapheme before its caret
107  				e.preventDefault();
108 +				pushMultiUndo();
109 +				var focusedOffset = getCaretOffset(taskInput);
110 +				if (focusedOffset != null) state.multiCaretOffsets[task.id] = focusedOffset;
111  				for (var t of selected) {
    -					if (t.text.length > 0) {
    -						t.text = t.text.slice(0, -1);
    -						var inp = document.querySelector(`.task-container[data-id="${t.id}"] .task-text`);
    -						if (inp) inp.textContent = t.text;
    -					}
112 +					deleteAtMultiCaret(t, 'backward');
113  				}
114 +				// rewriting textContent collapses the (hidden) native caret; restore it
115 +				setCaretOffset(taskInput, clampCaret(task.text, state.multiCaretOffsets[task.id]));
116 +				renderSimCarets();
117  				keyHandler.backspace.canDelete = false;
118  				scheduleSave();
119  				return;

150  				e.preventDefault();
151  				return;
152  			}
    -			if (isLastSubtask(task) && state.lastSubtaskDownArrowReleased && task !== state.currentTask) {
153 +			// during multi-select, "at the bottom" means the chunk's bottom line
154 +			var bottomTask = state.multiSelectedIds.length > 1
155 +				? getMultiSelectedTasks().slice(-1)[0] || task
156 +				: task;
157 +			if (isLastSubtask(bottomTask) && state.lastSubtaskDownArrowReleased && bottomTask !== state.currentTask) {
158  				e.preventDefault();
159  				keyHandler.arrowDown.blocked = true;
    -				addNewSubtask(state.currentTask, task);
160 +				clearMultiSelect();
161 +				addNewSubtask(state.currentTask, bottomTask);
162  				state.lastSubtaskDownArrowReleased = false;
163  			} else {
164  				handleKeyDown(e, task);

243  	taskInput.addEventListener('keydown', handleCopyAndCut);
244  	taskInput.addEventListener('input', (e) => {
245  		var oldText = task.text;
    -		// Keep text single-line: strip any newlines a paste/IME may introduce
    -		if (taskInput.textContent.indexOf('\n') !== -1) {
246 +		// Normalize: strip newlines a paste/IME may introduce, and convert
247 +		// contenteditable's nbsp space substitutions back to plain spaces
248 +		var normalized = taskInput.textContent.replace(/\n/g, '').replace(/\u00A0/g, ' ');
249 +		if (normalized !== taskInput.textContent) {
250  			var caret = getCaretOffset(taskInput);
    -			taskInput.textContent = taskInput.textContent.replace(/\n/g, '');
251 +			taskInput.textContent = normalized;
252  			if (caret != null) setCaretOffset(taskInput, caret);
253  		}
254  		task.text = taskInput.textContent;
255  		// Propagate edits to all other multi-selected tasks
256  		if (state.multiSelectedIds.length > 1 && state.multiSelectedIds.includes(task.id)) {
257 +			// snapshot before mirroring; the focused line already changed, so
258 +			// its pre-edit text comes from oldText
259 +			pushMultiUndo(task.id, oldText);
260 +			var focusedOffset = getCaretOffset(taskInput);
261 +			if (focusedOffset != null) state.multiCaretOffsets[task.id] = focusedOffset;
262  			var otherSelected = getMultiSelectedTasks().filter(t => t.id !== task.id);
263  
264  			if (e.inputType === 'insertText' && e.data) {
265 +				var data = e.data.replace(/\u00A0/g, ' ');
266  				for (var t of otherSelected) {
    -					t.text = t.text + e.data;
    -					var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] .task-text`);
    -					if (otherInput) otherInput.textContent = t.text;
267 +					insertAtMultiCaret(t, data);
268  				}
    -			} else if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteContentForward') {
269 +			} else if (e.inputType === 'deleteContentBackward') {
270  				for (var t of otherSelected) {
    -					if (t.text.length > 0) {
    -						t.text = t.text.slice(0, -1);
    -					}
    -					var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] .task-text`);
    -					if (otherInput) otherInput.textContent = t.text;
271 +					deleteAtMultiCaret(t, 'backward');
272 +				}
273 +			} else if (e.inputType === 'deleteContentForward') {
274 +				for (var t of otherSelected) {
275 +					deleteAtMultiCaret(t, 'forward');
276  				}
277  			} else if (e.inputType === 'insertFromPaste' || e.inputType === 'insertFromDrop') {
    -				var addedLen = taskInput.textContent.length - oldText.length;
278 +				// the paste may have replaced the focused line's selection, so
279 +				// account for the replaced range when sizing the pasted text
280 +				var preRange = state.multiSelectRanges[task.id];
281 +				var replacedLen = preRange
282 +					? Math.min(preRange.end, oldText.length) - Math.min(preRange.start, oldText.length)
283 +					: 0;
284 +				var addedLen = taskInput.textContent.length - oldText.length + Math.max(0, replacedLen);
285  				if (addedLen > 0) {
286  					var caretPos = getCaretOffset(taskInput);
287  					var pastedText = taskInput.textContent.slice(caretPos - addedLen, caretPos);
288  					for (var t of otherSelected) {
    -						t.text = t.text + pastedText;
    -						var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] .task-text`);
    -						if (otherInput) otherInput.textContent = t.text;
289 +						insertAtMultiCaret(t, pastedText);
290  					}
291  				}
292  			}
293 +			// the edit consumed the focused line's selection
294 +			delete state.multiSelectRanges[task.id];
295 +			renderSimCarets();
296  		}
297  		if (task === state.currentTask) {
298  			updatePageTitle(task);