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");