use contenteditable for task text so ellipsis works in firefox

Hunter
Thursday, June 4th, 2026 at 7:21 PM

resources/main.css+25-19
resources/main.js+10-4
resources/render.js+79-10
resources/task-element.js+24-17
resources/task-model.js+2-2
resources/theme.js+4 

resources/main.css
 88  	--highlight: var(--burl);
 89  	--accent: var(--flame);
 90  	--icon: '⚔️';
    -	& input[type="text"] {
 91 +	.task-text {
 92  		font-family: BasteleurMoonlight, Arial, sans-serif;
 93  	}
 94  }

 99  	--highlight: var(--ash);
100  	--accent: var(--cinder);
101  	--icon: '🪵';
    -	& input[type="text"] {
102 +	.task-text {
103  		font-family: BasteleurMoonlight, Arial, sans-serif;
104  	}
105  	.parent-task {

123  	.custom-checkbox:checked + .checkbox-label::before {
124  		display: none;
125  	}
    -	& input[type="text"] {
126 +	.task-text {
127  		text-shadow: 1px 1px 1px var(--cola);
128  	}
    -	input[type="text"]::selection {
129 +	.task-text::selection {
130  		color: var(--text);
131  	}
132  }

137  	--highlight: var(--muscat);
138  	--accent: var(--muscat);
139  	--icon: '🍇';
    -	& input[type="text"] {
140 +	.task-text {
141  		font-family: BasteleurMoonlight;
142  	}
    -	input[type="text"]::selection {
143 +	.task-text::selection {
144  		background-color: var(--background);
145  		color: var(--text);
146  		text-shadow: none;

148  	.active {
149  		border-radius: 30px 0px 0px 30px;
150  	}
    -	.active input[type="text"] {
151 +	.active .task-text {
152  		color: var(--bloom);
153  		text-shadow: 1px 1px 2px black;
154  	}

200  	--highlight: var(--bakelite);
201  	--accent: var(--mothball);
202  	--icon: '👺';
    -	& input[type="text"] {
203 +	.task-text {
204  		font-family: BasteleurMoonlight, Arial, sans-serif;
205  	}
    -	.active input[type="text"] {
206 +	.active .task-text {
207  		color: var(--mothball);
208  		text-shadow: 1px 1px 1px var(--bakelite);
209  	}
210  	.parent-task {
211  		--text: var(--mothball)
212  	}
    -	.parent-task input[type="text"] {
213 +	.parent-task .task-text {
214  		text-shadow: 1px 1px 1px var(--bakelite);
215  	}
216  	.checkbox-label {

238  	.active .custom-checkbox:indeterminate + .checkbox-label::before {
239  		background-color: var(--mothball);
240  	}
    -	input[type="text"]::selection {
241 +	.task-text::selection {
242  		background-color: var(--lipstick);
243  		color: var(--mothball);
244  	}

287  	list-style-type: none;
288  	padding-left: 20px;
289  }
    -input[type="text"] {
    -	border: none;
    -	background: transparent;
290 +.task-text {
291  	font-size: 1.17em;
292  	font-weight: bold;
    -	width: calc(100% - 30px);
    -	margin-left: 5px;
    -	padding: 5px;
293 +	flex: 1;
294 +	min-width: 0;
295 +	margin-left: 10px;
296 +	padding: 5px 5px 5px 0;
297  	color: var(--text);
298  	overflow: hidden;
299  	text-overflow: ellipsis;
300  	white-space: nowrap;
301 +	cursor: text;
302 +	line-height: normal;
303 +	min-height: 1.17em;
304  }
    -input[type="text"]:focus {
305 +.task-text:focus {
306  	outline: none;
307  }
    -input[type="text"]::selection {
308 +.active .task-text {
309 +	text-overflow: clip;
310 +}
311 +.task-text::selection {
312  	background-color: var(--accent);
313  	color: var(--background);
314  }

317  	align-items: center;
318  	margin: 5px 0;
319  	padding-left: 11px;
320 +	padding-right: 11px;
321  	padding-top: 4px;
322  	padding-bottom: 4px;
323  }
resources/main.js
 4  	if (!e.target.closest('.task-container')) {
 5  		var activeTask = document.querySelector('.task-container.active');
 6  		if (activeTask) {
   -			var taskInput = activeTask.querySelector('input[type="text"]');
 7 +			var taskInput = activeTask.querySelector('.task-text');
 8  			taskInput.focus();
 9  		} else {
10  			selectFirstSubtask();

65  	document.addEventListener('keydown', handleSave);
66  	document.addEventListener('keydown', handleOpen);
67  
   -	// Reset cursor on non-focused inputs
68 +	// Reset cursor on non-focused task-text elements
69  	state.appContainer.addEventListener('focusin', function(e) {
   -		if (e.target.tagName === 'INPUT' && e.target.type === 'text') {
   -			document.querySelectorAll('input[type="text"]').forEach(input => {
70 +		if (e.target.classList && e.target.classList.contains('task-text')) {
71 +			document.querySelectorAll('.task-text').forEach(input => {
72  				if (input !== e.target) {
73  					placeCursorAtBeginning(input);
74  				}

80  	setInitialTheme();
81  	renderCurrentView();
82  	selectFirstSubtask();
83 +
84 +	// re-pin the active caret's scroll once layout settles and once fonts load
85 +	requestAnimationFrame(rescrollActiveCaret);
86 +	if (document.fonts && document.fonts.ready) {
87 +		document.fonts.ready.then(rescrollActiveCaret);
88 +	}
89  });
resources/render.js
  1  // Render: view rendering, breadcrumbs, UI updates, and shared DOM helpers
  2  
  3 +function getCaretOffset(el) {
  4 +	var sel = window.getSelection();
  5 +	if (!sel || sel.rangeCount === 0 || !el.contains(sel.anchorNode)) return null;
  6 +	var range = sel.getRangeAt(0);
  7 +	var pre = range.cloneRange();
  8 +	pre.selectNodeContents(el);
  9 +	pre.setEnd(range.endContainer, range.endOffset);
 10 +	return pre.toString().length;
 11 +}
 12 +
 13 +function setCaretOffset(el, offset) {
 14 +	el.focus();
 15 +	var node = el.firstChild;
 16 +	var range = document.createRange();
 17 +	var sel = window.getSelection();
 18 +	var pos = 0;
 19 +	if (node && node.nodeType === Node.TEXT_NODE) {
 20 +		pos = Math.max(0, Math.min(offset, node.textContent.length));
 21 +		range.setStart(node, pos);
 22 +	} else {
 23 +		range.setStart(el, 0);
 24 +	}
 25 +	range.collapse(true);
 26 +	sel.removeAllRanges();
 27 +	sel.addRange(range);
 28 +	// browsers don't auto-scroll a programmatic caret, so do it manually
 29 +	scrollCaretIntoView(el, range, pos);
 30 +}
 31 +
 32 +function scrollCaretIntoView(el, range, pos) {
 33 +	if (pos >= el.textContent.length) {
 34 +		el.scrollLeft = el.scrollWidth;
 35 +		return;
 36 +	}
 37 +	if (pos <= 0) {
 38 +		el.scrollLeft = 0;
 39 +		return;
 40 +	}
 41 +	var caretRect = range.getBoundingClientRect();
 42 +	var elRect = el.getBoundingClientRect();
 43 +	var pad = parseFloat(getComputedStyle(el).paddingRight) || 0;
 44 +	if (caretRect.right > elRect.right - pad) {
 45 +		el.scrollLeft += caretRect.right - (elRect.right - pad);
 46 +	} else if (caretRect.left < elRect.left) {
 47 +		el.scrollLeft -= elRect.left - caretRect.left;
 48 +	}
 49 +}
 50 +
 51 +function selectAllText(el) {
 52 +	var range = document.createRange();
 53 +	range.selectNodeContents(el);
 54 +	var sel = window.getSelection();
 55 +	sel.removeAllRanges();
 56 +	sel.addRange(range);
 57 +}
 58 +
 59 +function rescrollActiveCaret() {
 60 +	var el = document.querySelector('.task-container.active .task-text');
 61 +	if (el) setCaretOffset(el, getCaretOffset(el) || 0);
 62 +}
 63 +
 64 +function isSelectionCollapsed() {
 65 +	var sel = window.getSelection();
 66 +	return !sel || sel.isCollapsed;
 67 +}
 68 +
 69  function generateBreadcrumbs(rootTask, currentPath, selectedTaskId) {
 70  	var breadcrumbs = '';
 71  	var currentTask = rootTask;

118  }
119  
120  function selectAndFocusTask(task, cursorPos) {
    -	var taskInput = document.querySelector(`.task-container[data-id="${task.id}"] input[type="text"]`);
121 +	var taskInput = document.querySelector(`.task-container[data-id="${task.id}"] .task-text`);
122  	if (taskInput) {
123  		taskInput.focus();
    -		var pos = cursorPos != null ? cursorPos : taskInput.value.length;
    -		taskInput.setSelectionRange(pos, pos);
124 +		var pos = cursorPos != null ? cursorPos : taskInput.textContent.length;
125 +		setCaretOffset(taskInput, pos);
126  		setActiveTask(taskInput, task);
127  	}
128  }
129  
130  function placeCursorAtBeginning(input) {
    -	input.setSelectionRange(0, 0);
131 +	input.scrollLeft = 0;
132  }
133  
134  function setActiveTask(input, task) {

187  
188  function handleCopyAndCut(e) {
189  	if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'x')) {
    -		var activeTaskInput = document.querySelector('.task-container.active input[type="text"]');
190 +		var activeTaskInput = document.querySelector('.task-container.active .task-text');
191  		if (activeTaskInput) {
192  			e.preventDefault();
193  
    -			if (activeTaskInput.selectionStart === activeTaskInput.selectionEnd) {
    -				activeTaskInput.select();
194 +			// With no selection, act on the whole task text
195 +			var collapsed = isSelectionCollapsed();
196 +			if (collapsed) {
197 +				selectAllText(activeTaskInput);
198  			}
199  
200  			if (e.key === 'c') {

206  				var taskId = taskContainer.dataset.id;
207  				var task = state.currentTask.id === taskId ? state.currentTask : state.currentTask.subtasks.find(t => t.id === taskId);
208  				if (task) {
    -					task.text = activeTaskInput.value;
209 +					task.text = activeTaskInput.textContent;
210  					scheduleSave();
211  				}
212  			}
213  
    -			if (e.key === 'c' && activeTaskInput.selectionStart === 0 && activeTaskInput.selectionEnd === activeTaskInput.value.length) {
    -				activeTaskInput.setSelectionRange(activeTaskInput.value.length, activeTaskInput.value.length);
214 +			// After a copy that auto-selected everything, collapse the caret to the end
215 +			if (e.key === 'c' && collapsed) {
216 +				setCaretOffset(activeTaskInput, activeTaskInput.textContent.length);
217  			}
218  		}
219  	}
resources/task-element.js
 20  	checkboxLabel.className = 'checkbox-label';
 21  	checkboxLabel.setAttribute('for', `checkbox-${task.id}`);
 22  
    -	var taskInput = document.createElement('input');
    -	taskInput.type = 'text';
    -	taskInput.value = task.text;
    -	taskInput.setAttribute('autocomplete', 'off');
 23 +	var taskInput = document.createElement('div');
 24 +	taskInput.className = 'task-text';
 25 +	taskInput.contentEditable = 'plaintext-only';
 26 +	taskInput.textContent = task.text;
 27  	taskInput.setAttribute('spellcheck', 'false');
 28  	taskInput.setAttribute('autocorrect', 'off');
 29  	taskInput.setAttribute('autocapitalize', 'off');

 76  				for (var t of selected) {
 77  					if (t.text.length > 0) {
 78  						t.text = t.text.slice(0, -1);
    -						var inp = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`);
    -						if (inp) inp.value = t.text;
 79 +						var inp = document.querySelector(`.task-container[data-id="${t.id}"] .task-text`);
 80 +						if (inp) inp.textContent = t.text;
 81  					}
 82  				}
 83  				keyHandler.backspace.canDelete = false;
 84  				scheduleSave();
 85  				return;
 86  			}
    -			if (taskInput.value === '' && keyHandler.backspace.canDelete && state.multiSelectedIds.length <= 1) {
 87 +			if (taskInput.textContent === '' && keyHandler.backspace.canDelete && state.multiSelectedIds.length <= 1) {
 88  				e.preventDefault();
 89  				if (task !== state.taskPath[0]) {
 90  					keyHandler.backspace.blocked = true;

 97  				} else {
 98  					applyShakeAnimation(task.id);
 99  				}
    -			} else if (taskInput.value !== '') {
100 +			} else if (taskInput.textContent !== '') {
101  				keyHandler.backspace.canDelete = false;
102  			}
103  		} else if (e.key === 'Enter' && !e.shiftKey) {

204  	taskInput.addEventListener('keydown', handleCopyAndCut);
205  	taskInput.addEventListener('input', (e) => {
206  		var oldText = task.text;
    -		task.text = taskInput.value;
207 +		// Keep text single-line: strip any newlines a paste/IME may introduce
208 +		if (taskInput.textContent.indexOf('\n') !== -1) {
209 +			var caret = getCaretOffset(taskInput);
210 +			taskInput.textContent = taskInput.textContent.replace(/\n/g, '');
211 +			if (caret != null) setCaretOffset(taskInput, caret);
212 +		}
213 +		task.text = taskInput.textContent;
214  		// Propagate edits to all other multi-selected tasks
215  		if (state.multiSelectedIds.length > 1 && state.multiSelectedIds.includes(task.id)) {
216  			var otherSelected = getMultiSelectedTasks().filter(t => t.id !== task.id);

218  			if (e.inputType === 'insertText' && e.data) {
219  				for (var t of otherSelected) {
220  					t.text = t.text + e.data;
    -					var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`);
    -					if (otherInput) otherInput.value = t.text;
221 +					var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] .task-text`);
222 +					if (otherInput) otherInput.textContent = t.text;
223  				}
224  			} else if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteContentForward') {
225  				for (var t of otherSelected) {
226  					if (t.text.length > 0) {
227  						t.text = t.text.slice(0, -1);
228  					}
    -					var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`);
    -					if (otherInput) otherInput.value = t.text;
229 +					var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] .task-text`);
230 +					if (otherInput) otherInput.textContent = t.text;
231  				}
232  			} else if (e.inputType === 'insertFromPaste' || e.inputType === 'insertFromDrop') {
    -				var addedLen = taskInput.value.length - oldText.length;
233 +				var addedLen = taskInput.textContent.length - oldText.length;
234  				if (addedLen > 0) {
    -					var pastedText = taskInput.value.slice(taskInput.selectionStart - addedLen, taskInput.selectionStart);
235 +					var caretPos = getCaretOffset(taskInput);
236 +					var pastedText = taskInput.textContent.slice(caretPos - addedLen, caretPos);
237  					for (var t of otherSelected) {
238  						t.text = t.text + pastedText;
    -						var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`);
    -						if (otherInput) otherInput.value = t.text;
239 +						var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] .task-text`);
240 +						if (otherInput) otherInput.textContent = t.text;
241  					}
242  				}
243  			}
resources/task-model.js
 7  
 8  function toggleTaskState(task) {
 9  	// Save cursor position before re-rendering
   -	var taskInput = document.querySelector(`.task-container[data-id="${task.id}"] input[type="text"]`);
   -	var cursorPos = taskInput ? taskInput.selectionStart : null;
10 +	var taskInput = document.querySelector(`.task-container[data-id="${task.id}"] .task-text`);
11 +	var cursorPos = taskInput ? getCaretOffset(taskInput) : null;
12  
13  	if (task.state === 1) {
14  		if (task.subtasks.length === 0 || !task.subtasks.every(t => t.state === 1)) {
resources/theme.js
24  	var backgroundColor = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
25  	document.getElementById('themeColor').setAttribute('content', backgroundColor);
26  	updateFavicon();
27 +	rescrollActiveCaret();
28 +	if (document.fonts && document.fonts.ready) {
29 +		document.fonts.ready.then(rescrollActiveCaret);
30 +	}
31  }
32  
33  function setInitialTheme() {