diff --git a/CHANGELOG.md b/CHANGELOG.md index 268863a..dd96164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/features/editor/plugins/sourceProjection.test.tsx b/src/features/editor/plugins/sourceProjection.test.tsx index 6a7d25d..654b908 100644 --- a/src/features/editor/plugins/sourceProjection.test.tsx +++ b/src/features/editor/plugins/sourceProjection.test.tsx @@ -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); diff --git a/src/features/editor/utils/sourceProjectionAdapters.ts b/src/features/editor/utils/sourceProjectionAdapters.ts index 097f236..493c3cd 100644 --- a/src/features/editor/utils/sourceProjectionAdapters.ts +++ b/src/features/editor/utils/sourceProjectionAdapters.ts @@ -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, diff --git a/src/features/editor/utils/sourceProjectionFootnoteReferenceAdapter.ts b/src/features/editor/utils/sourceProjectionFootnoteReferenceAdapter.ts index 1bf2b4d..46a8236 100644 --- a/src/features/editor/utils/sourceProjectionFootnoteReferenceAdapter.ts +++ b/src/features/editor/utils/sourceProjectionFootnoteReferenceAdapter.ts @@ -10,6 +10,7 @@ import type { Parser, Serializer } from "@milkdown/kit/transformer"; import { createLiteralSourceProjectionSlice, + shouldHandleInlineObjectTextInput, type SourceProjectionAdapter, type SourceProjectionParseResult, type SourceProjectionSessionRange, @@ -265,4 +266,5 @@ export const createFootnoteReferenceSourceProjectionAdapter = ({ }, restoreCleanTarget: (state, session) => state.tr.replace(session.from, session.to, session.target.originalContent), + shouldHandleTextInput: shouldHandleInlineObjectTextInput, }); diff --git a/src/features/editor/utils/sourceProjectionLinkAdapter.ts b/src/features/editor/utils/sourceProjectionLinkAdapter.ts index 2cfce28..9772659 100644 --- a/src/features/editor/utils/sourceProjectionLinkAdapter.ts +++ b/src/features/editor/utils/sourceProjectionLinkAdapter.ts @@ -9,6 +9,7 @@ import { serializeLinkRunSource } from "./logicalLinkMarkdown"; import { getCandidateMarksAtSelection, getMarkRangeAtSelection } from "./marks"; import { createLiteralSourceProjectionSlice, + shouldHandleInlineObjectTextInput, type SourceProjectionAdapter, type SourceProjectionPresentationSpan, type SourceProjectionSessionRange, @@ -640,4 +641,5 @@ export const createLinkSourceProjectionAdapter = ({ restoreCleanTarget: createRestoreCleanLinkTransaction, serializeInlineSource: (state, fragment) => serializeInlineLinkSource(state, serializer, fragment), + shouldHandleTextInput: shouldHandleInlineObjectTextInput, });