break out css and js Hunter Sunday, March 8th, 2026 at 9:49 AM
10 <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🪆</text></svg>"> 11 12 <title>todo</title> - <style> - /* custom colors */ - :root { - --coconut: #ffffff; - --licorice: #000000; - --wasabi: #ccff00; - --eggplant: #7700ff; - - --murk: #8e9918; - --tadpole: #27350f; - --lilypad: #6f8823; - --reed: #4a5d23; - - --pollen: #f4a127; - --loam: #5a352b; - --chlorophyll: #5aa83b; - --terracotta: #b15c2e; - - --wheat: #d2c3a3; - --earth: #4a3c31; - --pumpkin: #cb7c52; - --tobacco: #7d6c55; - - --moss: #20302f; - --goat-milk: #d8d3c9; - --burl: #231f20; - --flame: #c63728; - - --soot: #181a1b; - --smoke: #e8e6e3; - --ash: #252829; - --cinder: #ff5532; - - --blue-raspberry: #0458b3; - --cola: #1e1515; - --bubblegum: #e11761; - --taffy: #eaeaea; - - --muscat: #663399; - --cask: #722f37; - --bloom: #f8f6ff; - } - - /* custom fonts */ - @font-face { - font-family: BasteleurMoonlight; - src: url('resources/fonts/Basteleur-Moonlight.ttf') format('truetype'); - } - - /* themes */ - :root[data-theme="gak"] { - --background: var(--coconut); - --text: var(--licorice); - --highlight: var(--wasabi); - --accent: var(--eggplant); - --icon: ''; - } - - :root[data-theme="swamp"] { - --background: var(--murk); - --text: var(--tadpole); - --highlight: var(--lilypad); - --accent: var(--reed); - --icon: '🦠'; - } - - :root[data-theme="sunflower"] { - --background: var(--pollen); - --text: var(--loam); - --highlight: var(--chlorophyll); - --accent: var(--terracotta); - --icon: '🌻'; - } - - :root[data-theme="harvest"] { - --background: var(--wheat); - --text: var(--earth); - --highlight: var(--pumpkin); - --accent: var(--tobacco); - --icon: '🌾'; - } - - :root[data-theme="medieval"] { - --background: var(--moss); - --text: var(--goat-milk); - --highlight: var(--burl); - --accent: var(--flame); - --icon: '⚔️'; - & input[type="text"] { - font-family: BasteleurMoonlight, Arial, sans-serif; - } - } - - :root[data-theme="woodstove"] { - --background: var(--soot); - --text: var(--smoke); - --highlight: var(--ash); - --accent: var(--cinder); - --icon: '🪵'; - & input[type="text"] { - font-family: BasteleurMoonlight, Arial, sans-serif; - } - .parent-task { - --text: var(--cinder) - } - } - - :root[data-theme="sugar"] { - --background: var(--cola); - --text: var(--taffy); - --highlight: var(--blue-raspberry); - --accent: var(--bubblegum); - --icon: '🍬'; - - .checkbox-label { - border-radius: 10px; - } - .active { - border-radius: 30px 0px 0px 30px; - } - .custom-checkbox:checked + .checkbox-label::before { - display: none; - } - & input[type="text"] { - text-shadow: 1px 1px 1px var(--cola); - } - input[type="text"]::selection { - color: var(--text); - } - } - - :root[data-theme="vineyard"] { - --background: var(--bloom); - --text: var(--cask); - --highlight: var(--muscat); - --accent: var(--muscat); - --icon: '🍇'; - & input[type="text"] { - font-family: BasteleurMoonlight; - } - input[type="text"]::selection { - background-color: var(--background); - color: var(--text); - text-shadow: none; - } - .active { - border-radius: 30px 0px 0px 30px; - } - .active input[type="text"] { - color: var(--bloom); - text-shadow: 1px 1px 2px black; - } - .checkbox-label { - border-radius: 50%; - border: none; - background: radial-gradient(circle at 30% 30%, #d4a5d8, #b19cd9, #8a4baf); - box-shadow: - inset 1px 1px 3px rgba(0,0,0,0.4), - inset -1px -1px 2px rgba(255,255,255,0.3), - 0 1px 2px rgba(0,0,0,0.3); - } - .custom-checkbox:checked + .checkbox-label { - background: radial-gradient(circle at 30% 30%, #b19cd9, #8a4baf, #663399); - box-shadow: - inset -2px -2px 4px rgba(0,0,0,0.3), - inset 1px 1px 2px rgba(255,255,255,0.5), - 1px 1px 2px rgba(0,0,0,0.6); - } - .active .custom-checkbox:checked + .checkbox-label { - box-shadow: - inset -2px -2px 4px rgba(0,0,0,0.3), - inset 1px 1px 2px rgba(255,255,255,0.5), - 3px 3px 6px rgba(0,0,0,0.6); - } - .custom-checkbox:checked + .checkbox-label::before { - display: none; - } - .custom-checkbox:indeterminate + .checkbox-label::before { - content: ''; - position: absolute; - left: 50%; - top: 50%; - width: 50%; - height: 50%; - border-radius: 50%; - background: radial-gradient(circle at 30% 30%, #b19cd9, #8a4baf, #663399); - box-shadow: - inset -1px -1px 2px rgba(0,0,0,0.3), - inset 0.5px 0.5px 1px rgba(255,255,255,0.5); - transform: translate(-50%, -50%); - } - scrollbar-color: color-mix(in srgb, var(--muscat) 75%, transparent) color-mix(in srgb, var(--bloom) 75%, transparent); - } - - /* interface styling */ - @keyframes shake { - 0% { transform: translateX(0); } - 20% { transform: translateX(3px); } - 40% { transform: translateX(-3px); } - 60% { transform: translateX(3px); } - 80% { transform: translateX(-3px); } - 100% { transform: translateX(0); } - } - .shake { - animation: shake 0.25s ease-out; - } - @keyframes shake-vertical { - 0% { transform: translateY(0); } - 20% { transform: translateY(3px); } - 40% { transform: translateY(-3px); } - 60% { transform: translateY(3px); } - 80% { transform: translateY(-3px); } - 100% { transform: translateY(0); } - } - .shake-vertical { - animation: shake-vertical 0.25s ease-out; - } - html { - scroll-behavior: smooth; - overflow-y: scroll; - overflow-x: hidden; - scrollbar-width: auto; - scrollbar-color: color-mix(in srgb, var(--text) 75%, transparent) color-mix(in srgb, var(--background) 75%, transparent); - } - body { - font-family: Arial, sans-serif; - max-width: 800px; - margin: 0 auto; - padding: 0px 20px 20px 20px; - background-color: var(--background); - color: var(--text); - } - ul { - list-style-type: none; - padding-left: 20px; - } - input[type="text"] { - border: none; - background: transparent; - font-size: 1.17em; - font-weight: bold; - width: calc(100% - 30px); - margin-left: 5px; - padding: 5px; - color: var(--text); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - input[type="text"]:focus { - outline: none; - } - input[type="text"]::selection { - background-color: var(--accent); - color: var(--background); - } - .task-container { - display: flex; - align-items: center; - margin: 5px 0; - padding-left: 11px; - padding-top: 4px; - padding-bottom: 4px; - } - .active { - background-color: var(--highlight); - } - .sticky-header { - position: sticky; - top: 0; - background-color: var(--background); - z-index: 1; - padding-top: 20px; - } - .parent-task { - font-size: 1.5em; - font-weight: bold; - } - #breadcrumbs { - font-size: 24px; - margin-bottom: 10px; - color: var(--text); - user-select: none; - } - .custom-checkbox { - display: none; - } - .checkbox-label { - display: inline-block; - width: 20px; - height: 20px; - background-color: var(--background); - border: 2px solid var(--accent); - position: relative; - cursor: pointer; - box-sizing: border-box; - border-radius: 4px; - } - .custom-checkbox:checked + .checkbox-label { - background-color: var(--accent); - } - .custom-checkbox:checked + .checkbox-label::before { - content: ''; - position: absolute; - left: 6px; - top: 2px; - width: 5px; - height: 10px; - border: solid var(--background); - border-width: 0 2px 2px 0; - transform: rotate(45deg); - box-sizing: border-box; - } - .custom-checkbox:indeterminate + .checkbox-label::before { - content: ''; - position: absolute; - left: 25%; - right: 25%; - top: 50%; - height: 2px; - background-color: var(--accent); - transform: translateY(-50%); - } - - /* print styles */ - @media print { - body * { - visibility: hidden; - } - #print-content, #print-content * { - visibility: visible; - } - #print-content { - position: absolute; - left: 0; - top: 0; - font-family: monospace; - font-size: 12pt; - line-height: 1.4; - white-space: pre-wrap; - color: black; - background: white; - } - #app-container { - display: none; - } - } - </style> 13 + <link rel="stylesheet" href="resources/main.css"> 14 + <script src="resources/state.js"></script> 15 + <script src="resources/storage.js"></script> 16 + <script src="resources/task-model.js"></script> 17 + <script src="resources/multi-select.js"></script> 18 + <script src="resources/navigation.js"></script> 19 + <script src="resources/reorganize.js"></script> 20 + <script src="resources/keyboard.js"></script> 21 + <script src="resources/render.js"></script> 22 + <script src="resources/task-element.js"></script> 23 + <script src="resources/theme.js"></script> 24 + <script src="resources/main.js"></script> 25 <script> - document.addEventListener('click', function(e) { - if (!e.target.closest('.task-container')) { - const activeTask = document.querySelector('.task-container.active'); - if (activeTask) { - const taskInput = activeTask.querySelector('input[type="text"]'); - taskInput.focus(); - } else { - selectFirstSubtask(); - } - } - }); - document.addEventListener('DOMContentLoaded', function() { - // Variable to track if we're in a wheel event - let isInWheelEvent = false; - const appContainer = document.getElementById('app-container'); - let rootTask = loadTasksFromLocalStorage(); - let currentTask = rootTask; - let taskPath = [currentTask]; - let lastSubtaskDownArrowReleased = false; - let lastSubtaskShiftDownReleased = false; - let saveTimer = null; - let currentThemeIndex = 0; - let isF2Pressed = false; - let themes = []; - - // Multi-select state - let multiSelectAnchorId = null // task where multi-select started - let multiSelectedIds = [] // ordered array of selected task ids - - function clearMultiSelect() { - multiSelectAnchorId = null - multiSelectedIds = [] - // Remove .active from non-focused tasks - const focused = document.activeElement - const focusedContainer = focused ? focused.closest('.task-container') : null - document.querySelectorAll('.task-container.active').forEach(el => { - if (el !== focusedContainer) el.classList.remove('active') - }) - } - - function getMultiSelectedTasks() { - // Return task objects in subtask order - return currentTask.subtasks.filter(t => multiSelectedIds.includes(t.id)) - } - - function applyMultiSelectHighlights() { - for (const id of multiSelectedIds) { - const container = document.querySelector(`.task-container[data-id="${id}"]`) - if (container) container.classList.add('active') - } - } - - function shakeAllSelected(direction = 'horizontal') { - for (const id of multiSelectedIds) { - applyShakeAnimation(id, direction) - } - } - - function handleSave(e) { - if ((e.metaKey || e.ctrlKey) && e.key === 's') { - e.preventDefault(); - saveTaskTreeToFile(); - } - } - - function saveTaskTreeToFile() { - const serializedTasks = serializeTaskTree(taskPath[0]); - const rootTaskName = taskPath[0].text || 'Untitled'; - const date = new Date(); - const fileName = `${rootTaskName} - ${date.toLocaleString('default', { month: 'short' }).toLowerCase()} ${date.getDate()}, '${date.getFullYear().toString().slice(-2)}.txt`; - - const blob = new Blob([serializedTasks], { type: 'text/plain' }); - const url = URL.createObjectURL(blob); - - const a = document.createElement('a'); - a.href = url; - a.download = fileName; - a.click(); - - URL.revokeObjectURL(url); - } - - function handleOpen(e) { - if ((e.metaKey || e.ctrlKey) && e.key === 'o') { - e.preventDefault(); - openTaskTreeFromFile(); - } - } - - function openTaskTreeFromFile() { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.txt'; - input.onchange = function(event) { - const file = event.target.files[0]; - const reader = new FileReader(); - reader.onload = function(e) { - try { - const newRootTask = deserializeTaskTree(e.target.result); - if (confirm('Are you sure you want to overwrite the existing task tree?')) { - rootTask = newRootTask; - currentTask = rootTask; - taskPath = [currentTask]; - renderCurrentView(); - saveTasksToLocalStorage(); - } - } catch (error) { - alert(`Error importing task tree: ${error.message}`); - } - }; - reader.readAsText(file); - }; - input.click(); - } - - function scheduleSave() { - if (saveTimer) { - clearTimeout(saveTimer); - } - saveTimer = setTimeout(saveTasksToLocalStorage, 1000); - } - - function saveTasksToLocalStorage() { - const serializedTasks = serializeTaskTree(taskPath[0]); - localStorage.setItem('taskTree', serializedTasks); - saveTimer = null; - } - - function serializeTaskTree(task, depth = 0) { - const indentation = '\t'.repeat(depth); - let status = task.state === 0 ? '_' : (task.state === 1 ? 'x' : '?'); - let serialized = `${indentation}${status} ${task.text}\n`; - - for (let subtask of task.subtasks) { - serialized += serializeTaskTree(subtask, depth + 1); - } - - return serialized; - } - - function generateId() { - return Date.now().toString(36) + Math.random().toString(36).slice(2) - } - - function loadTasksFromLocalStorage() { - const savedTasks = localStorage.getItem('taskTree'); - if (savedTasks) { - console.log('%cloaded tasks from local storage:', "color: green;"); - console.log(savedTasks); - return deserializeTaskTree(savedTasks); - } else { - return { id: 'root', text: 'todo', state: 0, subtasks: [{ id: generateId(), text: '', state: 0, subtasks: [] }], selectedSubtaskId: null }; - } - } - - function deserializeTaskTree(serialized) { - const lines = serialized.split('\n').filter(line => line.trim() !== ''); - const root = { id: 'root', subtasks: [] }; - const stack = [{ task: root, depth: -1 }]; - - for (let line of lines) { - const depth = (line.match(/^\t*/)[0] || '').length; - const status = line[depth]; - const text = line.slice(depth + 2); - - const newTask = { - id: depth === 0 ? 'root' : generateId(), - text: text, - state: status === '_' ? 0 : (status === 'x' ? 1 : 2), - subtasks: [], - selectedSubtaskId: null - }; - - while (stack.length > 1 && stack[stack.length - 1].depth >= depth) { - stack.pop(); - } - - if (depth === 0) { - Object.assign(root, newTask); - } else { - stack[stack.length - 1].task.subtasks.push(newTask); - } - stack.push({ task: newTask, depth: depth }); - } - - return root; - } - - const keyHandler = { - backspace: { - canDelete: true, - blocked: false - }, - enter: { - canAdd: true, - blocked: false - }, - arrowDown: { - canAdd: true, - blocked: false - }, - shiftArrowDown: { - blocked: false - }, - shiftEnter: { - pressed: false - }, - shiftRight: { - pressed: false - }, - shiftLeft: { - pressed: false - } - }; - - function createTaskElement(task, isParentTask = false) { - const taskContainer = document.createElement('div'); - taskContainer.className = 'task-container'; - taskContainer.dataset.id = task.id; - if (isParentTask) taskContainer.classList.add('parent-task'); - - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.className = 'custom-checkbox'; - checkbox.id = `checkbox-${task.id}`; - updateCheckboxState(checkbox, task.state); - checkbox.addEventListener('click', (e) => { - e.preventDefault(); - toggleTaskState(task); - }); - - const checkboxLabel = document.createElement('label'); - checkboxLabel.className = 'checkbox-label'; - checkboxLabel.setAttribute('for', `checkbox-${task.id}`); - - const taskInput = document.createElement('input'); - taskInput.type = 'text'; - taskInput.value = task.text; - taskInput.setAttribute('autocomplete', 'off'); - - taskInput.addEventListener('mousedown', function(e) { - e.stopPropagation(); - }); - - const keydownHandler = function(e) { - if (e.key === 'Backspace') { - if (keyHandler.backspace.blocked) { - e.preventDefault(); - return; - } - // Multi-select backspace handling - if (multiSelectedIds.length > 1) { - const selected = getMultiSelectedTasks() - const allEmpty = selected.every(t => t.text === '') - if (allEmpty) { - // All empty: require release+repress (canDelete) before bulk deleting - e.preventDefault() - if (keyHandler.backspace.canDelete) { - keyHandler.backspace.blocked = true - const toDelete = selected.filter(t => t !== taskPath[0] && t !== currentTask) - // Don't delete if it would leave the root task with no subtasks - if (toDelete.length > 0 && !(currentTask.id === 'root' && toDelete.length >= currentTask.subtasks.length)) { - // Find the index of the first task to delete (for post-delete selection) - const firstDeleteIdx = Math.min(...toDelete.map(t => currentTask.subtasks.findIndex(s => s.id === t.id)).filter(i => i !== -1)) - for (const t of toDelete) { - const idx = currentTask.subtasks.findIndex(s => s.id === t.id) - if (idx !== -1) currentTask.subtasks.splice(idx, 1) - } - clearMultiSelect() - updateTaskAndAncestors(currentTask) - if (currentTask.subtasks.length === 0 && taskPath.length > 1) { - navigateToParentTask() - } else { - renderCurrentView() - const targetIndex = Math.max(0, firstDeleteIdx - 1) - selectAndFocusTask(currentTask.subtasks[targetIndex]) - } - scheduleSave() - } else { - // Can't delete: shake all selected tasks - for (const t of selected) { - applyShakeAnimation(t.id) - } - } - } - return - } - // Some tasks still have text: remove last char from all that have text - e.preventDefault() - for (const t of selected) { - if (t.text.length > 0) { - t.text = t.text.slice(0, -1) - const inp = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`) - if (inp) inp.value = t.text - } - } - // Mark canDelete false so when all become empty, user must release+repress - keyHandler.backspace.canDelete = false - scheduleSave() - return - } - if (taskInput.value === '' && keyHandler.backspace.canDelete && multiSelectedIds.length <= 1) { - e.preventDefault(); - if (task !== taskPath[0]) { - keyHandler.backspace.blocked = true; - clearMultiSelect() - if (task === currentTask) { - deleteCurrentParentTask(); - } else { - deleteSubtask(task); - } - } else { - // Attempting to delete root task - applyShakeAnimation(task.id); - } - } else if (taskInput.value !== '') { - keyHandler.backspace.canDelete = false; - } - } else if (e.key === 'Enter' && !e.shiftKey) { - if (keyHandler.enter.blocked) { - e.preventDefault(); - return; - } - if (keyHandler.enter.canAdd) { - e.preventDefault(); - keyHandler.enter.blocked = true; - clearMultiSelect() - addNewSubtask(currentTask, task); - } - } else if (e.key === 'ArrowDown' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) { - if (keyHandler.arrowDown.blocked) { - e.preventDefault(); - return; - } - if (isLastSubtask(task) && lastSubtaskDownArrowReleased && task !== currentTask) { - e.preventDefault(); - keyHandler.arrowDown.blocked = true; - addNewSubtask(currentTask, task); - lastSubtaskDownArrowReleased = false; - } else { - handleKeyDown(e, task); - } - } else if (e.key === 'ArrowDown' && e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) { - if (keyHandler.shiftArrowDown.blocked) { - e.preventDefault(); - return; - } - // Multi-select: insert new task above chunk when at bottom - if (multiSelectedIds.length > 1 && !e.repeat) { - const selected = getMultiSelectedTasks() - const lastSelected = selected[selected.length - 1] - if (isLastSubtask(lastSelected) && lastSubtaskShiftDownReleased) { - e.preventDefault() - keyHandler.shiftArrowDown.blocked = true - // Find topmost selected task index and insert above chunk - const topIndex = currentTask.subtasks.findIndex(t => multiSelectedIds.includes(t.id)) - const newSubtask = { id: generateId(), text: '', state: 0, subtasks: [], selectedSubtaskId: null } - currentTask.subtasks.splice(topIndex, 0, newSubtask) - updateTaskAndAncestors(currentTask) - currentTask.selectedSubtaskId = multiSelectAnchorId - renderCurrentView() - applyMultiSelectHighlights() - scheduleSave() - lastSubtaskShiftDownReleased = false - } else { - handleKeyDown(e, task) - } - } else if (isLastSubtask(task) && lastSubtaskShiftDownReleased && task !== currentTask && !e.repeat) { - e.preventDefault(); - keyHandler.shiftArrowDown.blocked = true; - // Insert a new empty task above the current task, keeping current task selected - const parentTask = findParentTask(task); - if (parentTask) { - const index = parentTask.subtasks.findIndex(t => t.id === task.id); - const newSubtask = { id: generateId(), text: '', state: 0, subtasks: [], selectedSubtaskId: null }; - parentTask.subtasks.splice(index, 0, newSubtask); - updateTaskAndAncestors(parentTask); - renderCurrentView(); - selectAndFocusTask(task); - scheduleSave(); - } - lastSubtaskShiftDownReleased = false; - } else { - handleKeyDown(e, task); - } - } else { - handleKeyDown(e, task); - } - }; - - const keyupHandler = function(e) { - if (e.key === 'Backspace') { - keyHandler.backspace.canDelete = true; - keyHandler.backspace.blocked = false; - } else if (e.key === 'Enter') { - keyHandler.enter.canAdd = true; - keyHandler.enter.blocked = false; - keyHandler.shiftEnter.pressed = false; - } else if (e.key === 'ArrowDown') { - keyHandler.arrowDown.canAdd = true; - keyHandler.arrowDown.blocked = false; - keyHandler.shiftArrowDown.blocked = false; - // During multi-select, check if bottommost selected task is last subtask - const isAtBottom = multiSelectedIds.length > 1 - ? isLastSubtask(getMultiSelectedTasks().slice(-1)[0]) - : isLastSubtask(task) - if (isAtBottom) { - lastSubtaskDownArrowReleased = true; - lastSubtaskShiftDownReleased = true; - } else { - lastSubtaskDownArrowReleased = false; - lastSubtaskShiftDownReleased = false; - } - } else if (e.key === 'ArrowRight') { - keyHandler.shiftRight.pressed = false; - } else if (e.key === 'ArrowLeft') { - keyHandler.shiftLeft.pressed = false; - } - }; - - taskInput.addEventListener('keydown', keydownHandler); - taskInput.addEventListener('keyup', keyupHandler); - taskInput.addEventListener('keydown', handleCopyAndCut); - taskInput.addEventListener('input', (e) => { - const oldText = task.text - task.text = taskInput.value - // Propagate edits to all other multi-selected tasks (always at end of text) - if (multiSelectedIds.length > 1 && multiSelectedIds.includes(task.id)) { - const otherSelected = getMultiSelectedTasks().filter(t => t.id !== task.id) - - if (e.inputType === 'insertText' && e.data) { - // Append typed character(s) to end of each task - for (const t of otherSelected) { - t.text = t.text + e.data - const otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`) - if (otherInput) otherInput.value = t.text - } - } else if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteContentForward') { - // Remove last character from each task - for (const t of otherSelected) { - if (t.text.length > 0) { - t.text = t.text.slice(0, -1) - } - const otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`) - if (otherInput) otherInput.value = t.text - } - } else if (e.inputType === 'insertFromPaste' || e.inputType === 'insertFromDrop') { - // Compute what was inserted by comparing old and new text length - const addedLen = taskInput.value.length - oldText.length - if (addedLen > 0) { - const pastedText = taskInput.value.slice(taskInput.selectionStart - addedLen, taskInput.selectionStart) - for (const t of otherSelected) { - t.text = t.text + pastedText - const otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`) - if (otherInput) otherInput.value = t.text - } - } - } - } - if (task === currentTask) { - updatePageTitle(task); - } - scheduleSave(); - }); - - taskInput.addEventListener('focus', () => { - // If clicking a task outside the multi-selection, clear it - if (multiSelectedIds.length > 1 && !multiSelectedIds.includes(task.id)) { - clearMultiSelect() - } - setActiveTask(taskInput, task) - }); - - taskContainer.appendChild(checkbox); - taskContainer.appendChild(checkboxLabel); - taskContainer.appendChild(taskInput); - - return taskContainer; - } - - function generateBreadcrumbs(rootTask, currentPath, selectedTaskId) { - let breadcrumbs = ''; - let currentTask = rootTask; - let currentDepth = 0; - - for (let i = 0; i < currentPath.length - 1; i++) { - breadcrumbs += '○ '; - currentTask = currentTask.subtasks.find(t => t.id === currentPath[i + 1].id); - } - - breadcrumbs += '● '; - currentDepth = currentPath.length - 1; - - if (selectedTaskId !== currentTask.id) { - let selectedTask = currentTask.subtasks.find(t => t.id === selectedTaskId); - - if (selectedTask) { - function calculateMaxDepth(task, depth) { - if (task.subtasks.length === 0) return depth; - return Math.max(...task.subtasks.map(st => calculateMaxDepth(st, depth + 1))); - } - - let maxDepth = calculateMaxDepth(selectedTask, currentDepth + 1); - - for (let i = currentDepth + 1; i < maxDepth; i++) { - breadcrumbs += '○ '; - } - } - } - - return breadcrumbs.trim(); - } - - function updateCheckboxState(checkbox, state) { - checkbox.checked = state === 1; - checkbox.indeterminate = state === 2; - } - - function toggleTaskState(task) { - // Save cursor position before re-rendering - const taskInput = document.querySelector(`.task-container[data-id="${task.id}"] input[type="text"]`) - const cursorPos = taskInput ? taskInput.selectionStart : null - - if (task.state === 1) { - if (task.subtasks.length === 0 || !task.subtasks.every(t => t.state === 1)) { - task.state = 0; - updateSubtasksState(task, 0); - - const parent = findParentTask(task); - if (parent) { - updateTaskAndAncestors(parent); - } - - renderCurrentView(); - selectAndFocusTask(task, cursorPos); - scheduleSave(); - } else { - // disallow unchecking and play a 'shake' animation - applyShakeAnimation(task.id); - } - } else { - task.state = 1; - updateSubtasksState(task, 1); - - const parent = findParentTask(task); - if (parent) { - updateTaskAndAncestors(parent); - } - - renderCurrentView(); - selectAndFocusTask(task, cursorPos); - scheduleSave(); - } - } - - function applyShakeAnimation(taskId, direction = 'horizontal') { - const checkbox = document.querySelector(`.task-container[data-id="${taskId}"] .checkbox-label`); - if (checkbox) { - const className = direction === 'vertical' ? 'shake-vertical' : 'shake' - if (checkbox.dataset.shaking) return - checkbox.dataset.shaking = '1' - checkbox.classList.add(className); - checkbox.addEventListener('animationend', () => { - checkbox.classList.remove(className) - }, { once: true }) - const clearShaking = () => { - delete checkbox.dataset.shaking - document.removeEventListener('keyup', clearShaking) - } - document.addEventListener('keyup', clearShaking) - } - } - - function recalculateTaskState(task) { - if (task.subtasks.length === 0) { - return task.state; // If no subtasks, keep current state - } - const anyUnchecked = task.subtasks.some(t => t.state === 0); - const allChecked = task.subtasks.every(t => t.state === 1); - const allCheckedOrIndeterminate = task.subtasks.every(t => t.state === 1 || t.state === 2); - - if (allChecked) { - return 1; // All children genuinely checked - } else if (allCheckedOrIndeterminate) { - // Mix of checked and indeterminate (no unchecked) - // Indeterminate parent stays indeterminate; checked/unchecked parent becomes checked - return task.state === 2 ? 2 : 1; - } else if (anyUnchecked) { - return 0; // Unchecked - } - } - - function updateTaskAndAncestors(task) { - const newState = recalculateTaskState(task); - if (task.state !== newState) { - const oldState = task.state; - task.state = newState; - - // If the task changed from checked to unchecked, update its descendants - if (oldState === 1 && newState === 0) { - updateSubtasksState(task, 0); - } - - const parent = findParentTask(task); - if (parent) { - updateTaskAndAncestors(parent); - } - } - } - - function updateSubtasksState(task, state) { - task.subtasks.forEach(subtask => { - if (subtask.state !== 1) { // Only update if the subtask is not checked - if (state === 1) { - subtask.state = subtask.state === 0 ? 2 : subtask.state; - } else if (state === 0) { - subtask.state = 0; - } - if (subtask.subtasks.length > 0) { - updateSubtasksState(subtask, state); - } - } - }); - } - - // Adjust a moved task's state to be valid under its new parent - function adjustMovedTaskState(movedTask, newParent) { - if (movedTask.state === 2 && newParent.state === 0) { - movedTask.state = 0; - updateSubtasksState(movedTask, 0); - } - } - - function deleteSubtask(subtask) { - const parentTask = taskPath[taskPath.length - 1]; - const index = parentTask.subtasks.findIndex(t => t.id === subtask.id); - - if (parentTask.id === 'root' && parentTask.subtasks.length === 1) { - // Disallow deletion of the root task's sole remaining child - applyShakeAnimation(subtask.id); - return; - } - - // Delete the subtask - parentTask.subtasks = parentTask.subtasks.filter(t => t.id !== subtask.id); - - // Recalculate parent state and propagate changes - updateTaskAndAncestors(parentTask); - - if (parentTask.subtasks.length === 0 && taskPath.length > 1) { - navigateToParentTask(); - } else { - renderCurrentView(); - if (parentTask.subtasks.length > 0) { - const targetIndex = Math.max(0, index - 1); - selectAndFocusTask(parentTask.subtasks[targetIndex]); - } else { - selectAndFocusTask(parentTask); - } - } - scheduleSave(); - } - - function deleteCurrentParentTask() { - if (taskPath.length <= 1) return; // Don't delete root task - - const currentParentTask = taskPath[taskPath.length - 1]; - const grandparentTask = taskPath[taskPath.length - 2]; - - // Check if we're trying to delete the sole child of the root - if (grandparentTask.id === 'root' && grandparentTask.subtasks.length === 1) { - // Don't allow deletion of the sole child of root - applyShakeAnimation(currentParentTask.id); - return; - } - - const index = grandparentTask.subtasks.findIndex(t => t.id === currentParentTask.id); - - grandparentTask.subtasks = grandparentTask.subtasks.filter(t => t.id !== currentParentTask.id); - updateTaskAndAncestors(grandparentTask); - - taskPath.pop(); // Remove the deleted task from the path - currentTask = grandparentTask; - - if (grandparentTask.subtasks.length === 0 && taskPath.length > 1) { - // If we've just deleted the last subtask, navigate up another level - navigateToParentTask(); - } else { - renderCurrentView(); - if (grandparentTask.subtasks.length > 0) { - // Select the subtask before the deleted one, or the one after if at the start - const targetIndex = Math.max(0, index - 1); - selectAndFocusTask(grandparentTask.subtasks[targetIndex]); - } else { - selectAndFocusTask(grandparentTask); - } - } - } - - function findParentTask(task) { - for (let i = taskPath.length - 1; i >= 0; i--) { - const potentialParent = taskPath[i]; - if (potentialParent.subtasks.some(t => t.id === task.id)) { - return potentialParent; - } - } - return null; - } - - // Multi-select: extend selection with Alt+Up/Down - function extendMultiSelect(task, direction) { - if (task === currentTask) return // can't multi-select the parent - - const tasks = currentTask.subtasks - if (tasks.length === 0) return - - // Initialize multi-select from current task - if (multiSelectAnchorId === null) { - multiSelectAnchorId = task.id - multiSelectedIds = [task.id] - } - - const anchorIndex = tasks.findIndex(t => t.id === multiSelectAnchorId) - const currentIndex = tasks.findIndex(t => t.id === task.id) - - if (direction === 'down') { - // Find the bottommost selected task - const bottomIndex = tasks.findIndex((t, i) => { - return multiSelectedIds.includes(t.id) && - (i === tasks.length - 1 || !multiSelectedIds.includes(tasks[i + 1].id)) - }) - if (currentIndex <= anchorIndex && multiSelectedIds.length > 1) { - // Contracting: remove from top - multiSelectedIds = multiSelectedIds.filter(id => id !== task.id) - const nextTask = tasks[currentIndex + 1] - if (nextTask) selectAndFocusTask(nextTask) - } else if (bottomIndex < tasks.length - 1) { - // Extending downward - const nextTask = tasks[bottomIndex + 1] - multiSelectedIds.push(nextTask.id) - selectAndFocusTask(nextTask) - } - } else { - // Find the topmost selected task - const topIndex = tasks.findIndex(t => multiSelectedIds.includes(t.id)) - if (currentIndex >= anchorIndex && multiSelectedIds.length > 1) { - // Contracting: remove from bottom - multiSelectedIds = multiSelectedIds.filter(id => id !== task.id) - const prevTask = tasks[currentIndex - 1] - if (prevTask) selectAndFocusTask(prevTask) - } else if (topIndex > 0) { - // Extending upward - const prevTask = tasks[topIndex - 1] - multiSelectedIds.unshift(prevTask.id) - selectAndFocusTask(prevTask) - } - } - - applyMultiSelectHighlights() - } - - function handleKeyDown(e, task) { - const cmd = e.metaKey || e.ctrlKey; - const hasMultiSelect = multiSelectedIds.length > 1 - - // Alt+Up/Down: extend multi-selection - if (e.key === 'ArrowUp' && e.altKey && !cmd && !e.shiftKey) { - e.preventDefault(); - extendMultiSelect(task, 'up') - return - } else if (e.key === 'ArrowDown' && e.altKey && !cmd && !e.shiftKey) { - e.preventDefault(); - extendMultiSelect(task, 'down') - return - } - - // Shift+Enter: toggle task state (bulk if multi-selected) - if (e.key === 'Enter' && e.shiftKey) { - e.preventDefault(); - if (!keyHandler.shiftEnter.pressed) { - keyHandler.shiftEnter.pressed = true; - if (hasMultiSelect) { - bulkToggleTaskState() - } else { - toggleTaskState(task); - } - } - // Cmd+Up: push into task above (bulk if multi-selected) - } else if (e.key === 'ArrowUp' && cmd && !e.shiftKey && !e.altKey) { - e.preventDefault(); - if (!e.repeat) { - const success = hasMultiSelect ? pushMultiSelectedIntoTarget('up') : pushSubtaskIntoTarget(task, 'up') - if (!success) { - hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical') - } - } - // Cmd+Down: push into task below (bulk if multi-selected) - } else if (e.key === 'ArrowDown' && cmd && !e.shiftKey && !e.altKey) { - e.preventDefault(); - if (!e.repeat) { - const success = hasMultiSelect ? pushMultiSelectedIntoTarget('down') : pushSubtaskIntoTarget(task, 'down') - if (!success) { - hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical') - } - } - } else if (e.key === 'ArrowRight' && cmd && !e.shiftKey && !e.altKey) { - e.preventDefault(); - hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id); - // Cmd+Left: pull out one level (bulk if multi-selected) - } else if (e.key === 'ArrowLeft' && cmd && !e.shiftKey && !e.altKey) { - e.preventDefault(); - if (!e.repeat) { - const success = hasMultiSelect ? pullMultiSelectedOutLayer() : pullSubtaskOutLayer(task) - if (!success) { - hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id) - } - } - // Cmd+Shift+Up: push and navigate (bulk if multi-selected) - } else if (e.key === 'ArrowUp' && cmd && e.shiftKey && !e.altKey) { - e.preventDefault(); - if (!e.repeat) { - const success = hasMultiSelect ? pushMultiSelectedIntoTarget('up', true) : pushSubtaskIntoTarget(task, 'up', true) - if (!success) { - hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical') - } - } - // Cmd+Shift+Down: push and navigate (bulk if multi-selected) - } else if (e.key === 'ArrowDown' && cmd && e.shiftKey && !e.altKey) { - e.preventDefault(); - if (!e.repeat) { - const success = hasMultiSelect ? pushMultiSelectedIntoTarget('down', true) : pushSubtaskIntoTarget(task, 'down', true) - if (!success) { - hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical') - } - } - // Cmd+Shift+Left: pull and navigate (bulk if multi-selected) - } else if (e.key === 'ArrowLeft' && cmd && e.shiftKey && !e.altKey) { - e.preventDefault(); - if (!e.repeat) { - const success = hasMultiSelect ? pullMultiSelectedOutLayer(true) : pullSubtaskOutLayer(task, true) - if (!success) { - hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id) - } - } - } else if (e.key === 'ArrowRight' && cmd && e.shiftKey && !e.altKey) { - e.preventDefault(); - hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id); - // Plain Up/Down: navigate (clears multi-select) - } else if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) { - e.preventDefault(); - if (hasMultiSelect) clearMultiSelect() - navigateTasks('up'); - } else if (e.key === 'ArrowDown' && !e.shiftKey && !e.altKey) { - e.preventDefault(); - if (hasMultiSelect) clearMultiSelect() - navigateTasks('down'); - // Shift+Up/Down: move task (bulk if multi-selected) - } else if (e.key === 'ArrowUp' && e.shiftKey && !e.altKey) { - e.preventDefault(); - if (hasMultiSelect) { - if (!moveMultiSelected('up')) shakeAllSelected('vertical') - } else { - if (!moveSubtask(task, 'up')) applyShakeAnimation(task.id, 'vertical') - } - } else if (e.key === 'ArrowDown' && e.shiftKey && !e.altKey) { - e.preventDefault(); - if (hasMultiSelect) { - if (!moveMultiSelected('down')) shakeAllSelected('vertical') - } else { - if (!moveSubtask(task, 'down')) applyShakeAnimation(task.id, 'vertical') - } - // Shift+Right: blocked during multi-select, shake all - } else if (e.key === 'ArrowRight' && e.shiftKey && !e.altKey) { - e.preventDefault(); - if (hasMultiSelect) { - if (!keyHandler.shiftRight.pressed) { - keyHandler.shiftRight.pressed = true - shakeAllSelected() - } - } else if (!keyHandler.shiftRight.pressed) { - keyHandler.shiftRight.pressed = true; - if (task !== currentTask) { - navigateIntoSubtask(task); - } else { - applyShakeAnimation(task.id); - } - } - // Shift+Left: during multi-select, shake at root or clear and navigate out - } else if (e.key === 'ArrowLeft' && e.shiftKey && !e.altKey) { - e.preventDefault(); - if (hasMultiSelect && currentTask.id === 'root') { - if (!keyHandler.shiftLeft.pressed) { - keyHandler.shiftLeft.pressed = true - shakeAllSelected() - } - } else if (hasMultiSelect) { - clearMultiSelect() - if (!keyHandler.shiftLeft.pressed) { - keyHandler.shiftLeft.pressed = true - navigateToParentTask() - } - } else if (!keyHandler.shiftLeft.pressed) { - keyHandler.shiftLeft.pressed = true; - navigateToParentTask(); - } - } - } - - function moveSubtask(subtask, direction) { - if (subtask === currentTask) return false; - - const parentTask = findParentTask(subtask); - if (!parentTask) return false; - - const index = parentTask.subtasks.findIndex(t => t.id === subtask.id); - if (index === -1) return false; - - if (direction === 'up' && index > 0) { - [parentTask.subtasks[index - 1], parentTask.subtasks[index]] = [parentTask.subtasks[index], parentTask.subtasks[index - 1]]; - } else if (direction === 'down' && index < parentTask.subtasks.length - 1) { - [parentTask.subtasks[index], parentTask.subtasks[index + 1]] = [parentTask.subtasks[index + 1], parentTask.subtasks[index]]; - } else { - return false; - } - - renderCurrentView(); - selectAndFocusTask(subtask); - scheduleSave(); - return true; - } - - function pushSubtaskIntoTarget(subtask, direction, navigate = false) { - // Don't allow pushing the current parent task - if (subtask === currentTask) return false; - - if (navigate) { - document.documentElement.style.scrollBehavior = 'auto'; - } - - const parentTask = findParentTask(subtask); - if (!parentTask) return false; - - const index = parentTask.subtasks.findIndex(t => t.id === subtask.id); - if (index === -1) return false; - - let targetTask = null; - if (direction === 'up' && index > 0) { - targetTask = parentTask.subtasks[index - 1]; - } else if (direction === 'down' && index < parentTask.subtasks.length - 1) { - targetTask = parentTask.subtasks[index + 1]; - } - - if (!targetTask) return false; - - // Remove the subtask from its current parent - parentTask.subtasks.splice(index, 1); - - // Add the subtask to the top of the target task's subtasks - targetTask.subtasks.unshift(subtask); - - // Mark the pushed task as selected within the target - targetTask.selectedSubtaskId = subtask.id; - - // Adjust moved task's state for its new parent - adjustMovedTaskState(subtask, targetTask); - - // Update parent task state after removal - updateTaskAndAncestors(parentTask); - - // Update target task state after addition - updateTaskAndAncestors(targetTask); - - if (navigate) { - navigateIntoTaskAndSelectSubtask(targetTask, subtask); - } else { - renderCurrentView(); - selectAndFocusTask(targetTask); - } - - scheduleSave(); - return true; - } - - function pullSubtaskOutLayer(subtask, navigate = false) { - // Don't allow pulling the current parent task - if (subtask === currentTask) return false; - - // Don't allow pulling when at root level - if (taskPath.length <= 1) return false; - - if (navigate) { - document.documentElement.style.scrollBehavior = 'auto'; - } - - const currentParent = findParentTask(subtask); - if (!currentParent) return; - - // Find the grandparent (the outer layer we're pulling into) - const grandParent = findParentTask(currentParent); - if (!grandParent) return; - - // Remove the subtask from its current parent - const currentIndex = currentParent.subtasks.findIndex(t => t.id === subtask.id); - if (currentIndex === -1) return; - currentParent.subtasks.splice(currentIndex, 1); - - // Find where to insert in the grandparent's subtasks - const currentParentIndex = grandParent.subtasks.findIndex(t => t.id === currentParent.id); - if (currentParentIndex === -1) { - // Fallback: add to end of grandparent's subtasks - grandParent.subtasks.push(subtask); - } else { - // Insert after the current parent's position - grandParent.subtasks.splice(currentParentIndex + 1, 0, subtask); - } - - // Adjust moved task's state for its new parent - adjustMovedTaskState(subtask, grandParent); - - // Update task states - updateTaskAndAncestors(currentParent); - updateTaskAndAncestors(grandParent); - - if (navigate || (currentParent.subtasks.length === 0 && taskPath.length > 1)) { - // Navigate to parent level and select the moved task - navigateToParentTaskAndSelectTask(subtask); - } else { - // Stay at current level and select adjacent task - renderCurrentView(); - if (currentParent.subtasks.length > 0) { - const targetIndex = Math.max(0, currentIndex - 1); - selectAndFocusTask(currentParent.subtasks[targetIndex]); - } else { - selectAndFocusTask(currentParent); - } - } - - scheduleSave(); - return true; - } - - // Bulk move: shift entire multi-selected chunk up or down - function moveMultiSelected(direction) { - const selected = getMultiSelectedTasks() - if (selected.length === 0) return false - - const subtasks = currentTask.subtasks - const indices = selected.map(t => subtasks.findIndex(s => s.id === t.id)).sort((a, b) => a - b) - const topIndex = indices[0] - const bottomIndex = indices[indices.length - 1] - - if (direction === 'up' && topIndex <= 0) return false - if (direction === 'down' && bottomIndex >= subtasks.length - 1) return false - - // Remove the chunk - const chunk = subtasks.splice(topIndex, selected.length) - // Re-insert shifted by one - const insertAt = direction === 'up' ? topIndex - 1 : topIndex + 1 - subtasks.splice(insertAt, 0, ...chunk) - - // Ensure render focuses a multi-selected task - currentTask.selectedSubtaskId = multiSelectAnchorId - renderCurrentView() - applyMultiSelectHighlights() - scheduleSave() - return true - } - - // Bulk push: push entire multi-selected chunk into adjacent task - function pushMultiSelectedIntoTarget(direction, navigate = false) { - const selected = getMultiSelectedTasks() - if (selected.length === 0) return false - - if (navigate) { - document.documentElement.style.scrollBehavior = 'auto' - } - - const subtasks = currentTask.subtasks - const indices = selected.map(t => subtasks.findIndex(s => s.id === t.id)).sort((a, b) => a - b) - const topIndex = indices[0] - const bottomIndex = indices[indices.length - 1] - - // Find target: the task adjacent to the chunk that is NOT in the selection - let targetTask = null - if (direction === 'up' && topIndex > 0) { - targetTask = subtasks[topIndex - 1] - } else if (direction === 'down' && bottomIndex < subtasks.length - 1) { - targetTask = subtasks[bottomIndex + 1] - } - if (!targetTask) return false - - // Remove selected tasks from current parent, keeping target - const chunk = selected.slice() - currentTask.subtasks = currentTask.subtasks.filter(t => !multiSelectedIds.includes(t.id)) - - // Add chunk to target's subtasks (at the top, preserving order) - targetTask.subtasks.unshift(...chunk) - - // Adjust states for moved tasks - for (const t of chunk) { - adjustMovedTaskState(t, targetTask) - } - targetTask.selectedSubtaskId = multiSelectAnchorId - updateTaskAndAncestors(currentTask) - updateTaskAndAncestors(targetTask) - - if (navigate) { - // Navigate into target, keep multi-select - currentTask.selectedSubtaskId = targetTask.id - taskPath.push(targetTask) - currentTask = targetTask - updateBreadcrumbs(currentTask) - renderCurrentView() - applyMultiSelectHighlights() - } else { - // Stay at current level, chunk is no longer visible - clearMultiSelect() - renderCurrentView() - selectAndFocusTask(targetTask) - } - - scheduleSave() - return true - } - - // Bulk pull: pull entire multi-selected chunk out one level - function pullMultiSelectedOutLayer(navigate = false) { - if (taskPath.length <= 1) return false - - const selected = getMultiSelectedTasks() - if (selected.length === 0) return false - - if (navigate) { - document.documentElement.style.scrollBehavior = 'auto' - } - - const currentParent = currentTask - const grandParent = taskPath[taskPath.length - 2] - if (!grandParent) return false - - // Remove chunk from current parent - currentParent.subtasks = currentParent.subtasks.filter(t => !multiSelectedIds.includes(t.id)) - - // Insert after current parent in grandparent's subtasks, preserving order - const parentIndex = grandParent.subtasks.findIndex(t => t.id === currentParent.id) - if (parentIndex === -1) { - grandParent.subtasks.push(...selected) - } else { - grandParent.subtasks.splice(parentIndex + 1, 0, ...selected) - } - - // Adjust states for moved tasks - for (const t of selected) { - adjustMovedTaskState(t, grandParent) - } - - updateTaskAndAncestors(currentParent) - updateTaskAndAncestors(grandParent) - - if (navigate || currentParent.subtasks.length === 0) { - // Navigate out to parent level, keep multi-select - taskPath.pop() - currentTask = taskPath[taskPath.length - 1] - // Set selectedSubtaskId to anchor so renderCurrentView focuses a multi-selected task - currentTask.selectedSubtaskId = multiSelectAnchorId - updateBreadcrumbs(currentTask) - renderCurrentView() - applyMultiSelectHighlights() - } else { - // Stay at current level, chunk is gone - clearMultiSelect() - renderCurrentView() - if (currentParent.subtasks.length > 0) { - selectAndFocusTask(currentParent.subtasks[0]) - } else { - selectAndFocusTask(currentParent) - } - } - - scheduleSave() - return true - } - - // Bulk toggle: uniform toggle for multi-selected tasks - function bulkToggleTaskState() { - const selected = getMultiSelectedTasks() - if (selected.length === 0) return - - // If any are unchecked (state !== 1), check all. Otherwise uncheck all. - const anyUnchecked = selected.some(t => t.state !== 1) - const newState = anyUnchecked ? 1 : 0 - - for (const t of selected) { - t.state = newState - // Update subtask states recursively - function setAllSubtasks(task, state) { - task.state = state - for (const sub of task.subtasks) { - setAllSubtasks(sub, state) - } - } - setAllSubtasks(t, newState) - } - - updateTaskAndAncestors(currentTask) - currentTask.selectedSubtaskId = multiSelectAnchorId - renderCurrentView() - applyMultiSelectHighlights() - scheduleSave() - } - - function addNewSubtask(parentTask, currentSubtask = null) { - const newSubtask = { id: generateId(), text: '', state: 0, subtasks: [], selectedSubtaskId: null }; - if (currentSubtask) { - const index = parentTask.subtasks.findIndex(t => t.id === currentSubtask.id); - parentTask.subtasks.splice(index + 1, 0, newSubtask); - } else { - parentTask.subtasks.push(newSubtask); - } - - updateTaskAndAncestors(parentTask); - - renderCurrentView(); - selectAndFocusTask(newSubtask); - scheduleSave(); - } - - function selectAndFocusTask(task, cursorPos) { - const taskInput = document.querySelector(`.task-container[data-id="${task.id}"] input[type="text"]`); - if (taskInput) { - taskInput.focus(); - const pos = cursorPos != null ? cursorPos : taskInput.value.length - taskInput.setSelectionRange(pos, pos); - setActiveTask(taskInput, task); - } - } - - function placeCursorAtBeginning(input) { - input.setSelectionRange(0, 0); - } - - function navigateTasks(direction) { - const tasks = currentTask.subtasks; - const subtaskIndex = tasks.findIndex(t => t.id === currentTask.selectedSubtaskId); - - // Check if the parent task itself is focused - const currentElement = document.activeElement; - const currentContainer = currentElement ? currentElement.closest('.task-container') : null; - const isParentFocused = currentContainer && currentContainer.dataset.id == currentTask.id; - - if (direction === 'up') { - if (isParentFocused) { - // Already at parent, can't go higher - } else if (subtaskIndex <= 0) { - // At first subtask or not found, go to parent - selectAndFocusTask(currentTask); - } else { - selectAndFocusTask(tasks[subtaskIndex - 1]); - } - } else { - if (isParentFocused) { - // Move from parent to first subtask - if (tasks.length > 0) { - selectAndFocusTask(tasks[0]); - } - } else if (subtaskIndex >= 0 && subtaskIndex < tasks.length - 1) { - selectAndFocusTask(tasks[subtaskIndex + 1]); - } - } - lastSubtaskDownArrowReleased = false; - lastSubtaskShiftDownReleased = false; - } - - function navigateIntoSubtask(subtask) { - // Temporarily disable smooth scrolling - document.documentElement.style.scrollBehavior = 'auto'; - - if (subtask.subtasks.length > 0) { - currentTask.selectedSubtaskId = subtask.id; - taskPath.push(subtask); - currentTask = subtask; - updateBreadcrumbs(currentTask); - renderCurrentView(); - const selectedSubtask = subtask.selectedSubtaskId ? - subtask.subtasks.find(t => t.id === subtask.selectedSubtaskId) : - subtask.subtasks[0]; - selectAndFocusTask(selectedSubtask); - } else { - addNewSubtask(subtask); - currentTask.selectedSubtaskId = subtask.id; - taskPath.push(subtask); - currentTask = subtask; - updateBreadcrumbs(currentTask); - renderCurrentView(); - selectAndFocusTask(subtask.subtasks[0]); - } - } - - function navigateIntoTaskAndSelectSubtask(targetTask, subtaskToSelect) { - // Temporarily disable smooth scrolling - document.documentElement.style.scrollBehavior = 'auto'; - - // Set the selectedSubtaskId on the current task before navigating - currentTask.selectedSubtaskId = targetTask.id; - - // Navigate into the target task - taskPath.push(targetTask); - currentTask = targetTask; - updateBreadcrumbs(currentTask); - renderCurrentView(); - - // Select the moved subtask - selectAndFocusTask(subtaskToSelect); - currentTask.selectedSubtaskId = subtaskToSelect.id; - } - - function navigateToParentTask() { - // Check if the current active parent task is the root - if (currentTask.id === 'root') { - // We're at root level - cannot navigate out, apply shake animation to active task - const activeTaskElement = document.querySelector('.task-container.active'); - if (activeTaskElement) { - const taskId = activeTaskElement.dataset.id; - applyShakeAnimation(taskId); - } - } else if (taskPath.length > 1) { - // Normal navigation out - we're deeper than root level - // Temporarily disable smooth scrolling - document.documentElement.style.scrollBehavior = 'auto'; - - const currentTaskId = currentTask.id; - taskPath.pop(); - currentTask = taskPath[taskPath.length - 1]; - updateBreadcrumbs(currentTask); - renderCurrentView(); - const selectedSubtask = currentTask.subtasks.find(t => t.id === currentTaskId); - if (selectedSubtask) { - selectAndFocusTask(selectedSubtask); - } else if (currentTask.subtasks.length > 0) { - selectAndFocusTask(currentTask.subtasks[0]); - } else { - selectAndFocusTask(currentTask); - } - currentTask.selectedSubtaskId = currentTaskId; - } - } - - function navigateToParentTaskAndSelectTask(targetTask) { - // Check if the current active parent task is the root - if (currentTask.id === 'root') { - // We're at root level, just re-render and select the target task - renderCurrentView(); - selectAndFocusTask(targetTask); - } else if (taskPath.length > 1) { - // Normal navigation out - we're deeper than root level - // Temporarily disable smooth scrolling - document.documentElement.style.scrollBehavior = 'auto'; - - taskPath.pop(); - currentTask = taskPath[taskPath.length - 1]; - updateBreadcrumbs(currentTask); - renderCurrentView(); - selectAndFocusTask(targetTask); - currentTask.selectedSubtaskId = targetTask.id; - } - } - - function setActiveTask(input, task) { - document.querySelectorAll('.active').forEach(el => el.classList.remove('active')); - input.closest('.task-container').classList.add('active'); - if (task !== currentTask) { - currentTask.selectedSubtaskId = task.id; - } - // Re-apply multi-select highlights after clearing - if (multiSelectedIds.length > 1) { - applyMultiSelectHighlights() - } - updateBreadcrumbs(task); - // During multi-select, check bottommost selected task for "last subtask" status - const bottomTask = multiSelectedIds.length > 1 - ? getMultiSelectedTasks().slice(-1)[0] - : task - lastSubtaskDownArrowReleased = isLastSubtask(bottomTask); - lastSubtaskShiftDownReleased = isLastSubtask(bottomTask); - input.focus(); - - // Center the active task in the viewport - const activeTaskElement = input.closest('.task-container'); - if (activeTaskElement && activeTaskElement.parentElement && activeTaskElement.parentElement.tagName === 'LI') { - // Use scrollIntoView with block: "center" to center the element vertically - activeTaskElement.scrollIntoView({ - behavior: 'auto', // Use 'auto' for immediate scrolling - block: 'center', // Center vertically - inline: 'nearest' // Don't change horizontal scroll - }); - - // Re-enable smooth scrolling after all DOM updates and scrolling are complete - setTimeout(() => { - document.documentElement.style.scrollBehavior = 'smooth'; - }, 200); - } - } - - function updateBreadcrumbs(selectedTask) { - const breadcrumbsContainer = document.getElementById('breadcrumbs'); - // During multi-select, show breadcrumbs as if parent is selected (no deeper traversal) - const effectiveId = multiSelectedIds.length > 1 ? currentTask.id : selectedTask.id; - const trail = generateBreadcrumbs(taskPath[0], taskPath, effectiveId); - breadcrumbsContainer.textContent = trail; - } - - function renderCurrentView() { - appContainer.innerHTML = ''; - currentTask = taskPath[taskPath.length - 1]; - - const stickyHeader = document.createElement('div'); - stickyHeader.className = 'sticky-header'; - - const breadcrumbsElement = document.createElement('div'); - breadcrumbsElement.id = 'breadcrumbs'; - stickyHeader.appendChild(breadcrumbsElement); - - const parentElement = createTaskElement(currentTask, true); - stickyHeader.appendChild(parentElement); - - appContainer.appendChild(stickyHeader); - - const subtasksContainer = document.createElement('div'); - subtasksContainer.id = 'subtasks-container'; - - const subtasksList = document.createElement('ul'); - currentTask.subtasks.forEach(subtask => { - const li = document.createElement('li'); - li.appendChild(createTaskElement(subtask)); - subtasksList.appendChild(li); - }); - subtasksContainer.appendChild(subtasksList); - appContainer.appendChild(subtasksContainer); - - updateBreadcrumbs(currentTask); - updatePageTitle(currentTask); - - const parentCheckbox = parentElement.querySelector('input[type="checkbox"]'); - updateCheckboxState(parentCheckbox, currentTask.state); - - if (currentTask.selectedSubtaskId) { - const selectedTask = currentTask.subtasks.find(t => t.id === currentTask.selectedSubtaskId); - if (selectedTask) { - selectAndFocusTask(selectedTask); - } else { - selectFirstSubtask(); - } - } else { - selectFirstSubtask(); - } - } - - function selectFirstSubtask() { - if (currentTask.subtasks.length > 0) { - const firstSubtask = currentTask.subtasks[0]; - selectAndFocusTask(firstSubtask); - } else { - selectAndFocusTask(currentTask); - } - } - - function handleCopyAndCut(e) { - if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'x')) { - const activeTaskInput = document.querySelector('.task-container.active input[type="text"]'); - if (activeTaskInput) { - e.preventDefault(); - - // If there's no selection, select all text - if (activeTaskInput.selectionStart === activeTaskInput.selectionEnd) { - activeTaskInput.select(); - } - - // Use execCommand for copy or cut - if (e.key === 'c') { - document.execCommand('copy'); - } else if (e.key === 'x') { - document.execCommand('cut'); - - // Update the task object - const taskContainer = activeTaskInput.closest('.task-container'); - const taskId = taskContainer.dataset.id; - const task = currentTask.id === taskId ? currentTask : currentTask.subtasks.find(t => t.id === taskId); - if (task) { - task.text = activeTaskInput.value; - scheduleSave(); - } - } - - // Deselect text if we had to select all - if (e.key === 'c' && activeTaskInput.selectionStart === 0 && activeTaskInput.selectionEnd === activeTaskInput.value.length) { - activeTaskInput.setSelectionRange(activeTaskInput.value.length, activeTaskInput.value.length); - } - } - } - } - - function updatePageTitle(task) { - document.title = task.text || '?'; - } - - function isLastSubtask(task) { - const parentTask = findParentTask(task); - if (!parentTask) return false; - return parentTask.subtasks[parentTask.subtasks.length - 1].id === task.id; - } - - function getThemesFromCSS() { - const styleElement = document.querySelector('style'); - if (styleElement && styleElement.textContent) { - const cssText = styleElement.textContent; - const themeRegex = /:root\[data-theme="([^"]+)"\]/g; - let match; - while ((match = themeRegex.exec(cssText)) !== null) { - themes.push(match[1]); - } - } - } - - function cycleTheme() { - currentThemeIndex = (currentThemeIndex + 1) % themes.length; - const newTheme = themes[currentThemeIndex]; - setTheme(newTheme); - saveThemeToLocalStorage(newTheme); - } - - function saveThemeToLocalStorage(theme) { - localStorage.setItem('currentTheme', theme); - } - - function updateFavicon() { - const iconEmoji = getComputedStyle(document.documentElement).getPropertyValue('--icon').trim().replace(/'/g, ''); - const faviconLink = document.querySelector('link[rel="icon"]'); - if (faviconLink && iconEmoji) { - faviconLink.href = `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>${iconEmoji}</text></svg>`; - } - } - - function setTheme(theme) { - document.documentElement.setAttribute('data-theme', theme); - const backgroundColor = getComputedStyle(document.documentElement).getPropertyValue('--background').trim(); - document.getElementById('themeColor').setAttribute('content', backgroundColor); - updateFavicon(); - } - - function setInitialTheme() { - const savedTheme = localStorage.getItem('currentTheme'); - const defaultTheme = document.documentElement.getAttribute('data-theme'); - - if (savedTheme && themes.includes(savedTheme)) { - currentThemeIndex = themes.indexOf(savedTheme); - } else if (themes.includes(defaultTheme)) { - currentThemeIndex = themes.indexOf(defaultTheme); - } else { - currentThemeIndex = 0; - } - - setTheme(themes[currentThemeIndex]); - } - - document.addEventListener('keydown', function(event) { - if (event.key === 'F2' && !isF2Pressed) { - event.preventDefault(); - isF2Pressed = true; - cycleTheme(); - } - }); - - document.addEventListener('keyup', function(event) { - if (event.key === 'F2') { - isF2Pressed = false; - } - }); - - document.addEventListener('keydown', handleSave); - document.addEventListener('keydown', handleOpen); - - // Add scroll handler to block default scroll behavior - window.addEventListener('wheel', handleScroll, { passive: false }); - - // Add print handler - window.addEventListener('beforeprint', handleBeforePrint); - window.addEventListener('afterprint', handleAfterPrint); - - function handleBeforePrint() { - // Create print content div if it doesn't exist - let printContent = document.getElementById('print-content'); - if (!printContent) { - printContent = document.createElement('div'); - printContent.id = 'print-content'; - document.body.appendChild(printContent); - } - - // Generate the serialized task tree for current parent task and its descendants - const serializedTasks = serializeTaskTree(currentTask); - printContent.textContent = serializedTasks; - } - - function handleAfterPrint() { - // Clean up print content - const printContent = document.getElementById('print-content'); - if (printContent) { - printContent.remove(); - } - } - - function handleScroll(e) { - // Prevent the default scroll behavior - e.preventDefault(); - } - - // Register focusin handler once (not per-element) to reset cursor on non-focused inputs - appContainer.addEventListener('focusin', function(e) { - if (e.target.tagName === 'INPUT' && e.target.type === 'text') { - document.querySelectorAll('input[type="text"]').forEach(input => { - if (input !== e.target) { - placeCursorAtBeginning(input); - } - }); - } - }); - - getThemesFromCSS(); - setInitialTheme(); - renderCurrentView(); - selectFirstSubtask(); - }); 26 + if ('serviceWorker' in navigator) { 27 + navigator.serviceWorker.register('sw.js'); 28 + } 29 </script> 30 </head> 31 <body> 32 <div id="app-container"></div> 33 </body> -</html> 34 +</html>
11 - unlimited subtask depth 12 - intuitive keyboard controls 13 - automatic saving using browser's local storage -- portable, "single html file" architecture that works completely offline 14 +- installable as a <a href="https://hunterirving.github.io/web_workshop/pages/pwa">Progressive Web App</a> 15 16 ### quickstart 17 1. press the `Return` / `Enter` key to add subtasks to the root "todo" task
1 +// Keyboard: key handler state and handleKeyDown dispatch 2 + 3 +var keyHandler = { 4 + backspace: { 5 + canDelete: true, 6 + blocked: false 7 + }, 8 + enter: { 9 + canAdd: true, 10 + blocked: false 11 + }, 12 + arrowDown: { 13 + canAdd: true, 14 + blocked: false 15 + }, 16 + shiftArrowDown: { 17 + blocked: false 18 + }, 19 + shiftEnter: { 20 + pressed: false 21 + }, 22 + shiftRight: { 23 + pressed: false 24 + }, 25 + shiftLeft: { 26 + pressed: false 27 + } 28 +}; 29 + 30 +function handleKeyDown(e, task) { 31 + var cmd = e.metaKey || e.ctrlKey; 32 + var hasMultiSelect = state.multiSelectedIds.length > 1; 33 + 34 + // Alt+Up/Down: extend multi-selection 35 + if (e.key === 'ArrowUp' && e.altKey && !cmd && !e.shiftKey) { 36 + e.preventDefault(); 37 + extendMultiSelect(task, 'up'); 38 + return; 39 + } else if (e.key === 'ArrowDown' && e.altKey && !cmd && !e.shiftKey) { 40 + e.preventDefault(); 41 + extendMultiSelect(task, 'down'); 42 + return; 43 + } 44 + 45 + // Shift+Enter: toggle task state (bulk if multi-selected) 46 + if (e.key === 'Enter' && e.shiftKey) { 47 + e.preventDefault(); 48 + if (!keyHandler.shiftEnter.pressed) { 49 + keyHandler.shiftEnter.pressed = true; 50 + if (hasMultiSelect) { 51 + bulkToggleTaskState(); 52 + } else { 53 + toggleTaskState(task); 54 + } 55 + } 56 + // Cmd+Up: push into task above 57 + } else if (e.key === 'ArrowUp' && cmd && !e.shiftKey && !e.altKey) { 58 + e.preventDefault(); 59 + if (!e.repeat) { 60 + var success = hasMultiSelect ? pushMultiSelectedIntoTarget('up') : pushSubtaskIntoTarget(task, 'up'); 61 + if (!success) { 62 + hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical'); 63 + } 64 + } 65 + // Cmd+Down: push into task below 66 + } else if (e.key === 'ArrowDown' && cmd && !e.shiftKey && !e.altKey) { 67 + e.preventDefault(); 68 + if (!e.repeat) { 69 + var success = hasMultiSelect ? pushMultiSelectedIntoTarget('down') : pushSubtaskIntoTarget(task, 'down'); 70 + if (!success) { 71 + hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical'); 72 + } 73 + } 74 + } else if (e.key === 'ArrowRight' && cmd && !e.shiftKey && !e.altKey) { 75 + e.preventDefault(); 76 + hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id); 77 + // Cmd+Left: pull out one level 78 + } else if (e.key === 'ArrowLeft' && cmd && !e.shiftKey && !e.altKey) { 79 + e.preventDefault(); 80 + if (!e.repeat) { 81 + var success = hasMultiSelect ? pullMultiSelectedOutLayer() : pullSubtaskOutLayer(task); 82 + if (!success) { 83 + hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id); 84 + } 85 + } 86 + // Cmd+Shift+Up: push and navigate 87 + } else if (e.key === 'ArrowUp' && cmd && e.shiftKey && !e.altKey) { 88 + e.preventDefault(); 89 + if (!e.repeat) { 90 + var success = hasMultiSelect ? pushMultiSelectedIntoTarget('up', true) : pushSubtaskIntoTarget(task, 'up', true); 91 + if (!success) { 92 + hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical'); 93 + } 94 + } 95 + // Cmd+Shift+Down: push and navigate 96 + } else if (e.key === 'ArrowDown' && cmd && e.shiftKey && !e.altKey) { 97 + e.preventDefault(); 98 + if (!e.repeat) { 99 + var success = hasMultiSelect ? pushMultiSelectedIntoTarget('down', true) : pushSubtaskIntoTarget(task, 'down', true); 100 + if (!success) { 101 + hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical'); 102 + } 103 + } 104 + // Cmd+Shift+Left: pull and navigate 105 + } else if (e.key === 'ArrowLeft' && cmd && e.shiftKey && !e.altKey) { 106 + e.preventDefault(); 107 + if (!e.repeat) { 108 + var success = hasMultiSelect ? pullMultiSelectedOutLayer(true) : pullSubtaskOutLayer(task, true); 109 + if (!success) { 110 + hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id); 111 + } 112 + } 113 + } else if (e.key === 'ArrowRight' && cmd && e.shiftKey && !e.altKey) { 114 + e.preventDefault(); 115 + hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id); 116 + // Plain Up/Down: navigate (clears multi-select) 117 + } else if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) { 118 + e.preventDefault(); 119 + if (hasMultiSelect) clearMultiSelect(); 120 + navigateTasks('up'); 121 + } else if (e.key === 'ArrowDown' && !e.shiftKey && !e.altKey) { 122 + e.preventDefault(); 123 + if (hasMultiSelect) clearMultiSelect(); 124 + navigateTasks('down'); 125 + // Shift+Up/Down: move task 126 + } else if (e.key === 'ArrowUp' && e.shiftKey && !e.altKey) { 127 + e.preventDefault(); 128 + if (hasMultiSelect) { 129 + if (!moveMultiSelected('up')) shakeAllSelected('vertical'); 130 + } else { 131 + if (!moveSubtask(task, 'up')) applyShakeAnimation(task.id, 'vertical'); 132 + } 133 + } else if (e.key === 'ArrowDown' && e.shiftKey && !e.altKey) { 134 + e.preventDefault(); 135 + if (hasMultiSelect) { 136 + if (!moveMultiSelected('down')) shakeAllSelected('vertical'); 137 + } else { 138 + if (!moveSubtask(task, 'down')) applyShakeAnimation(task.id, 'vertical'); 139 + } 140 + // Shift+Right: navigate into subtask (blocked during multi-select) 141 + } else if (e.key === 'ArrowRight' && e.shiftKey && !e.altKey) { 142 + e.preventDefault(); 143 + if (hasMultiSelect) { 144 + if (!keyHandler.shiftRight.pressed) { 145 + keyHandler.shiftRight.pressed = true; 146 + shakeAllSelected(); 147 + } 148 + } else if (!keyHandler.shiftRight.pressed) { 149 + keyHandler.shiftRight.pressed = true; 150 + if (task !== state.currentTask) { 151 + navigateIntoSubtask(task); 152 + } else { 153 + applyShakeAnimation(task.id); 154 + } 155 + } 156 + // Shift+Left: navigate to parent 157 + } else if (e.key === 'ArrowLeft' && e.shiftKey && !e.altKey) { 158 + e.preventDefault(); 159 + if (hasMultiSelect && state.currentTask.id === 'root') { 160 + if (!keyHandler.shiftLeft.pressed) { 161 + keyHandler.shiftLeft.pressed = true; 162 + shakeAllSelected(); 163 + } 164 + } else if (hasMultiSelect) { 165 + clearMultiSelect(); 166 + if (!keyHandler.shiftLeft.pressed) { 167 + keyHandler.shiftLeft.pressed = true; 168 + navigateToParentTask(); 169 + } 170 + } else if (!keyHandler.shiftLeft.pressed) { 171 + keyHandler.shiftLeft.pressed = true; 172 + navigateToParentTask(); 173 + } 174 + } 175 +}
1 +/* custom colors */ 2 +:root { 3 + --coconut: #ffffff; 4 + --licorice: #000000; 5 + --wasabi: #ccff00; 6 + --eggplant: #7700ff; 7 + 8 + --murk: #8e9918; 9 + --tadpole: #27350f; 10 + --lilypad: #6f8823; 11 + --reed: #4a5d23; 12 + 13 + --pollen: #f4a127; 14 + --loam: #5a352b; 15 + --chlorophyll: #5aa83b; 16 + --terracotta: #b15c2e; 17 + 18 + --wheat: #d2c3a3; 19 + --earth: #4a3c31; 20 + --pumpkin: #cb7c52; 21 + --tobacco: #7d6c55; 22 + 23 + --moss: #20302f; 24 + --goat-milk: #d8d3c9; 25 + --burl: #231f20; 26 + --flame: #c63728; 27 + 28 + --soot: #181a1b; 29 + --smoke: #e8e6e3; 30 + --ash: #252829; 31 + --cinder: #ff5532; 32 + 33 + --blue-raspberry: #0458b3; 34 + --cola: #1e1515; 35 + --bubblegum: #e11761; 36 + --taffy: #eaeaea; 37 + 38 + --muscat: #663399; 39 + --cask: #722f37; 40 + --bloom: #f8f6ff; 41 +} 42 + 43 +/* custom fonts */ 44 +@font-face { 45 + font-family: BasteleurMoonlight; 46 + src: url('fonts/Basteleur-Moonlight.ttf') format('truetype'); 47 +} 48 + 49 +/* themes */ 50 +:root[data-theme="gak"] { 51 + --background: var(--coconut); 52 + --text: var(--licorice); 53 + --highlight: var(--wasabi); 54 + --accent: var(--eggplant); 55 + --icon: ''; 56 +} 57 + 58 +:root[data-theme="swamp"] { 59 + --background: var(--murk); 60 + --text: var(--tadpole); 61 + --highlight: var(--lilypad); 62 + --accent: var(--reed); 63 + --icon: '🦠'; 64 +} 65 + 66 +:root[data-theme="sunflower"] { 67 + --background: var(--pollen); 68 + --text: var(--loam); 69 + --highlight: var(--chlorophyll); 70 + --accent: var(--terracotta); 71 + --icon: '🌻'; 72 +} 73 + 74 +:root[data-theme="harvest"] { 75 + --background: var(--wheat); 76 + --text: var(--earth); 77 + --highlight: var(--pumpkin); 78 + --accent: var(--tobacco); 79 + --icon: '🌾'; 80 +} 81 + 82 +:root[data-theme="medieval"] { 83 + --background: var(--moss); 84 + --text: var(--goat-milk); 85 + --highlight: var(--burl); 86 + --accent: var(--flame); 87 + --icon: '⚔️'; 88 + & input[type="text"] { 89 + font-family: BasteleurMoonlight, Arial, sans-serif; 90 + } 91 +} 92 + 93 +:root[data-theme="woodstove"] { 94 + --background: var(--soot); 95 + --text: var(--smoke); 96 + --highlight: var(--ash); 97 + --accent: var(--cinder); 98 + --icon: '🪵'; 99 + & input[type="text"] { 100 + font-family: BasteleurMoonlight, Arial, sans-serif; 101 + } 102 + .parent-task { 103 + --text: var(--cinder) 104 + } 105 +} 106 + 107 +:root[data-theme="sugar"] { 108 + --background: var(--cola); 109 + --text: var(--taffy); 110 + --highlight: var(--blue-raspberry); 111 + --accent: var(--bubblegum); 112 + --icon: '🍬'; 113 + 114 + .checkbox-label { 115 + border-radius: 10px; 116 + } 117 + .active { 118 + border-radius: 30px 0px 0px 30px; 119 + } 120 + .custom-checkbox:checked + .checkbox-label::before { 121 + display: none; 122 + } 123 + & input[type="text"] { 124 + text-shadow: 1px 1px 1px var(--cola); 125 + } 126 + input[type="text"]::selection { 127 + color: var(--text); 128 + } 129 +} 130 + 131 +:root[data-theme="vineyard"] { 132 + --background: var(--bloom); 133 + --text: var(--cask); 134 + --highlight: var(--muscat); 135 + --accent: var(--muscat); 136 + --icon: '🍇'; 137 + & input[type="text"] { 138 + font-family: BasteleurMoonlight; 139 + } 140 + input[type="text"]::selection { 141 + background-color: var(--background); 142 + color: var(--text); 143 + text-shadow: none; 144 + } 145 + .active { 146 + border-radius: 30px 0px 0px 30px; 147 + } 148 + .active input[type="text"] { 149 + color: var(--bloom); 150 + text-shadow: 1px 1px 2px black; 151 + } 152 + .checkbox-label { 153 + border-radius: 50%; 154 + border: none; 155 + background: radial-gradient(circle at 30% 30%, #d4a5d8, #b19cd9, #8a4baf); 156 + box-shadow: 157 + inset 1px 1px 3px rgba(0,0,0,0.4), 158 + inset -1px -1px 2px rgba(255,255,255,0.3), 159 + 0 1px 2px rgba(0,0,0,0.3); 160 + } 161 + .custom-checkbox:checked + .checkbox-label { 162 + background: radial-gradient(circle at 30% 30%, #b19cd9, #8a4baf, #663399); 163 + box-shadow: 164 + inset -2px -2px 4px rgba(0,0,0,0.3), 165 + inset 1px 1px 2px rgba(255,255,255,0.5), 166 + 1px 1px 2px rgba(0,0,0,0.6); 167 + } 168 + .active .custom-checkbox:checked + .checkbox-label { 169 + box-shadow: 170 + inset -2px -2px 4px rgba(0,0,0,0.3), 171 + inset 1px 1px 2px rgba(255,255,255,0.5), 172 + 3px 3px 6px rgba(0,0,0,0.6); 173 + } 174 + .custom-checkbox:checked + .checkbox-label::before { 175 + display: none; 176 + } 177 + .custom-checkbox:indeterminate + .checkbox-label::before { 178 + content: ''; 179 + position: absolute; 180 + left: 50%; 181 + top: 50%; 182 + width: 50%; 183 + height: 50%; 184 + border-radius: 50%; 185 + background: radial-gradient(circle at 30% 30%, #b19cd9, #8a4baf, #663399); 186 + box-shadow: 187 + inset -1px -1px 2px rgba(0,0,0,0.3), 188 + inset 0.5px 0.5px 1px rgba(255,255,255,0.5); 189 + transform: translate(-50%, -50%); 190 + } 191 + scrollbar-color: color-mix(in srgb, var(--muscat) 75%, transparent) color-mix(in srgb, var(--bloom) 75%, transparent); 192 +} 193 + 194 +/* interface styling */ 195 +@keyframes shake { 196 + 0% { transform: translateX(0); } 197 + 20% { transform: translateX(3px); } 198 + 40% { transform: translateX(-3px); } 199 + 60% { transform: translateX(3px); } 200 + 80% { transform: translateX(-3px); } 201 + 100% { transform: translateX(0); } 202 +} 203 +.shake { 204 + animation: shake 0.25s ease-out; 205 +} 206 +@keyframes shake-vertical { 207 + 0% { transform: translateY(0); } 208 + 20% { transform: translateY(3px); } 209 + 40% { transform: translateY(-3px); } 210 + 60% { transform: translateY(3px); } 211 + 80% { transform: translateY(-3px); } 212 + 100% { transform: translateY(0); } 213 +} 214 +.shake-vertical { 215 + animation: shake-vertical 0.25s ease-out; 216 +} 217 +html { 218 + scroll-behavior: smooth; 219 + overflow-y: scroll; 220 + overflow-x: hidden; 221 + scrollbar-width: auto; 222 + scrollbar-color: color-mix(in srgb, var(--text) 75%, transparent) color-mix(in srgb, var(--background) 75%, transparent); 223 +} 224 +body { 225 + font-family: Arial, sans-serif; 226 + max-width: 800px; 227 + margin: 0 auto; 228 + padding: 0px 20px 20px 20px; 229 + background-color: var(--background); 230 + color: var(--text); 231 +} 232 +ul { 233 + list-style-type: none; 234 + padding-left: 20px; 235 +} 236 +input[type="text"] { 237 + border: none; 238 + background: transparent; 239 + font-size: 1.17em; 240 + font-weight: bold; 241 + width: calc(100% - 30px); 242 + margin-left: 5px; 243 + padding: 5px; 244 + color: var(--text); 245 + overflow: hidden; 246 + text-overflow: ellipsis; 247 + white-space: nowrap; 248 +} 249 +input[type="text"]:focus { 250 + outline: none; 251 +} 252 +input[type="text"]::selection { 253 + background-color: var(--accent); 254 + color: var(--background); 255 +} 256 +.task-container { 257 + display: flex; 258 + align-items: center; 259 + margin: 5px 0; 260 + padding-left: 11px; 261 + padding-top: 4px; 262 + padding-bottom: 4px; 263 +} 264 +.active { 265 + background-color: var(--highlight); 266 +} 267 +.sticky-header { 268 + position: sticky; 269 + top: 0; 270 + background-color: var(--background); 271 + z-index: 1; 272 + padding-top: 20px; 273 +} 274 +.parent-task { 275 + font-size: 1.5em; 276 + font-weight: bold; 277 +} 278 +#breadcrumbs { 279 + font-size: 24px; 280 + margin-bottom: 10px; 281 + color: var(--text); 282 + user-select: none; 283 +} 284 +.custom-checkbox { 285 + display: none; 286 +} 287 +.checkbox-label { 288 + display: inline-block; 289 + width: 20px; 290 + height: 20px; 291 + background-color: var(--background); 292 + border: 2px solid var(--accent); 293 + position: relative; 294 + cursor: pointer; 295 + box-sizing: border-box; 296 + border-radius: 4px; 297 +} 298 +.custom-checkbox:checked + .checkbox-label { 299 + background-color: var(--accent); 300 +} 301 +.custom-checkbox:checked + .checkbox-label::before { 302 + content: ''; 303 + position: absolute; 304 + left: 6px; 305 + top: 2px; 306 + width: 5px; 307 + height: 10px; 308 + border: solid var(--background); 309 + border-width: 0 2px 2px 0; 310 + transform: rotate(45deg); 311 + box-sizing: border-box; 312 +} 313 +.custom-checkbox:indeterminate + .checkbox-label::before { 314 + content: ''; 315 + position: absolute; 316 + left: 25%; 317 + right: 25%; 318 + top: 50%; 319 + height: 2px; 320 + background-color: var(--accent); 321 + transform: translateY(-50%); 322 +} 323 + 324 +/* print styles */ 325 +@media print { 326 + body * { 327 + visibility: hidden; 328 + } 329 + #print-content, #print-content * { 330 + visibility: visible; 331 + } 332 + #print-content { 333 + position: absolute; 334 + left: 0; 335 + top: 0; 336 + font-family: monospace; 337 + font-size: 12pt; 338 + line-height: 1.4; 339 + white-space: pre-wrap; 340 + color: black; 341 + background: white; 342 + } 343 + #app-container { 344 + display: none; 345 + } 346 +}
1 +// Main: initialization and global event listeners 2 + 3 +document.addEventListener('click', function(e) { 4 + if (!e.target.closest('.task-container')) { 5 + var activeTask = document.querySelector('.task-container.active'); 6 + if (activeTask) { 7 + var taskInput = activeTask.querySelector('input[type="text"]'); 8 + taskInput.focus(); 9 + } else { 10 + selectFirstSubtask(); 11 + } 12 + } 13 +}); 14 + 15 +document.addEventListener('DOMContentLoaded', function() { 16 + state.appContainer = document.getElementById('app-container'); 17 + state.rootTask = loadTasksFromLocalStorage(); 18 + state.currentTask = state.rootTask; 19 + state.taskPath = [state.currentTask]; 20 + 21 + // Scroll handler to block default scroll behavior 22 + function handleScroll(e) { 23 + e.preventDefault(); 24 + } 25 + window.addEventListener('wheel', handleScroll, { passive: false }); 26 + 27 + // Print handlers 28 + function handleBeforePrint() { 29 + var printContent = document.getElementById('print-content'); 30 + if (!printContent) { 31 + printContent = document.createElement('div'); 32 + printContent.id = 'print-content'; 33 + document.body.appendChild(printContent); 34 + } 35 + var serializedTasks = serializeTaskTree(state.currentTask); 36 + printContent.textContent = serializedTasks; 37 + } 38 + 39 + function handleAfterPrint() { 40 + var printContent = document.getElementById('print-content'); 41 + if (printContent) { 42 + printContent.remove(); 43 + } 44 + } 45 + 46 + window.addEventListener('beforeprint', handleBeforePrint); 47 + window.addEventListener('afterprint', handleAfterPrint); 48 + 49 + // Theme cycling with F2 50 + document.addEventListener('keydown', function(event) { 51 + if (event.key === 'F2' && !state.isF2Pressed) { 52 + event.preventDefault(); 53 + state.isF2Pressed = true; 54 + cycleTheme(); 55 + } 56 + }); 57 + 58 + document.addEventListener('keyup', function(event) { 59 + if (event.key === 'F2') { 60 + state.isF2Pressed = false; 61 + } 62 + }); 63 + 64 + // File save/open handlers 65 + document.addEventListener('keydown', handleSave); 66 + document.addEventListener('keydown', handleOpen); 67 + 68 + // Reset cursor on non-focused inputs 69 + state.appContainer.addEventListener('focusin', function(e) { 70 + if (e.target.tagName === 'INPUT' && e.target.type === 'text') { 71 + document.querySelectorAll('input[type="text"]').forEach(input => { 72 + if (input !== e.target) { 73 + placeCursorAtBeginning(input); 74 + } 75 + }); 76 + } 77 + }); 78 + 79 + // Initialize 80 + getThemesFromCSS(); 81 + setInitialTheme(); 82 + renderCurrentView(); 83 + selectFirstSubtask(); 84 +});
1 +// Multi-select: state management and bulk operations 2 + 3 +function clearMultiSelect() { 4 + state.multiSelectAnchorId = null; 5 + state.multiSelectedIds = []; 6 + var focused = document.activeElement; 7 + var focusedContainer = focused ? focused.closest('.task-container') : null; 8 + document.querySelectorAll('.task-container.active').forEach(el => { 9 + if (el !== focusedContainer) el.classList.remove('active'); 10 + }); 11 +} 12 + 13 +function getMultiSelectedTasks() { 14 + return state.currentTask.subtasks.filter(t => state.multiSelectedIds.includes(t.id)); 15 +} 16 + 17 +function applyMultiSelectHighlights() { 18 + for (var id of state.multiSelectedIds) { 19 + var container = document.querySelector(`.task-container[data-id="${id}"]`); 20 + if (container) container.classList.add('active'); 21 + } 22 +} 23 + 24 +function shakeAllSelected(direction = 'horizontal') { 25 + for (var id of state.multiSelectedIds) { 26 + applyShakeAnimation(id, direction); 27 + } 28 +} 29 + 30 +function extendMultiSelect(task, direction) { 31 + if (task === state.currentTask) return; 32 + 33 + var tasks = state.currentTask.subtasks; 34 + if (tasks.length === 0) return; 35 + 36 + if (state.multiSelectAnchorId === null) { 37 + state.multiSelectAnchorId = task.id; 38 + state.multiSelectedIds = [task.id]; 39 + } 40 + 41 + var anchorIndex = tasks.findIndex(t => t.id === state.multiSelectAnchorId); 42 + var currentIndex = tasks.findIndex(t => t.id === task.id); 43 + 44 + if (direction === 'down') { 45 + var bottomIndex = tasks.findIndex((t, i) => { 46 + return state.multiSelectedIds.includes(t.id) && 47 + (i === tasks.length - 1 || !state.multiSelectedIds.includes(tasks[i + 1].id)); 48 + }); 49 + if (currentIndex <= anchorIndex && state.multiSelectedIds.length > 1) { 50 + state.multiSelectedIds = state.multiSelectedIds.filter(id => id !== task.id); 51 + var nextTask = tasks[currentIndex + 1]; 52 + if (nextTask) selectAndFocusTask(nextTask); 53 + } else if (bottomIndex < tasks.length - 1) { 54 + var nextTask = tasks[bottomIndex + 1]; 55 + state.multiSelectedIds.push(nextTask.id); 56 + selectAndFocusTask(nextTask); 57 + } 58 + } else { 59 + var topIndex = tasks.findIndex(t => state.multiSelectedIds.includes(t.id)); 60 + if (currentIndex >= anchorIndex && state.multiSelectedIds.length > 1) { 61 + state.multiSelectedIds = state.multiSelectedIds.filter(id => id !== task.id); 62 + var prevTask = tasks[currentIndex - 1]; 63 + if (prevTask) selectAndFocusTask(prevTask); 64 + } else if (topIndex > 0) { 65 + var prevTask = tasks[topIndex - 1]; 66 + state.multiSelectedIds.unshift(prevTask.id); 67 + selectAndFocusTask(prevTask); 68 + } 69 + } 70 + 71 + applyMultiSelectHighlights(); 72 +} 73 + 74 +function moveMultiSelected(direction) { 75 + var selected = getMultiSelectedTasks(); 76 + if (selected.length === 0) return false; 77 + 78 + var subtasks = state.currentTask.subtasks; 79 + var indices = selected.map(t => subtasks.findIndex(s => s.id === t.id)).sort((a, b) => a - b); 80 + var topIndex = indices[0]; 81 + var bottomIndex = indices[indices.length - 1]; 82 + 83 + if (direction === 'up' && topIndex <= 0) return false; 84 + if (direction === 'down' && bottomIndex >= subtasks.length - 1) return false; 85 + 86 + var chunk = subtasks.splice(topIndex, selected.length); 87 + var insertAt = direction === 'up' ? topIndex - 1 : topIndex + 1; 88 + subtasks.splice(insertAt, 0, ...chunk); 89 + 90 + state.currentTask.selectedSubtaskId = state.multiSelectAnchorId; 91 + renderCurrentView(); 92 + applyMultiSelectHighlights(); 93 + scheduleSave(); 94 + return true; 95 +} 96 + 97 +function pushMultiSelectedIntoTarget(direction, navigate = false) { 98 + var selected = getMultiSelectedTasks(); 99 + if (selected.length === 0) return false; 100 + 101 + if (navigate) { 102 + document.documentElement.style.scrollBehavior = 'auto'; 103 + } 104 + 105 + var subtasks = state.currentTask.subtasks; 106 + var indices = selected.map(t => subtasks.findIndex(s => s.id === t.id)).sort((a, b) => a - b); 107 + var topIndex = indices[0]; 108 + var bottomIndex = indices[indices.length - 1]; 109 + 110 + var targetTask = null; 111 + if (direction === 'up' && topIndex > 0) { 112 + targetTask = subtasks[topIndex - 1]; 113 + } else if (direction === 'down' && bottomIndex < subtasks.length - 1) { 114 + targetTask = subtasks[bottomIndex + 1]; 115 + } 116 + if (!targetTask) return false; 117 + 118 + var chunk = selected.slice(); 119 + state.currentTask.subtasks = state.currentTask.subtasks.filter(t => !state.multiSelectedIds.includes(t.id)); 120 + 121 + targetTask.subtasks.unshift(...chunk); 122 + 123 + for (var t of chunk) { 124 + adjustMovedTaskState(t, targetTask); 125 + } 126 + targetTask.selectedSubtaskId = state.multiSelectAnchorId; 127 + updateTaskAndAncestors(state.currentTask); 128 + updateTaskAndAncestors(targetTask); 129 + 130 + if (navigate) { 131 + state.currentTask.selectedSubtaskId = targetTask.id; 132 + state.taskPath.push(targetTask); 133 + state.currentTask = targetTask; 134 + updateBreadcrumbs(state.currentTask); 135 + renderCurrentView(); 136 + applyMultiSelectHighlights(); 137 + } else { 138 + clearMultiSelect(); 139 + renderCurrentView(); 140 + selectAndFocusTask(targetTask); 141 + } 142 + 143 + scheduleSave(); 144 + return true; 145 +} 146 + 147 +function pullMultiSelectedOutLayer(navigate = false) { 148 + if (state.taskPath.length <= 1) return false; 149 + 150 + var selected = getMultiSelectedTasks(); 151 + if (selected.length === 0) return false; 152 + 153 + if (navigate) { 154 + document.documentElement.style.scrollBehavior = 'auto'; 155 + } 156 + 157 + var currentParent = state.currentTask; 158 + var grandParent = state.taskPath[state.taskPath.length - 2]; 159 + if (!grandParent) return false; 160 + 161 + currentParent.subtasks = currentParent.subtasks.filter(t => !state.multiSelectedIds.includes(t.id)); 162 + 163 + var parentIndex = grandParent.subtasks.findIndex(t => t.id === currentParent.id); 164 + if (parentIndex === -1) { 165 + grandParent.subtasks.push(...selected); 166 + } else { 167 + grandParent.subtasks.splice(parentIndex + 1, 0, ...selected); 168 + } 169 + 170 + for (var t of selected) { 171 + adjustMovedTaskState(t, grandParent); 172 + } 173 + 174 + updateTaskAndAncestors(currentParent); 175 + updateTaskAndAncestors(grandParent); 176 + 177 + if (navigate || currentParent.subtasks.length === 0) { 178 + state.taskPath.pop(); 179 + state.currentTask = state.taskPath[state.taskPath.length - 1]; 180 + state.currentTask.selectedSubtaskId = state.multiSelectAnchorId; 181 + updateBreadcrumbs(state.currentTask); 182 + renderCurrentView(); 183 + applyMultiSelectHighlights(); 184 + } else { 185 + clearMultiSelect(); 186 + renderCurrentView(); 187 + if (currentParent.subtasks.length > 0) { 188 + selectAndFocusTask(currentParent.subtasks[0]); 189 + } else { 190 + selectAndFocusTask(currentParent); 191 + } 192 + } 193 + 194 + scheduleSave(); 195 + return true; 196 +} 197 + 198 +function bulkToggleTaskState() { 199 + var selected = getMultiSelectedTasks(); 200 + if (selected.length === 0) return; 201 + 202 + var anyUnchecked = selected.some(t => t.state !== 1); 203 + var newState = anyUnchecked ? 1 : 0; 204 + 205 + for (var t of selected) { 206 + function setAllSubtasks(task, s) { 207 + task.state = s; 208 + for (var sub of task.subtasks) { 209 + setAllSubtasks(sub, s); 210 + } 211 + } 212 + setAllSubtasks(t, newState); 213 + } 214 + 215 + updateTaskAndAncestors(state.currentTask); 216 + state.currentTask.selectedSubtaskId = state.multiSelectAnchorId; 217 + renderCurrentView(); 218 + applyMultiSelectHighlights(); 219 + scheduleSave(); 220 +}
1 +// Navigation: moving between tasks, navigating into/out of subtask levels 2 + 3 +function navigateTasks(direction) { 4 + var tasks = state.currentTask.subtasks; 5 + var subtaskIndex = tasks.findIndex(t => t.id === state.currentTask.selectedSubtaskId); 6 + 7 + var currentElement = document.activeElement; 8 + var currentContainer = currentElement ? currentElement.closest('.task-container') : null; 9 + var isParentFocused = currentContainer && currentContainer.dataset.id == state.currentTask.id; 10 + 11 + if (direction === 'up') { 12 + if (isParentFocused) { 13 + // Already at parent, can't go higher 14 + } else if (subtaskIndex <= 0) { 15 + selectAndFocusTask(state.currentTask); 16 + } else { 17 + selectAndFocusTask(tasks[subtaskIndex - 1]); 18 + } 19 + } else { 20 + if (isParentFocused) { 21 + if (tasks.length > 0) { 22 + selectAndFocusTask(tasks[0]); 23 + } 24 + } else if (subtaskIndex >= 0 && subtaskIndex < tasks.length - 1) { 25 + selectAndFocusTask(tasks[subtaskIndex + 1]); 26 + } 27 + } 28 + state.lastSubtaskDownArrowReleased = false; 29 + state.lastSubtaskShiftDownReleased = false; 30 +} 31 + 32 +function navigateIntoSubtask(subtask) { 33 + document.documentElement.style.scrollBehavior = 'auto'; 34 + 35 + if (subtask.subtasks.length > 0) { 36 + state.currentTask.selectedSubtaskId = subtask.id; 37 + state.taskPath.push(subtask); 38 + state.currentTask = subtask; 39 + updateBreadcrumbs(state.currentTask); 40 + renderCurrentView(); 41 + var selectedSubtask = subtask.selectedSubtaskId ? 42 + subtask.subtasks.find(t => t.id === subtask.selectedSubtaskId) : 43 + subtask.subtasks[0]; 44 + selectAndFocusTask(selectedSubtask); 45 + } else { 46 + addNewSubtask(subtask); 47 + state.currentTask.selectedSubtaskId = subtask.id; 48 + state.taskPath.push(subtask); 49 + state.currentTask = subtask; 50 + updateBreadcrumbs(state.currentTask); 51 + renderCurrentView(); 52 + selectAndFocusTask(subtask.subtasks[0]); 53 + } 54 +} 55 + 56 +function navigateIntoTaskAndSelectSubtask(targetTask, subtaskToSelect) { 57 + document.documentElement.style.scrollBehavior = 'auto'; 58 + 59 + state.currentTask.selectedSubtaskId = targetTask.id; 60 + 61 + state.taskPath.push(targetTask); 62 + state.currentTask = targetTask; 63 + updateBreadcrumbs(state.currentTask); 64 + renderCurrentView(); 65 + 66 + selectAndFocusTask(subtaskToSelect); 67 + state.currentTask.selectedSubtaskId = subtaskToSelect.id; 68 +} 69 + 70 +function navigateToParentTask() { 71 + if (state.currentTask.id === 'root') { 72 + var activeTaskElement = document.querySelector('.task-container.active'); 73 + if (activeTaskElement) { 74 + var taskId = activeTaskElement.dataset.id; 75 + applyShakeAnimation(taskId); 76 + } 77 + } else if (state.taskPath.length > 1) { 78 + document.documentElement.style.scrollBehavior = 'auto'; 79 + 80 + var currentTaskId = state.currentTask.id; 81 + state.taskPath.pop(); 82 + state.currentTask = state.taskPath[state.taskPath.length - 1]; 83 + updateBreadcrumbs(state.currentTask); 84 + renderCurrentView(); 85 + var selectedSubtask = state.currentTask.subtasks.find(t => t.id === currentTaskId); 86 + if (selectedSubtask) { 87 + selectAndFocusTask(selectedSubtask); 88 + } else if (state.currentTask.subtasks.length > 0) { 89 + selectAndFocusTask(state.currentTask.subtasks[0]); 90 + } else { 91 + selectAndFocusTask(state.currentTask); 92 + } 93 + state.currentTask.selectedSubtaskId = currentTaskId; 94 + } 95 +} 96 + 97 +function navigateToParentTaskAndSelectTask(targetTask) { 98 + if (state.currentTask.id === 'root') { 99 + renderCurrentView(); 100 + selectAndFocusTask(targetTask); 101 + } else if (state.taskPath.length > 1) { 102 + document.documentElement.style.scrollBehavior = 'auto'; 103 + 104 + state.taskPath.pop(); 105 + state.currentTask = state.taskPath[state.taskPath.length - 1]; 106 + updateBreadcrumbs(state.currentTask); 107 + renderCurrentView(); 108 + selectAndFocusTask(targetTask); 109 + state.currentTask.selectedSubtaskId = targetTask.id; 110 + } 111 +}
1 +// Render: view rendering, breadcrumbs, UI updates, and shared DOM helpers 2 + 3 +function generateBreadcrumbs(rootTask, currentPath, selectedTaskId) { 4 + var breadcrumbs = ''; 5 + var currentTask = rootTask; 6 + var currentDepth = 0; 7 + 8 + for (var i = 0; i < currentPath.length - 1; i++) { 9 + breadcrumbs += '○ '; 10 + currentTask = currentTask.subtasks.find(t => t.id === currentPath[i + 1].id); 11 + } 12 + 13 + breadcrumbs += '● '; 14 + currentDepth = currentPath.length - 1; 15 + 16 + if (selectedTaskId !== currentTask.id) { 17 + var selectedTask = currentTask.subtasks.find(t => t.id === selectedTaskId); 18 + 19 + if (selectedTask) { 20 + function calculateMaxDepth(task, depth) { 21 + if (task.subtasks.length === 0) return depth; 22 + return Math.max(...task.subtasks.map(st => calculateMaxDepth(st, depth + 1))); 23 + } 24 + 25 + var maxDepth = calculateMaxDepth(selectedTask, currentDepth + 1); 26 + 27 + for (var i = currentDepth + 1; i < maxDepth; i++) { 28 + breadcrumbs += '○ '; 29 + } 30 + } 31 + } 32 + 33 + return breadcrumbs.trim(); 34 +} 35 + 36 +function applyShakeAnimation(taskId, direction = 'horizontal') { 37 + var checkbox = document.querySelector(`.task-container[data-id="${taskId}"] .checkbox-label`); 38 + if (checkbox) { 39 + var className = direction === 'vertical' ? 'shake-vertical' : 'shake'; 40 + if (checkbox.dataset.shaking) return; 41 + checkbox.dataset.shaking = '1'; 42 + checkbox.classList.add(className); 43 + checkbox.addEventListener('animationend', () => { 44 + checkbox.classList.remove(className); 45 + }, { once: true }); 46 + var clearShaking = () => { 47 + delete checkbox.dataset.shaking; 48 + document.removeEventListener('keyup', clearShaking); 49 + }; 50 + document.addEventListener('keyup', clearShaking); 51 + } 52 +} 53 + 54 +function selectAndFocusTask(task, cursorPos) { 55 + var taskInput = document.querySelector(`.task-container[data-id="${task.id}"] input[type="text"]`); 56 + if (taskInput) { 57 + taskInput.focus(); 58 + var pos = cursorPos != null ? cursorPos : taskInput.value.length; 59 + taskInput.setSelectionRange(pos, pos); 60 + setActiveTask(taskInput, task); 61 + } 62 +} 63 + 64 +function placeCursorAtBeginning(input) { 65 + input.setSelectionRange(0, 0); 66 +} 67 + 68 +function setActiveTask(input, task) { 69 + document.querySelectorAll('.active').forEach(el => el.classList.remove('active')); 70 + input.closest('.task-container').classList.add('active'); 71 + if (task !== state.currentTask) { 72 + state.currentTask.selectedSubtaskId = task.id; 73 + } 74 + // Re-apply multi-select highlights after clearing 75 + if (state.multiSelectedIds.length > 1) { 76 + applyMultiSelectHighlights(); 77 + } 78 + updateBreadcrumbs(task); 79 + // During multi-select, check bottommost selected task for "last subtask" status 80 + var bottomTask = state.multiSelectedIds.length > 1 81 + ? getMultiSelectedTasks().slice(-1)[0] 82 + : task; 83 + state.lastSubtaskDownArrowReleased = isLastSubtask(bottomTask); 84 + state.lastSubtaskShiftDownReleased = isLastSubtask(bottomTask); 85 + input.focus(); 86 + 87 + // Center the active task in the viewport 88 + var activeTaskElement = input.closest('.task-container'); 89 + if (activeTaskElement && activeTaskElement.parentElement && activeTaskElement.parentElement.tagName === 'LI') { 90 + activeTaskElement.scrollIntoView({ 91 + behavior: 'auto', 92 + block: 'center', 93 + inline: 'nearest' 94 + }); 95 + 96 + setTimeout(() => { 97 + document.documentElement.style.scrollBehavior = 'smooth'; 98 + }, 200); 99 + } 100 +} 101 + 102 +function updateBreadcrumbs(selectedTask) { 103 + var breadcrumbsContainer = document.getElementById('breadcrumbs'); 104 + var effectiveId = state.multiSelectedIds.length > 1 ? state.currentTask.id : selectedTask.id; 105 + var trail = generateBreadcrumbs(state.taskPath[0], state.taskPath, effectiveId); 106 + breadcrumbsContainer.textContent = trail; 107 +} 108 + 109 +function updatePageTitle(task) { 110 + document.title = task.text || '?'; 111 +} 112 + 113 +function selectFirstSubtask() { 114 + if (state.currentTask.subtasks.length > 0) { 115 + var firstSubtask = state.currentTask.subtasks[0]; 116 + selectAndFocusTask(firstSubtask); 117 + } else { 118 + selectAndFocusTask(state.currentTask); 119 + } 120 +} 121 + 122 +function handleCopyAndCut(e) { 123 + if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'x')) { 124 + var activeTaskInput = document.querySelector('.task-container.active input[type="text"]'); 125 + if (activeTaskInput) { 126 + e.preventDefault(); 127 + 128 + if (activeTaskInput.selectionStart === activeTaskInput.selectionEnd) { 129 + activeTaskInput.select(); 130 + } 131 + 132 + if (e.key === 'c') { 133 + document.execCommand('copy'); 134 + } else if (e.key === 'x') { 135 + document.execCommand('cut'); 136 + 137 + var taskContainer = activeTaskInput.closest('.task-container'); 138 + var taskId = taskContainer.dataset.id; 139 + var task = state.currentTask.id === taskId ? state.currentTask : state.currentTask.subtasks.find(t => t.id === taskId); 140 + if (task) { 141 + task.text = activeTaskInput.value; 142 + scheduleSave(); 143 + } 144 + } 145 + 146 + if (e.key === 'c' && activeTaskInput.selectionStart === 0 && activeTaskInput.selectionEnd === activeTaskInput.value.length) { 147 + activeTaskInput.setSelectionRange(activeTaskInput.value.length, activeTaskInput.value.length); 148 + } 149 + } 150 + } 151 +} 152 + 153 +function renderCurrentView() { 154 + state.appContainer.innerHTML = ''; 155 + state.currentTask = state.taskPath[state.taskPath.length - 1]; 156 + 157 + var stickyHeader = document.createElement('div'); 158 + stickyHeader.className = 'sticky-header'; 159 + 160 + var breadcrumbsElement = document.createElement('div'); 161 + breadcrumbsElement.id = 'breadcrumbs'; 162 + stickyHeader.appendChild(breadcrumbsElement); 163 + 164 + var parentElement = createTaskElement(state.currentTask, true); 165 + stickyHeader.appendChild(parentElement); 166 + 167 + state.appContainer.appendChild(stickyHeader); 168 + 169 + var subtasksContainer = document.createElement('div'); 170 + subtasksContainer.id = 'subtasks-container'; 171 + 172 + var subtasksList = document.createElement('ul'); 173 + state.currentTask.subtasks.forEach(subtask => { 174 + var li = document.createElement('li'); 175 + li.appendChild(createTaskElement(subtask)); 176 + subtasksList.appendChild(li); 177 + }); 178 + subtasksContainer.appendChild(subtasksList); 179 + state.appContainer.appendChild(subtasksContainer); 180 + 181 + updateBreadcrumbs(state.currentTask); 182 + updatePageTitle(state.currentTask); 183 + 184 + var parentCheckbox = parentElement.querySelector('input[type="checkbox"]'); 185 + updateCheckboxState(parentCheckbox, state.currentTask.state); 186 + 187 + if (state.currentTask.selectedSubtaskId) { 188 + var selectedTask = state.currentTask.subtasks.find(t => t.id === state.currentTask.selectedSubtaskId); 189 + if (selectedTask) { 190 + selectAndFocusTask(selectedTask); 191 + } else { 192 + selectFirstSubtask(); 193 + } 194 + } else { 195 + selectFirstSubtask(); 196 + } 197 +}
1 +// Reorganize: single-task move, push, and pull operations 2 + 3 +function moveSubtask(subtask, direction) { 4 + if (subtask === state.currentTask) return false; 5 + 6 + var parentTask = findParentTask(subtask); 7 + if (!parentTask) return false; 8 + 9 + var index = parentTask.subtasks.findIndex(t => t.id === subtask.id); 10 + if (index === -1) return false; 11 + 12 + if (direction === 'up' && index > 0) { 13 + [parentTask.subtasks[index - 1], parentTask.subtasks[index]] = [parentTask.subtasks[index], parentTask.subtasks[index - 1]]; 14 + } else if (direction === 'down' && index < parentTask.subtasks.length - 1) { 15 + [parentTask.subtasks[index], parentTask.subtasks[index + 1]] = [parentTask.subtasks[index + 1], parentTask.subtasks[index]]; 16 + } else { 17 + return false; 18 + } 19 + 20 + renderCurrentView(); 21 + selectAndFocusTask(subtask); 22 + scheduleSave(); 23 + return true; 24 +} 25 + 26 +function pushSubtaskIntoTarget(subtask, direction, navigate = false) { 27 + if (subtask === state.currentTask) return false; 28 + 29 + if (navigate) { 30 + document.documentElement.style.scrollBehavior = 'auto'; 31 + } 32 + 33 + var parentTask = findParentTask(subtask); 34 + if (!parentTask) return false; 35 + 36 + var index = parentTask.subtasks.findIndex(t => t.id === subtask.id); 37 + if (index === -1) return false; 38 + 39 + var targetTask = null; 40 + if (direction === 'up' && index > 0) { 41 + targetTask = parentTask.subtasks[index - 1]; 42 + } else if (direction === 'down' && index < parentTask.subtasks.length - 1) { 43 + targetTask = parentTask.subtasks[index + 1]; 44 + } 45 + 46 + if (!targetTask) return false; 47 + 48 + parentTask.subtasks.splice(index, 1); 49 + targetTask.subtasks.unshift(subtask); 50 + targetTask.selectedSubtaskId = subtask.id; 51 + 52 + adjustMovedTaskState(subtask, targetTask); 53 + updateTaskAndAncestors(parentTask); 54 + updateTaskAndAncestors(targetTask); 55 + 56 + if (navigate) { 57 + navigateIntoTaskAndSelectSubtask(targetTask, subtask); 58 + } else { 59 + renderCurrentView(); 60 + selectAndFocusTask(targetTask); 61 + } 62 + 63 + scheduleSave(); 64 + return true; 65 +} 66 + 67 +function pullSubtaskOutLayer(subtask, navigate = false) { 68 + if (subtask === state.currentTask) return false; 69 + if (state.taskPath.length <= 1) return false; 70 + 71 + if (navigate) { 72 + document.documentElement.style.scrollBehavior = 'auto'; 73 + } 74 + 75 + var currentParent = findParentTask(subtask); 76 + if (!currentParent) return; 77 + 78 + var grandParent = findParentTask(currentParent); 79 + if (!grandParent) return; 80 + 81 + var currentIndex = currentParent.subtasks.findIndex(t => t.id === subtask.id); 82 + if (currentIndex === -1) return; 83 + currentParent.subtasks.splice(currentIndex, 1); 84 + 85 + var currentParentIndex = grandParent.subtasks.findIndex(t => t.id === currentParent.id); 86 + if (currentParentIndex === -1) { 87 + grandParent.subtasks.push(subtask); 88 + } else { 89 + grandParent.subtasks.splice(currentParentIndex + 1, 0, subtask); 90 + } 91 + 92 + adjustMovedTaskState(subtask, grandParent); 93 + updateTaskAndAncestors(currentParent); 94 + updateTaskAndAncestors(grandParent); 95 + 96 + if (navigate || (currentParent.subtasks.length === 0 && state.taskPath.length > 1)) { 97 + navigateToParentTaskAndSelectTask(subtask); 98 + } else { 99 + renderCurrentView(); 100 + if (currentParent.subtasks.length > 0) { 101 + var targetIndex = Math.max(0, currentIndex - 1); 102 + selectAndFocusTask(currentParent.subtasks[targetIndex]); 103 + } else { 104 + selectAndFocusTask(currentParent); 105 + } 106 + } 107 + 108 + scheduleSave(); 109 + return true; 110 +}
1 +// Shared mutable application state 2 + 3 +var state = { 4 + appContainer: null, 5 + rootTask: null, 6 + currentTask: null, 7 + taskPath: [], 8 + lastSubtaskDownArrowReleased: false, 9 + lastSubtaskShiftDownReleased: false, 10 + saveTimer: null, 11 + currentThemeIndex: 0, 12 + isF2Pressed: false, 13 + themes: [], 14 + isInWheelEvent: false, 15 + 16 + // Multi-select state 17 + multiSelectAnchorId: null, 18 + multiSelectedIds: [], 19 +}; 20 + 21 +function generateId() { 22 + return Date.now().toString(36) + Math.random().toString(36).slice(2); 23 +}
1 +// Storage: localStorage persistence, file import/export, serialization 2 + 3 +function scheduleSave() { 4 + if (state.saveTimer) { 5 + clearTimeout(state.saveTimer); 6 + } 7 + state.saveTimer = setTimeout(saveTasksToLocalStorage, 1000); 8 +} 9 + 10 +function saveTasksToLocalStorage() { 11 + var serializedTasks = serializeTaskTree(state.taskPath[0]); 12 + localStorage.setItem('taskTree', serializedTasks); 13 + state.saveTimer = null; 14 +} 15 + 16 +function serializeTaskTree(task, depth = 0) { 17 + var indentation = '\t'.repeat(depth); 18 + var status = task.state === 0 ? '_' : (task.state === 1 ? 'x' : '?'); 19 + var serialized = `${indentation}${status} ${task.text}\n`; 20 + 21 + for (var subtask of task.subtasks) { 22 + serialized += serializeTaskTree(subtask, depth + 1); 23 + } 24 + 25 + return serialized; 26 +} 27 + 28 +function deserializeTaskTree(serialized) { 29 + var lines = serialized.split('\n').filter(line => line.trim() !== ''); 30 + var root = { id: 'root', subtasks: [] }; 31 + var stack = [{ task: root, depth: -1 }]; 32 + 33 + for (var line of lines) { 34 + var depth = (line.match(/^\t*/)[0] || '').length; 35 + var status = line[depth]; 36 + var text = line.slice(depth + 2); 37 + 38 + var newTask = { 39 + id: depth === 0 ? 'root' : generateId(), 40 + text: text, 41 + state: status === '_' ? 0 : (status === 'x' ? 1 : 2), 42 + subtasks: [], 43 + selectedSubtaskId: null 44 + }; 45 + 46 + while (stack.length > 1 && stack[stack.length - 1].depth >= depth) { 47 + stack.pop(); 48 + } 49 + 50 + if (depth === 0) { 51 + Object.assign(root, newTask); 52 + } else { 53 + stack[stack.length - 1].task.subtasks.push(newTask); 54 + } 55 + stack.push({ task: newTask, depth: depth }); 56 + } 57 + 58 + return root; 59 +} 60 + 61 +function loadTasksFromLocalStorage() { 62 + var savedTasks = localStorage.getItem('taskTree'); 63 + if (savedTasks) { 64 + console.log('%cloaded tasks from local storage:', "color: green;"); 65 + console.log(savedTasks); 66 + return deserializeTaskTree(savedTasks); 67 + } else { 68 + return { id: 'root', text: 'todo', state: 0, subtasks: [{ id: generateId(), text: '', state: 0, subtasks: [] }], selectedSubtaskId: null }; 69 + } 70 +} 71 + 72 +function handleSave(e) { 73 + if ((e.metaKey || e.ctrlKey) && e.key === 's') { 74 + e.preventDefault(); 75 + saveTaskTreeToFile(); 76 + } 77 +} 78 + 79 +function saveTaskTreeToFile() { 80 + var serializedTasks = serializeTaskTree(state.taskPath[0]); 81 + var rootTaskName = state.taskPath[0].text || 'Untitled'; 82 + var date = new Date(); 83 + var fileName = `${rootTaskName} - ${date.toLocaleString('default', { month: 'short' }).toLowerCase()} ${date.getDate()}, '${date.getFullYear().toString().slice(-2)}.txt`; 84 + 85 + var blob = new Blob([serializedTasks], { type: 'text/plain' }); 86 + var url = URL.createObjectURL(blob); 87 + 88 + var a = document.createElement('a'); 89 + a.href = url; 90 + a.download = fileName; 91 + a.click(); 92 + 93 + URL.revokeObjectURL(url); 94 +} 95 + 96 +function handleOpen(e) { 97 + if ((e.metaKey || e.ctrlKey) && e.key === 'o') { 98 + e.preventDefault(); 99 + openTaskTreeFromFile(); 100 + } 101 +} 102 + 103 +function openTaskTreeFromFile() { 104 + var input = document.createElement('input'); 105 + input.type = 'file'; 106 + input.accept = '.txt'; 107 + input.onchange = function(event) { 108 + var file = event.target.files[0]; 109 + var reader = new FileReader(); 110 + reader.onload = function(e) { 111 + try { 112 + var newRootTask = deserializeTaskTree(e.target.result); 113 + if (confirm('Are you sure you want to overwrite the existing task tree?')) { 114 + state.rootTask = newRootTask; 115 + state.currentTask = state.rootTask; 116 + state.taskPath = [state.currentTask]; 117 + renderCurrentView(); 118 + saveTasksToLocalStorage(); 119 + } 120 + } catch (error) { 121 + alert(`Error importing task tree: ${error.message}`); 122 + } 123 + }; 124 + reader.readAsText(file); 125 + }; 126 + input.click(); 127 +}
1 +// Task element: DOM creation and per-element event handlers 2 + 3 +function createTaskElement(task, isParentTask = false) { 4 + var taskContainer = document.createElement('div'); 5 + taskContainer.className = 'task-container'; 6 + taskContainer.dataset.id = task.id; 7 + if (isParentTask) taskContainer.classList.add('parent-task'); 8 + 9 + var checkbox = document.createElement('input'); 10 + checkbox.type = 'checkbox'; 11 + checkbox.className = 'custom-checkbox'; 12 + checkbox.id = `checkbox-${task.id}`; 13 + updateCheckboxState(checkbox, task.state); 14 + checkbox.addEventListener('click', (e) => { 15 + e.preventDefault(); 16 + toggleTaskState(task); 17 + }); 18 + 19 + var checkboxLabel = document.createElement('label'); 20 + checkboxLabel.className = 'checkbox-label'; 21 + checkboxLabel.setAttribute('for', `checkbox-${task.id}`); 22 + 23 + var taskInput = document.createElement('input'); 24 + taskInput.type = 'text'; 25 + taskInput.value = task.text; 26 + taskInput.setAttribute('autocomplete', 'off'); 27 + 28 + taskInput.addEventListener('mousedown', function(e) { 29 + e.stopPropagation(); 30 + }); 31 + 32 + var keydownHandler = function(e) { 33 + if (e.key === 'Backspace') { 34 + if (keyHandler.backspace.blocked) { 35 + e.preventDefault(); 36 + return; 37 + } 38 + // Multi-select backspace handling 39 + if (state.multiSelectedIds.length > 1) { 40 + var selected = getMultiSelectedTasks(); 41 + var allEmpty = selected.every(t => t.text === ''); 42 + if (allEmpty) { 43 + e.preventDefault(); 44 + if (keyHandler.backspace.canDelete) { 45 + keyHandler.backspace.blocked = true; 46 + var toDelete = selected.filter(t => t !== state.taskPath[0] && t !== state.currentTask); 47 + if (toDelete.length > 0 && !(state.currentTask.id === 'root' && toDelete.length >= state.currentTask.subtasks.length)) { 48 + var firstDeleteIdx = Math.min(...toDelete.map(t => state.currentTask.subtasks.findIndex(s => s.id === t.id)).filter(i => i !== -1)); 49 + for (var t of toDelete) { 50 + var idx = state.currentTask.subtasks.findIndex(s => s.id === t.id); 51 + if (idx !== -1) state.currentTask.subtasks.splice(idx, 1); 52 + } 53 + clearMultiSelect(); 54 + updateTaskAndAncestors(state.currentTask); 55 + if (state.currentTask.subtasks.length === 0 && state.taskPath.length > 1) { 56 + navigateToParentTask(); 57 + } else { 58 + renderCurrentView(); 59 + var targetIndex = Math.max(0, firstDeleteIdx - 1); 60 + selectAndFocusTask(state.currentTask.subtasks[targetIndex]); 61 + } 62 + scheduleSave(); 63 + } else { 64 + for (var t of selected) { 65 + applyShakeAnimation(t.id); 66 + } 67 + } 68 + } 69 + return; 70 + } 71 + // Some tasks still have text: remove last char from all that have text 72 + e.preventDefault(); 73 + for (var t of selected) { 74 + if (t.text.length > 0) { 75 + t.text = t.text.slice(0, -1); 76 + var inp = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`); 77 + if (inp) inp.value = t.text; 78 + } 79 + } 80 + keyHandler.backspace.canDelete = false; 81 + scheduleSave(); 82 + return; 83 + } 84 + if (taskInput.value === '' && keyHandler.backspace.canDelete && state.multiSelectedIds.length <= 1) { 85 + e.preventDefault(); 86 + if (task !== state.taskPath[0]) { 87 + keyHandler.backspace.blocked = true; 88 + clearMultiSelect(); 89 + if (task === state.currentTask) { 90 + deleteCurrentParentTask(); 91 + } else { 92 + deleteSubtask(task); 93 + } 94 + } else { 95 + applyShakeAnimation(task.id); 96 + } 97 + } else if (taskInput.value !== '') { 98 + keyHandler.backspace.canDelete = false; 99 + } 100 + } else if (e.key === 'Enter' && !e.shiftKey) { 101 + if (keyHandler.enter.blocked) { 102 + e.preventDefault(); 103 + return; 104 + } 105 + if (keyHandler.enter.canAdd) { 106 + e.preventDefault(); 107 + keyHandler.enter.blocked = true; 108 + clearMultiSelect(); 109 + addNewSubtask(state.currentTask, task); 110 + } 111 + } else if (e.key === 'ArrowDown' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) { 112 + if (keyHandler.arrowDown.blocked) { 113 + e.preventDefault(); 114 + return; 115 + } 116 + if (isLastSubtask(task) && state.lastSubtaskDownArrowReleased && task !== state.currentTask) { 117 + e.preventDefault(); 118 + keyHandler.arrowDown.blocked = true; 119 + addNewSubtask(state.currentTask, task); 120 + state.lastSubtaskDownArrowReleased = false; 121 + } else { 122 + handleKeyDown(e, task); 123 + } 124 + } else if (e.key === 'ArrowDown' && e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) { 125 + if (keyHandler.shiftArrowDown.blocked) { 126 + e.preventDefault(); 127 + return; 128 + } 129 + // Multi-select: insert new task above chunk when at bottom 130 + if (state.multiSelectedIds.length > 1 && !e.repeat) { 131 + var selected = getMultiSelectedTasks(); 132 + var lastSelected = selected[selected.length - 1]; 133 + if (isLastSubtask(lastSelected) && state.lastSubtaskShiftDownReleased) { 134 + e.preventDefault(); 135 + keyHandler.shiftArrowDown.blocked = true; 136 + var topIndex = state.currentTask.subtasks.findIndex(t => state.multiSelectedIds.includes(t.id)); 137 + var newSubtask = { id: generateId(), text: '', state: 0, subtasks: [], selectedSubtaskId: null }; 138 + state.currentTask.subtasks.splice(topIndex, 0, newSubtask); 139 + updateTaskAndAncestors(state.currentTask); 140 + state.currentTask.selectedSubtaskId = state.multiSelectAnchorId; 141 + renderCurrentView(); 142 + applyMultiSelectHighlights(); 143 + scheduleSave(); 144 + state.lastSubtaskShiftDownReleased = false; 145 + } else { 146 + handleKeyDown(e, task); 147 + } 148 + } else if (isLastSubtask(task) && state.lastSubtaskShiftDownReleased && task !== state.currentTask && !e.repeat) { 149 + e.preventDefault(); 150 + keyHandler.shiftArrowDown.blocked = true; 151 + var parentTask = findParentTask(task); 152 + if (parentTask) { 153 + var index = parentTask.subtasks.findIndex(t => t.id === task.id); 154 + var newSubtask = { id: generateId(), text: '', state: 0, subtasks: [], selectedSubtaskId: null }; 155 + parentTask.subtasks.splice(index, 0, newSubtask); 156 + updateTaskAndAncestors(parentTask); 157 + renderCurrentView(); 158 + selectAndFocusTask(task); 159 + scheduleSave(); 160 + } 161 + state.lastSubtaskShiftDownReleased = false; 162 + } else { 163 + handleKeyDown(e, task); 164 + } 165 + } else { 166 + handleKeyDown(e, task); 167 + } 168 + }; 169 + 170 + var keyupHandler = function(e) { 171 + if (e.key === 'Backspace') { 172 + keyHandler.backspace.canDelete = true; 173 + keyHandler.backspace.blocked = false; 174 + } else if (e.key === 'Enter') { 175 + keyHandler.enter.canAdd = true; 176 + keyHandler.enter.blocked = false; 177 + keyHandler.shiftEnter.pressed = false; 178 + } else if (e.key === 'ArrowDown') { 179 + keyHandler.arrowDown.canAdd = true; 180 + keyHandler.arrowDown.blocked = false; 181 + keyHandler.shiftArrowDown.blocked = false; 182 + var isAtBottom = state.multiSelectedIds.length > 1 183 + ? isLastSubtask(getMultiSelectedTasks().slice(-1)[0]) 184 + : isLastSubtask(task); 185 + if (isAtBottom) { 186 + state.lastSubtaskDownArrowReleased = true; 187 + state.lastSubtaskShiftDownReleased = true; 188 + } else { 189 + state.lastSubtaskDownArrowReleased = false; 190 + state.lastSubtaskShiftDownReleased = false; 191 + } 192 + } else if (e.key === 'ArrowRight') { 193 + keyHandler.shiftRight.pressed = false; 194 + } else if (e.key === 'ArrowLeft') { 195 + keyHandler.shiftLeft.pressed = false; 196 + } 197 + }; 198 + 199 + taskInput.addEventListener('keydown', keydownHandler); 200 + taskInput.addEventListener('keyup', keyupHandler); 201 + taskInput.addEventListener('keydown', handleCopyAndCut); 202 + taskInput.addEventListener('input', (e) => { 203 + var oldText = task.text; 204 + task.text = taskInput.value; 205 + // Propagate edits to all other multi-selected tasks 206 + if (state.multiSelectedIds.length > 1 && state.multiSelectedIds.includes(task.id)) { 207 + var otherSelected = getMultiSelectedTasks().filter(t => t.id !== task.id); 208 + 209 + if (e.inputType === 'insertText' && e.data) { 210 + for (var t of otherSelected) { 211 + t.text = t.text + e.data; 212 + var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`); 213 + if (otherInput) otherInput.value = t.text; 214 + } 215 + } else if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteContentForward') { 216 + for (var t of otherSelected) { 217 + if (t.text.length > 0) { 218 + t.text = t.text.slice(0, -1); 219 + } 220 + var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`); 221 + if (otherInput) otherInput.value = t.text; 222 + } 223 + } else if (e.inputType === 'insertFromPaste' || e.inputType === 'insertFromDrop') { 224 + var addedLen = taskInput.value.length - oldText.length; 225 + if (addedLen > 0) { 226 + var pastedText = taskInput.value.slice(taskInput.selectionStart - addedLen, taskInput.selectionStart); 227 + for (var t of otherSelected) { 228 + t.text = t.text + pastedText; 229 + var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`); 230 + if (otherInput) otherInput.value = t.text; 231 + } 232 + } 233 + } 234 + } 235 + if (task === state.currentTask) { 236 + updatePageTitle(task); 237 + } 238 + scheduleSave(); 239 + }); 240 + 241 + taskInput.addEventListener('focus', () => { 242 + if (state.multiSelectedIds.length > 1 && !state.multiSelectedIds.includes(task.id)) { 243 + clearMultiSelect(); 244 + } 245 + setActiveTask(taskInput, task); 246 + }); 247 + 248 + taskContainer.appendChild(checkbox); 249 + taskContainer.appendChild(checkboxLabel); 250 + taskContainer.appendChild(taskInput); 251 + 252 + return taskContainer; 253 +}
1 +// Task model: state calculations, checkbox logic, tree traversal 2 + 3 +function updateCheckboxState(checkbox, taskState) { 4 + checkbox.checked = taskState === 1; 5 + checkbox.indeterminate = taskState === 2; 6 +} 7 + 8 +function toggleTaskState(task) { 9 + // Save cursor position before re-rendering 10 + var taskInput = document.querySelector(`.task-container[data-id="${task.id}"] input[type="text"]`); 11 + var cursorPos = taskInput ? taskInput.selectionStart : null; 12 + 13 + if (task.state === 1) { 14 + if (task.subtasks.length === 0 || !task.subtasks.every(t => t.state === 1)) { 15 + task.state = 0; 16 + updateSubtasksState(task, 0); 17 + 18 + var parent = findParentTask(task); 19 + if (parent) { 20 + updateTaskAndAncestors(parent); 21 + } 22 + 23 + renderCurrentView(); 24 + selectAndFocusTask(task, cursorPos); 25 + scheduleSave(); 26 + } else { 27 + applyShakeAnimation(task.id); 28 + } 29 + } else { 30 + task.state = 1; 31 + updateSubtasksState(task, 1); 32 + 33 + var parent = findParentTask(task); 34 + if (parent) { 35 + updateTaskAndAncestors(parent); 36 + } 37 + 38 + renderCurrentView(); 39 + selectAndFocusTask(task, cursorPos); 40 + scheduleSave(); 41 + } 42 +} 43 + 44 +function recalculateTaskState(task) { 45 + if (task.subtasks.length === 0) { 46 + return task.state; 47 + } 48 + var anyUnchecked = task.subtasks.some(t => t.state === 0); 49 + var allChecked = task.subtasks.every(t => t.state === 1); 50 + var allCheckedOrIndeterminate = task.subtasks.every(t => t.state === 1 || t.state === 2); 51 + 52 + if (allChecked) { 53 + return 1; 54 + } else if (allCheckedOrIndeterminate) { 55 + return task.state === 2 ? 2 : 1; 56 + } else if (anyUnchecked) { 57 + return 0; 58 + } 59 +} 60 + 61 +function updateTaskAndAncestors(task) { 62 + var newState = recalculateTaskState(task); 63 + if (task.state !== newState) { 64 + var oldState = task.state; 65 + task.state = newState; 66 + 67 + if (oldState === 1 && newState === 0) { 68 + updateSubtasksState(task, 0); 69 + } 70 + 71 + var parent = findParentTask(task); 72 + if (parent) { 73 + updateTaskAndAncestors(parent); 74 + } 75 + } 76 +} 77 + 78 +function updateSubtasksState(task, newState) { 79 + task.subtasks.forEach(subtask => { 80 + if (subtask.state !== 1) { 81 + if (newState === 1) { 82 + subtask.state = subtask.state === 0 ? 2 : subtask.state; 83 + } else if (newState === 0) { 84 + subtask.state = 0; 85 + } 86 + if (subtask.subtasks.length > 0) { 87 + updateSubtasksState(subtask, newState); 88 + } 89 + } 90 + }); 91 +} 92 + 93 +function adjustMovedTaskState(movedTask, newParent) { 94 + if (movedTask.state === 2 && newParent.state === 0) { 95 + movedTask.state = 0; 96 + updateSubtasksState(movedTask, 0); 97 + } 98 +} 99 + 100 +function findParentTask(task) { 101 + for (var i = state.taskPath.length - 1; i >= 0; i--) { 102 + var potentialParent = state.taskPath[i]; 103 + if (potentialParent.subtasks.some(t => t.id === task.id)) { 104 + return potentialParent; 105 + } 106 + } 107 + return null; 108 +} 109 + 110 +function isLastSubtask(task) { 111 + var parentTask = findParentTask(task); 112 + if (!parentTask) return false; 113 + return parentTask.subtasks[parentTask.subtasks.length - 1].id === task.id; 114 +} 115 + 116 +function addNewSubtask(parentTask, currentSubtask = null) { 117 + var newSubtask = { id: generateId(), text: '', state: 0, subtasks: [], selectedSubtaskId: null }; 118 + if (currentSubtask) { 119 + var index = parentTask.subtasks.findIndex(t => t.id === currentSubtask.id); 120 + parentTask.subtasks.splice(index + 1, 0, newSubtask); 121 + } else { 122 + parentTask.subtasks.push(newSubtask); 123 + } 124 + 125 + updateTaskAndAncestors(parentTask); 126 + renderCurrentView(); 127 + selectAndFocusTask(newSubtask); 128 + scheduleSave(); 129 +} 130 + 131 +function deleteSubtask(subtask) { 132 + var parentTask = state.taskPath[state.taskPath.length - 1]; 133 + var index = parentTask.subtasks.findIndex(t => t.id === subtask.id); 134 + 135 + if (parentTask.id === 'root' && parentTask.subtasks.length === 1) { 136 + applyShakeAnimation(subtask.id); 137 + return; 138 + } 139 + 140 + parentTask.subtasks = parentTask.subtasks.filter(t => t.id !== subtask.id); 141 + updateTaskAndAncestors(parentTask); 142 + 143 + if (parentTask.subtasks.length === 0 && state.taskPath.length > 1) { 144 + navigateToParentTask(); 145 + } else { 146 + renderCurrentView(); 147 + if (parentTask.subtasks.length > 0) { 148 + var targetIndex = Math.max(0, index - 1); 149 + selectAndFocusTask(parentTask.subtasks[targetIndex]); 150 + } else { 151 + selectAndFocusTask(parentTask); 152 + } 153 + } 154 + scheduleSave(); 155 +} 156 + 157 +function deleteCurrentParentTask() { 158 + if (state.taskPath.length <= 1) return; 159 + 160 + var currentParentTask = state.taskPath[state.taskPath.length - 1]; 161 + var grandparentTask = state.taskPath[state.taskPath.length - 2]; 162 + 163 + if (grandparentTask.id === 'root' && grandparentTask.subtasks.length === 1) { 164 + applyShakeAnimation(currentParentTask.id); 165 + return; 166 + } 167 + 168 + var index = grandparentTask.subtasks.findIndex(t => t.id === currentParentTask.id); 169 + 170 + grandparentTask.subtasks = grandparentTask.subtasks.filter(t => t.id !== currentParentTask.id); 171 + updateTaskAndAncestors(grandparentTask); 172 + 173 + state.taskPath.pop(); 174 + state.currentTask = grandparentTask; 175 + 176 + if (grandparentTask.subtasks.length === 0 && state.taskPath.length > 1) { 177 + navigateToParentTask(); 178 + } else { 179 + renderCurrentView(); 180 + if (grandparentTask.subtasks.length > 0) { 181 + var targetIndex = Math.max(0, index - 1); 182 + selectAndFocusTask(grandparentTask.subtasks[targetIndex]); 183 + } else { 184 + selectAndFocusTask(grandparentTask); 185 + } 186 + } 187 +}
1 +// Theme: theme detection, cycling, and persistence 2 + 3 +function getThemesFromCSS() { 4 + for (var sheet of document.styleSheets) { 5 + try { 6 + for (var rule of sheet.cssRules) { 7 + var match = rule.selectorText && rule.selectorText.match(/:root\[data-theme="([^"]+)"\]/); 8 + if (match) { 9 + state.themes.push(match[1]); 10 + } 11 + } 12 + } catch (e) { 13 + // Skip cross-origin stylesheets 14 + } 15 + } 16 +} 17 + 18 +function cycleTheme() { 19 + state.currentThemeIndex = (state.currentThemeIndex + 1) % state.themes.length; 20 + var newTheme = state.themes[state.currentThemeIndex]; 21 + setTheme(newTheme); 22 + saveThemeToLocalStorage(newTheme); 23 +} 24 + 25 +function saveThemeToLocalStorage(theme) { 26 + localStorage.setItem('currentTheme', theme); 27 +} 28 + 29 +function updateFavicon() { 30 + var iconEmoji = getComputedStyle(document.documentElement).getPropertyValue('--icon').trim().replace(/'/g, ''); 31 + var faviconLink = document.querySelector('link[rel="icon"]'); 32 + if (faviconLink && iconEmoji) { 33 + faviconLink.href = `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>${iconEmoji}</text></svg>`; 34 + } 35 +} 36 + 37 +function setTheme(theme) { 38 + document.documentElement.setAttribute('data-theme', theme); 39 + var backgroundColor = getComputedStyle(document.documentElement).getPropertyValue('--background').trim(); 40 + document.getElementById('themeColor').setAttribute('content', backgroundColor); 41 + updateFavicon(); 42 +} 43 + 44 +function setInitialTheme() { 45 + var savedTheme = localStorage.getItem('currentTheme'); 46 + var defaultTheme = document.documentElement.getAttribute('data-theme'); 47 + 48 + if (savedTheme && state.themes.includes(savedTheme)) { 49 + state.currentThemeIndex = state.themes.indexOf(savedTheme); 50 + } else if (state.themes.includes(defaultTheme)) { 51 + state.currentThemeIndex = state.themes.indexOf(defaultTheme); 52 + } else { 53 + state.currentThemeIndex = 0; 54 + } 55 + 56 + setTheme(state.themes[state.currentThemeIndex]); 57 +}
1 +const CACHE_NAME = 'matryoshka-v1'; 2 +const ASSETS = [ 3 + './', 4 + 'index.html', 5 + 'manifest.json', 6 + 'resources/main.css', 7 + 'resources/state.js', 8 + 'resources/storage.js', 9 + 'resources/task-model.js', 10 + 'resources/multi-select.js', 11 + 'resources/navigation.js', 12 + 'resources/reorganize.js', 13 + 'resources/keyboard.js', 14 + 'resources/render.js', 15 + 'resources/task-element.js', 16 + 'resources/theme.js', 17 + 'resources/main.js', 18 + 'resources/icons/icon.svg', 19 + 'resources/fonts/Basteleur-Moonlight.ttf', 20 +]; 21 + 22 +// Cache assets on install 23 +self.addEventListener('install', (event) => { 24 + event.waitUntil( 25 + caches.open(CACHE_NAME) 26 + .then((cache) => cache.addAll(ASSETS)) 27 + .then(() => self.skipWaiting()) 28 + ); 29 +}); 30 + 31 +// Clean up old caches on activate 32 +self.addEventListener('activate', (event) => { 33 + event.waitUntil( 34 + caches.keys() 35 + .then((keys) => Promise.all( 36 + keys.filter((key) => key !== CACHE_NAME) 37 + .map((key) => caches.delete(key)) 38 + )) 39 + .then(() => self.clients.claim()) 40 + ); 41 +}); 42 + 43 +// Serve from cache, fall back to network, and update cache 44 +self.addEventListener('fetch', (event) => { 45 + event.respondWith( 46 + caches.match(event.request).then((cached) => { 47 + const fetchPromise = fetch(event.request).then((response) => { 48 + // Only cache same-origin, successful GET requests 49 + if (response.ok && event.request.method === 'GET') { 50 + const clone = response.clone(); 51 + caches.open(CACHE_NAME).then((cache) => { 52 + cache.put(event.request, clone); 53 + }); 54 + } 55 + return response; 56 + }).catch(() => cached); 57 + 58 + return cached || fetchPromise; 59 + }) 60 + ); 61 +});