feat: rename a footnote by editing its definition's label - #410
Merged
Conversation
The body keeps its document depth rather than moving into a node of its own. Wrapping it would have preserved the `<dd>` the layout used, but it shifts every position inside a definition by a depth, which the block structure, lazy continuation, and projection ancestry checks all read. The label node alone leaves those untouched, and a two-column grid puts a body of several blocks under one label without the wrapper.
…thor types The rename is derived from the disagreement between the committed label and the label node, rather than tracked as an edit session. A caret moving off, `Undo` restoring the earlier text, and `Redo` restoring it again all leave the two apart the same way and are answered the same way, so nothing about the rename has to be stored to be reversed. It stays out of history for that reason: capturing it would put a second, redundant step behind every `Undo` of the typing that caused it. An empty label, one holding a bracket or a line ending, and one another definition already answers to are refused rather than committed. Each would leave the references that named the definition resolving somewhere the author did not ask for, which is the objection recorded against editable link and image reference definitions. `joinTextblocksAround` consults `isolating` only on nodes it descends through to reach a textblock, so the label is joined into despite the flag when a backspace reaches it directly from the body. A forward delete never gets that far, because `findCutAfter` refuses to leave the isolating label at all.
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 footnote definition's
[^label]:was node chrome. #197 reduced it to one chip with deliberately no document position, which is what stopped a caret being aimed at it, but it also meant the label could not be edited: renaming a footnote meant deleting the definition line and writing a new one, and the marker read as a badge rather than as the source the file holds.The definition now always shows that source. Its first child is a
footnote_definition_labelnode holding the label as document text, and[^and]:are drawn around that node as CSS generated content. Because generated content sits in no text node, the marker runs hold no document position at all, so a caret aimed at one resolves inside the definition — what #197 fixed now holds by construction rather than by a placement rule, and the decoration and the preset's duplicate<dt>both go away.Committing a label edit renames the definition and every reference that named it. Carry a reference link rather than resolve it away refused an editable definition because it would leave references pointing at a destination the file no longer names until the document was reopened; a footnote label is the same resolution key, so this had to answer that objection rather than inherit it. Renaming both sides together is the answer: the key moves atomically and nothing is left stale. The reverse direction is unchanged — editing a reference label still renames nothing.
The committed label stays on
attrs.labeland the label node holds what is being typed, so the rename is derived from the two disagreeing rather than tracked as an edit session. A caret leaving,Undorestoring the earlier text, andRedorestoring it again all produce the same disagreement and get the same answer, so the rename is never stored to be reversed, and it stays out of history because capturing it would put a redundant step behind everyUndoof the typing that caused it. Keeping the committed label on the attribute also leaves every existing reader that resolves a reference against a definition untouched.Undorestores the selection the typing began from, which returns the caret to the label and reopens the edit, so a reversed rename settles on the next caret leave. Writing the file commits first, on the seam source projection already finalizes at, so a file is never written with a label its references disagree with.A label is valid when
[^and]:can be written around it and read back unchanged, which covers empty, whitespace-only, and bracket- or newline-bearing labels without a rule for each. Those and a label another definition already answers to do not commit; the label the definition was read with stands.Source projection is not the mechanism, and
docs/decisions.mdrecords why. Its guarantees are that projected source is transient, that a clean session restores its original target exactly, and that projection finalizes before serialization; an always-open presentation on every definition is none of those, and the engine holds one session where this needs one per definition. As schema content the label gets selection, deletion, clipboard, and IME from ProseMirror instead of recreating them.One ProseMirror behaviour needed an explicit guard.
joinTextblocksAroundconsultsisolatingonly on nodes it descends through to reach a textblock; the label is reached as one directly, so its flag was never read and a backspace at the start of the body merged the body into the label. A backspace there now moves the caret to the end of the label. A forward delete never gets that far, becausefindCutAfterrefuses to leave the isolating label at all.The definition's body keeps its document depth. Wrapping it in a node of its own would have preserved the
<dd>the layout used, but it shifts every position inside a definition by a depth, which the block structure, lazy continuation, and projection ancestry checks all read. A two-column grid puts a body of several blocks under one label without the wrapper.Related Issue
Closes #407
Verification
pnpm check:frontendpasses on the complete change.Focused tests in
footnoteDefinitionLabel.test.tsprove the presentation and that it does not depend on the caret, that the marker runs hold no document position and a caret aimed at one resolves inside the definition, that a committed edit renames the definition and its references while an uncommitted one leaves both alone, that an empty, unwritable, or colliding label leaves the definition as it was read, thatUndoreopens the edit and a file written meanwhile carries the typed label, that the label survives a copy, that typing marks the document changed while the derived rename does not, and that the definition survivesEnterandBackspaceat the label's edges.editorPresentation.test.tsguards the stylesheet rules that draw the marker runs and the grid. The corpus round trip is unchanged.Manually verified in Chrome against the dev server, on Windows 11, with the editor mounted on a document holding a multi-block definition, a second definition, and two references to the first:
[^and]:render as muted monospace runs around a bold label with the body muted beside it, and the second block of a definition stays under the first rather than splitting across the row.[^run puts the caret at the start of the label.Undorestores the earlier label and returns the caret to it; leaving the label then restores both references.Not verified in this session: the Tauri window, which the frontend was driven at the Vite dev server in place of; it renders the same editor and stylesheet but not the native shell.
Notes
corpus/extensions/footnotes.mdstill converges rather than round-tripping byte for byte, on one difference this change does not reach: a definition written with no body,[^empty]:, is written back as[^empty]:. An empty body has to be filled with a paragraph to satisfy the definition's content expression, which was alreadyblock+before this change, so the fragment reaching the serializer is the same either way. A definition with a body round-trips byte for byte. That file is not incorpusRoundTrip.test.ts's lists and so was never under the identity or convergence contract; the conclusion that the difference predates this change is reasoned from the content expression rather than measured againstmain.Out of scope as #407 sets it: inserting references or definitions, preview of and navigation to a definition from a reference (#127), link and image reference definitions, which stay blocks that are not typed into, footnote reference presentation and projection, and raw HTML inside definitions.
Footnote labels are matched exactly rather than case-insensitively, which is what the existing footnote reference resolution already does; CommonMark-style label normalization for footnotes is not touched here.