From 34cdd52211ab1cfb4e4d3873c3f2583e35c830a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sat, 15 Aug 2026 19:08:01 -0300 Subject: [PATCH 1/7] Commit typed link source when the caret leaves it A run of literal source stays unprojected while the caret is in it. A session over typed text would take the keystrokes at its boundary through the projection edit path, against bounds fixed before the source was finished. --- CHANGELOG.md | 3 +- docs/architecture.md | 2 + docs/specification.md | 1 + .../editor/plugins/sourceProjection.ts | 95 +++++++++-- .../tests/markdownCompatibility.test.tsx | 8 +- .../tests/sourceProjectionTypedLink.test.tsx | 155 ++++++++++++++++++ .../editor/utils/sourceProjectionAdapters.ts | 23 ++- .../utils/sourceProjectionLinkAdapter.ts | 52 +++++- .../utils/sourceProjectionLinkSyntax.ts | 32 ++++ 9 files changed, 350 insertions(+), 21 deletions(-) create mode 100644 src/features/editor/tests/sourceProjectionTypedLink.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index fb519e8..33c9c08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - Present active mixed-format link labels as one coordinated source range. - Extend link label source projection to labels that contain an image, such as badge links. - Keep a URL written on its own as it was written, bare or between angle brackets, instead of putting angle brackets around every bare URL in the file on the first save. +- Turn a typed link, URL, or angle-bracket URL into the link it describes once the caret leaves it, as pasting the same text already did. ### Fixed @@ -28,7 +29,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - 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. +- Escape text the editor keeps literal even when a space follows it, instead of writing it as live Markdown that turns into something else the next time the document is opened. - Open the Markdown source of a link whose label holds a footnote reference, instead of leaving it closed everywhere in the label except on the reference itself. - Keep a link label that mixes formatted text with a footnote reference as one link, instead of saving it as two links. - Open bold, italic, or strikethrough that wraps a link as one Markdown source with the link inside it, instead of one side of the link at a time with markers that do not match the file. diff --git a/docs/architecture.md b/docs/architecture.md index 1a2423e..13f7e45 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,6 +92,8 @@ Source projection temporarily exposes a supported Markdown object as unmarked, e The shared projection engine owns the active session, projected range, projection-local history, dirty-state integration, and finalization. A clean session restores its original content; an edited session rehydrates valid source or commits literal text so projected characters are not discarded. A projected range holds flat text; a session whose range stops holding it ends without committing, leaving what landed there in the document. +Document text that already spells a supported object, rather than having been projected from one, commits through the same adapter validation when the caret leaves it. + A change that reaches the projected range without passing through the engine's edit path is an unauthored write; composition input is the path that produces one. The engine keeps an unauthored write out of native history, where it would replay against coordinates the commit discards, and otherwise treats it as the content change it is: the document becomes dirty and projection-local history can step back over it. Object adapters own target discovery, source generation, validation, rehydration, presentation spans, and selection mapping. Ownership precedence is logical link, qualifying marked fragment, then standalone footnote reference. Adapters that cannot preserve a semantic mapping fall back to literal text. diff --git a/docs/specification.md b/docs/specification.md index 47d138e..fb5be1d 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -150,6 +150,7 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi - 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 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. +- Text that is exactly the source of one inline link, autolink literal, or URI autolink becomes that link when the caret leaves it, including when a line break ends the line it sits on, so typing and plain-text paste of the same characters reach the same document. The caret has not left while only source characters separate them, which keeps a bare URL whole as it is typed. Backslash-escaped source, incomplete source, and text that is not exactly one link's source stay literal. Because a committed link projects its source again whenever the caret returns, the visible text does not change. - 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, 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. diff --git a/src/features/editor/plugins/sourceProjection.ts b/src/features/editor/plugins/sourceProjection.ts index 91927ef..867a6ef 100644 --- a/src/features/editor/plugins/sourceProjection.ts +++ b/src/features/editor/plugins/sourceProjection.ts @@ -20,7 +20,9 @@ import { createLiteralSourceProjectionSlice, createMarkSourceProjectionAdapter, findSourceProjectionInsertionCandidate, + findSourceProjectionLiteralSourceCommit, findSourceProjectionTarget, + type LiteralSourceCommit, type SourceProjectionAdapter, type SourceProjectionEdit, type SourceProjectionTarget, @@ -95,8 +97,8 @@ export const createSourceProjectionProsePlugin = (adapters: readonly SourceProje return new Plugin({ key: leafdownSourceProjectionPluginKey, - appendTransaction: (transactions, _oldState, newState) => - appendProjectionTransaction(transactions, newState, adapters), + appendTransaction: (transactions, oldState, newState) => + appendProjectionTransaction(transactions, oldState, newState, adapters), // A change captured in native history while the document holds projected source replays // against coordinates the commit discards. `filterTransaction` is the only hook that runs // before the history plugin reads the meta. @@ -201,7 +203,7 @@ export const getSourceProjectionClipboardSlice = (state: EditorState): Slice | n const { session } = getSourceProjectionState(state); const { selection } = state; - if (!session || selection.empty || !isRangeInsideProjection(selection, session)) { + if (!session || selection.empty || !isRangeInside(selection, session)) { return null; } @@ -310,7 +312,7 @@ export const pasteIntoSourceProjection = (view: EditorView, text: string) => { const session = getSourceProjectionState(view.state).session; const { selection } = view.state; - if (!session || !isRangeInsideProjection(selection, session)) { + if (!session || !isRangeInside(selection, session)) { return false; } @@ -328,7 +330,7 @@ export const deleteSourceProjectionSelection = (view: EditorView) => { const session = getSourceProjectionState(view.state).session; const { selection } = view.state; - if (!session || selection.empty || !isRangeInsideProjection(selection, session)) { + if (!session || selection.empty || !isRangeInside(selection, session)) { return false; } @@ -368,6 +370,7 @@ const getProjectionMeta = (transaction: Transaction) => const appendProjectionTransaction = ( transactions: readonly Transaction[], + oldState: EditorState, state: EditorState, adapters: readonly SourceProjectionAdapter[], ) => { @@ -378,7 +381,7 @@ const appendProjectionTransaction = ( } if (projectionState.session) { - if (isRangeInsideProjection(state.selection, projectionState.session)) { + if (isRangeInside(state.selection, projectionState.session)) { return null; } @@ -397,6 +400,21 @@ const appendProjectionTransaction = ( return null; } + const literalSourceCommit = findExitedLiteralSourceCommit( + transactions, + oldState, + state, + adapters, + ); + + if (literalSourceCommit) { + return state.tr.replace( + literalSourceCommit.from, + literalSourceCommit.to, + literalSourceCommit.replacement, + ); + } + const match = findSourceProjectionTarget(state, adapters); if (!match) { @@ -406,6 +424,56 @@ const appendProjectionTransaction = ( return createEnterProjectionTransaction(state, match); }; +const mapRangeThroughTransactions = ( + transactions: readonly Transaction[], + range: TextRange, +): TextRange => + transactions.reduce( + (mapped, transaction) => ({ + from: transaction.mapping.map(mapped.from, -1), + to: transaction.mapping.map(mapped.to, -1), + }), + { from: range.from, to: range.to }, + ); + +// While nothing but more source characters stand between the caret and the run, the caret has not +// left it: the next character can still move where the source ends, as every character a bare URL +// absorbs does. +const hasCaretLeftLiteralSource = (state: EditorState, range: TextRange) => { + const { selection } = state; + + if (range.to <= selection.from) { + return /\s/u.test(getTextBetween(state.doc, range.to, selection.from)); + } + + if (selection.to <= range.from) { + return /\s/u.test(getTextBetween(state.doc, selection.to, range.from)); + } + + return false; +}; + +// Literal source is never projected, so the caret leaving it is the only signal that it is +// finished. Reading the run out of both documents keeps an edit that replaces what surrounds the +// previous selection from committing a run the caret was never in. +const findExitedLiteralSourceCommit = ( + transactions: readonly Transaction[], + oldState: EditorState, + state: EditorState, + adapters: readonly SourceProjectionAdapter[], +): LiteralSourceCommit | null => { + const previousRange = mapRangeThroughTransactions(transactions, oldState.selection); + const commit = findSourceProjectionLiteralSourceCommit(state, previousRange, adapters); + + if (!commit || !hasCaretLeftLiteralSource(state, commit)) { + return null; + } + + return findSourceProjectionLiteralSourceCommit(oldState, oldState.selection, adapters) + ? commit + : null; +}; + const applyProjectionTransaction = ( transaction: Transaction, pluginState: SourceProjectionPluginState, @@ -508,8 +576,7 @@ const applyProjectionTransaction = ( if ( transaction.docChanged && - (!isRangeInsideProjection(newState.selection, session) || - !isProjectionRangeFlatText(newState, session)) + (!isRangeInside(newState.selection, session) || !isProjectionRangeFlatText(newState, session)) ) { return { isLinkLabelHovered: false, @@ -663,7 +730,7 @@ const handleProjectionTextInput = ( return handleProjectionSourceTextInput(view, from, to, text, adapters); } - if (!isRangeInsideProjection({ from, to }, session)) { + if (!isRangeInside({ from, to }, session)) { return false; } @@ -755,7 +822,7 @@ const handleProjectionPaste = (view: EditorView, event: ClipboardEvent, slice?: const session = getSourceProjectionState(view.state).session; const { selection } = view.state; - if (!session || !isRangeInsideProjection(selection, session)) { + if (!session || !isRangeInside(selection, session)) { return false; } @@ -993,7 +1060,7 @@ const getDeletionRange = ( ): TextRange | null => { const { selection } = state; - if (!isRangeInsideProjection(selection, session)) { + if (!isRangeInside(selection, session)) { return null; } @@ -1085,7 +1152,7 @@ const createFinalizeProjectionTransaction = ( replacement: session.target.originalContent, replacementSize: session.target.originalContentSize, }; - const shouldSuppressProjectionAtSelection = isRangeInsideProjection(state.selection, session); + const shouldSuppressProjectionAtSelection = isRangeInside(state.selection, session); const shouldMapCrossingTextSelection = state.selection instanceof TextSelection && state.selection.from < session.to && @@ -1260,8 +1327,8 @@ const replaceProjectionRange = ( replacement: Slice, ) => transaction.replace(from, to, replacement); -const isRangeInsideProjection = (range: TextRange, session: ProjectionSession) => - session.from <= range.from && range.to <= session.to; +const isRangeInside = (range: TextRange, bounds: TextRange) => + bounds.from <= range.from && range.to <= bounds.to; // The projected range is modelled as flat literal text, and `getTextBetween` reads every leaf // node back as a newline. A hard break is the only node that survives that reading, since it diff --git a/src/features/editor/tests/markdownCompatibility.test.tsx b/src/features/editor/tests/markdownCompatibility.test.tsx index 0884bbe..6a27c6b 100644 --- a/src/features/editor/tests/markdownCompatibility.test.tsx +++ b/src/features/editor/tests/markdownCompatibility.test.tsx @@ -417,7 +417,7 @@ describe("Typed link source", () => { ]; it.each(typedLinkSourceFixtures)( - "keeps a typed $name literal when it ends the paragraph", + "keeps a typed $name literal while the caret is still on it", async ({ expected, typed }) => { const mounted = await mountEditor(""); @@ -429,14 +429,14 @@ describe("Typed link source", () => { ); it.each(typedLinkSourceFixtures)( - "keeps a typed $name literal when a space follows it", - async ({ expected, typed }) => { + "writes a typed $name as the link it describes once a space follows it", + async ({ typed }) => { const mounted = await mountEditor(""); setSelectionAtDocumentEnd(mounted.view); typeText(mounted.view, `${typed} `); - expect(mounted.getMarkdown()).toBe(`${expected} \n`); + expect(mounted.getMarkdown()).toBe(`${typed} \n`); }, ); diff --git a/src/features/editor/tests/sourceProjectionTypedLink.test.tsx b/src/features/editor/tests/sourceProjectionTypedLink.test.tsx new file mode 100644 index 0000000..f972855 --- /dev/null +++ b/src/features/editor/tests/sourceProjectionTypedLink.test.tsx @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; + +import { TEXT_PLAIN_MIME_TYPE } from "@/lib/mime"; +import { EDITOR_TEST_ROOT_CLASS_NAME } from "@/test/factories/editor"; +import { dispatchClipboardEvent } from "@/test/utils/events"; +import { setupMilkdownEditorMount, type MountedMilkdownEditor } from "@/test/utils/milkdown"; +import { + getEditorTextContent, + runKeyDownHandlers, + setSelectionAtDocumentEnd, + setTextSelection, + typeText, +} from "@/test/utils/prosemirror"; + +import { hasActiveSourceProjection } from "../plugins/sourceProjection"; + +const mountProjectionEditor = setupMilkdownEditorMount({ + rootClassName: EDITOR_TEST_ROOT_CLASS_NAME, +}); + +const typedLinkSourceFixtures = [ + { name: "inline link", target: "./test.html", typed: "[test link](./test.html)" }, + { name: "autolink literal", target: "https://example.com", typed: "https://example.com" }, + { name: "URI autolink", target: "https://example.com", typed: "" }, +]; + +const getLinkTargets = (mounted: MountedMilkdownEditor) => + Array.from(mounted.view.dom.querySelectorAll("a"), (link) => link.getAttribute("href")); + +const typeAtDocumentEnd = (mounted: MountedMilkdownEditor, text: string) => { + setSelectionAtDocumentEnd(mounted.view); + typeText(mounted.view, text); +}; + +describe("typed link source", () => { + it.each(typedLinkSourceFixtures)( + "commits a typed $name when the caret leaves it", + async ({ target, typed }) => { + const mounted = await mountProjectionEditor("start"); + + typeAtDocumentEnd(mounted, ` ${typed}`); + + expect(getLinkTargets(mounted)).toEqual([]); + + setTextSelection(mounted.view, 1); + + expect(getLinkTargets(mounted)).toEqual([target]); + expect(mounted.getMarkdown()).toBe(`start ${typed}\n`); + }, + ); + + it.each(typedLinkSourceFixtures)( + "commits a typed $name when the sentence continues past it", + async ({ target, typed }) => { + const mounted = await mountProjectionEditor("start"); + + typeAtDocumentEnd(mounted, ` ${typed} tail`); + + expect(getLinkTargets(mounted)).toEqual([target]); + expect(mounted.getMarkdown()).toBe(`start ${typed} tail\n`); + }, + ); + + // The parser reads `https://example.com/path.` as a link that stops before the dot. + it("commits a typed URL once, at its full length", async () => { + const mounted = await mountProjectionEditor("start"); + + typeAtDocumentEnd(mounted, " https://example.com/path.html"); + setTextSelection(mounted.view, 1); + + expect(getLinkTargets(mounted)).toEqual(["https://example.com/path.html"]); + expect(mounted.getMarkdown()).toBe("start https://example.com/path.html\n"); + }); + + it.each([ + { name: "Enter", shiftKey: false }, + { name: "Shift+Enter", shiftKey: true }, + ])("commits typed source through $name", async ({ shiftKey }) => { + const mounted = await mountProjectionEditor("start"); + + typeAtDocumentEnd(mounted, " [test link](./test.html)"); + runKeyDownHandlers(mounted.view, "Enter", { shiftKey }); + typeText(mounted.view, "tail"); + + expect(getLinkTargets(mounted)).toEqual(["./test.html"]); + expect(mounted.getMarkdown()).toBe( + shiftKey + ? "start [test link](./test.html)\\\ntail\n" + : "start [test link](./test.html)\n\ntail\n", + ); + }); + + it.each([ + { name: "backslash-escaped source", typed: "\\[test link](./test.html)" }, + { name: "incomplete source", typed: "[test link] (./test.html)" }, + ])("leaves $name literal", async ({ typed }) => { + const mounted = await mountProjectionEditor("start"); + + typeAtDocumentEnd(mounted, ` ${typed}`); + setTextSelection(mounted.view, 1); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(getEditorTextContent(mounted)).toBe(`start ${typed}`); + }); + + it("leaves typed source in a code block literal", async () => { + const mounted = await mountProjectionEditor("```\ncode\n```"); + + typeAtDocumentEnd(mounted, " [test link](./test.html)"); + setTextSelection(mounted.view, 1); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe("```\ncode [test link](./test.html)\n```\n"); + }); + + it.each(typedLinkSourceFixtures)( + "reaches the same document typing or pasting a $name", + async ({ typed }) => { + const typedEditor = await mountProjectionEditor("start"); + + typeAtDocumentEnd(typedEditor, ` ${typed}`); + setTextSelection(typedEditor.view, 1); + + const pastedEditor = await mountProjectionEditor("start"); + + typeAtDocumentEnd(pastedEditor, " "); + dispatchClipboardEvent(pastedEditor.view.dom, "paste", { [TEXT_PLAIN_MIME_TYPE]: typed }); + setTextSelection(pastedEditor.view, 1); + + expect(typedEditor.view.state.doc.toString()).toBe(pastedEditor.view.state.doc.toString()); + expect(typedEditor.getMarkdown()).toBe(pastedEditor.getMarkdown()); + }, + ); + + it("projects a committed link as the source it was typed as", async () => { + const mounted = await mountProjectionEditor("start"); + + typeAtDocumentEnd(mounted, " [test link](./test.html)"); + setTextSelection(mounted.view, 1); + setTextSelection(mounted.view, 9); + + expect(getEditorTextContent(mounted)).toBe("start [test link](./test.html)"); + }); + + it("projects the link the caret lands in while committing the run it left", async () => { + const mounted = await mountProjectionEditor("[first](./first.md) start"); + + typeAtDocumentEnd(mounted, " [test link](./test.html)"); + setTextSelection(mounted.view, 3); + + expect(hasActiveSourceProjection(mounted.view.state)).toBe(true); + expect(getEditorTextContent(mounted)).toBe("[first](./first.md) start test link"); + expect(mounted.getMarkdown()).toBe("[first](./first.md) start [test link](./test.html)\n"); + }); +}); diff --git a/src/features/editor/utils/sourceProjectionAdapters.ts b/src/features/editor/utils/sourceProjectionAdapters.ts index 08d829f..614e673 100644 --- a/src/features/editor/utils/sourceProjectionAdapters.ts +++ b/src/features/editor/utils/sourceProjectionAdapters.ts @@ -95,6 +95,10 @@ export interface SourceProjectionInsertionCandidate< target: TTarget; } +export interface LiteralSourceCommit extends TextRange { + replacement: Slice; +} + export interface SourceProjectionAdapter< TTarget extends SourceProjectionTarget = SourceProjectionTarget, > { @@ -111,6 +115,7 @@ export interface SourceProjectionAdapter< position: number, text: string, ): SourceProjectionInsertionCandidate | null; + findLiteralSourceCommit?(state: EditorState, range: TextRange): LiteralSourceCommit | null; findTarget(state: EditorState): TTarget | null; getPresentation(target: TTarget, source: string): SourceProjectionPresentation; mapSelectionFromSource( @@ -449,7 +454,7 @@ const getProjectionMarksFromInlineNode = ( }); }; -const isPlainTextRange = (state: EditorState, from: number, to: number) => { +export const isPlainTextRange = (state: EditorState, from: number, to: number) => { let isPlain = true; state.doc.nodesBetween(from, to, (node) => { @@ -1161,3 +1166,19 @@ export const findSourceProjectionInsertionCandidate = ( return null; }; + +export const findSourceProjectionLiteralSourceCommit = ( + state: EditorState, + range: TextRange, + adapters: readonly SourceProjectionAdapter[], +): LiteralSourceCommit | null => { + for (const adapter of adapters) { + const commit = adapter.findLiteralSourceCommit?.(state, range) ?? null; + + if (commit) { + return commit; + } + } + + return null; +}; diff --git a/src/features/editor/utils/sourceProjectionLinkAdapter.ts b/src/features/editor/utils/sourceProjectionLinkAdapter.ts index 2f4e61e..252f906 100644 --- a/src/features/editor/utils/sourceProjectionLinkAdapter.ts +++ b/src/features/editor/utils/sourceProjectionLinkAdapter.ts @@ -10,8 +10,10 @@ import { getCandidateMarksAtSelection, getMarkRangeAtSelection } from "./marks"; import { createLiteralSourceProjectionSlice, decodeSourceProjectionEscapes, + isPlainTextRange, mapLiteralSourceOffsetToDocument, shouldHandleInlineObjectTextInput, + type LiteralSourceCommit, type SourceProjectionAdapter, type SourceProjectionParseResult, type SourceProjectionPresentationSpan, @@ -25,12 +27,13 @@ import { } from "./sourceProjectionFootnoteReferenceSyntax"; import { createLinkSourceMap, + findLinkSourceBounds, isAtomicLinkSegment, mapLinkDocumentPositionToSource, mapLinkSourcePositionToDocument, type LinkSourceMap, } from "./sourceProjectionLinkSyntax"; -import { getTextBetween } from "./textRanges"; +import { getTextBetween, type TextRange } from "./textRanges"; const LINK_ADAPTER_ID = "link"; const LINK_MARK_NAME = "link"; @@ -286,6 +289,51 @@ const parseLinkSource = ( }; }; +// Parsing every text block on every transaction is not worth what it finds, and link source +// always carries one of these. A form this misses stays the literal text it already was. +const LINK_SOURCE_HINT_PATTERN = /\]\(|<|:\/\/|www\.|@/u; + +const findLiteralLinkSourceCommit = ( + state: EditorState, + range: TextRange, + parser: Parser, + remark: RemarkParser, +): LiteralSourceCommit | null => { + const linkType = state.schema.marks[LINK_MARK_NAME]; + const $position = state.doc.resolve(range.from); + const textBlock = $position.parent; + + if (!linkType || !textBlock.isTextblock || !textBlock.type.allowsMarkType(linkType)) { + return null; + } + + const text = getTextBetween(textBlock, 0, textBlock.content.size); + + if (!LINK_SOURCE_HINT_PATTERN.test(text)) { + return null; + } + + const start = $position.start(); + const bounds = findLinkSourceBounds(remark, text, { + from: range.from - start, + to: range.to - start, + }); + + if (!bounds) { + return null; + } + + const commitRange = { from: start + bounds.from, to: start + bounds.to }; + + if (!isPlainTextRange(state, commitRange.from, commitRange.to)) { + return null; + } + + const parsed = parseLinkSource(state, text.slice(bounds.from, bounds.to), parser, remark, []); + + return parsed ? { ...commitRange, replacement: parsed.replacement } : null; +}; + // Every node in a label stands for one document position, so the text has to spend one // character on each of them to stay aligned with the source map's document offsets. const getLinkContentText = (target: LinkSourceProjectionTarget) => @@ -603,6 +651,8 @@ export const createLinkSourceProjectionAdapter = ({ canCopySelectionSemantically: (selection, session, parsed) => isLinkSelectionSemantic(selection, session, createLinkSourceMap(remark, parsed.source)), createEnterTransaction: createEnterLinkProjectionTransaction, + findLiteralSourceCommit: (state, range) => + findLiteralLinkSourceCommit(state, range, parser, remark), findTarget: (state) => findLinkTarget(state, serializer, remark), getPresentation: (linkTarget, source) => { const parsedMap = createLinkSourceMap(remark, source); diff --git a/src/features/editor/utils/sourceProjectionLinkSyntax.ts b/src/features/editor/utils/sourceProjectionLinkSyntax.ts index a7341a8..533fbc9 100644 --- a/src/features/editor/utils/sourceProjectionLinkSyntax.ts +++ b/src/features/editor/utils/sourceProjectionLinkSyntax.ts @@ -6,6 +6,7 @@ import { getFootnoteReferenceSourceBounds, withFootnoteDefinitions, } from "./sourceProjectionFootnoteReferenceSyntax"; +import type { TextRange } from "./textRanges"; interface LinkSourceSegmentBase { className: string; @@ -409,6 +410,37 @@ export const createLinkSourceMap = (remark: RemarkParser, source: string): LinkS }; }; +const findLinkNodeBounds = (node: MarkdownNode, range: TextRange): TextRange | null => { + const position = getMarkdownPosition(node); + + if (node.type === "link" && position && position.from <= range.from && range.to <= position.to) { + return position; + } + + for (const child of node.children ?? []) { + const bounds = findLinkNodeBounds(child, range); + + if (bounds) { + return bounds; + } + } + + return null; +}; + +// Text that was never a link carries no mark whose range could bound it. +export const findLinkSourceBounds = ( + remark: RemarkParser, + text: string, + range: TextRange, +): TextRange | null => { + try { + return findLinkNodeBounds(remark.parse(text) as MarkdownNode, range); + } catch { + return null; + } +}; + export const mapLinkDocumentPositionToSource = ( position: number, map: LinkSourceMap, From 5284887c0d6133a185b6223fb4ce7eafcaf551e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sat, 15 Aug 2026 19:26:26 -0300 Subject: [PATCH 2/7] Commit only source the session wrote A file can hold escaped source because its author wanted the characters, and the escape does not survive parsing, so the document alone cannot tell typed source from text that arrived that way. The engine records the ranges the session writes and commits only within them; history clears the record, without which an undone commit returned on the next caret move. The link adapter looks for source markers within a fixed radius of the caret before parsing the text block. Typing beside link syntax then costs about 0.1 ms a keystroke, and a 10k-character paragraph no longer costs 1.2 ms. --- docs/architecture.md | 2 +- docs/specification.md | 2 +- .../editor/plugins/sourceProjection.ts | 90 ++++++++++++++++--- .../tests/sourceProjectionTypedLink.test.tsx | 36 ++++++++ .../utils/sourceProjectionLinkAdapter.ts | 16 ++-- 5 files changed, 127 insertions(+), 19 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 13f7e45..58e7203 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,7 +92,7 @@ Source projection temporarily exposes a supported Markdown object as unmarked, e The shared projection engine owns the active session, projected range, projection-local history, dirty-state integration, and finalization. A clean session restores its original content; an edited session rehydrates valid source or commits literal text so projected characters are not discarded. A projected range holds flat text; a session whose range stops holding it ends without committing, leaving what landed there in the document. -Document text that already spells a supported object, rather than having been projected from one, commits through the same adapter validation when the caret leaves it. +Document text that already spells a supported object, rather than having been projected from one, commits through the same adapter validation when the caret leaves it. The engine tracks the ranges the session has written and commits only within them, because the same characters can reach the document from source that escaped them, and history clears the record so an undone commit stays undone. A change that reaches the projected range without passing through the engine's edit path is an unauthored write; composition input is the path that produces one. The engine keeps an unauthored write out of native history, where it would replay against coordinates the commit discards, and otherwise treats it as the content change it is: the document becomes dirty and projection-local history can step back over it. diff --git a/docs/specification.md b/docs/specification.md index fb5be1d..ff5a687 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -150,7 +150,7 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi - 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 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. -- Text that is exactly the source of one inline link, autolink literal, or URI autolink becomes that link when the caret leaves it, including when a line break ends the line it sits on, so typing and plain-text paste of the same characters reach the same document. The caret has not left while only source characters separate them, which keeps a bare URL whole as it is typed. Backslash-escaped source, incomplete source, and text that is not exactly one link's source stay literal. Because a committed link projects its source again whenever the caret returns, the visible text does not change. +- Text written in the current editing session that is exactly the source of one inline link, autolink literal, or URI autolink becomes that link when the caret leaves it, including when a line break ends the line it sits on, so typing and plain-text paste of the same characters reach the same document. The caret has not left while only source characters separate them, which keeps a bare URL whole as it is typed. Backslash-escaped source, incomplete source, and text that is not exactly one link's source stay literal. Source the file escaped is literal text the author asked for, so it stays literal until it is written in, and `Undo` returns a committed link to the source it was written as. Because a committed link projects its source again whenever the caret returns, the visible text does not change. - 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, 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. diff --git a/src/features/editor/plugins/sourceProjection.ts b/src/features/editor/plugins/sourceProjection.ts index 867a6ef..dba106e 100644 --- a/src/features/editor/plugins/sourceProjection.ts +++ b/src/features/editor/plugins/sourceProjection.ts @@ -5,7 +5,7 @@ import { remarkCtx, serializerCtx, } from "@milkdown/kit/core"; -import { closeHistory } from "@milkdown/kit/prose/history"; +import { closeHistory, isHistoryTransaction } from "@milkdown/kit/prose/history"; import { DOMParser, type Slice } from "@milkdown/kit/prose/model"; import type { EditorState, Selection, Transaction } from "@milkdown/kit/prose/state"; import { Plugin, PluginKey, TextSelection } from "@milkdown/kit/prose/state"; @@ -37,6 +37,7 @@ const EMPTY_PROJECTION_STATE: SourceProjectionPluginState = { pendingCommit: null, session: null, suppressedSelection: null, + writtenRanges: [], }; export const leafdownSourceProjectionPluginKey = new PluginKey( @@ -71,8 +72,11 @@ interface SourceProjectionPluginState { pendingCommit: PendingProjectionCommit | null; session: ProjectionSession | null; suppressedSelection: SuppressedProjectionSelection | null; + writtenRanges: TextRange[]; } +type ProjectionSessionState = Omit; + type ProjectionHistoryDirection = "redo" | "undo"; type ProjectionMeta = @@ -436,10 +440,9 @@ const mapRangeThroughTransactions = ( { from: range.from, to: range.to }, ); -// While nothing but more source characters stand between the caret and the run, the caret has not -// left it: the next character can still move where the source ends, as every character a bare URL -// absorbs does. -const hasCaretLeftLiteralSource = (state: EditorState, range: TextRange) => { +// A character written against a run can still move where its source ends, as every character a +// bare URL absorbs does, so only whitespace stands for the caret having left. +const isSelectionSeparatedFrom = (state: EditorState, range: TextRange) => { const { selection } = state; if (range.to <= selection.from) { @@ -453,23 +456,29 @@ const hasCaretLeftLiteralSource = (state: EditorState, range: TextRange) => { return false; }; -// Literal source is never projected, so the caret leaving it is the only signal that it is -// finished. Reading the run out of both documents keeps an edit that replaces what surrounds the -// previous selection from committing a run the caret was never in. +const overlapsWrittenRange = (writtenRanges: readonly TextRange[], range: TextRange) => + writtenRanges.some((written) => written.from < range.to && range.from < written.to); + +// The run holds the previous selection, so the separator measured here contains the run's own: +// nothing that fails it can pass the check on the run. const findExitedLiteralSourceCommit = ( transactions: readonly Transaction[], oldState: EditorState, state: EditorState, adapters: readonly SourceProjectionAdapter[], ): LiteralSourceCommit | null => { + const { writtenRanges } = getSourceProjectionState(state); const previousRange = mapRangeThroughTransactions(transactions, oldState.selection); - const commit = findSourceProjectionLiteralSourceCommit(state, previousRange, adapters); - if (!commit || !hasCaretLeftLiteralSource(state, commit)) { + if (!writtenRanges.length || !isSelectionSeparatedFrom(state, previousRange)) { return null; } - return findSourceProjectionLiteralSourceCommit(oldState, oldState.selection, adapters) + const commit = findSourceProjectionLiteralSourceCommit(state, previousRange, adapters); + + return commit && + isSelectionSeparatedFrom(state, commit) && + overlapsWrittenRange(writtenRanges, commit) ? commit : null; }; @@ -479,7 +488,64 @@ const applyProjectionTransaction = ( pluginState: SourceProjectionPluginState, oldState: EditorState, newState: EditorState, -): SourceProjectionPluginState => { +): SourceProjectionPluginState => ({ + ...applyProjectionSessionState(transaction, pluginState, oldState, newState), + writtenRanges: getUpdatedWrittenRanges(pluginState.writtenRanges, transaction), +}); + +const mergeTextRanges = (ranges: TextRange[]) => + ranges + .sort((left, right) => left.from - right.from || left.to - right.to) + .reduce((merged, range) => { + const previous = merged.at(-1); + + if (previous && range.from <= previous.to) { + previous.to = Math.max(previous.to, range.to); + } else { + merged.push({ ...range }); + } + + return merged; + }, []); + +// Only text this session wrote may commit. The same characters can reach the document from a file +// that escaped them, where the author asked for the source itself, and a caret passing through +// must not spend that escape. History rewrites what a commit did, so the record goes with it and +// an undone commit stays undone. +const getUpdatedWrittenRanges = (ranges: TextRange[], transaction: Transaction) => { + if (isHistoryTransaction(transaction)) { + return []; + } + + if (!transaction.docChanged) { + return ranges; + } + + const { mapping } = transaction; + const written = ranges.map((range) => ({ + from: mapping.map(range.from, -1), + to: mapping.map(range.to, 1), + })); + + mapping.maps.forEach((stepMap, index) => { + const remaining = mapping.slice(index + 1); + + stepMap.forEach((_from, _to, stepFrom, stepTo) => { + if (stepFrom < stepTo) { + written.push({ from: remaining.map(stepFrom, -1), to: remaining.map(stepTo, 1) }); + } + }); + }); + + return mergeTextRanges(written); +}; + +const applyProjectionSessionState = ( + transaction: Transaction, + pluginState: SourceProjectionPluginState, + oldState: EditorState, + newState: EditorState, +): ProjectionSessionState => { const meta = getProjectionMeta(transaction); if (meta?.type === "enter" || meta?.type === "enterFromUserEdit") { diff --git a/src/features/editor/tests/sourceProjectionTypedLink.test.tsx b/src/features/editor/tests/sourceProjectionTypedLink.test.tsx index f972855..abe7dbb 100644 --- a/src/features/editor/tests/sourceProjectionTypedLink.test.tsx +++ b/src/features/editor/tests/sourceProjectionTypedLink.test.tsx @@ -12,6 +12,7 @@ import { typeText, } from "@/test/utils/prosemirror"; +import { runEditorCommand } from "../commands"; import { hasActiveSourceProjection } from "../plugins/sourceProjection"; const mountProjectionEditor = setupMilkdownEditorMount({ @@ -103,6 +104,41 @@ describe("typed link source", () => { expect(getEditorTextContent(mounted)).toBe(`start ${typed}`); }); + it("keeps source the file escaped literal when the caret visits it", async () => { + const mounted = await mountProjectionEditor("\\[test link](./test.html) tail"); + + setTextSelection(mounted.view, 12); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe("\\[test link]\\(./test.html) tail\n"); + }); + + it("keeps source the file escaped literal when the paragraph is edited elsewhere", async () => { + const mounted = await mountProjectionEditor("\\[test link](./test.html) tail"); + + setTextSelection(mounted.view, 1); + typeText(mounted.view, "edit "); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe("edit \\[test link]\\(./test.html) tail\n"); + }); + + it("reverts a commit through undo and leaves it reverted", async () => { + const mounted = await mountProjectionEditor("start"); + + typeAtDocumentEnd(mounted, " [test link](./test.html) tail"); + + expect(getLinkTargets(mounted)).toEqual(["./test.html"]); + expect(await runEditorCommand(mounted.editor, "edit.undo")).toBe(true); + + setTextSelection(mounted.view, 1); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe("start \\[test link]\\(./test.html)\n"); + }); + it("leaves typed source in a code block literal", async () => { const mounted = await mountProjectionEditor("```\ncode\n```"); diff --git a/src/features/editor/utils/sourceProjectionLinkAdapter.ts b/src/features/editor/utils/sourceProjectionLinkAdapter.ts index 252f906..90c2921 100644 --- a/src/features/editor/utils/sourceProjectionLinkAdapter.ts +++ b/src/features/editor/utils/sourceProjectionLinkAdapter.ts @@ -289,9 +289,10 @@ const parseLinkSource = ( }; }; -// Parsing every text block on every transaction is not worth what it finds, and link source -// always carries one of these. A form this misses stays the literal text it already was. +// Markers further from the caret than the radius, or in a form the pattern misses, leave their +// source literal text. const LINK_SOURCE_HINT_PATTERN = /\]\(|<|:\/\/|www\.|@/u; +const LINK_SOURCE_HINT_RADIUS = 1000; const findLiteralLinkSourceCommit = ( state: EditorState, @@ -308,14 +309,19 @@ const findLiteralLinkSourceCommit = ( } const text = getTextBetween(textBlock, 0, textBlock.content.size); + const start = $position.start(); + const offset = range.from - start; - if (!LINK_SOURCE_HINT_PATTERN.test(text)) { + if ( + !LINK_SOURCE_HINT_PATTERN.test( + text.slice(Math.max(offset - LINK_SOURCE_HINT_RADIUS, 0), offset + LINK_SOURCE_HINT_RADIUS), + ) + ) { return null; } - const start = $position.start(); const bounds = findLinkSourceBounds(remark, text, { - from: range.from - start, + from: offset, to: range.to - start, }); From 0ed3b13a2923e04c976917f9829402fd3d7d4fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sat, 15 Aug 2026 20:06:25 -0300 Subject: [PATCH 3/7] Refuse to commit source the file escaped Overlapping a written range was enough to commit a run, so one typed character inside an escaped example spent an escape its author had asked for and the next save dropped it. The engine now records the source run a change lands in while the document there still reads as the file wrote it, and refuses any run overlapping one. The two ends of a change locate that reading without step arithmetic: everything outside them is shared with the document before it, so both positions mean the same place in either one, and a move has one end at what it took and the other at where it put it. Requiring the whole run to be written would have closed the same hole, but it also refuses source wrapped by hand around words the file already held. --- docs/architecture.md | 2 +- docs/specification.md | 2 +- .../editor/plugins/sourceProjection.ts | 100 ++++++++++++++---- .../tests/sourceProjectionTypedLink.test.tsx | 87 +++++++++++++++ 4 files changed, 168 insertions(+), 23 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 58e7203..f6e83d9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,7 +92,7 @@ Source projection temporarily exposes a supported Markdown object as unmarked, e The shared projection engine owns the active session, projected range, projection-local history, dirty-state integration, and finalization. A clean session restores its original content; an edited session rehydrates valid source or commits literal text so projected characters are not discarded. A projected range holds flat text; a session whose range stops holding it ends without committing, leaving what landed there in the document. -Document text that already spells a supported object, rather than having been projected from one, commits through the same adapter validation when the caret leaves it. The engine tracks the ranges the session has written and commits only within them, because the same characters can reach the document from source that escaped them, and history clears the record so an undone commit stays undone. +Document text that already spells a supported object, rather than having been projected from one, commits through the same adapter validation when the caret leaves it. The engine commits only inside the ranges the session has written, and never inside a run of source the file escaped, which it recognizes from the state a write lands in before that write changes it. The same characters reach the document either way, because the escape does not survive parsing. History clears both records, so an undone commit stays undone. A change that reaches the projected range without passing through the engine's edit path is an unauthored write; composition input is the path that produces one. The engine keeps an unauthored write out of native history, where it would replay against coordinates the commit discards, and otherwise treats it as the content change it is: the document becomes dirty and projection-local history can step back over it. diff --git a/docs/specification.md b/docs/specification.md index ff5a687..3997253 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -150,7 +150,7 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi - 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 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. -- Text written in the current editing session that is exactly the source of one inline link, autolink literal, or URI autolink becomes that link when the caret leaves it, including when a line break ends the line it sits on, so typing and plain-text paste of the same characters reach the same document. The caret has not left while only source characters separate them, which keeps a bare URL whole as it is typed. Backslash-escaped source, incomplete source, and text that is not exactly one link's source stay literal. Source the file escaped is literal text the author asked for, so it stays literal until it is written in, and `Undo` returns a committed link to the source it was written as. Because a committed link projects its source again whenever the caret returns, the visible text does not change. +- Text written in the current editing session that is exactly the source of one inline link, autolink literal, or URI autolink becomes that link when the caret leaves it, including when a line break ends the line it sits on, so typing and plain-text paste of the same characters reach the same document. The caret has not left while only source characters separate them, which keeps a bare URL whole as it is typed. Backslash-escaped source, incomplete source, and text that is not exactly one link's source stay literal. Source the file escaped is literal text the author asked for, so editing it leaves it literal and only replacing it outright commits it, and `Undo` returns a committed link to the source it was written as. Because a committed link projects its source again whenever the caret returns, the visible text does not change. - 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, 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. diff --git a/src/features/editor/plugins/sourceProjection.ts b/src/features/editor/plugins/sourceProjection.ts index dba106e..2c3e4d0 100644 --- a/src/features/editor/plugins/sourceProjection.ts +++ b/src/features/editor/plugins/sourceProjection.ts @@ -35,6 +35,7 @@ import { getRangeText, getTextBetween, type TextRange } from "../utils/textRange const EMPTY_PROJECTION_STATE: SourceProjectionPluginState = { isLinkLabelHovered: false, pendingCommit: null, + protectedRanges: [], session: null, suppressedSelection: null, writtenRanges: [], @@ -67,15 +68,19 @@ interface SuppressedProjectionSelection { head: number; } -interface SourceProjectionPluginState { +interface SourceProvenance { + protectedRanges: TextRange[]; + writtenRanges: TextRange[]; +} + +interface SourceProjectionPluginState extends SourceProvenance { isLinkLabelHovered: boolean; pendingCommit: PendingProjectionCommit | null; session: ProjectionSession | null; suppressedSelection: SuppressedProjectionSelection | null; - writtenRanges: TextRange[]; } -type ProjectionSessionState = Omit; +type ProjectionSessionState = Omit; type ProjectionHistoryDirection = "redo" | "undo"; @@ -151,7 +156,7 @@ export const createSourceProjectionProsePlugin = (adapters: readonly SourceProje state: { init: () => EMPTY_PROJECTION_STATE, apply: (transaction, pluginState, oldState, newState) => - applyProjectionTransaction(transaction, pluginState, oldState, newState), + applyProjectionTransaction(transaction, pluginState, oldState, newState, adapters), }, }); }; @@ -456,8 +461,8 @@ const isSelectionSeparatedFrom = (state: EditorState, range: TextRange) => { return false; }; -const overlapsWrittenRange = (writtenRanges: readonly TextRange[], range: TextRange) => - writtenRanges.some((written) => written.from < range.to && range.from < written.to); +const overlapsRange = (ranges: readonly TextRange[], range: TextRange) => + ranges.some((candidate) => candidate.from < range.to && range.from < candidate.to); // The run holds the previous selection, so the separator measured here contains the run's own: // nothing that fails it can pass the check on the run. @@ -467,7 +472,7 @@ const findExitedLiteralSourceCommit = ( state: EditorState, adapters: readonly SourceProjectionAdapter[], ): LiteralSourceCommit | null => { - const { writtenRanges } = getSourceProjectionState(state); + const { protectedRanges, writtenRanges } = getSourceProjectionState(state); const previousRange = mapRangeThroughTransactions(transactions, oldState.selection); if (!writtenRanges.length || !isSelectionSeparatedFrom(state, previousRange)) { @@ -478,7 +483,8 @@ const findExitedLiteralSourceCommit = ( return commit && isSelectionSeparatedFrom(state, commit) && - overlapsWrittenRange(writtenRanges, commit) + overlapsRange(writtenRanges, commit) && + !overlapsRange(protectedRanges, commit) ? commit : null; }; @@ -488,9 +494,10 @@ const applyProjectionTransaction = ( pluginState: SourceProjectionPluginState, oldState: EditorState, newState: EditorState, + adapters: readonly SourceProjectionAdapter[], ): SourceProjectionPluginState => ({ ...applyProjectionSessionState(transaction, pluginState, oldState, newState), - writtenRanges: getUpdatedWrittenRanges(pluginState.writtenRanges, transaction), + ...getUpdatedSourceProvenance(pluginState, transaction, oldState, adapters), }); const mergeTextRanges = (ranges: TextRange[]) => @@ -508,36 +515,87 @@ const mergeTextRanges = (ranges: TextRange[]) => return merged; }, []); -// Only text this session wrote may commit. The same characters can reach the document from a file -// that escaped them, where the author asked for the source itself, and a caret passing through -// must not spend that escape. History rewrites what a commit did, so the record goes with it and -// an undone commit stays undone. -const getUpdatedWrittenRanges = (ranges: TextRange[], transaction: Transaction) => { - if (isHistoryTransaction(transaction)) { +// A region reads as the file wrote it only until the first write lands in it. +const findLoadedSourceRanges = ( + oldState: EditorState, + transaction: Transaction, + writtenRanges: readonly TextRange[], + adapters: readonly SourceProjectionAdapter[], +) => { + const changedFrom = oldState.doc.content.findDiffStart(transaction.doc.content); + + if (changedFrom === null) { return []; } + const changedTo = oldState.doc.content.findDiffEnd(transaction.doc.content)?.a; + const positions = + changedTo === undefined || changedTo === changedFrom ? [changedFrom] : [changedFrom, changedTo]; + + return positions.flatMap((position) => { + if (writtenRanges.some((range) => range.from <= position && position <= range.to)) { + return []; + } + + const loadedSource = findSourceProjectionLiteralSourceCommit( + oldState, + { from: position, to: position }, + adapters, + ); + + return loadedSource ? [loadedSource] : []; + }); +}; + +// Protected ranges map inward, so a deletion drops them and the escape can be spent deliberately, +// while written ranges map outward to take in what extends them. +const getUpdatedSourceProvenance = ( + { protectedRanges, session, writtenRanges }: SourceProjectionPluginState, + transaction: Transaction, + oldState: EditorState, + adapters: readonly SourceProjectionAdapter[], +): SourceProvenance => { + if (isHistoryTransaction(transaction)) { + return { protectedRanges: [], writtenRanges: [] }; + } + if (!transaction.docChanged) { - return ranges; + return { protectedRanges, writtenRanges }; } const { mapping } = transaction; - const written = ranges.map((range) => ({ + const written = writtenRanges.map((range) => ({ from: mapping.map(range.from, -1), to: mapping.map(range.to, 1), })); + const loaded = protectedRanges.map((range) => ({ + from: mapping.map(range.from, 1), + to: mapping.map(range.to, -1), + })); mapping.maps.forEach((stepMap, index) => { const remaining = mapping.slice(index + 1); - stepMap.forEach((_from, _to, stepFrom, stepTo) => { - if (stepFrom < stepTo) { - written.push({ from: remaining.map(stepFrom, -1), to: remaining.map(stepTo, 1) }); + stepMap.forEach((_stepFrom, _stepTo, insertedFrom, insertedTo) => { + if (insertedFrom < insertedTo) { + written.push({ from: remaining.map(insertedFrom, -1), to: remaining.map(insertedTo, 1) }); } }); }); - return mergeTextRanges(written); + // Text under an active projection is source the engine placed there, not source the file holds. + const loadedSources = session + ? [] + : findLoadedSourceRanges(oldState, transaction, writtenRanges, adapters); + + for (const loadedSource of loadedSources) { + loaded.push({ from: mapping.map(loadedSource.from, 1), to: mapping.map(loadedSource.to, -1) }); + } + + return { + protectedRanges: mergeTextRanges(loaded.filter((range) => range.from < range.to)), + writtenRanges: mergeTextRanges(written), + }; }; const applyProjectionSessionState = ( diff --git a/src/features/editor/tests/sourceProjectionTypedLink.test.tsx b/src/features/editor/tests/sourceProjectionTypedLink.test.tsx index abe7dbb..3c0084b 100644 --- a/src/features/editor/tests/sourceProjectionTypedLink.test.tsx +++ b/src/features/editor/tests/sourceProjectionTypedLink.test.tsx @@ -28,6 +28,25 @@ const typedLinkSourceFixtures = [ const getLinkTargets = (mounted: MountedMilkdownEditor) => Array.from(mounted.view.dom.querySelectorAll("a"), (link) => link.getAttribute("href")); +const dispatchDropEvent = (target: EventTarget, moved: boolean) => { + const event = new Event("drop", { bubbles: true, cancelable: true }); + + Object.assign(event, { + clientX: 0, + clientY: 0, + ctrlKey: !moved, + dataTransfer: { + dropEffect: moved ? "move" : "copy", + effectAllowed: "all", + getData: () => "", + types: [], + }, + metaKey: false, + }); + + target.dispatchEvent(event); +}; + const typeAtDocumentEnd = (mounted: MountedMilkdownEditor, text: string) => { setSelectionAtDocumentEnd(mounted.view); typeText(mounted.view, text); @@ -125,6 +144,74 @@ describe("typed link source", () => { expect(mounted.getMarkdown()).toBe("edit \\[test link]\\(./test.html) tail\n"); }); + it("keeps source the file escaped literal when it is edited inside", async () => { + const mounted = await mountProjectionEditor("\\[test link](./test.html) tail"); + + setTextSelection(mounted.view, 6); + typeText(mounted.view, "ed"); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe("\\[tested link]\\(./test.html) tail\n"); + }); + + // Auto-pairing around a selection is one change that writes on both sides of it. + it("keeps source the file escaped literal when one change writes into it twice", async () => { + const mounted = await mountProjectionEditor("\\[test link](./test.html) tail"); + + mounted.view.dispatch(mounted.view.state.tr.insertText("(", 6).insertText(")", 11)); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe("\\[test( lin)k]\\(./test.html) tail\n"); + }); + + it.each([ + { moved: true, name: "moved" }, + { moved: false, name: "copied" }, + ])("keeps source the file escaped literal when a word is $name into it", async ({ moved }) => { + const mounted = await mountProjectionEditor("\\[test link](./test.html) tail"); + const { view } = mounted; + + setTextSelection(view, 26, 30); + view.posAtCoords = () => ({ inside: -1, pos: 7 }); + view.dragging = { move: moved, slice: view.state.doc.slice(26, 30) }; + dispatchDropEvent(view.dom, moved); + setSelectionAtDocumentEnd(view); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe( + moved ? "\\[test taillink]\\(./test.html) \n" : "\\[test taillink]\\(./test.html) tail\n", + ); + }); + + it("commits source the file escaped once it is replaced outright", async () => { + const mounted = await mountProjectionEditor("\\[test link](./test.html) tail"); + + mounted.view.dispatch(mounted.view.state.tr.delete(1, 25)); + setTextSelection(mounted.view, 1); + typeText(mounted.view, "[test link](./test.html)"); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual(["./test.html"]); + expect(mounted.getMarkdown()).toBe("[test link](./test.html) tail\n"); + }); + + it("commits source written by hand around words the file already held", async () => { + const mounted = await mountProjectionEditor("test link tail", { + autoPairBracketsAndQuotes: false, + }); + + setTextSelection(mounted.view, 1); + typeText(mounted.view, "["); + setTextSelection(mounted.view, 11); + typeText(mounted.view, "](./test.html)"); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual(["./test.html"]); + expect(mounted.getMarkdown()).toBe("[test link](./test.html) tail\n"); + }); + it("reverts a commit through undo and leaves it reverted", async () => { const mounted = await mountProjectionEditor("start"); From e1771dc103ec9a107cba2e6ba5b4f8331e80e5f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sat, 15 Aug 2026 23:08:02 -0300 Subject: [PATCH 4/7] Cover the guard that keeps a split label literal Source typed under a mark never reaches the guard: the caret opens a mark projection, and the engine skips the literal path while a session is active. A soft break inside the source is the reachable case, and without the guard it commits a label holding a break the source cannot carry back. --- .../editor/tests/sourceProjectionTypedLink.test.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/features/editor/tests/sourceProjectionTypedLink.test.tsx b/src/features/editor/tests/sourceProjectionTypedLink.test.tsx index 3c0084b..6e70d48 100644 --- a/src/features/editor/tests/sourceProjectionTypedLink.test.tsx +++ b/src/features/editor/tests/sourceProjectionTypedLink.test.tsx @@ -236,6 +236,17 @@ describe("typed link source", () => { expect(mounted.getMarkdown()).toBe("```\ncode [test link](./test.html)\n```\n"); }); + it("leaves typed source split by a line ending literal", async () => { + const mounted = await mountProjectionEditor("start"); + + typeAtDocumentEnd(mounted, " [test link"); + runKeyDownHandlers(mounted.view, "Enter", { shiftKey: true }); + typeText(mounted.view, "](./test.html) tail"); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe("start \\[test link\\\n]\\(./test.html) tail\n"); + }); + it.each(typedLinkSourceFixtures)( "reaches the same document typing or pasting a $name", async ({ typed }) => { From 2e5852a43ca647e6d5b7b1fb9461f9d34796cf7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sat, 15 Aug 2026 23:14:11 -0300 Subject: [PATCH 5/7] Record no authorship for a change that only relocates The table commands rebuild the whole table in one step, so every cell in it was recorded as text the session wrote, and the next caret exit spent the escape on any literal source those cells held. A step map cannot tell content re-inserted where it came from apart from content typed, so the change that moves it says so instead. --- docs/architecture.md | 2 +- .../editor/commands/formatting/tables.ts | 5 +- .../editor/plugins/sourceProjection.ts | 27 +++++---- .../tests/sourceProjectionTypedLink.test.tsx | 58 +++++++++++++++++++ 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index f6e83d9..b17ac02 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,7 +92,7 @@ Source projection temporarily exposes a supported Markdown object as unmarked, e The shared projection engine owns the active session, projected range, projection-local history, dirty-state integration, and finalization. A clean session restores its original content; an edited session rehydrates valid source or commits literal text so projected characters are not discarded. A projected range holds flat text; a session whose range stops holding it ends without committing, leaving what landed there in the document. -Document text that already spells a supported object, rather than having been projected from one, commits through the same adapter validation when the caret leaves it. The engine commits only inside the ranges the session has written, and never inside a run of source the file escaped, which it recognizes from the state a write lands in before that write changes it. The same characters reach the document either way, because the escape does not survive parsing. History clears both records, so an undone commit stays undone. +Document text that already spells a supported object, rather than having been projected from one, commits through the same adapter validation when the caret leaves it. The engine commits only inside the ranges the session has written, and never inside a run of source the file escaped, which it recognizes from the state a write lands in before that write changes it. A change that only relocates content the document already held, such as a table row move, declares itself and records nothing as written, because its steps re-insert what they took. The same characters reach the document either way, because the escape does not survive parsing. History clears both records, so an undone commit stays undone. A change that reaches the projected range without passing through the engine's edit path is an unauthored write; composition input is the path that produces one. The engine keeps an unauthored write out of native history, where it would replay against coordinates the commit discards, and otherwise treats it as the content change it is: the document becomes dirty and projection-local history can step back over it. diff --git a/src/features/editor/commands/formatting/tables.ts b/src/features/editor/commands/formatting/tables.ts index 830dd57..1380c3d 100644 --- a/src/features/editor/commands/formatting/tables.ts +++ b/src/features/editor/commands/formatting/tables.ts @@ -9,6 +9,7 @@ import type { EditorView } from "@milkdown/kit/prose/view"; import { areNonNullish } from "@/lib/predicates"; +import { SOURCE_PROJECTION_RESTRUCTURE_META } from "../../plugins/sourceProjection"; import { getNodeType, runProseMirrorCommand, setSelectionNear } from "../../utils/milkdown"; import { getSelectedTableRect, @@ -58,7 +59,9 @@ const dispatchTableReplacement = ( ) => { const tablePos = getTablePosition(rect); const tableStart = tablePos + 1; - const tr = view.state.tr.replaceWith(tablePos, tablePos + rect.table.nodeSize, table); + const tr = view.state.tr + .replaceWith(tablePos, tablePos + rect.table.nodeSize, table) + .setMeta(SOURCE_PROJECTION_RESTRUCTURE_META, true); if (selectionCell) { setTableCellSelection(tr, tableStart, table, selectionCell); diff --git a/src/features/editor/plugins/sourceProjection.ts b/src/features/editor/plugins/sourceProjection.ts index 2c3e4d0..9febf68 100644 --- a/src/features/editor/plugins/sourceProjection.ts +++ b/src/features/editor/plugins/sourceProjection.ts @@ -45,6 +45,7 @@ export const leafdownSourceProjectionPluginKey = new PluginKey ({ from: mapping.map(range.from, -1), to: mapping.map(range.to, 1), @@ -573,20 +577,23 @@ const getUpdatedSourceProvenance = ( to: mapping.map(range.to, -1), })); - mapping.maps.forEach((stepMap, index) => { - const remaining = mapping.slice(index + 1); + if (!isRestructure) { + mapping.maps.forEach((stepMap, index) => { + const remaining = mapping.slice(index + 1); - stepMap.forEach((_stepFrom, _stepTo, insertedFrom, insertedTo) => { - if (insertedFrom < insertedTo) { - written.push({ from: remaining.map(insertedFrom, -1), to: remaining.map(insertedTo, 1) }); - } + stepMap.forEach((_stepFrom, _stepTo, insertedFrom, insertedTo) => { + if (insertedFrom < insertedTo) { + written.push({ from: remaining.map(insertedFrom, -1), to: remaining.map(insertedTo, 1) }); + } + }); }); - }); + } // Text under an active projection is source the engine placed there, not source the file holds. - const loadedSources = session - ? [] - : findLoadedSourceRanges(oldState, transaction, writtenRanges, adapters); + const loadedSources = + session || isRestructure + ? [] + : findLoadedSourceRanges(oldState, transaction, writtenRanges, adapters); for (const loadedSource of loadedSources) { loaded.push({ from: mapping.map(loadedSource.from, 1), to: mapping.map(loadedSource.to, -1) }); diff --git a/src/features/editor/tests/sourceProjectionTypedLink.test.tsx b/src/features/editor/tests/sourceProjectionTypedLink.test.tsx index 6e70d48..5941022 100644 --- a/src/features/editor/tests/sourceProjectionTypedLink.test.tsx +++ b/src/features/editor/tests/sourceProjectionTypedLink.test.tsx @@ -185,6 +185,64 @@ describe("typed link source", () => { ); }); + // Restructuring a table rebuilds every cell in it, including the ones the session never touched. + describe("with source the file escaped in a table cell", () => { + const mountTableEditor = () => + mountProjectionEditor( + "| a | b |\n| --- | --- |\n| \\[test link](./test.html) tail | c |\n| d | e |\n| f | g |", + ); + const escapedCellRow = "| \\[test link]\\(./test.html) tail | c |"; + + it.each(["format.table.moveRowDown", "format.table.moveColumnRight"] as const)( + "keeps it literal through %s from the cell", + async (commandId) => { + const mounted = await mountTableEditor(); + + setTextSelection(mounted.view, 20); + await runEditorCommand(mounted.editor, commandId); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + }, + ); + + it("keeps it literal when the caret returns to the cell later", async () => { + const mounted = await mountTableEditor(); + + setTextSelection(mounted.view, 20); + await runEditorCommand(mounted.editor, "format.table.addRowBelow"); + setSelectionAtDocumentEnd(mounted.view); + setTextSelection(mounted.view, 20); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toContain(escapedCellRow); + }); + + it("keeps it literal when another row is moved", async () => { + const mounted = await mountTableEditor(); + + setSelectionAtDocumentEnd(mounted.view); + await runEditorCommand(mounted.editor, "format.table.moveRowUp"); + setTextSelection(mounted.view, 20); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toContain(escapedCellRow); + }); + + it("still commits source typed into a cell after the table is restructured", async () => { + const mounted = await mountTableEditor(); + + setTextSelection(mounted.view, 20); + await runEditorCommand(mounted.editor, "format.table.addRowBelow"); + setSelectionAtDocumentEnd(mounted.view); + typeText(mounted.view, " [typed](./typed.html) "); + + expect(getLinkTargets(mounted)).toEqual(["./typed.html"]); + }); + }); + it("commits source the file escaped once it is replaced outright", async () => { const mounted = await mountProjectionEditor("\\[test link](./test.html) tail"); From b7b596b8ed47c08589df4ba35479ba2435907122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Tue, 18 Aug 2026 01:37:14 -0300 Subject: [PATCH 6/7] Follow precise escaping in the typed-source fixtures The subject of these assertions is that a run stays literal, not which character carries its escape. The label split by a line ending keeps its pair, where the opening bracket sits in an earlier sibling than the analysis reads. --- .../tests/sourceProjectionTypedLink.test.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/features/editor/tests/sourceProjectionTypedLink.test.tsx b/src/features/editor/tests/sourceProjectionTypedLink.test.tsx index 5941022..f5bb31c 100644 --- a/src/features/editor/tests/sourceProjectionTypedLink.test.tsx +++ b/src/features/editor/tests/sourceProjectionTypedLink.test.tsx @@ -130,7 +130,7 @@ describe("typed link source", () => { setSelectionAtDocumentEnd(mounted.view); expect(getLinkTargets(mounted)).toEqual([]); - expect(mounted.getMarkdown()).toBe("\\[test link]\\(./test.html) tail\n"); + expect(mounted.getMarkdown()).toBe("\\[test link](./test.html) tail\n"); }); it("keeps source the file escaped literal when the paragraph is edited elsewhere", async () => { @@ -141,7 +141,7 @@ describe("typed link source", () => { setSelectionAtDocumentEnd(mounted.view); expect(getLinkTargets(mounted)).toEqual([]); - expect(mounted.getMarkdown()).toBe("edit \\[test link]\\(./test.html) tail\n"); + expect(mounted.getMarkdown()).toBe("edit \\[test link](./test.html) tail\n"); }); it("keeps source the file escaped literal when it is edited inside", async () => { @@ -152,7 +152,7 @@ describe("typed link source", () => { setSelectionAtDocumentEnd(mounted.view); expect(getLinkTargets(mounted)).toEqual([]); - expect(mounted.getMarkdown()).toBe("\\[tested link]\\(./test.html) tail\n"); + expect(mounted.getMarkdown()).toBe("\\[tested link](./test.html) tail\n"); }); // Auto-pairing around a selection is one change that writes on both sides of it. @@ -163,7 +163,7 @@ describe("typed link source", () => { setSelectionAtDocumentEnd(mounted.view); expect(getLinkTargets(mounted)).toEqual([]); - expect(mounted.getMarkdown()).toBe("\\[test( lin)k]\\(./test.html) tail\n"); + expect(mounted.getMarkdown()).toBe("\\[test( lin)k](./test.html) tail\n"); }); it.each([ @@ -181,7 +181,7 @@ describe("typed link source", () => { expect(getLinkTargets(mounted)).toEqual([]); expect(mounted.getMarkdown()).toBe( - moved ? "\\[test taillink]\\(./test.html) \n" : "\\[test taillink]\\(./test.html) tail\n", + moved ? "\\[test taillink](./test.html) \n" : "\\[test taillink](./test.html) tail\n", ); }); @@ -191,7 +191,7 @@ describe("typed link source", () => { mountProjectionEditor( "| a | b |\n| --- | --- |\n| \\[test link](./test.html) tail | c |\n| d | e |\n| f | g |", ); - const escapedCellRow = "| \\[test link]\\(./test.html) tail | c |"; + const escapedCellRow = "| \\[test link](./test.html) tail | c |"; it.each(["format.table.moveRowDown", "format.table.moveColumnRight"] as const)( "keeps it literal through %s from the cell", @@ -281,7 +281,7 @@ describe("typed link source", () => { setTextSelection(mounted.view, 1); expect(getLinkTargets(mounted)).toEqual([]); - expect(mounted.getMarkdown()).toBe("start \\[test link]\\(./test.html)\n"); + expect(mounted.getMarkdown()).toBe("start \\[test link](./test.html)\n"); }); it("leaves typed source in a code block literal", async () => { From b5134ef74d1e57651594877c8e50ca638a4de6d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Tue, 18 Aug 2026 01:37:23 -0300 Subject: [PATCH 7/7] Keep an escape spent in projection out of the commit rule A backslash typed into projected source commits the run as literal text, and that text is exactly the source the caret-leave rule commits, written by the session and protected by nothing. The escape survived the save and died on the next caret move. The pending commit carries whether it consumed an escape, and the transaction that writes it records the range it wrote as protected, which is the state the same run reaches when the file already held the escape. --- .../editor/plugins/sourceProjection.ts | 16 ++++++++ .../tests/sourceProjectionTypedLink.test.tsx | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/features/editor/plugins/sourceProjection.ts b/src/features/editor/plugins/sourceProjection.ts index 9febf68..8635d24 100644 --- a/src/features/editor/plugins/sourceProjection.ts +++ b/src/features/editor/plugins/sourceProjection.ts @@ -19,6 +19,7 @@ import { applyLiteralSourceProjectionEdit, createLiteralSourceProjectionSlice, createMarkSourceProjectionAdapter, + decodeSourceProjectionEscapes, findSourceProjectionInsertionCandidate, findSourceProjectionLiteralSourceCommit, findSourceProjectionTarget, @@ -58,6 +59,7 @@ interface ProjectionSession extends TextRange { } interface PendingProjectionCommit extends TextRange { + consumedEscape: boolean; replacement: Slice; selectionAnchor: number | null; selectionHead: number | null; @@ -99,6 +101,7 @@ type ProjectionMeta = } | { type: "commitAfterRestore"; + escapedRange: TextRange | null; suppressedSelection: SuppressedProjectionSelection | null; }; @@ -589,6 +592,12 @@ const getUpdatedSourceProvenance = ( }); } + const meta = getProjectionMeta(transaction); + + if (meta?.type === "commitAfterRestore" && meta.escapedRange) { + loaded.push(meta.escapedRange); + } + // Text under an active projection is source the engine placed there, not source the file holds. const loadedSources = session || isRestructure @@ -1308,6 +1317,7 @@ const createFinalizeProjectionTransaction = ( return createRestoreBeforeCommitTransaction({ commitSelection, + consumedEscape: decodeSourceProjectionEscapes(source) !== source, replacement: parsed.replacement, restoreSelection, session, @@ -1319,6 +1329,7 @@ const createFinalizeProjectionTransaction = ( interface RestoreBeforeCommitTransactionInput { commitSelection: { anchor: number; head: number } | null; + consumedEscape: boolean; replacement: Slice; restoreSelection: { anchor: number; head: number } | null; session: ProjectionSession; @@ -1329,6 +1340,7 @@ interface RestoreBeforeCommitTransactionInput { const createRestoreBeforeCommitTransaction = ({ commitSelection, + consumedEscape, replacement, restoreSelection, session, @@ -1340,6 +1352,7 @@ const createRestoreBeforeCommitTransaction = ({ source === session.target.originalSource ? null : { + consumedEscape, from: session.from, replacement, selectionAnchor: commitSelection?.anchor ?? null, @@ -1424,6 +1437,9 @@ const createCommitAfterRestoreTransaction = ( transaction .setStoredMarks([]) .setMeta(leafdownSourceProjectionPluginKey, { + escapedRange: pendingCommit.consumedEscape + ? { from: pendingCommit.from, to: pendingCommit.from + pendingCommit.replacement.size } + : null, suppressedSelection: pendingCommit.suppressedSelection, type: "commitAfterRestore", } satisfies ProjectionMeta) diff --git a/src/features/editor/tests/sourceProjectionTypedLink.test.tsx b/src/features/editor/tests/sourceProjectionTypedLink.test.tsx index f5bb31c..b6689c0 100644 --- a/src/features/editor/tests/sourceProjectionTypedLink.test.tsx +++ b/src/features/editor/tests/sourceProjectionTypedLink.test.tsx @@ -6,11 +6,13 @@ import { dispatchClipboardEvent } from "@/test/utils/events"; import { setupMilkdownEditorMount, type MountedMilkdownEditor } from "@/test/utils/milkdown"; import { getEditorTextContent, + getEditorTextPosition, runKeyDownHandlers, setSelectionAtDocumentEnd, setTextSelection, typeText, } from "@/test/utils/prosemirror"; +import { enterProjection } from "@/test/utils/sourceProjection"; import { runEditorCommand } from "../commands"; import { hasActiveSourceProjection } from "../plugins/sourceProjection"; @@ -123,6 +125,41 @@ describe("typed link source", () => { expect(getEditorTextContent(mounted)).toBe(`start ${typed}`); }); + it("keeps source an escape in projection wrote literal through later caret moves", async () => { + const mounted = await mountProjectionEditor("[test link](./test.html) tail"); + + enterProjection(mounted, "a"); + setTextSelection(mounted.view, getEditorTextPosition(mounted, "[test link](./test.html)")); + typeText(mounted.view, "\\"); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + + setTextSelection(mounted.view, 3); + setSelectionAtDocumentEnd(mounted.view); + + expect(getLinkTargets(mounted)).toEqual([]); + expect(mounted.getMarkdown()).toBe("\\[test link](./test.html) tail\n"); + }); + + it("keeps source an escape in projection wrote literal when it is edited inside", async () => { + const mounted = await mountProjectionEditor("[test link](./test.html) tail"); + + enterProjection(mounted, "a"); + setTextSelection(mounted.view, getEditorTextPosition(mounted, "[test link](./test.html)")); + typeText(mounted.view, "\\"); + setSelectionAtDocumentEnd(mounted.view); + + const linkStart = getEditorTextPosition(mounted, "[test link](./test.html)"); + + setTextSelection(mounted.view, linkStart + 1); + typeText(mounted.view, "X"); + setSelectionAtDocumentEnd(mounted.view); + + expect(getEditorTextContent(mounted)).toBe("[Xtest link](./test.html) tail"); + expect(getLinkTargets(mounted)).toEqual([]); + }); + it("keeps source the file escaped literal when the caret visits it", async () => { const mounted = await mountProjectionEditor("\\[test link](./test.html) tail");