feat: enable headings to be configurable#754
feat: enable headings to be configurable#754ShugKnight24 wants to merge 11 commits intobasecamp:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This pull request implements configurable headings with a dropdown selector, replacing the previous rotation-based heading selection. The change addresses issue #614 by allowing users to select specific heading levels (H1-H6) from a dropdown and making the available heading levels configurable.
Changes:
- Replaced rotation-based heading button with a dropdown selector showing all configured heading levels
- Added configuration option for customizing available heading levels (defaults to H1-H6)
- Added visual indication of currently selected heading level in the dropdown
- Introduced
setHeadingcommand to set specific heading levels directly
Reviewed changes
Copilot reviewed 10 out of 15 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| test/system/toolbar_test.rb | Updated tests to use dropdown selection instead of rotation, added tests for active state highlighting |
| test/javascript/editor/headings_configuration.test.js | New test file for heading configuration behavior |
| src/elements/dropdown/heading.js | New dropdown component implementing heading selection UI |
| src/elements/index.js | Registered new HeadingDropdown element |
| src/elements/toolbar.js | Replaced heading button with dropdown structure (includes formatting changes) |
| src/editor/selection.js | Added headingTag to selection format for active state detection (includes formatting changes) |
| src/editor/command_dispatcher.js | Added dispatchSetHeading method and updated dispatchRotateHeadingFormat to respect configuration |
| src/config/lexxy.js | Added default headings configuration |
| app/assets/stylesheets/lexxy-editor.css | Added styles for heading dropdown (includes trailing whitespace cleanup) |
| app/assets/javascript/lexxy.js | Compiled version of source changes |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- top level configure - test h1 - test empty case - validate headings to prevent other elements from being used - revert previous whitespace changes
| this.#dropdowns.forEach((details) => { | ||
| details.open = false | ||
| }) | ||
| } |
There was a problem hiding this comment.
this indentation change looks correct
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 15 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (1)
src/elements/toolbar.js:105
- Indentation in
#dispatchButtonCommandlooks off (thedispatchCommandline is indented an extra level), which makes this block harder to read and inconsistent with the rest of the file. Can you reformat this block to match the surrounding indentation?
#dispatchButtonCommand(event, { dataset: { command, payload } }) {
const isKeyboard = event instanceof PointerEvent && event.pointerId === -1
this.editor.update(() => {
this.editor.dispatchCommand(command, payload)
}, { tag: isKeyboard ? SKIP_DOM_SELECTION_TAG : undefined })
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/editor/command_dispatcher.js
Outdated
| dispatchSetHeading(tag) { | ||
| const selection = $getSelection() | ||
| if (!$isRangeSelection(selection)) return | ||
|
|
||
| if (!tag) { | ||
| this.contents.removeFormattingFromSelectedLines() | ||
| return | ||
| } | ||
|
|
||
| if ($isRootOrShadowRoot(selection.anchor.getNode())) { | ||
| selection.insertNodes([ $createHeadingNode(tag) ]) | ||
| return | ||
| } | ||
|
|
||
| this.contents.insertNodeWrappingEachSelectedLine(() => $createHeadingNode(tag)) | ||
| } |
There was a problem hiding this comment.
dispatchSetHeading(tag) does not validate tag before passing it to $createHeadingNode(tag). Since setHeading is now a registered command, callers can pass invalid values (or values not allowed by the configured headings), which can throw or bypass the configured restriction. Consider rejecting non-/^h[1-6]$/ tags (and optionally enforcing membership in #configuredHeadings) before applying.
There was a problem hiding this comment.
This seems like we're defensively programming against the previously configured and validated heading elements. If y'all think this is valuable to implement here as well I will
|
If this gets merged, I'd value the ability to configure which headings are available as I want to prevent |
- adjust styles to give more consistent results - ensure valid headings are passed in as an array - clean up test that was formatted by prettier
|
Made some updates that Copilot suggested. I think the last one is a bit redundant as we're setting an array of already configured and validated heading elements. I can adjust things as needed I likely I ran Prettier which changed the formatting of the test - tests passing after my changes
Hey @lylo, thanks for the comment. My thought process was give access to all headings out of the box and folks could configure things as they desired. I could see the case where a h1 already exists on a page or you'd want to prevent users from adding more than 1. Is the original h2-h4 default better? Would your customers use h5 or h6 tags? I suppose it's ultimately irrelevant as it's configurable. The default should be whatever is most useful. Happy to update this based on the preference of the community |
|
Hey @ShugKnight24 I think there's two features here:
The configuration feature will be simpler to land, and I think keeping the default Given the UI changes are a more complex piece, it's worth considering proposing the configuration as a separate PR and targeting this branch into that. |
- users can still configure headings as desired
|
@samuelpecher, looks like we're on the same wavelength in regards to the headings
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 15 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| multiLine: true, | ||
| richText: true, | ||
| toolbar: true, | ||
| headings: [ "h2", "h3", "h4" ], |
There was a problem hiding this comment.
PR description says the defaults were changed to H1–H6, but the code sets the default preset headings to ["h2", "h3", "h4"]. Either update the description to match the implementation, or adjust the default headings preset here (and any other defaults) to reflect the intended behavior.
src/editor/command_dispatcher.js
Outdated
| dispatchSetHeading(tag) { | ||
| const selection = $getSelection() | ||
| if (!$isRangeSelection(selection)) return | ||
|
|
||
| if (!tag) { | ||
| this.contents.removeFormattingFromSelectedLines() | ||
| return | ||
| } | ||
|
|
||
| if ($isRootOrShadowRoot(selection.anchor.getNode())) { | ||
| selection.insertNodes([ $createHeadingNode(tag) ]) | ||
| return | ||
| } | ||
|
|
||
| this.contents.insertNodeWrappingEachSelectedLine(() => $createHeadingNode(tag)) | ||
| } |
There was a problem hiding this comment.
dispatchSetHeading(tag) passes tag straight into $createHeadingNode(tag) without validating it. Since setHeading is now a registered command, callers can dispatch it with invalid tags (or tags not allowed by configuration), which can throw at runtime and/or bypass the configured heading restrictions. Consider validating tag against /^h[1-6]$/ and (optionally) this.#configuredHeadings before applying; otherwise treat it as a no-op or fall back to removing formatting.
There was a problem hiding this comment.
No need; users should configure it correctly.
…ormat` - remove select heading UI changes
|
@samuelpecher thanks for your feedback, I removed the UI changes and kept the configuration ability If there is a decision on the desired UI let me know and I'll take a look Cheers! |
There was a problem hiding this comment.
Lovely @ShugKnight24 👏. Could you please also document the new option in the docs.
And also rebuild the assets please 🙏.
|
Sure, @jorgemanrubia, thank you for the review - I'll take a look this afternoon and update the docs around the configuration Realizing there are some conflicts in the generated files. I'll bring in the latest from |
samuelpecher
left a comment
There was a problem hiding this comment.
Hey @ShugKnight24, thanks for paring this down; I like the config. When you rebase, I found some opportunitied to tighten the code up a bit.
src/editor/command_dispatcher.js
Outdated
| dispatchSetHeading(tag) { | ||
| const selection = $getSelection() | ||
| if (!$isRangeSelection(selection)) return | ||
|
|
||
| if (!tag) { | ||
| this.contents.removeFormattingFromSelectedLines() | ||
| return | ||
| } | ||
|
|
||
| if ($isRootOrShadowRoot(selection.anchor.getNode())) { | ||
| selection.insertNodes([ $createHeadingNode(tag) ]) | ||
| return | ||
| } | ||
|
|
||
| this.contents.insertNodeWrappingEachSelectedLine(() => $createHeadingNode(tag)) | ||
| } |
There was a problem hiding this comment.
No need; users should configure it correctly.
There was a problem hiding this comment.
I ❤️ the use of presets here
There was a problem hiding this comment.
🙏🏻 - Thank copilot. I originally had each in the various tests and it pointed out I could have a preset based on the codebase
src/editor/command_dispatcher.js
Outdated
| const configured = this.editorElement.config.get("headings") | ||
| const headings = Array.isArray(configured) ? configured : [ "h2", "h3", "h4" ] | ||
| return headings.filter((heading) => /^h[1-6]$/.test(heading)) |
There was a problem hiding this comment.
I feel like this is duplcating the default value. Mis-configuration producing errors is ok I think.
There was a problem hiding this comment.
Yeah - this is the one aspect having automated code review kind of misses and adds junk. Has you defensively programming around ghost scenarios that will likely never happen. Thanks for your thinking here
|
Thanks for the thorough review @samuelpecher - will dig into your comments |
- Simplify heading rotation using array index mechanics (indexOf + 1 with ?? fallback) - Use nearestNodeOfType(HeadingNode) instead of getTopLevelElementOrThrow() - Simplify headingTag in selection.js using optional chaining with $getNearestNodeOfType - Remove defensive Array.isArray guard and regex filter from #configuredHeadings - Return false from command handler early exits per Lexical convention - Remove redundant blog preset test that duplicated defaults - Rebuild assets
|
@jorgemanrubia - added configuration to the docs in 9478bbb, let me know if you'd like that updated @samuelpecher - addressed your review in 1ee1a03
Think we're all set here - let me know if either of you have any additional asks and I'm happy to address them Cheers |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 12 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| get #configuredHeadings() { | ||
| return this.editorElement.config.get("headings") | ||
| } | ||
|
|
||
| dispatchRotateHeadingFormat() { | ||
| const selection = $getSelection() | ||
| if (!$isRangeSelection(selection)) return | ||
| if (!$isRangeSelection(selection)) return false | ||
|
|
||
| const headings = this.#configuredHeadings | ||
| if (headings.length === 0) return false | ||
|
|
There was a problem hiding this comment.
#configuredHeadings returns the raw config value. Because EditorConfiguration falls back to returning the attribute string when JSON parsing fails, headings="h2" would yield a string here; headings.length would be non-zero and headings[0] would become 'h', causing $createHeadingNode('h') to throw. Consider normalizing in #configuredHeadings (e.g., Array.isArray(...) ? ... : []) and filtering to valid /^h[1-6]$/ strings before use.
There was a problem hiding this comment.
@samuelpecher said users having errors with their own configuration is fine
… rotation - Remove unused $isHeadingNode import (replaced by nearestNodeOfType) - Restore heading→paragraph cycle: clicking Heading on the last configured level now removes formatting instead of wrapping back to the first heading - Fixes the existing "rotate headers" system test expectation (h4 → p)


Hey there, listened to a chat between @dhh, @ThePrimeagen, and @tjdevries. I really enjoyed and I came away with a lot of value and wanted to pay it forward
This PR allows headings to be configured and closes #614
Based on feedback from @samuelpecher, I removed the UI updates and only kept headings being configurable
Cheers!
Preview
configurable_headings_rotate.mov
Tests