Repository navigation
fix: keep the visual editor's insert symbol popup open across decoration-only transactions - #1204
kevinushey wants to merge 9 commits into
Conversation
The realtime spelling plugin dispatches an updateSpelling transaction 100ms and 5s after the editor is first focused, whether or not realtime spelling is enabled. InsertSymbolPlugin closed its popup on every plugin view update, so an Insert Emoji / Insert Unicode popup still open at the 5s mark vanished mid-typing. Only close the popup when the document or selection changed.
gtritchie
left a comment
There was a problem hiding this comment.
Two things to address before merging; details inline.
| - Fixed a bug where the first character of lines outside code cells (paragraphs, headings, YAML) and of IPython magic lines in Python cells (`%`, `!`) could be highlighted as a comment (<https://github.com/quarto-dev/quarto/issues/985>). | ||
| - Fixed "Quarto: Preview" and render in the terminal when the document path contains shell special characters, such as parentheses, `;`, `@`, `$`, or quotes (<https://github.com/quarto-dev/quarto/pull/1197>). | ||
| - In Positron, the Viewer now shows a stop button for Quarto previews. Clicking it stops `quarto preview` and closes the preview. The button goes away when the preview server stops (<https://github.com/quarto-dev/quarto/pull/1193>). | ||
| - In the visual editor, the Insert Emoji and Insert Unicode popups no longer close on their own about five seconds after the editor is first focused, which could happen while a search term was still being typed (<https://github.com/quarto-dev/quarto/pull/1204>). |
There was a problem hiding this comment.
1.139.0 has been released since this branch was cut. main now has ## 1.139.0 (Release on 2026-10-20) and an empty ## 1.140.0 (Unreleased) above it. This hunk merges without a conflict, so the entry would land in the released 1.139.0 section. Please move it under 1.140.0.
There was a problem hiding this comment.
Merged main and moved the entry under 1.140.0 in 893fdee.
| // focused), so only treat a change to the document or selection as | ||
| // a reason to dismiss the popup. | ||
| if (!view.state.doc.eq(prevState.doc) || !view.state.selection.eq(prevState.selection)) { | ||
| this.closePopup(); |
There was a problem hiding this comment.
'onfocus' isn't a DOM event name, so this listener and the one added at line 68 never fire, and focusChanged never runs. completion.ts uses 'focusin' for the same purpose.
This predates the PR, but it matters more now. Previously the popup was sometimes closed after focus moved elsewhere by an unrelated transaction (the spelling updates, a theme change). With this change only document/selection changes, scroll, Escape, and destroy close it. Suggest 'focusin' in both places.
There was a problem hiding this comment.
Good catch, switched both to 'focusin' in d906240.
Making the listener live exposed one more thing: Editor.commands() refocuses the editor after a command unless keepFocus is false, and the Emoji/Symbol commands used the default. From a toolbar menu (which takes DOM focus in RStudio), that refocus fires focusin while the popup exists but before it has rendered, and focusChanged closes it immediately. So the two commands now pass keepFocus: false; the popup focuses its own input on mount and already calls view.focus() on Escape and on insert, so nothing else needed the refocus.
…popup-spelling-update
… event 'onfocus' is not a DOM event name, so the focus listener never fired and the popup was never dismissed when focus moved elsewhere. Listen for 'focusin' instead, as the completion plugin does. The editor's command wrapper refocuses the editor after a command runs (keepFocus defaults to true). With a working listener that refocus closes the popup before it renders whenever the command came from a toolbar menu, which takes DOM focus. The popup focuses its own input once mounted, so the two commands now pass keepFocus: false.
| // The popup takes focus itself once it mounts, and focusChanged closes it | ||
| // when focus lands outside of it. Refocusing the editor after the command | ||
| // would fire that before the popup has rendered, so keepFocus is false. | ||
| return [new ProsemirrorCommand(EditorCommandId.Emoji, [], performInsertSymbol(key), emojiOmniInsert(ui), false)]; |
There was a problem hiding this comment.
With the focusin listener now working, I think choosing Emoji... or Symbol... from the / omni insert list with the mouse still closes the popup immediately (traced through the code, not run in a browser):
- The completion popup is a
<div>ondocument.bodywith non-focusable rows and nomousedownhandler (completion-popup.tsx), so the mousedown moves focus off the editor. insertCompletion(completion.ts) runsreplace, which opens the popup synchronously, then callsview.focus()because neither inserter setsnoFocus.- That refocus fires
focusinon the editor while the popup is open, sofocusChangedcloses it before the search input takes focus.
Keyboard selection is unaffected, since the editor already has focus and view.focus() fires no event. Setting noFocus: true in emojiOmniInsert and symbolOmniInsert, as the table inserter does, should cover it.
There was a problem hiding this comment.
Agreed, the trace holds up. Both omni inserters now set noFocus: true in 3abffca, matching the table inserter. No regression test: the editor package tests run under plain node --test with no DOM, and the VS Code harness has no bridge into the webview, so this stays a manual check.
There was a problem hiding this comment.
Follow-up after actually running this in a VS Code webview (dev build driven over CDP, /emoji then a mouse click on the Emoji... row):
The trace is right, but it did not reproduce as pushed, and the reason is a pre-existing bug that also hides the focusin rename. On the webview document the only focusin listener was Fluent UI's keyborg tracker; neither focusChanged here nor clearCompletions in completion.ts was attached. Both plugins add the listener in the constructor and remove it in the plugin view's destroy. The initial setMarkdown builds a new state with EditorState.create({ plugins: this.state.plugins }), prosemirror-state copies that array, so view.updateState sees a different plugins identity, destroys and recreates every plugin view with the same instances, and the constructor-registered listeners are gone for good.
95e7595 moves both addEventListener calls into the plugin view factory, paired with the existing removeEventListener in destroy. With that in place the scenario you described reproduces exactly (the editor refocus from insertCompletion closes the popup), and 3abffca (noFocus: true on both inserters) fixes it: the popup stays open with its search input focused, and refocusing the editor while it is open now closes it, as intended. Changelog updated to mention that the completion popups now also close on focus change, since that is new behaviour in practice.
gtritchie
left a comment
There was a problem hiding this comment.
Thanks, all three points are addressed. Approving on the condition that the pending CI jobs (Quarto release, Quarto pre-release, Positron API) pass.
|
Summary of the changes made in response to review, and what they turned up. Changes
Why the last oneRunning the mouse-pick scenario in a VS Code webview (dev build driven over CDP) showed that neither popup had a Verified on the final build
The completion popups (citations, cross references, |
gtritchie
left a comment
There was a problem hiding this comment.
The focusin change is right, but the scroll subscriptions have the same problem; details inline.
| @@ -65,7 +79,6 @@ export class InsertSymbolPlugin extends Plugin<boolean> { | |||
| this.scrollUnsubscribe = events.subscribe(ScrollEvent, this.closePopup); | |||
There was a problem hiding this comment.
The reasoning in the new view() comment applies to this subscription too. It's made once here in the constructor, but destroy calls this.scrollUnsubscribe(), and subscribe really removes the DOM listener (api/events.ts:41-43). After the initial setMarkdown recreates the plugin views, nothing subscribes again, so scrolling the editor no longer closes the popup. The popup is absolutely positioned on document.body, so it stays put on screen while the text scrolls under it.
This predates the PR. Subscribing in view() alongside the focusin listener would fix it, which means scrollUnsubscribe can no longer be readonly and assigned in the constructor.
There was a problem hiding this comment.
Confirmed, same failure mode as the focusin listener. Fixed in cf18260: the subscription now happens in view() next to the focusin listener. I kept the unsubscribe function as a local in the view() closure instead of a mutable field, since each view() call is paired with exactly one destroy, so the field could go away entirely. Changelog updated to mention that scrolling now dismisses the popups.
| // hide completions when we scroll or the focus changes | ||
| // hide completions when we scroll | ||
| this.clearCompletions = this.clearCompletions.bind(this); | ||
| this.scrollUnsubscribe = events.subscribe(ScrollEvent, this.clearCompletions); |
There was a problem hiding this comment.
Same issue as the scroll subscription in InsertSymbolPlugin: subscribed once here, unsubscribed in the view's destroy, and never subscribed again after the first plugin view recreation. So scrolling no longer clears the completion popup. Moving it into view() next to the focusin listener fixes it.
There was a problem hiding this comment.
Fixed in cf18260, same approach as InsertSymbolPlugin.
…w recreation The insert symbol and completion plugins subscribed to ScrollEvent once in their constructors and unsubscribed in the plugin view's destroy. The initial setMarkdown recreates every plugin view, so the subscription was gone after that and scrolling no longer dismissed either popup. Subscribe in view() alongside the focusin listener, with the unsubscribe function held in the closure rather than a mutable field.
MathPopupPlugin had the same bug as the insert symbol and completion plugins: the 100ms reposition timer and the scroll and resize subscriptions were created once in the constructor and torn down in the plugin view's destroy, so after the initial setMarkdown recreated the plugin views the equation preview only repositioned on transactions. Create them in view() with the handles held in the closure. Note that this re-enables the 100ms timer, which has been effectively dead after the first setMarkdown. It is cheap: updatePopup bails on the first getMarkRange check whenever the selection is not in math.
|
One more instance of the same pattern, fixed in b0c0b73: Worth knowing: this re-enables the 100ms timer, which has effectively been dead in shipped builds. It is cheap since |
In the visual editor, the Insert Special Characters > Emoji and Insert Special Characters > Unicode popups close on their own about five seconds after the editor is first focused, usually while the user is still typing a search term.
Cause
RealtimeSpellingPlugin(packages/editor/src/behaviors/spelling/spelling-realtime.ts) subscribes to the editor's firstFocusEventand schedulesupdateSpelling()twice, at 100ms and at 5s. Each call dispatches a transaction carrying only theupdateSpellingmeta; the realtime-spelling preference only gates the decorations computed from it, so the transaction is dispatched even when spelling is off.InsertSymbolPlugin(packages/editor/src/behaviors/insert_symbol/insert_symbol-plugin.tsx) closed its popup from the plugin view'supdate()hook on every transaction. A popup still open when the 5s transaction lands is unmounted under the user.Call stack from a captured repro (RStudio, Firefox 151):
focus->emitEvent(FocusEvent)-> spellingsetTimeout->updateSpelling->view.dispatch->updatePluginViews->InsertSymbolPlugin.update->closePopup.Fix
update(view, prevState)now closes the popup only when the document or the selection changed. Inserting a symbol still dispatches a document change, so the popup still closes after a selection is committed, and clicking elsewhere in the document still dismisses it. Decoration-only transactions leave it alone.Review also turned up that the plugin's focus listener was registered for
'onfocus', which is not a DOM event, so the popup was never dismissed when focus moved elsewhere. It now listens for'focusin', like the completion plugin. BecauseEditor.commands()refocuses the editor after every command unlesskeepFocusis false, and a toolbar menu takes DOM focus in RStudio, that refocus would have closed the popup before it rendered. The Emoji and Symbol commands therefore passkeepFocus: false; the popup focuses its own search input on mount and already returns focus to the editor on Escape and on insert.Verification
Harness-free Playwright script against an RStudio dev server, in the Playwright Linux image, with the fix applied to the served
panmirror.jsby string replacement:This is what has been failing the "insert special characters" visual editor test in RStudio's Firefox e2e jobs since the panmirror pin moved to
release/rstudio-montauk-daisy; the code involved is unchanged there, the newer bundle only shifted the timing.