From 58baa3cde0cbddae888af196202d8a0caddef2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Mon, 17 Aug 2026 15:17:33 -0300 Subject: [PATCH] Keep input at an inline object's opening delimiter out of its source The link and footnote-reference adapters took any character typed at the opening delimiter into the projected source, where it stopped parsing and committed the construct as literal text. The mark adapter already declines input at its boundaries, and this is the same guard with the backslash admitted, so the escape gesture still reaches the source. --- CHANGELOG.md | 1 + .../editor/plugins/sourceProjection.test.tsx | 75 +++++++++++++++++++ .../editor/utils/sourceProjectionAdapters.ts | 7 ++ ...ourceProjectionFootnoteReferenceAdapter.ts | 2 + .../utils/sourceProjectionLinkAdapter.ts | 2 + 5 files changed, 87 insertions(+) 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, });