commit 04685fead72fb76fda5cb21bb0c8302d077f5aca
parent bcc4acd28c1a045023fbe4d2e0a982404f57033d
Author: Hunter
Date: Sun, 8 Mar 2026 09:49:22 -0400
break out css and js
Diffstat:
| M | index.html | | | 1997 | +------------------------------------------------------------------------------ |
| M | readme.md | | | 2 | +- |
| A | resources/keyboard.js | | | 175 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | resources/main.css | | | 346 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | resources/main.js | | | 84 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | resources/multi-select.js | | | 220 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | resources/navigation.js | | | 111 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | resources/render.js | | | 197 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | resources/reorganize.js | | | 110 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | resources/state.js | | | 23 | +++++++++++++++++++++++ |
| A | resources/storage.js | | | 127 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | resources/task-element.js | | | 253 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | resources/task-model.js | | | 187 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | resources/theme.js | | | 57 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | sw.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;
+ })
+ );
+});