fix: keep a heading's authored form through a level change - #366
Merged
Conversation
A formatting command changes the block it acts on rather than authoring a new one, so a block that stays the construct it already was keeps every attribute the command does not name. `setBlockType` upstream builds each block from one set of attributes and leaves the rest at their schema defaults, which is where a recorded form was lost; a per-block resolver stands in for that argument and carries the node's own attributes through wherever the type is unchanged. A block that becomes another construct still carries nothing, because the form belonged to the construct that is gone.
Azganoth
deleted the
bug/keep-authored-form-through-formatting-commands
branch
September 2, 2026 16:00
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 formatting command now keeps the authored form of a block that stays the construct it was, and writes the default form for one that becomes another construct. An attribute the command itself names is still the one written, so the rule reaches only what the command leaves alone.
Two commands making the same level change to a heading disagreed on this.
Increase heading leveldispatchessetNodeMarkupwith the node's attributes spread and kept the form;Heading 1throughHeading 6reached forsetBlockType, which builds every block it changes from the attributes named in the call and leaves the rest at their schema defaults.Setext oneover===saved as## Setext onethrough one path andSetext oneover---through the other. The difference was which helper each command reached for rather than a decision about form, and it delivered the rewrite the preservation program exists to prevent through an editing gesture instead of through a save.setBlockTypecannot express a form each block carries its own copy of, since its one set of attributes reaches every block in the selection. A per-block resolver stands in for that argument: a block already of the target type is rebuilt from{ ...node.attrs, ...attrs }, and one changing construct from the named attributes alone. The applicability pass is the upstream one, reading each block's own attributes the way the change that follows does, so a mixed selection still resolves per block:Heading 2over a setext heading, a paragraph, and a closed ATX heading writes the underline, the default, and the closing hash respectively.The rule reaches further than the defect. A heading turned into a paragraph and made a heading again is a heading the editor made, and is written as one, because the form belonged to a construct that is gone.
Related Issue
Closes #359
Verification
markdownCompatibility.test.tsgains ten rows in itsHeading formgroup.Six assert that one level change keeps the form whichever command reaches it. Each row runs one document through both the
Heading Ncommand and theIncrease/Decrease heading levelstep that lands on the same level, so a level change that keeps the form on one path cannot silently reset it on the other: a setext heading raised and one lowered, a closed ATX heading, a level-six heading closed by six hashes lowered to five, a tab separator, and a three-space separator. One more covers a mixed selection, where a heading keeps its own form while a paragraph beside it takes the default. All seven fail on the pre-change source and pass after it.Three assert the other direction, that a heading becoming another construct writes the default:
# Closed atx #toggled off throughHeading 1, turned into a paragraph, and cleared, each savingClosed atxand then# Closed atxwhen made a heading again. These pass before and after, which is what makes them the guard rather than the fix.The commands that rebuild a block from named attributes were audited by driving the editor mount and reading the saved bytes:
Heading 1throughHeading 6setBlockTypeseparator,closingSequence, andunderline. Fixed here.Increase/Decrease heading levelsetNodeMarkup, attributes spreadParagraph,Clear block formattingsetBlockTypeCode blocksetBlockType, nameslanguage: ""Ordered list, bullet to orderedsetNodeMarkup(pos, type)-is no ordered delimiter. Item padding survives,- Paddedwriting1. Padded.Unordered list, ordered to bulletTask list,Toggle task checkedsetNodeMarkup, attributes spread- Paddedwrites- [ ] Padded.Blockquote, list indent commandswrapIn,lift,sinkListItem,liftListItem# Closed atx #writes> # Closed atx #.outerPipes: "none"survivesAdd row belowon the node, and the pipes that row writes are the documented blank-cell fallback, sinceAdd column afteron the same table still writes none.pnpm check:frontendpasses, including the corpus round trip. The backend is untouched, sopnpm check:backendwas not run.Not verified: the manual pass over
corpus/in the running application, which covers rendering, interaction, and navigator behavior beyond the automated round trip. The desktop E2E suite was not run locally; its fixtures hold no heading command.Notes
The two audit rows that are not fixed here are filed rather than folded in. Neither is a dropped form. #364 is a command that does not perform its conversion, and repairing it means writing the item attributes the preset maintains for rendering, which decides nothing about form. #365 deletes an info string, which is content, and both it and the level of
Code block's toggle are questions the preservation program does not answer. Each changes behavior this issue does not name.docs/decisions.mdreplaces the bullet that deferred this question underPreserve the form a file was written in, anddocs/specification.mdrecords the command rule beside the heading-level commands it settles. The Typora measurement already in the decision found the same answer for the same question, preserving a construct's authored form through an edit to that construct.