improve grapheme-aware deletion

Hunter
Thursday, June 11th, 2026 at 5:26 PM

resources/multi-select.js+15 
resources/task-element.js+31-10

resources/multi-select.js
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();
resources/task-element.js
 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) {