@0m0g1/griot 0.1.2 → 0.1.4

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.
@@ -0,0 +1,138 @@
1
+ // ─── FormatToolbar.js ─────────────────────────────────────────────────────────
2
+ // Floating toolbar that appears above a text selection inside the editor.
3
+ // Provides one-click inline formatting (bold, italic, underline, etc.) and
4
+ // link / color-mark insertion.
5
+ //
6
+ // Usage:
7
+ // const tb = new FormatToolbar(editorContainerEl, {
8
+ // onWrap(syntax) {}, // wrap selection with syntax chars e.g. '**'
9
+ // onLink() {}, // open link insertion prompt
10
+ // onColor() {}, // open color-mark prompt
11
+ // });
12
+ // tb.destroy();
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+
15
+ const FORMATS = [
16
+ { key: 'bold', label: 'B', title: 'Bold (Ctrl+B)', syntax: '**' },
17
+ { key: 'italic', label: 'I', title: 'Italic (Ctrl+I)', syntax: '*' },
18
+ { key: 'underline', label: 'U', title: 'Underline (Ctrl+U)', syntax: '__' },
19
+ { key: 'strike', label: 'S̶', title: 'Strikethrough', syntax: '~~' },
20
+ { key: 'code', label: '`', title: 'Inline Code', syntax: '`' },
21
+ { key: 'highlight', label: '▐', title: 'Highlight', syntax: '==' },
22
+ { key: 'link', label: '🔗', title: 'Link', action: 'link' },
23
+ { key: 'color', label: '🎨', title: 'Color', action: 'color' },
24
+ ];
25
+
26
+ export class FormatToolbar {
27
+ constructor(container, callbacks = {}) {
28
+ this._container = container;
29
+ this._cb = callbacks;
30
+ this._el = null;
31
+ this._visible = false;
32
+
33
+ this._build();
34
+ this._attach();
35
+ }
36
+
37
+ // ── Build DOM ────────────────────────────────────────────────────────────────
38
+
39
+ _build() {
40
+ const el = document.createElement('div');
41
+ el.className = 'griot-format-toolbar';
42
+ el.setAttribute('role', 'toolbar');
43
+ el.setAttribute('aria-label', 'Inline formatting');
44
+
45
+ for (const fmt of FORMATS) {
46
+ const btn = document.createElement('button');
47
+ btn.type = 'button';
48
+ btn.className = `griot-format-toolbar__btn griot-ftb--${fmt.key}`;
49
+ btn.title = fmt.title;
50
+ btn.textContent = fmt.label;
51
+
52
+ btn.addEventListener('mousedown', (e) => {
53
+ e.preventDefault(); // preserve selection before acting
54
+ if (fmt.syntax) this._cb.onWrap?.(fmt.syntax);
55
+ else if (fmt.action === 'link') this._cb.onLink?.();
56
+ else if (fmt.action === 'color') this._cb.onColor?.();
57
+ this._hide();
58
+ });
59
+
60
+ el.appendChild(btn);
61
+ }
62
+
63
+ document.body.appendChild(el);
64
+ this._el = el;
65
+ }
66
+
67
+ // ── Event listeners ──────────────────────────────────────────────────────────
68
+
69
+ _attach() {
70
+ // Show after mouse release inside editor
71
+ this._onMouseUp = () => setTimeout(() => this._checkAndShow(), 20);
72
+ this._container.addEventListener('mouseup', this._onMouseUp);
73
+
74
+ // Show after Shift+arrow / Ctrl+A keyboard selection
75
+ this._onKeyUp = (e) => {
76
+ if (e.shiftKey || ((e.ctrlKey || e.metaKey) && e.key === 'a')) {
77
+ setTimeout(() => this._checkAndShow(), 20);
78
+ }
79
+ };
80
+ this._container.addEventListener('keyup', this._onKeyUp);
81
+
82
+ // Hide when clicking anywhere outside the toolbar
83
+ this._onDocDown = (e) => {
84
+ if (this._visible && !this._el.contains(e.target)) this._hide();
85
+ };
86
+ document.addEventListener('mousedown', this._onDocDown);
87
+ }
88
+
89
+ // ── Visibility ───────────────────────────────────────────────────────────────
90
+
91
+ _checkAndShow() {
92
+ const sel = window.getSelection();
93
+ if (!sel || sel.isCollapsed || !sel.rangeCount) { this._hide(); return; }
94
+
95
+ const range = sel.getRangeAt(0);
96
+ if (!this._container.contains(range.commonAncestorContainer)) { this._hide(); return; }
97
+ if (!range.toString().trim()) { this._hide(); return; }
98
+
99
+ this._show(range.getBoundingClientRect());
100
+ }
101
+
102
+ _show(rect) {
103
+ const el = this._el;
104
+ el.style.display = 'flex';
105
+
106
+ requestAnimationFrame(() => {
107
+ const tbW = el.offsetWidth || 280;
108
+ const tbH = el.offsetHeight || 36;
109
+
110
+ let left = rect.left + rect.width / 2 - tbW / 2 + window.scrollX;
111
+ let top = rect.top - tbH - 10 + window.scrollY;
112
+
113
+ // Clamp horizontally
114
+ left = Math.max(8 + window.scrollX, Math.min(left, window.scrollX + window.innerWidth - tbW - 8));
115
+ // Flip below if too close to top
116
+ if (top < window.scrollY + 8) top = rect.bottom + 8 + window.scrollY;
117
+
118
+ el.style.left = `${left}px`;
119
+ el.style.top = `${top}px`;
120
+ this._visible = true;
121
+ });
122
+ }
123
+
124
+ _hide() {
125
+ if (this._el) this._el.style.display = 'none';
126
+ this._visible = false;
127
+ }
128
+
129
+ // ── Lifecycle ────────────────────────────────────────────────────────────────
130
+
131
+ destroy() {
132
+ this._container.removeEventListener('mouseup', this._onMouseUp);
133
+ this._container.removeEventListener('keyup', this._onKeyUp);
134
+ document.removeEventListener('mousedown', this._onDocDown);
135
+ this._el?.remove();
136
+ this._el = null;
137
+ }
138
+ }
@@ -1,169 +1,155 @@
1
- // ─── Keyboard.js ──────────────────────────────────────────────────────────────
2
- // All keyboard behaviour for the block editor.
3
- // Pure logic receives the current doc + focused block ID, returns events
4
- // the Editor class acts on.
5
- //
6
- // Exported: attachKeyboardHandler(el, callbacks)
7
- // Attaches a keydown listener to a contenteditable element for one block.
8
- // Calls back into Editor which owns the document state.
1
+ // ─── Keyboard.js ─────────────────────────────────────────────────────────────
2
+ // Keyboard event handling for contenteditable editor blocks.
3
+ // Also exports cursor position helpers shared by Editor and FormatToolbar.
9
4
  // ─────────────────────────────────────────────────────────────────────────────
10
5
 
11
6
  /**
12
- * @param {HTMLElement} el The contenteditable element
13
- * @param {object} callbacks
14
- * onEnter(blockId, offset) — Enter pressed: split at cursor offset
15
- * onBackspaceAtStart(blockId) Backspace at offset 0: merge with prev
16
- * onDeleteAtEnd(blockId) Delete at end: merge next into this
17
- * onTab(blockId, shift) Tab / Shift+Tab
18
- * onArrowUp(blockId) Arrow up at first line → move focus to prev block
19
- * onArrowDown(blockId) Arrow down at last line → move focus to next block
20
- * onUndo() Ctrl/Cmd+Z
21
- * onRedo() — Ctrl/Cmd+Shift+Z or Ctrl+Y
7
+ * Attach all editor keyboard shortcuts to a contenteditable element.
8
+ *
9
+ * Callbacks:
10
+ * onEnter(id, offset) Enter (no shift)
11
+ * onBackspaceAtStart(id) Backspace at offset 0 with no selection
12
+ * onDeleteAtEnd(id) Delete at end with no selection
13
+ * onTab(id, isShift) Tab / Shift+Tab
14
+ * onArrowUp(id) on first visual line
15
+ * onArrowDown(id) ↓ on last visual line
16
+ * onUndo() — Ctrl/Cmd+Z
17
+ * onRedo() — Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z
18
+ * onFormatKey(key) — Ctrl/Cmd+B/I/U
22
19
  */
23
- export function attachKeyboardHandler(el, blockId, callbacks) {
24
- el.addEventListener('keydown', (e) => {
25
- const {
26
- onEnter, onBackspaceAtStart, onDeleteAtEnd,
27
- onTab, onArrowUp, onArrowDown, onUndo, onRedo,
28
- } = callbacks;
20
+ export function attachKeyboardHandler(el, blockId, callbacks = {}) {
21
+ const {
22
+ onEnter, onBackspaceAtStart, onDeleteAtEnd,
23
+ onTab, onArrowUp, onArrowDown,
24
+ onUndo, onRedo, onFormatKey,
25
+ } = callbacks;
29
26
 
27
+ el.addEventListener('keydown', (e) => {
30
28
  const ctrl = e.ctrlKey || e.metaKey;
31
29
 
32
- // ── Undo / Redo ──────────────────────────────────────────────
33
- if (ctrl && e.key === 'z' && !e.shiftKey) {
34
- e.preventDefault();
35
- onUndo?.();
36
- return;
37
- }
38
- if ((ctrl && e.key === 'z' && e.shiftKey) || (ctrl && e.key === 'y')) {
30
+ // ── Undo / Redo ───────────────────────────────────────────────────────────
31
+ if (ctrl && e.key === 'z' && !e.shiftKey) { e.preventDefault(); onUndo?.(); return; }
32
+ if (ctrl && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { e.preventDefault(); onRedo?.(); return; }
33
+
34
+ // ── Inline format shortcuts ───────────────────────────────────────────────
35
+ if (ctrl && ['b', 'i', 'u'].includes(e.key.toLowerCase())) {
39
36
  e.preventDefault();
40
- onRedo?.();
37
+ onFormatKey?.(e.key.toLowerCase());
41
38
  return;
42
39
  }
43
40
 
44
- // ── Enter → split block ──────────────────────────────────────
41
+ // ── Enter ─────────────────────────────────────────────────────────────────
45
42
  if (e.key === 'Enter' && !e.shiftKey) {
46
43
  e.preventDefault();
47
44
  onEnter?.(blockId, getCursorOffset(el));
48
45
  return;
49
46
  }
50
47
 
51
- // ── Backspace at start → merge with previous ─────────────────
52
- if (e.key === 'Backspace' && getCursorOffset(el) === 0 && !hasSelection()) {
48
+ // ── Backspace at start ────────────────────────────────────────────────────
49
+ if (e.key === 'Backspace' && getCursorOffset(el) === 0 && selLen(el) === 0) {
53
50
  e.preventDefault();
54
51
  onBackspaceAtStart?.(blockId);
55
52
  return;
56
53
  }
57
54
 
58
- // ── Delete at end → merge next into this ─────────────────────
59
- if (e.key === 'Delete' && getCursorOffset(el) === el.textContent.length && !hasSelection()) {
55
+ // ── Delete at end ─────────────────────────────────────────────────────────
56
+ if (e.key === 'Delete' && isAtEnd(el) && selLen(el) === 0) {
60
57
  e.preventDefault();
61
58
  onDeleteAtEnd?.(blockId);
62
59
  return;
63
60
  }
64
61
 
65
- // ── Tab ───────────────────────────────────────────────────────
62
+ // ── Tab ───────────────────────────────────────────────────────────────────
66
63
  if (e.key === 'Tab') {
67
64
  e.preventDefault();
68
65
  onTab?.(blockId, e.shiftKey);
69
66
  return;
70
67
  }
71
68
 
72
- // ── Arrow navigation across blocks ───────────────────────────
73
- if (e.key === 'ArrowUp' && isAtFirstLine(el)) {
74
- e.preventDefault();
75
- onArrowUp?.(blockId);
76
- return;
77
- }
78
- if (e.key === 'ArrowDown' && isAtLastLine(el)) {
79
- e.preventDefault();
80
- onArrowDown?.(blockId);
81
- return;
82
- }
69
+ // ── Arrow navigation between blocks ──────────────────────────────────────
70
+ if (e.key === 'ArrowUp' && isOnFirstLine(el)) { e.preventDefault(); onArrowUp?.(blockId); }
71
+ if (e.key === 'ArrowDown' && isOnLastLine(el)) { e.preventDefault(); onArrowDown?.(blockId); }
83
72
  });
84
73
  }
85
74
 
86
- // ─── Cursor helpers ───────────────────────────────────────────────────────────
75
+ // ── Cursor offset helpers ─────────────────────────────────────────────────────
87
76
 
88
- /** Returns the character offset of the caret within el.textContent */
77
+ /** Character offset of the caret within `el`. */
89
78
  export function getCursorOffset(el) {
90
79
  const sel = window.getSelection();
91
- if (!sel || sel.rangeCount === 0) return 0;
92
- const range = sel.getRangeAt(0).cloneRange();
93
- range.selectNodeContents(el);
94
- range.setEnd(sel.getRangeAt(0).endContainer, sel.getRangeAt(0).endOffset);
95
- return range.toString().length;
80
+ if (!sel?.rangeCount) return 0;
81
+ const r = sel.getRangeAt(0);
82
+ const pre = r.cloneRange();
83
+ pre.selectNodeContents(el);
84
+ pre.setEnd(r.startContainer, r.startOffset);
85
+ return pre.toString().length;
86
+ }
87
+
88
+ /** { start, end } character offsets of the current selection within `el`. */
89
+ export function getSelectionOffsets(el) {
90
+ const sel = window.getSelection();
91
+ if (!sel?.rangeCount) return { start: 0, end: 0 };
92
+ const r = sel.getRangeAt(0);
93
+ const pre = r.cloneRange();
94
+ pre.selectNodeContents(el);
95
+ pre.setEnd(r.startContainer, r.startOffset);
96
+ const start = pre.toString().length;
97
+ const end = start + r.toString().length;
98
+ return { start, end };
96
99
  }
97
100
 
98
- /** Place the caret at a specific character offset within el */
101
+ /** Move the caret to `offset` characters from the start of `el`. */
99
102
  export function setCursorOffset(el, offset) {
100
- const range = document.createRange();
101
103
  const sel = window.getSelection();
102
- if (!sel) return;
103
-
104
- let remaining = offset;
105
- let found = false;
104
+ const range = document.createRange();
105
+ let rem = offset, found = false;
106
106
 
107
- function walk(node) {
107
+ (function walk(node) {
108
108
  if (found) return;
109
109
  if (node.nodeType === Node.TEXT_NODE) {
110
- if (remaining <= node.textContent.length) {
111
- range.setStart(node, remaining);
112
- range.setEnd(node, remaining);
110
+ if (rem <= node.length) {
111
+ range.setStart(node, rem);
112
+ range.setEnd(node, rem);
113
113
  found = true;
114
114
  } else {
115
- remaining -= node.textContent.length;
115
+ rem -= node.length;
116
116
  }
117
117
  } else {
118
- for (const child of node.childNodes) walk(child);
118
+ node.childNodes.forEach(walk);
119
119
  }
120
- }
121
-
122
- walk(el);
123
-
124
- if (!found) {
125
- // Clamp to end
126
- range.selectNodeContents(el);
127
- range.collapse(false);
128
- }
120
+ })(el);
129
121
 
122
+ if (!found) { range.selectNodeContents(el); range.collapse(false); }
130
123
  sel.removeAllRanges();
131
124
  sel.addRange(range);
132
125
  }
133
126
 
134
- /** Focus el and place caret at end */
135
- export function focusAtEnd(el) {
136
- el.focus();
137
- setCursorOffset(el, el.textContent.length);
138
- }
127
+ export function focusAtEnd(el) { el.focus(); setCursorOffset(el, el.textContent?.length ?? 0); }
128
+ export function focusAtStart(el) { el.focus(); setCursorOffset(el, 0); }
139
129
 
140
- /** Focus el and place caret at start */
141
- export function focusAtStart(el) {
142
- el.focus();
143
- setCursorOffset(el, 0);
144
- }
130
+ // ── Internal helpers ──────────────────────────────────────────────────────────
145
131
 
146
- // ─── Line detection ───────────────────────────────────────────────────────────
147
- function hasSelection() {
148
- const sel = window.getSelection();
149
- return sel && sel.type === 'Range';
132
+ function selLen(el) {
133
+ try { return window.getSelection()?.getRangeAt(0)?.toString().length ?? 0; }
134
+ catch { return 0; }
150
135
  }
151
136
 
152
- function isAtFirstLine(el) {
153
- const sel = window.getSelection();
154
- if (!sel || sel.rangeCount === 0) return false;
155
- const range = sel.getRangeAt(0);
156
- const rect = range.getBoundingClientRect();
157
- const elRect = el.getBoundingClientRect();
158
- // Within 1.5x line-height from top
159
- return Math.abs(rect.top - elRect.top) < 30;
137
+ function isAtEnd(el) {
138
+ return getCursorOffset(el) >= (el.textContent?.length ?? 0);
160
139
  }
161
140
 
162
- function isAtLastLine(el) {
163
- const sel = window.getSelection();
164
- if (!sel || sel.rangeCount === 0) return false;
165
- const range = sel.getRangeAt(0);
166
- const rect = range.getBoundingClientRect();
167
- const elRect = el.getBoundingClientRect();
168
- return Math.abs(rect.bottom - elRect.bottom) < 30;
141
+ function isOnFirstLine(el) {
142
+ try {
143
+ const sel = window.getSelection();
144
+ if (!sel?.rangeCount) return true;
145
+ return sel.getRangeAt(0).getBoundingClientRect().top < el.getBoundingClientRect().top + 10;
146
+ } catch { return true; }
169
147
  }
148
+
149
+ function isOnLastLine(el) {
150
+ try {
151
+ const sel = window.getSelection();
152
+ if (!sel?.rangeCount) return true;
153
+ return sel.getRangeAt(0).getBoundingClientRect().bottom > el.getBoundingClientRect().bottom - 10;
154
+ } catch { return true; }
155
+ }
@@ -0,0 +1,197 @@
1
+ // ─── SlashMenu.js ─────────────────────────────────────────────────────────────
2
+ // Slash-command palette. Triggered when '/' is typed at the very start of
3
+ // an empty block editable. Shows a searchable list of block types grouped
4
+ // by category. Selecting an item calls onSelect(type).
5
+ //
6
+ // Usage (inside Editor):
7
+ // const menu = new SlashMenu(editorContainerEl, onSelect);
8
+ // menu.show(anchorEl); // position near anchorEl
9
+ // menu.hide();
10
+ // menu.destroy();
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+
13
+ import { getAllTypes, getBlockDef, getTypesByCategory } from '../blocks/BlockSchema.js';
14
+
15
+ const CATEGORIES = [
16
+ { key: 'text', label: 'Text' },
17
+ { key: 'media', label: 'Media' },
18
+ { key: 'embed', label: 'Embed' },
19
+ { key: 'structure', label: 'Structure' },
20
+ ];
21
+
22
+ export class SlashMenu {
23
+ constructor(container, onSelect) {
24
+ this._container = container;
25
+ this._onSelect = onSelect;
26
+ this._el = null;
27
+ this._query = '';
28
+ this._idx = 0;
29
+ this._items = []; // filtered list of type strings
30
+ this._visible = false;
31
+
32
+ this._build();
33
+ }
34
+
35
+ // ── Public ────────────────────────────────────────────────────────────────
36
+
37
+ /** Show menu anchored below anchorEl. */
38
+ show(anchorEl) {
39
+ this._query = '';
40
+ this._refresh();
41
+ this._visible = true;
42
+ this._el.style.display = 'block';
43
+ this._reposition(anchorEl);
44
+ this._el.querySelector('.griot-slash__search')?.focus();
45
+ }
46
+
47
+ /** Filter list to items matching query string (the text after '/'). */
48
+ filter(query) {
49
+ this._query = query;
50
+ this._refresh();
51
+ if (!this._items.length) { this.hide(); return; }
52
+ this._reposition(this._anchorEl);
53
+ }
54
+
55
+ hide() {
56
+ this._visible = false;
57
+ if (this._el) this._el.style.display = 'none';
58
+ }
59
+
60
+ get visible() { return this._visible; }
61
+
62
+ /** Handle keydown while slash menu is open. Returns true if consumed. */
63
+ handleKey(e) {
64
+ if (!this._visible) return false;
65
+ if (e.key === 'ArrowDown') { e.preventDefault(); this._move(1); return true; }
66
+ if (e.key === 'ArrowUp') { e.preventDefault(); this._move(-1); return true; }
67
+ if (e.key === 'Enter') { e.preventDefault(); this._select(); return true; }
68
+ if (e.key === 'Escape') { e.preventDefault(); this.hide(); return true; }
69
+ return false;
70
+ }
71
+
72
+ destroy() {
73
+ this._el?.remove();
74
+ this._el = null;
75
+ }
76
+
77
+ // ── Build ─────────────────────────────────────────────────────────────────
78
+
79
+ _build() {
80
+ const el = document.createElement('div');
81
+ el.className = 'griot-slash';
82
+ el.setAttribute('role', 'listbox');
83
+ el.setAttribute('aria-label', 'Block type');
84
+
85
+ const search = document.createElement('input');
86
+ search.type = 'text';
87
+ search.className = 'griot-slash__search';
88
+ search.placeholder = 'Search blocks…';
89
+ search.addEventListener('input', () => { this._query = search.value; this._refresh(); });
90
+ search.addEventListener('keydown', e => { this.handleKey(e); });
91
+
92
+ const list = document.createElement('div');
93
+ list.className = 'griot-slash__list';
94
+
95
+ el.append(search, list);
96
+ document.body.appendChild(el);
97
+ this._el = el;
98
+ this._list = list;
99
+ this._search = search;
100
+
101
+ // Hide on outside click
102
+ this._onDocClick = (e) => { if (this._visible && !el.contains(e.target)) this.hide(); };
103
+ document.addEventListener('mousedown', this._onDocClick);
104
+ }
105
+
106
+ _refresh() {
107
+ const q = this._query.toLowerCase().trim();
108
+ this._list.innerHTML = '';
109
+ this._items = [];
110
+ this._idx = 0;
111
+
112
+ const all = getAllTypes();
113
+ const filtered = q
114
+ ? all.filter(t => {
115
+ const d = getBlockDef(t);
116
+ return d.slashLabel?.toLowerCase().includes(q) || t.includes(q) || d.label.toLowerCase().includes(q);
117
+ })
118
+ : all;
119
+
120
+ // Group by category when not searching
121
+ const groups = q
122
+ ? [{ key: 'results', label: 'Results', types: filtered }]
123
+ : CATEGORIES.map(c => ({ ...c, types: filtered.filter(t => getBlockDef(t).category === c.key) })).filter(g => g.types.length);
124
+
125
+ for (const group of groups) {
126
+ const hdr = document.createElement('div');
127
+ hdr.className = 'griot-slash__group';
128
+ hdr.textContent = group.label;
129
+ this._list.appendChild(hdr);
130
+
131
+ for (const type of group.types) {
132
+ const def = getBlockDef(type);
133
+ const idx = this._items.length;
134
+ this._items.push(type);
135
+
136
+ const item = document.createElement('div');
137
+ item.className = 'griot-slash__item';
138
+ item.setAttribute('role', 'option');
139
+ item.dataset.idx = idx;
140
+ item.innerHTML = `<span class="griot-slash__item-icon">${def.icon}</span><span class="griot-slash__item-label">${def.slashLabel ?? def.label}</span>`;
141
+
142
+ item.addEventListener('mousedown', (e) => {
143
+ e.preventDefault();
144
+ this._idx = idx;
145
+ this._select();
146
+ });
147
+ item.addEventListener('mouseover', () => { this._idx = idx; this._highlight(); });
148
+
149
+ this._list.appendChild(item);
150
+ }
151
+ }
152
+
153
+ this._highlight();
154
+ }
155
+
156
+ _highlight() {
157
+ for (const el of this._list.querySelectorAll('.griot-slash__item')) {
158
+ el.classList.toggle('is-active', Number(el.dataset.idx) === this._idx);
159
+ }
160
+ // Scroll into view
161
+ const active = this._list.querySelector('.griot-slash__item.is-active');
162
+ active?.scrollIntoView({ block: 'nearest' });
163
+ }
164
+
165
+ _move(dir) {
166
+ this._idx = Math.max(0, Math.min(this._items.length - 1, this._idx + dir));
167
+ this._highlight();
168
+ }
169
+
170
+ _select() {
171
+ const type = this._items[this._idx];
172
+ if (type) { this.hide(); this._onSelect(type); }
173
+ }
174
+
175
+ _reposition(anchorEl) {
176
+ this._anchorEl = anchorEl;
177
+ if (!anchorEl || !this._el) return;
178
+
179
+ requestAnimationFrame(() => {
180
+ const rect = anchorEl.getBoundingClientRect();
181
+ const menuH = this._el.offsetHeight || 280;
182
+ let top = rect.bottom + window.scrollY + 4;
183
+ let left = rect.left + window.scrollX;
184
+
185
+ // Flip above if too close to bottom
186
+ if (rect.bottom + menuH + 8 > window.innerHeight) {
187
+ top = rect.top + window.scrollY - menuH - 4;
188
+ }
189
+ // Clamp left
190
+ left = Math.min(left, window.scrollX + window.innerWidth - 280 - 8);
191
+ left = Math.max(left, window.scrollX + 8);
192
+
193
+ this._el.style.top = `${top}px`;
194
+ this._el.style.left = `${left}px`;
195
+ });
196
+ }
197
+ }