Tweaks & fixes

This commit is contained in:
2026-03-29 09:56:40 -04:00
parent 6a2d0cffd6
commit c21d8b1097
11 changed files with 310 additions and 81 deletions

View File

@ -1,7 +1,7 @@
import { App, TFile } from 'obsidian';
import { TFile } from 'obsidian';
export interface FileSwitcherBarOptions {
app: App;
app: unknown; // kept for interface compatibility, unused after modal removal
currentFile: TFile;
taskFiles: TFile[];
inboxPath: string;
@ -10,8 +10,8 @@ export interface FileSwitcherBarOptions {
}
/**
* 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.
* Renders a header bar with a file switcher dropdown and an inline
* "New task file" affordance. Returns the container element.
*/
export function buildFileSwitcherBar(opts: FileSwitcherBarOptions): HTMLElement {
const { currentFile, taskFiles, inboxPath, onSwitchFile, onCreateFile } = opts;
@ -20,39 +20,70 @@ export function buildFileSwitcherBar(opts: FileSwitcherBarOptions): HTMLElement
// 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;
}
for (const file of (inbox ? [inbox, ...others] : others)) {
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
// Right side: "+ New list" button / inline create input (mutually exclusive)
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());
const createGroup = bar.createDiv({ cls: 'yaotp-switcher-create-group' });
createGroup.style.display = 'none';
const nameInput = createGroup.createEl('input', {
type: 'text',
cls: 'yaotp-switcher-name-input',
attr: { placeholder: 'File name (without .md)' },
}) as HTMLInputElement;
const confirmBtn = createGroup.createEl('button', {
text: 'Create',
cls: 'mod-cta yaotp-switcher-confirm-btn',
});
const cancelBtn = createGroup.createEl('button', {
text: '✕',
cls: 'yaotp-switcher-cancel-btn',
});
const showInput = () => {
newBtn.style.display = 'none';
createGroup.style.display = 'flex';
nameInput.value = '';
setTimeout(() => nameInput.focus(), 0);
};
const hideInput = () => {
createGroup.style.display = 'none';
newBtn.style.display = '';
};
const submit = () => {
const name = nameInput.value.trim();
if (name) {
hideInput();
onCreateFile(name);
}
};
newBtn.addEventListener('click', showInput);
confirmBtn.addEventListener('click', submit);
cancelBtn.addEventListener('click', hideInput);
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') submit();
if (e.key === 'Escape') hideInput();
});
return bar;

View File

@ -9,6 +9,7 @@ export interface TaskEditorResult {
}
type SaveCallback = (result: TaskEditorResult) => void;
type DeleteCallback = () => void;
/**
* Modal for editing a task's text and notes together, and optionally
@ -19,6 +20,7 @@ export class TaskEditorModal extends Modal {
private taskFiles: TFile[];
private currentFile: TFile;
private onSave: SaveCallback;
private onDelete: DeleteCallback;
private textarea: HTMLTextAreaElement | null = null;
private selectedFile: TFile | null = null;
private fileLabel: HTMLSpanElement | null = null;
@ -28,13 +30,15 @@ export class TaskEditorModal extends Modal {
task: Task,
taskFiles: TFile[],
currentFile: TFile,
onSave: SaveCallback
onSave: SaveCallback,
onDelete: DeleteCallback
) {
super(app);
this.task = task;
this.taskFiles = taskFiles;
this.currentFile = currentFile;
this.onSave = onSave;
this.onDelete = onDelete;
}
onOpen(): void {
@ -44,8 +48,11 @@ export class TaskEditorModal extends 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');
// Textarea: first line = task text, blank line, then notes
const initialValue =
this.task.notes.length > 0
? [this.task.text, '', ...this.task.notes].join('\n')
: this.task.text;
this.textarea = contentEl.createEl('textarea', {
cls: 'yaotp-editor-textarea',
attr: { rows: '8', placeholder: 'Task title\n\nNotes…' },
@ -81,6 +88,18 @@ export class TaskEditorModal extends Modal {
// Buttons
const btnRow = contentEl.createDiv({ cls: 'yaotp-editor-btn-row' });
const deleteBtn = btnRow.createEl('button', {
text: 'Delete',
cls: 'yaotp-editor-delete-btn',
});
deleteBtn.addEventListener('click', () => {
new ConfirmModal(this.app, 'Delete this task? This cannot be undone.', () => {
this.close();
this.onDelete();
}).open();
});
const saveBtn = btnRow.createEl('button', {
text: 'Save',
cls: 'mod-cta yaotp-editor-save-btn',
@ -100,7 +119,7 @@ export class TaskEditorModal extends Modal {
const lines = this.textarea.value.split('\n');
const text = lines[0] ?? '';
const notes = lines.slice(1);
// Trim leading blank lines from notes
// Trim leading blank lines from notes (user may have removed or added the blank separator)
while (notes.length > 0 && notes[0].trim() === '') notes.shift();
this.onSave({
text,
@ -111,6 +130,37 @@ export class TaskEditorModal extends Modal {
}
}
class ConfirmModal extends Modal {
private message: string;
private onConfirm: () => void;
constructor(app: App, message: string, onConfirm: () => void) {
super(app);
this.message = message;
this.onConfirm = onConfirm;
}
onOpen(): void {
const { contentEl } = this;
contentEl.createEl('p', { text: this.message });
const row = contentEl.createDiv({ cls: 'yaotp-editor-btn-row' });
const confirmBtn = row.createEl('button', {
text: 'Delete',
cls: 'mod-warning yaotp-editor-delete-btn',
});
confirmBtn.addEventListener('click', () => {
this.onConfirm();
this.close();
});
const cancelBtn = row.createEl('button', { text: 'Cancel' });
cancelBtn.addEventListener('click', () => this.close());
}
onClose(): void {
this.contentEl.empty();
}
}
class TaskFilePicker extends FuzzySuggestModal<TFile> {
private files: TFile[];
private onSelect: (file: TFile) => void;

View File

@ -5,11 +5,13 @@ import { TaskListComponent } from './TaskListComponent';
import { buildFileSwitcherBar } from './FileSwitcherBar';
import { TaskEditorModal } from './TaskEditorModal';
import { VIEW_ICON, VIEW_TYPE } from '../constants';
import { allowMarkdownOnce } from '../file-intercept';
import type { Task } from '../types';
import type { YaotpSettings } from '../settings';
export class TaskFileView extends TextFileView {
private tasks: Task[] = [];
private fileHeader: string = '';
private taskList: TaskListComponent | null = null;
private rootEl: HTMLElement | null = null;
private listEl: HTMLElement | null = null;
@ -40,16 +42,19 @@ export class TaskFileView extends TextFileView {
// Called by Obsidian when the file content is loaded or changes externally.
setViewData(data: string, _clear: boolean): void {
this.tasks = parseTaskFile(data);
const { header, tasks } = parseTaskFile(data);
this.fileHeader = header;
this.tasks = tasks;
this.renderView();
}
// Called by Obsidian when it needs to read back the file content.
getViewData(): string {
return serializeTaskFile(this.tasks);
return serializeTaskFile({ header: this.fileHeader, tasks: this.tasks });
}
clear(): void {
this.fileHeader = '';
this.tasks = [];
this.renderView();
}
@ -58,6 +63,24 @@ export class TaskFileView extends TextFileView {
async onOpen(): Promise<void> {
this.contentEl.addClass('yaotp-view');
this.rootEl = this.contentEl;
this.addAction('pencil', 'Edit source', async () => {
if (!this.file) return;
const filePath = this.file.path;
// First call: bypass the intercept to land in markdown (Obsidian may
// open in Live Preview regardless of the mode we pass here).
allowMarkdownOnce(this.leaf);
await this.leaf.setViewState({
type: 'markdown',
state: { file: filePath },
});
// Second call: now the leaf is markdown + same file, so the intercept
// treats this as a mode change and lets it through.
await this.leaf.setViewState({
type: 'markdown',
state: { file: filePath, mode: 'source' },
});
});
}
async onClose(): Promise<void> {
@ -100,7 +123,16 @@ export class TaskFileView extends TextFileView {
private async persistTasks(): Promise<void> {
if (!this.file) return;
await this.app.vault.modify(this.file, serializeTaskFile(this.tasks));
await this.app.vault.modify(
this.file,
serializeTaskFile({ header: this.fileHeader, tasks: this.tasks })
);
}
private async addTask(text: string): Promise<void> {
this.tasks.unshift({ text, notes: [], completed: false });
await this.persistTasks();
this.renderView();
}
private async completeTask(index: number): Promise<void> {
@ -142,18 +174,17 @@ export class TaskFileView extends TextFileView {
await this.persistTasks();
}
this.renderView();
},
async () => {
this.tasks.splice(index, 1);
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);
@ -163,9 +194,9 @@ export class TaskFileView extends TextFileView {
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));
const data = parseTaskFile(content);
data.tasks.push(task);
await this.app.vault.modify(file, serializeTaskFile(data));
}
private openFile(file: TFile): void {
@ -173,11 +204,8 @@ export class TaskFileView extends TextFileView {
}
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`;
const folder = this.settings.taskFileFolder;
const path = folder ? `${folder}/${name}.md` : `${name}.md`;
let file = this.app.vault.getAbstractFileByPath(path) as TFile | null;
if (!file) {