commit 11dfcba5d992e330f88e718dd29f4759d1c05373
parent 2c718ca11b460e6c689c5844857928c78d1e270e
Author: Hunter
Date:   Fri,  2 Oct 2026 23:52:34 -0400

add undo/redo

Diffstat:
M.gitignore | 3+--
Mindex.html | 1+
Mreadme.md | 2++
Aresources/history.js | 297+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mresources/keyboard.js | 7+++----
Mresources/main.js | 4++++
Mresources/multi-select.js | 52----------------------------------------------------
Mresources/state.js | 2--
Mresources/storage.js | 14+++++++-------
Mresources/task-element.js | 6+++---
Msw.js | 1+
11 files changed, 319 insertions(+), 70 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1 +1 @@ -CLAUDE.md -\ No newline at end of file +tests/ diff --git a/index.html b/index.html @@ -14,6 +14,7 @@ <link rel="stylesheet" href="resources/main.css"> <script src="resources/state.js"></script> <script src="resources/storage.js"></script> + <script src="resources/history.js"></script> <script src="resources/task-model.js"></script> <script src="resources/multi-select.js"></script> <script src="resources/navigation.js"></script> diff --git a/readme.md b/readme.md @@ -35,6 +35,8 @@ the nested todo list that breaks complex tasks into manageable subtasks. - `⌘ + C` copy selected task(s) and their subtasks - `⌘ + X` cut selected task(s) and their subtasks - `⌘ + V` paste copied task(s) and their subtasks +- `⌘ + Z` undo +- `⌘ + Shift + Z` redo ### reorganization - `Shift + ⬆️/⬇️` reposition selected task(s) within their current level diff --git a/resources/history.js b/resources/history.js @@ -0,0 +1,297 @@ +// History: app-wide undo/redo of per-task changes and view moves + +var editHistory = { + undoStack: [], + redoStack: [], + committedIndex: null, + committedView: null, + treeChanged: false, + checkPending: false, + typingTaskId: null, + lastTypingTime: 0, + typingPauseMs: 1000, + maxEdits: 10 +}; + +function createTaskRecord(task) { + return { + text: task.text, + state: task.state, + selectedSubtaskId: task.selectedSubtaskId || null, + subtaskIds: task.subtasks.map(t => t.id) + }; +} + +function indexTaskTree(root) { + var index = new Map(); + (function visit(task) { + index.set(task.id, createTaskRecord(task)); + task.subtasks.forEach(visit); + })(root); + return index; +} + +function liveTaskMap() { + var tasks = new Map(); + (function visit(task) { + tasks.set(task.id, task); + task.subtasks.forEach(visit); + })(state.taskPath[0]); + return tasks; +} + +function subtaskIdsMatch(ids, subtasks) { + if (ids.length !== subtasks.length) return false; + for (var i = 0; i < ids.length; i++) { + if (ids[i] !== subtasks[i].id) return false; + } + return true; +} + +function diffSubtaskIds(oldIds, newIds) { + var start = 0; + while (start < oldIds.length && start < newIds.length && oldIds[start] === newIds[start]) start++; + var oldEnd = oldIds.length; + var newEnd = newIds.length; + while (oldEnd > start && newEnd > start && oldIds[oldEnd - 1] === newIds[newEnd - 1]) { + oldEnd--; + newEnd--; + } + return { + before: { start: start, ids: oldIds.slice(start, oldEnd) }, + after: { start: start, ids: newIds.slice(start, newEnd) } + }; +} + +function diffTaskRecords(old, record) { + var before = {}; + var after = {}; + for (var field of ['text', 'state', 'selectedSubtaskId']) { + if (old[field] !== record[field]) { + before[field] = old[field]; + after[field] = record[field]; + } + } + if (old.subtaskIds !== record.subtaskIds) { + var splice = diffSubtaskIds(old.subtaskIds, record.subtaskIds); + before.subtaskSplice = splice.before; + after.subtaskSplice = splice.after; + } + return { before: before, after: after }; +} + +function collectTreeChanges() { + var index = editHistory.committedIndex; + var changes = []; + var created = []; + var visitedExisting = 0; + (function visit(task) { + var old = index.get(task.id); + if (!old) { + var record = createTaskRecord(task); + created.push([task.id, record]); + changes.push({ id: task.id, before: null, after: record }); + } else { + visitedExisting++; + var sameSubtasks = subtaskIdsMatch(old.subtaskIds, task.subtasks); + if (old.text !== task.text || old.state !== task.state + || old.selectedSubtaskId !== (task.selectedSubtaskId || null) || !sameSubtasks) { + var record = createTaskRecord(task); + if (sameSubtasks) record.subtaskIds = old.subtaskIds; + var diff = diffTaskRecords(old, record); + changes.push({ id: task.id, before: diff.before, after: diff.after }); + index.set(task.id, record); + } + } + task.subtasks.forEach(visit); + })(state.taskPath[0]); + + if (visitedExisting < index.size) { + var live = liveTaskMap(); + index.forEach((record, id) => { + if (!live.has(id)) changes.push({ id: id, before: record, after: null }); + }); + for (var change of changes) { + if (change.after === null) index.delete(change.id); + } + } + for (var entry of created) { + index.set(entry[0], entry[1]); + } + return changes; +} + +function applyTaskChanges(changes, side) { + var tasks = liveTaskMap(); + var linking = []; + for (var change of changes) { + var record = change[side]; + var otherRecord = side === 'before' ? change.after : change.before; + if (record === null) { + tasks.delete(change.id); + continue; + } + if (otherRecord === null) { + tasks.set(change.id, { id: change.id, subtasks: [] }); + } + var task = tasks.get(change.id); + if ('text' in record) task.text = record.text; + if ('state' in record) task.state = record.state; + if ('selectedSubtaskId' in record) task.selectedSubtaskId = record.selectedSubtaskId; + if ('subtaskIds' in record || 'subtaskSplice' in record) { + linking.push({ task: task, record: record, otherRecord: otherRecord }); + } + } + for (var link of linking) { + if ('subtaskIds' in link.record) { + link.task.subtasks = link.record.subtaskIds.map(id => tasks.get(id)); + } else { + var splice = link.record.subtaskSplice; + var removedCount = link.otherRecord.subtaskSplice.ids.length; + link.task.subtasks.splice(splice.start, removedCount, ...splice.ids.map(id => tasks.get(id))); + } + } + return tasks; +} + +function captureView() { + var el = document.activeElement; + if (!el || !el.classList || !el.classList.contains('task-text')) return null; + return { + pathIds: state.taskPath.map(t => t.id), + focusedId: el.closest('.task-container').dataset.id, + caret: getCaretOffset(el), + multi: state.multiSelectedIds.length > 1 ? { + anchorId: state.multiSelectAnchorId, + ids: state.multiSelectedIds.slice(), + offsets: Object.assign({}, state.multiCaretOffsets), + ranges: JSON.parse(JSON.stringify(state.multiSelectRanges)) + } : null + }; +} + +function viewLocation(view) { + return view.pathIds.join('/') + '|' + view.focusedId + '|' + (view.multi ? view.multi.ids.join(',') : ''); +} + +function isSelectionOnlyChange(change) { + return change.before !== null && change.after !== null + && Object.keys(change.after).every(field => field === 'selectedSubtaskId'); +} + +function isTextOnlyChange(change) { + return change.before !== null && change.after !== null + && Object.keys(change.after).every(field => field === 'text'); +} + +function trimUndoStack() { + var edits = editHistory.undoStack.filter(entry => entry.isEdit).length; + while (edits > editHistory.maxEdits) { + if (editHistory.undoStack.shift().isEdit) edits--; + } +} + +function initHistory() { + editHistory.committedIndex = indexTaskTree(state.taskPath[0]); + editHistory.committedView = captureView(); +} + +function scheduleHistoryCheck() { + editHistory.treeChanged = true; + scheduleViewCheck(); +} + +function scheduleViewCheck() { + if (editHistory.checkPending) return; + editHistory.checkPending = true; + queueMicrotask(checkHistory); +} + +function checkHistory() { + editHistory.checkPending = false; + if (!editHistory.committedIndex) return; + var view = captureView() || editHistory.committedView; + var moved = viewLocation(view) !== viewLocation(editHistory.committedView); + if (!editHistory.treeChanged && !moved) { + editHistory.committedView = view; + return; + } + editHistory.treeChanged = false; + var changes = collectTreeChanges(); + if (changes.length === 0 && !moved) { + editHistory.committedView = view; + return; + } + + var now = Date.now(); + var isTyping = !moved && changes.every(isTextOnlyChange); + var continuesTyping = isTyping + && view.focusedId === editHistory.typingTaskId + && now - editHistory.lastTypingTime < editHistory.typingPauseMs; + if (continuesTyping) { + mergeTypingChanges(editHistory.undoStack[editHistory.undoStack.length - 1], changes, view); + } else { + editHistory.undoStack.push({ + changes: changes, + before: editHistory.committedView, + after: view, + isEdit: !changes.every(isSelectionOnlyChange) + }); + trimUndoStack(); + editHistory.redoStack = []; + } + editHistory.typingTaskId = isTyping ? view.focusedId : null; + editHistory.lastTypingTime = now; + editHistory.committedView = view; +} + +function mergeTypingChanges(entry, changes, view) { + for (var change of changes) { + var existing = entry.changes.find(c => c.id === change.id); + if (existing) { + existing.after.text = change.after.text; + } else { + entry.changes.push(change); + } + } + entry.after = view; +} + +function undoHistory(task) { + stepHistory(editHistory.undoStack, editHistory.redoStack, 'before', task); +} + +function redoHistory(task) { + stepHistory(editHistory.redoStack, editHistory.undoStack, 'after', task); +} + +function stepHistory(fromStack, toStack, side, task) { + var entry = fromStack.pop(); + if (!entry) { + state.multiSelectedIds.length > 1 ? shakeAllSelected() : applyShakeAnimation(task.id); + return; + } + toStack.push(entry); + var sameLevel = entry[side].pathIds.join('/') === state.taskPath.map(t => t.id).join('/'); + var tasks = applyTaskChanges(entry.changes, side); + restoreView(entry[side], tasks, entry.isEdit || !sameLevel); + editHistory.typingTaskId = null; + scheduleSave(); + collectTreeChanges(); + editHistory.treeChanged = false; + editHistory.committedView = captureView(); +} + +function restoreView(view, tasks, redraw) { + clearMultiSelect(); + state.taskPath = view.pathIds.map(id => tasks.get(id)); + if (view.multi) { + state.multiSelectAnchorId = view.multi.anchorId; + state.multiSelectedIds = view.multi.ids.slice(); + state.multiCaretOffsets = Object.assign({}, view.multi.offsets); + state.multiSelectRanges = JSON.parse(JSON.stringify(view.multi.ranges)); + } + if (redraw) renderCurrentView(); + selectAndFocusTask(tasks.get(view.focusedId), view.caret); + if (view.multi) applyMultiSelectHighlights(); +} diff --git a/resources/keyboard.js b/resources/keyboard.js @@ -208,10 +208,9 @@ function handleKeyDown(e, task) { } else if ((e.key === 'a' || e.key === 'A') && cmd && !e.shiftKey && !e.altKey && hasMultiSelect) { e.preventDefault(); 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) { + // Cmd+Z / Cmd+Shift+Z: app-wide undo/redo + } else if ((e.key === 'z' || e.key === 'Z') && cmd && !e.altKey) { e.preventDefault(); - e.shiftKey ? redoMultiEdit(task) : undoMultiEdit(task); + e.shiftKey ? redoHistory(task) : undoHistory(task); } } diff --git a/resources/main.js b/resources/main.js @@ -62,6 +62,8 @@ document.addEventListener('DOMContentLoaded', function() { } }); + document.addEventListener('keydown', checkHistory, true); + // File save/open handlers document.addEventListener('keydown', handleSave); document.addEventListener('keydown', handleOpen); @@ -107,6 +109,7 @@ document.addEventListener('DOMContentLoaded', function() { } } renderSimCarets(); + scheduleViewCheck(); }); // hide simulated carets while the window is inactive, like native ones @@ -129,6 +132,7 @@ document.addEventListener('DOMContentLoaded', function() { setInitialTheme(); renderCurrentView(); selectFirstSubtask(); + initHistory(); // re-pin the active caret's scroll once layout settles and once fonts load requestAnimationFrame(rescrollActiveCaret); diff --git a/resources/multi-select.js b/resources/multi-select.js @@ -5,8 +5,6 @@ function clearMultiSelect() { state.multiSelectedIds = []; state.multiCaretOffsets = {}; state.multiSelectRanges = {}; - state.multiUndoStack = []; - state.multiRedoStack = []; updateMultiRangeStyles(); document.querySelectorAll('.sim-caret').forEach(el => el.remove()); var focused = document.activeElement; @@ -115,7 +113,6 @@ function deleteAtMultiCaret(t, direction) { // Backspace/Delete during multi-select: simulate deletion on every selected // line (including the focused one) so native deletion can't split a grapheme function deleteAcrossMultiSelection(focusedTask, taskInput, direction) { - pushMultiUndo(); var focusedOffset = getCaretOffset(taskInput); if (focusedOffset != null) state.multiCaretOffsets[focusedTask.id] = focusedOffset; for (var t of getMultiSelectedTasks()) { @@ -142,55 +139,6 @@ function exitMultiSelect(direction) { } } -// 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(); -} - // Cmd+A: native select-all on the focused line plus stored full-line ranges // on the others; the next edit then replaces each line's selection function selectAllMultiSelected(focusedTask) { diff --git a/resources/state.js b/resources/state.js @@ -19,8 +19,6 @@ var state = { multiSelectedIds: [], multiCaretOffsets: {}, multiSelectRanges: {}, - multiUndoStack: [], - multiRedoStack: [], }; function generateId() { diff --git a/resources/storage.js b/resources/storage.js @@ -5,6 +5,7 @@ function scheduleSave() { clearTimeout(state.saveTimer); } state.saveTimer = setTimeout(saveTasksToLocalStorage, 1000); + scheduleHistoryCheck(); } function saveTasksToLocalStorage() { @@ -119,13 +120,12 @@ function openTaskTreeFromFile() { 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(); - } + state.rootTask = newRootTask; + state.currentTask = state.rootTask; + state.taskPath = [state.currentTask]; + renderCurrentView(); + scheduleHistoryCheck(); + saveTasksToLocalStorage(); } catch (error) { alert(`Error importing task tree: ${error.message}`); } diff --git a/resources/task-element.js b/resources/task-element.js @@ -233,6 +233,9 @@ function createTaskElement(task, isParentTask = false) { taskInput.addEventListener('keyup', keyupHandler); taskInput.addEventListener('keydown', handleCopyCutPaste); taskInput.addEventListener('paste', (e) => e.preventDefault()); + taskInput.addEventListener('beforeinput', (e) => { + if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') e.preventDefault(); + }); taskInput.addEventListener('input', (e) => { var oldText = task.text; // Normalize: strip newlines a paste/IME may introduce, and convert @@ -246,9 +249,6 @@ function createTaskElement(task, isParentTask = false) { task.text = taskInput.textContent; // Propagate edits to all other multi-selected tasks 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); var focusedOffset = getCaretOffset(taskInput); if (focusedOffset != null) state.multiCaretOffsets[task.id] = focusedOffset; var otherSelected = getMultiSelectedTasks().filter(t => t.id !== task.id); diff --git a/sw.js b/sw.js @@ -6,6 +6,7 @@ const ASSETS = [ 'resources/main.css', 'resources/state.js', 'resources/storage.js', + 'resources/history.js', 'resources/task-model.js', 'resources/multi-select.js', 'resources/navigation.js',