⌘+c/⌘+x/⌘+v to copy/cut/paste tasks and their subtasks
Hunter
Thursday, September 24th, 2026 at 12:05 AM
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
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
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);
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 }
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,
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) {
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 }
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 }