commit 42e63acacee92fd67e906576af1ea906aca21316
parent 9b30751b7fc06a98aa18e202b2c5fd24c52fa661
Author: Hunter
Date: Thu, 11 Jun 2026 16:24:20 -0400
further multi-select UX improvements
Diffstat:
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;
}