@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.
- package/README.md +322 -127
- package/package.json +1 -1
- package/src/Griot.js +33 -35
- package/src/blocks/BlockRenderer.js +240 -93
- package/src/blocks/BlockSchema.js +29 -86
- package/src/core/Block.js +42 -45
- package/src/core/Document.js +63 -98
- package/src/core/History.js +17 -42
- package/src/editor/Editor.js +405 -138
- package/src/editor/FormatToolbar.js +138 -0
- package/src/editor/Keyboard.js +92 -106
- package/src/editor/SlashMenu.js +197 -0
- package/src/inline/InlineLexer.js +69 -72
- package/src/inline/InlineRenderer.js +110 -95
|
@@ -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
|
+
}
|
package/src/editor/Keyboard.js
CHANGED
|
@@ -1,169 +1,155 @@
|
|
|
1
|
-
// ─── Keyboard.js
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
37
|
+
onFormatKey?.(e.key.toLowerCase());
|
|
41
38
|
return;
|
|
42
39
|
}
|
|
43
40
|
|
|
44
|
-
// ── Enter
|
|
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
|
|
52
|
-
if (e.key === 'Backspace' && getCursorOffset(el) === 0 &&
|
|
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
|
|
59
|
-
if (e.key === 'Delete' &&
|
|
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
|
|
73
|
-
if (e.key === 'ArrowUp'
|
|
74
|
-
|
|
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
|
-
//
|
|
75
|
+
// ── Cursor offset helpers ─────────────────────────────────────────────────────
|
|
87
76
|
|
|
88
|
-
/**
|
|
77
|
+
/** Character offset of the caret within `el`. */
|
|
89
78
|
export function getCursorOffset(el) {
|
|
90
79
|
const sel = window.getSelection();
|
|
91
|
-
if (!sel
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 (
|
|
111
|
-
range.setStart(node,
|
|
112
|
-
range.setEnd(node,
|
|
110
|
+
if (rem <= node.length) {
|
|
111
|
+
range.setStart(node, rem);
|
|
112
|
+
range.setEnd(node, rem);
|
|
113
113
|
found = true;
|
|
114
114
|
} else {
|
|
115
|
-
|
|
115
|
+
rem -= node.length;
|
|
116
116
|
}
|
|
117
117
|
} else {
|
|
118
|
-
|
|
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
|
-
|
|
135
|
-
export function
|
|
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
|
-
|
|
141
|
-
export function focusAtStart(el) {
|
|
142
|
-
el.focus();
|
|
143
|
-
setCursorOffset(el, 0);
|
|
144
|
-
}
|
|
130
|
+
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
145
131
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
153
|
-
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
+
}
|