This commit is contained in:
2026-03-28 10:55:42 -04:00
commit 6a2d0cffd6
21 changed files with 2175 additions and 0 deletions

9
src/constants.ts Normal file
View File

@ -0,0 +1,9 @@
export const VIEW_TYPE = 'yaotp-task-view';
export const VIEW_ICON = 'check-square';
export const DEFAULT_INBOX_PATH = 'Tasks/Inbox.md';
export const DEFAULT_TASK_REGEX = '^Tasks\\/.*\\.md$';
export const DEFAULT_DAILY_NOTE_FORMAT = 'YYYY-MM-DD';
export const DEFAULT_DAILY_NOTE_FOLDER = '';
export const DAILY_NOTE_TASKS_HEADING = '#### Tasks';

85
src/daily-notes.ts Normal file
View File

@ -0,0 +1,85 @@
import { App, Notice, TFile } from 'obsidian';
import type { Task } from './types';
import { DAILY_NOTE_TASKS_HEADING } from './constants';
// obsidian-daily-notes-interface re-exports moment from Obsidian's bundle.
// We import it this way so esbuild treats moment as external.
declare const moment: (date?: unknown) => {
format(fmt: string): string;
};
interface DailyNotesInterface {
appHasDailyNotesPluginLoaded(app: App): boolean;
getDailyNote(
date: ReturnType<typeof moment>,
notes: Record<string, TFile>
): TFile | null;
createDailyNote(date: ReturnType<typeof moment>): Promise<TFile>;
getAllDailyNotes(): Record<string, TFile>;
}
let dni: DailyNotesInterface | null = null;
async function getDni(): Promise<DailyNotesInterface> {
if (!dni) {
dni = await import('obsidian-daily-notes-interface') as unknown as DailyNotesInterface;
}
return dni;
}
/**
* Append a completed task (and its notes) to the `#### Tasks` section
* of today's daily note, creating the section and/or the note if needed.
*/
export async function appendTaskToDailyNote(task: Task, app: App): Promise<void> {
const lib = await getDni();
if (!lib.appHasDailyNotesPluginLoaded(app)) {
new Notice(
'YAOTP: Daily Notes plugin is not enabled. ' +
'Please enable it in Settings → Core plugins.'
);
return;
}
const all = lib.getAllDailyNotes();
// moment is available as a global in Obsidian's runtime
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const today = (window as any).moment();
let file: TFile | null = lib.getDailyNote(today, all);
if (!file) {
file = await lib.createDailyNote(today);
}
const existing = await app.vault.read(file);
const appended = buildAppendedContent(existing, task);
await app.vault.modify(file, appended);
}
function buildAppendedContent(existing: string, task: Task): string {
const taskLine = `- [x] ${task.text}`;
const notesBlock =
task.notes.length > 0 ? '\n\n' + task.notes.join('\n') : '';
const entry = taskLine + notesBlock;
const headingIndex = existing.lastIndexOf(DAILY_NOTE_TASKS_HEADING);
if (headingIndex === -1) {
// Section doesn't exist — append it at the end
const base = existing.trimEnd();
return base + (base.length > 0 ? '\n\n' : '') + DAILY_NOTE_TASKS_HEADING + '\n\n' + entry + '\n';
}
// Section exists — insert after heading and any existing items
const afterHeading = existing.slice(headingIndex + DAILY_NOTE_TASKS_HEADING.length);
// Find where the next same-or-higher-level heading starts (if any)
const nextHeadingMatch = afterHeading.match(/\n#{1,4} /);
const insertionRelative = nextHeadingMatch?.index ?? afterHeading.length;
const before = existing.slice(0, headingIndex + DAILY_NOTE_TASKS_HEADING.length);
const sectionContent = afterHeading.slice(0, insertionRelative).trimEnd();
const after = afterHeading.slice(insertionRelative);
return before + sectionContent + '\n\n' + entry + (after.length > 0 ? '\n' + after : '\n');
}

45
src/file-intercept.ts Normal file
View File

@ -0,0 +1,45 @@
import { WorkspaceLeaf } from 'obsidian';
import { VIEW_TYPE } from './constants';
type SetViewState = WorkspaceLeaf['setViewState'];
let originalSetViewState: SetViewState | null = null;
/**
* Monkey-patch WorkspaceLeaf.prototype.setViewState so that opening a
* task file (identified by the given predicate) redirects to our custom
* view type instead of the default markdown editor.
*/
export function installFileIntercept(isTaskFile: (path: string) => boolean): void {
if (originalSetViewState) return; // already installed
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const proto = (WorkspaceLeaf as any).prototype;
originalSetViewState = proto.setViewState as SetViewState;
proto.setViewState = function (
this: WorkspaceLeaf,
state: Parameters<SetViewState>[0],
eState?: Parameters<SetViewState>[1]
) {
if (
state?.type === 'markdown' &&
state?.state?.file &&
isTaskFile(state.state.file as string)
) {
const newState = { ...state, type: VIEW_TYPE };
return originalSetViewState!.call(this, newState, eState);
}
return originalSetViewState!.call(this, state, eState);
};
}
/**
* Restore the original setViewState. Call from Plugin.onunload().
*/
export function uninstallFileIntercept(): void {
if (!originalSetViewState) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(WorkspaceLeaf as any).prototype.setViewState = originalSetViewState;
originalSetViewState = null;
}

96
src/main.ts Normal file
View File

@ -0,0 +1,96 @@
import { Plugin, TFile } from 'obsidian';
import { VIEW_TYPE } from './constants';
import { DEFAULT_SETTINGS, YaotpSettings, YaotpSettingTab } from './settings';
import { TaskFileView } from './view/TaskFileView';
import { installFileIntercept, uninstallFileIntercept } from './file-intercept';
export default class YaotpPlugin extends Plugin {
settings: YaotpSettings = { ...DEFAULT_SETTINGS };
private taskRegex: RegExp = new RegExp(DEFAULT_SETTINGS.taskFileRegex);
async onload(): Promise<void> {
await this.loadSettings();
this.rebuildRegex();
// Register the custom view
this.registerView(
VIEW_TYPE,
(leaf) =>
new TaskFileView(leaf, this.settings, () => this.getTaskFiles())
);
// Intercept file opens for task files
installFileIntercept((path) => this.isTaskFile(path));
// Open existing task-file leaves in the custom view on startup
this.app.workspace.onLayoutReady(() => {
this.redirectExistingLeaves();
});
// Settings tab
this.addSettingTab(new YaotpSettingTab(this.app, this));
// Command: open inbox
this.addCommand({
id: 'open-inbox',
name: 'Open Inbox',
callback: () => this.openInbox(),
});
}
onunload(): void {
uninstallFileIntercept();
}
async loadSettings(): Promise<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
this.rebuildRegex();
}
isTaskFile(path: string): boolean {
return this.taskRegex.test(path);
}
getTaskFiles(): TFile[] {
return this.app.vault
.getMarkdownFiles()
.filter((f) => this.isTaskFile(f.path));
}
private rebuildRegex(): void {
try {
this.taskRegex = new RegExp(this.settings.taskFileRegex);
} catch {
// Fall back to default if the setting is invalid
this.taskRegex = new RegExp(DEFAULT_SETTINGS.taskFileRegex);
}
}
private redirectExistingLeaves(): void {
this.app.workspace.iterateAllLeaves((leaf) => {
const state = leaf.getViewState();
if (
state.type === 'markdown' &&
state.state?.file &&
this.isTaskFile(state.state.file as string)
) {
leaf.setViewState({ ...state, type: VIEW_TYPE });
}
});
}
private async openInbox(): Promise<void> {
const { inboxPath } = this.settings;
let file = this.app.vault.getAbstractFileByPath(inboxPath) as TFile | null;
if (!file) {
file = await this.app.vault.create(inboxPath, '');
}
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
}
}

123
src/parser.ts Normal file
View File

@ -0,0 +1,123 @@
import type { Task } from './types';
const CHECKLIST_RE = /^- \[([ xX])\] (.*)$/;
/**
* Parse a task file's markdown content into an array of Tasks.
*
* Format expected:
* - [ ] Task title
*
* notes line 1
* notes line 2
*
* - [ ] Next task
*
* Notes are lines between two blank-line boundaries following a checklist item.
* Blank lines that are not between a checklist item and notes, or between notes
* lines, are ignored.
*/
export function parseTaskFile(markdown: string): Task[] {
const lines = markdown.split('\n');
const tasks: Task[] = [];
let current: Task | null = null;
// State: 'idle' | 'after-task' | 'in-notes'
// after-task: saw checklist line, waiting for blank line or next checklist
// in-notes: saw blank line after checklist, collecting note lines
let state: 'idle' | 'after-task' | 'in-notes' = 'idle';
let noteBuffer: string[] = [];
const flush = () => {
if (current) {
// Trim trailing blank lines from notes
while (noteBuffer.length > 0 && noteBuffer[noteBuffer.length - 1].trim() === '') {
noteBuffer.pop();
}
current.notes = noteBuffer;
tasks.push(current);
current = null;
noteBuffer = [];
}
};
for (const line of lines) {
const match = CHECKLIST_RE.exec(line);
if (match) {
// Start of a new task — flush the previous one first
flush();
current = {
text: match[2],
notes: [],
completed: match[1].toLowerCase() === 'x',
};
state = 'after-task';
continue;
}
if (current === null) {
// No active task; ignore non-checklist lines before the first task
continue;
}
const isBlank = line.trim() === '';
if (state === 'after-task') {
if (isBlank) {
// Blank line after task — transition to note-collection mode
state = 'in-notes';
} else {
// Non-blank, non-checklist line immediately after task (no separator blank)
// Treat as a note anyway, for robustness
noteBuffer.push(line);
state = 'in-notes';
}
continue;
}
if (state === 'in-notes') {
noteBuffer.push(line);
continue;
}
}
flush();
return tasks;
}
/**
* Serialize an array of Tasks back to canonical markdown.
*
* Format produced:
* - [ ] Task title
*
* notes line 1
* notes line 2
*
* - [ ] Next task
*
* Tasks with no notes have no trailing blank line before the next task,
* except the final newline at EOF.
*/
export function serializeTaskFile(tasks: Task[]): string {
if (tasks.length === 0) return '';
const parts: string[] = [];
for (const task of tasks) {
const marker = task.completed ? 'x' : ' ';
parts.push(`- [${marker}] ${task.text}`);
if (task.notes.length > 0) {
parts.push('');
for (const note of task.notes) {
parts.push(note);
}
}
parts.push('');
}
// Join and ensure single trailing newline
return parts.join('\n').replace(/\n+$/, '') + '\n';
}

102
src/settings.ts Normal file
View File

@ -0,0 +1,102 @@
import { App, PluginSettingTab, Setting } from 'obsidian';
import {
DEFAULT_DAILY_NOTE_FOLDER,
DEFAULT_DAILY_NOTE_FORMAT,
DEFAULT_INBOX_PATH,
DEFAULT_TASK_REGEX,
} from './constants';
import type YaotpPlugin from './main';
export interface YaotpSettings {
inboxPath: string;
taskFileRegex: string;
dailyNoteFormat: string;
dailyNoteFolder: string;
}
export const DEFAULT_SETTINGS: YaotpSettings = {
inboxPath: DEFAULT_INBOX_PATH,
taskFileRegex: DEFAULT_TASK_REGEX,
dailyNoteFormat: DEFAULT_DAILY_NOTE_FORMAT,
dailyNoteFolder: DEFAULT_DAILY_NOTE_FOLDER,
};
export class YaotpSettingTab extends PluginSettingTab {
plugin: YaotpPlugin;
constructor(app: App, plugin: YaotpPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Inbox file path')
.setDesc('Vault-relative path to the default Inbox task file.')
.addText((text) =>
text
.setPlaceholder('Tasks/Inbox.md')
.setValue(this.plugin.settings.inboxPath)
.onChange(async (value) => {
this.plugin.settings.inboxPath = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Task file regex')
.setDesc(
'Regular expression matched against vault-relative file paths to identify task files.'
)
.addText((text) => {
text
.setPlaceholder('^Tasks\\/.*\\.md$')
.setValue(this.plugin.settings.taskFileRegex)
.onChange(async (value) => {
// Validate before saving
try {
new RegExp(value);
text.inputEl.removeClass('yaotp-setting-error');
this.plugin.settings.taskFileRegex = value.trim();
await this.plugin.saveSettings();
} catch {
text.inputEl.addClass('yaotp-setting-error');
}
});
text.inputEl.style.width = '100%';
});
new Setting(containerEl)
.setName('Daily note date format')
.setDesc(
'Moment.js date format for daily notes. Should match your Daily Notes plugin setting.'
)
.addText((text) =>
text
.setPlaceholder('YYYY-MM-DD')
.setValue(this.plugin.settings.dailyNoteFormat)
.onChange(async (value) => {
this.plugin.settings.dailyNoteFormat = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName('Daily note folder')
.setDesc(
'Folder where daily notes are stored. Leave empty for the vault root. Should match your Daily Notes plugin setting.'
)
.addText((text) =>
text
.setPlaceholder('Daily Notes')
.setValue(this.plugin.settings.dailyNoteFolder)
.onChange(async (value) => {
this.plugin.settings.dailyNoteFolder = value.trim();
await this.plugin.saveSettings();
})
);
}
}

8
src/types.ts Normal file
View File

@ -0,0 +1,8 @@
export interface Task {
/** The text of the checklist item (without the `- [ ] ` prefix). */
text: string;
/** Lines of notes associated with this task. Empty array if none. */
notes: string[];
/** Whether the task is completed (`- [x]`). */
completed: boolean;
}

View File

@ -0,0 +1,59 @@
import { App, TFile } from 'obsidian';
export interface FileSwitcherBarOptions {
app: App;
currentFile: TFile;
taskFiles: TFile[];
inboxPath: string;
onSwitchFile: (file: TFile) => void;
onCreateFile: (name: string) => void;
}
/**
* Renders a header bar with a file switcher dropdown and a "New task file" button.
* Returns the container element to be prepended into the view's root.
*/
export function buildFileSwitcherBar(opts: FileSwitcherBarOptions): HTMLElement {
const { currentFile, taskFiles, inboxPath, onSwitchFile, onCreateFile } = opts;
const bar = createDiv({ cls: 'yaotp-switcher-bar' });
// Left side: file selector
const selectWrap = bar.createDiv({ cls: 'yaotp-switcher-select-wrap' });
const select = selectWrap.createEl('select', { cls: 'yaotp-switcher-select' });
// Always show Inbox first if it exists in the list
const inbox = taskFiles.find((f) => f.path === inboxPath);
const others = taskFiles.filter((f) => f.path !== inboxPath);
const ordered = inbox ? [inbox, ...others] : others;
for (const file of ordered) {
const opt = select.createEl('option', {
text: file.basename,
value: file.path,
});
if (file.path === currentFile.path) {
opt.selected = true;
}
}
select.addEventListener('change', () => {
const chosen = taskFiles.find((f) => f.path === select.value);
if (chosen) onSwitchFile(chosen);
});
// Right side: new file button
const newBtn = bar.createEl('button', {
text: '+ New list',
cls: 'yaotp-switcher-new-btn',
});
newBtn.addEventListener('click', () => {
const name = prompt('New task file name (without .md):');
if (name && name.trim()) {
onCreateFile(name.trim());
}
});
return bar;
}

136
src/view/TaskEditorModal.ts Normal file
View File

@ -0,0 +1,136 @@
import { App, FuzzySuggestModal, Modal, TFile } from 'obsidian';
import type { Task } from '../types';
export interface TaskEditorResult {
text: string;
notes: string[];
/** If set, move the task to this file. */
targetFile: TFile | null;
}
type SaveCallback = (result: TaskEditorResult) => void;
/**
* Modal for editing a task's text and notes together, and optionally
* moving it to a different task file.
*/
export class TaskEditorModal extends Modal {
private task: Task;
private taskFiles: TFile[];
private currentFile: TFile;
private onSave: SaveCallback;
private textarea: HTMLTextAreaElement | null = null;
private selectedFile: TFile | null = null;
private fileLabel: HTMLSpanElement | null = null;
constructor(
app: App,
task: Task,
taskFiles: TFile[],
currentFile: TFile,
onSave: SaveCallback
) {
super(app);
this.task = task;
this.taskFiles = taskFiles;
this.currentFile = currentFile;
this.onSave = onSave;
}
onOpen(): void {
const { contentEl } = this;
contentEl.addClass('yaotp-editor-modal');
// Title
contentEl.createEl('h2', { text: 'Edit task' });
// Textarea: first line = task text, rest = notes
const initialValue = [this.task.text, ...this.task.notes].join('\n');
this.textarea = contentEl.createEl('textarea', {
cls: 'yaotp-editor-textarea',
attr: { rows: '8', placeholder: 'Task title\n\nNotes…' },
});
this.textarea.value = initialValue;
// Auto-focus
setTimeout(() => this.textarea?.focus(), 50);
// File selector row
const fileRow = contentEl.createDiv({ cls: 'yaotp-editor-file-row' });
fileRow.createEl('span', { text: 'File: ', cls: 'yaotp-editor-file-label-prefix' });
this.fileLabel = fileRow.createEl('span', {
text: this.currentFile.basename,
cls: 'yaotp-editor-file-name',
});
const changeBtn = fileRow.createEl('button', {
text: 'Change…',
cls: 'yaotp-editor-file-btn',
});
changeBtn.addEventListener('click', () => {
const picker = new TaskFilePicker(
this.app,
this.taskFiles,
(file) => {
this.selectedFile = file;
if (this.fileLabel) {
this.fileLabel.setText(file.basename);
}
}
);
picker.open();
});
// Buttons
const btnRow = contentEl.createDiv({ cls: 'yaotp-editor-btn-row' });
const saveBtn = btnRow.createEl('button', {
text: 'Save',
cls: 'mod-cta yaotp-editor-save-btn',
});
saveBtn.addEventListener('click', () => this.save());
const cancelBtn = btnRow.createEl('button', { text: 'Cancel' });
cancelBtn.addEventListener('click', () => this.close());
}
onClose(): void {
this.contentEl.empty();
}
private save(): void {
if (!this.textarea) return;
const lines = this.textarea.value.split('\n');
const text = lines[0] ?? '';
const notes = lines.slice(1);
// Trim leading blank lines from notes
while (notes.length > 0 && notes[0].trim() === '') notes.shift();
this.onSave({
text,
notes,
targetFile: this.selectedFile,
});
this.close();
}
}
class TaskFilePicker extends FuzzySuggestModal<TFile> {
private files: TFile[];
private onSelect: (file: TFile) => void;
constructor(app: App, files: TFile[], onSelect: (file: TFile) => void) {
super(app);
this.files = files;
this.onSelect = onSelect;
this.setPlaceholder('Select a task file…');
}
getItems(): TFile[] {
return this.files;
}
getItemText(file: TFile): string {
return file.path;
}
onChooseItem(file: TFile): void {
this.onSelect(file);
}
}

193
src/view/TaskFileView.ts Normal file
View File

@ -0,0 +1,193 @@
import { App, Notice, TextFileView, TFile, WorkspaceLeaf } from 'obsidian';
import { parseTaskFile, serializeTaskFile } from '../parser';
import { appendTaskToDailyNote } from '../daily-notes';
import { TaskListComponent } from './TaskListComponent';
import { buildFileSwitcherBar } from './FileSwitcherBar';
import { TaskEditorModal } from './TaskEditorModal';
import { VIEW_ICON, VIEW_TYPE } from '../constants';
import type { Task } from '../types';
import type { YaotpSettings } from '../settings';
export class TaskFileView extends TextFileView {
private tasks: Task[] = [];
private taskList: TaskListComponent | null = null;
private rootEl: HTMLElement | null = null;
private listEl: HTMLElement | null = null;
private settings: YaotpSettings;
private getTaskFiles: () => TFile[];
constructor(
leaf: WorkspaceLeaf,
settings: YaotpSettings,
getTaskFiles: () => TFile[]
) {
super(leaf);
this.settings = settings;
this.getTaskFiles = getTaskFiles;
}
getViewType(): string {
return VIEW_TYPE;
}
getDisplayText(): string {
return this.file?.basename ?? 'Tasks';
}
getIcon(): string {
return VIEW_ICON;
}
// Called by Obsidian when the file content is loaded or changes externally.
setViewData(data: string, _clear: boolean): void {
this.tasks = parseTaskFile(data);
this.renderView();
}
// Called by Obsidian when it needs to read back the file content.
getViewData(): string {
return serializeTaskFile(this.tasks);
}
clear(): void {
this.tasks = [];
this.renderView();
}
// Obsidian calls this after the view is attached to the DOM.
async onOpen(): Promise<void> {
this.contentEl.addClass('yaotp-view');
this.rootEl = this.contentEl;
}
async onClose(): Promise<void> {
this.taskList?.destroy();
this.taskList = null;
}
// ── Private ──────────────────────────────────────────────────────────────
private renderView(): void {
if (!this.rootEl) return;
this.rootEl.empty();
// Switcher bar
if (this.file) {
const taskFiles = this.getTaskFiles();
const bar = buildFileSwitcherBar({
app: this.app,
currentFile: this.file,
taskFiles,
inboxPath: this.settings.inboxPath,
onSwitchFile: (file) => this.openFile(file),
onCreateFile: (name) => this.createAndOpenFile(name),
});
this.rootEl.appendChild(bar);
}
// Task list container
this.listEl = this.rootEl.createDiv({ cls: 'yaotp-list-container' });
this.taskList = new TaskListComponent(this.listEl, {
onComplete: (index) => this.completeTask(index),
onEdit: (index) => this.editTask(index),
onReorder: (oldIndex, newIndex) => this.reorderTask(oldIndex, newIndex),
onAdd: (text) => this.addTask(text),
});
this.taskList.render(this.tasks);
}
private async persistTasks(): Promise<void> {
if (!this.file) return;
await this.app.vault.modify(this.file, serializeTaskFile(this.tasks));
}
private async completeTask(index: number): Promise<void> {
const task = this.tasks[index];
if (!task) return;
// Mark completed and remove from this file
task.completed = true;
this.tasks.splice(index, 1);
await this.persistTasks();
// Append to daily note
await appendTaskToDailyNote(task, this.app);
this.renderView();
}
private editTask(index: number): void {
const task = this.tasks[index];
if (!task || !this.file) return;
const taskFiles = this.getTaskFiles();
const modal = new TaskEditorModal(
this.app,
task,
taskFiles,
this.file,
async (result) => {
task.text = result.text;
task.notes = result.notes;
if (result.targetFile && result.targetFile.path !== this.file?.path) {
// Move task to another file
this.tasks.splice(index, 1);
await this.persistTasks();
await this.appendTaskToFile(task, result.targetFile);
} else {
await this.persistTasks();
}
this.renderView();
}
);
modal.open();
}
private async addTask(text: string): Promise<void> {
this.tasks.unshift({ text, notes: [], completed: false });
await this.persistTasks();
this.renderView();
}
private reorderTask(oldIndex: number, newIndex: number): void {
const moved = this.tasks.splice(oldIndex, 1)[0];
this.tasks.splice(newIndex, 0, moved);
this.persistTasks();
// No need to re-render; SortableJS already updated the DOM
}
private async appendTaskToFile(task: Task, file: TFile): Promise<void> {
const content = await this.app.vault.read(file);
const existing = parseTaskFile(content);
existing.push(task);
await this.app.vault.modify(file, serializeTaskFile(existing));
}
private openFile(file: TFile): void {
this.app.workspace.getLeaf(false).openFile(file);
}
private async createAndOpenFile(name: string): Promise<void> {
// Derive folder from inboxPath
const inboxFolder = this.settings.inboxPath.includes('/')
? this.settings.inboxPath.slice(0, this.settings.inboxPath.lastIndexOf('/'))
: '';
const path = inboxFolder ? `${inboxFolder}/${name}.md` : `${name}.md`;
let file = this.app.vault.getAbstractFileByPath(path) as TFile | null;
if (!file) {
try {
file = await this.app.vault.create(path, '');
} catch (e) {
new Notice(`YAOTP: Could not create file: ${path}`);
return;
}
}
this.openFile(file);
}
}

View File

@ -0,0 +1,111 @@
import Sortable from 'sortablejs';
import type { Task } from '../types';
export interface TaskListCallbacks {
onComplete: (index: number) => void;
onEdit: (index: number) => void;
onReorder: (oldIndex: number, newIndex: number) => void;
onAdd: (text: string) => void;
}
/**
* Builds and manages the sortable task list DOM.
*/
export class TaskListComponent {
private container: HTMLElement;
private callbacks: TaskListCallbacks;
private sortable: Sortable | null = null;
constructor(container: HTMLElement, callbacks: TaskListCallbacks) {
this.container = container;
this.callbacks = callbacks;
}
render(tasks: Task[]): void {
this.destroy();
this.container.empty();
// Add-task row — always first, outside the sortable list
const addItem = this.container.createDiv({ cls: 'yaotp-task-item yaotp-add-task-item' });
const addHandle = addItem.createDiv({ cls: 'yaotp-drag-handle yaotp-drag-handle-disabled' });
addHandle.innerHTML = '&#8942;';
addItem.createEl('input', {
cls: 'yaotp-checkbox',
type: 'checkbox',
attr: { disabled: true, 'aria-hidden': 'true' },
});
const input = addItem.createEl('input', {
cls: 'yaotp-new-task-input',
type: 'text',
attr: { placeholder: 'Enter new task...' },
}) as HTMLInputElement;
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const text = input.value.trim();
if (text) {
this.callbacks.onAdd(text);
}
}
});
if (tasks.length === 0) return;
const list = this.container.createEl('ul', { cls: 'yaotp-task-list' });
tasks.forEach((task, index) => {
const item = list.createEl('li', { cls: 'yaotp-task-item' });
if (task.completed) item.addClass('yaotp-task-completed');
item.dataset.index = String(index);
// Drag handle
const handle = item.createDiv({ cls: 'yaotp-drag-handle' });
handle.innerHTML = '&#8942;'; // vertical ellipsis ⋮
// Checkbox
const checkbox = item.createEl('input', {
cls: 'yaotp-checkbox',
type: 'checkbox',
attr: { 'aria-label': 'Complete task' },
}) as HTMLInputElement;
checkbox.checked = task.completed;
checkbox.addEventListener('click', (e) => {
e.stopPropagation();
this.callbacks.onComplete(index);
});
// Task text (wraps)
const textEl = item.createDiv({ cls: 'yaotp-task-text' });
textEl.setText(task.text);
if (task.notes.length > 0) {
textEl.addClass('yaotp-task-has-notes');
}
textEl.addEventListener('click', () => {
this.callbacks.onEdit(index);
});
});
this.sortable = Sortable.create(list, {
handle: '.yaotp-drag-handle',
animation: 150,
delay: 300,
delayOnTouchOnly: true,
onEnd: (evt) => {
const oldIndex = evt.oldIndex;
const newIndex = evt.newIndex;
if (oldIndex !== undefined && newIndex !== undefined && oldIndex !== newIndex) {
this.callbacks.onReorder(oldIndex, newIndex);
}
},
});
}
destroy(): void {
if (this.sortable) {
this.sortable.destroy();
this.sortable = null;
}
}
}