multiselect improvements
Hunter
Thursday, June 11th, 2026 at 4:09 PM
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 }
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);
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();
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 }
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 }
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() {
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);