@itsammarb/mention-editor 1.0.0
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/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/MentionEditor.d.ts +17 -0
- package/dist/MentionEditor.js +243 -0
- package/dist/MentionMenu.d.ts +11 -0
- package/dist/MentionMenu.js +26 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +13 -0
- package/dist/serialize.d.ts +22 -0
- package/dist/serialize.js +66 -0
- package/dist/styles.css +2 -0
- package/dist/types.d.ts +29 -0
- package/dist/types.js +12 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ammar B.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @nexcore/mention-editor
|
|
2
|
+
|
|
3
|
+
A Discord-style `@mention` rich-text editor built on [Slate.js](https://www.slatejs.org/). Mentions are atomic (void + inline) nodes: backspacing next to one deletes it whole, and the suggestion menu tracks the real caret position via `ReactEditor.toDOMRange`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @nexcore/mention-editor slate slate-react slate-history
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Peer dependencies: React 18 or 19, `slate` and `slate-react` `^0.94.0`. No Tailwind setup required on your end — see [Styling](#styling).
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
import { MentionEditor } from '@nexcore/mention-editor';
|
|
17
|
+
import '@nexcore/mention-editor/styles.css';
|
|
18
|
+
|
|
19
|
+
const fields = [
|
|
20
|
+
{ id: 'a1b2c3d4-0000-0000-0000-000000000001', label: 'Landlord Name' },
|
|
21
|
+
{ id: 'a1b2c3d4-0000-0000-0000-000000000002', label: 'Monthly Rent' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function ClauseEditor() {
|
|
25
|
+
const [value, setValue] = useState('Hello <@a1b2c3d4-0000-0000-0000-000000000001>, welcome.');
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<MentionEditor
|
|
29
|
+
value={value}
|
|
30
|
+
fields={fields}
|
|
31
|
+
onChange={setValue}
|
|
32
|
+
placeholder="Type @ to reference a field..."
|
|
33
|
+
dir="rtl" // or "ltr"; omit to let the browser infer it
|
|
34
|
+
rows={4}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Wire format
|
|
41
|
+
|
|
42
|
+
`value` / `onChange` use a plain string, not a Slate document: mentions are written as the field's `id` wrapped in `<@` and `>`, e.g.:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
Hello <@a1b2c3d4-0000-0000-0000-000000000001>, welcome.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`serialize`/`deserialize` (also exported) convert between this string and Slate's internal `Descendant[]` tree. `\n` in the string is a paragraph break. The closing `>` means any id shape works out of the box — no separate id-format configuration needed.
|
|
49
|
+
|
|
50
|
+
## Props
|
|
51
|
+
|
|
52
|
+
| Prop | Type | Description |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| `value` | `string` | Wire-format content (controlled). |
|
|
55
|
+
| `fields` | `{ id: string; label: string }[]` | Fields offered in the `@` suggestion menu; filter/localize before passing in. |
|
|
56
|
+
| `onChange` | `(value: string) => void` | Called with the new wire-format string. |
|
|
57
|
+
| `dir` | `'ltr' \| 'rtl'` | Applied to the editable surface for correct caret/bidi behavior. |
|
|
58
|
+
| `disabled` | `boolean` | Makes the editor read-only. |
|
|
59
|
+
| `isError` | `boolean` | Applies the invalid-state styling. |
|
|
60
|
+
| `placeholder` | `string` | Shown when the editor is empty. |
|
|
61
|
+
| `rows` | `number` | Approximate visible height, `<textarea rows>`-equivalent. |
|
|
62
|
+
| `className` | `string` | Extra class name(s) on the root container. |
|
|
63
|
+
|
|
64
|
+
## Styling
|
|
65
|
+
|
|
66
|
+
The default look is authored with Tailwind utility classes internally, but compiled at *this package's* build time into a self-contained `dist/styles.css` — import it once (as shown above) and it works with zero Tailwind setup on your end, whether or not your app uses Tailwind at all. The compiled CSS deliberately excludes Tailwind's Preflight (base element reset), so it can't leak out and restyle your app's own `<h1>`, `<button>`, etc.
|
|
67
|
+
|
|
68
|
+
Each rendered part also carries a stable class name (`.mention-editor`, `.mention-editor__editable`, `.mention-editor__mention`, `.mention-editor__menu`, `.mention-editor__menu-item`) for overrides or e2e test selectors. Pass `className` to add to the root container, or write CSS/Tailwind rules targeting these classes directly — they ship at normal specificity (single class selectors), so a later rule wins.
|
|
69
|
+
|
|
70
|
+
If your own app happens to use Tailwind and you want to reuse its utility classes against this component's internal DOM (beyond what `className` on the root reaches), you can optionally point your app's Tailwind config/`@source` at `node_modules/@nexcore/mention-editor/dist` — but this is purely an opt-in extra, not required for the component to work or look right.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { MentionFieldOption } from './types';
|
|
3
|
+
export interface MentionEditorProps {
|
|
4
|
+
/** Wire-format string, e.g. `"Hello <@a1b2c3d4-...>, pay rent."` */
|
|
5
|
+
value: string;
|
|
6
|
+
/** Already localized/filtered list of fields the consumer wants offered. */
|
|
7
|
+
fields: MentionFieldOption[];
|
|
8
|
+
onChange?: (value: string) => void;
|
|
9
|
+
dir?: 'ltr' | 'rtl';
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
isError?: boolean;
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
/** Approximate visible height, textarea-`rows`-equivalent. */
|
|
14
|
+
rows?: number;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function MentionEditor(props: MentionEditorProps): React.JSX.Element;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MentionEditor = MentionEditor;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
const react_1 = require("react");
|
|
6
|
+
const slate_1 = require("slate");
|
|
7
|
+
const slate_react_1 = require("slate-react");
|
|
8
|
+
const slate_history_1 = require("slate-history");
|
|
9
|
+
const types_1 = require("./types");
|
|
10
|
+
const serialize_1 = require("./serialize");
|
|
11
|
+
const MentionMenu_1 = require("./MentionMenu");
|
|
12
|
+
function MentionEditor(props) {
|
|
13
|
+
const { value, fields, onChange, dir, disabled = false, isError = false, placeholder, rows, className, } = props;
|
|
14
|
+
// `generation` forces a full remount of the Slate editor instance whenever the
|
|
15
|
+
// `value` prop changes for a reason *other* than our own onChange echoing back
|
|
16
|
+
// down (e.g. the consumer swaps to editing a different record). See the
|
|
17
|
+
// render-time reset block below for the echo-vs-external distinction.
|
|
18
|
+
const [generation, setGeneration] = (0, react_1.useState)(0);
|
|
19
|
+
const editor = (0, react_1.useMemo)(() => withMentions((0, slate_history_1.withHistory)((0, slate_react_1.withReact)((0, slate_1.createEditor)()))), [generation]);
|
|
20
|
+
const lastEmittedRef = (0, react_1.useRef)(value);
|
|
21
|
+
const [committedValue, setCommittedValue] = (0, react_1.useState)(value);
|
|
22
|
+
const [slateValue, setSlateValue] = (0, react_1.useState)(() => value ? (0, serialize_1.deserialize)(value, fields) : types_1.INITIAL_VALUE);
|
|
23
|
+
const [target, setTarget] = (0, react_1.useState)(null);
|
|
24
|
+
const [index, setIndex] = (0, react_1.useState)(0);
|
|
25
|
+
const [search, setSearch] = (0, react_1.useState)('');
|
|
26
|
+
if (value !== committedValue) {
|
|
27
|
+
setCommittedValue(value);
|
|
28
|
+
if (value !== lastEmittedRef.current) {
|
|
29
|
+
setSlateValue(value ? (0, serialize_1.deserialize)(value, fields) : types_1.INITIAL_VALUE);
|
|
30
|
+
setGeneration((g) => g + 1);
|
|
31
|
+
setTarget(null);
|
|
32
|
+
setSearch('');
|
|
33
|
+
setIndex(0);
|
|
34
|
+
lastEmittedRef.current = value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Filter fields based on the search query
|
|
38
|
+
const filteredFields = (0, react_1.useMemo)(() => {
|
|
39
|
+
if (!search)
|
|
40
|
+
return fields.slice(0, 10); // Show top 10 if no search
|
|
41
|
+
return fields
|
|
42
|
+
.filter((field) => field.label.toLowerCase().includes(search.toLowerCase()))
|
|
43
|
+
.slice(0, 10);
|
|
44
|
+
}, [fields, search]);
|
|
45
|
+
// Calculate the DOM position for the popup menu. This has to run in a layout
|
|
46
|
+
// effect, *after* the DOM commits -- `target` and the text content it points
|
|
47
|
+
// into are usually set in the very same state update (the user just typed
|
|
48
|
+
// the character that both changed the content and opened the menu), so
|
|
49
|
+
// resolving the DOM range during render itself would run before the new
|
|
50
|
+
// text node exists, and silently fail.
|
|
51
|
+
const [menuTargetRect, setMenuTargetRect] = (0, react_1.useState)(null);
|
|
52
|
+
(0, react_1.useLayoutEffect)(() => {
|
|
53
|
+
if (!target) {
|
|
54
|
+
setMenuTargetRect(null);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const recompute = () => {
|
|
58
|
+
try {
|
|
59
|
+
const domRange = slate_react_1.ReactEditor.toDOMRange(editor, target);
|
|
60
|
+
setMenuTargetRect(domRange.getBoundingClientRect());
|
|
61
|
+
}
|
|
62
|
+
catch (_a) {
|
|
63
|
+
setMenuTargetRect(null);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
recompute();
|
|
67
|
+
// getBoundingClientRect() is viewport-relative, so it goes stale the
|
|
68
|
+
// moment any scrollable ancestor scrolls (capture phase catches that,
|
|
69
|
+
// since plain `scroll` events don't bubble) or the window resizes --
|
|
70
|
+
// recompute it fresh rather than trusting the last-known value.
|
|
71
|
+
window.addEventListener('scroll', recompute, true);
|
|
72
|
+
window.addEventListener('resize', recompute);
|
|
73
|
+
return () => {
|
|
74
|
+
window.removeEventListener('scroll', recompute, true);
|
|
75
|
+
window.removeEventListener('resize', recompute);
|
|
76
|
+
};
|
|
77
|
+
}, [target, editor, slateValue]);
|
|
78
|
+
// --- CORE LOGIC: Trigger Detection ---
|
|
79
|
+
const handleChange = (0, react_1.useCallback)((newValue) => {
|
|
80
|
+
setSlateValue(newValue);
|
|
81
|
+
const serialized = (0, serialize_1.serialize)(newValue);
|
|
82
|
+
if (serialized !== lastEmittedRef.current) {
|
|
83
|
+
lastEmittedRef.current = serialized;
|
|
84
|
+
setCommittedValue(serialized);
|
|
85
|
+
onChange === null || onChange === void 0 ? void 0 : onChange(serialized);
|
|
86
|
+
}
|
|
87
|
+
const mention = findMentionTrigger(editor);
|
|
88
|
+
if (mention) {
|
|
89
|
+
setTarget(mention.range);
|
|
90
|
+
setSearch(mention.search);
|
|
91
|
+
setIndex(0);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
setTarget(null);
|
|
95
|
+
}
|
|
96
|
+
}, [editor, onChange]);
|
|
97
|
+
// --- CORE LOGIC: Insertion ---
|
|
98
|
+
const selectField = (0, react_1.useCallback)((selectedField) => {
|
|
99
|
+
if (target) {
|
|
100
|
+
slate_1.Transforms.select(editor, target);
|
|
101
|
+
slate_1.Transforms.delete(editor);
|
|
102
|
+
slate_1.Transforms.insertNodes(editor, {
|
|
103
|
+
type: 'mention',
|
|
104
|
+
field: selectedField,
|
|
105
|
+
children: [{ text: '' }],
|
|
106
|
+
}, { select: true });
|
|
107
|
+
// Move cursor after the inserted void mention so the user can keep typing
|
|
108
|
+
slate_1.Transforms.move(editor, { distance: 1, unit: 'offset' });
|
|
109
|
+
setTarget(null);
|
|
110
|
+
}
|
|
111
|
+
}, [editor, target]);
|
|
112
|
+
// --- KEYBOARD NAVIGATION ---
|
|
113
|
+
const handleKeyDown = (0, react_1.useCallback)((event) => {
|
|
114
|
+
if (target) {
|
|
115
|
+
switch (event.key) {
|
|
116
|
+
case 'ArrowDown': {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
const nextIndex = index >= filteredFields.length - 1 ? 0 : index + 1;
|
|
119
|
+
setIndex(nextIndex);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case 'ArrowUp': {
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
const prevIndex = index <= 0 ? filteredFields.length - 1 : index - 1;
|
|
125
|
+
setIndex(prevIndex);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
case 'Tab':
|
|
129
|
+
case 'Enter':
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
selectField(filteredFields[index]);
|
|
132
|
+
break;
|
|
133
|
+
case 'Escape':
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
setTarget(null);
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}, [target, index, filteredFields, selectField]);
|
|
140
|
+
const rootClassName = [
|
|
141
|
+
'mention-editor relative rounded-md border bg-white dark:bg-neutral-900',
|
|
142
|
+
isError ? 'border-red-500' : 'border-gray-300 dark:border-neutral-700',
|
|
143
|
+
disabled && 'opacity-60',
|
|
144
|
+
className,
|
|
145
|
+
]
|
|
146
|
+
.filter(Boolean)
|
|
147
|
+
.join(' ');
|
|
148
|
+
const editableStyle = rows ? { minHeight: `${rows * 1.5}em` } : undefined;
|
|
149
|
+
return ((0, jsx_runtime_1.jsx)(slate_react_1.Slate, { editor: editor, value: slateValue, onChange: handleChange, children: (0, jsx_runtime_1.jsxs)("div", { className: rootClassName, children: [(0, jsx_runtime_1.jsx)(slate_react_1.Editable, { className: "mention-editor__editable min-h-[44px] px-3 py-2 text-base leading-6 text-gray-900 outline-none dark:text-gray-100", style: editableStyle, dir: dir, readOnly: disabled, onKeyDown: handleKeyDown, placeholder: placeholder, renderElement: (elementProps) => (0, jsx_runtime_1.jsx)(Element, { ...elementProps }), renderPlaceholder: renderPlaceholder }), target && ((0, jsx_runtime_1.jsx)(MentionMenu_1.MentionMenu, { fields: filteredFields, selectedIndex: index, onSelect: selectField, onHover: setIndex, targetRect: menuTargetRect }))] }) }, generation));
|
|
150
|
+
}
|
|
151
|
+
// slate-react's default placeholder renders at a fixed dim-black opacity,
|
|
152
|
+
// which reads poorly in dark mode. Overriding it lets the placeholder use
|
|
153
|
+
// real (theme-aware) Tailwind colors at full opacity instead.
|
|
154
|
+
const renderPlaceholder = ({ attributes, children }) => ((0, jsx_runtime_1.jsx)("span", { ...attributes, style: { ...attributes.style, opacity: 1 }, className: "text-gray-400 dark:text-neutral-500", children: children }));
|
|
155
|
+
// --- TRIGGER DETECTION ---
|
|
156
|
+
const MAX_MENTION_SEARCH_LENGTH = 50;
|
|
157
|
+
/**
|
|
158
|
+
* Finds an in-progress `@search` token immediately before the cursor, if any.
|
|
159
|
+
*
|
|
160
|
+
* This deliberately walks backward one character at a time via
|
|
161
|
+
* `unit: 'character'` rather than `unit: 'word'`: Slate's word-unit movement
|
|
162
|
+
* skips *past* a lone trailing `@` to find the nearest real word, which makes
|
|
163
|
+
* it useless for detecting "the user just typed @ with nothing after it yet"
|
|
164
|
+
* -- exactly the moment this needs to fire. Character-unit stepping is the
|
|
165
|
+
* one primitive that behaves predictably here, including hopping over void
|
|
166
|
+
* mentions as a single atomic step.
|
|
167
|
+
*
|
|
168
|
+
* Recomputing the full range (both ends) on every keystroke -- rather than
|
|
169
|
+
* caching the anchor once and trusting a stale focus -- also means selecting
|
|
170
|
+
* a suggestion always deletes exactly the `@search` text typed so far, never
|
|
171
|
+
* leaving leftover characters behind.
|
|
172
|
+
*/
|
|
173
|
+
const findMentionTrigger = (editor) => {
|
|
174
|
+
const { selection } = editor;
|
|
175
|
+
if (!selection || !slate_1.Range.isCollapsed(selection))
|
|
176
|
+
return null;
|
|
177
|
+
const [start] = slate_1.Range.edges(selection);
|
|
178
|
+
let point = start;
|
|
179
|
+
let text = '';
|
|
180
|
+
for (let i = 0; i < MAX_MENTION_SEARCH_LENGTH; i++) {
|
|
181
|
+
const prev = slate_1.Editor.before(editor, point, { unit: 'character' });
|
|
182
|
+
if (!prev)
|
|
183
|
+
break;
|
|
184
|
+
const char = slate_1.Editor.string(editor, { anchor: prev, focus: point });
|
|
185
|
+
if (char === '' || /\s/.test(char))
|
|
186
|
+
break; // hit a void boundary or whitespace
|
|
187
|
+
text = char + text;
|
|
188
|
+
point = prev;
|
|
189
|
+
if (char === '@')
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
if (!text.startsWith('@'))
|
|
193
|
+
return null;
|
|
194
|
+
// The '@' must be at the very start of the block or preceded by whitespace,
|
|
195
|
+
// never mid-word (e.g. "user@").
|
|
196
|
+
const charBeforeAt = slate_1.Editor.before(editor, point, { unit: 'character' });
|
|
197
|
+
if (charBeforeAt) {
|
|
198
|
+
const charBefore = slate_1.Editor.string(editor, { anchor: charBeforeAt, focus: point });
|
|
199
|
+
if (charBefore !== '' && !/\s/.test(charBefore))
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return { range: { anchor: point, focus: start }, search: text.slice(1) };
|
|
203
|
+
};
|
|
204
|
+
// --- SLATE PLUGIN & RENDERERS ---
|
|
205
|
+
// Extends the editor to handle specific mention behaviors (like deleting the whole node on backspace)
|
|
206
|
+
const withMentions = (editor) => {
|
|
207
|
+
const { isInline, isVoid, deleteBackward } = editor;
|
|
208
|
+
editor.isInline = (element) => {
|
|
209
|
+
return element.type === 'mention' ? true : isInline(element);
|
|
210
|
+
};
|
|
211
|
+
editor.isVoid = (element) => {
|
|
212
|
+
return element.type === 'mention' ? true : isVoid(element);
|
|
213
|
+
};
|
|
214
|
+
// Custom delete behavior: if you backspace a mention, delete the whole thing
|
|
215
|
+
editor.deleteBackward = (...args) => {
|
|
216
|
+
const { selection } = editor;
|
|
217
|
+
if (selection && slate_1.Range.isCollapsed(selection)) {
|
|
218
|
+
const before = slate_1.Editor.before(editor, selection.anchor);
|
|
219
|
+
if (before) {
|
|
220
|
+
const [match] = Array.from(slate_1.Editor.nodes(editor, {
|
|
221
|
+
at: before,
|
|
222
|
+
match: (n) => n.type === 'mention',
|
|
223
|
+
}));
|
|
224
|
+
if (match) {
|
|
225
|
+
slate_1.Transforms.delete(editor, { at: match[1] });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
deleteBackward(...args);
|
|
231
|
+
};
|
|
232
|
+
return editor;
|
|
233
|
+
};
|
|
234
|
+
// Renders Slate nodes into React components
|
|
235
|
+
const Element = ({ attributes, children, element }) => {
|
|
236
|
+
var _a;
|
|
237
|
+
switch (element.type) {
|
|
238
|
+
case 'mention':
|
|
239
|
+
return ((0, jsx_runtime_1.jsxs)("span", { ...attributes, contentEditable: false, className: "mention-editor__mention cursor-default text-blue-600 underline decoration-1 underline-offset-2 [unicode-bidi:isolate] dark:text-blue-400", children: ["@", (_a = element.field) === null || _a === void 0 ? void 0 : _a.label, children] }));
|
|
240
|
+
default:
|
|
241
|
+
return ((0, jsx_runtime_1.jsx)("p", { ...attributes, className: "mention-editor__paragraph m-0 [&+&]:mt-1", children: children }));
|
|
242
|
+
}
|
|
243
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { MentionFieldOption } from './types';
|
|
3
|
+
interface MentionMenuProps {
|
|
4
|
+
fields: MentionFieldOption[];
|
|
5
|
+
selectedIndex: number;
|
|
6
|
+
onSelect: (field: MentionFieldOption) => void;
|
|
7
|
+
onHover: (index: number) => void;
|
|
8
|
+
targetRect: DOMRect | null;
|
|
9
|
+
}
|
|
10
|
+
export declare const MentionMenu: React.FC<MentionMenuProps>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MentionMenu = void 0;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
const react_dom_1 = require("react-dom");
|
|
6
|
+
const MentionMenu = ({ fields, selectedIndex, onSelect, onHover, targetRect, }) => {
|
|
7
|
+
if (!targetRect || fields.length === 0)
|
|
8
|
+
return null;
|
|
9
|
+
// Rendered into document.body via a portal and positioned with `fixed` using
|
|
10
|
+
// raw viewport coordinates from getBoundingClientRect(). This is what makes
|
|
11
|
+
// the menu land correctly regardless of whether the editor sits inside a
|
|
12
|
+
// scrollable container, a `position: fixed` modal, or a modal nested inside
|
|
13
|
+
// another modal: portaling to <body> means there's no transformed/positioned
|
|
14
|
+
// ancestor between the menu and the viewport to throw off `fixed` coordinates.
|
|
15
|
+
// The caller (MentionEditor) keeps `targetRect` fresh across scroll/resize.
|
|
16
|
+
return (0, react_dom_1.createPortal)((0, jsx_runtime_1.jsx)("div", { className: "mention-editor__menu z-[9999] w-[280px] max-h-[300px] overflow-y-auto rounded-md border border-gray-300 bg-white py-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800", style: {
|
|
17
|
+
position: 'fixed',
|
|
18
|
+
top: targetRect.bottom + 8,
|
|
19
|
+
left: targetRect.left,
|
|
20
|
+
}, children: fields.map((field, i) => ((0, jsx_runtime_1.jsx)("div", { className: 'mention-editor__menu-item cursor-pointer px-3 py-2 text-gray-900 dark:text-gray-100' +
|
|
21
|
+
(i === selectedIndex ? ' bg-indigo-50 dark:bg-neutral-700' : ''), onClick: (e) => {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
onSelect(field);
|
|
24
|
+
}, onMouseDown: (e) => e.preventDefault(), onMouseEnter: () => onHover(i), children: field.label }, field.id))) }), document.body);
|
|
25
|
+
};
|
|
26
|
+
exports.MentionMenu = MentionMenu;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { MentionEditor } from './MentionEditor';
|
|
2
|
+
export type { MentionEditorProps } from './MentionEditor';
|
|
3
|
+
export { serialize, deserialize, serializeToDiscordMarkup } from './serialize';
|
|
4
|
+
export type { MentionFieldOption } from './types';
|
|
5
|
+
export { INITIAL_VALUE, MENTION_OPEN, MENTION_CLOSE } from './types';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MENTION_CLOSE = exports.MENTION_OPEN = exports.INITIAL_VALUE = exports.serializeToDiscordMarkup = exports.deserialize = exports.serialize = exports.MentionEditor = void 0;
|
|
4
|
+
var MentionEditor_1 = require("./MentionEditor");
|
|
5
|
+
Object.defineProperty(exports, "MentionEditor", { enumerable: true, get: function () { return MentionEditor_1.MentionEditor; } });
|
|
6
|
+
var serialize_1 = require("./serialize");
|
|
7
|
+
Object.defineProperty(exports, "serialize", { enumerable: true, get: function () { return serialize_1.serialize; } });
|
|
8
|
+
Object.defineProperty(exports, "deserialize", { enumerable: true, get: function () { return serialize_1.deserialize; } });
|
|
9
|
+
Object.defineProperty(exports, "serializeToDiscordMarkup", { enumerable: true, get: function () { return serialize_1.serializeToDiscordMarkup; } });
|
|
10
|
+
var types_1 = require("./types");
|
|
11
|
+
Object.defineProperty(exports, "INITIAL_VALUE", { enumerable: true, get: function () { return types_1.INITIAL_VALUE; } });
|
|
12
|
+
Object.defineProperty(exports, "MENTION_OPEN", { enumerable: true, get: function () { return types_1.MENTION_OPEN; } });
|
|
13
|
+
Object.defineProperty(exports, "MENTION_CLOSE", { enumerable: true, get: function () { return types_1.MENTION_CLOSE; } });
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Descendant } from 'slate';
|
|
2
|
+
import { MentionFieldOption } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Serializes Slate content to the wire format string, e.g.
|
|
5
|
+
* `"Hello <@a1b2c3d4-...>, pay rent."`.
|
|
6
|
+
*
|
|
7
|
+
* Each top-level paragraph node becomes one line of output, joined by `\n` —
|
|
8
|
+
* `deserialize` treats every `\n` as a paragraph break (not a soft line break
|
|
9
|
+
* within a paragraph), so this is the inverse operation.
|
|
10
|
+
*/
|
|
11
|
+
export declare const serialize: (nodes: Descendant[]) => string;
|
|
12
|
+
/** @deprecated use {@link serialize} */
|
|
13
|
+
export declare const serializeToDiscordMarkup: (nodes: Descendant[]) => string;
|
|
14
|
+
/**
|
|
15
|
+
* Parses a wire format string back into a Slate `Descendant[]` value, resolving
|
|
16
|
+
* each `<@id>` token against `fields` to recover its display label. Tokens whose
|
|
17
|
+
* id isn't found in `fields` still render (falling back to the raw id as the
|
|
18
|
+
* label) rather than being dropped, so content never silently loses a mention.
|
|
19
|
+
*
|
|
20
|
+
* Inverse of `serialize`: every `\n` in `value` starts a new paragraph node.
|
|
21
|
+
*/
|
|
22
|
+
export declare const deserialize: (value: string, fields?: MentionFieldOption[]) => Descendant[];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.deserialize = exports.serializeToDiscordMarkup = exports.serialize = void 0;
|
|
4
|
+
const slate_1 = require("slate");
|
|
5
|
+
const types_1 = require("./types");
|
|
6
|
+
const isMentionElement = (node) => !slate_1.Text.isText(node) && node.type === 'mention';
|
|
7
|
+
const MENTION_TOKEN_REGEX = new RegExp(`${types_1.MENTION_OPEN}([^${types_1.MENTION_CLOSE}]+)${types_1.MENTION_CLOSE}`, 'g');
|
|
8
|
+
/**
|
|
9
|
+
* Serializes Slate content to the wire format string, e.g.
|
|
10
|
+
* `"Hello <@a1b2c3d4-...>, pay rent."`.
|
|
11
|
+
*
|
|
12
|
+
* Each top-level paragraph node becomes one line of output, joined by `\n` —
|
|
13
|
+
* `deserialize` treats every `\n` as a paragraph break (not a soft line break
|
|
14
|
+
* within a paragraph), so this is the inverse operation.
|
|
15
|
+
*/
|
|
16
|
+
const serialize = (nodes) => {
|
|
17
|
+
return nodes.map(serializeParagraph).join('\n');
|
|
18
|
+
};
|
|
19
|
+
exports.serialize = serialize;
|
|
20
|
+
const serializeParagraph = (node) => {
|
|
21
|
+
if (slate_1.Text.isText(node) || !('children' in node))
|
|
22
|
+
return '';
|
|
23
|
+
return node.children.map(serializeInline).join('');
|
|
24
|
+
};
|
|
25
|
+
const serializeInline = (node) => {
|
|
26
|
+
if (isMentionElement(node)) {
|
|
27
|
+
return `${types_1.MENTION_OPEN}${node.field.id}${types_1.MENTION_CLOSE}`;
|
|
28
|
+
}
|
|
29
|
+
return node.text;
|
|
30
|
+
};
|
|
31
|
+
/** @deprecated use {@link serialize} */
|
|
32
|
+
exports.serializeToDiscordMarkup = exports.serialize;
|
|
33
|
+
/**
|
|
34
|
+
* Parses a wire format string back into a Slate `Descendant[]` value, resolving
|
|
35
|
+
* each `<@id>` token against `fields` to recover its display label. Tokens whose
|
|
36
|
+
* id isn't found in `fields` still render (falling back to the raw id as the
|
|
37
|
+
* label) rather than being dropped, so content never silently loses a mention.
|
|
38
|
+
*
|
|
39
|
+
* Inverse of `serialize`: every `\n` in `value` starts a new paragraph node.
|
|
40
|
+
*/
|
|
41
|
+
const deserialize = (value, fields = []) => {
|
|
42
|
+
const fieldsById = new Map(fields.map((field) => [field.id, field]));
|
|
43
|
+
return value.split('\n').map((line) => {
|
|
44
|
+
return { type: 'paragraph', children: deserializeLine(line, fieldsById) };
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
exports.deserialize = deserialize;
|
|
48
|
+
const deserializeLine = (line, fieldsById) => {
|
|
49
|
+
var _a;
|
|
50
|
+
// Slate requires every inline (void) element to be surrounded by text nodes,
|
|
51
|
+
// including at the start/end of the block and between two adjacent mentions.
|
|
52
|
+
// So every push below is unconditional, even when the slice is ''.
|
|
53
|
+
const children = [];
|
|
54
|
+
let lastIndex = 0;
|
|
55
|
+
MENTION_TOKEN_REGEX.lastIndex = 0;
|
|
56
|
+
let match;
|
|
57
|
+
while ((match = MENTION_TOKEN_REGEX.exec(line))) {
|
|
58
|
+
children.push({ text: line.slice(lastIndex, match.index) });
|
|
59
|
+
const id = match[1];
|
|
60
|
+
const field = (_a = fieldsById.get(id)) !== null && _a !== void 0 ? _a : { id, label: id };
|
|
61
|
+
children.push({ type: 'mention', field, children: [{ text: '' }] });
|
|
62
|
+
lastIndex = match.index + match[0].length;
|
|
63
|
+
}
|
|
64
|
+
children.push({ text: line.slice(lastIndex) });
|
|
65
|
+
return children;
|
|
66
|
+
};
|
package/dist/styles.css
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
/*! tailwindcss v4.3.2 | MIT License | https://tailwindcss.com */
|
|
2
|
+
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-style:solid;--tw-leading:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}:root,:host{--color-red-500:oklch(63.7% .237 25.331);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-600:oklch(54.6% .245 262.881);--color-indigo-50:oklch(96.2% .018 272.314);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-900:oklch(21% .034 264.665);--color-neutral-500:oklch(55.6% 0 0);--color-neutral-700:oklch(37.1% 0 0);--color-neutral-800:oklch(26.9% 0 0);--color-neutral-900:oklch(20.5% 0 0);--color-white:#fff;--spacing:.25rem;--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--radius-md:.375rem}.visible{visibility:visible}.fixed{position:fixed}.relative{position:relative}.z-\[9999\]{z-index:9999}.m-0{margin:0}.block{display:block}.inline{display:inline}.max-h-\[300px\]{max-height:300px}.min-h-\[44px\]{min-height:44px}.w-\[280px\]{width:280px}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.resize{resize:both}.overflow-y-auto{overflow-y:auto}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-gray-300{border-color:var(--color-gray-300)}.border-red-500{border-color:var(--color-red-500)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-white{background-color:var(--color-white)}.px-3{padding-inline:calc(var(--spacing) * 3)}.py-1{padding-block:var(--spacing)}.py-2{padding-block:calc(var(--spacing) * 2)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.text-blue-600{color:var(--color-blue-600)}.text-gray-400{color:var(--color-gray-400)}.text-gray-900{color:var(--color-gray-900)}.underline{text-decoration-line:underline}.decoration-1{text-decoration-thickness:1px}.underline-offset-2{text-underline-offset:2px}.opacity-60{opacity:.6}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.outline-none{--tw-outline-style:none;outline-style:none}.\[unicode-bidi\:isolate\]{unicode-bidi:isolate}@media (prefers-color-scheme:dark){.dark\:border-neutral-700{border-color:var(--color-neutral-700)}.dark\:bg-neutral-700{background-color:var(--color-neutral-700)}.dark\:bg-neutral-800{background-color:var(--color-neutral-800)}.dark\:bg-neutral-900{background-color:var(--color-neutral-900)}.dark\:text-blue-400{color:var(--color-blue-400)}.dark\:text-gray-100{color:var(--color-gray-100)}.dark\:text-neutral-500{color:var(--color-neutral-500)}}.\[\&\+\&\]\:mt-1+.\[\&\+\&\]\:mt-1{margin-top:var(--spacing)}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { BaseEditor, Descendant } from 'slate';
|
|
2
|
+
import { ReactEditor } from 'slate-react';
|
|
3
|
+
import { HistoryEditor } from 'slate-history';
|
|
4
|
+
export interface MentionFieldOption {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
}
|
|
8
|
+
export type CustomText = {
|
|
9
|
+
text: string;
|
|
10
|
+
};
|
|
11
|
+
export type MentionElement = {
|
|
12
|
+
type: 'mention';
|
|
13
|
+
field: MentionFieldOption;
|
|
14
|
+
children: CustomText[];
|
|
15
|
+
};
|
|
16
|
+
export type ParagraphElement = {
|
|
17
|
+
type: 'paragraph';
|
|
18
|
+
children: (CustomText | MentionElement)[];
|
|
19
|
+
};
|
|
20
|
+
declare module 'slate' {
|
|
21
|
+
interface CustomTypes {
|
|
22
|
+
Editor: BaseEditor & ReactEditor & HistoryEditor;
|
|
23
|
+
Element: ParagraphElement | MentionElement;
|
|
24
|
+
Text: CustomText;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export declare const INITIAL_VALUE: Descendant[];
|
|
28
|
+
export declare const MENTION_OPEN = "<@";
|
|
29
|
+
export declare const MENTION_CLOSE = ">";
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MENTION_CLOSE = exports.MENTION_OPEN = exports.INITIAL_VALUE = void 0;
|
|
4
|
+
exports.INITIAL_VALUE = [
|
|
5
|
+
{
|
|
6
|
+
type: 'paragraph',
|
|
7
|
+
children: [{ text: '' }],
|
|
8
|
+
},
|
|
9
|
+
];
|
|
10
|
+
// Delimiters wrapping a mention's id in the wire format, e.g. `<@a1b2c3d4-...>`.
|
|
11
|
+
exports.MENTION_OPEN = '<@';
|
|
12
|
+
exports.MENTION_CLOSE = '>';
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@itsammarb/mention-editor",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A Discord-style @mention rich-text editor built on Slate.js",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./styles.css": "./dist/styles.css"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc && tailwindcss -i src/tailwind-entry.css -o dist/styles.css --minify",
|
|
20
|
+
"prepublishOnly": "npm run build",
|
|
21
|
+
"test": "vitest run"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
25
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
26
|
+
"slate": "^0.94.0",
|
|
27
|
+
"slate-react": "^0.94.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@tailwindcss/cli": "^4.3.2",
|
|
31
|
+
"@types/react": "^19.2.15",
|
|
32
|
+
"@types/react-dom": "^19.2.3",
|
|
33
|
+
"react": "^19.2.3",
|
|
34
|
+
"react-dom": "^19.2.3",
|
|
35
|
+
"slate": "^0.94.0",
|
|
36
|
+
"slate-history": "^0.93.0",
|
|
37
|
+
"slate-react": "^0.94.0",
|
|
38
|
+
"tailwindcss": "^4.3.2",
|
|
39
|
+
"typescript": "^5.0.0",
|
|
40
|
+
"vitest": "^4.1.10"
|
|
41
|
+
}
|
|
42
|
+
}
|