@optilogic/editor 1.0.0-beta.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 +87 -0
- package/dist/index.cjs +487 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +95 -0
- package/dist/index.d.ts +95 -0
- package/dist/index.js +463 -0
- package/dist/index.js.map +1 -0
- package/package.json +69 -0
- package/src/index.ts +7 -0
- package/src/slate-editor.tsx +659 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SlateEditor Component
|
|
3
|
+
*
|
|
4
|
+
* A reusable rich text editor primitive based on Slate.js
|
|
5
|
+
* Provides consistent theming and can be used as an alternative to textarea
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as React from "react";
|
|
9
|
+
import {
|
|
10
|
+
createEditor,
|
|
11
|
+
Descendant,
|
|
12
|
+
Editor,
|
|
13
|
+
Transforms,
|
|
14
|
+
Text,
|
|
15
|
+
Element as SlateElement,
|
|
16
|
+
Range,
|
|
17
|
+
Node,
|
|
18
|
+
BaseEditor,
|
|
19
|
+
Path,
|
|
20
|
+
} from "slate";
|
|
21
|
+
import {
|
|
22
|
+
Slate,
|
|
23
|
+
Editable,
|
|
24
|
+
withReact,
|
|
25
|
+
ReactEditor,
|
|
26
|
+
RenderElementProps,
|
|
27
|
+
RenderLeafProps,
|
|
28
|
+
} from "slate-react";
|
|
29
|
+
import { withHistory, HistoryEditor } from "slate-history";
|
|
30
|
+
import { cn } from "@optilogic/core";
|
|
31
|
+
|
|
32
|
+
type TagElement = {
|
|
33
|
+
type: "tag";
|
|
34
|
+
tag: string;
|
|
35
|
+
children: [{ text: "" }];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type ParagraphElement = {
|
|
39
|
+
type: "paragraph";
|
|
40
|
+
children: Descendant[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type CustomElement = TagElement | ParagraphElement;
|
|
44
|
+
type CustomText = { text: string };
|
|
45
|
+
|
|
46
|
+
declare module "slate" {
|
|
47
|
+
interface CustomTypes {
|
|
48
|
+
Editor: BaseEditor & ReactEditor & HistoryEditor;
|
|
49
|
+
Element: CustomElement;
|
|
50
|
+
Text: CustomText;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const isTagElement = (element: CustomElement): element is TagElement => {
|
|
55
|
+
return element.type === "tag";
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export interface SlateEditorRef {
|
|
59
|
+
/** Focus the editor */
|
|
60
|
+
focus: () => void;
|
|
61
|
+
/** Clear the editor content */
|
|
62
|
+
clear: () => void;
|
|
63
|
+
/** Get plain text content */
|
|
64
|
+
getText: () => string;
|
|
65
|
+
/** Insert text at cursor */
|
|
66
|
+
insertText: (text: string) => void;
|
|
67
|
+
/** Insert a tag element */
|
|
68
|
+
insertTag: (tag: string) => void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface SlateEditorProps {
|
|
72
|
+
/** Initial plain text value */
|
|
73
|
+
value?: string;
|
|
74
|
+
/** Callback when content changes (plain text) */
|
|
75
|
+
onChange?: (value: string) => void;
|
|
76
|
+
/** Callback when a tag is created */
|
|
77
|
+
onTagCreate?: (tag: string) => void;
|
|
78
|
+
/** Callback when a tag is deleted */
|
|
79
|
+
onTagDelete?: (tag: string) => void;
|
|
80
|
+
/** Callback when Enter is pressed (without Shift) */
|
|
81
|
+
onSubmit?: (text: string) => void;
|
|
82
|
+
/** Placeholder text */
|
|
83
|
+
placeholder?: string;
|
|
84
|
+
/** Whether the editor is disabled */
|
|
85
|
+
disabled?: boolean;
|
|
86
|
+
/** Whether to enable tag detection (# character) */
|
|
87
|
+
enableTags?: boolean;
|
|
88
|
+
/** Minimum number of rows */
|
|
89
|
+
minRows?: number;
|
|
90
|
+
/** Maximum number of rows before scrolling */
|
|
91
|
+
maxRows?: number;
|
|
92
|
+
/** Additional class names */
|
|
93
|
+
className?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const withTags = (editor: Editor) => {
|
|
97
|
+
const { isInline, isVoid, deleteBackward, deleteForward } = editor;
|
|
98
|
+
|
|
99
|
+
editor.isInline = (element) => {
|
|
100
|
+
return element.type === "tag" ? true : isInline(element);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
editor.isVoid = (element) => {
|
|
104
|
+
return element.type === "tag" ? true : isVoid(element);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
editor.deleteBackward = (unit) => {
|
|
108
|
+
const { selection } = editor;
|
|
109
|
+
|
|
110
|
+
if (selection && Range.isCollapsed(selection)) {
|
|
111
|
+
const [tagEntry] = Editor.nodes(editor, {
|
|
112
|
+
match: (n) =>
|
|
113
|
+
!Editor.isEditor(n) && SlateElement.isElement(n) && n.type === "tag",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (tagEntry) {
|
|
117
|
+
const [, path] = tagEntry;
|
|
118
|
+
const after = Editor.after(editor, path);
|
|
119
|
+
if (after && Path.equals(selection.anchor.path, after.path)) {
|
|
120
|
+
Transforms.removeNodes(editor, { at: path });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const before = Editor.before(editor, selection);
|
|
126
|
+
if (before) {
|
|
127
|
+
const [beforeTagEntry] = Editor.nodes(editor, {
|
|
128
|
+
at: before,
|
|
129
|
+
match: (n) =>
|
|
130
|
+
!Editor.isEditor(n) &&
|
|
131
|
+
SlateElement.isElement(n) &&
|
|
132
|
+
n.type === "tag",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (beforeTagEntry) {
|
|
136
|
+
const [, path] = beforeTagEntry;
|
|
137
|
+
Transforms.removeNodes(editor, { at: path });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
deleteBackward(unit);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
editor.deleteForward = (unit) => {
|
|
147
|
+
const { selection } = editor;
|
|
148
|
+
|
|
149
|
+
if (selection && Range.isCollapsed(selection)) {
|
|
150
|
+
const after = Editor.after(editor, selection);
|
|
151
|
+
if (after) {
|
|
152
|
+
const [afterTagEntry] = Editor.nodes(editor, {
|
|
153
|
+
at: after,
|
|
154
|
+
match: (n) =>
|
|
155
|
+
!Editor.isEditor(n) &&
|
|
156
|
+
SlateElement.isElement(n) &&
|
|
157
|
+
n.type === "tag",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (afterTagEntry) {
|
|
161
|
+
const [, path] = afterTagEntry;
|
|
162
|
+
Transforms.removeNodes(editor, { at: path });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
deleteForward(unit);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return editor;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Convert Slate content to plain text
|
|
176
|
+
*/
|
|
177
|
+
export function serializeToText(nodes: Descendant[]): string {
|
|
178
|
+
return nodes
|
|
179
|
+
.map((n) => {
|
|
180
|
+
if (Text.isText(n)) {
|
|
181
|
+
return n.text;
|
|
182
|
+
}
|
|
183
|
+
if (SlateElement.isElement(n)) {
|
|
184
|
+
if (n.type === "tag") {
|
|
185
|
+
return `#${n.tag}`;
|
|
186
|
+
}
|
|
187
|
+
return serializeToText(n.children);
|
|
188
|
+
}
|
|
189
|
+
return "";
|
|
190
|
+
})
|
|
191
|
+
.join("");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Convert plain text to Slate nodes (preserving existing tags)
|
|
196
|
+
*/
|
|
197
|
+
export function deserializeFromText(text: string): Descendant[] {
|
|
198
|
+
if (!text) {
|
|
199
|
+
return [{ type: "paragraph", children: [{ text: "" }] }];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const children: Descendant[] = [];
|
|
203
|
+
const tagRegex = /#([a-zA-Z0-9@#$_-]+(?:--[a-zA-Z0-9_-]+)?)/g;
|
|
204
|
+
let lastIndex = 0;
|
|
205
|
+
let match;
|
|
206
|
+
|
|
207
|
+
while ((match = tagRegex.exec(text)) !== null) {
|
|
208
|
+
if (match.index > lastIndex) {
|
|
209
|
+
children.push({ text: text.substring(lastIndex, match.index) });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
children.push({
|
|
213
|
+
type: "tag",
|
|
214
|
+
tag: match[1],
|
|
215
|
+
children: [{ text: "" }],
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
lastIndex = match.index + match[0].length;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (lastIndex < text.length) {
|
|
222
|
+
children.push({ text: text.substring(lastIndex) });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (children.length === 0) {
|
|
226
|
+
children.push({ text: "" });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return [{ type: "paragraph", children }];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const TagElementRenderer = ({
|
|
233
|
+
attributes,
|
|
234
|
+
children,
|
|
235
|
+
element,
|
|
236
|
+
}: RenderElementProps & { element: TagElement }) => {
|
|
237
|
+
return (
|
|
238
|
+
<span
|
|
239
|
+
{...attributes}
|
|
240
|
+
contentEditable={false}
|
|
241
|
+
className={cn(
|
|
242
|
+
"inline-flex items-center",
|
|
243
|
+
"px-1.5 py-0.5 mx-0.5",
|
|
244
|
+
"rounded-md border",
|
|
245
|
+
"bg-accent/20 text-accent border-accent/40",
|
|
246
|
+
"text-sm font-medium",
|
|
247
|
+
"select-none cursor-default"
|
|
248
|
+
)}
|
|
249
|
+
>
|
|
250
|
+
<span className="font-bold">#</span>
|
|
251
|
+
{element.tag}
|
|
252
|
+
{children}
|
|
253
|
+
</span>
|
|
254
|
+
);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const DefaultElement = ({ attributes, children }: RenderElementProps) => {
|
|
258
|
+
return <p {...attributes}>{children}</p>;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const Leaf = ({ attributes, children }: RenderLeafProps) => {
|
|
262
|
+
return <span {...attributes}>{children}</span>;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* SlateEditor component
|
|
267
|
+
*
|
|
268
|
+
* A rich text editor based on Slate.js with support for inline tags.
|
|
269
|
+
* Can be used as a drop-in replacement for textarea with enhanced features.
|
|
270
|
+
*
|
|
271
|
+
* @example
|
|
272
|
+
* <SlateEditor
|
|
273
|
+
* value={content}
|
|
274
|
+
* onChange={setContent}
|
|
275
|
+
* placeholder="Type your message..."
|
|
276
|
+
* onSubmit={(text) => console.log('Submitted:', text)}
|
|
277
|
+
* />
|
|
278
|
+
*/
|
|
279
|
+
export const SlateEditor = React.forwardRef<SlateEditorRef, SlateEditorProps>(
|
|
280
|
+
(
|
|
281
|
+
{
|
|
282
|
+
value = "",
|
|
283
|
+
onChange,
|
|
284
|
+
onTagCreate,
|
|
285
|
+
onTagDelete,
|
|
286
|
+
onSubmit,
|
|
287
|
+
placeholder = "Type something...",
|
|
288
|
+
disabled = false,
|
|
289
|
+
enableTags = true,
|
|
290
|
+
minRows = 3,
|
|
291
|
+
maxRows = 8,
|
|
292
|
+
className,
|
|
293
|
+
},
|
|
294
|
+
ref
|
|
295
|
+
) => {
|
|
296
|
+
const editor = React.useMemo(
|
|
297
|
+
() => withTags(withHistory(withReact(createEditor()))),
|
|
298
|
+
[]
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const [isCreatingTag, setIsCreatingTag] = React.useState(false);
|
|
302
|
+
const [tagStartPoint, setTagStartPoint] = React.useState<{
|
|
303
|
+
path: Path;
|
|
304
|
+
offset: number;
|
|
305
|
+
} | null>(null);
|
|
306
|
+
const [currentTagText, setCurrentTagText] = React.useState("");
|
|
307
|
+
|
|
308
|
+
const [editorValue, setEditorValue] = React.useState<Descendant[]>(() =>
|
|
309
|
+
deserializeFromText(value)
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
React.useEffect(() => {
|
|
313
|
+
const newValue = deserializeFromText(value);
|
|
314
|
+
const currentText = serializeToText(editorValue);
|
|
315
|
+
if (currentText !== value) {
|
|
316
|
+
setEditorValue(newValue);
|
|
317
|
+
Transforms.removeNodes(editor, { at: [] });
|
|
318
|
+
Transforms.insertNodes(editor, newValue, { at: [0] });
|
|
319
|
+
Transforms.select(editor, Editor.start(editor, []));
|
|
320
|
+
}
|
|
321
|
+
}, [value, editor]);
|
|
322
|
+
|
|
323
|
+
React.useImperativeHandle(
|
|
324
|
+
ref,
|
|
325
|
+
() => ({
|
|
326
|
+
focus: () => {
|
|
327
|
+
ReactEditor.focus(editor);
|
|
328
|
+
},
|
|
329
|
+
clear: () => {
|
|
330
|
+
const newValue: Descendant[] = [
|
|
331
|
+
{ type: "paragraph", children: [{ text: "" }] },
|
|
332
|
+
];
|
|
333
|
+
setEditorValue(newValue);
|
|
334
|
+
Transforms.select(editor, Editor.start(editor, []));
|
|
335
|
+
},
|
|
336
|
+
getText: () => serializeToText(editorValue),
|
|
337
|
+
insertText: (text: string) => {
|
|
338
|
+
Transforms.insertText(editor, text);
|
|
339
|
+
},
|
|
340
|
+
insertTag: (tag: string) => {
|
|
341
|
+
insertTag(editor, tag);
|
|
342
|
+
onTagCreate?.(tag);
|
|
343
|
+
},
|
|
344
|
+
}),
|
|
345
|
+
[editor, editorValue, onTagCreate]
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const handleChange = React.useCallback(
|
|
349
|
+
(newValue: Descendant[]) => {
|
|
350
|
+
setEditorValue(newValue);
|
|
351
|
+
|
|
352
|
+
if (isCreatingTag && tagStartPoint) {
|
|
353
|
+
const { selection } = editor;
|
|
354
|
+
if (selection && Range.isCollapsed(selection)) {
|
|
355
|
+
const currentPath = selection.anchor.path;
|
|
356
|
+
const currentOffset = selection.anchor.offset;
|
|
357
|
+
|
|
358
|
+
if (Path.equals(currentPath, tagStartPoint.path)) {
|
|
359
|
+
try {
|
|
360
|
+
const [textNode] = Editor.node(editor, currentPath);
|
|
361
|
+
if (Text.isText(textNode)) {
|
|
362
|
+
const tagText = textNode.text.substring(
|
|
363
|
+
tagStartPoint.offset + 1,
|
|
364
|
+
currentOffset
|
|
365
|
+
);
|
|
366
|
+
setCurrentTagText(tagText);
|
|
367
|
+
}
|
|
368
|
+
} catch {
|
|
369
|
+
// Ignore - node may not exist
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const oldTags = new Set<string>();
|
|
376
|
+
const newTags = new Set<string>();
|
|
377
|
+
|
|
378
|
+
for (const node of Node.descendants(editor, {
|
|
379
|
+
from: [],
|
|
380
|
+
pass: ([n]) => SlateElement.isElement(n) && n.type === "paragraph",
|
|
381
|
+
})) {
|
|
382
|
+
const [n] = node;
|
|
383
|
+
if (SlateElement.isElement(n) && n.type === "tag") {
|
|
384
|
+
oldTags.add(n.tag);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const extractTags = (nodes: Descendant[]) => {
|
|
389
|
+
for (const node of nodes) {
|
|
390
|
+
if (SlateElement.isElement(node)) {
|
|
391
|
+
if (node.type === "tag") {
|
|
392
|
+
newTags.add(node.tag);
|
|
393
|
+
}
|
|
394
|
+
if ("children" in node) {
|
|
395
|
+
extractTags(node.children);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
extractTags(newValue);
|
|
401
|
+
|
|
402
|
+
for (const tag of oldTags) {
|
|
403
|
+
if (!newTags.has(tag)) {
|
|
404
|
+
onTagDelete?.(tag);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const text = serializeToText(newValue);
|
|
409
|
+
onChange?.(text);
|
|
410
|
+
},
|
|
411
|
+
[editor, onChange, onTagDelete, isCreatingTag, tagStartPoint]
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
const insertTag = (editor: Editor, tag: string) => {
|
|
415
|
+
const tagNode: TagElement = {
|
|
416
|
+
type: "tag",
|
|
417
|
+
tag,
|
|
418
|
+
children: [{ text: "" }],
|
|
419
|
+
};
|
|
420
|
+
Transforms.insertNodes(editor, tagNode);
|
|
421
|
+
Transforms.move(editor);
|
|
422
|
+
Transforms.insertText(editor, " ");
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const completeTag = React.useCallback(() => {
|
|
426
|
+
if (!isCreatingTag || !tagStartPoint) return;
|
|
427
|
+
|
|
428
|
+
const { selection } = editor;
|
|
429
|
+
if (!selection || !Range.isCollapsed(selection)) return;
|
|
430
|
+
|
|
431
|
+
const currentPath = selection.anchor.path;
|
|
432
|
+
const currentOffset = selection.anchor.offset;
|
|
433
|
+
|
|
434
|
+
if (!Path.equals(currentPath, tagStartPoint.path)) {
|
|
435
|
+
setIsCreatingTag(false);
|
|
436
|
+
setTagStartPoint(null);
|
|
437
|
+
setCurrentTagText("");
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const [textNode] = Editor.node(editor, currentPath);
|
|
442
|
+
if (!Text.isText(textNode)) {
|
|
443
|
+
setIsCreatingTag(false);
|
|
444
|
+
setTagStartPoint(null);
|
|
445
|
+
setCurrentTagText("");
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const tagText = textNode.text.substring(
|
|
450
|
+
tagStartPoint.offset + 1,
|
|
451
|
+
currentOffset
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
if (!tagText.trim()) {
|
|
455
|
+
setIsCreatingTag(false);
|
|
456
|
+
setTagStartPoint(null);
|
|
457
|
+
setCurrentTagText("");
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const start = { path: currentPath, offset: tagStartPoint.offset };
|
|
462
|
+
const end = { path: currentPath, offset: currentOffset };
|
|
463
|
+
|
|
464
|
+
Transforms.delete(editor, { at: { anchor: start, focus: end } });
|
|
465
|
+
insertTag(editor, tagText.trim());
|
|
466
|
+
|
|
467
|
+
onTagCreate?.(tagText.trim());
|
|
468
|
+
|
|
469
|
+
setIsCreatingTag(false);
|
|
470
|
+
setTagStartPoint(null);
|
|
471
|
+
setCurrentTagText("");
|
|
472
|
+
}, [editor, isCreatingTag, tagStartPoint, onTagCreate]);
|
|
473
|
+
|
|
474
|
+
const cancelTag = React.useCallback(() => {
|
|
475
|
+
setIsCreatingTag(false);
|
|
476
|
+
setTagStartPoint(null);
|
|
477
|
+
setCurrentTagText("");
|
|
478
|
+
}, []);
|
|
479
|
+
|
|
480
|
+
const handleKeyDown = React.useCallback(
|
|
481
|
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
482
|
+
if (isCreatingTag) {
|
|
483
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
484
|
+
event.preventDefault();
|
|
485
|
+
completeTag();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (event.key === "Escape") {
|
|
490
|
+
event.preventDefault();
|
|
491
|
+
cancelTag();
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (enableTags && event.key === "#" && !isCreatingTag) {
|
|
497
|
+
const { selection } = editor;
|
|
498
|
+
if (selection && Range.isCollapsed(selection)) {
|
|
499
|
+
setIsCreatingTag(true);
|
|
500
|
+
setCurrentTagText("");
|
|
501
|
+
setTagStartPoint({
|
|
502
|
+
path: selection.anchor.path,
|
|
503
|
+
offset: selection.anchor.offset,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (event.key === "Enter" && !event.shiftKey && !isCreatingTag) {
|
|
509
|
+
event.preventDefault();
|
|
510
|
+
const text = serializeToText(editorValue);
|
|
511
|
+
if (text.trim() && onSubmit) {
|
|
512
|
+
onSubmit(text.trim());
|
|
513
|
+
}
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (
|
|
518
|
+
(event.key === "Backspace" || event.key === "Delete") &&
|
|
519
|
+
event.ctrlKey
|
|
520
|
+
) {
|
|
521
|
+
const { selection } = editor;
|
|
522
|
+
if (selection && Range.isCollapsed(selection)) {
|
|
523
|
+
const direction =
|
|
524
|
+
event.key === "Backspace" ? "backward" : "forward";
|
|
525
|
+
const pointFn =
|
|
526
|
+
direction === "backward" ? Editor.before : Editor.after;
|
|
527
|
+
const point = pointFn(editor, selection, { unit: "word" });
|
|
528
|
+
|
|
529
|
+
if (point) {
|
|
530
|
+
const [tagEntry] = Editor.nodes(editor, {
|
|
531
|
+
at:
|
|
532
|
+
direction === "backward"
|
|
533
|
+
? { anchor: point, focus: selection.anchor }
|
|
534
|
+
: { anchor: selection.anchor, focus: point },
|
|
535
|
+
match: (n) =>
|
|
536
|
+
!Editor.isEditor(n) &&
|
|
537
|
+
SlateElement.isElement(n) &&
|
|
538
|
+
n.type === "tag",
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
if (tagEntry) {
|
|
542
|
+
event.preventDefault();
|
|
543
|
+
const [tagNode, tagPath] = tagEntry;
|
|
544
|
+
if (SlateElement.isElement(tagNode) && tagNode.type === "tag") {
|
|
545
|
+
onTagDelete?.(tagNode.tag);
|
|
546
|
+
}
|
|
547
|
+
Transforms.removeNodes(editor, { at: tagPath });
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
[
|
|
555
|
+
editor,
|
|
556
|
+
editorValue,
|
|
557
|
+
isCreatingTag,
|
|
558
|
+
enableTags,
|
|
559
|
+
completeTag,
|
|
560
|
+
cancelTag,
|
|
561
|
+
onSubmit,
|
|
562
|
+
onTagDelete,
|
|
563
|
+
]
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
const renderElement = React.useCallback((props: RenderElementProps) => {
|
|
567
|
+
switch (props.element.type) {
|
|
568
|
+
case "tag":
|
|
569
|
+
return (
|
|
570
|
+
<TagElementRenderer
|
|
571
|
+
{...props}
|
|
572
|
+
element={props.element as TagElement}
|
|
573
|
+
/>
|
|
574
|
+
);
|
|
575
|
+
default:
|
|
576
|
+
return <DefaultElement {...props} />;
|
|
577
|
+
}
|
|
578
|
+
}, []);
|
|
579
|
+
|
|
580
|
+
const renderLeaf = React.useCallback((props: RenderLeafProps) => {
|
|
581
|
+
return <Leaf {...props} />;
|
|
582
|
+
}, []);
|
|
583
|
+
|
|
584
|
+
const lineHeight = 24;
|
|
585
|
+
const minHeight = lineHeight * minRows;
|
|
586
|
+
const maxHeight = lineHeight * maxRows;
|
|
587
|
+
|
|
588
|
+
return (
|
|
589
|
+
<div
|
|
590
|
+
className={cn(
|
|
591
|
+
"relative w-full rounded-md",
|
|
592
|
+
"bg-transparent",
|
|
593
|
+
disabled && "opacity-50 cursor-not-allowed",
|
|
594
|
+
className
|
|
595
|
+
)}
|
|
596
|
+
>
|
|
597
|
+
<Slate
|
|
598
|
+
editor={editor}
|
|
599
|
+
initialValue={editorValue}
|
|
600
|
+
onChange={handleChange}
|
|
601
|
+
>
|
|
602
|
+
<Editable
|
|
603
|
+
readOnly={disabled}
|
|
604
|
+
placeholder={placeholder}
|
|
605
|
+
renderElement={renderElement}
|
|
606
|
+
renderLeaf={renderLeaf}
|
|
607
|
+
onKeyDown={handleKeyDown}
|
|
608
|
+
className={cn(
|
|
609
|
+
"w-full outline-none",
|
|
610
|
+
"text-foreground placeholder:text-muted-foreground",
|
|
611
|
+
"leading-6",
|
|
612
|
+
"overflow-y-auto overflow-x-hidden",
|
|
613
|
+
"whitespace-pre-wrap break-words"
|
|
614
|
+
)}
|
|
615
|
+
style={{
|
|
616
|
+
minHeight: `${minHeight}px`,
|
|
617
|
+
maxHeight: `${maxHeight}px`,
|
|
618
|
+
wordBreak: "break-word",
|
|
619
|
+
overflowWrap: "break-word",
|
|
620
|
+
}}
|
|
621
|
+
spellCheck
|
|
622
|
+
autoCorrect="off"
|
|
623
|
+
autoCapitalize="off"
|
|
624
|
+
/>
|
|
625
|
+
</Slate>
|
|
626
|
+
|
|
627
|
+
{isCreatingTag && (
|
|
628
|
+
<div className="absolute bottom-full left-0 mb-2 flex items-center gap-2.5 animate-in fade-in-0 slide-in-from-bottom-1 duration-150">
|
|
629
|
+
<div className="flex items-center px-2 py-1 rounded-md border shadow-sm bg-accent/20 border-accent/40">
|
|
630
|
+
<span className="font-bold text-sm text-accent">#</span>
|
|
631
|
+
<span className="text-sm font-medium min-w-[2ch] text-accent">
|
|
632
|
+
{currentTagText || (
|
|
633
|
+
<span className="opacity-50 italic">tag</span>
|
|
634
|
+
)}
|
|
635
|
+
</span>
|
|
636
|
+
</div>
|
|
637
|
+
<span className="text-muted-foreground/80 text-[11px]">
|
|
638
|
+
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono mr-1">
|
|
639
|
+
Enter
|
|
640
|
+
</kbd>
|
|
641
|
+
or
|
|
642
|
+
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono mx-1">
|
|
643
|
+
Space
|
|
644
|
+
</kbd>
|
|
645
|
+
to add
|
|
646
|
+
<span className="mx-1.5 text-muted-foreground/50">·</span>
|
|
647
|
+
<kbd className="px-1.5 py-0.5 bg-muted rounded text-[10px] font-mono mr-1">
|
|
648
|
+
Esc
|
|
649
|
+
</kbd>
|
|
650
|
+
to cancel
|
|
651
|
+
</span>
|
|
652
|
+
</div>
|
|
653
|
+
)}
|
|
654
|
+
</div>
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
SlateEditor.displayName = "SlateEditor";
|