diff --git a/scripts/taskToggleMemory.test.ts b/scripts/taskToggleMemory.test.ts index cbd9bf2..c731731 100644 --- a/scripts/taskToggleMemory.test.ts +++ b/scripts/taskToggleMemory.test.ts @@ -3,12 +3,33 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; const session = readFileSync(new URL('../src/lib/sessions/documentSession.svelte.ts', import.meta.url), 'utf8'); +const markdownProcessing: string = readFileSync(new URL('../src/lib/utils/markdown.ts', import.meta.url), 'utf8'); +const viewer = readFileSync(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url), 'utf8'); test('preview task toggles transform the active in-memory buffer before saving', () => { const taskToggle = session.match(/async function toggleTaskCheckbox[\s\S]*?\n\t}\n\n\treturn/); assert.ok(taskToggle); assert.doesNotMatch(taskToggle[0], /read_file_content/); assert.match(taskToggle[0], /const raw = tab\.rawContent;/); + assert.match(taskToggle[0], /getMarkdownBodyWithoutFrontMatter\(raw\)/); + assert.match(taskToggle[0], /body\.slice\(0, offset\)\.split\('\\n'\)\.length/); + assert.match(taskToggle[0], /(?:\[-\+\*\]|\\d\+\[\.\)\])/); assert.match(taskToggle[0], /tabManager\.updateTabRawContent\(tab\.id, updated\);/); assert.match(taskToggle[0], /await saveContent\(tab\.id\)/); }); + +test('preview task toggles use the renderer source line instead of checkbox order', () => { + const viewerToggle = viewer.match(/async function toggleTaskCheckbox[\s\S]*?\n\t}\n\n/); + assert.ok(viewerToggle); + assert.match(viewerToggle[0], /closest\('li'\)\?\.getAttribute\('data-sourcepos'\)/); + assert.match(viewerToggle[0], /documentSession\.toggleTaskCheckbox\(sourceLine, nowChecked\)/); + assert.doesNotMatch(viewerToggle[0], /allBoxes/); +}); + +test('preview task processing trusts the Markdown renderer task marker', () => { + const taskProcessing = markdownProcessing.match(/function processTaskItems[\s\S]*?\n}\n\nexport function processMarkdownHtml/); + assert.ok(taskProcessing); + assert.match(taskProcessing[0], /input\.hasAttribute\("data-task-checkbox"\)/); + assert.match(taskProcessing[0], /input\.setAttribute\("disabled", ""\)/); + assert.match(markdownProcessing, /input\.removeAttribute\("disabled"\)/); +}); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d786ff9..ec20cee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -198,6 +198,47 @@ mod tests { ); } + #[test] + fn task_list_checkbox_is_emitted_at_the_start_of_its_list_item() { + let html = convert_markdown("- [ ] open task\n- [x] completed task\n"); + assert!( + html.contains("
  • open task
  • "), + "unexpected task-list HTML: {html}", + ); + assert!( + html.contains("
  • completed task
  • "), + "unexpected task-list HTML: {html}", + ); + } + + #[test] + fn raw_html_checkboxes_are_not_marked_as_tasks() { + let html = convert_markdown("- raw control\n"); + assert!( + !html.contains("data-task-checkbox"), + "raw HTML control was incorrectly marked as a task: {html}", + ); + } + + #[test] + fn nested_and_quoted_task_checkboxes_are_marked() { + let html = convert_markdown("- [ ] parent\n - [x] nested\n\n> - [ ] quoted\n"); + assert_eq!( + html.matches("data-task-checkbox").count(), + 3, + "unexpected task-list HTML: {html}", + ); + } + + #[test] + fn multiline_wikilinks_do_not_shift_task_source_positions() { + let html = convert_markdown("[[#first\nsecond|alias]]\n- [ ] task\n"); + assert!( + html.contains("data-task-checkbox"), + "task source position was shifted by a multiline wikilink: {html}", + ); + } + #[test] fn embed_protection_survives_longer_backtick_runs_earlier_in_the_doc() { // A 4-backtick inline sample desynchronized the old regex pairing and @@ -747,7 +788,37 @@ fn convert_markdown(content: &str) -> String { options.render.hardbreaks = true; options.render.sourcepos = true; - markdown_to_html(&processed_links, &options) + let html = markdown_to_html(&processed_links, &options); + annotate_task_checkboxes(html, content) +} + +fn annotate_task_checkboxes(html: String, markdown: &str) -> String { + let task_item = Regex::new( + r#"
  • (?)"#, + ) + .unwrap(); + let task_source = Regex::new(r"^\s*(?:>\s*)*(?:[-+*]|\d+[.)])\s+\[[ xX]\](?:\s|$)").unwrap(); + + task_item + .replace_all(&html, |captures: &Captures| { + let line = captures["line"].parse::().unwrap_or_default(); + let source_line = markdown.lines().nth(line.saturating_sub(1)); + if !source_line.is_some_and(|line| task_source.is_match(line)) { + return captures[0].to_string(); + } + + let input = captures["input"].replacen( + " disabled=\"\"", + " data-task-checkbox=\"\" disabled=\"\"", + 1, + ); + format!( + "
  • {}", + &captures["sourcepos"], + input, + ) + }) + .into_owned() } #[tauri::command] diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index d058cc8..90151f5 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -1535,11 +1535,11 @@ import { createDocumentSession, type LoadMarkdownOptions } from './sessions/docu } async function toggleTaskCheckbox(checkbox: HTMLInputElement) { - const allBoxes = Array.from(markdownBody?.querySelectorAll('[data-task-checkbox]') || []); - const index = allBoxes.indexOf(checkbox); - if (index === -1) return; + const sourcePosition = checkbox.closest('li')?.getAttribute('data-sourcepos'); + const sourceLine = Number(sourcePosition?.match(/^(\d+):/)?.[1]); + if (!Number.isInteger(sourceLine) || sourceLine < 1) return; const nowChecked = !checkbox.checked; - if (!(await documentSession.toggleTaskCheckbox(index, nowChecked))) return; + if (!(await documentSession.toggleTaskCheckbox(sourceLine, nowChecked))) return; checkbox.checked = nowChecked; const li = checkbox.closest('li'); if (li) { diff --git a/src/lib/sessions/documentSession.svelte.ts b/src/lib/sessions/documentSession.svelte.ts index 1802163..7c975d0 100644 --- a/src/lib/sessions/documentSession.svelte.ts +++ b/src/lib/sessions/documentSession.svelte.ts @@ -2,6 +2,7 @@ import { invoke } from '@tauri-apps/api/core'; import { save } from '@tauri-apps/plugin-dialog'; import { settings } from '../stores/settings.svelte.js'; import { tabManager } from '../stores/tabs.svelte.js'; +import { getMarkdownBodyWithoutFrontMatter } from '../utils/frontMatter.js'; import { hasMarkdownLinkExtension } from '../utils/markdownLinks.js'; export type LoadMarkdownOptions = { @@ -209,15 +210,17 @@ export function createDocumentSession(options: DocumentSessionOptions) { } } - async function toggleTaskCheckbox(index: number, nowChecked: boolean) { + async function toggleTaskCheckbox(sourceLine: number, nowChecked: boolean) { const tab = tabManager.activeTab; if (!tab || !tab.path) return false; const raw = tab.rawContent; - let count = 0; - const updated = raw.replace(/^(\s*[-*+] )\[( |x|X)\]/gm, (match, prefix) => { - if (count++ === index) return `${prefix}[${nowChecked ? 'x' : ' '}]`; + const body = getMarkdownBodyWithoutFrontMatter(raw); + const updatedBody = body.replace(/^(\s*(?:>\s*)*(?:[-+*]|\d+[.)])\s+)\[( |x|X)\]/gm, (match, prefix, _state, offset) => { + const line = body.slice(0, offset).split('\n').length; + if (line === sourceLine) return `${prefix}[${nowChecked ? 'x' : ' '}]`; return match; }); + const updated = `${raw.slice(0, raw.length - body.length)}${updatedBody}`; if (updated === raw) return false; tabManager.updateTabRawContent(tab.id, updated); await saveContent(tab.id); diff --git a/src/lib/utils/markdown.ts b/src/lib/utils/markdown.ts index 4b1c61c..5e9b9d5 100644 --- a/src/lib/utils/markdown.ts +++ b/src/lib/utils/markdown.ts @@ -367,43 +367,16 @@ function stripLeadingWhitespace(nodes: Node[]) { } } -function isWhitespaceText(node: Node) { - return node.nodeType === 3 && !node.textContent?.trim(); -} - -function startsWithNode(element: Element, target: Node) { - const firstContentNode = Array.from(element.childNodes).find( - (node) => !isWhitespaceText(node), - ); - return firstContentNode === target; -} - -function isTaskCheckbox(input: Element, li: Element) { - const looksRenderedByTaskList = - input.hasAttribute("data-task-checkbox") || - li.classList.contains("task-list-item"); - if (!looksRenderedByTaskList) return false; - - const inputParent = input.parentElement; - if (inputParent === li) { - return startsWithNode(li, input); - } - - return ( - inputParent?.tagName === "P" && - inputParent.parentElement === li && - startsWithNode(li, inputParent) && - startsWithNode(inputParent, input) - ); -} - function processTaskItems(root: Element) { for (const input of Array.from( root.querySelectorAll('li input[type="checkbox"]'), )) { const li = input.closest("li"); if (!li) continue; - if (!isTaskCheckbox(input, li)) continue; + if (!input.hasAttribute("data-task-checkbox")) { + input.setAttribute("disabled", ""); + continue; + } input.setAttribute("data-task-checkbox", ""); input.removeAttribute("disabled");