Skip to content

feat: commit typed link source to a link when the caret leaves it - #246

Merged
Azganoth merged 7 commits into
mainfrom
feature/commit-typed-link-source
Aug 18, 2026
Merged

feat: commit typed link source to a link when the caret leaves it#246
Azganoth merged 7 commits into
mainfrom
feature/commit-typed-link-source

Conversation

@Azganoth

@Azganoth Azganoth commented Aug 16, 2026

Copy link
Copy Markdown
Owner

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. A backslash typed into projected link source becomes literal text instead of an escape #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.

@Azganoth

Copy link
Copy Markdown
Owner Author

Measured a state this branch converts, in the escape hatch's own terms. The full evidence is on #265; this is the part that belongs here.

Editing projected link source to [a](b) x makes it invalid, so the engine commits it as literal text. The run it writes is exactly valid link source, sitting in a range this branch records as written, with nothing protecting it:

After the literal commit After one further caret move
main \[a](b) x tail, literal \[a](b) x tail, literal
f0d1da57 \[a]\(b) x tail, literal [a](b) x tail, live link mark

Plugin state at that point: writtenRanges [{from:1,to:4}], protectedRanges []. Measured by driving the editor mount used by the plugin tests. The escaping still reads \[a]\(b) because the branch predates #263.

Read on its own that is arguable — the author did type those characters, which is this branch's premise. It stops being arguable under #265. That fix makes typing \ before projected link source commit the run as literal [a](b): the same shape, in the same kind of range, so the escape the author just asked for is spent on the next caret move. Today's defect is the only thing hiding it, since \[a](b) is not valid link source and nothing re-commits it.

getUpdatedSourceProvenance stands findLoadedSourceRanges down while a session is active, and the commit that ends the session is the one that would need the record, so a literal commit out of the engine is never a candidate for protectedRanges.

The protection cannot be blanket over the engine's literal commits, or the [a](b) x gesture above stops converting and contradicts this branch's premise. The condition is the fallback having consumed an escape, and whichever of the two changes lands second has to carry it.

Azganoth added a commit that referenced this pull request Aug 18, 2026
## Summary

A backslash typed into projected link source turned the link literal,
which the specification asks for, but the backslash itself landed in the
document as an ordinary character. The run then saved with three
backslashes where one was enough, and the document differed from the
same file authored by hand.

The literal commit now reads the source as the text it spells rather
than as the characters it is written with, which is what a file holding
those same characters would hold. `decodeSourceProjectionEscapes`
consumes a backslash before CommonMark's escapable punctuation and keeps
every other backslash, so `a \ b` and `C:\Users\me` still reach the
document intact.

- The fix is defined by outcome rather than by position, so it also
covers a backslash typed before the destination: `[a]\(b)` and `\[a](b)`
produce the same document, exactly as the two files do.
- The footnote-reference adapter shared the defect through the same
commit shape and is fixed with it. #265 lists the other adapters as
unprobed rather than out of scope; the mark adapters do not share it,
measured and recorded in [this
comment](#265 (comment)).
- Consuming a character shortens the committed text, so the literal
branch of selection mapping maps through the decode instead of assuming
the document and its source share offsets.

An escaped backslash is where this stops short of what the file means.
`\\[a](b)` in a file is a literal backslash followed by a live link;
committed out of projection it spells one backslash and leaves the run
literal, because committing an object out of the invalid path is the one
thing the projection rule forbids.

## Related Issue

Closes #265
Refs #238, #255

## Verification

`src/features/editor/plugins/sourceProjection.test.tsx` gains five cases
in `source editing`. A backslash typed at a link's source start and one
typed before its destination each produce a document equal to mounting
`\[a](b) tail`, compared as ProseMirror documents rather than as
strings, and each saves as `\[a](b) tail`. The same holds for a footnote
reference against `text\[^a] tail`. A backslash the author means as text
survives, and an escaped backslash spells one character. Four of the
five fail with the adapters stashed and the tests otherwise untouched;
the fifth is the guard against consuming too much, and passes either
way.

`src/features/editor/tests/sourceProjectionClipboard.test.tsx` pins the
consequence for an in-app copy: the editor flavor of escaped invalid
source carries the text it spells, while the plain-text flavor keeps the
exact projected characters the event writes from the selection.

The corpus round trip and the rest of the editor suites are unchanged.

Not verified: no manual pass in the Tauri application. The gesture is
text input and serialization, both driven directly by the editor mount.

## Notes

`docs/specification.md` said invalid edits become *exact* literal text
and `docs/decisions.md` said no projected character is lost. Both now
carry the escape rule, since this drops the backslash deliberately.

Files already saved with the extra backslashes are not repaired.
`\\\[a](b)` reopens as a document holding the backslash and saves back
unchanged, so an author who hit this deletes the stray character by
hand.

This does not touch what #246 does with the run afterwards. The literal
commit now lands text that is exactly valid link source inside a range
that branch records as written and does not protect, so the escape is
spent on the next caret move; measured there and recorded in [that pull
request](#246 (comment)).
The protection belongs with whichever of the two lands second,
conditional on the fallback having consumed an escape rather than
blanket over every literal commit.
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.
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.
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.
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.
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.
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.
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.
@Azganoth
Azganoth force-pushed the feature/commit-typed-link-source branch from f0d1da5 to b5134ef Compare August 18, 2026 05:56
@Azganoth

Copy link
Copy Markdown
Owner Author

Rebased onto main at 6a6942e2, which carries #263, #267, and #268. The previous head was f0d1da57. Two commits are added on top of the branch's own.

The rebase itself was mechanical. Four conflicts, all of them text: an import list, one changelog line, and the specification's link-projection bullet twice, where main rewrote it to carry the escape rule while this branch refines its typed-source bullet beside it. No logic conflicted, and the branch's own wording won everywhere it disagreed only by being older.

Nine assertions moved to the single escape precise escaping now writes: \[test link](./test.html) where they expected \[test link]\(./test.html). Every one of those tests still asserts what it always asserted — that the run stays literal — since the subject is literalness rather than which character carries it. One case keeps the pair: a label split by a line ending puts the opening bracket in an earlier sibling than #263's analysis reads, which that pull request records as its conservative limit.

The escape hatch needed one addition to keep working. #268 makes a backslash typed into projected source commit the run as the text it spells, and that text is exactly the source this branch commits on caret-leave, written by the session and protected by nothing:

After the escape commits After four more caret moves
Before \[a](b) tail, literal [a](b) tail, live link
After \[a](b) tail, literal \[a](b) tail, literal

The pending commit now carries whether it consumed an escape, and the transaction that writes it records the range as protected, so the run reaches the same state it would have if the file had held the escape. Ordinary typed source still commits, which the existing cases cover.

Two tests come with it: an escape spent in projection stays literal through repeated caret moves, and a run holding one stays literal when it is edited inside, which mirrors what this branch already does for source the file escaped.

Worth flagging rather than deciding here: that second test started as its opposite. I expected an edit inside a protected run to release it, and this branch's model is that only replacing it outright does. I matched the test to the branch. If the intent was that writing into a run spends its escape, that assertion is the one to change.

pnpm check:frontend is green.

@Azganoth
Azganoth merged commit 621866a into main Aug 18, 2026
3 checks passed
@Azganoth
Azganoth deleted the feature/commit-typed-link-source branch August 18, 2026 07:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Commit typed link source to a link when the caret leaves it

1 participant