Compare commits
3 Commits
8785f97120
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fe38af7b3 | |||
| 2fd5abf40d | |||
| 1cbc127769 |
@@ -7,6 +7,10 @@ import {
|
|||||||
moveTodoWithChildren,
|
moveTodoWithChildren,
|
||||||
moveTodoUnderHeadingInFile,
|
moveTodoUnderHeadingInFile,
|
||||||
indentTodoLines,
|
indentTodoLines,
|
||||||
|
collectTodoBlockLines,
|
||||||
|
removeTodoBlock,
|
||||||
|
moveTodoBlock,
|
||||||
|
insertTodoAtBeginning,
|
||||||
} from './todo-transformer';
|
} from './todo-transformer';
|
||||||
|
|
||||||
describe('toggleTodo', () => {
|
describe('toggleTodo', () => {
|
||||||
@@ -315,3 +319,117 @@ describe('insertTodoUnderHeading with blank line', () => {
|
|||||||
expect(result).toBe('# Heading\n\n- [ ] New todo\nExisting content');
|
expect(result).toBe('# Heading\n\n- [ ] New todo\nExisting content');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('collectTodoBlockLines', () => {
|
||||||
|
it('should collect checkbox children only', () => {
|
||||||
|
const content = '- [ ] Parent\n - [ ] Child\n - [ ] Child 2';
|
||||||
|
const result = collectTodoBlockLines(content, 0);
|
||||||
|
expect(result).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should collect non-checkbox lines with deeper indentation', () => {
|
||||||
|
const content = '- [ ] Parent\n - Non-checkbox bullet\n - [ ] Child';
|
||||||
|
const result = collectTodoBlockLines(content, 0);
|
||||||
|
expect(result).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop at same-level indent', () => {
|
||||||
|
const content = '- [ ] First\n - [ ] Child\n- [ ] Second';
|
||||||
|
const result = collectTodoBlockLines(content, 0);
|
||||||
|
expect(result).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop at blank line', () => {
|
||||||
|
const content = '- [ ] First\n - [ ] Child\n\n- [ ] Next';
|
||||||
|
const result = collectTodoBlockLines(content, 0);
|
||||||
|
expect(result).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for todo with no children', () => {
|
||||||
|
const content = '- [ ] Solo\n- [ ] Next';
|
||||||
|
const result = collectTodoBlockLines(content, 0);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested indentation levels', () => {
|
||||||
|
const content = '- [ ] A\n - B\n - C\n - D\n - E';
|
||||||
|
const result = collectTodoBlockLines(content, 0);
|
||||||
|
expect(result).toEqual([1, 2, 3, 4]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeTodoBlock', () => {
|
||||||
|
it('should remove todo with no children', () => {
|
||||||
|
const content = '- [ ] Remove\n- [ ] Keep';
|
||||||
|
const result = removeTodoBlock(content, 0);
|
||||||
|
expect(result).toBe('- [ ] Keep');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove todo and all its children', () => {
|
||||||
|
const content = '- [ ] Remove\n - Child 1\n - Child 2\n- [ ] Keep';
|
||||||
|
const result = removeTodoBlock(content, 0);
|
||||||
|
expect(result).toBe('- [ ] Keep');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove todo from middle of content', () => {
|
||||||
|
const content = '- [ ] First\n- [ ] Remove\n - Child\n- [ ] Last';
|
||||||
|
const result = removeTodoBlock(content, 1);
|
||||||
|
expect(result).toBe('- [ ] First\n- [ ] Last');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested mixed content', () => {
|
||||||
|
const content = '- [ ] Parent\n - Bullet\n - [ ] Checkbox child\n- [ ] Next';
|
||||||
|
const result = removeTodoBlock(content, 0);
|
||||||
|
expect(result).toBe('- [ ] Next');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('moveTodoBlock', () => {
|
||||||
|
it('should move todo without children to end', () => {
|
||||||
|
const content = '- [ ] Move\n- [ ] A\n- [ ] B';
|
||||||
|
const result = moveTodoBlock(content, 0, 3);
|
||||||
|
expect(result).toBe('- [ ] A\n- [ ] B\n- [ ] Move');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should move todo with children as a block', () => {
|
||||||
|
const content = '- [ ] Parent\n - Child\n - [ ] Nested\n- [ ] Other';
|
||||||
|
const result = moveTodoBlock(content, 0, 4);
|
||||||
|
expect(result).toBe('- [ ] Other\n- [ ] Parent\n - Child\n - [ ] Nested');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should adjust indentation with positive delta', () => {
|
||||||
|
const content = '- [ ] Todo\n - Child\n- [ ] Target';
|
||||||
|
const result = moveTodoBlock(content, 0, 3, 1);
|
||||||
|
expect(result).toBe('- [ ] Target\n - [ ] Todo\n - Child');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should adjust indentation with negative delta', () => {
|
||||||
|
const content = ' - [ ] Todo\n - Child\n- [ ] Target';
|
||||||
|
const result = moveTodoBlock(content, 0, 3, -1);
|
||||||
|
expect(result).toBe('- [ ] Target\n- [ ] Todo\n - Child');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should move within same document without losing children', () => {
|
||||||
|
const content = '- [ ] A\n- [ ] B\n - B1\n - B2\n- [ ] C';
|
||||||
|
const result = moveTodoBlock(content, 1, 0);
|
||||||
|
expect(result).toBe('- [ ] B\n - B1\n - B2\n- [ ] A\n- [ ] C');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('insertTodoAtBeginning', () => {
|
||||||
|
it('should insert at the beginning of file content', () => {
|
||||||
|
const content = 'Line 1\nLine 2';
|
||||||
|
const result = insertTodoAtBeginning(content, '- [ ] New todo');
|
||||||
|
expect(result).toBe('- [ ] New todo\nLine 1\nLine 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty file', () => {
|
||||||
|
const result = insertTodoAtBeginning('', '- [ ] New todo');
|
||||||
|
expect(result).toBe('- [ ] New todo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle file with only whitespace', () => {
|
||||||
|
const result = insertTodoAtBeginning('\n\n', '- [ ] New todo');
|
||||||
|
expect(result).toBe('- [ ] New todo\n\n\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { parseIndentLevel } from './todo-parser';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regex pattern for markdown todo checkboxes (for toggling).
|
* Regex pattern for markdown todo checkboxes (for toggling).
|
||||||
*/
|
*/
|
||||||
@@ -47,6 +49,16 @@ export function removeTodoLine(content: string, lineNumber: number): string {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a todo line at the beginning of the content.
|
||||||
|
*/
|
||||||
|
export function insertTodoAtBeginning(content: string, todoLine: string): string {
|
||||||
|
if (!content) {
|
||||||
|
return todoLine;
|
||||||
|
}
|
||||||
|
return todoLine + '\n' + content;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert a todo line at the end of the content.
|
* Insert a todo line at the end of the content.
|
||||||
* Handles files with/without trailing newlines.
|
* Handles files with/without trailing newlines.
|
||||||
@@ -165,21 +177,22 @@ export function indentTodoLines(todoLines: string[], indentDelta: number): strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert a todo line under a specific heading.
|
* Insert a todo line (or block of lines) under a specific heading.
|
||||||
* @param content - The file content
|
* @param content - The file content
|
||||||
* @param headingLineNumber - The line number of the heading (0-based)
|
* @param headingLineNumber - The line number of the heading (0-based)
|
||||||
* @param todoLine - The todo line to insert
|
* @param todoLine - The todo line(s) to insert (single string or array of strings)
|
||||||
* @param nextHeadingLineNumber - Optional line number of the next heading (for section boundary)
|
* @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
|
* @param addBlankLine - Whether to add a blank line between the heading and the todo
|
||||||
*/
|
*/
|
||||||
export function insertTodoUnderHeading(
|
export function insertTodoUnderHeading(
|
||||||
content: string,
|
content: string,
|
||||||
headingLineNumber: number,
|
headingLineNumber: number,
|
||||||
todoLine: string,
|
todoLine: string | string[],
|
||||||
nextHeadingLineNumber?: number,
|
nextHeadingLineNumber?: number,
|
||||||
addBlankLine = false
|
addBlankLine = false
|
||||||
): string {
|
): string {
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
|
const todoLines = Array.isArray(todoLine) ? todoLine : [todoLine];
|
||||||
|
|
||||||
// Determine where to insert
|
// Determine where to insert
|
||||||
let insertPosition: number;
|
let insertPosition: number;
|
||||||
@@ -197,14 +210,104 @@ export function insertTodoUnderHeading(
|
|||||||
|
|
||||||
if (alreadyHasBlank) {
|
if (alreadyHasBlank) {
|
||||||
// Insert after the existing blank line
|
// Insert after the existing blank line
|
||||||
lines.splice(headingLineNumber + 2, 0, todoLine);
|
lines.splice(headingLineNumber + 2, 0, ...todoLines);
|
||||||
} else {
|
} else {
|
||||||
// Insert blank line + todo
|
// Insert blank line + todos
|
||||||
lines.splice(insertPosition, 0, '', todoLine);
|
lines.splice(insertPosition, 0, '', ...todoLines);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lines.splice(insertPosition, 0, todoLine);
|
lines.splice(insertPosition, 0, ...todoLines);
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all line numbers that belong to a todo's block (by indentation).
|
||||||
|
* A line is part of the block if its indentation is greater than the todo's.
|
||||||
|
* Stops at blank line or line with same/lesser indentation.
|
||||||
|
*/
|
||||||
|
export function collectTodoBlockLines(content: string, todoLineNumber: number): number[] {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const todoLine = lines[todoLineNumber];
|
||||||
|
if (!todoLine) return [];
|
||||||
|
|
||||||
|
const todoIndent = parseIndentLevel(todoLine);
|
||||||
|
const blockLines: number[] = [];
|
||||||
|
|
||||||
|
for (let i = todoLineNumber + 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i]!;
|
||||||
|
|
||||||
|
// Stop at blank line
|
||||||
|
if (line.trim() === '') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop at same or lesser indentation
|
||||||
|
if (parseIndentLevel(line) <= todoIndent) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockLines.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return blockLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a todo and all its block lines (children, nested content, etc.).
|
||||||
|
*/
|
||||||
|
export function removeTodoBlock(content: string, todoLineNumber: number): string {
|
||||||
|
const blockLines = collectTodoBlockLines(content, todoLineNumber);
|
||||||
|
const allLineNumbers = [todoLineNumber, ...blockLines].sort((a, b) => a - b);
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
// Remove in reverse to preserve indices
|
||||||
|
for (let i = allLineNumbers.length - 1; i >= 0; i--) {
|
||||||
|
lines.splice(allLineNumbers[i]!, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a todo block to a new position with optional indentation adjustment.
|
||||||
|
* Auto-detects all lines in the block by indentation.
|
||||||
|
* @param indentDelta - Positive = indent, negative = outdent
|
||||||
|
*/
|
||||||
|
export function moveTodoBlock(
|
||||||
|
content: string,
|
||||||
|
todoLineNumber: number,
|
||||||
|
targetLineNumber: number,
|
||||||
|
indentDelta = 0
|
||||||
|
): string {
|
||||||
|
const blockLines = collectTodoBlockLines(content, todoLineNumber);
|
||||||
|
const allLineNumbers = [todoLineNumber, ...blockLines].sort((a, b) => a - b);
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
// Extract lines to move
|
||||||
|
let movedLines = allLineNumbers.map((ln) => lines[ln]!);
|
||||||
|
|
||||||
|
// Apply indentation if needed
|
||||||
|
if (indentDelta !== 0) {
|
||||||
|
movedLines = indentTodoLines(movedLines, indentDelta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove original lines (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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { App, FuzzySuggestModal, TFile, HeadingCache } from 'obsidian';
|
import { App, FuzzySuggestModal, TFile, HeadingCache } from 'obsidian';
|
||||||
import type { TodoItem } from '../core/types';
|
import type { TodoItem } from '../core/types';
|
||||||
import { removeTodoLine, insertTodoAtEnd, insertTodoUnderHeading } from '../core/todo-transformer';
|
import {
|
||||||
|
removeTodoBlock,
|
||||||
|
insertTodoAtBeginning,
|
||||||
|
insertTodoUnderHeading,
|
||||||
|
collectTodoBlockLines,
|
||||||
|
} from '../core/todo-transformer';
|
||||||
|
|
||||||
interface HeadingOption {
|
interface HeadingOption {
|
||||||
heading: HeadingCache | null;
|
heading: HeadingCache | null;
|
||||||
@@ -25,24 +30,21 @@ export class HeadingSelectModal extends FuzzySuggestModal<HeadingOption> {
|
|||||||
this.sourceFile = sourceFile;
|
this.sourceFile = sourceFile;
|
||||||
this.targetFile = targetFile;
|
this.targetFile = targetFile;
|
||||||
this.headings = headings;
|
this.headings = headings;
|
||||||
this.setPlaceholder('Select a heading (or end of file)...');
|
this.setPlaceholder('Select a heading...');
|
||||||
}
|
}
|
||||||
|
|
||||||
getItems(): HeadingOption[] {
|
getItems(): HeadingOption[] {
|
||||||
const items: HeadingOption[] = [];
|
const items: HeadingOption[] = [];
|
||||||
|
|
||||||
// Add "End of file" option first
|
|
||||||
items.push({
|
items.push({
|
||||||
heading: null,
|
heading: null,
|
||||||
displayText: 'End of file',
|
displayText: 'Beginning of file',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add all headings
|
|
||||||
for (const heading of this.headings) {
|
for (const heading of this.headings) {
|
||||||
const indent = ' '.repeat(heading.level - 1);
|
|
||||||
items.push({
|
items.push({
|
||||||
heading,
|
heading,
|
||||||
displayText: `${indent}${'#'.repeat(heading.level)} ${heading.heading}`,
|
displayText: heading.heading,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,11 +60,14 @@ export class HeadingSelectModal extends FuzzySuggestModal<HeadingOption> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async moveTodo(selectedHeading: HeadingCache | null): Promise<void> {
|
private async moveTodo(selectedHeading: HeadingCache | null): Promise<void> {
|
||||||
const todoLine = this.todo.rawLine;
|
// Capture block lines (todo + all its nested content) before removing
|
||||||
|
let blockLines: string[] = [];
|
||||||
|
|
||||||
// Remove from source file
|
|
||||||
await this.app.vault.process(this.sourceFile, (content) => {
|
await this.app.vault.process(this.sourceFile, (content) => {
|
||||||
return removeTodoLine(content, this.todo.lineNumber);
|
const lines = content.split('\n');
|
||||||
|
const blockLineNumbers = [this.todo.lineNumber, ...collectTodoBlockLines(content, this.todo.lineNumber)];
|
||||||
|
blockLines = blockLineNumbers.map((ln) => lines[ln]!);
|
||||||
|
return removeTodoBlock(content, this.todo.lineNumber);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add to target file
|
// Add to target file
|
||||||
@@ -78,15 +83,16 @@ export class HeadingSelectModal extends FuzzySuggestModal<HeadingOption> {
|
|||||||
return insertTodoUnderHeading(
|
return insertTodoUnderHeading(
|
||||||
content,
|
content,
|
||||||
selectedHeading.position.start.line,
|
selectedHeading.position.start.line,
|
||||||
todoLine,
|
blockLines,
|
||||||
nextHeadingLine,
|
nextHeadingLine,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// End of file
|
// Beginning of file
|
||||||
await this.app.vault.process(this.targetFile, (content) => {
|
await this.app.vault.process(this.targetFile, (content) => {
|
||||||
return insertTodoAtEnd(content, todoLine);
|
const blockContent = blockLines.join('\n');
|
||||||
|
return insertTodoAtBeginning(content, blockContent);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { App, FuzzySuggestModal, TFile } from 'obsidian';
|
import { App, FuzzySuggestModal, TFile } from 'obsidian';
|
||||||
import type { TodoItem } from '../core/types';
|
import type { TodoItem } from '../core/types';
|
||||||
import { HeadingSelectModal } from './heading-select-modal';
|
import { HeadingSelectModal } from './heading-select-modal';
|
||||||
import { removeTodoLine, insertTodoAtEnd } from '../core/todo-transformer';
|
import { removeTodoBlock, insertTodoAtBeginning, collectTodoBlockLines } from '../core/todo-transformer';
|
||||||
|
|
||||||
export class NoteSelectModal extends FuzzySuggestModal<TFile> {
|
export class NoteSelectModal extends FuzzySuggestModal<TFile> {
|
||||||
private todo: TodoItem;
|
private todo: TodoItem;
|
||||||
@@ -37,28 +37,32 @@ export class NoteSelectModal extends FuzzySuggestModal<TFile> {
|
|||||||
headings
|
headings
|
||||||
).open();
|
).open();
|
||||||
} else {
|
} else {
|
||||||
// No headings - move directly to end of file
|
// No headings - move directly to beginning of file
|
||||||
await this.moveTodo(file);
|
await this.moveTodo(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async moveTodo(targetFile: TFile, headingLineNumber?: number, nextHeadingLineNumber?: number): Promise<void> {
|
private async moveTodo(targetFile: TFile, headingLineNumber?: number, nextHeadingLineNumber?: number): Promise<void> {
|
||||||
const todoLine = this.todo.rawLine;
|
// Capture block lines (todo + all its nested content) before removing
|
||||||
|
let blockLines: string[] = [];
|
||||||
|
|
||||||
// Remove from source file
|
|
||||||
await this.app.vault.process(this.sourceFile, (content) => {
|
await this.app.vault.process(this.sourceFile, (content) => {
|
||||||
return removeTodoLine(content, this.todo.lineNumber);
|
const lines = content.split('\n');
|
||||||
|
const blockLineNumbers = [this.todo.lineNumber, ...collectTodoBlockLines(content, this.todo.lineNumber)];
|
||||||
|
blockLines = blockLineNumbers.map((ln) => lines[ln]!);
|
||||||
|
return removeTodoBlock(content, this.todo.lineNumber);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add to target file
|
// Add to target file
|
||||||
if (headingLineNumber !== undefined) {
|
if (headingLineNumber !== undefined) {
|
||||||
const { insertTodoUnderHeading } = await import('../core/todo-transformer');
|
const { insertTodoUnderHeading } = await import('../core/todo-transformer');
|
||||||
await this.app.vault.process(targetFile, (content) => {
|
await this.app.vault.process(targetFile, (content) => {
|
||||||
return insertTodoUnderHeading(content, headingLineNumber, todoLine, nextHeadingLineNumber, true);
|
return insertTodoUnderHeading(content, headingLineNumber, blockLines, nextHeadingLineNumber, true);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await this.app.vault.process(targetFile, (content) => {
|
await this.app.vault.process(targetFile, (content) => {
|
||||||
return insertTodoAtEnd(content, todoLine);
|
const blockContent = blockLines.join('\n');
|
||||||
|
return insertTodoAtBeginning(content, blockContent);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ export interface TodoItemCallbacks {
|
|||||||
onToggle: (todo: TodoItem) => void;
|
onToggle: (todo: TodoItem) => void;
|
||||||
onMoveClick: (todo: TodoItem) => void;
|
onMoveClick: (todo: TodoItem) => void;
|
||||||
onClick: (todo: TodoItem) => void;
|
onClick: (todo: TodoItem) => void;
|
||||||
onDragStart: (evt: DragEvent, todo: TodoItem) => void;
|
|
||||||
onDragEnd: (evt: DragEvent) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,7 +29,6 @@ export function createTodoItemEl(
|
|||||||
callbacks: TodoItemCallbacks
|
callbacks: TodoItemCallbacks
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
const itemEl = container.createEl('li', { cls: 'todo-tracker-item' });
|
const itemEl = container.createEl('li', { cls: 'todo-tracker-item' });
|
||||||
itemEl.setAttribute('draggable', 'true');
|
|
||||||
itemEl.dataset.lineNumber = String(todo.lineNumber);
|
itemEl.dataset.lineNumber = String(todo.lineNumber);
|
||||||
|
|
||||||
// Checkbox
|
// Checkbox
|
||||||
@@ -70,17 +67,6 @@ export function createTodoItemEl(
|
|||||||
menu.showAtMouseEvent(evt);
|
menu.showAtMouseEvent(evt);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drag events — stopPropagation so nested children don't bubble to parent
|
|
||||||
itemEl.addEventListener('dragstart', (evt) => {
|
|
||||||
evt.stopPropagation();
|
|
||||||
callbacks.onDragStart(evt, todo);
|
|
||||||
});
|
|
||||||
|
|
||||||
itemEl.addEventListener('dragend', (evt) => {
|
|
||||||
evt.stopPropagation();
|
|
||||||
callbacks.onDragEnd(evt);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Render children recursively
|
// Render children recursively
|
||||||
if (todo.children.length > 0) {
|
if (todo.children.length > 0) {
|
||||||
const nestedList = itemEl.createEl('ul', { cls: 'todo-tracker-nested-list' });
|
const nestedList = itemEl.createEl('ul', { cls: 'todo-tracker-nested-list' });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ItemView, Keymap, Platform, TFile, WorkspaceLeaf } from 'obsidian';
|
import { ItemView, Keymap, Platform, TFile, WorkspaceLeaf } from 'obsidian';
|
||||||
import type { TodoItem, TodoGroup } from '../core/types';
|
import type { TodoItem, TodoGroup } from '../core/types';
|
||||||
import { parseTodosGroupedByHeading } from '../core/todo-parser';
|
import { parseTodosGroupedByHeading } from '../core/todo-parser';
|
||||||
import { toggleTodo, moveTodoWithChildren, moveTodoUnderHeadingInFile, indentTodoLines } from '../core/todo-transformer';
|
import { toggleTodo, collectTodoBlockLines, removeTodoBlock } from '../core/todo-transformer';
|
||||||
import { createTodoItemEl, collectChildLineNumbers } from './todo-item-component';
|
import { createTodoItemEl, collectChildLineNumbers } from './todo-item-component';
|
||||||
|
|
||||||
export const TODO_VIEW_TYPE = 'todo-tracker-view';
|
export const TODO_VIEW_TYPE = 'todo-tracker-view';
|
||||||
@@ -17,8 +17,6 @@ export class TodoSidebarView extends ItemView {
|
|||||||
private flatTodoList: FlatTodoEntry[] = [];
|
private flatTodoList: FlatTodoEntry[] = [];
|
||||||
private focusedIndex = -1;
|
private focusedIndex = -1;
|
||||||
private groups: TodoGroup[] = [];
|
private groups: TodoGroup[] = [];
|
||||||
private draggedTodo: TodoItem | null = null;
|
|
||||||
private draggedChildLines: number[] = [];
|
|
||||||
|
|
||||||
constructor(leaf: WorkspaceLeaf) {
|
constructor(leaf: WorkspaceLeaf) {
|
||||||
super(leaf);
|
super(leaf);
|
||||||
@@ -127,9 +125,6 @@ export class TodoSidebarView extends ItemView {
|
|||||||
for (const todo of group.todos) {
|
for (const todo of group.todos) {
|
||||||
this.renderTodoItem(listEl, todo, file, group);
|
this.renderTodoItem(listEl, todo, file, group);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up drop zone on the group
|
|
||||||
this.setupGroupDropZone(groupEl, group, file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't auto-focus — user activates via ArrowDown (like Outline view)
|
// Don't auto-focus — user activates via ArrowDown (like Outline view)
|
||||||
@@ -150,16 +145,11 @@ export class TodoSidebarView extends ItemView {
|
|||||||
onToggle: (t) => this.handleToggle(file, t),
|
onToggle: (t) => this.handleToggle(file, t),
|
||||||
onMoveClick: (t) => this.handleMoveClick(t, file),
|
onMoveClick: (t) => this.handleMoveClick(t, file),
|
||||||
onClick: (t) => this.openTodoInEditor(file, t),
|
onClick: (t) => this.openTodoInEditor(file, t),
|
||||||
onDragStart: (evt, t) => this.handleDragStart(evt, t),
|
|
||||||
onDragEnd: (evt) => this.handleDragEnd(evt),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register in flat list for keyboard navigation
|
// Register in flat list for keyboard navigation
|
||||||
this.flatTodoList.push({ todo, element: itemEl, group });
|
this.flatTodoList.push({ todo, element: itemEl, group });
|
||||||
|
|
||||||
// Set up drop target on individual items
|
|
||||||
this.setupItemDropZone(itemEl, todo, file);
|
|
||||||
|
|
||||||
// Recursively register children in flat list
|
// Recursively register children in flat list
|
||||||
for (const child of todo.children) {
|
for (const child of todo.children) {
|
||||||
this.registerChildInFlatList(itemEl, child, group);
|
this.registerChildInFlatList(itemEl, child, group);
|
||||||
@@ -177,7 +167,6 @@ export class TodoSidebarView extends ItemView {
|
|||||||
) as HTMLElement | null;
|
) as HTMLElement | null;
|
||||||
if (childEl) {
|
if (childEl) {
|
||||||
this.flatTodoList.push({ todo, element: childEl, group });
|
this.flatTodoList.push({ todo, element: childEl, group });
|
||||||
this.setupItemDropZone(childEl, todo, this.currentFile!);
|
|
||||||
|
|
||||||
for (const grandchild of todo.children) {
|
for (const grandchild of todo.children) {
|
||||||
this.registerChildInFlatList(childEl, grandchild, group);
|
this.registerChildInFlatList(childEl, grandchild, group);
|
||||||
@@ -185,219 +174,6 @@ export class TodoSidebarView extends ItemView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Drag and Drop ---
|
|
||||||
|
|
||||||
private handleDragStart(evt: DragEvent, todo: TodoItem): void {
|
|
||||||
this.draggedTodo = todo;
|
|
||||||
this.draggedChildLines = collectChildLineNumbers(todo);
|
|
||||||
|
|
||||||
evt.dataTransfer?.setData('text/plain', todo.text);
|
|
||||||
if (evt.dataTransfer) {
|
|
||||||
evt.dataTransfer.effectAllowed = 'move';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add dragging class
|
|
||||||
const target = evt.target as HTMLElement;
|
|
||||||
target.addClass('todo-tracker-item-dragging');
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleDragEnd(evt: DragEvent): void {
|
|
||||||
this.draggedTodo = null;
|
|
||||||
this.draggedChildLines = [];
|
|
||||||
|
|
||||||
// Remove dragging class
|
|
||||||
const target = evt.target as HTMLElement;
|
|
||||||
target.removeClass('todo-tracker-item-dragging');
|
|
||||||
|
|
||||||
// Clean up all drag-over indicators
|
|
||||||
this.contentEl.querySelectorAll('.todo-tracker-drag-over').forEach((el) => {
|
|
||||||
el.classList.remove('todo-tracker-drag-over');
|
|
||||||
});
|
|
||||||
this.contentEl.querySelectorAll('.todo-tracker-drop-above, .todo-tracker-drop-below, .todo-tracker-drop-nest').forEach((el) => {
|
|
||||||
el.classList.remove('todo-tracker-drop-above');
|
|
||||||
el.classList.remove('todo-tracker-drop-below');
|
|
||||||
el.classList.remove('todo-tracker-drop-nest');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDropZone(evt: DragEvent, itemEl: HTMLElement): 'above' | 'nest' | 'below' {
|
|
||||||
const rect = itemEl.getBoundingClientRect();
|
|
||||||
const relativeY = evt.clientY - rect.top;
|
|
||||||
const thirdHeight = rect.height / 3;
|
|
||||||
|
|
||||||
if (relativeY < thirdHeight) return 'above';
|
|
||||||
if (relativeY < thirdHeight * 2) return 'nest';
|
|
||||||
return 'below';
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupItemDropZone(itemEl: HTMLElement, targetTodo: TodoItem, file: TFile): void {
|
|
||||||
itemEl.addEventListener('dragover', (evt) => {
|
|
||||||
if (!this.draggedTodo || this.draggedTodo.lineNumber === targetTodo.lineNumber) return;
|
|
||||||
evt.preventDefault();
|
|
||||||
if (evt.dataTransfer) {
|
|
||||||
evt.dataTransfer.dropEffect = 'move';
|
|
||||||
}
|
|
||||||
|
|
||||||
const zone = this.getDropZone(evt, itemEl);
|
|
||||||
|
|
||||||
itemEl.removeClass('todo-tracker-drop-above');
|
|
||||||
itemEl.removeClass('todo-tracker-drop-below');
|
|
||||||
itemEl.removeClass('todo-tracker-drop-nest');
|
|
||||||
|
|
||||||
if (zone === 'above') itemEl.addClass('todo-tracker-drop-above');
|
|
||||||
else if (zone === 'below') itemEl.addClass('todo-tracker-drop-below');
|
|
||||||
else itemEl.addClass('todo-tracker-drop-nest');
|
|
||||||
});
|
|
||||||
|
|
||||||
itemEl.addEventListener('dragleave', () => {
|
|
||||||
itemEl.removeClass('todo-tracker-drop-above');
|
|
||||||
itemEl.removeClass('todo-tracker-drop-below');
|
|
||||||
itemEl.removeClass('todo-tracker-drop-nest');
|
|
||||||
});
|
|
||||||
|
|
||||||
itemEl.addEventListener('drop', (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
itemEl.removeClass('todo-tracker-drop-above');
|
|
||||||
itemEl.removeClass('todo-tracker-drop-below');
|
|
||||||
itemEl.removeClass('todo-tracker-drop-nest');
|
|
||||||
|
|
||||||
if (!this.draggedTodo || !file) return;
|
|
||||||
|
|
||||||
const zone = this.getDropZone(evt, itemEl);
|
|
||||||
|
|
||||||
if (zone === 'nest') {
|
|
||||||
this.performNest(file, targetTodo);
|
|
||||||
} else {
|
|
||||||
const targetLine = zone === 'above' ? targetTodo.lineNumber : targetTodo.lineNumber + 1;
|
|
||||||
this.performMove(file, targetLine);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupGroupDropZone(groupEl: HTMLElement, group: TodoGroup, file: TFile): void {
|
|
||||||
groupEl.addEventListener('dragover', (evt) => {
|
|
||||||
if (!this.draggedTodo) return;
|
|
||||||
|
|
||||||
// Only handle if drop is on the group itself, not a child item
|
|
||||||
const target = evt.target as HTMLElement;
|
|
||||||
if (target.closest('.todo-tracker-item')) return;
|
|
||||||
|
|
||||||
evt.preventDefault();
|
|
||||||
if (evt.dataTransfer) {
|
|
||||||
evt.dataTransfer.dropEffect = 'move';
|
|
||||||
}
|
|
||||||
groupEl.addClass('todo-tracker-drag-over');
|
|
||||||
});
|
|
||||||
|
|
||||||
groupEl.addEventListener('dragleave', (evt) => {
|
|
||||||
// Only remove if actually leaving the group
|
|
||||||
const relatedTarget = evt.relatedTarget as HTMLElement | null;
|
|
||||||
if (!relatedTarget || !groupEl.contains(relatedTarget)) {
|
|
||||||
groupEl.removeClass('todo-tracker-drag-over');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
groupEl.addEventListener('drop', (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
groupEl.removeClass('todo-tracker-drag-over');
|
|
||||||
|
|
||||||
if (!this.draggedTodo || !file || !group.heading) return;
|
|
||||||
|
|
||||||
// Find the next heading's line number for section boundary
|
|
||||||
const groupIndex = this.groups.indexOf(group);
|
|
||||||
let nextHeadingLine: number | undefined;
|
|
||||||
for (let i = groupIndex + 1; i < this.groups.length; i++) {
|
|
||||||
if (this.groups[i]?.heading) {
|
|
||||||
nextHeadingLine = this.groups[i]!.heading!.lineNumber;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.performMoveUnderHeading(file, group.heading.lineNumber, nextHeadingLine);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async performMove(file: TFile, targetLine: number): Promise<void> {
|
|
||||||
if (!this.draggedTodo) return;
|
|
||||||
|
|
||||||
const todoLine = this.draggedTodo.lineNumber;
|
|
||||||
const childLines = this.draggedChildLines;
|
|
||||||
|
|
||||||
await this.app.vault.process(file, (content) => {
|
|
||||||
return moveTodoWithChildren(content, todoLine, childLines, targetLine);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.draggedTodo = null;
|
|
||||||
this.draggedChildLines = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async performNest(file: TFile, targetTodo: TodoItem): Promise<void> {
|
|
||||||
if (!this.draggedTodo) return;
|
|
||||||
|
|
||||||
const todoLine = this.draggedTodo.lineNumber;
|
|
||||||
const childLines = this.draggedChildLines;
|
|
||||||
const targetIndent = targetTodo.indentLevel;
|
|
||||||
|
|
||||||
await this.app.vault.process(file, (content) => {
|
|
||||||
const lines = content.split('\n');
|
|
||||||
const allLineNumbers = [todoLine, ...childLines].sort((a, b) => a - b);
|
|
||||||
|
|
||||||
// Extract lines to move
|
|
||||||
const movedLines = allLineNumbers.map((ln) => lines[ln]!);
|
|
||||||
|
|
||||||
// Calculate indent delta: we want dragged todo to be one level deeper than target
|
|
||||||
const draggedIndent = this.draggedTodo!.indentLevel;
|
|
||||||
const indentDelta = targetIndent + 1 - draggedIndent;
|
|
||||||
|
|
||||||
// Adjust indentation
|
|
||||||
const indentedLines = indentTodoLines(movedLines, indentDelta);
|
|
||||||
|
|
||||||
// Find insert position: right after target todo and its existing children
|
|
||||||
const targetChildLines = collectChildLineNumbers(targetTodo);
|
|
||||||
const allTargetLines = [targetTodo.lineNumber, ...targetChildLines];
|
|
||||||
const insertAfter = Math.max(...allTargetLines);
|
|
||||||
|
|
||||||
// Remove original lines (in reverse to preserve indices)
|
|
||||||
for (let i = allLineNumbers.length - 1; i >= 0; i--) {
|
|
||||||
lines.splice(allLineNumbers[i]!, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust insert position for removed lines
|
|
||||||
let adjustedInsert = insertAfter;
|
|
||||||
for (const ln of allLineNumbers) {
|
|
||||||
if (ln <= insertAfter) {
|
|
||||||
adjustedInsert--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert indented lines after the target
|
|
||||||
lines.splice(adjustedInsert + 1, 0, ...indentedLines);
|
|
||||||
|
|
||||||
return lines.join('\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
this.draggedTodo = null;
|
|
||||||
this.draggedChildLines = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async performMoveUnderHeading(
|
|
||||||
file: TFile,
|
|
||||||
headingLine: number,
|
|
||||||
nextHeadingLine?: number
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.draggedTodo) return;
|
|
||||||
|
|
||||||
const todoLine = this.draggedTodo.lineNumber;
|
|
||||||
const childLines = this.draggedChildLines;
|
|
||||||
|
|
||||||
await this.app.vault.process(file, (content) => {
|
|
||||||
return moveTodoUnderHeadingInFile(content, todoLine, childLines, headingLine, nextHeadingLine);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.draggedTodo = null;
|
|
||||||
this.draggedChildLines = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Keyboard Navigation ---
|
// --- Keyboard Navigation ---
|
||||||
|
|
||||||
private handleKeydown(evt: KeyboardEvent): void {
|
private handleKeydown(evt: KeyboardEvent): void {
|
||||||
|
|||||||
23
styles.css
23
styles.css
@@ -58,7 +58,6 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 4px;
|
padding: 6px 4px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: grab;
|
|
||||||
transition: background-color 0.15s ease;
|
transition: background-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,25 +89,3 @@
|
|||||||
background-color: var(--background-modifier-hover);
|
background-color: var(--background-modifier-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Drag and drop */
|
|
||||||
.todo-tracker-item-dragging {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo-tracker-drop-above {
|
|
||||||
border-top: 2px solid var(--interactive-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo-tracker-drop-below {
|
|
||||||
border-bottom: 2px solid var(--interactive-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo-tracker-drop-nest {
|
|
||||||
outline: 2px solid var(--interactive-accent);
|
|
||||||
outline-offset: -2px;
|
|
||||||
background-color: var(--background-modifier-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo-tracker-drag-over {
|
|
||||||
background-color: var(--background-modifier-hover);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user