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:
Mreadme.md | 8+++-----
Mresources/main.js | 1+
Mresources/multi-select.js | 26++++++++++++++++++++++++++
Mresources/render.js | 51++++++++++++++++++++++++++-------------------------
Mresources/state.js | 1+
Mresources/storage.js | 9+++++++++
Mresources/task-element.js | 47++++++++---------------------------------------
Mresources/task-model.js | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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(); }