Skip to content

Conversation

@zachasme
Copy link
Contributor

@zachasme zachasme commented Dec 18, 2025

Based off suggestions from @jorgemanrubia, this PR allows configuring lexxy instances in three layers:

  • Default config properties for all Lexxy instances.
  • "Preset" configurations so that you can re-use special instances here and there.
  • Instance-level configuration via element attributes for one-off overrides.
// In app JS initializer
import { configure } from "lexxy"
configure({
  default: { ... }, // To override Lexxy defaults
  simple: {
    toolbar: false
  }
})

// Reference the preset and override specific values
<lexxy-editor preset="simple" attachments="false" ...>

I took into consideration the work on custom-upload-events, which will require:

  • "global" options not attached to any instance, due to the static nature of Lexical's importDOM API.
lexxyConfig.global.get("attachmentTagName")

Note that I'm only exposing the configure function as public API, but we could include a getter if end-users need to read current config values.

As for next steps:

  • We can introduce more fine-grained control over e.g. the toolbar. This will require additional logic around merging things like {toolbar: true} and {toolbar:{bold:false}}.
  • Lexical Extensions! Should we consider refactoring parts of Lexxy into a set of extensions?

Copy link
Contributor

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

Nice. I like that configuration can still be done on the element directly. Just a couple comments on attachment tag name and a potential listener issue.

Overall I think we should build a way to provide the config from Rails as well.

Lexical Extensions! Should we consider refactoring parts of Lexxy into a set of extensions?

This feels like the right direction! I was going to try it with the next feature to make sure we can still keep the nice OO-patterns established in Lexxy.


createDOM() {
const figure = createElement("action-text-attachment", { "content-type": this.contentType, "data-lexxy-decorator": true })
const figure = createElement(lexxyConfig.get("global.attachmentTagName"), { "content-type": this.contentType, "data-lexxy-decorator": true })
Copy link
Contributor

Choose a reason for hiding this comment

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

What do you think about passing the tag name to the Node constructor and keeping as internal node state rather than hooking into the config at every step?

Same would apply to ActionTextAttachmentNode

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I like it! I noticed you did the same for custom-upload-events, and considered including it here. But I also wanted to keep this minimal.

If you are expecting to merge custom-upload-events soonish I'd rather not introduce it here, otherwise I'm ok to include it.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm splitting that branch into separate PRs back to main, so I'll handle that then 👌

Copy link
Member

Choose a reason for hiding this comment

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

This would be a quite edge case but if we kept it in the internal node state, it would be copied/pasted carrying the tag, which assumes that the editor where you are pasting the node has the same override. I would keep it simple with a global access I think.

Copy link
Contributor

Choose a reason for hiding this comment

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

@jorgemanrubia: the exchange format will be text/html unless the editors share the same namespace. The rendered HTML will include the rendered tag name and I think we should match that behavior.

Though I do propose that differently configured Lexxy editors shouldn't share a namespace and we should set it depending on the loaded config:

   // ...
   namespace: `Lexxy.${configName}`,

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.

Good spike @zachasme!

We need to add tests here. So far, Capybara has served us well. But here, to test the configuration interfaces and such, I think we could add jest to Lexxy. I would still rely on Capybara as our main testing mechanism, but it is nice to have more traditional unit testing available too. We could configure in the local CI script and in GH actions too. That would let us test the Configuration objects properly here.

I would still add some high level system tests to make sure the configurations work as expected. I would be fine with just testing the overrides in capybara, assuming the system is tested through jest. WDYT?

@zachasme
Copy link
Contributor Author

Thank you for the feedback @samuelpecher and @jorgemanrubia! 👌

We need to add tests here. So far, Capybara has served us well. But here, to test the configuration interfaces and such, I think we could add jest to Lexxy. I would still rely on Capybara as our main testing mechanism, but it is nice to have more traditional unit testing available too. We could configure in the local CI script and in GH actions too. That would let us test the Configuration objects properly here.

Agreed. I wanted to hear your thoughts on the general approach before writing new tests.

I would still add some high level system tests to make sure the configurations work as expected. I would be fine with just testing the overrides in capybara, assuming the system is tested through jest. WDYT?

Yes, I think adding jest is justified at this point. Though I'd rather tackle that in a separate PR. Until we introduce more granular options for toolbar/etc, I believe I can cover most of the configuration system with some high-level system tests.

@jorgemanrubia
Copy link
Member

To me, the motivation of jest would be to test the system we are bootstrapping here, more than the granular options themselves. I think those tests belong to this PR.

@samuelpecher
Copy link
Contributor

samuelpecher commented Dec 18, 2025

Good spike @zachasme!

We need to add tests here. So far, Capybara has served us well. But here, to test the configuration interfaces and such, I think we could add jest to Lexxy.

If you're open to it I recommend vitest which is powered by the rapid vite which uses esbuild

@zachasme
Copy link
Contributor Author

To me, the motivation of jest would be to test the system we are bootstrapping here, more than the granular options themselves. I think those tests belong to this PR.

Makes sense. Alright, I'll set up jest here. @samuelpecher I'll give vitest a spin, it's "jest-compatbile" so why not? 👍

return this.isEmpty || this.toString().match(/^\s*$/g) !== null
}

get preset() {
Copy link
Member

Choose a reason for hiding this comment

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

Since you configure the element with config= it makes sense to me that this is get config instead of using a new name. We do that with other attributes lke name. Feels more consistent. Any reason to use a different name here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No reason, they should definitely be consistent. I've been back and forth on config/preset. I prefer preset, it feels more descriptive than the generic config.

Copy link
Member

Choose a reason for hiding this comment

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

But config is what we expose as the public API to be used by the editor users. What is confusing to me is using different terms here:

<lexxy-editor config="whatever"

I would expect that if you do editorElement.config returns whatever. Now, we want to make that, if config is not set, then its default value is default, so that you can query editorElement.config consistently in any scenario. See what I mean?

We could also change the public api method to preset, but I think it is important that things remain consistent here. I like config slightly better because we are talking about configurations when defining these.

Copy link
Member

@jorgemanrubia jorgemanrubia Dec 19, 2025

Choose a reason for hiding this comment

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

Maybe we could go with presets indeed at the configuration level. So the lexxy configuration could expose two different structures:

{
  global: new Configuration(...)
  presets: new Configuration({
    default: {
    }
   ...
  })
}

So that we use preset at the public API (setter) and as a getter in the element.

And we make global a explicit configuration object to query like config.global.get("tagName") or whatever. And same for the presets: config.presets.get("default.whatever") or just config.presets.default to grab the properties we want for a preset.

Copy link
Member

@jorgemanrubia jorgemanrubia Dec 19, 2025

Choose a reason for hiding this comment

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

So, for creating a editor-specific config we could do:

this.#config = new Configuration(lexxyConfig.presets.default)
this.#config.merge(this.#overrides)

Thinking we could even extend the configuration constructor to admit a list of configurations (...configurations) that it will merge to avoid the explicit merge:

this.#config = new Configuration(lexxyConfig.presets.default, this.#overrides)

I think that is pretty clean. The code expresses what we are trying to do in a single line.

@zachasme zachasme force-pushed the config branch 5 times, most recently from 322cbb7 to c9d12d8 Compare December 19, 2025 13:18
@zachasme
Copy link
Contributor Author

Thanks for the thorough feedback @jorgemanrubia! The PR might have been a bit too WIP, but the responses here are really clearing things up for me. It's slowly coming together nicely.

I think this is an important discussion:

{ toolbar: true }
// or
{
  toolbar: {
    bold: true
  }
}

Hmm not sure about this, it does not look super intuitive. I think it is important that we keep the core config abstraction lean and generic. I would rather interpret this at toolbar-rendering time: assume that everything is on unless there is a FALSE in the settings.

I agree we should assume everything is on by default, fully defined by the default preset. But I also think we should allow users to

configure({ default: { toolbar: false }})

In cases where a bool is overriding a hash, I think it would be valuable to recursively write the bool into nested hashes. It will complicate the merge logic, but it will make get super simple, we can more easily validate reads/writes and consumers of the config can rely on a known structure.

Alternatively, we can keep the config class completely generic and rely on the rest of the codebase to handle these differences.

We could also change the public api method to preset, but I think it is important that things remain consistent here. I like config slightly better because we are talking about configurations when defining these.

I think they both have merit. In isolation config= makes sense for the public API, it matches configure. But internally preset is more descriptive, and we avoid having both config and configuration referring to separate things. I also think it makes sense to introduce this as presets in the docs rather than configs.

Great to have JS tests available now 👍. Please configure an entry in Github actions and our local ci setup to run them as part of our pipeline.

I've also changed our workflow .yml to run on all pushes. Was there a specific reason we previously had:

on:
  pull_request:
  push:
    branches: [ main ]

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.

Looking tighter @zachasme 👏

@@ -0,0 +1,5 @@
import { JSDOM } from "jsdom"
Copy link
Member

Choose a reason for hiding this comment

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

I think I would prefer to keep test/ as a first level folder, parallel to src/. I think it is cleaner, so that it doesn't look like another system inside the source. I see that in trix it was inside, but in trix we also had a top level src/trix folder that we don't have here.

Also, I would move the dom_helper.js to a folder test/helpers.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've moved them into test/javascript/. 👍

My thinking was we wanted them separate from the rails tests, but I agree this is cleaner.


test("uses defaults", () => {
const element = createElement("<lexxy-editor></lexxy-editor>")
const config = new EditorConfiguration(element, "default")
Copy link
Member

Choose a reason for hiding this comment

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

With the new implementation, we don't have the problem I mentioned of having tests altering the same global structure over and over. I would remove the second param (preset) in the editor configuration constructor, since we don't need to override the config for testing purposes anymore. In the tests, I would insert that preset at the element level createElement("<lexxy-editor preset="simple"></lexxy-editor>") or just omit it there if you want to pass the default.

  const element = createElement("<lexxy-editor></lexxy-editor>")
  const config = new EditorConfiguration(element)

// or, for a specific one:

  const element = createElement("<lexxy-editor preset="simple"></lexxy-editor>")
  const config = new EditorConfiguration(element)

That way we would be testing the real thing.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Indeed. I kept the param because I had trouble getting jsdom to play nice with custom elements. I found a solution, though, and the param is gone.

presets: new Configuration({ default: DEFAULT_PRESET })
}

export function configure(changes) {
Copy link
Member

@jorgemanrubia jorgemanrubia Dec 20, 2025

Choose a reason for hiding this comment

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

Since this is a function to import, maybe configureLexxy instead of just configure would be more convenient.

The thing is that I would prefer an OO interface, so that you would do:

Lexxy.configure(...)
Lexxy.config.global
Lexxy.config.presets.get(...)

I think that feels more consistent and offers a proper namespace for other stuff we may add down the road. This way also you don't have to figure out how to rename lexxy functions and identifiers: lexxyConfig, lexxyConfigure... they are properly namespaced.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If we're talking public interface, we could keep the existing named exports and also provide a convenience default export, so the following would work the same:

import * as Lexxy from "lexxy"
// same as
import Lexxy from "lexxy"
// usage
Lexxy.configure(...)
Lexxy.highlightAll()

We could also assign the same object to a global window.Lexxy. What do you think?

In terms of internal usage, how about exporting the following from config/lexxy.js?

export default {
  global,
  presets,
  configure(changes) {
    return presets.merge(changes)
  }
}
// usage
import Lexxy from "./config/lexxy"
Lexxy.global.get(..)

And then re-export Lexxy.configure for the public interface.

@jorgemanrubia
Copy link
Member

Alternatively, we can keep the config class completely generic and rely on the rest of the codebase to handle these differences.

I would wait to see if we need the extra complication. If someone sets that the toolbar is false, the editor won't configure any toolbar, so who is going to check nested toolbar properties? I would lean towards simple and generic unless the are specific reasons. Maybe there are specific scenarios where we need this right away, but what are those?

@jorgemanrubia
Copy link
Member

On @zachasme and we also need to document the new configuration API in the README 🙏

@zachasme
Copy link
Contributor Author

I would lean towards simple and generic unless the are specific reasons. Maybe there are specific scenarios where we need this right away, but what are those?

Good point, let's keep it simple.

we also need to document the new configuration API in the README 🙏

Almost there!

@zachasme zachasme force-pushed the config branch 2 times, most recently from eaa7673 to a7eb6ed Compare January 2, 2026 10:29
@zachasme zachasme force-pushed the config branch 4 times, most recently from fc66146 to 623b785 Compare January 2, 2026 12:00
@zachasme
Copy link
Contributor Author

zachasme commented Jan 2, 2026

Further refinements:

  • Documentation.
  • I've stripped down Configuration objects to only merge and naive get, no early returns for bools.
  • Support toolbar config:
    • Previously the toolbar element attribute accepted dom id for external toolbars. I've renamed it to toolbartarget (inspired by popovertarget), such that toolbar config is always boolean.
    • I've created a test for external toolbars. I had to change the implementation to construct a full <lexxy-toolbar> inside the provided target. Is this how you intended it to work?
  • Support markdown config:
    • I'm falling back to stock Lexical rich text paste when markdown is disabled.
    • I attempted to change our PASTE_COMMAND handler to skip preventDefault if no special paste branch is taken. But that required replacing the asynchronous DataTransferItem.getAsString with synchronous DataTransfer.getData. Turns out we rely on asynchronous handling (or at least the testsuite does), and comments indicate some special care needs to taken for safari, so instead I fell back to directly calling $insertDataTransferForRichText and not touch existing paste logic when markdown is enabled.
  • Support multiline config
    • The previous option was called singleLine. Since we prefer to have all default options enabled by default, it made more sense have a multiline option.

@zachasme zachasme mentioned this pull request Jan 5, 2026
} else if (this.editorElement.supportsMarkdown) {
this.#pasteMarkdown(text)
} else {
this.#pasteRichText(clipboardData)
Copy link
Member

Choose a reason for hiding this comment

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

nice 👏

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.

Looking tight @zachasme 👏! Couple of minor comments.

}

#createDefaultToolbar() {
#createDefaultToolbar(container = this) {
Copy link
Member

Choose a reason for hiding this comment

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

Why this new parameter? #findOrCreateDefaultToolbar was tighter before. Why expanding the container and pass it as a param now?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm using it to optionally construct the default toolbar inside an external container, if provided:

Previously the toolbar element attribute accepted dom id for external toolbars. I've renamed it to toolbartarget (inspired by popovertarget), such that toolbar config is always boolean.

I've created a test for external toolbars. I had to change the implementation to construct a full <lexxy-toolbar> inside the provided target. Is this how you intended it to work?

I wanted to add a test for external toolbars, and couldn't make it work without building the full default toolbar. But I'm possibly misunderstanding how external toolbars should work.

Copy link
Member

Choose a reason for hiding this comment

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

@zachasme if a toolbar ID is provided, then Lexical should just grab that toolbar. So we just need to keep the previous behavior/code there.

I see that you changed toolbar to toolbarTarget. That is a breaking change here, I would just stick to toolbar.

I think the semantic should be:

  • If false: no toolbar
  • If true: default toolbar
  • If other string: reuse that toolbar directly (existing behavior)

Does that make sense to you?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes total sense. Splitting toolbar into toolbar/toolbartarget was just personal preference, I'll revert that part.

That is unrelated to the new parameter mentioned above, though. I added that to make the following pattern work, which currently fails on main:

<div id="external"></div>
<lexxy-editor toolbar="external">
// Uncaught TypeError: this.toolbarElement.setEditor is not a function

I assumed it a bug that we weren't constructing a full toolbar inside the external element (README says "or pass an element ID to render the toolbar in an external element").

Copy link
Member

@jorgemanrubia jorgemanrubia Jan 7, 2026

Choose a reason for hiding this comment

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

Oh but the toolbar should be a lexxy-toolbar element, not a regular div

Copy link
Member

Choose a reason for hiding this comment

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

BTW good point that this is confusing. We can mention in the README that the expected element is a lexxy-toolbar

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I tried that as well but got another error.

<lexxy-toolbar id="external"></lexxy-toolbar>
<lexxy-editor toolbar="external">
// Uncaught TypeError: Cannot read properties of null (reading 'children')

I could only make it work by providing a full toolbar directly in html:

<lexxy-toolbar id="external">
  <button class="lexxy-editor__toolbar-button" type="button" name="bold" data-command="bold" title="Bold">
    <svg ...
  ...
</lexxy-toolbar>
<lexxy-editor toolbar="external">

Is this how it's meant to be used?

Copy link
Member

Choose a reason for hiding this comment

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

Yes: it is meant to let you define a fully customizable toolbar as an external element and reference it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I see! Thanks for clearing that up 👍 I'll revert, update the README and test the intended usage instead.

// you can override specific options with attributes on editor elements
<lexxy-editor preset="simple" attachments="true"></lexxy-editor>
```

Copy link
Member

Choose a reason for hiding this comment

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

This should include a reference to Lexxy.global and the option to configure tag names, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right! ✅

What do you think of the public interface? Right now I only expose Lexxy.configure which re-routes it's input to global/presets.

configure({ global: newGlobal, ...newPresets }) {
  if (newGlobal) {
    global.merge(newGlobal)
  }
  presets.merge(newPresets)
}

Copy link
Member

Choose a reason for hiding this comment

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

I think it is good 👍

@zachasme zachasme force-pushed the config branch 8 times, most recently from 01dbfed to 221aeb4 Compare January 8, 2026 18:59
Introduce `Lexxy.configure()` to customize editor behavior through
presets and global options. Users can configure per-preset or override
via element attributes.
Test for Configuration class and EditorConfiguration.
Update GitHub Actions and local CI to run JS tests.
@zachasme zachasme merged commit 73e9cd6 into main Jan 8, 2026
5 checks passed
@zachasme zachasme deleted the config branch January 8, 2026 19:06
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.

4 participants