commit f47b9d9e0ca5c076ba1c4d43b2cd329a545494ab
parent 75eff02692c574d9afa0f140620248cdabcb4a43
Author: Hunter
Date:   Thu,  4 Jun 2026 19:21:25 -0400

use contenteditable for task text so ellipsis works in firefox

Diffstat:
Mresources/main.css | 44+++++++++++++++++++++++++-------------------
Mresources/main.js | 14++++++++++----
Mresources/render.js | 89++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mresources/task-element.js | 41++++++++++++++++++++++++-----------------
Mresources/task-model.js | 4++--
Mresources/theme.js | 4++++
6 files changed, 144 insertions(+), 52 deletions(-)

diff --git a/resources/main.css b/resources/main.css @@ -88,7 +88,7 @@ --highlight: var(--burl); --accent: var(--flame); --icon: '⚔️'; - & input[type="text"] { + .task-text { font-family: BasteleurMoonlight, Arial, sans-serif; } } @@ -99,7 +99,7 @@ --highlight: var(--ash); --accent: var(--cinder); --icon: '🪵'; - & input[type="text"] { + .task-text { font-family: BasteleurMoonlight, Arial, sans-serif; } .parent-task { @@ -123,10 +123,10 @@ .custom-checkbox:checked + .checkbox-label::before { display: none; } - & input[type="text"] { + .task-text { text-shadow: 1px 1px 1px var(--cola); } - input[type="text"]::selection { + .task-text::selection { color: var(--text); } } @@ -137,10 +137,10 @@ --highlight: var(--muscat); --accent: var(--muscat); --icon: '🍇'; - & input[type="text"] { + .task-text { font-family: BasteleurMoonlight; } - input[type="text"]::selection { + .task-text::selection { background-color: var(--background); color: var(--text); text-shadow: none; @@ -148,7 +148,7 @@ .active { border-radius: 30px 0px 0px 30px; } - .active input[type="text"] { + .active .task-text { color: var(--bloom); text-shadow: 1px 1px 2px black; } @@ -200,17 +200,17 @@ --highlight: var(--bakelite); --accent: var(--mothball); --icon: '👺'; - & input[type="text"] { + .task-text { font-family: BasteleurMoonlight, Arial, sans-serif; } - .active input[type="text"] { + .active .task-text { color: var(--mothball); text-shadow: 1px 1px 1px var(--bakelite); } .parent-task { --text: var(--mothball) } - .parent-task input[type="text"] { + .parent-task .task-text { text-shadow: 1px 1px 1px var(--bakelite); } .checkbox-label { @@ -238,7 +238,7 @@ .active .custom-checkbox:indeterminate + .checkbox-label::before { background-color: var(--mothball); } - input[type="text"]::selection { + .task-text::selection { background-color: var(--lipstick); color: var(--mothball); } @@ -287,23 +287,28 @@ ul { list-style-type: none; padding-left: 20px; } -input[type="text"] { - border: none; - background: transparent; +.task-text { font-size: 1.17em; font-weight: bold; - width: calc(100% - 30px); - margin-left: 5px; - padding: 5px; + flex: 1; + min-width: 0; + margin-left: 10px; + padding: 5px 5px 5px 0; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + cursor: text; + line-height: normal; + min-height: 1.17em; } -input[type="text"]:focus { +.task-text:focus { outline: none; } -input[type="text"]::selection { +.active .task-text { + text-overflow: clip; +} +.task-text::selection { background-color: var(--accent); color: var(--background); } @@ -312,6 +317,7 @@ input[type="text"]::selection { align-items: center; margin: 5px 0; padding-left: 11px; + padding-right: 11px; padding-top: 4px; padding-bottom: 4px; } diff --git a/resources/main.js b/resources/main.js @@ -4,7 +4,7 @@ document.addEventListener('click', function(e) { if (!e.target.closest('.task-container')) { var activeTask = document.querySelector('.task-container.active'); if (activeTask) { - var taskInput = activeTask.querySelector('input[type="text"]'); + var taskInput = activeTask.querySelector('.task-text'); taskInput.focus(); } else { selectFirstSubtask(); @@ -65,10 +65,10 @@ document.addEventListener('DOMContentLoaded', function() { document.addEventListener('keydown', handleSave); document.addEventListener('keydown', handleOpen); - // Reset cursor on non-focused inputs + // Reset cursor on non-focused task-text elements state.appContainer.addEventListener('focusin', function(e) { - if (e.target.tagName === 'INPUT' && e.target.type === 'text') { - document.querySelectorAll('input[type="text"]').forEach(input => { + if (e.target.classList && e.target.classList.contains('task-text')) { + document.querySelectorAll('.task-text').forEach(input => { if (input !== e.target) { placeCursorAtBeginning(input); } @@ -80,4 +80,10 @@ document.addEventListener('DOMContentLoaded', function() { setInitialTheme(); renderCurrentView(); selectFirstSubtask(); + + // re-pin the active caret's scroll once layout settles and once fonts load + requestAnimationFrame(rescrollActiveCaret); + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(rescrollActiveCaret); + } }); diff --git a/resources/render.js b/resources/render.js @@ -1,5 +1,71 @@ // Render: view rendering, breadcrumbs, UI updates, and shared DOM helpers +function getCaretOffset(el) { + var sel = window.getSelection(); + if (!sel || sel.rangeCount === 0 || !el.contains(sel.anchorNode)) return null; + var range = sel.getRangeAt(0); + var pre = range.cloneRange(); + pre.selectNodeContents(el); + pre.setEnd(range.endContainer, range.endOffset); + return pre.toString().length; +} + +function setCaretOffset(el, offset) { + el.focus(); + var node = el.firstChild; + var range = document.createRange(); + var sel = window.getSelection(); + var pos = 0; + if (node && node.nodeType === Node.TEXT_NODE) { + pos = Math.max(0, Math.min(offset, node.textContent.length)); + range.setStart(node, pos); + } else { + range.setStart(el, 0); + } + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + // browsers don't auto-scroll a programmatic caret, so do it manually + scrollCaretIntoView(el, range, pos); +} + +function scrollCaretIntoView(el, range, pos) { + if (pos >= el.textContent.length) { + el.scrollLeft = el.scrollWidth; + return; + } + if (pos <= 0) { + el.scrollLeft = 0; + return; + } + var caretRect = range.getBoundingClientRect(); + var elRect = el.getBoundingClientRect(); + var pad = parseFloat(getComputedStyle(el).paddingRight) || 0; + if (caretRect.right > elRect.right - pad) { + el.scrollLeft += caretRect.right - (elRect.right - pad); + } else if (caretRect.left < elRect.left) { + el.scrollLeft -= elRect.left - caretRect.left; + } +} + +function selectAllText(el) { + var range = document.createRange(); + range.selectNodeContents(el); + var sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); +} + +function rescrollActiveCaret() { + var el = document.querySelector('.task-container.active .task-text'); + if (el) setCaretOffset(el, getCaretOffset(el) || 0); +} + +function isSelectionCollapsed() { + var sel = window.getSelection(); + return !sel || sel.isCollapsed; +} + function generateBreadcrumbs(rootTask, currentPath, selectedTaskId) { var breadcrumbs = ''; var currentTask = rootTask; @@ -52,17 +118,17 @@ function applyShakeAnimation(taskId, direction = 'horizontal') { } function selectAndFocusTask(task, cursorPos) { - var taskInput = document.querySelector(`.task-container[data-id="${task.id}"] input[type="text"]`); + var taskInput = document.querySelector(`.task-container[data-id="${task.id}"] .task-text`); if (taskInput) { taskInput.focus(); - var pos = cursorPos != null ? cursorPos : taskInput.value.length; - taskInput.setSelectionRange(pos, pos); + var pos = cursorPos != null ? cursorPos : taskInput.textContent.length; + setCaretOffset(taskInput, pos); setActiveTask(taskInput, task); } } function placeCursorAtBeginning(input) { - input.setSelectionRange(0, 0); + input.scrollLeft = 0; } function setActiveTask(input, task) { @@ -121,12 +187,14 @@ function selectFirstSubtask() { function handleCopyAndCut(e) { if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'x')) { - var activeTaskInput = document.querySelector('.task-container.active input[type="text"]'); + var activeTaskInput = document.querySelector('.task-container.active .task-text'); if (activeTaskInput) { e.preventDefault(); - if (activeTaskInput.selectionStart === activeTaskInput.selectionEnd) { - activeTaskInput.select(); + // With no selection, act on the whole task text + var collapsed = isSelectionCollapsed(); + if (collapsed) { + selectAllText(activeTaskInput); } if (e.key === 'c') { @@ -138,13 +206,14 @@ function handleCopyAndCut(e) { var taskId = taskContainer.dataset.id; var task = state.currentTask.id === taskId ? state.currentTask : state.currentTask.subtasks.find(t => t.id === taskId); if (task) { - task.text = activeTaskInput.value; + task.text = activeTaskInput.textContent; scheduleSave(); } } - if (e.key === 'c' && activeTaskInput.selectionStart === 0 && activeTaskInput.selectionEnd === activeTaskInput.value.length) { - activeTaskInput.setSelectionRange(activeTaskInput.value.length, activeTaskInput.value.length); + // After a copy that auto-selected everything, collapse the caret to the end + if (e.key === 'c' && collapsed) { + setCaretOffset(activeTaskInput, activeTaskInput.textContent.length); } } } diff --git a/resources/task-element.js b/resources/task-element.js @@ -20,10 +20,10 @@ function createTaskElement(task, isParentTask = false) { checkboxLabel.className = 'checkbox-label'; checkboxLabel.setAttribute('for', `checkbox-${task.id}`); - var taskInput = document.createElement('input'); - taskInput.type = 'text'; - taskInput.value = task.text; - taskInput.setAttribute('autocomplete', 'off'); + var taskInput = document.createElement('div'); + taskInput.className = 'task-text'; + taskInput.contentEditable = 'plaintext-only'; + taskInput.textContent = task.text; taskInput.setAttribute('spellcheck', 'false'); taskInput.setAttribute('autocorrect', 'off'); taskInput.setAttribute('autocapitalize', 'off'); @@ -76,15 +76,15 @@ function createTaskElement(task, isParentTask = false) { 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}"] input[type="text"]`); - if (inp) inp.value = t.text; + var inp = document.querySelector(`.task-container[data-id="${t.id}"] .task-text`); + if (inp) inp.textContent = t.text; } } keyHandler.backspace.canDelete = false; scheduleSave(); return; } - if (taskInput.value === '' && keyHandler.backspace.canDelete && state.multiSelectedIds.length <= 1) { + if (taskInput.textContent === '' && keyHandler.backspace.canDelete && state.multiSelectedIds.length <= 1) { e.preventDefault(); if (task !== state.taskPath[0]) { keyHandler.backspace.blocked = true; @@ -97,7 +97,7 @@ function createTaskElement(task, isParentTask = false) { } else { applyShakeAnimation(task.id); } - } else if (taskInput.value !== '') { + } else if (taskInput.textContent !== '') { keyHandler.backspace.canDelete = false; } } else if (e.key === 'Enter' && !e.shiftKey) { @@ -204,7 +204,13 @@ function createTaskElement(task, isParentTask = false) { taskInput.addEventListener('keydown', handleCopyAndCut); taskInput.addEventListener('input', (e) => { var oldText = task.text; - task.text = taskInput.value; + // Keep text single-line: strip any newlines a paste/IME may introduce + if (taskInput.textContent.indexOf('\n') !== -1) { + var caret = getCaretOffset(taskInput); + taskInput.textContent = taskInput.textContent.replace(/\n/g, ''); + 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)) { var otherSelected = getMultiSelectedTasks().filter(t => t.id !== task.id); @@ -212,25 +218,26 @@ function createTaskElement(task, isParentTask = false) { if (e.inputType === 'insertText' && e.data) { for (var t of otherSelected) { 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; + var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] .task-text`); + if (otherInput) otherInput.textContent = t.text; } } else if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteContentForward') { 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}"] input[type="text"]`); - if (otherInput) otherInput.value = t.text; + var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] .task-text`); + if (otherInput) otherInput.textContent = t.text; } } else if (e.inputType === 'insertFromPaste' || e.inputType === 'insertFromDrop') { - var addedLen = taskInput.value.length - oldText.length; + var addedLen = taskInput.textContent.length - oldText.length; if (addedLen > 0) { - var pastedText = taskInput.value.slice(taskInput.selectionStart - addedLen, taskInput.selectionStart); + 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}"] input[type="text"]`); - if (otherInput) otherInput.value = t.text; + var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] .task-text`); + if (otherInput) otherInput.textContent = t.text; } } } diff --git a/resources/task-model.js b/resources/task-model.js @@ -7,8 +7,8 @@ function updateCheckboxState(checkbox, taskState) { function toggleTaskState(task) { // 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; + var taskInput = document.querySelector(`.task-container[data-id="${task.id}"] .task-text`); + var cursorPos = taskInput ? getCaretOffset(taskInput) : null; if (task.state === 1) { if (task.subtasks.length === 0 || !task.subtasks.every(t => t.state === 1)) { diff --git a/resources/theme.js b/resources/theme.js @@ -24,6 +24,10 @@ function setTheme(theme) { var backgroundColor = getComputedStyle(document.documentElement).getPropertyValue('--background').trim(); document.getElementById('themeColor').setAttribute('content', backgroundColor); updateFavicon(); + rescrollActiveCaret(); + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(rescrollActiveCaret); + } } function setInitialTheme() {