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

### Fixed

- Read a backslash typed into an open link or footnote-reference source as the escape it spells, so the run turns into the text it describes and saves with one backslash, instead of keeping the backslash as a character and saving three.
- Keep a link or footnote reference whole when a character is typed at the start of its open Markdown source, instead of turning the whole construct into literal text that saves with escapes.
- Write a backslash on save only where the character it precedes would otherwise be read as Markdown, so text such as `garden_sensor_name` keeps its underscores bare, instead of escaping every character that could be syntax somewhere else.
- Keep a list item that starts with a code block, table, quote, nested list, heading, or thematic break nested in the saved file, instead of writing an empty item and leaving the block outside the list the next time the document is opened.
Expand Down
2 changes: 1 addition & 1 deletion docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@
- Milkdown's canonical model remains the default; projected source is transient and never becomes saved semantic content.
- Active marker characters are ordinary unmarked document text rather than widget content.
- A clean session restores its original target exactly. Projection entry and exit are housekeeping, while user edits remain ordinary editor changes managed through an explicit projection-session history bridge.
- Projection finalizes before serialization. Valid source rehydrates semantic content; invalid source becomes literal text so no projected character is lost.
- Projection finalizes before serialization. Valid source rehydrates semantic content; invalid source becomes the literal text it spells, so no projected character is lost except a backslash that escapes the character after it, which the file writes back.
- Marker presentation remains separate from projection lifecycle.
- Architecture owns projection lifecycle and adapter boundaries; Specification owns supported objects and observable editing behavior.

Expand Down
4 changes: 2 additions & 2 deletions docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,10 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi
- Strong, emphasis, inline code, and strikethrough render visually and expose editable local markers near the caret.
- Seamless source projection for strong, emphasis, strikethrough, inline code, links, autolinks, and footnote references is local to the active inline object. For mark-based content, a caret or text selection activates projection when it is contained within one exact, contiguous combination of supported inline marks. Editing a projected marker can change that object's inline style, but it does not automatically merge adjacent marked runs; broader reshaping is done with an explicit selection or formatting command.
- 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, or a footnote 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 exact literal text. Mixed-format and multiline labels do not fall back to fragmented projections for their nested content.
- 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, or a footnote 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.
- A link wrapped by one exact, contiguous supported mark combination belongs to that marked fragment. Entering from either side of the link projects one outer wrapper holding the link's complete source, such as `**bold [a b](./doc.md) tail**`, and a valid edit commits one mark around the link, its label, and its destination. A mark that stops at the link keeps its own projection, and logical links retain higher semantic ownership, so a caret inside the label still projects the link alone.
- A footnote reference within one exact, contiguous supported mark combination belongs to that marked fragment. Entering through its text, either reference boundary, or the atomic reference projects one outer wrapper such as `**archive note[^archive]**`; the complete compatible mark set applies to both text and reference nodes. Logical links retain higher semantic ownership, while standalone or otherwise ineligible references use the reference-only adapter.
- Standalone footnote references project their complete `[^label]` source as editable document text. A caret entering from the left starts at the beginning of the source, a caret entering from the right starts at the end, and selecting an atomic reference selects its label after projection. Valid edits in either projection rehydrate canonical Milkdown footnote-reference nodes. If a marked wrapper remains valid, incomplete reference-like content remains exact text inside its outer marks; if the outer wrapper becomes invalid, the complete projected source becomes exact unmarked literal text. Editing a reference label does not create, rename, delete, or modify any footnote definition.
- Standalone footnote references project their complete `[^label]` source as editable document text. A caret entering from the left starts at the beginning of the source, a caret entering from the right starts at the end, and selecting an atomic reference selects its label after projection. Valid edits in either projection rehydrate canonical Milkdown footnote-reference nodes, and an invalid edit to a standalone reference becomes the literal text its source spells, on the same escape rule as a link. If a marked wrapper remains valid, incomplete reference-like content remains exact text inside its outer marks; if the outer wrapper becomes invalid, the complete projected source becomes exact unmarked literal text. Editing a reference label does not create, rename, delete, or modify any footnote definition.
- A selection crossing plain text, another exact mark combination, another inline object, or a text-block boundary does not activate projection. When a selection crosses into or out of an active source projection, the projection finalizes and preserves the user's selection range and direction.
- `Enter` and `Shift+Enter` internally finalize active projected source before continuing through the editor's normal line-break behavior in the same keypress. When formatted content moves with the caret, its new inline target immediately enters projection. `Escape` leaves projection active while the caret remains on its target.
- Normal click places the caret in a link; `Mod+click` opens it.
Expand Down
58 changes: 58 additions & 0 deletions src/features/editor/plugins/sourceProjection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1960,6 +1960,64 @@ describe("source projection", () => {
},
);

it.each([
{ markdown: "[a](b) tail", offset: 0, side: "the source start" },
{ markdown: "[a](b) tail", offset: "[a]".length, side: "the destination" },
])("escapes a link with a backslash typed at $side", async ({ markdown, offset }) => {
const mounted = await mountProjectionEditor(markdown);

enterProjection(mounted, "a");

setTextSelection(mounted.view, getEditorTextPosition(mounted, "[a](b)") + offset);
typeText(mounted.view, "\\");
setSelectionAtDocumentEnd(mounted.view);

const authored = await mountProjectionEditor("\\[a](b) tail");

expect(getEditorTextContent(mounted)).toBe("[a](b) tail");
expect(mounted.view.state.doc.toJSON()).toEqual(authored.view.state.doc.toJSON());
expect(mounted.getMarkdown()).toBe("\\[a](b) tail\n");
});

it("escapes a footnote reference with a backslash typed at its source start", async () => {
const mounted = await mountProjectionEditor("text[^a] tail\n\n[^a]: note");

selectFootnoteReference(mounted);

setTextSelection(mounted.view, getEditorTextPosition(mounted, "[^a]"));
typeText(mounted.view, "\\");
setSelectionAtDocumentEnd(mounted.view);

const authored = await mountProjectionEditor("text\\[^a] tail\n\n[^a]: note");

expect(mounted.view.state.doc.toJSON()).toEqual(authored.view.state.doc.toJSON());
expect(mounted.getMarkdown()).toBe("text\\[^a] tail\n\n[^a]: note\n");
});

it("keeps a backslash the author means as text", async () => {
const mounted = await mountProjectionEditor("[a](b) tail");

enterProjection(mounted, "a");

setTextSelection(mounted.view, getEditorTextPosition(mounted, "[a](b)"));
typeText(mounted.view, "\\ ");
setSelectionAtDocumentEnd(mounted.view);

expect(getEditorTextContent(mounted)).toBe("\\ [a](b) tail");
});

it("spells an escaped backslash as one character", async () => {
const mounted = await mountProjectionEditor("[a](b) tail");

enterProjection(mounted, "a");

setTextSelection(mounted.view, getEditorTextPosition(mounted, "[a](b)"));
typeText(mounted.view, "\\\\");
setSelectionAtDocumentEnd(mounted.view);

expect(getEditorTextContent(mounted)).toBe("\\[a](b) tail");
});

it("inserts delimiter-interior text inside the projected content", async () => {
const mounted = await mountProjectionEditor(BOLD_PLAIN_MARKDOWN);

Expand Down
18 changes: 18 additions & 0 deletions src/features/editor/tests/sourceProjectionClipboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,24 @@ describe("source projection clipboard slices", () => {
expect(fragment.textContent).toBe(invalidSource);
});

it("carries the text an escaped link source spells in its editor flavor", async () => {
const source = "[Label](https://example.com)";
const mounted = await mountEditor(source);

enterProjection(mounted, "a");

const sourceStart = getEditorTextPosition(mounted, source);

setTextSelection(mounted.view, sourceStart);
typeText(mounted.view, "\\");
setTextSelection(mounted.view, sourceStart, sourceStart + `\\${source}`.length);

const fragment = parseClipboardHtml(getClipboardHtml(mounted));

expect(fragment.querySelector("a")).not.toBeInTheDocument();
expect(fragment.textContent).toBe(source);
});

it("maps complete atomic references but declines partial labels", async () => {
const source = "[^note]";
const mounted = await mountEditor(`Before${source} after\n\n[^note]: Detail`);
Expand Down
10 changes: 8 additions & 2 deletions src/features/editor/utils/sourceProjectionAdapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,14 @@ const createMarkedContentSlice = (
export const createLiteralSourceProjectionSlice = (state: EditorState, text: string) =>
createTextSlice(state, text);

const ESCAPED_PUNCTUATION_PATTERN = /\\([!-/:-@[-`{-~])/gu;

export const decodeSourceProjectionEscapes = (source: string) =>
source.replace(ESCAPED_PUNCTUATION_PATTERN, "$1");

export const mapLiteralSourceOffsetToDocument = (source: string, offset: number) =>
decodeSourceProjectionEscapes(source.slice(0, offset)).length;

export const applyLiteralSourceProjectionEdit = (
source: string,
{ from, text, to }: SourceProjectionEdit,
Expand All @@ -211,8 +219,6 @@ export const applyLiteralSourceProjectionEdit = (
};
};

// Only the opening delimiter is guarded; what an edit at the closing delimiter should do is a
// separate open question.
export const shouldHandleInlineObjectTextInput = (
_source: string,
{ from, text, to }: SourceProjectionEdit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type { Parser, Serializer } from "@milkdown/kit/transformer";

import {
createLiteralSourceProjectionSlice,
decodeSourceProjectionEscapes,
mapLiteralSourceOffsetToDocument,
shouldHandleInlineObjectTextInput,
type SourceProjectionAdapter,
type SourceProjectionParseResult,
Expand Down Expand Up @@ -119,7 +121,7 @@ const mapLiteralSelectionPositionFromSource = (
return session.from + result.replacementSize + (position - session.to);
}

return position;
return session.from + mapLiteralSourceOffsetToDocument(result.source, position - session.from);
};

const mapAtomicSelectionPositionFromSource = (
Expand Down Expand Up @@ -252,17 +254,21 @@ export const createFootnoteReferenceSourceProjectionAdapter = ({
parseSource: (state, source, { ambientMarks }) => {
const reference = parseFootnoteReferenceSource(parser, source);

return reference
? {
replacement: new Slice(Fragment.from(reference.mark(ambientMarks)), 0, 0),
replacementSize: reference.nodeSize,
source,
}
: {
replacement: createMarkedLiteralSlice(state, source, ambientMarks),
replacementSize: source.length,
source,
};
if (reference) {
return {
replacement: new Slice(Fragment.from(reference.mark(ambientMarks)), 0, 0),
replacementSize: reference.nodeSize,
source,
};
}

const literal = decodeSourceProjectionEscapes(source);

return {
replacement: createMarkedLiteralSlice(state, literal, ambientMarks),
replacementSize: literal.length,
source,
};
},
restoreCleanTarget: (state, session) =>
state.tr.replace(session.from, session.to, session.target.originalContent),
Expand Down
44 changes: 28 additions & 16 deletions src/features/editor/utils/sourceProjectionLinkAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ import { serializeLinkRunSource } from "./logicalLinkMarkdown";
import { getCandidateMarksAtSelection, getMarkRangeAtSelection } from "./marks";
import {
createLiteralSourceProjectionSlice,
decodeSourceProjectionEscapes,
mapLiteralSourceOffsetToDocument,
shouldHandleInlineObjectTextInput,
type SourceProjectionAdapter,
type SourceProjectionParseResult,
type SourceProjectionPresentationSpan,
type SourceProjectionSessionRange,
type SourceProjectionTarget,
Expand Down Expand Up @@ -479,20 +482,25 @@ const mapSelectionPositionToSource = (
const mapSelectionPositionFromSource = (
position: number,
session: SourceProjectionSessionRange,
source: string,
result: SourceProjectionParseResult,
map: LinkSourceMap | null,
) => {
if (position <= session.from) {
return position;
}

if (position >= session.to) {
return session.from + (map?.documentSize ?? source.length) + (position - session.to);
return session.from + result.replacementSize + (position - session.to);
}

const sourceOffset = position - session.from;

return session.from + (map ? mapLinkSourcePositionToDocument(sourceOffset, map) : sourceOffset);
return (
session.from +
(map
? mapLinkSourcePositionToDocument(sourceOffset, map)
: mapLiteralSourceOffsetToDocument(result.source, sourceOffset))
);
};

const isLinkSelectionSemantic = (
Expand Down Expand Up @@ -609,8 +617,8 @@ export const createLinkSourceProjectionAdapter = ({
const map = createLinkSourceMap(remark, result.source);

return {
anchor: mapSelectionPositionFromSource(selection.anchor, session, result.source, map),
head: mapSelectionPositionFromSource(selection.head, session, result.source, map),
anchor: mapSelectionPositionFromSource(selection.anchor, session, result, map),
head: mapSelectionPositionFromSource(selection.head, session, result, map),
};
},
mapSelectionToSource: (selection, linkTarget) => {
Expand All @@ -626,17 +634,21 @@ export const createLinkSourceProjectionAdapter = ({
parseSource: (state, source, { ambientMarks }) => {
const parsed = parseLinkSource(state, source, parser, remark, ambientMarks);

return parsed
? {
replacement: parsed.replacement,
replacementSize: parsed.map.documentSize,
source,
}
: {
replacement: createLiteralSourceProjectionSlice(state, source),
replacementSize: source.length,
source,
};
if (parsed) {
return {
replacement: parsed.replacement,
replacementSize: parsed.map.documentSize,
source,
};
}

const literal = decodeSourceProjectionEscapes(source);

return {
replacement: createLiteralSourceProjectionSlice(state, literal),
replacementSize: literal.length,
source,
};
},
restoreCleanTarget: createRestoreCleanLinkTransaction,
serializeInlineSource: (state, fragment) =>
Expand Down