commit 002e0b2e0f744e80721001ce8cd041187429462f
parent 42e63acacee92fd67e906576af1ea906aca21316
Author: Hunter
Date:   Thu, 11 Jun 2026 17:26:19 -0400

improve grapheme-aware deletion

Diffstat:
Mresources/multi-select.js | 15+++++++++++++++
Mresources/task-element.js | 41+++++++++++++++++++++++++++++++----------
2 files changed, 46 insertions(+), 10 deletions(-)

diff --git a/resources/multi-select.js b/resources/multi-select.js @@ -112,6 +112,21 @@ function deleteAtMultiCaret(t, direction) { syncMultiTaskText(t); } +// Backspace/Delete during multi-select: simulate deletion on every selected +// line (including the focused one) so native deletion can't split a grapheme +function deleteAcrossMultiSelection(focusedTask, taskInput, direction) { + pushMultiUndo(); + var focusedOffset = getCaretOffset(taskInput); + if (focusedOffset != null) state.multiCaretOffsets[focusedTask.id] = focusedOffset; + for (var t of getMultiSelectedTasks()) { + deleteAtMultiCaret(t, direction); + } + // rewriting textContent collapses the (hidden) native caret; restore it + setCaretOffset(taskInput, clampCaret(focusedTask.text, state.multiCaretOffsets[focusedTask.id])); + renderSimCarets(); + scheduleSave(); +} + // Up/Down: leave multi-select onto the task just above/below the chunk function exitMultiSelect(direction) { var selected = getMultiSelectedTasks(); diff --git a/resources/task-element.js b/resources/task-element.js @@ -31,6 +31,26 @@ function nextGraphemeBoundary(text, offset) { return next; } +// native deletion splits ZWJ emoji in firefox; when the grapheme next to the +// caret spans multiple code units, delete it whole and report handled +function deleteWholeGrapheme(taskInput, direction) { + var sel = window.getSelection(); + var off = getCaretOffset(taskInput); + if (!sel || !sel.isCollapsed || off == null) return false; + var text = taskInput.textContent; + var start = direction === 'backward' ? prevGraphemeBoundary(text, off) : off; + var end = direction === 'backward' ? off : nextGraphemeBoundary(text, off); + if (end - start <= 1) return false; + taskInput.textContent = text.slice(0, start) + text.slice(end); + setCaretOffset(taskInput, start); + // synthetic input event so the regular listener syncs task.text and saves + taskInput.dispatchEvent(new InputEvent('input', { + inputType: direction === 'backward' ? 'deleteContentBackward' : 'deleteContentForward', + bubbles: true + })); + return true; +} + function createTaskElement(task, isParentTask = false) { var taskContainer = document.createElement('div'); taskContainer.className = 'task-container'; @@ -105,17 +125,8 @@ function createTaskElement(task, isParentTask = false) { // 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) { - deleteAtMultiCaret(t, 'backward'); - } - // rewriting textContent collapses the (hidden) native caret; restore it - setCaretOffset(taskInput, clampCaret(task.text, state.multiCaretOffsets[task.id])); - renderSimCarets(); + deleteAcrossMultiSelection(task, taskInput, 'backward'); keyHandler.backspace.canDelete = false; - scheduleSave(); return; } if (taskInput.textContent === '' && keyHandler.backspace.canDelete && state.multiSelectedIds.length <= 1) { @@ -133,6 +144,16 @@ function createTaskElement(task, isParentTask = false) { } } else if (taskInput.textContent !== '') { keyHandler.backspace.canDelete = false; + if (deleteWholeGrapheme(taskInput, 'backward')) e.preventDefault(); + } + } else if (e.key === 'Delete') { + if (state.multiSelectedIds.length > 1) { + e.preventDefault(); + deleteAcrossMultiSelection(task, taskInput, 'forward'); + } else if (deleteWholeGrapheme(taskInput, 'forward')) { + e.preventDefault(); + } else { + handleKeyDown(e, task); } } else if (e.key === 'Enter' && !e.shiftKey) { if (keyHandler.enter.blocked) {