commit 04685fead72fb76fda5cb21bb0c8302d077f5aca
parent bcc4acd28c1a045023fbe4d2e0a982404f57033d
Author: Hunter
Date:   Sun,  8 Mar 2026 09:49:22 -0400

break out css and js

Diffstat:
Mindex.html | 1997+------------------------------------------------------------------------------
Mreadme.md | 2+-
Aresources/keyboard.js | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/main.css | 346+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/main.js | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/multi-select.js | 220+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/navigation.js | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/render.js | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/reorganize.js | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/state.js | 23+++++++++++++++++++++++
Aresources/storage.js | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/task-element.js | 253+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/task-model.js | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/theme.js | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asw.js | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
15 files changed, 1968 insertions(+), 1982 deletions(-)

diff --git a/index.html b/index.html @@ -10,1989 +10,25 @@ <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>"> <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> + <link rel="stylesheet" href="resources/main.css"> + <script src="resources/state.js"></script> + <script src="resources/storage.js"></script> + <script src="resources/task-model.js"></script> + <script src="resources/multi-select.js"></script> + <script src="resources/navigation.js"></script> + <script src="resources/reorganize.js"></script> + <script src="resources/keyboard.js"></script> + <script src="resources/render.js"></script> + <script src="resources/task-element.js"></script> + <script src="resources/theme.js"></script> + <script src="resources/main.js"></script> <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(); - }); + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('sw.js'); + } </script> </head> <body> <div id="app-container"></div> </body> -</html> -\ No newline at end of file +</html> diff --git a/readme.md b/readme.md @@ -11,7 +11,7 @@ the nested todo list that breaks complex tasks into manageable subtasks. - unlimited subtask depth - intuitive keyboard controls - automatic saving using browser's local storage -- portable, "single html file" architecture that works completely offline +- installable as a <a href="https://hunterirving.github.io/web_workshop/pages/pwa">Progressive Web App</a> ### quickstart 1. press the `Return` / `Enter` key to add subtasks to the root "todo" task diff --git a/resources/keyboard.js b/resources/keyboard.js @@ -0,0 +1,175 @@ +// Keyboard: key handler state and handleKeyDown dispatch + +var 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 handleKeyDown(e, task) { + var cmd = e.metaKey || e.ctrlKey; + var hasMultiSelect = state.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 + } else if (e.key === 'ArrowUp' && cmd && !e.shiftKey && !e.altKey) { + e.preventDefault(); + if (!e.repeat) { + var success = hasMultiSelect ? pushMultiSelectedIntoTarget('up') : pushSubtaskIntoTarget(task, 'up'); + if (!success) { + hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical'); + } + } + // Cmd+Down: push into task below + } else if (e.key === 'ArrowDown' && cmd && !e.shiftKey && !e.altKey) { + e.preventDefault(); + if (!e.repeat) { + var 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 + } else if (e.key === 'ArrowLeft' && cmd && !e.shiftKey && !e.altKey) { + e.preventDefault(); + if (!e.repeat) { + var success = hasMultiSelect ? pullMultiSelectedOutLayer() : pullSubtaskOutLayer(task); + if (!success) { + hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id); + } + } + // Cmd+Shift+Up: push and navigate + } else if (e.key === 'ArrowUp' && cmd && e.shiftKey && !e.altKey) { + e.preventDefault(); + if (!e.repeat) { + var success = hasMultiSelect ? pushMultiSelectedIntoTarget('up', true) : pushSubtaskIntoTarget(task, 'up', true); + if (!success) { + hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical'); + } + } + // Cmd+Shift+Down: push and navigate + } else if (e.key === 'ArrowDown' && cmd && e.shiftKey && !e.altKey) { + e.preventDefault(); + if (!e.repeat) { + var success = hasMultiSelect ? pushMultiSelectedIntoTarget('down', true) : pushSubtaskIntoTarget(task, 'down', true); + if (!success) { + hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical'); + } + } + // Cmd+Shift+Left: pull and navigate + } else if (e.key === 'ArrowLeft' && cmd && e.shiftKey && !e.altKey) { + e.preventDefault(); + if (!e.repeat) { + var 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 + } 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: navigate into subtask (blocked during multi-select) + } 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 !== state.currentTask) { + navigateIntoSubtask(task); + } else { + applyShakeAnimation(task.id); + } + } + // Shift+Left: navigate to parent + } else if (e.key === 'ArrowLeft' && e.shiftKey && !e.altKey) { + e.preventDefault(); + if (hasMultiSelect && state.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(); + } + } +} diff --git a/resources/main.css b/resources/main.css @@ -0,0 +1,346 @@ +/* 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('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; + } +} diff --git a/resources/main.js b/resources/main.js @@ -0,0 +1,84 @@ +// Main: initialization and global event listeners + +document.addEventListener('click', function(e) { + if (!e.target.closest('.task-container')) { + var activeTask = document.querySelector('.task-container.active'); + if (activeTask) { + var taskInput = activeTask.querySelector('input[type="text"]'); + taskInput.focus(); + } else { + selectFirstSubtask(); + } + } +}); + +document.addEventListener('DOMContentLoaded', function() { + state.appContainer = document.getElementById('app-container'); + state.rootTask = loadTasksFromLocalStorage(); + state.currentTask = state.rootTask; + state.taskPath = [state.currentTask]; + + // Scroll handler to block default scroll behavior + function handleScroll(e) { + e.preventDefault(); + } + window.addEventListener('wheel', handleScroll, { passive: false }); + + // Print handlers + function handleBeforePrint() { + var printContent = document.getElementById('print-content'); + if (!printContent) { + printContent = document.createElement('div'); + printContent.id = 'print-content'; + document.body.appendChild(printContent); + } + var serializedTasks = serializeTaskTree(state.currentTask); + printContent.textContent = serializedTasks; + } + + function handleAfterPrint() { + var printContent = document.getElementById('print-content'); + if (printContent) { + printContent.remove(); + } + } + + window.addEventListener('beforeprint', handleBeforePrint); + window.addEventListener('afterprint', handleAfterPrint); + + // Theme cycling with F2 + document.addEventListener('keydown', function(event) { + if (event.key === 'F2' && !state.isF2Pressed) { + event.preventDefault(); + state.isF2Pressed = true; + cycleTheme(); + } + }); + + document.addEventListener('keyup', function(event) { + if (event.key === 'F2') { + state.isF2Pressed = false; + } + }); + + // File save/open handlers + document.addEventListener('keydown', handleSave); + document.addEventListener('keydown', handleOpen); + + // Reset cursor on non-focused inputs + state.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); + } + }); + } + }); + + // Initialize + getThemesFromCSS(); + setInitialTheme(); + renderCurrentView(); + selectFirstSubtask(); +}); diff --git a/resources/multi-select.js b/resources/multi-select.js @@ -0,0 +1,220 @@ +// Multi-select: state management and bulk operations + +function clearMultiSelect() { + state.multiSelectAnchorId = null; + state.multiSelectedIds = []; + var focused = document.activeElement; + var focusedContainer = focused ? focused.closest('.task-container') : null; + document.querySelectorAll('.task-container.active').forEach(el => { + if (el !== focusedContainer) el.classList.remove('active'); + }); +} + +function getMultiSelectedTasks() { + return state.currentTask.subtasks.filter(t => state.multiSelectedIds.includes(t.id)); +} + +function applyMultiSelectHighlights() { + for (var id of state.multiSelectedIds) { + var container = document.querySelector(`.task-container[data-id="${id}"]`); + if (container) container.classList.add('active'); + } +} + +function shakeAllSelected(direction = 'horizontal') { + for (var id of state.multiSelectedIds) { + applyShakeAnimation(id, direction); + } +} + +function extendMultiSelect(task, direction) { + if (task === state.currentTask) return; + + var tasks = state.currentTask.subtasks; + if (tasks.length === 0) return; + + if (state.multiSelectAnchorId === null) { + state.multiSelectAnchorId = task.id; + state.multiSelectedIds = [task.id]; + } + + var anchorIndex = tasks.findIndex(t => t.id === state.multiSelectAnchorId); + var currentIndex = tasks.findIndex(t => t.id === task.id); + + if (direction === 'down') { + var bottomIndex = tasks.findIndex((t, i) => { + return state.multiSelectedIds.includes(t.id) && + (i === tasks.length - 1 || !state.multiSelectedIds.includes(tasks[i + 1].id)); + }); + if (currentIndex <= anchorIndex && state.multiSelectedIds.length > 1) { + state.multiSelectedIds = state.multiSelectedIds.filter(id => id !== task.id); + var nextTask = tasks[currentIndex + 1]; + if (nextTask) selectAndFocusTask(nextTask); + } else if (bottomIndex < tasks.length - 1) { + var nextTask = tasks[bottomIndex + 1]; + state.multiSelectedIds.push(nextTask.id); + selectAndFocusTask(nextTask); + } + } else { + var topIndex = tasks.findIndex(t => state.multiSelectedIds.includes(t.id)); + if (currentIndex >= anchorIndex && state.multiSelectedIds.length > 1) { + state.multiSelectedIds = state.multiSelectedIds.filter(id => id !== task.id); + var prevTask = tasks[currentIndex - 1]; + if (prevTask) selectAndFocusTask(prevTask); + } else if (topIndex > 0) { + var prevTask = tasks[topIndex - 1]; + state.multiSelectedIds.unshift(prevTask.id); + selectAndFocusTask(prevTask); + } + } + + applyMultiSelectHighlights(); +} + +function moveMultiSelected(direction) { + var selected = getMultiSelectedTasks(); + if (selected.length === 0) return false; + + var subtasks = state.currentTask.subtasks; + var indices = selected.map(t => subtasks.findIndex(s => s.id === t.id)).sort((a, b) => a - b); + var topIndex = indices[0]; + var bottomIndex = indices[indices.length - 1]; + + if (direction === 'up' && topIndex <= 0) return false; + if (direction === 'down' && bottomIndex >= subtasks.length - 1) return false; + + var chunk = subtasks.splice(topIndex, selected.length); + var insertAt = direction === 'up' ? topIndex - 1 : topIndex + 1; + subtasks.splice(insertAt, 0, ...chunk); + + state.currentTask.selectedSubtaskId = state.multiSelectAnchorId; + renderCurrentView(); + applyMultiSelectHighlights(); + scheduleSave(); + return true; +} + +function pushMultiSelectedIntoTarget(direction, navigate = false) { + var selected = getMultiSelectedTasks(); + if (selected.length === 0) return false; + + if (navigate) { + document.documentElement.style.scrollBehavior = 'auto'; + } + + var subtasks = state.currentTask.subtasks; + var indices = selected.map(t => subtasks.findIndex(s => s.id === t.id)).sort((a, b) => a - b); + var topIndex = indices[0]; + var bottomIndex = indices[indices.length - 1]; + + var 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; + + var chunk = selected.slice(); + state.currentTask.subtasks = state.currentTask.subtasks.filter(t => !state.multiSelectedIds.includes(t.id)); + + targetTask.subtasks.unshift(...chunk); + + for (var t of chunk) { + adjustMovedTaskState(t, targetTask); + } + targetTask.selectedSubtaskId = state.multiSelectAnchorId; + updateTaskAndAncestors(state.currentTask); + updateTaskAndAncestors(targetTask); + + if (navigate) { + state.currentTask.selectedSubtaskId = targetTask.id; + state.taskPath.push(targetTask); + state.currentTask = targetTask; + updateBreadcrumbs(state.currentTask); + renderCurrentView(); + applyMultiSelectHighlights(); + } else { + clearMultiSelect(); + renderCurrentView(); + selectAndFocusTask(targetTask); + } + + scheduleSave(); + return true; +} + +function pullMultiSelectedOutLayer(navigate = false) { + if (state.taskPath.length <= 1) return false; + + var selected = getMultiSelectedTasks(); + if (selected.length === 0) return false; + + if (navigate) { + document.documentElement.style.scrollBehavior = 'auto'; + } + + var currentParent = state.currentTask; + var grandParent = state.taskPath[state.taskPath.length - 2]; + if (!grandParent) return false; + + currentParent.subtasks = currentParent.subtasks.filter(t => !state.multiSelectedIds.includes(t.id)); + + var parentIndex = grandParent.subtasks.findIndex(t => t.id === currentParent.id); + if (parentIndex === -1) { + grandParent.subtasks.push(...selected); + } else { + grandParent.subtasks.splice(parentIndex + 1, 0, ...selected); + } + + for (var t of selected) { + adjustMovedTaskState(t, grandParent); + } + + updateTaskAndAncestors(currentParent); + updateTaskAndAncestors(grandParent); + + if (navigate || currentParent.subtasks.length === 0) { + state.taskPath.pop(); + state.currentTask = state.taskPath[state.taskPath.length - 1]; + state.currentTask.selectedSubtaskId = state.multiSelectAnchorId; + updateBreadcrumbs(state.currentTask); + renderCurrentView(); + applyMultiSelectHighlights(); + } else { + clearMultiSelect(); + renderCurrentView(); + if (currentParent.subtasks.length > 0) { + selectAndFocusTask(currentParent.subtasks[0]); + } else { + selectAndFocusTask(currentParent); + } + } + + scheduleSave(); + return true; +} + +function bulkToggleTaskState() { + var selected = getMultiSelectedTasks(); + if (selected.length === 0) return; + + var anyUnchecked = selected.some(t => t.state !== 1); + var newState = anyUnchecked ? 1 : 0; + + for (var t of selected) { + function setAllSubtasks(task, s) { + task.state = s; + for (var sub of task.subtasks) { + setAllSubtasks(sub, s); + } + } + setAllSubtasks(t, newState); + } + + updateTaskAndAncestors(state.currentTask); + state.currentTask.selectedSubtaskId = state.multiSelectAnchorId; + renderCurrentView(); + applyMultiSelectHighlights(); + scheduleSave(); +} diff --git a/resources/navigation.js b/resources/navigation.js @@ -0,0 +1,111 @@ +// Navigation: moving between tasks, navigating into/out of subtask levels + +function navigateTasks(direction) { + var tasks = state.currentTask.subtasks; + var subtaskIndex = tasks.findIndex(t => t.id === state.currentTask.selectedSubtaskId); + + var currentElement = document.activeElement; + var currentContainer = currentElement ? currentElement.closest('.task-container') : null; + var isParentFocused = currentContainer && currentContainer.dataset.id == state.currentTask.id; + + if (direction === 'up') { + if (isParentFocused) { + // Already at parent, can't go higher + } else if (subtaskIndex <= 0) { + selectAndFocusTask(state.currentTask); + } else { + selectAndFocusTask(tasks[subtaskIndex - 1]); + } + } else { + if (isParentFocused) { + if (tasks.length > 0) { + selectAndFocusTask(tasks[0]); + } + } else if (subtaskIndex >= 0 && subtaskIndex < tasks.length - 1) { + selectAndFocusTask(tasks[subtaskIndex + 1]); + } + } + state.lastSubtaskDownArrowReleased = false; + state.lastSubtaskShiftDownReleased = false; +} + +function navigateIntoSubtask(subtask) { + document.documentElement.style.scrollBehavior = 'auto'; + + if (subtask.subtasks.length > 0) { + state.currentTask.selectedSubtaskId = subtask.id; + state.taskPath.push(subtask); + state.currentTask = subtask; + updateBreadcrumbs(state.currentTask); + renderCurrentView(); + var selectedSubtask = subtask.selectedSubtaskId ? + subtask.subtasks.find(t => t.id === subtask.selectedSubtaskId) : + subtask.subtasks[0]; + selectAndFocusTask(selectedSubtask); + } else { + addNewSubtask(subtask); + state.currentTask.selectedSubtaskId = subtask.id; + state.taskPath.push(subtask); + state.currentTask = subtask; + updateBreadcrumbs(state.currentTask); + renderCurrentView(); + selectAndFocusTask(subtask.subtasks[0]); + } +} + +function navigateIntoTaskAndSelectSubtask(targetTask, subtaskToSelect) { + document.documentElement.style.scrollBehavior = 'auto'; + + state.currentTask.selectedSubtaskId = targetTask.id; + + state.taskPath.push(targetTask); + state.currentTask = targetTask; + updateBreadcrumbs(state.currentTask); + renderCurrentView(); + + selectAndFocusTask(subtaskToSelect); + state.currentTask.selectedSubtaskId = subtaskToSelect.id; +} + +function navigateToParentTask() { + if (state.currentTask.id === 'root') { + var activeTaskElement = document.querySelector('.task-container.active'); + if (activeTaskElement) { + var taskId = activeTaskElement.dataset.id; + applyShakeAnimation(taskId); + } + } else if (state.taskPath.length > 1) { + document.documentElement.style.scrollBehavior = 'auto'; + + var currentTaskId = state.currentTask.id; + state.taskPath.pop(); + state.currentTask = state.taskPath[state.taskPath.length - 1]; + updateBreadcrumbs(state.currentTask); + renderCurrentView(); + var selectedSubtask = state.currentTask.subtasks.find(t => t.id === currentTaskId); + if (selectedSubtask) { + selectAndFocusTask(selectedSubtask); + } else if (state.currentTask.subtasks.length > 0) { + selectAndFocusTask(state.currentTask.subtasks[0]); + } else { + selectAndFocusTask(state.currentTask); + } + state.currentTask.selectedSubtaskId = currentTaskId; + } +} + +function navigateToParentTaskAndSelectTask(targetTask) { + if (state.currentTask.id === 'root') { + renderCurrentView(); + selectAndFocusTask(targetTask); + } else if (state.taskPath.length > 1) { + document.documentElement.style.scrollBehavior = 'auto'; + + state.taskPath.pop(); + state.currentTask = state.taskPath[state.taskPath.length - 1]; + updateBreadcrumbs(state.currentTask); + renderCurrentView(); + selectAndFocusTask(targetTask); + state.currentTask.selectedSubtaskId = targetTask.id; + } +} diff --git a/resources/render.js b/resources/render.js @@ -0,0 +1,197 @@ +// Render: view rendering, breadcrumbs, UI updates, and shared DOM helpers + +function generateBreadcrumbs(rootTask, currentPath, selectedTaskId) { + var breadcrumbs = ''; + var currentTask = rootTask; + var currentDepth = 0; + + for (var 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) { + var 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))); + } + + var maxDepth = calculateMaxDepth(selectedTask, currentDepth + 1); + + for (var i = currentDepth + 1; i < maxDepth; i++) { + breadcrumbs += '○ '; + } + } + } + + return breadcrumbs.trim(); +} + +function applyShakeAnimation(taskId, direction = 'horizontal') { + var checkbox = document.querySelector(`.task-container[data-id="${taskId}"] .checkbox-label`); + if (checkbox) { + var 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 }); + var clearShaking = () => { + delete checkbox.dataset.shaking; + document.removeEventListener('keyup', clearShaking); + }; + document.addEventListener('keyup', clearShaking); + } +} + +function selectAndFocusTask(task, cursorPos) { + var taskInput = document.querySelector(`.task-container[data-id="${task.id}"] input[type="text"]`); + if (taskInput) { + taskInput.focus(); + var pos = cursorPos != null ? cursorPos : taskInput.value.length; + taskInput.setSelectionRange(pos, pos); + setActiveTask(taskInput, task); + } +} + +function placeCursorAtBeginning(input) { + input.setSelectionRange(0, 0); +} + +function setActiveTask(input, task) { + document.querySelectorAll('.active').forEach(el => el.classList.remove('active')); + input.closest('.task-container').classList.add('active'); + if (task !== state.currentTask) { + state.currentTask.selectedSubtaskId = task.id; + } + // Re-apply multi-select highlights after clearing + if (state.multiSelectedIds.length > 1) { + applyMultiSelectHighlights(); + } + updateBreadcrumbs(task); + // During multi-select, check bottommost selected task for "last subtask" status + var bottomTask = state.multiSelectedIds.length > 1 + ? getMultiSelectedTasks().slice(-1)[0] + : task; + state.lastSubtaskDownArrowReleased = isLastSubtask(bottomTask); + state.lastSubtaskShiftDownReleased = isLastSubtask(bottomTask); + input.focus(); + + // Center the active task in the viewport + var activeTaskElement = input.closest('.task-container'); + if (activeTaskElement && activeTaskElement.parentElement && activeTaskElement.parentElement.tagName === 'LI') { + activeTaskElement.scrollIntoView({ + behavior: 'auto', + block: 'center', + inline: 'nearest' + }); + + setTimeout(() => { + document.documentElement.style.scrollBehavior = 'smooth'; + }, 200); + } +} + +function updateBreadcrumbs(selectedTask) { + var breadcrumbsContainer = document.getElementById('breadcrumbs'); + var effectiveId = state.multiSelectedIds.length > 1 ? state.currentTask.id : selectedTask.id; + var trail = generateBreadcrumbs(state.taskPath[0], state.taskPath, effectiveId); + breadcrumbsContainer.textContent = trail; +} + +function updatePageTitle(task) { + document.title = task.text || '?'; +} + +function selectFirstSubtask() { + if (state.currentTask.subtasks.length > 0) { + var firstSubtask = state.currentTask.subtasks[0]; + selectAndFocusTask(firstSubtask); + } else { + selectAndFocusTask(state.currentTask); + } +} + +function handleCopyAndCut(e) { + if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'x')) { + var activeTaskInput = document.querySelector('.task-container.active input[type="text"]'); + if (activeTaskInput) { + e.preventDefault(); + + if (activeTaskInput.selectionStart === activeTaskInput.selectionEnd) { + activeTaskInput.select(); + } + + if (e.key === 'c') { + document.execCommand('copy'); + } else if (e.key === 'x') { + document.execCommand('cut'); + + var taskContainer = activeTaskInput.closest('.task-container'); + var taskId = taskContainer.dataset.id; + var task = state.currentTask.id === taskId ? state.currentTask : state.currentTask.subtasks.find(t => t.id === taskId); + if (task) { + task.text = activeTaskInput.value; + scheduleSave(); + } + } + + if (e.key === 'c' && activeTaskInput.selectionStart === 0 && activeTaskInput.selectionEnd === activeTaskInput.value.length) { + activeTaskInput.setSelectionRange(activeTaskInput.value.length, activeTaskInput.value.length); + } + } + } +} + +function renderCurrentView() { + state.appContainer.innerHTML = ''; + state.currentTask = state.taskPath[state.taskPath.length - 1]; + + var stickyHeader = document.createElement('div'); + stickyHeader.className = 'sticky-header'; + + var breadcrumbsElement = document.createElement('div'); + breadcrumbsElement.id = 'breadcrumbs'; + stickyHeader.appendChild(breadcrumbsElement); + + var parentElement = createTaskElement(state.currentTask, true); + stickyHeader.appendChild(parentElement); + + state.appContainer.appendChild(stickyHeader); + + var subtasksContainer = document.createElement('div'); + subtasksContainer.id = 'subtasks-container'; + + var subtasksList = document.createElement('ul'); + state.currentTask.subtasks.forEach(subtask => { + var li = document.createElement('li'); + li.appendChild(createTaskElement(subtask)); + subtasksList.appendChild(li); + }); + subtasksContainer.appendChild(subtasksList); + state.appContainer.appendChild(subtasksContainer); + + updateBreadcrumbs(state.currentTask); + updatePageTitle(state.currentTask); + + var parentCheckbox = parentElement.querySelector('input[type="checkbox"]'); + updateCheckboxState(parentCheckbox, state.currentTask.state); + + if (state.currentTask.selectedSubtaskId) { + var selectedTask = state.currentTask.subtasks.find(t => t.id === state.currentTask.selectedSubtaskId); + if (selectedTask) { + selectAndFocusTask(selectedTask); + } else { + selectFirstSubtask(); + } + } else { + selectFirstSubtask(); + } +} diff --git a/resources/reorganize.js b/resources/reorganize.js @@ -0,0 +1,110 @@ +// Reorganize: single-task move, push, and pull operations + +function moveSubtask(subtask, direction) { + if (subtask === state.currentTask) return false; + + var parentTask = findParentTask(subtask); + if (!parentTask) return false; + + var 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) { + if (subtask === state.currentTask) return false; + + if (navigate) { + document.documentElement.style.scrollBehavior = 'auto'; + } + + var parentTask = findParentTask(subtask); + if (!parentTask) return false; + + var index = parentTask.subtasks.findIndex(t => t.id === subtask.id); + if (index === -1) return false; + + var 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; + + parentTask.subtasks.splice(index, 1); + targetTask.subtasks.unshift(subtask); + targetTask.selectedSubtaskId = subtask.id; + + adjustMovedTaskState(subtask, targetTask); + updateTaskAndAncestors(parentTask); + updateTaskAndAncestors(targetTask); + + if (navigate) { + navigateIntoTaskAndSelectSubtask(targetTask, subtask); + } else { + renderCurrentView(); + selectAndFocusTask(targetTask); + } + + scheduleSave(); + return true; +} + +function pullSubtaskOutLayer(subtask, navigate = false) { + if (subtask === state.currentTask) return false; + if (state.taskPath.length <= 1) return false; + + if (navigate) { + document.documentElement.style.scrollBehavior = 'auto'; + } + + var currentParent = findParentTask(subtask); + if (!currentParent) return; + + var grandParent = findParentTask(currentParent); + if (!grandParent) return; + + var currentIndex = currentParent.subtasks.findIndex(t => t.id === subtask.id); + if (currentIndex === -1) return; + currentParent.subtasks.splice(currentIndex, 1); + + var currentParentIndex = grandParent.subtasks.findIndex(t => t.id === currentParent.id); + if (currentParentIndex === -1) { + grandParent.subtasks.push(subtask); + } else { + grandParent.subtasks.splice(currentParentIndex + 1, 0, subtask); + } + + adjustMovedTaskState(subtask, grandParent); + updateTaskAndAncestors(currentParent); + updateTaskAndAncestors(grandParent); + + if (navigate || (currentParent.subtasks.length === 0 && state.taskPath.length > 1)) { + navigateToParentTaskAndSelectTask(subtask); + } else { + renderCurrentView(); + if (currentParent.subtasks.length > 0) { + var targetIndex = Math.max(0, currentIndex - 1); + selectAndFocusTask(currentParent.subtasks[targetIndex]); + } else { + selectAndFocusTask(currentParent); + } + } + + scheduleSave(); + return true; +} diff --git a/resources/state.js b/resources/state.js @@ -0,0 +1,23 @@ +// Shared mutable application state + +var state = { + appContainer: null, + rootTask: null, + currentTask: null, + taskPath: [], + lastSubtaskDownArrowReleased: false, + lastSubtaskShiftDownReleased: false, + saveTimer: null, + currentThemeIndex: 0, + isF2Pressed: false, + themes: [], + isInWheelEvent: false, + + // Multi-select state + multiSelectAnchorId: null, + multiSelectedIds: [], +}; + +function generateId() { + return Date.now().toString(36) + Math.random().toString(36).slice(2); +} diff --git a/resources/storage.js b/resources/storage.js @@ -0,0 +1,127 @@ +// Storage: localStorage persistence, file import/export, serialization + +function scheduleSave() { + if (state.saveTimer) { + clearTimeout(state.saveTimer); + } + state.saveTimer = setTimeout(saveTasksToLocalStorage, 1000); +} + +function saveTasksToLocalStorage() { + var serializedTasks = serializeTaskTree(state.taskPath[0]); + localStorage.setItem('taskTree', serializedTasks); + state.saveTimer = null; +} + +function serializeTaskTree(task, depth = 0) { + var indentation = '\t'.repeat(depth); + var status = task.state === 0 ? '_' : (task.state === 1 ? 'x' : '?'); + var serialized = `${indentation}${status} ${task.text}\n`; + + for (var subtask of task.subtasks) { + serialized += serializeTaskTree(subtask, depth + 1); + } + + return serialized; +} + +function deserializeTaskTree(serialized) { + var lines = serialized.split('\n').filter(line => line.trim() !== ''); + var root = { id: 'root', subtasks: [] }; + var stack = [{ task: root, depth: -1 }]; + + for (var line of lines) { + var depth = (line.match(/^\t*/)[0] || '').length; + var status = line[depth]; + var text = line.slice(depth + 2); + + var 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; +} + +function loadTasksFromLocalStorage() { + var 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 handleSave(e) { + if ((e.metaKey || e.ctrlKey) && e.key === 's') { + e.preventDefault(); + saveTaskTreeToFile(); + } +} + +function saveTaskTreeToFile() { + var serializedTasks = serializeTaskTree(state.taskPath[0]); + var rootTaskName = state.taskPath[0].text || 'Untitled'; + var date = new Date(); + var fileName = `${rootTaskName} - ${date.toLocaleString('default', { month: 'short' }).toLowerCase()} ${date.getDate()}, '${date.getFullYear().toString().slice(-2)}.txt`; + + var blob = new Blob([serializedTasks], { type: 'text/plain' }); + var url = URL.createObjectURL(blob); + + var 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() { + var input = document.createElement('input'); + input.type = 'file'; + input.accept = '.txt'; + input.onchange = function(event) { + var file = event.target.files[0]; + var reader = new FileReader(); + reader.onload = function(e) { + try { + var newRootTask = deserializeTaskTree(e.target.result); + if (confirm('Are you sure you want to overwrite the existing task tree?')) { + state.rootTask = newRootTask; + state.currentTask = state.rootTask; + state.taskPath = [state.currentTask]; + renderCurrentView(); + saveTasksToLocalStorage(); + } + } catch (error) { + alert(`Error importing task tree: ${error.message}`); + } + }; + reader.readAsText(file); + }; + input.click(); +} diff --git a/resources/task-element.js b/resources/task-element.js @@ -0,0 +1,253 @@ +// Task element: DOM creation and per-element event handlers + +function createTaskElement(task, isParentTask = false) { + var taskContainer = document.createElement('div'); + taskContainer.className = 'task-container'; + taskContainer.dataset.id = task.id; + if (isParentTask) taskContainer.classList.add('parent-task'); + + var 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); + }); + + var checkboxLabel = document.createElement('label'); + checkboxLabel.className = 'checkbox-label'; + checkboxLabel.setAttribute('for', `checkbox-${task.id}`); + + var taskInput = document.createElement('input'); + taskInput.type = 'text'; + taskInput.value = task.text; + taskInput.setAttribute('autocomplete', 'off'); + + taskInput.addEventListener('mousedown', function(e) { + e.stopPropagation(); + }); + + var keydownHandler = function(e) { + if (e.key === 'Backspace') { + if (keyHandler.backspace.blocked) { + e.preventDefault(); + return; + } + // Multi-select backspace handling + if (state.multiSelectedIds.length > 1) { + var selected = getMultiSelectedTasks(); + var allEmpty = selected.every(t => t.text === ''); + if (allEmpty) { + e.preventDefault(); + if (keyHandler.backspace.canDelete) { + keyHandler.backspace.blocked = true; + var toDelete = selected.filter(t => t !== state.taskPath[0] && t !== state.currentTask); + if (toDelete.length > 0 && !(state.currentTask.id === 'root' && toDelete.length >= state.currentTask.subtasks.length)) { + var firstDeleteIdx = Math.min(...toDelete.map(t => state.currentTask.subtasks.findIndex(s => s.id === t.id)).filter(i => i !== -1)); + for (var t of toDelete) { + var idx = state.currentTask.subtasks.findIndex(s => s.id === t.id); + if (idx !== -1) state.currentTask.subtasks.splice(idx, 1); + } + clearMultiSelect(); + updateTaskAndAncestors(state.currentTask); + if (state.currentTask.subtasks.length === 0 && state.taskPath.length > 1) { + navigateToParentTask(); + } else { + renderCurrentView(); + var targetIndex = Math.max(0, firstDeleteIdx - 1); + selectAndFocusTask(state.currentTask.subtasks[targetIndex]); + } + scheduleSave(); + } else { + for (var t of selected) { + applyShakeAnimation(t.id); + } + } + } + return; + } + // Some tasks still have text: remove last char from all that have text + e.preventDefault(); + for (var t of selected) { + if (t.text.length > 0) { + t.text = t.text.slice(0, -1); + var inp = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`); + if (inp) inp.value = t.text; + } + } + keyHandler.backspace.canDelete = false; + scheduleSave(); + return; + } + if (taskInput.value === '' && keyHandler.backspace.canDelete && state.multiSelectedIds.length <= 1) { + e.preventDefault(); + if (task !== state.taskPath[0]) { + keyHandler.backspace.blocked = true; + clearMultiSelect(); + if (task === state.currentTask) { + deleteCurrentParentTask(); + } else { + deleteSubtask(task); + } + } else { + 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(state.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) && state.lastSubtaskDownArrowReleased && task !== state.currentTask) { + e.preventDefault(); + keyHandler.arrowDown.blocked = true; + addNewSubtask(state.currentTask, task); + state.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 (state.multiSelectedIds.length > 1 && !e.repeat) { + var selected = getMultiSelectedTasks(); + var lastSelected = selected[selected.length - 1]; + if (isLastSubtask(lastSelected) && state.lastSubtaskShiftDownReleased) { + e.preventDefault(); + keyHandler.shiftArrowDown.blocked = true; + var topIndex = state.currentTask.subtasks.findIndex(t => state.multiSelectedIds.includes(t.id)); + var newSubtask = { id: generateId(), text: '', state: 0, subtasks: [], selectedSubtaskId: null }; + state.currentTask.subtasks.splice(topIndex, 0, newSubtask); + updateTaskAndAncestors(state.currentTask); + state.currentTask.selectedSubtaskId = state.multiSelectAnchorId; + renderCurrentView(); + applyMultiSelectHighlights(); + scheduleSave(); + state.lastSubtaskShiftDownReleased = false; + } else { + handleKeyDown(e, task); + } + } else if (isLastSubtask(task) && state.lastSubtaskShiftDownReleased && task !== state.currentTask && !e.repeat) { + e.preventDefault(); + keyHandler.shiftArrowDown.blocked = true; + var parentTask = findParentTask(task); + if (parentTask) { + var index = parentTask.subtasks.findIndex(t => t.id === task.id); + var newSubtask = { id: generateId(), text: '', state: 0, subtasks: [], selectedSubtaskId: null }; + parentTask.subtasks.splice(index, 0, newSubtask); + updateTaskAndAncestors(parentTask); + renderCurrentView(); + selectAndFocusTask(task); + scheduleSave(); + } + state.lastSubtaskShiftDownReleased = false; + } else { + handleKeyDown(e, task); + } + } else { + handleKeyDown(e, task); + } + }; + + var 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; + var isAtBottom = state.multiSelectedIds.length > 1 + ? isLastSubtask(getMultiSelectedTasks().slice(-1)[0]) + : isLastSubtask(task); + if (isAtBottom) { + state.lastSubtaskDownArrowReleased = true; + state.lastSubtaskShiftDownReleased = true; + } else { + state.lastSubtaskDownArrowReleased = false; + state.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) => { + var oldText = task.text; + task.text = taskInput.value; + // Propagate edits to all other multi-selected tasks + if (state.multiSelectedIds.length > 1 && state.multiSelectedIds.includes(task.id)) { + var otherSelected = getMultiSelectedTasks().filter(t => t.id !== task.id); + + if (e.inputType === 'insertText' && e.data) { + for (var t of otherSelected) { + t.text = t.text + e.data; + var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`); + if (otherInput) otherInput.value = t.text; + } + } else if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteContentForward') { + for (var t of otherSelected) { + if (t.text.length > 0) { + t.text = t.text.slice(0, -1); + } + var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`); + if (otherInput) otherInput.value = t.text; + } + } else if (e.inputType === 'insertFromPaste' || e.inputType === 'insertFromDrop') { + var addedLen = taskInput.value.length - oldText.length; + if (addedLen > 0) { + var pastedText = taskInput.value.slice(taskInput.selectionStart - addedLen, taskInput.selectionStart); + for (var t of otherSelected) { + t.text = t.text + pastedText; + var otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`); + if (otherInput) otherInput.value = t.text; + } + } + } + } + if (task === state.currentTask) { + updatePageTitle(task); + } + scheduleSave(); + }); + + taskInput.addEventListener('focus', () => { + if (state.multiSelectedIds.length > 1 && !state.multiSelectedIds.includes(task.id)) { + clearMultiSelect(); + } + setActiveTask(taskInput, task); + }); + + taskContainer.appendChild(checkbox); + taskContainer.appendChild(checkboxLabel); + taskContainer.appendChild(taskInput); + + return taskContainer; +} diff --git a/resources/task-model.js b/resources/task-model.js @@ -0,0 +1,187 @@ +// Task model: state calculations, checkbox logic, tree traversal + +function updateCheckboxState(checkbox, taskState) { + checkbox.checked = taskState === 1; + checkbox.indeterminate = taskState === 2; +} + +function toggleTaskState(task) { + // Save cursor position before re-rendering + var taskInput = document.querySelector(`.task-container[data-id="${task.id}"] input[type="text"]`); + var cursorPos = taskInput ? taskInput.selectionStart : null; + + if (task.state === 1) { + if (task.subtasks.length === 0 || !task.subtasks.every(t => t.state === 1)) { + task.state = 0; + updateSubtasksState(task, 0); + + var parent = findParentTask(task); + if (parent) { + updateTaskAndAncestors(parent); + } + + renderCurrentView(); + selectAndFocusTask(task, cursorPos); + scheduleSave(); + } else { + applyShakeAnimation(task.id); + } + } else { + task.state = 1; + updateSubtasksState(task, 1); + + var parent = findParentTask(task); + if (parent) { + updateTaskAndAncestors(parent); + } + + renderCurrentView(); + selectAndFocusTask(task, cursorPos); + scheduleSave(); + } +} + +function recalculateTaskState(task) { + if (task.subtasks.length === 0) { + return task.state; + } + var anyUnchecked = task.subtasks.some(t => t.state === 0); + var allChecked = task.subtasks.every(t => t.state === 1); + var allCheckedOrIndeterminate = task.subtasks.every(t => t.state === 1 || t.state === 2); + + if (allChecked) { + return 1; + } else if (allCheckedOrIndeterminate) { + return task.state === 2 ? 2 : 1; + } else if (anyUnchecked) { + return 0; + } +} + +function updateTaskAndAncestors(task) { + var newState = recalculateTaskState(task); + if (task.state !== newState) { + var oldState = task.state; + task.state = newState; + + if (oldState === 1 && newState === 0) { + updateSubtasksState(task, 0); + } + + var parent = findParentTask(task); + if (parent) { + updateTaskAndAncestors(parent); + } + } +} + +function updateSubtasksState(task, newState) { + task.subtasks.forEach(subtask => { + if (subtask.state !== 1) { + if (newState === 1) { + subtask.state = subtask.state === 0 ? 2 : subtask.state; + } else if (newState === 0) { + subtask.state = 0; + } + if (subtask.subtasks.length > 0) { + updateSubtasksState(subtask, newState); + } + } + }); +} + +function adjustMovedTaskState(movedTask, newParent) { + if (movedTask.state === 2 && newParent.state === 0) { + movedTask.state = 0; + updateSubtasksState(movedTask, 0); + } +} + +function findParentTask(task) { + for (var i = state.taskPath.length - 1; i >= 0; i--) { + var potentialParent = state.taskPath[i]; + if (potentialParent.subtasks.some(t => t.id === task.id)) { + return potentialParent; + } + } + return null; +} + +function isLastSubtask(task) { + var parentTask = findParentTask(task); + if (!parentTask) return false; + return parentTask.subtasks[parentTask.subtasks.length - 1].id === task.id; +} + +function addNewSubtask(parentTask, currentSubtask = null) { + var newSubtask = { id: generateId(), text: '', state: 0, subtasks: [], selectedSubtaskId: null }; + if (currentSubtask) { + var 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 deleteSubtask(subtask) { + var parentTask = state.taskPath[state.taskPath.length - 1]; + var index = parentTask.subtasks.findIndex(t => t.id === subtask.id); + + if (parentTask.id === 'root' && parentTask.subtasks.length === 1) { + applyShakeAnimation(subtask.id); + return; + } + + parentTask.subtasks = parentTask.subtasks.filter(t => t.id !== subtask.id); + updateTaskAndAncestors(parentTask); + + if (parentTask.subtasks.length === 0 && state.taskPath.length > 1) { + navigateToParentTask(); + } else { + renderCurrentView(); + if (parentTask.subtasks.length > 0) { + var targetIndex = Math.max(0, index - 1); + selectAndFocusTask(parentTask.subtasks[targetIndex]); + } else { + selectAndFocusTask(parentTask); + } + } + scheduleSave(); +} + +function deleteCurrentParentTask() { + if (state.taskPath.length <= 1) return; + + var currentParentTask = state.taskPath[state.taskPath.length - 1]; + var grandparentTask = state.taskPath[state.taskPath.length - 2]; + + if (grandparentTask.id === 'root' && grandparentTask.subtasks.length === 1) { + applyShakeAnimation(currentParentTask.id); + return; + } + + var index = grandparentTask.subtasks.findIndex(t => t.id === currentParentTask.id); + + grandparentTask.subtasks = grandparentTask.subtasks.filter(t => t.id !== currentParentTask.id); + updateTaskAndAncestors(grandparentTask); + + state.taskPath.pop(); + state.currentTask = grandparentTask; + + if (grandparentTask.subtasks.length === 0 && state.taskPath.length > 1) { + navigateToParentTask(); + } else { + renderCurrentView(); + if (grandparentTask.subtasks.length > 0) { + var targetIndex = Math.max(0, index - 1); + selectAndFocusTask(grandparentTask.subtasks[targetIndex]); + } else { + selectAndFocusTask(grandparentTask); + } + } +} diff --git a/resources/theme.js b/resources/theme.js @@ -0,0 +1,57 @@ +// Theme: theme detection, cycling, and persistence + +function getThemesFromCSS() { + for (var sheet of document.styleSheets) { + try { + for (var rule of sheet.cssRules) { + var match = rule.selectorText && rule.selectorText.match(/:root\[data-theme="([^"]+)"\]/); + if (match) { + state.themes.push(match[1]); + } + } + } catch (e) { + // Skip cross-origin stylesheets + } + } +} + +function cycleTheme() { + state.currentThemeIndex = (state.currentThemeIndex + 1) % state.themes.length; + var newTheme = state.themes[state.currentThemeIndex]; + setTheme(newTheme); + saveThemeToLocalStorage(newTheme); +} + +function saveThemeToLocalStorage(theme) { + localStorage.setItem('currentTheme', theme); +} + +function updateFavicon() { + var iconEmoji = getComputedStyle(document.documentElement).getPropertyValue('--icon').trim().replace(/'/g, ''); + var 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); + var backgroundColor = getComputedStyle(document.documentElement).getPropertyValue('--background').trim(); + document.getElementById('themeColor').setAttribute('content', backgroundColor); + updateFavicon(); +} + +function setInitialTheme() { + var savedTheme = localStorage.getItem('currentTheme'); + var defaultTheme = document.documentElement.getAttribute('data-theme'); + + if (savedTheme && state.themes.includes(savedTheme)) { + state.currentThemeIndex = state.themes.indexOf(savedTheme); + } else if (state.themes.includes(defaultTheme)) { + state.currentThemeIndex = state.themes.indexOf(defaultTheme); + } else { + state.currentThemeIndex = 0; + } + + setTheme(state.themes[state.currentThemeIndex]); +} diff --git a/sw.js b/sw.js @@ -0,0 +1,61 @@ +const CACHE_NAME = 'matryoshka-v1'; +const ASSETS = [ + './', + 'index.html', + 'manifest.json', + 'resources/main.css', + 'resources/state.js', + 'resources/storage.js', + 'resources/task-model.js', + 'resources/multi-select.js', + 'resources/navigation.js', + 'resources/reorganize.js', + 'resources/keyboard.js', + 'resources/render.js', + 'resources/task-element.js', + 'resources/theme.js', + 'resources/main.js', + 'resources/icons/icon.svg', + 'resources/fonts/Basteleur-Moonlight.ttf', +]; + +// Cache assets on install +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => cache.addAll(ASSETS)) + .then(() => self.skipWaiting()) + ); +}); + +// Clean up old caches on activate +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all( + keys.filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)) + )) + .then(() => self.clients.claim()) + ); +}); + +// Serve from cache, fall back to network, and update cache +self.addEventListener('fetch', (event) => { + event.respondWith( + caches.match(event.request).then((cached) => { + const fetchPromise = fetch(event.request).then((response) => { + // Only cache same-origin, successful GET requests + if (response.ok && event.request.method === 'GET') { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, clone); + }); + } + return response; + }).catch(() => cached); + + return cached || fetchPromise; + }) + ); +});