commit 9b30751b7fc06a98aa18e202b2c5fd24c52fa661
parent 437aaecfb191a15400bb6b8902d4e8c026bccbb5
Author: Hunter
Date: Thu, 11 Jun 2026 16:09:37 -0400
multiselect improvements
Diffstat:
7 files changed, 534 insertions(+), 38 deletions(-)
diff --git a/resources/keyboard.js b/resources/keyboard.js
@@ -113,15 +113,21 @@ function handleKeyDown(e, task) {
} else if (e.key === 'ArrowRight' && cmd && e.shiftKey && !e.altKey) {
e.preventDefault();
hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id);
- // Plain Up/Down: navigate (clears multi-select)
+ // Plain Up/Down: navigate (multi-select exits onto the task beyond the chunk)
} else if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) {
e.preventDefault();
- if (hasMultiSelect) clearMultiSelect();
- navigateTasks('up');
+ if (hasMultiSelect) {
+ exitMultiSelect('up');
+ } else {
+ navigateTasks('up');
+ }
} else if (e.key === 'ArrowDown' && !e.shiftKey && !e.altKey) {
e.preventDefault();
- if (hasMultiSelect) clearMultiSelect();
- navigateTasks('down');
+ if (hasMultiSelect) {
+ exitMultiSelect('down');
+ } else {
+ navigateTasks('down');
+ }
// Shift+Up/Down: move task
} else if (e.key === 'ArrowUp' && e.shiftKey && !e.altKey) {
e.preventDefault();
@@ -171,5 +177,34 @@ function handleKeyDown(e, task) {
keyHandler.shiftLeft.pressed = true;
navigateToParentTask();
}
+ // 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();
+ // 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();
+ selectAllMultiSelected(task);
+ // Cmd+Z / Cmd+Shift+Z during multi-select: app-level undo/redo (native
+ // history would only touch the focused line, so it's blocked)
+ } else if ((e.key === 'z' || e.key === 'Z') && cmd && !e.altKey && hasMultiSelect) {
+ e.preventDefault();
+ e.shiftKey ? redoMultiEdit(task) : undoMultiEdit(task);
}
}
diff --git a/resources/main.css b/resources/main.css
@@ -123,10 +123,12 @@
.custom-checkbox:checked + .checkbox-label::before {
display: none;
}
- .task-text {
+ .task-text,
+ .pseudo-selection {
text-shadow: 1px 1px 1px var(--cola);
}
- .task-text::selection {
+ .task-text::selection,
+ .pseudo-selection {
color: var(--text);
}
}
@@ -140,9 +142,12 @@
.task-text {
font-family: BasteleurMoonlight;
}
- .task-text::selection {
+ .task-text::selection,
+ .pseudo-selection {
background-color: var(--background);
color: var(--text);
+ }
+ .task-text::selection {
text-shadow: none;
}
.active {
@@ -207,6 +212,9 @@
color: var(--mothball);
text-shadow: 1px 1px 1px var(--bakelite);
}
+ .active .pseudo-selection {
+ text-shadow: 1px 1px 1px var(--bakelite);
+ }
.parent-task {
--text: var(--mothball)
}
@@ -238,7 +246,8 @@
.active .custom-checkbox:indeterminate + .checkbox-label::before {
background-color: var(--mothball);
}
- .task-text::selection {
+ .task-text::selection,
+ .pseudo-selection {
background-color: var(--lipstick);
color: var(--mothball);
}
@@ -297,7 +306,7 @@ ul {
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
- white-space: nowrap;
+ white-space: pre; /* preserve spaces; stops contenteditable nbsp substitution */
cursor: text;
line-height: normal;
min-height: 1.17em;
@@ -308,7 +317,8 @@ ul {
.active .task-text {
text-overflow: clip;
}
-.task-text::selection {
+.task-text::selection,
+.pseudo-selection {
background-color: var(--accent);
color: var(--background);
}
@@ -320,6 +330,31 @@ ul {
padding-right: 11px;
padding-top: 4px;
padding-bottom: 4px;
+ position: relative;
+}
+/* the native caret is hidden app-wide; simulated carets render identically
+ across OSes and between single- and multi-select editing */
+.task-text {
+ caret-color: transparent;
+}
+.sim-caret {
+ position: absolute;
+ width: 1px;
+ pointer-events: none;
+ animation: caret-blink 1s step-end infinite;
+}
+/* non-focused lines' selections: a clipped text duplicate in ::selection
+ colors, built and positioned by updateMultiRangeStyles */
+.pseudo-selection {
+ position: absolute;
+ overflow: hidden;
+ pointer-events: none;
+ white-space: pre;
+ line-height: normal;
+}
+@keyframes caret-blink {
+ 0%, 49% { opacity: 1; }
+ 50%, 100% { opacity: 0; }
}
.active {
background-color: var(--highlight);
diff --git a/resources/main.js b/resources/main.js
@@ -69,13 +69,61 @@ document.addEventListener('DOMContentLoaded', function() {
state.appContainer.addEventListener('focusin', function(e) {
if (e.target.classList && e.target.classList.contains('task-text')) {
document.querySelectorAll('.task-text').forEach(input => {
- if (input !== e.target) {
- placeCursorAtBeginning(input);
+ if (input === e.target) return;
+ // multi-selected lines keep their own caret scrolled into view
+ if (state.multiSelectedIds.length > 1) {
+ var container = input.closest('.task-container');
+ if (container && state.multiSelectedIds.includes(container.dataset.id)) return;
}
+ placeCursorAtBeginning(input);
});
}
});
+ // The native caret is hidden app-wide; mirror the focused line's caret
+ // position and redraw the simulated carets on every selection change
+ document.addEventListener('selectionchange', function() {
+ var el = document.activeElement;
+ if (!el || !el.classList || !el.classList.contains('task-text')) return;
+ if (state.multiSelectedIds.length > 1) {
+ var container = el.closest('.task-container');
+ var id = container ? container.dataset.id : null;
+ var sel = window.getSelection();
+ if (id && state.multiSelectedIds.includes(id) && sel && sel.rangeCount > 0) {
+ var offset = getCaretOffset(el);
+ if (offset != null) {
+ // live-track the focused line's selection so it persists as a
+ // painted range when focus moves to another line; a collapse
+ // (click, arrow) clears it, like native
+ var len = sel.isCollapsed ? 0 : sel.toString().length;
+ if (len > 0) {
+ state.multiSelectRanges[id] = { start: offset - len, end: offset };
+ } else {
+ delete state.multiSelectRanges[id];
+ }
+ state.multiCaretOffsets[id] = offset;
+ }
+ }
+ }
+ renderSimCarets();
+ });
+
+ // hide simulated carets while the window is inactive, like native ones
+ state.windowFocused = document.hasFocus();
+ window.addEventListener('focus', function() {
+ state.windowFocused = true;
+ renderSimCarets();
+ });
+ window.addEventListener('blur', function() {
+ state.windowFocused = false;
+ renderSimCarets();
+ });
+
+ // drop the caret if focus leaves the task texts entirely
+ state.appContainer.addEventListener('focusout', function() {
+ requestAnimationFrame(renderSimCarets);
+ });
+
// Initialize
setInitialTheme();
renderCurrentView();
diff --git a/resources/multi-select.js b/resources/multi-select.js
@@ -3,6 +3,12 @@
function clearMultiSelect() {
state.multiSelectAnchorId = null;
state.multiSelectedIds = [];
+ state.multiCaretOffsets = {};
+ state.multiSelectRanges = {};
+ state.multiUndoStack = [];
+ state.multiRedoStack = [];
+ updateMultiRangeStyles();
+ document.querySelectorAll('.sim-caret').forEach(el => el.remove());
var focused = document.activeElement;
var focusedContainer = focused ? focused.closest('.task-container') : null;
document.querySelectorAll('.task-container.active').forEach(el => {
@@ -27,8 +33,206 @@ function applyMultiSelectHighlights() {
var container = document.querySelector(`.task-container[data-id="${id}"]`);
if (container) container.classList.add('active');
}
+ renderSimCarets();
}
+function clampCaret(text, offset) {
+ if (typeof offset !== 'number') return text.length;
+ return Math.max(0, Math.min(offset, text.length));
+}
+
+function syncMultiTaskText(t) {
+ var inp = document.querySelector(`.task-container[data-id="${t.id}"] .task-text`);
+ if (inp) inp.textContent = t.text;
+}
+
+// a line's pending edit target: its stored selection range if it has one
+// (clamped to the current text), else null
+function getMultiRange(t) {
+ var range = state.multiSelectRanges[t.id];
+ if (!range) return null;
+ var start = Math.min(range.start, t.text.length);
+ var end = Math.min(range.end, t.text.length);
+ return end > start ? { start: start, end: end } : null;
+}
+
+function insertAtMultiCaret(t, str) {
+ var range = getMultiRange(t);
+ var start = range ? range.start : clampCaret(t.text, state.multiCaretOffsets[t.id]);
+ var end = range ? range.end : start;
+ t.text = t.text.slice(0, start) + str + t.text.slice(end);
+ state.multiCaretOffsets[t.id] = start + str.length;
+ delete state.multiSelectRanges[t.id];
+ syncMultiTaskText(t);
+}
+
+function deleteAtMultiCaret(t, direction) {
+ var range = getMultiRange(t);
+ var start, end;
+ if (range) {
+ start = range.start;
+ end = range.end;
+ } else {
+ var off = clampCaret(t.text, state.multiCaretOffsets[t.id]);
+ if (direction === 'forward') {
+ start = off;
+ end = nextGraphemeBoundary(t.text, off);
+ } else {
+ start = prevGraphemeBoundary(t.text, off);
+ end = off;
+ }
+ }
+ if (start < end) {
+ t.text = t.text.slice(0, start) + t.text.slice(end);
+ }
+ state.multiCaretOffsets[t.id] = start;
+ delete state.multiSelectRanges[t.id];
+ syncMultiTaskText(t);
+}
+
+// Up/Down: leave multi-select onto the task just above/below the chunk
+function exitMultiSelect(direction) {
+ var selected = getMultiSelectedTasks();
+ clearMultiSelect();
+ if (selected.length === 0) return;
+ var subtasks = state.currentTask.subtasks;
+ var indices = selected.map(t => subtasks.findIndex(s => s.id === t.id)).sort((a, b) => a - b);
+ if (direction === 'up') {
+ // above the first subtask sits the parent task
+ selectAndFocusTask(subtasks[indices[0] - 1] || state.currentTask);
+ } else {
+ selectAndFocusTask(subtasks[Math.min(indices[indices.length - 1] + 1, subtasks.length - 1)]);
+ }
+}
+
+// Cmd+Z / Cmd+Shift+Z: per-session undo/redo of multi-line edits; snapshots
+// are pushed before each mutation (focusedOldText covers input events, where
+// the focused line has already changed)
+function captureMultiSnapshot(focusedId, focusedOldText) {
+ var texts = {};
+ for (var t of getMultiSelectedTasks()) {
+ texts[t.id] = t.id === focusedId ? focusedOldText : t.text;
+ }
+ return {
+ texts: texts,
+ offsets: Object.assign({}, state.multiCaretOffsets),
+ ranges: Object.assign({}, state.multiSelectRanges)
+ };
+}
+
+function pushMultiUndo(focusedId, focusedOldText) {
+ state.multiUndoStack.push(captureMultiSnapshot(focusedId, focusedOldText));
+ state.multiRedoStack = [];
+}
+
+function undoMultiEdit(focusedTask) {
+ var entry = state.multiUndoStack.pop();
+ if (!entry) return;
+ state.multiRedoStack.push(captureMultiSnapshot());
+ applyMultiSnapshot(entry, focusedTask);
+}
+
+function redoMultiEdit(focusedTask) {
+ var entry = state.multiRedoStack.pop();
+ if (!entry) return;
+ state.multiUndoStack.push(captureMultiSnapshot());
+ applyMultiSnapshot(entry, focusedTask);
+}
+
+function applyMultiSnapshot(entry, focusedTask) {
+ for (var t of getMultiSelectedTasks()) {
+ if (typeof entry.texts[t.id] === 'string') {
+ t.text = entry.texts[t.id];
+ syncMultiTaskText(t);
+ }
+ }
+ state.multiCaretOffsets = entry.offsets;
+ state.multiSelectRanges = entry.ranges || {};
+ var input = document.querySelector(`.task-container[data-id="${focusedTask.id}"] .task-text`);
+ if (input) setCaretOffset(input, clampCaret(focusedTask.text, state.multiCaretOffsets[focusedTask.id]));
+ renderSimCarets();
+ scheduleSave();
+}
+
+// Cmd+A: native select-all on the focused line plus stored full-line ranges
+// on the others; the next edit then replaces each line's selection
+function selectAllMultiSelected(focusedTask) {
+ for (var t of getMultiSelectedTasks()) {
+ state.multiSelectRanges[t.id] = { start: 0, end: t.text.length };
+ state.multiCaretOffsets[t.id] = t.text.length;
+ }
+ var input = document.querySelector(`.task-container[data-id="${focusedTask.id}"] .task-text`);
+ if (input) selectAllText(input);
+ renderSimCarets();
+}
+
+// only one real DOM selection can exist, so non-focused lines paint their
+// stored ranges with an overlay: a clipped duplicate of the line's text in
+// the theme's ::selection colors, glyph-aligned with the original
+function updateMultiRangeStyles() {
+ document.querySelectorAll('.pseudo-selection').forEach(el => el.remove());
+ if (state.multiSelectedIds.length <= 1) return;
+ 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) continue;
+ var container = document.querySelector(`.task-container[data-id="${t.id}"]`);
+ var input = container ? container.querySelector('.task-text') : null;
+ var node = input ? input.firstChild : null;
+ if (!node || node.nodeType !== Node.TEXT_NODE) continue;
+ var r = document.createRange();
+ r.setStart(node, Math.min(range.start, node.textContent.length));
+ r.setEnd(node, Math.min(range.end, node.textContent.length));
+ var rect = r.getBoundingClientRect();
+ var inputRect = input.getBoundingClientRect();
+ var containerRect = container.getBoundingClientRect();
+ var scale = container.offsetWidth ? containerRect.width / container.offsetWidth : 1;
+ // clamp to the line's visible text box
+ var left = Math.max(rect.left, inputRect.left);
+ var right = Math.min(rect.right, inputRect.right);
+ if (right <= left || rect.height <= 0) continue;
+
+ var overlay = document.createElement('div');
+ overlay.className = 'pseudo-selection';
+ var inner = document.createElement('span');
+ inner.textContent = t.text;
+ overlay.appendChild(inner);
+
+ // match the line's text rendering so the duplicate glyphs coincide;
+ // styled on the overlay (not the span) so its line-box strut shares
+ // the font metrics, else baseline alignment shifts the span down.
+ // text-shadow stays in CSS: themes restyle it under ::selection, and
+ // an inline copy of the row's shadow couldn't be overridden there
+ var cs = getComputedStyle(input);
+ overlay.style.fontFamily = cs.fontFamily;
+ overlay.style.fontSize = cs.fontSize;
+ overlay.style.fontWeight = cs.fontWeight;
+ overlay.style.letterSpacing = cs.letterSpacing;
+
+ overlay.style.left = ((left - containerRect.left) / scale) + 'px';
+ overlay.style.top = ((rect.top - containerRect.top) / scale) + 'px';
+ overlay.style.width = ((right - left) / scale) + 'px';
+ overlay.style.height = (rect.height / scale) + 'px';
+ // shift the duplicate so the selected substring lands in the window
+ inner.style.marginLeft = (-((left - inputRect.left) / scale + input.scrollLeft)) + 'px';
+ container.appendChild(overlay);
+
+ // some fonts' selection rects sit off their line box by ~1px, which
+ // would drift the duplicate; measure against the original and cancel
+ var probe = document.createRange();
+ probe.setStart(inner.firstChild, Math.min(range.start, t.text.length));
+ probe.setEnd(inner.firstChild, Math.min(range.end, t.text.length));
+ var drift = probe.getBoundingClientRect().top - rect.top;
+ if (drift) {
+ inner.style.position = 'relative';
+ inner.style.top = (-drift / scale) + 'px';
+ }
+ }
+}
+
+
function shakeAllSelected(direction = 'horizontal') {
for (var id of state.multiSelectedIds) {
applyShakeAnimation(id, direction);
@@ -44,6 +248,9 @@ function extendMultiSelect(task, direction) {
if (state.multiSelectAnchorId === null) {
state.multiSelectAnchorId = task.id;
state.multiSelectedIds = [task.id];
+ var anchorInput = document.querySelector(`.task-container[data-id="${task.id}"] .task-text`);
+ var anchorOffset = anchorInput ? getCaretOffset(anchorInput) : null;
+ state.multiCaretOffsets[task.id] = anchorOffset != null ? anchorOffset : task.text.length;
}
var anchorIndex = tasks.findIndex(t => t.id === state.multiSelectAnchorId);
@@ -56,22 +263,30 @@ function extendMultiSelect(task, direction) {
});
if (currentIndex <= anchorIndex && state.multiSelectedIds.length > 1) {
state.multiSelectedIds = state.multiSelectedIds.filter(id => id !== task.id);
+ delete state.multiCaretOffsets[task.id];
+ delete state.multiSelectRanges[task.id];
var nextTask = tasks[currentIndex + 1];
if (nextTask) selectAndFocusTask(nextTask);
} else if (bottomIndex < tasks.length - 1) {
var nextTask = tasks[bottomIndex + 1];
state.multiSelectedIds.push(nextTask.id);
+ state.multiCaretOffsets[nextTask.id] = nextTask.text.length;
+ delete state.multiSelectRanges[nextTask.id];
selectAndFocusTask(nextTask);
}
} else {
var topIndex = tasks.findIndex(t => state.multiSelectedIds.includes(t.id));
if (currentIndex >= anchorIndex && state.multiSelectedIds.length > 1) {
state.multiSelectedIds = state.multiSelectedIds.filter(id => id !== task.id);
+ delete state.multiCaretOffsets[task.id];
+ delete state.multiSelectRanges[task.id];
var prevTask = tasks[currentIndex - 1];
if (prevTask) selectAndFocusTask(prevTask);
} else if (topIndex > 0) {
var prevTask = tasks[topIndex - 1];
state.multiSelectedIds.unshift(prevTask.id);
+ state.multiCaretOffsets[prevTask.id] = prevTask.text.length;
+ delete state.multiSelectRanges[prevTask.id];
selectAndFocusTask(prevTask);
}
}
diff --git a/resources/render.js b/resources/render.js
@@ -124,6 +124,105 @@ function applyShakeAnimation(taskId, direction = 'horizontal') {
}
}
+// Simulated carets: the native caret is hidden app-wide (caret-color) so the
+// caret looks identical across OSes and between single- and multi-select.
+// Single mode mirrors the hidden native caret; multi mode uses the tracked
+// per-line offsets. Carets hide on window blur and behind range selections.
+function renderSimCarets() {
+ var multi = state.multiSelectedIds.length > 1;
+ var focusedEl = document.activeElement;
+ if (!focusedEl || !focusedEl.classList || !focusedEl.classList.contains('task-text')) focusedEl = null;
+ var sel = window.getSelection();
+ var focusedHasRange = !!(focusedEl && sel && sel.rangeCount > 0 && !sel.isCollapsed && focusedEl.contains(sel.anchorNode));
+
+ document.querySelectorAll('.sim-caret').forEach(el => {
+ var container = el.closest('.task-container');
+ var id = container ? container.dataset.id : null;
+ var keep = multi ? state.multiSelectedIds.includes(id) : !!(focusedEl && container && container.contains(focusedEl));
+ if (!keep) el.remove();
+ });
+
+ if (multi) {
+ for (var t of getMultiSelectedTasks()) {
+ var container = document.querySelector(`.task-container[data-id="${t.id}"]`);
+ if (!container) continue;
+ var offset = clampCaret(t.text, state.multiCaretOffsets[t.id]);
+ state.multiCaretOffsets[t.id] = offset;
+ var isFocusedLine = !!(focusedEl && container.contains(focusedEl));
+ // a line showing a selection shows no caret, like native
+ var lineHasRange = isFocusedLine ? focusedHasRange : !!getMultiRange(t);
+ positionSimCaret(container, offset, !state.windowFocused || lineHasRange);
+ }
+ updateMultiRangeStyles();
+ } else if (focusedEl) {
+ var container = focusedEl.closest('.task-container');
+ var offset = getCaretOffset(focusedEl);
+ if (container) {
+ if (offset == null) {
+ var stray = container.querySelector('.sim-caret');
+ if (stray) stray.remove();
+ } else {
+ positionSimCaret(container, offset, !state.windowFocused || focusedHasRange);
+ }
+ }
+ }
+}
+
+function positionSimCaret(container, offset, hidden) {
+ var textEl = container.querySelector('.task-text');
+ var caretEl = container.querySelector('.sim-caret');
+ if (!caretEl) {
+ caretEl = document.createElement('div');
+ caretEl.className = 'sim-caret';
+ container.appendChild(caretEl);
+ }
+ var containerRect = container.getBoundingClientRect();
+ var textRect = textEl.getBoundingClientRect();
+ // client rects include the body zoom but style px get re-zoomed, so unscale
+ var scale = container.offsetWidth ? containerRect.width / container.offsetWidth : 1;
+ var x = textRect.left;
+ var top = textRect.top;
+ var height = textRect.height;
+ var node = textEl.firstChild;
+ if (node && node.nodeType === Node.TEXT_NODE) {
+ var range = document.createRange();
+ range.setStart(node, Math.min(offset, node.textContent.length));
+ range.collapse(true);
+ // keep each line scrolled so its own insertion point stays visible
+ scrollCaretIntoView(textEl, range, offset);
+ var rect = range.getBoundingClientRect();
+ if (rect.height > 0) {
+ x = rect.left;
+ top = rect.top;
+ height = rect.height;
+ }
+ } else {
+ // empty line: the element box (padding + min-height) is taller than a
+ // caret; approximate the line box from font metrics instead
+ var styles = getComputedStyle(textEl);
+ height = parseFloat(styles.fontSize) * 1.15 * scale;
+ top = textRect.top + (parseFloat(styles.paddingTop) || 0) * scale;
+ }
+ // keep the caret inside the line's visible text box
+ x = Math.max(textRect.left, Math.min(x, textRect.right - 1));
+ // snap to whole zoomed pixels and size to exactly one, so the caret
+ // never straddles a pixel boundary and fattens to 2px
+ caretEl.style.left = (Math.round(x - containerRect.left) / scale) + 'px';
+ caretEl.style.top = ((top - containerRect.top) / scale) + 'px';
+ caretEl.style.height = (height / scale) + 'px';
+ caretEl.style.width = (1 / scale) + 'px';
+ if (hidden) {
+ caretEl.style.visibility = 'hidden';
+ return;
+ }
+ caretEl.style.visibility = '';
+ caretEl.style.backgroundColor = getComputedStyle(textEl).color;
+ // restart the blink so all carets stay in phase
+ caretEl.style.animation = 'none';
+ void caretEl.offsetWidth;
+ caretEl.style.animation = '';
+}
+
function selectAndFocusTask(task, cursorPos) {
var taskInput = document.querySelector(`.task-container[data-id="${task.id}"] .task-text`);
if (taskInput) {
@@ -194,6 +293,11 @@ function selectFirstSubtask() {
function handleCopyAndCut(e) {
if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'x')) {
+ // copy/cut are disabled during multi-select (no single "true" line)
+ if (state.multiSelectedIds.length > 1) {
+ e.preventDefault();
+ return;
+ }
var activeTaskInput = document.querySelector('.task-container.active .task-text');
if (activeTaskInput) {
e.preventDefault();
@@ -263,7 +367,9 @@ function renderCurrentView() {
if (state.currentTask.selectedSubtaskId) {
var selectedTask = state.currentTask.subtasks.find(t => t.id === state.currentTask.selectedSubtaskId);
if (selectedTask) {
- selectAndFocusTask(selectedTask);
+ // during multi-select, keep the focused line's caret where it was
+ var pos = state.multiCaretOffsets[selectedTask.id];
+ selectAndFocusTask(selectedTask, typeof pos === 'number' ? pos : undefined);
} else {
selectFirstSubtask();
}
diff --git a/resources/state.js b/resources/state.js
@@ -12,10 +12,15 @@ var state = {
isF2Pressed: false,
themes: ['gak', 'swamp', 'sunflower', 'harvest', 'sugar', 'vineyard', 'woodstove', 'medieval', 'goblin'],
isInWheelEvent: false,
+ windowFocused: true,
// Multi-select state
multiSelectAnchorId: null,
multiSelectedIds: [],
+ multiCaretOffsets: {},
+ multiSelectRanges: {},
+ multiUndoStack: [],
+ multiRedoStack: [],
};
function generateId() {
diff --git a/resources/task-element.js b/resources/task-element.js
@@ -1,5 +1,36 @@
// Task element: DOM creation and per-element event handlers
+// grapheme-aware caret stepping so surrogate pairs / ZWJ emoji never split
+function prevGraphemeBoundary(text, offset) {
+ if (offset <= 0) return 0;
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
+ var prev = 0;
+ for (var seg of new Intl.Segmenter().segment(text)) {
+ if (seg.index >= offset) break;
+ prev = seg.index;
+ }
+ return prev;
+ }
+ // fallback: step one code point
+ var cut = offset - 1;
+ if (cut > 0 && text.charCodeAt(cut) >= 0xDC00 && text.charCodeAt(cut) <= 0xDFFF) cut--;
+ return cut;
+}
+
+function nextGraphemeBoundary(text, offset) {
+ if (offset >= text.length) return text.length;
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
+ for (var seg of new Intl.Segmenter().segment(text)) {
+ var end = seg.index + seg.segment.length;
+ if (end > offset) return end;
+ }
+ return text.length;
+ }
+ var next = offset + 1;
+ if (next < text.length && text.charCodeAt(offset) >= 0xD800 && text.charCodeAt(offset) <= 0xDBFF) next++;
+ return next;
+}
+
function createTaskElement(task, isParentTask = false) {
var taskContainer = document.createElement('div');
taskContainer.className = 'task-container';
@@ -71,15 +102,18 @@ function createTaskElement(task, isParentTask = false) {
}
return;
}
- // Some tasks still have text: remove last char from all that have text
+ // Some tasks still have text: delete each line's selection,
+ // or the grapheme before its caret
e.preventDefault();
+ pushMultiUndo();
+ var focusedOffset = getCaretOffset(taskInput);
+ if (focusedOffset != null) state.multiCaretOffsets[task.id] = focusedOffset;
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;
- }
+ deleteAtMultiCaret(t, 'backward');
}
+ // rewriting textContent collapses the (hidden) native caret; restore it
+ setCaretOffset(taskInput, clampCaret(task.text, state.multiCaretOffsets[task.id]));
+ renderSimCarets();
keyHandler.backspace.canDelete = false;
scheduleSave();
return;
@@ -116,10 +150,15 @@ function createTaskElement(task, isParentTask = false) {
e.preventDefault();
return;
}
- if (isLastSubtask(task) && state.lastSubtaskDownArrowReleased && task !== state.currentTask) {
+ // during multi-select, "at the bottom" means the chunk's bottom line
+ var bottomTask = state.multiSelectedIds.length > 1
+ ? getMultiSelectedTasks().slice(-1)[0] || task
+ : task;
+ if (isLastSubtask(bottomTask) && state.lastSubtaskDownArrowReleased && bottomTask !== state.currentTask) {
e.preventDefault();
keyHandler.arrowDown.blocked = true;
- addNewSubtask(state.currentTask, task);
+ clearMultiSelect();
+ addNewSubtask(state.currentTask, bottomTask);
state.lastSubtaskDownArrowReleased = false;
} else {
handleKeyDown(e, task);
@@ -204,43 +243,56 @@ function createTaskElement(task, isParentTask = false) {
taskInput.addEventListener('keydown', handleCopyAndCut);
taskInput.addEventListener('input', (e) => {
var oldText = task.text;
- // Keep text single-line: strip any newlines a paste/IME may introduce
- if (taskInput.textContent.indexOf('\n') !== -1) {
+ // Normalize: strip newlines a paste/IME may introduce, and convert
+ // contenteditable's nbsp space substitutions back to plain spaces
+ var normalized = taskInput.textContent.replace(/\n/g, '').replace(/\u00A0/g, ' ');
+ if (normalized !== taskInput.textContent) {
var caret = getCaretOffset(taskInput);
- taskInput.textContent = taskInput.textContent.replace(/\n/g, '');
+ taskInput.textContent = normalized;
if (caret != null) setCaretOffset(taskInput, caret);
}
task.text = taskInput.textContent;
// Propagate edits to all other multi-selected tasks
if (state.multiSelectedIds.length > 1 && state.multiSelectedIds.includes(task.id)) {
+ // snapshot before mirroring; the focused line already changed, so
+ // its pre-edit text comes from oldText
+ pushMultiUndo(task.id, oldText);
+ var focusedOffset = getCaretOffset(taskInput);
+ if (focusedOffset != null) state.multiCaretOffsets[task.id] = focusedOffset;
var otherSelected = getMultiSelectedTasks().filter(t => t.id !== task.id);
if (e.inputType === 'insertText' && e.data) {
+ var data = e.data.replace(/\u00A0/g, ' ');
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;
+ insertAtMultiCaret(t, data);
}
- } else if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteContentForward') {
+ } else if (e.inputType === 'deleteContentBackward') {
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;
+ deleteAtMultiCaret(t, 'backward');
+ }
+ } else if (e.inputType === 'deleteContentForward') {
+ for (var t of otherSelected) {
+ deleteAtMultiCaret(t, 'forward');
}
} else if (e.inputType === 'insertFromPaste' || e.inputType === 'insertFromDrop') {
- var addedLen = taskInput.textContent.length - oldText.length;
+ // the paste may have replaced the focused line's selection, so
+ // account for the replaced range when sizing the pasted text
+ var preRange = state.multiSelectRanges[task.id];
+ var replacedLen = preRange
+ ? Math.min(preRange.end, oldText.length) - Math.min(preRange.start, oldText.length)
+ : 0;
+ var addedLen = taskInput.textContent.length - oldText.length + Math.max(0, replacedLen);
if (addedLen > 0) {
var caretPos = getCaretOffset(taskInput);
var pastedText = taskInput.textContent.slice(caretPos - addedLen, caretPos);
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;
+ insertAtMultiCaret(t, pastedText);
}
}
}
+ // the edit consumed the focused line's selection
+ delete state.multiSelectRanges[task.id];
+ renderSimCarets();
}
if (task === state.currentTask) {
updatePageTitle(task);