hold option to select multiple subtasks
Hunter
Saturday, March 7th, 2026 at 11:56 PM
384 let isF2Pressed = false;
385 let themes = [];
386
387 + // Multi-select state
388 + let multiSelectAnchorId = null // task where multi-select started
389 + let multiSelectedIds = [] // ordered array of selected task ids
390 +
391 + function clearMultiSelect() {
392 + multiSelectAnchorId = null
393 + multiSelectedIds = []
394 + // Remove .active from non-focused tasks
395 + const focused = document.activeElement
396 + const focusedContainer = focused ? focused.closest('.task-container') : null
397 + document.querySelectorAll('.task-container.active').forEach(el => {
398 + if (el !== focusedContainer) el.classList.remove('active')
399 + })
400 + }
401 +
402 + function getMultiSelectedTasks() {
403 + // Return task objects in subtask order
404 + return currentTask.subtasks.filter(t => multiSelectedIds.includes(t.id))
405 + }
406 +
407 + function applyMultiSelectHighlights() {
408 + for (const id of multiSelectedIds) {
409 + const container = document.querySelector(`.task-container[data-id="${id}"]`)
410 + if (container) container.classList.add('active')
411 + }
412 + }
413 +
414 + function shakeAllSelected(direction = 'horizontal') {
415 + for (const id of multiSelectedIds) {
416 + applyShakeAnimation(id, direction)
417 + }
418 + }
419 +
420 function handleSave(e) {
421 if ((e.metaKey || e.ctrlKey) && e.key === 's') {
422 e.preventDefault();
609 e.preventDefault();
610 return;
611 }
- if (taskInput.value === '' && keyHandler.backspace.canDelete) {
612 + // Multi-select backspace handling
613 + if (multiSelectedIds.length > 1) {
614 + const selected = getMultiSelectedTasks()
615 + const allEmpty = selected.every(t => t.text === '')
616 + if (allEmpty) {
617 + // All empty: require release+repress (canDelete) before bulk deleting
618 + e.preventDefault()
619 + if (keyHandler.backspace.canDelete) {
620 + keyHandler.backspace.blocked = true
621 + const toDelete = selected.filter(t => t !== taskPath[0] && t !== currentTask)
622 + // Don't delete if it would leave the root task with no subtasks
623 + if (toDelete.length > 0 && !(currentTask.id === 'root' && toDelete.length >= currentTask.subtasks.length)) {
624 + for (const t of toDelete) {
625 + const idx = currentTask.subtasks.findIndex(s => s.id === t.id)
626 + if (idx !== -1) currentTask.subtasks.splice(idx, 1)
627 + }
628 + clearMultiSelect()
629 + updateTaskAndAncestors(currentTask)
630 + if (currentTask.subtasks.length === 0 && taskPath.length > 1) {
631 + navigateToParentTask()
632 + } else {
633 + renderCurrentView()
634 + }
635 + scheduleSave()
636 + } else {
637 + // Can't delete: shake all selected tasks
638 + for (const t of selected) {
639 + applyShakeAnimation(t.id)
640 + }
641 + }
642 + }
643 + return
644 + }
645 + // Some tasks still have text: remove last char from all that have text
646 + e.preventDefault()
647 + for (const t of selected) {
648 + if (t.text.length > 0) {
649 + t.text = t.text.slice(0, -1)
650 + const inp = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`)
651 + if (inp) inp.value = t.text
652 + }
653 + }
654 + // Mark canDelete false so when all become empty, user must release+repress
655 + keyHandler.backspace.canDelete = false
656 + scheduleSave()
657 + return
658 + }
659 + if (taskInput.value === '' && keyHandler.backspace.canDelete && multiSelectedIds.length <= 1) {
660 e.preventDefault();
661 if (task !== taskPath[0]) {
662 keyHandler.backspace.blocked = true;
663 + clearMultiSelect()
664 if (task === currentTask) {
665 deleteCurrentParentTask();
666 } else {
681 if (keyHandler.enter.canAdd) {
682 e.preventDefault();
683 keyHandler.enter.blocked = true;
684 + clearMultiSelect()
685 addNewSubtask(currentTask, task);
686 }
- } else if (e.key === 'ArrowDown' && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
687 + } else if (e.key === 'ArrowDown' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
688 if (keyHandler.arrowDown.blocked) {
689 e.preventDefault();
690 return;
697 } else {
698 handleKeyDown(e, task);
699 }
- } else if (e.key === 'ArrowDown' && e.shiftKey && !e.metaKey && !e.ctrlKey) {
700 + } else if (e.key === 'ArrowDown' && e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
701 if (keyHandler.shiftArrowDown.blocked) {
702 e.preventDefault();
703 return;
704 }
- if (isLastSubtask(task) && lastSubtaskShiftDownReleased && task !== currentTask && !e.repeat) {
705 + // Multi-select: insert new task above chunk when at bottom
706 + if (multiSelectedIds.length > 1 && !e.repeat) {
707 + const selected = getMultiSelectedTasks()
708 + const lastSelected = selected[selected.length - 1]
709 + if (isLastSubtask(lastSelected) && lastSubtaskShiftDownReleased) {
710 + e.preventDefault()
711 + keyHandler.shiftArrowDown.blocked = true
712 + // Find topmost selected task index and insert above chunk
713 + const topIndex = currentTask.subtasks.findIndex(t => multiSelectedIds.includes(t.id))
714 + const newSubtask = { id: generateId(), text: '', state: 0, subtasks: [], selectedSubtaskId: null }
715 + currentTask.subtasks.splice(topIndex, 0, newSubtask)
716 + updateTaskAndAncestors(currentTask)
717 + currentTask.selectedSubtaskId = multiSelectAnchorId
718 + renderCurrentView()
719 + applyMultiSelectHighlights()
720 + scheduleSave()
721 + lastSubtaskShiftDownReleased = false
722 + } else {
723 + handleKeyDown(e, task)
724 + }
725 + } else if (isLastSubtask(task) && lastSubtaskShiftDownReleased && task !== currentTask && !e.repeat) {
726 e.preventDefault();
727 keyHandler.shiftArrowDown.blocked = true;
728 // Insert a new empty task above the current task, keeping current task selected
757 keyHandler.arrowDown.canAdd = true;
758 keyHandler.arrowDown.blocked = false;
759 keyHandler.shiftArrowDown.blocked = false;
- if (isLastSubtask(task)) {
760 + // During multi-select, check if bottommost selected task is last subtask
761 + const isAtBottom = multiSelectedIds.length > 1
762 + ? isLastSubtask(getMultiSelectedTasks().slice(-1)[0])
763 + : isLastSubtask(task)
764 + if (isAtBottom) {
765 lastSubtaskDownArrowReleased = true;
766 lastSubtaskShiftDownReleased = true;
767 } else {
778 taskInput.addEventListener('keydown', keydownHandler);
779 taskInput.addEventListener('keyup', keyupHandler);
780 taskInput.addEventListener('keydown', handleCopyAndCut);
- taskInput.addEventListener('input', () => {
- task.text = taskInput.value;
781 + taskInput.addEventListener('input', (e) => {
782 + const oldText = task.text
783 + task.text = taskInput.value
784 + // Propagate edits to all other multi-selected tasks (always at end of text)
785 + if (multiSelectedIds.length > 1 && multiSelectedIds.includes(task.id)) {
786 + const otherSelected = getMultiSelectedTasks().filter(t => t.id !== task.id)
787 +
788 + if (e.inputType === 'insertText' && e.data) {
789 + // Append typed character(s) to end of each task
790 + for (const t of otherSelected) {
791 + t.text = t.text + e.data
792 + const otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`)
793 + if (otherInput) otherInput.value = t.text
794 + }
795 + } else if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteContentForward') {
796 + // Remove last character from each task
797 + for (const t of otherSelected) {
798 + if (t.text.length > 0) {
799 + t.text = t.text.slice(0, -1)
800 + }
801 + const otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`)
802 + if (otherInput) otherInput.value = t.text
803 + }
804 + } else if (e.inputType === 'insertFromPaste' || e.inputType === 'insertFromDrop') {
805 + // Compute what was inserted by comparing old and new text length
806 + const addedLen = taskInput.value.length - oldText.length
807 + if (addedLen > 0) {
808 + const pastedText = taskInput.value.slice(taskInput.selectionStart - addedLen, taskInput.selectionStart)
809 + for (const t of otherSelected) {
810 + t.text = t.text + pastedText
811 + const otherInput = document.querySelector(`.task-container[data-id="${t.id}"] input[type="text"]`)
812 + if (otherInput) otherInput.value = t.text
813 + }
814 + }
815 + }
816 + }
817 if (task === currentTask) {
818 updatePageTitle(task);
819 }
820 scheduleSave();
821 });
822
- taskInput.addEventListener('focus', () => setActiveTask(taskInput, task));
823 + taskInput.addEventListener('focus', () => {
824 + // If clicking a task outside the multi-selection, clear it
825 + if (multiSelectedIds.length > 1 && !multiSelectedIds.includes(task.id)) {
826 + clearMultiSelect()
827 + }
828 + setActiveTask(taskInput, task)
829 + });
830
831 taskContainer.appendChild(checkbox);
832 taskContainer.appendChild(checkboxLabel);
914 const checkbox = document.querySelector(`.task-container[data-id="${taskId}"] .checkbox-label`);
915 if (checkbox) {
916 const className = direction === 'vertical' ? 'shake-vertical' : 'shake'
917 + if (checkbox.dataset.shaking) return
918 + checkbox.dataset.shaking = '1'
919 checkbox.classList.add(className);
- setTimeout(() => {
- checkbox.classList.remove(className);
- }, 250);
920 + checkbox.addEventListener('animationend', () => {
921 + checkbox.classList.remove(className)
922 + }, { once: true })
923 + const clearShaking = () => {
924 + delete checkbox.dataset.shaking
925 + document.removeEventListener('keyup', clearShaking)
926 + }
927 + document.addEventListener('keyup', clearShaking)
928 }
929 }
930
1064 return null;
1065 }
1066
1067 + // Multi-select: extend selection with Alt+Up/Down
1068 + function extendMultiSelect(task, direction) {
1069 + if (task === currentTask) return // can't multi-select the parent
1070 +
1071 + const tasks = currentTask.subtasks
1072 + if (tasks.length === 0) return
1073 +
1074 + // Initialize multi-select from current task
1075 + if (multiSelectAnchorId === null) {
1076 + multiSelectAnchorId = task.id
1077 + multiSelectedIds = [task.id]
1078 + }
1079 +
1080 + const anchorIndex = tasks.findIndex(t => t.id === multiSelectAnchorId)
1081 + const currentIndex = tasks.findIndex(t => t.id === task.id)
1082 +
1083 + if (direction === 'down') {
1084 + // Find the bottommost selected task
1085 + const bottomIndex = tasks.findIndex((t, i) => {
1086 + return multiSelectedIds.includes(t.id) &&
1087 + (i === tasks.length - 1 || !multiSelectedIds.includes(tasks[i + 1].id))
1088 + })
1089 + if (currentIndex <= anchorIndex && multiSelectedIds.length > 1) {
1090 + // Contracting: remove from top
1091 + multiSelectedIds = multiSelectedIds.filter(id => id !== task.id)
1092 + const nextTask = tasks[currentIndex + 1]
1093 + if (nextTask) selectAndFocusTask(nextTask)
1094 + } else if (bottomIndex < tasks.length - 1) {
1095 + // Extending downward
1096 + const nextTask = tasks[bottomIndex + 1]
1097 + multiSelectedIds.push(nextTask.id)
1098 + selectAndFocusTask(nextTask)
1099 + }
1100 + } else {
1101 + // Find the topmost selected task
1102 + const topIndex = tasks.findIndex(t => multiSelectedIds.includes(t.id))
1103 + if (currentIndex >= anchorIndex && multiSelectedIds.length > 1) {
1104 + // Contracting: remove from bottom
1105 + multiSelectedIds = multiSelectedIds.filter(id => id !== task.id)
1106 + const prevTask = tasks[currentIndex - 1]
1107 + if (prevTask) selectAndFocusTask(prevTask)
1108 + } else if (topIndex > 0) {
1109 + // Extending upward
1110 + const prevTask = tasks[topIndex - 1]
1111 + multiSelectedIds.unshift(prevTask.id)
1112 + selectAndFocusTask(prevTask)
1113 + }
1114 + }
1115 +
1116 + applyMultiSelectHighlights()
1117 + }
1118 +
1119 function handleKeyDown(e, task) {
1120 const cmd = e.metaKey || e.ctrlKey;
1121 + const hasMultiSelect = multiSelectedIds.length > 1
1122
1123 + // Alt+Up/Down: extend multi-selection
1124 + if (e.key === 'ArrowUp' && e.altKey && !cmd && !e.shiftKey) {
1125 + e.preventDefault();
1126 + extendMultiSelect(task, 'up')
1127 + return
1128 + } else if (e.key === 'ArrowDown' && e.altKey && !cmd && !e.shiftKey) {
1129 + e.preventDefault();
1130 + extendMultiSelect(task, 'down')
1131 + return
1132 + }
1133 +
1134 + // Shift+Enter: toggle task state (bulk if multi-selected)
1135 if (e.key === 'Enter' && e.shiftKey) {
1136 e.preventDefault();
1137 if (!keyHandler.shiftEnter.pressed) {
1138 keyHandler.shiftEnter.pressed = true;
- toggleTaskState(task);
1139 + if (hasMultiSelect) {
1140 + bulkToggleTaskState()
1141 + } else {
1142 + toggleTaskState(task);
1143 + }
1144 }
- } else if (e.key === 'ArrowUp' && cmd && !e.shiftKey) {
1145 + // Cmd+Up: push into task above (bulk if multi-selected)
1146 + } else if (e.key === 'ArrowUp' && cmd && !e.shiftKey && !e.altKey) {
1147 e.preventDefault();
- if (!e.repeat && !pushSubtaskIntoTarget(task, 'up')) {
- applyShakeAnimation(task.id, 'vertical');
1148 + if (!e.repeat) {
1149 + const success = hasMultiSelect ? pushMultiSelectedIntoTarget('up') : pushSubtaskIntoTarget(task, 'up')
1150 + if (!success) {
1151 + hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical')
1152 + }
1153 }
- } else if (e.key === 'ArrowDown' && cmd && !e.shiftKey) {
1154 + // Cmd+Down: push into task below (bulk if multi-selected)
1155 + } else if (e.key === 'ArrowDown' && cmd && !e.shiftKey && !e.altKey) {
1156 e.preventDefault();
- if (!e.repeat && !pushSubtaskIntoTarget(task, 'down')) {
- applyShakeAnimation(task.id, 'vertical');
1157 + if (!e.repeat) {
1158 + const success = hasMultiSelect ? pushMultiSelectedIntoTarget('down') : pushSubtaskIntoTarget(task, 'down')
1159 + if (!success) {
1160 + hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical')
1161 + }
1162 }
- } else if (e.key === 'ArrowRight' && cmd && !e.shiftKey) {
1163 + } else if (e.key === 'ArrowRight' && cmd && !e.shiftKey && !e.altKey) {
1164 e.preventDefault();
- applyShakeAnimation(task.id);
- } else if (e.key === 'ArrowLeft' && cmd && !e.shiftKey) {
1165 + hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id);
1166 + // Cmd+Left: pull out one level (bulk if multi-selected)
1167 + } else if (e.key === 'ArrowLeft' && cmd && !e.shiftKey && !e.altKey) {
1168 e.preventDefault();
- if (!e.repeat && !pullSubtaskOutLayer(task)) {
- applyShakeAnimation(task.id);
1169 + if (!e.repeat) {
1170 + const success = hasMultiSelect ? pullMultiSelectedOutLayer() : pullSubtaskOutLayer(task)
1171 + if (!success) {
1172 + hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id)
1173 + }
1174 }
- } else if (e.key === 'ArrowUp' && cmd && e.shiftKey) {
1175 + // Cmd+Shift+Up: push and navigate (bulk if multi-selected)
1176 + } else if (e.key === 'ArrowUp' && cmd && e.shiftKey && !e.altKey) {
1177 e.preventDefault();
- if (!e.repeat && !pushSubtaskIntoTarget(task, 'up', true)) {
- applyShakeAnimation(task.id, 'vertical');
1178 + if (!e.repeat) {
1179 + const success = hasMultiSelect ? pushMultiSelectedIntoTarget('up', true) : pushSubtaskIntoTarget(task, 'up', true)
1180 + if (!success) {
1181 + hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical')
1182 + }
1183 }
- } else if (e.key === 'ArrowDown' && cmd && e.shiftKey) {
1184 + // Cmd+Shift+Down: push and navigate (bulk if multi-selected)
1185 + } else if (e.key === 'ArrowDown' && cmd && e.shiftKey && !e.altKey) {
1186 e.preventDefault();
- if (!e.repeat && !pushSubtaskIntoTarget(task, 'down', true)) {
- applyShakeAnimation(task.id, 'vertical');
1187 + if (!e.repeat) {
1188 + const success = hasMultiSelect ? pushMultiSelectedIntoTarget('down', true) : pushSubtaskIntoTarget(task, 'down', true)
1189 + if (!success) {
1190 + hasMultiSelect ? shakeAllSelected('vertical') : applyShakeAnimation(task.id, 'vertical')
1191 + }
1192 }
- } else if (e.key === 'ArrowLeft' && cmd && e.shiftKey) {
1193 + // Cmd+Shift+Left: pull and navigate (bulk if multi-selected)
1194 + } else if (e.key === 'ArrowLeft' && cmd && e.shiftKey && !e.altKey) {
1195 e.preventDefault();
- if (!e.repeat && !pullSubtaskOutLayer(task, true)) {
- applyShakeAnimation(task.id);
1196 + if (!e.repeat) {
1197 + const success = hasMultiSelect ? pullMultiSelectedOutLayer(true) : pullSubtaskOutLayer(task, true)
1198 + if (!success) {
1199 + hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id)
1200 + }
1201 }
- } else if (e.key === 'ArrowRight' && cmd && e.shiftKey) {
1202 + } else if (e.key === 'ArrowRight' && cmd && e.shiftKey && !e.altKey) {
1203 e.preventDefault();
- applyShakeAnimation(task.id);
- } else if (e.key === 'ArrowUp' && !e.shiftKey) {
1204 + hasMultiSelect ? shakeAllSelected() : applyShakeAnimation(task.id);
1205 + // Plain Up/Down: navigate (clears multi-select)
1206 + } else if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) {
1207 e.preventDefault();
1208 + if (hasMultiSelect) clearMultiSelect()
1209 navigateTasks('up');
- } else if (e.key === 'ArrowDown' && !e.shiftKey) {
1210 + } else if (e.key === 'ArrowDown' && !e.shiftKey && !e.altKey) {
1211 e.preventDefault();
1212 + if (hasMultiSelect) clearMultiSelect()
1213 navigateTasks('down');
- } else if (e.key === 'ArrowUp' && e.shiftKey) {
1214 + // Shift+Up/Down: move task (bulk if multi-selected)
1215 + } else if (e.key === 'ArrowUp' && e.shiftKey && !e.altKey) {
1216 e.preventDefault();
- if (!moveSubtask(task, 'up')) {
- applyShakeAnimation(task.id, 'vertical');
1217 + if (hasMultiSelect) {
1218 + if (!moveMultiSelected('up')) shakeAllSelected('vertical')
1219 + } else {
1220 + if (!moveSubtask(task, 'up')) applyShakeAnimation(task.id, 'vertical')
1221 }
- } else if (e.key === 'ArrowDown' && e.shiftKey) {
1222 + } else if (e.key === 'ArrowDown' && e.shiftKey && !e.altKey) {
1223 e.preventDefault();
- if (!moveSubtask(task, 'down')) {
- applyShakeAnimation(task.id, 'vertical');
1224 + if (hasMultiSelect) {
1225 + if (!moveMultiSelected('down')) shakeAllSelected('vertical')
1226 + } else {
1227 + if (!moveSubtask(task, 'down')) applyShakeAnimation(task.id, 'vertical')
1228 }
- } else if (e.key === 'ArrowRight' && e.shiftKey) {
1229 + // Shift+Right: blocked during multi-select, shake all
1230 + } else if (e.key === 'ArrowRight' && e.shiftKey && !e.altKey) {
1231 e.preventDefault();
- if (!keyHandler.shiftRight.pressed) {
1232 + if (hasMultiSelect) {
1233 + if (!keyHandler.shiftRight.pressed) {
1234 + keyHandler.shiftRight.pressed = true
1235 + shakeAllSelected()
1236 + }
1237 + } else if (!keyHandler.shiftRight.pressed) {
1238 keyHandler.shiftRight.pressed = true;
1239 if (task !== currentTask) {
1240 navigateIntoSubtask(task);
1242 applyShakeAnimation(task.id);
1243 }
1244 }
- } else if (e.key === 'ArrowLeft' && e.shiftKey) {
1245 + // Shift+Left: during multi-select, shake at root or clear and navigate out
1246 + } else if (e.key === 'ArrowLeft' && e.shiftKey && !e.altKey) {
1247 e.preventDefault();
- if (!keyHandler.shiftLeft.pressed) {
1248 + if (hasMultiSelect && currentTask.id === 'root') {
1249 + if (!keyHandler.shiftLeft.pressed) {
1250 + keyHandler.shiftLeft.pressed = true
1251 + shakeAllSelected()
1252 + }
1253 + } else if (hasMultiSelect) {
1254 + clearMultiSelect()
1255 + if (!keyHandler.shiftLeft.pressed) {
1256 + keyHandler.shiftLeft.pressed = true
1257 + navigateToParentTask()
1258 + }
1259 + } else if (!keyHandler.shiftLeft.pressed) {
1260 keyHandler.shiftLeft.pressed = true;
1261 navigateToParentTask();
1262 }
1396 return true;
1397 }
1398
1399 + // Bulk move: shift entire multi-selected chunk up or down
1400 + function moveMultiSelected(direction) {
1401 + const selected = getMultiSelectedTasks()
1402 + if (selected.length === 0) return false
1403 +
1404 + const subtasks = currentTask.subtasks
1405 + const indices = selected.map(t => subtasks.findIndex(s => s.id === t.id)).sort((a, b) => a - b)
1406 + const topIndex = indices[0]
1407 + const bottomIndex = indices[indices.length - 1]
1408 +
1409 + if (direction === 'up' && topIndex <= 0) return false
1410 + if (direction === 'down' && bottomIndex >= subtasks.length - 1) return false
1411 +
1412 + // Remove the chunk
1413 + const chunk = subtasks.splice(topIndex, selected.length)
1414 + // Re-insert shifted by one
1415 + const insertAt = direction === 'up' ? topIndex - 1 : topIndex + 1
1416 + subtasks.splice(insertAt, 0, ...chunk)
1417 +
1418 + // Ensure render focuses a multi-selected task
1419 + currentTask.selectedSubtaskId = multiSelectAnchorId
1420 + renderCurrentView()
1421 + applyMultiSelectHighlights()
1422 + scheduleSave()
1423 + return true
1424 + }
1425 +
1426 + // Bulk push: push entire multi-selected chunk into adjacent task
1427 + function pushMultiSelectedIntoTarget(direction, navigate = false) {
1428 + const selected = getMultiSelectedTasks()
1429 + if (selected.length === 0) return false
1430 +
1431 + if (navigate) {
1432 + document.documentElement.style.scrollBehavior = 'auto'
1433 + }
1434 +
1435 + const subtasks = currentTask.subtasks
1436 + const indices = selected.map(t => subtasks.findIndex(s => s.id === t.id)).sort((a, b) => a - b)
1437 + const topIndex = indices[0]
1438 + const bottomIndex = indices[indices.length - 1]
1439 +
1440 + // Find target: the task adjacent to the chunk that is NOT in the selection
1441 + let targetTask = null
1442 + if (direction === 'up' && topIndex > 0) {
1443 + targetTask = subtasks[topIndex - 1]
1444 + } else if (direction === 'down' && bottomIndex < subtasks.length - 1) {
1445 + targetTask = subtasks[bottomIndex + 1]
1446 + }
1447 + if (!targetTask) return false
1448 +
1449 + // Remove selected tasks from current parent, keeping target
1450 + const chunk = selected.slice()
1451 + currentTask.subtasks = currentTask.subtasks.filter(t => !multiSelectedIds.includes(t.id))
1452 +
1453 + // Add chunk to target's subtasks (at the top, preserving order)
1454 + targetTask.subtasks.unshift(...chunk)
1455 +
1456 + // Adjust states for moved tasks
1457 + for (const t of chunk) {
1458 + adjustMovedTaskState(t, targetTask)
1459 + }
1460 + targetTask.selectedSubtaskId = multiSelectAnchorId
1461 + updateTaskAndAncestors(currentTask)
1462 + updateTaskAndAncestors(targetTask)
1463 +
1464 + if (navigate) {
1465 + // Navigate into target, keep multi-select
1466 + currentTask.selectedSubtaskId = targetTask.id
1467 + taskPath.push(targetTask)
1468 + currentTask = targetTask
1469 + updateBreadcrumbs(currentTask)
1470 + renderCurrentView()
1471 + applyMultiSelectHighlights()
1472 + } else {
1473 + // Stay at current level, chunk is no longer visible
1474 + clearMultiSelect()
1475 + renderCurrentView()
1476 + selectAndFocusTask(targetTask)
1477 + }
1478 +
1479 + scheduleSave()
1480 + return true
1481 + }
1482 +
1483 + // Bulk pull: pull entire multi-selected chunk out one level
1484 + function pullMultiSelectedOutLayer(navigate = false) {
1485 + if (taskPath.length <= 1) return false
1486 +
1487 + const selected = getMultiSelectedTasks()
1488 + if (selected.length === 0) return false
1489 +
1490 + if (navigate) {
1491 + document.documentElement.style.scrollBehavior = 'auto'
1492 + }
1493 +
1494 + const currentParent = currentTask
1495 + const grandParent = taskPath[taskPath.length - 2]
1496 + if (!grandParent) return false
1497 +
1498 + // Remove chunk from current parent
1499 + currentParent.subtasks = currentParent.subtasks.filter(t => !multiSelectedIds.includes(t.id))
1500 +
1501 + // Insert after current parent in grandparent's subtasks, preserving order
1502 + const parentIndex = grandParent.subtasks.findIndex(t => t.id === currentParent.id)
1503 + if (parentIndex === -1) {
1504 + grandParent.subtasks.push(...selected)
1505 + } else {
1506 + grandParent.subtasks.splice(parentIndex + 1, 0, ...selected)
1507 + }
1508 +
1509 + // Adjust states for moved tasks
1510 + for (const t of selected) {
1511 + adjustMovedTaskState(t, grandParent)
1512 + }
1513 +
1514 + updateTaskAndAncestors(currentParent)
1515 + updateTaskAndAncestors(grandParent)
1516 +
1517 + if (navigate || currentParent.subtasks.length === 0) {
1518 + // Navigate out to parent level, keep multi-select
1519 + taskPath.pop()
1520 + currentTask = taskPath[taskPath.length - 1]
1521 + // Set selectedSubtaskId to anchor so renderCurrentView focuses a multi-selected task
1522 + currentTask.selectedSubtaskId = multiSelectAnchorId
1523 + updateBreadcrumbs(currentTask)
1524 + renderCurrentView()
1525 + applyMultiSelectHighlights()
1526 + } else {
1527 + // Stay at current level, chunk is gone
1528 + clearMultiSelect()
1529 + renderCurrentView()
1530 + if (currentParent.subtasks.length > 0) {
1531 + selectAndFocusTask(currentParent.subtasks[0])
1532 + } else {
1533 + selectAndFocusTask(currentParent)
1534 + }
1535 + }
1536 +
1537 + scheduleSave()
1538 + return true
1539 + }
1540 +
1541 + // Bulk toggle: uniform toggle for multi-selected tasks
1542 + function bulkToggleTaskState() {
1543 + const selected = getMultiSelectedTasks()
1544 + if (selected.length === 0) return
1545 +
1546 + // If any are unchecked (state !== 1), check all. Otherwise uncheck all.
1547 + const anyUnchecked = selected.some(t => t.state !== 1)
1548 + const newState = anyUnchecked ? 1 : 0
1549 +
1550 + for (const t of selected) {
1551 + t.state = newState
1552 + // Update subtask states recursively
1553 + function setAllSubtasks(task, state) {
1554 + task.state = state
1555 + for (const sub of task.subtasks) {
1556 + setAllSubtasks(sub, state)
1557 + }
1558 + }
1559 + setAllSubtasks(t, newState)
1560 + }
1561 +
1562 + updateTaskAndAncestors(currentTask)
1563 + currentTask.selectedSubtaskId = multiSelectAnchorId
1564 + renderCurrentView()
1565 + applyMultiSelectHighlights()
1566 + scheduleSave()
1567 + }
1568
1569 function addNewSubtask(parentTask, currentSubtask = null) {
1570 const newSubtask = { id: generateId(), text: '', state: 0, subtasks: [], selectedSubtaskId: null };
1728 if (task !== currentTask) {
1729 currentTask.selectedSubtaskId = task.id;
1730 }
1731 + // Re-apply multi-select highlights after clearing
1732 + if (multiSelectedIds.length > 1) {
1733 + applyMultiSelectHighlights()
1734 + }
1735 updateBreadcrumbs(task);
- lastSubtaskDownArrowReleased = isLastSubtask(task);
- lastSubtaskShiftDownReleased = isLastSubtask(task);
1736 + // During multi-select, check bottommost selected task for "last subtask" status
1737 + const bottomTask = multiSelectedIds.length > 1
1738 + ? getMultiSelectedTasks().slice(-1)[0]
1739 + : task
1740 + lastSubtaskDownArrowReleased = isLastSubtask(bottomTask);
1741 + lastSubtaskShiftDownReleased = isLastSubtask(bottomTask);
1742 input.focus();
-
1743 +
1744 // Center the active task in the viewport
- const activeTaskElement = document.querySelector('.task-container.active');
1745 + const activeTaskElement = input.closest('.task-container');
1746 if (activeTaskElement && activeTaskElement.parentElement && activeTaskElement.parentElement.tagName === 'LI') {
1747 // Use scrollIntoView with block: "center" to center the element vertically
1748 activeTaskElement.scrollIntoView({
1750 block: 'center', // Center vertically
1751 inline: 'nearest' // Don't change horizontal scroll
1752 });
-
1753 +
1754 // Re-enable smooth scrolling after all DOM updates and scrolling are complete
1755 setTimeout(() => {
1756 document.documentElement.style.scrollBehavior = 'smooth';
1760
1761 function updateBreadcrumbs(selectedTask) {
1762 const breadcrumbsContainer = document.getElementById('breadcrumbs');
- const trail = generateBreadcrumbs(taskPath[0], taskPath, selectedTask.id);
1763 + // During multi-select, show breadcrumbs as if parent is selected (no deeper traversal)
1764 + const effectiveId = multiSelectedIds.length > 1 ? currentTask.id : selectedTask.id;
1765 + const trail = generateBreadcrumbs(taskPath[0], taskPath, effectiveId);
1766 breadcrumbsContainer.textContent = trail;
1767 }
1768