⌘+c/⌘+x/⌘+v to copy/cut/paste tasks and their subtasks

Hunter
Thursday, September 24th, 2026 at 12:05 AM

readme.md+3-5
resources/main.js+1 
resources/multi-select.js+26 
resources/render.js+26-25
resources/state.js+1 
resources/storage.js+9 
resources/task-element.js+8-39
resources/task-model.js+63 

readme.md
32  - `⬇️` (on last subtask) add a new task at the bottom of the list
33  - `Backspace` (when task text is empty) delete selected task(s) and their subtasks
34  - `Shift + Enter` toggle selected task(s) completion status
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  
39  ### reorganization
40  - `Shift + ⬆️/⬇️` reposition selected task(s) within their current level

46  - `Option + ⬆️/⬇️` extend or contract the selection to include adjacent tasks
47  - `⬆️/⬇️` (without modifier) clear the selection and resume single-task navigation
48  
   -### text editing
   -- `⌘ + C` copy task text (or highlighted substring)
   -- `⌘ + X` cut task text (or highlighted substring)
   -- `⌘ + V` paste text from the clipboard
   -
49  ### theming
50  - press `F2` to cycle through available themes
51  
resources/main.js
15  document.addEventListener('DOMContentLoaded', function() {
16  	state.appContainer = document.getElementById('app-container');
17  	state.rootTask = loadTasksFromLocalStorage();
18 +	state.copiedTasks = loadCopiedTasksFromSessionStorage();
19  	state.currentTask = state.rootTask;
20  	state.taskPath = [state.currentTask];
21  
resources/multi-select.js
270  }
271  
272  
273 +function deleteMultiSelected() {
274 +	var selected = getMultiSelectedTasks();
275 +	var toDelete = selected.filter(t => t !== state.taskPath[0] && t !== state.currentTask);
276 +	if (toDelete.length > 0 && !(state.currentTask.id === 'root' && toDelete.length >= state.currentTask.subtasks.length)) {
277 +		var firstDeleteIdx = Math.min(...toDelete.map(t => state.currentTask.subtasks.findIndex(s => s.id === t.id)).filter(i => i !== -1));
278 +		for (var t of toDelete) {
279 +			var idx = state.currentTask.subtasks.findIndex(s => s.id === t.id);
280 +			if (idx !== -1) state.currentTask.subtasks.splice(idx, 1);
281 +		}
282 +		clearMultiSelect();
283 +		updateTaskAndAncestors(state.currentTask);
284 +		if (state.currentTask.subtasks.length === 0 && state.taskPath.length > 1) {
285 +			navigateToParentTask();
286 +		} else {
287 +			renderCurrentView();
288 +			var targetIndex = Math.max(0, firstDeleteIdx - 1);
289 +			selectAndFocusTask(state.currentTask.subtasks[targetIndex]);
290 +		}
291 +		scheduleSave();
292 +	} else {
293 +		for (var t of selected) {
294 +			applyShakeAnimation(t.id);
295 +		}
296 +	}
297 +}
298 +
299  function shakeAllSelected(direction = 'horizontal') {
300  	for (var id of state.multiSelectedIds) {
301  		applyShakeAnimation(id, direction);
resources/render.js
 99  	sel.addRange(range);
100  }
101  
    -function isSelectionCollapsed() {
    -	var sel = window.getSelection();
    -	return !sel || sel.isCollapsed;
    -}
    -
102  function generateBreadcrumbs(rootTask, currentPath, selectedTaskId) {
103  	var breadcrumbs = '';
104  	var currentTask = rootTask;

395  	}
396  }
397  
    -function handleCopyAndCut(e) {
    -	if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'x')) {
    -		// copy/cut are disabled during multi-select (no single "true" line)
398 +function handleCopyCutPaste(e) {
399 +	if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'x' || e.key === 'v')) {
400  		if (state.multiSelectedIds.length > 1) {
401  			e.preventDefault();
402 +			if (e.key === 'c') {
403 +				setCopiedTasks(getMultiSelectedTasks());
404 +				shakeAllSelected('vertical');
405 +			} else if (e.key === 'x' && !e.repeat) {
406 +				setCopiedTasks(getMultiSelectedTasks());
407 +				deleteMultiSelected();
408 +			} else if (e.key === 'v' && !e.repeat) {
409 +				pasteCopiedTasks(getMultiSelectedTasks());
410 +			}
411  			return;
412  		}
413  		var activeTaskInput = document.querySelector('.task-container.active .task-text');
414  		if (activeTaskInput) {
415  			e.preventDefault();
416  
    -			// With no selection, act on the whole task text
    -			var collapsed = isSelectionCollapsed();
    -			if (collapsed) {
    -				selectAllText(activeTaskInput);
    -			}
417 +			var taskId = activeTaskInput.closest('.task-container').dataset.id;
418 +			var task = state.currentTask.id === taskId ? state.currentTask : state.currentTask.subtasks.find(t => t.id === taskId);
419  
420  			if (e.key === 'c') {
    -				document.execCommand('copy');
    -			} else if (e.key === 'x') {
    -				document.execCommand('cut');
    -
    -				var taskContainer = activeTaskInput.closest('.task-container');
    -				var taskId = taskContainer.dataset.id;
    -				var task = state.currentTask.id === taskId ? state.currentTask : state.currentTask.subtasks.find(t => t.id === taskId);
421  				if (task) {
    -					task.text = activeTaskInput.textContent;
    -					scheduleSave();
422 +					setCopiedTasks([task]);
423 +					applyShakeAnimation(task.id, 'vertical');
424 +				}
425 +				return;
426 +			}
427 +
428 +			if (e.key === 'x') {
429 +				if (task && !e.repeat) {
430 +					setCopiedTasks([task]);
431 +					deleteTask(task);
432  				}
433 +				return;
434  			}
435  
    -			// After a copy that auto-selected everything, collapse the caret to the end
    -			if (e.key === 'c' && collapsed) {
    -				setCaretOffset(activeTaskInput, activeTaskInput.textContent.length);
436 +			if (e.key === 'v') {
437 +				if (task && !e.repeat) pasteCopiedTasks([task]);
438  			}
439  		}
440  	}
resources/state.js
12  	isF2Pressed: false,
13  	themes: ['gak', 'swamp', 'sunflower', 'harvest', 'sugar', 'vineyard', 'woodstove', 'medieval', 'goblin'],
14  	windowFocused: true,
15 +	copiedTasks: [],
16  
17  	// Multi-select state
18  	multiSelectAnchorId: null,
resources/storage.js
58  	return root;
59  }
60  
61 +function setCopiedTasks(tasks) {
62 +	state.copiedTasks = tasks.map(cloneTaskWithNewIds);
63 +	sessionStorage.setItem('copiedTasks', JSON.stringify(state.copiedTasks));
64 +}
65 +
66 +function loadCopiedTasksFromSessionStorage() {
67 +	return JSON.parse(sessionStorage.getItem('copiedTasks')) || [];
68 +}
69 +
70  function loadTasksFromLocalStorage() {
71  	var savedTasks = localStorage.getItem('taskTree');
72  	if (savedTasks) {
resources/task-element.js
 97  					e.preventDefault();
 98  					if (keyHandler.backspace.canDelete) {
 99  						keyHandler.backspace.blocked = true;
    -						var toDelete = selected.filter(t => t !== state.taskPath[0] && t !== state.currentTask);
    -						if (toDelete.length > 0 && !(state.currentTask.id === 'root' && toDelete.length >= state.currentTask.subtasks.length)) {
    -							var firstDeleteIdx = Math.min(...toDelete.map(t => state.currentTask.subtasks.findIndex(s => s.id === t.id)).filter(i => i !== -1));
    -							for (var t of toDelete) {
    -								var idx = state.currentTask.subtasks.findIndex(s => s.id === t.id);
    -								if (idx !== -1) state.currentTask.subtasks.splice(idx, 1);
    -							}
    -							clearMultiSelect();
    -							updateTaskAndAncestors(state.currentTask);
    -							if (state.currentTask.subtasks.length === 0 && state.taskPath.length > 1) {
    -								navigateToParentTask();
    -							} else {
    -								renderCurrentView();
    -								var targetIndex = Math.max(0, firstDeleteIdx - 1);
    -								selectAndFocusTask(state.currentTask.subtasks[targetIndex]);
    -							}
    -							scheduleSave();
    -						} else {
    -							for (var t of selected) {
    -								applyShakeAnimation(t.id);
    -							}
    -						}
100 +						deleteMultiSelected();
101  					}
102  					return;
103  				}

110  			}
111  			if (taskInput.textContent === '' && keyHandler.backspace.canDelete && state.multiSelectedIds.length <= 1) {
112  				e.preventDefault();
    -				if (task !== state.taskPath[0]) {
    -					keyHandler.backspace.blocked = true;
    -					clearMultiSelect();
    -					if (task === state.currentTask) {
    -						deleteCurrentParentTask();
    -					} else {
    -						deleteSubtask(task);
    -					}
    -				} else {
    -					applyShakeAnimation(task.id);
    -				}
113 +				if (task !== state.taskPath[0]) keyHandler.backspace.blocked = true;
114 +				deleteTask(task);
115  			} else if (taskInput.textContent !== '') {
116  				keyHandler.backspace.canDelete = false;
117  				if (deleteWholeGrapheme(taskInput, 'backward')) e.preventDefault();

231  
232  	taskInput.addEventListener('keydown', keydownHandler);
233  	taskInput.addEventListener('keyup', keyupHandler);
    -	taskInput.addEventListener('keydown', handleCopyAndCut);
234 +	taskInput.addEventListener('keydown', handleCopyCutPaste);
235 +	taskInput.addEventListener('paste', (e) => e.preventDefault());
236  	taskInput.addEventListener('input', (e) => {
237  		var oldText = task.text;
238  		// Normalize: strip newlines a paste/IME may introduce, and convert

266  				for (var t of otherSelected) {
267  					deleteAtMultiCaret(t, 'forward');
268  				}
    -			} else if (e.inputType === 'insertFromPaste' || e.inputType === 'insertFromDrop') {
    -				// the paste may have replaced the focused line's selection, so
    -				// account for the replaced range when sizing the pasted text
269 +			} else if (e.inputType === 'insertFromDrop') {
270  				var preRange = state.multiSelectRanges[task.id];
271  				var replacedLen = preRange
272  					? Math.min(preRange.end, oldText.length) - Math.min(preRange.start, oldText.length)

274  				var addedLen = taskInput.textContent.length - oldText.length + Math.max(0, replacedLen);
275  				if (addedLen > 0) {
276  					var caretPos = getCaretOffset(taskInput);
    -					var pastedText = taskInput.textContent.slice(caretPos - addedLen, caretPos);
277 +					var droppedText = taskInput.textContent.slice(caretPos - addedLen, caretPos);
278  					for (var t of otherSelected) {
    -						insertAtMultiCaret(t, pastedText);
279 +						insertAtMultiCaret(t, droppedText);
280  					}
281  				}
282  			}
resources/task-model.js
128  	scheduleSave();
129  }
130  
131 +function isEmptyTask(task) {
132 +	return task.text === '' && task.subtasks.length === 0;
133 +}
134 +
135 +function cloneTaskWithNewIds(task) {
136 +	var subtasks = task.subtasks.map(cloneTaskWithNewIds);
137 +	var selectedIndex = task.subtasks.findIndex(t => t.id === task.selectedSubtaskId);
138 +	return {
139 +		id: generateId(),
140 +		text: task.text,
141 +		state: task.state,
142 +		subtasks: subtasks,
143 +		selectedSubtaskId: selectedIndex === -1 ? null : subtasks[selectedIndex].id
144 +	};
145 +}
146 +
147 +function pasteCopiedTasks(targetTasks) {
148 +	if (state.copiedTasks.length === 0) return;
149 +
150 +	var pastedTasks = state.copiedTasks.map(cloneTaskWithNewIds);
151 +	var parentTask = state.currentTask;
152 +	if (targetTasks[0] === parentTask) {
153 +		parentTask.subtasks.unshift(...pastedTasks);
154 +	} else {
155 +		var firstIndex = parentTask.subtasks.findIndex(t => t.id === targetTasks[0].id);
156 +		var lastIndex = parentTask.subtasks.findIndex(t => t.id === targetTasks[targetTasks.length - 1].id);
157 +		var isEmptySlot = targetTasks.every(isEmptyTask) && !state.copiedTasks.every(isEmptyTask);
158 +		if (isEmptySlot) {
159 +			parentTask.subtasks.splice(firstIndex, targetTasks.length, ...pastedTasks);
160 +		} else {
161 +			parentTask.subtasks.splice(lastIndex + 1, 0, ...pastedTasks);
162 +		}
163 +	}
164 +	pastedTasks.forEach(t => adjustMovedTaskState(t, parentTask));
165 +	updateTaskAndAncestors(parentTask);
166 +
167 +	clearMultiSelect();
168 +	var lastPasted = pastedTasks[pastedTasks.length - 1];
169 +	if (pastedTasks.length > 1) {
170 +		state.multiSelectAnchorId = pastedTasks[0].id;
171 +		state.multiSelectedIds = pastedTasks.map(t => t.id);
172 +		pastedTasks.forEach(t => state.multiCaretOffsets[t.id] = t.text.length);
173 +	}
174 +	parentTask.selectedSubtaskId = lastPasted.id;
175 +	renderCurrentView();
176 +	pastedTasks.forEach(t => applyShakeAnimation(t.id, 'vertical'));
177 +	scheduleSave();
178 +}
179 +
180 +function deleteTask(task) {
181 +	if (task === state.taskPath[0]) {
182 +		applyShakeAnimation(task.id);
183 +		return;
184 +	}
185 +	clearMultiSelect();
186 +	if (task === state.currentTask) {
187 +		deleteCurrentParentTask();
188 +	} else {
189 +		deleteSubtask(task);
190 +	}
191 +}
192 +
193  function deleteSubtask(subtask) {
194  	var parentTask = state.taskPath[state.taskPath.length - 1];
195  	var index = parentTask.subtasks.findIndex(t => t.id === subtask.id);

246  			selectAndFocusTask(grandparentTask);
247  		}
248  	}
249 +	scheduleSave();
250  }