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

### Fixed

- Pair a `*`, `_`, or `~` typed against bold, italic, or strikethrough with the matching literal marker already on the other side of it, so closing `_**text**` with a `_` gives italic bold and saves `_**text**_`, instead of leaving both markers as text that saved as `\_**text**\_` and reopened without the italic. A marker a file keeps literal by escaping it stays literal.
- Leave a `*`, `_`, or `~` bare on save wherever nothing else on its line could pair with it, so text such as `[a](b)*` keeps its marker as written instead of collecting a backslash merely because a link, an image, or a bold span shares the line with it. A marker that could still pair, including one that could pair with the markers of a span beside it, keeps its backslash.
- Keep the address of a URL or email address written on its own when a `*`, `_`, or `~` follows it, so text such as `https://example.com*` keeps its link pointing where it did. The backslash the file writes to keep that marker literal was being read back as part of the address, which gained another backslash every time the document was opened and saved.
- Keep a URL or email address written on its own bare when a run shaped like a character reference but naming nothing, such as `&notarealentity;`, follows it, so text such as `https://example.com&notarealentity;` is saved as it was written instead of gaining angle brackets. Markdown leaves such a run outside the link whether or not the name exists.
Expand Down
1 change: 1 addition & 0 deletions docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi
- Inline-code projection uses a valid canonical backtick delimiter run rather than preserving the exact source delimiter length.
- Link and autolink projection exposes their source directly in the document; links preserve their label, target, optional title, and compatible uniform outer inline formatting. An autolink keeps the form it was authored in, bare or angle-bracket, when it is projected and when it is saved. A link remains one semantic projection owner. A caret or contained text selection anywhere in a supported label projects the complete link source, including labels with nested strong, emphasis, strikethrough, inline-code formatting, semantic soft line endings, an image, a footnote reference, or a preserved character reference. An image or footnote reference in a projected label becomes its own Markdown source and returns as its object when the label commits. Soft line endings remain one logical label; indentation follows Leafdown's canonical serialization. Valid edits rehydrate one link over the complete rich label; invalid or incomplete edits become the literal text the source spells, where a backslash escapes the character it precedes and is otherwise kept as text. Mixed-format and multiline labels do not fall back to fragmented projections for their nested content.
- Text written in the current editing session that is exactly the source of one inline link, image, autolink literal, or URI autolink becomes that object when the caret leaves it, including when a line break ends the line it sits on, so typing and plain-text paste of the same characters reach the same document. The caret has not left while only source characters separate them, which keeps a bare URL whole as it is typed. Backslash-escaped source, incomplete source, and text that is not exactly one object's source stay literal. Source the file escaped is literal text the author asked for, so editing it leaves it literal and only the escape gesture below converts it, and `Undo` returns a committed link to the source it was written as. Because a committed link projects its source again whenever the caret returns, the visible text does not change.
- A `*`, `_`, or `~` typed against a marked run pairs with a matching literal run of the same length on the other side of that run, so the two become the emphasis, strong emphasis, or strikethrough the line spells and neither delimiter is left as content. The pair forms only where the outer neighbours let both runs flank their content, which leaves a run sitting after a letter or a digit literal, and only where the span does not already carry the mark the pair would apply. Pairing answers the character typed in this session rather than the shape the document holds, so a file that keeps a delimiter on each side of a run literal by escaping it opens that way and stays that way.
- Literal text the file keeps literal by escaping projects that escape when a caret or contained text selection reaches it, so the run reads as the file will write it. The gesture covers a run that spells exactly one inline link or image, which is what an escape there can be spent on; text kept literal in any other form shows no escape, because deleting one would change nothing. Deleting a projected backslash converts the run to the object it describes at once, without waiting for the caret to leave; that object then projects its own source in the escape's place, so the characters stay put and the caret keeps its position while the markers restyle around it. `Undo` reverses the conversion like any other document change, and typing the backslash back turns the object literal again. Leaving the escape alone restores the run unchanged, and an edit that neither keeps the escape nor spells one object becomes the literal text the source spells. Nothing durable records the escape; it is derived from the text and the serializer on every open, so the same gesture is available after a save and reopen. Source this session wrote keeps the commit rule above instead, so a run being typed never shows a backslash the author did not write.
- A character reference the file preserves projects its authored source when a caret or contained text selection reaches it, so `©`, `©`, and `©` read as the file will write them rather than as the character they render. References written next to each other are each preserved and each projected on their own, whether they repeat, differ, or name more than one character, so a file holding `©©` keeps both and breaking one leaves its neighbour intact, saving `&copy©`. Entry direction follows the rule for atomic inline projections: a caret entering from the left starts at the beginning of the source, a caret entering from the right starts at the end, a position between two adjacent references enters the one that follows it, and a selection over the rendered character selects the complete source. Leaving the source unchanged restores the rendered character exactly. An edit that still spells one reference commits that reference, so a form can be rewritten in place; any other edit becomes the literal text the source spells, on the same escape rule as a link, which is how removing a character from the source removes the reference from the saved file. Nothing durable records the projection, and a stored source that no longer spells the text it covers is written as that text and projects nothing.
- A character reference within one exact, contiguous supported mark combination belongs to that marked fragment. Entering through its text, the reference itself, or either end of the fragment projects one outer wrapper such as `**a©b**`, and a valid edit commits one mark with the reference intact. Logical links retain higher semantic ownership, so a caret or contained text selection anywhere in a label holding a reference projects the complete link source, while standalone or otherwise ineligible references use the reference-only projection above.
Expand Down
148 changes: 148 additions & 0 deletions src/features/editor/plugins/attentionPairing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// @vitest-environment happy-dom

import { describe, expect, it } from "vitest";

import { type MountedMilkdownEditor, setupMilkdownEditorMount } from "@/test/utils/milkdown";
import { getEditorTextPosition, setTextSelection, typeText } from "@/test/utils/prosemirror";

const mountEditor = setupMilkdownEditorMount();

const TAIL = "\n\ntail";

const describeDocument = (mounted: MountedMilkdownEditor) => {
const parts: string[] = [];

mounted.view.state.doc.descendants((node) => {
if (node.isText) {
const marks = node.marks.map((mark) => mark.type.name).join("+");
parts.push(`${node.text ?? ""}${marks ? `[${marks}]` : ""}`);
}

return true;
});

return parts.join(" ");
};

const settle = (mounted: MountedMilkdownEditor) => {
setTextSelection(mounted.view, getEditorTextPosition(mounted, "tail") + 2);

return describeDocument(mounted);
};

const typeAtProjectedOffset = (
mounted: MountedMilkdownEditor,
source: string,
offset: number,
text: string,
) => {
setTextSelection(mounted.view, getEditorTextPosition(mounted, "text") + 1);
setTextSelection(mounted.view, getEditorTextPosition(mounted, source) + offset);
typeText(mounted.view, text);
};

describe("pairing a delimiter typed beside a mark", () => {
it.each([
{ source: "_**text**", offset: 9, typed: "_", saved: "_**text**_" },
{ source: "*__text__", offset: 9, typed: "*", saved: "*__text__*" },
{ source: "**text**_", offset: 0, typed: "_", saved: "_**text**_" },
{ source: "__text__*", offset: 0, typed: "*", saved: "*__text__*" },
])("pairs $typed across $source", async ({ offset, saved, source, typed }) => {
const mounted = await mountEditor(`${source}${TAIL}`);

typeAtProjectedOffset(mounted, source, offset, typed);

expect(settle(mounted)).toBe("text[emphasis+strong] tail");
expect(mounted.getMarkdown()).toBe(`${saved}\n\ntail\n`);

const reopened = await mountEditor(mounted.getMarkdown());

expect(describeDocument(reopened)).toBe("text[emphasis+strong] tail");
});

// The serializer writes a mark set in schema order, so the saved nesting is the order the same
// source already takes when the editor opens it from a file rather than the order it was typed
// in. What the pairing owes is the mark set and the document the file reloads as.
it("pairs a two-character run into strong emphasis", async () => {
const mounted = await mountEditor(`__*text*${TAIL}`);

typeAtProjectedOffset(mounted, "__*text*", 8, "_");
typeText(mounted.view, "_");

expect(settle(mounted)).toBe("text[emphasis+strong] tail");
expect(mounted.getMarkdown()).toBe("*__text__*\n\ntail\n");

const reopened = await mountEditor(mounted.getMarkdown());

expect(describeDocument(reopened)).toBe("text[emphasis+strong] tail");
});

it("pairs a tilde run into strikethrough", async () => {
const mounted = await mountEditor(`~~**text**${TAIL}`);

typeAtProjectedOffset(mounted, "~~**text**", 10, "~");
typeText(mounted.view, "~");

expect(settle(mounted)).toBe("text[strong+strike_through] tail");
expect(mounted.getMarkdown()).toBe("**~~text~~**\n\ntail\n");

const reopened = await mountEditor(mounted.getMarkdown());

expect(describeDocument(reopened)).toBe("text[strong+strike_through] tail");
});

it.each([
// A file that escapes both delimiters holds the same three siblings, and nothing was typed
// into them, so they stay literal.
{ name: "an escaped pair a file already holds", source: "\\_**text**\\_" },
{ name: "an escaped pair with the other marker", source: "\\*__text__\\*" },
])("leaves $name literal", async ({ source }) => {
const mounted = await mountEditor(`${source}${TAIL}`);

expect(describeDocument(mounted)).toBe("_ text[strong] _ tail".replace(/_/gu, source[1]));
expect(mounted.getMarkdown()).toBe(`${source}\n\ntail\n`);
});

it("leaves a run that cannot open literal", async () => {
const mounted = await mountEditor(`a_**text**${TAIL}`);

typeAtProjectedOffset(mounted, "a_**text**", 10, "_");

expect(settle(mounted)).toBe("a_ text[strong] _ tail");
expect(mounted.getMarkdown()).toBe("a\\_**text**\\_\n\ntail\n");
});

it("leaves a run that cannot close literal", async () => {
const mounted = await mountEditor(`_**text**a${TAIL}`);

typeAtProjectedOffset(mounted, "_**text**a", 9, "_");

expect(settle(mounted)).toBe("_ text[strong] _a tail");
expect(mounted.getMarkdown()).toBe("\\_**text**\\_a\n\ntail\n");
});

it("leaves runs of unequal length literal", async () => {
const mounted = await mountEditor(`__**text**${TAIL}`);

typeAtProjectedOffset(mounted, "__**text**", 10, "_");

expect(settle(mounted)).toBe("__ text[strong] _ tail");
});

it("leaves a span that already carries the mark alone", async () => {
const mounted = await mountEditor(`_*text*${TAIL}`);

typeAtProjectedOffset(mounted, "_*text*", 7, "_");

expect(settle(mounted)).toBe("_ text[emphasis] _ tail");
});

it("keeps the ordinary input rule working with no mark between", async () => {
const mounted = await mountEditor(TAIL);

setTextSelection(mounted.view, 1);
typeText(mounted.view, "_text_");

expect(settle(mounted)).toBe("text[emphasis] tail");
});
});
Loading