Merge pull request #3 from hunterirving/pull-in-pull-out

Push and Pull Tasks Between Layers
Hunter Irving
Wednesday, July 2nd, 2025 at 12:27 AM

index.html+254 
readme.md+21-15

index.html
 865  						keyHandler.shiftEnter.pressed = true;
 866  						toggleTaskState(task);
 867  					}
 868 +				} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
 869 +					e.preventDefault();
 870 +					pushSubtaskIntoTarget(task, 'up');
 871 +				} else if (e.key === 'ArrowDown' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
 872 +					e.preventDefault();
 873 +					pushSubtaskIntoTarget(task, 'down');
 874  				} else if (e.key === 'ArrowUp' && !e.shiftKey) {
 875  					e.preventDefault();
 876  					navigateTasks('up');
 877  				} else if (e.key === 'ArrowDown' && !e.shiftKey) {
 878  					e.preventDefault();
 879  					navigateTasks('down');
 880 +				} else if (e.key === 'ArrowLeft' && (e.metaKey || e.ctrlKey) && e.shiftKey) {
 881 +					e.preventDefault();
 882 +					pullSubtaskOutLayerAndNavigate(task);
 883 +				} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey) && e.shiftKey) {
 884 +					e.preventDefault();
 885 +					pushSubtaskIntoTargetAndNavigate(task, 'up');
 886 +				} else if (e.key === 'ArrowDown' && (e.metaKey || e.ctrlKey) && e.shiftKey) {
 887 +					e.preventDefault();
 888 +					pushSubtaskIntoTargetAndNavigate(task, 'down');
 889  				} else if (e.key === 'ArrowUp' && e.shiftKey) {
 890  					e.preventDefault();
 891  					moveSubtask(task, 'up');

 903  							applyShakeAnimation(task.id);
 904  						}
 905  					}
 906 +				} else if (e.key === 'ArrowLeft' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
 907 +					e.preventDefault();
 908 +					pullSubtaskOutLayer(task);
 909  				} else if (e.key === 'ArrowLeft' && e.shiftKey) {
 910  					e.preventDefault();
 911  					if (!keyHandler.shiftLeft.pressed) {

 933  				scheduleSave();
 934  			}
 935  
 936 +			function pushSubtaskIntoTarget(subtask, direction) {
 937 +				// Don't allow pushing the current parent task
 938 +				if (subtask === currentTask) {
 939 +					return;
 940 +				}
 941 +
 942 +				const parentTask = findParentTask(subtask);
 943 +				if (!parentTask) return;
 944 +
 945 +				const index = parentTask.subtasks.findIndex(t => t.id === subtask.id);
 946 +				if (index === -1) return;
 947 +
 948 +				let targetTask = null;
 949 +				if (direction === 'up' && index > 0) {
 950 +					targetTask = parentTask.subtasks[index - 1];
 951 +				} else if (direction === 'down' && index < parentTask.subtasks.length - 1) {
 952 +					targetTask = parentTask.subtasks[index + 1];
 953 +				}
 954 +
 955 +				if (!targetTask) return;
 956 +
 957 +				// Remove the subtask from its current parent
 958 +				parentTask.subtasks.splice(index, 1);
 959 +
 960 +				// Add the subtask to the target task's subtasks
 961 +				targetTask.subtasks.push(subtask);
 962 +
 963 +				// Update parent task state after removal
 964 +				updateTaskAndAncestors(parentTask);
 965 +
 966 +				// Update target task state after addition
 967 +				updateTaskAndAncestors(targetTask);
 968 +
 969 +				// Re-render the view
 970 +				renderCurrentView();
 971 +
 972 +				// Focus on the target task that now contains the moved subtask
 973 +				selectAndFocusTask(targetTask);
 974 +
 975 +				scheduleSave();
 976 +			}
 977 +
 978 +			function pushSubtaskIntoTargetAndNavigate(subtask, direction) {
 979 +				// Don't allow pushing the current parent task
 980 +				if (subtask === currentTask) {
 981 +					return;
 982 +				}
 983 +
 984 +				// Temporarily disable smooth scrolling
 985 +				document.documentElement.style.scrollBehavior = 'auto';
 986 +
 987 +				const parentTask = findParentTask(subtask);
 988 +				if (!parentTask) return;
 989 +
 990 +				const index = parentTask.subtasks.findIndex(t => t.id === subtask.id);
 991 +				if (index === -1) return;
 992 +
 993 +				let targetTask = null;
 994 +				if (direction === 'up' && index > 0) {
 995 +					targetTask = parentTask.subtasks[index - 1];
 996 +				} else if (direction === 'down' && index < parentTask.subtasks.length - 1) {
 997 +					targetTask = parentTask.subtasks[index + 1];
 998 +				}
 999 +
1000 +				if (!targetTask) return;
1001 +
1002 +				// Remove the subtask from its current parent
1003 +				parentTask.subtasks.splice(index, 1);
1004 +
1005 +				// Add the subtask to the target task's subtasks
1006 +				targetTask.subtasks.push(subtask);
1007 +
1008 +				// Update parent task state after removal
1009 +				updateTaskAndAncestors(parentTask);
1010 +
1011 +				// Update target task state after addition
1012 +				updateTaskAndAncestors(targetTask);
1013 +
1014 +				// Navigate into the target task and select the moved subtask
1015 +				navigateIntoTaskAndSelectSubtask(targetTask, subtask);
1016 +
1017 +				scheduleSave();
1018 +			}
1019 +
1020 +			function pullSubtaskOutLayer(subtask) {
1021 +				// Don't allow pulling the current parent task
1022 +				if (subtask === currentTask) {
1023 +					return;
1024 +				}
1025 +
1026 +				// Don't allow pulling when at root level
1027 +				if (taskPath.length <= 1) {
1028 +					return;
1029 +				}
1030 +
1031 +				const currentParent = findParentTask(subtask);
1032 +				if (!currentParent) return;
1033 +
1034 +				// Find the grandparent (the outer layer we're pulling into)
1035 +				const grandParent = findGrandParentTask(currentParent);
1036 +				if (!grandParent) return;
1037 +
1038 +				// Remove the subtask from its current parent
1039 +				const currentIndex = currentParent.subtasks.findIndex(t => t.id === subtask.id);
1040 +				if (currentIndex === -1) return;
1041 +				currentParent.subtasks.splice(currentIndex, 1);
1042 +
1043 +				// Find where to insert in the grandparent's subtasks
1044 +				const currentParentIndex = grandParent.subtasks.findIndex(t => t.id === currentParent.id);
1045 +				if (currentParentIndex === -1) {
1046 +					// Fallback: add to end of grandparent's subtasks
1047 +					grandParent.subtasks.push(subtask);
1048 +				} else {
1049 +					// Insert after the current parent's position
1050 +					grandParent.subtasks.splice(currentParentIndex + 1, 0, subtask);
1051 +				}
1052 +
1053 +				// Update task states
1054 +				updateTaskAndAncestors(currentParent);
1055 +				updateTaskAndAncestors(grandParent);
1056 +
1057 +				// Handle view and selection based on remaining tasks at current level
1058 +				if (currentParent.subtasks.length === 0 && taskPath.length > 1) {
1059 +					// No remaining subtasks, navigate to parent level and select the moved task
1060 +					navigateToParentTaskAndSelectTask(subtask);
1061 +				} else {
1062 +					// Stay at current level and select adjacent task
1063 +					renderCurrentView();
1064 +					if (currentParent.subtasks.length > 0) {
1065 +						// Select the task that was above the moved one, or below if at the top
1066 +						const targetIndex = Math.max(0, currentIndex - 1);
1067 +						selectAndFocusTask(currentParent.subtasks[targetIndex]);
1068 +					} else {
1069 +						// Focus on the parent task if no subtasks remain
1070 +						selectAndFocusTask(currentParent);
1071 +					}
1072 +				}
1073 +
1074 +				scheduleSave();
1075 +			}
1076 +
1077 +			function pullSubtaskOutLayerAndNavigate(subtask) {
1078 +				// Don't allow pulling the current parent task
1079 +				if (subtask === currentTask) {
1080 +					return;
1081 +				}
1082 +
1083 +				// Don't allow pulling when at root level
1084 +				if (taskPath.length <= 1) {
1085 +					return;
1086 +				}
1087 +
1088 +				// Temporarily disable smooth scrolling
1089 +				document.documentElement.style.scrollBehavior = 'auto';
1090 +
1091 +				const currentParent = findParentTask(subtask);
1092 +				if (!currentParent) return;
1093 +
1094 +				// Find the grandparent (the outer layer we're pulling into)
1095 +				const grandParent = findGrandParentTask(currentParent);
1096 +				if (!grandParent) return;
1097 +
1098 +				// Remove the subtask from its current parent
1099 +				const currentIndex = currentParent.subtasks.findIndex(t => t.id === subtask.id);
1100 +				if (currentIndex === -1) return;
1101 +				currentParent.subtasks.splice(currentIndex, 1);
1102 +
1103 +				// Find where to insert in the grandparent's subtasks
1104 +				const currentParentIndex = grandParent.subtasks.findIndex(t => t.id === currentParent.id);
1105 +				if (currentParentIndex === -1) {
1106 +					// Fallback: add to end of grandparent's subtasks
1107 +					grandParent.subtasks.push(subtask);
1108 +				} else {
1109 +					// Insert after the current parent's position
1110 +					grandParent.subtasks.splice(currentParentIndex + 1, 0, subtask);
1111 +				}
1112 +
1113 +				// Update task states
1114 +				updateTaskAndAncestors(currentParent);
1115 +				updateTaskAndAncestors(grandParent);
1116 +
1117 +				// Always navigate to parent level and select the moved task
1118 +				navigateToParentTaskAndSelectTask(subtask);
1119 +
1120 +				scheduleSave();
1121 +			}
1122 +
1123 +			function findGrandParentTask(parentTask) {
1124 +				// Find the grandparent of the given parent task
1125 +				for (let i = taskPath.length - 1; i >= 0; i--) {
1126 +					const potentialGrandParent = taskPath[i];
1127 +					if (potentialGrandParent.subtasks.some(t => t.id === parentTask.id)) {
1128 +						return potentialGrandParent;
1129 +					}
1130 +				}
1131 +				return null;
1132 +			}
1133 +
1134  			function addNewSubtask(parentTask, currentSubtask = null) {
1135  				const newSubtask = { id: Date.now(), text: '', state: 0, subtasks: [], selectedSubtaskId: null };
1136  				if (currentSubtask) {

1204  				}
1205  			}
1206  
1207 +			function navigateIntoTaskAndSelectSubtask(targetTask, subtaskToSelect) {
1208 +				// Temporarily disable smooth scrolling
1209 +				document.documentElement.style.scrollBehavior = 'auto';
1210 +				
1211 +				// Set the selectedSubtaskId on the current task before navigating
1212 +				currentTask.selectedSubtaskId = targetTask.id;
1213 +				
1214 +				// Navigate into the target task
1215 +				taskPath.push(targetTask);
1216 +				currentTask = targetTask;
1217 +				updateBreadcrumbs(currentTask);
1218 +				renderCurrentView();
1219 +				
1220 +				// Select the moved subtask
1221 +				selectAndFocusTask(subtaskToSelect);
1222 +				currentTask.selectedSubtaskId = subtaskToSelect.id;
1223 +			}
1224 +
1225  			function navigateToParentTask() {
1226  				// Check if the current active parent task is the root
1227  				if (currentTask.id === 'root') {

1253  				}
1254  			}
1255  
1256 +			function navigateToParentTaskAndSelectTask(targetTask) {
1257 +				// Check if the current active parent task is the root
1258 +				if (currentTask.id === 'root') {
1259 +					// We're at root level, just re-render and select the target task
1260 +					renderCurrentView();
1261 +					selectAndFocusTask(targetTask);
1262 +				} else if (taskPath.length > 1) {
1263 +					// Normal navigation out - we're deeper than root level
1264 +					// Temporarily disable smooth scrolling
1265 +					document.documentElement.style.scrollBehavior = 'auto';
1266 +					
1267 +					taskPath.pop();
1268 +					currentTask = taskPath[taskPath.length - 1];
1269 +					updateBreadcrumbs(currentTask);
1270 +					renderCurrentView();
1271 +					selectAndFocusTask(targetTask);
1272 +					currentTask.selectedSubtaskId = targetTask.id;
1273 +				}
1274 +			}
1275 +
1276  			function setActiveTask(input, task) {
1277  				document.querySelectorAll('.active').forEach(el => el.classList.remove('active'));
1278  				input.closest('.task-container').classList.add('active');
readme.md
16  ### quickstart
17  1. press the `Return` / `Enter` key to add subtasks to the root "todo" task
18  2. give each new subtask a meaningful name
   -3. use `Shift + Arrow Right` to navigate into a subtask
   -4. use `Shift + Arrow Left` to return to the parent task
19 +3. use `Shift + ➡️` to navigate into a subtask
20 +4. use `Shift + ⬅️` to return to the parent task
21  5. use `Shift + Enter` to quickly mark tasks as complete or incomplete
22  
23  ## controls
24  
25  ### navigation
   -- `Arrow Up/Down` move between tasks at the same level
   -- `Shift + Arrow Right` navigate into a subtask
   -- `Shift + Arrow Left` return to the enclosing parent task
26 +- `⬆️/⬇️` move between tasks at the same level
27 +- `Shift + ➡️` navigate into a subtask
28 +- `Shift + ⬅️` return to the enclosing parent task
29  
30  ### task management
31  - `Enter` add a new task
   -- `Arrow Down` (on last subtask) add a new task at the bottom of the list
32 +- `⬇️` (on last subtask) add a new task at the bottom of the list
33  - `Backspace` (when selected task's text is empty) remove the task and its subtasks
34  - `Shift + Enter` toggle selected task's completion status
   -- `Shift + Arrow Up/Down` reposition the selected task within its current level
35 +- `Shift + ⬆️/⬇️` reposition the selected task within its current level
36 +- `⌘ + ⬆️/⬇️` push the selected task into the task above or below it
37 +- `⌘ + ⬅️` pull the selected task out one level (to the level of its parent)
38 +
39 +### combined movement + navigation
40 +- `⌘ + Shift + ⬆️/⬇️` push the selected task into the task above or below it AND navigate to its new position
41 +- `⌘ + Shift + ⬅️` pull the selected task out one level AND navigate to its new position
42  
43  ### text editing
   -- `Left/Right` move text cursor within selected task
   -- `Command/Ctrl + C` copy the selected task's text (or highlighted substring)
   -- `Command/Ctrl + X` cut the selected task's text (or highlighted substring)
   -- `Command/Ctrl + V` paste text content from the clipboard
   -- `Command/Ctrl + Z` undo text edit
   -- `Command/Ctrl + Shift + Z` redo text edit
44 +- `⬅️/➡️` move text cursor within selected task
45 +- `⌘ + C` copy the selected task's text (or highlighted substring)
46 +- `⌘ + X` cut the selected task's text (or highlighted substring)
47 +- `⌘ + V` paste text content from the clipboard
48 +- `⌘ + Z` undo text edit
49 +- `⌘ + Shift + Z` redo text edit
50  
51  ## data persistence
52  your task tree is automatically saved to your browser's local storage after each edit. this ensures your tasks will persist even if you close the browser or refresh the page.

54  > [!WARNING]  
55  > clearing your browsing data may erase your saved tasks. to avoid losing progress, create a manual backup before clearing your browsing data.
56  
   -- `Command/Ctrl + S` export tasks to .txt file
   -- `Command/Ctrl + O` import tasks from .txt file
57 +- `⌘ + S` export tasks to .txt file
58 +- `⌘ + O` import tasks from .txt file
59  
60  ## themes
61  press `F2` to cycle through available themes.