Files
todo-tracker-obsidian/src/core/todo-transformer.ts
Brendan Chen a00b96231c Add focus activation, 3-zone drag nesting, and click-to-navigate
Implement remaining Round 3 enhancements:
- ArrowDown when panel unfocused activates it at first item (like Outline view)
- 3-zone drag-drop: top/bottom thirds insert above/below, middle third nests as child
- Click on todo text to focus it in editor (onClick callback)
- Dragging parent automatically moves nested children (stopPropagation fix)
- Cross-file move inserts todo below heading with blank line (addBlankLine param)
- Updated CLAUDE.md with sidebar view architecture documentation

Build: 85 tests pass, production build succeeds.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-20 10:17:27 -08:00

211 lines
5.9 KiB
TypeScript

/**
* Regex pattern for markdown todo checkboxes (for toggling).
*/
const TODO_PATTERN = /^(\s*[-*+]\s+\[)([xX ]?)(\].*)$/;
/**
* Toggle the checkbox state at the specified line number.
* Returns the modified content.
*/
export function toggleTodo(content: string, lineNumber: number): string {
const lines = content.split('\n');
if (lineNumber < 0 || lineNumber >= lines.length) {
return content;
}
const line = lines[lineNumber];
if (line === undefined) {
return content;
}
const match = line.match(TODO_PATTERN);
if (!match) {
return content;
}
const checkboxContent = match[2];
const isChecked = checkboxContent === 'x' || checkboxContent === 'X';
const newCheckbox = isChecked ? ' ' : 'x';
lines[lineNumber] = `${match[1]}${newCheckbox}${match[3]}`;
return lines.join('\n');
}
/**
* Remove the line at the specified line number.
* Returns the modified content.
*/
export function removeTodoLine(content: string, lineNumber: number): string {
const lines = content.split('\n');
if (lineNumber < 0 || lineNumber >= lines.length) {
return content;
}
lines.splice(lineNumber, 1);
return lines.join('\n');
}
/**
* Insert a todo line at the end of the content.
* Handles files with/without trailing newlines.
*/
export function insertTodoAtEnd(content: string, todoLine: string): string {
if (!content) {
return todoLine;
}
// If content ends with newline(s), append directly
if (content.endsWith('\n')) {
return content + todoLine;
}
// Otherwise add a newline before the todo
return content + '\n' + todoLine;
}
/**
* Move a todo (and its children) from one position to another within the same content.
* @param content - The file content
* @param todoLineNumber - Line number of the todo to move
* @param childLineNumbers - Line numbers of child todos (must be consecutive after todoLineNumber)
* @param targetLineNumber - Line number to insert before (0-based). Lines are inserted at this position.
*/
export function moveTodoWithChildren(
content: string,
todoLineNumber: number,
childLineNumbers: number[],
targetLineNumber: number
): string {
const lines = content.split('\n');
const allLineNumbers = [todoLineNumber, ...childLineNumbers].sort((a, b) => a - b);
// Extract the lines to move
const movedLines = allLineNumbers.map((ln) => lines[ln]!);
// If moving to same position, no-op
if (targetLineNumber === todoLineNumber) {
return content;
}
// Remove lines from original positions (in reverse to preserve indices)
for (let i = allLineNumbers.length - 1; i >= 0; i--) {
lines.splice(allLineNumbers[i]!, 1);
}
// Adjust target position for removed lines
let adjustedTarget = targetLineNumber;
for (const ln of allLineNumbers) {
if (ln < targetLineNumber) {
adjustedTarget--;
}
}
// Insert at adjusted target
lines.splice(adjustedTarget, 0, ...movedLines);
return lines.join('\n');
}
/**
* Move a todo (and its children) under a heading within the same file.
* Inserts at the end of the heading's section.
* @param content - The file content
* @param todoLineNumber - Line number of the todo to move
* @param childLineNumbers - Line numbers of child todos
* @param headingLineNumber - Line number of the target heading
* @param nextHeadingLineNumber - Optional line number of the next heading (section boundary)
*/
export function moveTodoUnderHeadingInFile(
content: string,
todoLineNumber: number,
childLineNumbers: number[],
headingLineNumber: number,
nextHeadingLineNumber?: number
): string {
// Determine insert position: end of section (before next heading) or after heading
let targetLineNumber: number;
if (nextHeadingLineNumber !== undefined) {
targetLineNumber = nextHeadingLineNumber;
} else {
// No next heading — insert at end of file
const lines = content.split('\n');
targetLineNumber = lines.length;
}
return moveTodoWithChildren(content, todoLineNumber, childLineNumbers, targetLineNumber);
}
/**
* Adjust indentation of todo lines by a delta (positive = indent, negative = outdent).
* Each level is 2 spaces. Will not outdent past zero.
*/
export function indentTodoLines(todoLines: string[], indentDelta: number): string[] {
if (indentDelta === 0) return [...todoLines];
const indent = ' '; // 2 spaces per level
return todoLines.map((line) => {
if (indentDelta > 0) {
return indent.repeat(indentDelta) + line;
} else {
// Outdent: remove leading spaces
let result = line;
for (let i = 0; i < Math.abs(indentDelta); i++) {
if (result.startsWith(indent)) {
result = result.slice(indent.length);
} else if (result.startsWith('\t')) {
result = result.slice(1);
}
}
return result;
}
});
}
/**
* Insert a todo line under a specific heading.
* @param content - The file content
* @param headingLineNumber - The line number of the heading (0-based)
* @param todoLine - The todo line to insert
* @param nextHeadingLineNumber - Optional line number of the next heading (for section boundary)
* @param addBlankLine - Whether to add a blank line between the heading and the todo
*/
export function insertTodoUnderHeading(
content: string,
headingLineNumber: number,
todoLine: string,
nextHeadingLineNumber?: number,
addBlankLine = false
): string {
const lines = content.split('\n');
// Determine where to insert
let insertPosition: number;
if (nextHeadingLineNumber !== undefined) {
insertPosition = nextHeadingLineNumber;
} else {
insertPosition = headingLineNumber + 1;
}
if (addBlankLine) {
// Check if there's already a blank line at the insert position
const lineAfterHeading = lines[headingLineNumber + 1];
const alreadyHasBlank = lineAfterHeading !== undefined && lineAfterHeading.trim() === '';
if (alreadyHasBlank) {
// Insert after the existing blank line
lines.splice(headingLineNumber + 2, 0, todoLine);
} else {
// Insert blank line + todo
lines.splice(insertPosition, 0, '', todoLine);
}
} else {
lines.splice(insertPosition, 0, todoLine);
}
return lines.join('\n');
}