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 }