diff --git a/src/Obsidian.ts b/src/Obsidian.ts index b4df12dc19..99bc088374 100644 --- a/src/Obsidian.ts +++ b/src/Obsidian.ts @@ -91,15 +91,6 @@ export class Obsidian { this.plugin.addCommand(command); } - public subscribeToClickEvent( - eventsHandler: ( - this: HTMLElement, - ev: HTMLElementEventMap['click'], - ) => any, - ): void { - this.plugin.registerDomEvent(document, 'click', eventsHandler); - } - public subscribeToLayoutReadyEvent(func: () => void): void { this.workspace.onLayoutReady(func); } @@ -128,10 +119,15 @@ export class Obsidian { this.eventReferences.push(eventRef); } - public subscribeToRenaming(func: (oldPath: string, newPath: string) => Promise) { - const eventRef = this.vault.on('rename', (file: TAbstractFile, oldPath: string) => { - func(oldPath, file.path); - }); + public subscribeToRenaming( + func: (oldPath: string, newPath: string) => Promise, + ) { + const eventRef = this.vault.on( + 'rename', + (file: TAbstractFile, oldPath: string) => { + func(oldPath, file.path); + }, + ); this.eventReferences.push(eventRef); } diff --git a/src/Tasks/Events.ts b/src/Tasks/Events.ts deleted file mode 100644 index 245e5db23b..0000000000 --- a/src/Tasks/Events.ts +++ /dev/null @@ -1,126 +0,0 @@ -import stringSimilarity from 'string-similarity'; -import { Obsidian } from '../Obsidian'; -import { File } from './File'; -import { CLASS_CHECKBOX } from './Render'; -import { DATA_PAGE_INDEX, DATA_PATH, REGEX_TASK } from './Task'; - -export class Events { - private readonly file: File; - private readonly obsidian: Obsidian; - - constructor({ file, obsidian }: { file: File; obsidian: Obsidian }) { - this.file = file; - this.obsidian = obsidian; - - this.obsidian.subscribeToClickEvent( - this.handleCheckBoxClick.bind(this), - ); - } - - private async handleCheckBoxClick(event: MouseEvent): Promise { - if ((event.target as any)?.hasClass(CLASS_CHECKBOX)) { - event.preventDefault(); - - const liElement = (event as any)?.path[1]; - if (liElement.nodeName !== 'LI') { - return; - } - - const path = liElement.getAttribute(DATA_PATH); - - const fileLines = await this.obsidian.readLines({ path }); - if (fileLines === undefined) { - return; - } - - let lineNumber: number | undefined; - // Use the page index if it is available. It is more accurate and - // should be faster than calculating the string similarity. - const pageIndex = liElement.getAttribute(DATA_PAGE_INDEX); - if (pageIndex) { - lineNumber = this.getLineNumberBasedOnPageIndex({ - pageIndex, - fileLines, - }); - } else { - lineNumber = this.getMostSimilarLineNumber({ - liElement, - fileLines, - }); - } - - if (lineNumber !== undefined) { - await this.file.toggleDone({ - path, - lineNumber, - }); - } - } - } - - private getLineNumberBasedOnPageIndex({ - pageIndex, - fileLines, - }: { - pageIndex: string; - fileLines: string[]; - }): number | undefined { - let currentIndex = 0; - for ( - let currentLine = 0; - currentLine < fileLines.length; - currentLine = currentLine + 1 - ) { - if (REGEX_TASK.test(fileLines[currentLine])) { - if (currentIndex.toString() === pageIndex) { - return currentLine; - } - currentIndex = currentIndex + 1; - } - } - - return undefined; - } - - private getMostSimilarLineNumber({ - liElement, - fileLines, - }: { - liElement: Element; - fileLines: string[]; - }): number | undefined { - if (liElement.textContent === null) { - console.error( - "Tasks: cannot toggle task: li's `textContent` is `null`", - ); - - return undefined; - } - const liText = liElement.textContent.split('\n')[0]; - - let maxSimilarity = 0; - let mostSimilarLine: number | undefined = undefined; - for ( - let currentLine = 0; - currentLine < fileLines.length; - currentLine = currentLine + 1 - ) { - const match = fileLines[currentLine].match(REGEX_TASK); - if (match !== null) { - // The LI only knows the text part after the list identifier - // and the status. - const taskText = match[3] + match[4]; - const similarity = stringSimilarity.compareTwoStrings( - taskText, - liText, - ); - if (similarity > maxSimilarity) { - maxSimilarity = similarity; - mostSimilarLine = currentLine; - } - } - } - - return mostSimilarLine; - } -} diff --git a/src/Tasks/Render/Checkbox.ts b/src/Tasks/Render/Checkbox.ts deleted file mode 100644 index ae7086d294..0000000000 --- a/src/Tasks/Render/Checkbox.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Status } from '../Status'; -import { NodeTypes } from './NodeTypes'; - -export const CLASS_CHECKBOX = 'tasks-checkbox'; -export class Checkbox { - public static prependTo({ - listItem, - taskStatus, - }: { - listItem: Element; - taskStatus: Status; - }): void { - for (let i = 0; i < listItem.childNodes.length; i = i + 1) { - const childNode = listItem.childNodes[i]; - if ( - // Prepend to the first text child in the list item. - childNode.nodeType == NodeTypes.TEXT && - childNode.textContent !== null - ) { - childNode.textContent = Checkbox.removeStatusIfPresent( - childNode.textContent, - ); - - const checkbox: HTMLInputElement = document.createElement( - 'INPUT', - ) as HTMLInputElement; - checkbox.type = 'checkbox'; - checkbox.addClass(CLASS_CHECKBOX); - if (taskStatus !== Status.TODO) { - checkbox.checked = true; - } - listItem.prepend(checkbox); - break; // Break loop as we only need one checkbox. - } - } - } - - private static removeStatusIfPresent(text: string): string { - const existingStatusRegex = /^(TODO|DONE) (.*)/u; - const existingStatusMatch = text.match(existingStatusRegex); - if (existingStatusMatch !== null) { - text = existingStatusMatch[2]; - } - - return text; - } -} diff --git a/src/Tasks/Render/ListItem.ts b/src/Tasks/Render/ListItem.ts deleted file mode 100644 index 5c71361770..0000000000 --- a/src/Tasks/Render/ListItem.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Component, MarkdownRenderer } from 'obsidian'; -import { CLASS_ITEM, DATA_PAGE_INDEX, DATA_PATH, Task } from '../Task'; -import { Checkbox } from './Checkbox'; - -export class ListItem { - public static async fromTask({ - task, - parentComponent, - }: { - task: Task; - parentComponent: Component; - }): Promise { - const listItem = document.createElement('li'); - - await MarkdownRenderer.renderMarkdown( - task.toLiString(), - listItem, - task.path, - parentComponent, - ); - - // Unwrap the p-tag that was created by the MarkdownRenderer: - const pElement = listItem.querySelector('p'); - if (pElement !== null) { - listItem.innerHTML = pElement.innerHTML; - pElement.remove(); - } - - ListItem.addAttributes({ listItem, task }); - Checkbox.prependTo({ listItem, taskStatus: task.status }); - - return listItem; - } - - public static addAttributes({ - listItem, - task, - }: { - listItem: Element; - task: Task; - }): void { - listItem.addClass(CLASS_ITEM); - listItem.setAttribute(DATA_PATH, task.path); - if (task.pageIndex !== undefined) { - listItem.setAttribute(DATA_PAGE_INDEX, task.pageIndex.toString()); - } - } -} diff --git a/src/Tasks/Render/Query.ts b/src/Tasks/Render/Query.ts index 68ea0d4a76..8e341da897 100644 --- a/src/Tasks/Render/Query.ts +++ b/src/Tasks/Render/Query.ts @@ -22,7 +22,7 @@ export class Query { source: string; }): ((task: Task) => boolean)[] { const filters: ((task: Task) => boolean)[] = []; - const sourceLines = source.split('\n').map(line => line.trim()); + const sourceLines = source.split('\n').map((line) => line.trim()); for (const sourceLine of sourceLines) { if (sourceLine === '') { @@ -122,11 +122,9 @@ export class Query { let filter; const filterMethod = pathMatch[1]; if (filterMethod === 'includes') { - filter = (task: Task) => - task.path.includes(pathMatch[2]); + filter = (task: Task) => task.path.includes(pathMatch[2]); } else if (pathMatch[1] === 'does not include') { - filter = (task: Task) => - !task.path.includes(pathMatch[2]); + filter = (task: Task) => !task.path.includes(pathMatch[2]); } else { console.error('Tasks: do not understand path query: ' + line); return undefined; diff --git a/src/Tasks/Render/Render.ts b/src/Tasks/Render/Render.ts index 03bbdce68c..1915c8b237 100644 --- a/src/Tasks/Render/Render.ts +++ b/src/Tasks/Render/Render.ts @@ -5,25 +5,28 @@ import { CLASS_ITEM, Task } from '../Task'; import { Cache } from '../Cache'; import { Query } from './Query'; import { Transclusion } from './Transclusion'; -import { Checkbox } from './Checkbox'; -import { ListItem } from './ListItem'; +import { TaskItem } from './TaskItem'; export class Render { private readonly cache: Cache; + private readonly taskItem: TaskItem; private readonly obsidian: Obsidian; public constructor({ cache, + taskItem, obsidian, }: { cache: Cache; + taskItem: TaskItem; obsidian: Obsidian; }) { this.cache = cache; + this.taskItem = taskItem; this.obsidian = obsidian; this.obsidian.registerMarkdownPostProcessor( - this.renderCheckBoxes.bind(this), + this.renderInLineTasks.bind(this), ); this.obsidian.registerCodeBlockPostProcessor( @@ -31,7 +34,7 @@ export class Render { ); } - private renderCheckBoxes( + private renderInLineTasks( element: HTMLElement, context: MarkdownPostProcessorContext, ) { @@ -52,8 +55,7 @@ export class Render { return; } - ListItem.addAttributes({ listItem, task }); - Checkbox.prependTo({ listItem, taskStatus: task.status }); + this.taskItem.processListItem({ listItem, task }); }); } @@ -67,6 +69,7 @@ export class Render { context.addChild( new Transclusion({ cache: this.cache, + taskItem: this.taskItem, container: element, filters: query.filters, }), diff --git a/src/Tasks/Render/TaskItem.ts b/src/Tasks/Render/TaskItem.ts new file mode 100644 index 0000000000..1c99b8b127 --- /dev/null +++ b/src/Tasks/Render/TaskItem.ts @@ -0,0 +1,186 @@ +import stringSimilarity from 'string-similarity'; +import { Obsidian } from '../../Obsidian'; +import { File } from '../File'; +import { Status } from '../Status'; +import { + CLASS_ITEM, + DATA_PAGE_INDEX, + DATA_PATH, + REGEX_TASK, + Task, +} from '../Task'; +import { NodeTypes } from './NodeTypes'; + +export const CLASS_CHECKBOX = 'tasks-checkbox'; + +export class TaskItem { + private readonly file: File; + private readonly obsidian: Obsidian; + + constructor({ file, obsidian }: { file: File; obsidian: Obsidian }) { + this.file = file; + this.obsidian = obsidian; + } + + public processListItem({ + listItem, + task, + }: { + listItem: Element; + task: Task; + }): void { + listItem.addClass(CLASS_ITEM); + listItem.setAttribute(DATA_PATH, task.path); + if (task.pageIndex !== undefined) { + listItem.setAttribute(DATA_PAGE_INDEX, task.pageIndex.toString()); + } + + for (let i = 0; i < listItem.childNodes.length; i = i + 1) { + const childNode = listItem.childNodes[i]; + if ( + // Prepend to the first text child in the list item. + childNode.nodeType == NodeTypes.TEXT && + childNode.textContent !== null + ) { + childNode.textContent = this.removeStatusIfPresent( + childNode.textContent, + ); + + const checkbox: HTMLInputElement = document.createElement( + 'INPUT', + ) as HTMLInputElement; + checkbox.type = 'checkbox'; + checkbox.addClass(CLASS_CHECKBOX); + if (task.status !== Status.TODO) { + checkbox.checked = true; + } + + checkbox.addEventListener('click', async (event) => { + this.handleCheckboxClick({ event, listItem }); + }); + + listItem.prepend(checkbox); + break; // Break loop as we only need one checkbox. + } + } + } + + private async handleCheckboxClick({ + event, + listItem, + }: { + event: UIEvent; + listItem: Element; + }): Promise { + event.preventDefault(); + + const path = listItem.getAttribute(DATA_PATH); + if (path === null) { + return; + } + + const fileLines = await this.obsidian.readLines({ path }); + if (fileLines === undefined) { + return; + } + + let lineNumber: number | undefined; + // Use the page index if it is available. It is more accurate and + // should be faster than calculating the string similarity. + const pageIndex = listItem.getAttribute(DATA_PAGE_INDEX); + if (pageIndex) { + lineNumber = this.getLineNumberBasedOnPageIndex({ + pageIndex, + fileLines, + }); + } else { + lineNumber = this.getMostSimilarLineNumber({ + listItem, + fileLines, + }); + } + + if (lineNumber !== undefined) { + this.file.toggleDone({ + path, + lineNumber, + }); + } + } + + private removeStatusIfPresent(text: string): string { + const existingStatusRegex = /^(TODO|DONE) (.*)/u; + const existingStatusMatch = text.match(existingStatusRegex); + if (existingStatusMatch !== null) { + text = existingStatusMatch[2]; + } + + return text; + } + + private getLineNumberBasedOnPageIndex({ + pageIndex, + fileLines, + }: { + pageIndex: string; + fileLines: string[]; + }): number | undefined { + let currentIndex = 0; + for ( + let currentLine = 0; + currentLine < fileLines.length; + currentLine = currentLine + 1 + ) { + if (REGEX_TASK.test(fileLines[currentLine])) { + if (currentIndex.toString() === pageIndex) { + return currentLine; + } + currentIndex = currentIndex + 1; + } + } + + return undefined; + } + + private getMostSimilarLineNumber({ + listItem, + fileLines, + }: { + listItem: Element; + fileLines: string[]; + }): number | undefined { + if (listItem.textContent === null) { + console.error( + "Tasks: cannot toggle task: li's `textContent` is `null`", + ); + + return undefined; + } + const liText = listItem.textContent.split('\n')[0]; + + let maxSimilarity = 0; + let mostSimilarLine: number | undefined = undefined; + for ( + let currentLine = 0; + currentLine < fileLines.length; + currentLine = currentLine + 1 + ) { + const match = fileLines[currentLine].match(REGEX_TASK); + if (match !== null) { + // The LI only knows the text part after the list identifier + // and the status. + const taskText = match[3] + match[4]; + const similarity = stringSimilarity.compareTwoStrings( + taskText, + liText, + ); + if (similarity > maxSimilarity) { + maxSimilarity = similarity; + mostSimilarLine = currentLine; + } + } + } + + return mostSimilarLine; + } +} diff --git a/src/Tasks/Render/Transclusion.ts b/src/Tasks/Render/Transclusion.ts index 5a397861ba..16eb2f71dc 100644 --- a/src/Tasks/Render/Transclusion.ts +++ b/src/Tasks/Render/Transclusion.ts @@ -1,12 +1,13 @@ -import { MarkdownRenderChild } from 'obsidian'; +import { Component, MarkdownRenderChild, MarkdownRenderer } from 'obsidian'; import { Task } from '../Task'; import { Cache } from '../Cache'; +import { TaskItem } from './TaskItem'; import { Sort } from './Sort'; -import { ListItem } from './ListItem'; export class Transclusion extends MarkdownRenderChild { private readonly cache: Cache; private readonly container: HTMLElement; + private readonly taskItem: TaskItem; private readonly filters: ((task: Task) => boolean)[]; // private cacheCallbackId: number | undefined; @@ -14,16 +15,19 @@ export class Transclusion extends MarkdownRenderChild { constructor({ cache, container, + taskItem, filters, }: { cache: Cache; container: HTMLElement; + taskItem: TaskItem; filters: ((task: Task) => boolean)[]; }) { super(); this.cache = cache; this.container = container; + this.taskItem = taskItem; this.filters = filters; } @@ -58,7 +62,7 @@ export class Transclusion extends MarkdownRenderChild { fileName = fileNameMatch[1]; } - const listItem = await ListItem.fromTask({ + const listItem = await this.listItemFromTask({ task, parentComponent: this, }); @@ -94,4 +98,32 @@ export class Transclusion extends MarkdownRenderChild { this.container.firstChild?.replaceWith(allTaskLists); } + + private async listItemFromTask({ + task, + parentComponent, + }: { + task: Task; + parentComponent: Component; + }): Promise { + const listItem = document.createElement('li'); + + await MarkdownRenderer.renderMarkdown( + task.toLiString(), + listItem, + task.path, + parentComponent, + ); + + // Unwrap the p-tag that was created by the MarkdownRenderer: + const pElement = listItem.querySelector('p'); + if (pElement !== null) { + listItem.innerHTML = pElement.innerHTML; + pElement.remove(); + } + + this.taskItem.processListItem({ listItem, task }); + + return listItem; + } } diff --git a/src/Tasks/Render/index.ts b/src/Tasks/Render/index.ts index 2cb3100cb7..3d8dee96a0 100644 --- a/src/Tasks/Render/index.ts +++ b/src/Tasks/Render/index.ts @@ -1,2 +1,2 @@ -export { CLASS_CHECKBOX, Checkbox } from './Checkbox'; +export { TaskItem } from './TaskItem'; export { Render } from './Render'; diff --git a/src/Tasks/index.ts b/src/Tasks/index.ts index 312cbad028..993da91d4f 100644 --- a/src/Tasks/index.ts +++ b/src/Tasks/index.ts @@ -1,7 +1,5 @@ export { Cache } from './Cache'; export { Commands } from './Commands'; -export { Events } from './Events'; export { File } from './File'; -export { Render } from './Render'; +export { Render, TaskItem } from './Render'; export { Settings } from './Settings'; -export { Task } from './Task'; diff --git a/src/main.ts b/src/main.ts index 1d8cad8b40..265cd5a3f4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import { Plugin } from 'obsidian'; import { Obsidian } from './Obsidian'; -import { Cache, Commands, Events, File, Render, Settings } from './Tasks'; +import { Cache, Commands, File, Render, Settings, TaskItem } from './Tasks'; const DEFAULT_SETTINGS: Settings = {}; @@ -17,12 +17,9 @@ export default class TasksPlugin extends Plugin { this.obsidian = new Obsidian({ plugin: this }); const cache = new Cache({ obsidian: this.obsidian }); const file = new File({ obsidian: this.obsidian }); - new Events({ - file, - obsidian: this.obsidian, - }); + const taskItem = new TaskItem({ file, obsidian: this.obsidian }); new Commands({ file, obsidian: this.obsidian }); - new Render({ cache, obsidian: this.obsidian }); + new Render({ cache, taskItem, obsidian: this.obsidian }); } onunload() {