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:
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() {