From 089e9a7e6b0e33b7c0b7c0d246a4ba5c68cc8fea Mon Sep 17 00:00:00 2001 From: Gadzhi Gadzhiev <168296+resure@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:54:17 +0300 Subject: [PATCH] fix(SelectionContext): keep selection tooltip alive across plugin view re-creations and native context menus ProseMirror destroys and re-creates plugin views whenever state.plugins changes identity (any host-side EditorState.create, even one that passes the current plugins through). The spec's destroyed flag stayed true after that, so the mouse release was ignored, the press gate stuck, and the tooltip never showed again. Re-arm the flag in view() and re-evaluate on release without the mousedown snapshot, so a click that leaves the doc/selection unchanged re-shows the tooltip instead of stranding it hidden. Rework the press gate around a single armPressGate helper that owns its document listeners via AbortController: - treat ctrl+click as a context-menu press: macOS reports it with button 0 while the native menu still swallows the mouseup, which left the gate stuck - gate updates during a non-primary press so the browser's right-click word-selection doesn't pop the tooltip up under the open native menu; the gate releases on the next input that reaches the page - keep the gate armed across a mid-press plugin view re-creation instead of resetting it, so a host-side state swap during a drag doesn't un-gate updates - disarm the pending mouseup listener on dragstart instead of leaving it to fire on the next unrelated mouseup - ignore chorded secondary-button releases and releases whose editor was destroyed while the button was held --- .../behavior/SelectionContext/index.test.ts | 238 ++++++++++++++++++ .../behavior/SelectionContext/index.ts | 109 ++++++-- 2 files changed, 327 insertions(+), 20 deletions(-) create mode 100644 packages/editor/src/extensions/behavior/SelectionContext/index.test.ts diff --git a/packages/editor/src/extensions/behavior/SelectionContext/index.test.ts b/packages/editor/src/extensions/behavior/SelectionContext/index.test.ts new file mode 100644 index 000000000..13b692437 --- /dev/null +++ b/packages/editor/src/extensions/behavior/SelectionContext/index.test.ts @@ -0,0 +1,238 @@ +import {EditorState, type Plugin, TextSelection} from 'prosemirror-state'; +import {builders} from 'prosemirror-test-builder'; +import {EditorView} from 'prosemirror-view'; + +import {ExtensionsManager} from '../../../core'; +import {Logger2} from '../../../logger'; +import {BaseNode, BaseSchemaSpecs} from '../../base/specs'; + +import {SelectionContext} from './index'; + +const logger = new Logger2().nested({env: 'test'}); + +const views: EditorView[] = []; + +/** + * Builds the SelectionContext plugin through the public extension and replaces its + * TooltipView's show/hide with recorders (the real ones render a React popup and + * need the ReactRenderer extension; the plugin's gating logic under test does not), + * then mounts a focused editor view with ` world` selected + */ +function setup() { + const {schema, plugins} = new ExtensionsManager({ + extensions: (builder) => + builder.use(BaseSchemaSpecs, {}).use(SelectionContext, {config: [[]]}), + logger, + }).build(); + const plugin = plugins[0]; + const {tooltip} = plugin.spec as unknown as { + tooltip: {show(view: EditorView): void; hide(view: EditorView): void}; + }; + const show = jest.spyOn(tooltip, 'show').mockImplementation(() => {}); + const hide = jest.spyOn(tooltip, 'hide').mockImplementation(() => {}); + + const {doc, p} = builders<'doc' | 'p'>(schema, { + doc: {nodeType: BaseNode.Doc}, + p: {nodeType: BaseNode.Paragraph}, + }); + const initialDoc = doc(p('hello world')); + const view = new EditorView(document.body.appendChild(document.createElement('div')), { + state: EditorState.create({ + doc: initialDoc, + selection: TextSelection.create(initialDoc, 1, 6), + plugins: [plugin], + }), + }); + jest.spyOn(view, 'hasFocus').mockReturnValue(true); + views.push(view); + + return {plugin, view, show, hide}; +} + +/** Calls the plugin's mousedown DOM-event handler, as prosemirror-view does on a press */ +function press(plugin: Plugin, view: EditorView, init: MouseEventInit = {}): void { + plugin.props.handleDOMEvents?.mousedown?.call( + plugin, + view, + new MouseEvent('mousedown', {button: 0, ...init}), + ); +} + +/** The document-level mouseup release the plugin's press gate waits for */ +function releaseMouse(button = 0): void { + document.dispatchEvent(new MouseEvent('mouseup', {button})); +} + +/** A document-level keydown — the input that releases a context-menu press gate */ +function pressKey(): void { + document.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowRight'})); +} + +function changeSelection(view: EditorView, from: number, to: number): void { + view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, from, to))); +} + +/** + * Swaps in a state built by EditorState.create: it always builds a fresh plugins array, + * and prosemirror-view responds by destroying and re-creating every plugin view + */ +function recreatePluginViews(view: EditorView): void { + view.updateState( + EditorState.create({ + doc: view.state.doc, + selection: view.state.selection, + plugins: view.state.plugins, + }), + ); +} + +afterEach(() => { + releaseMouse(); // flush any still-armed press gate + for (const view of views.splice(0)) view.destroy(); + document.body.innerHTML = ''; + jest.restoreAllMocks(); +}); + +describe('SelectionContext', () => { + it('shows the tooltip after a mouse selection (press → select → release)', () => { + const {plugin, view, show, hide} = setup(); + show.mockClear(); + hide.mockClear(); + + press(plugin, view); + expect(hide).toHaveBeenCalledTimes(1); // the press hides the tooltip… + changeSelection(view, 1, 12); // …selection grows while pressed (updates are gated)… + expect(show).not.toHaveBeenCalled(); + releaseMouse(); // …and the release re-evaluates and shows it + + expect(show).toHaveBeenCalledTimes(1); + }); + + it('re-shows the tooltip on a click that keeps doc and selection unchanged', () => { + const {plugin, view, show} = setup(); + show.mockClear(); + + // Press and release over the existing selection (e.g. re-selecting the same word): + // doc and selection are unchanged, but the press just hid the tooltip — + // "unchanged" must not strand it hidden over a live selection + press(plugin, view); + releaseMouse(); + + expect(show).toHaveBeenCalledTimes(1); + }); + + it('shows the tooltip after a host-side state swap re-creates the plugin views', () => { + const {plugin, view, show} = setup(); + + recreatePluginViews(view); + show.mockClear(); + + // One click over the unchanged selection: a release ignored because of the + // re-creation would leave the tooltip hidden forever + press(plugin, view); + releaseMouse(); + + expect(show).toHaveBeenCalledTimes(1); + }); + + it('keeps the press gate through a mid-press plugin view re-creation', () => { + const {plugin, view, show} = setup(); + show.mockClear(); + + // A host-side state swap while the button is still held must not un-gate + // updates: the tooltip would show and chase the cursor for the rest of the drag + press(plugin, view); + recreatePluginViews(view); + changeSelection(view, 1, 12); + expect(show).not.toHaveBeenCalled(); + + releaseMouse(); + expect(show).toHaveBeenCalledTimes(1); + }); + + it('gates updates during a non-primary press and releases on the next input', () => { + const {plugin, view, show, hide} = setup(); + show.mockClear(); + hide.mockClear(); + + // A right press hides the tooltip and may open the native context menu: the menu + // can swallow the release, and the press itself may select the clicked word — + // that selection must not pop the tooltip up under the open menu + press(plugin, view, {button: 2}); + expect(hide).toHaveBeenCalledTimes(1); + changeSelection(view, 1, 12); // the browser's word-selection on right-click + expect(show).not.toHaveBeenCalled(); + + // The menu can swallow the mouseup, so the next input reaching the page + // releases the gate (e.g. a keyboard-driven selection) + pressKey(); + changeSelection(view, 1, 6); + + expect(show).toHaveBeenCalledTimes(1); + }); + + it('treats ctrl+click on macOS as a context-menu press (reported with button 0)', () => { + jest.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel'); + const {plugin, view, show} = setup(); + show.mockClear(); + + // macOS opens the native context menu for ctrl+click and swallows the mouseup; + // arming the primary mouseup gate would leave it stuck and block all updates + press(plugin, view, {button: 0, ctrlKey: true}); + changeSelection(view, 1, 12); // the browser's word-selection under the menu + expect(show).not.toHaveBeenCalled(); + + pressKey(); // the next input releases the gate… + changeSelection(view, 1, 6); // …and a later selection shows the tooltip again + + expect(show).toHaveBeenCalledTimes(1); + }); + + it('treats ctrl+click elsewhere as a primary press', () => { + jest.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32'); + const {plugin, view, show} = setup(); + show.mockClear(); + + // Outside macOS ctrl+click doesn't open the context menu — the ordinary + // press-release cycle applies, including the unchanged-selection re-show + press(plugin, view, {button: 0, ctrlKey: true}); + releaseMouse(); + + expect(show).toHaveBeenCalledTimes(1); + }); + + it('ignores a secondary-button release while the primary button is held', () => { + const {plugin, view, show} = setup(); + show.mockClear(); + + // A middle/right-button tap during a left drag must not consume the gate: + // the tooltip would show over the still-growing selection + press(plugin, view); + releaseMouse(1); + changeSelection(view, 1, 12); + expect(show).not.toHaveBeenCalled(); + + releaseMouse(); + expect(show).toHaveBeenCalledTimes(1); + }); + + it('releases the gate when the press turns into a native drag', () => { + const {plugin, view, show} = setup(); + show.mockClear(); + + // A drag ends with dragend, not mouseup — dragstart must release the gate + press(plugin, view); + plugin.props.handleDOMEvents?.dragstart?.call( + plugin, + view, + new Event('dragstart') as DragEvent, + ); + changeSelection(view, 1, 12); + expect(show).toHaveBeenCalledTimes(1); + + // …and disarm the pending mouseup listener: a later unrelated mouseup must + // not spuriously re-evaluate (e.g. re-show a tooltip the user just dismissed) + releaseMouse(); + expect(show).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/editor/src/extensions/behavior/SelectionContext/index.ts b/packages/editor/src/extensions/behavior/SelectionContext/index.ts index 0d900a404..120305012 100644 --- a/packages/editor/src/extensions/behavior/SelectionContext/index.ts +++ b/packages/editor/src/extensions/behavior/SelectionContext/index.ts @@ -17,6 +17,7 @@ import type {EditorProps, EditorView} from 'prosemirror-view'; import type {ActionStorage, ExtensionAuto} from '../../../core'; import type {Logger2} from '../../../logger'; import {isCodeBlock} from '../../../utils/nodes'; +import {isMac} from '../../../utils/platform'; import {type ContextConfig, TooltipView} from './tooltip'; @@ -56,20 +57,25 @@ export const hideSelectionMenu = (tr: Transaction) => { const pluginKey = new PluginKey('selection-context'); +/** + * A press that may open the native context menu, or any other non-primary press whose + * release is unreliable. macOS opens the menu during mousedown — swallowing the following + * mouseup — and reports ctrl+click with button 0 + */ +function isContextMenuPress(event: MouseEvent): boolean { + return event.button !== 0 || (isMac() && event.ctrlKey); +} + type PluginState = { disabled: boolean; }; -type TinyState = Pick; - class SelectionTooltip implements PluginSpec { - private destroyed = false; - private tooltip: TooltipView; private editorView: EditorView | null = null; private hideTimeoutRef: ReturnType | null = null; - private _isMousePressed = false; + private _releasePressGate: (() => void) | null = null; constructor( actions: ActionStorage, @@ -104,22 +110,43 @@ class SelectionTooltip implements PluginSpec { }, }), handleDOMEvents: { - mousedown: (view) => { - const startState: TinyState = { - doc: view.state.doc, - selection: view.state.selection, - }; - this._isMousePressed = true; + mousedown: (view, event) => { this.cancelTooltipHiding(); this.tooltip.hide(view); - const onMouseUp = () => { - if (this.destroyed) return; - this._isMousePressed = false; - this.update(view, startState); - }; + // The native menu may swallow the document mouseup, and the press itself + // may select the clicked word. Keep updates gated so the tooltip doesn't + // pop up under the menu, and release the gate on the next input that + // reaches the page + if (isContextMenuPress(event)) { + this.armPressGate({ + mousedown: () => true, + keydown: () => true, + mouseup: (e) => e.button === 0, + }); + return; + } - document.addEventListener('mouseup', onMouseUp, {once: true}); + this.armPressGate( + // a chorded secondary-button release must not consume the gate + {mouseup: (e) => e.button === 0}, + () => { + // Ignore a release whose editor or plugin was replaced while + // the button was held + if (view.isDestroyed || pluginKey.get(view.state)?.spec !== this) { + return; + } + // Re-evaluate without the mousedown snapshot: mousedown just hid + // the tooltip, so an unchanged doc/selection must show it again, + // not keep it hidden + this.update(view); + }, + ); + }, + // A press that turns into a native drag ends with dragend, not mouseup — + // release the gate and disarm its listeners here + dragstart: () => { + this._releasePressGate?.(); }, }, }; @@ -139,17 +166,25 @@ class SelectionTooltip implements PluginSpec { return { update: this.update.bind(this), destroy: () => { - this.destroyed = true; this.cancelTooltipHiding(); this.tooltip.destroy(); + // ProseMirror re-creates plugin views synchronously (destroy, then view()) + // whenever state.plugins changes identity — a press in flight must keep its + // gate through that. Disarm only on a real editor teardown, so the gate's + // document listeners don't pin the destroyed view until the next input + queueMicrotask(() => { + if (view.isDestroyed) this._releasePressGate?.(); + }); }, }; } - private update(view: EditorView, prevState?: TinyState) { + private update(view: EditorView, prevState?: EditorState) { this.editorView = view; - if (this._isMousePressed) return; + // A press interaction is in flight (button held, or a native context menu + // possibly open) — the gate's release will re-evaluate when it ends + if (this._releasePressGate) return; this.cancelTooltipHiding(); @@ -197,6 +232,40 @@ class SelectionTooltip implements PluginSpec { this.tooltip.show(view); } + /** + * Blocks tooltip updates until one of the listed document events passes its release + * check; arming a new gate releases the previous one. The gate intentionally survives + * plugin view re-creations (its release is the only reliable end of a press + * interaction) — only a real editor teardown disarms it early + */ + private armPressGate( + releaseOn: { + [K in 'mousedown' | 'keydown' | 'mouseup']?: (e: DocumentEventMap[K]) => boolean; + }, + onRelease?: () => void, + ) { + this._releasePressGate?.(); + + const controller = new AbortController(); + const release = () => { + controller.abort(); + this._releasePressGate = null; + }; + this._releasePressGate = release; + + for (const [type, shouldRelease] of Object.entries(releaseOn)) { + document.addEventListener( + type, + (event) => { + if (!(shouldRelease as (e: Event) => boolean)(event)) return; + release(); + onRelease?.(); + }, + {capture: true, signal: controller.signal}, + ); + } + } + private scheduleTooltipHiding(view: EditorView) { this.hideTimeoutRef = setTimeout(() => { // hide tooltip if view is out of focus after 30 ms