improve grapheme-aware deletion
Hunter
Thursday, June 11th, 2026 at 5:26 PM
112 syncMultiTaskText(t);
113 }
114
115 +// Backspace/Delete during multi-select: simulate deletion on every selected
116 +// line (including the focused one) so native deletion can't split a grapheme
117 +function deleteAcrossMultiSelection(focusedTask, taskInput, direction) {
118 + pushMultiUndo();
119 + var focusedOffset = getCaretOffset(taskInput);
120 + if (focusedOffset != null) state.multiCaretOffsets[focusedTask.id] = focusedOffset;
121 + for (var t of getMultiSelectedTasks()) {
122 + deleteAtMultiCaret(t, direction);
123 + }
124 + // rewriting textContent collapses the (hidden) native caret; restore it
125 + setCaretOffset(taskInput, clampCaret(focusedTask.text, state.multiCaretOffsets[focusedTask.id]));
126 + renderSimCarets();
127 + scheduleSave();
128 +}
129 +
130 // Up/Down: leave multi-select onto the task just above/below the chunk
131 function exitMultiSelect(direction) {
132 var selected = getMultiSelectedTasks();
31 return next;
32 }
33
34 +// native deletion splits ZWJ emoji in firefox; when the grapheme next to the
35 +// caret spans multiple code units, delete it whole and report handled
36 +function deleteWholeGrapheme(taskInput, direction) {
37 + var sel = window.getSelection();
38 + var off = getCaretOffset(taskInput);
39 + if (!sel || !sel.isCollapsed || off == null) return false;
40 + var text = taskInput.textContent;
41 + var start = direction === 'backward' ? prevGraphemeBoundary(text, off) : off;
42 + var end = direction === 'backward' ? off : nextGraphemeBoundary(text, off);
43 + if (end - start <= 1) return false;
44 + taskInput.textContent = text.slice(0, start) + text.slice(end);
45 + setCaretOffset(taskInput, start);
46 + // synthetic input event so the regular listener syncs task.text and saves
47 + taskInput.dispatchEvent(new InputEvent('input', {
48 + inputType: direction === 'backward' ? 'deleteContentBackward' : 'deleteContentForward',
49 + bubbles: true
50 + }));
51 + return true;
52 +}
53 +
54 function createTaskElement(task, isParentTask = false) {
55 var taskContainer = document.createElement('div');
56 taskContainer.className = 'task-container';
125 // Some tasks still have text: delete each line's selection,
126 // or the grapheme before its caret
127 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();
128 + deleteAcrossMultiSelection(task, taskInput, 'backward');
129 keyHandler.backspace.canDelete = false;
- scheduleSave();
130 return;
131 }
132 if (taskInput.textContent === '' && keyHandler.backspace.canDelete && state.multiSelectedIds.length <= 1) {
144 }
145 } else if (taskInput.textContent !== '') {
146 keyHandler.backspace.canDelete = false;
147 + if (deleteWholeGrapheme(taskInput, 'backward')) e.preventDefault();
148 + }
149 + } else if (e.key === 'Delete') {
150 + if (state.multiSelectedIds.length > 1) {
151 + e.preventDefault();
152 + deleteAcrossMultiSelection(task, taskInput, 'forward');
153 + } else if (deleteWholeGrapheme(taskInput, 'forward')) {
154 + e.preventDefault();
155 + } else {
156 + handleKeyDown(e, task);
157 }
158 } else if (e.key === 'Enter' && !e.shiftKey) {
159 if (keyHandler.enter.blocked) {