Skip to content

feat: rename a footnote by editing its definition's label - #410

Merged
Azganoth merged 2 commits into
mainfrom
feature/editable-footnote-definition-label
Sep 5, 2026
Merged

feat: rename a footnote by editing its definition's label#410
Azganoth merged 2 commits into
mainfrom
feature/editable-footnote-definition-label

Conversation

@Azganoth

@Azganoth Azganoth commented Sep 5, 2026

Copy link
Copy Markdown
Owner

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_label node 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.label and 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, Undo restoring the earlier text, and Redo restoring 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 every Undo of 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.

Undo restores 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.md records 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. joinTextblocksAround consults isolating only 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, because findCutAfter refuses 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:frontend passes on the complete change.

Focused tests in footnoteDefinitionLabel.test.ts prove 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, that Undo reopens 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 survives Enter and Backspace at the label's edges. editorPresentation.test.ts guards 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:

  1. [^ 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.
  2. Clicking the [^ run puts the caret at the start of the label.
  3. Typing in the label leaves the definition and its references untouched until the caret leaves, at which point both references follow the new label.
  4. Undo restores 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.md still 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 already block+ 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 in corpusRoundTrip.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 against main.

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.

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.
@Azganoth Azganoth added the Feature New feature or request label Sep 5, 2026
@Azganoth Azganoth self-assigned this Sep 5, 2026
@Azganoth
Azganoth merged commit 3834dc4 into main Sep 5, 2026
3 checks passed
@Azganoth
Azganoth deleted the feature/editable-footnote-definition-label branch September 5, 2026 22: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.

Edit a footnote definition's label through an always-open source projection

1 participant