history.js (8.6 KB)
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 }