fix: keep input at an inline object's opening delimiter out of its source - #267
Merged
Conversation
The link and footnote-reference adapters took any character typed at the opening delimiter into the projected source, where it stopped parsing and committed the construct as literal text. The mark adapter already declines input at its boundaries, and this is the same guard with the backslash admitted, so the escape gesture still reaches the source.
Azganoth
added a commit
that referenced
this pull request
Aug 18, 2026
## Summary Typing `[a](b)`, `https://example.com`, or `<https://example.com>` left literal text while pasting the same characters created a link. A run of text that is exactly one link's source now becomes that link when the caret leaves it, for inline links, autolink literals, and URI autolinks. - The run is never projected. It is already the source, so the caret sitting in it changes nothing; the link adapter validates it through the same `parseLinkSource` that already turns edited projected source into a link, and the engine's existing `appendTransaction` trigger commits it. - The caret has left once whitespace, a line break, or another block separates it from the run. Until then the next character can still move where the source ends, which is what keeps a bare URL whole while it is typed: the parser reads `https://example.com/path.` as a link stopping before the dot the author is still typing. - Only source the session wrote commits, and never a run the file escaped. 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 the two apart. The engine records the ranges the session writes, and the source run a write lands in while the document there still reads as the file wrote it; a commit needs the first and must avoid the second. - A run an escape spent in projection also stays literal. #265 makes a backslash typed into projected source commit the run as the text it spells, and that text is exactly the source this rule commits, written by the session and named by nothing else. The commit that spends an escape records the range it wrote, which puts the run where a file-escaped one already sits. - History clears that record, so an undone commit stays undone rather than returning on the next caret move. - A committed link projects its source again whenever the caret returns, so the visible text never changes. ## Related Issue Closes #239 Refs #238, #265 ## Verification Focused tests in `sourceProjectionTypedLink.test.tsx` cover each form committing when the caret leaves it and when the sentence continues past it; a typed URL committing once at its full length; `Enter` and `Shift+Enter`; backslash-escaped, incomplete, and code-block source staying literal; typing and plain-text paste of the same characters reaching the same document; a committed link projecting its source again, and the link the caret lands in projecting while the run it left commits. Nine tests pin the escape hatch. Source the file escaped stays literal through a caret visit, through an edit elsewhere in its paragraph, through an edit inside it, through one change that writes into it twice, and through a word dropped into it by either a move or a copy, while replacing it outright commits it. Source an escape spent in projection stays literal through repeated caret moves and through an edit inside it, matching what the file-escaped run does. One guards the gesture that the strict alternative would have broken: source written by hand around words the file already held still commits. One pins reversal: `Undo` returns a committed link to the source it was written as, and a caret move afterwards leaves it reverted. `markdownCompatibility.test.tsx` carries #234's typed-source fixtures forward. With the caret still on the source, all three forms still serialize escaped; once a space follows, the same fixtures assert the live form, which is the behavior this change accepts. Nine assertions in `sourceProjectionTypedLink.test.tsx` moved to the single escape #263 now writes, from `\[test link]\(./test.html)` to `\[test link](./test.html)`. Each still asserts what it always did, that the run stays literal, since the subject is literalness rather than which character carries the escape. The label split by a line ending keeps its pair, where the opening bracket sits in an earlier sibling than that analysis reads. Measured the added cost against `main` by driving the editor mount used by the plugin tests, medians of seven 120-operation batches per workload, each in its own mount (ms per keystroke or caret move): | Workload | `main` | This branch | | --- | --- | --- | | Typing in a 600-character prose paragraph | 0.140 | 0.150 | | Typing in a paragraph holding link syntax | 0.098 | 0.196 | | Typing in a 10k-character paragraph whose link syntax is far from the caret | 0.066 | 0.078 | | Typing in a paragraph of 200 links | 1.194 | 1.191 | | Caret moves through an edited document | 0.204 | 0.277 | | First character written into an untouched region | 0.310 | 0.694 | Not verified: those figures were taken at `f0d1da57`, before the rebase onto #263, #267, and #268, and they were not taken again after it. What those releases change is what the serializer writes and what reaches projected source, while the scan these numbers describe parses and is untouched. Also not verified: the desktop E2E suite and manual verification in the Tauri application. The rest of the change is document text and serialization, both observable in the automated DOM tests. ## Notes Caret-leave is the only trigger, including when the caret leaves by writing. Typing a space after `[a](b)` commits it, because that space is what puts the caret off the run; without that, source typed mid-sentence would never commit, since the caret leaves such a run by writing rather than by moving. #239 rules out an eager commit on a boundary character, and no boundary character is a trigger here: the same separation rule decides every case. `Enter` and `Shift+Enter` commit through that rule rather than through `finalizeSourceProjection`, which serves an active session and there is none. The line break separates the caret from the run, so the behavior #239 asks for holds without a second path. The escape-hatch tests exist because the first implementation broke #238's shared decision that `\[a](b)` round-trips as literal text: a caret passing through `corpus/commonmark/links-and-images.md`'s `\[intentionally literal](garden.md)` converted it and the save rewrote the file. Requiring the whole run to be written would close that hole too, but it refuses source wrapped by hand around words the file already held, so the run a write lands in is recorded instead. Recognizing it costs the extra time in the last row of the table, once per region rather than once per keystroke. It locates the change by its two ends, which name the same place in the document before and after it however many steps ran, and stands down under an active projection, where the source belongs to the engine rather than to the file. Standing down there is also why an escape spent in projection needs the commit to report itself. The engine writes that run while its own session is still open, so nothing classifies it as source a file holds, and the record the commit leaves is what a later caret move reads. The condition is that the commit consumed an escape rather than that it committed literal text at all: an author who edits projected source into `[a](b) x` typed those characters, and committing that run is this rule working rather than failing. An edit inside a protected run leaves it protected here, as it already did for source the file escaped, and only replacing the run outright commits it. If writing into a run was meant to spend its escape instead, the assertion that pins it is the one to change. Before parsing a text block, the adapter looks for link markers within 1000 characters of the caret. That radius is a ceiling rather than a guarantee: source whose markers all sit further away, or a form the marker pattern does not describe, stays the literal text it already was. Without it, a URL anywhere in a 10k-character paragraph cost 1.2 ms of parsing per keystroke; the table above is measured with it. The written-range record grows with the number of distinct places a session edits, not with the number of edits — continuous typing merges into one range. Two thousand scattered single-character edits leave 1911 ranges and add about 0.1 ms per keystroke. It is uncapped: every safe drop policy loses coverage, and the cost of growth is gradual rather than a cliff, while dropping the wrong range would withhold a commit the author expects. What the engine now knows, the author cannot see. A run the file escaped and a run typed a moment ago read identically, yet only one of them commits, and the escape has no character to remove: converting a protected run means deleting it and retyping it. The consequence reaches the clipboard too. An in-app copy carries characters rather than literalness, so pasting a copy of literal link source now commits it to a link where it stayed literal before, and the Markdown flavor of such a copy is written unescaped whatever this branch does. #255 supplies the gesture that answers the first half, and #244 covers the clipboard writing live source. Neither is a precondition for this change: the protections here hold without them, and both were reachable before it. The changelog's #234 entry claimed typed source stays literal when a space follows it. It now describes the escaping fix that entry was really about, since the space case is what this change alters.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A character typed at the opening delimiter of an open link or footnote-reference source was taken into the projected source, which then stopped parsing, so the construct committed as literal text and saved escaped. One letter typed at the start of a link turned it into text that survived the save.
The mark adapter already declines input at its boundaries through
shouldHandleTextInput, and the link and footnote-reference adapters did not implement the hook. They now share one guard that declines a single-character insertion at source offset 0, so the character lands beside the object and leaves it whole.A backslash is admitted, because escaping the run is the one edit at that position that means the source. That clause is load-bearing rather than decorative: without it the backslash would land outside the projection like any other character, the link would stay live, and the gesture #265 describes would stop existing rather than being fixed.
Related Issue
Closes #266
Refs #231, #265
Verification
src/features/editor/plugins/sourceProjection.test.tsxgains four cases beside the existing mark boundary test: a letter typed at a link's opening delimiter leaves the link live and lands ahead of it, a space there is applied rather than discarded, a letter at a footnote reference's opening delimiter leaves the reference intact through its serialization, and a backslash at that position still commits the run as literal text for both constructs. The first three fail with the guard stashed and the adapters otherwise untouched.The backslash case asserts that the construct is gone rather than asserting the serialization, since the backslash count there is the defect #265 owns and pinning it would fix today's wrong bytes in place.
Measured on this branch by driving the editor mount, the four positions #265 reports are unchanged from
main: document text and saved output are identical for a backslash at a link's source start, before its destination, on a realistic link, and at a footnote reference.Not verified: no manual pass in the Tauri application. The change is text input inside the editor, which the automated DOM tests drive directly.
Notes
The guard covers the opening delimiter only, while the mark adapter's covers both boundaries. What an edit at the closing delimiter should do belongs to #231, and covering both here would settle that issue's link row while leaving its mark rows open.
No specification change.
docs/specification.mddescribes what projection commits rather than where input at a boundary belongs, and this restores for links and footnote references the behavior a mark already had.