diff --git a/CHANGELOG.md b/CHANGELOG.md index b226d91..5c96abb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - Remove a fenced code block with `Code block` whatever language it names. The command deleted the language instead of removing the block, so a block opened with ` ```ts ` came back opened by a bare fence and the language was gone from the file on the next save, with nowhere to type it back. Running the same command over a selection that holds a paragraph beside a code block no longer clears that block's language either. - Turn an ordered list into a bullet list when `Unordered list` asks for one. The command reported success and left the list ordered, replacing the `)` it was written with by `.` and adding a blank line between every item on the way. Both list commands now leave the spaces between a marker and its content, and the blank lines between items, exactly as they were, and write the default marker for the list they make, because a bullet list has no ordered delimiter to carry and an ordered list no bullet. - Keep the bullet a list was written with, so a file authored with `-` no longer comes back with its lists rewritten into a mixture of `*` and `-` that follows the order the lists appear in, and a `+` list stays a `+` list. An ordered list keeps its own delimiter and the numbers its items were written with, so `3.` followed by `8.` is no longer renumbered to `3.` and `4.`, and `4)` no longer becomes `4.`. The spaces between a marker and its content are kept too, along with an item whose content was written on the line after its marker. A list made in the editor is still written with `*`, or `.` when it is ordered, and two lists that meet with the same marker are still written apart, because Markdown reads them back as one list. +- Keep the marker a task list item was written with, so `- [X] done` keeps its uppercase `X` and an item written with a tab between its brackets keeps that tab, instead of both being rewritten on the first save. Checking or unchecking an item still writes `[x]` or `[ ]`, because the marker spells the state the item is leaving, and an item that stops being a task item and is made one again is written with the default marker as well. - Show a table written with a header row and no body rows as the table it is, instead of adding an empty row beneath it that holds no cells and takes no text. - Keep the outer pipes a table's rows were written with, so a table authored without them stays that way instead of gaining one on both sides of every row on the first save. A table inserted from the editor is still written with both, and so is one whose own form would no longer be read back as the table it is. - Keep the Markdown an image's description was written with, so `![Alt with *emphasis*](leaf.svg)` keeps its emphasis and `![Outer ![inner](inner.svg)](leaf.svg)` keeps the image inside it, instead of flattening the description to its text on open and losing the inner image's destination from the file on the first save. The image is still named by the text its description spells, and a description edited in the raw image Markdown is written as the text typed there. diff --git a/docs/decisions.md b/docs/decisions.md index 203b008..130626d 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -28,6 +28,7 @@ - A table's cell padding, delimiter row width, and alignment padding are normalized, which is the class [issue #323](https://github.com/Azganoth/leafdown/issues/323) raises and this decision answers. No node owns a layout computed across a column, and a keystroke in any cell invalidates it, which is what separates it from every other form under [issue #251](https://github.com/Azganoth/leafdown/issues/251): those survive an edit to the content they decorate, and a column width is a function of it. `corpus/gfm/tables.md` therefore cannot reach byte identity, and the target is identity for the files whose forms a node can hold. - A construct the editor creates writes a defined default form, and so does one whose authored form a fallback withdraws. That default is fixed rather than read from the document around it, so a list inserted into a file whose lists are all `-` is written `*`, and a heading added to a file of setext headings is written `##`. Each is correct on its own and the file ends up mixed, which is the rewrite this decision exists to prevent arriving one construct at a time instead of all at once on first save. [Issue #360](https://github.com/Azganoth/leafdown/issues/360) narrows the default to the form the document prevails in, leaving it in place where the document names none or disagrees with itself. - A formatting command keeps the authored form of a block that stays the construct it was, and writes the default form for one that becomes another construct. A heading moved to another level keeps the runs the file wrote it with, whichever command moves it; a heading turned into a paragraph and a paragraph turned into a heading each carry nothing, because the form belonged to a construct that is gone. Settled in [issue #359](https://github.com/Azganoth/leafdown/issues/359), where two commands making the same level change disagreed: one rebuilt the block from its level and dropped the form, the other carried the node's attributes through and kept it. Preserving through an edit to a construct is also what the Typora measurement above found, and the alternative delivers the rewrite this decision exists to prevent through an editing gesture instead of through a save. An attribute the command itself names is still the one written, so the rule reaches only what the command leaves alone. +- A form that spells the property an edit changes is not kept across that edit, which is the limit of the rule above. A heading's closing sequence decorates a heading whatever its level, so it survives a level change; a task marker is `x` or `X` for a checked item and a space or a tab for an unchecked one, so it _is_ the state rather than a decoration on it, and checking or unchecking an item leaves it with no state to answer for. Such a form is dropped rather than held for a return to the state it spells, so an item authored `[X]`, unchecked and checked again, is written `[x]`. Typora 1.14.9 was measured on this in September 2026 and writes `[x]` there, while preserving `[X]` and four-space marker padding across a save that changes nothing. It is not evidence for the unchecked pair: it does not read `[]` as a checkbox at all, rendering it as literal text and preserving it as such, where GFM reads a tab and a space alike and [issue #318](https://github.com/Azganoth/leafdown/issues/318) follows GFM. - Two constructs that mean the same thing are written by one rule. A strikethrough wrapping a link keeps its wrapper outside the link because bold and italic already do. - A form is normalized on cost only where both spellings are interchangeable to a reader, and the cost is recorded as such so it can be revisited when it changes. A form nothing can own is not revisitable on those terms: the nesting order of two marks is lost because ProseMirror holds marks as a set. - Output-formatting customization remains deferred, though not because preservation answers it. The two are orthogonal and Typora ships both: preservation settles the form a file already holds, while a preference settles the form a construct is created with, which is the question [issue #360](https://github.com/Azganoth/leafdown/issues/360) proposes to answer from the document instead of from a setting. Until it does, a document whose form the editor holds no record of is written in the serializer's own. diff --git a/docs/specification.md b/docs/specification.md index c292319..27b3359 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -263,6 +263,7 @@ For editor input and clipboard ownership, see [Architecture](./architecture.md#e - A thematic break keeps the character run it was authored with, including the spaces or tabs written between its characters. Indentation before the run and whitespace after it are not part of it and are not written. A break the editor inserts is written as `***`. A break whose authored run would be read back as something else where it lands is written as `***` instead: a run of hyphens directly under a paragraph in a tight list item underlines it, and a run sharing its list item's bullet character joins that bullet into one longer break. - A table keeps the outer pipes its rows were authored with, whether both, one, or neither. A table the editor creates is written with both. A table whose rows disagree keeps the pipe the rows that carry one were written with. A table is written with both pipes instead wherever its own form would not be read back: when the first or last cell of any row is blank, or when its first column is one character wide and carries no alignment marker. Cell padding, delimiter row width, and the padding an alignment marker redistributes are normalized rather than kept, because no part of the table owns a width computed across a column. - A list keeps the marker it was authored with: `-`, `+`, or `*` for a bullet list, and `.` or `)` for an ordered one. An ordered list keeps the numbers its items were written with rather than renumbering them from its start, except for its first item, which is written with the list's own start because that is the number the file is read back with. Each item keeps the one to four spaces written between its marker and its content, and an item whose content was written on the line after its marker keeps that line. A list the editor creates is written with `*`, or `.` when it is ordered, and an item it creates with one space. Two adjacent lists are never written with the same marker, because CommonMark reads them back as one list: a bullet list moves to `*`, or to `-` where `*` is what collided, and an ordered list moves to the other delimiter. An item is written on its marker's line instead wherever its own form would not be read back: where its list follows a paragraph it has to interrupt, which an item opening on a blank line cannot do. +- A task list item keeps the marker it was authored with: `x` or `X` for a checked item, and a space or a tab for an unchecked one. A marker spells one of the two states rather than decorating both, so an item the editor checks or unchecks, by command or by a click on its checkbox, is written with the default marker from then on, `x` or a space, including when it returns to the state it was authored in. An item that stops being a task item is moved the same way, so one made a task item again is written in the default form too. A marker GFM reads as ordinary text, such as `[]`, `[y]`, or one with no whitespace after it, is not a task marker and is written back as the text it is. - A blank paragraph between blocks survives save and reopen. - Save output trims trailing blank lines and writes at most one final line ending, controlled by `Insert final newline on save`. Trailing blank paragraphs go with them. diff --git a/src/features/editor/commands/formatting/blocks.ts b/src/features/editor/commands/formatting/blocks.ts index 2a4b727..fb7ecdd 100644 --- a/src/features/editor/commands/formatting/blocks.ts +++ b/src/features/editor/commands/formatting/blocks.ts @@ -4,7 +4,10 @@ import { liftListItem, sinkListItem, wrapInList } from "@milkdown/kit/prose/sche import type { Command, EditorState } from "@milkdown/kit/prose/state"; import type { EditorView } from "@milkdown/kit/prose/view"; -import { createConvertedListItemAttrs } from "../../utils/listMarkdown"; +import { + createConvertedListItemAttrs, + createTaskStateListItemAttrs, +} from "../../utils/listMarkdown"; import { getNodeType, runProseMirrorCommand } from "../../utils/milkdown"; interface NodeRange { @@ -298,7 +301,12 @@ const updateSelectedTaskState = ( const tr = view.state.tr; for (const { node, pos } of listItems) { - tr.setNodeMarkup(pos, undefined, { ...node.attrs, checked: getChecked(node) }, node.marks); + tr.setNodeMarkup( + pos, + undefined, + { ...node.attrs, ...createTaskStateListItemAttrs(node, getChecked(node)) }, + node.marks, + ); } view.focus(); diff --git a/src/features/editor/plugins/listForm.ts b/src/features/editor/plugins/listForm.ts index 1876703..06b0624 100644 --- a/src/features/editor/plugins/listForm.ts +++ b/src/features/editor/plugins/listForm.ts @@ -5,10 +5,12 @@ import { DEFAULT_BULLET_LIST_MARKER, DEFAULT_ORDERED_LIST_MARKER, findListItemForm, + findTaskMarker, LIST_ITEM_LEADING_BLANK_LINE_ATTRIBUTE_NAME, LIST_ITEM_MARKDOWN_TYPE, LIST_ITEM_NUMBER_ATTRIBUTE_NAME, LIST_ITEM_PADDING_ATTRIBUTE_NAME, + LIST_ITEM_TASK_MARKER_ATTRIBUTE_NAME, LIST_MARKDOWN_TYPE, LIST_MARKER_ATTRIBUTE_NAME, } from "../utils/listMarkdown"; @@ -28,6 +30,18 @@ const readListItemHead = (item: MarkdownNode, source: string) => { : source.slice(start, Math.min(end, start + LIST_ITEM_HEAD_LENGTH)); }; +// A checkbox stands between the item's marker and the content the item opens with, and neither the +// marker nor the padding is long enough to bound it, so it is read off the whole slice standing +// before that content rather than off a head of a fixed length. +const readListItemOpening = (item: MarkdownNode, source: string) => { + const start = item.position?.start.offset; + const contentStart = item.children?.[0]?.position?.start.offset; + + return start === undefined || contentStart === undefined + ? undefined + : source.slice(start, contentStart); +}; + // CommonMark puts an item's content one space past its marker wherever the marker's own line // carries nothing else, so an item whose first block opens on a later line is one that was written // with a blank line after its marker. @@ -58,6 +72,8 @@ const markAuthoredListForm = (list: MarkdownNode, source: string) => { listMarker ??= form.marker; + const opening = readListItemOpening(item, source); + const taskMarker = opening === undefined ? undefined : findTaskMarker(opening); const authored = item as Record; authored[LIST_ITEM_PADDING_ATTRIBUTE_NAME] = form.padding; @@ -66,6 +82,10 @@ const markAuthoredListForm = (list: MarkdownNode, source: string) => { if (form.number !== undefined) { authored[LIST_ITEM_NUMBER_ATTRIBUTE_NAME] = form.number; } + + if (taskMarker !== undefined) { + authored[LIST_ITEM_TASK_MARKER_ATTRIBUTE_NAME] = taskMarker; + } } (list as Record)[LIST_MARKER_ATTRIBUTE_NAME] = diff --git a/src/features/editor/tests/markdownCompatibility.test.ts b/src/features/editor/tests/markdownCompatibility.test.ts index 472a174..7da2e76 100644 --- a/src/features/editor/tests/markdownCompatibility.test.ts +++ b/src/features/editor/tests/markdownCompatibility.test.ts @@ -19,6 +19,7 @@ import { waitFor } from "@/test/utils/react"; import { mockTauriApiCommand } from "@/test/utils/tauriApi"; import { type EditorCommandId, runEditorCommand } from "../commands"; +import { toggleTaskCheckedAt } from "../utils/taskLists"; const mountEditor = setupMilkdownEditorMount(); @@ -1478,6 +1479,97 @@ describe("List marker form", () => { }); }); +describe("Task marker form", () => { + // GFM reads `x` and `X` as the same checked marker and a space and a tab as the same unchecked + // one, so the state survives whichever is written and only the authored spelling is at stake. + // The reopened document is asserted beside the bytes, because a marker written in a form the + // next read does not answer for costs the state rather than the form. + it.each([ + "- [ ] Unchecked", + "- [x] Checked", + "- [X] Checked in uppercase", + "- [\t] Unchecked with a tab", + "- [X] Outer\n - [\t] Nested", + "1) [X] Ordered", + "- [X] After the padding", + "-\n [X] Opening on the line after its marker", + "> - [X] Inside a blockquote", + ])("writes the task marker in %j as it was authored", async (source) => { + const mounted = await mountEditor(`${source}\n`); + const saved = mounted.getMarkdown(); + + expect(saved).toBe(`${source}\n`); + + const reopened = await mountEditor(saved); + + expect(reopened.view.state.doc.toJSON()).toEqual(mounted.view.state.doc.toJSON()); + }); + + // A marker spells one of the two states, so the state the editor moves an item to is one the + // authored marker cannot answer for and the default for it is written. + it.each([ + { name: "unchecks", saved: "- [ ] Task\n", source: "- [X] Task\n" }, + { name: "checks", saved: "- [x] Task\n", source: "- [\t] Task\n" }, + ])("writes the default marker for an item the editor $name", async ({ saved, source }) => { + const mounted = await mountEditor(source); + + await runEditorCommand(mounted.editor, "format.toggleTaskChecked"); + + expect(mounted.getMarkdown()).toBe(saved); + }); + + // The marker did not survive the edit to the state it spells, so it is gone rather than held for + // a return to that state. Measured against Typora 1.14.9, which writes `[x]` here. + it("writes the default marker for an item moved off its state and back", async () => { + const mounted = await mountEditor("- [X] Task\n"); + + await runEditorCommand(mounted.editor, "format.toggleTaskChecked"); + await runEditorCommand(mounted.editor, "format.toggleTaskChecked"); + + expect(mounted.getMarkdown()).toBe("- [x] Task\n"); + }); + + // Clicking the checkbox is the same move made through the rendered item rather than a command. + it("writes the default marker for an item whose checkbox was clicked", async () => { + const mounted = await mountEditor("- [X] Task\n"); + + toggleTaskCheckedAt(mounted.view, getEditorNodePosition(mounted, "list_item")); + + expect(mounted.getMarkdown()).toBe("- [ ] Task\n"); + + toggleTaskCheckedAt(mounted.view, getEditorNodePosition(mounted, "list_item")); + + expect(mounted.getMarkdown()).toBe("- [x] Task\n"); + }); + + // The marker spells a checkbox, so it belongs to a construct that is gone once the item stops + // being a task item, and the item made a task item again is one the editor made. + it("writes the default marker for an item made a task item again", async () => { + const mounted = await mountEditor("- [X] Task\n"); + + await runEditorCommand(mounted.editor, "format.taskList"); + + expect(mounted.getMarkdown()).toBe("- Task\n"); + + await runEditorCommand(mounted.editor, "format.taskList"); + await runEditorCommand(mounted.editor, "format.toggleTaskChecked"); + + expect(mounted.getMarkdown()).toBe("- [x] Task\n"); + }); + + it("writes a task item made in the editor with the default marker", async () => { + const mounted = await mountEditor("Paragraph\n"); + + await runEditorCommand(mounted.editor, "format.taskList"); + + expect(mounted.getMarkdown()).toBe("* [ ] Paragraph\n"); + + await runEditorCommand(mounted.editor, "format.toggleTaskChecked"); + + expect(mounted.getMarkdown()).toBe("* [x] Paragraph\n"); + }); +}); + describe("Link and image title form", () => { it.each([ '[Double quote](garden.md "Garden")', diff --git a/src/features/editor/utils/listMarkdown.ts b/src/features/editor/utils/listMarkdown.ts index 9c409fd..b244536 100644 --- a/src/features/editor/utils/listMarkdown.ts +++ b/src/features/editor/utils/listMarkdown.ts @@ -27,6 +27,7 @@ export const LIST_MARKER_ATTRIBUTE_NAME = "marker"; export const LIST_ITEM_NUMBER_ATTRIBUTE_NAME = "number"; export const LIST_ITEM_PADDING_ATTRIBUTE_NAME = "padding"; export const LIST_ITEM_LEADING_BLANK_LINE_ATTRIBUTE_NAME = "leadingBlankLine"; +export const LIST_ITEM_TASK_MARKER_ATTRIBUTE_NAME = "taskMarker"; const PARAGRAPH_MARKDOWN_TYPE = "paragraph"; const LIST_ITEM_LABEL_ATTRIBUTE_NAME = "label"; @@ -55,6 +56,18 @@ const ALTERNATE_BULLET_LIST_MARKER: BulletListMarker = "-"; // match a marker. const NON_BULLET_RULE_MARKER = "_"; +// GFM reads `x` and `X` as the same checked marker, and a space and a tab as the same unchecked +// one, so each pair spells one state and a marker answers for the state it belongs to. +const CHECKED_TASK_MARKERS = ["x", "X"] as const; +const UNCHECKED_TASK_MARKERS = [" ", "\t"] as const; + +type TaskMarker = (typeof CHECKED_TASK_MARKERS)[number] | (typeof UNCHECKED_TASK_MARKERS)[number]; + +// The marker a task item is written with when it has none of its own: one the editor checked or +// unchecked, and one whose authored marker cannot be recovered. +const DEFAULT_CHECKED_TASK_MARKER: TaskMarker = "x"; +const DEFAULT_UNCHECKED_TASK_MARKER: TaskMarker = " "; + // The spaces between a marker and the content it opens. CommonMark reads one to four of them and // puts the content that many columns past the marker; a fifth space opens indented code inside the // item and leaves the content one space past the marker, which is also where an item beginning on @@ -70,6 +83,9 @@ const ORDERED_LIST_ITEM_PATTERN = /^(\d{1,9})([.)])/u; // five or more spaces belong to indented code, and none at all mean the content opens on a later // line. const LIST_ITEM_PADDING_PATTERN = /^ {1,4}(?=[^\t\n\r ])/u; +// Anchored at the end of what stands before the item's content, which is where GFM leaves the +// checkbox and the whitespace it requires after it. +const TASK_MARKER_PATTERN = /\[([\t xX])\][\t\n\r ]*$/u; export interface AuthoredListItemForm { marker: BulletListMarker | OrderedListMarker; @@ -122,6 +138,30 @@ export const readListItemPadding = (source: object): number => { export const readListItemLeadingBlankLine = (source: object): boolean => readAttribute(source, LIST_ITEM_LEADING_BLANK_LINE_ATTRIBUTE_NAME) === true; +const isTaskMarker = (value: unknown): value is TaskMarker => + CHECKED_TASK_MARKERS.includes(value as never) || UNCHECKED_TASK_MARKERS.includes(value as never); + +const readAuthoredTaskMarker = (source: object): TaskMarker | null => { + const marker = readAttribute(source, LIST_ITEM_TASK_MARKER_ATTRIBUTE_NAME); + + return isTaskMarker(marker) ? marker : null; +}; + +// A marker spells one of the two states, so one disagreeing with the state the item is in now is +// the marker of the state it was moved off. The item is still the task item the file wrote, and +// the state it moved to is the editor's, so the default for that state is written and the authored +// marker is kept for the state it answers for. +const readTaskMarker = (source: object, checked: boolean): TaskMarker => { + const marker = readAuthoredTaskMarker(source); + const markers: readonly TaskMarker[] = checked ? CHECKED_TASK_MARKERS : UNCHECKED_TASK_MARKERS; + + if (marker !== null && markers.includes(marker)) { + return marker; + } + + return checked ? DEFAULT_CHECKED_TASK_MARKER : DEFAULT_UNCHECKED_TASK_MARKER; +}; + // The preset numbers an ordered list's items onto an mdast field of its own before the parse and // keeps the pair up to date from the document afterwards, so they decorate the rendered item rather // than answering for the marker the file is written with. The item is written from the number it @@ -146,6 +186,15 @@ export const createConvertedListItemAttrs = (ordered: boolean, index: number) => [LIST_ITEM_NUMBER_ATTRIBUTE_NAME]: null, }); +// A marker spells the state it was written for rather than decorating both, so an item the editor +// moves to another state carries none: the form did not survive an edit to the very thing it +// decorates. Removing the checkbox is such a move, so an item made a task item again is written in +// the default form as well. +export const createTaskStateListItemAttrs = (item: ProseNode, checked: boolean | null) => + checked === item.attrs.checked + ? { checked } + : { checked, [LIST_ITEM_TASK_MARKER_ATTRIBUTE_NAME]: null }; + const findListItemPadding = (afterMarker: string) => LIST_ITEM_PADDING_PATTERN.exec(afterMarker)?.[0].length ?? DEFAULT_LIST_ITEM_PADDING; @@ -172,6 +221,12 @@ export const findListItemForm = ( }; }; +// GFM consumes the checkbox and the whitespace after it, so an item's own content opens past both +// and the marker is the last thing the slice between the two holds. An item carrying no checkbox +// has nothing but its marker and padding there, which the pattern cannot match. +export const findTaskMarker = (opening: string): TaskMarker | undefined => + TASK_MARKER_PATTERN.exec(opening)?.[1] as TaskMarker | undefined; + // The list item schema requires a leading paragraph, so an item whose source starts with any other // block parses with an empty one filled in ahead of it. Written out it becomes a blank line, and // CommonMark ends the item at the second one. @@ -309,7 +364,7 @@ export const serializeListItem: NonNullable // item opening on any other block cannot carry one. const checkbox = typeof node.checked === "boolean" && node.children[0]?.type === PARAGRAPH_MARKDOWN_TYPE - ? `[${node.checked ? "x" : " "}] ` + ? `[${readTaskMarker(node, node.checked)}] ` : ""; const opening = leadingBlankLine ? `${marker}\n${" ".repeat(size)}` @@ -426,6 +481,10 @@ export const withListItemForm = (schema: NodeSchema): NodeSchema => ({ default: false, validate: "boolean", }, + [LIST_ITEM_TASK_MARKER_ATTRIBUTE_NAME]: { + default: null, + validate: "string|null", + }, }, parseMarkdown: { ...schema.parseMarkdown, @@ -437,6 +496,7 @@ export const withListItemForm = (schema: NodeSchema): NodeSchema => ({ [LIST_ITEM_NUMBER_ATTRIBUTE_NAME]: readListItemNumber(node) ?? null, [LIST_ITEM_PADDING_ATTRIBUTE_NAME]: readListItemPadding(node), [LIST_ITEM_LEADING_BLANK_LINE_ATTRIBUTE_NAME]: readListItemLeadingBlankLine(node), + [LIST_ITEM_TASK_MARKER_ATTRIBUTE_NAME]: readAuthoredTaskMarker(node), }); state.next(node.children); state.closeNode(); @@ -453,6 +513,7 @@ export const withListItemForm = (schema: NodeSchema): NodeSchema => ({ [LIST_ITEM_NUMBER_ATTRIBUTE_NAME]: readListItemNumber(item.attrs) ?? null, [LIST_ITEM_PADDING_ATTRIBUTE_NAME]: readListItemPadding(item.attrs), [LIST_ITEM_LEADING_BLANK_LINE_ATTRIBUTE_NAME]: readListItemLeadingBlankLine(item.attrs), + [LIST_ITEM_TASK_MARKER_ATTRIBUTE_NAME]: readAuthoredTaskMarker(item.attrs), }); state.next(item.content); state.closeNode(); diff --git a/src/features/editor/utils/taskLists.ts b/src/features/editor/utils/taskLists.ts index b908ac3..50d3f5e 100644 --- a/src/features/editor/utils/taskLists.ts +++ b/src/features/editor/utils/taskLists.ts @@ -1,5 +1,7 @@ import type { EditorView } from "@milkdown/kit/prose/view"; +import { createTaskStateListItemAttrs } from "./listMarkdown"; + export const toggleTaskCheckedAt = (view: EditorView, pos: number) => { const node = view.state.doc.nodeAt(pos); @@ -10,7 +12,7 @@ export const toggleTaskCheckedAt = (view: EditorView, pos: number) => { const tr = view.state.tr.setNodeMarkup( pos, undefined, - { ...node.attrs, checked: !node.attrs.checked }, + { ...node.attrs, ...createTaskStateListItemAttrs(node, !node.attrs.checked) }, node.marks, );