From 19df9db9ccfb7fc4723f141c90aa984feed53eb8 Mon Sep 17 00:00:00 2001 From: Martin Schenck Date: Wed, 14 Apr 2021 21:02:15 +0200 Subject: [PATCH] Tasks now works on iOS The way that the LI was taken from the path of the event did not work on mobile. Instead, the list item is now given in an arrow function of a click handler on the checkbox. This also means there is no longer the global click event listener. Overall, some due clean-up happened. Fixes #22 --- src/Obsidian.ts | 22 ++-- src/Tasks/Events.ts | 126 --------------------- src/Tasks/Render/Checkbox.ts | 47 -------- src/Tasks/Render/ListItem.ts | 48 -------- src/Tasks/Render/Query.ts | 8 +- src/Tasks/Render/Render.ts | 15 ++- src/Tasks/Render/TaskItem.ts | 186 +++++++++++++++++++++++++++++++ src/Tasks/Render/Transclusion.ts | 38 ++++++- src/Tasks/Render/index.ts | 2 +- src/Tasks/index.ts | 4 +- src/main.ts | 9 +- 11 files changed, 247 insertions(+), 258 deletions(-) delete mode 100644 src/Tasks/Events.ts delete mode 100644 src/Tasks/Render/Checkbox.ts delete mode 100644 src/Tasks/Render/ListItem.ts create mode 100644 src/Tasks/Render/TaskItem.ts 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() {