Skip to content

fix: keep a heading's authored form through a level change - #366

Merged
Azganoth merged 1 commit into
mainfrom
bug/keep-authored-form-through-formatting-commands
Sep 2, 2026
Merged

fix: keep a heading's authored form through a level change#366
Azganoth merged 1 commit into
mainfrom
bug/keep-authored-form-through-formatting-commands

Conversation

@Azganoth

@Azganoth Azganoth commented Sep 2, 2026

Copy link
Copy Markdown
Owner

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 level dispatches setNodeMarkup with the node's attributes spread and kept the form; Heading 1 through Heading 6 reached for setBlockType, which builds every block it changes from the attributes named in the call and leaves the rest at their schema defaults. Setext one over === saved as ## Setext one through one path and Setext one over --- 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.

setBlockType cannot 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 2 over 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.ts gains ten rows in its Heading form group.

Six assert that one level change keeps the form whichever command reaches it. Each row runs one document through both the Heading N command and the Increase/Decrease heading level step 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 through Heading 1, turned into a paragraph, and cleared, each saving Closed atx and then # Closed atx when 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:

Command Mechanism Result
Heading 1 through Heading 6 setBlockType Dropped separator, closingSequence, and underline. Fixed here.
Increase/Decrease heading level setNodeMarkup, attributes spread Preserving. The reference behavior.
Paragraph, Clear block formatting setBlockType Construct change to a paragraph, which holds no form.
Code block setBlockType, names language: "" Deletes a fence's info string. Content rather than form, and a named attribute, so the rule does not reach it. Filed as #365.
Ordered list, bullet to ordered setNodeMarkup(pos, type) Marker resets, correctly: a - is no ordered delimiter. Item padding survives, - Padded writing 1. Padded.
Unordered list, ordered to bullet same Does not convert at all, and loosens the list on the way. Filed as #364.
Task list, Toggle task checked setNodeMarkup, attributes spread Preserving. - Padded writes - [ ] Padded.
Blockquote, list indent commands wrapIn, lift, sinkListItem, liftListItem No rebuild. # Closed atx # writes > # Closed atx #.
Table commands table, row, and cell attributes carried Preserving. outerPipes: "none" survives Add row below on the node, and the pipes that row writes are the documented blank-cell fallback, since Add column after on the same table still writes none.

pnpm check:frontend passes, including the corpus round trip. The backend is untouched, so pnpm check:backend was 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.md replaces the bullet that deferred this question under Preserve the form a file was written in, and docs/specification.md records 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.

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 Azganoth added the Bug Something isn't working label Sep 2, 2026
@Azganoth Azganoth self-assigned this Sep 2, 2026
@Azganoth
Azganoth merged commit f317331 into main Sep 2, 2026
3 checks passed
@Azganoth
Azganoth deleted the bug/keep-authored-form-through-formatting-commands branch September 2, 2026 16:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A formatting command drops the authored form of the block it changes

1 participant