@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.
@@ -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";