Skip to content

Native toolbar mobile support#737

Merged
jorgemanrubia merged 52 commits intomainfrom
native-toolbar-sync
Mar 31, 2026
Merged

Native toolbar mobile support#737
jorgemanrubia merged 52 commits intomainfrom
native-toolbar-sync

Conversation

@mbarta
Copy link
Copy Markdown
Member

@mbarta mbarta commented Feb 17, 2026

This PR adds native Strada bridge support code for Lexxy's toolbar, selection, and attachments so native Android/iOS apps can render their own toolbar UI while the editor handles the content.

  • Attribute syncing: Extracts a Native class that emits attributes-change events on every editor state update and focus, reporting active formatting (bold, italic, highlight, link, etc.) so the native toolbar can reflect the current selection state
  • Selection freeze/thaw: Adds freezeSelection()/thawSelection() on the editor element so native dialogs (link editing, etc.) can preserve the user's selection while a modal is open. Expands collapsed selections within links to select the full link text before freezing
  • Highlight colors: Dispatches resolved highlight color palettes (highlight-colors event) on initialization so the native side knows which swatches to display, including computed RGB values for CSS custom properties
  • Code language picker: Intercepts the native mousedown to emit a code-language-picker-open event, letting native apps show their own picker UI. Adds setCodeLanguage command for applying the selection back
  • Bridge-managed attachments: insertPendingAttachment() creates upload nodes without a uploadUrl, skipping DirectUpload. Returns a handle with setAttributes(), setUploadProgress(), and remove() for the native side to drive the upload lifecycle
  • Disable Autocapitalize: prevents mobile keyboards from capitalizing input

This support functionality needs to be paired with a Strada controller and native mobile implementation.

@samuelpecher
Copy link
Copy Markdown
Collaborator

@mbarta how critical is having autocapitalize="none" ? I think Lexical may well be changing the value.

@mbarta
Copy link
Copy Markdown
Member Author

mbarta commented Feb 24, 2026

@mbarta how critical is having autocapitalize="none" ? I think Lexical may well be changing the value.

With autocapitalize enabled on Android, as the sentence was autocapitalised, the whole line was replaced and lost its formatting (like text highlight) and got replaced by the formatting of the last part (maybe where the cursor was?). It might be better to fix this but I couldn't find a way.

Milan Barta added 2 commits February 24, 2026 11:49
* origin/main: (43 commits)
  Remove all sleeps in favor of awaiting lexical updates
  Build assets
  Optimize selection format getter
  Extract format getter to `Selection` class
  Introduce `selection.nearestNodeOfType(klass)`
  Build Assets
  Incomplete/Error uploads don't export DOM
  Build assets
  Remove `wait_for_node_selection` test helper
  Simplify decorator selection with DELETE_CHARACTER_COMMAND
  Use click command handler for DecoratorNode selection
  Build assets
  Remove uncalled code
  Prevent double node deletion
  Fix selection target after prev/next node selection
  Fix floating paragraph
  Changed provisional p sizing to be clickable
  Don't generate an empty paragraph node on serialization
  Create Provisional Paragraphs to accept selection between unselectable nodes
  Add explicit lexical dependencies
  ...
Copy link
Copy Markdown
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.

@mbarta I've made a few notes; to maximize throughput I can handle tightening the implementation if you have the API set like you want it.

One thing I'd like is to not ensure we don't get any drift which impacts native unintentionally. Do you think some good native API test coverage would be a good investment?

Milan Barta added 6 commits February 24, 2026 14:59
When the native bridge sends willShowDialog, freeze() sets
contentEditable=false which causes Lexical to null the selection.
By the time remove-link arrives, dispatchUnlink has no selection
to work with. Fix by saving the LinkNode key in freeze() and
using it as a fallback in dispatchUnlink when no selection exists.
@mbarta mbarta force-pushed the native-toolbar-sync branch from 35e21db to 5655f16 Compare February 25, 2026 09:47
@mbarta
Copy link
Copy Markdown
Member Author

mbarta commented Feb 25, 2026

@mbarta I've made a few notes; to maximize throughput I can handle tightening the implementation if you have the API set like you want it.

Thank you @samuelpecher, I'd appreciate your help 🙏

One thing I'd like is to not ensure we don't get any drift which impacts native unintentionally. Do you think some good native API test coverage would be a good investment?

I asked AI to add test coverage for the native implementation. They are here: 872a243

That said, I’m not entirely sure about their quality. Since I’m not a JS developer, I rely on AI quite a bit 😬

The API for mobile is ready. However, we should probably wait for feedback from the iOS side as well.
@alpkeser — I know you’re quite busy, but it would be great if you could find some time to take a look 🙏

Copy link
Copy Markdown
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 17 out of 17 changed files in this pull request and generated 3 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown
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 18 out of 18 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +26 to +40
freeze() {
let frozenLinkKey = null
this.editorElement.editor?.getEditorState().read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return

const linkNode = $getNearestNodeOfType(selection.anchor.getNode(), LinkNode)
if (linkNode) {
frozenLinkKey = linkNode.getKey()
}
})

this.frozenLinkKey = frozenLinkKey
this.editorContentElement.contentEditable = "false"
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

The PR description says freezing selection should expand collapsed selections within links to select the full link text before freezing, but freeze() currently only records the nearest LinkNode key and disables contentEditable without modifying the selection. Either implement the link-selection expansion behavior here (and any needed selection preservation) or adjust the PR description/API expectations so they match the implemented behavior.

Copilot uses AI. Check for mistakes.
return $getNearestNodeOfType(anchorNode, nodeType)
}


Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

There is an extra blank line between nearestNodeOfType() and hasSelectedWordsInSingleLine, which makes this section inconsistent with the surrounding formatting. Consider removing the redundant blank line to keep spacing consistent.

Suggested change

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +21
const originalAttachInternals = HTMLElement.prototype.attachInternals
HTMLElement.prototype.attachInternals = function() {
const internals = originalAttachInternals.call(this)
internals.setFormValue ??= () => {}
internals.setValidity ??= () => {}
if (!internals.labels) {
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

stubElementInternals() assumes HTMLElement.prototype.attachInternals exists and is callable. In some jsdom versions/configs attachInternals may be missing or not implemented, which would throw when calling originalAttachInternals.call(this) and break the unit suite. Consider guarding for a missing implementation (e.g., provide a minimal stub internals object when attachInternals is undefined) before overriding it.

Suggested change
const originalAttachInternals = HTMLElement.prototype.attachInternals
HTMLElement.prototype.attachInternals = function() {
const internals = originalAttachInternals.call(this)
internals.setFormValue ??= () => {}
internals.setValidity ??= () => {}
if (!internals.labels) {
const originalAttachInternals =
typeof HTMLElement !== "undefined" && typeof HTMLElement.prototype.attachInternals === "function"
? HTMLElement.prototype.attachInternals
: undefined
HTMLElement.prototype.attachInternals = function() {
const internals = originalAttachInternals ? originalAttachInternals.call(this) : {}
internals.setFormValue ??= () => {}
internals.setValidity ??= () => {}
if (!("labels" in internals)) {

Copilot uses AI. Check for mistakes.
Comment on lines +177 to +185
registerAdapter(adapter) {
this.adapter = adapter

if (!this.editor) return

this.#cancelEditorInitializedDispatch()
this.#dispatchEditorInitialized()
this.#dispatchAttributesChange()
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

registerAdapter() cancels the scheduled RAF that dispatches lexxy:initialize and then immediately calls #dispatchEditorInitialized(). If registerAdapter() is called before the first animation frame, this can prevent lexxy:initialize from ever firing (since the only dispatch is inside #scheduleEditorInitializedDispatch()). Consider ensuring lexxy:initialize still dispatches exactly once even when the adapter is registered early (e.g., track an "initialized" flag and/or dispatch the initialize event when canceling the RAF).

Copilot uses AI. Check for mistakes.
svara and others added 3 commits March 30, 2026 13:37
* origin/main: (28 commits)
  Version 0.9.2.beta
  Add leaks test
  Add clean-up for event handlers in toolbar and dropdowns
  Add dispose to Contents and Selection handlers.
  Clear toolbar reference on #reset
  Add toolbar dispose instructions in docs
  Keep custom elements in Lexxy DOM
  Unregister the toolbar's history and selection listeners on dispose
  Move custom element handler removal into `dispose()` and call from editor
  Remove associated tools before base editor
  Add reconnection test
  Remove non-disposable handler from mergeRegister
  Dispose Lexxy editor on element reconnect
  version 0.9.1.beta
  Responsive style tweaks
  More robust tests for toolbar actions
  Update tests
  Rearrange toolbar list icons
  Better responsive sizing for toolbar dropdowns
  Remove duplicate test
  ...

# Conflicts:
#	src/elements/editor.js
Mergiraf incorrectly combined the PR's event listener placement with
main's refactored structure, placing the mousedown listener inside
#createLanguagePicker where this.languagePickerElement hasn't been
assigned yet. Restore the PR branch's approach: listeners go in
#attachLanguagePicker after element creation and assignment.
Copilot AI review requested due to automatic review settings March 31, 2026 13:15
Copy link
Copy Markdown
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 18 out of 18 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


// jsdom doesn't fully support ElementInternals (setFormValue, setValidity, labels)
function stubElementInternals() {
const originalAttachInternals = HTMLElement.prototype.attachInternals
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

stubElementInternals assumes HTMLElement.prototype.attachInternals exists and calls originalAttachInternals.call(this). In environments where attachInternals is missing/undefined, this will throw and all editor tests will fail. Add a guard (e.g., no-op stub that returns a minimal internals object when attachInternals isn’t present) before wrapping it.

Suggested change
const originalAttachInternals = HTMLElement.prototype.attachInternals
const originalAttachInternals = HTMLElement.prototype.attachInternals
// In environments where attachInternals is not implemented (e.g., jsdom),
// provide a minimal no-op stub so that tests do not fail.
if (typeof originalAttachInternals !== "function") {
HTMLElement.prototype.attachInternals = function() {
const internals = {}
internals.setFormValue = () => {}
internals.setValidity = () => {}
if (!Object.prototype.hasOwnProperty.call(internals, "labels")) {
Object.defineProperty(internals, "labels", { get: () => [] })
}
return internals
}
return
}

Copilot uses AI. Check for mistakes.
Comment on lines +195 to +199
freezeSelection() {
this.adapter.freeze()
}

thawSelection() {
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

freezeSelection() will throw if this.adapter has been cleared (e.g., after disconnectedCallback/#dispose sets adapter = null). Since this is a public API intended for native bridge calls, it should be safe to call even if the element is disconnected; guard with optional chaining / early return when adapter or editorContentElement is missing.

Suggested change
freezeSelection() {
this.adapter.freeze()
}
thawSelection() {
freezeSelection() {
if (!this.adapter || !this.editorContentElement) return
this.adapter.freeze()
}
thawSelection() {
if (!this.adapter || !this.editorContentElement) return

Copilot uses AI. Check for mistakes.
Comment on lines +195 to +199
freezeSelection() {
this.adapter.freeze()
}

thawSelection() {
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

thawSelection() will throw if this.adapter has been cleared (adapter is set to null in #dispose). Consider making thawSelection a no-op when disconnected/no adapter so native controllers don’t crash if they call it after teardown.

Suggested change
freezeSelection() {
this.adapter.freeze()
}
thawSelection() {
freezeSelection() {
if (!this.adapter) return
this.adapter.freeze()
}
thawSelection() {
if (!this.adapter) return

Copilot uses AI. Check for mistakes.
this.adapter.thaw()
}

dispatchAttributesChange() {
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

dispatchAttributesChange() ultimately calls this.adapter.dispatchAttributesChange(...) without checking that adapter is still present. Since adapter is set to null during #dispose, this public method can throw if called after disconnect. Add a guard (e.g., return early when !this.adapter or !this.editor).

Suggested change
dispatchAttributesChange() {
dispatchAttributesChange() {
if (!this.adapter || !this.editor) return

Copilot uses AI. Check for mistakes.
Comment on lines +29 to +33
this.languagePickerElement.addEventListener("change", () => {
this.#updateCodeBlockLanguage(this.languagePickerElement.value)
})

this.languagePickerElement.addEventListener("mousedown", (event) => {
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

#attachLanguagePicker adds new "change" / "mousedown" listeners every time the element is connected, but disconnectedCallback.dispose() only unregisters the update listener and does not remove these DOM listeners. If this custom element is disconnected/reconnected (e.g., Turbo morphs), handlers will accumulate and fire multiple times. Only add listeners when creating the select, or remove them in dispose/disconnectedCallback.

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +31
freeze() {
let frozenLinkKey = null
this.editorElement.editor?.getEditorState().read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

PR description mentions expanding collapsed selections inside links to select the full link text before freezing, but freeze() currently only captures the nearest LinkNode key and disables contentEditable. Either expand the selection when it’s collapsed inside a link, or update the PR description so it matches the implemented behavior.

Copilot uses AI. Check for mistakes.
On reconnect cycles, connectedCallback re-added change and mousedown
listeners to the existing <select> without removing old ones. Use an
AbortController to automatically clean them up on dispose.
- Add AbortController to ESLint browser globals
- Remove extra blank lines in selection.js and upload node
Copilot AI review requested due to automatic review settings March 31, 2026 13:26
@jorgemanrubia jorgemanrubia merged commit 1e3c300 into main Mar 31, 2026
15 of 16 checks passed
Copy link
Copy Markdown
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 18 out of 18 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


this.#cancelEditorInitializedDispatch()
this.#dispatchEditorInitialized()
this.#dispatchAttributesChange()
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

registerAdapter() calls #cancelEditorInitializedDispatch(), which cancels the RAF that dispatches the public lexxy:initialize event. If a consumer registers an adapter before the first animation frame, lexxy:initialize will never fire (docs/events.md says it fires when attached/ready). Consider ensuring lexxy:initialize still dispatches in this path (e.g., only suppress the adapter’s initialized callback, or re-dispatch/schedule initialize after adapter registration).

Suggested change
this.#dispatchAttributesChange()
this.#dispatchAttributesChange()
// Ensure public "lexxy:initialize" still fires even when we cancel
// the RAF-based dispatch and initialize via adapter registration.
if (!this._lexxyInitializeDispatched && this.editor) {
dispatch(this, "lexxy:initialize", { editor: this.editor })
this._lexxyInitializeDispatched = true
}

Copilot uses AI. Check for mistakes.
Comment on lines +631 to +639
#scheduleEditorInitializedDispatch() {
this.#cancelEditorInitializedDispatch()
this.#editorInitializedRafId = requestAnimationFrame(() => {
this.#editorInitializedRafId = null
if (!this.isConnected || !this.adapter) return

dispatch(this, "lexxy:initialize")
this.#dispatchEditorInitialized()
})
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

#scheduleEditorInitializedDispatch gates lexxy:initialize on this.adapter being set (if (!this.isConnected || !this.adapter) return). lexxy:initialize is a public readiness signal and shouldn’t depend on adapter presence (especially since #dispose sets adapter=null, and registerAdapter can set it to null). Consider always dispatching lexxy:initialize when connected/ready, and only gating the adapter-specific dispatchEditorInitialized() call.

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +41
freeze() {
let frozenLinkKey = null
this.editorElement.editor?.getEditorState().read(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return

const linkNode = $getNearestNodeOfType(selection.anchor.getNode(), LinkNode)
if (linkNode) {
frozenLinkKey = linkNode.getKey()
}
})

this.frozenLinkKey = frozenLinkKey
this.editorContentElement.contentEditable = "false"
}
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

PR description mentions expanding collapsed selections inside links to select the full link text before freezing, but NativeAdapter.freeze() currently only captures the nearest LinkNode key and disables contentEditable—no selection expansion is performed. Either implement the described expansion behavior here (or in LexicalEditorElement.freezeSelection), or adjust the PR description to match the actual behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +263 to +264
blobUrlTemplate: this.editorElement.blobUrlTemplate,
editor: this.editor
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

insertPendingAttachment passes editor: this.editor into ActionTextAttachmentUploadNode, but ActionTextAttachmentUploadNode’s constructor doesn’t accept/use an editor field (it sets this.editor via $getEditor() in the superclass). Consider removing the unused property to avoid implying the node depends on an externally provided editor reference.

Suggested change
blobUrlTemplate: this.editorElement.blobUrlTemplate,
editor: this.editor
blobUrlTemplate: this.editorElement.blobUrlTemplate

Copilot uses AI. Check for mistakes.
@jorgemanrubia jorgemanrubia deleted the native-toolbar-sync branch March 31, 2026 13:35
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.

7 participants