commit 46ac95094d56ad2a23b7e06985f41159ed6705b5
parent 1c3f2df80b8b4955630e3111f52ec1fd73a2d2de
Author: Hunter
Date: Thu, 24 Sep 2026 00:05:52 -0400
⌘+c/⌘+x/⌘+v to copy/cut/paste tasks and their subtasks
Diffstat:
8 files changed, 137 insertions(+), 69 deletions(-)
diff --git a/readme.md b/readme.md
@@ -32,6 +32,9 @@ the nested todo list that breaks complex tasks into manageable subtasks.
- `⬇️` (on last subtask) add a new task at the bottom of the list
- `Backspace` (when task text is empty) delete selected task(s) and their subtasks
- `Shift + Enter` toggle selected task(s) completion status
+- `⌘ + C` copy selected task(s) and their subtasks
+- `⌘ + X` cut selected task(s) and their subtasks
+- `⌘ + V` paste copied task(s) and their subtasks
### reorganization
- `Shift + ⬆️/⬇️` reposition selected task(s) within their current level
@@ -43,11 +46,6 @@ the nested todo list that breaks complex tasks into manageable subtasks.
- `Option + ⬆️/⬇️` extend or contract the selection to include adjacent tasks
- `⬆️/⬇️` (without modifier) clear the selection and resume single-task navigation
-### text editing
-- `⌘ + C` copy task text (or highlighted substring)
-- `⌘ + X` cut task text (or highlighted substring)
-- `⌘ + V` paste text from the clipboard
-
### theming
- press `F2` to cycle through available themes
diff --git a/resources/main.js b/resources/main.js
@@ -15,6 +15,7 @@ document.addEventListener('click', function(e) {
document.addEventListener('DOMContentLoaded', function() {
state.appContainer = document.getElementById('app-container');
state.rootTask = loadTasksFromLocalStorage();
+ state.copiedTasks = loadCopiedTasksFromSessionStorage();
state.currentTask = state.rootTask;
state.taskPath = [state.currentTask];
diff --git a/resources/multi-select.js b/resources/multi-select.js
@@ -270,6 +270,32 @@ function updateMultiRangeStyles() {
}
+function deleteMultiSelected() {
+ var selected = getMultiSelectedTasks();
+ 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);
+ }
+ }
+}
+
function shakeAllSelected(direction = 'horizontal') {
for (var id of state.multiSelectedIds) {
applyShakeAnimation(id, direction);
diff --git a/resources/render.js b/resources/render.js
@@ -99,11 +99,6 @@ function restoreActiveSelection(saved) {
sel.addRange(range);
}
-function isSelectionCollapsed() {
- var sel = window.getSelection();
- return !sel || sel.isCollapsed;
-}
-
function generateBreadcrumbs(rootTask, currentPath, selectedTaskId) {
var breadcrumbs = '';
var currentTask = rootTask;
@@ -400,40 +395,46 @@ function selectFirstSubtask() {
}
}
-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)
+function handleCopyCutPaste(e) {
+ if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'x' || e.key === 'v')) {
if (state.multiSelectedIds.length > 1) {
e.preventDefault();
+ if (e.key === 'c') {
+ setCopiedTasks(getMultiSelectedTasks());
+ shakeAllSelected('vertical');
+ } else if (e.key === 'x' && !e.repeat) {
+ setCopiedTasks(getMultiSelectedTasks());
+ deleteMultiSelected();
+ } else if (e.key === 'v' && !e.repeat) {
+ pasteCopiedTasks(getMultiSelectedTasks());
+ }
return;
}
var activeTaskInput = document.querySelector('.task-container.active .task-text');
if (activeTaskInput) {
e.preventDefault();
- // With no selection, act on the whole task text
- var collapsed = isSelectionCollapsed();
- if (collapsed) {
- selectAllText(activeTaskInput);
- }
+ var taskId = activeTaskInput.closest('.task-container').dataset.id;
+ var task = state.currentTask.id === taskId ? state.currentTask : state.currentTask.subtasks.find(t => t.id === taskId);
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);
if (task) {
- task.text = activeTaskInput.textContent;
- scheduleSave();
+ setCopiedTasks([task]);
+ applyShakeAnimation(task.id, 'vertical');
+ }
+ return;
+ }
+
+ if (e.key === 'x') {
+ if (task && !e.repeat) {
+ setCopiedTasks([task]);
+ deleteTask(task);
}
+ return;
}
- // After a copy that auto-selected everything, collapse the caret to the end
- if (e.key === 'c' && collapsed) {
- setCaretOffset(activeTaskInput, activeTaskInput.textContent.length);
+ if (e.key === 'v') {
+ if (task && !e.repeat) pasteCopiedTasks([task]);
}
}
}
diff --git a/resources/state.js b/resources/state.js
@@ -12,6 +12,7 @@ var state = {
isF2Pressed: false,
themes: ['gak', 'swamp', 'sunflower', 'harvest', 'sugar', 'vineyard', 'woodstove', 'medieval', 'goblin'],
windowFocused: true,
+ copiedTasks: [],
// Multi-select state
multiSelectAnchorId: null,
diff --git a/resources/storage.js b/resources/storage.js
@@ -58,6 +58,15 @@ function deserializeTaskTree(serialized) {
return root;
}
+function setCopiedTasks(tasks) {
+ state.copiedTasks = tasks.map(cloneTaskWithNewIds);
+ sessionStorage.setItem('copiedTasks', JSON.stringify(state.copiedTasks));
+}
+
+function loadCopiedTasksFromSessionStorage() {
+ return JSON.parse(sessionStorage.getItem('copiedTasks')) || [];
+}
+
function loadTasksFromLocalStorage() {
var savedTasks = localStorage.getItem('taskTree');
if (savedTasks) {
diff --git a/resources/task-element.js b/resources/task-element.js
@@ -97,28 +97,7 @@ function createTaskElement(task, isParentTask = false) {
e.preventDefault();
if (keyHandler.backspace.canDelete) {
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);
- }
- }
+ deleteMultiSelected();
}
return;
}
@@ -131,17 +110,8 @@ function createTaskElement(task, isParentTask = false) {
}
if (taskInput.textContent === '' && keyHandler.backspace.canDelete && state.multiSelectedIds.length <= 1) {
e.preventDefault();
- if (task !== state.taskPath[0]) {
- keyHandler.backspace.blocked = true;
- clearMultiSelect();
- if (task === state.currentTask) {
- deleteCurrentParentTask();
- } else {
- deleteSubtask(task);
- }
- } else {
- applyShakeAnimation(task.id);
- }
+ if (task !== state.taskPath[0]) keyHandler.backspace.blocked = true;
+ deleteTask(task);
} else if (taskInput.textContent !== '') {
keyHandler.backspace.canDelete = false;
if (deleteWholeGrapheme(taskInput, 'backward')) e.preventDefault();
@@ -261,7 +231,8 @@ function createTaskElement(task, isParentTask = false) {
taskInput.addEventListener('keydown', keydownHandler);
taskInput.addEventListener('keyup', keyupHandler);
- taskInput.addEventListener('keydown', handleCopyAndCut);
+ taskInput.addEventListener('keydown', handleCopyCutPaste);
+ taskInput.addEventListener('paste', (e) => e.preventDefault());
taskInput.addEventListener('input', (e) => {
var oldText = task.text;
// Normalize: strip newlines a paste/IME may introduce, and convert
@@ -295,9 +266,7 @@ function createTaskElement(task, isParentTask = false) {
for (var t of otherSelected) {
deleteAtMultiCaret(t, 'forward');
}
- } 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
+ } else if (e.inputType === 'insertFromDrop') {
var preRange = state.multiSelectRanges[task.id];
var replacedLen = preRange
? Math.min(preRange.end, oldText.length) - Math.min(preRange.start, oldText.length)
@@ -305,9 +274,9 @@ function createTaskElement(task, isParentTask = false) {
var addedLen = taskInput.textContent.length - oldText.length + Math.max(0, replacedLen);
if (addedLen > 0) {
var caretPos = getCaretOffset(taskInput);
- var pastedText = taskInput.textContent.slice(caretPos - addedLen, caretPos);
+ var droppedText = taskInput.textContent.slice(caretPos - addedLen, caretPos);
for (var t of otherSelected) {
- insertAtMultiCaret(t, pastedText);
+ insertAtMultiCaret(t, droppedText);
}
}
}
diff --git a/resources/task-model.js b/resources/task-model.js
@@ -128,6 +128,68 @@ function addNewSubtask(parentTask, currentSubtask = null) {
scheduleSave();
}
+function isEmptyTask(task) {
+ return task.text === '' && task.subtasks.length === 0;
+}
+
+function cloneTaskWithNewIds(task) {
+ var subtasks = task.subtasks.map(cloneTaskWithNewIds);
+ var selectedIndex = task.subtasks.findIndex(t => t.id === task.selectedSubtaskId);
+ return {
+ id: generateId(),
+ text: task.text,
+ state: task.state,
+ subtasks: subtasks,
+ selectedSubtaskId: selectedIndex === -1 ? null : subtasks[selectedIndex].id
+ };
+}
+
+function pasteCopiedTasks(targetTasks) {
+ if (state.copiedTasks.length === 0) return;
+
+ var pastedTasks = state.copiedTasks.map(cloneTaskWithNewIds);
+ var parentTask = state.currentTask;
+ if (targetTasks[0] === parentTask) {
+ parentTask.subtasks.unshift(...pastedTasks);
+ } else {
+ var firstIndex = parentTask.subtasks.findIndex(t => t.id === targetTasks[0].id);
+ var lastIndex = parentTask.subtasks.findIndex(t => t.id === targetTasks[targetTasks.length - 1].id);
+ var isEmptySlot = targetTasks.every(isEmptyTask) && !state.copiedTasks.every(isEmptyTask);
+ if (isEmptySlot) {
+ parentTask.subtasks.splice(firstIndex, targetTasks.length, ...pastedTasks);
+ } else {
+ parentTask.subtasks.splice(lastIndex + 1, 0, ...pastedTasks);
+ }
+ }
+ pastedTasks.forEach(t => adjustMovedTaskState(t, parentTask));
+ updateTaskAndAncestors(parentTask);
+
+ clearMultiSelect();
+ var lastPasted = pastedTasks[pastedTasks.length - 1];
+ if (pastedTasks.length > 1) {
+ state.multiSelectAnchorId = pastedTasks[0].id;
+ state.multiSelectedIds = pastedTasks.map(t => t.id);
+ pastedTasks.forEach(t => state.multiCaretOffsets[t.id] = t.text.length);
+ }
+ parentTask.selectedSubtaskId = lastPasted.id;
+ renderCurrentView();
+ pastedTasks.forEach(t => applyShakeAnimation(t.id, 'vertical'));
+ scheduleSave();
+}
+
+function deleteTask(task) {
+ if (task === state.taskPath[0]) {
+ applyShakeAnimation(task.id);
+ return;
+ }
+ clearMultiSelect();
+ if (task === state.currentTask) {
+ deleteCurrentParentTask();
+ } else {
+ deleteSubtask(task);
+ }
+}
+
function deleteSubtask(subtask) {
var parentTask = state.taskPath[state.taskPath.length - 1];
var index = parentTask.subtasks.findIndex(t => t.id === subtask.id);
@@ -184,4 +246,5 @@ function deleteCurrentParentTask() {
selectAndFocusTask(grandparentTask);
}
}
+ scheduleSave();
}