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 @@ -30,6 +30,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0

- Keep the form a heading was written in, so `# Level one #` keeps the hashes closing it instead of losing them on the first save, and a heading underlined with `=` or `-` stays underlined instead of being rewritten as `#`. The underline keeps the length it was written at, and the spaces or tabs between an opening `#` run and the heading text are kept too. A heading made in the editor is still written with `#` and nothing closing it, and so is one moved past the levels an underline can carry or written where the underline would be read as part of the paragraph above it.
- Keep that form when a formatting command changes a heading's level, so `Heading 1` through `Heading 6` leave a heading exactly as `Increase heading level` and `Decrease heading level` already did. A heading underlined with `=` stays underlined when `Heading 2` moves it, and `# Level one #` keeps the hash closing it when `Heading 3` moves it, instead of arriving as a rewritten heading a gesture before a save would have written one. A heading turned into a paragraph and made a heading again is a heading the editor made, and is written as one.
- 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.
- 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.
Expand Down
1 change: 1 addition & 0 deletions docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ For editor input and clipboard ownership, see [Architecture](./architecture.md#e
- Toggling the same block command removes the marker when applicable.
- `Increase heading level` moves `Heading 1` toward `Heading 6`; `Decrease heading level` moves `Heading 6` toward `Heading 1`.
- A block command keeps the form the block it changes was authored in wherever that block stays the same construct, so `Heading 1` through `Heading 6` leave a heading's form exactly as `Increase heading level` and `Decrease heading level` do. A block that becomes another construct is written in the default form for what it becomes, and one that becomes a paragraph and a heading again is a heading the editor made.
- `Ordered list` and `Unordered list` convert a list of the other kind on that rule. The marker and the numbers belonged to the list that is gone, so the one they make is written in the default form; each item keeps the padding and the opening line it holds itself, and the list keeps its tightness, which the conversion does not ask about.
- `Clear block formatting` converts the selected blocks, or the current block, to paragraphs when applicable.
- Insert commands add new content after the current block, or after the last selected block when the selection spans multiple blocks.
- The `Image` insert command inserts `![]()` and places the caret inside the parentheses.
Expand Down
19 changes: 18 additions & 1 deletion src/features/editor/commands/formatting/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 { getNodeType, runProseMirrorCommand } from "../../utils/milkdown";

interface NodeRange {
Expand Down Expand Up @@ -225,7 +226,23 @@ const toggleListFormat = (view: EditorView, listNodeName: "bullet_list" | "order
}

if (currentList) {
const tr = view.state.tr.setNodeMarkup(currentList.pos, listType);
const ordered = listNodeName === "ordered_list";
// The list becomes another construct, so it is written in the default form for the one it
// becomes: a bullet list carries no ordered delimiter and an ordered one no bullet. Tightness
// is not a form the conversion replaces, and nothing about it asks for a blank line between
// items, so the list keeps the spread it had.
const tr = view.state.tr.setNodeMarkup(currentList.pos, listType, {
spread: currentList.node.attrs.spread,
});

currentList.node.forEach((item, offset, index) => {
tr.setNodeMarkup(
currentList.pos + 1 + offset,
undefined,
{ ...item.attrs, ...createConvertedListItemAttrs(ordered, index) },
item.marks,
);
});

view.focus();
view.dispatch(tr.scrollIntoView());
Expand Down
86 changes: 86 additions & 0 deletions src/features/editor/tests/markdownCompatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1390,6 +1390,92 @@ describe("List marker form", () => {

expect(mounted.getMarkdown()).toBe("- Paragraph\n - Nested\n");
});

// A list converted to the other kind is a construct that is gone, so the marker and the numbers
// it was authored with go with it and the list it becomes is written in the default form. Each
// row asserts the node the command left behind beside the bytes, because a conversion that never
// happened writes a file the bytes alone cannot tell from one that did.
it.each([
{
command: "format.unorderedList",
node: "bullet_list",
saved: "* One\n* Two\n",
source: "1) One\n1) Two\n",
},
{
command: "format.unorderedList",
node: "bullet_list",
saved: "* Three\n* Eight\n",
source: "3. Three\n8. Eight\n",
},
{
command: "format.orderedList",
node: "ordered_list",
saved: "1. One\n2. Two\n",
source: "+ One\n+ Two\n",
},
// Tightness is not a form the conversion replaces, and nothing about it asks for a blank line
// between the items, so a loose list stays loose and a tight one stays tight.
{
command: "format.unorderedList",
node: "bullet_list",
saved: "* One\n\n* Two\n",
source: "1. One\n\n2. Two\n",
},
{
command: "format.orderedList",
node: "ordered_list",
saved: "1. One\n\n2. Two\n",
source: "+ One\n\n+ Two\n",
},
// The items stay the items they were, so each keeps the form it holds itself.
{
command: "format.unorderedList",
node: "bullet_list",
saved: "* One\n* Two\n",
source: "1) One\n2) Two\n",
},
{
command: "format.orderedList",
node: "ordered_list",
saved: "1. One\n2. Two\n",
source: "- One\n- Two\n",
},
{
command: "format.unorderedList",
node: "bullet_list",
saved: "* [ ] Todo\n* [x] Done\n",
source: "1) [ ] Todo\n2) [x] Done\n",
},
{
command: "format.orderedList",
node: "ordered_list",
saved: "1.\n Content\n",
source: "-\n Content\n",
},
] satisfies { command: EditorCommandId; node: string; saved: string; source: string }[])(
"writes $source converted to a $node in the default form for it",
async ({ command, node, saved, source }) => {
const mounted = await mountEditor(source);

await runEditorCommand(mounted.editor, command);

expect(mounted.view.state.doc.firstChild?.type.name).toBe(node);
expect(mounted.getMarkdown()).toBe(saved);
},
);

// The numbers belonged to the ordered list the first conversion left behind, so the second one
// makes a list the editor made rather than bringing that one back.
it("writes the default numbers for a list converted away from ordered and back", async () => {
const mounted = await mountEditor("3. Three\n8. Eight\n");

await runEditorCommand(mounted.editor, "format.unorderedList");
await runEditorCommand(mounted.editor, "format.orderedList");

expect(mounted.view.state.doc.firstChild?.type.name).toBe("ordered_list");
expect(mounted.getMarkdown()).toBe("1. Three\n2. Eight\n");
});
});

describe("Link and image title form", () => {
Expand Down
12 changes: 12 additions & 0 deletions src/features/editor/utils/listMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,18 @@ const readListItemLabel = (source: object) => {
: { label: BULLET_LIST_ITEM_LABEL, listType: BULLET_LIST_ITEM_TYPE };
};

// The preset reads that pair back from the document as well: a bullet list whose first item still
// reads `ordered` is turned back into an ordered list and rebuilt from its spread alone, which is
// where the list's authored marker and its tightness go. A command converting a list to the other
// kind writes the pair for the list the items end up in, and drops the number each item was
// authored with along with the ordered list that held it.
export const createConvertedListItemAttrs = (ordered: boolean, index: number) => ({
...(ordered
? { label: `${index + 1}.`, listType: ORDERED_LIST_ITEM_TYPE }
: { label: BULLET_LIST_ITEM_LABEL, listType: BULLET_LIST_ITEM_TYPE }),
[LIST_ITEM_NUMBER_ATTRIBUTE_NAME]: null,
});

const findListItemPadding = (afterMarker: string) =>
LIST_ITEM_PADDING_PATTERN.exec(afterMarker)?.[0].length ?? DEFAULT_LIST_ITEM_PADDING;

Expand Down