Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `[<tab>]` 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.
Expand Down
1 change: 1 addition & 0 deletions docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 10 additions & 2 deletions src/features/editor/commands/formatting/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
20 changes: 20 additions & 0 deletions src/features/editor/plugins/listForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
Expand Down Expand Up @@ -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<string, unknown>;

authored[LIST_ITEM_PADDING_ATTRIBUTE_NAME] = form.padding;
Expand All @@ -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<string, unknown>)[LIST_MARKER_ATTRIBUTE_NAME] =
Expand Down
92 changes: 92 additions & 0 deletions src/features/editor/tests/markdownCompatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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")',
Expand Down
Loading