commit 42e63acacee92fd67e906576af1ea906aca21316
parent 9b30751b7fc06a98aa18e202b2c5fd24c52fa661
Author: Hunter
Date:   Thu, 11 Jun 2026 16:24:20 -0400

further multi-select UX improvements

Diffstat:
Mresources/keyboard.js | 41++++++++++++++++++++++++-----------------
Mresources/multi-select.js | 24++++++++++++++++++++++++
Mresources/render.js | 40+++++++++++++++++++++++++++++++++++++++-
Mresources/reorganize.js | 2++
4 files changed, 89 insertions(+), 18 deletions(-)

diff --git a/resources/keyboard.js b/resources/keyboard.js @@ -42,6 +42,29 @@ function handleKeyDown(e, task) { return; } + // Alt+Left/Right: act like a plain arrow instead of the native word jump; + // native is prevented, so the focused caret steps by simulation + if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && e.altKey && !cmd && !e.shiftKey) { + e.preventDefault(); + var input = e.currentTarget; + var sel = window.getSelection(); + var end = getCaretOffset(input); + if (end != null) { + var target; + if (sel && !sel.isCollapsed) { + // arrows collapse a selection to its start or end, like native + target = e.key === 'ArrowLeft' ? end - sel.toString().length : end; + } else { + target = e.key === 'ArrowLeft' + ? prevGraphemeBoundary(input.textContent, end) + : nextGraphemeBoundary(input.textContent, end); + } + setCaretOffset(input, target); + } + if (hasMultiSelect) stepMultiCarets(e.key); + return; + } + // Shift+Enter: toggle task state (bulk if multi-selected) if (e.key === 'Enter' && e.shiftKey) { e.preventDefault(); @@ -180,23 +203,7 @@ function handleKeyDown(e, task) { // Plain Left/Right during multi-select: every line's caret steps together } else if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.shiftKey && !e.altKey && hasMultiSelect) { // 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(); + stepMultiCarets(e.key); // Cmd+A during multi-select: select all text across all selected lines } else if ((e.key === 'a' || e.key === 'A') && cmd && !e.shiftKey && !e.altKey && hasMultiSelect) { e.preventDefault(); diff --git a/resources/multi-select.js b/resources/multi-select.js @@ -56,6 +56,28 @@ function getMultiRange(t) { return end > start ? { start: start, end: end } : null; } +// plain Left/Right during multi-select: step every non-focused line's caret +// (the focused line is handled natively or by the caller) +function stepMultiCarets(key) { + 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] = 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] = key === 'ArrowLeft' + ? prevGraphemeBoundary(t.text, off) + : nextGraphemeBoundary(t.text, off); + } + } + renderSimCarets(); +} + function insertAtMultiCaret(t, str) { var range = getMultiRange(t); var start = range ? range.start : clampCaret(t.text, state.multiCaretOffsets[t.id]); @@ -314,8 +336,10 @@ function moveMultiSelected(direction) { subtasks.splice(insertAt, 0, ...chunk); state.currentTask.selectedSubtaskId = refocusId; + var savedSelection = captureActiveSelection(); renderCurrentView(); applyMultiSelectHighlights(); + restoreActiveSelection(savedSelection); scheduleSave(); return true; } diff --git a/resources/render.js b/resources/render.js @@ -58,7 +58,45 @@ function selectAllText(el) { function rescrollActiveCaret() { var el = document.querySelector('.task-container.active .task-text'); - if (el) setCaretOffset(el, getCaretOffset(el) || 0); + var focused = document.activeElement; + // in multi-select every row is .active; prefer the truly focused line + if (focused && focused.classList && focused.classList.contains('task-text')) el = focused; + if (!el) return; + var sel = window.getSelection(); + // keep a live range selection; resetting the caret would collapse it + if (sel && sel.rangeCount > 0 && !sel.isCollapsed && el.contains(sel.anchorNode)) { + scrollCaretIntoView(el, sel.getRangeAt(0), getCaretOffset(el) || 0); + renderSimCarets(); + return; + } + setCaretOffset(el, getCaretOffset(el) || 0); +} + +// capture/restore the focused line's text selection across a re-render, +// which destroys the DOM nodes the live selection points at +function captureActiveSelection() { + var el = document.activeElement; + if (!el || !el.classList || !el.classList.contains('task-text')) return null; + var sel = window.getSelection(); + if (!sel || sel.rangeCount === 0 || sel.isCollapsed || !el.contains(sel.anchorNode)) return null; + var container = el.closest('.task-container'); + if (!container) return null; + var end = getCaretOffset(el); + if (end == null) return null; + return { id: container.dataset.id, start: end - sel.toString().length, end: end }; +} + +function restoreActiveSelection(saved) { + if (!saved) return; + var input = document.querySelector(`.task-container[data-id="${saved.id}"] .task-text`); + var node = input ? input.firstChild : null; + if (!node || node.nodeType !== Node.TEXT_NODE) return; + var range = document.createRange(); + range.setStart(node, Math.max(0, Math.min(saved.start, node.textContent.length))); + range.setEnd(node, Math.max(0, Math.min(saved.end, node.textContent.length))); + var sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); } function isSelectionCollapsed() { diff --git a/resources/reorganize.js b/resources/reorganize.js @@ -17,8 +17,10 @@ function moveSubtask(subtask, direction) { return false; } + var savedSelection = captureActiveSelection(); renderCurrentView(); selectAndFocusTask(subtask); + restoreActiveSelection(savedSelection); scheduleSave(); return true; }