Skip to content

fix(SelectionContext): keep selection tooltip alive after plugin view re-creation#1171

Open
resure wants to merge 2 commits into
gravity-ui:mainfrom
resure:fix/selection-context-tooltip-rearm
Open

fix(SelectionContext): keep selection tooltip alive after plugin view re-creation#1171
resure wants to merge 2 commits into
gravity-ui:mainfrom
resure:fix/selection-context-tooltip-rearm

Conversation

@resure

@resure resure commented Jul 2, 2026

Copy link
Copy Markdown

Text below and fix itself is mostly ai-generated, but I've also checked suggested fix manually on my project that uses markdown-editor


The floating selection toolbar (WYSIWYG mode, non-empty selectionContext config) goes permanently dead after any host-side EditorState swap.

The bug

SelectionTooltip keeps two mutable flags on the extension instance — destroyed and _isMousePressed — and nothing re-arms them when its plugin view is re-created. ProseMirror re-creates every plugin view whenever state.plugins changes identity, and EditorState.create always builds a fresh plugins array — so any host-side state swap triggers this (e.g. an undo-history reset), even one that passes view.state.plugins through unchanged:

  1. The swap destroys the plugin views → destroy() sets destroyed = true. The view is re-created immediately, but view() never resets the flag.
  2. The next mousedown hides the tooltip, sets _isMousePressed = true, and arms a one-shot document mouseup listener.
  3. That listener starts with if (this.destroyed) return — so _isMousePressed is never reset.
  4. Every later update() exits at if (this._isMousePressed) return. The toolbar never appears again for the lifetime of the editor — no errors, nothing in the console.

Minimal reproduction:

// 1. Select text with the mouse → the floating toolbar appears. Fine.
// 2. Swap the state once:
view.updateState(EditorState.create({doc: view.state.doc, plugins: view.state.plugins}));
// 3. Click once anywhere in the editor.
// 4. Select any text, with mouse or keyboard → the toolbar never appears again.

Two related flaws in the same handler:

  • The mouseup re-check passes the state snapshotted at mousedown, and update() skips when doc + selection are unchanged since then. But the mousedown just hid the tooltip — so a press-release cycle that keeps the selection (e.g. re-selecting the same word) strands the toolbar hidden over a live selection.
  • The mouseup gate is armed for every button, but some presses never deliver a document mouseup at all — a right-click on macOS (the native context menu opens during mousedown and swallows the release), or a press that turns into a native drag (ends with dragend) — wedging _isMousePressed until the next completed left click.

The fix

  • view(): re-arm destroyed and _isMousePressed when the plugin view is (re-)created — plugin views are re-created far more often than plugins are.
  • mouseup: re-evaluate without the mousedown snapshot — "unchanged" must not mean "keep hidden".
  • mousedown: arm the gate only for the primary button (a non-left press still hides, as before), and release it on dragstart.

The plugin-view update path keeps its prevState short-circuit — that one is correct (the hide-meta check runs before it).

Tests

The new index.test.ts drives the plugin through the public SelectionContext extension with a real EditorView in jsdom, including a real view.updateState(EditorState.create(...)) swap for the main scenario — so it exercises ProseMirror's actual plugin-view re-creation. On current main the baseline test passes and the four regression tests fail (one per flaw); with the fix all five pass. The package unit suite stays green.

Found in gravity-notes, which currently ships a vendored copy of this plugin with these fixes; also verified end-to-end there in a packaged app with the plugin's gate decisions instrumented (before: mousedown → mouseup → (nothing), toolbar dead; after: mousedown → mouseup → SHOW).

Summary by Sourcery

Fix SelectionContext tooltip behavior so the floating selection toolbar remains functional after editor state swaps and various mouse interactions.

Bug Fixes:

  • Prevent the selection tooltip from becoming permanently disabled after plugin view re-creation by rearming internal state flags when the plugin view is created.
  • Ensure the tooltip reappears after clicks that keep the document and selection unchanged instead of remaining hidden.
  • Avoid wedging tooltip updates on non-primary mouse button presses that may not emit a corresponding mouseup event.
  • Release the tooltip’s mouseup gating when a mouse press turns into a native drag sequence so subsequent updates are not blocked.

Tests:

  • Add integration-style tests for SelectionContext using a real EditorView to cover state swaps, unchanged-click scenarios, non-primary button presses, and drag interactions.

Summary by Sourcery

Ensure the floating selection tooltip remains functional and correctly gated around mouse interactions and editor state swaps.

Bug Fixes:

  • Prevent the selection tooltip from becoming permanently disabled after editor state swaps that recreate plugin views.
  • Allow the tooltip to reappear after click interactions that leave the document and selection unchanged instead of remaining hidden.
  • Avoid situations where non-primary or context-menu mouse presses wedge tooltip updates by handling unreliable mouseup events and drag starts.

Tests:

  • Add integration-style tests for SelectionContext using a real EditorView to cover state swaps, unchanged-click scenarios, context-menu presses, multi-button interactions, and native drags.

@gravity-ui

gravity-ui Bot commented Jul 2, 2026

Copy link
Copy Markdown

🎭 Playwright Report

resure added a commit to resure/gravity-notes that referenced this pull request Jul 2, 2026
The virtualized note list now renders only an IconPickerButton glyph per
row and shares ONE controlled IconPickerPopup (the split lives in
IconPicker.tsx) — a per-row picker was a large render cost and an open
one died when its row left the virtual window. Plus fixes from a
max-effort review of the change:

- editorCaret: the no-rect first-line fallback treats block boundaries
  as line breaks (an empty second bullet/blockquote line no longer
  yanks ArrowUp to the title), skips ProseMirror-trailingBreak
  placeholders (a caret at (p,1) on a blank line still hands off),
  gates on the collapsed range start (backward selections), and scans
  with comparePoint instead of deep-cloning via cloneContents
- NoteList: Enter on a focused row button activates the button instead
  of opening the note; the ⋯ menu gains the picker's anchor keepalive +
  close-when-note-leaves; the shared popup is memoized with stable
  handlers (live-ref) and drops picks whose note id went stale
- IconPicker: popup resets query/tab/scroll/highlight on every close so
  no state leaks across rows; open derived from the anchor
- Workspace: F2 no-ops inside any floating layer (.g-popup /
  [role=dialog]) instead of a per-widget class blocklist
- docs: EDITOR_BUG_REPORT.md superseded by the filed upstream PR
  (gravity-ui/markdown-editor#1171, linked in selectionContextFix.ts);
  HANDOFF.md follow-ups moved to README (fonts-CSP known limitation,
  icon-picker backlog); note-icons feature bullet added

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@resure resure force-pushed the fix/selection-context-tooltip-rearm branch from 255ed54 to 00d197b Compare July 6, 2026 12:54
…w 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
@resure resure force-pushed the fix/selection-context-tooltip-rearm branch from 00d197b to 089e9a7 Compare July 6, 2026 15:24
@resure resure marked this pull request as ready for review July 7, 2026 17:20
@resure resure requested review from d3m1d0v and makhnatkin as code owners July 7, 2026 17:20
@sourcery-ai

sourcery-ai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Reviewer's Guide

Refactors SelectionContext’s tooltip press-handling to use a reusable gate mechanism that survives plugin view re-creations, correctly re-evaluates state on mouseup, and robustly handles context-menu presses and drags, with new integration-style tests verifying behavior across editor state swaps and diverse input patterns.

File-Level Changes

Change Details Files
Replace fragile destroyed/_isMousePressed flags with a press-gating mechanism that survives plugin view re-creations and safely controls tooltip updates.
  • Remove the destroyed and _isMousePressed flags from the SelectionTooltip plugin spec implementation.
  • Introduce a _releasePressGate callback and armPressGate helper that installs document-level listeners with AbortController to gate tooltip updates until specific events occur.
  • Ensure the press gate is only fully disarmed on real editor teardown via a queued microtask in the plugin view destroy handler so mid-press plugin-view re-creations don’t lose the gate.
packages/editor/src/extensions/behavior/SelectionContext/index.ts
Adjust mouse/keyboard interaction handling so tooltip visibility is correctly updated after clicks, context-menu presses, and drags.
  • Change handleDOMEvents.mousedown to accept the original MouseEvent, always hide the tooltip on press, and conditionally arm different press gates for primary vs. context-menu style presses.
  • Treat right-click and macOS ctrl+click as context-menu presses, gating updates until a keydown or primary mouseup occurs so the tooltip doesn’t reappear under a native menu.
  • For primary-button presses, gate updates until a qualifying mouseup, ignore releases when the editor/plugin has been replaced, and re-evaluate without using a mousedown state snapshot so unchanged selection can still reshow the tooltip.
  • Add dragstart handler to release the press gate when a press turns into a native drag, preventing the gate from being left permanently armed.
  • Update the update() method to check _releasePressGate instead of _isMousePressed and accept an optional full EditorState as prevState.
packages/editor/src/extensions/behavior/SelectionContext/index.ts
Add robust integration tests around SelectionContext tooltip behavior with real EditorView instances and editor state swaps.
  • Introduce a new index.test.ts that builds SelectionContext via ExtensionsManager, spies on TooltipView.show/hide, and exercises a focused editor containing a simple document.
  • Cover mouse selection flows, unchanged-click behavior, and tooltip behavior across host-side state swaps that recreate plugin views.
  • Test that the press gate survives plugin view re-creations mid-press and is correctly released on mouseup, keyboard input after context-menu presses, and dragstart events.
  • Verify platform-specific behavior for ctrl+click on macOS vs other platforms and handling of secondary-button releases during primary drags, as well as ensuring late mouseup events do not spuriously re-show the tooltip.
packages/editor/src/extensions/behavior/SelectionContext/index.test.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • The press-gating logic (armPressGate + handleDOMEvents.mousedown/dragstart) has grown fairly complex; consider extracting it into a small helper module or at least a separate class to keep the plugin spec focused on selection behavior and make the gate’s lifecycle easier to reason about.
  • Tests currently mock platform behavior via navigator.platform while the implementation uses isMac(); stubbing isMac() directly in tests would better match the production path and avoid relying on navigator.platform quirks across environments.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The press-gating logic (armPressGate + handleDOMEvents.mousedown/dragstart) has grown fairly complex; consider extracting it into a small helper module or at least a separate class to keep the plugin spec focused on selection behavior and make the gate’s lifecycle easier to reason about.
- Tests currently mock platform behavior via navigator.platform while the implementation uses isMac(); stubbing isMac() directly in tests would better match the production path and avoid relying on navigator.platform quirks across environments.

## Individual Comments

### Comment 1
<location path="packages/editor/src/extensions/behavior/SelectionContext/index.ts" line_range="78" />
<code_context>
     private hideTimeoutRef: ReturnType<typeof setTimeout> | null = null;

-    private _isMousePressed = false;
+    private _releasePressGate: (() => void) | null = null;

     constructor(
</code_context>
<issue_to_address>
**issue (complexity):** Consider replacing the dynamic `armPressGate`/`_releasePressGate` gating logic with an explicit press-state machine and fixed document listeners to manage tooltip updates during input.

The new `armPressGate` + `_releasePressGate` flow does materially increase complexity; you can get the same behavior with a simpler, explicit state machine and fixed listeners.

Instead of dynamically arming document listeners with a generic `{[eventType]: predicate}` map per press, consider:

1. Make press state explicit.
2. Install a small, fixed set of document listeners once.
3. Drive behavior from `pressState` inside those listeners and `update`.

This keeps all the “gate until next input” semantics, but removes `AbortController`, the indirection via predicates, and the need to reason about which listeners are currently armed.

### 1. Make press state explicit

```ts
type PressState =
  | 'idle'
  | 'primaryPress'
  | 'contextMenuPress'
  | 'dragging';

class SelectionTooltip implements PluginSpec<PluginState> {
  private pressState: PressState = 'idle';
  private teardownPressListeners: (() => void) | null = null;

  // ...
}
```

### 2. Register fixed document listeners once

Install them when the plugin view is created, and tear them down on real editor destroy. This replaces `armPressGate` and `_releasePressGate`:

```ts
view(view: EditorView) {
  this.update(view);

  const onDocMouseUp = (event: MouseEvent) => {
    if (this.pressState === 'primaryPress' && event.button === 0) {
      this.pressState = 'idle';
      if (!view.isDestroyed) this.update(view);
    } else if (this.pressState === 'contextMenuPress' && event.button === 0) {
      this.pressState = 'idle';
      if (!view.isDestroyed) this.update(view);
    }
  };

  const onDocMouseDown = () => {
    if (this.pressState === 'contextMenuPress') {
      this.pressState = 'idle';
      if (!view.isDestroyed) this.update(view);
    }
  };

  const onDocKeyDown = () => {
    if (this.pressState !== 'idle') {
      this.pressState = 'idle';
      if (!view.isDestroyed) this.update(view);
    }
  };

  document.addEventListener('mouseup', onDocMouseUp, {capture: true});
  document.addEventListener('mousedown', onDocMouseDown, {capture: true});
  document.addEventListener('keydown', onDocKeyDown, {capture: true});

  this.teardownPressListeners = () => {
    document.removeEventListener('mouseup', onDocMouseUp, {capture: true} as any);
    document.removeEventListener('mousedown', onDocMouseDown, {capture: true} as any);
    document.removeEventListener('keydown', onDocKeyDown, {capture: true} as any);
  };

  return {
    update: this.update.bind(this),
    destroy: () => {
      this.cancelTooltipHiding();
      this.tooltip.destroy();
      queueMicrotask(() => {
        if (view.isDestroyed) {
          this.pressState = 'idle';
          this.teardownPressListeners?.();
          this.teardownPressListeners = null;
        }
      });
    },
  };
}
```

This preserves the “survive plugin view re-creations, but not editor teardown” behavior without storing closures per press or an `AbortController`.

### 3. Drive press state via `handleDOMEvents`

`mousedown` sets the state explicitly instead of arming a gate:

```ts
handleDOMEvents: {
  mousedown: (view, event) => {
    this.cancelTooltipHiding();
    this.tooltip.hide(view);

    if (isContextMenuPress(event)) {
      // native menu may swallow mouseup; treat as contextMenuPress
      this.pressState = 'contextMenuPress';
      return;
    }

    // chorded secondary-button releases can be handled inside the fixed mouseup handler
    this.pressState = 'primaryPress';
  },
  dragstart: () => {
    // drag ends with dragend, not mouseup; we just mark dragging
    this.pressState = 'dragging';
  },
},
```

You can handle `dragend` either with another fixed document listener or a `view.dom` listener:

```ts
const onDocDragEnd = () => {
  if (this.pressState === 'dragging') {
    this.pressState = 'idle';
  }
};
document.addEventListener('dragend', onDocDragEnd, {capture: true});
```

### 4. Simplify `update`

With explicit `pressState`, you no longer need `_releasePressGate` or an `EditorState` param:

```ts
private update(view: EditorView) {
  this.editorView = view;

  // A press interaction is in flight (button held, or a native context menu possibly open)
  if (this.pressState !== 'idle') return;

  this.cancelTooltipHiding();

  const hideFromTr = pluginKey.getState(view.state)?.disabled;
  if (hideFromTr || !view.dom.parentNode) {
    this.tooltip.hide(view);
    return;
  }

  // ...rest unchanged
}
```

This keeps behavior (don’t update while a press is in flight) but removes the hidden gating via `_releasePressGate` and the need for the generic `armPressGate` API.

---

Overall effect:

- Same robustness around context menu presses, macOS ctrl+click, and drag.
- No dynamic listener maps or `AbortController` per press.
- No need to mentally simulate predicate logic across multiple “armed” gates.
- Tooltip behavior is now a straightforward function of `pressState` + `EditorState`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

private hideTimeoutRef: ReturnType<typeof setTimeout> | null = null;

private _isMousePressed = false;
private _releasePressGate: (() => void) | null = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (complexity): Consider replacing the dynamic armPressGate/_releasePressGate gating logic with an explicit press-state machine and fixed document listeners to manage tooltip updates during input.

The new armPressGate + _releasePressGate flow does materially increase complexity; you can get the same behavior with a simpler, explicit state machine and fixed listeners.

Instead of dynamically arming document listeners with a generic {[eventType]: predicate} map per press, consider:

  1. Make press state explicit.
  2. Install a small, fixed set of document listeners once.
  3. Drive behavior from pressState inside those listeners and update.

This keeps all the “gate until next input” semantics, but removes AbortController, the indirection via predicates, and the need to reason about which listeners are currently armed.

1. Make press state explicit

type PressState =
  | 'idle'
  | 'primaryPress'
  | 'contextMenuPress'
  | 'dragging';

class SelectionTooltip implements PluginSpec<PluginState> {
  private pressState: PressState = 'idle';
  private teardownPressListeners: (() => void) | null = null;

  // ...
}

2. Register fixed document listeners once

Install them when the plugin view is created, and tear them down on real editor destroy. This replaces armPressGate and _releasePressGate:

view(view: EditorView) {
  this.update(view);

  const onDocMouseUp = (event: MouseEvent) => {
    if (this.pressState === 'primaryPress' && event.button === 0) {
      this.pressState = 'idle';
      if (!view.isDestroyed) this.update(view);
    } else if (this.pressState === 'contextMenuPress' && event.button === 0) {
      this.pressState = 'idle';
      if (!view.isDestroyed) this.update(view);
    }
  };

  const onDocMouseDown = () => {
    if (this.pressState === 'contextMenuPress') {
      this.pressState = 'idle';
      if (!view.isDestroyed) this.update(view);
    }
  };

  const onDocKeyDown = () => {
    if (this.pressState !== 'idle') {
      this.pressState = 'idle';
      if (!view.isDestroyed) this.update(view);
    }
  };

  document.addEventListener('mouseup', onDocMouseUp, {capture: true});
  document.addEventListener('mousedown', onDocMouseDown, {capture: true});
  document.addEventListener('keydown', onDocKeyDown, {capture: true});

  this.teardownPressListeners = () => {
    document.removeEventListener('mouseup', onDocMouseUp, {capture: true} as any);
    document.removeEventListener('mousedown', onDocMouseDown, {capture: true} as any);
    document.removeEventListener('keydown', onDocKeyDown, {capture: true} as any);
  };

  return {
    update: this.update.bind(this),
    destroy: () => {
      this.cancelTooltipHiding();
      this.tooltip.destroy();
      queueMicrotask(() => {
        if (view.isDestroyed) {
          this.pressState = 'idle';
          this.teardownPressListeners?.();
          this.teardownPressListeners = null;
        }
      });
    },
  };
}

This preserves the “survive plugin view re-creations, but not editor teardown” behavior without storing closures per press or an AbortController.

3. Drive press state via handleDOMEvents

mousedown sets the state explicitly instead of arming a gate:

handleDOMEvents: {
  mousedown: (view, event) => {
    this.cancelTooltipHiding();
    this.tooltip.hide(view);

    if (isContextMenuPress(event)) {
      // native menu may swallow mouseup; treat as contextMenuPress
      this.pressState = 'contextMenuPress';
      return;
    }

    // chorded secondary-button releases can be handled inside the fixed mouseup handler
    this.pressState = 'primaryPress';
  },
  dragstart: () => {
    // drag ends with dragend, not mouseup; we just mark dragging
    this.pressState = 'dragging';
  },
},

You can handle dragend either with another fixed document listener or a view.dom listener:

const onDocDragEnd = () => {
  if (this.pressState === 'dragging') {
    this.pressState = 'idle';
  }
};
document.addEventListener('dragend', onDocDragEnd, {capture: true});

4. Simplify update

With explicit pressState, you no longer need _releasePressGate or an EditorState param:

private update(view: EditorView) {
  this.editorView = view;

  // A press interaction is in flight (button held, or a native context menu possibly open)
  if (this.pressState !== 'idle') return;

  this.cancelTooltipHiding();

  const hideFromTr = pluginKey.getState(view.state)?.disabled;
  if (hideFromTr || !view.dom.parentNode) {
    this.tooltip.hide(view);
    return;
  }

  // ...rest unchanged
}

This keeps behavior (don’t update while a press is in flight) but removes the hidden gating via _releasePressGate and the need for the generic armPressGate API.


Overall effect:

  • Same robustness around context menu presses, macOS ctrl+click, and drag.
  • No dynamic listener maps or AbortController per press.
  • No need to mentally simulate predicate logic across multiple “armed” gates.
  • Tooltip behavior is now a straightforward function of pressState + EditorState.

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.

1 participant