Conversation
|
@mbarta how critical is having |
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. |
* 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 ...
samuelpecher
left a comment
There was a problem hiding this comment.
@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?
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.
35e21db to
5655f16
Compare
Thank you @samuelpecher, I'd appreciate your help 🙏
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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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" | ||
| } |
There was a problem hiding this comment.
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.
src/editor/selection.js
Outdated
| return $getNearestNodeOfType(anchorNode, nodeType) | ||
| } | ||
|
|
||
|
|
There was a problem hiding this comment.
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.
| const originalAttachInternals = HTMLElement.prototype.attachInternals | ||
| HTMLElement.prototype.attachInternals = function() { | ||
| const internals = originalAttachInternals.call(this) | ||
| internals.setFormValue ??= () => {} | ||
| internals.setValidity ??= () => {} | ||
| if (!internals.labels) { |
There was a problem hiding this comment.
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.
| 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)) { |
| registerAdapter(adapter) { | ||
| this.adapter = adapter | ||
|
|
||
| if (!this.editor) return | ||
|
|
||
| this.#cancelEditorInitializedDispatch() | ||
| this.#dispatchEditorInitialized() | ||
| this.#dispatchAttributesChange() | ||
| } |
There was a problem hiding this comment.
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).
* 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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
| 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 | |
| } |
| freezeSelection() { | ||
| this.adapter.freeze() | ||
| } | ||
|
|
||
| thawSelection() { |
There was a problem hiding this comment.
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.
| freezeSelection() { | |
| this.adapter.freeze() | |
| } | |
| thawSelection() { | |
| freezeSelection() { | |
| if (!this.adapter || !this.editorContentElement) return | |
| this.adapter.freeze() | |
| } | |
| thawSelection() { | |
| if (!this.adapter || !this.editorContentElement) return |
| freezeSelection() { | ||
| this.adapter.freeze() | ||
| } | ||
|
|
||
| thawSelection() { |
There was a problem hiding this comment.
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.
| freezeSelection() { | |
| this.adapter.freeze() | |
| } | |
| thawSelection() { | |
| freezeSelection() { | |
| if (!this.adapter) return | |
| this.adapter.freeze() | |
| } | |
| thawSelection() { | |
| if (!this.adapter) return |
| this.adapter.thaw() | ||
| } | ||
|
|
||
| dispatchAttributesChange() { |
There was a problem hiding this comment.
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).
| dispatchAttributesChange() { | |
| dispatchAttributesChange() { | |
| if (!this.adapter || !this.editor) return |
| this.languagePickerElement.addEventListener("change", () => { | ||
| this.#updateCodeBlockLanguage(this.languagePickerElement.value) | ||
| }) | ||
|
|
||
| this.languagePickerElement.addEventListener("mousedown", (event) => { |
There was a problem hiding this comment.
#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.
| freeze() { | ||
| let frozenLinkKey = null | ||
| this.editorElement.editor?.getEditorState().read(() => { | ||
| const selection = $getSelection() | ||
| if (!$isRangeSelection(selection)) return |
There was a problem hiding this comment.
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.
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
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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).
| 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 | |
| } |
| #scheduleEditorInitializedDispatch() { | ||
| this.#cancelEditorInitializedDispatch() | ||
| this.#editorInitializedRafId = requestAnimationFrame(() => { | ||
| this.#editorInitializedRafId = null | ||
| if (!this.isConnected || !this.adapter) return | ||
|
|
||
| dispatch(this, "lexxy:initialize") | ||
| this.#dispatchEditorInitialized() | ||
| }) |
There was a problem hiding this comment.
#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.
| 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" | ||
| } |
There was a problem hiding this comment.
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.
| blobUrlTemplate: this.editorElement.blobUrlTemplate, | ||
| editor: this.editor |
There was a problem hiding this comment.
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.
| blobUrlTemplate: this.editorElement.blobUrlTemplate, | |
| editor: this.editor | |
| blobUrlTemplate: this.editorElement.blobUrlTemplate |
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.
This support functionality needs to be paired with a Strada controller and native mobile implementation.