further multi-select UX improvements
Hunter
Thursday, June 11th, 2026 at 4:24 PM
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();
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 }
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() {
17 return false;
18 }
19
20 + var savedSelection = captureActiveSelection();
21 renderCurrentView();
22 selectAndFocusTask(subtask);
23 + restoreActiveSelection(savedSelection);
24 scheduleSave();
25 return true;
26 }