From 551389407221a5fddb66631b48c8c388c72bb4af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Mon, 17 Aug 2026 23:17:00 -0300 Subject: [PATCH] Commit a projected escape as the text it spells An edit that stopped being valid committed the raw source, so a backslash typed to escape a run landed in the document as an ordinary character and saved as an escaped backslash beside the escape the text still needed. The literal commit now decodes escapes the way a file holding those same characters would. An escaped backslash spells one backslash and leaves the run literal rather than reviving the link such a file would carry, since committing an object out of the invalid path is what the rule forbids. --- CHANGELOG.md | 1 + docs/decisions.md | 2 +- docs/specification.md | 4 +- .../editor/plugins/sourceProjection.test.tsx | 58 +++++++++++++++++++ .../tests/sourceProjectionClipboard.test.tsx | 18 ++++++ .../editor/utils/sourceProjectionAdapters.ts | 10 +++- ...ourceProjectionFootnoteReferenceAdapter.ts | 30 ++++++---- .../utils/sourceProjectionLinkAdapter.ts | 44 +++++++++----- 8 files changed, 134 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd96164..fb519e8 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 +- 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. diff --git a/docs/decisions.md b/docs/decisions.md index 9c53b02..b5b084f 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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. diff --git a/docs/specification.md b/docs/specification.md index 38ad519..47d138e 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -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. diff --git a/src/features/editor/plugins/sourceProjection.test.tsx b/src/features/editor/plugins/sourceProjection.test.tsx index 654b908..f0d1783 100644 --- a/src/features/editor/plugins/sourceProjection.test.tsx +++ b/src/features/editor/plugins/sourceProjection.test.tsx @@ -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); diff --git a/src/features/editor/tests/sourceProjectionClipboard.test.tsx b/src/features/editor/tests/sourceProjectionClipboard.test.tsx index f28afc7..6026568 100644 --- a/src/features/editor/tests/sourceProjectionClipboard.test.tsx +++ b/src/features/editor/tests/sourceProjectionClipboard.test.tsx @@ -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`); diff --git a/src/features/editor/utils/sourceProjectionAdapters.ts b/src/features/editor/utils/sourceProjectionAdapters.ts index 493c3cd..08d829f 100644 --- a/src/features/editor/utils/sourceProjectionAdapters.ts +++ b/src/features/editor/utils/sourceProjectionAdapters.ts @@ -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, @@ -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, diff --git a/src/features/editor/utils/sourceProjectionFootnoteReferenceAdapter.ts b/src/features/editor/utils/sourceProjectionFootnoteReferenceAdapter.ts index 46a8236..734508e 100644 --- a/src/features/editor/utils/sourceProjectionFootnoteReferenceAdapter.ts +++ b/src/features/editor/utils/sourceProjectionFootnoteReferenceAdapter.ts @@ -10,6 +10,8 @@ import type { Parser, Serializer } from "@milkdown/kit/transformer"; import { createLiteralSourceProjectionSlice, + decodeSourceProjectionEscapes, + mapLiteralSourceOffsetToDocument, shouldHandleInlineObjectTextInput, type SourceProjectionAdapter, type SourceProjectionParseResult, @@ -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 = ( @@ -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), diff --git a/src/features/editor/utils/sourceProjectionLinkAdapter.ts b/src/features/editor/utils/sourceProjectionLinkAdapter.ts index 9772659..2f4e61e 100644 --- a/src/features/editor/utils/sourceProjectionLinkAdapter.ts +++ b/src/features/editor/utils/sourceProjectionLinkAdapter.ts @@ -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, @@ -479,7 +482,7 @@ const mapSelectionPositionToSource = ( const mapSelectionPositionFromSource = ( position: number, session: SourceProjectionSessionRange, - source: string, + result: SourceProjectionParseResult, map: LinkSourceMap | null, ) => { if (position <= session.from) { @@ -487,12 +490,17 @@ const mapSelectionPositionFromSource = ( } 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 = ( @@ -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) => { @@ -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) =>