Skip to content

feat: enable headings to be configurable#754

Open
ShugKnight24 wants to merge 11 commits intobasecamp:mainfrom
ShugKnight24:feat/configurable_headings
Open

feat: enable headings to be configurable#754
ShugKnight24 wants to merge 11 commits intobasecamp:mainfrom
ShugKnight24:feat/configurable_headings

Conversation

@ShugKnight24
Copy link

@ShugKnight24 ShugKnight24 commented Feb 26, 2026

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

configurable_ruby_tests configurable_ui_tests

Copilot AI review requested due to automatic review settings February 26, 2026 07:44
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 setHeading command 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
Comment on lines +291 to +294
this.#dropdowns.forEach((details) => {
details.open = false
})
}
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this indentation change looks correct

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 #dispatchButtonCommand looks off (the dispatchCommand line 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.

Comment on lines +195 to +210
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))
}
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@lylo
Copy link
Contributor

lylo commented Feb 26, 2026

If this gets merged, I'd value the ability to configure which headings are available as I want to prevent h1 being used by my customers :)

- adjust styles to give more consistent results
- ensure valid headings are passed in as an array
- clean up test that was formatted by prettier
@ShugKnight24
Copy link
Author

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

ui_tests_still_pass

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

@samuelpecher
Copy link
Collaborator

Hey @ShugKnight24 I think there's two features here:

  • Configuring the headings available
  • New UI to toggle/select heading status

The configuration feature will be simpler to land, and I think keeping the default h2-h4 is the way to go.

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
Copilot AI review requested due to automatic review settings February 26, 2026 18:19
@ShugKnight24
Copy link
Author

@samuelpecher, looks like we're on the same wavelength in regards to the headings

revert_h2_h4

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" ],
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +196 to +211
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))
}
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need; users should configure it correctly.

@ShugKnight24
Copy link
Author

@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!

Copy link
Member

@jorgemanrubia jorgemanrubia left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lovely @ShugKnight24 👏. Could you please also document the new option in the docs.

And also rebuild the assets please 🙏.

@ShugKnight24
Copy link
Author

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 main and regenerate to see if this fixes things

Copy link
Collaborator

@samuelpecher samuelpecher left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +196 to +211
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))
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need; users should configure it correctly.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ❤️ the use of presets here

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙏🏻 - Thank copilot. I originally had each in the various tests and it pointed out I could have a preset based on the codebase

Comment on lines +156 to +158
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))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this is duplcating the default value. Mis-configuration producing errors is ok I think.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@ShugKnight24
Copy link
Author

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
Copilot AI review requested due to automatic review settings March 3, 2026 19:02
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@ShugKnight24
Copy link
Author

ShugKnight24 commented Mar 3, 2026

@jorgemanrubia - added configuration to the docs in 9478bbb, let me know if you'd like that updated

@samuelpecher - addressed your review in 1ee1a03

I have some conflicts - let me see if I can resolve

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

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +155 to 165
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

Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#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.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consider making Headings configurable and selectable

5 participants