commit 11dfcba5d992e330f88e718dd29f4759d1c05373
parent 2c718ca11b460e6c689c5844857928c78d1e270e
Author: Hunter
Date: Fri, 2 Oct 2026 23:52:34 -0400
add undo/redo
Diffstat:
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',