add undo/redo Hunter Friday, October 2nd, 2026 at 11:52 PM
-CLAUDE.md 1 +tests/
14 <link rel="stylesheet" href="resources/main.css"> 15 <script src="resources/state.js"></script> 16 <script src="resources/storage.js"></script> 17 + <script src="resources/history.js"></script> 18 <script src="resources/task-model.js"></script> 19 <script src="resources/multi-select.js"></script> 20 <script src="resources/navigation.js"></script>
35 - `⌘ + C` copy selected task(s) and their subtasks 36 - `⌘ + X` cut selected task(s) and their subtasks 37 - `⌘ + V` paste copied task(s) and their subtasks 38 +- `⌘ + Z` undo 39 +- `⌘ + Shift + Z` redo 40 41 ### reorganization 42 - `Shift + ⬆️/⬇️` reposition selected task(s) within their current level
1 +// History: app-wide undo/redo of per-task changes and view moves 2 + 3 +var editHistory = { 4 + undoStack: [], 5 + redoStack: [], 6 + committedIndex: null, 7 + committedView: null, 8 + treeChanged: false, 9 + checkPending: false, 10 + typingTaskId: null, 11 + lastTypingTime: 0, 12 + typingPauseMs: 1000, 13 + maxEdits: 10 14 +}; 15 + 16 +function createTaskRecord(task) { 17 + return { 18 + text: task.text, 19 + state: task.state, 20 + selectedSubtaskId: task.selectedSubtaskId || null, 21 + subtaskIds: task.subtasks.map(t => t.id) 22 + }; 23 +} 24 + 25 +function indexTaskTree(root) { 26 + var index = new Map(); 27 + (function visit(task) { 28 + index.set(task.id, createTaskRecord(task)); 29 + task.subtasks.forEach(visit); 30 + })(root); 31 + return index; 32 +} 33 + 34 +function liveTaskMap() { 35 + var tasks = new Map(); 36 + (function visit(task) { 37 + tasks.set(task.id, task); 38 + task.subtasks.forEach(visit); 39 + })(state.taskPath[0]); 40 + return tasks; 41 +} 42 + 43 +function subtaskIdsMatch(ids, subtasks) { 44 + if (ids.length !== subtasks.length) return false; 45 + for (var i = 0; i < ids.length; i++) { 46 + if (ids[i] !== subtasks[i].id) return false; 47 + } 48 + return true; 49 +} 50 + 51 +function diffSubtaskIds(oldIds, newIds) { 52 + var start = 0; 53 + while (start < oldIds.length && start < newIds.length && oldIds[start] === newIds[start]) start++; 54 + var oldEnd = oldIds.length; 55 + var newEnd = newIds.length; 56 + while (oldEnd > start && newEnd > start && oldIds[oldEnd - 1] === newIds[newEnd - 1]) { 57 + oldEnd--; 58 + newEnd--; 59 + } 60 + return { 61 + before: { start: start, ids: oldIds.slice(start, oldEnd) }, 62 + after: { start: start, ids: newIds.slice(start, newEnd) } 63 + }; 64 +} 65 + 66 +function diffTaskRecords(old, record) { 67 + var before = {}; 68 + var after = {}; 69 + for (var field of ['text', 'state', 'selectedSubtaskId']) { 70 + if (old[field] !== record[field]) { 71 + before[field] = old[field]; 72 + after[field] = record[field]; 73 + } 74 + } 75 + if (old.subtaskIds !== record.subtaskIds) { 76 + var splice = diffSubtaskIds(old.subtaskIds, record.subtaskIds); 77 + before.subtaskSplice = splice.before; 78 + after.subtaskSplice = splice.after; 79 + } 80 + return { before: before, after: after }; 81 +} 82 + 83 +function collectTreeChanges() { 84 + var index = editHistory.committedIndex; 85 + var changes = []; 86 + var created = []; 87 + var visitedExisting = 0; 88 + (function visit(task) { 89 + var old = index.get(task.id); 90 + if (!old) { 91 + var record = createTaskRecord(task); 92 + created.push([task.id, record]); 93 + changes.push({ id: task.id, before: null, after: record }); 94 + } else { 95 + visitedExisting++; 96 + var sameSubtasks = subtaskIdsMatch(old.subtaskIds, task.subtasks); 97 + if (old.text !== task.text || old.state !== task.state 98 + || old.selectedSubtaskId !== (task.selectedSubtaskId || null) || !sameSubtasks) { 99 + var record = createTaskRecord(task); 100 + if (sameSubtasks) record.subtaskIds = old.subtaskIds; 101 + var diff = diffTaskRecords(old, record); 102 + changes.push({ id: task.id, before: diff.before, after: diff.after }); 103 + index.set(task.id, record); 104 + } 105 + } 106 + task.subtasks.forEach(visit); 107 + })(state.taskPath[0]); 108 + 109 + if (visitedExisting < index.size) { 110 + var live = liveTaskMap(); 111 + index.forEach((record, id) => { 112 + if (!live.has(id)) changes.push({ id: id, before: record, after: null }); 113 + }); 114 + for (var change of changes) { 115 + if (change.after === null) index.delete(change.id); 116 + } 117 + } 118 + for (var entry of created) { 119 + index.set(entry[0], entry[1]); 120 + } 121 + return changes; 122 +} 123 + 124 +function applyTaskChanges(changes, side) { 125 + var tasks = liveTaskMap(); 126 + var linking = []; 127 + for (var change of changes) { 128 + var record = change[side]; 129 + var otherRecord = side === 'before' ? change.after : change.before; 130 + if (record === null) { 131 + tasks.delete(change.id); 132 + continue; 133 + } 134 + if (otherRecord === null) { 135 + tasks.set(change.id, { id: change.id, subtasks: [] }); 136 + } 137 + var task = tasks.get(change.id); 138 + if ('text' in record) task.text = record.text; 139 + if ('state' in record) task.state = record.state; 140 + if ('selectedSubtaskId' in record) task.selectedSubtaskId = record.selectedSubtaskId; 141 + if ('subtaskIds' in record || 'subtaskSplice' in record) { 142 + linking.push({ task: task, record: record, otherRecord: otherRecord }); 143 + } 144 + } 145 + for (var link of linking) { 146 + if ('subtaskIds' in link.record) { 147 + link.task.subtasks = link.record.subtaskIds.map(id => tasks.get(id)); 148 + } else { 149 + var splice = link.record.subtaskSplice; 150 + var removedCount = link.otherRecord.subtaskSplice.ids.length; 151 + link.task.subtasks.splice(splice.start, removedCount, ...splice.ids.map(id => tasks.get(id))); 152 + } 153 + } 154 + return tasks; 155 +} 156 + 157 +function captureView() { 158 + var el = document.activeElement; 159 + if (!el || !el.classList || !el.classList.contains('task-text')) return null; 160 + return { 161 + pathIds: state.taskPath.map(t => t.id), 162 + focusedId: el.closest('.task-container').dataset.id, 163 + caret: getCaretOffset(el), 164 + multi: state.multiSelectedIds.length > 1 ? { 165 + anchorId: state.multiSelectAnchorId, 166 + ids: state.multiSelectedIds.slice(), 167 + offsets: Object.assign({}, state.multiCaretOffsets), 168 + ranges: JSON.parse(JSON.stringify(state.multiSelectRanges)) 169 + } : null 170 + }; 171 +} 172 + 173 +function viewLocation(view) { 174 + return view.pathIds.join('/') + '|' + view.focusedId + '|' + (view.multi ? view.multi.ids.join(',') : ''); 175 +} 176 + 177 +function isSelectionOnlyChange(change) { 178 + return change.before !== null && change.after !== null 179 + && Object.keys(change.after).every(field => field === 'selectedSubtaskId'); 180 +} 181 + 182 +function isTextOnlyChange(change) { 183 + return change.before !== null && change.after !== null 184 + && Object.keys(change.after).every(field => field === 'text'); 185 +} 186 + 187 +function trimUndoStack() { 188 + var edits = editHistory.undoStack.filter(entry => entry.isEdit).length; 189 + while (edits > editHistory.maxEdits) { 190 + if (editHistory.undoStack.shift().isEdit) edits--; 191 + } 192 +} 193 + 194 +function initHistory() { 195 + editHistory.committedIndex = indexTaskTree(state.taskPath[0]); 196 + editHistory.committedView = captureView(); 197 +} 198 + 199 +function scheduleHistoryCheck() { 200 + editHistory.treeChanged = true; 201 + scheduleViewCheck(); 202 +} 203 + 204 +function scheduleViewCheck() { 205 + if (editHistory.checkPending) return; 206 + editHistory.checkPending = true; 207 + queueMicrotask(checkHistory); 208 +} 209 + 210 +function checkHistory() { 211 + editHistory.checkPending = false; 212 + if (!editHistory.committedIndex) return; 213 + var view = captureView() || editHistory.committedView; 214 + var moved = viewLocation(view) !== viewLocation(editHistory.committedView); 215 + if (!editHistory.treeChanged && !moved) { 216 + editHistory.committedView = view; 217 + return; 218 + } 219 + editHistory.treeChanged = false; 220 + var changes = collectTreeChanges(); 221 + if (changes.length === 0 && !moved) { 222 + editHistory.committedView = view; 223 + return; 224 + } 225 + 226 + var now = Date.now(); 227 + var isTyping = !moved && changes.every(isTextOnlyChange); 228 + var continuesTyping = isTyping 229 + && view.focusedId === editHistory.typingTaskId 230 + && now - editHistory.lastTypingTime < editHistory.typingPauseMs; 231 + if (continuesTyping) { 232 + mergeTypingChanges(editHistory.undoStack[editHistory.undoStack.length - 1], changes, view); 233 + } else { 234 + editHistory.undoStack.push({ 235 + changes: changes, 236 + before: editHistory.committedView, 237 + after: view, 238 + isEdit: !changes.every(isSelectionOnlyChange) 239 + }); 240 + trimUndoStack(); 241 + editHistory.redoStack = []; 242 + } 243 + editHistory.typingTaskId = isTyping ? view.focusedId : null; 244 + editHistory.lastTypingTime = now; 245 + editHistory.committedView = view; 246 +} 247 + 248 +function mergeTypingChanges(entry, changes, view) { 249 + for (var change of changes) { 250 + var existing = entry.changes.find(c => c.id === change.id); 251 + if (existing) { 252 + existing.after.text = change.after.text; 253 + } else { 254 + entry.changes.push(change); 255 + } 256 + } 257 + entry.after = view; 258 +} 259 + 260 +function undoHistory(task) { 261 + stepHistory(editHistory.undoStack, editHistory.redoStack, 'before', task); 262 +} 263 + 264 +function redoHistory(task) { 265 + stepHistory(editHistory.redoStack, editHistory.undoStack, 'after', task); 266 +} 267 + 268 +function stepHistory(fromStack, toStack, side, task) { 269 + var entry = fromStack.pop(); 270 + if (!entry) { 271 + state.multiSelectedIds.length > 1 ? shakeAllSelected() : applyShakeAnimation(task.id); 272 + return; 273 + } 274 + toStack.push(entry); 275 + var sameLevel = entry[side].pathIds.join('/') === state.taskPath.map(t => t.id).join('/'); 276 + var tasks = applyTaskChanges(entry.changes, side); 277 + restoreView(entry[side], tasks, entry.isEdit || !sameLevel); 278 + editHistory.typingTaskId = null; 279 + scheduleSave(); 280 + collectTreeChanges(); 281 + editHistory.treeChanged = false; 282 + editHistory.committedView = captureView(); 283 +} 284 + 285 +function restoreView(view, tasks, redraw) { 286 + clearMultiSelect(); 287 + state.taskPath = view.pathIds.map(id => tasks.get(id)); 288 + if (view.multi) { 289 + state.multiSelectAnchorId = view.multi.anchorId; 290 + state.multiSelectedIds = view.multi.ids.slice(); 291 + state.multiCaretOffsets = Object.assign({}, view.multi.offsets); 292 + state.multiSelectRanges = JSON.parse(JSON.stringify(view.multi.ranges)); 293 + } 294 + if (redraw) renderCurrentView(); 295 + selectAndFocusTask(tasks.get(view.focusedId), view.caret); 296 + if (view.multi) applyMultiSelectHighlights(); 297 +}
208 } else if ((e.key === 'a' || e.key === 'A') && cmd && !e.shiftKey && !e.altKey && hasMultiSelect) { 209 e.preventDefault(); 210 selectAllMultiSelected(task); - // Cmd+Z / Cmd+Shift+Z during multi-select: app-level undo/redo (native - // history would only touch the focused line, so it's blocked) - } else if ((e.key === 'z' || e.key === 'Z') && cmd && !e.altKey && hasMultiSelect) { 211 + // Cmd+Z / Cmd+Shift+Z: app-wide undo/redo 212 + } else if ((e.key === 'z' || e.key === 'Z') && cmd && !e.altKey) { 213 e.preventDefault(); - e.shiftKey ? redoMultiEdit(task) : undoMultiEdit(task); 214 + e.shiftKey ? redoHistory(task) : undoHistory(task); 215 } 216 }
62 } 63 }); 64 65 + document.addEventListener('keydown', checkHistory, true); 66 + 67 // File save/open handlers 68 document.addEventListener('keydown', handleSave); 69 document.addEventListener('keydown', handleOpen); 109 } 110 } 111 renderSimCarets(); 112 + scheduleViewCheck(); 113 }); 114 115 // hide simulated carets while the window is inactive, like native ones 132 setInitialTheme(); 133 renderCurrentView(); 134 selectFirstSubtask(); 135 + initHistory(); 136 137 // re-pin the active caret's scroll once layout settles and once fonts load 138 requestAnimationFrame(rescrollActiveCaret);
5 state.multiSelectedIds = []; 6 state.multiCaretOffsets = {}; 7 state.multiSelectRanges = {}; - state.multiUndoStack = []; - state.multiRedoStack = []; 8 updateMultiRangeStyles(); 9 document.querySelectorAll('.sim-caret').forEach(el => el.remove()); 10 var focused = document.activeElement; 113 // Backspace/Delete during multi-select: simulate deletion on every selected 114 // line (including the focused one) so native deletion can't split a grapheme 115 function deleteAcrossMultiSelection(focusedTask, taskInput, direction) { - pushMultiUndo(); 116 var focusedOffset = getCaretOffset(taskInput); 117 if (focusedOffset != null) state.multiCaretOffsets[focusedTask.id] = focusedOffset; 118 for (var t of getMultiSelectedTasks()) { 139 } 140 } 141 -// Cmd+Z / Cmd+Shift+Z: per-session undo/redo of multi-line edits; snapshots -// are pushed before each mutation (focusedOldText covers input events, where -// the focused line has already changed) -function captureMultiSnapshot(focusedId, focusedOldText) { - var texts = {}; - for (var t of getMultiSelectedTasks()) { - texts[t.id] = t.id === focusedId ? focusedOldText : t.text; - } - return { - texts: texts, - offsets: Object.assign({}, state.multiCaretOffsets), - ranges: Object.assign({}, state.multiSelectRanges) - }; -} - -function pushMultiUndo(focusedId, focusedOldText) { - state.multiUndoStack.push(captureMultiSnapshot(focusedId, focusedOldText)); - state.multiRedoStack = []; -} - -function undoMultiEdit(focusedTask) { - var entry = state.multiUndoStack.pop(); - if (!entry) return; - state.multiRedoStack.push(captureMultiSnapshot()); - applyMultiSnapshot(entry, focusedTask); -} - -function redoMultiEdit(focusedTask) { - var entry = state.multiRedoStack.pop(); - if (!entry) return; - state.multiUndoStack.push(captureMultiSnapshot()); - applyMultiSnapshot(entry, focusedTask); -} - -function applyMultiSnapshot(entry, focusedTask) { - for (var t of getMultiSelectedTasks()) { - if (typeof entry.texts[t.id] === 'string') { - t.text = entry.texts[t.id]; - syncMultiTaskText(t); - } - } - state.multiCaretOffsets = entry.offsets; - state.multiSelectRanges = entry.ranges || {}; - var input = document.querySelector(`.task-container[data-id="${focusedTask.id}"] .task-text`); - if (input) setCaretOffset(input, clampCaret(focusedTask.text, state.multiCaretOffsets[focusedTask.id])); - renderSimCarets(); - scheduleSave(); -} - 142 // Cmd+A: native select-all on the focused line plus stored full-line ranges 143 // on the others; the next edit then replaces each line's selection 144 function selectAllMultiSelected(focusedTask) {
19 multiSelectedIds: [], 20 multiCaretOffsets: {}, 21 multiSelectRanges: {}, - multiUndoStack: [], - multiRedoStack: [], 22 }; 23 24 function generateId() {
5 clearTimeout(state.saveTimer); 6 } 7 state.saveTimer = setTimeout(saveTasksToLocalStorage, 1000); 8 + scheduleHistoryCheck(); 9 } 10 11 function saveTasksToLocalStorage() { 120 reader.onload = function(e) { 121 try { 122 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(); - } 123 + state.rootTask = newRootTask; 124 + state.currentTask = state.rootTask; 125 + state.taskPath = [state.currentTask]; 126 + renderCurrentView(); 127 + scheduleHistoryCheck(); 128 + saveTasksToLocalStorage(); 129 } catch (error) { 130 alert(`Error importing task tree: ${error.message}`); 131 }
233 taskInput.addEventListener('keyup', keyupHandler); 234 taskInput.addEventListener('keydown', handleCopyCutPaste); 235 taskInput.addEventListener('paste', (e) => e.preventDefault()); 236 + taskInput.addEventListener('beforeinput', (e) => { 237 + if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') e.preventDefault(); 238 + }); 239 taskInput.addEventListener('input', (e) => { 240 var oldText = task.text; 241 // Normalize: strip newlines a paste/IME may introduce, and convert 249 task.text = taskInput.textContent; 250 // Propagate edits to all other multi-selected tasks 251 if (state.multiSelectedIds.length > 1 && state.multiSelectedIds.includes(task.id)) { - // snapshot before mirroring; the focused line already changed, so - // its pre-edit text comes from oldText - pushMultiUndo(task.id, oldText); 252 var focusedOffset = getCaretOffset(taskInput); 253 if (focusedOffset != null) state.multiCaretOffsets[task.id] = focusedOffset; 254 var otherSelected = getMultiSelectedTasks().filter(t => t.id !== task.id);
6 'resources/main.css', 7 'resources/state.js', 8 'resources/storage.js', 9 + 'resources/history.js', 10 'resources/task-model.js', 11 'resources/multi-select.js', 12 'resources/navigation.js',