@signal9/era-ui 3.14.0 → 3.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -57,6 +57,11 @@ class BubbleMenuView {
57
57
  #host;
58
58
  #toolbar;
59
59
  #editor;
60
+ /**
61
+ * The DOCUMENT range the toolbar is currently parked over, or null while it is
62
+ * hidden. This is what the toolbar is anchored to — see update().
63
+ */
64
+ #anchor = null;
60
65
  #view = $state({
61
66
  items,
62
67
  active: {},
@@ -80,16 +85,42 @@ class BubbleMenuView {
80
85
  !state.doc.textBetween(selection.from, selection.to).length ||
81
86
  this.#editor.isActive('codeBlock')) {
82
87
  this.#host.style.display = 'none';
88
+ this.#anchor = null;
83
89
  return;
84
90
  }
85
91
  this.#view.active = Object.fromEntries(buttons.map((b) => [b.mark, this.#editor.isActive(b.mark)]));
86
- // Measuring requires a laid-out box, so unhide before placing. flushSync
87
- // applies the `active` change above FIRST — measuring a Svelte component
88
- // before its pending render lands reads the previous frame's box, which is
89
- // the same trap the slash menu documents.
92
+ const wasHidden = this.#host.style.display === 'none';
90
93
  this.#host.style.display = 'block';
91
- flushSync();
92
- placeOverSelection(this.#host, selectionRect(view));
94
+ /*
95
+ * The toolbar is anchored to the SELECTION RANGE, not to its pixel box.
96
+ *
97
+ * Those are not the same thing, and the difference is the whole bug:
98
+ * applying a mark re-renders the selected text — code sets it monospace, a
99
+ * link adds an underline — so its rect moves while the selection itself
100
+ * does not change by a single character. Re-centring on the new rect slides
101
+ * the toolbar out from under the pointer that just clicked it, and the next
102
+ * click lands on nothing. The user has to chase the menu to keep using it.
103
+ *
104
+ * So: reposition only when the range actually changes (or when the toolbar
105
+ * is appearing fresh). While `from`/`to` hold steady the toolbar is pinned,
106
+ * and a whole run of formatting happens under a motionless pointer.
107
+ *
108
+ * The trade is deliberate: if a format reflows the text far enough, the
109
+ * toolbar can end up slightly off its selection until the selection next
110
+ * changes. Being a few pixels stale beats moving while it is being used —
111
+ * a toolbar is a target first and an indicator second.
112
+ */
113
+ const { from, to } = selection;
114
+ const movedRange = !this.#anchor || this.#anchor.from !== from || this.#anchor.to !== to;
115
+ if (wasHidden || movedRange) {
116
+ // flushSync applies the `active` change above BEFORE measuring — reading
117
+ // a Svelte component's box before its pending render lands gives the
118
+ // previous frame's, the same trap the slash menu documents. Only needed
119
+ // on the frames that actually measure.
120
+ flushSync();
121
+ placeOverSelection(this.#host, selectionRect(view));
122
+ this.#anchor = { from, to };
123
+ }
93
124
  }
94
125
  destroy() {
95
126
  unmount(this.#toolbar);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signal9/era-ui",
3
- "version": "3.14.0",
3
+ "version": "3.14.1",
4
4
  "scripts": {
5
5
  "dev": "vite dev --host",
6
6
  "build": "vite build && npm run prepack",