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

- 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.
- Keep typed link and autolink source literal in the saved file when a space follows it, instead of writing it as live Markdown that turns into a link the next time the document is opened.
Expand Down
75 changes: 75 additions & 0 deletions src/features/editor/plugins/sourceProjection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1885,6 +1885,81 @@ describe("source projection", () => {
expect(mounted.getMarkdown()).toBe(`\\${marker}${BOLD_PLAIN_MARKDOWN}\n`);
});

it("keeps text typed at a link's opening delimiter outside its source", async () => {
const mounted = await mountProjectionEditor("[a](b) tail");

enterProjection(mounted, "a");

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

expect(hasActiveSourceProjection(mounted.view.state)).toBe(true);
expect(getEditorTextContent(mounted)).toBe("x[a](b) tail");

setSelectionAtDocumentEnd(mounted.view);

expect(mounted.view.dom.querySelector("a")).toBeInTheDocument();
expect(mounted.getMarkdown()).toBe("x[a](b) tail\n");
});

it("applies a space typed at a link's opening delimiter", async () => {
const mounted = await mountProjectionEditor("see [a](b) tail");

enterProjection(mounted, "a");

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

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

setSelectionAtDocumentEnd(mounted.view);

expect(mounted.view.dom.querySelector("a")).toBeInTheDocument();
expect(getEditorTextContent(mounted)).toBe("see a tail");
});

it("keeps text typed at a footnote reference's opening delimiter outside its source", async () => {
const mounted = await mountProjectionEditor("text[^a] tail\n\n[^a]: note");

selectFootnoteReference(mounted);

expect(hasActiveSourceProjection(mounted.view.state)).toBe(true);

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

setSelectionAtDocumentEnd(mounted.view);

expect(mounted.getMarkdown()).toBe("textx[^a] tail\n\n[^a]: note\n");
});

it.each([
{ markdown: "[a](b) tail", name: "link", source: "[a](b)", tagName: "a" },
{
markdown: "text[^a] tail\n\n[^a]: note",
name: "footnote reference",
source: "[^a]",
tagName: "sup",
},
])(
"commits a $name as literal text when a backslash opens its source",
async ({ markdown, source, tagName }) => {
const mounted = await mountProjectionEditor(markdown);

if (tagName === "a") {
enterProjection(mounted, "a");
} else {
selectFootnoteReference(mounted);
}

setTextSelection(mounted.view, getEditorTextPosition(mounted, source));
typeText(mounted.view, "\\");
setSelectionAtDocumentEnd(mounted.view);

expect(mounted.view.dom.querySelector(tagName)).not.toBeInTheDocument();
},
);

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

Expand Down
7 changes: 7 additions & 0 deletions src/features/editor/utils/sourceProjectionAdapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,13 @@ 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,
) => !(from === to && from === 0 && text.length > 0 && text !== "\\");

const createMarkSourceProjectionTarget = (
state: EditorState,
range: ActiveProjectionRange,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { Parser, Serializer } from "@milkdown/kit/transformer";

import {
createLiteralSourceProjectionSlice,
shouldHandleInlineObjectTextInput,
type SourceProjectionAdapter,
type SourceProjectionParseResult,
type SourceProjectionSessionRange,
Expand Down Expand Up @@ -265,4 +266,5 @@ export const createFootnoteReferenceSourceProjectionAdapter = ({
},
restoreCleanTarget: (state, session) =>
state.tr.replace(session.from, session.to, session.target.originalContent),
shouldHandleTextInput: shouldHandleInlineObjectTextInput,
});
2 changes: 2 additions & 0 deletions src/features/editor/utils/sourceProjectionLinkAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { serializeLinkRunSource } from "./logicalLinkMarkdown";
import { getCandidateMarksAtSelection, getMarkRangeAtSelection } from "./marks";
import {
createLiteralSourceProjectionSlice,
shouldHandleInlineObjectTextInput,
type SourceProjectionAdapter,
type SourceProjectionPresentationSpan,
type SourceProjectionSessionRange,
Expand Down Expand Up @@ -640,4 +641,5 @@ export const createLinkSourceProjectionAdapter = ({
restoreCleanTarget: createRestoreCleanLinkTransaction,
serializeInlineSource: (state, fragment) =>
serializeInlineLinkSource(state, serializer, fragment),
shouldHandleTextInput: shouldHandleInlineObjectTextInput,
});