commit 9b30751b7fc06a98aa18e202b2c5fd24c52fa661
parent 437aaecfb191a15400bb6b8902d4e8c026bccbb5
Author: Hunter
Date:   Thu, 11 Jun 2026 16:09:37 -0400

multiselect improvements

Diffstat:
Mresources/keyboard.js | 45++++++++++++++++++++++++++++++++++++++++-----
Mresources/main.css | 47+++++++++++++++++++++++++++++++++++++++++------
Mresources/main.js | 52++++++++++++++++++++++++++++++++++++++++++++++++++--
Mresources/multi-select.js | 215+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mresources/render.js | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mresources/state.js | 5+++++
Mresources/task-element.js | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
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);