@signal9/era-ui 3.13.1 → 3.13.2

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.
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { Extension } from '@tiptap/core';
11
11
  import { Plugin, PluginKey } from '@tiptap/pm/state';
12
- import { mount, unmount } from 'svelte';
12
+ import { flushSync, mount, unmount } from 'svelte';
13
13
  import Bold from '@lucide/svelte/icons/bold';
14
14
  import Italic from '@lucide/svelte/icons/italic';
15
15
  import Strikethrough from '@lucide/svelte/icons/strikethrough';
@@ -83,15 +83,43 @@ class BubbleMenuView {
83
83
  return;
84
84
  }
85
85
  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.
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.
87
90
  this.#host.style.display = 'block';
88
- placeOverSelection(this.#host, view.coordsAtPos(selection.from), view.coordsAtPos(selection.to));
91
+ flushSync();
92
+ placeOverSelection(this.#host, selectionRect(view));
89
93
  }
90
94
  destroy() {
91
95
  unmount(this.#toolbar);
92
96
  this.#host.remove();
93
97
  }
94
98
  }
99
+ /**
100
+ * The selection's bounding box in viewport coordinates.
101
+ *
102
+ * Prefers the live DOM selection, whose rect already unions every line of a
103
+ * multi-line selection. ProseMirror's coordsAtPos only describes ONE position,
104
+ * so reconstructing a tall selection's box from its two endpoints is what put
105
+ * the toolbar 305px off centre. The endpoint union is kept as a fallback for the
106
+ * case where the DOM selection is unavailable or collapsed to a zero rect (it
107
+ * still beats nothing, and it is exactly right on a single line).
108
+ */
109
+ function selectionRect(view) {
110
+ const dom = window.getSelection();
111
+ if (dom && dom.rangeCount > 0) {
112
+ const rect = dom.getRangeAt(0).getBoundingClientRect();
113
+ if (rect.width > 0 || rect.height > 0)
114
+ return rect;
115
+ }
116
+ const { from, to } = view.state.selection;
117
+ const a = view.coordsAtPos(from);
118
+ const b = view.coordsAtPos(to);
119
+ const left = Math.min(a.left, b.left);
120
+ const top = Math.min(a.top, b.top);
121
+ return new DOMRect(left, top, Math.max(a.right, b.right) - left, Math.max(a.bottom, b.bottom) - top);
122
+ }
95
123
  export const BubbleMenu = Extension.create({
96
124
  name: 'notesBubbleMenu',
97
125
  addProseMirrorPlugins() {
@@ -21,12 +21,18 @@
21
21
  let { items, active, onCommand }: Props = $props();
22
22
  </script>
23
23
 
24
- <Bar size="sm" class="w-max bg-elevated shadow-lg glass-blur">
24
+ <!-- md with xxs buttons, matching the app's other bars. It was sm + size="icon"
25
+ — a 22px bar of 14px buttons around 10px glyphs, the smallest combination in
26
+ the ladder, which is why the formatting controls read as unhittable. -->
27
+ <Bar
28
+ size="md"
29
+ class="w-max gap-(--era-xxs-inset-md) bg-elevated px-(--era-xxs-inset-md) shadow-lg glass-blur"
30
+ >
25
31
  {#each items as item (item.mark)}
26
32
  {@const Icon = item.icon}
27
33
  <Button
28
34
  icon
29
- size="icon"
35
+ size="xxs"
30
36
  active={active[item.mark] ?? false}
31
37
  aria-label={item.label}
32
38
  title={item.label}
@@ -11,11 +11,15 @@
11
11
  export declare function createOverlayHost(): HTMLDivElement;
12
12
  /** Anchor below `rect`, flipping above when the bottom edge would run off. */
13
13
  export declare function placeBelow(host: HTMLElement, rect: DOMRect, gap?: number): void;
14
- /** Centre over a selection, flipping below when there is no room above. */
15
- export declare function placeOverSelection(host: HTMLElement, start: {
16
- top: number;
17
- left: number;
18
- bottom: number;
19
- }, end: {
20
- right: number;
21
- }, gap?: number): void;
14
+ /**
15
+ * Centre over a selection's BOUNDING BOX, flipping below when there is no room
16
+ * above.
17
+ *
18
+ * Takes the whole rect on purpose. This used to take the two endpoint coords and
19
+ * centre on `(start.left + end.right) / 2`, which is only correct while the
20
+ * selection sits on one line: across several lines it averages an x from the
21
+ * FIRST line with an x from the LAST, and the result points at neither. Measured
22
+ * on a 7-line selection, the toolbar landed 305px from the selection's centre —
23
+ * one line was 0px, two lines 4px, so it looked fine until a selection got tall.
24
+ */
25
+ export declare function placeOverSelection(host: HTMLElement, rect: DOMRect, gap?: number): void;
@@ -28,14 +28,24 @@ export function placeBelow(host, rect, gap = EDGE) {
28
28
  host.style.left = `${Math.max(EDGE, left)}px`;
29
29
  host.style.top = `${Math.max(EDGE, top)}px`;
30
30
  }
31
- /** Centre over a selection, flipping below when there is no room above. */
32
- export function placeOverSelection(host, start, end, gap = 6) {
31
+ /**
32
+ * Centre over a selection's BOUNDING BOX, flipping below when there is no room
33
+ * above.
34
+ *
35
+ * Takes the whole rect on purpose. This used to take the two endpoint coords and
36
+ * centre on `(start.left + end.right) / 2`, which is only correct while the
37
+ * selection sits on one line: across several lines it averages an x from the
38
+ * FIRST line with an x from the LAST, and the result points at neither. Measured
39
+ * on a 7-line selection, the toolbar landed 305px from the selection's centre —
40
+ * one line was 0px, two lines 4px, so it looked fine until a selection got tall.
41
+ */
42
+ export function placeOverSelection(host, rect, gap = 6) {
33
43
  const box = host.getBoundingClientRect();
34
- const centerX = (start.left + end.right) / 2;
44
+ const centerX = rect.left + rect.width / 2;
35
45
  const left = clamp(centerX - box.width / 2, EDGE, window.innerWidth - box.width - EDGE);
36
- let top = start.top - box.height - gap;
46
+ let top = rect.top - box.height - gap;
37
47
  if (top < EDGE)
38
- top = start.bottom + gap;
48
+ top = rect.bottom + gap;
39
49
  host.style.left = `${Math.max(EDGE, left)}px`;
40
50
  host.style.top = `${Math.max(EDGE, top)}px`;
41
51
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signal9/era-ui",
3
- "version": "3.13.1",
3
+ "version": "3.13.2",
4
4
  "scripts": {
5
5
  "dev": "vite dev --host",
6
6
  "build": "vite build && npm run prepack",